
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字完整的鉴权机制章节 ├── 子块1250字鉴权方式概述 ├── 子块2250字Token 生成流程 ├── 子块3250字Token 校验规则 ├── 子块4250字过期与刷新策略 └── 子块5250字常见错误码用户问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 到 1BM25 分数可能是 0 到几十直接加权需要归一化而归一化又会引入新的偏差。RRF 更省心它不看具体分数只看排名。公式是score Σ 1/(k rank)k 一般取 60。两路结果各自排名然后按这个公式算融合分数。RRF 的好处是不用归一化对分数尺度不敏感实测下来比加权求和稳。def rrf_fusion(vector_results, keyword_results, k60): 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(), keylambda x: x[1], reverseTrue)我一开始用加权求和调权重调到怀疑人生换成 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 的难点从来不在模型而在数据治理。模型是现成的但版本、分块、检索、引用这些工程细节才是决定这套系统能不能用的关键。你把这四件事做扎实了哪怕用最普通的模型效果也不会差反过来这四件事没做好用再强的模型也是白搭。