Skip to main content

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:

Query
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;
Result
 id | title                         | rrf----+-------------------------------+---------  7 | Star Trek: The Motion Picture | 0.01639  8 | Alien                         | 0.01639

Each 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.

k1/(k+1)1/(k+10)Top-vs-10 ratio
100.09090.05001.8×
600.01640.01431.15×
2000.004980.004761.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 the GROUP 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