AI EngineeringZero to ProductionHome·About·What’s new·Contact
Retrieval (RAG) · Part 3.2 · Intermediate

Retrieval that works

The retrieve stage caps the quality of the whole RAG pipeline, and naive cosine top-k is where most beginner systems quietly fail. This chapter goes deep on the mechanics that actually move the needle: how you chunk (fixed-size vs sentence vs structural, plus overlap and metadata), how a vector index finds neighbours, and — the centrepiece — hybrid search that fuses keyword (BM25-style) and dense (vector) retrieval with Reciprocal Rank Fusion. You will also add metadata filtering and basic query preprocessing, all as self-contained Python that runs offline right here. By the end you can diagnose why a chunk was missed and choose the retrieval strategy that fits your corpus.

⏱️ ~90 min🧲 Retrieval🎯 Intermediate🐍 Runnable Python
🌱 What runs here, and what needs a keyEvery retrieval technique in this chapter — chunking, brute-force top-k, BM25 scoring, Reciprocal Rank Fusion, metadata filtering — is plain Python and runs right here in your browser (no key, no install). We reuse the same fake deterministic embedder from 3.1 (a bag-of-words vector) so the dense side runs offline; a real embedding model plugs into the identical embed() slot. Any scores or timings shown are illustrative — the techniques (BM25, RRF, HNSW, cosine) are all real and public.

Learning objectives

  • Explain why naive cosine top-k misses relevant chunks, and name the two failure modes.
  • Choose between fixed-size, sentence, and structural chunking, and attach metadata to chunks.
  • Describe what a vector index does and the trade-off between brute-force and approximate search.
  • Build hybrid search: fuse a BM25-style keyword ranker with dense retrieval using RRF.
  • Apply metadata filtering (date, source, type) before or after vector search, and know which to pick.
  • Do basic query preprocessing and say when exact keyword match still beats semantics.
📋 Before you startThis page builds on: RAG foundations · Embeddings & vector databases. Jumping in? Skim those first — or open the recap.
30-second recap
  • RAG foundations: Retrieval-augmented generation: find the relevant passages first, then give them to the model as context, so answers come from your documents instead of memory.
  • Embeddings & vector databases: An embedding turns text into a vector so that similar meanings sit close together; a vector database finds the nearest vectors to a query fast.

1 · Recap: the retrieve stage, and why top-k isn't enough

Here's the demo that goes wrong, and it's a story you'll live if you ship a RAG system. On your test set of a dozen questions, naive cosine top-k looks brilliant — you ask "how do I roll back a deploy?" and the right runbook comes back first. You ship it. Then a real user pastes an error code, "ERR_5041", and the system confidently returns three chunks about queues and caches that never mention the code at all. Nobody wrote a bug. The retriever did exactly what it was built to do — and that's the problem.

Think of dense embedding search as a librarian who's brilliant at gist but never reads the exact words. Ask her for "a book about a boy wizard" and she nails it; ask her for "the book with ISBN 978-0439708180" and she hands you something about ISBNs, or a different wizard book, because she matched the vibe of your request, not the literal string. That's dense retrieval: it maps meaning into geometry and ranks by nearness in that space, so paraphrases cluster together — which is exactly the superpower you want most of the time, and exactly the blind spot that sinks precise queries.

The mistake nearly everyone makes is assuming that because embeddings are newer and smarter, they must be a superset of old-fashioned keyword search — that dense search can do everything keyword search does, plus more. It can't. Meaning-matching and token-matching are different tools with different failure modes, and neither contains the other. The whole chapter hangs off internalising that one fact: retrieval quality isn't a single dial you turn up, it's a pipeline of three separable decisions.

In 3.1 the retrieve stage was one function: embed the question, score every chunk by cosine similarity, return the top k. That is the correct skeleton — but on real corpora it leaks. Dense cosine search is fuzzy by design: it ranks by overall meaning, which is exactly what you want for paraphrases and exactly what you don't want for precise tokens.

the same retriever, two kinds of query dense cosine top-k "vehicle won't crank" "ERR_5041" right chunk found meaning matched nearby-but-wrong literal token lost one dial can't serve both — the rest of the chapter adds the missing tools Dense search is fuzzy by design. The identical retriever nails the paraphrase (left, green) and quietly fails the exact code (right, red) — because it ranks by meaning, not by literal token.
🗺️ How to read this diagram

Start at the box on top and follow the two arrows down — the left branch is a paraphrase query, the right branch is an exact-code query, and they end very differently.

  • The top box is your 3.1 retriever: embed, cosine-score every chunk, take the top k. It's the same code for both queries.
  • The left path (blue arrows, green result) is a meaning query — "vehicle won't crank". Dense search maps it near "engine won't start" and returns the right chunk. This is the superpower.
  • The right path (red dashed arrows, red result) is a literal token — "ERR_5041". Dense search has no special respect for the exact string, so it drifts to semantically nearby chunks that never contain the code — the quiet failure.

