LangChain 父文档检索器:解决 “文档块匹配准” 与 “信息全” 的矛盾
本文介绍了RAG(检索增强生成)中文档切分的核心矛盾及解决方案。传统方法面临两难:小文档块匹配精准但信息不全,大文档块信息完整但匹配效率低。LangChain的父文档检索器通过"双层文档块"设计解决了这一矛盾:子文档块(小)用于精准匹配,父文档块(大)提供完整上下文。文章详细解析了两种实现方案(短文档/长文档处理),并提供了代码示例,展示了如何通过协调向量库、文档存储和切割器实
目录
方案 2:检索较大的文档块 —— 适合长文档(如 100 页 PDF)
在 RAG(检索增强生成)中,“文档怎么切” 是个让人头疼的问题:切太小,虽然和用户问题的匹配度高(比如 “客户经理投诉扣分” 能精准命中 “扣 2 分” 的小块),但信息不全,LLM 可能漏关键背景;切太大,信息是全了,可向量匹配时容易 “抓不住重点”(比如 1000 字块里混了 “投诉”“入职”“评聘”,用户问 “投诉” 却匹配到整个块,效率低)。
LangChain 的父文档检索器(Parent Document Retriever) 就是为解决这个 “矛盾需求” 而生 —— 它通过 “双层文档块” 设计,让 “匹配准” 和 “信息全” 两者兼得,就像给文档加了 “精准书签”,既靠书签快速定位,又能看到完整的书页内容。
一、先搞懂:父文档检索器解决的核心痛点
传统文档检索的 “两难” 问题,是父文档检索器的出发点,先看清楚问题才能理解解决方案:
文档块大小 | 优势 | 劣势 | 典型场景问题 |
---|---|---|---|
小(如 256 字符) | 向量化后匹配准(语义聚焦) | 信息碎片化,LLM 缺上下文,答案不完整 | 用户问 “投诉扣分影响评优吗”,小块只提 “扣 2 分”,没提 “超 5 次取消评优”,LLM 答不全 |
大(如 2048 字符) | 信息全,LLM 能生成完整答案 | 向量化后匹配差(语义分散) | 用户问 “投诉扣分”,大块里混了 “入职要求”,匹配时可能优先命中无关内容 |
父文档检索器的核心思路:用 “小块做匹配,用大块给信息” —— 先通过小文档块(子文档)精准找到相关位置,再关联到对应的大文档块(父文档),把大文档块喂给 LLM,既保证匹配精度,又不缺上下文。
二、核心方案:两种父文档检索模式
父文档检索器针对不同文档长度,提供了两种解决方案,分别对应 “短文档” 和 “长文档” 场景,灵活适配不同需求。
方案 1:检索完整文档 —— 适合短文档(如单页 PDF)
如果原始文档本身不长(比如 1 页、500 字以内),没必要切得太碎,父文档检索器会:
逻辑:把原始文档切分成多个 “小书签”(子文档块),检索时先匹配子文档,找到对应的完整原始文档,再把完整文档给 LLM。
相当于 “用书签找书,找到后读整本书”。
具体步骤(以《客户经理考核办法》单页为例)
-
文档切割:
用RecursiveCharacterTextSplitter
把单页 PDF(约 500 字)切成 2 个 “子文档块”(每个 256 字符):- 子块 1:“客户经理考核标准:每投诉一次扣 2 分,年度累计投诉超 5 次取消评优资格……”
- 子块 2:“客户经理等级分为助理、普通、高级、资深四级,考核分低于 80 分不得晋升……”
同时保留 “完整原始文档”(父文档):包含两个子块的全部内容 + 上下文衔接(如 “投诉扣分与评优挂钩,具体规则如下……”)。
-
建立关联:
把 “子文档块” 向量化存入向量库(如 FAISS),“完整父文档” 存入文档存储(如 InMemoryStore),并记录 “子块→父文档” 的映射关系(比如子块 1 对应父文档 ID=1,子块 2 也对应父文档 ID=1)。 -
检索流程:
用户问 “客户经理投诉超 5 次会怎样?”:- 第一步:用问题向量匹配向量库中的子文档块,命中 “子块 1”(含 “累计投诉超 5 次取消评优”);
- 第二步:通过 “子块 1→父文档 ID=1” 的映射,从文档存储中取出完整父文档;
- 第三步:把完整父文档(含 “扣 2 分”+“取消评优”)喂给 LLM,LLM 生成完整答案:“客户经理每投诉一次扣 2 分,年度累计超 5 次将取消评优资格”。
适用场景
- 原始文档短(如单页 PDF、短篇报告,500 字以内);
- 需保留文档内上下文衔接(如 “投诉扣分” 和 “评优” 的关联关系)。
方案 2:检索较大的文档块 —— 适合长文档(如 100 页 PDF)
如果原始文档很长(比如 100 页的《考核办法》),完整文档超 LLM 上下文限制,父文档检索器会采用 “主文档块 + 子文档块” 双层切割,既保留局部完整信息,又不超上下文。
逻辑:先把长文档切成 “主文档块”(如 512 字符,信息较全),再把每个主文档块切成 “子文档块”(如 128 字符,用于匹配);检索时先匹配子块,找到对应的主块,把主块给 LLM。
相当于 “用小书签找章节,找到后读整章内容”。
具体步骤(以 100 页《考核办法》为例)
-
双层切割:
- 第一层(主文档块):用
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-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. 代码关键流程解释
- 文档切割:调用
add_documents(docs)
时,父文档检索器会先把原始 PDF 切成 “父文档块”(512 字符),再把每个父块切成 “子文档块”(128 字符); - 关联存储:子块向量化后存入 FAISS,父块存入 InMemoryStore,同时记录 “子块 ID→父块 ID” 的映射;
- 检索匹配:用户查询时,先在 FAISS 中匹配 Top-2 子块,再通过映射找到对应的父块;
- 生成答案:把父块内容喂给 Qwen-max,LLM 基于完整的父块信息(如 “投诉一次扣 2 分,超 5 次取消评优”)生成答案,避免信息碎片化。
四、父文档检索器 vs 传统检索:优势对比
维度 | 传统单一层级检索 | 父文档检索器 |
---|---|---|
匹配精度 | 小块高,大块低 | 子块匹配高,兼顾父块信息 |
信息完整性 | 小块差(缺上下文),大块全 | 父块信息全,不缺关键背景 |
LLM 答案质量 | 小块易漏信息,大块易混淆重点 | 答案连贯、完整,少遗漏 |
上下文控制 | 难平衡(要么超限制,要么太碎) | 父块大小可控,适配 LLM 上下文限制 |
五、总结
父文档检索器的核心价值,是用 “双层文档块” 设计破解了 RAG 中的 “匹配准” 与 “信息全” 的矛盾 —— 子文档块像 “精准书签”,帮我们快速找到相关位置;父文档块像 “完整书页”,给 LLM 提供足够的上下文。无论是短文档还是长文档,都能通过灵活的切割策略,让 RAG 的检索既准又全,最终生成更优质的答案。
更多推荐
所有评论(0)