Parent-Child Retrieval

In production systems, simple fixed-size chunking fails because it forces a trade-off: small chunks give high search precision but lose context; large chunks keep context but dilute semantic meaning. Parent-Child Retrieval is the industry-standard solution to this dilemma. Today, you will learn not just how it works, but exactly how to implement it in production-grade Python.

Parent-Child Retrieval (also known as Small-to-Big Retrieval) is a two-tier hierarchical indexing strategy. It splits documents into large Parent chunks (stored in a DocStore) and small Child chunks (stored in a VectorStore). Each Child stores its Parent’s unique ID in its metadata. At query time, the system performs vector search on Child chunks for maximum precision, extracts the deduplicated Parent IDs, retrieves the full Parent chunks from the DocStore, and passes those large Parents to the LLM for generation.

Parent-Child Retrieval(也称为从小到大的检索)是一种两层分层索引策略。它将文档切分为大的父块(存储在文档库DocStore中)和小的子块(存储在向量库VectorStore中)。每个子块在其元数据中存储其父块的唯一ID。查询时,系统对子块进行向量搜索以获得最大精度,提取去重后的父ID,从文档库中检索完整的父块,然后将这些大的父块传递给LLM用于生成。

Two-Phase Workflow

Phase (阶段)EN DescriptionCN Description
Indexing (索引)Split raw doc into Parents → store in DocStore. Split each Parent into Children → embed Children → store in VectorStore with parent_id metadata.将原始文档切分为父块 → 存入文档库。将每个父块切分为子块 → 将子块嵌入 → 连同parent_id元数据存入向量库。
Retrieval (检索)Query → vector search on Children → get top-k Child IDs → extract unique parent_ids → fetch Parents from DocStore → return Parents to LLM.查询 → 对子块做向量搜索 → 获取top-k子块ID → 提取唯一parent_id → 从文档库获取父块 → 将父块返回给LLM。

Embed Children:

“Embedding the Children” means taking the raw textual string of a Child chunk and passing it through a pre-trained neural network model (e.g., text-embedding-3-smallBAAI/bge-large-en) to produce a fixed-length, dense vector representation (typically 768 to 3072 dimensions). This process maps discrete text tokens into a continuous high-dimensional vector space where semantic similarity correlates with geometric proximity (e.g., cosine distance). The resulting vectors are then indexed in a Vector Database (like Chroma, Pinecone, or Milvus) to enable Approximate Nearest Neighbor (ANN) search at query time.

“嵌入子块” 是指取子块的原始文本字符串,通过一个预训练的神经网络模型(例如 text-embedding-3-smallBAAI/bge-large-en)进行前向传播,产生一个固定长度的稠密向量表示(通常为 768 到 3072 维)。这个过程将离散的文本标记映射到一个连续的高维向量空间,在此空间中,语义相似性与几何邻近度(例如余弦距离)相关联。生成的向量随后会被索引到向量数据库(如 Chroma、Pinecone 或 Milvus)中,以便在查询时支持近似最近邻(ANN)搜索。

Why do we only embed the children?

Why don’t we embed the Parents directly?Semantic Concentration (语义集中): A Child chunk is small (e.g., 100 tokens). It usually talks about one single idea. When you embed it, the resulting vector is highly focused on that one idea.

Semantic Dilution (语义稀释): A Parent chunk is large (e.g., 1000 tokens). It might contain 10 different ideas. If you embed the whole Parent, the vector averages out all those 10 ideas. When a user asks about one specific idea, the averaged vector will match poorly.

The Golden Rule of Retrieval: You want the search index (the vectors) to be highly precise (pointing to exactly the right concept). You want the context given to the LLM to be rich (containing all surrounding details). By embedding only the Children, your search index is ultra-precise; by returning the Parents, your LLM context is ultra-rich.
你可能会问:“为什么我们不直接嵌入父块呢?” 这是关键原因:

语义集中:子块很小(例如 100 个 token)。它通常只谈论一个单一想法。当你嵌入它时,生成的向量高度集中于这一个想法。

语义稀释:父块很大(例如 1000 个 token)。它可能包含 10 个不同的想法。如果你嵌入整个父块,向量会把那 10 个想法平均掉。当用户询问其中某一个特定想法时,这个平均后的向量匹配度会很差。

检索的黄金法则:你希望搜索索引(向量)高度精确(精准指向特定概念)。你希望给 LLM 的上下文丰富(包含所有周围细节)。仅通过嵌入子块,你的搜索索引超精确;通过返回父块,你的 LLM 上下文超丰富。

