AI EngineeringZero to ProductionHome·About·Contact
Frontier Agent Capabilities · Part 5

RAG evaluation

A RAG answer can be wrong two ways — the retriever missed the doc, or the model ignored the doc it was handed. This lesson gives RAG its own evaluation: the four Ragas-style metrics (faithfulness, answer relevance, context precision/recall), how to build a ground-truth eval set, and how to eval-gate the whole pipeline in CI — modeled offline in plain Python.

⏱️ ~1.5 hours🧪 4 labs🎯 Beginner→Tech-lead

Learning objectives

  • Explain why a RAG system needs its own evaluation — retrieval and generation fail separately.
  • Define the four core metrics: faithfulness, answer relevance, context precision, context recall.
  • Build a RAG eval set with ground-truth questions, answers, and relevant chunk ids.
  • Model each metric offline with simple set/overlap math and an LLM-as-judge proxy.
  • Aggregate metrics into a scorecard and eval-gate a RAG system in CI.
  • Own the eval strategy as a lead: thresholds, regression gates, and avoiding overfitting.

1 · Why RAG needs its own evaluation essential

A RAG system has two moving parts that can fail independently: the retriever (did it find the right chunks?) and the generator (did the model answer correctly from those chunks?). A plain accuracy score can't tell them apart — a wrong answer might mean the retriever missed the doc, or the model ignored a doc it was handed. RAG evaluation splits the pipeline so you know which half to fix. This builds on the retrieval pipeline in Ch 3 · RAG and the general eval discipline in Ch 5 · Evaluation.

Two failure modes, two fixesIf retrieval is bad, no amount of prompt tuning helps — fix chunking/embeddings/top-k. If retrieval is good but the answer is wrong, fix the generation prompt or the model. One number can't point you at the right lever; four can.

2 · The four core metrics essential

Ragas-style evaluation scores each question on four axes. Two grade the generator and two grade the retriever:

MetricGradesQuestion it answersCatches
FaithfulnessgeneratorIs the answer grounded in the retrieved context?hallucination — claims not supported by any chunk
Answer relevancegeneratorDoes the answer actually address the question?evasive / off-topic / padded answers
Context precisionretrieverAre the retrieved chunks relevant?noisy retrieval — junk chunks diluting the context
Context recallretrieverDid we retrieve the chunk(s) we needed?misses — the answer-bearing doc was never fetched

Read them as a grid: precision/recall judge the retriever against ground-truth relevant chunks; faithfulness/relevance judge the answer against the context and the question. A healthy system needs all four high — a great answer built on lucky guessing (low recall) is fragile, and perfect retrieval wasted by a hallucinating model (low faithfulness) is unsafe.

3 · The RAG eval pipeline essential

Every eval sample is a triple — question + generated answer + retrieved contexts — plus ground-truth (the reference answer and the ids of the chunks that should have been retrieved). Each metric is a scorer; the per-question scores are aggregated, then compared to thresholds to produce a single pass/fail gate:

Q + answer + contexts one eval sample Per-metric scorers 4 metrics Aggregate scorecard mean per metric Pass / fail gate CI exit code
🗺️ How to read this diagram

This is the shape of every RAG evaluation run. Read it left to right — each box is a stage, and the whole pipeline turns one messy answer into a single yes/no CI decision.

  • Q + answer + contexts — one eval sample: the question you asked, the answer your RAG app produced, and the chunks it retrieved to produce it. Ground-truth (a reference answer and the ids of the chunks that should have been fetched) rides along too.
  • Per-metric scorers — four separate graders run on that sample: faithfulness and answer relevance grade the answer; context precision and recall grade the retrieval. Four numbers, not one, so you learn which half failed.
  • Aggregate scorecard — you run many samples and take the mean of each metric across the whole eval set. That's the table a human reads.
  • Pass / fail gate — compare each mean to a threshold; if all pass, CI is green, otherwise the build fails. The gate is literally an exit code.

In short: retrieval and generation are graded by different boxes, so a red score tells you exactly which half of the RAG system to go fix.

The scorers are the interesting part. The next sections model each one offline with plain Python so you can see exactly what the number means before trusting a framework to compute it.

4 · Intermediate — retrieval metrics as set overlap intermediate

