Module 09 — Advanced RAG & Knowledge Systems¶
Time: 7–10 days · Depends on: 07 Tools & RAG · Pairs with: 08 MCP if retrieval is exposed as a server · Next: Cost optimization
Learning objectives¶
By the end of this module you will be able to:
- Diagnose why naive top-k dense retrieval fails on real corpora
- Build hybrid retrieval (BM25 + dense) fused with Reciprocal Rank Fusion (RRF)
- Apply cross-encoder reranking after a cheap first-stage retrieve
- Design hierarchical indices (doc → section → span) and agentic multi-step retrieval
- Evaluate retrieval and generation separately (Hit@k, MRR, faithfulness, context precision)
Why this matters (CS engineer)¶
Ops bot answers “what does ERR_INV_88421 mean?” with a confident essay about inventory philosophy. The gold runbook title is ERR_INV_88421 — never retrieved. Dense-only search mapped the question to “inventory errors” prose and missed the rare token. Support escalates. Team “fixes quality” by switching to a larger generator. Bill goes up. Hit@5 stays flat. The crime scene was retrieval, not eloquence.
Basic RAG is a vector nearest-neighbor lookup plus a prompt. Production RAG is closer to a search system: inverted indices, multi-stage ranking, query understanding, freshness, and offline metrics.
If you treat embeddings as magic:
- SKU codes, error IDs, and proper nouns miss because dense models blur rare tokens
- Multi-hop questions (“compare policy A with the exception in B”) need multiple retrieves
- You optimize the LLM when the gold document never entered the context
Your job is the information path: query → candidates → ranking → packing → generation → attribution. Generation quality is capped by what that path delivers.
Mental model¶
flowchart LR
Q[User question] --> QU[Query understanding]
QU --> D[Dense top-N]
QU --> S[Sparse / BM25 top-N]
D --> F[RRF fusion]
S --> F
F --> R[Rerank top-k]
R --> P[Pack + cite]
P --> LLM[Generator]
LLM --> A[Answer + sources]
QU -.->|multi-hop / agentic| Q2[Next sub-query]
Q2 --> D
First stage optimizes recall (get the right docs in a shortlist).
Second stage optimizes precision (put the best spans in the window).
Generation should only compose what retrieval already supports.
Intuition lock
Sticky picture: Hybrid search is keyword cop + semantic cop on the same case — one chases exact IDs, the other chases paraphrase. RRF fuses rankings, not scores (you don’t average Fahrenheit and Celsius). Agentic RAG is a detective with a step budget, not an infinite coffee tab.
Kill this idea: “Better embeddings (or a bigger LLM) fix all RAG failures.” → Replace with: Diagnose the path — sparse miss, bad chunk, packing drop, multi-hop need, or generator ignoring context — and measure retrieval separately from generation.
Core tutorial¶
1. Failure modes of basic RAG (start here)¶
| Symptom | Likely cause | Direction |
|---|---|---|
| Misses keyword SKUs / IDs | Dense-only; rare tokens | Hybrid BM25 + dense |
| Right doc, wrong span | Chunks too big or overlapping poorly | Smaller / structure-aware chunks + rerank |
| Multi-hop fails | One-shot query | Decomposition / agentic loop |
| Contradictory sources | No time/version filter | Metadata filters + conflict-aware prompt |
| Stale answers | Index drift | Freshness TTL, re-embed policy |
| Fluent wrong answer | No faithfulness gate | Cite + verify against context |
Naive pipeline from Module 07:
That is a good lab baseline and a bad production default.
Explainer
Embeddings recap (from Module 07): a dense retriever embeds the query and each chunk independently (bi-encoder) so you can precompute document vectors and search with nearest-neighbor. Nearby vectors mean semantic closeness, not shared keywords. BM25 scores term match with IDF weighting. Product codes (INV-88421) and stack traces are high-IDF; dense models often under-weight them. Hybrid search is not “enterprise theater” — it is covering two different failure modes with two rankers.
A cross-encoder (rerank stage) reads query and passage together. It is slower and cannot precompute the whole corpus, which is why it only sees a shortlist.
2. Query understanding before you retrieve¶
Start with the raw query as your retrieval baseline and measure it with the eval harness from §10. Add rewriting, decomposition, expansion, or HyDE only when that measurement shows a retrieval problem the raw query can't solve — each of these techniques has a real cost: an exact identifier (order number, SKU, error code) can disappear during rewriting, expansion can hallucinate terms that pull in irrelevant documents, decomposition can drift from user intent, and every extra LLM call adds latency. Treat rewriting as a fix for a measured failure, not a default step.
Decompose multi-part questions — reach for this once a golden-set eval shows single-shot retrieval missing multi-hop questions:
import json
DECOMPOSE = """
Break the user question into independent search queries.
Return JSON only:
{{"queries": ["...", "..."], "needs_multi_hop": bool}}
Question: {q}
"""
def parse_queries(model_json: str) -> list[str]:
data = json.loads(model_json)
return list(data.get("queries") or [])
Other useful rewrites:
| Technique | Idea | When |
|---|---|---|
| HyDE | LLM writes a hypothetical answer; embed that | Vague questions, short queries |
| Step-back | Ask a more general question first | Policy / conceptual retrieval |
| Expand synonyms | Domain glossary expansion | Vertical jargon |
| Filter extract | Pull product=, date> from natural language |
Structured metadata exists |
Always log the rewritten queries. Eval failures often come from bad rewrites, not bad embeddings.
Think about it
Question: A user asks: “Did the 2024 refund policy change the 30-day window that applied to enterprise SKUs last year?” How many retrievals do you need, and what goes wrong if you only retrieve once?
Reveal a strong answer
At least two conceptual hops: (1) 2024 refund policy text for the new window, (2) prior enterprise SKU policy / 30-day rule. A single embedding of the full sentence may land near one policy doc and miss the other. Decomposition into “2024 refund policy window” + “enterprise SKU return window 2023” (plus a filter on product tier if available) raises the chance both sources enter the context so the model can *compare* rather than invent.3. Hybrid search: dense + sparse¶
Dense path: embedding model → ANN index (FAISS, Qdrant, Pinecone, pgvector).
Sparse path: BM25 / Elasticsearch / OpenSearch / sparse vectors (SPLADE-style).
Sketch (IDs only — swap in real scorers):
def dense_top(query: str, n: int = 50) -> list[str]:
... # ANN over embeddings
def bm25_top(query: str, n: int = 50) -> list[str]:
... # inverted index
def hybrid_candidates(query: str, n: int = 50) -> list[str]:
return rrf([dense_top(query, n), bm25_top(query, n)])
This repo ships RRF in src.rag:
from src.rag import rrf
fused = rrf(
[
["docA", "docB", "docC"], # dense ranks
["docC", "docA", "docD"], # bm25 ranks
],
k=60,
)
# docs that rank well in *either* list rise; agreement boosts further
4. Reciprocal Rank Fusion (why not just average scores?)¶
Different retrievers produce incomparable scores (cosine vs BM25). RRF ignores raw scores and uses ranks:
[ \mathrm{RRF}(d) = \sum_{r \in R} \frac{1}{k + \mathrm{rank}_r(d)} ]
- (k) (commonly 60) damps the top ranks so a #1 on one list does not dominate forever
- Missing from a list ⇒ that list contributes 0
- No score calibration needed
def rrf(rank_lists: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
for ranks in rank_lists:
for i, doc_id in enumerate(ranks):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + i + 1)
return [d for d, _ in sorted(scores.items(), key=lambda x: x[1], reverse=True)]
Weighted RRF (multiply a list’s contribution by (w)) is fine once you have offline Hit@k data. Do not invent weights without a labeled set.
Explainer
Why ranks, not scores? Cosine 0.82 and BM25 12.4 are not comparable — calibrating them is a research project. Rank position is comparable: “this doc was #3 for dense and #1 for BM25.” RRF is a cheap agreement vote. If you min-max both score lists into ([0,1]) without offline labels, you are inventing a fusion that only looks scientific.
5. Reranking (second stage)¶
Cross-encoders score (query, passage) jointly. They are too slow for millions of docs, so run them on a small first-stage shortlist rather than the full corpus.
There's no universal right size for N. Cross-encoders are normally applied to a relatively small first-stage candidate set — commonly tens to hundreds of passages — sized by your p95 latency budget and the cross-encoder's own speed (a small local model tolerates a larger shortlist than a hosted rerank API call). Measure rerank latency at your actual shortlist size before picking a default; don't copy a number from a blog post that ran on different hardware and a different model.
def rerank(query: str, passages: list[tuple[str, str]], top_k: int = 5):
"""passages: list of (id, text). score_fn is a cross-encoder or API."""
scored = [(pid, score_fn(query, text)) for pid, text in passages]
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
Options: sentence-transformers cross-encoders, Cohere/Jina/Voyage rerank APIs, or a small local model. Measure latency budget: rerank should fit inside your p95, not only the happy path.
Explainer
Bi-encoders (dense retrieval) embed query and doc independently so you can precompute doc vectors. Cross-encoders see both texts at once — higher quality, no ANN precompute. Classic IR cascade: cheap broad recall → expensive precise ranking. Skipping the cascade either explodes cost or tanks quality.
6. Hierarchical RAG¶
Index at multiple granularities:
- Doc-level summaries — route which documents matter
- Section-level chunks — main context for the LLM
- Sentence / span — precise citations and table cells
query → retrieve summaries → open top documents
→ retrieve fine chunks *only inside* those docs
→ optional span extract for citations
Benefits:
- Less noise (global top-k from a huge corpus mixes unrelated domains)
- Better parent context (section headers survive)
- Cheaper fine retrieval when constrained to a doc set
Implementation tip: store parent_id / doc_id metadata on every chunk; never drop it in the vector payload.
7. Graph-oriented retrieval (when relationships matter)¶
Use entity/graph structure when questions are about edges, not bags of text:
- “Who owns service X and what depends on it?”
- “Which tickets share root cause entity Y?”
Pattern:
- Entity-link the query (
service:X) - Traverse 1–2 hops in a graph or join table
- Pull text chunks for the resulting node set
- Generate with those chunks
Start with an entity linking table + SQL/Cypher before a full GraphRAG product. Graphs help structure; vectors still help language.
8. Agentic RAG¶
When one retrieve is not enough, wrap retrieval in a bounded loop:
while not done and steps < limit:
plan next info need
retrieve / tool call
critique: is evidence sufficient?
answer or continue
def agentic_answer(question: str, retrieve, llm, max_steps: int = 4) -> str:
notes: list[str] = []
for step in range(max_steps):
plan = llm(
f"Goal: {question}\nNotes:\n{notes}\n"
"Return JSON: {\"action\":\"search|answer\","
"\"query\":str|null,\"draft\":str|null}"
)
# parse plan ...
if action == "answer":
return draft
hits = retrieve(query, k=5)
notes.append(f"Q: {query}\n" + "\n".join(hits))
return llm(f"Answer with available notes only:\n{notes}\nQ: {question}")
Hard rules (same spirit as Module 11 agents):
- Cap steps and total retrieved tokens
- Log every query + hit IDs for offline eval
- Prefer “I don’t know” over another expensive hop when notes are empty
Agentic RAG multiplies cost. Gate it: only when a cheap single-shot retrieve scores low confidence or the query is classified multi-hop.
Raw dense-retrieval similarity scores are not calibrated confidence — a 0.72 cosine score doesn't mean "72% likely relevant," and the threshold that separates good from bad retrieval shifts per embedding model, index, and domain. Before wiring a similarity score into the escalation gate above, validate the threshold against a representative labeled evaluation set (queries with known-good/known-bad retrievals); skip agentic escalation only once that threshold rule is measured, not assumed.
Think about it
Question: Your agentic RAG loop has max_steps=8 and no memory of prior queries. On step 3–7 it re-embeds the same paraphrase of the original question and re-pulls the same empty shortlist. What two controls stop this class of burn?
Reveal a strong answer
(1) **Repeated-query abort** (normalize query text / embedding near-duplicates) so thrash ends after 1–2 identical retrieves. (2) **Early exit on empty evidence** — if notes stay empty, answer “I don’t know” or escalate instead of spending remaining steps. Bonus: cache retrieve(query)→ids, lower max_steps for single-fact classifiers, and log intermediate queries so offline eval can see the loop.9. Packing context for the generator¶
Retrieval quality dies in packing if you:
- Dump 20 near-duplicate chunks
- Exceed the window so system instructions get truncated
- Omit source IDs the model cannot cite
Good packing:
- Deduplicate near-identical text (hash / embedding similarity)
- Enforce a token budget (Module 05) — e.g. 2–4k tokens of evidence
- Preserve structure: title, section, chunk id
- Instruct: answer only from sources; cite ids; refuse if insufficient
def pack(chunks: list[tuple[str, str]], budget_chars: int = 12_000) -> str:
out, used = [], 0
for cid, text in chunks:
block = f"[{cid}]\n{text}\n"
if used + len(block) > budget_chars:
break
out.append(block)
used += len(block)
return "\n".join(out)
10. Evaluation: measure the path, not the vibes¶
Split metrics:
| Layer | Metric | Meaning |
|---|---|---|
| Retrieval | Hit@k / Recall@k | Gold doc id appears in top-k? |
| Retrieval | MRR | (1/\mathrm{rank}) of first relevant |
| Retrieval | nDCG@k | Graded relevance ranking quality |
| Context | Context precision | Fraction of packed chunks that are useful |
| Generation | Faithfulness | Claims supported by provided context? |
| Generation | Answer relevance | On-topic vs the question? |
def hit_at_k(retrieved_ids: list[str], gold_ids: set[str], k: int) -> float:
return float(bool(gold_ids & set(retrieved_ids[:k])))
def mrr(retrieved_ids: list[str], gold_ids: set[str]) -> float:
for i, doc_id in enumerate(retrieved_ids, start=1):
if doc_id in gold_ids:
return 1.0 / i
return 0.0
Build a labeled set: (question, must_have_doc_ids, optional gold_answer).
Run retrieval eval without calling the LLM when iterating on hybrid/RRF/rerank.
Use LLM-as-judge only for faithfulness/relevance with a fixed rubric (Module 04), and spot-check humans.
Ecosystem: RAGAS, promptfoo, DeepEval, Langfuse experiments.
Think about it
Question: Hit@5 improved from 0.55 → 0.78 after hybrid+RRF, but user-rated answer quality barely moved. What might still be broken?
Reveal a strong answer
Retrieval now finds the right docs, but generation may still fail: wrong span inside the doc (need rerank / smaller chunks), packing drops the gold chunk (budget / dedupe bug), prompt does not force citation grounding, or the gold answer requires multi-hop synthesis the single-shot prompt cannot do. Also check faithfulness: the model may ignore context. Fix by measuring context precision and faithfulness separately, then inspect a few failure traces end-to-end.11. Multimodal and document structure notes¶
- PDFs: use layout-aware parsers; keep tables as tables, not garbled line soup
- Images: caption-then-embed or vision embeddings; store modality in metadata
- Code: chunk by symbol / AST, not fixed 500-char windows
- Never mix incompatible embedding spaces without an explicit routing plan
Failure modes (advanced RAG)¶
| Symptom | Likely root cause | Fix |
|---|---|---|
| Hybrid worse than dense alone | Bad fusion / noisy BM25 corpus | Tune N; filter stopwordy fields; weight lists from eval |
| Reranker slow / timeouts | Scoring 200+ passages | Cap shortlist; batch; smaller cross-encoder |
| Agentic loop burns $ | No step budget / thrashing queries | max_steps, repeated-query abort, cache |
| High Hit@k, low faithfulness | Generator ignores context | Stronger cite prompt; post-hoc claim check |
| Citations hallucinated | Free-form ids | Constrain to provided id set; validate like TinyRAG.validate_citations |
| Index stale after deploys | No ingestion versioning | Content hash, re-embed changed docs only |
Lab¶
Goal: Prove hybrid + fusion beats dense-only on a small labeled set.
- Take 5–10 of your own notes / READMEs (Module 07 corpus is fine).
- Write 20 questions with
must_havechunk or doc ids (include 5 keyword/ID questions and 5 multi-hop). - Implement or mock:
- dense ranks
- keyword/BM25 ranks
rrffromsrc.rag- Report Hit@5 and MRR for dense-only vs hybrid.
- Optional: add a tiny rerank (even a lexical overlap score) and show delta.
- For 5 multi-hop items, run a 2-step decompose → retrieve → answer; log intermediate queries.
Stretch: hierarchical parent filter — retrieve doc summaries first, then only child chunks of top docs.
Quizzes¶
Quiz · +25 XP
Why is Reciprocal Rank Fusion preferred over averaging dense cosine scores with BM25 scores?
Quiz · +25 XP
You change only the embedding model. Which metric should move first if the change is good?
Quiz · +25 XP
When is agentic (multi-step) RAG the wrong default?
Open source materials¶
| Resource | Use it for |
|---|---|
| mlabonne/llm-course | RAG engineer path context |
| RAGAS | Faithfulness / context metrics |
| FAISS · Qdrant · Chroma | Vector indices |
| huggingface/agents-course | Agentic retrieval patterns |
| Sentence-transformers docs | Bi-encoders + cross-encoders |
Course src/rag.py |
rrf, TinyRAG teaching baseline |
Also: Curated resources → RAG & embeddings.
Checkpoint¶
- You can explain hybrid search + RRF in one clear paragraph
- You rerank or fuse — not only single-vector top-k
- You measure retrieval (Hit@k / MRR) separately from generation
- Multi-hop path has a step budget and logged intermediate queries
- Citations are constrained to retrieved ids
Mark complete when you have run a Hit@k comparison (dense vs hybrid) on a small labeled set and can defend your fusion/rerank choices.
Exercise¶
- Catalog: EX-09 — Hybrid retrieval
- Prove: Hybrid Hit@5 / MRR is reported against dense-only on a labeled slice.
- Test:
pytest tests/test_rag.py -v