AI EngineeringZero to ProductionHome·About·What’s new·Contact
Project 10 · Design Chapter

Agentic RAG Knowledge Assistant

Ordinary RAG retrieves once and answers. An agentic RAG assistant reasons about retrieval: it decides what to look up, searches multiple times, judges whether the results are good enough, reformulates when they aren't, and only answers when it has real evidence. It's the difference between a lookup and a researcher.

🎯 Advanced📈 trending🧠 internal knowledge / supportagentic + RAG
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it (set USE_REAL_API=1) you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the pieces code):Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.
Builds on Semantic Search + Deep ResearchThis composes Project 9 (the retrieval engine), M3 (Agentic RAG), and the loop discipline of Project 6 (Deep Research). If plain RAG (Ch 3) is a single lookup, this is a retrieval loop with self-critique.

What this project teaches you to design

  • A retrieval loop: query → judge relevance → reformulate/re-search → answer.
  • Query planning: decomposing a complex question into sub-queries.
  • Self-grading of retrieved context, and abstaining when evidence is thin.
  • Multi-source retrieval (docs, DB, web) chosen by the agent.

The brief advanced

You demo the bot and it dazzles. "What's our refund window?" — instant, cited, correct. Everyone nods, you ship it. Then the first real support ticket lands: "The customer bought under the 2023 policy but is asking about a product we discontinued — does the old warranty still apply?" Your bot retrieves the current policy, sees nothing about 2023 or discontinued products, and either shrugs or — worse — confidently makes something up. The demo questions and the real questions were never the same species.

Here's the pattern behind that failure, and it's the whole reason this project exists. A single-shot retriever is like sending an intern to the filing cabinet with one search term and no permission to come back. If the answer lives across three folders, or the first search term was slightly wrong, the intern returns with a partial folder and you get a partial (or invented) answer. A good researcher, by contrast, searches, reads what came back, notices what's missing, and searches again with a sharper query — looping until the evidence actually covers the question. That loop is the entire difference, and it's what "agentic" means here: not a smarter model, but a model given the authority to search more than once and to judge its own results.

Why does this matter for a design brief specifically? Because the temptation is to "fix" bad answers by swapping in a bigger model or a fancier embedding. Neither helps when the real problem is that the system only looked once. The common mistake teams make is treating retrieval quality as a model problem when it's a control-flow problem — the fix is a loop with a self-check, not a better one-shot. Everything below designs that loop.

"Our chatbot answers easy questions but falls apart on real ones." Single-shot RAG fails when the answer needs several lookups, when the first retrieval misses, or when the question spans multiple sources. An agentic RAG assistant plans its search, checks its own results, and iterates — so it handles the multi-hop, compound questions that a one-shot retriever gets wrong.

single-shot intern — search once researcher — search until covered question search x1 partial evidence answers anyway → half-answer or made-up question search + check gap missing?re-search covered → answer The whole difference is the loop-back. The intern (left) searches once and answers on partial evidence; the researcher (right) checks what's missing and searches again until the evidence actually covers the question.
🗺️ How to read this diagram

Read the two panels side by side, split by the dashed line: the left is today's single-shot bot, the right is the agentic assistant this project builds.

  • The left (blue → red) path goes question → one search → and then answers straight off partial evidence, so you get a half-answer or an invented one — the red box is the failure the brief opens with.
  • The right (amber) path adds a "check the gap" step after searching; the curved arrow back up is the re-search when something is still missing.
  • The green "covered → answer" box only fires once the evidence genuinely covers the question — that gated exit is what stops the researcher from answering too early.

In short: single-shot answers after one search whether or not it has enough; the researcher only answers once a self-check says the evidence is complete.

1 · Discovery — where does plain RAG break? advanced

Before you build the loop, earn the right to build it. Agentic RAG is more expensive and slower than plain RAG — every extra retrieval is another round-trip and every self-grade is another model call. If you wrap the whole loop around questions that a single lookup already answered, you've added latency and cost and gotten nothing back. So the first design move isn't to build; it's to find the questions where a plain retriever actually breaks, and confirm there are enough of them to justify the machinery.

Think of it like deciding whether to hire a research analyst versus using a search box. For "what's the capital of France," the search box wins — instant, free, right. You only want the analyst for questions that need several steps: cross-referencing sources, chasing a lead the first search surfaced, reconciling documents that disagree. The table below is a triage of exactly that: which question shapes give agentic retrieval real leverage (multi-hop, missed-first-retrieval, cross-source) and which are a waste of it. Read the ⭐ ratings as "how much does the loop buy me here" — and notice the bottom row is a deliberate warning, not an oversight.

The mistake to avoid: assuming "agentic is better, so use it everywhere." The honest design isn't loop-everything — it's route. Cheap single-fact questions go straight to plain RAG; only the hard shapes enter the loop. Discovery is where you gather the evidence that this split is worth engineering, and it's what your evaluation in §6 will later have to prove.

discovery isn't loop-everything — it's routing the question to the right cost question incoming route? what shape is it plain RAG — one lookup single-fact · cheap · fast agentic loop — search + judge multi-hop · missed-first · cross-src easy → hard → Route, don't loop-everything. The expensive retrieval loop only earns its keep on the hard question shapes; single-fact lookups should go straight to cheap plain RAG.
🗺️ How to read this diagram

Follow the question left to right: it hits a router in the middle that sends it down one of two paths depending on how hard it is.

  • The blue "question" box on the left is any incoming user question; the amber "route?" box is the triage decision — this is the design move §1 is really about.
  • The upper green path ("easy →") is cheap plain RAG: one lookup answers a single-fact question, so paying for the loop would only add latency and cost.
  • The lower purple path ("hard →") is the agentic loop, reserved for the shapes the table below rates ⭐⭐⭐: multi-hop, first-retrieval-misses, and cross-source questions where searching more than once actually pays off.

In short: the value of agentic RAG isn't using it on everything — it's sending only the hard questions into the loop and letting the easy ones take the cheap path.

Hard caseAgentic leverage
Multi-hop ("compare our policy to the 2024 one")⭐⭐⭐ high — plan + multiple retrievals
First retrieval misses the answer⭐⭐⭐ high — judge results, reformulate, retry
Answer spans docs + database + web⭐⭐ medium — agent picks the source
Simple single-fact lookup⭐ low — plain RAG is cheaper; don't over-loop
Problem statement"Our assistant gives up or hallucinates on questions that need more than one lookup. We want it to think about what it needs, search as many times as it takes, check that the evidence actually answers the question, and say 'I don't have enough to answer' rather than guess."

2 · Architecture advanced

The moment you say "search again if the first result is bad," you've stopped drawing a pipeline and started drawing a loop — and loops are where systems get away from you. A straight line always terminates. A loop can spin forever, and if each spin costs an API call, "forever" costs money. So the architecture's real job isn't just wiring boxes together; it's deciding where the loop is allowed to go around again, and — just as importantly — where it must stop.

Picture the four moving parts as roles on a tiny research team. Plan is the lead who breaks a fat question into searchable sub-questions. Retrieve is the runner who goes to the shelf and brings back documents. Grade is the skeptical editor who reads what came back and asks one question — "does this actually answer what was asked, yes or no?" And the two exits are the editor's verdict: publish with citations, or refuse to publish because the evidence isn't there. The diagram below is exactly this team, drawn as a cycle with that skeptic in the middle.

who talks to whom — the real components and how a question flows through them user question agent loop plan → sub-queries judge_sufficiency MAX_STEPS leash retrieve P9 search engine embed → top-k vector store DB / web retrieved context → grade sufficient → answer + citations leash exhausted → abstain — not enough Every arrow into the grader can turn back. The user's question drives an agent loop that plans, calls the Project 9 search engine (backed by a vector store) plus DB/web sources, and grades the returned context; a sufficient grade emits a cited answer, an insufficient one loops back under the MAX_STEPS leash, and running out of steps abstains.
🗺️ How to read this diagram

