Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] 51a735972e ⬆️(dependencies) update python dependencies 2026-03-23 01:04:03 +00:00
11 changed files with 22 additions and 430 deletions
-1
View File
@@ -31,7 +31,6 @@ and this project adheres to
- ✨(backend) add deletion endpoint
- ✨(backend) add path filter
- ✨(backend) add search_type param
- ✨(backend) implement reranker #47
## Changed
-1
View File
@@ -120,7 +120,6 @@ class SearchQueryParametersSchema(BaseModel):
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)
rerank: Optional[bool] = Field(default=None)
class DeleteDocumentsSchema(BaseModel):
-128
View File
@@ -1,128 +0,0 @@
"""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
+1 -11
View File
@@ -9,7 +9,6 @@ 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__)
@@ -28,7 +27,6 @@ def search( # noqa : PLR0913
tags,
search_type,
path=None,
rerank_requested=None,
):
"""Perform an OpenSearch search"""
query = get_query(
@@ -42,7 +40,7 @@ def search( # noqa : PLR0913
path=path,
search_type=search_type,
)
response = opensearch_client().search( # pylint: disable=unexpected-keyword-arg
return opensearch_client().search( # pylint: disable=unexpected-keyword-arg
index=",".join(search_indices),
body={
"_source": enums.SOURCE_FIELDS, # limit the fields to return
@@ -64,14 +62,6 @@ 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,7 +8,6 @@ of documents is slow and better done only once.
import logging
import operator
import random
from unittest import mock
import pytest
import responses
@@ -994,36 +993,3 @@ 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
@@ -1,140 +0,0 @@
"""
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,7 +5,6 @@ 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
@@ -713,58 +712,3 @@ 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
+2 -5
View File
@@ -354,9 +354,6 @@ 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:
--------
@@ -367,6 +364,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 = []
params = schemas.SearchQueryParametersSchema(**request.data)
# Get index list for search query
@@ -389,7 +387,7 @@ 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
@@ -397,7 +395,6 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
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,24 +319,6 @@ 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)
+19 -20
View File
@@ -23,32 +23,31 @@ description = "An application to print markdown to pdf from a set of managed tem
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = "~=3.12.0"
requires-python = "~=3.14.3"
dependencies = [
"celery[redis]==5.6.2",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"redis==5.3.1",
"redis==7.3.0",
"django-redis==6.0.0",
"django==5.2.12",
"django-lasuite[all]==0.0.22",
"django==6.0.3",
"django-lasuite[all]==0.0.25",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"dockerflow==2026.3.4",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"gunicorn==25.1.0",
"py3langid==0.3.0",
"langchain-text-splitters==1.1.0",
"langchain-text-splitters==1.1.1",
"mozilla-django-oidc==5.0.2",
"psycopg[binary]==3.3.2",
"psycopg[binary]==3.3.3",
"pydantic==2.12.5",
"pyjwt==2.10.1",
"requests==2.32.5",
"rerankers[api]==0.10.0",
"sentry-sdk==2.48.0",
"sentry-sdk==2.54.0",
"url-normalize==2.2.1",
"opensearch-py==3.1.0",
"whitenoise==6.11.0",
"whitenoise==6.12.0",
]
[project.urls]
@@ -60,21 +59,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1",
"faker==40.1.0",
"drf-spectacular-sidecar==2026.3.1",
"faker==40.11.0",
"ipdb==0.13.13",
"ipython==9.8.0",
"pyfakefs==6.0.0",
"ipython==9.11.0",
"pyfakefs==6.1.5",
"pylint-django==2.7.0",
"pylint==4.0.4",
"pylint==4.0.5",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.10",
"types-requests==2.32.4.20250913",
"responses==0.26.0",
"ruff==0.15.6",
"types-requests==2.32.4.20260107",
]
[tool.setuptools]
-16
View File
@@ -514,7 +514,6 @@ dependencies = [
{ name = "pyjwt" },
{ name = "redis" },
{ name = "requests" },
{ name = "rerankers", extra = ["api"] },
{ name = "sentry-sdk" },
{ name = "url-normalize" },
{ name = "whitenoise" },
@@ -575,7 +574,6 @@ 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" },
@@ -1419,20 +1417,6 @@ 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"