Hybrid Search
- Name the characteristic failure modes of dense retrieval and of lexical retrieval, with examples
- Implement BM25 from scratch and explain what each term in the formula rewards
- Fuse dense and lexical rankings with reciprocal rank fusion (RRF) and justify rank-based fusion over score mixing
- Layer metadata filters, recency, and source weighting onto hybrid retrieval
| Spaced-rep: due cards + recite HNSW trade-off from memory (D115) | 10 min |
| ELI5 + tech read; trace the BM25 formula term by term | 25 min |
| Guided: BM25 from scratch + RRF fusion | 40 min |
| Practice: query zoo | 20 min |
| Project: HybridRetriever module | 20 min |
| Quiz + flashcards | 5 min |
Builds on: Day 115 β Embeddings & vector DBs Β· Day 114 β Chunking strategies
A bloodhound finds things by smell β it doesn't need the exact name, just the scent of the thing. Ask it for "something about taking time off" and it happily leads you to the vacation shelf. But ask it for "form W-4991-B" and the bloodhound is useless: form numbers have no smell. The card catalog is the opposite: type "W-4991-B" and it finds the exact card instantly β but ask it for "time off" when every card says "vacation" and it returns nothing at all.
Dense retrieval (embeddings) is the bloodhound: brilliant at meaning, blind to exact strings β error codes, SKUs, function names, people's names all smell like nothing. Lexical retrieval (BM25, the algorithm behind classic search engines) is the card catalog: exact words or bust. Hybrid search runs BOTH on every query and merges the two ranked lists, so "why am I getting ERR-4092 after taking time off" β half jargon, half meaning β finds both halves. The merge trick, reciprocal rank fusion, is delightfully dumb: ignore the incomparable scores, reward whatever ranks high on either list.
Real user queries are full of unsmellable tokens β ticket numbers, product codes, API names, invoice IDs β and pure-dense RAG systems fail on exactly the queries enterprise users care most about. "Why didn't it find ERR-4092?" is a bug you will personally field as an FDE. Every serious vector DB (Qdrant, Weaviate, Pinecone, pgvector+tsvector) now ships hybrid because pure dense lost this fight in production. Your capstone's retrieval layer (Day 119) is hybrid from v0, and interviewers love asking "when does semantic search fail?" β today you'll have a crisp, example-backed answer.
Guided practice
BM25 from scratch, then watch each retriever fail
25 min- Create
hybrid_lab.pywith the starter code: a compact BM25 class, the synonym-aware dense stand-in from Day 113 (upgraded with a tiny synonym map so it behaves like real embeddings on this corpus), and a shared 6-doc corpus. - Run query A: "what does ERR-4092 mean". Confirm BM25 nails it (#1) and dense flails β the code shares almost no "meaning" tokens with anything.
- Run query B: "how much time off can I carry into next year". Confirm dense nails it (the synonym map bridges time off β vacation) and BM25 returns near-zero scores.
- Read the BM25
scoremethod against the formula in the tech section: point to the IDF line, the saturation constant k1, and the length-normalization term b. Change k1 to 0 and re-run β term frequency now barely matters. Put it back. - Record both failure cases; the next exercise fuses them.
Reciprocal rank fusion β ten lines that fix both
15 min- Append the RRF function below to
hybrid_lab.py. - Run both yesterday's failing queries through
hybrid. Confirm each now ranks its correct doc #1: RRF trusts whichever system is confident, because the OTHER system's noise lands at deep ranks worth almost nothing (1/(60+5) β 0.015). - Run the mixed query "ERR-4092 while on time off" and inspect the fused list: both prod-1 and hb-01 should surface β neither retriever alone puts both in its top-2.
- Change k from 60 to 1 and re-run: small k over-trusts rank #1 of each list; large k flattens everything. See why 60 is the boring, robust default.
- Add a third list: docs sorted by a fake
updateddate, and fuse all three. Fresher docs get a bounded nudge β this is recency weighting without touching stored scores.
On your own
Build the query zoo
20 minCreate query_zoo.md plus code: 9 queries against today's corpus (extend it to ~10 docs first) β 3 where BM25 must win, 3 where dense must win, 3 mixed. For each, record the top-2 from bm25-only, dense-only, and hybrid, and mark which systems got it right. Deliverable: a table plus a 3-sentence conclusion on WHEN hybrid pays.
Constraints: at least one query with a misspelling, one with an exact identifier, one pure paraphrase. Hints: identifiers and rare jargon β lexical; paraphrase/synonyms β dense; misspellings hurt both, which is a preview of query rewriting (Day 117).
Hybrid retrieval module for the capstone
Refactor today's lab into retrieval.py, a clean module your capstone imports on Day 119: class HybridRetriever wrapping a BM25 index and your Day 115 VecStore over the SAME chunk IDs, with search(query, k=5, where=None) that (1) applies metadata filters to both retrievers BEFORE ranking, (2) fuses with RRF, (3) returns (id, fused_score, text, metadata) tuples, and (4) exposes explain(query) returning both raw rankings for debugging β the tool you will reach for every time retrieval looks wrong. Include 5 pytest cases from your query zoo.
Common mistakes & misconceptions
- Averaging raw BM25 and cosine scores. They live on different scales and distributions β fuse ranks (RRF) or normalize very carefully; never mix raw scores.
- Assuming embeddings handle everything and lexical search is legacy. Exact identifiers, fresh jargon, and names have no semantic neighborhood; dense-only RAG fails its most valuable enterprise queries.
- Tokenizing away the identifiers ("ERR-4092" split into "err" and "4092"). Your lexical tokenizer must preserve codes, or the card catalog loses its one superpower.
- Filtering after fusion. Apply metadata/permission filters inside both retrievers, or fused top-k can be entirely filtered away β returning nothing for valid queries.
- Letting the two indexes drift. If chunks are re-ingested into the vector store but not the BM25 index (or vice versa), fusion silently degrades β one ingest path, two indexes, same IDs.
- Boosting recency by mutating stored scores at ingest. Bake policy into query-time fusion instead, so changing the boost never requires re-indexing.
Q1. A user searches "invoice INV-88291 status" and your dense-only RAG returns generic billing docs. Root cause?
Q2. Why does RRF fuse ranks instead of scores?
Q3. In BM25, what does the k1 saturation term prevent?
Go deeper β curated resources
- Learned sparse retrieval (SPLADE-style) β A neural model that outputs weighted term expansions β lexical precision with learned synonyms, one index instead of two. The best of both, at higher serving cost.
- BM25 implemented; both single-retriever failure modes demonstrated
- RRF fixes both failing queries; k sensitivity explored
- Query zoo table with 9 queries and a written conclusion
- retrieval.py committed with 5 passing tests
- Quiz β₯ 2/3
β Back: Day 115 built the bloodhound; today you built the card catalog it was missing β with Day 23's hashing (inverted index) and Day 62's information-as-surprise (IDF is literally rarity-as-informativeness) doing the work.
Forward β: Day 117 adds a reranker after fusion, and Day 119 wires HybridRetriever into your capstone v0. On Day 136 you'll measure exactly how much hybrid lifts retrieval recall over either retriever alone.
Unlocks: D117 Reranking & Query Transforms Β· D119 Week 17 Checkpoint: Capstone Kickoff β Docs-QA v0