Trace one question through the components left to right, then watch where it can bend back — this is the wiring behind the loop the next figure draws abstractly.

  • The user question (left) feeds the agent loop (purple) — the only stateful component; inside it the amber plan and judge_sufficiency steps run under the MAX_STEPS leash.
  • The loop retrieves through the Project 9 search engine (blue), which embeds the sub-query and pulls top-k from the vector store (green) or from DB / web sources — the amber return arrow carries that context back up to the grader.
  • Two terminal boxes on the right: a green "answer + citations" when the grade is sufficient, and a red "abstain" reached by the dashed path when the leash is exhausted — evidence never earns the exit, so the system refuses rather than guesses.

In short: the agent loop is the hub; the search engine + vector store + DB/web are the sources it queries, and the grader is the one component every retrieval must satisfy before an answer leaves the system.

The common mistake here is to draw the happy path (question → answer) beautifully and treat the loop-back and the abstain exit as afterthoughts. They're the opposite: the happy path is what plain RAG already gave you, and these two edges are the entire reason this project earns the word "agentic."

question plan querysub-queries retrieveP9 engine / DB / web grade contextgood enough? answer + cite abstainnot enough evidence insufficient → reformulate & re-search
🗺️ How to read this diagram

This is the whole project in one picture: a loop, not a straight line. Read it left to right, but notice the pink arrow at the bottom that curls back — that return path is what makes this 'agentic' RAG instead of ordinary RAG.

  • question (far left) — the user's real, possibly multi-part question comes in.
  • plan query → sub-queries (purple box) — the agent breaks a hard question into smaller searchable pieces instead of searching for the whole thing at once.
  • retrieve — it looks the sub-query up in a source: the Project 9 search engine, a database, or the web. This is the actual 'go find documents' step.
  • grade context (yellow box, "good enough?") — the key step. The agent stops and asks itself: does what I just found actually answer the question?
  • Two exits on the right. Green answer + cite if the evidence is sufficient; red abstain ("not enough evidence") if it never becomes sufficient.
  • The pink loop-back arrow ("insufficient → reformulate & re-search") is the heart of it: when the grade says 'not enough', the agent rewrites its query and searches again, up to a fixed number of tries.

In short: ordinary RAG is just question → retrieve → answer. This diagram adds a self-check ("grade context") and a way to try again — that single feedback loop is the entire idea of agentic RAG.

A loop, not a line. The agent plans (decomposing the question into sub-queries), retrieves from the best source, then grades whether the context actually answers the question. If yes → answer with citations. If no → reformulate and search again. If it can't find evidence after a bounded number of tries → abstain. This is ReAct (E1) applied to retrieval.

3 · Risk & safety model advanced

The scariest bug in an agentic RAG system isn't a crash — it's a $400 invoice for a question nobody could answer. A retrieval loop that can decide to search again is, by construction, a loop that can decide to search again forever: the grade keeps coming back "insufficient," the agent keeps reformulating, and each pass burns tokens on a question the corpus simply doesn't contain the answer to. Nothing errors. It just spins, and you find out when the bill arrives. The autonomy you gave the loop in §2 is precisely the thing that has to be constrained here.

The mental model that keeps this straight: an autonomous loop is a dog off the leash. Off-leash is what makes it useful — it can chase the answer down paths you didn't script. But a dog off the leash near a road needs a fence and a whistle, or one day it doesn't come back. The controls in the table below are that fence: a hard cap on iterations (it can search at most N times, then it must stop), a token/cost budget (it can spend at most this much, then it must stop), and a graceful degrade path ("best effort, with a caveat") for when it hits either limit. None of these make the agent smarter — they make its failure bounded.

Read the table as two colors of risk. The 🔴 rows — answering without evidence, hallucinated citations — are trust failures: the system lies, and one confident wrong answer poisons user trust in every right one. The 🟠 rows — runaway loops, drifting reformulations — are resource failures: the system wastes money or wanders off-topic. The common mistake is to obsess over the trust failures (they're embarrassing) and forget the resource ones (they're just expensive) — but in production it's usually the unbounded loop that pages you at 3 a.m. Design both leashes in from the start; retrofitting a cost cap onto a live loop is a bad afternoon.

without a leash with a leash re-search grade → insufficient no exit → spins forever, $$$ step < MAX & under budget? retrieve → grade → retry cap / budget hit abstain / best-effort + caveat The leash turns "forever" into "at most N." A hard step cap plus a cost budget guarantee the loop terminates — and when it hits a limit it degrades to abstain / best-effort-with-caveat instead of spinning.
🗺️ How to read this diagram

Compare the two panels side by side — the red one on the left is the danger, the plain one on the right is the fix.

  • The left red panel is an unbounded loop: the dashed circle is "grade says insufficient → re-search" with no exit, so it can spin forever and burn cost — nothing errors, the bill just grows.
  • The right panel adds the leash. The amber gate at the top checks two limits every pass — "step < MAX and still under the cost budget?" — before it's allowed to retrieve and grade again.
  • The dashed "cap / budget hit" arrow is the escape hatch: when either limit is reached, the loop drops into the green box — abstain, or best-effort with a caveat — rather than looping on.

In short: the two 🟠 controls in the table below (hard iteration cap + cost budget) are exactly what converts the left panel into the right one — bounded failure instead of a runaway bill.

RiskControl
🔴 Answering without sufficient evidenceSelf-grading gate — must pass a relevance check; abstain otherwise
🟠 Infinite / expensive retrieval loopsHard cap on iterations + a token/cost budget (E2); degrade to "best effort + caveat"
🟠 Reformulation drifting off-topicAnchor every sub-query to the original question; log the trajectory
🔴 Hallucinated citationsCitations must reference actually-retrieved chunks; verify before answering (Project 7)
🟠 Wrong source chosenConstrain tool choice; prefer authoritative internal sources over web
The loop needs a leashAn agent that can re-search freely can also loop forever and burn money. Every agentic-RAG system needs a hard iteration cap and a cost budget, plus a graceful "I couldn't fully verify this" path. Autonomy without bounds is the #1 way these fail in production.

4 · The retrieval loop, in code advanced

Every diagram you've seen so far collapses into about ten lines of Python — and it's worth seeing that collapse happen, because it kills a myth. Newcomers assume "agentic" means a heavyweight framework, a graph engine, some orchestration layer. It doesn't. The core of an agentic RAG system is a for loop with a range bound and an if that breaks early. If you can read a loop that stops after N tries unless it hits a good result sooner, you can read this whole system.

The shape to hold in your head as you read the snippet: it's a search-until-satisfied loop with a hard ceiling, exactly like retrying a flaky network call — try, check the result, retry with a tweak if it failed, but never more than a fixed number of times. Four verbs do the work — plan the sub-queries, retrieve the hits, judge_sufficiency on what came back, and reformulate when it's not enough — and the range(max_steps) is the leash from §3 made literal: the loop cannot run more than max_steps times, so "forever" is impossible by construction.

Watch one thing especially: the loop has two ways out and they mean opposite things. Returning early with a cited answer is success — the evidence cleared the bar. Falling out the bottom to abstain is honest failure — the leash ran out before the evidence did. A loop with only the success exit is the classic beginner bug: it either loops forever or, worse, returns a confident answer built on evidence it never actually verified. The abstain line is short, easy to skip, and the most important line in the file.