Context precision and recall are, at heart, set arithmetic over chunk ids. If you know which chunk ids are truly relevant (ground-truth) and which the retriever returned, precision and recall fall straight out — no model needed. Precision = fraction of retrieved that are relevant; recall = fraction of relevant that were retrieved.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · context precision & recall via set overlap (runs)
retrieval_metrics.pydef context_precision(retrieved, relevant):
    if not retrieved:
        return 0.0
    hits = [c for c in retrieved if c in relevant]
    return round(len(hits) / len(retrieved), 2)

def context_recall(retrieved, relevant):
    if not relevant:
        return 1.0
    got = [c for c in relevant if c in retrieved]
    return round(len(got) / len(relevant), 2)

relevant  = {"doc7", "doc12"}
retrieved = ["doc7", "doc3", "doc9"]

print("precision:", context_precision(retrieved, relevant))
print("recall:   ", context_recall(retrieved, relevant))
precision: 0.33
recall:    0.5
▶ How this works

This block computes the two retrieval metrics using nothing but set membership — no model, no network. It compares the chunk ids the retriever returned against the ids you labeled as truly relevant (the ground-truth).

  1. relevant is a set of the chunk ids that actually contain the answer (you labeled these when building the eval set). retrieved is the ordered list the retriever returned this time.
  2. Precisionhits keeps the retrieved ids that are relevant, then divides by how many were retrieved. "Of what I fetched, how much was useful?" Low precision = noisy retrieval.
  3. Recall — counts how many of the relevant ids were actually retrieved, divided by how many relevant ids exist. "Of what I needed, how much did I find?" Low recall = the answer-bearing chunk was missed.

What the output means: precision: 0.33 (only 1 of 3 fetched chunks was relevant) and recall: 0.5 (found 1 of the 2 needed chunks) — a weak retriever that would starve even a perfect generator.

Try this: Add "doc12" to retrieved and re-run — recall jumps to 1.0 because you've now fetched both needed chunks, while precision improves too.

Precision 0.33 means only 1 of 3 retrieved chunks was relevant (noisy retrieval); recall 0.5 means we found 1 of the 2 chunks we needed (a miss). Both are low here — a retriever this bad would starve even a perfect generator. This is the same precision/recall you'd tune top-k and chunking against in Ch 3.

5 · Advanced — faithfulness as an LLM-as-judge proxy advanced

Faithfulness asks: is every claim in the answer supported by the retrieved context? In production this is scored by an LLM-as-judge — a second model reads the answer and the context and rules on each claim (Ch 5 covers judge design). We can model the shape of that judge offline with a token-overlap proxy: split the answer into claims, and count a claim as supported if most of its content words appear in the context.

Python · faithfulness proxy — claims supported by context (runs)
faithfulness.pyimport re

def tokens(text):
    return set(re.findall(r"[a-z0-9]+", text.lower()))

def faithfulness(answer, contexts):
    """Proxy: split the answer into claims (sentences); a claim is 'supported'
    if most of its content tokens appear somewhere in the retrieved context."""
    context_tokens = set()
    for c in contexts:
        context_tokens |= tokens(c)
    claims = [s.strip() for s in re.split(r"[.!?]", answer) if s.strip()]
    supported = 0
    for claim in claims:
        ct = tokens(claim)
        if not ct:
            continue
        overlap = len(ct & context_tokens) / len(ct)
        if overlap >= 0.6:
            supported += 1
    return round(supported / len(claims), 2) if claims else 0.0

contexts = ["The refund window is 30 days from delivery.",
            "Refunds are issued to the original payment method."]

grounded     = "Refunds go to the original payment method within 30 days."
hallucinated = "Refunds are issued as store credit only and expire after a year."

print("grounded answer:    ", faithfulness(grounded, contexts))
print("hallucinated answer:", faithfulness(hallucinated, contexts))
grounded answer:     1.0
hallucinated answer: 0.0
▶ How this works

This models the faithfulness metric — is the answer grounded in the retrieved context? — with a simple token-overlap stand-in for a real LLM judge. The idea: an answer is trustworthy only if its claims can be traced back to the context.

  1. tokens() lowercases text and pulls out the word/number tokens as a set, so we can do set math on words.
  2. We pool every context chunk's tokens into context_tokens — the universe of things the answer is allowed to say.
  3. The answer is split into claims (sentences). For each claim, overlap = len(ct & context_tokens) / len(ct) is the fraction of its words that appear in the context; ≥ 0.6 counts the claim as supported.
  4. Faithfulness is the fraction of claims supported — 1.0 if every claim is grounded, 0.0 if none.

