Compare commits

..

1 Commits

Author SHA1 Message Date
Charles Englebert 5189db1b2b ♻️(backend) reorganize evaluation data (#59)
I am improving the data management of evaluation system
2026-03-16 09:58:15 +01:00
11 changed files with 254 additions and 150 deletions
+1 -4
View File
@@ -9,7 +9,7 @@ and this project adheres to
# Unreleased
## Added
- ✨(backend) add rescore on `updated_at`
- 👷(docker) add arm64 platform support for image builds
- ✨(backend) add semantic search
- ✨(backend) add multi-embedding and chunking
@@ -42,6 +42,3 @@ and this project adheres to
- 🐛(backend) fix missing index creation in 'index/' view
- 🐛(backend) fix parallel test execution issues
## Removed
- 🗑️(backend) remove sorting
+3
View File
@@ -38,6 +38,9 @@ UPDATED_AT = "updated_at"
USERS = "users"
GROUPS = "groups"
RELEVANCE = "relevance"
ORDER_BY_OPTIONS = (RELEVANCE, TITLE, CREATED_AT, UPDATED_AT, SIZE, REACH)
SOURCE_FIELDS = (
TITLE,
CONTENT,
+3 -2
View File
@@ -1,6 +1,6 @@
"""Pydantic model to validate documents before indexation."""
from typing import Annotated, List, Optional
from typing import Annotated, List, Literal, Optional
from django.utils import timezone
from django.utils.text import slugify
@@ -116,9 +116,10 @@ class SearchQueryParametersSchema(BaseModel):
reach: Optional[enums.ReachEnum] = None
tags: StringListParameter = Field(default_factory=list)
path: Optional[str] = None
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)
rescore: bool = Field(default=True)
class DeleteDocumentsSchema(BaseModel):
+1 -10
View File
@@ -21,6 +21,7 @@ def embed_text(text):
json={
"input": text,
"model": settings.EMBEDDING_API_MODEL_NAME,
"dimensions": settings.EMBEDDING_DIMENSION,
"encoding_format": "float",
},
timeout=settings.EMBEDDING_REQUEST_TIMEOUT,
@@ -38,14 +39,4 @@ def embed_text(text):
logger.warning("unexpected embedding response format: %s", response.text)
return None
if len(embedding) != settings.EMBEDDING_DIMENSION:
logger.warning(
"unexpected embedding dimension: "
"EMBEDDING_DIMENSION is set to %d "
"but the configured embedding model returned a vector of dimension %d",
settings.EMBEDDING_DIMENSION,
len(embedding),
)
return None
return embedding
+18 -31
View File
@@ -17,6 +17,8 @@ logger = logging.getLogger(__name__)
def search( # noqa : PLR0913
q,
nb_results,
order_by,
order_direction,
search_indices,
reach,
visited,
@@ -25,7 +27,6 @@ def search( # noqa : PLR0913
tags,
search_type,
path=None,
rescore=False,
):
"""Perform an OpenSearch search"""
query = get_query(
@@ -47,9 +48,13 @@ def search( # noqa : PLR0913
"number_of_users": {"script": {"source": "doc['users'].size()"}},
"number_of_groups": {"script": {"source": "doc['groups'].size()"}},
},
"sort": get_sort(
query_keys=query.keys(),
order_by=order_by,
order_direction=order_direction,
),
"size": nb_results,
"query": query,
"rescore": get_rescore(nb_results=nb_results) if rescore else [],
},
params=get_params(query_keys=query.keys()),
# disable=unexpected-keyword-arg because
@@ -222,35 +227,17 @@ def get_filter( # noqa : PLR0913
return filters
def get_rescore(nb_results):
"""
Build rescore query.
Rescore is based on the `updated_at` field to boost more recently updated documents
"""
return [
{
"window_size": nb_results,
"query": {
"rescore_query_weight": settings.RESCORE_UPDATED_AT_WEIGHT,
"rescore_query": {
"function_score": {
"functions": [
{
"gauss": {
"updated_at": {
"origin": "now",
"offset": settings.RESCORE_UPDATED_AT_OFFSET,
"scale": settings.RESCORE_UPDATED_AT_SCALE,
"decay": settings.RESCORE_UPDATED_AT_DECAY,
}
}
}
],
}
},
},
}
]
def get_sort(query_keys, order_by, order_direction):
"""Build OpenSearch sort clause"""
# Add sorting logic based on relevance or specified field
if "hybrid" in query_keys:
# sorting by other field than "_score" is not supported in hybrid search
# see: https://github.com/opensearch-project/neural-search/issues/866
return {"_score": {"order": order_direction}}
if order_by == enums.RELEVANCE:
return {"_score": {"order": order_direction}}
return {order_by: {"order": order_direction}}
def get_params(query_keys):
@@ -1,7 +1,6 @@
"""Tests indexing documents in OpenSearch over the API"""
import datetime
import logging
from django.utils import timezone
@@ -119,43 +118,6 @@ def test_api_documents_index_single_hybrid_enabled_success(settings):
assert len(chunk["content"]) < len(document["content"])
@responses.activate
def test_api_documents_index_with_wrong_embedding_dimension(settings, caplog):
"""Test embedding with wrong dimension should log a warning and not index the embedding."""
service = factories.ServiceFactory()
enable_hybrid_search(settings)
settings.EMBEDDING_DIMENSION = 8
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
document = factories.DocumentSchemaFactory.build()
with caplog.at_level(logging.WARNING):
APIClient().post(
"/api/v1.0/documents/index/",
document,
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
format="json",
)
assert any(
"unexpected embedding dimension: EMBEDDING_DIMENSION is set to 8 "
"but the configured embedding model returned a vector of dimension 1024"
in message
for message in caplog.messages
)
new_indexed_document = opensearch.opensearch_client().get(
index=service.index_name, id=str(document["id"])
)
# check embedding
assert new_indexed_document["_source"]["chunks"] is None
def test_api_documents_index_language_params():
"""language_code query param should control which language is indexed."""
service = factories.ServiceFactory()
@@ -6,6 +6,7 @@ of documents is slow and better done only once.
"""
import logging
import operator
import random
import pytest
@@ -193,11 +194,7 @@ def test_api_documents_search_match_all(settings):
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "*",
"visited": [doc["id"] for doc in documents],
"rescore": False,
},
{"q": "*", "visited": [doc["id"] for doc in documents]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
@@ -502,6 +499,181 @@ def test_api_documents_hybrid_search(settings):
}
@responses.activate
def test_api_documents_search_ordering_by_fields(settings):
"""It should be possible to order by several fields"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
service = factories.ServiceFactory()
documents = factories.DocumentSchemaFactory.build_batch(
4, reach=random.choice(["public", "authenticated"])
)
prepare_index(service.index_name, documents)
parameters = [
(enums.CREATED_AT, "asc"),
(enums.CREATED_AT, "desc"),
(enums.UPDATED_AT, "asc"),
(enums.UPDATED_AT, "desc"),
(enums.SIZE, "asc"),
(enums.SIZE, "desc"),
(enums.REACH, "asc"),
(enums.REACH, "desc"),
]
for field, direction in parameters:
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "*",
"order_by": field,
"order_direction": direction,
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
data = response.json()
assert len(data) == 4
# Check that results are sorted by the field as expected
compare = operator.le if direction == "asc" else operator.ge
for i in range(len(data) - 1):
assert compare(data[i]["_source"][field], data[i + 1]["_source"][field])
@responses.activate
def test_api_documents_search_ordering_by_relevance(settings):
"""It should be possible to order by relevance (score)"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
service = factories.ServiceFactory()
documents = factories.DocumentSchemaFactory.build_batch(
4, reach=random.choice(["public", "authenticated"])
)
prepare_index(service.index_name, documents)
for direction in ["asc", "desc"]:
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "*",
"order_by": "relevance",
"order_direction": direction,
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
data = response.json()
assert len(data) == 4
# Check that results are sorted by score as expected
compare = operator.le if direction == "asc" else operator.ge
for i in range(len(data) - 1):
assert compare(data[i]["_score"], data[i + 1]["_score"])
@responses.activate
def test_api_documents_search_ordering_by_unknown_field(settings):
"""Trying to sort by an unknown field should return a 400 error"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
# Setup: Initialize the service and documents only once
service = factories.ServiceFactory()
documents = factories.DocumentSchemaFactory.build_batch(
2, reach=random.choice(["public", "authenticated"])
)
prepare_index(service.index_name, documents)
# Define the parameters manually
directions = ["asc", "desc"]
# Perform the parameterized tests
for direction in directions:
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "*",
"order_by": "unknown",
"order_direction": direction,
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
assert response.json() == [
{
"loc": ["order_by"],
"msg": (
"Input should be 'relevance', 'title', 'created_at', "
"'updated_at', 'size' or 'reach'"
),
"type": "literal_error",
}
]
@responses.activate
def test_api_documents_search_ordering_by_unknown_direction(settings):
"""Trying to sort with an unknown direction should return a 400 error"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
service = factories.ServiceFactory()
documents = factories.DocumentSchemaFactory.build_batch(
2, reach=random.choice(["public", "authenticated"])
)
prepare_index(service.index_name, documents)
for field in enums.ORDER_BY_OPTIONS:
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "*",
"order_by": field,
"order_direction": "unknown",
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
assert response.json() == [
{
"loc": ["order_direction"],
"msg": "Input should be 'asc' or 'desc'",
"type": "literal_error",
}
]
@responses.activate
def test_api_documents_search_filtering_by_reach(settings):
"""It should be possible to filter results by their reach"""
@@ -561,7 +733,6 @@ def test_api_documents_search_with_nb_results(settings):
"q": "*",
"nb_results": nb_results,
"visited": [doc["id"] for doc in documents],
"rescore": False,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
@@ -584,7 +755,7 @@ def test_api_documents_search_with_nb_results(settings):
)
assert response.status_code == 200
data = response.json()
assert {r["_id"] for r in data} == set(ids[0:nb_results])
assert [r["_id"] for r in data] == ids[0:nb_results]
nb_results = 10
response = APIClient().post(
@@ -600,7 +771,7 @@ def test_api_documents_search_with_nb_results(settings):
assert response.status_code == 200
data = response.json()
# nb_results > total number of documents => returns all documents
assert {r["_id"] for r in data} == set(ids[0:9])
assert [r["_id"] for r in data] == ids[0:9]
@responses.activate
@@ -669,13 +840,12 @@ def test_api_documents_search_nb_results_with_filtering(settings):
"reach": "public",
"nb_results": nb_results,
"visited": public_ids,
"rescore_enable": False,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert {r["_id"] for r in response.json()} == set(public_ids[0:nb_results])
assert [r["_id"] for r in response.json()] == public_ids[0:nb_results]
@responses.activate
+38 -41
View File
@@ -2,8 +2,8 @@
Test suite for opensearch search service
"""
import datetime
import logging
import operator
from json import dumps as json_dumps
import pytest
@@ -32,6 +32,8 @@ def search_params(service):
"""Build opensearch.search() parameters for tests using the service index name"""
return {
"nb_results": 20,
"order_by": "relevance",
"order_direction": "desc",
"search_indices": {service.index_name},
"reach": None,
"user_sub": "user_sub",
@@ -405,6 +407,41 @@ def test_match_all(settings, caplog):
assert len(result["hits"]["hits"]) == 3
@responses.activate
def test_search_ordering_by_relevance(settings, caplog):
"""Test the hybrid supports ordering by relevance asc and desc"""
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"},
]
)
q = "canine pet"
service = factories.ServiceFactory(name=SERVICE_NAME)
prepare_index(service.index_name, documents)
for direction in ["asc", "desc"]:
with caplog.at_level(logging.INFO):
result = search(
q=q, **{**search_params(service), "order_direction": direction}
)
# Check that results are sorted by score as expected
hits = result["hits"]["hits"]
compare = operator.le if direction == "asc" else operator.ge
for i in range(len(hits) - 1):
assert compare(hits[i]["_score"], hits[i + 1]["_score"])
@responses.activate
def test_hybrid_search_number_of_matches(settings):
"""
@@ -675,43 +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
def test_search_with_rescore(settings):
"""Test rescore feature"""
service = factories.ServiceFactory(name=SERVICE_NAME)
today = datetime.datetime.today()
forty_days_ago = today - datetime.timedelta(days=40)
documents = [
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="my first document",
created_at=today,
updated_at=today,
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="another document",
created_at=forty_days_ago,
updated_at=forty_days_ago,
),
]
prepare_index(service.index_name, documents)
# set a cray big RESCORE_UPDATED_AT_WEIGHT to demonstrate the effect of boosting on rescores
settings.RESCORE_UPDATED_AT_WEIGHT = 200
results = search(
q="another document",
**{
**search_params(service),
"rescore": True,
},
)
hits = results["hits"]["hits"]
# the first document is ranked first because it more recent
# even though the second one matches the query better
assert hits[0]["_source"]["title.en"] == "my first document"
assert hits[1]["_source"]["title.en"] == "another document"
+8 -1
View File
@@ -332,6 +332,12 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
path : str, optional
Filter results based on the 'path' field. Only documents whose path
starts with the provided value will be returned.
order_by : str, optional
Order results by 'relevance', 'created_at', 'updated_at', or 'size'.
Defaults to 'relevance' if not specified.
order_direction : str, optional
Order direction, 'asc' for ascending or 'desc' for descending.
Defaults to 'desc'.
nb_results : int, optional
The number of results to return.
Defaults to 50 if not specified.
@@ -375,6 +381,8 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
result = search(
q=params.q,
nb_results=params.nb_results,
order_by=params.order_by,
order_direction=params.order_direction,
search_indices=search_indices,
reach=params.reach,
visited=params.visited,
@@ -387,7 +395,6 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
else SearchTypeEnum.HYBRID
if check_hybrid_search_enabled()
else SearchTypeEnum.FULL_TEXT,
rescore=params.rescore,
)["hits"]["hits"]
logger.info("found %d results", len(result))
logger.debug("results %s", result)
@@ -38,6 +38,8 @@ class Command(BaseCommand):
index_name = "evaluation-index"
search_params = {
"nb_results": 20,
"order_by": "relevance",
"order_direction": "desc",
"search_indices": {index_name},
"reach": None,
"user_sub": "user_sub",
-13
View File
@@ -318,19 +318,6 @@ class Base(Configuration):
EMBEDDING_DIMENSION = values.IntegerValue(
default=1024, environ_name="EMBEDDING_DIMENSION", environ_prefix=None
)
# rescore
RESCORE_UPDATED_AT_WEIGHT = values.FloatValue(
default=0.2, environ_name="RESCORE_UPDATED_AT_WEIGHT", environ_prefix=None
)
RESCORE_UPDATED_AT_OFFSET = values.Value(
default="2d", environ_name="RESCORE_UPDATED_AT_OFFSET", environ_prefix=None
)
RESCORE_UPDATED_AT_SCALE = values.Value(
default="6d", environ_name="RESCORE_UPDATED_AT_SCALE", environ_prefix=None
)
RESCORE_UPDATED_AT_DECAY = values.IntegerValue(
default=0.5, environ_name="RESCORE_UPDATED_AT_SCALE", environ_prefix=None
)
# CORS
CORS_ALLOW_CREDENTIALS = True