Agentic RAG
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page’s examples target LangChain / langchain-core / langgraph 1.4 / 1.6 / 1.2 with Ollama llama3.1:8b,
and Spring Boot 4.1 with Spring AI 2.0.1, against the shared DocsAssistant search_docs tool. The MCP
sections refer to the Model Context Protocol specification dated 2026-07-28.
From a fixed pipeline to a decision
Every page so far in this section retrieves unconditionally: a question always triggers exactly one vector
search, and the retrieved chunks are always stuffed into the prompt before the model ever sees the question.
Agentic RAG removes that certainty. The LLM is given retrieval as one of several tools it can call, and
it decides, for each turn, whether to call it, how many times, with what query, and whether the result is
good enough to answer from — the same tool-calling loop used for any other agent, but with search_docs
(or a small set of retrieval tools) on the tool list instead of, or alongside, a calculator or a weather API.
The framework-neutral design of such agents — the agent loop, tool design, memory, planning, multi-agent
systems and human-in-the-loop approval — is covered in the AI Agents sub-section, with
memory on its own Memory page.
Retriever-as-tool
The shift is entirely one of framing: the retriever function does not change, only how it is invoked. Instead
of a Python function called unconditionally between "receive question" and "build prompt", search_docs
becomes a tool with a name, a description and a typed signature that the model reads and decides to invoke:
def search_docs(query: str, component: str | None = None, k: int = 5) -> list[dict]:
"""Search the DocsAssistant documentation corpus for chunks relevant to `query`.
Args:
query: A natural-language question or search phrase.
component: Optional filter, one of "vector-rag" or "spring-ai".
k: Number of chunks to return.
Returns:
A list of dicts with source_path, title, section, content and score.
"""
...
The model reads the docstring (or an equivalent JSON schema) and the current conversation, and decides on its
own whether the question needs a documentation lookup at all — "what does ef_search control" clearly does,
"what’s 2 + 2" clearly does not. This single change is what lets everything below happen: the model can call
the tool more than once, change the query between calls, or call a different tool entirely.
Multi-hop and iterative retrieval
A single retrieval call answers a question that one chunk already contains. A question like "how does the
HNSW M parameter affect the memory formula used in the capacity example" may need one search for the HNSW
parameter meaning and a second, differently worded search for the memory formula, because no single chunk
covers both. An agent with search_docs as a tool can issue that second call itself, using the first
result’s content to phrase a better second query — multi-hop retrieval — and can repeat the pattern
iteratively until it judges the accumulated context sufficient or a step budget (Safety,
below) is reached, rather than a fixed number of retrieval rounds decided in advance by the application.
Routing between tools
Once retrieval is a tool, it can sit next to other tools, and the model routes between them based on the question rather than the application hard-coding which source to use:
-
search_docs— semantic search over the DocsAssistant vector store, for conceptual questions. -
A SQL tool — a parameterized query function over the same PostgreSQL database, for structured questions ("how many chunks does the
vector-ragcomponent have") that a vector search cannot answer. -
A web-search tool — for questions the indexed corpus does not cover at all, such as a library’s very latest release notes.
The routing decision — which tool, or combination, answers this question — is made by the model from the tools' descriptions and the conversation, the same mechanism `search_docs’s own docstring uses above; no separate router component is required, though Retrieval Strategies describes an explicit query router as an alternative when the choice needs to be deterministic rather than model-driven.
With create_agent (introduced in full below), routing is nothing more than
a second tool on the list. count_chunks runs a fixed, parameterized query against the shared documents /
chunks schema, so the model only ever supplies a value, never SQL text:
import psycopg
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_ollama import ChatOllama
from docsassistant.search import search_docs
DB_URI = "postgresql://docs_readonly:docs@localhost:5432/docsassistant"
@tool
def count_chunks(component: str) -> int:
"""Count the indexed chunks of one documentation component ("vector-rag" or "spring-ai").
Use this for questions about how much documentation exists, not for questions about its content.
"""
with psycopg.connect(DB_URI) as conn:
return conn.execute(
"SELECT count(*) FROM chunks c JOIN documents d ON d.id = c.document_id WHERE d.component = %s",
(component,),
).fetchone()[0]
agent = create_agent(
model=ChatOllama(model="llama3.1:8b", temperature=0.1),
tools=[search_docs, count_chunks],
system_prompt=(
"You are DocsAssistant. Use search_docs for questions about what the documentation says, "
"and count_chunks for questions about how much documentation exists."
),
)
agent.invoke({"messages": [{"role": "user", "content": "How many chunks does the vector-rag component have?"}]})
The two docstrings are the whole router: the model picks count_chunks for the counting question and
search_docs for a conceptual one. docs_readonly is a read-only database role, one of the tool-permissioning
measures described under Safety. See
LangChain: Tools for the @tool decorator and how
docstrings become tool descriptions.
Self-correcting RAG: grade, rewrite, retry
A plain retriever-as-tool agent still has one weak point: it does not know whether what it retrieved was any good, and it can hand a weak or off-topic set of chunks to the answer step regardless. Self-correcting RAG (the pattern behind Self-RAG and Corrective RAG) adds an explicit grading step between retrieval and generation:
-
Retrieve — call
search_docswith the current query. -
Grade — have the model (or a small classifier) judge each retrieved chunk as relevant or irrelevant to the question, typically with a short structured yes/no prompt per chunk.
-
Decide — if enough chunks graded relevant, proceed to generation with just those; otherwise:
-
Rewrite — have the model rephrase the query (more specific terms, a synonym, a decomposed sub-question) and go back to step 1, up to a bounded number of retries.
-
Generate — answer using the retained, relevant chunks — or state plainly that the corpus has no relevant answer once retries are exhausted, rather than answering from irrelevant context.
This trades one extra model call (the grading step) per retrieval for materially better resistance to a single bad query wording, without ever asking a human to intervene.
Answer verification against sources
A second, complementary check runs after generation instead of before it: ask the model (or a separate verifier call) whether the drafted answer’s claims are actually supported by the retrieved chunks it was given, rather than trusting that grounding held. A verification prompt such as "for each claim in the answer below, does the following context support it — yes, no, or not addressed" catches the case where retrieval and grading both succeeded but generation still drifted or extrapolated beyond the sources, which grading alone cannot detect because grading only looks at retrieval, never at what the model did with it.
from pydantic import BaseModel
from langchain_ollama import ChatOllama
VERIFY_PROMPT = """You are checking an answer against its sources.
For each factual claim in the ANSWER, decide whether the CONTEXT supports it.
Reply with supported=true only if every claim is supported; otherwise list the unsupported claims.
CONTEXT:
{context}
ANSWER:
{answer}"""
class Verification(BaseModel):
supported: bool
unsupported_claims: list[str]
verifier = ChatOllama(model="llama3.1:8b", temperature=0).with_structured_output(Verification)
def verify_answer(answer: str, chunks: list[dict]) -> Verification:
context = "\n\n".join(f"[{c['source_path']}] {c['content']}" for c in chunks)
return verifier.invoke(VERIFY_PROMPT.format(context=context, answer=answer))
When supported comes back false, the application (or a LangGraph verify node) can regenerate with an
instruction to drop the listed claims, retrieve again, or return the answer with an explicit caveat, rather
than silently serving ungrounded text. See
LangChain: Structured output for
with_structured_output.
The DocsAssistant agent, three ways
LangChain 1.x create_agent
LangChain 1.x’s create_agent builds a tool-calling agent from a chat model and a list of tools in one call,
letting the model decide when to invoke search_docs:
from langchain.agents import create_agent
from langchain_ollama import ChatOllama
from docsassistant.search import search_docs
model = ChatOllama(model="llama3.1:8b", temperature=0.1, num_ctx=8192)
agent = create_agent(
model=model,
tools=[search_docs],
system_prompt=(
"You are DocsAssistant. Use the search_docs tool to look up the "
"documentation before answering questions about vector databases, "
"RAG or Spring AI. Cite the source_path of every chunk you rely on."
),
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "What does ef_search control in an HNSW index?"}]}
)
print(result["messages"][-1].content)
create_agent runs the tool-calling loop internally: the model decides whether to call search_docs, the
tool result is appended to the conversation, and the model is invoked again until it produces a final answer
with no further tool calls. See
LangChain: Agents for `create_agent’s full
configuration surface (structured output, hooks, interrupt points).
A LangGraph agentic-RAG graph
`create_agent’s built-in loop is enough for a simple retriever-as-tool agent, but the grade/rewrite/retry pattern above needs explicit branching that a graph expresses more directly than a single agent loop. LangGraph models the self-correcting flow as a small state graph with one node per step:
-
retrieve — calls
search_docswith the current query, stores the chunks in the graph state. -
grade — scores each chunk’s relevance to the question; the state records how many passed.
-
A conditional edge out of grade: if enough chunks are relevant, go to generate; otherwise, go to rewrite (bounded by a retry counter also held in the graph state, to guarantee termination).
-
rewrite — rephrases the query from the original question and the previous attempt, then loops back to retrieve.
-
generate — produces the final answer from the retained chunks, and can itself route to an answer-verification node before returning, following the pattern above.
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
class RagState(TypedDict):
question: str
query: str
chunks: list[dict]
graded_chunks: list[dict]
retries: int
answer: str
graph = StateGraph(RagState)
graph.add_node("retrieve", retrieve_node)
graph.add_node("grade", grade_node)
graph.add_node("rewrite", rewrite_node)
graph.add_node("generate", generate_node)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "grade")
graph.add_conditional_edges(
"grade",
lambda state: "generate" if len(state["graded_chunks"]) >= 2 or state["retries"] >= 3 else "rewrite",
{"generate": "generate", "rewrite": "rewrite"},
)
graph.add_edge("rewrite", "retrieve")
graph.add_edge("generate", END)
app = graph.compile()
retrieve_node, grade_node, rewrite_node and generate_node are ordinary Python functions that take and
return a RagState; retrieve_node wraps search_docs, and grade_node and rewrite_node each make one
model call with a small structured prompt. See
LangChain: Agentic RAG (LangGraph) for a full
worked graph of this exact retrieve/grade/rewrite/generate shape, including the conditional-edge wiring.
Spring AI ChatClient with a documentation tool
Spring AI exposes the same retriever-as-tool pattern by registering a plain Java object whose method is
annotated @Tool; ChatClient handles the tool-calling loop the same way create_agent does on the Python
side:
package com.example.docsassistant;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.document.Document;
import java.util.List;
public class DocsTools {
private final VectorStore vectorStore;
public DocsTools(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@Tool(description = "Search the DocsAssistant documentation for chunks relevant to a query")
public List<DocHit> searchDocs(String query, String component) {
SearchRequest.Builder request = SearchRequest.builder().query(query).topK(5);
if (component != null) {
request.filterExpression("component == '" + component + "'");
}
List<Document> hits = vectorStore.similaritySearch(request.build());
return hits.stream()
.map(d -> new DocHit(
(String) d.getMetadata().get("source_path"),
(String) d.getMetadata().get("section"),
d.getText(),
d.getScore() != null ? d.getScore() : 0.0))
.toList();
}
}
record DocHit(String sourcePath, String section, String content, double score) {}
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.vectorstore.VectorStore;
public class DocsAssistantAgent {
private final ChatClient chatClient;
public DocsAssistantAgent(ChatClient.Builder builder, VectorStore vectorStore) {
this.chatClient = builder
.defaultTools(new DocsTools(vectorStore))
.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
ChatClient sends the @Tool-annotated method’s schema to the model alongside the conversation; when the
model requests searchDocs, Spring AI invokes the Java method, feeds the returned DocHit list back as a
tool result, and re-invokes the model, iterating until a final answer with no further tool calls is produced — the same loop shape as create_agent and the LangGraph graph above. See
Spring AI: Tool calling for the @Tool annotation,
ToolCallback alternatives, and defaultTools/tools(…) registration.
GraphRAG and exposing retrieval as an MCP tool
GraphRAG
Every agent above treats search_docs as a single vector lookup. GraphRAG extends that lookup with a graph
traversal step: after the vector search finds the most similar chunks or entities, a graph walk pulls in the
related entities, cross-references, or provenance those hits connect to, before either is handed to the LLM.
This site’s Neo4j section already documents that pattern in depth, including its own worked pipeline and
diagram; see Neo4j:
GraphRAG — vector retrieval plus graph expansion rather than repeating it here. Microsoft’s GraphRAG project
takes the idea further at indexing time, building an LLM-extracted knowledge graph and community summaries
from a corpus so that retrieval can answer both specific, entity-level questions and broad, corpus-wide
"what are the themes in this dataset" questions that a flat vector index cannot answer well on its own; see
Microsoft: GraphRAG for its indexing pipeline and query modes.
Exposing vector search as an MCP server tool
The retriever-as-tool pattern above wires search_docs directly into one agent’s process. The Model
Context Protocol (MCP) standardizes the same idea across processes: a server exposes a tool — with a name,
a description and a JSON Schema input/output shape, the same information search_docs’s docstring already
carries — over a client-server protocol, so any MCP-compatible agent (not just the one that embedded
`search_docs in its own code) can call it. A minimal tool definition for search_docs, following the MCP
specification dated 2026-07-28, looks like:
{
"name": "search_docs",
"description": "Search the DocsAssistant documentation corpus for chunks relevant to a query.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Natural-language question or search phrase."},
"component": {"type": ["string", "null"], "enum": ["vector-rag", "spring-ai", null]},
"k": {"type": "integer", "default": 5}
},
"required": ["query"]
}
}
This is the tool’s advertised shape only; the server behind it still runs the same search_docs function (or
DocsTools.searchDocs) against the pgvector store, and a full server also needs transport wiring, a
tools/call handler and lifecycle negotiation, which is out of scope here. See
Model Context Protocol for the full specification and message flow, and
Spring AI: MCP overview for exposing an
@Tool-annotated Spring bean such as DocsTools as an MCP server tool, and for consuming one as a
ToolCallback on the client side. The full treatment — a complete docs-assistant MCP server with search and
retrieve tools, access control from the caller’s identity and wiring to pgvector and to a Spring AI
VectorStore — is Exposing RAG through MCP.
Safety
Letting an LLM decide when and what to retrieve, and then act on what comes back, introduces failure modes a
fixed pipeline does not have. A retrieved chunk is untrusted input the moment it reaches the prompt: if the
indexed corpus can contain attacker-influenced text — a crafted support ticket, a wiki page anyone can edit,
a web page pulled in by a search tool — that text can carry instructions aimed at the model itself, a prompt
injection through retrieved documents, and an agent that treats every message in its context as equally
authoritative can be talked into ignoring its system prompt, calling tools it should not, or leaking the
conversation. Treating retrieved content as data to answer from rather than instructions to follow, stripping
or flagging suspicious imperative text before it reaches the prompt, and keeping the system prompt’s own
instructions clearly separated from retrieved context, all reduce this risk without eliminating it. Tool
permissioning narrows the blast radius further: search_docs is read-only by construction, but an agent
whose tool list also includes a SQL tool or a web tool should scope each one as tightly as the task allows — a read-only database role for the SQL tool, a query-only web tool with no ability to submit forms or follow
authenticated links — so that even a successfully injected instruction has little to act on. Finally, the
same iterative loop that lets an agent retry a bad query (the
self-correcting pattern above) can also spin indefinitely on a query it can never satisfy, so every agentic
RAG loop needs an explicit step limit (a maximum number of retrieve/grade/rewrite cycles, as the LangGraph
retry counter above enforces) and, for a longer-running agent, an overall wall-clock or token budget, so that
a stuck loop fails loudly and cheaply instead of retrying forever.
In LangChain and Spring AI
LangChain
create_agent with search_docs on its tools list is LangChain’s minimal retriever-as-tool agent, shown
above; the self-correcting retrieve/grade/rewrite/generate loop is expressed as a LangGraph graph instead,
also shown above. See database/vector-rag/integrating-with-langchain.adoc#langchain-agentic-rag
for the wider agentic-RAG surface, including how the retriever tool composes with LangChain’s other
retrievers.
Spring AI
A ChatClient configured with .defaultTools(new DocsTools(vectorStore)), shown above, is Spring AI’s equivalent:
the @Tool-annotated searchDocs method is exposed to the model the same way search_docs is exposed to
create_agent. See database/vector-rag/integrating-with-spring-ai.adoc#spring-ai-tools for the wider
tool-calling surface, including registering multiple tools and function-based `ToolCallback`s.
Related pages
-
Retrieval Strategies — query rewriting, multi-query expansion and routing as deterministic (non-agentic) alternatives to the model-driven decisions on this page.
-
Prompt Augmentation & Generation — context formatting and "no relevant context" handling that the generate step above relies on.
-
Agent Memory & Semantic Cache — conversation memory and semantic caching for the same agent across turns.
-
Agents with create_agent —
create_agentin depth: models, tools, prompts and middleware for the agent loop on this page. -
Short-Term Memory & Checkpointers — LangGraph checkpointers and
thread_idfor per-conversation state. -
Long-Term Memory Stores — LangGraph stores for memory shared across threads.
-
Vector Search and GenAI — the GraphRAG pipeline referenced above, in full.
-
RAG Integration Patterns — retrieval patterns from the Qdrant side.
-
Apple Intelligence and Machine Learning — a prompt-injection discussion from a different platform’s perspective.