Agentic RAG Pipelines

10 min read Module 4 of 10 Topic 12 of 30

What you'll learn

  • Understand the difference between naive RAG and agentic RAG
  • Implement query decomposition, multi-hop retrieval, and self-reflection in a RAG pipeline
  • Build a document ingestion pipeline with chunking, embedding, and metadata extraction
  • Add iterative retrieval where the agent refines its queries based on what it finds
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Naive RAG vs Agentic RAG

The standard RAG pipeline looks like this:

flowchart LR
    Q([User Query]) --> E[Embed Query] --> R[Retrieve top-K docs] --> G[LLM] --> A([Answer])

This works for simple factual questions over a well-structured knowledge base. It breaks down for:

  • Multi-hop questions (“What did the CEO say about the product that won the 2024 award?”)
  • Ambiguous queries where the first retrieval misses the intent
  • Synthesis tasks that require information from multiple independent documents
  • Verification (“Is this fact actually in the retrieved documents?”)

Agentic RAG replaces the single-step retrieval with an agent loop that can retrieve multiple times, reformulate queries, grade results, and synthesize intelligently.


The Agentic RAG Architecture

flowchart TD
    Q([User Query]) --> QA["Query Analyzer\nSimple or complex?\nDecompose if needed"]
    QA --> RET["Retriever\nVector DB + Full-text search"]
    RET --> GRD["Relevance Grader\nScore each doc\nDiscard irrelevant"]
    GRD -->|Enough relevant docs| SYN["Synthesizer\nGenerate answer"] --> ANS([Answer])
    GRD -->|Not enough| REF["Query Reformulator\nRewrite for better recall"]
    REF --> RET
    style QA fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style RET fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style GRD fill:#EEF0F7,stroke:#818CF8,color:#0F172A
    style SYN fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style REF fill:#fff0f0,stroke:#f87171,color:#0F172A

Document Ingestion Pipeline

Before retrieval, you need to ingest documents. Quality chunking and metadata extraction at index time pays dividends at query time.

# src/rag/ingestion.py
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader, WebBaseLoader
import hashlib

class DocumentIngester:
    def __init__(self, vector_store: AgentMemoryStore):
        self.vector_store = vector_store
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=800,     # ~200-250 tokens per chunk, small enough to stay focused, large enough for context
            chunk_overlap=100,  # overlap prevents cutting mid-sentence and losing cross-boundary context
            separators=["\n\n", "\n", ". ", " "],  # try paragraph breaks before line breaks before word breaks
        )
    
    async def ingest_pdf(self, pdf_path: str, metadata: dict = {}) -> int:
        """Load, chunk, and index a PDF document."""
        loader = PyPDFLoader(pdf_path)
        pages = loader.load()  # returns one Document per page, preserving page numbers
        
        chunks = []
        for page in pages:
            page_chunks = self.splitter.split_text(page.page_content)
            for i, chunk in enumerate(page_chunks):
                chunks.append({
                    "content": chunk,
                    "metadata": {
                        **metadata,
                        "source": pdf_path,
                        "page": page.metadata.get("page", 0),  # preserve page number for citations
                        "chunk_index": i,
                        # short hash to detect duplicate chunks during re-ingestion
                        "chunk_id": hashlib.md5(chunk.encode()).hexdigest()[:8],
                    }
                })
        
        # Batch embed and store
        for i in range(0, len(chunks), 50):  # embed in batches of 50 to stay within API limits
            batch = chunks[i:i+50]
            contents = [c["content"] for c in batch]
            embeddings = await embed_batch(contents)  # one API call per batch, not per chunk
            
            for chunk, embedding in zip(batch, embeddings):
                await self.vector_store.store_memory(
                    content=chunk["content"],
                    metadata=chunk["metadata"],
                )
        
        return len(chunks)
    
    async def ingest_web(self, url: str, metadata: dict = {}) -> int:
        """Scrape, chunk, and index a web page."""
        loader = WebBaseLoader(url)
        docs = loader.load()
        
        # join all page sections into one text before chunking
        text = " ".join([d.page_content for d in docs])
        chunks = self.splitter.split_text(text)
        
        for i, chunk in enumerate(chunks):
            await self.vector_store.store_memory(
                content=chunk,
                metadata={"source": url, "chunk_index": i, **metadata}
            )
        
        return len(chunks)

Building the Agentic RAG Graph

This LangGraph implementation wires together the four pipeline stages: decompose, retrieve, grade, and generate, with a conditional loop that retries retrieval up to 3 times if the first results are insufficient.

# src/rag/agentic_rag.py
from langgraph.graph import StateGraph, END
from pydantic import BaseModel

class RagState(TypedDict):
    question: str
    sub_questions: list[str]        # decomposed sub-questions for multi-hop retrieval
    retrieved_docs: list[dict]
    graded_docs: list[dict]         # docs that passed relevance grading
    generation: str | None
    retrieval_attempts: int         # tracks how many times we've tried to retrieve

class RelevanceGrade(BaseModel):
    is_relevant: bool
    reasoning: str

# Structured output ensures grader always returns a well-typed boolean, not free text
grader_llm = llm.with_structured_output(RelevanceGrade)

async def decompose_question(state: RagState) -> dict:
    """Break complex questions into simpler sub-questions."""
    prompt = f"""Analyze this question: {state['question']}

If it's simple and self-contained, return just this question as a list with one item.
If it requires multi-step reasoning, break it into 2-4 simpler sub-questions.

Return as JSON: {{"sub_questions": ["question1", "question2", ...]}}"""
    
    result = llm.invoke([HumanMessage(content=prompt)])
    import json
    data = json.loads(result.content)
    return {"sub_questions": data["sub_questions"]}

