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.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
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
"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.
1 · Discovery — where does plain RAG break? advanced
| Hard case | Agentic 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 |
2 · Architecture advanced
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
| Risk | Control |
|---|---|
| 🔴 Answering without sufficient evidence | Self-grading gate — must pass a relevance check; abstain otherwise |
| 🟠 Infinite / expensive retrieval loops | Hard cap on iterations + a token/cost budget (E2); degrade to "best effort + caveat" |
| 🟠 Reformulation drifting off-topic | Anchor every sub-query to the original question; log the trajectory |
| 🔴 Hallucinated citations | Citations must reference actually-retrieved chunks; verify before answering (Project 7) |
| 🟠 Wrong source chosen | Constrain tool choice; prefer authoritative internal sources over web |
4 · The retrieval loop, in code advanced
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"
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.
def answer(question, max_steps=4):— the whole assistant is one function.max_steps=4is the leash: it can search at most four times, then it must stop. That cap is a safety feature, not a detail.subqs = plan(question)breaks the question into sub-questions;context = []is the empty notebook where retrieved facts pile up.for step in range(max_steps):repeats the search cycle up to four times. Each pass:retrieve(...)fetches hits,context += hitsadds them to the notebook, andjudge_sufficiency(...)asks 'is this enough?'.if grade.sufficient: return generate_answer(...)— the moment the evidence is good enough, it writes a cited answer and leaves the loop. Otherwisereformulate(question, grade.gap)rewrites the query to chase the missing piece and the loop goes round again.- 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.
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
| Tool | Does | Risk |
|---|---|---|
search_knowledge | Semantic/hybrid retrieval (Project 9) | 🟢 read-only |
query_database | Read structured facts (read-only SQL) | 🟢 read-only |
web_search (optional) | External info when internal is insufficient | 🟠 untrusted source — prefer internal, label web |
judge_sufficiency | Grade whether context answers the question | 🟢 the control gate |
plan / reformulate | Decompose & refine queries | 🟢 reasoning |
6 · Evaluation advanced
| Eval | Measures |
|---|---|
| Answer correctness (multi-hop set) | Accuracy on questions that need >1 retrieval — where plain RAG fails |
| Faithfulness & citation correctness | Every claim grounded in retrieved evidence (Ch 5) |
| Abstention correctness | Abstains when the answer truly isn't retrievable |
| Steps / cost per answer | Efficiency — is it looping more than needed? |
| Uplift vs plain RAG | Prove the agentic loop beats single-shot on hard questions (and isn't wasteful on easy ones) |
7 · Phased rollout expert
Skills & course map expert
| Skill | Learn it in |
|---|---|
| RAG fundamentals & grounded answers | Ch 3 |
| Agentic RAG & GraphRAG | M3 |
| The retrieval engine | Project 9 |
| Loops, state, iteration caps | L5 |
| ReAct / query planning | E1 |
| Faithfulness + abstention evals | Ch 5 |
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
terminalmkdir -p agentic-rag/tests
cd agentic-rag
# you are now inside agentic-rag/
Step 2 · Virtual environment expert
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
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.
agentic-rag/pieces.py
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
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.
CORPUSis 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.@dataclass class Grade:is a small record with two fields:sufficient(a yes/no) andgap(what's still missing). The grader hands one of these back so the loop knows whether to stop or search again.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.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 returnsGrade(False, gap=...)naming what's missing. Any other question is satisfied by any context.generate(...)writes the answer (mock: just joins the facts and tags them[grounded]). The_real_judge/_real_generatehelpers do the same jobs by callingclient.messages.create(...)— the grader asks Claude for JSON{sufficient, gap}, and only runs whenUSE_REAL_APIis 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.)
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.
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"])
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.
MAX_STEPS = 4andABSTAIN = "..."are the leash and the honest fallback message, defined once at the top where they're easy to find and change.def answer(question, retrieve=_retrieve, judge=_judge, generate=_generate):— the pieces default to the real ones frompieces.py, but a caller (like a test) can pass its own. This is dependency injection: same loop, swappable parts.subquery = questionstarts the search with the raw question;contextcollects facts andtracerecords what happened each step (for debugging and observability).- Inside
for step in range(MAX_STEPS):— retrieve hits, add only new ones to context (if h not in contextavoids duplicates), grade, and append a trace row. Ifgrade.sufficient, return the generated answer plussteps/trace. Otherwise rebuildsubqueryasf"{question} (need: {grade.gap})"— the gap steers the next search. - If the
forloop finishes without returning, all tries failed: the finalreturngives back theABSTAINmessage with"abstained": True. Theif __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.
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
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
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.
- Step 0: subquery is the raw question;
'hits': 1means retrieve found only one document (the current policy);'sufficient': False— the grader correctly says 'this compare question isn't fully answered yet'. - Step 1: the subquery now ends with
(need: the 2024 policy)— the loop reformulated using the gap the grader reported. That extra phrase makesretrievefind the second document, and now'sufficient': True. - ANSWER line: both facts are joined together and tagged
[grounded]— the complete answer that needed two lookups to assemble. - 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.
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).
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"]
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.
test_answers_when_first_retrieval_suffices— a judge that always saysGrade(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.test_reformulates_then_answers— a judge that returns False the first time and True the second (counting withcalls["n"]) proves the gap drives a second retrieval:steps == 2.test_stops_at_max_stepsandtest_abstains_when_never_sufficientuse a judge that always says False. The loop must stop atMAX_STEPSand return theABSTAINmessage — this is the safety leash under test.test_trace_records_every_stepchecks the trace has one entry per step (observability).test_real_multihop_uses_two_stepsuses 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.
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
| Test | Proves |
|---|---|
| answers on first sufficiency | no wasted loops when the answer's already there |
| reformulates then answers | the gap drives a second retrieval |
| stops at MAX_STEPS | the leash holds — no infinite loop |
| abstains when never sufficient | honest "not enough" instead of a guess |
| trace records every step | the loop is observable/debuggable |
| real multi-hop uses 2 steps | the built-in mock genuinely does the multi-hop |
Step 8 · Go live with Claude (optional) expert
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
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.
export ANTHROPIC_API_KEY="sk-ant-..."puts your secret key in the environment so theanthropicclient can authenticate. It never appears in your code.export USE_REAL_API=1is the switch the pieces check: with it set,judgeandgeneratecall their_real_*versions (Claude) instead of the mocks.python loop.pythen 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 Anthropic API. 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.
loop.py run directly uses the real model. Return to offline mode with unset USE_REAL_API.Troubleshooting — every error you might hit expert
| What you see | What it means & the fix |
|---|---|
python3: command not found | Install Python; on Windows use py. |
No (.venv) | Re-run the Step 2 activate line. |
ModuleNotFoundError: loop / pieces | Run pytest from inside agentic-rag/. |
| Loop hits MAX every time | Grader too strict — check the "compare" branch returns Grade(True) once both facts are present. |
| Answers with thin evidence | Grader too lenient — require the gap to be genuinely filled. |
| Real mode: JSON parse error in judge | The 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.
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.
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
Graderecord 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.
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_STEPScap 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.
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 Anthropic client's messages API for the grade
- A system instruction forces a reply of strict JSON with a sufficiency flag and gap
- Parse the JSON and return the same
Gradetype 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 (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.
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.
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.
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.pyanswers the compare question in 2 steps with both facts.python -m pytest tests/ -vshows 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.
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)
| Dimension | Meets the bar | Above the bar |
|---|---|---|
| The loop is bounded | A 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 works | judge_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 & citations | Every 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 correctness | Accuracy 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 RAG | The 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 observed | Steps 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
What single component turns ordinary RAG into agentic RAG, and how does it drive the loop?
Show answer
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.Why does the loop need both a hard MAX_STEPS cap and an abstention path rather than just searching until it succeeds?