AI Engineering (Applied) · Retrieval Engineering

Retrieval Engineering

The Retrieval & RAG lesson taught you the cartoon: embed your docs, drop them in a vector store, grab the top-k nearest chunks. That’s enough to demo — and nowhere near enough to ship. This page turns the cartoon into a production system: chunk deliberately, blend keyword and vector search, rerank for precision, rewrite the query, and — the part everyone skips — measure retrieval on its own, before you ever blame the model. You won’t train anything; you’ll wire together hosted embedding and rerank APIs and get the plumbing right.

☺ Explain it like I’m 10

Last time, our magic librarian grabbed the three index cards that looked most like your question. That works for a small shelf. But in a real library — millions of cards, some cut badly, some out of date — “looks similar” isn’t good enough. Now the librarian searches two ways at once, double-checks the pile before handing it over, and gets graded on how often the right card ends up in your hands.

🐿️Your host for this topic: 🐿️ Nutty the Squirrel — Nutty lives in the data and cares about one thing above all: bringing back the right nut, not just a nut-shaped one.

Recap & the gap

☺ Like you’re 10: Grabbing “the three most similar cards” sounds smart, but similar-looking isn’t the same as correct. A card that rhymes with your question can beat the card that actually answers it — so the librarian confidently hands you the wrong thing.

Recall the RAG pipeline: chunk the docs, embed each chunk, store the vectors, and at question time embed the query and return the top-k nearest chunks by cosine similarity. That single pipeline is where Retrieval & RAG stopped. It’s the right mental model, but naive top-k semantic search quietly underperforms the moment real users and real documents show up:

Retrieval engineering is the applied craft of closing each of these gaps with off-the-shelf, hosted components — better chunking, hybrid search, a reranker, query transformation — and then proving it worked with retrieval-specific metrics. None of it requires training a model.

Naive top-k semantic search is a great demo and a mediocre product. The jump to production is a handful of well-known upgrades plus one discipline: measure the retriever by itself.

Chunking, done properly

☺ Like you’re 10: If you tear a book into cards, where you tear matters. Rip a sentence in half and neither piece makes sense. Make one card hold ten pages and the good bit gets buried. Cut along the natural seams — chapters, headings — and write a little label on each card so you can find it later.

A chunk is the unit that gets embedded, retrieved, and shown to the model. Get chunking wrong and everything downstream inherits the damage — most “our RAG is bad” complaints trace back here. Three levers:

Just as important as the text is the metadata you attach to each chunk: source URL, title, section heading, author, last-updated date, product line, access level. Metadata is what lets you filter before you rank — “only chunks from the current product version,” “only docs this user is allowed to see.” Filtering shrinks the haystack and kills a whole class of wrong-but-similar hits.

# INDEX ONCE — chunk on structure, carry metadata (neutral pseudocode)
for doc in corpus:
    for section in split_on_headings(doc):          # structure-aware, not blind count
        for chunk in window(section, size=350, overlap=60):   # size + overlap in tokens
            index.add(
                vector   = embed(chunk.text),        # hosted embedding API
                text     = chunk.text,
                metadata = {                          # filterable fields
                    "source":  doc.url,
                    "section": section.heading,
                    "product": doc.product,
                    "updated": doc.updated_at,
                },
            )
◆ Key idea

Chunk on meaning boundaries, not byte counts, and treat metadata as a first-class part of the chunk. A well-labelled corpus lets you filter first and rank second — which is both faster and more accurate than ranking the whole world.

Hybrid search

☺ Like you’re 10: Two librarians with different superpowers. One finds cards that mean the same thing even with different words. The other finds cards with the exact words you said, like a product code. Ask both, then merge their piles — you catch what either one alone would miss.

Vector search is strong on meaning and weak on literals. Old-fashioned keyword search is the mirror image: it nails exact terms — names, codes, rare acronyms — but is blind to paraphrase. The production answer is to run both and combine them. That’s hybrid search.

The keyword side is usually BM25, a decades-old, battle-tested scoring function that ranks documents by how often the query’s terms appear, weighted so that rare words count more and long documents don’t get an unfair boost. It ships in virtually every search engine and needs no model at all. So you get two ranked lists for the same query:

Now you must fuse them into one list. You can’t just add the scores — a cosine similarity of 0.82 and a BM25 score of 14.6 live on totally different scales. The clean, popular trick is Reciprocal Rank Fusion (RRF), which throws away the raw scores and uses only each item’s rank in each list. An item’s fused score is the sum, across lists, of 1 / (k + rank) (a small constant k, often around 60, keeps the top ranks from dominating). Anything that ranks high in either list floats up; anything high in both floats highest.

Query 🐿️ user asks BM25 keyword list Vector meaning list RRF fuse merge by rank Rerank 🐢 top precise → to the model
# RETRIEVE — run both searches, fuse by rank (neutral pseudocode)
dense  = index.vector_search(embed(query), k=50)   # list of chunk ids, best-first
sparse = index.bm25_search(query,          k=50)   # keyword list, best-first

