AI EngineeringZero to ProductionHome·About·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 you'll need:
  • an Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
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

"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 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

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

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

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

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

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

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

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 client.messages.create(...) — the grader asks Claude 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-..." puts your secret key in the environment so the anthropic 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 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.

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 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 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 (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.

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