hands-on labIDE in your browser

Evaluate a RAG Pipeline: Retrieval Metrics, Faithfulness and a Regression Gate

Turn 'the RAG bot seems better' into numbers you can gate on: chunk a knowledge base with paragraph provenance, label a golden set, score retrieval with recall, precision, MRR and nDCG, sweep chunk sizes, judge answer faithfulness claim by claim with a model, grade correctness, triage every failure by where it happened, and block a candidate configuration that regresses a protected question.

time
80 min
checked steps
8
level
Intermediate
setup
None
NCP-AAINCA-GENLNCA-AMADPart of the AI Engineer Course

$29.99a month with Pro

Pro opens all 8 steps of this lab and every other lab. Cancel any time.

no account needed · Pro opens all 8 steps

See Pro plans
Lab cockpit80 min · 8 stepsSession running
3 / 8 steps passingSweep the chunk size and read the trade-off · step 4 of 8
evalrag.py▶ Run✓ Check
# ------------------------------------------------------------------ Step 4: a run, then a sweepdef evaluate_retrieval(run, qa, k):    """Per-question recall/precision/mrr/ndcg at k for every labelled question that has relevant    paragraphs, plus the means. Returns {"rows": [...], "mean": {...}, "n": n, "k": k}."""           
TerminalOutput
step 4 of 8, replayed: Sweep the chunk size and read the trade-off. The code, the output and the check are the lab's own.

The job

Northwind Cloud's support bot answers from a knowledge base of a dozen help articles. The team wants to change the chunk size and cut the number of passages in the prompt to save tokens, and the demo looked fine. You build the harness that decides: a golden set of questions labelled at the paragraph level, retrieval metrics that survive re-chunking, a faithfulness judge that checks each claim of an answer against the passages it was given, a deterministic correctness grade, a triage that says whether a failure was retrieval, grounding or generation, and a regression gate that compares the candidate to the baseline and refuses the change if a protected question breaks. Retrieval and answers are recorded once so most steps run offline and every number is reproducible.

8 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Chunk with provenance: every chunk knows which paragraphs it holds

    Northwind's support bot answers from a dozen help articles in kb/.

    read this step below ↓

  2. 2

    Recall@k and precision@k against paragraph labels

    runs/retrieval-s500.jsonl is a recorded retrieval run: for every question in qa.jsonl, the top five chunks the 500-character index returned, with their paragraph ids and scores.

  3. 3

    Rank matters: MRR and nDCG

    Recall@3 says whether the right paragraph is somewhere in the top three.

  4. 4

    Sweep the chunk size and read the trade-off

    Four metrics per question is a spreadsheet; what the team needs is one row per configuration.

  5. 5

    Faithfulness: split the answer into claims and let a judge verify each one

    Retrieval metrics stop at the prompt.

  6. 6

    Correctness by rule, and a triage that names where each failure happened

    A faithful answer can still be wrong, and a wrong answer can be wrong for three different reasons.

  7. 7

    Run the suite: one report per configuration

    Everything so far scores one question.

  8. 8

    The regression gate: block the change that breaks a protected question

    The team's proposal is in runs/candidate.jsonl: 1000-character chunks and two passages per prompt instead of three, recorded and judged the same way as the baseline.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Chunk with provenance: every chunk knows which paragraphs it holds

Northwind's support bot answers from a dozen help articles in kb/. The team wants to change the chunk size and send fewer passages to the model, and the demo looked fine. "Looked fine" is the problem this lab replaces with numbers, and the numbers need one thing first: a way to say whether a retrieved chunk contains the right text that still works after the chunks change.

Chunk ids are useless for that, because every chunk size produces different ids. Paragraphs are stable. qa.jsonl therefore labels each question with the paragraph ids that answer it (billing:p2 is the second paragraph of kb/billing.md), and every chunk carries the list of paragraph ids it covers. Retrieval is scored on that list, so the same labels score a 250-character index and a 1000-character index alike.

rag.py is the pipeline under test: it loads the corpus, chunks it, embeds the chunks through the lab's proxy in batches, searches by cosine similarity and asks a small model to answer from the passages. The one piece missing is the chunker.

do this