def rrf(lists, k=60):
    score = {}
    for lst in lists:
        for rank, doc_id in enumerate(lst):          # rank 0 = best
            score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank)
    return sorted(score, key=score.get, reverse=True)

fused = rrf([dense, sparse])[:20]                    # one merged shortlist

Hybrid search is close to a free lunch: BM25 is cheap, the fusion is a few lines, and you recover exactly the exact-match cases where pure vectors fail. It’s the single highest-leverage upgrade over naive top-k for most real corpora.

Reranking

☺ Like you’re 10: First you grab a big stack of maybe-good cards, fast. Then a careful checker reads each card next to your actual question and re-sorts them so the truly-right ones are on top. Slower, but only on the small stack — and way more accurate.

Fusion gives you a solid shortlist, but it still ranks by cheap signals. To squeeze out real precision, add a reranking stage. The retriever’s job is recall — cast a wide net, get 20–50 candidates, don’t miss the answer. The reranker’s job is precision — take that shortlist and put the genuinely best few on top.

The magic is in how a reranker scores. Your embeddings are a bi-encoder: the query and each document are embedded separately, then compared with a dot product. Fast (you can pre-compute all the document vectors), but the two never actually “see” each other. A cross-encoder reranker instead feeds the query and one candidate together into a model and outputs a single relevance score for that exact pair. Because it reads them jointly, it catches subtle relevance a bi-encoder misses — but it’s far more expensive, so you can only run it on a shortlist, never the whole corpus.

Bi-encoder (embeddings)Cross-encoder (reranker)
How it scoresQuery and doc embedded separately, then comparedQuery + doc read together, one relevance score out
SpeedVery fast; doc vectors precomputed & indexedSlow; must run per query–doc pair at request time
PrecisionGood for a first passHigher — sees interactions between query and doc
Where it runsOver the whole corpus (millions of chunks)Over the shortlist only (tens of chunks)

So the production shape is a funnel: hybrid retrieval fetches ~50 candidates, a hosted rerank API scores each one against the query, and you keep the top handful to hand the model. You get the reranker’s accuracy without paying its cost across everything.

# RERANK the shortlist with a hosted cross-encoder (neutral pseudocode)
candidates = fused[:50]                               # from hybrid + RRF
scored = rerank(query=query, documents=[c.text for c in candidates])
top    = [c for c, _ in sort_by_score(candidates, scored)][:5]   # keep the best 5
context = "\n\n".join(c.text for c in top)            # → into the prompt

Query transformation

☺ Like you’re 10: Sometimes the problem isn’t the library — it’s how you asked. “Does it cover that?” is too vague to find anything. So before searching, you fix up the question: spell it out, ask it a few different ways, or even guess what a perfect answer would look like and go hunting for cards like that.

Everything so far assumed the query is fine and the retriever is the weak link. Often it’s the opposite: a terse, ambiguous, or context-dependent question that no retriever can serve well. Query transformation uses a cheap LLM call to reshape the query before it hits the index. The main moves:

# QUERY TRANSFORMATION before retrieval (neutral pseudocode)
clean   = llm(f"Rewrite as a standalone search query, using this chat:\n{history}\nUser: {query}")
variants = llm(f"Give 3 alternative phrasings of: {clean}")   # multi-query
hyde    = llm(f"Write a short passage that would answer: {clean}")  # HyDE draft

results = []
for q in [clean] + variants:
    results.append(index.hybrid_search(q, k=30))
results.append(index.vector_search(embed(hyde), k=30))         # search by hypothetical answer
shortlist = rrf(results)[:50]                                  # fuse everything, then rerank

These aren’t all-or-nothing — start with rewriting (cheap, high-impact for chat) and add multi-query or HyDE only if evals show retrieval is still missing answers. Which brings us to the part nobody wants to do.

Measuring retrieval separately from generation

☺ Like you’re 10: If the whole answer is wrong, you need to know whose fault it was: did the librarian bring the wrong cards, or did the writer mess up cards that were right? So you grade them separately — one score for “did the right card get pulled,” another for “did the answer stick to the cards.”

Here is the core discipline of this entire lesson. A RAG system has two stages that can each fail — retrieval and generation — and if you only ever look at the final answer (an “end-to-end” eval), a low score can’t tell you which stage broke. So you evaluate the retriever on its own. Build a small test set of (question → the chunk(s) that truly contain the answer), run your retriever, and score how well it found them:

Those three grade retrieval. Separately, grade generation with faithfulness (a.k.a. groundedness): given the chunks that were retrieved, does the answer actually follow from them, with no invented claims? A common approach is an LLM-as-judge that checks each sentence of the answer against the supplied context. Splitting the two lets you read failures precisely:

Retrieval scoreFaithfulness scoreDiagnosis → fix
LowRight chunk never retrieved → chunking, hybrid, rerank, query rewriting
HighLowGood chunks, bad answer → prompt, model, or context formatting
HighHighSystem is working — protect it with regression evals

