Compare commits

..

5 Commits

Author SHA1 Message Date
charles 5c722635af (backend) add rerank param on search route
We want to be able to disable the reranker at run time
even if it is enabled in settings
2026-03-16 18:24:40 +01:00
charles 8cc86f9d64 (backend) test reranking function
I am adding test about reranking
2026-03-16 18:24:40 +01:00
charles bcc8d1c96e ♻️(backend) refactor
I am refactoring the reranking
2026-03-16 18:24:40 +01:00
charles acdd04eed1 (backend) implement reranker
I implement a reranker to improve accuracy of search results
2026-03-16 18:24:40 +01:00
charles 534d2e2454 🔧(backend) add rerankers package with API dependencies
i am adding the reranker dependency and updating the lock file
2026-03-16 18:24:40 +01:00
12 changed files with 424 additions and 32 deletions
+1 -1
View File
@@ -31,6 +31,7 @@ and this project adheres to
- ✨(backend) add deletion endpoint
- ✨(backend) add path filter
- ✨(backend) add search_type param
- ✨(backend) implement reranker #47
## Changed
@@ -42,4 +43,3 @@ and this project adheres to
- 🐛(backend) fix missing index creation in 'index/' view
- 🐛(backend) fix parallel test execution issues
- 🐛(backend) fix search type value #68
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchTypeEnum(str, Enum):
"""Search type options"""
HYBRID = "hybrid"
FULL_TEXT = "full-text"
FULL_TEXT = "full_text"
# Fields
+3 -17
View File
@@ -18,7 +18,7 @@ from pydantic import (
)
from . import enums
from .services.opensearch import check_hybrid_search_enabled
from .enums import SearchTypeEnum
class DocumentSchema(BaseModel):
@@ -119,22 +119,8 @@ 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[enums.SearchTypeEnum] = None
@model_validator(mode="after")
def set_default_search_type(self):
"""
Set default search_type dynamically.
If search_type is not provided, it will be set to hybrid if it is configured
and fall back on full text otherwise.
"""
if self.search_type is None:
self.search_type = (
enums.SearchTypeEnum.HYBRID
if check_hybrid_search_enabled()
else enums.SearchTypeEnum.FULL_TEXT
)
return self
search_type: Optional[SearchTypeEnum] = Field(default=None)
rerank: Optional[bool] = Field(default=None)
class DeleteDocumentsSchema(BaseModel):
+128
View File
@@ -0,0 +1,128 @@
"""Reranking utilities using rerankers library."""
import logging
from functools import cache
from django.conf import settings
from rerankers import Reranker # pylint: disable=import-error
from rerankers.models.ranker import BaseRanker # pylint: disable=import-error
from core.services.indexing import format_document
from core.utils import get_language_value
logger = logging.getLogger(__name__)
def rerank(query: str, hits: list[dict]) -> list[dict]:
"""
Rerank search results using the configured reranker model.
Args:
query: The search query string
hits: List of OpenSearch hit objects with _source containing title and content
Returns:
List of reranked results in the same format as input with a _reranked_score
"""
reranker = get_reranker()
if reranker is None:
logger.warning("Could not import reranker, returning original results")
return hits
try:
return _rerank(reranker, query, hits)
except Exception as e: # noqa: BLE001# pylint: disable=broad-exception-caught
logger.error("Reranking failed: %s, returning original results", str(e))
return hits
@cache
def get_reranker() -> BaseRanker | None:
"""
Get the reranker instance.
Returns None if the reranker library is not available or if initialization fails but
does not raise an exception to avoid crashing the application.
"""
try:
logger.info("Initializing reranker model: %s", settings.RERANKER_MODEL_NAME)
return Reranker(
settings.RERANKER_MODEL_NAME,
model_type=settings.RERANKER_MODEL_TYPE,
api_key=settings.RERANKER_API_KEY,
)
except Exception as e: # noqa: BLE001# pylint: disable=broad-exception-caught
logger.error("Failed to initialize reranker: %s", str(e))
return None
def _rerank(reranker: BaseRanker, query: str, original_hits: list[dict]) -> list[dict]:
"""Rerank the original results using the provided reranker."""
docs, doc_ids = prepare_rerank_data(original_hits)
logger.info("Reranking %d results for query: %s", len(original_hits), query)
reranked = reranker.rank(query=query, docs=docs, doc_ids=doc_ids)
reranked_results: list[dict] = []
for reranked_result in reranked.results:
matching_hits = [
hit for hit in original_hits if hit["_id"] == reranked_result.doc_id
]
if not matching_hits:
logger.warning(
"Reranked document ID %s not found in original hits, skipping",
reranked_result.doc_id,
)
continue
if len(matching_hits) > 1:
logger.warning(
"Multiple hits found for document ID %s, using first match",
reranked_result.doc_id,
)
hit = matching_hits[0]
hit["_reranked_score"] = reranked_result.score
reranked_results.append(hit)
logger.info("Reranking completed, returned %d results", len(reranked_results))
return reranked_results
def prepare_rerank_data(original_hits: list[dict]) -> tuple[list[str], list[str]]:
"""
Prepare the documents for reranking by extracting the title and content from the original hits.
"""
docs = []
doc_ids = []
for hit in original_hits:
title = get_language_value(hit["_source"], "title")
content = get_language_value(hit["_source"], "content")
docs.append(format_document(title, content))
doc_ids.append(hit["_id"])
return docs, doc_ids
def should_rerank(is_rerank_requested: bool | None) -> bool:
"""
Determine whether to perform reranking based on the input parameter and settings.
logs warning if reranking was explicitly requested but the reranker is disabled in settings.
falls back to settings.RERANKER_ENABLED if is_rerank_requested is None.
"""
if is_rerank_requested and not settings.RERANKER_ENABLED:
logger.warning(
"Reranking was explicitly requested but the reranker "
"is disabled in settings. Reranking skipped."
)
return False
if is_rerank_requested is None:
return settings.RERANKER_ENABLED
return is_rerank_requested and settings.RERANKER_ENABLED
+11 -1
View File
@@ -9,6 +9,7 @@ from core.enums import SearchTypeEnum
from .embedding import embed_text
from .opensearch import check_hybrid_search_enabled, opensearch_client
from .reranking import rerank, should_rerank
logger = logging.getLogger(__name__)
@@ -27,6 +28,7 @@ def search( # noqa : PLR0913
tags,
search_type,
path=None,
rerank_requested=None,
):
"""Perform an OpenSearch search"""
query = get_query(
@@ -40,7 +42,7 @@ def search( # noqa : PLR0913
path=path,
search_type=search_type,
)
return opensearch_client().search( # pylint: disable=unexpected-keyword-arg
response = opensearch_client().search( # pylint: disable=unexpected-keyword-arg
index=",".join(search_indices),
body={
"_source": enums.SOURCE_FIELDS, # limit the fields to return
@@ -62,6 +64,14 @@ def search( # noqa : PLR0913
ignore_unavailable=True,
)
if should_rerank(rerank_requested) and q != "*":
response["hits"]["hits"] = rerank(
query=q,
hits=response["hits"]["hits"],
)
return response
# pylint: disable=too-many-arguments, too-many-positional-arguments
def get_query( # noqa : PLR0913
@@ -8,6 +8,7 @@ of documents is slow and better done only once.
import logging
import operator
import random
from unittest import mock
import pytest
import responses
@@ -94,7 +95,7 @@ def test_api_documents_search_opensearch_env_variables_not_set(settings):
@responses.activate
def test_api_documents_search_query_unknown_user(settings):
"""Searching a document without an existing user should result in a 400 error"""
"""Searching a document without an existing user should result in a 401 error"""
setup_oicd_resource_server(
responses,
settings,
@@ -993,3 +994,36 @@ def test_api_documents_search_filtering_by_path(settings):
assert len(response.json()) == 2
for hit in response.json():
assert hit["_source"]["path"].startswith(path_filter)
@responses.activate
@mock.patch("core.services.reranking.rerank")
def test_api_documents_rerank_blocked(
mock_rerank,
settings,
caplog,
):
"""Test can disable rerank at run time even when enabled in settings"""
settings.RERANKER_ENABLED = True
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
documents = bulk_create_documents([{"title": "Fox"}, {"title": "Wolf"}])
prepare_index(service.index_name, documents)
mock_rerank.return_value = documents
with caplog.at_level(logging.INFO):
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "programming",
"rerank": False,
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert not mock_rerank.called
+140
View File
@@ -0,0 +1,140 @@
"""
Test suite for reranking service
"""
import logging
from unittest.mock import MagicMock, patch
import pytest
from core.services.reranking import rerank
pytestmark = pytest.mark.django_db
logger = logging.getLogger(__name__)
ORIGINAL_HITS = [
{
"_id": "doc-1",
"_score": 1.5,
"_source": {
"title.en": "Dogs and wolves",
"content.en": "Dogs are domesticated wolves",
},
},
{
"_id": "doc-2",
"_score": 1.2,
"_source": {
"title.en": "Cats as pets",
"content.en": "Cats are popular pets",
},
},
{
"_id": "doc-3",
"_score": 1.0,
"_source": {
"title.en": "Birds in nature",
"content.en": "Birds fly in the sky",
},
},
]
RERANKER_RESULTS = [
{"doc_id": "doc-2", "score": 0.95},
{"doc_id": "doc-1", "score": 0.87},
{"doc_id": "doc-3", "score": 0.45},
]
@pytest.fixture(name="get_reranker")
def mock_get_reranker():
"""Mock reranker result with reranked scores"""
with patch("core.services.reranking.get_reranker") as mocked_get_reranker:
reranker_result = MagicMock()
reranker_result.results = [
MagicMock(**reranker_result) for reranker_result in RERANKER_RESULTS
]
mock_reranker = MagicMock()
mock_reranker.rank.return_value = reranker_result
mocked_get_reranker.return_value = mock_reranker
# Yield both the patched function and the mock reranker instance
yield mock_reranker
@patch("core.services.reranking.get_reranker")
def test_return_original_results_if_reranker_import_fails(mocked_get_reranker, caplog):
"""Test that original results are returned if reranker import fails"""
mocked_get_reranker.return_value = None
with caplog.at_level(logging.WARNING):
result = rerank("test query", ORIGINAL_HITS)
assert result == ORIGINAL_HITS
assert any(
"Could not import reranker, returning original results" in message
for message in caplog.messages
)
@patch("core.services.reranking.get_reranker")
def test_return_original_results_if_reranking_fails(mocked_get_reranker, caplog):
"""Test that original results are returned if reranking fails"""
mock_reranker = MagicMock()
mock_reranker.rank.side_effect = Exception("Reranking service error")
mocked_get_reranker.return_value = mock_reranker
q = "test query"
with caplog.at_level(logging.ERROR):
result = rerank(q, ORIGINAL_HITS)
assert result == ORIGINAL_HITS
assert any(
"Reranking failed: Reranking service error, returning original results"
in message
for message in caplog.messages
)
def test_reranking_success(get_reranker, caplog):
"""Test successful reranking of search results"""
mock_reranker = get_reranker
q = "test query"
with caplog.at_level(logging.INFO):
reranked_hits = rerank(q, ORIGINAL_HITS.copy())
assert any(
f"Reranking 3 results for query: {q}" in message for message in caplog.messages
)
assert any(
"Reranking completed, returned 3 results" in message
for message in caplog.messages
)
mock_reranker.rank.assert_called_once()
call_args = mock_reranker.rank.call_args
assert call_args.kwargs["query"] == q
assert len(call_args.kwargs["docs"]) == len(RERANKER_RESULTS)
for ranked_hit_index, ranked_hit in enumerate(reranked_hits):
reranker_result = RERANKER_RESULTS[ranked_hit_index]
expected_doc_id = reranker_result["doc_id"]
expected_score = reranker_result["score"]
expected_reranked_hit = ORIGINAL_HITS[
next(
_hit_index
for _hit_index, hit in enumerate(ORIGINAL_HITS)
if hit["_id"] == expected_doc_id
)
]
assert expected_doc_id == ranked_hit["_id"]
assert expected_reranked_hit["_id"] == ranked_hit["_id"]
assert expected_reranked_hit["_source"] == ranked_hit["_source"]
assert expected_reranked_hit["_score"] == ranked_hit["_score"]
assert expected_score == expected_reranked_hit["_reranked_score"]
+56
View File
@@ -5,6 +5,7 @@ Test suite for opensearch search service
import logging
import operator
from json import dumps as json_dumps
from unittest.mock import patch
import pytest
import responses
@@ -712,3 +713,58 @@ def test_search_filtering_by_query_path_and_tag():
assert result["hits"]["total"]["value"] == len(documents_to_search)
assert returned_ids == expected_ids
# pylint: disable=too-many-arguments, too-many-positional-arguments
@responses.activate
@patch("core.services.search.rerank")
@pytest.mark.parametrize(
"rerank_requested, reranker_enabled, rerank_called, caplog_messages",
[
(True, True, True, []),
(
True,
False,
False,
[
"Reranking was explicitly requested but the reranker is "
"disabled in settings. Reranking skipped."
],
),
(False, True, False, []),
(False, False, False, []),
(None, False, False, []),
(None, True, True, []),
],
)
def test_search_rerank( # noqa : PLR0913
mock_rerank,
rerank_requested,
reranker_enabled,
rerank_called,
caplog_messages,
settings,
caplog,
):
"""Test that rerank activation according to args and settings"""
settings.RERANKER_ENABLED = reranker_enabled
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"},
]
)
mock_rerank.return_value = documents
service = factories.ServiceFactory(name=SERVICE_NAME)
prepare_index(service.index_name, documents)
with caplog.at_level(logging.INFO):
search(
q="canine pet", rerank_requested=rerank_requested, **search_params(service)
)
assert mock_rerank.called == rerank_called
for message in caplog_messages:
assert message in caplog.text
+14 -11
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
@@ -305,6 +306,7 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
- The search results can be sorted or filtered via querystring parameters.
"""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [IsAuthAuthenticated]
def post(self, request, *args, **kwargs):
@@ -352,6 +354,9 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
- '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
rerank : bool, optional
Enable or disable reranking of results. If not specified, falls back to
the RERANKER_ENABLED setting.
Returns:
--------
@@ -362,14 +367,7 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
# Get list of groups related to the user from SCIM provider (consider caching result)
audience = self._get_service_provider_audience()
user_sub = self.request.user.sub
groups = []
try:
params = schemas.SearchQueryParametersSchema(**request.data)
except PydanticValidationError as excpt:
errors = {error["loc"][0]: error["msg"] for error in excpt.errors()}
logger.error("Validation error: %s", errors)
raise excpt
params = schemas.SearchQueryParametersSchema(**request.data)
# Get index list for search query
try:
@@ -391,10 +389,15 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
reach=params.reach,
visited=params.visited,
user_sub=user_sub,
groups=groups,
groups=[],
tags=params.tags,
path=params.path,
search_type=params.search_type,
search_type=params.search_type
if params.search_type
else SearchTypeEnum.HYBRID
if check_hybrid_search_enabled()
else SearchTypeEnum.FULL_TEXT,
rerank_requested=params.rerank,
)["hits"]["hits"]
logger.info("found %d results", len(result))
logger.debug("results %s", result)
+18
View File
@@ -319,6 +319,24 @@ class Base(Configuration):
default=1024, environ_name="EMBEDDING_DIMENSION", environ_prefix=None
)
# Reranking
RERANKER_ENABLED = values.BooleanValue(
default=False, environ_name="RERANKER_ENABLED", environ_prefix=None
)
RERANKER_MODEL_NAME = values.Value(
default=None,
environ_name="RERANKER_MODEL_NAME",
environ_prefix=None,
)
RERANKER_MODEL_TYPE = values.Value(
default=None,
environ_name="RERANKER_MODEL_TYPE",
environ_prefix=None,
)
RERANKER_API_KEY = values.Value(
default=None, environ_name="RERANKER_API_KEY", environ_prefix=None
)
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
+1
View File
@@ -44,6 +44,7 @@ dependencies = [
"pydantic==2.12.5",
"pyjwt==2.10.1",
"requests==2.32.5",
"rerankers[api]==0.10.0",
"sentry-sdk==2.48.0",
"url-normalize==2.2.1",
"opensearch-py==3.1.0",
+16
View File
@@ -514,6 +514,7 @@ dependencies = [
{ name = "pyjwt" },
{ name = "redis" },
{ name = "requests" },
{ name = "rerankers", extra = ["api"] },
{ name = "sentry-sdk" },
{ name = "url-normalize" },
{ name = "whitenoise" },
@@ -574,6 +575,7 @@ requires-dist = [
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" },
{ name = "redis", specifier = "==5.3.1" },
{ name = "requests", specifier = "==2.32.5" },
{ name = "rerankers", extras = ["api"], specifier = "==0.10.0" },
{ name = "responses", marker = "extra == 'dev'", specifier = "==0.25.8" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.10" },
{ name = "sentry-sdk", specifier = "==2.48.0" },
@@ -1417,6 +1419,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "rerankers"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/1e/3ed2026be7c135939905eac4f50d1bf8339180821c6757b2e91b83de2fa5/rerankers-0.10.0.tar.gz", hash = "sha256:b8e8b363abc4e9757151956949c27b197993c0a774437287a932f855afc17a73", size = 49679, upload-time = "2025-05-22T08:22:53.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/ed/f3b81ca8743d69b95d679b95e6e1d22cb7cc678ae77c6a57827303a7e48c/rerankers-0.10.0-py3-none-any.whl", hash = "sha256:634a6befa130a245ed46022ade217ee482869448f01aae2051ed54d7d5bd2791", size = 53084, upload-time = "2025-05-22T08:22:52.022Z" },
]
[package.optional-dependencies]
api = [
{ name = "requests" },
]
[[package]]
name = "responses"
version = "0.25.8"