Reciprocal Rank Fusion: The Algorithm Behind Hybrid Search
TL;DR — Reciprocal Rank Fusion (RRF) is a seven-line algorithm that combines rankings from multiple retrieval systems without score normalization. The formula: RRF_score(d) = sum(1 / (k + rank_i(d))) for each result list i. The constant k=60 (from Cormack et al. 2009) is the default in Elasticsearch, OpenSearch, Weaviate, Qdrant, Azure AI Search, and MongoDB Atlas. RRF outperforms Condorcet Fuse, CombMNZ, and individual learning-to-rank methods. It works because it's score-agnostic: BM25 scores and cosine similarity are on different scales, but ranks are comparable. For enterprise RAG, RRF is the fusion layer that makes hybrid retrieval work. Tune k in {10, 30, 60, 100} if you have labeled data; otherwise, k=60 is reliable.
Reciprocal Rank Fusion is the algorithm that makes hybrid search work. It combines ranked result lists from multiple retrievers — typically BM25 (keyword) and vector (semantic) — into a single ranking without needing to normalize their scores.
The algorithm is seven lines of Python. It has one parameter. It was published in 2009 and has since become the default fusion method in Elasticsearch, OpenSearch, Weaviate, Qdrant, Azure AI Search, and MongoDB Atlas.
The RRF Formula
RRF_score(d) = Σ 1 / (k + rank_i(d))
i∈R
Where:
- d is a document
- R is the set of result lists (retrievers)
- rank_i(d) is the 1-based rank of document d in result list i
- k is a smoothing constant (default: 60)
Documents missing from a list contribute zero from that list. The fused score is sorted descending to produce the final ranking.
def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""Reciprocal Rank Fusion — combine rankings from multiple retrievers."""
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.items(), key=lambda item: -item[1])
# Example: fuse BM25 and vector search results
bm25_results = ["docA", "docC", "docB", "docE", "docD"]
vector_results = ["docC", "docB", "docA", "docF", "docE"]
fused = rrf([bm25_results, vector_results], k=60)
# Result: docC ranks first (rank 2 in BM25, rank 1 in vector)
# docA ranks second (rank 1 in BM25, rank 3 in vector)
# docB ranks third (rank 3 in BM25, rank 2 in vector)
Why RRF Works: Score Agnosticism
The core problem RRF solves is score incompatibility. BM25 scores can range from 0 to 100+. Cosine similarity scores range from 0 to 1. A document with BM25 score 15.3 and cosine similarity 0.82 cannot be meaningfully averaged.
| Property | BM25 Score | Cosine Similarity |
|---|---|---|
| Range | 0 to ∞ | 0 to 1 |
| Scale | Unbounded | Bounded |
| Distribution | Skewed by term frequency | Relatively uniform |
| Interpretation | Term frequency × IDF | Angular distance |
Approaches that try to normalize these scores (min-max scaling, z-score normalization) are fragile and dataset-dependent. RRF sidesteps the problem entirely by throwing away the scores and keeping only the ranks.
The k Parameter: What It Controls
The constant k controls how aggressively top ranks are weighted:
| k Value | Effect | Best For |
|---|---|---|
| k=10 | Sharp drop-off after rank 1-2 | Top-1 precision (e-commerce, exact match) |
| k=30 | Moderate drop-off | Top-3 precision (knowledge base lookup) |
| k=60 | Balanced (default) | General purpose (most use cases) |
| k=100 | Gentle drop-off | Top-10 recall (feeding to reranker or LLM) |
With k=10, rank 1 gets 0.091 and rank 5 gets 0.067 — a 26% difference. With k=60, rank 1 gets 0.0164 and rank 5 gets 0.0154 — a 6% difference. Lower k amplifies the advantage of being ranked first. Higher k treats all ranks more equally.
Tuning guidance: If you have labeled queries, sweep k in {10, 30, 60, 100} and measure NDCG@10 and Recall@k. The curve is usually flat between 40 and 80. If you don't have labeled data, use k=60 — it's the empirical sweet spot that has held up across nearly two decades of benchmarks.
Weighted RRF
The basic formula treats every retriever equally. The weighted variant lets you bias one retriever when you have evidence it's systematically better:
RRF_weighted(d) = Σ w_r / (k + rank_r(d))
r∈R
Where w_r is the weight for retriever r.
| Use Case | Weight Configuration | Rationale |
|---|---|---|
| Equal weights (default) | w_bm25 = 1, w_vector = 1 | No evidence either is better |
| BM25-heavy | w_bm25 = 1.5, w_vector = 1.0 | Exact match domain (legal, compliance) |
| Vector-heavy | w_bm25 = 1.0, w_vector = 1.5 | Semantic domain (support, conversational) |
| Knowledge graph + vector | w_graph = 1.2, w_vector = 1.0 | Multi-hop queries |
Don't add weights until you've measured. The equal-weight default is usually fine. Per-query tuning is a signal that you want a learned ranker, not weighted RRF.
RRF Implementation Across Databases
Elasticsearch
{
"retriever": {
"rrf": {
"rank_constant": 60,
"rank_window_size": 50,
"retrievers": [
{
"standard": {
"query": { "match": { "content": "error code 48291" } }
}
},
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"num_candidates": 100
}
}
]
}
},
"size": 10
}
OpenSearch
{
"query": {
"hybrid": {
"queries": [
{ "match": { "content": { "query": "error code 48291" } } },
{ "knn": { "embedding": { "vector": [0.1, ...], "k": 50 } } }
]
}
},
"search_pipeline": "hybrid-rrf-pipeline"
}
pgvector (Manual SQL)
WITH bm25_results AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS rank
FROM documents, plainto_tsquery('english', $1) query
WHERE tsv @@ query LIMIT 50
),
vector_results AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $2) AS rank
FROM documents ORDER BY embedding <=> $2 LIMIT 50
)
SELECT id,
COALESCE(1.0 / (60 + bm25_results.rank), 0) +
COALESCE(1.0 / (60 + vector_results.rank), 0) AS rrf_score
FROM bm25_results FULL OUTER JOIN vector_results USING (id)
ORDER BY rrf_score DESC LIMIT 10;
Vendor Comparison
| Database | RRF Support | Parameter Name | Weighted RRF | Notes |
|---|---|---|---|---|
| Elasticsearch | ✅ Native | rank_constant |
❌ Equal weight | Retriever API (paid tier) |
| OpenSearch | ✅ Native | rank_constant |
✅ Per-retriever | Search pipeline |
| Weaviate | ✅ Default | alpha (0-1) |
✅ Alpha mix | Alpha controls dense/sparse mix |
| Qdrant | ✅ Native | k |
❌ Equal weight | Fusion.RRF |
| Azure AI Search | ✅ Native | Fixed k=60 | ❌ Equal weight | Hybrid search |
| MongoDB Atlas | ✅ Native | k |
✅ $rankFusion weights |
Aggregation pipeline |
| Milvus | ✅ Ranker | k |
❌ Equal weight | RRF Ranker for hybrid |
| pgvector | ⚠️ Manual | SQL k value |
✅ SQL coefficients | ROW_NUMBER + COALESCE |
RRF vs Other Fusion Methods
| Method | How It Works | Pros | Cons |
|---|---|---|---|
| RRF | Sum of 1/(k+rank) | Score-agnostic, no tuning, robust | Ignores score magnitude |
| CombSUM | Normalize scores, sum | Uses score information | Fragile normalization |
| CombMNZ | Normalize, sum, bonus for co-occurrence | Rewards consensus | Complex, normalization-dependent |
| Condorcet | Pairwise majority voting | Democratic | Computationally expensive |
| Learned Ranker | ML model on features | Best accuracy | Requires training data, overfits |
The original Cormack et al. 2009 paper showed RRF outperforming Condorcet Fuse, CombMNZ, and the best individual system by 4-5% on average across TREC experiments. All differences were statistically significant (p < 0.04).
When RRF Is Not Enough
RRF is the fusion layer — it combines rankings. But it doesn't improve the quality of the individual rankings. If both retrievers return poor results, RRF produces a poor fused ranking.
Add cross-encoder reranking when RRF-fused results need higher precision. The pattern: retrieve top-50 with hybrid search + RRF, rerank top-10 with a cross-encoder. This is covered in detail in our hybrid retrieval guide.
Add a knowledge graph when queries require multi-hop reasoning that neither BM25 nor vector search can handle. RRF fuses two retrieval lists; GraphRAG adds a third retrieval channel based on relationship traversal.
RRF Implementation Checklist
- [ ] Choose database with native RRF support (OpenSearch, Elasticsearch, Weaviate)
- [ ] Start with k=60 (the default that works for most use cases)
- [ ] Run BM25 and vector search in parallel, not sequentially
- [ ] Set rank_window_size / top_k for each retriever to 3-5x final result count
- [ ] Apply RRF fusion to combine the two ranked lists
- [ ] Measure NDCG@10 and Recall@k on a labeled query set (if available)
- [ ] Sweep k in {10, 30, 60, 100} if you have labeled data
- [ ] Use k=40-60 for precision-focused workloads (lookup, exact match)
- [ ] Use k=60-100 for recall-focused workloads (feeding to reranker/LLM)
- [ ] Consider weighted RRF only after measuring equal-weight performance
- [ ] Add cross-encoder reranking for top-k precision boost
- [ ] Enforce document-level ACLs before fusion
- [ ] For pgvector: implement RRF in SQL with ROW_NUMBER and COALESCE
- [ ] Benchmark RRF vs score-normalization fusion on your query distribution
- [ ] Integrate with hybrid retrieval pipeline
- [ ] Monitor fusion latency (should be negligible — RRF is O(n) in result size)
FAQ
What is Reciprocal Rank Fusion (RRF)?
Reciprocal Rank Fusion is a rank-based algorithm for combining results from multiple retrieval systems. Instead of normalizing incompatible scores (BM25 and cosine similarity are on different scales), RRF uses only rank positions. The formula is: RRF_score(d) = sum(1 / (k + rank_i(d))) for each result list i. The constant k (default 60) controls how aggressively top ranks are weighted. RRF was introduced by Cormack, Clarke, and Büttcher in their 2009 SIGIR paper and outperformed Condorcet Fuse and CombMNZ.
Why does RRF use rank instead of score?
BM25 scores and cosine similarity scores are on completely different scales. BM25 can produce scores from 0 to 100+, while cosine similarity ranges from 0 to 1. Averaging or weighted-summing these scores requires normalization, which is fragile and dataset-dependent. RRF sidesteps this entirely by using only the rank position of each document in each result list. This makes it score-agnostic and robust across different retrieval systems without any tuning.
What is the optimal k value for RRF?
The default k=60 comes from the original Cormack et al. 2009 paper and has held up across nearly two decades of benchmarks. Most production systems ship k=60 as the default. For high-precision workloads (e-commerce, knowledge base lookup), k in the 40-60 range tends to win. For recall-oriented workloads (feeding top-50 to a reranker or LLM), k in the 60-100 range keeps the long tail from getting flattened. If you have labeled data, sweep k in {10, 30, 60, 100} and measure NDCG@10.
Which databases support RRF natively?
Elasticsearch ships an rrf retriever with rank_constant parameter. OpenSearch supports RRF as a score-combination technique in hybrid search. Weaviate uses RRF as the default fusion algorithm. Qdrant exposes Fusion.RRF as a first-class mode. Azure AI Search uses RRF for hybrid ranking. MongoDB Atlas supports $rankFusion with weights. Milvus provides an RRF Ranker for hybrid search. pgvector requires manual SQL implementation using ROW_NUMBER() and COALESCE.
Can RRF be weighted between retrievers?
Yes. The weighted RRF variant is: sum(w_r / (k + rank_r(d))) for each retriever r, where w_r is the weight for retriever r. This lets you bias one retriever when you have evidence it's systematically better for your use case. MongoDB exposes weights directly, Weaviate's alpha parameter interpolates between dense and sparse, and OpenSearch supports per-retriever weights. However, the equal-weight default is usually fine — don't add weights until you've measured.
Want a self-hosted AI company brain that does all of this out of the box?
Book a demo →