This module installs the production discipline for RAG: every upgrade — hybrid fusion, reranking, query rewriting, ACL filters, incremental sync — is measured against your labeled test set, and every failure is attributed to ingestion, retrieval, grounding or generation before anything is touched.
The support assistant that scored well on Neha's fifty-query test set is quietly failing on questions the test set never imagined.
A user asked "error E4022 on ingest job" and got a generic non-answer — even though a runbook titled Ingest error codes sits right there in the corpus with E4022 in a heading.
Karthik pulls it: the gold chunk isn't in the top twenty. The embedding model, brilliant at matching meaning, has no privileged representation of a rare token like E4022; it maps it into a crowded region and moves on.
"You measured it once and declared victory. What's your process when it breaks in a way your test set didn't cover?" That question is this module.
Neha runs the fifty queries through BM25-only, dense-only, and hybrid — and the identifier queries that dense retrieval fumbled are exactly where lexical search is unbeatable.
What Priya is really demanding is a repeatable way to attribute each failure to the component that caused it, before anyone touches anything.
Module 3 left you with something rare: a RAG system with a labeled retrieval test set and metrics wired in from the first commit. This module spends that investment. Every technique here — hybrid fusion, cross-encoder reranking, query rewriting, metadata scoping, incremental ingestion — is an intervention whose value you will measure against your baseline, not assert. That is the difference between this track and the tutorial ecosystem: production RAG is not a bag of tricks, it is a sequence of evidence-backed upgrades to a pipeline you can already score.
The 2026 production consensus is stable on where the leverage lives: hybrid search plus reranking are the two biggest retrieval-quality levers available without touching your data, and component-level failure diagnosis is what separates teams that fix their RAG systems from teams that re-tune prompts at random. Employers agree — "reranking" and enterprise retrieval patterns (knowledge bases over OpenSearch and pgvector with ACL scoping) now appear verbatim in postings for senior DS/ML roles.
Every retrieval upgrade in this module ends the same way: run the labeled test set, compare recall@k, MRR, and faithfulness before and after, keep the change only if the numbers moved. Upgrades you cannot measure are upgrades you cannot defend.
Embedding-based retrieval earns its place by matching meaning: "how do I cancel my subscription" finds a document titled "Ending your plan." But experienced practitioners hit its failure modes within the first week of real traffic, and they are systematic, not random:
E4022; it's a rare token mapped into a crowded region of embedding space. BM25 treats it as what it is — a rare, high-IDF term that should dominate the match.Lexical search has the inverse profile: brittle to paraphrase, blind to synonyms, unbeatable on exact terms. The production answer is not to choose — it is to run both and fuse.
RRF is the default fusion method because it needs no score normalization — BM25 scores and cosine similarities live on incomparable scales, but ranks are always comparable. Each document's fused score is the sum, across retrievers, of 1 / (k + rank), with k = 60 the conventional damping constant:
| Document | BM25 rank | Vector rank | RRF = 1/(60+r_bm25) + 1/(60+r_vec) | Fused rank |
|---|---|---|---|---|
runbook-ingest-errors.md#c3 | 1 | 9 | 1/61 + 1/69 = 0.0309 | 1 |
api-reference.md#c12 | 4 | 2 | 1/64 + 1/62 = 0.0318 → 0.0318 | — see note |
faq-billing.md#c7 | — (not in top 50) | 1 | 1/61 = 0.0164 | 4 |
oncall-guide.md#c2 | 2 | 15 | 1/62 + 1/75 = 0.0295 | 3 |
(api-reference.md#c12, ranked well by both retrievers, actually tops the list at 0.0318 — the ordering insight of RRF: agreement between retrievers beats a solo #1 from either.) A document that only one retriever found still survives with a diminished score, which is exactly the behavior you want: hybrid retrieval is a recall-widening move, and the reranker downstream will handle precision.
In Qdrant, hybrid retrieval with RRF is a single query — dense and sparse (BM25-style) vectors stored side by side:
from qdrant_client import QdrantClient, models
results = client.query_points(
collection_name="kb_chunks",
prefetch=[
models.Prefetch(query=models.SparseVector(**bm25_query), using="sparse", limit=50),
models.Prefetch(query=dense_query_vector, using="dense", limit=50),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=20, # feed the reranker, not the LLM
)
OpenSearch offers the equivalent via a hybrid query clause with a normalization processor in the search pipeline. Either way, the config decision that matters is the per-retriever limit: fetch generously (50 per arm is a sane default) because fusion and reranking exist precisely to sift a wide candidate pool.
Run your labeled retrieval set through BM25-only, dense-only, and hybrid, and keep the three-column recall@10 table. On corpora heavy with IDs, SKUs, or internal jargon, BM25-only sometimes embarrasses dense-only — knowing your corpus's profile tells you where future retrieval bugs will come from.
Your first-stage retriever — even hybrid — is a bi-encoder system: query and documents are embedded independently, and relevance is approximated by vector proximity computed with no interaction between the query's tokens and the document's tokens. That independence is what makes it fast enough to scan millions of chunks, and it is also why it saturates: "compare these two texts without reading them together" has a ceiling.
A cross-encoder removes the independence. It takes the pair — query and candidate concatenated — through a transformer with full cross-attention and outputs a relevance score. It reads them together. The cost is that it must run once per candidate pair, so it cannot search a corpus; it can only re-order a shortlist. Hence the canonical production pattern:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # open weights, runs on CPU/free GPU
pairs = [(query, hit.payload["text"]) for hit in candidates] # 20 pairs
scores = reranker.predict(pairs)
top5 = [c for c, _ in sorted(zip(candidates, scores), key=lambda x: -x[1])[:5]]
Three engineering notes that tutorials skip:
Scenario questions in this domain hinge on stage attribution: "recall@20 is high, answer quality is poor" points at reranking or generation, never at adding more first-stage candidates. Know which metric isolates which stage.
Everything so far assumed the query is worth retrieving for. Production queries frequently aren't — and no amount of fusion or reranking rescues a retrieval executed against the wrong question. Three transforms cover most of the gap, all implemented as a cheap LLM call before retrieval:
HyDE — generating a hypothetical answer document and embedding that for retrieval — belongs in your vocabulary as a fallback for short, underspecified queries where query-document vocabulary mismatch is extreme. Treat it as an experiment to run against your test set, not a default: it adds an LLM call of latency to every retrieval and helps unevenly across corpora.
Every pre-retrieval LLM transform is a new failure surface — a bad rewrite is a self-inflicted retrieval-miss. Log the transformed query in your traces alongside the original, or your failure diagnosis will attribute rewrite bugs to the retriever and you'll tune the wrong component.
Enterprise retrieval questions are rarely "find similar text." They are "find similar text from the current policy year, in documents this user is allowed to read, preferring the runbook over the Slack export." That's metadata, and it has to be designed at ingestion, not bolted on at query time.
A production chunk payload looks like this:
{
"text": "...chunk content...",
"doc_id": "runbook-ingest",
"parent_span": [1240, 2180],
"source_system": "confluence",
"doc_type": "runbook",
"updated_at": "2026-05-14",
"version": "v3",
"acl_groups": ["data-platform", "sre"],
"language": "en"
}
Which enables the retrieval patterns that generic tutorials never reach:
doc_type or source_system when the query classifier detects intent ("how do I…" → runbooks and how-tos, not meeting notes). Recency filters (updated_at > …) beat hoping the embedding notices a document is stale.acl_groups as a pre-filter inside the vector search, so unauthorized chunks are never candidates. Filtering after retrieval — or worse, instructing the LLM "don't reveal documents the user can't access" — is a security incident with extra steps: the content has already entered the candidate set and, in the prompt-level version, the context window. Modern vector DBs execute filtered HNSW search efficiently; there is no performance excuse for post-filtering. And ACL sync is an ingestion obligation — when a document's permissions change at the source, the index payload must update on the same sync cycle as the content.For genuinely multi-hop questions — "which dashboards break if we deprecate the orders_v2 table?" — flat chunk retrieval hits a structural wall: no single chunk contains the answer, and the reasoning path runs across entities. That is the GraphRAG motivation: extract entities and relations at ingestion into a property graph, retrieve by graph traversal plus vector search, and answer from the combined evidence. It is a significant ingestion investment; adopt it when your failure taxonomy (below) shows a persistent cluster of multi-hop failures that retrieval tuning didn't dent — not because it's interesting.
Access control belongs in the retrieval filter, not the prompt. If a chunk the user may not read can appear in the candidate list, the system is already broken — no generation-side instruction repairs it.
Every RAG demo ingests once and retrieves forever. Every production corpus is alive — documents are edited, superseded, deleted, re-permissioned — and an index that drifts from its sources produces a uniquely corrosive failure: confident answers, correctly grounded, in stale truth. Ingestion is a data engineering problem, and your pipeline instincts transfer directly.
hash(doc_id, version, chunk_index) — so a changed document upserts its new chunks and tombstones the old ones atomically.Budget note for self-funded learners — incremental sync is also a cost story: re-embedding only changed chunks keeps a living corpus's embedding spend near zero, and every pipeline in this track's labs runs on open embedding models precisely so reindex experiments cost nothing but compute time on a free-tier GPU.
A RAG system that answers badly is not one problem — it is four possible problems wearing the same symptom, and the fix for each is useless against the others. The production skill, rehearsed in lab L7, is attributing each failure to a component from the trace before touching anything:
| Failure class | Definition | Trace signature | Fix domain |
|---|---|---|---|
| Ingestion-miss | The evidence isn't in the index at all | No chunk in the corpus contains the answer; source doc missing, stale, or chunked so the fact straddles a boundary | Sync pipeline, chunking, parsing |
| Retrieval-miss | Evidence is indexed but absent from top-k | Manual corpus search finds the gold chunk; retrieved set doesn't contain it | Hybrid config, embeddings, query rewriting, reranker |
| Grounding-miss | Evidence is in the context; the answer ignores or contradicts it | Gold chunk visible in the prompt; answer fabricates or contradicts it | Generation prompt, context ordering, chunk noise, model choice |
| Generation-miss | Evidence present and used; the answer still fails the task | Answer is grounded but wrong-format, incomplete, off-tone, or misses a sub-question | Prompt spec, output schema, decomposition |
The triage procedure per failing trace is a fixed interrogation order — cheap checks first:
Run this over a batch of failures and the counts become a Pareto chart that dictates your roadmap. A real-shaped clinic outcome from a 60-failure triage:
| Attribution | Count | Top pattern | Chosen fix |
|---|---|---|---|
| Retrieval-miss | 26 | Acronym-heavy queries missing jargon docs | Glossary-aware query rewriting + hybrid (was dense-only) |
| Ingestion-miss | 14 | Tables in PDFs flattened to noise at parse time | Layout-aware parser for tabular docs |
| Grounding-miss | 11 | Gold chunk ranked 7th of 8, ignored below distractors | Rerank to top-5; drop distractor chunks |
| Generation-miss | 9 | Multi-part questions answered partially | Query decomposition + answer checklist in prompt |
Note what the table does: it converts "the bot is unreliable" into two engineering tickets that address two-thirds of all failures. It also connects forward — the attribution logic runs on exactly the component metrics (context recall for retrieval, faithfulness for grounding) that Modules 5 and 6 will let you compute automatically over every trace instead of by hand.
Keep every triaged trace. A failure with a component attribution is a labeled eval example with the diagnosis included — the failure clinic's output is the seed of Module 5's golden dataset, and teams that discard triage notes pay to rediscover the same failures quarterly.
"Faithfulness dropped after a chunking change — which component do you inspect first?" is the assessment's signature question shape. Chunking changes move what the retriever can return, so start at retrieval (is the gold evidence still intact in a single chunk, and still retrieved?) before touching the generator.
Your first 15 minutes Monday: take the last five "the bot got this wrong" complaints in your channel and run the four-step interrogation on each — is the answer in the corpus at all, is the gold chunk in top-k, did the answer use it, or is it a generation defect? Five attributed failures turn "it's unreliable" into named tickets faster than any dashboard, which is exactly what Priya asked Neha for.
Check yourself: A user's query "error E4022 on ingest job" returns a generic non-answer, even though a runbook with E4022 in a heading is in the corpus. Before you retrain, re-chunk, or re-prompt, what do you determine first — and what is the likely fix?
The playbook library for this module packages the reusable assets: the RRF fusion config recipes for Qdrant and OpenSearch, the retrieve-20→rerank→top-5 implementation card with stage-wise evaluation queries, the query-rewrite prompt with glossary injection, the chunk-payload metadata schema with ACL pre-filter patterns, and the ingestion-sync checklist (upsert keys, deletion reconciliation, blue-green reindex procedure). Do this at work this week → pb-24 Hybrid Search, Reranking, and Query Understanding (the fix for the E4022 retrieval-miss), pb-25 Beyond Flat Vector Search: Metadata Architecture, Parent-Document Retrieval, and GraphRAG (ACL pre-filters and parent-document retrieval), and pb-26 Production RAG Health: Failure-Mode Triage and Ingestion at Scale (the component triage Priya demanded, plus living-corpus sync). Two graded labs anchor the module: L6 upgrades your Module 3 pipeline to hybrid + reranked retrieval and demands the before/after evidence table, and L7 is the failure clinic — 60 traced failures on a deliberately broken app, graded on attribution accuracy and metric recovery after your fixes. Do L6 first; the clinic assumes you know what a healthy retrieval stage looks like.