This is exactly the split-the-pipeline philosophy from Evals, applied to retrieval — and it’s the only honest way to know whether your fancy reranker or HyDE trick actually helped, rather than just felt like it did. Measure retrieval, change one thing, measure again.

◆ Key idea

Optimize in order: recall@k first (did we even fetch the answer?), then MRR/nDCG (is it near the top?), then faithfulness (did the model use it honestly?). Chasing generation quality while recall is low is polishing a door with no house behind it.

🎬 At the AI Academy
🦊

Foxy: Users say the help bot can’t find the return policy for part SKU-4471-B. But our vector search is state of the art! What gives?

🐿️

Nutty the Squirrel: That’s the trap — embeddings shrug at exact codes. Let me add BM25 keyword search alongside the vectors and fuse the two lists with RRF. Now SKU-4471-B pops straight to the top of the keyword list even if the vectors ignore it.

🐢

Timmy the Turtle: Before we ship, prove it. I built a set of 40 real questions with the exact chunk that answers each. Naive top-k had recall@10 of 0.62. Add hybrid + a cross-encoder rerank and… 0.91. That’s a real fix, not a vibe.

🐘

Ellie the Elephant: And the return question is one message deep — “does it cover that?” means nothing alone. Let me rewrite it from the chat into a standalone query before it ever hits the index.

🐿️

Nutty the Squirrel: Hybrid to catch the code, rerank for precision, rewrite for context, and Timmy’s scores to keep us honest. That’s retrieval engineering — not just “top-k and hope.”

Pitfalls

☺ Like you’re 10: Three classic ways to trip: only grading the final answer so you never learn who messed up; letting the card catalog go stale so it swears yesterday’s rules are today’s; and cutting cards so big or so tiny that the good part is either buried or torn in half.

Even with every upgrade above, the same few mistakes sink real systems:

⚠ Untrusted by default

A retrieved chunk is data, not a command. If your corpus includes anything users or outsiders can write to (web pages, tickets, shared drives), assume some chunk contains an injection attempt and design so retrieved text can never silently change what the agent does. Guardrails covers the defenses.

Retrieval engineering also doesn’t live in a vacuum: what you retrieve becomes the model’s context, so how you order, trim, and format those chunks in the prompt is its own craft — see Context Engineering. Retrieval decides what the model sees; context engineering decides how it sees it.

◆ In practice you’ll reach for…

Vector stores: pgvector (Postgres), Pinecone, Weaviate, Qdrant, Milvus, or Chroma. Keyword/hybrid: Elasticsearch/OpenSearch (BM25) fused with vectors via reciprocal-rank fusion. Rerankers: Cohere Rerank or open cross-encoders (e.g. bge-reranker). Frameworks that wire it together: LlamaIndex and Haystack. Treat specifics as current-at-writing.

🦫 Benny’s workshop · 5 min

Take one real query your system handles and inspect its top-10 results two ways. First, pure vector (semantic) search. Then, add a BM25 keyword search over the same corpus and eyeball the two lists side by side. Find one query with an exact term — a code, a name, an acronym — where the keyword list surfaces the right chunk that the vector list buried. That single example is the case for hybrid search, and you found it by hand in five minutes.

🐢 Timmy’s checkpoint

(1) Why does naive top-k semantic search miss exact matches like a part number, and how does hybrid search fix it? (2) What does a cross-encoder reranker do that your embedding model can’t — and why can you only run it on a shortlist? (3) Name two query-transformation techniques and what each is for. (4) You have a low end-to-end score. Which retrieval metric do you check first, and what would a high recall@k but low faithfulness tell you about where the bug is?

Check your answers
  1. Naive top-k vs. hybrid: Semantic embeddings capture meaning, so an exact token like a part number gets blurred and the retriever shrugs at it. Hybrid search runs a BM25 keyword search alongside the vector search and fuses the two ranked lists with RRF, so an exact match such as SKU-4471-B pops to the top of the keyword list even when the vectors ignore it.
  2. Cross-encoder reranker: Your embeddings are a bi-encoder that embeds query and document separately, so the two never see each other; a cross-encoder feeds the query and one candidate together into a model and outputs a single relevance score for that exact pair, catching subtle relevance a bi-encoder misses. It is far more expensive per query–doc pair, so you can only run it on the ~50-candidate shortlist, never across the whole corpus.
  3. Two query-transformation techniques: Query rewriting turns a messy, context-dependent question into a clean standalone query, resolving pronouns from chat history (essential in multi-turn chat). HyDE asks an LLM to draft a hypothetical answer and searches with that embedding, since answers look more like documents than questions do. (Query expansion and multi-query also qualify.)
  4. Which metric first: Check recall@k first — if the answer chunk was never retrieved, no model can save you, so it is the make-or-break number to tune first. High recall@k but low faithfulness means the right chunks were retrieved but the answer doesn't follow from them, so the bug is in generation (prompt, model, or context formatting), not retrieval.