Lab 7

Observability & Evaluation Stack

Make a RAG assistant observable, so you can see what it did and how long it took, and evaluated, so you can measure whether it is correct, grounded, fast and affordable. Then use those numbers as a release gate.

90 minTraces & spansGolden setLLM judgeLangfuseModule 8

The Colab button loads the notebook from the public GitHub repository, so it works only after the notebook is pushed there. Until then, download it and use Colab's File > Upload notebook. Run the cells in order, and complete each YOUR CODE HERE blank before running the cells after it.

What you will build

An LLM system can fail silently. It does not crash, it gives a fluent wrong answer. Without traces and evals you find out from a customer. In this lab you trace every question, run a golden set of 20, score each answer with an LLM judge, summarise latency, estimated cost and quality, and apply pass/fail gates. You then make a small change and watch the numbers catch it.

Question→Trace starts→Retrieve span→Generate span→Trace ends→Golden-set scores→Release gates

Prerequisites

  • ▸Read Module 8 sections 2 (traces, metrics, logs), 3 (evals), 8 (open-source tools) and 10 (production dashboard).
  • ▸Labs 3 and 4 are recommended but not required. This notebook rebuilds a small RAG assistant itself.
  • ▸Any model provider from Lab 4: Ollama, Groq, Gemini, OpenRouter, OpenAI or Anthropic. One run makes about 40 model calls, plus about 40 more for the regression test, so on a free tier lower LIMIT or raise PAUSE.
  • ▸Only for the optional Langfuse exercise: a free Langfuse Cloud account, or Docker installed on your machine for self-hosting.

Exercises

0

Choose your model provider

Why we do this: The same provider setup as Labs 4 and 6. This lab makes about 40 model calls per run (20 answers and 20 judgements), so a free tier with tight limits may slow you down.

What the code does: Defines chat(messages), then an ask(system, user) helper with a few retries and a short wait, because rate-limit errors are common when many calls run in a row. LIMIT and PAUSE let you run fewer questions or slow down.

LIMIT = 20    # how many golden questions to run
PAUSE = 0.5   # seconds between questions (raise on strict free tiers)

def ask(system, user, retries=3):
    ...  # chat() with back-off on errors

Expected output: A short greeting from the model when the setup cell runs, and no errors.

What to notice: Ollama must run locally, since Colab cannot reach it. Model names change, so edit MODEL if one is rejected.

1

Build the assistant (recap of Lab 3)

Why we do this: You need something to observe. This is a compact version of Lab 3: download the SEBI memo, chunk it, store it in ChromaDB, retrieve, and answer from the retrieved text only.

What the code does: retrieve_chunks returns the top matching chunks with page numbers. generate builds a prompt from them and asks the model, replying 'Not found in the provided documents.' when the context lacks the answer. It returns both the answer and the prompt, because the next exercise estimates tokens from the prompt.

Key terms
  • ▸Token: the unit models read and are billed in, roughly three-quarters of an English word.
def retrieve_chunks(question, k=4): ...
def generate(question, chunks):
    ...
    return {"answer": ask(SYSTEM, prompt), "prompt": SYSTEM + "\n" + prompt}

Expected output: An answer mentioning 2019 with a page citation. If it says 'Not found', check that chunks were stored and try a stronger model.

What to notice: Keep this assistant simple. The interesting part is how we watch and measure it.

2

Add tracing

Why we do this: When an answer is wrong or slow you need to know where: did retrieval fail, or did generation? A trace records one request from start to finish, and each step inside it is a span with its own timing and details (Module 8, section 2).

What the code does: The Trace class records spans. Its span() context manager times whatever runs inside the with block. finish() adds up time, estimated tokens and estimated cost. You complete rag_answer so it records a retrieve span and a generate span, storing token estimates in the generate span.

Key terms
  • ▸Trace: the full record of one request.
  • ▸Span: one timed step inside a trace.
  • ▸Estimated tokens: about 4 characters per token. These are estimates, not billed numbers. Your provider's usage fields are exact (a stretch goal).
