HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 932 min read

Hybrid Search & Reciprocal Rank Fusion (RRF)

Build It in 5 Lines

AI engineeringAI engineering / RAG

The problem you hit around week 3 of RAG

Your vector search is good at "how do I cancel my plan" → finds the doc titled "Ending a subscription". Semantics work.

Then a user searches "ERR_TLS_1042" or "flag --no-verify-peer" and you get back three friendly articles about security in general. Zero exact hits.

Embeddings compress meaning. Rare tokens — error codes, SKUs, CLI flags, surnames — are exactly what gets compressed away. BM25 (plain keyword/lexical search) nails those, because a rare term has huge IDF weight.

So: run both, then fuse. That's hybrid search.

Build it in 5 lines (Reciprocal Rank Fusion)

def rrf(rankings, k=60):
    scores = defaultdict(float)
    for ranked_ids in rankings:              # 1
        for rank, doc_id in enumerate(ranked_ids, start=1):   # 2
            scores[doc_id] += 1.0 / (k + rank)                # 3
    return sorted(scores, key=scores.get, reverse=True)       # 4

top = rrf([bm25_search(q, 50), vector_search(q, 50)])[:10]    # 5

What each line teaches:

1️⃣ Input is lists of IDs, not scores. Deliberate.

2️⃣ Only rank position matters — position 1, 2, 3…

3️⃣ The whole algorithm. Contribution decays as 1/(k+rank).

4️⃣ A doc in both lists sums both contributions → agreement wins.

5️⃣ Over-fetch (50) from each side, return 10. Fusion needs candidates to fuse.

Why ranks, not scores — the bit people get wrong

BM25 returns unbounded scores (0 → 40+, corpus-dependent). Cosine similarity returns ~0.0–1.0, and in practice clusters tight: your best hit is 0.86, your worst is 0.81. Those two scales have no common unit.

The usual fix — min-max normalize each list, then 0.7*dense + 0.3*bm25 — is fragile: normalization depends on the min/max in that result set, so one outlier rescales everything and your weights silently change per query. RRF sidesteps it by throwing scores away entirely.

What k=60 actually does

k damps the top of the list. With k=60:

  • rank 1 → 1/61 = 0.0164
  • rank 10 → 1/70 = 0.0143 (only 13% less)
  • rank 50 → 1/110 = 0.0091

So rank 1 is NOT dominant — a doc at rank 8 in both lists (0.0294) beats a doc at rank 1 in one list and absent from the other (0.0164). That's the intended behavior: cross-retriever agreement is the signal.

Lower k (say 10) makes it top-heavy: rank 1 = 0.091 vs rank 10 = 0.05. Tune k, don't treat 60 as sacred — it's just the value from the original 2009 paper.

Three traps

  • Fetching only top-10 per retriever. A doc at BM25 rank 23 never gets to redeem a weak dense rank. Over-fetch 5x what you return.
  • Assuming RRF replaces a reranker. It doesn't. RRF = cheap candidate merging. A cross-encoder reranker on the fused top-50 is still where most quality comes from.
  • Feeding it three retrievers where two are near-duplicates (e.g. two embedding models from the same family). They vote together and drown the lexical signal. Diversity of failure modes is the point.

📊 Rough expectation: on BEIR-style benchmarks, hybrid typically buys +5–12 points nDCG@10 over dense alone, and the gain is biggest on entity/code/ID-heavy corpora — smallest on chatty FAQ text.

Your exercise

Take 10 real queries from your app. Split them: 5 "conceptual" (how do I…), 5 "literal" (an error code, a function name, a product ID).

Run each through dense-only and BM25-only, record the rank of the correct doc in both.

Then compute RRF by hand for the worst literal query. Question to answer: did fusion actually pull the right doc up, or was it already absent from both top-50s? If it's absent, your problem is chunking or recall — not fusion.