7. Agentic RAG - ReAct Agent with Tool-Calling

Give your RAG system the ability to reason, act, and self-correct. Build a ReAct agent that decides when to retrieve, when to search the web, and when to compute — using LangGraph and custom tools.

7. Agentic RAG - ReAct Agent with Tool-Calling
7. Agentic RAG - ReAct Agent with Tool-Calling
Series · Article 7 of 10

Agentic RAG
ReAct Agent with Tool-Calling

Stop pre-wiring your retrieval pipeline. Let the LLM decide what to search, when to dig deeper, and when it has enough evidence - using Ollama's native tool-calling API and a three-phase ReAct loop.

⏱ ~40 min build 🔧 ollama tool-calling · chromadb · fastapi 📦 Builds on Article 3

🔍Why Static Pipelines Break

Every RAG pipeline built so far in this series follows the same fixed sequence: receive a question, run a vector search with a hardcoded k, pass the top chunks to the LLM, return an answer. That works well when questions are simple and self-contained.

Real-world questions rarely are. Consider: "Which of our services handles the most concurrent connections, and what does the documentation say about tuning it for high traffic?" A static pipeline runs one search, retrieves four chunks that may or may not cover both halves, and guesses. There is no mechanism to go back for more evidence.

⚠ Anti-pattern - hardcoded retrieval count
Setting k=4 globally assumes every question needs exactly four chunks. Simple questions waste the context budget on irrelevant text; complex multi-part questions starve for evidence from only one search.
Let the model call search_documents(k=N) and choose N itself based on how confident it is after each retrieval.

The deeper issue: in a static pipeline the developer decides how retrieval happens at design time. In an agentic pipeline the model decides at runtime - and it has far more context about what it specifically needs to answer the question it was asked.

STATIC PIPELINE Question from user Vector Search always k=4 LLM generate Answer maybe correct AGENTIC PIPELINE Question from user LLM + Tools reason + decide loop until confident Tool Results search · fetch · sum. Answer grounded ✓

🔄The ReAct Pattern

ReAct (Reason + Act) is a prompting strategy that interleaves LLM reasoning with external tool use. Each cycle has three phases that repeat until the model has enough evidence:

🧠

Reason

The LLM internally reflects on what it knows so far and decides what action to take next. This happens inside the model's response before any tool call.

Act

The LLM emits a structured tool_call - a function name plus JSON arguments - instead of plain text. Ollama handles this natively.

👁

Observe

The tool runs; its output is appended to the message history as a role: tool message. The loop continues with full context of what was found.

Answer

When no tool calls are emitted, the LLM produces a final text response. Every claim is grounded in evidence retrieved during the loop.

💡

Modern Ollama models (llama3.1:8b, qwen2.5:7b, mistral:7b v0.3+) support tool-calling natively via the tools parameter - no prompt engineering required. Just pass the tool schemas and inspect the response for tool_calls.

🏗Architecture Overview

Three new files extend the Article 3 multi-tenant API:

textproject structure - additions
project/
├── main.py               # add agent router
├── agent_tools.py        # NEW - tool schemas + executor functions
├── agent.py              # NEW - ReAct loop
└── routers/
    └── agent.py          # NEW - POST /agent/query
pythonmain.py - add one line
from routers import documents, query, agent   # add agent
app.include_router(agent.router)

The agent is stateless per request - it builds a fresh message list each call. Persistence is ChromaDB's job, not the agent's.

🔧Tool Registry

We expose three tools. Each has a JSON Schema definition passed to Ollama, and an executor function called in Python when the model requests it.

ToolWhat it doesWhen the agent uses it
search_documents Vector-searches ChromaDB; returns [{id, text, score}] up to k results First step of every question; re-called with different query angles when initial results are insufficient
get_chunk Fetches full text of one chunk by ID When a search result was truncated and the model needs the complete passage
summarise_chunks Asks Ollama to condense a list of texts into one paragraph When the model has gathered many chunks and wants to synthesise before writing its final answer

The JSON Schema definition for search_documents - this is what Ollama receives as part of the tools parameter:

pythonagent_tools.py - TOOL_SCHEMAS
{
    "type": "function",
    "function": {
        "name": "search_documents",
        "description": "Search the knowledge base for relevant chunks.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "k":     {"type": "integer", "default": 4},
            },
            "required": ["query"],
        },
    },
}

All three tool schemas are collected in TOOL_SCHEMAS - a plain list that gets passed to ollama.chat(tools=...) on every iteration. The dispatcher routes each tool_call to the right Python function and returns the result as a JSON string:

pythonagent_tools.py - dispatch()
def dispatch(name: str, args: dict, collection, model: str) -> str:
    try:
        if   name == "search_documents":
            result = search_documents(collection, args["query"], args.get("k", 4))
        elif name == "get_chunk":
            result = get_chunk(collection, args["chunk_id"])
        elif name == "summarise_chunks":
            result = summarise_chunks(model, args["texts"], args.get("focus", ""))
        else:
            result = {"error": f"Unknown tool: {name}"}
    except (KeyError, ValueError) as exc:
        result = {"error": str(exc)}   # agent adapts gracefully
    return json.dumps(result, ensure_ascii=False)
⚠️

LLMs occasionally hallucinate argument names. Always wrap dispatch() in a try/except and return a {"error": "..."} dict - the model will read the error and correct itself on the next iteration rather than crashing the loop.

🔁The Agent Loop