def rag_answer(question, k=4):
    trace = Trace("rag_answer", question)
    # YOUR CODE HERE:
    # with trace.span("retrieve", k=k) as s: ...
    # with trace.span("generate", model=MODEL) as s: ...
    #   set s["in_tok"], s["out_tok"] with est_tokens(...)
    # trace.finish(answer); return {...}
    raise NotImplementedError

Expected output: The answer, then one line per span with its name, milliseconds and details. The generate span is almost always far slower than retrieve.

What to notice: That gap tells you where optimisation would pay off. If retrieve were the slow step you would tune the database, not the model. Set PRICE_IN_PER_M and PRICE_OUT_PER_M from your provider's current pricing page, or leave them at 0 for free tiers.

Finance link: A trace is also an audit record: which question, which sources, which model, and when (Module 8, section 9).

3

The golden set

Why we do this: You cannot measure quality without a fixed set of questions with known right answers. Running the same set after every change shows whether you made things better or worse, which is a regression test.

What the code does: 20 questions written from the SEBI memo. 18 have answers in the document. 2 do not, because the memo excises the number of comments received and states no penalty, so the correct behaviour there is to say 'Not found'. Each item has keys: a list of groups, and an answer passes if at least one alternative from every group appears in it. You add two questions of your own after checking the memo text.

Key terms
  • ▸Golden set: a fixed set of questions with known good answers.
  • ▸Regression test: re-running the golden set after a change to see if anything got worse.
{"q": "What is the date of the SEBI consultation paper on this proposal?",
 "answerable": True,
 "keys": [["november 13, 2024", "13 november 2024", "13th november 2024"]]}

Expected output: The line '20 golden questions, 18 answerable', or more if you added your own.

What to notice: The fact check is strict about wording, and a correct answer phrased in an unexpected way would fail it. That is why we also use an LLM judge, and why some failures deserve a human look. A set of 20 is enough to teach the idea and catch big regressions, but far too small to detect small quality changes. Real teams grow it from production failures.

Finance link: Testing that the system says 'not found' matters in finance, where an assistant that always answers is dangerous.

4

Evaluate with an LLM judge

Why we do this: The fact check tells you whether key facts are present. It does not tell you whether the answer invented anything else. Groundedness asks whether every claim in the answer is supported by the retrieved text. An answer can be correct yet partly invented, and in finance that matters (Module 8, section 3).

What the code does: An LLM judge is a second model call that reads the sources and the answer and gives 1 (fully supported) or 0 (some unsupported claim). You write groundedness(context, answer): a judge prompt ending in SCORE: 1 or SCORE: 0, then a regular expression to parse it. evaluate runs every golden question and records correctness, groundedness, latency and cost.

Key terms
  • ▸LLM-as-judge: using a model to grade another model's output.
  • ▸Groundedness (faithfulness): the answer sticks to the supplied sources.
def groundedness(context: str, answer: str) -> int:
    # YOUR CODE HERE:
    # 1. judge prompt: is every claim supported by the context?
    # 2. reply format: 'SCORE: 1' or 'SCORE: 0', then 'REASON: ...'
    # 3. re.search(r"SCORE:\s*([01])", reply) -> int, or 0 if no match
    raise NotImplementedError

Expected output: One line per question marked ok or FAIL. Most should pass, and a few FAIL lines are normal. They are the point.

What to notice: Read two or three failing answers in full and decide whether each is a real error, a retrieval miss, a too-strict fact check or a judge mistake, because each needs a different fix. Judges are imperfect: they can be lenient with fluent text, and a judge that is the same model as the generator tends to favour its own style. Use a different, stronger judge when you can, and spot-check by hand.

5

Summary, gates and a regression test

Why we do this: Twenty rows are hard to act on. A production dashboard reduces them to a few numbers with thresholds, so a person or a CI job can say ship or do not ship (Module 8, section 10).

