目录

一、先搞懂:父文档检索器解决的核心痛点

二、核心方案:两种父文档检索模式

方案 1:检索完整文档 —— 适合短文档(如单页 PDF)

具体步骤(以《客户经理考核办法》单页为例)

适用场景

方案 2:检索较大的文档块 —— 适合长文档(如 100 页 PDF)

具体步骤(以 100 页《考核办法》为例)

关键优势

三、落地实现:父文档检索器的核心组件与代码示例

1. 核心组件说明

2. 代码示例(基于《客户经理考核办法》)

3. 代码关键流程解释

四、父文档检索器 vs 传统检索:优势对比

五、总结


在 RAG(检索增强生成)中,“文档怎么切” 是个让人头疼的问题:切太小,虽然和用户问题的匹配度高(比如 “客户经理投诉扣分” 能精准命中 “扣 2 分” 的小块),但信息不全,LLM 可能漏关键背景;切太大,信息是全了,可向量匹配时容易 “抓不住重点”(比如 1000 字块里混了 “投诉”“入职”“评聘”,用户问 “投诉” 却匹配到整个块,效率低)。

LangChain 的父文档检索器(Parent Document Retriever) 就是为解决这个 “矛盾需求” 而生 —— 它通过 “双层文档块” 设计,让 “匹配准” 和 “信息全” 两者兼得,就像给文档加了 “精准书签”,既靠书签快速定位,又能看到完整的书页内容。

一、先搞懂:父文档检索器解决的核心痛点

传统文档检索的 “两难” 问题,是父文档检索器的出发点,先看清楚问题才能理解解决方案:

文档块大小 优势 劣势 典型场景问题
小(如 256 字符) 向量化后匹配准(语义聚焦) 信息碎片化,LLM 缺上下文,答案不完整 用户问 “投诉扣分影响评优吗”,小块只提 “扣 2 分”,没提 “超 5 次取消评优”,LLM 答不全
大(如 2048 字符) 信息全,LLM 能生成完整答案 向量化后匹配差(语义分散) 用户问 “投诉扣分”,大块里混了 “入职要求”,匹配时可能优先命中无关内容

父文档检索器的核心思路:用 “小块做匹配,用大块给信息” —— 先通过小文档块(子文档)精准找到相关位置,再关联到对应的大文档块(父文档),把大文档块喂给 LLM,既保证匹配精度,又不缺上下文。

二、核心方案:两种父文档检索模式

父文档检索器针对不同文档长度,提供了两种解决方案,分别对应 “短文档” 和 “长文档” 场景,灵活适配不同需求。

方案 1:检索完整文档 —— 适合短文档(如单页 PDF)

如果原始文档本身不长(比如 1 页、500 字以内),没必要切得太碎,父文档检索器会:
逻辑:把原始文档切分成多个 “小书签”(子文档块),检索时先匹配子文档,找到对应的完整原始文档,再把完整文档给 LLM。
相当于 “用书签找书,找到后读整本书”。

具体步骤(以《客户经理考核办法》单页为例)
  1. 文档切割
    RecursiveCharacterTextSplitter把单页 PDF(约 500 字)切成 2 个 “子文档块”(每个 256 字符):

    • 子块 1:“客户经理考核标准:每投诉一次扣 2 分,年度累计投诉超 5 次取消评优资格……”
    • 子块 2:“客户经理等级分为助理、普通、高级、资深四级,考核分低于 80 分不得晋升……”
      同时保留 “完整原始文档”(父文档):包含两个子块的全部内容 + 上下文衔接(如 “投诉扣分与评优挂钩,具体规则如下……”)。
  2. 建立关联
    把 “子文档块” 向量化存入向量库(如 FAISS),“完整父文档” 存入文档存储(如 InMemoryStore),并记录 “子块→父文档” 的映射关系(比如子块 1 对应父文档 ID=1,子块 2 也对应父文档 ID=1)。

  3. 检索流程
    用户问 “客户经理投诉超 5 次会怎样?”:

    • 第一步:用问题向量匹配向量库中的子文档块,命中 “子块 1”(含 “累计投诉超 5 次取消评优”);
    • 第二步:通过 “子块 1→父文档 ID=1” 的映射,从文档存储中取出完整父文档;
    • 第三步:把完整父文档(含 “扣 2 分”+“取消评优”)喂给 LLM,LLM 生成完整答案:“客户经理每投诉一次扣 2 分,年度累计超 5 次将取消评优资格”。
适用场景
  • 原始文档短(如单页 PDF、短篇报告,500 字以内);
  • 需保留文档内上下文衔接(如 “投诉扣分” 和 “评优” 的关联关系)。

方案 2:检索较大的文档块 —— 适合长文档(如 100 页 PDF)

如果原始文档很长(比如 100 页的《考核办法》),完整文档超 LLM 上下文限制,父文档检索器会采用 “主文档块 + 子文档块” 双层切割,既保留局部完整信息,又不超上下文。
逻辑:先把长文档切成 “主文档块”(如 512 字符,信息较全),再把每个主文档块切成 “子文档块”(如 128 字符,用于匹配);检索时先匹配子块,找到对应的主块,把主块给 LLM。
相当于 “用小书签找章节,找到后读整章内容”。