In short: Retrieval quality isn't one dial you turn up; the green and red endings from one box prove you need more than dense cosine alone.

Failure modeWhat happensWhat fixes it (this chapter)
Exact terms lostA query for an error code, product SKU, or function name (“ERR_5041”) gets fuzzed into nearby-but-wrong chunks — the embedding doesn't privilege the literal token.Add a keyword ranker (BM25) and fuse it with dense (hybrid search).
Wrong slice, right docThe right document is found but the retrieved chunk cuts the fact in half, or averages three topics so it scores weakly.Better chunking — structural boundaries plus overlap, with metadata to scope the search.
No scopingThe top chunk is from a 2019 doc when the user only wants this quarter, or from the wrong product line entirely.Metadata filtering — constrain by date/source/type before or after the vector search.
The mental model for this chapterRetrieval isn't one knob. It's chunk → search → scope: how you cut the text, how you find candidates (keyword + dense), and how you constrain the result set. This chapter makes each of those three real. Re-ranking the survivors and rewriting the query come next chapter (3.3).

2 · Chunking strategies in depth

Before you retrieve anything, you make one quiet decision that caps everything downstream: where to cut the text. It feels like plumbing — split the document, move on — but it's the most consequential choice on this page. Every clever ranker, hybrid fuser, and re-ranker you build later can only ever return chunks you actually created. If the answer to a question got sliced across two chunks, no retriever on earth can hand you the whole answer, because the whole answer doesn't exist anywhere as a single unit.

Picture cutting a printed manual into index cards to file in a box. Cut carelessly — every 3 inches with scissors — and you'll slice a sentence in half, orphan a heading from its steps, or staple the end of one procedure to the start of the next. Later, when someone asks "how do I restart the service?", the card that surfaces might hold the heading "Restart" but only the first step, with steps two and three stranded on a card that got filed elsewhere. Cut along the natural seams instead — one procedure per card, heading included — and each card answers its own question. Chunking is choosing where those scissors land.

The classic mistake is fixed-size chunking on structured content because it's the easiest to code: just count 500 tokens and cut. It works fine on a wall of uniform prose, but on a runbook, an API doc, or a wiki it guillotines the exact structure that made the content answerable — separating a code example from the sentence explaining it, or a table row from its header. The fix isn't a fancier algorithm; it's cutting on the boundaries the author already gave you (headings, paragraphs, list items) and adding a little overlap so a fact straddling a seam still lands whole in at least one chunk.

3.1 introduced overlap; here we compare the three strategies you will actually choose between. A fact can only be retrieved if it sits inside a chunk that scores well — so the boundary you cut on decides what is findable.

fixed-size cut (every N tokens) # Restart · Drain the heading + only step 1 queue then scale · # Roll… step 2 stranded, heading glued on answer sliced across a seam → unfindable whole structural cut (on headings) Restart: drain queue, scale whole procedure, heading kept Rollback: redeploy, clear one self-contained section each chunk answers its own question cut on the author's seams and the fact lands whole in one chunk Where the scissors land caps everything downstream. Fixed-size (left, red) can orphan a heading from step 2; structural (right, green) keeps each section — heading and body — as one findable chunk.
🗺️ How to read this diagram

Compare the two columns side by side: the left is a blind fixed-size cut, the right is a structural cut on the document's own headings.

  • The red boxes on the left show fixed-size chunking counting off N tokens with no regard for meaning — it splits "Restart" from its later steps and glues the tail of one procedure to the head of the next.
  • The green boxes on the right show structural chunking cutting on the headings the author already gave you, so each chunk is one whole procedure with its heading folded in.
  • The red vs green captions at the bottom are the payoff: a fact sliced across a seam can never be retrieved whole, while a self-contained section answers its own question.

In short: Chunking is choosing where the scissors land — cut on structure and the answer survives as one findable unit.

StrategyHow it splitsBest when
Fixed-sizeEvery N words/tokens, with a small overlap. Simple, predictable chunk count.Uniform prose with no structure; you want a dependable token budget per chunk.
Sentence / paragraphSplit on sentence or paragraph boundaries, then pack up to a size limit.Natural-language docs where a sentence is a coherent unit; avoids cutting mid-thought.
Semantic / structuralSplit on document structure — headings, list items, code blocks, table rows — so each chunk is one self-contained section.Structured content (docs, wikis, tickets); keeps a heading with its body and a step with its list.

Two things ride along with every chunk. Overlap repeats a little text across boundaries so a straddling fact lands whole in at least one chunk. Metadata — source, section, date, doc type — travels with the chunk so you can filter and cite it later. Here is a structural chunker that keeps headings attached and carries metadata:

python · structural chunker (runnable — click ▶ Open in terminal)
chunk_structural.pyimport re

def chunk_structural(doc, source, doc_type):
    """Split on markdown-ish headings; keep each heading with its body.
    Returns a list of {text, source, section, type} chunks (metadata rides along)."""
    chunks, section, buf = [], 'intro', []
    def flush():
        if buf:
            chunks.append({
                'text': f'{section}: ' + ' '.join(buf).strip(),
                'source': source, 'section': section, 'type': doc_type,
            })
    for line in doc.strip().splitlines():
        line = line.strip()
        if not line:
            continue
        m = re.match(r'#+\s*(.+)', line)   # a heading starts a new section
        if m:
            flush(); buf = []; section = m.group(1)
        else:
            buf.append(line)
    flush()
    return chunks

doc = '''# Restart
Drain the queue then scale the consumer group.
# Rollback
Redeploy the previous image tag and clear the cache.'''

for c in chunk_structural(doc, source='runbook.md', doc_type='runbook'):
    print(c)
{'text': 'Restart: Drain the queue then scale the consumer group.', 'source': 'runbook.md', 'section': 'Restart', 'type': 'runbook'}
{'text': 'Rollback: Redeploy the previous image tag and clear the cache.', 'source': 'runbook.md', 'section': 'Rollback', 'type': 'runbook'}

Each chunk is one self-contained section, its heading is folded into the text (so “Restart” is embedded with its steps), and it carries the metadata we will filter and cite on later. That metadata is not decoration — section 5 uses it to scope search.

3 · Vector search: what an index actually does

Your 3.1 retriever had a scaling bomb quietly ticking inside it, and it won't go off in testing. Scoring the query against every stored vector is fine at a hundred chunks and invisible at a thousand. But retrieval cost grows linearly with the corpus — double the documents, double the work per query — so the same code that answers in 5 milliseconds on your laptop demo takes half a second when the knowledge base grows to a million chunks, and that's before any concurrent users. The word for that pattern is the O(n) scan you met in the DSA track, and it's why "it was fast when we launched" is one of the most common laments in production RAG.

Here's the intuition for the fix. Imagine finding the nearest coffee shop to you. The brute-force way is to measure the straight-line distance to every coffee shop in the country and take the minimum — correct, but absurd. What you actually do is glance at a map: the map has already organised places by region, so you ignore everything outside your city and only compare the handful nearby. A vector index is that map for embeddings — a data structure that pre-organises the vectors so a query can jump to the promising neighbourhood and skip the 99.9% that couldn't possibly be closest.

The mistake to avoid is reaching for the fancy index too early. Approximate indexes (HNSW, IVF) are a genuine trade — they buy speed by occasionally missing a true nearest neighbour — and that trade only pays off at scale. For a few thousand chunks, brute-force is exact, dead simple, and plenty fast; adding an ANN index there just buys you tuning knobs and a recall risk for a speed-up you don't need. Learn brute-force first because it's the honest baseline every index is measured against; switch when your n — not your excitement about the technique — demands it.

brute-force: compare against every vector query N comparisons → O(n) index: jump to the near neighbourhood query skip the far 99.9% same answer, far fewer distance checks — the payoff only shows at scale An index is a map for vectors. Brute-force (left) checks every vector — exact but linear; an index (right) jumps to the promising neighbourhood and skips the far ones.
🗺️ How to read this diagram

Read the two panels left-to-right: on the left the query touches every stored vector; on the right it lands in one region and ignores the rest.

  • The left panel fans an arrow from the query to every amber vector — that's brute-force, exact but doing O(n) distance checks that grow with the corpus.
  • The right panel sends one arrow into the promising green neighbourhood; the faded grey boxes are the far vectors the index never scores.
  • The faded boxes and "skip the far 99.9%" label are the payoff — the index pre-organises vectors so a query can ignore almost all of them.

In short: Brute-force compares against all N vectors; an index skips the ones that couldn't be nearest — a trade worth making only once N is large.

Scoring the query against every stored vector is called brute-force (or “flat”) search. It is exact — it truly finds the nearest neighbours — and for a few thousand chunks it is perfectly fast. It is also what you built in 3.1. Here it is, made explicit as a tiny index:

python · brute-force top-k
brute_force.pyimport math, re
from collections import Counter

VOCAB = {}
def embed(text):                      # the SAME fake embedder as 3.1
    counts = Counter(re.findall(r'[a-z]+', text.lower()))
    for w in counts: VOCAB.setdefault(w, len(VOCAB))
    vec = [0.0] * len(VOCAB)
    for w, c in counts.items(): vec[VOCAB[w]] = float(c)
    return vec

