Abstract

RRF (reciprocal rank fusion) combines ranked lists from different retrievers into one. Each document scores from every list it appears in, and the scores are summed. It was proposed by Cormack, Clarke and Büttcher (SIGIR 2009) with , and it is now the default way to fuse BM25 and vector search in hybrid search.

It looks only at ranks, never at scores. A BM25 score of 7.3 and a cosine similarity of 0.82 can’t be added, but “1st” and “3rd” can.

Cue

  • What does a document score from one list? → , or 0 if the list doesn’t contain it
  • Why fuse ranks rather than scores? → Scores from different retrievers are on different scales and distributions. Ranks are always comparable
  • What does do? → Flattens the curve, so rank 1 is worth only 15% more than rank 10. Agreement between lists beats a single first place
  • Rank 60 in two lists vs rank 1 in one? → beats . Agreement wins
  • How is RRF related to MRR? → Same reciprocal, but MRR is over queries, and RRF is over lists

Formula

For a set of ranked lists , where is document ‘s 1-based rank in list :

A document missing from a list gets 0 from it. Sort by , highest first. Weighted RRF multiplies each list’s contribution by a weight , so you can trust one retriever more:

Two different s

RRF’s is a smoothing constant (60). The in Hit@k and Recall@k is a cutoff (how many results to look at). Engines often call RRF’s one rank_constant to avoid the clash.

Why ranks, not scores

The obvious way to combine retrievers is to add their scores. It doesn’t work well:

  • BM25 is unbounded. Scores depend on the query length and the corpus, so a “good” score for one query is a poor one for another.
  • Cosine similarity is bunched. Embedding models often put everything between 0.7 and 0.9, so the gap between relevant and irrelevant is tiny.
  • Normalising is fragile. Min-max scaling each list to hangs on the single best and worst result, so one outlier reshapes the whole list.

RRF sidesteps all of this. It needs no training data, no tuning per query, and no knowledge of how either retriever scores.

What does

controls how steeply the score falls with rank:

Rank1251050
(like MRR)1.0000.5000.2000.1000.020
0.01640.01610.01540.01430.0091
Rank 1 ÷ rank 1010× at 1.15× at
  • Small trusts each retriever’s top pick. One list’s first place can outweigh everything else.
  • Large rewards consensus. A document that both retrievers rank fairly well beats one that only a single retriever loves.

60 isn’t magic. Cormack et al. found it worked well across TREC collections, and results are not very sensitive to it. Treat it as a sensible default, and tune only if you have labelled queries to tune against.

Worked example

The BM25 results for “search index” from the BM25 worked example, fused with a (made-up) vector search over the same six documents, :

DocBM25 rankVector rankRRFFused rank
d3130.016390.015870.032261
d1320.015870.016130.032002
d4–1–0.016390.016393
d62–0.01613–0.016134
d2–4–0.015630.015635
  • d3 and d1 appear in both lists, so they score roughly double and take the top two places.
  • d4 is the vector search’s first pick, but BM25 never found it, so it drops to third.
  • d6 is BM25’s second pick but has no semantic match, so it drops to fourth.

The BM25 scores (0.985, 0.888, 0.611) never enter the calculation.

Implementation (Rust)

use std::collections::HashMap;
use std::hash::Hash;
 
const K: f64 = 60.0; // Cormack et al.'s constant: larger values flatten the gap between ranks
 
/// Fuse ranked lists of ids (each best first) into one list, best first.
pub fn rrf<T: Clone + Eq + Hash + Ord>(lists: &[Vec<T>]) -> Vec<(T, f64)> {
    let weighted: Vec<(&[T], f64)> = lists.iter().map(|l| (l.as_slice(), 1.0)).collect();
    weighted_rrf(&weighted)
}
 
