Introduction: The Chunking Paradox in Enterprise RAG
Retrieval-Augmented Generation (RAG) has matured from a novelty to a critical enterprise infrastructure. However, production teams consistently hit the Chunking Paradox: small chunks are necessary for precise vector retrieval, but large chunks are necessary for coherent LLM generation. When you retrieve a 100-token snippet about "Q3 revenue variance," the model lacks the surrounding context of accounting methodologies, regional adjustments, and forward-looking guidance needed to answer accurately.
Parent Document Retriever (PDR) solves this by decoupling the retrieval unit from the generation unit. You embed and search over granular child chunks, but return the full parent document (or larger section) to the LLM. This article demonstrates an end-to-end implementation of PDR within a stateful, multi-agent LangGraph architecture designed for enterprise financial analysis. We move beyond simple chains to build a system with memory, conditional routing, and human-in-the-loop validation.
Real-Time Use Case: Institutional Earnings Call Analysis
The Scenario
A Tier-1 Investment Bank’s research desk needs to analyze quarterly earnings calls and 10-K filings. Analysts ask complex questions like: "How does management's commentary on supply chain headwinds in the Q3 call compare to the risk factors outlined in the latest 10-K, and what is the net impact on FY26 guidance?"
Why Simple RAG Fails Here
Semantic Fragmentation: Supply chain risks are mentioned in passing during Q&A (small chunk) but detailed in Item 1A of the 10-K (large chunk). Vector search retrieves the Q&A mention but misses the structured risk disclosure.
Temporal State: The analyst’s question references "FY26 guidance." The system must remember that the previous turn established the baseline as "consensus estimates," not "company guidance."
Multi-Source Synthesis: Answering requires retrieving from two distinct corpora (transcripts vs. filings), validating consistency, and synthesizing a unified view.
The Solution Architecture
We implement a LangGraph Multi-Agent System with three specialized agents:
Retrieval Agent: Uses Parent Document Retriever to fetch context from both transcripts and filings.
Analyst Agent: Synthesizes retrieved parent documents into a draft response.
Compliance Agent: Validates claims against source text and checks for hallucination.
The graph maintains a shared state containing conversation memory, retrieved documents, and intermediate drafts.

Technical Implementation
Prerequisites
pip install langgraph langchain-community langchain-openai \
chromadb tiktoken pydanticStep 1: Define the Shared State
In LangGraph, state is the single source of truth passed between nodes. For enterprise RAG, we need typed, validated state.
from typing import Annotated, List, Optional, Sequence
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.documents import Document
import operator
class AgentState(TypedDict):
"""Shared state for the multi-agent RAG system."""
# Conversation history with reducer pattern
messages: Annotated[list, add_messages]
# Retrieved parent documents (accumulated across agents)
retrieved_docs: Annotated[List[Document], operator.add]
# Current query being processed
current_query: str
# Intermediate analysis draft
draft_response: Optional[str]
# Compliance validation result
is_compliant: bool
# Metadata for audit trail
session_id: str
sources_cited: List[str]Step 2: Build the Parent Document Retriever
This is the core differentiator. We use RecursiveCharacterTextSplitter for parents and a finer splitter for children, linked via metadata.
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
def build_parent_document_retriever(pdf_paths: list[str]):
"""
Builds a PDR optimized for financial documents.
- Parent chunks: ~4000 tokens (full sections/paragraphs)
- Child chunks: ~400 tokens (precise retrieval targets)
"""
# Load documents
docs = []
for path in pdf_paths:
loader = PyPDFLoader(path)
docs.extend(loader.load())
# Parent splitter: preserves section coherence
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=4000,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", " ", ""]
)
# Child splitter: optimized for embedding precision
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=50,
separators=["\n", ". ", " ", ""]
)
vectorstore = Chroma(
collection_name="financial_pdr",
embedding_function=OpenAIEmbeddings(model="text-embedding-3-small")
)
store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# Index documents (children go to vectorstore, parents to docstore)
retriever.add_documents(docs, ids=None)
return retriever⚠️ Production Note: Replace
InMemoryStorewith Redis, PostgreSQL, or S3-backed storage for persistence. Chroma should be replaced with Pinecone, Weaviate, or pgvector in production.
Step 3: Define the Agents as Graph Nodes
Each agent is a function that reads/writes to AgentState.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# --- RETRIEVAL NODE ---
async def retrieval_node(state: AgentState) -> dict:
"""Uses PDR to fetch relevant parent documents."""
query = state["current_query"]
# PDR returns full parent documents, not tiny chunks
docs = await retriever.ainvoke(query)
# Deduplicate by parent doc ID
seen_ids = set()
unique_docs = []
for doc in docs:
doc_id = doc.metadata.get("doc_id", id(doc))
if doc_id not in seen_ids:
seen_ids.add(doc_id)
unique_docs.append(doc)
return {
"retrieved_docs": unique_docs,
"sources_cited": [d.metadata.get("source", "unknown") for d in unique_docs]
}
# --- ANALYST NODE ---
analyst_prompt = ChatPromptTemplate.from_messages([
("system", """You are a senior equity research analyst.
Synthesize the provided source documents into a precise answer.
Always cite sources using [Source: filename, page X] format.
If documents conflict, note the discrepancy explicitly."""),
("human", "Query: {query}\n\nSources:\n{context}")
])
async def analyst_node(state: AgentState) -> dict:
context = "\n\n---\n\n".join(
f"[{d.metadata.get('source','')} p.{d.metadata.get('page','')}]\n{d.page_content}"
for d in state["retrieved_docs"]
)
response = await analyst_prompt | llm | (lambda x: x.content)
draft = await response.ainvoke({
"query": state["current_query"],
"context": context
})
return {"draft_response": draft}
# --- COMPLIANCE NODE ---
compliance_prompt = ChatPromptTemplate.from_messages([
("system", """You are a compliance reviewer. Verify that EVERY claim
in the draft is directly supported by the source documents.
Return JSON: {"compliant": bool, "issues": list[str]}"""),
("human", "Draft:\n{draft}\n\nSources:\n{context}")
])
async def compliance_node(state: AgentState) -> dict:
context = "\n\n".join(d.page_content for d in state["retrieved_docs"])
result = await compliance_prompt | llm.with_structured_output(
method="json_schema"
) | (lambda x: x)
validation = await result.ainvoke({
"draft": state["draft_response"],
"context": context
})
return {"is_compliant": validation.get("compliant", False)}

Join the conversation! Your thoughts help the community grow.