def cosine(a, b):
    n = max(len(a), len(b)); a = a + [0.0]*(n-len(a)); b = b + [0.0]*(n-len(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(x*x for x in b))
    return dot / (na*nb + 1e-9)

docs = ['drain the queue then scale the consumer group',
        'redeploy the previous image tag and clear the cache',
        'invoices are stored in postgres by the billing service']
index = [(d, embed(d)) for d in docs]          # embed once, keep the vectors

def brute_force_topk(query, k=2):
    q = embed(query)
    scored = sorted(index, key=lambda di: cosine(q, di[1]), reverse=True)
    return [(round(cosine(q, v), 3), d) for d, v in scored[:k]]

for score, d in brute_force_topk('how do I clear the image cache?'):
    print(score, d)
0.57 redeploy the previous image tag and clear the cache
0.239 drain the queue then scale the consumer group

Brute-force compares against N vectors, so cost grows with the corpus. At millions of vectors that is too slow, so production stores use an Approximate Nearest Neighbour (ANN) index — HNSW (a navigable graph of vectors) or IVF (cluster the space, search only the near clusters). These trade a little recall for a huge speed-up. The index internals and when to tune them are 3.4's job — here just hold the idea: an index is a data structure that finds near vectors without scanning them all.

Approximate ≠ wrongANN indexes may occasionally miss a true nearest neighbour, but they are tuned so recall stays very high while queries go 10–100× faster (illustrative ranges). For learning, brute-force is exact and clear; for scale, ANN is the standard. Same interface — embed, then ask the index for the top k.

4 · Hybrid search — keyword + dense, fused with RRF

This is the section that turns a demo retriever into one you'd actually trust, so slow down here. Everything so far has been setup; hybrid search is the single change that fixes the most real-world misses for the least effort. If you take one technique from this whole chapter into a production system, make it this one — it's the difference between a retriever that shrugs at error codes and one that handles both "vehicle won't crank" and "ERR_5041" without you knowing in advance which kind of query is coming.

The core idea is embarrassingly simple once you see it: you have two specialists, so ask both and combine their answers. Dense search is your meaning expert; BM25 is your exact-token expert. A real query — "ERR_5041 on checkout" — has a literal part (the code) that keyword search nails and a conceptual part (what to do about it) that dense search nails. Running only one throws away half the query. Think of a hiring panel: you don't want a room full of people who all interview for the same trait; you want one who probes technical depth and one who probes communication, then you merge their rankings. Hybrid search is that panel for retrieval.

The trap that eats days of debugging is trying to merge by adding the scores. It feels obvious — BM25 gives a chunk 2.5, cosine gives it 0.4, add them, sort. But those numbers live on incompatible scales that have no shared meaning: BM25 scores are unbounded and corpus-dependent, cosine sits in a narrow band near zero-to-one, and "normalising" them into agreement is a per-corpus tuning nightmare that breaks the moment your data shifts. The escape hatch is to throw away the raw scores and keep only rank position — first, second, third — because a "#1 result" means the same thing coming from either ranker. That's exactly what Reciprocal Rank Fusion does, and it's why it's the near-universal default for hybrid merge.

Dense search finds meaning; keyword search finds exact tokens. Real queries need both — a user asking about “ERR_5041 on checkout” wants the literal error code (keyword) and the conceptually related recovery steps (dense). Hybrid search runs both retrievers and merges their ranked lists.

Query from user BM25 (keyword) exact terms Dense (vector) meaning RRF merge fuse rankings Top-k best of both
🗺️ How to read this diagram

Read it left-to-right, following the brand-coloured arrows: one query splits into two different retrievers, whose separate results are then fused back into a single list.

  • The leftmost box is the user's Query. The two boxes it feeds are the two ways of searching: BM25 (keyword) matches exact terms, and Dense (vector) matches meaning — the same query is run through both.
  • The arrows carry each retriever's ranked list into the middle RRF merge box, which fuses the two rankings by rank position rather than by raw score.
  • The rightmost box is the fused Top-k — the "best of both" result that combines keyword precision and semantic recall.

In short: Fan the query out to BM25 and dense retrieval, then let RRF fuse the two ranked lists into one top-k that usually beats either retriever alone.

The keyword side uses BM25, the standard term-frequency ranker behind classic search engines: a chunk scores higher when the query's terms appear often in it but are rare across the corpus, with diminishing returns as a term repeats. Below is a simplified BM25 (term-frequency scoring; the real formula adds document-length normalisation, omitted here for clarity):

python · simplified BM25
bm25.pyimport math, re
from collections import Counter

def toks(t): return re.findall(r'[a-z0-9]+', t.lower())

def build_bm25(corpus):
    docs = [Counter(toks(d)) for d in corpus]
    N = len(docs)
    df = Counter()                          # in how many docs each term appears
    for d in docs:
        for term in d: df[term] += 1
    idf = {t: math.log(1 + (N - n + 0.5) / (n + 0.5)) for t, n in df.items()}
    return docs, idf

def bm25_rank(query, corpus, k1=1.5):
    docs, idf = build_bm25(corpus)
    scores = []
    for i, d in enumerate(docs):
        s = 0.0
        for term in toks(query):
            if term in d:
                tf = d[term]
                s += idf.get(term, 0.0) * (tf * (k1 + 1)) / (tf + k1)   # TF saturation
        scores.append((s, i))
    return sorted(scores, reverse=True)     # (score, doc_index), best first

corpus = ['checkout error err_5041 means the queue is full',
          'drain the queue then scale the consumer group to recover',
          'billing stores invoices in postgres']
for s, i in bm25_rank('err_5041 checkout', corpus):
    print(round(s, 3), corpus[i])
2.942 checkout error err_5041 means the queue is full
0.0 billing stores invoices in postgres
0.0 drain the queue then scale the consumer group to recover

BM25 nails the exact-token query that dense search fuzzes. Now the fusion. You could try to add the two scores, but BM25 and cosine live on different scales — that comparison is meaningless. Reciprocal Rank Fusion (RRF) sidesteps it entirely: it merges by rank position, not score, giving each chunk 1/(k + rank) from each list and summing. Rank is comparable across any two rankers, which is why RRF is the standard hybrid-merge trick:

python · Reciprocal Rank Fusion (hybrid merge)
rrf.pydef rrf(ranked_lists, k=60):
    """Fuse several ranked lists of ids into one. ranked_lists: list of
    [id, id, ...] each ordered best-first. Returns [(id, fused_score)] best-first.
    k=60 is the common default; it damps the weight of very top ranks a little."""
    fused = {}
    for ranking in ranked_lists:
        for rank, doc_id in enumerate(ranking):     # rank 0 = best
            fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(fused.items(), key=lambda kv: kv[1], reverse=True)

# id lists from two retrievers over the SAME chunk ids (0,1,2,3):
bm25_ids  = [0, 2, 1, 3]     # keyword liked chunk 0 best
dense_ids = [2, 0, 3, 1]     # dense liked chunk 2 best

for doc_id, score in rrf([bm25_ids, dense_ids]):
    print(f'chunk {doc_id}: {round(score, 5)}')
chunk 0: 0.03252
chunk 2: 0.03252
chunk 1: 0.0315
chunk 3: 0.0315

Chunks 0 and 2 tie at the top because each was ranked #1 by one retriever and #2 by the other — RRF rewards agreement across the two lists without ever comparing their raw scores. In a full hybrid retriever you run bm25_rank and your dense brute_force_topk, pass both id-lists to rrf, and return the fused top k. That single merge typically beats either retriever alone.

Why RRF and not weighted score-blendingScore-blending needs you to normalise two incompatible scales and hand-tune a weight per corpus. RRF needs neither — it only reads rank order, so it is robust and nearly parameter-free (just k). Start with RRF; reach for tuned weighting only if you have evaluation data proving it helps (evals are 3.5).

5 · Metadata filtering — scoping the search

A retriever can be perfectly relevant and still perfectly wrong. Picture a user in a regulated company asking "what's our current refund policy?" The best-matching chunk by pure meaning might be a beautifully worded policy — from 2019, since superseded, and legally dangerous to quote. Or worse: in a multi-tenant product, the most semantically similar chunk belongs to another customer's data. Similarity answered "what's most related?"; nobody asked "what's this user allowed and expected to see?" Those are different questions, and conflating them is how RAG systems leak stale answers and, occasionally, private data.

The mental model is a search with a bouncer at the door. Metadata — the source, section, date, and type tags that rode along with each chunk back in section 2 — is the guest list. Before (or after) you rank by relevance, you check each candidate against the list: wrong year, wrong product, wrong tenant, not admitted. Relevance ranking decides who's interesting; metadata filtering decides who's eligible, and eligibility is not negotiable when the constraint is security or compliance rather than mere preference.

The subtle mistake is doing the filter in the wrong order — specifically, ranking first and filtering the survivors (post-filter) when you should have filtered first (pre-filter). It sounds harmless until you picture it: you take the top 5 by similarity, then drop everything not from 2026, and discover 4 of your 5 were old — you're left with one result, or none, even though plenty of fresh, relevant chunks existed just below the cut. Pre-filtering searches only the eligible pool, so it always fills your k when eligible matches exist, and for hard constraints like tenant isolation it's the only correct choice. Post-filter only when the constraint is loose and your index can't filter natively.

pre-filter → then rank filter on metadata first rank the eligible pool k filled — always rank → then post-filter rank all, take top-k drop the non-matching (red) only 1 left — starved Order changes the result. Pre-filter (left) ranks only eligible chunks and always fills k; post-filter (right) can take top-k, drop the ineligible ones, and leave you short.
🗺️ How to read this diagram

Follow each column top-to-bottom — same two operations (filter and rank), just swapped in order, and the endings differ.

  • The left column filters on metadata first, then ranks only the eligible pool — so the two green result boxes at the bottom mean k is always filled when eligible matches exist.
  • The right column ranks everything and takes top-k first; the red box is a top result that fails the filter and gets dropped afterward.
  • The "only 1 left — starved" label is the trap: post-filtering after the cut can strip your results down below k, even though eligible chunks existed lower in the ranking.

In short: Pre-filter searches only who's eligible and always fills k; post-filter can starve you — which is why pre-filter is the default for hard constraints.

Semantic relevance is not the same as being allowed or in scope. A user asking “what changed this quarter?” does not want a semantically perfect chunk from 2019. Because our chunks carry metadata (section 2), we can constrain by date, source, or doc type. There are two places to do it:

ApproachWhen it runsPick it when
Pre-filterRestrict the candidate set before vector search — only search chunks that match the metadata.The filter removes a large fraction of the corpus, or the constraint is hard (security / tenant isolation). Most correct and often faster.
Post-filterSearch everything, then drop results that fail the filter.The filter is loose and cheap, or your index can't filter natively — but beware: if you post-filter after taking top-k you may be left with too few results.
python · pre-filter then rank
metadata_filter.pychunks = [
    {'text': 'drain the queue then scale', 'type': 'runbook', 'year': 2026},
    {'text': 'legacy queue restart steps', 'type': 'runbook', 'year': 2019},
    {'text': 'quarterly billing summary',  'type': 'report',  'year': 2026},
]

def retrieve(query, chunks, where=None, k=2):
    # 1) PRE-FILTER on metadata before any scoring
    pool = [c for c in chunks if all(c.get(f) == v for f, v in (where or {}).items())]
    # 2) rank the survivors (toy keyword overlap stands in for a real scorer)
    qset = set(query.lower().split())
    scored = sorted(pool, key=lambda c: len(qset & set(c['text'].lower().split())), reverse=True)
    return scored[:k]

q = 'how do I restart the queue'
print('no filter :', [c['text'] for c in retrieve(q, chunks)])
print('2026 only :', [c['text'] for c in retrieve(q, chunks, where={'year': 2026})])
print('runbooks  :', [c['text'] for c in retrieve(q, chunks, where={'type': 'runbook'})])
no filter : ['drain the queue then scale', 'legacy queue restart steps']
2026 only : ['drain the queue then scale', 'quarterly billing summary']
runbooks  : ['drain the queue then scale', 'legacy queue restart steps']

The where clause scopes the search: asking for 2026 drops the 2019 runbook before scoring, and asking for runbooks drops the report. Real vector databases expose exactly this as a filter argument on the query; the mechanism above is what they do under the hood.

6 · Query preprocessing — small, boring, high-leverage

The highest return-on-effort fix in retrieval is also the one nobody wants to write, because it's dull. There are no embeddings to admire and no clever fusion to diagram — just quietly making sure the query and the index agree on the basics. Yet a whole category of "why did it miss the obvious chunk?" bugs trace back to a mismatch this boring: the query said ERR_5041 and the index stored err_5041, and because they're different strings, the exact-match ranker that should have scored 10 scored 0.

The rule of thumb is a single sentence: whatever you do to the text you index, do the identical thing to the query — no more, no less. Think of it like a key and a lock cut from the same jig. If you lowercase your documents when indexing, you must lowercase the query, or the "key" won't turn. If you don't touch the query but silently lowercased the index (or vice versa), you've filed the key and the lock differently and they'll never mesh. Symmetry is the whole game; asymmetry is the bug.

same transform on both → match ERR_5041 err_5041 lowercase both err_5041 err_5041 query = index → exact match the key turns the lock only one side transformed → miss ERR_5041 err_5041 query untouched, index lowercased ERR_5041 err_5041 query ≠ index → scores 0 key and lock cut differently whatever you do to the index, do the identical thing to the query Symmetry is the whole game. Lowercasing both sides (left) makes the tokens equal and the exact-match ranker fires; touching only one side (right) leaves two different strings that never match.
🗺️ How to read this diagram

Read each column top-to-bottom: the top row is the raw query and indexed token, the arrows are the normalisation step, and the bottom row is what actually gets compared.

  • The left column applies the same transform — lowercasing — to both the query and the index, so both become err_5041 and the green result is an exact match.
  • The right column lowercases only the index and leaves the query as ERR_5041 (the greyed arrow is the step that was skipped), so the two strings differ.
  • The red "scores 0" endpoint is the bug: the exact-match ranker that should have scored high sees two unequal strings and scores nothing — the key and lock were cut differently.

In short: Do the identical normalisation to query and index, or the exact-match power you added with BM25 silently disappears.

The trap here is over-processing — being too clever. Beginners reach for aggressive stemming and stopword stripping because a tutorial said classic search engines do it, and end up mangling the exact tokens that keyword search exists to protect: a version string "v2.1" gets stemmed to "v2", a SKU or error code gets its digits shaved off, and now the literal-match power you carefully added with BM25 is gone. The lesson from the whole chapter reappears here in miniature: normalise lightly and symmetrically, and leave codes, IDs, and versions exactly as they are — the exact token is the signal, not noise to be scrubbed.

Before you search, normalise the query so it lines up with how chunks were indexed. This is unglamorous and cheap, and it prevents whole classes of misses:

StepWhat it doesWhy it matters
LowercasingFold case on both query and index.So “ERR_5041” and “err_5041” match; do it on both sides or neither.
Stopword noteWords like “the/of/is” carry little signal.BM25's IDF already down-weights them; you rarely need to strip them, and stripping can hurt phrase queries — usually leave them.
Keep exact tokensDon't over-normalise codes, IDs, versions.Aggressive stemming can turn “v2.1” or a SKU into mush — the exact token is the whole point for keyword search.

The larger lesson: exact match still matters. Dense retrieval is not a superset of keyword retrieval — it is a different, complementary tool. That is the entire justification for hybrid search: keep the literal-token power of BM25 and the meaning-power of embeddings, and let RRF decide.

7 · When each approach wins

Here's the moment the toolkit becomes judgement. You now hold five tools — dense search, BM25, hybrid, metadata filtering, ANN indexing — and the beginner instinct is to grab the newest or most impressive one for everything. That's exactly backwards. A senior engineer isn't the person who knows the most techniques; it's the person who can look at a query and a corpus and say "this one wants keyword, that one wants dense, and at your scale you'll also need an index." Matching the tool to the situation is the skill this whole chapter was building toward.

The analogy is a mechanic's toolbox. A torque wrench is a wonderful instrument, but you don't use it to hammer a nail — not because it's a bad tool, but because it's the wrong tool for that job. Dense embeddings are your torque wrench: precise and modern and genuinely better for the paraphrase queries they're built for, and genuinely worse than a plain BM25 "hammer" when the job is driving an exact error code home. The table below is your at-a-glance guide to which tool each job calls for — read it as decisions, not trivia.

The one summary worth carrying out of here: when in doubt, reach for hybrid. The honest truth of production retrieval is that you rarely know in advance whether the next query is a paraphrase or an exact token — users mix both, often in the same sentence. Hybrid plus RRF covers both cases safely with almost no tuning, which is why it's the sane default and the pure-dense or pure-keyword rows below are the specialisations you drop to only when you know the query shape.

what shape is the query? incoming query pick the tool paraphrase → Dense unsure → Hybrid exact code → BM25 + metadata filter (scope) + ANN index (scale) match the tool to the job — and when unsure, hybrid is the safe default The toolkit becomes judgement. Route by query shape — dense for paraphrases, BM25 for exact tokens, hybrid (green) when unsure — then layer metadata filtering for scope and an ANN index for scale.
🗺️ How to read this diagram

Start at the box on the left and follow the arrows right: the query fans out to the retriever that fits its shape, then the amber add-ons attach on top.

  • The left box is any incoming query; the three blue arrows are the branch — you're deciding which retriever the query's shape calls for.
  • The three middle boxes are the choices: dense for paraphrases, BM25 for exact codes, and the green hybrid box in the middle as the safe default when you can't tell in advance.
  • The amber boxes on the right are orthogonal add-ons that layer on any choice — a metadata filter to enforce scope, and an ANN index once the corpus is large.

In short: Seniority is matching the retriever to the query, not always grabbing the newest tool — and hybrid is the default when the query shape is unknown.

SituationReach forBecause
Paraphrase / synonym query (“vehicle won't crank”)Dense (vector)Embeddings capture meaning across different words; keyword search finds nothing without overlap.
Exact code / SKU / name (“ERR_5041”, “func_x()”)Keyword (BM25)The literal token is the signal; dense search blurs it into neighbours.
Real user queries (a mix of both)Hybrid (BM25 + dense, RRF)You rarely know which kind a query is; hybrid covers both and RRF fuses them safely.
Scope constraint (this quarter / this product)Metadata filterRelevance ≠ permission or recency; pre-filter to enforce it before ranking.
Millions of vectors, latency-sensitiveANN index (HNSW/IVF)Brute-force is exact but linear; ANN keeps recall high at a fraction of the cost (detail in 3.4).
✓ Knowledge check

Why does Reciprocal Rank Fusion merge by rank position instead of by adding the BM25 and cosine scores?

Show answer
Because the two scores live on incomparable scales. A BM25 score of 2.5 and a cosine of 0.4 mean nothing added together, and normalising them requires per-corpus tuning. RRF uses only rank order (1/(k+rank)), which is comparable across any two rankers, so it fuses them robustly with almost no tuning.
✓ Knowledge check

A user searches for the exact error code “ERR_5041”. Dense vector search returns semantically similar but wrong chunks. What's happening, and what's the fix?

Show answer
Dense search ranks by meaning, so it fuzzes a literal token into nearby-but-wrong chunks. The fix is hybrid search: add a keyword ranker (BM25) that privileges the exact token, and fuse the two lists with RRF so the code-matching chunk surfaces alongside the conceptually related ones.

🪜 Practice — from chunking to a real hybrid retriever beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Swap chunking strategiesBeginner

Take the doc from chunk_structural.py and also chunk it with a simple fixed-size splitter (60 words, 15 overlap). Compare the chunk boundaries.

Show solution Reuse chunk_words() from 3.1 on the raw text. The fixed-size version can cut a heading away from its steps; the structural version keeps “Restart” with its body — that's the whole point of structural chunking on structured docs.
Exercise 2 · Tune BM25 saturationIntermediate

In bm25.py, change k1 from 1.5 to 0.5 and then to 5.0, re-running for a query whose term repeats in one chunk. Explain what k1 controls.

Show solution k1 sets how fast term-frequency saturates. Low k1 means the 2nd+ occurrence of a term barely adds score (repetition ignored); high k1 lets frequency keep mattering. It trades off rewarding repetition vs. treating presence as binary.
Exercise 3 · Build the full hybrid retrieverAdvanced

Wire bm25_rank and the dense brute_force_topk over the SAME chunk ids, convert each to an ordered id-list, and pass both to rrf. Return the fused top-k.

Show solution Run each retriever to get [(score, id), ...], sort, strip to id-lists, then rrf([bm25_ids, dense_ids]). Return the top-k ids and look up their text. The fused result should surface both the exact-token chunk and the semantically related one.
Exercise 4 · Pre-filter vs post-filterExpert

Modify metadata_filter.py to also support post-filtering (rank first, then drop non-matching). Construct a case where post-filtering leaves you with fewer than k results.

Show solution If you take top-k=2 then filter, and both top chunks fail the filter, you return zero — even though matching chunks existed lower down. Pre-filtering searches only the matching pool, so it always fills k when possible. This is why pre-filter is usually correct.
Exercise 5 · Guard the hybrid retrieverProfessional

Add a floor to your hybrid retriever: if neither BM25 nor dense produces any candidate above a minimum signal, return “I don't know” instead of the least-bad chunk. Explain why this matters for hybrid specifically.

Show solution Check the top fused score (or that at least one retriever had a non-zero score) against a threshold; if it fails, return [] and let the prompt instruct a grounded refusal. Hybrid can otherwise return a confidently-ranked chunk that only matched a stopword — a floor prevents that.
Exercise 6 · Choose the retrieval design for a real corpusIndustry scenario

You're indexing 200k support tickets: each has a title, body, product, and date. Users search with a mix of paraphrases and exact error codes, and often want only recent tickets for one product. Specify the retrieval design.

Show solution Structural/field-aware chunking (title+body per ticket, product/date as metadata); hybrid BM25+dense with RRF to cover both error codes and paraphrases; pre-filter on product and date range before ranking; and, at 200k, an ANN index for the dense side. Re-ranking the fused survivors is the next chapter's job.

Context: Your team's naive-cosine RAG keeps missing exact error codes and returning stale docs, and a teammate wants to know what to change.

Your task: Write a short design note (6–9 sentences) that turns the naive retriever into a working one, in the order you would implement it.

Requirements:

  • Name the chunking change and why metadata must ride along with each chunk.
  • Specify hybrid search: which two retrievers, and that they are fused with RRF (by rank, not score).
  • Specify metadata filtering and say whether you pre- or post-filter, with the reason.
  • State what you deliberately defer to later (re-ranking / query rewriting → 3.3; ANN index tuning → 3.4) so the scope stays honest.

💡 Hint: You don't need full code — communicate the design and the order of leverage. Everything you'd write here runs offline with the fake embedder; only swapping in a real embedder changes the dense side.

✓ Checkpoint — you can move on when you can…

  • Naive cosine top-k leaks on exact tokens, bad chunk slices, and missing scope.
  • Chunk on structure when the doc has it, keep overlap, and carry metadata on every chunk.
  • A vector index finds near vectors; brute-force is exact, ANN (HNSW/IVF) trades a little recall for scale.
  • Hybrid search = BM25 (exact tokens) + dense (meaning), fused with RRF by rank, not raw score.
  • Metadata filtering scopes results by date/source/type — prefer pre-filter for hard or large-fraction constraints.
  • Exact match still matters, so keep keyword search alongside embeddings; normalise queries the same way you indexed.