Reciprocal Rank Fusion
Reciprocal Rank Fusion (RRF) combines two or more ranked result lists into one. Reach for it when no single signal — BM25 over one field, BM25 over another, fuzzy match, vector distance — captures every relevant document, and each surfaces some that the others miss.
See Setup for the shared dataset used in all examples.
How it works
For each branch, every matching document gets a rank (1, 2, 3, ...) under that branch's own scoring. RRF combines those ranks per document with:
rrf_score(d) = Σ over branches 1 / (k + rank_in_branch(d))
A document missing from a branch contributes nothing for that branch. Documents that rank high in any branch end up with a high combined score; documents that rank high in several branches dominate.
k controls how steeply top ranks outweigh lower ranks. The default in the original paper and in Elasticsearch is 60.
Template
Copy the skeleton and replace each branch with your own ranking query:
WITH fused AS (
-- Branch 1
SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM (
SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx
WHERE title @@ ts_phrase('YOUR_QUERY')
ORDER BY s DESC LIMIT 100
) t
UNION ALL
-- Branch 2
SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM (
SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx
WHERE description @@ ts_phrase('YOUR_QUERY')
ORDER BY s DESC LIMIT 100
) t
)
SELECT id, SUM(1.0 / (60 + rank)) AS rrf_score
FROM fused
GROUP BY id
ORDER BY rrf_score DESC
LIMIT 10;
Each branch:
- selects
(id, score)rows that match whatever predicate you want, - sorts by its own score, capped with a per-branch
LIMIT(the window size — see Tuning), - assigns ranks with
ROW_NUMBER().
The outer query sums 1 / (60 + rank) per id and returns the top fused results.
Worked example
Same query word, two fields. "Alien" appears in the title of one film and only in the description of another:
WITH fused AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx WHERE title @@ ts_phrase('alien') ORDER BY s DESC LIMIT 100 ) t UNION ALL SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx WHERE description @@ ts_phrase('alien') ORDER BY s DESC LIMIT 100 ) t)SELECT m.id, m.title, SUM(1.0 / (60 + rank))::DECIMAL(6,5) AS rrfFROM fused f JOIN movies m ON m.id = f.idGROUP BY m.id, m.titleORDER BY rrf DESC, m.idLIMIT 5; id | title | rrf----+-------------------------------+--------- 7 | Star Trek: The Motion Picture | 0.01639 8 | Alien | 0.01639Each branch alone returns one document. Fused, both surface — and a document matching both fields would score about twice as high.
Tuning
k — top-rank weight
k = 60 is the published default and works well out of the box. Lower k widens the gap between top ranks; higher k flattens the curve so that the set of candidates matters more than the order within each branch.
k | 1/(k+1) | 1/(k+10) | Top-vs-10 ratio |
|---|---|---|---|
10 | 0.0909 | 0.0500 | 1.8× |
60 | 0.0164 | 0.0143 | 1.15× |
200 | 0.00498 | 0.00476 | 1.05× |
Window size — per-branch LIMIT
Each branch's LIMIT N is the window: only the top N results per branch contribute. A document outside every branch's window scores 0.
- Navigational ("I know what I want") queries:
LIMIT 50–100. - Exploratory queries where the long tail matters:
LIMIT 200+, at the cost of more rows flowing into theGROUP BY.
More branches
Add another UNION ALL block per extra signal — the shape doesn't change:
WITH fused AS (
-- branch 1: title BM25
SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM (...) t
UNION ALL
-- branch 2: description BM25
SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM (...) t
UNION ALL
-- branch 3: fuzzy, n-gram, or any other ranked source
SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS rank FROM (...) t
)
SELECT id, SUM(1.0 / (60 + rank)) AS rrf_score
FROM fused GROUP BY id ORDER BY rrf_score DESC LIMIT 10;
When not to use RRF
- One signal dominates. If BM25 alone gives the right answer, fusing it with a weaker signal only dilutes the ranking.
- You need calibrated scores. RRF discards the original scores in favor of ranks. If downstream code needs "this document is 92% relevant", rerank the fused candidates with a calibrated model instead.
- Your branches' scores are already on the same scale. Then a weighted sum
α·s₁ + β·s₂is usually better — it doesn't throw away score magnitudes.
See also
- BM25/TFIDF Ranking — the scoring functions you'll most often feed into RRF.
- Fuzzy Search — a natural second branch alongside exact-form BM25.