
做知识库检索的时候,很多人最先遇到的坑就是切分文档。
你拿一份几十页的技术规范、法律合同或者财务报表,直接用 `RecursiveCharacterTextSplitter` 顺着切成 500 字符的块。结果上线测试发现,检索经常牛头不对马嘴。
用户问:“某某系统的故障切换时间是多久?”
向量数据库确实召回了一个片段,上面写着:“在该情况下,切换时间不得超过 30 秒。”
但是整个片段里根本没有提到“该情况”到底指哪种故障场景。大模型拿到这段上下文,只能两手一摊告诉你不知道,或者直接开始编造。
这就是长文本检索最常见的切分错乱。根源在于一个根本矛盾:用于向量检索的最佳文本长度,和用于大模型阅读理解的最佳文本长度,根本不是一回事。
如果你把文本块切得太细(比如 100 到 200 个字符),语义很聚焦,向量匹配度高,但是上下文全丢了。代词指代不清,表格被切得稀碎,条件判断的前半句在上一块,后半句在下一块。
如果你把文本块切得很大(比如 1500 到 2000 个字符),上下文倒是完整了,但是单个向量里混杂了太多主题。语义被稀释之后,余弦相似度算出来普遍偏低,导致真正的答案根本排不进前 3 名,直接造成召回遗漏。
解决这个问题的直接思路就是:把“检索载体”和“生成载体”彻底拆开。
检索时,用信息密度高、粒度小的向量去匹配问题;匹配成功后,不要把这个小碎片直接送给大模型,而是顺着它绑定的 ID,把包含完整上下文的大文本块取出来喂给大模型。
在 LangChain 里面,这套逻辑主要通过两个组件落地:`MultiVectorRetriever` 以及在它之上封装的 `ParentDocumentRetriever`。
为了搞清楚不同切分和召回方案的差异,我整理了下面这张对比表格:
| 方案选型 | 切分粒度 | 召回准确率 | 上下文完整度 | 存储与计算成本 | 适合场景 |
|---|---|---|---|---|---|
| 基础单向量检索 (`RecursiveSplitter`) | 固定单一分块 (如 500 字符) | 中等(容易受语义稀释影响) | 较差(边界信息经常丢失) | 最低(单套向量库) | 简单短文、结构松散的新闻资讯 |
| 父子文档检索 (`ParentDocumentRetriever`) | 父块 (1000-2000) + 子块 (200-400) | 高(小块命中率高) | 高(直接返回完整父块) | 中等(需额外 KV 存储) | 技术手册、长篇合同、业务流程规范 |
| 摘要多向量检索 (`MultiVectorRetriever`) | 文本块 + LLM 提取的摘要/问答对 | 很高(问答对与用户提问语义极近) | 很高(直接返回原始段落或整页) | 较高(入库阶段需额外调用 LLM) | 复杂多表报告、多模态图表排版文档 |
如果你想在现有的知识库里打通这套多向量召回管道,按照以下步骤操作:
第一步:准备存储底座
你需要准备两个存储组件:一个向量数据库(比如 Chroma、Milvus、Qdrant),用来存小块的嵌入向量;一个键值文档存储库(Docstore),用来存完整的父文档。
避坑提示:在生产环境中,千万不要用默认的 `InMemoryStore`。 内存存储只要服务一重启,所有的父文档就全丢了,剩下的向量数据库就成了无头幽灵。建议本地测试用 `LocalFileStore`,生产环境直接接 `RedisStore`。
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.storage import LocalFileStore, create_kv_docstore
from langchain.retrievers import ParentDocumentRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 1. 向量数据库存储子文档向量
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
collection_name="parent_child_docs",
embedding_function=embeddings,
persist_directory="./chroma_db"
)
# 2. 持久化存储父文档内容
fs = LocalFileStore("./docstore_cache")
docstore = create_kv_docstore(fs)
第二步:定义双层切分器
先做父块切分,再做子块切分。这样能帮你确保子块完全落在父块的边界内。
# 父切分器:保留完整的段落逻辑,供 LLM 阅读
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=1500,
chunk_overlap=150
)
# 子切分器:保留精准的语义片段,供向量检索
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=50
)
这里的核心参数比例建议控制在 1:4 到 1:6 之间。如果父块 1500,子块切 250 到 300 比较合适。子块太小会切碎词组,子块太大就失去了小颗粒度检索的意义。
第三步:组装检索器并入库
通过 `ParentDocumentRetriever` 把这几个部分串联起来。调用 `add_documents` 时,它在底层会自动做三件事:
1.用 `parent_splitter` 把原始长文本切成大块,生成唯一的 `doc_id`,写入 `docstore`。
2.用 `child_splitter` 把每个大块进一步切碎成小块,在元数据中加上对应的 `doc_id`。
3.把小块向量化并写入 `vectorstore`。
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 直接添加文档对象
retriever.add_documents(docs)
用户发起查询时,执行 `retriever.invoke(“查询内容”)`,系统在向量库命中子块后,会自动提取其元数据里的父块 ID,返回完整的父块。大模型拿到的输入就是完整连贯的段落,彻底解决了代词指代不明和信息被切断的问题。
进阶打法:基于摘要与假想问题的多向量映射
父子分块虽然解决了文本边界截断问题,但遇到大篇幅表格或者复杂配置项时依然不够用。因为原始表格的文本结构很难直接被向量模型捕捉。
这时需要使用原生的 `MultiVectorRetriever`。我们不对原始长文本切小块,而是让大模型为每个章节或表格生成一句话摘要,或者生成 3 到 5 个用户可能会问的“假想问题”(Hypothetical Questions)。
操作逻辑如下:
1.原始长文本或整个表格存入 `docstore`,分配一个 ID。
2.调用大模型,为该内容生成一段浓缩摘要和几个候选问题。
3.把生成的摘要和问题打上相同的 ID,存入 `vectorstore`。
因为用户的提问往往简短,与大模型生成的假想问题在语义空间里的距离最近,这样可以大幅增加初筛的召回概率。检索器一旦通过假想问题在向量库排查到对应项,就能拉出整份详细的数据表格送进提示词。
生产环境中的避坑重点
在真正跑批处理时,还有两个核心参数和逻辑需要注意:
第一,父文档去重机制。
搜索的时候设置召回 `k=4`,向量数据库可能会返回同一父块下的两个不同子块。LangChain 内部会基于 ID 去重,但如果你自己手写逻辑,切记检查去重代码,否则同一段几千字的父文档被重复塞进上下文窗口两次,既浪费 Token 费用,又容易让大模型产生幻觉。
第二,元数据继承问题。
子文档切分时,默认会自动复制父文档的 `metadata`。如果你在做多租户系统或者权限隔离,必须确保子文档保留了组织机构 ID、文件版本等过滤字段。使用 `child_splitter` 切块后,务必检查向量库里的元数据,确认过滤标签完好无损,否则混合检索时过滤条件会直接失效。
做好这几项处理,不用换向量模型,也不用调大模型的上下文窗口,长文本检索错乱的现象就能得到实质解决。
原创文章,作者:七点互动,如若转载,请注明出处:https://ai.qidianhudong.com/aikonw/4332.html