Reference · Lesson 0004
Reciprocal Rank Fusion (RRF) Cheat Sheet
Printable. Designed to live next to your editor.
The formula
RRF(d) = Σᵢ wᵢ / (k + rankᵢ(d))
d = document / chunk
i = index over rankers (vector, BM25, …)
rankᵢ(d) = 1-indexed position of d in ranker i's list
k = smoothing constant (Cormack et al. default: 60)
wᵢ = weight for ranker i (sum to 1.0 across rankers, optional)
- If
d is missing from ranker i, its contribution is 0.
Our constants (from config.py)
| Name | Value | Meaning |
RRF_K | 60 | Smoothing constant (Cormack default) |
VECTOR_WEIGHT | 0.7 | Weight for cosine-similarity ranker |
BM25_WEIGHT | 0.3 | Weight for BM25 ranker |
Rank-decay table (k = 60)
| rank | 1/(k+rank) | % of rank-1 | weighted (×0.7 vec, ×0.3 bm25) |
| 1 | 0.01639 | 100% | 0.01148 / 0.00492 |
| 2 | 0.01613 | 98% | 0.01129 / 0.00484 |
| 3 | 0.01587 | 97% | 0.01111 / 0.00476 |
| 5 | 0.01538 | 94% | 0.01077 / 0.00462 |
| 10 | 0.01429 | 87% | 0.01000 / 0.00429 |
| 20 | 0.01250 | 76% | 0.00875 / 0.00375 |
| 50 | 0.00909 | 55% | 0.00636 / 0.00273 |
| 100 | 0.00625 | 38% | 0.00438 / 0.00188 |
Top 5 ranks contribute almost identically. After rank 50, contributions are still non-trivial. After rank 100, they're down to ~38% of rank-1.
Algorithm (pseudocode)
function RRF(rank_lists, weights, k=60):
# rank_lists: dict[ranker_name → list of doc_ids] (rank 1 first)
# weights: dict[ranker_name → float]
scores = {}
for name, rlist in rank_lists.items():
w = weights[name]
for rank, doc in enumerate(rlist, start=1):
scores[doc] = scores.get(doc, 0) + w / (k + rank)
return sorted(scores.items(), key=lambda x: -x[1])
How our HybridSearch.search does it
vec_results = vector_store.vector_search(query_vec, top_k=top_k * 2)
bm25_results = bm25.search(query, top_k=top_k * 2)
scores = {}
# Vector list: starts each chunk with vector contribution
for rank, r in enumerate(vec_results):
cid = r["id"]
scores[cid] = {
"rrf_score": VECTOR_WEIGHT / (RRF_K + rank + 1),
"vector_rank": rank + 1,
"bm25_rank": None,
"vector_score": 1 - r["score"], # cosine in [0,1]
}
# BM25 list: adds BM25 contribution (or starts a new entry)
for rank, r in enumerate(bm25_results):
cid = r["doc_id"]
boost = BM25_WEIGHT / (RRF_K + rank + 1)
if cid in scores:
scores[cid]["rrf_score"] += boost
scores[cid]["bm25_rank"] = rank + 1
else:
scores[cid] = hit_from_bm25_only(cid, ..., rank + 1, boost)
return sorted(scores.values(), key=lambda x: -x["rrf_score"])[:top_k]
Fusion algorithm comparison
| Algorithm | Needs normalized scores? | Handles missing chunks? | Complexity | Used by |
| RRF | No (ranks only) | Yes (missing = 0) | O(N) | Elasticsearch, OpenSearch, Vespa, Weaviate, us |
| CombSUM | Yes (min-max / z-score) | Breaks if scale differs | O(N) | Older IR systems |
| CombMNZ | Yes | Breaks if scale differs | O(N) | Older IR systems |
| Condorcet | No | Yes | O(N²) | Rare; superseded by RRF |
| Linear blend | Yes | Breaks if scale differs | O(N) | Naive / first attempt |
When to change k
Smaller k (10–30)
Sharper top ranks. Top 1 dominates more. Use when:
- top_k is small (≤ 5)
- You trust both rankers near-perfectly
- You want to "punish" lower ranks harshly
Larger k (100+)
Flatter curve, more contribution from tail. Use when:
- top_k is large (≥ 50)
- One ranker has a noisy long tail
- You want "any presence in a list" to count
Default k=60 is fine for almost everything. Don't tune it before you've first tuned your actual rankers.
When to change weights
| Corpus / query type | Suggested split | Reason |
| Long-form prose (articles, books) | vec 0.5 / bm25 0.5 | BM25 has many terms to work with |
| Short technical docs (TILs, API refs) | vec 0.7 / bm25 0.3 | Vocab is small but semantic still wins |
| Code with identifiers / error codes | vec 0.3 / bm25 0.7 | Exact matches matter more than semantics |
| General web search | vec 0.6 / bm25 0.4 | Common starting point |
Common RRF failure modes
- Both rankers miss: the chunk just isn't there. Not RRF's fault.
- One ranker is broken: if cosine is returning random results, the RRF score is dominated by BM25 (and vice versa). Sanity-check each ranker independently before debugging RRF.
- Top-k too small in the inner calls: the
top_k * 2 fudge factor in our code exists because RRF can rescue a chunk ranked 7 in cosine but 1 in BM25. If you fetch only top_k from each, you exclude the rescue candidates.
- You try to use RRF score as a probability: it's not. It's a relative ranking signal. The absolute number 0.016 has no meaning across queries.
Glossary
| Term | Definition |
| Rank | 1-indexed position in a sorted list. Rank 1 = best. |
| Ranker | A retrieval system that produces a sorted list of candidates. |
| Fusion | Combining multiple rankers' outputs into one ranking. |
| Reciprocal rank | 1 / rank. The smaller the rank, the larger the reciprocal. RRF uses a smoothed version: 1 / (k + rank). |
| Smoothing constant (k) | Prevents the top ranks from completely dominating. Higher k = flatter curve. |
| Rescue pattern | When a chunk is high in one list and low (or missing) in another, RRF gives it a partial score from the high list. This is the main value of hybrid search. |