Skip to content

Commit 836e131

Browse files
Zohaibzohaib-7035
authored andcommitted
feat: implement Reciprocal Rank Fusion (RRF) for hybrid search ranking
1 parent 744727b commit 836e131

3 files changed

Lines changed: 166 additions & 15 deletions

File tree

backend/agents.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -435,25 +435,19 @@ async def execute_search(state: AgentState) -> Dict[str, Any]:
435435
return {"ks_results": all_ks_results, "vector_results": vec_results}
436436

437437

438+
from rrf import reciprocal_rank_fusion
439+
438440
def fuse_results(state: AgentState) -> AgentState:
439-
logger.info("Node: Result Fusion")
441+
logger.info("Node: Result Fusion (RRF)")
440442
ks_results = state.get("ks_results", [])
441443
vector_results = state.get("vector_results", [])
442-
combined: Dict[str, dict] = {}
443-
for res in vector_results:
444-
if isinstance(res, dict):
445-
doc_id = res.get("id") or res.get("_id") or f"vec_{len(combined)}"
446-
combined[doc_id] = {**res, "final_score": res.get("similarity", 0) * 0.6}
447-
for res in ks_results:
448-
if isinstance(res, dict):
449-
doc_id = res.get("_id") or res.get("id") or f"ks_{len(combined)}"
450-
if doc_id in combined:
451-
combined[doc_id]["final_score"] += res.get("_score", 0) * 0.4
452-
else:
453-
combined[doc_id] = {**res, "final_score": res.get("_score", 0) * 0.4}
454-
all_sorted = sorted(combined.values(), key=lambda x: x.get("final_score", 0), reverse=True)
444+
445+
# We pass both lists to RRF. RRF handles deduplication and ranking.
446+
# It takes care of ranking documents that appear in either or both lists.
447+
all_sorted = reciprocal_rank_fusion([vector_results, ks_results], k=60, top_k=60)
448+
455449
logger.info(
456-
"Results summary: KS=%d, Vector=%d, Combined=%d",
450+
"RRF fusion: KS=%d, Vector=%dCombined=%d unique results",
457451
len(ks_results),
458452
len(vector_results),
459453
len(all_sorted),

backend/rrf.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import logging
2+
from typing import List, Dict, Any, Set
3+
4+
logger = logging.getLogger("rrf")
5+
6+
def extract_doc_id(result: Dict[str, Any]) -> str:
7+
"""
8+
Safely extract a unique document ID from a search result dictionary.
9+
Handles differences between Keyword Search (KS) and Vector Search formats.
10+
"""
11+
return str(result.get("id") or result.get("_id") or "")
12+
13+
def reciprocal_rank_fusion(
14+
ranked_lists: List[List[Dict[str, Any]]],
15+
k: int = 60,
16+
top_k: int = 15
17+
) -> List[Dict[str, Any]]:
18+
"""
19+
Combines multiple ranked lists of documents into a single ranked list using
20+
Reciprocal Rank Fusion (RRF).
21+
22+
Formula: RRF_score(d) = sum(1 / (k + rank_i(d)))
23+
where `rank_i(d)` is the 1-based index (rank) of document `d` in list `i`.
24+
25+
Args:
26+
ranked_lists: A list of lists, where each inner list contains document dicts
27+
ordered by their original search score (highest first).
28+
k: The smoothing constant (default: 60, standard from literature).
29+
top_k: The number of top fused results to return.
30+
31+
Returns:
32+
A single fused list of document dictionaries, ordered by RRF score descending.
33+
Each dictionary will have an added 'rrf_score' field and an updated 'final_score'
34+
field for compatibility with the rest of the application.
35+
"""
36+
# 1. Initialize RRF scores for all unique document IDs
37+
rrf_scores: Dict[str, float] = {}
38+
39+
# We also keep a mapping of ID -> original document dict
40+
# so we can reconstruct the final list (we use the first occurrence we find)
41+
doc_map: Dict[str, Dict[str, Any]] = {}
42+
43+
for ranked_list in ranked_lists:
44+
for idx, doc in enumerate(ranked_list):
45+
doc_id = extract_doc_id(doc)
46+
47+
# Skip if we couldn't resolve an ID (should theoretically not happen, but safe)
48+
if not doc_id:
49+
# Generate a weak fallback ID based on content hash or title context if needed,
50+
# but for KnowledgeSpace, id or _id should always exist.
51+
doc_id = str(hash(doc.get("title_guess", "unknown")))
52+
53+
rank = idx + 1 # RRF uses 1-based ranks
54+
55+
# Add the reciprocal rank score for this document
56+
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (k + rank))
57+
58+
# Store the underlying doc if we haven't seen it yet
59+
if doc_id not in doc_map:
60+
# Make a shallow copy to avoid mutating the original deeply
61+
doc_map[doc_id] = dict(doc)
62+
63+
# 2. Sort documents by their accumulated RRF score descending
64+
sorted_keys = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True)
65+
sorted_doc_ids: List[str] = list(sorted_keys)
66+
67+
# 3. Construct the final fused list
68+
fused_results: List[Dict[str, Any]] = []
69+
70+
for doc_id in sorted_doc_ids[:top_k]:
71+
doc = doc_map[doc_id]
72+
score = rrf_scores[doc_id]
73+
74+
# Add tracking fields to the document
75+
doc["rrf_score"] = score
76+
# Maintain backward compatibility with agents.py expectations
77+
doc["final_score"] = score
78+
79+
fused_results.append(doc)
80+
81+
logger.debug(f"Combined {len(ranked_lists)} lists into {len(fused_results)} results.")
82+
return fused_results