/// Each list contributes `weight / (K + rank)` to every id it contains.
/// An id missing from a list gets nothing from that list.
pub fn weighted_rrf<T: Clone + Eq + Hash + Ord>(lists: &[(&[T], f64)]) -> Vec<(T, f64)> {
    let mut scores: HashMap<T, f64> = HashMap::new();
    for (list, weight) in lists {
        for (i, id) in list.iter().enumerate() {
            let rank = (i + 1) as f64; // ranks are 1-based
            *scores.entry(id.clone()).or_insert(0.0) += weight / (K + rank);
        }
    }
    let mut fused: Vec<_> = scores.into_iter().collect();
    fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    fused
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    fn ids<T: Clone>(fused: &[(T, f64)]) -> Vec<T> {
        fused.iter().map(|(id, _)| id.clone()).collect()
    }
 
    #[test]
    fn worked_example() {
        let bm25 = vec!["d3", "d6", "d1"];
        let vector = vec!["d4", "d1", "d3", "d2"];
        assert_eq!(ids(&rrf(&[bm25, vector])), ["d3", "d1", "d4", "d6", "d2"]);
    }
 
    #[test]
    fn agreement_beats_one_first_place() {
        // "x" is 60th in both lists, "top" is 1st in one list only
        let fill = |p: &str, n: usize| (1..=n).map(|i| format!("{p}{i}")).collect::<Vec<_>>();
        let a = [vec!["top".to_string()], fill("a", 58), vec!["x".to_string()]].concat();
        let b = [fill("b", 59), vec!["x".to_string()]].concat();
        let order = ids(&rrf(&[a, b]));
        let pos = |id: &str| order.iter().position(|d| d == id).unwrap();
        assert!(pos("x") < pos("top"));
    }
 
    #[test]
    fn weights_tip_a_disagreement() {
        let keyword = (&["a", "b"][..], 1.0);
        let vector = (&["b", "a"][..], 3.0);
        assert_eq!(ids(&weighted_rrf(&[keyword, vector])), ["b", "a"]);
    }
 
    #[test]
    fn ties_break_by_id() {
        assert_eq!(ids(&rrf(&[vec!["b", "a"], vec!["a", "b"]])), ["a", "b"]);
    }
}

Notes on the Rust:

  • Generic over the id type. Anything hashable and ordered works, such as &str, u64 or a chunk-id struct.
  • total_cmp plus an id tiebreak makes the order total and reproducible. Ties are common in RRF because scores depend only on ranks: two documents at ranks (1, 2) and (2, 1) score exactly the same.
  • It assumes each list has no duplicate ids. A list with the same id twice would credit it twice. This bites when one retriever returns chunks and you fuse by document id. Deduplicate first, keeping each document’s best rank.

In practice

  • Fuse a window, not everything. Each retriever returns its top (often 50–100), and RRF fuses those. A document ranked below the window gets 0 from that list. A deeper window lets late agreement count, at the cost of more retrieval work.
  • Search engines build it in. Elasticsearch and OpenSearch have RRF for hybrid queries, with rank_constant defaulting to 60. Azure AI Search uses RRF for its hybrid mode.
  • Rerank afterwards. RRF is cheap but crude. A common pipeline is BM25 + vectors → RRF → a cross-encoder reranker on the top 20–50. qmd’s query does exactly this over this vault.
  • Measure it. Compare BM25 alone, vectors alone and the fused list on the same questions with Hit@k and MRR. Fusion usually beats both, but not always: if one retriever is much weaker, it can drag good results down.

RRF vs score fusion

RRFScore fusion (e.g. )
UsesRanks onlyNormalised scores
NeedsNothingA normalisation scheme and a tuned
KeepsOrderHow far apart results are (“1st by a mile” vs “1st by a hair”)
Best whenNo labelled data, or retrievers you can’t inspectLabelled queries to tune on

Bruch, Gai and Ingber (An Analysis of Fusion Functions for Hybrid Retrieval, 2023) found that a tuned convex combination of scores can beat RRF, and that RRF is more sensitive to than often assumed. RRF remains the safe default when you can’t tune.

Strengths and blind spots

✅ Scale-freeWorks with any retriever that returns a ranked list, even a black-box API.
✅ No trainingOne constant, and the default is usually fine.
✅ Rewards consensusA document two different methods both find is usually relevant.
❌ Throws away scoresIt can’t tell a confident first place from a near tie.
❌ Weak lists still voteA poor retriever adds noise unless you down-weight it.
❌ Window-dependentA document just outside one list’s top loses that list’s vote entirely.

BLUESKY — START THE THREAD KO-FI / RSS