Tutorials · Claude Agent SDK · examples / python / p6_agentic_rag.pyExample code

Example code · python

p6_agentic_rag.py

Agentic RAG. Retrieval is a tool the agent calls as often as it likes; the answer must cite sources.

Raw fileDownload all examples (zip)See its output →

Used in Part 4 §6.2 · Agentic RAG

"""Pattern 6: agentic RAG. Retrieval is a tool the agent calls as often as it likes; the answer must cite sources."""

import asyncio
import re
from typing import Annotated, Any

from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage, ToolAnnotations, ToolUseBlock,
                              create_sdk_mcp_server, query, tool)

# A toy "knowledge base": in production this is an embedding index. Here: keyword overlap scoring, no dependencies.
DOCS = {
    "kb-101": ("Refund policy", "Refunds are issued within 5 business days to the original payment method. Annual plans are refundable within 30 days of purchase; monthly plans are not refundable after the billing date."),
    "kb-102": ("Double charges", "If a customer sees two charges in one month, the second is usually an authorization hold that drops off in 3-7 days. If both charges settle, support should issue a refund of the duplicate and file ticket type BILL-DUP."),
    "kb-203": ("Login outage runbook", "When login fails for all users, check the auth service health endpoint first, then the token signing key rotation job, which runs at 02:00 UTC."),
    "kb-204": ("Password reset", "Password reset emails expire after 15 minutes. Resending generates a new token and invalidates the old one."),
    "kb-305": ("Data retention", "Account data is retained for 90 days after cancellation, then permanently deleted. Backups are purged within 30 further days."),
}


def score(query_text: str, text: str) -> int:
    q = set(re.findall(r"\w+", query_text.lower()))
    return len(q & set(re.findall(r"\w+", text.lower())))


@tool("search_docs",
      "Keyword search over the support knowledge base. Returns the best-matching passages with their document IDs. "
      "Call again with different wording if results look off-topic.",
      {"query": Annotated[str, "A short search query"], "k": Annotated[int, "How many results, 1-5"]},
      annotations=ToolAnnotations(readOnlyHint=True))
async def search_docs(args: dict[str, Any]):
    ranked = sorted(DOCS.items(), key=lambda kv: -score(args["query"], kv[1][0] + " " + kv[1][1]))[: max(1, min(args["k"], 5))]
    hits = [f"[{doc_id}] {title}: {text[:120]}..." for doc_id, (title, text) in ranked if score(args["query"], title + " " + text) > 0]
    return {"content": [{"type": "text", "text": "\n".join(hits) or "No passages matched. Try different wording."}]}


@tool("get_doc", "Fetch the full text of one knowledge-base document by ID", {"doc_id": str},
      annotations=ToolAnnotations(readOnlyHint=True))
async def get_doc(args: dict[str, Any]):
    if args["doc_id"] not in DOCS:
        return {"content": [{"type": "text", "text": f"No document with id {args['doc_id']}. IDs look like kb-101."}], "is_error": True}
    title, text = DOCS[args["doc_id"]]
    return {"content": [{"type": "text", "text": f"[{args['doc_id']}] {title}\n{text}"}]}


ANSWER_SCHEMA = {"type": "object",
                 "properties": {"answer": {"type": "string"},
                                "citations": {"type": "array", "items": {"type": "string"}},
                                "confidence": {"type": "string", "enum": ["low", "medium", "high"]}},
                 "required": ["answer", "citations", "confidence"]}

kb = create_sdk_mcp_server(name="kb", version="1.0.0", tools=[search_docs, get_doc])


async def ask(question: str):
    calls = []
    async for m in query(
        prompt=question,
        options=ClaudeAgentOptions(
            mcp_servers={"kb": kb}, allowed_tools=["mcp__kb__*"], tools=[], setting_sources=[], max_turns=8,
            system_prompt="Answer ONLY from the knowledge base tools. Cite document IDs. If the KB has nothing relevant, say so with confidence=low.",
            output_format={"type": "json_schema", "schema": ANSWER_SCHEMA},
        ),
    ):
        if isinstance(m, AssistantMessage):
            for b in m.content:
                if isinstance(b, ToolUseBlock):
                    calls.append(f"{b.name.split('__')[-1]}({b.input.get('query') or b.input.get('doc_id')})")
        elif isinstance(m, ResultMessage):
            print(f"\nQ: {question}\n  retrieval calls: {calls}\n  A: {m.structured_output}\n  cost=${m.total_cost_usd:.4f}")


async def main():
    await ask("A customer on a monthly plan was charged twice this month and wants their money back. What should I do?")
    await ask("How long do we keep a cancelled account's data?")
    await ask("What is our policy on employee parking?")


asyncio.run(main())

79 lines · Back to the folder