backend/tests/test_rrf.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import pytest
2+
from rrf import reciprocal_rank_fusion, extract_doc_id
3+
4+
def test_extract_doc_id():
5+
assert extract_doc_id({"id": "123"}) == "123"
6+
assert extract_doc_id({"_id": "456"}) == "456"
7+
assert extract_doc_id({"id": "123", "_id": "456"}) == "123" # Prefers 'id'
8+
assert extract_doc_id({}) == ""
9+
10+
def test_rrf_single_list():
11+
list1 = [{"id": "A"}, {"id": "B"}, {"id": "C"}]
12+
fused = reciprocal_rank_fusion([list1], k=60, top_k=10)
13+
14+
assert len(fused) == 3
15+
assert fused[0]["id"] == "A"
16+
assert fused[1]["id"] == "B"
17+
assert fused[2]["id"] == "C"
18+
19+
# Check score math: A=1/61, B=1/62, C=1/63
20+
assert fused[0]["rrf_score"] == 1 / 61
21+
assert fused[1]["rrf_score"] == 1 / 62
22+
assert fused[2]["rrf_score"] == 1 / 63
23+
24+
def test_rrf_two_lists_same_order():
25+
list1 = [{"id": "A"}, {"id": "B"}]
26+
list2 = [{"_id": "A"}, {"_id": "B"}] # Note list2 uses _id
27+
fused = reciprocal_rank_fusion([list1, list2], k=60, top_k=10)
28+
29+
assert len(fused) == 2
30+
assert fused[0]["id"] == "A" # Source dict comes from list1 first
31+
assert fused[1]["id"] == "B"
32+
33+
# A is rank 1 in both: 1/61 + 1/61
34+
assert fused[0]["rrf_score"] == (1/61) + (1/61)
35+
36+
def test_rrf_boosts_overlap():
37+
# A is in both lists but ranked lower. B is rank 1 in list1 only. C is rank 1 in list2 only.
38+
list1 = [{"id": "B"}, {"id": "A"}, {"id": "X"}]
39+
list2 = [{"id": "C"}, {"id": "A"}, {"id": "Y"}]
40+
41+
fused = reciprocal_rank_fusion([list1, list2], k=60, top_k=10)
42+
43+
weights = {doc["id"]: doc["rrf_score"] for doc in fused}
44+
45+
# A: rank 2 + rank 2 = 1/62 + 1/62 = 0.032258
46+
# B: rank 1 + none = 1/61 + 0 = 0.016393
47+
# C: rank 1 + none = 1/61 + 0 = 0.016393
48+
49+
assert fused[0]["id"] == "A"
50+
assert weights["A"] > weights["B"]
51+
assert weights["A"] > weights["C"]
52+
53+
def test_rrf_empty_lists():
54+
assert reciprocal_rank_fusion([], k=60) == []
55+
assert reciprocal_rank_fusion([[], []], k=60) == []
56+
57+
list1 = [{"id": "A"}]
58+
# Fuses one empty list and one populated list
59+
fused = reciprocal_rank_fusion([list1, []], k=60)
60+
assert len(fused) == 1
61+
assert fused[0]["id"] == "A"
62+
63+
def test_rrf_top_k_truncates():
64+
list1 = [{"id": str(i)} for i in range(100)]
65+
fused = reciprocal_rank_fusion([list1], k=60, top_k=5)
66+
assert len(fused) == 5
67+
assert fused[-1]["id"] == "4" # Indices 0, 1, 2, 3, 4
68+
69+
def test_rrf_id_fallback():
70+
# If a document doesn't have id or _id, the function uses a hash fallback.
71+
# While relying on title_guess is weak, this ensures no crash.
72+
list1 = [{"title_guess": "Unique Title"}, {"title_guess": "Another Title"}]
73+
fused = reciprocal_rank_fusion([list1])
74+
assert len(fused) == 2
75+
assert fused[0].get("rrf_score") is not None

0 commit comments

Comments
 (0)