What the output means: The grounded answer scores 1.0 (its words are all in the context); the hallucinated answer scores 0.0 ("store credit", "expire", "year" appear nowhere in the context).

Try this: This proxy is fooled by negation — it would happily "support" "refunds are not issued" because the words match. That blind spot is exactly why production uses a reasoning LLM-as-judge, not token overlap.

A token proxy is NOT a real judgeThis overlap trick illustrates the idea, but it's fooled by paraphrase and by negation ("refunds are not allowed" shares tokens with "refunds are allowed"). A real faithfulness scorer uses an LLM-as-judge that reasons about entailment. Use the proxy to understand the metric; use a judge to trust it in production.

Below is what the real thing looks like with the Ragas framework — it wires up the metrics and runs the judge calls for you. It does not run here:

Python · Ragas faithfulness (needs the ragas library)
ragas_eval.py# needs the ragas library:  pip install ragas
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import (faithfulness, answer_relevancy,
                           context_precision, context_recall)

data = Dataset.from_dict({
    "question":     ["When are refunds issued?"],
    "answer":       ["Refunds go to the original payment method within 30 days."],
    "contexts":     [["The refund window is 30 days from delivery.",
                      "Refunds are issued to the original payment method."]],
    "ground_truth": ["Refunds go to the original payment method within 30 days."],
})

result = evaluate(data, metrics=[faithfulness, answer_relevancy,
                                 context_precision, context_recall])
print(result)   # {'faithfulness': 1.0, 'answer_relevancy': 0.98, ...}

6 · Professional — building an eval set & the scorecard professional

An eval set is the foundation — metrics are meaningless without ground-truth. Each sample needs a realistic question, a reference answer, and the relevant chunk ids. Cover the distribution you actually serve (easy lookups, multi-hop, and known-hard edge cases), and keep it version-controlled so runs are comparable over time.

How to build a RAG eval set

  1. Sample real user questions (or write representative ones per topic).
  2. Write the ground-truth answer a human expert would give.
  3. Label which chunk id(s) in your corpus contain that answer (for context recall).
  4. Freeze it in version control; grow it whenever a bug slips through to production.
  5. Re-run the whole set on every retriever/prompt/model change.

Once you can score each sample, you aggregate across the set (mean per metric) and compare to per-metric thresholds. That's the scorecard a human reads and CI enforces:

Python · aggregate scorecard with pass thresholds (runs)
scorecard.pyTHRESHOLDS = {"faithfulness": 0.90, "answer_relevance": 0.80,
              "context_precision": 0.70, "context_recall": 0.80}

def scorecard(per_question):
    """per_question: list of dicts, one per eval question, with each metric 0..1.
    Aggregate = mean per metric; a metric passes if its mean >= its threshold."""
    metrics = THRESHOLDS.keys()
    n = len(per_question)
    agg = {m: round(sum(q[m] for q in per_question) / n, 3) for m in metrics}
    verdict = {m: ("PASS" if agg[m] >= THRESHOLDS[m] else "FAIL") for m in metrics}
    overall = "PASS" if all(v == "PASS" for v in verdict.values()) else "FAIL"
    return agg, verdict, overall

eval_run = [
    {"faithfulness": 1.0, "answer_relevance": 0.9, "context_precision": 0.66, "context_recall": 1.0},
    {"faithfulness": 0.95, "answer_relevance": 0.85, "context_precision": 0.75, "context_recall": 0.8},
    {"faithfulness": 0.9, "answer_relevance": 0.8, "context_precision": 0.8, "context_recall": 0.7},
]

agg, verdict, overall = scorecard(eval_run)
for m in THRESHOLDS:
    print(f"{m:18} mean={agg[m]:.3f}  thr={THRESHOLDS[m]:.2f}  {verdict[m]}")
print("OVERALL:", overall)
faithfulness       mean=0.950  thr=0.90  PASS
answer_relevance   mean=0.850  thr=0.80  PASS
context_precision  mean=0.737  thr=0.70  PASS
context_recall     mean=0.833  thr=0.80  PASS
OVERALL: PASS
▶ How this works

