Study Notes
Notefoundation

Hybrid Search — BM25 + FAISS + RRF

Combining keyword and vector retrieval — BM25 lexical scoring, FAISS dense-vector search, and merging both rankings with Reciprocal Rank Fusion.

30 min read·verified Content review 2026·reviewed 2026-08-30

After this you should be able to

  • Explain the core idea in Hybrid Search — BM25 + FAISS + RRF
  • Apply Hybrid Search — BM25 + FAISS + RRF in a representative exercise
BM25FAISSReciprocal Rank Fusion

Why dense semantic search silently fails on exact identifiers, how the sparse keyword king BM25 fixes it, why you can't just add their scores, and how RRF fuses them by rank. Full study reference + runnable code + lab.

FAISS / dense BM25 / sparse RRF / fusion LangChain EnsembleRetriever RAG retrieval

Section I

Notebook Summary — copy by hand

Terse cheat sheet. Keyword first, then rule. Arrows = cause/effect. Formulas verbatim. Doodles = ~10s stick sketches. Fully derivable from Section II.

═══ THE CORE PROBLEM ═══
Dense (FAISS) search  → great at MEANING, blind to exact strings
Sparse (BM25) search  → great at exact TEXT, blind to synonyms
∴ need BOTH → run in parallel → fuse results = HYBRID SEARCH

Detective analogy:
FAISS = Behavioral Profiler (motives, themes, synonyms)
  BM25  = Fingerprint Analyst (exact, microscopic matches)
  Master Agent = runs both in parallel

doodle: query → forks into [FAISS box] + [BM25 box] → both feed → [FUSE] → 1 list
                

A. The Semantic Blind Spot (FAISS / dense)#

Tokenization catastrophe
ID "INV-2023-99X" has NO single pretrained concept
  → chopped: ["INV","2023","99","X"]
  → meaning averaged: "invoice"+"year 2023"+"num 99"
  → cosine sim: 99X ≈ 99Y  (both = "2023 invoice")
  ⇒ grabs WRONG invoice, pays wrong vendor, fails SILENTLY

Cosine sim scale → bounded, tight: real scores ~0.70–0.95
doodle: two dots labelled 99X &99Y sitting on top of each other = false match
                
= the ultimate Ctrl+F → match = big score, no match = ZERO
Strength → exact IDs, SKUs, codes, error strings
Flaw → VOCABULARY GAP: search "puppy", doc says "dog" → 0 results

Built on TF-IDF:
TF  = more mentions → more relevant
  IDF = rare words rewarded, common words ("the") ≈ ignored

BM25 = TF-IDF + 2 fixes for keyword stuffing:
k₁ → SATURATION  → caps repetition reward (10x ≠ 10× better)
  b  → LENGTH NORM → punishes long rambling docs
  defaults: k₁ ≈ 1.2–2.0 , b = 0.75            [verified]
  doodle: curve rising then flattening = saturation (k₁ ceiling)
                
                    BM25 FORMULA (verbatim):
                    score(D,Q) = Σ IDF(qᵢ) · [ f(qᵢ,D)·(k₁+1) ]
                          ───────────────────────────────────
                          [ f(qᵢ,D) + k₁·(1 − b + b·|D|/avgdl) ]

 IDF(qᵢ) = ln( (N − n(qᵢ) + 0.5) / (n(qᵢ) + 0.5) + 1 )

 f=term freq in D · |D|=doc len · avgdl=avg len · N=#docs · n=#docs w/ term
                

C. The Ensembling Problem (apples vs oranges)#

EngineScaleRange
FAISS cosinebounded / normalized~0.70–0.95
BM25unbounded summation0 → 45 → 150+
Why naive add FAILS:
"add 5 miles to 30°C"
  Doc A (perfect semantic): FAISS .88 + BM25 0.0 → .44
  Doc B (random IT ticket):  FAISS .40 + BM25 12.5 → 6.45  ✗ WRONG WINS
  ⇒ BM25's big ints swallow FAISS decimals → meaning destroyed

Why Min-Max normalize FAILS:
one outlier (80.0) crushes
  99 valid docs → ~0.02 each ("curve where 1 kid scores 5000%")
                

D. Reciprocal Rank Fusion (RRF) — the equalizer#

Shift:
stop reading SCORES → read the PODIUM (rank only)
Rank is the ONLY common language between the two engines.

RRF FORMULA (verbatim):
RRF(d) = Σ over retrievers   1 / (k + rank_r(d))     k = 60

Why k=60 → flatten so #1 isn't a dictator → reward CONSENSUS
   rank1 = 1/61 = 0.0164    rank2 = 1/62 = 0.0161   (tiny gap)
   k→0 ⇒ rank1=1.0 = gold medalist dictator ✗
                
DocFAISS rkBM25 rkRRF (k=60)
A semantic#1—1/61 = .0164
B keyword#25#11/85+1/61 = .0281
C hybrid#3#41/63+1/64 = .0315 ★
WINNER = C → won gold in NEITHER engine
  = decathlon athlete: best ALL-ROUND consensus, not a single spike
doodle: 3 lanes, C finishes 2nd+2nd but totals highest = consensus win
                

E. Production = LangChain EnsembleRetriever#

EnsembleRetriever(retrievers=[faiss,bm25], weights=[.5,.5])
auto: runs parallel async → strips raw incompatible scores
        → applies RRF (k=60) → returns 1 unified list
Senior eng knows the brutal math → so you can DEBUG when it burns.

