✨ handle search types (#51)
* ✅(backend) tests I am adding beautiful tests * ✨(backend) add search_type params We want to control the type of search to use at run time to feature flag Docs users with full-test or with hybrid search. I am adding a search_type params on the search route. * 🚨(backend) changelog chaaaaaaaaaangelooooooooooog * ♻️(backend) refactor i compute the actual search_type in the view and make the variable mandatory in the service.
This commit is contained in:
committed by
GitHub
parent
437fc0f049
commit
0b8bdb08f7
@@ -30,6 +30,7 @@ and this project adheres to
|
||||
- ✨(backend) adapt to conversation RAG
|
||||
- ✨(backend) add deletion endpoint
|
||||
- ✨(backend) add path filter
|
||||
- ✨(backend) add search_type param
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ class ReachEnum(str, Enum):
|
||||
RESTRICTED = "restricted"
|
||||
|
||||
|
||||
# Search type
|
||||
|
||||
|
||||
class SearchTypeEnum(str, Enum):
|
||||
"""Search type options"""
|
||||
|
||||
HYBRID = "hybrid"
|
||||
FULL_TEXT = "full_text"
|
||||
|
||||
|
||||
# Fields
|
||||
|
||||
CREATED_AT = "created_at"
|
||||
|
||||
@@ -18,6 +18,7 @@ from pydantic import (
|
||||
)
|
||||
|
||||
from . import enums
|
||||
from .enums import SearchTypeEnum
|
||||
|
||||
|
||||
class DocumentSchema(BaseModel):
|
||||
@@ -118,6 +119,7 @@ class SearchQueryParametersSchema(BaseModel):
|
||||
order_by: Optional[Literal[enums.ORDER_BY_OPTIONS]] = Field(default=enums.RELEVANCE)
|
||||
order_direction: Optional[Literal["asc", "desc"]] = Field(default="desc")
|
||||
nb_results: Optional[conint(ge=1, le=300)] = Field(default=50)
|
||||
search_type: Optional[SearchTypeEnum] = Field(default=None)
|
||||
|
||||
|
||||
class DeleteDocumentsSchema(BaseModel):
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from django.conf import settings
|
||||
|
||||
from core import enums
|
||||
from core.enums import SearchTypeEnum
|
||||
|
||||
from .embedding import embed_text
|
||||
from .opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
@@ -24,6 +25,7 @@ def search( # noqa : PLR0913
|
||||
user_sub,
|
||||
groups,
|
||||
tags,
|
||||
search_type,
|
||||
path=None,
|
||||
):
|
||||
"""Perform an OpenSearch search"""
|
||||
@@ -36,6 +38,7 @@ def search( # noqa : PLR0913
|
||||
groups=groups,
|
||||
tags=tags,
|
||||
path=path,
|
||||
search_type=search_type,
|
||||
)
|
||||
return opensearch_client().search( # pylint: disable=unexpected-keyword-arg
|
||||
index=",".join(search_indices),
|
||||
@@ -62,7 +65,15 @@ def search( # noqa : PLR0913
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def get_query( # noqa : PLR0913
|
||||
q, nb_results, reach, visited, user_sub, groups, tags, path=None
|
||||
q,
|
||||
nb_results,
|
||||
reach,
|
||||
visited,
|
||||
user_sub,
|
||||
groups,
|
||||
tags,
|
||||
search_type,
|
||||
path=None,
|
||||
):
|
||||
"""Build OpenSearch query body based on parameters"""
|
||||
filter_ = get_filter(reach, visited, user_sub, groups, tags, path)
|
||||
@@ -76,11 +87,7 @@ def get_query( # noqa : PLR0913
|
||||
},
|
||||
}
|
||||
|
||||
hybrid_search_enabled = check_hybrid_search_enabled()
|
||||
if hybrid_search_enabled:
|
||||
q_vector = embed_text(q)
|
||||
else:
|
||||
q_vector = None
|
||||
q_vector = vectorize_query(q, search_type)
|
||||
|
||||
if not q_vector:
|
||||
logger.info("Performing full-text search without embedding: %s", q)
|
||||
@@ -97,6 +104,22 @@ def get_query( # noqa : PLR0913
|
||||
}
|
||||
|
||||
|
||||
def vectorize_query(q, search_type):
|
||||
"""Vectorize the query if hybrid search is enabled and requested"""
|
||||
hybrid_search_enabled = check_hybrid_search_enabled()
|
||||
|
||||
if search_type == SearchTypeEnum.HYBRID:
|
||||
if not hybrid_search_enabled:
|
||||
logger.warning(
|
||||
"Hybrid search was requested (search_type=hybrid) but is disabled on server",
|
||||
)
|
||||
return None
|
||||
|
||||
return embed_text(q)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_semantic_search_query(q_vector, filter_, nb_results):
|
||||
"""Build OpenSearch semantic search query"""
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ Don't use pytest parametrized tests because batch generation and indexing
|
||||
of documents is slow and better done only once.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import operator
|
||||
import random
|
||||
|
||||
@@ -19,6 +20,7 @@ from core.services.opensearch import (
|
||||
)
|
||||
from core.utils import bulk_create_documents, prepare_index
|
||||
|
||||
from ..enums import SearchTypeEnum
|
||||
from .mock import albert_embedding_response
|
||||
from .utils import (
|
||||
build_authorization_bearer,
|
||||
@@ -350,6 +352,43 @@ def test_api_documents_full_text_search(settings):
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_with_search_type_full_text(settings, caplog):
|
||||
"""Test API with search_type=full_text forces full-text search even if hybrid is enabled"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json=albert_embedding_response.response,
|
||||
status=200,
|
||||
)
|
||||
service = factories.ServiceFactory()
|
||||
documents = bulk_create_documents(
|
||||
[
|
||||
{"title": "wolf", "content": "wolves live in packs and hunt together"},
|
||||
{"title": "dog", "content": "dogs are loyal domestic animals"},
|
||||
]
|
||||
)
|
||||
prepare_index(service.index_name, documents)
|
||||
with caplog.at_level(logging.INFO):
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{
|
||||
"q": "wolf",
|
||||
"search_type": SearchTypeEnum.FULL_TEXT,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert any(
|
||||
"Performing full-text search without embedding: wolf" in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_hybrid_search(settings):
|
||||
"""Searching a document by its content should work as expected"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from json import dumps as json_dumps
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import factories
|
||||
from core import enums, factories
|
||||
from core.services import opensearch
|
||||
from core.services.opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
from core.services.search import search
|
||||
@@ -40,6 +40,7 @@ def search_params(service):
|
||||
"groups": [],
|
||||
"visited": [],
|
||||
"tags": [],
|
||||
"search_type": enums.SearchTypeEnum.HYBRID,
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +200,114 @@ def test_fall_back_on_full_text_search_if_hybrid_search_disabled(settings, caplo
|
||||
assert result["hits"]["hits"][0]["_source"]["title.en"] == "wolf"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_force_full_text_search_with_search_type_parameter(settings, caplog):
|
||||
"""Test the full-text search is done when search_type=FULL_TEXT even if hybrid is enabled"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json=albert_embedding_response.response,
|
||||
status=200,
|
||||
)
|
||||
documents = bulk_create_documents(
|
||||
[
|
||||
{"title": "wolf", "content": "wolves live in packs and hunt together"},
|
||||
{"title": "dog", "content": "dogs are loyal domestic animals"},
|
||||
{"title": "cat", "content": "cats are curious and independent pets"},
|
||||
]
|
||||
)
|
||||
service = factories.ServiceFactory(name=SERVICE_NAME)
|
||||
prepare_index(service.index_name, documents)
|
||||
|
||||
q = "wolf"
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = search(
|
||||
q=q,
|
||||
**{**search_params(service), "search_type": enums.SearchTypeEnum.FULL_TEXT},
|
||||
)
|
||||
|
||||
assert any(
|
||||
f"Performing full-text search without embedding: {q}" in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
assert result["hits"]["max_score"] > 0.0
|
||||
assert len(result["hits"]["hits"]) == 1
|
||||
assert result["hits"]["hits"][0]["_source"]["title.en"] == "wolf"
|
||||
|
||||
|
||||
def test_request_hybrid_search_when_server_has_it_disabled(settings, caplog):
|
||||
"""Test warning when hybrid search is requested but disabled on server"""
|
||||
enable_hybrid_search(settings)
|
||||
settings.HYBRID_SEARCH_ENABLED = False
|
||||
documents = bulk_create_documents(
|
||||
[
|
||||
{"title": "wolf", "content": "wolves live in packs and hunt together"},
|
||||
{"title": "dog", "content": "dogs are loyal domestic animals"},
|
||||
{"title": "cat", "content": "cats are curious and independent pets"},
|
||||
]
|
||||
)
|
||||
service = factories.ServiceFactory(name=SERVICE_NAME)
|
||||
prepare_index(service.index_name, documents)
|
||||
|
||||
q = "canine pet"
|
||||
with caplog.at_level(logging.INFO):
|
||||
search(
|
||||
q=q,
|
||||
**{**search_params(service), "search_type": enums.SearchTypeEnum.HYBRID},
|
||||
)
|
||||
|
||||
assert any(
|
||||
"Hybrid search was requested (search_type=hybrid) but is disabled on server"
|
||||
in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
assert any(
|
||||
f"Performing full-text search without embedding: {q}" in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_with_search_type_hybrid(settings, caplog):
|
||||
"""Test API with search_type=hybrid uses hybrid search when enabled"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json=albert_embedding_response.response,
|
||||
status=200,
|
||||
)
|
||||
service = factories.ServiceFactory()
|
||||
|
||||
documents = bulk_create_documents(
|
||||
[
|
||||
{"title": "wolf", "content": "wolves live in packs and hunt together"},
|
||||
{"title": "dog", "content": "dogs are loyal domestic animals"},
|
||||
]
|
||||
)
|
||||
prepare_index(service.index_name, documents)
|
||||
|
||||
q = "canine pet"
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = search(
|
||||
q=q,
|
||||
**{**search_params(service), "search_type": enums.SearchTypeEnum.HYBRID},
|
||||
)
|
||||
|
||||
assert any(
|
||||
f"Performing hybrid search with embedding: {q}" in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
assert result["hits"]["max_score"] > 0.0
|
||||
# hybrid search always returns a response of fixed sized sorted and scored by relevance
|
||||
assert {hit["_source"]["title.en"] for hit in result["hits"]["hits"]} == {
|
||||
doc["title"] for doc in documents
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_fall_back_on_full_text_search_if_embedding_api_fails(settings, caplog):
|
||||
"""Test the full-text search is done when the embedding api fails"""
|
||||
|
||||
@@ -12,13 +12,14 @@ from rest_framework.response import Response
|
||||
|
||||
from . import schemas
|
||||
from .authentication import ServiceTokenAuthentication
|
||||
from .enums import SearchTypeEnum
|
||||
from .permissions import IsAuthAuthenticated
|
||||
from .services.indexing import (
|
||||
ensure_index_exists,
|
||||
get_opensearch_indices,
|
||||
prepare_document_for_indexing,
|
||||
)
|
||||
from .services.opensearch import opensearch_client
|
||||
from .services.opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
from .services.search import search
|
||||
from .utils import get_language_value
|
||||
|
||||
@@ -346,6 +347,13 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
|
||||
List of public/authenticated documents the user has visited to limit
|
||||
the document returned to the ones the current user has seen.
|
||||
Built from linkreach list of a document in docs app.
|
||||
search_type : str, optional
|
||||
Type of search to perform: 'hybrid' or 'full_text'.
|
||||
- 'hybrid': Uses hybrid search if enabled on the server,
|
||||
otherwise falls back to full-text search.
|
||||
- 'full_text': Uses only full-text search, even if hybrid search is enabled
|
||||
on the server.
|
||||
if the not specified, the server will use hybrid search when enabled
|
||||
|
||||
Returns:
|
||||
--------
|
||||
@@ -382,6 +390,11 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
|
||||
groups=groups,
|
||||
tags=params.tags,
|
||||
path=params.path,
|
||||
search_type=params.search_type
|
||||
if params.search_type
|
||||
else SearchTypeEnum.HYBRID
|
||||
if check_hybrid_search_enabled()
|
||||
else SearchTypeEnum.FULL_TEXT,
|
||||
)["hits"]["hits"]
|
||||
logger.info("found %d results", len(result))
|
||||
logger.debug("results %s", result)
|
||||
|
||||
@@ -11,6 +11,7 @@ import unicodedata
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.enums import SearchTypeEnum
|
||||
from core.management.commands.create_search_pipeline import (
|
||||
ensure_search_pipeline_exists,
|
||||
)
|
||||
@@ -166,7 +167,13 @@ class Command(BaseCommand):
|
||||
|
||||
def evaluate_query(self, query, min_score=0.0):
|
||||
"""Evaluate a single query and return metrics."""
|
||||
results = search(q=query["q"], **self.search_params)
|
||||
results = search(
|
||||
q=query["q"],
|
||||
search_type=SearchTypeEnum.HYBRID
|
||||
if check_hybrid_search_enabled()
|
||||
else SearchTypeEnum.FULL_TEXT,
|
||||
**self.search_params,
|
||||
)
|
||||
expected_titles = [
|
||||
self.id_to_title[document_id]
|
||||
for document_id in query["expected_document_ids"]
|
||||
@@ -262,7 +269,7 @@ class Command(BaseCommand):
|
||||
@staticmethod
|
||||
def overwrite_settings():
|
||||
"""Overwrite settings for evaluation purposes."""
|
||||
settings.HYBRID_SEARCH_ENABLED = True
|
||||
settings.HYBRID_SEARCH_ENABLED = False
|
||||
settings.HYBRID_SEARCH_WEIGHTS = [0.2, 0.8]
|
||||
settings.EMBEDDING_API_PATH = "https://albert.api.etalab.gouv.fr/v1/embeddings"
|
||||
settings.EMBEDDING_REQUEST_TIMEOUT = 10
|
||||
|
||||
Reference in New Issue
Block a user