Retrieval that actually works
"Embed the question, take the top 5" is where RAG starts, not where it ends. Production retrieval combines keyword search (BM25) with vector search, fuses the rankings, applies metadata filters, often reranks the candidates with a stronger model, and sometimes rewrites the question first. This lesson builds each piece and measures it on this site, including the case where a popular technique does not help.
A librarian with two catalogues, then a careful second look
One catalogue is indexed by exact words: perfect when you know the term ("error 1213"). The other is organised by topic: better when you only know what you mean ("two transactions stuck waiting"). A good librarian checks both, merges the lists, and then actually reads the top twenty candidates to pick the best five. That is hybrid search plus reranking.
1. Why one method is not enough
| Query type | Keyword (BM25) | Neural vectors |
|---|---|---|
Exact identifiers: innodb_buffer_pool_size, ERR 1213, SKU-4471 | excellent | often blurred: rare tokens carry little weight in a meaning vector |
| Paraphrase: "stuck waiting for each other" vs "deadlock" | fails: no shared words | excellent |
| Short, ambiguous queries ("joins") | many ties | vague |
| New jargon after the embedding model was trained | works immediately | may not know the word |
They fail on different questions, and that is the whole argument for hybrid search.
2. BM25 in 25 lines
BM25 scores a document for each query term by three ideas: rare terms matter more (IDF), repeating a term helps with diminishing returns (saturation, set by k1), and long documents should not win just by being long (length normalisation, set by b). It is still the backbone of Elasticsearch, OpenSearch and Lucene.
import math
import re
from collections import Counter
def tokenize(text):
return re.findall(r"[a-z0-9_]+", text.lower())
class BM25:
def __init__(self, texts, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.docs = [Counter(tokenize(t)) for t in texts]
self.lengths = [sum(d.values()) for d in self.docs]
self.avg = sum(self.lengths) / len(self.docs)
df = Counter(term for d in self.docs for term in d)
n = len(self.docs)
self.idf = {t: math.log(1 + (n - f + 0.5) / (f + 0.5)) for t, f in df.items()}
def score(self, query, i):
d, s = self.docs[i], 0.0
for t in set(tokenize(query)):
tf = d.get(t, 0)
if tf:
norm = 1 - self.b + self.b * self.lengths[i] / self.avg
s += self.idf[t] * tf * (self.k1 + 1) / (tf + self.k1 * norm)
return s
docs = ["deadlock deadlock deadlock: InnoDB error 1213 rolls back one transaction",
"a deadlock happens when two transactions wait for each other",
"indexes speed up lookups; a transaction groups statements"]
bm = BM25(docs)
print("idf:", {t: round(bm.idf[t], 2) for t in ("deadlock", "transaction", "1213")})
for q in ["deadlock", "error 1213", "transactions waiting for each other"]:
print(f"{q!r:40}", [round(bm.score(q, i), 2) for i in range(len(docs))])
Two details are worth seeing. "1213" appears in one document, so its IDF is twice that of "deadlock", which appears in two. And document 2 wins the third query only because it happens to contain the exact strings "transactions", "each" and "other". "Waiting" does not match "wait", and documents 1 and 3 get nothing from their "transaction", because BM25 compares strings. Real engines add stemming and synonyms. Neural vectors handle this naturally.
3. Hybrid search with reciprocal rank fusion
BM25 scores and cosine similarities live on different scales, so you cannot just add them. Reciprocal rank fusion (RRF) ignores the scores and uses only ranks: each document gets Σ 1/(k + rank) across the lists, with k = 60 by convention. It needs no tuning and is hard to break.
Now measure all three on the 32-question golden set, using mini_rag.HybridIndex (the same BM25,
the LSA vectors from lesson 06, and RRF):
from mini_rag import GOLDEN, HybridIndex, lessons_in
from site_docs import load_sections
docs = load_sections()
index = HybridIndex(docs)
def evaluate(mode, k=5):
hits, rr, found = 0, 0.0, {}
for question, answers in GOLDEN:
lessons = lessons_in(index.rank(question, mode), docs, k=10)
found[question] = bool(answers & set(lessons[:k]))
hits += found[question]
rank = next((i for i, url in enumerate(lessons, 1) if url in answers), None)
rr += 1 / rank if rank else 0
return hits / len(GOLDEN), rr / len(GOLDEN), found
results = {mode: evaluate(mode) for mode in ("bm25", "dense", "hybrid")}
for mode, (recall, mrr, _) in results.items():
print(f"{mode:7} recall@5={recall:.2f} MRR@10={mrr:.2f}")
bm, de = results["bm25"][2], results["dense"][2]
print("\nonly BM25 found: ", [q for q in bm if bm[q] and not de[q]])
print("only dense found:", [q for q in bm if de[q] and not bm[q]])
print("both missed: ", [q for q in bm if not bm[q] and not de[q]])
Fusion helps when the two methods fail on different questions. Our dense vectors are LSA, which
is itself built from word counts, so they fail on many of the same paraphrased questions as BM25. The
lists disagree only a little, and fusion lands between the two. With a neural embedding model the
failures diverge much more, and hybrid search is usually the strongest simple setup. The lesson that
transfers is not "hybrid wins". It is measure on your own golden set before and after every retrieval
change. Swap LsaEmbedder for real embeddings and re-run this block to see the difference
on your data.
4. Reranking: a careful second look
First-stage retrieval must be fast, so it compares one query vector with precomputed document vectors (a bi-encoder). A cross-encoder reads the question and a passage together and outputs a relevance score. It is far more accurate and far too slow to run over everything. So you retrieve 30–100 candidates quickly, then rerank only those.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") # small, runs on CPU
def rerank(question, hits, keep=5):
scores = reranker.predict([(question, h.doc["text"]) for h in hits])
ranked = sorted(zip(scores, hits), key=lambda pair: -pair[0])
return [h for _, h in ranked[:keep]]
candidates = index.search(question, k=50) # stage 1: fast, wide
top5 = rerank(question, candidates) # stage 2: slow, precise
Hosted rerank APIs (Cohere, Voyage and others) do the same over HTTP. An LLM can also rerank ("rate each passage 0–3 for this question"), which costs more and is slower. Whichever you use, it is a component with a quality number, so measure recall and MRR before and after, exactly as above.
5. Rewrite the question before searching
Users write "show tokens to the user as they are generated". The documents say "streaming". A cheap model call can rewrite the question into search-friendly terms first. All three retrievers above missed this question. Here the rewrite is scripted through the course's fake server (it is what a model typically returns), and the retrieval is real:
from fake_openai import fake_client
from mini_rag import HybridIndex, lessons_in
from site_docs import load_sections
docs = load_sections()
index = HybridIndex(docs)
client = fake_client(script=["stream the response token by token (streaming events, output text deltas)"])
REWRITE = ("Rewrite the user's question as a search query for technical documentation. "
"Use the standard technical terms. Reply with the query only.")
question = "show tokens to the user as they are generated"
rewritten = client.responses.create(model="gpt-5.6-luna", instructions=REWRITE, input=question).output_text
for label, q in [("original ", question), ("rewritten", rewritten)]:
print(label, lessons_in(index.rank(q), docs, k=3))
Rewrite
Translate the user's words into the corpus's vocabulary. Also resolve follow-ups: "and for MySQL?" becomes a standalone question using the chat history.
Multi-query
Generate 3–4 variants, search with each, and fuse with RRF. Better recall, more calls.
HyDE
Ask the model to write a hypothetical answer and embed that. Answers look more like documents than questions do.
Every rewrite adds a model call: latency and cost. Keep it on a small, fast model, and keep the original question too (search with both and fuse) so a bad rewrite cannot make things worse.
6. Filters and diversity
from mini_rag import HybridIndex
from site_docs import load_sections
docs = load_sections()
index = HybridIndex(docs)
q = "how to read EXPLAIN output"
print("no filter: ", [h.doc["url"] for h in index.search(q, k=4)])
print("course = mysql: ", [h.doc["url"] for h in index.search(q, k=4, where={"course": "mysql"})])
def one_per_lesson(hits, k):
seen, out = set(), []
for h in hits:
lesson = h.doc["url"].split("#")[0]
if lesson not in seen:
seen.add(lesson)
out.append(h)
return out[:k]
print("one per lesson: ", [h.doc["url"] for h in one_per_lesson(index.search(q, k=20), 4)])
Filters are also your access control (lesson 07): the user's tenant and groups go into
where, built by your code, on every query. Diversity rules such as "at most N chunks per
document", or MMR (maximal marginal relevance, which penalises candidates similar to ones already
chosen), stop five near-identical sections from filling the whole context budget.
7. A tuning order that works
- Build the golden set first (lesson 10). Without it every change below is a guess.
- Fix ingestion and chunking (lesson 07). They matter most.
- Add BM25 next to vectors, fuse with RRF, and measure.
- Add a reranker over the top 30–50, and measure.
- Add query rewriting for conversational or jargon-heavy traffic, and measure.
- Tune k (how many chunks reach the prompt) against answer quality and cost.
Recap
- BM25 nails exact terms; vectors handle paraphrase. They fail differently.
- RRF fuses rankings without score tuning, but measure: on our lexical stand-in, it did not beat dense.
- Rerank a wide candidate set with a cross-encoder for precision.
- Rewrite vague or conversational questions; filter by metadata for scope and access; diversify the final set.