Multi-agent RAG pipelines
Single-agent agentic RAG (M3) lets one agent decide when to retrieve. A multi-agent RAG pipeline splits that job across specialized agents — a router, retriever(s), a grader, a synthesizer, and a verifier — that hand off through shared state. This lesson is the orchestration pattern: how the agents connect, where it loops, how it fails, and how to evaluate the whole pipeline — not just retrieval.
Learning objectives
- Decide when a multi-agent RAG pipeline earns its cost — and when one agent + tools (M3) is enough.
- Name the canonical roles: router/planner, retriever(s), grader/re-ranker, synthesizer, verifier.
- Wire the agents through shared state with conditional routing and a bounded retrieve→grade→re-retrieve loop.
- Guard the failure modes: loop-forever, cost blow-up, grader/verifier disagreement, honest 'I don't know'.
- Evaluate the pipeline end-to-end — per-stage metrics plus a grounded-answer gate — not retrieval recall alone.
1 · One agent with tools vs a multi-agent RAG pipeline
Picture the moment this lesson exists for. You built a RAG agent in M3, it worked, and then the requests piled on: "also search the wiki, not just the docs"; "stop making things up — check the answer first"; "the judging step is burning our expensive model on trivial relevance calls." So you kept bolting instructions onto one system prompt until it read like a job description for five people. It half-plans, half-retrieves, half-judges, half-writes, half-verifies — and does none of them reliably. That bloated, do-everything prompt is the pain a multi-agent RAG pipeline cures.
The fix is the oldest trick in engineering: divide the labour. Think of a newsroom instead of a lone blogger. A blogger researches, writes, and fact-checks the same piece alone — fine for a small blog, but it doesn't scale and nobody's checking the writer's own claims. A newsroom splits those into an editor who assigns the story, reporters who gather sources, a copy editor who cuts the weak material, a writer who drafts, and a fact-checker with the authority to send a piece back. Each role is simpler, individually good at its job, separately measurable, and — crucially — the fact-checker is not the same person who wrote the story. A multi-agent RAG pipeline is that newsroom: router, retriever, grader, synthesizer, verifier, each a small focused agent handing work down the line through a shared notepad.
The common mistake — and it is common — is reaching for the pipeline too early. "Multi-agent" sounds sophisticated, so people split a perfectly good single agent into five just to have an impressive diagram. Every agent you add is another prompt to maintain, more tokens, more latency, and one more component that can misbehave — you have multiplied your surface area for bugs. The honest rule: M3's single agent with a retrieve→grade→rewrite loop is the right default. Split into a pipeline only when a specific stage genuinely needs its own prompt, its own model tier, or its own evaluation gate — the signals in the table below. If you can't point at which stage needs to be separate and why, you don't need a pipeline yet.
In M3 a single agent owns the whole flow: it decides when to retrieve, grades what came back, and answers. That is the right default. A multi-agent RAG pipeline is different — it splits the work across separate, specialized agents that each do one job well and hand off through a shared state object. You reach for it only when a single agent's one prompt is doing too many jobs at once.
Read left-to-right: the one crowded box on the left is what you start with; the arrow "splits" it into the stack of single-job agents on the right.
- The red box on the left is M3's single agent. The blue strips inside it are the five jobs — plan, retrieve, judge, write, verify — all crammed into one prompt, which is why it gets brittle.
- The split arrow is the design decision this lesson is about: break the one prompt into separate agents.
- The five stacked boxes on the right are those jobs given their own agent (router, retriever, grader, synthesizer, verifier). Each is simpler and independently measurable — and the verifier being a separate box is the whole point: nobody grades their own homework.
In short: You split into a pipeline only when one prompt is juggling too many jobs — the five boxes on the right are what "divide the labour" actually produces.
| Signal | One agent + tools (M3) | Multi-agent pipeline (this lesson) |
|---|---|---|
| Retrieval is a simple lookup | ✅ Just call a retriever tool | Overkill |
| One prompt is juggling 4+ jobs (plan, retrieve, judge, write) | Prompt gets brittle | ✅ Split into specialized agents |
| Multiple sources / indexes to fuse | Hard to reason about in one prompt | ✅ A dedicated retriever/fusion stage |
| You need a distinct faithfulness/hallucination gate | Bolted onto the answer step | ✅ A separate verifier agent that can send back |
| Each stage needs its own model / cost tier | One model for everything | ✅ Cheap grader, strong synthesizer |
| You want per-stage evals & observability | One opaque trace | ✅ Each agent is independently measurable |
2 · The pipeline roles — a canonical decomposition
Here is the good news that saves you a design meeting. You do not have to invent the roles from a blank page — the same five keep showing up in every serious RAG pipeline because they map to the five distinct jobs hidden inside "answer this question from documents." Learn them once and you'll recognize them in any framework, any codebase, any vendor's diagram: router, retriever, grader, synthesizer, verifier. Different names sometimes (planner, re-ranker, critic), same jobs.
The analogy that makes them stick is a restaurant kitchen taking an order. The router is the expediter reading the ticket and deciding which stations it goes to. The retriever is the line cooks pulling ingredients from the walk-in — maybe several at once from different fridges. The grader is the cook who inspects and throws out anything wilted before it hits the pan — junk in equals junk out. The synthesizer is the chef plating the dish from the good ingredients only. And the verifier is the head chef who inspects the plate before it leaves the pass and can send it back to be redone. Notice the pattern: work flows one direction, each station does one thing, and the last station has a veto.
The subtle point people miss is why the grader and the verifier both exist — they look like the same "quality check" and the instinct is to merge them to save an agent. Resist it. They guard opposite ends of the pipeline. The grader guards the input: it protects the synthesizer from irrelevant retrieved chunks so the answer is written from clean context. The verifier guards the output: it protects the user from a fluent, confident answer the context never actually supported — a hallucination. An answer can pass the grader (the chunks were relevant) and still fail the verifier (the draft claimed something not in those chunks). Collapsing them is exactly the "the writer fact-checks their own story" mistake from §1, and it is the single most valuable reason to run a pipeline at all. The table below is your reference card: each agent, its one job, and precisely what it reads from and writes to the shared state.
A multi-agent RAG pipeline is a small assembly line of specialists. Each agent reads the shared state, does one job, and writes its result back for the next stage. The canonical five:
Read it straight across left-to-right: five boxes in a row, each an arrow feeding the next, so the question flows from the leftmost stage to the rightmost.
- The leftmost box is the Router / Planner ("classify + plan") — it is where a question enters and gets assigned to sources.
- The arrows between the boxes are the one-directional handoffs: Router → Retriever(s) ("multi-source") → Grader / Re-rank ("drop junk") → Synthesizer ("grounded + cited"). Each stage does one job and passes its result to the next.
- The rightmost box is the Verifier ("faithful?") — the payoff stage: it is the separate final check that asks whether the draft is actually faithful, the role that makes this a pipeline rather than one overloaded agent.
In short: The canonical five roles — router, retriever, grader, synthesizer, verifier — laid out as a left-to-right assembly line, each doing one job and handing off to the next.
| Agent | Job (one thing) | Reads / writes in shared state |
|---|---|---|
| Router / Planner | Classify the question; pick sources; optionally split into sub-questions | reads question → writes plan, sources |
| Retriever agent(s) | Pull candidate chunks from one or more indexes/sources (may be several, in parallel) | reads plan → writes candidates |
| Grader / re-ranker | Score each candidate for relevance; drop junk; keep the top-k | reads candidates → writes context |
| Synthesizer | Write a grounded answer from context, with citations | reads context → writes draft, cites |
| Verifier / critic | Check the draft is faithful to context; can send back to retrieve or re-synthesize | reads draft, context → writes verdict |
3 · Orchestration & shared state
You have five specialists. Now: how do they actually pass work to each other? This is the question that separates a pipeline that you can debug at 2am from one that becomes an untraceable tangle of who-called-whom. The wiring matters as much as the agents, because a bug in a five-agent system is rarely "the synthesizer is wrong" — it's "the grader dropped everything and the synthesizer never noticed." How the agents communicate decides whether you can see that.
Think of the two coordination styles as two ways an office team works. One team shares a single whiteboard: everyone reads the current state off it, does their bit, and writes their result back for the next person. Anyone walking past can glance at the board and know exactly where the work stands. The other team fires emails at each other — flexible and great when the flow is a free-for-all, but reconstructing "what happened" means digging through everyone's inbox. The whiteboard is the blackboard / shared state pattern (one State object every agent reads and writes — what LangGraph's StateGraph gives you); the emails are message passing (AutoGen-style, M2). For a RAG pipeline, whose work flows mostly in one direction, the whiteboard wins: the code reads top-to-bottom like a data flow, and the state object is your debugging view.
Now the part that turns a straight line into a real pipeline, and the part beginners most often get dangerously wrong: the back-edges. A RAG pipeline is not purely one-directional. The grader might look at what it kept, decide the context is too thin, and send control back to the retriever to try again with a rewritten query — that is M3's retrieve→grade loop, now living as an edge between two separate stages. The verifier might reject a draft as unfaithful and send it back to re-synthesize or re-retrieve. So the pipeline is really a small state machine: mostly forward, with a couple of loops. The common — and costly — mistake is to add a back-edge and forget to bound it. An ungroundable question then loops retrieve→grade→retrieve forever, or until your token bill does the stopping for you. Every back-edge must carry a shared counter (the tries field in the lab below) so it can spin only a fixed number of times before the pipeline gives up honestly. A loop without a bound isn't a feature; it's an outage waiting for the wrong input.
How do the agents talk? Two common shapes. In a blackboard / shared scratchpad, every agent reads and writes one state object — simple, inspectable, and what LangGraph's StateGraph gives you. In message passing, agents send messages to each other (AutoGen-style, M2). For a linear-ish RAG pipeline the shared state object is usually the cleaner choice — the pipeline reads like a data flow.
The interesting part is conditional routing: the grader may decide the context is too weak and send control back to retrieve (the M3 loop, now a stage-to-stage edge); the verifier may reject the draft and send back to synthesize or re-retrieve. That is the whole pipeline — a state machine with a couple of back-edges, every one of them bounded.
State object (top); work flows left→right, but the grader and verifier can send control back — and each back-edge is capped by the shared tries counter so it can't spin forever.
Follow the blue arrows left-to-right along the bottom row for the normal flow, then notice the two amber curves looping backward.
- The top box is the one shared
Stateobject — the "blackboard." The dashed lines to it mean every agent reads the current state and writes its result back, so the state object is your debugging view. - The bottom row of blue arrows is the forward path: router → retriever → grader → synthesizer → verifier. Most work flows this one direction.
- The amber back-edges are the loops: the grader can send weak context back to re-retrieve, and the verifier (dashed) can reject an unfaithful draft and send it back too. Each carries the shared
triescounter — a back-edge without that bound is an outage waiting for the wrong input.
In short: The pipeline is a small state machine — mostly forward over a shared blackboard, with two bounded back-edges that let it retry instead of shipping a bad answer.
Lab M5.1 · The orchestration, offline & runnable
You do not need a cluster or an API key to get the orchestration logic right. Here the whole pipeline runs on deterministic fakes — a hash-based fake embed/retrieve, a term-overlap grader, a template synthesizer, a claim-support verifier — so the control flow (routing, the bounded loop, the send-back) is genuinely exercisable. Swap the fakes for real models later; the wiring is what this lab pins down.
run() loop, drawn. Router once, then a bounded loop: retrieve → grade (empty context re-retrieves), synthesize → verify — faithful returns the answer, and exhausting max_tries returns an honest "I don't know" instead of looping.
Follow the blue arrows top-to-bottom for one pass of the loop; the amber curves are the two send-backs, and the two boxes at the bottom are the two ways the run ends.
- The vertical blue chain is the happy path: router runs once, then each loop pass goes retriever → grader → synthesizer → verifier.
- The amber loop from grader back to retriever fires when the grader kept no context and
triesis still undermax_tries— it rewrites the query and retrieves again. The dashed amber loop from the verifier does the same when a draft is unfaithful but budget remains. - The two end boxes are the exits: a faithful verdict returns the grounded answer (green), while running out of
max_triesreturns the explicit "I don't know" (red) — never a fabricated answer.
In short: This is the code below as a picture — one router, a bounded retrieve→grade→synth→verify loop with two send-backs, and an honest give-up when the budget is spent.
pipeline.py"""Multi-agent RAG pipeline — ORCHESTRATION only, fully offline & deterministic.
No API key, no cluster: fake embed/retrieve + grader/synth/verify agents + the loop.
Run: python3 pipeline.py"""
from dataclasses import dataclass, field
# ---- shared state (the blackboard every agent reads/writes) ----------------
@dataclass
class State:
question: str
plan: str = ""
sources: tuple = ()
candidates: list = field(default_factory=list) # retriever output
context: list = field(default_factory=list) # grader-kept chunks
draft: str = ""
cites: list = field(default_factory=list)
verdict: str = ""
tries: int = 0
trace: list = field(default_factory=list)
CORPUS = {
"refund": "Refunds are accepted within 30 days of purchase.",
"reset": "Reset your password in Settings -> Security.",
"shipping":"Standard shipping takes 3-5 business days.",
}
def _score(query, text): # deterministic fake "relevance": prefix-term overlap
q = query.lower().split(); t = text.lower().split()
# count query words that prefix-match some doc word ("refund" ~ "refunds")
return sum(any(w.startswith(qw) or qw.startswith(w) for w in t) for qw in q)
# ---- the specialized agents (each does ONE job) ----------------------------
def router(s: State) -> State:
s.plan = "lookup"; s.sources = ("vector",)
s.trace.append(("router", s.plan)); return s
def retriever(s: State) -> State: # pull candidates from the index
s.candidates = [(k, v, _score(s.question, v)) for k, v in CORPUS.items()]
s.tries += 1
s.trace.append(("retriever", f"try#{s.tries} pulled {len(s.candidates)}")); return s
def grader(s: State, keep_min=1) -> State: # drop junk, keep the relevant
s.context = [(k, v) for k, v, sc in s.candidates if sc >= keep_min]
s.trace.append(("grader", f"kept {len(s.context)}/{len(s.candidates)}")); return s
def synthesizer(s: State) -> State: # grounded, cited draft
if s.context:
s.draft = " ".join(v for _, v in s.context)
s.cites = [k for k, _ in s.context]
else:
s.draft = ""; s.cites = []
s.trace.append(("synth", f"draft={bool(s.draft)} cites={s.cites}")); return s
def verifier(s: State) -> State: # faithful to context? every
ctx = " ".join(v for _, v in s.context).lower() # answer word must appear in ctx
unsupported = [w for w in s.draft.lower().split() if w not in ctx]
s.verdict = "faithful" if s.draft and not unsupported else "unfaithful"
s.trace.append(("verifier", s.verdict)); return s
# ---- orchestrator: the state machine with BOUNDED back-edges ---------------
def run(question, max_tries=3):
s = State(question=question)
s = router(s)
while True:
s = retriever(s)
s = grader(s)
if not s.context and s.tries < max_tries: # weak retrieval -> loop back
s.question += " policy" # (a real router would rewrite)
continue
s = synthesizer(s)
s = verifier(s)
if s.verdict == "faithful":
return {"answer": s.draft, "cites": s.cites, "trace": s.trace}
if s.tries >= max_tries: # bound the send-back loop
return {"answer": "I don't have enough grounded information to answer.",
"cites": [], "trace": s.trace}
# unfaithful but budget left -> send back to retrieve for more context
if __name__ == "__main__":
import json
print(json.dumps(run("what is the refund window"), indent=2))
print(json.dumps(run("who is the president"), indent=2)) # ungroundable -> refuse
{
"answer": "Refunds are accepted within 30 days of purchase.",
"cites": ["refund"],
"trace": [["router","lookup"],["retriever","try#1 pulled 3"],
["grader","kept 1/3"],["synth","draft=True cites=['refund']"],
["verifier","faithful"]]
}
{
"answer": "I don't have enough grounded information to answer.",
"cites": [], "trace": [ ... router/retriever/grader loop x3 ... ]
}
trace, so a single run shows every handoff and every loop. That per-stage log is not decoration — it is how you debug a misroute, a grader that drops everything, or a verifier stuck in a send-back cycle. Keep it in production too (I4).The same shape in a real framework (needs the lib / API key)
The offline lab above is the control flow. A real deployment swaps the fakes for models and expresses the graph in a framework. Here is the LangGraph shape — needs pip install langgraph and a model/API key; the node functions are the same agents:
add_edge calls draw the straight blue path; the two add_conditional_edges at grader and verifier are the amber branches that either move forward (synth / END) or loop back to the retriever — the exact code below.
Read it left-to-right as the graph LangGraph compiles: START on the left, END on the right, one node per agent in between.
- The blue arrows are the unconditional
g.add_edge(...)calls — the fixed forward wiring from START through router, retriever, grader, synth, verify to END. - The two amber "conditional" labels mark the nodes wired with
add_conditional_edges: the grader and the verifier each run a small lambda that chooses the next node. - The amber curves back to
retrieverare the "else" branch of each choice — grader loops back when context is thin, verifier loops back when the draft is unfaithful — but both are capped bytries ≥ 3in the state, so the graph terminates.
In short: Straight edges = fixed hops; conditional edges = the two bounded branches. This picture is a one-to-one map of the code in the block below.
graph_langgraph.py# needs: pip install langgraph + a model/API key. Node fns = the agents above.
from langgraph.graph import StateGraph, START, END
g = StateGraph(dict) # dict state == the blackboard
g.add_node("router", router)
g.add_node("retriever", retriever)
g.add_node("grader", grader)
g.add_node("synth", synthesizer)
g.add_node("verify", verifier)
g.add_edge(START, "router")
g.add_edge("router", "retriever")
g.add_edge("retriever", "grader")
# grader decides: enough context -> synth, else loop back to retriever (bounded in state)
g.add_conditional_edges("grader",
lambda s: "synth" if s["context"] or s["tries"] >= 3 else "retriever",
{"synth": "synth", "retriever": "retriever"})
g.add_edge("synth", "verify")
# verifier decides: faithful -> END, else send back to retriever (bounded)
g.add_conditional_edges("verify",
lambda s: END if s["verdict"] == "faithful" or s["tries"] >= 3 else "retriever",
{END: END, "retriever": "retriever"})
app = g.compile()
# CrewAI (needs: pip install crewai + key) models the same as role-based agents with a
# manager process; see M1. AutoGen (M2) would use message-passing between the agents.
StateGraph / add_conditional_edges API and the CrewAI/AutoGen references above are real but move fast — verify against current docs before you rely on exact signatures. The orchestration logic (the offline lab) is framework-independent and won't rot. Cross-links: M1 CrewAI, M2 AutoGen, L4 LangGraph.4 · Failure modes & control
Every agent you added in §2 was a feature. Every agent you added is also a new way to fail. That is the tax nobody prints on the multi-agent brochure: more moving parts means more moving parts that can jam. A single agent has a small blast radius — it does its one prompt and returns. A five-agent pipeline with a couple of loops has feedback, budget, and hand-off failures a single agent simply cannot have. This section is the honest counterweight to §1's enthusiasm: before you ship a pipeline, you must know the ways it breaks and have a control for each.
The mental model is a factory line with a rework loop. On a well-run line, a station that finds a defect sends the part back to be redone — good. But without limits, three specific disasters follow, and they're worth feeling before you meet them in a table. Loop forever: two stations bounce a part back and forth (grader rejects → retrieve → grader rejects again) and the line never produces anything. Cost blow-up: every trip through a station costs money, and a five-station line run three times is fifteen expensive operations — a single user question can quietly cost fifteen model calls. Deadlock by disagreement: the grader keeps passing a part the verifier keeps rejecting, and the two fight forever because neither has final authority. And the quiet fourth one, no graceful give-up: when the line genuinely can't build the part, a bad factory ships a broken one anyway — the pipeline invents an answer rather than admitting defeat.
The controls all share one idea: someone has to be allowed to stop. The beginner instinct is to trust the agents to sort it out among themselves — they won't, because each is optimizing its own narrow job. So you impose limits from outside. Bound every back-edge with a shared iteration cap (the tries counter from §3). Bound total spend with a call/token budget, and shift cheap work to a cheap model — router and grader don't need your strongest, most expensive model; the synthesizer does. Give the verifier final authority to end a run so a grader/verifier disagreement resolves instead of thrashing, and log the disagreement so you can tune it later. And make the give-up path a real, deliberate output: on budget exhaustion, return an explicit "I don't have enough grounded information to answer" — never a fabricated one. An honest "I don't know" is a feature; a confident wrong answer is the worst thing a RAG system can do. The table names each failure and its control.
A multi-agent pipeline has more moving parts than a single agent, so it has more ways to go wrong. The four that bite in production:
Read it in rows, left-to-right: each red failure on the left maps by a green arrow to the control that neutralizes it on the right.
- The red left column is the four failure modes a pipeline adds that a single agent can't have — loop forever, cost blow-up, grader/verifier deadlock, and shipping a fabricated answer.
- The green right column is the matching control for each: a shared iteration cap, a call/token budget with cheap model tiers, giving the verifier final authority, and an explicit honest refusal.
- The green arrows pair them one-to-one. Notice the common thread — every control is a limit imposed from outside; the agents won't stop on their own because each optimizes only its own narrow job.
In short: More agents means more ways to jam — so for each failure you pre-wire a bound that lets the pipeline stop instead of thrash or lie.
| Failure mode | What it looks like | Control |
|---|---|---|
| Loop forever | grader keeps rejecting → retrieve → grade → retrieve … | Bound every back-edge with a shared tries/iteration cap (M3's lesson, now across stages) |
| Cost blow-up | each agent = tokens; a 3-retry × 5-agent run is 15+ model calls | A budget guard: cap total calls/tokens; use a cheap model for router/grader, a strong one only for synth |
| Grader/verifier disagreement | grader keeps a chunk the verifier then rejects — thrash | Give the verifier authority to end the run; log the disagreement; don't re-loop indefinitely |
| No graceful give-up | pipeline invents an answer rather than admitting defeat | On budget exhaustion, return an explicit 'I don't know' — never a fabricated answer |
Lab M5.2 · A control / budget guard
One small object enforces the bounds: a cap on iterations and on total model calls, shared across all agents. It is offline and deterministic — the point is the accounting, not the model behind each call.
Budget exposes two gates — next_iter() caps how many times the loop spins, spend() caps total model calls — and either one hitting its limit stops the run.
Start at the top box (the one shared budget) and follow its two gates down to the two outcomes on the right.
- The top box is a single
Budgetobject threaded through every agent, holding two independent limits: an iteration cap and a call cap. - The two amber boxes are its gates:
next_iter()is checked at the top of each loop pass (bounds looping), andspend(n)is called on every model call (bounds cost). - The green vs red boxes are the two outcomes: while both limits hold the run continues, but the moment either is exceeded the pipeline stops — the red edge from
spend()is the hard stop a loop-count alone would miss.
In short: Iterations and calls are separate failure axes, so you cap both in one shared object — bounding only one still lets the other blow up.
budget.py"""A shared budget guard for the pipeline. Offline & deterministic.
Bounds BOTH the loop (iterations) and the spend (model calls). Run: python3 budget.py"""
class Budget:
def __init__(self, max_iters=3, max_calls=12):
self.max_iters = max_iters; self.max_calls = max_calls
self.iters = 0; self.calls = 0
def spend(self, n=1): # every agent call goes through here
self.calls += n
if self.calls > self.max_calls:
raise BudgetExceeded(f"call budget blown: {self.calls}/{self.max_calls}")
def next_iter(self) -> bool: # False -> stop looping, give up gracefully
self.iters += 1
return self.iters <= self.max_iters
class BudgetExceeded(Exception):
pass
def guarded_pipeline(question, budget: Budget):
"""Model the loop with the guard deciding when to stop."""
trace = []
while budget.next_iter():
budget.spend(2) # e.g. retriever + grader call
grounded = "refund" in question.lower() # fake: only 'refund' is groundable
trace.append((budget.iters, budget.calls, "hit" if grounded else "miss"))
if grounded:
budget.spend(2) # synth + verify
return {"answer": "Refunds within 30 days.", "cost": budget.calls, "trace": trace}
question += " policy" # rewrite & retry within budget
# loop exhausted WITHOUT a grounded hit -> the honest fallback
return {"answer": "I don't know — no grounded context found.",
"cost": budget.calls, "trace": trace}
if __name__ == "__main__":
print(guarded_pipeline("what is the refund window", Budget()))
print(guarded_pipeline("what is the meaning of life", Budget()))
try:
guarded_pipeline("x", Budget(max_iters=99, max_calls=3))
except BudgetExceeded as e:
print("STOPPED:", e)
{'answer': 'Refunds within 30 days.', 'cost': 4, 'trace': [(1, 2, 'hit')]}
{'answer': "I don't know — no grounded context found.", 'cost': 6, 'trace': [(1,2,'miss'),(2,4,'miss'),(3,6,'miss')]}
STOPPED: call budget blown: 4/3
5 · Evaluating the whole pipeline
Imagine the pipeline's grounded-answer rate drops from 90% to 70% overnight. Something regressed. Which agent? If your only measurement is "did the final answer come out right," you're standing in front of a five-station assembly line that just started producing defects and you have no idea which station broke. You'll spend a day guessing. This section is about never being in that position — and it hinges on one insight beginners routinely miss: evaluating a multi-agent pipeline is not one number, it is two layers of numbers.
Here's the analogy. A car has to pass an end-to-end test — does it drive from A to B safely? — but a mechanic diagnosing a problem also reads per-component gauges: tire pressure, oil level, brake wear. The end-to-end test tells you that something is wrong; the gauges tell you what. A pipeline is identical. The end-to-end metric is the grounded-answer rate: given a real question, is the final answer both grounded and correct? That's the number your lead cares about, the one on the dashboard. But it's the per-stage metrics — retriever recall@k, grader precision, verifier catch-rate — that tell you which agent to fix when the end-to-end number drops. Measure only the end-to-end number and you're the driver who knows the car is sluggish but can't pop the hood.
The trap here is subtle and specific to pipelines, so name it plainly: measuring retrieval recall alone and calling it done. Recall@k is a great retriever gauge, and it's tempting to stop there because M3 and Chapter 3 leaned on it. But a pipeline's whole selling point over M3 was the verifier — and a verifier that rubber-stamps everything is worse than useless, because it makes a single-agent RAG feel safe while adding latency and cost. So the most important pipeline-specific metric is the verifier catch-rate: deliberately feed it drafts you know are ungrounded and measure the fraction it rejects. That number is the honest answer to "does this pipeline actually guard against hallucination, or does it just look like it does?" You reuse the FA5 machinery per stage rather than reinventing it — recall@k for the retriever, faithfulness scoring for the verifier — and layer the end-to-end grounded-answer rate on top. The table splits the two layers so you always know which question each number is answering.
FA5 covers RAG evaluation — retrieval recall@k, faithfulness, and the eval-set scorecard. Do not repeat that work; reuse it per stage. A multi-agent pipeline needs two layers of evaluation:
Read it in two bands: the wide bar across the top is the whole-pipeline number; the small gauges hanging under each agent are the per-stage numbers.
- The top band is the end-to-end metric — the grounded-answer rate over the whole pipeline. It tells you that something regressed but not where.
- The per-agent gauges below (recall@k under the retriever, kept-chunk precision under the grader, catch-rate under the verifier) tell you which agent to fix when the top number drops — like a mechanic's per-component gauges versus a single drive test.
- The highlighted verifier catch-rate is the metric unique to pipelines: feed it drafts you know are ungrounded and measure the fraction it rejects — that separates a real hallucination guard from one that just rubber-stamps.
In short: One end-to-end number says something broke; the per-stage gauges say which agent — and the verifier catch-rate is the one that proves the pipeline was worth building.
| Layer | Question it answers | Metric |
|---|---|---|
| Per-stage | Is each agent doing its job? | Retriever: recall@k (FA5). Grader: precision of kept chunks. Verifier: catch-rate on planted-unfaithful drafts. |
| End-to-end | Given a real question, is the final answer grounded and correct? | Grounded-answer rate: fraction of answers the verifier passes AND that a held-out gold check confirms. |
The end-to-end number is the one leaders care about, but it's the per-stage numbers that tell you which agent to fix when it drops. The most important pipeline-specific metric is the verifier catch-rate: feed it drafts you know are ungrounded and measure how many it rejects — that is the difference between a pipeline that guards hallucination and one that just looks like it does.
Lab M5.3 · Recall@k + a faithfulness gate, offline
Two offline evals that run without a judge model. recall_at_k reuses the FA5 set-overlap idea for the retriever stage; faithfulness_gate is the end-to-end check the verifier stage performs. Deterministic — swap in a real judge later.
Read the two halves independently — the dashed divider splits the per-stage retriever eval (left) from the end-to-end verifier gate (right).
- The left half is
recall_at_k: intersect the retriever'sretrieved[:k]with thegold ids, and divide the overlap size by the gold size — a pure set-overlap number for the retriever stage. - The right half is
faithfulness_gate: take the answer's content words, check what fraction appear in the context words, and compute the supported fraction. - The green vs red boxes are the gate's verdict — the score passes only when it clears the threshold; below it the draft is flagged ungrounded, which is exactly what the verifier agent rejects and sends back.
In short: recall@k gauges the retriever with set overlap; the faithfulness gate gauges the final answer with word support against a threshold — both deterministic and offline.
eval.py"""Pipeline evaluation, offline & deterministic (no judge model needed).
Per-stage recall@k for the retriever + an end-to-end faithfulness gate. Run: python3 eval.py"""
# ---- per-stage: retriever recall@k (FA5 set-overlap idea) ------------------
def recall_at_k(retrieved_ids, gold_ids, k):
top = set(retrieved_ids[:k]); gold = set(gold_ids)
return len(top & gold) / len(gold) if gold else 0.0
# ---- end-to-end: faithfulness gate (what the verifier agent enforces) ------
import re
STOP = {"is","are","the","a","an","of","in","to","and","our","within"}
def _words(text): # lowercase, strip punctuation ("days." -> "days")
return re.findall(r"[a-z0-9]+", text.lower())
def faithfulness(answer, context):
"""Fraction of answer content-words supported by the retrieved context."""
ctx = set(w for c in context for w in _words(c)) - STOP
ans = [w for w in _words(answer) if w not in STOP]
if not ans:
return 0.0
return sum(w in ctx for w in ans) / len(ans)
def faithfulness_gate(answer, context, threshold=0.8):
score = faithfulness(answer, context)
return {"score": round(score, 2),
"verdict": "pass" if score >= threshold else "FAIL (ungrounded)"}
def evaluate_pipeline(cases):
"""cases: list of (retrieved_ids, gold_ids, answer, context)."""
recalls, passes = [], 0
for rid, gid, ans, ctx in cases:
recalls.append(recall_at_k(rid, gid, k=3))
if faithfulness_gate(ans, ctx)["verdict"] == "pass":
passes += 1
return {"mean_recall@3": round(sum(recalls)/len(recalls), 2),
"grounded_answer_rate": round(passes/len(cases), 2)}
if __name__ == "__main__":
print(faithfulness_gate("Refunds are accepted within 30 days.",
["Refunds are accepted within 30 days of purchase."]))
print(faithfulness_gate("The CEO is Bob.", # ungrounded
["Refunds are accepted within 30 days."]))
cases = [
(["refund","reset","x"], ["refund"], "Refunds within 30 days.",
["Refunds are accepted within 30 days."]),
(["a","b","c"], ["shipping"], "The CEO is Bob.", # bad retrieval + ungrounded
["Refunds are accepted within 30 days."]),
]
print(evaluate_pipeline(cases))
{'score': 1.0, 'verdict': 'pass'}
{'score': 0.33, 'verdict': 'FAIL (ungrounded)'}
{'mean_recall@3': 0.5, 'grounded_answer_rate': 0.5}
6 · Production shape (optional)
The offline lab runs on your laptop with fakes. Production is where the fakes become real models, real users, and a real bill — and where a design that looked clean in a diagram meets the unglamorous questions of where it runs, how you watch it, and what happens when it's wrong on a high-stakes answer. The reassuring news: you have already learned most of this. A multi-agent RAG pipeline is a set of stages, and stages deploy exactly like any other LLM service you've shipped in this course. There is no exotic new infrastructure here — just a handful of notes specific to the fact that you now have several agents instead of one.
The instinct to worry about is treating the pipeline as one indivisible black box in production. Don't. Its greatest operational strength is the same thing that made it worth building: it comes pre-divided into stages, and each stage is separately deployable, separately scalable, and separately observable. Heavy retrieval/fusion can be its own scalable service on the infra you know (K7). Each agent gets its own trace (I4) so the per-stage metrics from §5 become real dashboards, not aspirations. Cost tiers from §4 map straight onto deployment — cheap model for router/grader, strong for synthesizer/verifier, budget guard enforcing the ceiling.
Two production concerns deserve special care because they're easy to skip and expensive to skip. First, the human-in-the-loop gate: for high-stakes answers, a verifier FAIL (or a low-confidence pass) should route to a person, not auto-answer — the pipeline's honesty about uncertainty is only useful if something acts on it (L5's HITL pattern). Second, and most under-appreciated: every retrieved chunk is untrusted input. More retrieval hops means more surface for prompt injection — a poisoned document in your index can carry instructions the synthesizer might obey. Treat retrieved text as data, never as commands, and keep any destructive tool actions gated behind their own checks (T1). A pipeline that fuses six sources has six times the injection surface of a single lookup; the checklist below is how you deploy it without shipping that risk to your users.
Where does this run? The pipeline is a set of stages, so it maps cleanly onto the deployment patterns you already know. A few production notes specific to multi-agent RAG:
FAIL on a high-stakes answer routes to a human, and every retrieved chunk is treated as untrusted input.
Read the top row as the deployed pipeline, then the two callouts below it as the production-specific concerns hanging off it.
- The top row is the five stages, each its own separately-scalable, separately-traced service — labelled with its model tier (cheap for router/grader, strong for synth/verifier, retriever as a scalable service).
- The red box (bottom-left) flags the biggest new risk: every retrieved chunk is untrusted input, so fusing many sources multiplies the prompt-injection surface — treat retrieved text as data, never commands.
- The amber human-gate box (bottom-right) is the HITL control: a verifier
FAILor low-confidence pass on a high-stakes answer routes to a person, while low-risk answers auto-return.
In short: Production doesn't need new infra — deploy each stage on its own tier, trace it, gate the risky answers to a human, and treat every retrieved chunk as untrusted.
Production checklist
- Runs as stages — deploy on the same infra as any LLM service (K7); heavy retrieval/fusion can be its own scalable service.
- Observability per agent — trace each stage separately (I4); the per-stage metrics from §5 are your dashboards.
- Human-in-the-loop gate — for high-stakes answers, route a verifier
FAIL(or a low-confidence pass) to a human instead of auto-answering (L5's HITL pattern). - Cost tiers — cheap model for router/grader, strong model for synthesizer/verifier; the budget guard from §4 enforces the ceiling.
- Treat retrieved text as untrusted — more retrieval hops = more prompt-injection surface (T1); keep destructive actions gated.
Common pitfalls
| Pitfall | Fix |
|---|---|
| Multi-agent pipeline where one agent + tools (M3) would do | Start with M3; split only when a stage needs its own prompt/model/eval |
| Any back-edge without a bound | Share a tries/budget cap across all stages; give up gracefully |
| No verifier — the synthesizer marks its own homework | Add a separate faithfulness gate that can send the draft back |
| Grader and verifier fighting forever | Let the verifier end the run; log the disagreement instead of re-looping |
| Evaluating only retrieval recall | Add end-to-end grounded-answer rate + verifier catch-rate (FA5 + §5) |
| Trusting retrieved text across many hops | Every source is untrusted input; guard injection (T1) |
| Assuming the pattern replaces good chunking/rerank | Fix retrieval quality first (Chapter 3, A7) — orchestration can't rescue garbage |
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The grader is the first specialized agent — it protects the synthesizer from junk by scoring each retrieved candidate and keeping only what clears a bar.
Your task: Write grade(candidates, keep_min) that scores candidate chunks against a query and returns only the relevant ones, dropping the rest.
Requirements:
- Take a list of
(id, text)candidates and a query - Score by term overlap between query and chunk (stdlib only)
- Drop any candidate scoring below
keep_min - Return the kept chunks in score order (best first)
- Show a mix of relevant and junk candidates being filtered
💡 Hint: This is the M3 grade step promoted to its own agent; term overlap is a fine stand-in for a relevance model offline.
Show solution
The grader is a filter with a bar. Runnable (stdlib):
def grade(query, candidates, keep_min=1):
q = set(query.lower().split())
scored = [(cid, text, len(q & set(text.lower().split()))) for cid, text in candidates]
kept = [(cid, text) for cid, text, sc in scored if sc >= keep_min]
# best first
kept.sort(key=lambda ct: -len(q & set(ct[1].lower().split())))
return kept
cands = [("a", "Refunds are accepted within 30 days"),
("b", "Our office is in Berlin"),
("c", "Refund requests must include the order id")]
print(grade("how long is the refund window", cands))
# -> keeps a and c (mention refund), drops b
Dropping junk before synthesis is the grader's whole job — a smaller, cleaner context beats a big noisy one every time.
Context: Weak first retrieval shouldn't sink the answer — the grader can send control back to retrieve with a better query, but only within a bound so it can't spin forever.
Your task: Model the retrieve→grade→re-retrieve loop offline with a fake retriever and the grader from Exercise 1, capped at max_tries.
Requirements:
- Fake retriever returns candidates for known query terms, empty otherwise
- If the grader keeps nothing, rewrite/expand the query and retry
- Bound the loop with
max_tries - On exhausting the bound with no context, return the honest give-up
- Show a first-try hit, a rewrite-then-hit, and an ungroundable give-up
💡 Hint: This is M3's loop, but now it lives between two specialized stages rather than inside one agent; the cap is the correctness detail.
Show solution
The loop with a bound — the send-back is a stage-to-stage edge. Runnable:
CORPUS = {"refund": "Refunds within 30 days.", "reset": "Reset in Settings."}
def retrieve(query):
return [(k, v) for k, v in CORPUS.items()
if any(w in query.lower() for w in k.split())]
def grade(query, cands, keep_min=1):
q = set(query.lower().split())
return [(k, v) for k, v in cands if len(q & set(v.lower().split())) >= keep_min or k in query.lower()]
def loop(query, max_tries=3):
for attempt in range(1, max_tries + 1):
context = grade(query, retrieve(query))
if context:
return f"answer from {[k for k,_ in context]} (try {attempt})"
query += " refund" # rewrite & retry
return "give up: no grounded context (would say 'I don't know')"
print(loop("what is the refund window"))
print(loop("money back")) # rewritten -> refund
print(loop("what is the weather")) # ungroundable -> give up
The bound is not optional: without it an ungroundable question loops forever. The give-up path is the honest answer.
Context: The synthesizer can write a fluent answer the context doesn't support. A separate verifier agent checks faithfulness and, if the draft fails, sends it back rather than shipping a hallucination.
Your task: Add a verify(draft, context) agent to the loop that passes faithful drafts and, on failure, triggers a bounded re-synthesis / re-retrieval.
Requirements:
- Verifier scores what fraction of the draft's content words appear in the context
- Pass when the score clears a threshold; otherwise mark unfaithful
- On unfaithful, send back to synthesize (or re-retrieve) within a shared bound
- On exhausting the bound, return the honest 'I don't know'
- Show a faithful draft passing and an ungrounded draft being rejected then given up
💡 Hint: The verifier is the grader's mirror image: the grader guards the input, the verifier guards the output. Give it authority to end the run.
Show solution
The verifier guards the output; the send-back is bounded. Runnable:
STOP = {"is","are","the","a","an","of","within","our"}
def faithfulness(draft, context):
ctx = set(w for c in context for w in c.lower().split()) - STOP
ans = [w for w in draft.lower().split() if w not in STOP]
return sum(w in ctx for w in ans) / len(ans) if ans else 0.0
def verify(draft, context, threshold=0.8):
return "faithful" if faithfulness(draft, context) >= threshold else "unfaithful"
def synth_and_verify(context, bad_synth, max_tries=2):
for attempt in range(1, max_tries + 1):
# attempt 1 uses a (possibly bad) synth; later attempts stick to context
draft = bad_synth if attempt == 1 else " ".join(context)
if verify(draft, context) == "faithful":
return f"ship: '{draft}' (try {attempt})"
return "give up: could not produce a faithful draft -> 'I don't know'"
ctx = ["Refunds are accepted within 30 days."]
print(synth_and_verify(ctx, bad_synth="Refunds are accepted within 30 days.")) # faithful
print(synth_and_verify(["Shipping takes 3-5 days."], bad_synth="The CEO is Bob.")) # rejected
Without the verifier the synthesizer marks its own homework. Giving a separate agent authority to reject-and-resend is exactly why you'd choose a pipeline over M3.
Context: Real pipelines retrieve from several sources/indexes and must fuse the ranked lists into one. Reciprocal Rank Fusion (RRF) combines rankings without needing comparable scores.
Your task: Implement rrf(rankings, k) that fuses several ranked candidate lists into a single ranking, then feed the fused list into the grader stage.
Requirements:
- Take several ranked lists (each a list of doc ids, best first)
- Score each doc by the RRF formula: sum of 1/(k + rank) across lists
- Return doc ids sorted by fused score (best first)
- Use only the standard library
- Show two sources whose disagreement is resolved by the fusion
💡 Hint: RRF score for a doc = sum over lists of 1/(k + rank_in_that_list); k≈60 is the common default. Docs that rank well in multiple lists float to the top.
Show solution
RRF fuses rankings without comparable scores. Runnable (stdlib):
from collections import defaultdict
def rrf(rankings, k=60):
"""rankings: list of ranked id-lists (best first). Returns fused id list."""
score = defaultdict(float)
for ranked in rankings:
for rank, doc_id in enumerate(ranked): # rank 0 = best
score[doc_id] += 1.0 / (k + rank + 1)
return sorted(score, key=lambda d: -score[d])
vector_hits = ["d1", "d2", "d3"] # source A's ranking
graph_hits = ["d3", "d1", "d4"] # source B disagrees
fused = rrf([vector_hits, graph_hits])
print(fused) # d1 & d3 rank well in BOTH -> float to the top
# feed the fused list into the grader from Exercise 1
RRF is the workhorse for combining multi-source retrieval: no score calibration needed, and docs both sources like win. The fused list becomes the retriever agent's output that the grader then filters.
Context: A production pipeline must not blow its cost or latency ceiling, and when a stage regresses you need to know which one. A shared budget guard plus per-stage timing/counting gives you both.
Your task: Wrap the pipeline stages with a shared budget (iterations + calls) and a per-stage counter, and emit a run report showing spend and where time/calls went.
Requirements:
- Share one budget across all stages: cap iterations AND total calls
- Count calls (and optionally timing) per stage
- Raise/stop when either bound is exceeded — no silent overrun
- Emit a per-stage report (calls per agent) plus total spend
- Show a normal run and a run that hits the budget
💡 Hint: One Budget object threaded through every stage is enough; a dict keyed by stage name gives you the per-stage breakdown for the dashboard.
Show solution
Shared budget + per-stage accounting. Runnable:
from collections import defaultdict
class Budget:
def __init__(self, max_iters=3, max_calls=12):
self.max_iters, self.max_calls = max_iters, max_calls
self.iters = 0; self.by_stage = defaultdict(int)
@property
def calls(self): return sum(self.by_stage.values())
def spend(self, stage, n=1):
self.by_stage[stage] += n
if self.calls > self.max_calls:
raise RuntimeError(f"budget blown: {self.calls}/{self.max_calls}")
def next_iter(self):
self.iters += 1
return self.iters <= self.max_iters
def run(question, budget):
while budget.next_iter():
budget.spend("router"); budget.spend("retriever"); budget.spend("grader")
if "refund" in question.lower():
budget.spend("synth"); budget.spend("verifier")
return {"answer": "Refunds within 30 days.",
"spend": budget.calls, "per_stage": dict(budget.by_stage)}
question += " policy"
return {"answer": "I don't know.", "spend": budget.calls,
"per_stage": dict(budget.by_stage)}
print(run("refund window", Budget()))
print(run("meaning of life", Budget(max_iters=2)))
try:
run("x", Budget(max_calls=2))
except RuntimeError as e:
print("STOPPED:", e)
The per-stage counts are your observability dashboard in miniature: when cost regresses, the breakdown tells you the router is over-calling or the loop is thrashing. Trace each stage in production (I4).
Context: You are asked to design a research assistant: given a question, it retrieves from an internal index and the web, drops junk, writes a cited answer, and verifies faithfulness — all under a p95 latency SLO and a per-query cost ceiling, with an honest 'I don't know' when it can't ground the answer.
Your task: Produce a design + an offline reference orchestrator that models the whole pipeline (router → multi-source retrieve → fuse → grade → synth → verify) under a shared budget, then sketch the framework mapping labelled as needing the SDK.
Requirements:
- Model all five stages with a shared budget (iterations + calls) enforcing the SLO
- Fuse multi-source retrieval (reuse RRF from Exercise 4) before grading
- Verifier can send back once within budget; else return honest 'I don't know'
- Emit a per-stage trace + total spend for observability
- State the end-to-end eval you'd gate on (grounded-answer rate + verifier catch-rate, FA5 + §5)
- Include a commented LangGraph/CrewAI mapping clearly labelled 'needs the lib / API key'
💡 Hint: Everything except the model calls is offline-runnable. The SLO is enforced by the budget object; the design doc is the deliverable that ties per-stage evals to the ceiling.
Show solution
The offline reference pipeline under an SLO budget, framework mapping labelled. Runnable:
from collections import defaultdict
def rrf(rankings, k=60):
s = defaultdict(float)
for r in rankings:
for i, d in enumerate(r): s[d] += 1/(k+i+1)
return sorted(s, key=lambda d: -s[d])
INDEX = {"refund": "Refunds within 30 days.", "ship": "Shipping 3-5 days."}
WEB = {"refund": "Blog: refunds honored in 30 days."}
class Budget:
def __init__(self, max_iters=2, max_calls=14):
self.max_iters, self.max_calls, self.iters = max_iters, max_calls, 0
self.by_stage = defaultdict(int)
@property
def calls(self): return sum(self.by_stage.values())
def spend(self, stage, n=1):
self.by_stage[stage]+=n
if self.calls>self.max_calls: raise RuntimeError("SLO cost ceiling hit")
def next_iter(self):
self.iters+=1; return self.iters<=self.max_iters
STOP={"is","are","the","a","of","within","in"}
def faithful(draft, ctx):
c=set(w for t in ctx for w in t.lower().split())-STOP
a=[w for w in draft.lower().split() if w not in STOP]
return (sum(w in c for w in a)/len(a) if a else 0)>=0.7
def assistant(question, budget):
trace=[]
while budget.next_iter():
budget.spend("router")
key=next((k for k in INDEX if k in question.lower()), None)
# multi-source retrieve + fuse
budget.spend("retriever", 2)
idx=[key] if key in INDEX else []
web=[key] if key in WEB else []
fused=rrf([idx, web]) if (idx or web) else []
budget.spend("grader")
ctx=[INDEX.get(d) or WEB.get(d) for d in fused if (INDEX.get(d) or WEB.get(d))]
trace.append((budget.iters, "hit" if ctx else "miss", budget.calls))
if not ctx:
question+=" policy"; continue
budget.spend("synth"); draft=" ".join(ctx)
budget.spend("verifier")
if faithful(draft, ctx):
return {"answer": draft, "cost": budget.calls,
"per_stage": dict(budget.by_stage), "trace": trace}
# unfaithful -> loop back within budget
return {"answer": "I don't know — could not ground the answer.",
"cost": budget.calls, "per_stage": dict(budget.by_stage), "trace": trace}
import json
print(json.dumps(assistant("what is the refund window", Budget()), indent=2))
print(json.dumps(assistant("who won the election", Budget()), indent=2))
# needs the lib / an API key -- production mapping (verify against current docs):
# LangGraph: StateGraph(dict) with nodes router/retriever/grader/synth/verify;
# conditional edges enforce the bounded loop; compile() -> app. (L4)
# CrewAI: role-based agents (researcher/grader/writer/critic) + a manager process;
# the budget becomes a max_iter / step cap. (M1)
# Enforce the SLO with the Budget object; gate deploys on end-to-end grounded-answer
# rate AND verifier catch-rate from a held-out eval set (FA5 + section 5).
Design notes: the SLO is enforced by the shared Budget (iterations bound latency, calls bound cost); multi-source retrieval is fused with RRF before grading; the verifier gets one bounded send-back before the honest give-up. Ship it only when the end-to-end grounded-answer rate and verifier catch-rate clear your bar — that is the gate the crew project (P12) formalizes.
✓ Checkpoint — you can move on when you can…
- Say when a multi-agent RAG pipeline earns its cost — and when M3's single agent is enough.
- Name the five roles and what each reads/writes in shared state.
- Wire the pipeline with conditional routing and a bounded retrieve→grade→re-retrieve loop.
- Guard loop-forever, cost blow-up, and grader/verifier disagreement — and give up honestly.
- Evaluate the pipeline end-to-end (grounded-answer rate + verifier catch-rate), not recall alone.
Knowledge check
When is a multi-agent RAG pipeline worth its extra cost over the single-agent agentic RAG of M3?
Show answer
Why do you need both a grader and a verifier, and what stops the pipeline from looping forever between them?