Individual samples are noisy; this block turns a list of per-question scores into the one table a team reads — the aggregate scorecard with a pass/fail verdict per metric.

  1. THRESHOLDS sets the bar each metric must clear. They differ on purpose: faithfulness is held highest (0.90) because a hallucination is the worst failure.
  2. The dict-comprehension agg takes the mean of each metric across every question in the run — collapsing many samples into four numbers.
  3. verdict stamps PASS/FAIL per metric by comparing its mean to its threshold; overall is PASS only if every metric passes.
  4. The loop prints an aligned row per metric so a human can eyeball which one is close to the edge.

What the output means: Every metric clears its threshold — e.g. context precision mean 0.737 ≥ 0.70 — so OVERALL: PASS.

Try this: Lower one question's context_precision to 0.2 and re-run — its mean drops below 0.70, that row flips to FAIL, and OVERALL becomes FAIL. One weak metric fails the whole run.

7 · Tech-lead — eval-gating in CI & avoiding overfitting tech-lead

A lead turns the scorecard into a gate: CI runs the eval set on every change and blocks the merge if any metric regresses below a tolerance of the last known-good baseline. This is the RAG equivalent of a failing unit test — a prompt tweak that quietly tanks context recall never reaches production. The gate is just an exit code the CI runner checks.

Python · CI eval gate returning an exit code (runs)
ci_gate.pyimport sys

BASELINE = {"faithfulness": 0.92, "answer_relevance": 0.82,
            "context_precision": 0.72, "context_recall": 0.81}
# regression tolerance: a metric may not drop more than this below baseline
TOLERANCE = 0.03

def ci_gate(current, baseline=BASELINE, tolerance=TOLERANCE):
    regressions = []
    for m, base in baseline.items():
        if current[m] < base - tolerance:
            regressions.append(f"{m}: {current[m]:.2f} < {base - tolerance:.2f}")
    if regressions:
        print("RAG EVAL GATE: FAIL")
        for r in regressions:
            print("  regressed ->", r)
        return 1
    print("RAG EVAL GATE: PASS")
    return 0

current = {"faithfulness": 0.93, "answer_relevance": 0.83,
           "context_precision": 0.60, "context_recall": 0.82}

exit_code = ci_gate(current)
print("exit code:", exit_code)
sys.exit(exit_code)
RAG EVAL GATE: FAIL
  regressed -> context_precision: 0.60 < 0.69
exit code: 1
▶ How this works

This is the piece that plugs RAG evaluation into CI. Instead of an absolute floor, it gates on regression: a metric may not drop more than a small tolerance below the last known-good baseline. The function's return value becomes the process exit code CI checks.

  1. BASELINE is the scorecard from your last green run; TOLERANCE (0.03) is the wiggle room for normal judge noise.
  2. For each metric, if the current score falls below base - tolerance, it's recorded as a regression with a human-readable reason.
  3. Any regression prints RAG EVAL GATE: FAIL, lists the culprits, and returns 1; a clean run returns 0. sys.exit(exit_code) hands that to the shell.
  4. Because it names the failing metric, the CI log tells you which half of the pipeline broke — here, retrieval (context precision).

What the output means: Context precision fell to 0.60, past its 0.69 floor, so the gate FAILs and returns exit code 1 — a red build that a merge check would block.

Try this: Run python ci_gate.py; echo $? and watch the shell print 1. Then bump current["context_precision"] to 0.71 and re-run — the gate passes and echo $? prints 0.

Here faithfulness and relevance held, but context precision cratered — the gate fails the build and names the retriever as the culprit. That's the payoff of four metrics: the failure message points at the half to fix. (Run echo $? after this file and you'll see 1 — exactly what a CI runner reads to fail a job.)

Don't overfit to the eval setThe moment you tune a system against a fixed eval set, the scores stop measuring real quality and start measuring how well you memorized the test. Keep a held-out set you never tune on, rotate in fresh production failures, and be suspicious of a suite where every metric is pinned at 1.0 — that usually means the eval is too easy, not that the system is perfect.
Evals are the RAG team's regression suiteA lead treats the eval set like a test suite: version-controlled, gated in CI, grown from real incidents, and split into 'tuned-on' and 'held-out'. That discipline — not any single framework — is what lets a team change embeddings, chunking, prompts, or the model without silently shipping a regression.

