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:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user