← Back to DevBytes

Parent-Document Retrieval: Preserving Context in RAG

Parent-Document Retrieval: Preserving Context in RAG

Retrieval-Augmented Generation (RAG) has become the standard pattern for grounding large language models in private or domain-specific data. However, a subtle but persistent problem haunts most naive RAG implementations: the chunk-size tradeoff. Small chunks retrieve precisely but lack context. Large chunks preserve context but dilute the signal that embedding models need to match queries accurately. Parent-Document Retrieval is the architectural answer to this dilemma.

What Is Parent-Document Retrieval?

Parent-Document Retrieval (PDR) is a two-tier retrieval strategy where you search using small, semantically focused chunks but return the larger parent document (or a substantial section of it) that contains those chunks. The idea is to decouple the unit of retrieval from the unit of generation context.

Concretely, a long document is split into parent chunks — say, 2000 tokens each. Each parent chunk is then further subdivided into child chunks of 200 tokens. The child chunks are embedded and indexed. At query time, the system embeds the query, finds the most similar child chunks, maps each back to its parent, deduplicates, and passes the parent chunks to the LLM as context.

Why It Matters

How to Use It

Below is a complete, runnable example using LangChain. We will build a parent-document retriever over a set of markdown documents, store child embeddings in an in-memory vector store, and demonstrate a query that retrieves precise child chunks but returns rich parent context.

# pip install langchain langchain-openai langchain-community chromadb

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore

# 1. Load source documents
loader = TextLoader("api_docs.md")
docs = loader.load()

# 2. Parent splitter: large chunks that preserve context
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=2000,
    chunk_overlap=200,
)

# 3. Child splitter: small chunks optimized for embedding match
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
)

# 4. Vector store for child chunks (search index)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
    collection_name="child_chunks",
    embedding_function=embeddings,
)

# 5. Key-value store mapping child_id -> parent document
parent_store = InMemoryStore()

# 6. Build the retriever
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=parent_store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 7. Index documents: this splits into parents, then children,
#    embeds children, and stores parents in the docstore.
retriever.add_documents(docs)

# 8. Query: retrieves child chunks, returns parent documents
query = "How do I refresh an expired access token?"
results = retriever.invoke(query)

for i, doc in enumerate(results):
    print(f"--- Parent Document {i} ---")
    print(f"Length: {len(doc.page_content)} chars")
    print(doc.page_content[:500])
    print()

Notice that even though the vector store only contains 300-character child chunks, the results list contains full 2000-character parent documents. The retriever handles the mapping transparently.

Wiring It Into a RAG Chain

The retriever plugs directly into a standard LangChain RAG chain. Because the returned documents are full parent chunks, the LLM receives richer context than it would with naive chunk retrieval.

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("""
You are a technical assistant. Answer the question using only
the provided context. If the context is insufficient, say so.

Context:
{context}

Question: {question}
""")

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

answer = rag_chain.invoke("How do I refresh an expired access token?")
print(answer)

Using a Persistent Parent Store

The InMemoryStore is fine for prototyping but loses data on restart. For production, use a persistent backing store such as Redis, a local filesystem store, or a SQL database. LangChain ships with several options:

from langchain.storage import LocalFileStore

# Persist parent documents to disk
parent_store = LocalFileStore("./parent_docs")

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=parent_store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

The vector store (Chroma) persists child embeddings to its own on-disk directory, while the parent store keeps the full parent text. On restart, both layers reload automatically, and the retriever reconstructs the mapping.

Best Practices

When Not to Use It

Parent-Document Retrieval adds complexity: a second store, a mapping layer, and two splitters to tune. For small, homogeneous corpora where each document is already short and topically focused, naive single-pass chunking may perform just as well with less operational overhead. PDR shines most when documents are long, heterogeneous, and contain sections whose meaning depends on surrounding context — exactly the situation in most real-world knowledge bases.

Parent-Document Retrieval resolves the fundamental tension between retrieval precision and generation context by treating them as separate concerns. By embedding small, focused child chunks and returning their larger parent passages to the LLM, you get the best of both worlds: accurate semantic search and rich, interpretable context. For any RAG system operating over substantial documents, it is one of the highest-leverage architectural choices you can make.

— Ad —

Google AdSense will appear here after approval

← Back to all articles