Exercise FA5.1 — Score one RAG sample end to end

Context: Scoring one real sample end-to-end tells you which half of your pipeline — retriever or generator — is the weak link, which is the first question every RAG debugging session asks.

Your task: Take one question your RAG app answers: record the retrieved chunk ids and the generated answer, note the ground-truth answer and the ids that should be retrieved, then run retrieval_metrics.py and faithfulness.py.

Requirements:

  • Capture retrieved ids, the answer, and the ground truth for one question
  • Compute precision and recall on the retrieval
  • Compute faithfulness on the answer
  • Decide whether the retriever or the generator is the weak link on this sample

💡 Hint: Low recall points at the retriever; high recall but low faithfulness points at the generator.

Exercise FA5.2 — Gate it in CI

Context: The point of evals is to block regressions automatically. Wiring the scorecard into a CI gate with a baseline turns "did this change help" into an exit code.

Your task: Collect ~5 samples into the eval_run shape, run scorecard.py for an aggregate, then feed a tweaked run into ci_gate.py: set a baseline, make a change that drops one metric past tolerance, and confirm the gate returns exit code 1.

Requirements:

  • Assemble ~5 samples into the eval-run shape
  • Produce an aggregate scorecard
  • Set a baseline and introduce a regression past the tolerance
  • Confirm the gate fails with exit code 1
  • Explain why gating on regression (not an absolute floor) suits an evolving system

💡 Hint: A regression gate compares to yesterday's numbers, so it stays fair as the system genuinely improves or the eval set changes.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Context recall as set overlapBeginner

Context: You can't improve a retriever you don't measure. Context recall — did we fetch the chunks we needed — is the first and most fundamental RAG metric.

Your task: Given the ground-truth relevant chunk ids and the ids actually retrieved, compute context recall = |relevant ∩ retrieved| / |relevant|.

Requirements:

  • Treat both sets as chunk-id sets
  • Recall is the fraction of relevant chunks that were retrieved
  • Return a value in [0, 1]
  • Handle an empty relevant set sensibly

💡 Hint: Recall is a pure set-overlap ratio over the relevant set — no scores or embeddings needed.

Show solution

Recall is a set-overlap ratio — pure stdlib:

def context_recall(relevant_ids, retrieved_ids):
    relevant = set(relevant_ids)
    if not relevant:
        return 1.0
    hit = relevant & set(retrieved_ids)
    return len(hit) / len(relevant)

print(context_recall(["c1","c3"], ["c1","c2","c3","c4"]))  # 1.0  (both found)
print(context_recall(["c1","c3"], ["c2","c1","c5"]))       # 0.5  (missed c3)

Low recall means the answer-bearing chunk was never fetched — no prompt tuning can fix that; you fix chunking/embeddings/top-k.

Exercise 2 · Context precision — how much retrieved is junkIntermediate

Context: Recall alone rewards retrieving everything. Context precision — how much of what you retrieved is actually relevant — is its counterweight, and it falls as you widen top-k with junk.

Your task: Compute context precision = |relevant ∩ retrieved| / |retrieved| and show it dropping as you widen top-k to include junk chunks.

Requirements:

  • Precision is the relevant fraction of the retrieved set
  • Return a value in [0, 1]
  • Demonstrate precision falling as top-k grows with irrelevant chunks
  • Handle an empty retrieved set sensibly

💡 Hint: Precision divides by what you retrieved, so padding top-k with junk mechanically drives it down even if recall stays put.

Show solution

Precision penalizes noisy retrieval:

def context_precision(relevant_ids, retrieved_ids):
    if not retrieved_ids:
        return 0.0
    hit = set(relevant_ids) & set(retrieved_ids)
    return len(hit) / len(retrieved_ids)

rel = ["c1","c3"]
print(context_precision(rel, ["c1","c3"]))            # 1.0  (tight)
print(context_precision(rel, ["c1","c3","c8","c9"]))  # 0.5  (padded with junk)

Widening top-k can lift recall but drops precision as junk chunks dilute the context — the precision/recall tension every retriever tune navigates.

Exercise 3 · Faithfulness as an LLM-as-judge proxyAdvanced

