perf: enrich only top-k results with one batched related-docs query

Phase 3 enrichment ran a sequential Neo4j query per fused result and the
final trim then discarded most of the output. Enrichment now covers only
results that can still reach the response - the Phase 4 rerank slice
(RERANK_SLICE_SIZE = 20, results beyond it are dropped when reranking)
or final_result_count, whichever applies - and resolves every page in a
single UNWIND $page_ids Cypher query via the new tenant-scoped
GraphService.get_related_documents_batch (per-page ordering by
shared_entities and per-page limit preserved via ORDER BY + collect()).

Unenriched tail results still carry related_dossiers: [] so the response
shape is unchanged. Query validated with EXPLAIN against the live Neo4j.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
2026-07-14 14:23:34 +02:00
co-authored by Claude Fable 5
parent 041a0cafb8
commit c17c623936
5 changed files with 255 additions and 31 deletions
+118
View File
@@ -0,0 +1,118 @@
"""
Offline tests for Phase 3 enrichment running only on the top-k slice with
ONE batched related-documents lookup (perf: the old code ran a sequential
Neo4j query per fused result and the final trim discarded most of it).
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.services.hybrid_rag_service import HybridRAGService
TENANT = "llm_tester"
@pytest.fixture
def graph():
g = MagicMock()
g.get_related_documents_batch = AsyncMock(return_value={})
return g
@pytest.fixture
def service(graph):
settings = MagicMock()
settings.ollama_llm_model = "test-model"
return HybridRAGService(
vector_service=MagicMock(),
graph_service=graph,
searxng_client=MagicMock(),
ollama_client=MagicMock(),
content_extractor=MagicMock(),
settings=settings,
)
def _fused(n):
return [
{
"result": {"page_id": i + 1, "title": f"page {i + 1}"},
"rrf_score": 1.0 / (i + 1),
"sources": ["vector"],
}
for i in range(n)
]
@pytest.mark.unit
class TestTopKEnrichment:
async def test_only_top_k_enriched_with_single_batched_lookup(
self, service, graph
):
results = _fused(30)
enriched = await service._enrich_with_related_dossiers(
results, user=TENANT, top_k=5
)
# ONE batched lookup, only for the top-k page ids
graph.get_related_documents_batch.assert_awaited_once()
kwargs = graph.get_related_documents_batch.await_args.kwargs
assert kwargs["page_ids"] == [1, 2, 3, 4, 5]
assert kwargs["user"] == TENANT
# The single-page method must not be used anymore
graph.get_related_documents.assert_not_called()
# Every result still carries the key (tail is empty)
assert all("related_dossiers" in r for r in enriched)
assert all(r["related_dossiers"] == [] for r in enriched[5:])
async def test_related_docs_mapped_onto_results(self, service, graph):
graph.get_related_documents_batch = AsyncMock(return_value={
1: [{
"page_id": 9, "title": "rel", "path": "p/rel",
"tags": ["docker", "infra", "misc", "extra-tag-ignored"],
"shared_entities": 3,
}],
})
results = _fused(3)
enriched = await service._enrich_with_related_dossiers(
results, user=TENANT, top_k=2
)
dossiers = enriched[0]["related_dossiers"]
assert len(dossiers) == 3 # max 3 tags per related doc
assert dossiers[0] == {
"page_id": 9, "title": "rel", "path": "p/rel",
"tag": "docker", "shared_entities": 3,
}
assert enriched[1]["related_dossiers"] == []
assert enriched[2]["related_dossiers"] == []
async def test_results_without_page_id_are_skipped(self, service, graph):
results = [
{"result": {"url": "http://x", "title": "web"}, "sources": ["web"]},
{"result": {"page_id": 7, "title": "wiki"}, "sources": ["vector"]},
]
enriched = await service._enrich_with_related_dossiers(
results, user=TENANT, top_k=10
)
kwargs = graph.get_related_documents_batch.await_args.kwargs
assert kwargs["page_ids"] == [7]
assert enriched[0]["related_dossiers"] == []
async def test_batch_lookup_failure_degrades_gracefully(self, service, graph):
graph.get_related_documents_batch = AsyncMock(
side_effect=RuntimeError("neo4j down")
)
results = _fused(3)
enriched = await service._enrich_with_related_dossiers(
results, user=TENANT, top_k=3
)
assert all(r["related_dossiers"] == [] for r in enriched)
+12
View File
@@ -337,6 +337,18 @@ class TestEnrichmentScoping:
assert f"(d1:{TENANT_DOC_LABEL}:Document" in cypher
assert f"(d2:{TENANT_DOC_LABEL}:Document)" in cypher
async def test_batched_related_documents_scoped_to_tenant(
self, graph_service, mock_neo4j
):
await graph_service.get_related_documents_batch(
page_ids=[1, 2], user=TENANT
)
cypher = _all_cypher(mock_neo4j)
assert "UNWIND $page_ids" in cypher
assert f"(d1:{TENANT_DOC_LABEL}:Document" in cypher
assert f"(d2:{TENANT_DOC_LABEL}:Document)" in cypher
# =============================================================================
# Persistence phase (SearchQuery / FOUND / WebResult)