What the code does: You write summarise(rows) to compute correctness rate, groundedness rate, median and 95th-percentile latency, and cost per query. gates compares them with thresholds and prints PASS or FAIL for each. You then re-run with k=1 instead of 4, which simulates a cost-saving change someone might ship untested, and compare the two runs side by side.

Key terms
  • ▸p50 / p95 latency: half / 95% of requests finish faster than this. p95 shows the slow tail users notice.
  • ▸Gate: a pass/fail threshold that blocks a release.
def summarise(rows):
    # YOUR CODE HERE: return a dict with
    # correctness, groundedness, p50_ms, p95_ms, cost_per_query
    raise NotImplementedError

THRESHOLDS = {"correctness": 0.80, "groundedness": 0.90,
              "p95_ms": 8000, "cost_per_query": 0.01}   # illustrative

Expected output: A side-by-side table of baseline and candidate. With one chunk, correctness usually drops because questions needing several parts of the memo suffer, while latency and cost may improve slightly.

What to notice: Without the golden set you would see only the lower cost and think the change was free. With 20 questions, treat small differences as noise and big drops as real. The thresholds in the notebook are illustrative: set yours from your own risk appetite and review them with your risk team.

Finance link: This before-and-after table is the validation evidence model-risk reviewers expect for any change to a model-driven process (Module 5, section 2; Module 8, section 9).

6

Send traces to Langfuse (optional)

Why we do this: Your Trace class teaches what a trace is. In practice you use a tool that stores traces, draws them as a timeline, and lets you search and compare them. Langfuse is an open-source option (Module 8, section 8).

What the code does: Reads your keys, then wraps the existing retrieve_chunks and generate functions with Langfuse's observe decorator. Because rag_answer calls them, each question appears as a trace with nested retrieve and generate steps. With no keys set, the cell skips itself.

Key terms
  • ▸Langfuse Cloud: hosted by Langfuse. Works on Colab, but traces leave your machine.
  • ▸Self-hosted Langfuse: runs on your machine with Docker, so data stays with you.
from langfuse import observe, get_client

retrieve_chunks = observe(name="retrieve")(retrieve_chunks)
generate = observe(name="generate")(generate)
rag_answer("...")           # now appears as a trace
get_client().flush()

Expected output: 'Sent 3 traces', and the Traces page in Langfuse showing three entries with their timing.

What to notice: Compare Langfuse's timeline with the spans your own Trace recorded. Your class already stores the same facts, and the tool adds storage, search, dashboards and team sharing. The Langfuse SDK changes between versions: this cell was written for the v3 Python SDK, so check the current docs if an import fails. The rest of the lab does not depend on it.

Finance link: Traces include question text, which can contain personal data. With Langfuse Cloud, never send real client data. Redact before logging (reuse redact from Lab 6) and check your retention and residency rules.

Stretch goals

  • ▸Use your provider's exact token counts instead of the 4-characters-per-token estimate, and compare the two.
  • ▸Use a different, stronger model as judge and see how many judgements change.
  • ▸Try DeepEval or RAGAS, open-source evaluation libraries, and compare their faithfulness scores with your judge.
  • ▸Redact personal data from traces before logging them.
  • ▸Make a script exit with a non-zero code when a gate fails, so a CI pipeline can block a release.

Common errors

  • ▸NotImplementedError: you have not completed rag_answer, groundedness or summarise yet. Complete them in order.
  • ▸Rate limit or 429: raise PAUSE, lower LIMIT, or use a provider with higher limits. Each question uses two model calls.
  • ▸Groundedness is 0 for everything: the judge reply is probably not in the SCORE: 1 format. Print one raw reply and adjust the prompt or regular expression.
  • ▸Langfuse import errors: the SDK changes between versions. Check the current docs, or skip Exercise 6.
  • ▸Scores change between runs: expected. Repeat the run before drawing conclusions.