AI: One Course · Lesson 11 of 20
Evaluating and Improving RAG
recall@k, MRR and nDCG computed by hand, faithfulness, chunk-size experiments, and advanced RAG patterns.
- Advanced
- 26 min read
- 3 objectives
Before this lessonLesson 10: RAG: Retrieval-Augmented Generation
What you will learn
- Compute retrieval metrics
- Diagnose a bad RAG answer
- Apply advanced patterns like parent-child and GraphRAG
Your Progress
0 of 20 lessons 0%
- Lessons0 / 20
- Completed0
- Est. time left~ 9 hours
Create a free account to keep your progress on every device.
"It seems to work" is not a strategy. Teams that ship reliable RAG systems all share one habit: they measure. This lesson gives you the vocabulary and the tools: retrieval metrics you can compute in a few lines, a way to judge answer quality, a diagnosis flowchart for bad answers, and the advanced patterns (parent-child, GraphRAG, agentic RAG, caching) that senior engineers reach for after the basics work.
Step zero: build a golden dataset
Collect 50–200 realistic questions. For each, record the correct answer and which document chunk(s) contain it. Include tricky cases: paraphrased questions, multi-hop questions (needing two documents), questions with no answer in your data, and adversarial ones. Source them from real users, logs and support tickets, not just from your imagination. Every evaluation below runs against this set.
Retrieval metrics: did we fetch the right chunks?
Evaluate retrieval separately from generation. For each question you have a ranked list of retrieved chunk IDs and a set of truly relevant IDs:
- Hit rate / Recall@k: what fraction of the relevant chunks appear in the top k? The most important metric for RAG: if the answer is not retrieved, the model cannot use it.
- Precision@k: what fraction of the top k are relevant? Low precision means noise in the prompt.
- MRR (Mean Reciprocal Rank): average of
1 / rank of the first relevant result. Rewards putting the right answer near the top. - nDCG: rewards relevant results ranked high, with a smooth discount for lower positions; handles graded relevance.
import math
def recall_at_k(retrieved, relevant, k):
return len(set(retrieved[:k]) & relevant) / len(relevant)
def precision_at_k(retrieved, relevant, k):
return len(set(retrieved[:k]) & relevant) / k
def reciprocal_rank(retrieved, relevant):
for rank, doc in enumerate(retrieved, 1):
if doc in relevant:
return 1 / rank
return 0.0
def ndcg_at_k(retrieved, relevant, k):
dcg = sum(1 / math.log2(i + 2) for i, d in enumerate(retrieved[:k]) if d in relevant)
ideal = sum(1 / math.log2(i + 2) for i in range(min(len(relevant), k)))
return dcg / ideal
# Three test questions: what we retrieved (best first) vs what was truly relevant
tests = [
(["c1", "c7", "c3", "c9", "c2"], {"c1"}), # perfect: hit at rank 1
(["c4", "c8", "c2", "c5", "c6"], {"c2"}), # found, but at rank 3
(["c9", "c8", "c7", "c6", "c5"], {"c1", "c2"}), # missed completely
]
for name, fn in [("recall@3", lambda r, rel: recall_at_k(r, rel, 3)),
("precision@3", lambda r, rel: precision_at_k(r, rel, 3)),
("MRR", reciprocal_rank),
("nDCG@5", lambda r, rel: ndcg_at_k(r, rel, 5))]:
scores = [fn(r, rel) for r, rel in tests]
print(f"{name:<12} per question {[round(s, 2) for s in scores]} mean = {sum(scores)/len(scores):.2f}")recall@3 per question [1.0, 1.0, 0.0] mean = 0.67 precision@3 per question [0.33, 0.33, 0.0] mean = 0.22 MRR per question [1.0, 0.33, 0.0] mean = 0.44 nDCG@5 per question [1.0, 0.5, 0.0] mean = 0.50
Read the output like a doctor: recall@3 of 0.5 means half the time the good chunk is not even in the top three. That tells you to fix retrieval (chunking, hybrid search, reranking), and no amount of prompt tuning will help.
Comparing retrieval strategies, the honest way
The whole point of these metrics is A/B comparison. Run the same golden set through vector-only, BM25-only, hybrid, and hybrid+rerank, and compare recall@5 and MRR. Change one thing at a time (chunk size, k, embedding model, reranker) and keep a results table. Here is the shape of a chunk-size experiment on a toy problem:
import math, re
from collections import Counter
DOC = ("Refunds. Laptops can be returned within 30 days for a full refund. Opened software is not refundable. "
"Shipping. Shipping is free on orders over 50 dollars. Express shipping takes two days. "
"Warranty. Every laptop has a one year warranty. Batteries are covered for six months. "
"Support. Support is open Monday to Friday. Error ERR-4471 means the payment gateway timed out.")
QUESTIONS = [ # (question, phrase that must appear in a retrieved chunk to count as a hit)
("how many days to return a laptop", "30 days"),
("is shipping free", "free on orders"),
("battery warranty length", "six months"),
("what is ERR-4471", "ERR-4471"),
]
tok = lambda t: re.findall(r"[a-z0-9-]+", t.lower())
def make_chunks(size):
words = DOC.split()
return [" ".join(words[i:i + size]) for i in range(0, len(words), size)]
def score(q, chunk, chunks):
N = len(chunks); df = Counter(w for c in chunks for w in set(tok(c)))
tf = Counter(tok(chunk)); s = 0
for w in tok(q):
if w in tf:
s += math.log((N - df[w] + .5) / (df[w] + .5) + 1) * tf[w]
return s
for size in (6, 12, 24, 48):
chunks = make_chunks(size)
hits = 0
for q, needle in QUESTIONS:
best = max(chunks, key=lambda c: score(q, c, chunks))
hits += needle in best
avg_words = sum(len(c.split()) for c in chunks) / len(chunks)
print(f"chunk size {size:>2} words -> {len(chunks):>2} chunks, hit rate {hits}/{len(QUESTIONS)}")chunk size 6 words -> 10 chunks, hit rate 2/4 chunk size 12 words -> 5 chunks, hit rate 1/4 chunk size 24 words -> 3 chunks, hit rate 3/4 chunk size 48 words -> 2 chunks, hit rate 3/4
On this toy text the 6- and 12-word chunks slice answers away from their keywords, while 24 words and up worked. On real documents the curve usually rises and then falls again as chunks grow, because huge chunks dilute the signal. The exact best size is a property of your documents and questions, which is why you measure instead of copying someone's default.
Generation metrics: is the answer good?
- Faithfulness / groundedness: is every claim in the answer supported by the retrieved context? (Lesson 4.) The core anti-hallucination metric.
- Answer relevance: does it actually address the question?
- Correctness: does it match the reference answer?
- Context precision / recall: how much of the retrieved context was needed, and did it contain everything needed?
- Refusal accuracy: does it decline when the answer is not in the data, and only then?
Scoring these by hand does not scale, so teams use LLM-as-a-judge: a strong model reads (question, context, answer, reference) and returns scores against a rubric. It is imperfect (judges have biases: they favour longer answers and their own outputs), so calibrate it against human labels on a sample, and use pairwise comparison ("which answer is better?") where you can. Popular tooling: Ragas, DeepEval, TruLens, LangSmith, Braintrust and Arize Phoenix.
A diagnosis flowchart for a bad answer
Bad answer
|
+-- Was the right chunk in the index at all?
| NO -> ingestion bug: parsing, missing file, stale data
|
+-- Was it in the top-k retrieved?
| NO -> retrieval problem: chunking, hybrid/BM25, embedding model, filters, query rewriting
|
+-- Was it in the prompt (after reranking / trimming)?
| NO -> reranking or context-budget problem
|
+-- Did the model use it correctly?
NO -> generation problem: prompt, temperature, conflicting chunks, model too weakAdvanced RAG patterns
- Parent-child (small-to-big) retrieval: search over small chunks for precision, but return the surrounding parent section for context.
- Contextual chunks: prepend each chunk with a short LLM-written summary of where it sits in the document ("This is from the 2024 refund policy, section 3"), which improves both embedding and BM25 matching.
- Metadata and self-querying: let the LLM turn "invoices from March over 500 dollars" into filters.
- GraphRAG: extract entities and relationships into a knowledge graph, then answer "how is X connected to Y?" or summarise whole corpora, which flat chunk retrieval handles poorly.
- Agentic RAG: instead of one retrieval, an agent decides whether to search, what to search, inspects results, and searches again if unsatisfied. Great for multi-hop questions; costs more calls (see the agents lessons).
- Corrective / self-reflective RAG: grade the retrieved chunks; if poor, rewrite the query or fall back to web search; check the final answer against the sources.
- Semantic caching: if a new question is nearly identical (by embedding) to a previous one, return the cached answer, saving cost and latency.
- Multimodal RAG: index images, charts and tables (parse tables into structured text; use vision models for figures).
A practical improvement order
- 1. Build the golden set and a baseline number before touching anything.
- 2. Fix ingestion and parsing (garbage in, garbage out; check tables and PDFs).
- 3. Add hybrid search (BM25 + vectors).
- 4. Tune chunking, add overlap and metadata.
- 5. Add a reranker.
- 6. Improve the prompt and refusal behaviour.
- 7. Only then consider fine-tuning an embedding model, agentic loops or GraphRAG, since each adds cost and complexity.
# Write your solution here
