- Merge vector+graph into single wiki source before RRF with web - Wiki pages no longer get 2x advantage from dual retrieval - Add vector similarity threshold (0.7 default) - Skip synonyms in graph search to reduce noise - Fix duplicate entity links bug in graph search 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@ class Settings(BaseSettings):
|
||||
hybrid_rag_vector_limit: int = Field(default=10, ge=1, le=50, description="Vector search limit")
|
||||
hybrid_rag_graph_limit: int = Field(default=10, ge=1, le=50, description="Graph search limit")
|
||||
hybrid_rag_web_limit: int = Field(default=5, ge=1, le=20, description="Web search limit")
|
||||
vector_similarity_threshold: float = Field(default=0.7, ge=0.0, le=1.0, description="Minimum similarity score for vector results")
|
||||
|
||||
# Entity Linking Fuzzy Matching Configuration
|
||||
entity_linking_min_confidence: float = Field(default=0.70, ge=0.0, le=1.0, description="Minimum confidence for entity-document matching")
|
||||
|
||||
@@ -1077,8 +1077,18 @@ Feel free to expand it with more details!
|
||||
search_query,
|
||||
{"terms": all_terms, "limit": limit}
|
||||
)
|
||||
logger.info(f"Graph search found {len(results)} documents")
|
||||
return results
|
||||
|
||||
# Deduplicate by page_id (safety net for any edge cases)
|
||||
seen_page_ids = set()
|
||||
unique_results = []
|
||||
for r in results:
|
||||
page_id = r.get("page_id")
|
||||
if page_id and page_id not in seen_page_ids:
|
||||
seen_page_ids.add(page_id)
|
||||
unique_results.append(r)
|
||||
|
||||
logger.info(f"Graph search found {len(unique_results)} unique documents (raw: {len(results)})")
|
||||
return unique_results
|
||||
except Exception as e:
|
||||
logger.error(f"Graph document search failed: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
@@ -105,14 +105,20 @@ class HybridRAGService:
|
||||
timing["graph_ms"] = raw_results.get("timing", {}).get("graph_ms", 0)
|
||||
timing["web_ms"] = raw_results.get("timing", {}).get("web_ms", 0)
|
||||
|
||||
# Phase 2: RRF Fusion
|
||||
# Phase 2: Two-Stage RRF Fusion
|
||||
phase2_start = time.time()
|
||||
|
||||
# Stage 1: Merge wiki sources (vector + graph) into single ranking
|
||||
wiki_merged = self._merge_wiki_sources(
|
||||
vector_results=raw_results.get("vector", []),
|
||||
graph_results=raw_results.get("graph", []),
|
||||
k=config.rrf_k
|
||||
)
|
||||
|
||||
# Stage 2: Final RRF between wiki and web (equal footing)
|
||||
fused_results = self._reciprocal_rank_fusion(
|
||||
results_by_source={
|
||||
"vector": raw_results.get("vector", []),
|
||||
"graph": raw_results.get("graph", []),
|
||||
"web": raw_results.get("web", [])
|
||||
},
|
||||
wiki_results=wiki_merged,
|
||||
web_results=raw_results.get("web", []),
|
||||
k=config.rrf_k
|
||||
)
|
||||
timing["fusion_ms"] = (time.time() - phase2_start) * 1000
|
||||
@@ -288,7 +294,8 @@ JSON:"""
|
||||
response = await self.vector.search(
|
||||
query=query,
|
||||
user=user,
|
||||
limit=config.vector_limit
|
||||
limit=config.vector_limit,
|
||||
score_threshold=self.settings.vector_similarity_threshold
|
||||
)
|
||||
results = [
|
||||
{
|
||||
@@ -313,11 +320,19 @@ JSON:"""
|
||||
async def graph_search():
|
||||
start = time.time()
|
||||
try:
|
||||
# Skip synonyms for graph search - only use core keywords
|
||||
# Synonyms like "author" can match unrelated entities like "author2000"
|
||||
graph_keywords = {
|
||||
"core_keywords": keywords_data.get("core_keywords", []),
|
||||
"entities": keywords_data.get("entities", []),
|
||||
"synonyms": {}, # No synonyms for exact entity matching
|
||||
"expansions": {}
|
||||
}
|
||||
results = await self.graph.search_documents(
|
||||
query=query,
|
||||
user=user,
|
||||
limit=config.graph_limit,
|
||||
keywords_data=keywords_data
|
||||
keywords_data=graph_keywords
|
||||
)
|
||||
formatted = [
|
||||
{
|
||||
@@ -394,52 +409,134 @@ JSON:"""
|
||||
|
||||
return output
|
||||
|
||||
def _reciprocal_rank_fusion(
|
||||
def _merge_wiki_sources(
|
||||
self,
|
||||
results_by_source: Dict[str, List],
|
||||
vector_results: List[Dict],
|
||||
graph_results: List[Dict],
|
||||
k: int = 60
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Phase 2: Merge results using Reciprocal Rank Fusion.
|
||||
Stage 1: Merge vector and graph into single wiki ranking using RRF.
|
||||
|
||||
RRF formula: score = sum(1 / (k + rank)) for each source
|
||||
Both sources search the same wiki pool, so we combine them before
|
||||
final RRF with web to avoid double-counting wiki pages.
|
||||
|
||||
Args:
|
||||
results_by_source: Results from each source
|
||||
vector_results: Results from vector search
|
||||
graph_results: Results from graph search
|
||||
k: RRF constant (default 60)
|
||||
|
||||
Returns:
|
||||
Merged and sorted results
|
||||
Merged wiki results sorted by wiki RRF score
|
||||
"""
|
||||
wiki_scores = {}
|
||||
|
||||
# Process vector results
|
||||
for rank, result in enumerate(vector_results, start=1):
|
||||
page_id = result.get("page_id")
|
||||
if not page_id:
|
||||
continue
|
||||
result_id = f"page_{page_id}"
|
||||
|
||||
if result_id not in wiki_scores:
|
||||
wiki_scores[result_id] = {
|
||||
"result": dict(result), # Copy to avoid mutation
|
||||
"wiki_rrf_score": 0.0,
|
||||
"found_by": []
|
||||
}
|
||||
|
||||
wiki_scores[result_id]["wiki_rrf_score"] += 1 / (k + rank)
|
||||
wiki_scores[result_id]["found_by"].append("vector")
|
||||
|
||||
# Process graph results
|
||||
for rank, result in enumerate(graph_results, start=1):
|
||||
page_id = result.get("page_id")
|
||||
if not page_id:
|
||||
continue
|
||||
result_id = f"page_{page_id}"
|
||||
|
||||
if result_id not in wiki_scores:
|
||||
wiki_scores[result_id] = {
|
||||
"result": dict(result),
|
||||
"wiki_rrf_score": 0.0,
|
||||
"found_by": []
|
||||
}
|
||||
|
||||
wiki_scores[result_id]["wiki_rrf_score"] += 1 / (k + rank)
|
||||
wiki_scores[result_id]["found_by"].append("graph")
|
||||
|
||||
# Add graph metadata to existing result
|
||||
wiki_scores[result_id]["result"]["entity_matches"] = result.get("entity_matches")
|
||||
wiki_scores[result_id]["result"]["matched_entities"] = result.get("matched_entities")
|
||||
|
||||
# Sort by wiki RRF score
|
||||
sorted_wiki = sorted(
|
||||
wiki_scores.values(),
|
||||
key=lambda x: x["wiki_rrf_score"],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Return merged results with wiki ranking
|
||||
merged = []
|
||||
for wiki_rank, item in enumerate(sorted_wiki, start=1):
|
||||
merged.append({
|
||||
**item["result"],
|
||||
"wiki_rank": wiki_rank,
|
||||
"wiki_rrf_score": item["wiki_rrf_score"],
|
||||
"found_by": item["found_by"],
|
||||
"source": "wiki"
|
||||
})
|
||||
|
||||
logger.info(f"Wiki merge: {len(merged)} unique pages from vector+graph")
|
||||
return merged
|
||||
|
||||
def _reciprocal_rank_fusion(
|
||||
self,
|
||||
wiki_results: List[Dict],
|
||||
web_results: List[Dict],
|
||||
k: int = 60
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Stage 2: Final RRF between wiki (single source) and web.
|
||||
|
||||
Wiki results are pre-merged from vector+graph, so wiki and web
|
||||
now compete on equal footing.
|
||||
|
||||
Args:
|
||||
wiki_results: Pre-merged wiki results from _merge_wiki_sources()
|
||||
web_results: Results from web search
|
||||
k: RRF constant (default 60)
|
||||
|
||||
Returns:
|
||||
Final merged and sorted results
|
||||
"""
|
||||
rrf_scores = {}
|
||||
|
||||
for source, results in results_by_source.items():
|
||||
for rank, result in enumerate(results, start=1):
|
||||
# Use page_id for wiki results, url hash for web results
|
||||
if result.get("page_id"):
|
||||
result_id = f"page_{result['page_id']}"
|
||||
elif result.get("url"):
|
||||
result_id = f"url_{hash(result['url'])}"
|
||||
else:
|
||||
continue # Skip results without ID
|
||||
# Wiki results (single source, already merged)
|
||||
for rank, result in enumerate(wiki_results, start=1):
|
||||
page_id = result.get("page_id")
|
||||
if not page_id:
|
||||
continue
|
||||
result_id = f"page_{page_id}"
|
||||
rrf_scores[result_id] = {
|
||||
"result": result,
|
||||
"rrf_score": 1 / (k + rank),
|
||||
"sources": result.get("found_by", ["wiki"]),
|
||||
"source_type": "wiki"
|
||||
}
|
||||
|
||||
if result_id not in rrf_scores:
|
||||
rrf_scores[result_id] = {
|
||||
"result": result,
|
||||
"rrf_score": 0.0,
|
||||
"sources": [],
|
||||
"source_type": source
|
||||
}
|
||||
|
||||
# RRF formula: sum of 1/(k + rank) across sources
|
||||
rrf_scores[result_id]["rrf_score"] += 1 / (k + rank)
|
||||
rrf_scores[result_id]["sources"].append(source)
|
||||
|
||||
# If result appears in multiple sources, update source_type
|
||||
if len(rrf_scores[result_id]["sources"]) > 1:
|
||||
rrf_scores[result_id]["source_type"] = "+".join(
|
||||
sorted(set(rrf_scores[result_id]["sources"]))
|
||||
)
|
||||
# Web results (single source)
|
||||
for rank, result in enumerate(web_results, start=1):
|
||||
url = result.get("url")
|
||||
if not url:
|
||||
continue
|
||||
result_id = f"url_{hash(url)}"
|
||||
rrf_scores[result_id] = {
|
||||
"result": result,
|
||||
"rrf_score": 1 / (k + rank),
|
||||
"sources": ["web"],
|
||||
"source_type": "web"
|
||||
}
|
||||
|
||||
# Sort by RRF score descending
|
||||
sorted_results = sorted(
|
||||
@@ -448,7 +545,7 @@ JSON:"""
|
||||
reverse=True
|
||||
)
|
||||
|
||||
logger.info(f"RRF fusion: {len(sorted_results)} unique results from {len(results_by_source)} sources")
|
||||
logger.info(f"Final RRF: {len(sorted_results)} results (wiki + web)")
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
||||
Reference in New Issue
Block a user