Critical Design Decisions

How to split the Parent?

  • Paragraph mode: Split by \n\n (best for well-structured docs).
  • Full-doc mode: Treat the whole document as one Parent (best for short documents < 3000 chars).
  • Hierarchical mode: Use document headers (H1, H2) as Parent boundaries (most advanced).

A. 如何切分父块?

  • 段落模式:按\n\n切分(最适合结构良好的文档)。
  • 全文模式:将整个文档作为一个父块(最适合短文档 < 3000字符)。
  • 层级模式:使用文档标题(H1, H2)作为父块边界(最先进)。

B. How to split the Child?
Always use RecursiveCharacterTextSplitter so that paragraphs, sentences, and words are respected in that priority. This ensures the Child is semantically coherent even at small sizes.

B. 如何切分子块?
始终使用 RecursiveCharacterTextSplitter,这样会按段落→句子→单词的优先级进行切分。这确保子块即使在很小的尺寸下也具有语义连贯性。

C. Recommended Parameters (for English/Chinese mixed text)

ParameterChildParent
Chunk Size300–800 characters (100-200 tokens)1500–8000 characters (500-3000 tokens)
Overlap10–20%10–20%

C. 推荐参数(针对中英文混合文本)

参数子块父块
块大小300–800 字符 (100-200 tokens)1500–8000 字符 (500-3000 tokens)
重叠10–20%10–20%

Code Implementation

Environment Setup

# ================================================================
# EN: Install dependencies / CN: 安装依赖
# pip install langchain langchain-community langchain-chroma langchain-openai
# ================================================================

import os
from typing import List, Dict, Any, Optional
from collections import defaultdict

# ================================================================
# EN: Core LangChain imports / CN: 核心LangChain导入
# ================================================================
from langchain.retrievers import ParentDocumentRetriever
# EN: The main class implementing the Parent-Child logic.
# CN: 实现父子检索逻辑的主类。

from langchain.storage import InMemoryStore
# EN: In-memory DocStore for Parents (DEV only). Production use Redis/S3.
# CN: 内存中的文档库,存父块(仅开发用)。生产用Redis/S3。

from langchain_chroma import Chroma
# EN: Vector database for Children embeddings.
# CN: 用于存储子块向量的向量数据库。

from langchain_openai import OpenAIEmbeddings, ChatOpenAI
# EN: OpenAI embeddings and Chat model.
# CN: OpenAI嵌入模型和对话模型。

from langchain_text_splitters import RecursiveCharacterTextSplitter
# EN: Recursive splitter that prioritizes separators like "\n\n" > "\n" > "。" > " ".
# CN: 递归分割器,按分隔符优先级 "\n\n" > "\n" > "。" > " " 进行切分。

from langchain_community.document_loaders import TextLoader
from langchain.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# ================================================================
# EN: Set API Key / CN: 设置 API Key
# ================================================================
os.environ["OPENAI_API_KEY"] = "your-openai-api-key"

Indexing Phase

# ================================================================
# EN: 1. Load raw documents / CN: 1. 加载原始文档
# ================================================================
loader = TextLoader("./data/your_document.txt")
documents = loader.load()

# ================================================================
# EN: 2. Define Parent and Child splitters.
# CN: 2. 定义父块分割器和子块分割器。
# 
# EN: Parent splitter: large chunks for LLM context.
# CN: 父分割器:大块,供LLM上下文使用。
# ================================================================
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=2000,          # EN: Max 2000 chars per Parent / CN: 每父块最多2000字符
    chunk_overlap=200,        # EN: Overlap to preserve boundary context / CN: 重叠以保留边界上下文
    separators=["\n\n", "\n", "。", "!", "?", " ", ""]
    # EN: Priority: paragraphs > lines > Chinese periods > spaces > chars.
    # CN: 优先级:段落 > 行 > 中文句号 > 空格 > 字符。
)

child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,           # EN: Small, precise for vector search / CN: 小尺寸,精确用于向量搜索
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", " ", ""]
)

# ================================================================
# EN: 3. Initialize VectorStore (for Children) and DocStore (for Parents).
# CN: 3. 初始化向量库(存子块)和文档库(存父块)。
# ================================================================

vectorstore = Chroma(
    collection_name="child_embeddings",
    embedding_function=OpenAIEmbeddings()  # EN: text-embedding-3-small by default / CN: 默认用text-embedding-3-small
)