具体步骤(以 100 页《考核办法》为例)
  1. 双层切割

    • 第一层(主文档块):用RecursiveCharacterTextSplitter把 100 页 PDF 切成多个 512 字符的 “主块”,比如 “第 3 章 投诉处理” 切成主块 1(“投诉一次扣 2 分,超 5 次取消评优”)、主块 2(“投诉处理流程:24 小时内响应,3 天内结案”);
    • 第二层(子文档块):把每个主块再切成 128 字符的 “子块”,比如主块 1 切成子块 1-1(“投诉一次扣 2 分”)、子块 1-2(“超 5 次取消评优”);
    • 建立关联:每个子块对应唯一主块(如子块 1-1→主块 1,子块 1-2→主块 1)。
  2. 检索流程
    用户问 “客户经理投诉后多久要响应?”:

    • 第一步:问题向量匹配子文档块,命中 “子块 2-1”(“投诉处理流程:24 小时内响应”);
    • 第二步:通过子块 2-1 找到对应的 “主块 2”(含 “24 小时响应 + 3 天结案”);
    • 第三步:把主块 2 喂给 LLM,LLM 生成答案:“客户经理收到投诉后需在 24 小时内响应,3 天内完成结案”。
关键优势
  • 主块大小可控(如 512/1024 字符),不会超 LLM 上下文(比如 Qwen-max 支持 8k 上下文,主块 512 字符完全够用);
  • 子块匹配精准,避免主块太大导致的 “匹配偏差”;
  • 主块包含足够上下文,LLM 不用拼接多个小块,答案更连贯。

三、落地实现:父文档检索器的核心组件与代码示例

要在 LangChain 中实现父文档检索器,核心是 “向量库(存子块)+ 文档存储(存父块)+ 切割器(切双层块)” 的配合,下面结合你熟悉的《客户经理考核办法》PDF,给出实操代码和解释。

1. 核心组件说明

组件 作用 选型(LangChain)
文本切割器 切主文档块和子文档块 RecursiveCharacterTextSplitter
向量库(VectorStore) 存储子文档块的向量,用于匹配用户问题 FAISS/Chroma
文档存储(DocStore) 存储父文档块(或完整文档),关联子块 InMemoryStore(内存,适合测试)/Redis(生产)
父文档检索器 协调 “子块匹配→父块获取” 流程 ParentDocumentRetriever

2. 代码示例(基于《客户经理考核办法》)

import os
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.llms import Tongyi

# 1. 加载PDF文档(示例:浦发银行客户经理考核办法)
loader = PyPDFLoader("./金客户经理考核办法.pdf")
docs = loader.load()  # 加载所有页面

# 2. 初始化切割器:分别定义“子文档切割器”和“父文档切割器”
# 子文档切割器:切小一点,用于精准匹配(128字符,重叠32字符)
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=128,
    chunk_overlap=32,
    separators=["\n\n", "\n", ".", " "]
)
# 父文档切割器:切大一点,用于提供完整上下文(512字符,重叠64字符)
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", ".", " "]
)

# 3. 初始化嵌入模型和向量库(存子文档块向量)
embeddings = DashScopeEmbeddings(
    model="text-embedding-v4",
    dashscope_api_key=os.getenv("DASHSCOPE_API_KEY")
)
vectorstore = FAISS.from_texts([""], embeddings)  # 先创建空向量库,后续由父文档检索器填充

# 4. 初始化文档存储(存父文档块)
docstore = InMemoryStore()  # 测试用内存存储,生产可用Redis

# 5. 创建父文档检索器
parent_retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,  # 子文档向量库
    docstore=docstore,        # 父文档存储
    child_splitter=child_splitter,  # 子文档切割器
    parent_splitter=parent_splitter,  # 父文档切割器
    search_kwargs={"k": 2}    # 匹配Top-2个子文档,避免漏检
)

# 6. 向检索器添加文档(自动切割主-子块,建立关联)
parent_retriever.add_documents(docs)
print(f"子文档块数量:{vectorstore.index.ntotal}")  # 查看子块数量
print(f"父文档块数量:{len(list(docstore.yield_keys()))}")  # 查看父块数量

# 7. 检索测试:用户问“客户经理被投诉一次扣多少分?”
query = "客户经理被投诉一次扣多少分?"
retrieved_docs = parent_retriever.get_relevant_documents(query)

# 8. 用LLM生成答案(基于检索到的父文档块)
llm = Tongyi(model_name="qwen-max", dashscope_api_key=os.getenv("DASHSCOPE_API_KEY"))
prompt = f"基于以下文档回答问题:{retrieved_docs[0].page_content}\n问题:{query}"
response = llm.invoke(prompt)
print("LLM答案:", response)

3. 代码关键流程解释

  1. 文档切割:调用add_documents(docs)时,父文档检索器会先把原始 PDF 切成 “父文档块”(512 字符),再把每个父块切成 “子文档块”(128 字符);
  2. 关联存储:子块向量化后存入 FAISS,父块存入 InMemoryStore,同时记录 “子块 ID→父块 ID” 的映射;
  3. 检索匹配:用户查询时,先在 FAISS 中匹配 Top-2 子块,再通过映射找到对应的父块;
  4. 生成答案:把父块内容喂给 Qwen-max,LLM 基于完整的父块信息(如 “投诉一次扣 2 分,超 5 次取消评优”)生成答案,避免信息碎片化。

四、父文档检索器 vs 传统检索:优势对比

维度 传统单一层级检索 父文档检索器
匹配精度 小块高,大块低 子块匹配高,兼顾父块信息
信息完整性 小块差(缺上下文),大块全 父块信息全,不缺关键背景
LLM 答案质量 小块易漏信息,大块易混淆重点 答案连贯、完整,少遗漏
上下文控制 难平衡(要么超限制,要么太碎) 父块大小可控,适配 LLM 上下文限制


五、总结

父文档检索器的核心价值,是用 “双层文档块” 设计破解了 RAG 中的 “匹配准” 与 “信息全” 的矛盾 —— 子文档块像 “精准书签”,帮我们快速找到相关位置;父文档块像 “完整书页”,给 LLM 提供足够的上下文。无论是短文档还是长文档,都能通过灵活的切割策略,让 RAG 的检索既准又全,最终生成更优质的答案。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