range(max_steps) — the loop cannot run more than the leash allows plan(question) retrieve → context += hits judge_sufficiency? reformulate no → loop back answer+cite yes → abstain — leash ran out Ten lines, four verbs, two exits. plan runs once; then retrieve → judge_sufficiency repeats inside range(max_steps). A "yes" grade exits green (cited answer); running out of steps falls through to the red abstain.
🗺️ How to read this diagram

Follow it top-to-bottom, and note the loop is the middle band — the arrow curling back up on the right is the range(max_steps) cycle.

  • plan(question) (purple, top) runs once to break the question into sub-queries before the loop starts.
  • The loop body is retrieve (blue) then judge_sufficiency (amber) — the self-critique that decides everything.
  • A "yes" grade exits left to the green answer+cite; a "no" grade goes right to reformulate and the arrow loops back to retrieve again — but only while steps remain.
  • The dashed arrow down to the red abstain box is the second exit: when range(max_steps) is exhausted without a "yes", the loop falls through here — the short line that keeps the system honest.

In short: plan once, then loop retrieve→judge under a hard step cap; green exit = enough evidence, red exit = leash ran out.

Setup to run this snippet
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
def abstain(*a, **k):  # demo stub
    return _Any()
def generate_answer(*a, **k):  # demo stub
    return _Any()
def judge_sufficiency(*a, **k):  # demo stub
    return _Any()
def next_query(*a, **k):  # demo stub
    return _Any()
def plan(*a, **k):  # demo stub
    return _Any()
def reformulate(*a, **k):  # demo stub
    return _Any()
def retrieve(*a, **k):  # demo stub
    return _Any()
agentic_rag.py (shape)def answer(question, max_steps=4):
    subqs = plan(question)                 # decompose (E1)
    context = []
    for step in range(max_steps):     # the leash
        hits = retrieve(next_query(subqs, context))   # P9 engine
        context += hits
        grade = judge_sufficiency(question, context)  # self-critique
        if grade.sufficient:
            return generate_answer(question, context)  # cited
        subqs = reformulate(question, grade.gap)      # try again
    return abstain(question, context)   # honest "not enough evidence"
▶ How this works

Before any real files, this is the shape of the whole system in ten lines — a map you'll recognise in every file that follows. Don't run it; read it as pseudocode that names the four moving parts: plan, retrieve, judge_sufficiency, and reformulate.

  1. def answer(question, max_steps=4): — the whole assistant is one function. max_steps=4 is the leash: it can search at most four times, then it must stop. That cap is a safety feature, not a detail.
  2. subqs = plan(question) breaks the question into sub-questions; context = [] is the empty notebook where retrieved facts pile up.
  3. for step in range(max_steps): repeats the search cycle up to four times. Each pass: retrieve(...) fetches hits, context += hits adds them to the notebook, and judge_sufficiency(...) asks 'is this enough?'.
  4. if grade.sufficient: return generate_answer(...) — the moment the evidence is good enough, it writes a cited answer and leaves the loop. Otherwise reformulate(question, grade.gap) rewrites the query to chase the missing piece and the loop goes round again.
  5. If all four tries pass without ever being sufficient, the last line return abstain(...) runs — an honest 'I don't have enough to answer'.

What the output means: Nothing prints — this is a skeleton to read. The real, runnable version is loop.py in Step 5, which follows this exact shape.

Try this: Find the four verbs — plan, retrieve, judge, reformulate — in the picture above. This code is that diagram written out. Everything after here just fills in each verb with real Python.

The self-grader is the whole ideaThe single component that turns RAG "agentic" is judge_sufficiency: an LLM call that reads the question and the retrieved context and answers "can this be answered from here — yes/no, and what's missing?" That grade drives the loop. Get it right and everything else follows.

5 · Tool surface advanced

Give the agent a web-search tool "just in case," and one day it answers an internal-policy question by quoting a random blog it found online. That's the trap of the tool surface: every capability you hand the agent is a door it might walk through, and the ones that reach outside your trusted data are doors to answers you can't vouch for. So the tool surface isn't a menu of "everything we could wire up" — it's a deliberately short, deliberately typed list of what the agent is allowed to do, with the risky doors clearly marked.

A useful frame: think of the tools as the ingredients an agent is permitted to cook with, sorted by how much you trust the source. Your internal knowledge base and read-only database are the pantry — vetted, owned, safe to serve. The open web is the stuff a stranger left on the doorstep — sometimes exactly what you needed, sometimes spoiled, always to be labeled before it goes on the plate. The table below sorts the surface this way: 🟢 read-only internal sources you trust by default, and 🟠 the web tool that's fenced off with "prefer internal, label web." And notice judge_sufficiency sits in the list too — the self-grader is itself a tool, the one that decides when to stop reaching for the others.

Two design instincts to build here. First, read-only is a feature, not a limitation: a retrieval agent that can only read can't corrupt your data no matter how badly it reasons, which shrinks the blast radius of every other bug on this page. Second, the common mistake is adding tools because they're impressive rather than because a question needs them — every extra tool is another choice the agent can get wrong and another attack surface. Keep the surface small; make each entry earn its place against a real question shape from §1.

tools sorted by trust — the agent reaches into the pantry first agent loop chooses a tool 🟢 trusted pantry — read-only search_knowledge query_database judge_sufficiency plan / refine 🟠 doorstep — untrusted web_search (optional) prefer internal · label web output default → only if needed → Sort the tool surface by trust. Read-only internal tools are the default pantry; the one external tool (web_search) is fenced off as untrusted — prefer internal, and label anything the web returns.
🗺️ How to read this diagram

Start at the amber box on top — the agent choosing a tool — and follow its two arrows down into the two trust tiers.

  • The green box (left) is the trusted "pantry": the read-only tools you own — search_knowledge, query_database, the judge_sufficiency gate, and the plan / reformulate reasoning helpers. The solid "default →" arrow means the agent reaches here first.
  • The red box (right) is the untrusted "doorstep": the optional web_search. The dashed "only if needed →" arrow means it's a fallback, and its output must be labeled because you can't vouch for it.
  • Notice judge_sufficiency lives inside the pantry — the self-grader is itself a tool, the one that decides when the agent has enough and can stop reaching for the others.

In short: every tool is a door; the green ones are safe to open by default, the red one is fenced off — keep the surface small and trust-sorted.

ToolDoesRisk
search_knowledgeSemantic/hybrid retrieval (Project 9)🟢 read-only
query_databaseRead structured facts (read-only SQL)🟢 read-only
web_search (optional)External info when internal is insufficient🟠 untrusted source — prefer internal, label web
judge_sufficiencyGrade whether context answers the question🟢 the control gate
plan / reformulateDecompose & refine queries🟢 reasoning

6 · Evaluation advanced

"It feels smarter" is not a metric, and it's how good agentic RAG systems get quietly killed. You built the loop, it demos well, everyone's impressed — and then someone asks "is it actually better than the plain RAG we already had, or just slower?" If you can't answer with a number, the honest answer is you don't know, and the loop's extra latency and cost become impossible to defend. Evaluation is where you convert a feeling into evidence.

The trap unique to this system is that a single accuracy number lies. Imagine a version that answers every multi-hop question perfectly but also confidently answers questions it should have abstained on — high accuracy, terrible in production. Or one that abstains constantly to stay "safe" — never wrong, never useful. You have to measure the loop along its own axes, and the table below lays them out: correctness on the hard multi-hop set (where plain RAG fails and this should win), faithfulness and citation-correctness (does every claim trace to retrieved evidence, or did it garnish?), abstention-correctness (does it say "I don't know" exactly when it should — not more, not less), and steps/cost per answer (is the loop looping more than the question needed?).

But the one measurement that decides whether this project should exist is the last row: uplift versus plain RAG. This is the researcher-versus-search-box test made quantitative — run both systems on the same question set and compare. If the loop only ties plain RAG, you're paying for machinery that buys nothing, and the right call is to route those questions to the cheaper path. The common mistake is skipping the baseline entirely and reporting the agentic system's numbers in isolation; a number with no baseline can't tell you if the loop earned its keep. Always measure against the thing it's supposed to beat.