docstore = InMemoryStore()
# EN: InMemoryStore is a simple key-value store. Keys are UUIDs.
# CN: InMemoryStore 是一个简单的键值存储。键是UUID。

# ================================================================
# EN: 4. Instantiate the ParentDocumentRetriever.
# CN: 4. 实例化 ParentDocumentRetriever。
# ================================================================
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,      # EN: Children go here / CN: 子块存于此
    docstore=docstore,            # EN: Parents go here / CN: 父块存于此
    child_splitter=child_splitter, # EN: How to split Parent into Children / CN: 如何将父块切分为子块
    parent_splitter=parent_splitter, # EN: How to split Doc into Parents / CN: 如何将文档切分为父块
)

# ================================================================
# EN: 5. Run indexing.
# CN: 5. 执行索引。
# EN: Internal logic:
#     1) parent_splitter splits docs into Parent chunks.
#     2) For each Parent, generate a UUID -> store in docstore.
#     3) child_splitter splits each Parent into Child chunks.
#     4) For each Child, embed it, store in vectorstore, 
#        and add "parent_id" = Parent's UUID to its metadata.
# CN: 内部逻辑:
#     1) parent_splitter 将文档切分为父块。
#     2) 对每个父块,生成一个UUID -> 存入docstore。
#     3) child_splitter 将每个父块切分为子块。
#     4) 对每个子块,进行嵌入,存入vectorstore,
#        并将 "parent_id" = 父块的UUID 加入其元数据。
# ================================================================
retriever.add_documents(documents)

print(f"EN: Indexing complete. Child count: {vectorstore._collection.count()}")
print(f"CN: 索引完成。子块数量: {vectorstore._collection.count()}")
print(f"EN: Parent count: {len(docstore.yield_keys())}")
print(f"CN: 父块数量: {len(docstore.yield_keys())}")

Retrieval Phase

# ================================================================
# EN: 6. Basic retrieval (automatic Parent fetching).
# CN: 6. 基础检索(自动获取父块)。
# ================================================================
query = "What is asynchronous I/O in Python?"  # EN / CN: Python中的异步I/O是什么?

# EN: Behind the scenes:
#     1. Embed query -> search top-k Children in vectorstore.
#     2. Collect parent_ids from Child metadata.
#     3. Deduplicate parent_ids.
#     4. Fetch corresponding Parent chunks from docstore.
#     5. Return Parents (not Children).
# CN: 幕后流程:
#     1. 嵌入查询 -> 在向量库中搜索top-k子块。
#     2. 从子块元数据中收集parent_id。
#     3. 去重parent_id。
#     4. 从docstore中获取对应的父块。
#     5. 返回父块(而非子块)。
retrieved_parents = retriever.invoke(query)

print(f"EN: Query: {query}")
print(f"CN: 查询: {query}")
print(f"EN: Retrieved {len(retrieved_parents)} Parent chunks.")
print(f"CN: 检索到 {len(retrieved_parents)} 个父块。")

for i, doc in enumerate(retrieved_parents[:2]):
    print(f"\n--- Parent {i+1} ---")
    print(f"Preview: {doc.page_content[:150]}...")

Advanced: Manual Parent-Child Retrieval with Deduplication

手动实现去重检索

# ================================================================
# EN: 7. Manual implementation to understand the internals.
# CN: 7. 手动实现,以理解内部原理。
# ================================================================

def manual_parent_retrieve(query: str, child_k: int = 20) -> List[Dict[str, Any]]:
    """
    EN: Custom Parent-Child retrieval with score tracking.
    CN: 自定义父子检索,带分数跟踪。
    
    EN: Steps:
        1. Search Children with scores.
        2. Group by parent_id, keep the highest score (lowest distance).
        3. Sort Parents by score.
        4. Fetch full Parent texts from docstore.
    CN: 步骤:
        1. 搜索子块并获取分数。
        2. 按parent_id分组,保留最高分(距离最小)。
        3. 按分数排序父块。
        4. 从docstore获取完整父块文本。
    """
    # EN: Step 1: Search Children / CN: 步骤1:搜索子块
    child_results = vectorstore.similarity_search_with_score(query, k=child_k)
    
    # EN: Step 2: Group by parent_id, keep best score per parent.
    # CN: 步骤2:按parent_id分组,每个父块保留最佳分数。
    parent_best_score = defaultdict(float)
    parent_ids = set()
    
    for doc, score in child_results:
        # EN: metadata contains "parent_id" set by the retriever.
        # CN: metadata中包含由retriever设置的"parent_id"。
        parent_id = doc.metadata.get("parent_id")
        if parent_id:
            parent_ids.add(parent_id)
            # EN: Lower score = better (cosine distance).
            # CN: 分数越低越好(余弦距离)。
            if score < parent_best_score.get(parent_id, float('inf')):
                parent_best_score[parent_id] = score
    
    # EN: Step 3: Sort parents by score (best first).
    # CN: 步骤3:按分数排序父块(最优在前)。
    sorted_parents = sorted(parent_best_score.items(), key=lambda x: x[1])
    
    # EN: Step 4: Fetch from DocStore.
    # CN: 步骤4:从文档库获取。
    results = []
    for parent_id, score in sorted_parents:
        # EN: mget expects a list of keys, returns a list of documents.
        # CN: mget 接受一个键列表,返回一个文档列表。
        parent_doc = docstore.mget([parent_id])[0]
        if parent_doc:
            results.append({
                "parent_id": parent_id,
                "content": parent_doc.page_content,
                "score": score,
                "metadata": parent_doc.metadata
            })
    return results

