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
+62
View File
@@ -1185,6 +1185,68 @@ Feel free to expand it with more details!
logger.error(f"Failed to get related documents for page {page_id}: {e}", exc_info=True)
return []
async def get_related_documents_batch(
self,
page_ids: List[int],
user: str,
limit_per_page: int = 5
) -> Dict[int, List[Dict[str, Any]]]:
"""
Batch variant of get_related_documents: ONE UNWIND query for all pages
instead of one round-trip per page.
Args:
page_ids: Page IDs to find related documents for
user: User identifier
limit_per_page: Maximum related documents per page
Returns:
Mapping of page_id -> related-document rows (same shape as
get_related_documents). Pages with no related documents are
absent from the mapping.
"""
from src.core.multi_tenancy import get_neo4j_user_base_label
if not page_ids:
return {}
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
# ORDER BY runs before collect() so each page's list is sorted by
# shared_entities descending; [..$limit] trims per page.
query = f"""
UNWIND $page_ids AS pid
MATCH (d1:{user_doc_label}:Document {{page_id: pid}})
MATCH (d1)-[:MENTIONS]->(e:{user_base_label})<-[:MENTIONS]-(d2:{user_doc_label}:Document)
WHERE d1 <> d2 AND NOT e:Document
WITH pid, d2, d2.tags as tags, count(DISTINCT e) as shared_entities
WHERE tags IS NOT NULL AND size(tags) > 0
WITH pid, d2, tags, shared_entities
ORDER BY shared_entities DESC
WITH pid, collect({{
page_id: d2.page_id,
title: d2.title,
path: d2.path,
tags: tags,
shared_entities: shared_entities
}})[..$limit] AS related
RETURN pid AS page_id, related
"""
try:
rows = await self.neo4j.execute_query(
query,
{"page_ids": page_ids, "limit": limit_per_page}
)
return {row["page_id"]: row["related"] for row in rows}
except Exception as e:
logger.error(
f"Failed to get related documents for {len(page_ids)} pages: {e}",
exc_info=True
)
return {}
async def get_all_entities(self, user: str) -> List[Dict[str, Any]]:
"""
Get all entities from the knowledge graph for a user.
+62 -31
View File
@@ -44,6 +44,10 @@ class HybridRAGService:
Service for HybridRAG multi-source search with fusion and re-ranking.
"""
# Phase 4 reranking only ever considers this many fused results; results
# beyond the slice cannot reach the response when reranking is enabled.
RERANK_SLICE_SIZE = 20
# Maps internal retrieval leg names to source_status keys in the response
SOURCE_STATUS_KEYS = {
"vector": "vector",
@@ -148,10 +152,19 @@ class HybridRAGService:
)
timing["fusion_ms"] = (time.time() - phase2_start) * 1000
# Phase 3: Enrichment
# Phase 3: Enrichment — only for results that can still reach the
# response: the rerank slice (Phase 4 reorders within it) when
# reranking is on, otherwise just the final result count. Enriching
# the full fused set queried Neo4j per result and threw most of the
# output away at the final trim.
phase3_start = time.time()
if config.enable_enrichment:
enriched_results = await self._enrich_with_related_dossiers(fused_results, user)
enrich_top_k = config.final_result_count
if config.enable_reranking:
enrich_top_k = max(enrich_top_k, self.RERANK_SLICE_SIZE)
enriched_results = await self._enrich_with_related_dossiers(
fused_results, user, top_k=enrich_top_k
)
else:
enriched_results = fused_results
timing["enrichment_ms"] = (time.time() - phase3_start) * 1000
@@ -159,7 +172,9 @@ class HybridRAGService:
# Phase 4: LLM Re-ranking
phase4_start = time.time()
if config.enable_reranking and len(enriched_results) > 1:
reranked_results = await self._rerank_with_llm(enriched_results[:20], query)
reranked_results = await self._rerank_with_llm(
enriched_results[:self.RERANK_SLICE_SIZE], query
)
else:
reranked_results = enriched_results
timing["reranking_ms"] = (time.time() - phase4_start) * 1000
@@ -736,49 +751,65 @@ JSON:"""
async def _enrich_with_related_dossiers(
self,
results: List[Dict[str, Any]],
user: str
user: str,
top_k: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Phase 3: Enrich results with related documents via shared entities.
Only the top_k results are enriched (the rest get an empty
related_dossiers list — they cannot survive the final trim anyway),
and all lookups go through ONE UNWIND-batched Neo4j query instead of
a sequential round-trip per result.
Args:
results: Fused results
user: User identifier
top_k: How many leading results to enrich (None = all)
Returns:
Results with related_dossiers added
"""
enrich_slice = results if top_k is None else results[:top_k]
# Dedupe while preserving order; volatile/web results have no page_id
page_ids: List[int] = []
for result in enrich_slice:
page_id = result.get("result", {}).get("page_id")
if page_id and page_id not in page_ids:
page_ids.append(page_id)
try:
related_map = await self.graph.get_related_documents_batch(
page_ids=page_ids,
user=user,
limit_per_page=5
)
except Exception as e:
logger.warning(f"Failed batched related-docs lookup for {len(page_ids)} pages: {e}")
related_map = {}
for result in results:
result_data = result.get("result", {})
page_id = result_data.get("page_id")
result["related_dossiers"] = []
if page_id:
try:
related_docs = await self.graph.get_related_documents(
page_id=page_id,
user=user,
limit=5
)
for result in enrich_slice:
page_id = result.get("result", {}).get("page_id")
if not page_id:
continue
# Convert to RelatedDossier format
related_dossiers = []
for doc in related_docs:
for tag in doc.get("tags", [])[:3]: # Max 3 tags per doc
related_dossiers.append({
"page_id": doc["page_id"],
"title": doc["title"],
"path": doc["path"],
"tag": tag,
"shared_entities": doc["shared_entities"]
})
# Convert to RelatedDossier format
related_dossiers = []
for doc in related_map.get(page_id, []):
for tag in (doc.get("tags") or [])[:3]: # Max 3 tags per doc
related_dossiers.append({
"page_id": doc["page_id"],
"title": doc["title"],
"path": doc["path"],
"tag": tag,
"shared_entities": doc["shared_entities"]
})
result["related_dossiers"] = related_dossiers[:5] # Limit to 5 total
except Exception as e:
logger.warning(f"Failed to get related docs for page {page_id}: {e}")
result["related_dossiers"] = []
else:
result["related_dossiers"] = []
result["related_dossiers"] = related_dossiers[:5] # Limit to 5 total
return results