Context: Faithfulness — is every claim in the answer grounded in the retrieved context — is normally an LLM-as-judge metric, but you can model the judge offline with a term-overlap proxy.

Your task: Model the faithfulness judge offline: split the answer into claims and check each claim's key terms appear in some context chunk, scoring grounded claims / total claims.

Requirements:

  • Split the answer into individual claims
  • Check each claim's key terms against the context chunks
  • A claim is grounded if its terms appear in some chunk
  • Score = grounded claims / total claims
  • Runs offline as a stand-in for a real LLM judge

💡 Hint: This is a deliberately simple proxy for what a judge model would do; the point is the grounded-claims / total-claims ratio, not perfect NLP.

Show solution

A deterministic stand-in for the LLM judge (real systems call a model here):

def faithfulness(answer, contexts):
    claims = [c.strip() for c in answer.split(".") if c.strip()]
    ctx = " ".join(contexts).lower()
    grounded = 0
    for claim in claims:
        # crude grounding proxy: majority of content words appear in context
        words = [w for w in claim.lower().split() if len(w) > 3]
        hits = sum(1 for w in words if w in ctx)
        if words and hits / len(words) >= 0.6:
            grounded += 1
    return grounded / len(claims) if claims else 1.0

ctx = ["Refunds are issued within 5 business days to the original card."]
ans = "Refunds are issued within 5 business days. You get store credit instead."
print(round(faithfulness(ans, ctx), 2))   # 0.5 — 2nd claim unsupported (hallucination)

The real metric asks an LLM 'is this claim supported by the context?'; the offline proxy shows the shape — an ungrounded claim (store credit) drags faithfulness down and flags a hallucination.

Exercise 4 · Aggregate the four-metric scorecardExpert

Context: A single RAG metric is easy to game; a scorecard across all four, gated against thresholds, is what actually tells you if the system is good enough to ship.

Your task: For each eval sample (question, answer, contexts) plus ground truth, score all four metrics, average them across questions, and compare to thresholds to produce one pass/fail gate.

Requirements:

  • Score all four metrics per question
  • Average each metric across the eval set
  • Compare each average to its threshold
  • Emit a single overall pass/fail
  • Report the per-metric numbers alongside the verdict

💡 Hint: Reuse the recall, precision, and faithfulness functions from the earlier rungs; the gate is just every averaged metric clearing its threshold.

Show solution

Combine the four scorers into the lesson's scorecard:

def recall(rel, ret): return len(set(rel)&set(ret))/len(set(rel)) if rel else 1.0
def precision(rel, ret): return len(set(rel)&set(ret))/len(ret) if ret else 0.0

samples = [
    dict(rel=["c1"], ret=["c1","c2"], faith=1.0, relevance=0.9),
    dict(rel=["c3"], ret=["c9"],      faith=0.4, relevance=0.8),
]
agg = {"context_recall":0,"context_precision":0,"faithfulness":0,"answer_relevance":0}
for s in samples:
    agg["context_recall"]    += recall(s["rel"], s["ret"])
    agg["context_precision"] += precision(s["rel"], s["ret"])
    agg["faithfulness"]      += s["faith"]
    agg["answer_relevance"]  += s["relevance"]
agg = {k: round(v/len(samples),2) for k,v in agg.items()}
THRESH = {"context_recall":0.8,"context_precision":0.6,"faithfulness":0.8,"answer_relevance":0.7}
failed = [k for k,v in agg.items() if v < THRESH[k]]
print(agg)
print("GATE:", "PASS" if not failed else "FAIL -> " + ", ".join(failed))

A single number can't tell you which half to fix; four can. The gate fails on the specific axis (here recall and faithfulness) so you know whether to fix the retriever or the generator.

Exercise 5 · Build an eval set with ground truthProfessional

Context: An eval set is only as trustworthy as its data. Malformed rows — missing reference answers or relevant ids — silently poison the scorecard, so you validate before you score.

Your task: Write a validator that rejects malformed eval rows (missing reference answer or missing relevant chunk ids) before they enter the scorecard.

Requirements:

  • Require a question, a reference answer, and relevant chunk ids per row
  • Reject rows missing any required field
  • Return the offending rows (or a clear pass)
  • Run validation before any scoring happens