The loop maintains a single messages list that grows with each iteration. The model always has full context of what it has tried and found - it never repeats a search it already ran:

pythonagent.py - run()
messages = [
    {"role": "system",  "content": _SYSTEM_PROMPT},
    {"role": "user",    "content": question},
]

while iteration < max_iterations:
    iteration += 1
    resp = ollama.chat(
        model=model, messages=messages,
        tools=tools.TOOL_SCHEMAS,
        options={"temperature": 0, "num_predict": 1024},
    )
    msg        = resp["message"]
    tool_calls = msg.get("tool_calls") or []
    messages.append({"role": "assistant", "content": msg.get("content", ""),
                       "tool_calls": tool_calls})

    if not tool_calls:          # no more tool calls → final answer
        return AgentResult(answer=msg["content"].strip(), steps=steps, ...)

    for tc in tool_calls:
        name   = tc["function"]["name"]
        args   = tc["function"]["arguments"]
        result = tools.dispatch(name, args, collection, model)
        messages.append({"role": "tool", "content": result})

Two design choices matter here:

temperature = 0

  • Tool decisions must be deterministic
  • Non-zero temperature causes random variation in which tool is called
  • Makes debugging and evaluation nearly impossible otherwise

max_iterations guard

  • Prevents infinite loops on ambiguous questions
  • When limit is hit, inject a "give your best answer now" message
  • Graceful degradation - partial answer is better than an exception

Every decision is recorded in an AgentStep dataclass for full observability:

pythonagent.py - AgentStep
@dataclass
class AgentStep:
    step:      int
    type:      str    # "tool_call" | "tool_result" | "answer"
    tool_name: str   = ""
    tool_args: dict  = field(default_factory=dict)
    content:   str   = ""

🚀FastAPI Endpoints

pythonrouters/agent.py
@router.post("/agent/query", response_model=QueryResponse)
async def agent_query(
    q:              Annotated[str,  Query(description="Question to answer")],
    tenant_id:      Annotated[str,  Query()] = "default",
    model:          Annotated[str,  Query()] = "llama3.1:8b",
    max_iterations: Annotated[int,  Query(ge=1, le=10)] = 6,
):
    collection = _get_collection(tenant_id)
    result     = ag.run(question=q, collection=collection,
                         model=model, max_iterations=max_iterations)
    return QueryResponse(
        answer=result.answer, iterations=result.iterations,
        steps=[StepOut(**dataclasses.asdict(s)) for s in result.steps],
    )

POST /agent/query

  • Runs the full ReAct loop
  • Returns answer, steps (trace), iterations
  • Caller can render a reasoning timeline from the steps list

GET /agent/models

  • Lists Ollama models available on the host
  • Lets UIs populate a model selector dynamically
  • Returns [{name: "llama3.1:8b"}, ...]

📋Reasoning Trace in Action

Here is what a real multi-step trace looks like for: "What are the main scaling bottlenecks and how are they mitigated?"

TOOL CALL
search_documents - {"query": "scaling bottlenecks performance limits", "k": 5}
RESULT
5 chunks returned. Top result (score 0.87): "The primary bottleneck is embedding throughput - each request blocks on the model…"
TOOL CALL
search_documents - {"query": "mitigation strategies horizontal scaling caching replicas", "k": 4}
RESULT
4 chunks covering response caching, read replicas, and HNSW index tuning.
TOOL CALL
summarise_chunks - {"texts": [...9 chunks...], "focus": "bottlenecks and mitigations"}
RESULT
One-paragraph synthesis combining evidence from both searches.
ANSWER
"The main bottlenecks are (1) embedding throughput - mitigated by batching and caching; (2) vector search latency - addressed via HNSW tuning and read replicas; (3) LLM generation speed - managed with response streaming."

Three tool calls, three iterations, one grounded answer. The model searched with two different query angles then synthesised - something a hardcoded k=4 pipeline cannot do.

3
avg iterations
2-5
tool calls / query
6
max_iterations cap
0
paid API calls

Production Checklist

When to choose agentic over static RAG:

ScenarioStatic RAGAgentic RAG
Simple factual lookup✅ Fast, predictable⚠️ Overkill - 2-5 s overhead per tool call
Multi-part questions❌ Single search misses parts✅ Searches separately for each part
Ambiguous phrasing❌ One embedding vector✅ Re-queries with different phrasings
Comparative questions❌ Needs multiple doc evidence✅ Multiple searches build full picture
High-stakes accuracy⚠️ No self-correction✅ Can verify with follow-up searches
⚠ Anti-pattern - unbounded tool results
Returning the full text of every chunk fills the context window fast. With 8 chunks × 1,000 tokens plus tool-call overhead, you hit the 8k context limit of small models in two iterations.
Truncate at the executor level (text[:400]) and expose get_chunk for full text only when the model specifically asks for it by ID.

Verified tool-calling models: llama3.1:8b, llama3.1:70b, qwen2.5:7b, mistral:7b (v0.3+). Check the model's Ollama page for the Tools tag before deploying - older versions silently ignore the tools parameter.

From Pipeline to Agent

Three files, three tools, one loop. The model goes from a passive chunk consumer to an active retrieval participant - deciding what to look for, how many results it needs, and when it has enough to answer. No external orchestration framework required.

Next up: Corrective RAG (CRAG) - building a system that scores its own retrieved context and decides whether to use it, rephrase the query, or fall back to a broader search strategy.

→ Continue to Article 8: Corrective RAG (CRAG)