AI EngineeringZero to ProductionHome·About·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.

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

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.

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

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.

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

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

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

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

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

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

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.