S118 · RAG II — Retrieval, Hybrid Search, Reranking
The other 80% of RAG performance — dense vs sparse vs hybrid retrieval, cross-encoder reranking, query rewriting, and the failure modes each one fixes.
🎯 Design a production retrieval pipeline (dense + BM25 + rerank + query rewrite) that beats vector-only search by 10–30 recall points.
Why this session exists
Vector search alone is not enough. Every serious RAG system in 2026 layers three retrievers (dense, sparse, and sometimes a knowledge-graph lookup), fuses their results, and reranks the top candidates with a cross-encoder before passing anything to an LLM. Get this stack right and your bot answers correctly with 3 chunks. Get it wrong and 20 chunks won't save you. This session teaches you each layer, when it earns its cost, and how to measure whether it's actually helping.
- Explain dense (embedding), sparse (BM25), and hybrid retrieval — and pick one for a new problem.
- Add a cross-encoder reranker and measure the recall@K vs precision@K trade-off.
- Implement query rewriting (multi-query, HyDE) and know when each pays off.
- Use Reciprocal Rank Fusion (RRF) to combine multiple retrievers cleanly.
- Diagnose 'the right chunk exists but the retriever missed it' bugs and know which layer to add.
Prerequisites
- S117 · RAG I — Chunking & Indexing. You need chunks + an index to retrieve from.
- S116 · Prompting — for query rewriting.
- Rough sense of TF-IDF / BM25 (any classical IR video).
(a) Intuition · 5 min
You walk into a library with a question. One librarian (dense/embedding) understands meaning — ask for 'books about how AI thinks' and they know you mean 'cognition + LLMs'. Another librarian (sparse/BM25) is a keyword pedant — ask for 'RFC 6749 OAuth 2.0 refresh token grant' and they find the exact page in seconds where the semantic librarian is stumped.
You ask both. Each gives you a stack of ~20 candidates. Then an arbiter (the reranker) reads each candidate carefully and returns the top 3 that actually answer your question.
This is the modern RAG retriever. Dense = embeddings (semantic recall). Sparse = BM25 or SPLADE (lexical exactness). Reranker = a small cross-encoder that scores query+chunk pairs one at a time — 10-100× slower per pair than vector search, but only over 20 candidates instead of 10 million.
Add query rewriting on the front (LLM turns 'how do i refresh?' into 'OAuth 2.0 refresh token flow') and you have every production RAG stack in the industry.
- 1. Dense retrieval (embeddings) — the baseline. Semantic recall. Get this working first.
- 2. Sparse / BM25 in parallel — catches rare terms, IDs, product codes, error strings. Cheap. Add early.
- 3. Cross-encoder reranking on the top-20 — the biggest quality lever, usually 10–30 point NDCG@10 gain. Add second.
- 4. Query rewriting / HyDE / multi-query — for ambiguous or short queries. Add when top-K recall is still lacking.
Timeline of the retrieval stack
- 1994BM25 · OkapiProbabilistic term-weighting scheme. Still the sparse-retrieval baseline 30 years later.
- 2019DPR · dense passage retrieverKarpukhin et al. — trainable bi-encoder retrieves passages via dense embeddings. Foundation of modern RAG.
- 2020Sentence-TransformersReimers & Gurevych — reusable off-the-shelf embeddings. Retrieval quality without training your own encoder.
- 2022HyDE + query rewritingGao et al. — have an LLM produce a hypothetical answer, embed it, retrieve. Massive gains on out-of-domain queries.
- 2023Cohere Rerank + bge-rerankerHigh-quality cross-encoder rerankers become commodity APIs and open models.
- 2024Hybrid becomes defaultEvery serious vector DB (Pinecone, Weaviate, Elastic, pgvector+tsvector) ships hybrid search out of the box.
(b) Visual walkthrough · 15 min
The production retrieval pipeline
Dense vs Sparse — the classic complementarity
Semantic — 'means the same thing'
- Understands paraphrase and synonymy
- Handles typos and different phrasings
- Weak on rare terms, IDs, exact strings
- Cost: one vector-db query
Lexical — 'contains the same words'
- Exact match on product codes, IDs, error strings
- Interpretable — you can see WHY it matched
- Poor on synonyms ('car' vs 'vehicle')
- Cost: one inverted-index query
Both, always
- Recall gains 10–20 points on mixed workloads
- Especially strong on enterprise/technical corpora
- RRF fuses without tuning weights
- Cost: 2× retrievers, negligible in practice
Reciprocal Rank Fusion — the tuning-free combiner
For each document, sum 1/(k + rank_i) across all retrievers where it appears. k dampens the boost from being #1 in any single list — prevents one retriever from dominating.
Never try to combine cosine similarity with BM25 scores directly — they live on different scales. RRF only uses ranks, which are comparable.
The whole idea: documents both retrievers agree on get double-weighted (semantically AND lexically relevant).
Cross-encoder reranking
Why cross-encoders win on quality but not on scale
Query rewriting patterns
LLM generates 3–5 paraphrases; retrieve for each; fuse
- Helps ambiguous or short queries
- Cost: 3–5× retrieval calls + 1 LLM call
- Simple, robust
- Common LangChain recipe
LLM writes a hypothetical answer; embed that; retrieve
- Great for out-of-distribution queries
- Turns a bad query into a good pseudo-answer to match on
- Cost: 1 extra LLM call
- Downside: LLM makes things up (that's OK for retrieval)
LLM asks a broader question first, then original
- Helps multi-hop questions
- Two retrievals: broad context + specific answer
- Slower, deeper
- For research-style bots
"Embeddings capture meaning, so dense vector search strictly dominates keyword search. BM25 is legacy — I only need a vector index."
Dense retrieval generalises but cannot guarantee exact matching. Rare identifiers — error codes, SKUs, function names, ticket IDs, drug names — are exactly the tokens embedding models compress away, because they were rare in training. BM25 finds them reliably because it matches the literal term and weights it by inverse document frequency, which rewards rarity. The two methods fail on disjoint query types, which is why hybrid wins.
Because the demos that sell dense retrieval are all paraphrase queries — "how do I reset my password" against a doc titled "credential recovery" — where BM25 genuinely fails and embeddings look magical. Nobody demos "find ORA-01555" or "where is `computeDeltaSnapshot` called", where the ranking inverts completely. And the dense failure is silent: it returns plausible, topically-related, wrong documents rather than nothing.
Run both retrievers over the same corpus with two query classes and compare recall@10:
# Class A (paraphrase): 'how do I roll back a failed deployment'
# Class B (exact term) : 'ORA-01555', 'ERR_CACHE_MISS_7', 'v2.14.3-hotfix'
#
# Expect roughly:
# dense : strong on A, weak on B (the rare token has no distinctive direction)
# BM25 : weak on A, strong on B (IDF makes the rare token decisive)
#
# Report recall@10 per class, not averaged. The average hides the
# whole phenomenon -- which is why teams ship dense-only and are
# surprised in production.Why does a cross-encoder reranker beat a bi-encoder retriever on quality, yet cannot replace it? Derive why the architecture forces a two-stage system.
- 1A bi-encoder embeds query and document independently:
score = sim(f(q), f(d)). The document's vector is computed without ever seeing the query.forced by · independence is what allows documents to be embedded once, offline, and stored in an index - 2That independence is exactly what makes sub-linear search possible: a fixed vector per document can be organised into an ANN structure and searched in roughly O(log N).forced by · you cannot build an index over a function that depends on a query you have not seen yet
- 3But it also caps quality. All query–document interaction must be squeezed through a single dot product between two fixed vectors, so the document vector must anticipate every possible query in one representation.forced by · one vector cannot be simultaneously optimal for all the different questions a long document could answer
- 4A cross-encoder instead concatenates query and document and runs full attention over the pair, letting every query token attend to every document token. Relevance can then depend on precise term interactions, negation, and conditions.forced by · joint attention removes the information bottleneck entirely
- 5But now the score is a function of the pair, so nothing can be precomputed — scoring N documents requires N forward passes of a transformer, which is O(N) with a large constant. Over a million documents that is hopeless per query.forced by · no index can be built over a function that requires the query as input
Therefore the two-stage architecture is forced, not stylistic: stage one must be a bi-encoder (or BM25) because only decomposable scoring is indexable; stage two can be a cross-encoder because it only runs on the ~50–200 candidates stage one returned.
And note what this predicts: overall quality is bounded by stage-one recall, not its precision. A reranker can only reorder what it was given — if the right document is not in the top-k, no reranker recovers it. So the correct tuning strategy is: maximise recall@k cheaply in stage one (retrieve generously, use hybrid), then let the reranker supply precision. Teams that tune stage-one precision are optimising a quantity that stage two was going to fix anyway.
Retrieval is a funnel with a hard rule: each stage can only lose relevant documents, never recover them. Stage one casts a wide, cheap net over millions of documents optimised purely for recall. Stage two applies expensive, accurate scoring to the small survivor set, optimised purely for precision.
So the diagnostic question is always "at which stage was the right document lost?" — and it has exactly one answer per failure.
- Hybrid = dense (semantics, paraphrase) + sparse/BM25 (exact terms, rare identifiers). Fuse with RRF, which needs no score calibration because it uses ranks, not scores.
- Retrieve generously (k around 50–200) and rerank down to 3–10. Under-retrieving is the most common and most invisible cause of bad RAG.
- Query transformation happens before retrieval: rewriting a conversational follow-up into a standalone query, or generating several sub-queries. Without it, "and what about the second one?" retrieves nothing.
- Metadata filters (tenant, date, permissions) are correctness, not ranking. They must be applied inside the index, not after — post-filtering silently reduces your k.
Fire this model the moment you see: a RAG system that answers plausibly but wrong · queries containing IDs, codes or exact names failing · a multi-turn chat losing the thread · someone reporting "the model hallucinated" without checking whether the passage was even retrieved · permission-filtered search returning too few results.
Your first-stage retrieval has adequate recall. Do you add a cross-encoder reranker, spend on a better embedding model, or just widen k and give the LLM more context?
Measure recall@k and recall@5 separately before choosing — that one comparison identifies the failing stage unambiguously. High recall@50 with low recall@5 is a ranking problem and wants a reranker. Low recall@50 is a retrieval problem and no reranker will help.
In practice hybrid retrieval plus a reranker is the configuration that survives contact with real production queries, because it covers both failure modes rather than optimising one. Adding context length is the tempting shortcut and the one that quietly makes both cost and quality worse.
(c) Hands-on · 25 min
Build a hybrid retriever with reranking on top of the S117 Chroma index. This is the code your production RAG will look like — minus the concurrency, caching, and monitoring.
"""rag_retrieve.py — hybrid retrieval + cross-encoder reranking.
Assumes S117's Chroma collection 'docs_v1' already exists.
Requires: pip install sentence-transformers chromadb rank_bm25
"""
from __future__ import annotations
from collections import defaultdict
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi
import chromadb
EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" # tiny + strong
COLLECTION = "docs_v1"
K_RETRIEVE, K_RERANK, K_FINAL = 50, 20, 5
class HybridRetriever:
def __init__(self):
self.client = chromadb.PersistentClient(path="./chroma_store")
self.coll = self.client.get_collection(COLLECTION)
self.embed = SentenceTransformer(EMBED_MODEL)
self.rerank = CrossEncoder(RERANK_MODEL)
# Load all docs once for BM25 (fine up to a few hundred K chunks).
all_docs = self.coll.get(include=["documents", "metadatas"])
self.docs = all_docs["documents"]
self.ids = all_docs["ids"]
self.metas = all_docs["metadatas"]
tokenised = [d.lower().split() for d in self.docs]
self.bm25 = BM25Okapi(tokenised)
# ---------- Dense retrieval ----------
def dense(self, q: str, k: int = K_RETRIEVE) -> list[tuple[str, str, dict]]:
qv = self.embed.encode([q])[0].tolist()
hits = self.coll.query(query_embeddings=[qv], n_results=k,
include=["documents", "metadatas"])
return list(zip(hits["ids"][0], hits["documents"][0], hits["metadatas"][0]))
# ---------- Sparse retrieval (BM25 in-memory) ----------
def sparse(self, q: str, k: int = K_RETRIEVE) -> list[tuple[str, str, dict]]:
scores = self.bm25.get_scores(q.lower().split())
top = sorted(range(len(scores)), key=lambda i: -scores[i])[:k]
return [(self.ids[i], self.docs[i], self.metas[i]) for i in top]
# ---------- Reciprocal Rank Fusion ----------
def fuse(self, *result_lists, k_const: int = 60) -> list[tuple[str, str, dict]]:
score_by_id: dict[str, float] = defaultdict(float)
doc_by_id: dict[str, tuple[str, dict]] = {}
for results in result_lists:
for rank, (id_, doc, meta) in enumerate(results, start=1):
score_by_id[id_] += 1.0 / (k_const + rank)
doc_by_id[id_] = (doc, meta)
ranked_ids = sorted(score_by_id, key=lambda i: -score_by_id[i])
return [(id_, *doc_by_id[id_]) for id_ in ranked_ids]
# ---------- Cross-encoder rerank ----------
def rerank_top(self, q: str, candidates, k: int = K_FINAL):
pairs = [(q, doc) for _, doc, _ in candidates[:K_RERANK]]
scores = self.rerank.predict(pairs)
ranked = sorted(zip(scores, candidates[:K_RERANK]),
key=lambda x: -x[0])
return [(s, id_, doc, meta) for s, (id_, doc, meta) in ranked[:k]]
# ---------- One-shot: dense + sparse + fuse + rerank ----------
def search(self, q: str) -> list[tuple[float, str, str, dict]]:
dense_hits = self.dense(q)
sparse_hits = self.sparse(q)
fused = self.fuse(dense_hits, sparse_hits)
return self.rerank_top(q, fused, k=K_FINAL)
if __name__ == "__main__":
r = HybridRetriever()
for q in ("How do I refresh an OAuth token?",
"error code E-4821 damaged shipment",
"What is chunking in RAG?"):
print(f"\n=== {q!r} ===")
for score, id_, doc, meta in r.search(q):
print(f" [{score:+.3f}] {meta.get('breadcrumb','(no bcrumb)')}")
print(f" {doc[:120].replace(chr(10),' ')}...")What each block does
Anatomy of the retriever
Add:
GOLD = [("How do I refresh an OAuth token?", "a1b2c3d4e5"), # correct chunk id
# ... 19 more (q, id) pairs from your corpus
]
def recall_at_k(fn, k=5):
hits = 0
for q, gold_id in GOLD:
ranks = [id_ for _, id_, _, _ in fn(q)][:k] if fn.__name__=='search' \
else [id_ for id_, _, _ in fn(q)][:k]
if gold_id in ranks:
hits += 1
return hits / len(GOLD)
print("dense only :", recall_at_k(r.dense))
print("hybrid+rrf :", recall_at_k(lambda q: r.fuse(r.dense(q), r.sparse(q))))
print("full stack :", recall_at_k(r.search))Watch each layer add points.
(d) Production reality · 15 min
Vector-only retrieval kept missing questions that contained exact identifiers — 'CVE-2024-12345', 'HP LaserJet 4200 error 49.4C02', 'section 230 of the CDA'. Users saw plausible-but-wrong answers.
Every production answer engine now runs a sparse retriever (BM25 or SPLADE) in parallel with dense, fuses with RRF or a learned merger, and reranks the top-N. Users don't see 'the answer' unless the top reranked chunk contains a strong exact-match signal.
Team tunes chunk size and swaps embedding models for weeks trying to fix 'my bot answers wrong'. Retrieval improvements measured by cosine similarity look fine. But real user questions still get bad answers.
Add a cross-encoder reranker on the top-20 dense hits. Retrieval recall was fine; ordering was wrong — the correct chunk was at rank 8 but only top-3 were being stuffed into the prompt. Reranker moves it to rank 1. Fixed with 10 lines of code and one small model.
Pre-Cohere-Rerank / bge-reranker, teams built their own MonoT5 or MiniLM-Marco pipelines. Quality varied wildly by dataset, and the models needed careful serving infra.
Commodity rerank APIs (Cohere, Voyage, Jina) + strong open-source models (bge-reranker-v2, mxbai-rerank) made this a solved problem. Latency ~50–200ms for the top-100 rerank; quality often matches or beats hand-tuned retrievers.
Where this shows up next
(e) Recall + stretch · 10 min
Explain-out-loud test
- Why hybrid retrieval beats vector-only?
- What does RRF do and why is it tuning-free?
- When is a cross-encoder reranker worth adding?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.