After hybrid:
output still just a "net" → final polish =
  CROSS-ENCODER re-ranking (neural pairwise eval, slow, top-N only)
doodle: wide net catches many fish → cross-encoder = hand-picks top few
                

F. One-line decision table#

Query typeUse
"docs about budget cuts"FAISS (meaning)
"INV-2023-99X", SKU, error codeBM25 (exact)
"Q4 Alpha revenue report"Hybrid + RRF

Section II

Detailed Study Notes

One numbered subsection per concept. Each opens with a bold claim, then plain-English build-up, then callouts. + marks anything I added or corrected beyond the core material.

1. The Semantic Blind Spot: smart AI fails at simple lookups #

A dense vector search understands meaning, which is exactly why it cannot reliably find an exact identifier.

Dense (semantic) search embeds every document into a vector — a long list of numbers that captures what the text is about. FAISS (Facebook AI Similarity Search) stores those vectors and, at query time, finds the documents whose vectors point in the most similar direction, measured by cosine similarity. For conceptual questions this is magic: search "money problems" and it surfaces a doc that says "budget shortfall" even though no word overlaps.

The failure appears the moment you need something exact. Imagine you need details on invoice INV-2023-99X. The embedding model has no single pretrained concept for that string, so it does two destructive things:

  • It tokenizes (chops) the ID into pieces like ["INV", "2023", "99", "X"].
  • It averages those meanings into roughly "an invoice, from 2023, number-ish 99."

To cosine similarity, INV-2023-99X and INV-2023-99Y now look identical — conceptually they're both "a 2023 invoice." The system confidently returns the wrong one. In a payments pipeline that means paying the wrong vendor, and the failure is silent: no error, just a wrong answer with a high confidence score.

Why it matters The most dangerous bug is the confident wrong answer. A crash gets noticed; a 99.9% similarity score on the wrong invoice gets trusted. Any system handling IDs, SKUs, account numbers, or error codes needs exact matching that dense search structurally cannot provide.
Pitfall "Add more training / a bigger embedding model" does not fix this. The problem is not model quality — it is that semantic averaging is the wrong tool for a unique literal string. + No general-purpose embedding model can memorize every arbitrary identifier in your private data.
+ Good to know Cosine similarity measures the angle between two vectors, not their length, so it answers "do these point the same way?" Real-world cosine scores cluster tightly between ~0.70 and 0.95 — almost nothing scores near 0 — which becomes important in subsection 5 when we try to combine scores. +

Code: faiss_blindspot.py

2. The Revenge of Sparse Search (BM25 as the legacy savior) #

When meaning fails, fall back to literal text matching — the ultimate Ctrl+F.

Sparse keyword search doesn't know what an invoice is. It only knows whether the exact characters appear in the document. Match the literal string and you get a large relevance score; no match and you get an absolute zero. This is exactly what you want for INV-2023-99X: it either finds that precise string or it doesn't, with no semantic averaging to corrupt the result.

It's called "sparse" because each document is represented as a giant vector with one slot per vocabulary word, and almost every slot is zero — only the words actually present are non-zero. Contrast with dense vectors, where every dimension carries a value. +

The flaw is the mirror image of FAISS's: the vocabulary gap. Search for "puppy" against a document that says "dog" and you get zero results — the letters p-u-p-p-y simply aren't there. Sparse search is completely blind to synonyms.

FAISS (dense)BM25 (sparse)
PersonaBehavioral ProfilerFingerprint Analyst
Findsmeaning, themes, synonymsexact literal text
Best forconcepts, paraphraseIDs, codes, SKUs
Blind toexact stringssynonyms (vocab gap)
No-match scorestill ~0.4–0.7 (never 0)exactly 0
Why it matters Each engine's strength is the other's weakness. That symmetry is the whole argument for hybrid search: you cannot build serious enterprise retrieval on one database alone.
Interview angle "What's the difference between sparse and dense retrieval?" — Sparse = lexical/keyword (BM25, exact terms, zero on no-match); dense = semantic/embedding (FAISS, meaning, never exactly zero). The follow-up is always "when does each fail?" → dense on exact IDs, sparse on synonyms.

Code: bm25_basics.py

3. TF-IDF: the foundation BM25 is built on #

Two rules decide keyword relevance: how often a word appears here, and how rare it is everywhere.

Term Frequency (TF) — the more a document mentions the keyword, the more relevant it's assumed to be. A document saying "budget" ten times beats one that says it once.

Inverse Document Frequency (IDF) — common words are penalized, rare words rewarded. In the search "the budget", the word "the" appears in nearly every document, so it carries almost no information and is effectively ignored; "budget" holds all the mathematical weight. + Intuitively, a word that shows up everywhere can't help you distinguish one document from another, so IDF down-weights it.