💡 Hint: Fail loudly at ingestion: a row without ground truth can't be scored honestly, so it should never reach the metrics.

Show solution

Guard the eval set's structure — bad ground truth ruins every metric:

def validate_evalset(rows):
    good, bad = [], []
    for r in rows:
        problems = []
        if not r.get("question"): problems.append("no question")
        if not r.get("reference_answer"): problems.append("no reference answer")
        if not r.get("relevant_chunk_ids"): problems.append("no relevant ids")
        (bad if problems else good).append((r.get("question","?"), problems))
        if not problems: good[-1] = r
    ok = [r for r in good if isinstance(r, dict)]
    return ok, [b for b in bad]

rows = [
    {"question":"Refund window?","reference_answer":"5 days","relevant_chunk_ids":["c1"]},
    {"question":"","reference_answer":"x","relevant_chunk_ids":["c2"]},   # bad
    {"question":"2FA?","reference_answer":"toggle","relevant_chunk_ids":[]}, # bad
]
ok, bad = validate_evalset(rows)
print(f"usable: {len(ok)}   rejected: {len(rows)-len(ok)}")

Metrics are only as trustworthy as the ground truth. Every reference answer and relevant-id list is human-curated, so validating them up front stops a broken row from silently distorting the scorecard.

Exercise 6 · Eval-gate a RAG system in CI without overfittingIndustry scenario

Context: As the lead you design the CI gate — regression thresholds per metric plus a guard against overfitting to a fixed eval set, which is the subtle way RAG evals lie to you.

Your task: Design the CI gate with per-metric regression thresholds and a guard against overfitting to the eval set (rotate / hold out questions), printing the gate decision and an overfitting warning.

Requirements:

  • Gate on regression against a baseline, not just an absolute floor
  • Set a per-metric tolerance
  • Guard against overfitting by rotating or holding out questions
  • Print the gate decision (pass/fail)
  • Emit a warning when overfitting is suspected

💡 Hint: Regression gating tolerates an evolving system; the overfitting guard is why you never let the whole eval set become training feedback.

Show solution

The CI gate plus an eval-set-overfitting guard:

THRESH = {"faithfulness":0.85,"answer_relevance":0.75,
          "context_recall":0.80,"context_precision":0.60}

def ci_gate(scores, prev_scores=None):
    failed = [k for k,v in scores.items() if v < THRESH[k]]
    decision = "PASS" if not failed else "FAIL -> fix: " + ", ".join(failed)
    warn = ""
    # overfitting guard: suspiciously high on a static set that never rotates
    if prev_scores and all(scores[k] >= 0.97 for k in scores):
        warn = " WARN: near-perfect on a static eval set — rotate held-out questions"
    return decision + warn

now  = {"faithfulness":0.88,"answer_relevance":0.79,"context_recall":0.82,"context_precision":0.65}
print(ci_gate(now))   # PASS
perfect = {k:0.99 for k in THRESH}
print(ci_gate(perfect, prev_scores=perfect))  # PASS WARN: ... rotate

A lead owns thresholds and the discipline behind them: gate merges on per-metric regressions, but rotate held-out questions so the team optimizes the system, not the specific eval set.

✓ Checkpoint — you can move on when you can…

  • Explain why RAG needs its own eval and how retrieval vs generation fail separately.
  • Define faithfulness, answer relevance, context precision, and context recall — and what each catches.
  • Build an eval set with ground-truth answers and relevant chunk ids.
  • Compute retrieval metrics as set overlap and model faithfulness with an LLM-as-judge proxy.
  • Aggregate a scorecard and eval-gate a RAG system in CI without overfitting the suite.

Knowledge check check yourself

✓ Knowledge check

Why does RAG need its own evaluation instead of a single accuracy score?

Show answer
A RAG system has two independently-failing parts: the retriever (did it fetch the right chunks?) and the generator (did it answer correctly from them?). One accuracy number can't tell them apart, so separate metrics are needed to point at which half to fix.
✓ Knowledge check

Of the four Ragas-style metrics, which grade the retriever versus the generator, and what does faithfulness measure?

Show answer
Context precision and context recall grade the retriever (against ground-truth relevant chunks); faithfulness and answer relevance grade the generator. Faithfulness asks whether every claim in the answer is supported by the retrieved context — catching hallucinations even when retrieval was fine.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in