Skip to the solution
MediumSystem Design16 min read

Hybrid search: why combine BM25 with vectors and how does RRF work?

754 views

The problem

Question presented to candidate: "You have a BM25 result list and a vector-search result list for the same query, and you need one ranking to send to the reranker. Why can you not just add the scores, how does Reciprocal Rank Fusion combine the lists, and what are its knobs and its alternatives?"

What a strong answer should cover:

  • The scale problem: BM25 scores are unbounded and corpus-dependent while cosine similarities sit in a narrow band, so a raw sum is decided by BM25, and normalised scores are distorted by outliers
  • RRF itself: each document gets the sum over lists of 1 / (k + rank), with documents missing from a list contributing nothing; it uses only ranks, rewards agreement between retrievers, and needs no training (Cormack, Clarke and BΓΌttcher, SIGIR 2009, with k = 60)
  • The knobs: the rank constant k (small k favours each list top results, large k flattens toward counting appearances), per-retriever weights (weighted RRF), and the rank window, meaning how deep each list is read before fusing
  • The main alternative: normalise each list (min-max, z-score or distribution-based) and take a weighted convex combination; it keeps score margins and can beat RRF when tuned on labelled data (Bruch et al.), at the cost of tuning and outlier sensitivity
  • Engines expose these as parameters (for example Elasticsearch rank_constant and rank_window_size, Weaviate fusion types and alpha), so defaults should be checked per version and tuned on a labelled set

Code / implementation expected: Yes β€” implementing RRF over two ranked lists (a few lines) and working one example by hand is expected; comparing it with min-max fusion is a plus.

  • hybrid-search
  • bm25

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

16 min read Β· 11 sections

Read the code

Fuse a BM25 list and a vector list: raw sum, min-max, and RRF with k, weights and a rank window
// Fusing two ranked lists: raw-score sum, min-max convex combination, and RRF (with k, weights, window).
// The scores are fixed numbers of the kind BM25 (unbounded) and cosine (about 0 to 1) really produce.
const BM25 = [["d1", 14.2], ["d2", 9.1], ["d3", 8.7], ["d4", 3.2], ["d5", 2.9], ["d6", 2.5]];
const VEC = [["d7", 0.83], ["d1", 0.8], ["d8", 0.79], ["d3", 0.75], ["d2", 0.61], ["d5", 0.6]];
const f = (x, d = 4) => x.toFixed(d);

function rank(scores) { // scores: Map id -> number; ties keep first-seen order
  return [...scores.entries()].map(([id, s], i) => ({ id, s, i })).sort((a, b) => b.s - a.s || a.i - b.i);
}
function rawSum(lists) {
  const m = new Map();
  for (const l of lists) for (const [id, s] of l) m.set(id, (m.get(id) || 0) + s);
  return rank(m);
}
function minMax(lists, weights) {
  const m = new Map();
  lists.forEach((l, j) => {
    const hi = l[0][1], lo = l[l.length - 1][1];
    for (const [id, s] of l) m.set(id, (m.get(id) || 0) + weights[j] * (s - lo) / (hi - lo)); // missing from a list adds 0
  });
  return rank(m);
}
function rrf(lists, k = 60, weights = lists.map(() => 1), window = Infinity) {
  const m = new Map();
  lists.forEach((l, j) => l.slice(0, window).forEach(([id], r) => m.set(id, (m.get(id) || 0) + weights[j] / (k + r + 1))));
  return rank(m);
}
const ids = (res, n = 5) => res.slice(0, n).map((h) => h.id).join(" ");

console.log("input lists");
console.log(`  BM25   : ${BM25.map(([id, s]) => `${id}=${s}`).join("  ")}`);
console.log(`  vector : ${VEC.map(([id, s]) => `${id}=${s}`).join("  ")}`);

console.log("\n1) raw score sum (scales differ, so BM25 decides alone)");
console.log(`  top 5: ${ids(rawSum([BM25, VEC]))}`);

console.log("\n2) min-max normalise each list, then 0.5 * bm25 + 0.5 * vector");
const mm = minMax([BM25, VEC], [0.5, 0.5]);
console.log(`  top 5: ${ids(mm)}   scores: ${mm.slice(0, 5).map((h) => f(h.s, 3)).join(" ")}`);
const BM25b = [["d1", 41.0], ...BM25.slice(1)]; // one outlier score at the top of the BM25 list
const mo = minMax([BM25b, VEC], [0.5, 0.5]);
console.log(`  d1 scores 41.0 instead of 14.2: top 5: ${ids(mo)}   scores: ${mo.slice(0, 5).map((h) => f(h.s, 3)).join(" ")}`);

console.log("\n3) RRF, k = 60: score(d) = sum over lists of 1 / (k + rank)");
const R = rrf([BM25, VEC]);
const pos = (l, id) => l.findIndex(([x]) => x === id) + 1;
console.log("  doc  bm25 rank  vec rank  arithmetic                  score");
for (const h of R) {
  const a = pos(BM25, h.id), b = pos(VEC, h.id);
  const parts = [a && `1/${60 + a}`, b && `1/${60 + b}`].filter(Boolean).join(" + ");
  console.log(`  ${h.id}   ${String(a || "-").padStart(9)}  ${String(b || "-").padStart(8)}  ${parts.padEnd(26)}  ${f(h.s, 5)}`);
}
console.log(`  same with the outlier d1 = 41.0: top 5: ${ids(rrf([BM25b, VEC]))}  (ranks did not change, so neither does RRF)`);

console.log("\n4) the k constant: small k rewards the top of each list, large k flattens");
for (const k of [1, 10, 60, 600]) {
  const r = rrf([BM25, VEC], k);
  console.log(`  k = ${String(k).padStart(3)}: top 5 ${ids(r)}   weight of rank 1 / rank 10 = ${f((k + 10) / (k + 1), 2)}`);
}

console.log("\n5) weighted RRF, vector list weight 2 (trust semantics more)");
console.log(`  top 5: ${ids(rrf([BM25, VEC], 60, [1, 2]))}`);

console.log("\n6) rank window: each retriever only contributes its top 3");
const w = rrf([BM25, VEC], 60, [1, 1], 3);
const gone = rrf([BM25, VEC]).map((h) => h.id).filter((id) => !w.some((h) => h.id === id));
console.log(`  fused: ${ids(w, 9)}   (${w.length} docs; never seen: ${gone.join(" ")})`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 33 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

How do temperature, top-p and other sampling parameters affect output?

Continue