1. Implement chunk_doc(doc_id, paragraphs, size, overlap) in rag.py. Pack whole paragraphs into a chunk for as long as the joined text (paragraphs separated by a blank line) stays within size characters; when the next paragraph would not fit, close the chunk and start another. A paragraph longer than size on its own cannot be packed: hard-split it into windows of size characters that step forward by size - overlap, each window labelled with that one paragraph id. Each chunk is {"doc", "paragraphs", "text"}, paragraph ids are f"{doc_id}:p{n}" with n starting at 1, and the chunks are numbered f"{doc_id}:c{i}" at the end.

2. Run. At 500 characters the corpus becomes two dozen chunks, some of which span two paragraphs. The question about the reset link retrieves the getting-started paragraph at rank 1 with its paragraph id in the listing, and the model answers from it. At 250 characters every paragraph is split and nothing spans two; at 1000 every document is a single chunk. Those three indexes are the sweep you will score in Step 4.

Why this matters in practice

Provenance is what turns retrieval into something you can measure over time. Without it, every chunking change invalidates the labels and the team stops evaluating. With it, the golden set is written once at the paragraph level and every experiment (size, overlap, embedding model, reranker) is scored against the same truth. The chunker itself follows the chunking lecture's default: split on structure first, size second, and overlap only where a hard split forces it.

rag.py, the file you edit171 lines
"""rag.py: the pipeline under evaluation. Retrieval + generation over kb/*.md.

    python3 rag.py index --size 500 --overlap 60          # chunk + embed, writes index.json
    python3 rag.py ask "How long is a password reset link valid?" --k 5
    python3 rag.py run --qa qa.jsonl --out runs/mine.jsonl --size 500 --k 5 [--no-answers] [--model ...]

Chunks carry provenance: the paragraph ids they cover, which is what qa.jsonl labels.
"""
import argparse
import json
import math
import os
import re
import sys
import time
import urllib.request

BASE_URL = os.environ.get("RAG_BASE_URL", "http://nim-proxy.labs.svc:8080/v1")
EMBED_MODEL = os.environ.get("RAG_EMBED_MODEL", "nvidia/llama-nemotron-embed-vl-1b-v2")
EMBED_DIMENSIONS = int(os.environ.get("RAG_EMBED_DIMENSIONS", "1024"))
CHAT_MODEL = os.environ.get("RAG_CHAT_MODEL", "meta/llama-3.1-8b-instruct")
KB_DIR = os.environ.get("RAG_KB", "kb")
INDEX_PATH = "index.json"

SYSTEM = ("You are the Northwind Cloud support assistant. Answer the question using ONLY the numbered "
          "context passages. Be specific: quote the numbers, durations and menu paths the passages give. "
          "If the passages do not contain the answer, reply exactly: "
          "\"I do not have that information in the knowledge base.\" Answer in at most three sentences, "
          "as a direct reply: never mention the passages, their numbers or the word context.")


# ------------------------------------------------------------------ corpus
def load_corpus(kb_dir=KB_DIR):
    """[{doc, title, paragraphs}] with the title line removed from the paragraphs."""
    docs = []
    for name in sorted(os.listdir(kb_dir)):
        if not name.endswith(".md"):
            continue
        raw = open(os.path.join(kb_dir, name), encoding="utf-8").read()
        title, body = name[:-3], raw
        m = re.match(r"^---\n(.*?)\n---\n", raw, re.S)
        if m:
            body = raw[m.end():]
            t = re.search(r"^source:\s*(.+)$", m.group(1), re.M)
            title = t.group(1).strip() if t else title
        paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip() and not p.strip().startswith("# ")]
        docs.append({"doc": name[:-3], "title": title, "paragraphs": paragraphs})
    return docs


def chunk_doc(doc_id, paragraphs, size=500, overlap=60):
    """Split one document's paragraphs into chunks of at most `size` characters.

    Paragraph boundaries come first: whole paragraphs are packed into a chunk until the next one
    would not fit. A paragraph longer than `size` on its own is hard-split into windows of `size`
    characters that overlap by `overlap`. Every chunk records the paragraph ids it covers
    (`<doc>:p<n>`, 1-based) so retrieval can be scored against paragraph-level labels."""
    # TODO (Step 01): pack whole paragraphs into chunks of at most `size` characters; a paragraph
    #   longer than `size` is hard-split into windows of `size` with `overlap`; every chunk is
    #   {"doc": doc_id, "paragraphs": [<doc>:p<n>, ...], "text": ...}; number them "<doc>:c<i>" at the end.
    raise NotImplementedError("Step 01: implement chunk_doc()")