# EN: Execute manual retrieval / CN: 执行手动检索
manual_results = manual_parent_retrieve(query="asyncio vs threading", child_k=15)
for r in manual_results[:2]:
    print(f"EN: Parent ID: {r['parent_id']}, Score: {r['score']:.4f}")
    print(f"CN: 父ID: {r['parent_id']}, 分数: {r['score']:.4f}")
    print(f"Content Preview: {r['content'][:120]}...\n")

Full RAG Pipeline with Parent-Child

# ================================================================
# EN: 8. Complete RAG Chain using Parent-Child Retriever.
# CN: 8. 使用父子检索器的完整RAG链。
# ================================================================

llm = ChatOpenAI(model="gpt-4-turbo", temperature=0.2)

prompt = ChatPromptTemplate.from_template("""
EN: You are an expert Data Engineer assistant. Answer the question based ONLY on the following context.
CN: 你是一位资深数据工程师助理。请仅基于以下上下文回答问题。

<context>
{context}
</context>

EN: Question: / CN: 问题: {question}

EN: If the context does not contain the answer, say "I don't have this information."
CN: 如果上下文中没有答案,请说"我没有这个信息"。
""")

def format_docs(docs):
    """EN: Join Parent chunks with separators / CN: 用分隔符合并父块"""
    return "\n\n---SECTION BREAK---\n\n".join([d.page_content for d in docs])

# EN: Build the LCEL chain.
# CN: 构建LCEL链。
rag_chain = (
    {
        "context": retriever | format_docs,  # EN: Retrieve Parents -> format / CN: 检索父块 -> 格式化
        "question": RunnablePassthrough()     # EN: Pass user question as-is / CN: 原样传递用户问题
    }
    | prompt
    | llm
    | StrOutputParser()
)

# EN: Execute / CN: 执行
response = rag_chain.invoke("Explain Python's GIL impact on async I/O.")
print(f"EN: Final Answer: / CN: 最终答案:\n{response}")

KEY TAKEAWAYS

要点 (Concept)EN (English)CN (Chinese)
核心思想Use Child for retrieval (high precision), Parent for generation (rich context).用子块做检索(高精度),用父块做生成(丰富上下文)。
存储分离Child in VectorStore (embeddings). Parent in DocStore (raw text).子块存向量库(嵌入)。父块存文档库(原始文本)。
关联机制Child metadata stores parent_id to link back to its Parent.子块元数据存储 parent_id 来回链到它的父块。
检索去重Multiple Children may point to the same Parent; always deduplicate parent_ids before fetching.多个子块可能指向同一个父块;获取前必须对 parent_id 去重。
官方实现LangChain’s ParentDocumentRetriever handles all this automatically.LangChain 的 ParentDocumentRetriever 自动处理这一切。
分割策略Use RecursiveCharacterTextSplitter with different sizes for Parent and Child.对父块和子块使用不同尺寸的 RecursiveCharacterTextSplitter
生产就绪Replace InMemoryStore with Redis, S3, or PostgreSQL for production.生产环境用 Redis、S3 或 PostgreSQL 替换 InMemoryStore
掌握级别General Mastery: You must know when to tune Parent size (e.g., 2000 vs 5000 chars) based on your LLM’s context window and document complexity.一般掌握:你必须知道何时根据LLM上下文窗口和文档复杂度调整父块大小(例如2000 vs 5000字符)。