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.
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
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
| Engine | Scale | Range |
|---|---|---|
| FAISS cosine | bounded / normalized | ~0.70–0.95 |
| BM25 | unbounded summation | 0 → 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%")
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 ✗
| Doc | FAISS rk | BM25 rk | RRF (k=60) |
|---|---|---|---|
| A semantic | #1 | — | 1/61 = .0164 |
| B keyword | #25 | #1 | 1/85+1/61 = .0281 |
| C hybrid | #3 | #4 | 1/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
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
| Query type | Use |
|---|---|
| "docs about budget cuts" | FAISS (meaning) |
| "INV-2023-99X", SKU, error code | BM25 (exact) |
| "Q4 Alpha revenue report" | Hybrid + RRF |
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.
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:
["INV", "2023", "99", "X"].
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.
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) | |
|---|---|---|
| Persona | Behavioral Profiler | Fingerprint Analyst |
| Finds | meaning, themes, synonyms | exact literal text |
| Best for | concepts, paraphrase | IDs, codes, SKUs |
| Blind to | exact strings | synonyms (vocab gap) |
| No-match score | still ~0.4–0.7 (never 0) | exactly 0 |
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.
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."
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 ₁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 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]
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.
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?
BM25's large integers completely swallow FAISS's tiny decimals. Averaging the two destroys the semantic signal:
| Document | FAISS | BM25 | Naive avg |
|---|---|---|---|
| A — perfect semantic match | 0.88 | 0.0 | 0.44 |
| B — random IT ticket | 0.40 | 12.5 | 6.45 ←wins, wrongly |
The genuinely relevant Doc A loses to an irrelevant ticket purely because BM25's scale dwarfs FAISS's.
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.
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.
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.
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.
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:
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.
Query: "Q4 Revenue Project Alpha." Note that the winning document never won gold in either individual engine:
| Document | FAISS rank | BM25 rank | RRF (k=60) |
|---|---|---|---|
| A — pure semantic ("income pushbacks") | #1 | not found | 1/61 + 0 = 0.0164 |
| B — pure keyword (irrelevant IT ticket) | #25 | #1 | 1/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.
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:
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.
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.
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.
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. +
sentence-transformers
cross-encoders for exactly this stage. +
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:
| Layer | Stack (project) |
|---|---|
| Frontend | Next.js 15, Tailwind, Framer Motion, Web Speech API |
| Backend | Python 3.11, FastAPI, LangChain |
| LLM | Gemini 2.5 Flash via OpenRouter |
| Embeddings | Google embedding-001 |
| Vector store | FAISS (local, persisted to disk) |
| Re-ranking | sentence-transformers Cross-Encoder |
| Evaluation | RAGAS |
| Resilience | tenacity (exponential backoff) |
| DevOps | Docker, GitHub Actions |
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.
# 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}")
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. +
# 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}")
.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. +
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}")
avgdl
and b=0.75 penalizes it. Together: stuffing and rambling both fail. +
# 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}")
# 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}")
k=60
for k=0
and A/B jump to 1.0 each, becoming dictators; that's why k flattens the curve. +
# 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)
HuggingFaceEmbeddings
is used here so the demo needs no API key; the full project swaps in Google embedding-001. +
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.
# 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
Goal: see two distinct invoice IDs collapse to cosine 1.0.
faiss_blindspot.py
from Code 1.
"PO-2024-12A"
and print its cosine vs 99X.
PO-2024-12A
be closer or farther from 99X
than 99Y
is? Why?
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.
Goal: confirm BM25 returns 0 for a synonym.
bm25_basics.py
from Code 2
and run it.
"dog"
and score all docs.
"budget report"
(two terms) and note the scores sum per term.
"budget report"
score doc0 higher than the single word "budget"
would? Why might two terms not simply double the score?
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.
Goal: feel how k ₁controls the repetition ceiling.
bm25_saturation.py
from Code 3.
k1=1.5.
BM25(..., k1=0)
and again with k1=10.
k1=0, what happens to the difference between 1x and 100x? At k1=10, does the curve flatten sooner or later?
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.
Goal: watch naive fusion pick the wrong doc, then RRF pick the right one.
naive_fusion_fail.py
(Code 4
) — note who wins.
rrf()
from Code 5
and compare winners.
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).
Goal: build a working hybrid retriever with LangChain.
ensemble_retriever.py
from Code 6.
faiss_ret.invoke(...), bm25_ret.invoke(...)
) and compare.
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).
Goal: engineer a dataset where FAISS-alone fails, prove hybrid fixes it, then re-rank the top results with a cross-encoder.
sentence-transformers
CrossEncoder; re-sort.
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.
INV-2023-99X
≈99Y
to a dense model (tokenize → average → same concept).
Σ1/(k+rank)
from memory and explain why k=60 rewards consensus over a single spike.
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.