async def retrieve(state: RagState) -> dict:
    """Retrieve documents for each sub-question."""
    all_docs = []
    
    for sub_q in state["sub_questions"]:
        docs = await vector_store.search(
            query=sub_q,
            top_k=4,                    # 4 docs per sub-question; total can grow with multiple sub-questions
            score_threshold=0.65,       # lower threshold than 0.75 to cast a wider net before grading
        )
        all_docs.extend(docs)
    
    # Deduplicate by content hash
    seen = set()
    unique_docs = []
    for doc in all_docs:
        key = doc["content"][:100]  # first 100 chars as a cheap deduplication fingerprint
        if key not in seen:
            seen.add(key)
            unique_docs.append(doc)
    
    return {
        "retrieved_docs": unique_docs,
        "retrieval_attempts": state["retrieval_attempts"] + 1,
    }

async def grade_documents(state: RagState) -> dict:
    """Grade each retrieved document for relevance."""
    graded = []
    
    for doc in state["retrieved_docs"]:
        # ask the LLM to judge relevance: more accurate than relying on cosine score alone
        grade = await grader_llm.ainvoke([
            HumanMessage(content=f"""Question: {state['question']}
            
Document: {doc['content'][:500]}

Is this document relevant to answering the question?""")
        ])
        
        if grade.is_relevant:
            graded.append(doc)
    
    return {"graded_docs": graded}

def check_relevance(state: RagState) -> str:
    """Route based on whether we have enough relevant docs."""
    if len(state["graded_docs"]) >= 2:  # 2+ relevant docs is enough to generate a grounded answer
        return "generate"
    if state["retrieval_attempts"] >= 3:  # safety valve, don't loop forever
        return "generate"  # give up and generate with what we have
    return "reformulate"  # not enough relevant docs, try a better query

async def reformulate_query(state: RagState) -> dict:
    """Reformulate questions when retrieval is insufficient."""
    prompt = f"""The original question was: {state['question']}

We retrieved {len(state['retrieved_docs'])} documents but only {len(state['graded_docs'])} were relevant.

Generate improved search queries that might find better information.
Return JSON: {{"sub_questions": ["better query 1", "better query 2"]}}"""
    
    result = llm.invoke([HumanMessage(content=prompt)])
    import json
    data = json.loads(result.content)
    # reset retrieved_docs so the next retrieval starts fresh with the new queries
    return {"sub_questions": data["sub_questions"], "retrieved_docs": []}

async def generate_answer(state: RagState) -> dict:
    """Generate final answer from graded documents."""
    # prefer graded docs; fall back to all retrieved docs if grading yielded nothing
    context = "\n\n---\n\n".join([
        f"[Source: {d['metadata'].get('source', 'unknown')}]\n{d['content']}"
        for d in (state["graded_docs"] or state["retrieved_docs"])[:8]  # cap at 8 docs to stay within context limits
    ])
    
    result = llm.invoke([
        SystemMessage(content="You are a precise research assistant. Answer based strictly on the provided context. If the context doesn't contain the answer, say so."),
        HumanMessage(content=f"Question: {state['question']}\n\nContext:\n{context}")
    ])
    
    return {"generation": result.content}

# Build the graph
rag_builder = StateGraph(RagState)
rag_builder.add_node("decompose", decompose_question)
rag_builder.add_node("retrieve", retrieve)
rag_builder.add_node("grade", grade_documents)
rag_builder.add_node("reformulate", reformulate_query)
rag_builder.add_node("generate", generate_answer)

rag_builder.set_entry_point("decompose")
rag_builder.add_edge("decompose", "retrieve")
rag_builder.add_edge("retrieve", "grade")
rag_builder.add_conditional_edges(
    "grade",
    check_relevance,
    {"generate": "generate", "reformulate": "reformulate"}  # conditional routing based on grading result
)
rag_builder.add_edge("reformulate", "retrieve")  # loop back, retry with better queries
rag_builder.add_edge("generate", END)

rag_agent = rag_builder.compile()

Agentic RAG as a Tool for Other Agents

The most powerful pattern: expose your RAG pipeline as a tool that any agent can call:

@research_agent.tool
async def search_knowledge_base(
    ctx: RunContext[AgentDeps],
    query: str,
    scope: str = "all",
) -> str:
    """Search the internal knowledge base for relevant information.
    
    Args:
        query: Natural language search query
        scope: Filter scope - "all", "docs", "code", or "policies"
    
    Returns:
        Relevant passages from the knowledge base with source citations.
    """
    filter_by = {"scope": scope} if scope != "all" else None
    
    # Run the full agentic RAG pipeline: the calling agent doesn't need to know retrieval details
    result = await rag_agent.ainvoke({
        "question": query,
        "sub_questions": [],
        "retrieved_docs": [],
        "graded_docs": [],
        "generation": None,
        "retrieval_attempts": 0,  # reset attempt counter for each fresh invocation
    })
    
    return result["generation"] or "No relevant information found."

This approach makes your entire knowledge base available to any agent without that agent needing to understand retrieval internals. The RAG agent handles query decomposition, relevance grading, and reformulation transparently.

Knowledge Check

3 questions to test your understanding

1 What is the main limitation of naive RAG compared to agentic RAG?

2 What is query decomposition in agentic RAG?

3 What is a 'retrieval grade' step in advanced agentic RAG?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan