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:
Charles Englebert
2026-03-10 13:48:54 +01:00
committed by GitHub
parent 437fc0f049
commit 0b8bdb08f7
8 changed files with 214 additions and 10 deletions
+1
View File
@@ -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
+10
View File
@@ -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"
+2
View File
@@ -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):
+29 -6
View File
@@ -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"""
+110 -1
View File
@@ -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"""
+14 -1
View File
@@ -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