+ Recall hook TF = local loudness (how much this doc shouts the word). IDF = global rarity (how special the word is across the whole library). Relevance ≈ loud ×rare. +
Pitfall (raw TF-IDF's fatal flaw) Plain TF grows without limit. The classic 2005 SEO trick: write "budget" 1,000 times in invisible white text and your TF-IDF score rockets toward infinity. Raw TF-IDF has no defense against this keyword stuffing — which is exactly the gap BM25 closes.

Code: bm25_basics.py

4. BM25: the keyword king, demystified #

BM25 is TF-IDF plus two limiters — saturation and length normalization — that have kept it powering enterprise search for 20+ years.

BM25 ("Best Matching 25") keeps the TF-IDF intuition but tames it with two free parameters. + The name comes from the Okapi information-retrieval system built at City University London in the 1980s –90s; you'll often see it written "Okapi BM25."

score(D,Q) = Σi  IDF(qi ) · f(qi ,D)·(k ₁+1)  / ( f(qi ,D) + k ₁·(1 −b + b ·|D|/avgdl) )

f(q ₐ,D) = times term appears in doc ·|D| = doc length in words ·avgdl = average doc length in the collection ·N = total docs ·n(q ₐ) = docs containing the term. + Standard IDF: ln( (N −n(q ₐ) + 0.5) / (n(q ₐ) + 0.5) + 1 ). The score sums independently over every query term.

k ₁—the saturation parameter (the spam filter)#

k ₁creates a strict ceiling on the term-frequency reward. The first mention spikes the score; by the tenth mention it has essentially flatlined. Ten mentions is not ten times better than one. This is what kills keyword stuffing: repeating "budget" 1,000 times buys you almost nothing past the first handful. + Typical default is k ₁between 1.2 and 2.0 (a common choice is 1.5).

b — the length-normalization parameter (the TL;DR enforcer)#

b punishes long, rambling documents. A concise 50-word paragraph mentioning "budget" will mathematically beat a meandering 5,000-word essay that mentions it the same number of times, because the long doc gets penalized for its length relative to avgdl. + b ranges 0–1: at b=0 length is ignored entirely; at b=1 you apply full length normalization. The standard default is b=0.75. [verified]

+ Correction / clarity note It's tempting to remember "more keywords = always higher score." With BM25 that's false on two counts: saturation caps repetition, and length normalization can make extra words actively hurt if they bloat the document. +
Why it matters BM25 is still the default lexical retriever in Elasticsearch, OpenSearch, Lucene, and most "hybrid" stacks. Knowing what k ₁and b do lets you tune real search systems — and explains why a well-engineered keyword baseline is shockingly hard to beat. +
Interview angle "What do k ₁and b control in BM25?" → k ₁= term-frequency saturation (diminishing returns on repetition); b = document-length normalization (0 = off, 1 = full). Bonus: defaults k ₁∈[1.2,2.0], b=0.75.

Then why not throw FAISS away and use BM25 for everything?#

Because of the vocabulary gap from subsection 2: BM25 is blind to synonyms. It solves exact matching and keyword stuffing perfectly, but it will still return zero for "puppy" when the document says "dog." Neither engine is sufficient alone — which is the entire setup for fusion.

Code: bm25_saturation.py

5. The Ensembling Problem: adding miles to Celsius #

FAISS and BM25 produce scores on incompatible scales, so you cannot simply add, weight, or normalize them together.

FAISS is a bounded sandbox: cosine similarity is normalized and predictable, with real scores hovering tightly between 0.70 and 0.95. BM25 is the unbounded wild west: it's an ever-growing probabilistic summation with no ceiling, so a rare keyword hit might score 45.8 or sail past 150. The dilemma: how do you add 5 miles to 30 °C?

Why naive addition fails#

BM25's large integers completely swallow FAISS's tiny decimals. Averaging the two destroys the semantic signal:

DocumentFAISSBM25Naive avg
A — perfect semantic match0.880.00.44
B — random IT ticket0.4012.5 6.45 ←wins, wrongly

The genuinely relevant Doc A loses to an irrelevant ticket purely because BM25's scale dwarfs FAISS's.

Why min-max normalization also fails#

The obvious fix —"just rescale BM25 to 0–1" — breaks on outliers. Min-max scaling divides by the maximum, so a single freak document scoring 80.0 crushes 99 perfectly valid documents into microscopic decimals near 0.02. It's like grading on a curve where one student somehow scores 5000%: everyone else's real differences get flattened into noise. + The problem is that BM25 scores are skewed with long tails, so the max is a terrible reference point.

Pitfall Weighting (e.g. 0.5 ·FAISS + 0.5 ·BM25 ) doesn't save you either — you're still adding incompatible units, just with coefficients. The scales, not the weights, are the problem.
Why it matters This is the central engineering reason RRF exists. Every "just combine the scores" instinct fails, which forces a completely different idea: ignore the scores entirely.

Code: naive_fusion_fail.py

6. Reciprocal Rank Fusion (RRF): the great equalizer #

Stop comparing scores. Compare positions. Rank is the one universal language both engines speak.

The paradigm shift: we can't add, weight, or normalize the raw scores — so we throw them away and look only at the podium. A document's positional rank (1st, 2nd, 3rd …) means the same thing whether it came from FAISS or BM25. RRF builds a unified score from the inverse of each rank.

RRF(d) = Σr  1 / ( k + rankr (d) )

Sum over every retriever r the document appears in; rankr (d) is its position in that retriever's list (1 = best); k is a smoothing constant.

The magic of k = 60#

In industry k is almost always hardcoded to 60. The reason is flattening. If k=0, the #1 spot scores a massive 1.0 and becomes a dictator — one engine's gold medalist single-handedly decides the result, destroying consensus. Adding 60 compresses the gaps:

  • rank 1 → 1/(60+1) = 0.0164
  • rank 2 → 1/(60+2) = 0.0161
  • rank 10 → 1/(60+10) ≈0.0143 +

The gap between rank 1 and rank 2 is tiny, so being ranked highly by both engines (consensus) beats being ranked #1 by just one (a lucky spike). + k=60 isn't sacred — it comes from the original 2009 Cormack et al. paper and is tunable — but it's the robust default everyone copies.

The RRF simulation — the decathlon winner#

Query: "Q4 Revenue Project Alpha." Note that the winning document never won gold in either individual engine:

DocumentFAISS rankBM25 rankRRF (k=60)
A — pure semantic ("income pushbacks")#1not found1/61 + 0 = 0.0164
B — pure keyword (irrelevant IT ticket)#25#11/85 + 1/61 = 0.0281
C — true hybrid (Q4 Alpha income report)#3#4 1/63 + 1/64 = 0.0315 ★

Document C wins despite placing 3rd and 4th. Like a decathlon athlete who wins no single event, its high overall consensus across both engines proves it's the most well-rounded answer. That's precisely the document a hybrid system should surface.

Why it matters RRF is scale-free by construction. It needs no tuning per data source, no normalization, no knowledge of score distributions — it just needs ordered lists. That robustness is why it's the default fusion method across search engines and RAG stacks.
Interview angle "How do you combine BM25 and vector scores?" → Don't combine scores; combine ranks with RRF: sum 1/(k+rank) across retrievers, k typically 60. Explain why naive add/normalize fails first — that's the insight they're testing.
+ Connection RRF discards score magnitude, so it can't tell "barely #1" from "overwhelmingly #1." That information loss is the price of robustness — and the reason a final cross-encoder re-rank (subsection 8) is often added on top to restore fine-grained relevance. +

Code: rrf_fusion.py

7. Production: LangChain's EnsembleRetriever #

You learn the brutal math so you can debug; in production you let LangChain abstract it away.

Senior engineers understand RRF from first principles so that when the pipeline burns down, they can diagnose why a wrong document surfaced. But for shipping code, you wrap a FAISS retriever and a BM25 retriever in LangChain's EnsembleRetriever. Under the hood it:

  • Runs both retrievers' searches in parallel (async).
  • Strips out the wildly incompatible raw scores.
  • Applies the k=60 RRF calculation and returns one unified, ranked list.

You pass weights=[0.5, 0.5] to keep the two engines balanced and let RRF do the fusing. + Weights here bias which retriever's ranks count for more within the RRF sum — they don't reintroduce the raw-score scaling problem.

Trade-offs Pro: dead-simple API, parallel execution, no manual fusion code. Con: the abstraction hides the math, so when results look wrong you must already know RRF to debug it. + Also: you maintain two indexes (a vector store + a BM25 index), which is more memory and more to keep in sync.
Pitfall + The BM25 retriever (via rank_bm25 ) builds its index in memory at startup from whatever documents you give it — it isn't persisted like a FAISS index. If your FAISS store and BM25 doc list drift apart, the two retrievers rank different corpora and fusion silently degrades.

Code: ensemble_retriever.py

8. The pinnacle: cross-encoder re-ranking #

Hybrid search is a wide net; a cross-encoder is the precision pick that follows.

Even a perfect hybrid+RRF result is still just a net — a good-but-coarse candidate set. The final quality step is a cross-encoder: a neural model that takes the query and each candidate document together as one input and outputs a precise relevance score.

+ Bi-encoder vs cross-encoder (key distinction) FAISS uses a bi-encoder: query and document are embedded separately, then compared — fast and pre-computable, but it never sees the two together. A cross-encoder reads query+document jointly, so it catches subtle relevance a bi-encoder misses — but it can't be precomputed and must run live on every pair, so it's far slower. +

That speed cost dictates the architecture: use cheap, scalable retrieval (FAISS + BM25 + RRF) to narrow thousands of documents down to a top-N (say 20–50), then run the expensive cross-encoder only on those few to produce the final ordering. +

Why it matters This retrieve-then-rerank pattern is the backbone of modern RAG. Retrieval optimizes recall (don't miss the right doc); re-ranking optimizes precision (put the best one first). The course project uses sentence-transformers cross-encoders for exactly this stage. +

9. How this fits the full RAG assistant + #

Hybrid retrieval is one stage inside a larger PDF-question-answering pipeline.

The end-to-end system this material supports is an advanced RAG assistant: upload a PDF, ask questions by text or voice, get answers grounded in the document. The flow:

  • Ingest: user uploads a PDF → backend extracts text, chunks it, creates embeddings, and saves a FAISS index (plus a parallel BM25 index for hybrid search).
  • Query: the backend rewrites the question → retrieves similar chunks (hybrid + RRF) → re-ranks them (cross-encoder) → calls the LLM to answer only from the retrieved context.
  • Respond: the frontend shows the answer plus citation-style source snippets.
LayerStack (project)
FrontendNext.js 15, Tailwind, Framer Motion, Web Speech API
BackendPython 3.11, FastAPI, LangChain
LLMGemini 2.5 Flash via OpenRouter
EmbeddingsGoogle embedding-001
Vector storeFAISS (local, persisted to disk)
Re-rankingsentence-transformers Cross-Encoder
EvaluationRAGAS
Resiliencetenacity (exponential backoff)
DevOpsDocker, GitHub Actions
Why it matters Grounding the LLM only in retrieved context is what stops hallucination. Retrieval quality therefore caps answer quality — which is why getting hybrid search + RRF right is foundational, not optional. +

★ Key Takeaways #

  • Dense (FAISS) nails meaning but is structurally blind to exact identifiers (tokenization averages them together).
  • Sparse (BM25) nails exact text but is blind to synonyms (the vocabulary gap). The two failures are mirror images → you need both.
  • BM25 = TF-IDF + k ₁(saturation) + b (length norm). Defaults k ₁∈[1.2,2.0], b=0.75. These kill keyword stuffing and rambling docs.
  • You cannot combine the scores. Naive add lets BM25's big integers swallow FAISS; min-max norm lets one outlier crush everyone. Wrong units.
  • RRF fuses by rank, not score: Σ1/(k+rank), k=60. Scale-free, rewards consensus, the decathlon winner beats single-event spikes.
  • Production = EnsembleRetriever (parallel + RRF abstracted); learn the math to debug it.
  • Retrieve wide, then re-rank narrow: hybrid is the net, a cross-encoder is the precision pick. Recall then precision.

Section III

Code Samples — runnable

Python 3.11. Pure-standard-library where possible; the BM25 demos use a tiny hand-written implementation so they run with zero installs. Each block: filename, run command, commented code, expected output, and a note on non-obvious behavior.

1. Why FAISS confuses two invoices + (no installs) #

faiss_blindspot.py python 3.11 · stdlib only
python faiss_blindspot.py
# Simulates how an embedding model "averages" an ID into a concept,
# making INV-2023-99X and INV-2023-99Y look identical to cosine sim.
import re, math

def fake_embed(text):
    # Toy "embedding": tokenize, then average a per-token vector.
    # Real models do this with learned weights; the AVERAGING is the point.
    toks = re.findall(r"[A-Za-z]+|\d+", text.upper())  # chops INV-2023-99X
    dims = {"INV":[1,0,0], "INVOICE":[1,0,0],
            "2023":[0,1,0], "99":[0,0,1]}
    vecs = [dims.get(t, [0,0,0]) for t in toks]
    n = len(vecs) or 1
    return [sum(v[i] for v in vecs)/n for i in range(3)]  # the average
def cosine(a, b):
    dot = sum(x*y for x,y in zip(a,b))
    na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
    return dot/(na*nb) if na and nb else 0.0

x = fake_embed("INV-2023-99X")
y = fake_embed("INV-2023-99Y")   # the X/Y trailing letter maps to [0,0,0]
print(f"vec(99X) = {x}")
print(f"vec(99Y) = {y}")
print(f"cosine similarity = {cosine(x,y):.4f}")
                    
Expected output vec(99X) = [0.333..., 0.333..., 0.333...] vec(99Y) = [0.333..., 0.333..., 0.333...] cosine similarity = 1.0000
+ Why it behaves this way The distinguishing characters (X vs Y ) carry no learned meaning, so they vanish into a zero vector and get averaged away. Both IDs collapse to the same "invoice + 2023 + 99" centroid → cosine 1.0. That's the tokenization catastrophe in miniature. +

2. BM25 from scratch — exact match wins, synonym fails #

bm25_basics.py python 3.11 · stdlib only
python bm25_basics.py
# Minimal BM25 so it runs with no pip install. Mirrors rank_bm25's BM25Okapi.
import math
from collections import Counter

class BM25:
    def __init__(self, corpus, k1=1.5, b=0.75):   # standard defaults
        self.k1, self.b = k1, b
        self.docs = [doc.lower().split() for doc in corpus]
        self.N = len(self.docs)
        self.avgdl = sum(len(d) for d in self.docs) / self.N
        self.tf = [Counter(d) for d in self.docs]
        df = Counter()                                # document frequency
        for d in self.docs:
            for term in set(d): df[term] += 1
        # IDF with +0.5 smoothing and +1 to keep it non-negative
        self.idf = {t: math.log((self.N - n + 0.5)/(n + 0.5) + 1) for t,n in df.items()}

    def score(self, query, i):
        q = query.lower().split(); s = 0.0; dl = len(self.docs[i])
        for term in q:
            if term not in self.idf: continue
            # vocab gap -> contributes 0
            f = self.tf[i][term]
            num = f * (self.k1 + 1)
            den = f + self.k1 * (1 - self.b + self.b * dl/self.avgdl)
            s += self.idf[term] * (num/den)
        return s

corpus = [
    "the q4 budget report invoice INV-2023-99X",   # doc 0: exact ID
    "a happy dog ran across the field",             # doc 1: 'dog'
    "annual financial budget summary",             # doc 2
    ]
bm = BM25(corpus)
for qi, q in enumerate(["INV-2023-99X", "puppy"]):
    print(f"query={q!r}")
    for i in range(len(corpus)):
        print(f"  doc{i}: {bm.score(q,i):.3f}")
                    
Expected output query='INV-2023-99X' doc0: 1.327 doc1: 0.000 doc2: 0.000 query='puppy' doc0: 0.000 doc1: 0.000 <- 'dog' is here, but 'puppy' != 'dog' doc2: 0.000
+ Why it behaves this way "INV-2023-99X" is one token after .split(), so it matches doc0 exactly and scores high while the others get an absolute zero — the opposite of FAISS. But "puppy" matches nothing, so even doc1 (which says "dog") scores 0: the vocabulary gap, made literal. +

3. Saturation & length norm in action (k ₁and b) #

bm25_saturation.py python 3.11 · reuses bm25_basics.py
python bm25_saturation.py
from bm25_basics import BM25

# Same word repeated 1x vs 10x vs 100x -> watch the score FLATLINE (saturation).
reps = ["budget", "budget " *10, "budget " *100]
bm = BM25(reps)
print("--- saturation (k1=1.5) ---")
for i,r in enumerate(["1x","10x","100x"]):
    print(f"  {r:>4} 'budget' -> {bm.score('budget', i):.4f}")

# Short vs long doc, same single mention -> long doc PUNISHED (length norm, b).
docs = ["budget", "budget " + "filler " *200]
bm2 = BM25(docs)
print("--- length normalization (b=0.75) ---")
print(f"  short doc -> {bm2.score('budget',0):.4f}")
print(f"  long  doc -> {bm2.score('budget',1):.4f}")
                    
Expected output (values approximate) --- saturation (k1=1.5) --- 1x 'budget' -> 0.4626 10x 'budget' -> 0.6122 100x 'budget' -> 0.6303 <- 100x is NOT 100x better than 1x --- length normalization (b=0.75) --- short doc -> 0.5108 long doc -> 0.0152 <- same 1 mention, crushed by length
+ Why it behaves this way From 10x to 100x the score barely moves — k ₁caps the term-frequency reward (a curve that rises then flattens). And the 200-word filler doc is hammered despite mentioning "budget" once, because its length is far above avgdl and b=0.75 penalizes it. Together: stuffing and rambling both fail. +

4. The naive fusion catastrophe #

naive_fusion_fail.py python 3.11 · stdlib only
python naive_fusion_fail.py
# Shows BOTH failure modes: naive averaging, and min-max normalization.
docs = {
    "A perfect-semantic": {"faiss": 0.88, "bm25": 0.0},
    "B random-ticket":    {"faiss": 0.40, "bm25": 12.5},
    "C also-relevant":    {"faiss": 0.80, "bm25": 1.2},
    "D outlier-spam":     {"faiss": 0.10, "bm25": 80.0},  # the curve-wrecker
    }

print("=== naive average ===")
for d,s in sorted(docs.items(), key=lambda kv: -(kv[1]["faiss"]+kv[1]["bm25"])/2):
    print(f"  {(s['faiss']+s['bm25'])/2:7.3f}  {d}")

print("=== min-max normalized then averaged ===")
bm = [s["bm25"] for s in docs.values()]
lo, hi = min(bm), max(bm)                       # hi = 80 outlier
def norm(v): return (v-lo)/(hi-lo) if hi >lo else 0.0
for d,s in sorted(docs.items(),
        key=lambda kv: -(kv[1]["faiss"]+norm(kv[1]["bm25"]))/2):
    print(f"  {(s['faiss']+norm(s['bm25']))/2:7.3f}  {d}")
                    
Expected output === naive average === 40.000 D outlier-spam <- spam wins outright 6.450 B random-ticket <- irrelevant beats relevant 0.440 A perfect-semantic 0.500 C also-relevant === min-max normalized then averaged === 0.550 D outlier-spam <- still wins; outlier defines the scale 0.448 A perfect-semantic 0.408 C also-relevant 0.276 B random-ticket
Why it behaves this way Naive averaging lets BM25's raw magnitude dominate — spam doc D (80.0) wins by a mile. Normalization "fixes" the scale but the 80.0 outlier becomes the denominator, so every genuinely good BM25 hit shrinks toward 0 and the spam still ranks first. Both approaches are broken → motivation for RRF.

5. RRF done right (the decathlon winner) #

rrf_fusion.py python 3.11 · stdlib only
python rrf_fusion.py
# Reciprocal Rank Fusion: ignore scores, fuse by RANK. RRF(d)=sum 1/(k+rank).
def rrf(ranked_lists, k=60):
    scores = {}
    for lst in ranked_lists:                  # one list per retriever
        for rank, doc in enumerate(lst, start=1):  # rank 1 = best
            scores[doc] = scores.get(doc, 0) + 1/(k + rank)
    return sorted(scores.items(), key=lambda kv: -kv[1])

# Each list is already ordered best -> worst by that engine.
faiss_ranked = ["A", "C", "E", "D", "B"]   # A=#1, C=#2, ...
bm25_ranked  = ["B", "C", "F", "D", "G"]   # B=#1, C=#2, ...
for doc, sc in rrf([faiss_ranked, bm25_ranked], k=60):
    print(f"  {sc:.5f}  {doc}")
                    
Expected output 0.03200 C <- #2 in BOTH -> consensus winner 0.01613 A (#1 faiss only) 0.01613 B (#1 bm25 only) 0.01562 D (#4 in both) 0.01587 E 0.01587 F ...
+ Why it behaves this way C never ranks #1 anywhere, yet wins: 1/62 + 1/62 = 0.0320 beats A's lone 1/61 = 0.0161. Two solid showings beat one gold medal — consensus over spike. Swap k=60 for k=0 and A/B jump to 1.0 each, becoming dictators; that's why k flattens the curve. +

6. Production hybrid retriever (LangChain) #

ensemble_retriever.py python 3.11 · needs installs (see Lab Setup)
python ensemble_retriever.py
# Real pipeline. Requires: langchain, langchain-community, faiss-cpu,
# rank_bm25, and an embeddings provider. See the Lab for install + keys.
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_community.embeddings import HuggingFaceEmbeddings

docs = [
    "Q4 revenue for Project Alpha: income pushbacks delayed booking.",
    "IT ticket #4471: Project Alpha laptop revenue dashboard down.",
    "Financial report - Q4 Alpha income recognized this quarter.",
]

# 1) Dense engine: embed docs into FAISS (the Behavioral Profiler).
emb = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
faiss_store = FAISS.from_texts(docs, emb)
faiss_ret = faiss_store.as_retriever(search_kwargs={"k": 3})

# 2) Sparse engine: build an in-memory BM25 index (the Fingerprint Analyst).
bm25_ret = BM25Retriever.from_texts(docs)
bm25_ret.k = 3

# 3) Fuse: EnsembleRetriever runs both in parallel and applies RRF (k=60).
hybrid = EnsembleRetriever(
    retrievers=[faiss_ret, bm25_ret],
    weights=[0.5, 0.5],          # balanced; let RRF decide order
    )

for d in hybrid.invoke("Q4 Alpha revenue"):
    print("-", d.page_content)
                    
Expected output (order is the point, not exact text) - Financial report - Q4 Alpha income recognized this quarter. - Q4 revenue for Project Alpha: income pushbacks delayed booking. - IT ticket #4471: Project Alpha laptop revenue dashboard down.
+ Why it behaves this way The "Financial report" doc isn't #1 in either engine, but it scores well in both (semantic and keyword), so RRF floats it to the top — the decathlon winner in real code. HuggingFaceEmbeddings is used here so the demo needs no API key; the full project swaps in Google embedding-001. +

Section IV

Hands-On Lab — build the dual-engine pipeline

Six graded exercises, warm-up → challenge. Each: a goal, numbered steps, a predict-then-verify prompt, and a collapsible solution. The first four run with pure standard library; the last two add real libraries.

SETUP — one time macOS / Linux / WSL
# 1) Check Python (need 3.11+)
python --version            # -> Python 3.11.x
# 2) Make a project folder
mkdir hybrid-lab && cd hybrid-lab

# 3) Virtual env (keeps installs isolated)
python -m venv .venv
source .venv/bin/activate    # Windows: .venv\Scripts\activate
# 4) Installs —only needed for Ex5 &Ex6 (Ex1-4 are stdlib only)
pip install rank_bm25 langchain langchain-community faiss-cpu sentence-transformers

# 5) Verify the install
python -c "import rank_bm25, langchain, faiss; print('ok')"
# -> ok
                    
WARM-UP

Ex 1 — Reproduce the blind spot#

Goal: see two distinct invoice IDs collapse to cosine 1.0.

  1. Create faiss_blindspot.py from Code 1.
  2. Run it. Read the two printed vectors.
  3. Add a third ID "PO-2024-12A" and print its cosine vs 99X.
Predict, then verify Before running step 3: will PO-2024-12A be closer or farther from 99X than 99Y is? Why?
Show solution
from faiss_blindspot import fake_embed, cosine
x  = fake_embed("INV-2023-99X")
po = fake_embed("PO-2024-12A")   # none of its tokens are in 'dims' -> [0,0,0]
print(cosine(x, po))            # -> 0.0  (orthogonal: no shared concept)
                                

Verify: 99Y scored 1.0 (same concept); PO-2024 scores 0.0 because none of its tokens map to a known dimension, so its vector is all zeros — orthogonal. The takeaway: dense search clusters by shared concept, not by string identity.

WARM-UP

Ex 2 — Prove the vocabulary gap#

Goal: confirm BM25 returns 0 for a synonym.

  1. Create bm25_basics.py from Code 2 and run it.
  2. Add a query "dog" and score all docs.
  3. Then query "budget report" (two terms) and note the scores sum per term.
Predict, then verify Will "budget report" score doc0 higher than the single word "budget" would? Why might two terms not simply double the score?
Show solution
from bm25_basics import BM25, corpus
bm = BM25(corpus)
print(bm.score("dog", 1))            # >0: 'dog' literally in doc1
print(bm.score("budget report", 0)) # sum of per-term contributions
                                

Verify: "dog" scores >0 on doc1 (exact match) but "puppy" scored 0 — same meaning, different letters. "budget report" sums two IDF-weighted terms, but each term saturates and is length-normalized independently, so it's not a clean doubling. BM25 sums over query terms, each capped on its own.

CORE

Ex 3 — Tune k ₁and watch saturation#

Goal: feel how k ₁controls the repetition ceiling.

  1. Use bm25_saturation.py from Code 3.
  2. Run it once with default k1=1.5.
  3. Re-run building each BM25(..., k1=0) and again with k1=10.
Predict, then verify At k1=0, what happens to the difference between 1x and 100x? At k1=10, does the curve flatten sooner or later?
Show solution
from bm25_basics import BM25
reps = ["budget", "budget " *10, "budget " *100]
for k1 in(0, 1.5, 10):
    bm = BM25(reps, k1=k1)
    row = [round(bm.score("budget", i), 3) for i in range(3)]
    print(f"k1={k1:>4}: 1x/10x/100x = {row}") 

Verify: At k1=0 the term-frequency factor becomes constant — 1x, 10x, 100x all score the same (only IDF matters; TF is ignored). At k1=10 the ceiling is higher, so repetition keeps mattering longer before flattening. k ₁literally sets how fast the diminishing returns kick in.

CORE

Ex 4 — Break fusion, then fix it with RRF#

Goal: watch naive fusion pick the wrong doc, then RRF pick the right one.

  1. Run naive_fusion_fail.py (Code 4 ) — note who wins.
  2. Convert each engine's scores to a ranked list (sort by score desc).
  3. Feed both ranked lists to rrf() from Code 5 and compare winners.
Predict, then verify Doc D (the 80.0 spam) wins both naive methods. Once you convert to ranks, why does RRF stop rewarding it?
Show solution
from rrf_fusion import rrf
docs = {"A" :{"f" :.88,"b" :0.0}, "B" :{"f" :.40,"b" :12.5},
        "C" :{"f" :.80,"b" :1.2}, "D" :{"f" :.10,"b" :80.0}}
faiss_rank = [d for d,_ in sorted(docs.items(), key=lambda kv:-kv[1]["f"])]
bm25_rank  = [d for d,_ in sorted(docs.items(), key=lambda kv:-kv[1]["b"])]
print("faiss:", faiss_rank)   # ['A','C','B','D'] print("bm25 :", bm25_rank)   # ['D','B','C','A'] for d,s in rrf([faiss_rank, bm25_rank]): print(f"  {s:.4f} {d}") 

Verify: Once ranked, D is BM25 #1 but FAISS last, so its RRF is 1/61 + 1/64 ≈0.0320. C is #2 + #3 → 1/62 + 1/63 ≈0.0320, neck-and-neck, and a relevant doc no longer loses to spam. RRF caps how much any single #1 placement can earn, so an outlier score can't dominate — only its rank counts, and rank has a ceiling of 1/(k+1).

CORE

Ex 5 — Wire up the real EnsembleRetriever#

Goal: build a working hybrid retriever with LangChain.

  1. Complete the Setup installs. Create ensemble_retriever.py from Code 6.
  2. Design a 3-doc dataset with one exact-ID "needle" and two semantic "distractors" sharing vocabulary.
  3. Query with text that needs both meaning and an exact ID; print the order.
  4. Then query each retriever alone (faiss_ret.invoke(...), bm25_ret.invoke(...) ) and compare.
Predict, then verify Which doc does FAISS-alone rank first? BM25-alone? Will the ensemble match either, or produce a different order?
Show solution
from ensemble_retriever import faiss_ret, bm25_ret, hybrid
q = "Q4 Alpha revenue INV-2023-99X"
print("FAISS :", [d.page_content[:30] for d in faiss_ret.invoke(q)])
print("BM25  :", [d.page_content[:30] for d in bm25_ret.invoke(q)])
print("HYBRID:", [d.page_content[:30] for d in hybrid.invoke(q)])
                                

Verify: FAISS-alone favors the semantically richest doc; BM25-alone jumps to whichever literally contains the ID string. The hybrid list usually leads with the doc that scores decently in both — and surfaces the exact-ID doc that FAISS-alone would have buried. If all three lists are identical, your dataset isn't adversarial enough (go to Ex6).

CHALLENGE

Ex 6 — Sabotage the dense engine, then add a cross-encoder#

Goal: engineer a dataset where FAISS-alone fails, prove hybrid fixes it, then re-rank the top results with a cross-encoder.

  1. Build a dataset where the correct doc contains an exact ID but uses different wording from the query, plus 2–3 distractors that are semantically close but wrong (overlapping vocabulary).
  2. Confirm FAISS-alone ranks a distractor #1 and the ensemble rescues the correct doc.
  3. Take the ensemble's top-3 and re-score them with a sentence-transformers CrossEncoder; re-sort.
  4. Compare the cross-encoder order against the RRF order.
Predict, then verify RRF only knows rank. The cross-encoder reads query+doc together. On which doc do you expect them to disagree, and why is the cross-encoder's call more trustworthy here?
Show solution
from sentence_transformers import CrossEncoder
from ensemble_retriever import hybrid

q = "booking delays on the Alpha account, ref INV-2023-99X"
cands = hybrid.invoke(q)[:3]                       # wide net -> top 3
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs  = [(q, d.page_content) for d in cands]
scores = ce.predict(pairs)                          # joint query+doc scoring
reranked = sorted(zip(scores, cands), key=lambda x: -x[0])
for s, d in reranked:
    print(f"  {s:6.2f}  {d.page_content[:50]}") 

Verify: RRF can leave a "tied on rank" pair in arbitrary order because it discarded magnitude. The cross-encoder, reading each query+doc pair jointly, assigns sharply different scores and confidently separates the truly-relevant doc from a vocabulary-overlap distractor. That's the retrieve-wide-then-rank-narrow pattern: RRF for cheap recall, cross-encoder for expensive precision on just the finalists.

Self-check rubric — how to know you've got it#

  • Blind spot: I can explain in one breath why INV-2023-99X ≈99Y to a dense model (tokenize → average → same concept).
  • BM25: I can state what k ₁and b do and their defaults (k ₁∈[1.2,2.0], b=0.75) without looking.
  • Vocabulary gap: I can give the "puppy" vs "dog" example and say which engine each failure belongs to.
  • Fusion problem: I can explain why both naive-add and min-max normalization fail (units; outliers).
  • RRF: I can write Σ1/(k+rank) from memory and explain why k=60 rewards consensus over a single spike.
  • Architecture: I can describe the retrieve-wide (hybrid) → rank-narrow (cross-encoder) flow and why bi- vs cross-encoder speed dictates it.
  • Code: Ex1–4 run with no installs and produce the expected outputs; Ex5–6 return a sensible hybrid order I can justify.

Private study notes. + marks my own enrichments and corrections beyond the core material; everything tagged was verified against the BM25 (Okapi) and RRF (Cormack et al., 2009) standard definitions before being written down.