run both systems on the same set — the gap on hard questions is the uplift high low EASY · single-fact HARD · multi-hop plain agentic plain agentic uplift No baseline, no verdict. On easy questions the two systems tie (so the loop only adds cost there); on hard multi-hop questions the agentic bar towers over plain RAG — that gap is the uplift the last table row demands you measure.
🗺️ How to read this diagram

Read it as a bar chart: taller bars are better scores, and each cluster compares plain RAG (blue) against agentic RAG (purple) on one kind of question.

  • The left cluster (EASY · single-fact) shows the two bars at almost the same height — on simple lookups the loop wins nothing, so its extra latency and cost are pure waste. This is why §1 says route easy questions to the cheap path.
  • The right cluster (HARD · multi-hop) is the point: the blue plain-RAG bar is short (it fails questions needing more than one lookup) while the purple agentic bar is tall.
  • The double-headed "uplift" arrow between the two right bars is the single number that justifies the whole project — how much the loop beats the baseline where it matters.

In short: always measure against plain RAG; the loop earns its keep only if the hard-question gap (the uplift) is real, and it should be near-zero on easy ones.

EvalMeasures
Answer correctness (multi-hop set)Accuracy on questions that need >1 retrieval — where plain RAG fails
Faithfulness & citation correctnessEvery claim grounded in retrieved evidence (Ch 5)
Abstention correctnessAbstains when the answer truly isn't retrievable
Steps / cost per answerEfficiency — is it looping more than needed?
Uplift vs plain RAGProve the agentic loop beats single-shot on hard questions (and isn't wasteful on easy ones)
Justify the loop with a baselineAlways compare against plain single-shot RAG. If agentic RAG only ties on your questions, you're paying extra latency and cost for nothing — route easy questions to plain RAG and reserve the loop for the hard ones. Measure the split.

7 · Phased rollout expert

The failure mode here is building the whole loop, shipping it, watching it misbehave, and having no idea which piece is at fault. Was it the planner splitting the question badly? The grader passing weak evidence? The reformulation drifting off-topic? When everything went live at once, you can't tell — you have to debug the whole machine as one opaque unit. Phased rollout is the antidote: turn the pieces on one at a time so that when quality moves, you know exactly which piece moved it.

Think of it as adding one instrument to a band and listening before adding the next. Phase 1 is plain RAG alone — one retrieve, one answer, with citations and an abstain path. That's not a throwaway; it's your baseline, the number every later phase has to beat (this is the uplift comparison from §6, built in from day one). Phase 2 adds just the grader and a single retry — the smallest possible taste of "agentic" — and you measure whether one self-check and one re-search actually lifts the hard questions. Only in Phase 3 do you turn on the full loop: planning, bounded iterations, multiple sources, the cost budget, and the routing that sends easy questions to the cheap path.

The discipline this enforces is that every rung earns the next one. If Phase 2's grader-plus-retry doesn't beat the Phase 1 baseline, you've learned something cheap and early — maybe your questions don't actually need the loop, or your grader isn't discriminating. The common mistake is skipping straight to Phase 3 because it's the "real" system; you get a system that's better or worse than the baseline and no way to attribute why. And the "Never" rung is not a phase but a permanent boundary — no answer without passing the sufficiency gate, no loop without a hard cap and budget — the two invariants from §3 that hold in every phase, including the ones you haven't built yet.

turn on one piece at a time — each phase builds on the one below Phase 3 · full loop + multi-source planning · bounded iters · DB/web · routing Phase 2 · grader + one retry judge sufficiency · retry once · measure uplift Phase 1 · plain RAG baseline one retrieve + answer + abstain — the number to beat NEVER: answer without the gate ·loop without a cap + budget Each rung earns the next. Ship the plain-RAG baseline first, add just the grader + one retry, then the full loop — so when quality moves you know which piece moved it. The red band is a permanent boundary, not a phase.
🗺️ How to read this diagram

Read it bottom-to-top as a stack you build upward: the widest band at the bottom is what you ship first, and each phase above adds exactly one capability.

  • Phase 1 (bottom) is plain RAG with an abstain path — deliberately drawn as the foundation because it's the baseline every later phase must beat.
  • Phase 2 adds only the grader plus a single retry — the smallest taste of "agentic" — so you can measure whether one self-check actually lifts the hard questions.
  • Phase 3 (top) turns on the full loop: planning, bounded iterations, DB/web sources, and the routing that sends easy questions to the cheap path.
  • The red band underneath is the "Never" rung — the two invariants from §3 (no answer without the gate, no loop without a cap+budget) that hold in every phase, even ones you haven't built yet.

In short: add one instrument at a time and listen — if a phase doesn't beat the baseline below it, you've learned that cheaply, before shipping the whole machine.

Phase 1 · Plain RAG baseline — single retrieve-and-answer with citations + abstention. Establish the number to beat. (Ch 3)
Phase 2 · Add the grader + one retry — judge sufficiency; reformulate once. Measure uplift on multi-hop questions. (M3)
Phase 3 · Full loop + multi-source — planning, bounded iterations, DB/web tools, cost budget. Route easy vs hard. (L5 + E2)
Never — answer without passing the sufficiency gate, or loop without a hard cap and budget.

Skills & course map expert

SkillLearn it in
RAG fundamentals & grounded answersCh 3
Agentic RAG & GraphRAGM3
The retrieval engineProject 9
Loops, state, iteration capsL5
ReAct / query planningE1
Faithfulness + abstention evalsCh 5
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

What you need before you startOnly Python 3.10+ (python3 --version). The loop, grader, and cap are ordinary control flow, so everything is tested offline with scripted stand-ins — no key needed until you wire the real model at the end.

By the end you will have

  • A bounded retrieve → grade → reformulate loop.
  • A sufficiency grader that decides "answer" vs "search again".
  • A hard step cap and a graceful abstention when evidence is thin.
  • Six passing tests, including cap-enforcement and abstention, with no key.

How to use this page expert

Do the steps in order. terminal = commands to run; file = create that file with the exact contents. Expected output follows each command.

Step 1 · Project folder expert

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.
Step 1 — run in your terminal
terminalmkdir -p agentic-rag/tests
cd agentic-rag
# you are now inside agentic-rag/

Step 2 · Virtual environment expert

Step 2 — macOS / Linux
terminalpython3 -m venv .venv
source .venv/bin/activate
Step 2 — Windows (PowerShell)
terminalpy -m venv .venv
.venv\Scripts\Activate.ps1
(.venv) /Users/you/agentic-rag $

Step 3 · Install dependencies expert

Step 3 — run in your terminal
terminalpip install pytest "anthropic>=0.40"
pip freeze > requirements.txt
Successfully installed anthropic-0.69.0 pytest-8.3.4 ...

Step 4 · The pieces the loop needs (as swappable functions) expert

Create pieces.py. The loop depends on three functions: retrieve, judge (the grader), and generate. Here we give real (mock-backed) versions and make them injectable so tests can script them. Paste the whole file.

Step 4 — create this file

agentic-rag/pieces.py

The retrieve / judge / generate pieces — in Claude or OpenAI

The corpus, retrieval, the Grade dataclass, the mock judge/generate, and the whole loop are vendor-neutral — only the two _real_* helpers change. Claude uses client.messages.create(...) → msg.content[0].text; OpenAI uses client.responses.create(...) → resp.output_text. Toggle the tabs.

pieces.py"""The retrieve / judge / generate pieces the loop orchestrates."""
import os
from dataclasses import dataclass

# A tiny corpus: each 'doc' is (id, text). The loop learns to combine facts.
CORPUS = {
    "cur": "The current refund policy allows 30 days.",
    "old": "The 2024 refund policy allowed 14 days.",
}


@dataclass
class Grade:
    sufficient: bool
    gap: str = ""


def retrieve(query: str) -> list[str]:
    """Return doc texts whose id-keyword appears in the query. Deliberately
    naive so a first query finds only part of a multi-hop answer."""
    hits = []
    if "current" in query or "refund" in query: hits.append(CORPUS["cur"])
    if "2024" in query or "old" in query or "previous" in query:
        hits.append(CORPUS["old"])
    return hits


def judge(question: str, context: list[str]) -> Grade:
    """Sufficiency grader. Mock rule: a 'compare' question needs BOTH the
    current AND the 2024 fact. Real version calls the model (Step 8)."""
    if os.environ.get("USE_REAL_API") == "1":
        return _real_judge(question, context)
    joined = " ".join(context)
    if "compare" in question.lower():
        has_cur = "current" in joined
        has_old = "2024" in joined
        if has_cur and has_old:
            return Grade(True)
        return Grade(False, gap="the 2024 policy" if has_cur else "the current policy")
    return Grade(bool(context))       # simple question: any context suffices


def generate(question: str, context: list[str]) -> str:
    if os.environ.get("USE_REAL_API") == "1":
        return _real_generate(question, context)
    return " | ".join(context) + " [grounded]"   # mock answer


def _real_judge(question, context):
    import anthropic, json
    client = anthropic.Anthropic()
    msg = client.messages.create(model="claude-opus-4-8", max_tokens=150,
        system="Reply JSON {\"sufficient\":bool,\"gap\":str}. Does CONTEXT fully "
               "answer QUESTION? If not, name what's missing in gap.",
        messages=[{"role":"user","content":f"QUESTION:{question}\nCONTEXT:{context}"}])
    d = json.loads(msg.content[0].text)
    return Grade(d["sufficient"], d.get("gap", ""))


def _real_generate(question, context):
    import anthropic
    client = anthropic.Anthropic()
    msg = client.messages.create(model="claude-opus-4-8", max_tokens=400,
        system="Answer only from the context; cite it.",
        messages=[{"role":"user","content":f"Context:{context}\nQ:{question}"}])
    return msg.content[0].text
▶ How this works

This file builds the three workers the loop bosses around: retrieve (find documents), judge (the grader — decide if we have enough), and generate (write the final answer). Each has a fast mock version for learning offline and a real version that calls Claude — switched by one environment variable, so the loop's logic never changes.

  1. CORPUS is a tiny fake library: two documents, a "cur"rent refund policy and an "old" 2024 one. Answering "compare them" needs both — that's the multi-hop case the whole project is built to handle.
  2. @dataclass class Grade: is a small record with two fields: sufficient (a yes/no) and gap (what's still missing). The grader hands one of these back so the loop knows whether to stop or search again.
  3. retrieve(query) is deliberately naive: it only returns the current policy if the query mentions "current"/"refund", and only the 2024 policy if it mentions "2024"/"old"/"previous". So one query naturally finds only half a compare question — forcing the loop to go back for the rest.
  4. judge(question, context) is the grader. Its mock rule: for a "compare" question it needs both the current and the 2024 fact; if only one is present it returns Grade(False, gap=...) naming what's missing. Any other question is satisfied by any context.
  5. generate(...) writes the answer (mock: just joins the facts and tags them [grounded]). The _real_judge/_real_generate helpers do the same jobs by calling the model — client.messages.create(...) with Claude, client.responses.create(...) with OpenAI (toggle the tabs) — the grader asks for JSON {sufficient, gap}, and only runs when USE_REAL_API is set to "1".

What the output means: Nothing runs yet — this file only defines the three pieces. The loop in Step 5 imports and orchestrates them.

Try this: Read retrieve and predict: for the query "what is the current refund policy?", which of the two docs comes back? (Just the current one — which is exactly why a single lookup can't answer a compare question.)

The grader is what makes RAG "agentic"Plain RAG retrieves once and answers. judge() is the piece that lets the system notice it only has half the answer (the "compare" case needs both policies) and go back for more. Everything else in the loop serves this decision.

Step 5 · The bounded loop expert

Create loop.py. It plans, retrieves, grades, reformulates from the gap, and stops at MAX_STEPS — abstaining if evidence never suffices.

Step 5 — create this file

agentic-rag/loop.py

loop.py"""The agentic retrieval loop: retrieve -> grade -> reformulate, bounded."""
from pieces import retrieve as _retrieve, judge as _judge, generate as _generate

MAX_STEPS = 4
ABSTAIN = "I couldn't find enough to answer confidently."


def answer(question, retrieve=_retrieve, judge=_judge, generate=_generate):
    """Run the loop. The three pieces are injectable so tests can script
    them. Returns dict with answer, steps used, and the trace."""
    subquery = question
    context: list[str] = []
    trace: list[dict] = []
    for step in range(MAX_STEPS):        # the leash
        hits = retrieve(subquery)
        for h in hits:
            if h not in context:
                context.append(h)
        grade = judge(question, context)
        trace.append({"step": step, "subquery": subquery,
                      "hits": len(hits), "sufficient": grade.sufficient})
        if grade.sufficient:
            return {"answer": generate(question, context),
                    "steps": step + 1, "trace": trace, "abstained": False}
        subquery = f"{question} (need: {grade.gap})"   # reformulate
    return {"answer": ABSTAIN, "steps": MAX_STEPS,
            "trace": trace, "abstained": True}


if __name__ == "__main__":
    r = answer("compare the refund policy to the 2024 version")
    for t in r["trace"]:
        print(t)
    print("ANSWER:", r["answer"])
    print("steps:", r["steps"])
▶ How this works

This is the real, runnable version of the shape snippet — the agentic loop itself. It retrieves, grades, and reformulates from the gap, stopping either when the answer is sufficient or when it hits the step cap and abstains. Notice it takes the three pieces as arguments with defaults, so tests can swap in scripted fakes.

  1. MAX_STEPS = 4 and ABSTAIN = "..." are the leash and the honest fallback message, defined once at the top where they're easy to find and change.
  2. def answer(question, retrieve=_retrieve, judge=_judge, generate=_generate): — the pieces default to the real ones from pieces.py, but a caller (like a test) can pass its own. This is dependency injection: same loop, swappable parts.
  3. subquery = question starts the search with the raw question; context collects facts and trace records what happened each step (for debugging and observability).
  4. Inside for step in range(MAX_STEPS): — retrieve hits, add only new ones to context (if h not in context avoids duplicates), grade, and append a trace row. If grade.sufficient, return the generated answer plus steps/trace. Otherwise rebuild subquery as f"{question} (need: {grade.gap})" — the gap steers the next search.
  5. If the for loop finishes without returning, all tries failed: the final return gives back the ABSTAIN message with "abstained": True. The if __name__ == "__main__" block runs a demo compare question and prints the trace.

What the output means: Run directly, it prints one trace line per step and then the final answer — you'll see this in Step 6.

Try this: Change MAX_STEPS to 1 and re-run the demo. The compare question now abstains, because one search can't gather both policies — proof that the cap really does bound the work.

The cap and the abstention are the safety modelAn agent that re-searches freely can loop forever and burn money. MAX_STEPS bounds it; the final return is the honest "not enough evidence" path. Both are tested in Step 7 — without them, an agentic-RAG system is a production incident waiting to happen.

Step 6 · Run it — watch the loop reason expert

Step 6 — run it
terminalpython loop.py
{'step': 0, 'subquery': 'compare the refund policy to the 2024 version', 'hits': 1, 'sufficient': False}
{'step': 1, 'subquery': 'compare the refund policy to the 2024 version (need: the 2024 policy)', 'hits': 1, 'sufficient': True}
ANSWER: The current refund policy allows 30 days. | The 2024 refund policy allowed 14 days. [grounded]
steps: 2
▶ How this works

This is what python loop.py prints for the compare question — the loop reasoning out loud. Each dict is one pass through the search cycle, so you can literally watch it notice a gap and fix it.

  1. Step 0: subquery is the raw question; 'hits': 1 means retrieve found only one document (the current policy); 'sufficient': False — the grader correctly says 'this compare question isn't fully answered yet'.
  2. Step 1: the subquery now ends with (need: the 2024 policy) — the loop reformulated using the gap the grader reported. That extra phrase makes retrieve find the second document, and now 'sufficient': True.
  3. ANSWER line: both facts are joined together and tagged [grounded] — the complete answer that needed two lookups to assemble.
  4. steps: 2 — it used two of its four allowed tries. Efficient, and well under the leash.

What the output means: The two trace lines show the gap being detected then filled; the final two lines are the assembled grounded answer and the step count (2).

Try this: This is the payoff of the whole project: a plain RAG system would have stopped at Step 0 and answered with only half the story. Re-read Step 0's 'sufficient': False — that single 'no' is what saved the answer.

Multi-hop is where single-shot RAG diesStep 0 found only the current policy — a plain RAG system would answer half the question. The grader caught the gap ("the 2024 policy") and the loop reformulated and found the rest. That second retrieval is the whole value of going agentic.

Step 7 · Tests (no key — scripted pieces) expert

Create tests/test_loop.py. By injecting scripted retrieve/judge/generate, we test the loop's control flow deterministically — including a grader that never says sufficient (to prove the cap + abstention).

Step 7 — create this file

agentic-rag/tests/test_loop.py

tests/test_loop.py"""Offline loop tests with scripted pieces — no key."""
from loop import answer, MAX_STEPS, ABSTAIN
from pieces import Grade


def test_answers_when_first_retrieval_suffices():
    r = answer("q", retrieve=lambda q: ["fact"],
               judge=lambda question, ctx: Grade(True),
               generate=lambda question, ctx: "done")
    assert r["steps"] == 1 and not r["abstained"]


def test_reformulates_then_answers():
    calls = {"n": 0}
    def judge(question, ctx):
        calls["n"] += 1
        return Grade(True) if calls["n"] >= 2 else Grade(False, gap="more")
    r = answer("q", retrieve=lambda q: ["f"], judge=judge,
               generate=lambda question, ctx: "done")
    assert r["steps"] == 2 and not r["abstained"]


def test_stops_at_max_steps():
    r = answer("q", retrieve=lambda q: ["f"],
               judge=lambda question, ctx: Grade(False, gap="x"),
               generate=lambda question, ctx: "done")
    assert r["steps"] == MAX_STEPS


def test_abstains_when_never_sufficient():
    r = answer("q", retrieve=lambda q: ["f"],
               judge=lambda question, ctx: Grade(False, gap="x"),
               generate=lambda question, ctx: "done")
    assert r["abstained"] and r["answer"] == ABSTAIN


def test_trace_records_every_step():
    r = answer("q", retrieve=lambda q: ["f"],
               judge=lambda question, ctx: Grade(False, gap="x"),
               generate=lambda question, ctx: "done")
    assert len(r["trace"]) == MAX_STEPS


def test_real_multihop_uses_two_steps():
    # uses the built-in mock pieces (no key)
    r = answer("compare the refund policy to the 2024 version")
    assert r["steps"] == 2 and not r["abstained"]
▶ How this works

These six tests prove the loop's control flow without ever calling the API. The trick: each test passes its own tiny retrieve/judge/generate as arguments (using lambda, a one-line throwaway function), so the loop runs against scripted behaviour that's fast, free, and predictable.

  1. test_answers_when_first_retrieval_suffices — a judge that always says Grade(True) means the loop should finish in exactly 1 step and not abstain. This proves it doesn't waste loops when the answer is already there.
  2. test_reformulates_then_answers — a judge that returns False the first time and True the second (counting with calls["n"]) proves the gap drives a second retrieval: steps == 2.
  3. test_stops_at_max_steps and test_abstains_when_never_sufficient use a judge that always says False. The loop must stop at MAX_STEPS and return the ABSTAIN message — this is the safety leash under test.
  4. test_trace_records_every_step checks the trace has one entry per step (observability). test_real_multihop_uses_two_steps uses the real built-in mocks (no scripting) to confirm the genuine compare flow takes 2 steps.

What the output means: Run with pytest, all six should report PASSED and end with 6 passed — see the next lab.

Try this: A lambda q: ["fact"] is just a mini-function: 'given a query q, return the list ["fact"]'. Try adding a seventh test where the judge says True on the third call, and assert steps == 3.

Step 7 — run the tests
terminalpython -m pytest tests/ -v
tests/test_loop.py::test_answers_when_first_retrieval_suffices PASSED
tests/test_loop.py::test_reformulates_then_answers PASSED
tests/test_loop.py::test_stops_at_max_steps PASSED
tests/test_loop.py::test_abstains_when_never_sufficient PASSED
tests/test_loop.py::test_trace_records_every_step PASSED
tests/test_loop.py::test_real_multihop_uses_two_steps PASSED

6 passed in 0.05s
✅ What each test proves
TestProves
answers on first sufficiencyno wasted loops when the answer's already there
reformulates then answersthe gap drives a second retrieval
stops at MAX_STEPSthe leash holds — no infinite loop
abstains when never sufficienthonest "not enough" instead of a guess
trace records every stepthe loop is observable/debuggable
real multi-hop uses 2 stepsthe built-in mock genuinely does the multi-hop

Step 8 · Go live with Claude (optional) expert

Step 8 — set a key and enable
terminalexport ANTHROPIC_API_KEY="sk-ant-your-key-here"
export USE_REAL_API=1
python loop.py
# now judge() and generate() call Claude; the loop logic is unchanged
▶ How this works

This flips the same loop from the offline mock to the real Claude model — without changing a line of loop.py. Two environment variables do it, which is exactly why the pieces were built with a mock/real switch earlier.

  1. export ANTHROPIC_API_KEY="sk-ant-..." (or OPENAI_API_KEY="sk-..." for OpenAI) puts your secret key in the environment so the client can authenticate. It never appears in your code.
  2. export USE_REAL_API=1 is the switch the pieces check: with it set, judge and generate call their _real_* versions (Claude) instead of the mocks.
  3. python loop.py then runs the identical loop — same retrieve/grade/reformulate logic — but now the grading and answering are done by the real model.

What the output means: The loop behaves the same, but judge() and generate() now hit the real model API (Anthropic or OpenAI, depending on which tab's code you used). Costs real tokens; the loop structure is unchanged.

Try this: Run unset USE_REAL_API to go back to the free, offline mock. Being able to flip between mock and real with one variable — and keeping tests always on the mock — is a pattern you'll reuse in every LLM project.

Tests stay offlineThe tests inject scripted pieces, so they never call the API — fast, free, deterministic. Only loop.py run directly uses the real model. Return to offline mode with unset USE_REAL_API.

Troubleshooting — every error you might hit expert

⚠️ If something doesn't match
What you seeWhat it means & the fix
python3: command not foundInstall Python; on Windows use py.
No (.venv)Re-run the Step 2 activate line.
ModuleNotFoundError: loop / piecesRun pytest from inside agentic-rag/.
Loop hits MAX every timeGrader too strict — check the "compare" branch returns Grade(True) once both facts are present.
Answers with thin evidenceGrader too lenient — require the gap to be genuinely filled.
Real mode: JSON parse error in judgeThe model didn't return clean JSON — tighten the system prompt or use structured output.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Milestone 1 — a plain-RAG baseline that abstainsBeginner

Context: You can't claim the agentic loop is worth its cost until you have a number to beat. The baseline is plain retrieve-then-answer — but one that abstains instead of guessing, so it never hallucinates.

Your task: Build a single retrieve-then-answer function over a tiny corpus that answers when it retrieves something and returns a fixed abstain message when it retrieves nothing.

Requirements:

  • A small in-memory corpus and a naive keyword retrieve(query)
  • One retrieval call only — no loop, no reformulation
  • When retrieval is empty, return a fixed abstain string rather than a guess
  • When retrieval hits, return an answer grounded in the retrieved text
  • Demonstrate one query that answers and one that abstains

💡 Hint: The whole point of the baseline is the abstain branch: gate on whether anything was retrieved before you compose an answer.

Show solution

The single-shot baseline with an abstain path (pure stdlib):

CORPUS = {"cur": "The current refund policy allows 30 days.",
          "old": "The 2024 refund policy allowed 14 days."}
ABSTAIN = "I couldn't find enough to answer confidently."

def retrieve(query):
    hits = []
    if "current" in query or "refund" in query: hits.append(CORPUS["cur"])
    if "2024" in query or "old" in query:        hits.append(CORPUS["old"])
    return hits

def plain_rag(question):
    ctx = retrieve(question)
    if not ctx:
        return ABSTAIN                       # never guess
    return " ".join(ctx) + " [grounded]"

print(plain_rag("what is the refund policy"))    # answers from 'cur'
print(plain_rag("what is your uptime SLA"))       # abstains

Plain RAG retrieves once and answers — and critically abstains rather than hallucinating when retrieval returns nothing. This is the baseline the agentic loop must beat on multi-hop questions; if the loop can't beat it, the added complexity isn't earning its keep.

Exercise 2 · Milestone 2 — a sufficiency graderIntermediate

Context: The core of an agentic loop is a gate that judges its own evidence. Before the loop can iterate intelligently it needs a grader that says "this context is (in)sufficient" and, when not, names what's missing.

Your task: Implement a judge that decides whether the current context can answer the question and, when it can't, names the gap — including a multi-hop "compare" question that needs two distinct pieces of context.

Requirements:

  • A small Grade record carrying a sufficiency flag and a gap description
  • For a compare-style question, require both pieces of context to be present
  • For a simple question, any relevant context is sufficient
  • When insufficient, populate the gap with the specific missing piece
  • Demonstrate the grader returning an insufficient verdict that names the gap

💡 Hint: A dataclass with sufficient and gap is enough; the compare case is just "are both required facts in the joined context?"

Show solution

The grader that drives the loop (pure stdlib, runnable):

from dataclasses import dataclass

@dataclass
class Grade:
    sufficient: bool
    gap: str = ""

def judge(question, context):
    joined = " ".join(context)
    if "compare" in question.lower():        # needs both policies
        has_cur = "current" in joined
        has_old = "2024" in joined
        if has_cur and has_old:
            return Grade(True)
        return Grade(False, gap="the 2024 policy" if has_cur else "the current policy")
    return Grade(bool(context))

print(judge("compare refund to 2024", ["The current refund policy allows 30 days."]))
# Grade(sufficient=False, gap='the 2024 policy')

The grader is what makes RAG agentic: instead of answering from whatever came back, it asks "can this actually answer the question?" and, when not, names the missing piece. That gap string becomes the next query — the loop reasons about retrieval, not just runs it.

Exercise 3 · Milestone 3 — the bounded loop with reformulationAdvanced

Context: This is the milestone that makes the assistant agentic: retrieve, grade, and on insufficiency reformulate the query with the named gap and try again — all under a hard step leash so it can never loop forever.

Your task: Combine retrieve and judge into a bounded loop that reformulates using the grader's gap, accumulates context across steps, and abstains if it never becomes sufficient — solving a multi-hop question in two steps.

Requirements:

  • A hard MAX_STEPS cap bounds the loop
  • Each iteration retrieves, adds new hits to accumulated context, then grades
  • On insufficiency, reformulate the subquery using the grader's named gap
  • On sufficiency, return the answer plus the step count and a trace
  • If the cap is reached without sufficiency, return the abstain result
  • A multi-hop question is shown resolving in two steps

💡 Hint: Loop with for step in range(MAX_STEPS) and keep a running context list; the reformulated subquery is just the question plus the gap the grader named.

Show solution

The bounded loop — the project's core (pure stdlib, runnable):

from dataclasses import dataclass
@dataclass
class Grade:
    sufficient: bool; gap: str = ""
CORPUS = {"cur": "current: 30 days", "old": "2024: 14 days"}
MAX_STEPS, ABSTAIN = 4, "couldn't find enough"

def retrieve(q):
    h = []
    if "current" in q or "refund" in q: h.append(CORPUS["cur"])
    if "2024" in q or "old" in q:        h.append(CORPUS["old"])
    return h
def judge(question, ctx):
    j = " ".join(ctx)
    if "compare" in question.lower():
        return Grade(True) if ("current" in j and "2024" in j) else \
               Grade(False, "the 2024 policy" if "current" in j else "the current policy")
    return Grade(bool(ctx))

def answer(question):
    subquery, context, trace = question, [], []
    for step in range(MAX_STEPS):                 # THE LEASH
        for h in retrieve(subquery):
            if h not in context: context.append(h)
        g = judge(question, context)
        trace.append({"step": step, "sufficient": g.sufficient})
        if g.sufficient:
            return {"answer": " | ".join(context), "steps": step + 1, "trace": trace}
        subquery = f"{question} (need: {g.gap})"   # REFORMULATE
    return {"answer": ABSTAIN, "steps": MAX_STEPS, "trace": trace}

print(answer("compare the refund policy to the 2024 version"))

The loop plans, judges, and reformulates using the named gap — solving a two-hop question that plain RAG cannot. The MAX_STEPS leash is non-negotiable: bounded autonomy is the safety property that keeps a self-directing loop from running forever.

Exercise 4 · Milestone 4 — swap in a real LLM judge (needs API key)Expert

Context: The scripted grader proves the loop's shape; production grades with a model. The trick is to keep the exact same Grade interface so the loop code doesn't change at all when you swap the judge.

Your task: Show the real LLM judge: call Claude, force a strict JSON verdict, and return the same Grade object the scripted judge did so the loop is untouched. Mark this rung as needing an API key.

Requirements:

  • Call the model API for the grade (Claude's messages.create or OpenAI's responses.create)
  • A system instruction forces a reply of strict JSON with a sufficiency flag and gap
  • Parse the JSON and return the same Grade type the stub returned
  • The loop from the previous rung requires no changes to use it
  • Clearly label the rung as requiring ANTHROPIC_API_KEY

💡 Hint: Hold the interface fixed: whatever the model returns, adapt it into the same Grade(sufficient, gap) the loop already consumes.

Show solution

The real judge — needs pip install anthropic + ANTHROPIC_API_KEY (or openai + OPENAI_API_KEY — see the OpenAI variant in the comment below), a drop-in for the stub:

import json
from dataclasses import dataclass

@dataclass
class Grade:
    sufficient: bool
    gap: str = ""

def real_judge(question, context):
    import anthropic
    client = anthropic.Anthropic()                    # reads ANTHROPIC_API_KEY
    msg = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=150,
        system='Reply ONLY JSON: {"sufficient": bool, "gap": str}.',
        messages=[{"role": "user",
                   "content": f"QUESTION: {question}\nCONTEXT: {context}"}],
    )
    d = json.loads(msg.content[0].text)
    return Grade(bool(d["sufficient"]), d.get("gap", ""))
# Same Grade return type as the stub -> the loop code doesn't change.
#
# OpenAI drop-in (needs OPENAI_API_KEY): swap the client + call, parse the same JSON:
#   import openai; client = openai.OpenAI()
#   resp = client.responses.create(model="gpt-5.5", max_output_tokens=150,
#       instructions='Reply ONLY JSON: {"sufficient": bool, "gap": str}.',
#       input=f"QUESTION: {question}\nCONTEXT: {context}")
#   d = json.loads(resp.output_text)

Because the real judge returns the identical Grade, it swaps in behind the same interface the scripted tests exercise — the bounded loop is untouched. Forcing strict JSON output keeps the grader machine-parseable so a malformed judge response fails loudly instead of silently mis-driving the loop.

Exercise 5 · Milestone 5 — evaluate uplift and over-loopingProfessional

Context: An agentic loop that costs more must earn it. This milestone proves the loop wins on multi-hop questions while watching for the opposite failure: burning extra steps on easy questions it should answer in one.

Your task: Build an evaluation that compares agentic vs plain-RAG accuracy on a labelled set AND measures average steps per answer, then report the accuracy uplift and the average step count.

Requirements:

  • A labelled set of (question, correct_answer) cases including multi-hop ones
  • Run both the plain and agentic functions over the identical set
  • Score accuracy for each and compute the uplift between them
  • Accumulate steps taken by the agent and report the average
  • Show the agent winning on multi-hop without inflating steps on easy questions

💡 Hint: Report two things side by side — accuracy uplift and average steps — so a win on hard questions and over-looping on easy ones are both visible.

Show solution

The eval that justifies the complexity (pure stdlib, runnable):

def evaluate(cases, plain_fn, agent_fn):
    plain_correct = agent_correct = total_steps = 0
    for q, correct in cases:
        if plain_fn(q) == correct: plain_correct += 1
        r = agent_fn(q)
        if r["answer"] == correct: agent_correct += 1
        total_steps += r["steps"]
    n = len(cases)
    return {"plain_acc": plain_correct/n, "agent_acc": agent_correct/n,
            "uplift": (agent_correct - plain_correct)/n,
            "avg_steps": total_steps/n}

# stubbed fns: plain misses multi-hop, agent solves in 2 steps, easy in 1
cases = [("multi-hop", "AB"), ("easy", "A")]
plain = lambda q: "A" if q == "easy" else "wrong"
agent = lambda q: {"answer": "AB" if q=="multi-hop" else "A",
                   "steps": 2 if q=="multi-hop" else 1}
print(evaluate(cases, plain, agent))   # uplift on multi-hop, avg_steps low

Two metrics keep the loop honest: uplift proves it beats plain RAG where retrieval must iterate, and steps-per-answer catches the opposite failure — burning extra calls looping on easy questions it should answer in one shot. A good agentic RAG improves hard cases without inflating cost on easy ones.

Exercise 6 · Milestone 6 — bound cost in production as the ownerIndustry scenario

Context: In production the real risk isn't a wrong answer — it's an expensive infinite loop. As the owner you add a second leash: a per-answer cost budget on top of the step cap, so either limit can stop the run.

Your task: Add a per-answer cost budget alongside MAX_STEPS so the loop stops and abstains when either the step cap or the dollar/token budget is hit — modelling the dual leash.

Requirements:

  • Constants for the step cap, a max cost, and a per-step cost estimate
  • Accumulate spend each iteration and check it before doing more work
  • Abstain with a distinct message when the cost budget trips
  • Abstain with a distinct message when the step cap trips
  • Demonstrate that whichever leash is tighter stops execution first

💡 Hint: Track a running spent total and test it at the top of each iteration; the two leashes are independent, so return a different abstain reason for each.

Show solution

The dual leash — steps AND cost budget (pure stdlib):

MAX_STEPS = 4
MAX_COST = 0.05          # $ per answer ceiling
COST_PER_STEP = 0.015    # retrieve + judge + generate, illustrative

def answer_bounded(retrieve, judge, generate, question):
    context, spent = [], 0.0
    for step in range(MAX_STEPS):
        spent += COST_PER_STEP
        if spent > MAX_COST:                        # cost leash trips first
            return {"answer": "abstain (cost budget hit)", "steps": step, "spent": spent}
        context += retrieve(question)
        if judge(question, context):
            return {"answer": generate(question, context),
                    "steps": step + 1, "spent": round(spent, 3)}
    return {"answer": "abstain (step cap hit)", "steps": MAX_STEPS, "spent": spent}

# never-sufficient judge -> whichever leash is tighter stops it
print(answer_bounded(lambda q: ["x"], lambda q,c: False, lambda q,c: "ans", "q"))

Industry scenario: a support RAG loop hits a class of unanswerable questions and, without a budget, would retry until it drained the token quota. Two independent leashes — a step cap and a per-answer cost budget — guarantee it stops and abstains. Bounded autonomy plus a cost ceiling is what makes an autonomous loop safe to run on real traffic.

✓ You are done when…

  • python loop.py answers the compare question in 2 steps with both facts.
  • python -m pytest tests/ -v shows 6 passed.
  • You can explain why the cap and abstention are mandatory.
  • (Optional) The real model plugs into the same loop via one env var.
📁 Your finished folder
agentic-rag/
├─ .venv/
├─ requirements.txt
├─ pieces.py          (retrieve, judge, generate — mock + real)
├─ loop.py            (the bounded retrieve->grade->reformulate loop)
└─ tests/
   └─ test_loop.py    (6 offline tests)
📋 Staff-level self-scoring — is this agentic assistant safe and worth the loop?
DimensionMeets the barAbove the bar
The loop is boundedA hard iteration cap and a token/cost budget exist; the agent cannot loop forever.On budget exhaustion it degrades to an honest best-effort-with-caveat, not a crash or a silent stall.
Self-grading gate worksjudge_sufficiency drives the loop: it answers only when evidence is sufficient, else re-searches or abstains.The grader is itself evaluated — you can show it abstains correctly on unanswerable questions.
Faithfulness & citationsEvery claim is grounded in actually-retrieved chunks; citations reference real hits, not invented ones.Citation correctness is measured on a set; a verification step rejects any answer with unsupported claims.
Multi-hop correctnessAccuracy is measured on questions that need >1 retrieval — where single-shot RAG fails.A multi-hop eval set with graded answers; you can state accuracy and the failure taxonomy.
Justified vs plain RAGThe agentic loop is compared against single-shot RAG as a baseline.Easy questions route to plain RAG; the loop is reserved for hard ones and the split is measured.
Efficiency observedSteps and cost per answer are logged; you know the average and the tail.Cost/steps are budgeted per query class; a runaway trajectory is caught and capped, not discovered on the bill.

Score each row 0 (missing) / 1 (meets) / 2 (above). 0–4: a prototype — keep building. 5–8: a solid build you could take to review. 9–12: staff-level — production-defensible. Any dimension at 0 blocks shipping regardless of the total.

Knowledge check check yourself

✓ Knowledge check

What single component turns ordinary RAG into agentic RAG, and how does it drive the loop?

Show answer
The judge_sufficiency grader: an LLM call that reads the question and retrieved context and answers yes/no whether it can be answered, naming what's missing. That grade decides whether to answer, reformulate and re-search, or abstain — everything else serves that decision.
✓ Knowledge check

Why does the loop need both a hard MAX_STEPS cap and an abstention path rather than just searching until it succeeds?

Show answer
An agent that re-searches freely can loop forever and burn tokens/money. The cap bounds the work, and the abstention ("not enough evidence") is the honest exit when evidence never becomes sufficient — without both, the system is a production incident waiting to happen.
© 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