def chunk_corpus(docs, size, overlap):
    out = []
    for d in docs:
        for c in chunk_doc(d["doc"], d["paragraphs"], size, overlap):
            c["title"] = d["title"]
            out.append(c)
    return out


# ------------------------------------------------------------------ embeddings + search
def embed(texts, input_type):
    """One batched request per 50 texts through the lab proxy (rate-limited, so never per chunk)."""
    out, texts = [], list(texts)
    for i in range(0, len(texts), 50):
        body = json.dumps({"model": EMBED_MODEL, "input": texts[i:i + 50], "input_type": input_type,
                           "dimensions": EMBED_DIMENSIONS, "encoding_format": "float"}).encode()
        req = urllib.request.Request(BASE_URL.rstrip("/") + "/embeddings", data=body, method="POST",
                                     headers={"Content-Type": "application/json"})
        data = json.loads(urllib.request.urlopen(req, timeout=90).read())
        out.extend(item["embedding"] for item in sorted(data["data"], key=lambda x: x["index"]))
    return out


def _norm(v):
    n = math.sqrt(sum(x * x for x in v)) or 1.0
    return [x / n for x in v]


def build_index(size, overlap, kb_dir=KB_DIR):
    chunks = chunk_corpus(load_corpus(kb_dir), size, overlap)
    vecs = embed([f"{c['title']}\n{c['text']}" for c in chunks], "passage")
    return {"size": size, "overlap": overlap, "chunks": chunks, "vectors": [_norm(v) for v in vecs]}


def search(index, qvec, k):
    q = _norm(qvec)
    scored = [(sum(a * b for a, b in zip(q, v)), i) for i, v in enumerate(index["vectors"])]
    scored.sort(reverse=True)
    return [dict(index["chunks"][i], score=round(s, 4)) for s, i in scored[:k]]


# ------------------------------------------------------------------ generation
def generate(question, contexts, model=CHAT_MODEL):
    ctx = "\n\n".join(f"[{i + 1}] ({c['title']}) {c['text']}" for i, c in enumerate(contexts))
    body = json.dumps({"model": model, "temperature": 0, "max_tokens": 220,
                       "messages": [{"role": "system", "content": SYSTEM},
                                    {"role": "user", "content": f"CONTEXT:\n{ctx}\n\nQUESTION: {question}"}]}).encode()
    req = urllib.request.Request(BASE_URL.rstrip("/") + "/chat/completions", data=body, method="POST",
                                 headers={"Content-Type": "application/json"})
    data = json.loads(urllib.request.urlopen(req, timeout=120).read())
    return data["choices"][0]["message"]["content"].strip(), data.get("model", model)


# ------------------------------------------------------------------ CLI
def read_jsonl(path):
    with open(path, encoding="utf-8") as f:
        return [json.loads(l) for l in f if l.strip()]


def cmd_index(a):
    idx = build_index(a.size, a.overlap)
    json.dump(idx, open(INDEX_PATH, "w"))
    docs = len({c["doc"] for c in idx["chunks"]})
    multi = sum(1 for c in idx["chunks"] if len(c["paragraphs"]) > 1)
    print(f"indexed {len(idx['chunks'])} chunks from {docs} documents (size {a.size}, overlap {a.overlap}); "
          f"{multi} chunks span more than one paragraph")


def cmd_ask(a):
    if not os.path.exists(INDEX_PATH):
        sys.exit("no index.json: run `python3 rag.py index` first")
    idx = json.load(open(INDEX_PATH))
    hits = search(idx, embed([a.question], "query")[0], a.k)
    for i, h in enumerate(hits, start=1):
        print(f"{i}. {h['score']:.3f}  {h['id']:26} paragraphs={','.join(p.split(':')[1] for p in h['paragraphs'])}  {h['text'][:70]!r}")
    if not a.no_answer:
        answer, served = generate(a.question, hits, a.model)
        print(f"\n[{served}] {answer}")


