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