1. 为什么"上传 PDF 聊天"远远不够
1.1 从玩具到工具的分水岭
我最早接触 RAG 是在一个内部文档问答的场景里。当时团队里流行一句话:"把 PDF 丢进去,接个大模型,就能聊天了。" 我照着做了,第一天效果惊艳,第二天开始翻车。翻车的点很集中:同一份制度文档有三个版本,模型把已经废止的旧条款当成现行规定回答;一份两百页的技术手册被硬切成固定长度的小块,检索出来的片段前后不搭,答案断章取义;用户问"这个结论出自哪一页",我答不上来,因为整条链路里根本没有可追溯的引用信息。
这就是"上传 PDF 聊天"和"个人 RAG 知识库"之间的分水岭。前者是一个演示,后者是一套需要长期维护的系统。演示只需要跑通一次,系统要面对的是文档持续更新、版本不断迭代、检索质量必须稳定、答案必须可验证这些真实需求。我后来把整套东西重做了一遍,核心就围绕四件事:版本治理、父子分块、混合检索、可引用回答。这四个词听起来像术语堆砌,但每一个都对应着一类具体的翻车现场。
先说清楚这套东西适合谁。如果你只是想让模型读一篇论文然后聊两句,那真没必要搞这么复杂,直接长上下文塞进去就行。但如果你手里有一批会持续更新的资料——比如自己的技术笔记、团队规范、产品文档、法律合同、医学指南——并且你希望半年后它还能准确回答,那这套治理思路就是绕不开的。我下面讲的每一步,都是被真实问题逼出来的,不是从论文里抄的。
1.2 四个核心问题分别解决什么
我把这四个机制和它们对应的问题列成一张表,方便你对照自己的场景判断要不要上。
| 机制 | 解决的问题 | 不上会怎样 |
|---|---|---|
| 版本治理 | 文档更新后旧内容仍在被检索 | 模型引用废止条款,答案过期 |
| 父子分块 | 小块检索语义准但上下文缺失 | 答案断章取义,缺前因后果 |
| 混合检索 | 纯向量检索对专有名词、编号不敏感 | 搜"GB/T 1234"搜不到,搜人名搜不准 |
| 可引用回答 | 答案无法溯源,用户不敢信 | 无法验证,出错时找不到原因 |
这张表是我踩坑之后总结的。最开始我只做了向量检索加固定分块,结果就是上面说的三种翻车同时发生。后来一个一个补,补到最后发现这四件事其实是互相咬合的:版本治理决定了分块要带元数据,父子分块决定了检索要能按父块聚合,混合检索决定了引用要能定位到具体块,可引用回答又反过来要求版本和分块信息必须完整。所以它们不是四个独立功能,而是一套设计。
2. 版本治理:让知识库知道自己"过期了"
2.1 为什么版本治理是 RAG 最容易被忽略的一环
大部分人搭 RAG 的流程是这样的:读文档、切块、embedding、存向量库、检索、生成。整个流程里没有任何一个环节关心"这份文档是不是最新的"。问题在于,真实世界的文档是会变的。一份产品需求文档可能一周改三次,一份政策文件可能一年更新一版,一份技术规范可能有多个并行版本。如果你只是每次把新文档追加进去,向量库里就会同时存在新旧内容,而向量检索是按语义相似度排序的,它根本不知道哪个是旧的。
我遇到过一个特别典型的案例。团队有一份接口文档,v1 和 v2 的差异只在几个字段上。用户问"这个接口的鉴权方式是什么",检索同时命中了 v1 和 v2 的片段,因为两段文字语义高度相似。模型把两段都读了,然后给出了一个混合了新旧规则的答案,看起来头头是道,实际上完全不能用。这种错误比"答不出来"更危险,因为它伪装成了正确答案。
版本治理要解决的就是这个问题:让知识库在检索阶段就能区分内容的时效性,并且默认只返回当前有效版本。
2.2 版本治理的三种落地粒度
版本治理不是简单地给文档加个"版本号"字段就完事,粒度选错了照样出问题。我实践下来有三种粒度,各有适用场景。
第一种是文档级版本。每份文档有一个版本标识,新版本上传时把旧版本标记为失效。这种方式最简单,适合整份文档整体替换的场景,比如年度报告、政策文件。缺点是如果只是文档里改了一小段,整份重新 embedding 成本高,而且旧版本里没改的部分其实还有效,全丢掉有点浪费。
第二种是块级版本。每个切块带一个版本标识和生效时间,检索时按时间过滤。这种方式精细,适合文档局部更新的场景。但实现复杂度高,因为你要能识别出"哪些块变了",这需要做块级别的 diff。
第三种是逻辑文档加物理版本。这是我最终采用的方案。把"一份逻辑文档"和"它的多个物理版本"分开:逻辑文档是一个稳定的 ID,物理版本是实际的内容快照。检索时先按逻辑文档聚合,再在每个逻辑文档内部只取最新有效版本。这样既保留了历史,又保证了默认检索的是最新内容。
我用一个简单的数据结构来说明:
# 逻辑文档:稳定标识,代表"这份文档"这个概念 logical_doc = { "doc_id": "api-spec-auth", "title": "鉴权接口规范", "current_version": "v2.3", } # 物理版本:实际内容快照 physical_version = { "doc_id": "api-spec-auth", "version": "v2.3", "effective_from": "2024-03-01", "effective_to": None, # None 表示当前有效 "content_hash": "a1b2c3...", "chunks": [...], }检索的时候,过滤条件就是effective_to is None,也就是只取当前有效的版本。如果用户明确要查历史,再放开这个条件。这个设计的好处是,历史版本一直在库里,随时可以回溯,但默认不会污染检索结果。
2.3 版本切换时的实操细节
版本治理真正麻烦的地方不在数据结构,而在切换流程。我踩过的坑主要集中在三个点。
第一个坑是切换时机。新版本上传后,什么时候让旧版本失效?如果上传即失效,万一新版本有问题,你就没有回退余地了。我的做法是引入一个"待生效"状态:新版本上传后先标记为 pending,人工确认或者自动校验通过后再切换。校验可以很简单,比如检查新版本的块数量是否合理、关键字段是否齐全。
第二个坑是引用一致性。如果用户之前基于 v2.2 问过一个问题,答案里引用了 v2.2 的某个块,现在版本切到 v2.3 了,那条历史引用还能不能打开?我的处理是引用里同时存 doc_id、version 和 chunk_id,这样即使版本切换,历史引用依然能定位到当时的原文。这一点在做可引用回答时特别重要,后面还会展开。
第三个坑是embedding 复用。如果一份文档只改了一小段,没必要整份重新 embedding。我的做法是对每个块算内容哈希,哈希没变的块直接复用旧的向量。实测下来,一份改动 5% 的文档,重新 embedding 的成本能降到原来的十分之一左右。这个优化在文档量大之后非常关键,因为 embedding 调用是要花钱和花时间的。
提示:版本治理最容易犯的错是"只加字段不做过滤"。字段加了但检索时不带过滤条件,等于没做。一定要在检索入口处强制加上时效过滤,而不是指望模型自己判断。
3. 父子分块:解决"检索准"和"上下文全"的矛盾
3.1 固定分块为什么必然翻车
先说清楚固定分块的问题。最常见的做法是按字符数切,比如每 500 字一块,块之间留 50 字重叠。这个做法简单,但有两个致命问题。
第一个问题是语义截断。500 字这个数字是拍脑袋定的,它和文档的实际语义边界没有任何关系。一个完整的论证可能被切成两半,前半块在讲原因,后半块在讲结论,检索时只命中前半块,模型看到的就是一个没有结论的片段。我见过最离谱的一次,一份合同的责任条款被从中间切开,模型只读到"甲方应承担以下责任"然后就没了,直接导致回答残缺。
第二个问题是检索粒度和上下文粒度的矛盾。块切得小,向量表达更聚焦,检索更准;但块太小,上下文就不完整。块切得大,上下文完整,但向量被稀释,检索变模糊。这是一个死结,固定分块无论怎么调参数都解不开。
我试过很多参数组合,500、800、1000、1500 字,重叠 10%、20%,结论是:没有一个固定值能同时满足检索精度和上下文完整性。这不是调参能解决的问题,是分块策略本身的问题。
3.2 父子分块的核心思路
父子分块就是来解这个死结的。思路很直接:用小块做检索,用大块做上下文。具体来说,把文档切成两层:子块(child chunk)比较小,比如 200 到 300 字,专门用来做向量检索,保证检索精度;父块(parent chunk)比较大,比如 1500 到 2000 字,包含若干个子块,专门用来喂给模型,保证上下文完整。
检索的时候,先用子块去匹配,命中之后不直接把子块给模型,而是找到它所属的父块,把父块整体给模型。这样检索用的是小块的精度,生成用的是大块的完整性,两个需求同时满足。
我用一个具体例子说明。假设有一段技术文档:
父块(1500字):完整的"鉴权机制"章节 ├── 子块1(250字):鉴权方式概述 ├── 子块2(250字):Token 生成流程 ├── 子块3(250字):Token 校验规则 ├── 子块4(250字):过期与刷新策略 └── 子块5(250字):常见错误码用户问"Token 过期了怎么办",检索命中子块4。如果只有固定分块,模型可能只看到子块4那 250 字,缺少前面的背景。有了父子分块,模型拿到的是整个父块,包含鉴权方式的完整背景,回答自然更准确。
3.3 父子分块的实现要点
实现父子分块,关键在三个地方:切分逻辑、存储结构、检索聚合。
切分逻辑上,我的做法是先按文档的自然结构切父块,比如按章节、按标题层级。如果文档没有明显结构,就按较大的固定长度切父块,比如 1500 字。然后在父块内部再切子块,子块尽量按句子边界切,避免把一句话切断。这里有个细节:子块之间要留一点重叠,但重叠不要太多,否则检索时会命中多个相似子块,浪费上下文窗口。
存储结构上,子块和父块要分开存。子块存向量,用于检索;父块存原文,用于生成。两者通过 parent_id 关联。我用的结构大概是这样:
child_chunk = { "chunk_id": "c-001", "parent_id": "p-001", "doc_id": "api-spec-auth", "version": "v2.3", "text": "Token 过期后需要...", "embedding": [...], } parent_chunk = { "parent_id": "p-001", "doc_id": "api-spec-auth", "version": "v2.3", "text": "完整的鉴权机制章节...", "child_ids": ["c-001", "c-002", "c-003"], }检索聚合上,流程是:子块检索 → 拿到命中的子块列表 → 按 parent_id 去重 → 取出对应的父块 → 按相关性排序 → 取 top-k 父块喂给模型。这里有个优化点:如果多个子块命中同一个父块,说明这个父块整体相关度高,应该优先返回。我一般会给父块算一个聚合分数,比如命中的子块数量乘以平均相似度,用这个分数排序。
注意:父子分块会增加存储和检索的复杂度,如果你的文档量很小(比如就几篇),收益不明显。文档量上百篇之后,这个机制的性价比才真正体现出来。
3.4 分块参数的实测经验
参数没有标准答案,但我可以分享一组实测下来比较稳的配置,供你起步。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 父块大小 | 1200-1800 字 | 太小上下文不够,太大稀释相关性 |
| 子块大小 | 200-350 字 | 太小语义不完整,太大检索变模糊 |
| 子块重叠 | 30-60 字 | 保证句子边界完整即可,不宜过多 |
| 每父块子块数 | 4-8 个 | 太多说明父块过大,考虑再拆 |
| 检索 top-k 子块 | 10-20 | 宁多勿少,后面靠父块聚合去重 |
| 最终 top-k 父块 | 3-5 | 受模型上下文窗口限制 |
这组参数不是金科玉律,但作为起点能省你不少试错时间。我建议你先用这组跑通,然后根据实际检索效果微调。调的时候一次只动一个参数,否则你分不清是哪个参数起的作用。
4. 混合检索:向量不是万能的
4.1 纯向量检索的三个盲区
向量检索很强,但它有三个明显的盲区,我在实际使用中反复撞到。
第一个盲区是专有名词和编号。向量检索靠的是语义相似度,它对"GB/T 12345"这种编号、"SKU-8899"这种产品码、"张三"这种人名不敏感。你搜"GB/T 12345",它可能返回一堆讲标准规范的文档,但就是找不到那个具体编号。因为编号本身没有语义,embedding 表达不出来。
第二个盲区是精确匹配需求。有些查询就是要精确匹配,比如查一个错误码"ERR_4032",查一个函数名"initAuthClient"。向量检索会给你返回语义相近的内容,但你要的是那个精确的字符串。
第三个盲区是低频词。一个词如果在训练语料里出现得少,它的 embedding 质量就差,检索时容易被高频词淹没。技术文档里大量存在这种低频专业术语。
这三个盲区,靠调 embedding 模型是解决不了的,因为这是向量检索的机制决定的。解决办法就是引入关键词检索,做混合。
4.2 混合检索的融合策略
混合检索就是把向量检索和关键词检索(通常是 BM25)的结果融合起来。融合策略主要有两种:加权求和和倒数排名融合(RRF)。
加权求和是给两路结果各算一个分数,然后加权相加。问题是两路分数的量纲不一样,向量相似度是 0 到 1,BM25 分数可能是 0 到几十,直接加权需要归一化,而归一化又会引入新的偏差。
RRF 更省心,它不看具体分数,只看排名。公式是score = Σ 1/(k + rank),k 一般取 60。两路结果各自排名,然后按这个公式算融合分数。RRF 的好处是不用归一化,对分数尺度不敏感,实测下来比加权求和稳。
def rrf_fusion(vector_results, keyword_results, k=60): scores = {} for rank, item in enumerate(vector_results): scores[item.id] = scores.get(item.id, 0) + 1 / (k + rank + 1) for rank, item in enumerate(keyword_results): scores[item.id] = scores.get(item.id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)我一开始用加权求和,调权重调到怀疑人生,换成 RRF 之后基本不用调,效果还更稳定。所以如果你不想在融合策略上花太多时间,直接用 RRF。
4.3 关键词检索的落地选择
关键词检索这块,选择其实不多。最经典的是 BM25,成熟、稳定、无需训练。我用的是基于 BM25 的实现,配合中文分词。中文分词这块要注意,通用分词器对专业术语的切分经常不准,比如"鉴权令牌"可能被切成"鉴权"和"令牌",也可能被切成"鉴"和"权令牌"。我的做法是维护一个自定义词典,把领域内的专有名词加进去,分词准确率能提升不少。
另一个选择是用数据库自带的全文检索,比如很多向量数据库现在都支持全文索引。这样能省一套系统,但灵活性和可控性差一些。如果你的技术栈已经统一,用数据库自带的也行;如果追求效果,还是单独搭一套 BM25 更可控。
混合检索的完整流程是这样的:查询进来,同时走两路,向量路走向量库,关键词路走 BM25 索引,两路各取 top-N,然后用 RRF 融合,融合后取 top-k 进入父子分块的父块聚合环节。整个链路里,混合检索在前,父子聚合在后,顺序不能反。
提示:混合检索的收益在专有名词密集的场景下最明显。如果你的文档全是通用叙述,纯向量可能就够了。但只要文档里有编号、代码、人名、专业术语,混合检索几乎是必须的。
5. 可引用回答:让每个结论都能溯源
5.1 为什么引用不是"锦上添花"
很多人把引用当成一个体验优化,觉得有没有都行。我的看法完全相反:在知识库场景里,引用是刚需,不是优化。原因很简单,知识库回答的是事实性问题,用户需要判断这个答案可不可信。没有引用,用户只能选择全信或者全不信,而这两种选择都有风险。
我遇到过一个场景,用户问一个合规问题,模型给了一个答案,用户照着做了,结果发现模型引用的是已经废止的旧版本。如果当时答案里带了引用,用户点开一看是旧版本,立刻就能发现问题。没有引用,这个错误就悄无声息地传递下去了。
引用还有第二个作用:它是排查问题的入口。当答案不对时,你需要知道模型是基于哪些内容生成的。有了引用,你能快速定位是检索错了、分块错了、还是版本错了。没有引用,你只能盲猜。所以引用不只是给用户看的,也是给你自己调试用的。
5.2 引用信息的完整结构
一个可用的引用,至少要包含四个信息:文档标识、版本、块标识、原文位置。少任何一个,引用都会变得不可靠。
citation = { "doc_id": "api-spec-auth", "doc_title": "鉴权接口规范", "version": "v2.3", "chunk_id": "p-001", "position": "第 3 章 第 2 节", "text_snippet": "Token 过期后需要...", "source_url": "internal://docs/api-spec-auth#v2.3", }文档标识和版本保证你能定位到具体是哪份文档的哪个版本。块标识保证你能定位到具体段落。原文位置和片段让用户能快速核对。source_url 是可选的,如果你有内部文档系统,可以生成一个能直接跳转的链接。
这里有个细节:引用要指向父块还是子块?我的做法是引用指向父块,因为父块是实际喂给模型的内容,引用它才能准确反映模型看到了什么。但同时在引用里保留命中的子块信息,方便精确定位。这样既有整体上下文,又有精确位置。
5.3 让模型正确输出引用
引用信息有了,还要让模型在回答里正确使用。这里的关键是 prompt 设计。我的做法是在 prompt 里明确要求模型在每句话后面标注引用编号,并且只使用提供的引用,不允许编造。
一个简化的 prompt 结构是这样的:
你是一个知识库助手。请基于以下资料回答问题。 每一条结论后面必须标注来源编号,格式为 [1]、[2]。 如果资料中没有相关信息,直接说"资料中未找到",不要编造。 资料: [1] (文档:鉴权接口规范 v2.3,位置:第3章) Token 过期后需要... [2] (文档:鉴权接口规范 v2.3,位置:第4章) 刷新 Token 的接口是... 问题:Token 过期了怎么办?实测下来,明确要求"每条结论标注来源"比笼统说"请引用来源"效果好很多。另外要强调"不允许编造引用",否则模型偶尔会编一个不存在的编号。我还会在生成后做一次校验,检查回答里的引用编号是否都在提供的资料范围内,不在的就标记出来。
5.4 引用校验与兜底
生成之后不能直接返回,要做一次校验。校验分两步:第一步检查引用编号是否合法,第二步检查引用内容是否真的支持对应的结论。第二步比较难自动化,我的做法是用一个轻量模型做二次判断,或者干脆只做第一步,把第二步留给用户。
兜底策略也很重要。如果模型没有输出任何引用,或者引用全部非法,我的处理是降级返回:把检索到的原文片段直接展示给用户,让用户自己看。这比返回一个没有依据的答案要好。宁可少答,不可乱答,这是知识库场景的基本原则。
6. 把四个机制串成一条完整链路
6.1 完整流程拆解
前面四个机制是分开讲的,实际运行时它们是一条链路。我把完整流程拆成七步,每一步都对应前面讲的某个机制。
第一步,文档入库与版本判定。新文档进来,先算内容哈希,和已有版本比对,判断是新增、更新还是重复。更新的话,走版本切换流程,旧版本标记失效。
第二步,父子分块。按文档结构切父块,父块内切子块,子块算 embedding,父块存原文,两者通过 parent_id 关联。
第三步,双路索引。子块向量写入向量库,同时子块文本写入 BM25 索引。两路索引都要带上 doc_id、version、parent_id 这些元数据。
第四步,查询处理。用户查询进来,先做查询改写(可选),然后同时发起向量检索和关键词检索。
第五步,混合融合。两路结果用 RRF 融合,得到统一的子块排名。
第六步,父块聚合。按 parent_id 聚合子块,取出对应父块,按聚合分数排序,取 top-k。同时带上版本过滤,只取当前有效版本。
第七步,生成与引用。把父块和引用信息组装进 prompt,模型生成回答,回答里带引用编号,生成后做引用校验,通过则返回,不通过则降级。
这条链路里,版本过滤在第六步,父子聚合在第六步,混合检索在第五步,引用在第七步。顺序是有讲究的:先融合再聚合,因为聚合需要统一的排名;先聚合再生成,因为生成需要完整的上下文。
6.2 各环节的耗时与优化
整条链路的耗时主要在三块:embedding、检索、生成。我实测下来,embedding 是入库时的一次性成本,检索和生成是每次查询的在线成本。
检索这块,向量检索和 BM25 可以并行,总耗时取决于慢的那一路。父块聚合是内存操作,很快。生成是最慢的,取决于模型和上下文长度。优化的话,检索可以加缓存,相同查询直接返回缓存结果;生成可以控制 top-k 父块数量,别塞太多上下文。
入库这块,embedding 是瓶颈。我的优化是批量 embedding 加并发,同时用内容哈希跳过没变的块。文档量大之后,这两个优化能省很多时间。
6.3 一个容易忽略的细节:查询改写
查询改写不是必须的,但在多轮对话场景下很有用。用户第二句问"那它呢",如果不改写,检索根本不知道"它"指什么。我的做法是用一个轻量模型把当前查询和对话历史结合,改写成独立完整的查询,再去做检索。这个改动对多轮场景的检索准确率提升很明显。
不过查询改写也有风险,改写错了会把检索带偏。我的做法是改写后保留原查询,两路都检索,融合时一起算。这样即使改写有偏差,原查询还能兜底。
7. 常见问题与排查实录
7.1 检索不准的排查顺序
检索不准是最常见的问题,排查要有顺序,否则容易瞎调。我的排查顺序是:先看分块,再看检索,最后看融合。
先看分块:把命中的块原文打出来,看它是不是一个完整的语义单元。如果块本身是残缺的,那问题在分块,调检索没用。再看检索:如果块是完整的但没被命中,看是向量路没命中还是关键词路没命中。向量路没命中,可能是 embedding 模型不适合这个领域;关键词路没命中,可能是分词不对。最后看融合:如果两路都命中了但融合后排名靠后,那是融合策略的问题,调 RRF 的 k 值或者调整两路权重。
7.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 答案引用旧版本 | 版本过滤没生效 | 检查检索是否带 effective_to 过滤 |
| 答案断章取义 | 父块没生效或父块太小 | 检查是否返回父块,父块大小是否够 |
| 专有名词搜不到 | 缺关键词检索或分词不对 | 检查 BM25 索引和自定义词典 |
| 引用编号不存在 | 模型编造引用 | 加引用校验,非法引用降级 |
| 检索结果重复 | 子块重叠过多 | 减少子块重叠,父块聚合去重 |
| 回答太慢 | 上下文太长或 top-k 太大 | 减少 top-k 父块,控制上下文长度 |
这张表是我从实际排查记录里整理的,基本覆盖了八成以上的问题。遇到问题先对表,能省很多时间。
7.3 几个反直觉的经验
最后分享几个反直觉的经验,都是踩坑踩出来的。
第一个,分块不是越小越好。我一度以为子块越小检索越准,结果发现子块太小(比如 100 字以下)反而检索不准,因为语义信息不够。200 到 350 字是个比较稳的区间。
第二个,top-k 不是越大越好。检索返回太多父块,上下文被稀释,模型反而抓不住重点。3 到 5 个父块通常就够了,多了是负担。
第三个,引用不是越多越好。每句话都标引用,回答会变得很碎。我的做法是只在关键结论上标引用,背景性叙述不标。这个度需要根据场景调。
第四个,版本治理不是越细越好。块级版本听起来很美,但实现和维护成本很高。除非你的文档更新极其频繁且局部,否则文档级加逻辑文档的方案就够了。
这套东西我前后迭代了大半年,从最初的"上传 PDF 聊天"到现在能稳定支撑日常问答,最大的体会是:RAG 的难点从来不在模型,而在数据治理。模型是现成的,但版本、分块、检索、引用这些工程细节,才是决定这套系统能不能用的关键。你把这四件事做扎实了,哪怕用最普通的模型,效果也不会差;反过来,这四件事没做好,用再强的模型也是白搭。