def cmd_run(a):
    qa = read_jsonl(a.qa)
    idx = build_index(a.size, a.overlap)
    qvecs = embed([q["question"] for q in qa], "query")
    os.makedirs(os.path.dirname(a.out) or ".", exist_ok=True)
    with open(a.out, "w", encoding="utf-8") as f:
        for q, qv in zip(qa, qvecs):
            hits = search(idx, qv, a.k)
            rec = {"id": q["id"], "question": q["question"], "config": {"size": a.size, "overlap": a.overlap, "k": a.k, "chunks": len(idx["chunks"])},
                   "retrieved": [{"id": h["id"], "doc": h["doc"], "paragraphs": h["paragraphs"], "score": h["score"],
                                  "title": h["title"], "text": h["text"]} for h in hits]}
            if not a.no_answers:
                rec["answer"], rec["model"] = generate(q["question"], hits, a.model)
                print(f"{q['id']}: {rec['answer'][:90]!r}")
                if a.pace:
                    time.sleep(a.pace)
            f.write(json.dumps(rec) + "\n")
    print(f"wrote {len(qa)} records to {a.out}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("index"); p.add_argument("--size", type=int, default=500); p.add_argument("--overlap", type=int, default=60); p.set_defaults(fn=cmd_index)
    p = sub.add_parser("ask"); p.add_argument("question"); p.add_argument("--k", type=int, default=5); p.add_argument("--model", default=CHAT_MODEL); p.add_argument("--no-answer", action="store_true"); p.set_defaults(fn=cmd_ask)
    p = sub.add_parser("run"); p.add_argument("--qa", default="qa.jsonl"); p.add_argument("--out", required=True); p.add_argument("--size", type=int, default=500)
    p.add_argument("--overlap", type=int, default=60); p.add_argument("--k", type=int, default=5); p.add_argument("--model", default=CHAT_MODEL); p.add_argument("--no-answers", action="store_true"); p.add_argument("--pace", type=float, default=0.0); p.set_defaults(fn=cmd_run)
    a = ap.parse_args(); a.fn(a)

provided for you:evalrag.pykb/account-deletion.mdkb/api-keys.mdkb/audit-logs.mdkb/billing.mdkb/data-export.mdkb/getting-started.mdkb/plans-and-pricing.mdkb/quotas-and-limits.mdkb/regions.mdkb/sso.mdkb/status-incidents.mdkb/support-cases.mdqa.jsonl

what you get

  • An editor and terminal in your browser
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Python: functions, dicts, sets, json, list comprehensions
  • How a RAG pipeline works: chunk, embed, retrieve, generate (the module's earlier labs)
  • Read the module's two evaluation lectures first: recall and precision, then LLM-as-judge and failure modes

Exam domains covered

RAG SystemsLLM EvaluationLLM Operations

Part of the RAG and search track

Lab 12 of 14. Chunking, hybrid search and reranking, permission-aware retrieval, GraphRAG and RAG evaluation.

Questions about this lab

Why are the labels at the paragraph level instead of the chunk level?

Chunk ids change every time you change the chunk size, so chunk-level labels would have to be redone for every experiment. Paragraph ids are stable, chunks record which paragraphs they cover, and recall is scored on the paragraphs the retrieved chunks contain.

How is faithfulness measured?

The answer is split into sentence-level claims and a judge model is asked, with the retrieved passages in front of it, whether each claim is supported. Faithfulness is the supported fraction. A refusal asserts nothing and scores 1.0; correctness is graded separately, because a grounded answer can still be wrong or incomplete.

Which models does the lab use?

The lab's embedding model for retrieval, a hosted Llama 3.1 8B instruct model as the generator under test, and Llama 3.3 70B instruct as the judge, all through the lab's proxy. Retrieval runs, answers and judgments are recorded so the metric, triage, suite and gate steps run offline.

What does the regression gate check?

Mean recall and nDCG at k, mean faithfulness and the correct rate may not drop below the baseline, and every protected question that passed in the baseline must still pass in the candidate. The gate exits non-zero so a CI job can block the change.

Why evaluate RAG in two halves

A RAG system fails in two places. The retriever can miss the passage that holds the answer, and the generator can ignore or embellish the passages it was given. A single end-to-end score hides which one happened, and teams fix the wrong half. This lab measures each half with the metric that fits it and then combines them into a triage that names the failure. Retrieval is scored against paragraph-level labels with recall, precision, mean reciprocal rank and nDCG at k, and a chunk-size sweep shows the recall-versus-context trade-off with real numbers. Generation is scored by decomposing each answer into claims and asking a judge model whether the passages support each one, then by a deterministic check of the facts the answer must contain. A suite report rolls it up per question, and a regression gate compares a candidate configuration to the baseline and blocks it when a gated metric drops or a protected question stops passing.