Module 2 · Retrieval-augmented generation

Retrieval that actually works

Advanced 20 min read Keyword + vector, fused, filtered, reranked

"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 typeKeyword (BM25)Neural vectors
Exact identifiers: innodb_buffer_pool_size, ERR 1213, SKU-4471excellentoften blurred: rare tokens carry little weight in a meaning vector
Paraphrase: "stuck waiting for each other" vs "deadlock"fails: no shared wordsexcellent
Short, ambiguous queries ("joins")many tiesvague
New jargon after the embedding model was trainedworks immediatelymay 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))])
idf: {'deadlock': 0.47, 'transaction': 0.47, '1213': 0.98} 'deadlock' [0.77, 0.46, 0.0] 'error 1213' [1.9, 0.0, 0.0] 'transactions waiting for each other' [0.0, 3.8, 0.0]

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.

BM25 vectors fused (RRF) 1. doc A 2. doc C 3. doc B 1. doc C 2. doc D 3. doc A C 1/62 + 1/61 = 0.0325 A 1/61 + 1/63 = 0.0323 D 1/62 = 0.0161 B 1/63 = 0.0159 Agreement wins: C and A appear in both lists, so they beat D and B, which each appear in only one.

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]])
bm25 recall@5=0.81 MRR@10=0.77 dense recall@5=0.88 MRR@10=0.81 hybrid recall@5=0.84 MRR@10=0.78 only BM25 found: [] only dense found: ['One key has most of the rows and a single task runs forever', 'Why does comparing a column to NULL return no rows?'] both missed: ["Why doesn't my Spark code do anything until I call count()?", 'running total per customer ordered by date', 'find customers who never placed an order', 'show tokens to the user as they are generated']
An honest result: here, hybrid did not win

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.

1,000,000 chunksthe index hybrid search → 50milliseconds, high recall rerank → 5~50 model passes prompt Stage 1 is tuned for recall (don't miss it); stage 2 for precision (put the best first).
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))
original ['openai-sdk/13-cost-latency.html', 'openai-sdk/04-models-parameters.html', 'openai-sdk/07-function-calling.html'] rewritten ['openai-sdk/05-streaming.html', 'openai-sdk/02-setup.html', 'openai-sdk/08-built-in-tools.html']

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)])
no filter: ['mysql/13-explain.html', 'sql/27-explain.html', 'mysql/22-project.html', 'sql/27-explain.html#sqlite'] course = mysql: ['mysql/13-explain.html', 'mysql/22-project.html', 'mysql/13-explain.html#workflow', 'mysql/13-explain.html#recap'] one per lesson: ['mysql/13-explain.html', 'sql/27-explain.html', 'mysql/22-project.html', 'sql/02-execution-order.html#logical-physical']

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

  1. Build the golden set first (lesson 10). Without it every change below is a guess.
  2. Fix ingestion and chunking (lesson 07). They matter most.
  3. Add BM25 next to vectors, fuse with RRF, and measure.
  4. Add a reranker over the top 30–50, and measure.
  5. Add query rewriting for conversational or jargon-heavy traffic, and measure.
  6. 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.

Checkpoint

1 · Users search for part numbers like "HX-4471-B" and the vector search returns similar-looking but wrong parts. Fix?
Identifiers are where keyword search shines. Meaning vectors treat "HX-4471-B" and "HX-4417-B" as near neighbours.
2 · Why does RRF use ranks instead of adding BM25 and cosine scores?
BM25 is unbounded and corpus-dependent; cosine lies in [−1, 1]. Rank-based fusion sidesteps normalisation.
3 · Why rerank only the top 50 instead of all chunks?
Precomputed vectors make stage 1 cheap. Stage 2 spends real compute, but only on candidates.