RAG工程化实践:版本治理、父子分块、混合检索与可引用回答

发布时间:2026/10/7 6:23:21
RAG工程化实践:版本治理、父子分块、混合检索与可引用回答 很多人第一次接触 RAG都是从上传一份 PDF 然后开始提问开始的。我也一样最早搭个人知识库的时候觉得把文档丢进去、能问出点东西就已经很酷了。但真把知识库存到上百份文档、连续用几个月之后你会发现原来的方案根本撑不住文档更新了旧结论还在被引用一个段落设得不够整模型回答就前言不搭后语精确问一个型号、一个版本号向量检索反而给你一堆相关性很高但没有目标词的内容。这篇文章想聊的不是怎么把 PDF 喂给 AI 聊天而是怎么把 RAG 从能跑做到可用——具体说就是版本治理、父子分块、混合检索、可引用回答这四件事。适合那些已经跑通基本 RAG Demo、现在想把知识库真正用起来的人。1. 当上传 PDF 聊天失效时真正缺的是数据治理1.1 知识库不是文件堆而是有生命的数据集上传 PDF 聊天本质上是一个静态流程文档进向量库检索拼接给大模型完事。它把 RAG 简化成了以文档为中心的问答系统所以很多人在 Demo 阶段玩得很开心一旦进入真实使用就会撞墙。真实的知识库压根不是静态的。一份产品手册会改版一篇内部方案会废弃一条会议纪要会被另一条覆盖。你今天存进去的结论下周可能就过期了。如果你只做了上传 PDF 聊天问题就会变成删除旧文件向量库里旧文件的向量还在更新同名文档新旧内容混在一起改了一个章节其他章节引用的上下文还带着旧版本。最后回答出来的内容可能来自三个月前已经作废的版本。用户问一句你们现在支持哪种服务协议模型回答的却是旧版协议里已经删掉的条款——这种错误在 Demo 里不会暴露在线上会。所以我把知识库重新定义了一下它不是一个文件堆而是一组带版本、带来源、带生命周期的结构化数据。每一条可被检索的片段都应该能够回答三个问题它来自哪个文档它是哪个版本的它现在还有效吗想通了这一点RAG 的工程化才算真正开始。1.2 标准 RAG 流水线里四个环节各自要补什么一套个人知识库的完整流水线大致是解析 → 分块 → 向量化存储 → 检索生成。工程化之后每个环节都要额外承担一些 Demo 阶段不需要的任务。环节Demo 阶段的用法工程化之后需要补的东西解析直接把 PDF 文本抽出来保存来源路径、文档标题、修改时间、版本号、章节层级分块固定长度切块加 overlap建立父子块映射让检索粒度和上下文完整度解耦向量化存储文本 → embedding → 存向量库用文档 ID 管理向量支持按版本批量删除和重建检索生成top_k 召回拼 prompt混合召回 去重 排序融合生成时强制引用编号这四件事做下来你会发现系统工程里最耗时间的其实不是理解文档而是管理文档在数据库里的生命周期。2. 版本治理更新、回滚、过期向量库怎么跟着动2.1 一个反直觉的结论版本治理比检索更影响回答质量我一开始把大量时间花在选 embedding 模型、调 top_k 参数上结果发现真正让回答质量崩掉的不是检索排名而是旧数据还在库里。举个例子你的产品发版记录更新到了 2.5库里还有一份 2.4 的手册。用户问2.5 新增了什么向量召回同时带回了 2.4 和 2.5 的内容LLM 会把两者揉在一起回答因为 prompt 里根本没告诉模型这些片段来自不同版本。模型没有能力判断哪份文档更新它只知道这些片段都是用户给的参考资料。所以版本治理真正的目标不是备份历史而是保证参与检索的片段集合始终和当前知识库的有效状态一致。这句话听起来轻巧做起来需要三个能力识别文档版本、按版本失效旧向量、支持一键回滚。2.2 设计doc 表 chunk 表 版本元数据我用的是最朴素的 SQLite 加向量库的方案。先建两张核心表一存文档一存分块。CREATE TABLE docs ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, source_path TEXT NOT NULL, version TEXT NOT NULL, is_active INTEGER DEFAULT 1, content_hash TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chunks ( id INTEGER PRIMARY KEY, doc_id INTEGER NOT NULL, parent_chunk_id TEXT, chunk_text TEXT NOT NULL, chunk_hash TEXT NOT NULL, meta_json TEXT, FOREIGN KEY (doc_id) REFERENCES docs(id) );核心思路docs表里存版本信息和哈希chunks表只存分块内容。向量库里存向量时向量条目必须带doc_id和version这两个 metadata 字段。这样任何一次版本变更都只需要按 metadata 批量操作向量不需要重新遍历整个库。content_hash很关键。每次准备入库一份新文档时先算全文哈希。如果哈希和当前 active 文档一致直接跳过省去重复解析和向量化的开销。如果哈希不一致说明内容变了这时候才走更新流程。这是一个经常被忽略但非常省事的防重机制。2.3 更新与回滚软删除优先物理删除兜底更新一份文档时正确顺序不是删旧的插新的而是先把旧版本标记为失效再插入新版本最后把旧版本的向量从向量库删掉。顺序反了会导致瞬时窗口内新旧内容同时在库。def update_kb_document(doc_id: int, new_content: str, new_version: str): # 第一步计算新内容哈希判断是否真的变了 new_hash md5(new_content.encode(utf-8)).hexdigest() old_doc get_doc(doc_id) if old_doc[content_hash] new_hash: print(内容未变化跳过更新) return # 第二步软删除旧版本 set_doc_inactive(doc_id) # 第三步插入新版本文档拿到新 doc_id new_doc_id insert_doc( titleold_doc[title], source_pathold_doc[source_path], versionnew_version, content_hashnew_hash ) # 第四步重新切块 向量化全部挂到新 doc_id 下 chunks split_into_chunks(new_content) insert_chunks(new_doc_id, chunks) # 第五步物理删除向量库里所有旧版本的向量 vector_store.delete_by_metadata({doc_id: doc_id})这套流程里最容易踩的坑是第五步。很多人更新文档后只做了第三步忘了同步删掉旧向量结果就是文档已经换了检索结果还是老的。这也是我文章开头说的记忆回潮问题。回滚也简单把旧版本的is_active改回 1同时重新写入它的向量即可。前提是你当时保存了旧版本的原始文本——所以我强烈建议个人知识库如果文件体积可控把每个版本的原始文本留在本地磁盘别只留 embedding。embedding 是不可逆的丢了原始文本回滚就无从谈起。提示个人库最好做成软删除 定时物理清理。软删除让你随时能回滚物理清理是防止向量库越攒越脏。留两周的过渡期过期再彻底清掉。3. 父子分块把检索粒度和上下文完整度同时保住3.1 分块的两种失败太大检索不准太小上下文太碎分块是 RAG 里最常被低估的参数。块太大比如一整个章节 3000 字塞进一个向量检索时召回的是段落级的结果top_k 拿回来 5 个大块每块里大部分内容跟问题无关模型被噪声干扰。块太小比如按 200 字切检索倒是精准了但模型拿到的上下文太碎经常缺一段因果逻辑回答就会显得有根据但没头没尾。我早期试过把字库 300 字切块问答这个接口的返回值里 status 字段有哪些取值时向量召回的几个块分别讲的是字段类型异常处理调用示例每个都能沾上边但凑起来就是拼不成一个完整的答案。模型只能猜。而把块扩到 1500 字又会出现另一个极端明明问的是一个小点top_k 却返回了两三个大块成本翻倍答案反而松散。3.2 父子分块的核心思想检索时用细粒度生成时用粗粒度父子分块的思路很直接一个文本切两层。第一层是大块叫父块粒度接近小节或章节第二层是在每个父块内部再切细叫子块粒度接近段落。子块负责被向量检索召回父块负责在召回后提供给大模型作为上下文。为什么这样能成立因为向量检索本质上是一个模糊匹配查询语句和子块在语义上更接近所以用小粒度能提高召回精度。而大模型生成答案时需要的是完整上下文如果把召回的子块直接塞进 prompt信息是残缺的。所以检索命中一个子块之后立即把它对应的父块取出来拼进上下文。精度和上下文完整性同时拿到这是单层分块做不到的。这个方案在很多开源框架里都有对应实现比如 LangChain 的ParentDocumentRetriever、LlamaIndex 的RecursiveRetrieval。但我更建议自己在数据层把这个关系存出来因为后期调优时你能直接看到检索到哪个子块、父块是谁、来自哪个文档排错会直观很多。3.3 用代码实现父子块映射# 伪代码构建父子块落库时记好 parent_chunk_id def build_parent_child_chunks(markdown_text: str, child_size: int 300): parent_blocks split_by_heading(markdown_text) # 按标题拆成章节级父块 records [] for parent_id, parent_text in enumerate(parent_blocks): # 子块切分用滑窗保证相邻子块有 overlap 40 字左右 child_chunks sliding_window_split( parent_text, chunk_sizechild_size, overlap40, respect_sentence_boundaryTrue, ) for child in child_chunks: records.append({ parent_chunk_id: fpara_{parent_id}, parent_text: parent_text, child_text: child, }) return records切子块时我加了一个参数respect_sentence_boundaryTrue。意思是尽量在句号处切断而不是硬按字数切。硬切会导致一句话被腰斩向量语义被割裂。这个细节对中文尤其重要因为中文没有天然空格分词边界本来就靠算法硬切 300 字很可能把一句话劈成两半。父块怎么切优先按 Markdown 标题结构切利用##、###这些标题把文档切成有逻辑边界的章节。如果遇到没有标题结构的纯 PDF就用规则段落间距加字号变化。实在不行才退回固定字数切父块。3.4 实测参数子块 200~400 字父块 1500~3000 字下面这组数据来自我用 200 多份中文技术文档做的一个小规模对比测试场景是文档问答。数值不绝对但方向有参考价值。分块策略召回命中准确率回答完整度人工评分 1-5平均单次检索 token 消耗单层 300 字无父块78%3.12100单层 1500 字62%3.64800父子分块子块 300父块 200081%4.23200父子分块子块 500父块 250076%3.93600从结果看父子分块并不总是绝对高分——它只是让你在检索精度和上下文完整度之间不再必须做取舍。子块设 300 字左右是在语义完整和检索粒度之间的一个经验平衡点。子块太小比如 100 字检索召回率反而下降因为信息太少向量表达不出来子块太大比如 800 字又回到单层大块的老路上去了。注意父块也不是越大越好。父块 3000 字以上模型虽然能看到完整章节但 prompt 会被撑得很长回答容易抓不住重点。我通常把父块上限控制在 2000 字左右超过的部分再拆一个父块。4. 混合检索当向量忘记了精确词BM25 得顶上来4.1 一个差点让我放弃的故障向量检索对型号、代码变量名的召回很弱我知识库里存了很多设备手册里面有大量类似CH605-2 控制器Firmware v3.2.1这样的精确型号。有一次我搜CH605-2 的温度范围是多少向量检索返回的前几块全是讲设备温度保护逻辑工作环境温度的泛泛内容就是没有 CH605-2 的字面匹配。原因不复杂embedding 模型把CH605-2这个 token 编码成了一个查询向量这个向量在语义空间里更接近设备温度这些含义而不是字符串本身。向量检索擅长的是语义相近糟糕的是精确字面匹配——而知识库里偏偏充满了型号、编号、专有名词。4.2 BM25 为什么在知识库场景还是必需品BM25 是一种经典的稀疏检索算法本质上是 TF-IDF 的升级版。它统计查询词在文档片段里的出现频率再结合文档长度做归一化最后给每个片段打一个相关度分。它没有语义能力但它有向量检索没有的优势对精确词、缩写、型号、代码标识符非常敏感。CH605-2只要在哪一段里出现过BM25 就一定能把它捞出来。有次我把一份设备手册里某章节更新后用混合检索查FW 3.2 的固件升级条件BM25 命中了一个连 embedding 都没召回的段落——那段话里明确写了仅支持从 3.0 及以上版本升级而语义检索给出的片段都在讲升级失败怎么排查。两个结果合并之后问答质量立刻上了一个台阶。4.3 RRF 融合两个检索系统怎么合并排名两个检索系统各出一份 top_k怎么合最稳妥的方案是 RRFReciprocal Rank Fusion倒数排名融合不需要考虑两个系统分数尺度是否一致。向量检索给的是余弦相似度0~1BM25 给的是绝对分数可能到十几把它们硬加在一起没有意义。RRF 的思路很简单分别记录每个片段在两个系统的排名按公式融合。def rrf_fuse(scores_a: dict, scores_b: dict, k: int 60): fused {} for rank, doc_id in enumerate(scores_a): fused[doc_id] fused.get(doc_id, 0) 1 / (k rank 1) for rank, doc_id in enumerate(scores_b): fused[doc_id] fused.get(doc_id, 0) 1 / (k rank 1) return sorted(fused.items(), keylambda x: x[1], reverseTrue)k是一个平滑常数经验值取 60。它的作用是让早期排名带来的分数差异不要太大第一名和第二名的差距远小于第一百名和第二百名的差距。这个公式在多个搜索引擎集成场景里被反复验证过稳定、省心、不需要调权重。4.4 落地实现中文分词是 BM25 的生死线BM25 在英文场景直接按空格切词就行中文不行。如果直接按字符切跑出来的结果基本是垃圾。中文分词必须上分词器我在本地用的是jieba配合一个自定义词典把设备型号、产品名、专有缩写都收进去。自定义词典的作用非常大——不把CH605-2作为一个词分出来jieba 可能把它切成CH605/-/2BM25 匹配率直接崩掉。from rank_bm25 import BM25Okapi import jieba # 自定义词典把专有名词整体切出 jieba.add_word(CH605-2) jieba.add_word(Firmware v3.2.1) def tokenize(text: str): return list(jieba.cut(text)) # 入库时对每个子块 text 做分词存入倒排结构 corpus [tokenize(chunk[child_text]) for chunk in all_chunks] bm25 BM25Okapi(corpus) # 检索时对查询也做同样的分词 query_tokens tokenize(CH605-2 的温度范围是多少) scores bm25.get_scores(query_tokens)实现混合检索时还有一个关键细节融合和去重都要按 doc_id而不是按 chunk_id。原因很简单同一个父块下的多个子块可能同时被 BM25 和向量检索命中如果不按 doc_id 去重最后拼上下文时会出现同一个父块被塞了三四次的情况。按 doc_id 去重后再取父块既节省 token又避免上下文冗余。def hybrid_search(query: str, top_k: int 8): # 1. 向量检索返回按相似度排序的 doc_id 列表 vec_hits vector_store.query(query, top_ktop_k * 2) # 2. BM25 检索返回按分数排序的 doc_id 列表 bm25_scores bm25.get_scores(tokenize(query)) bm25_hits [all_chunk_ids[i] for i in np.argsort(bm25_scores)[::-1][:top_k * 2]] # 3. RRF 融合 fused rrf_fuse(vec_hits, bm25_hits) # 4. 按 doc_id 去重把命中的子块映射到父块返回 prompt 上下文 final_chunks dedup_and_map_to_parent(fused, top_k) return final_chunks4.5 混合检索救不了什么场景混合检索并不是万能药。两个明显短板同义词场景——用户问电机转速文档里全是马达转数BM25 匹配不到只能靠向量语义兜底跨语言场景——中文问答配英文文档BM25 基本失效只能靠多语言 embedding。但作为个人知识库大部分文档和查询还是同一语言、同一术语体系混合检索的收益远大于成本。5. 可引用回答让 AI 说的每句话都带着锚点返回原始上下文5.1 为什么可引用是个人知识库的质检员没有引用的 RAG 回答本质上是大模型的另一种幻觉——你不知道它说的内容是来自你的文档还是来自它预训练里残留的记忆。我的一位合作者使用知识库时问了一句话模型回答得头头是道结果发现那段内容根本不是他存的文档而是模型自己编的。可引用回答解决的不只是证明 AI 没说谎而是让你能快速定位这句话到底来自哪里、出自哪个版本、是否已经过期。没有这条链路知识库就只能靠信任运行有了引用锚点每次回答都可以被审阅和纠错。5.2 Prompt 层面强制引用编号我的做法是检索阶段先把命中的子块按顺序编号在拼 prompt 时让每个片段自带编号然后在 prompt 里明确规定——回答中凡是引用某个片段内容的句子结尾必须加引用标记[[n]]n 是片段编号。def build_prompt_with_citations(query: str, chunks: list): context_lines [] for idx, chunk in enumerate(chunks, start1): context_lines.append(f[{idx}] 来源{chunk[doc_title]}/{chunk[section]}\n{chunk[parent_text]}) context \n\n.join(context_lines) prompt f请根据以下编号资料回答问题。 {context} 回答要求 1. 只使用资料中的信息如果资料中没有明确回答知识库中未找到相关信息。 2. 每句话结尾标注其依据的编号格式如 [[1]] 或 [[1]][[3]]。 3. 引用编号必须来自资料中实际存在的编号禁止编造。 问题{query} return prompt这里最关键的是第三条规定禁止编造编号。大模型在输出时经常会出现幻觉引用编一个[[7]]但上下文里根本没有 7 号片段。所以生成后还要做一次后处理校验。5.3 后处理校验与渲染把编号映射成可点击的锚点模型输出的[[3]]是一个 token不是真正的链接。渲染层要做两件事校验编号是否存在于本次上下文中然后把它映射成文档标题 章节 原文摘要的可视化锚点。def render_answer_with_citations(answer: str, chunks: dict): import re output_lines [] valid_ids {str(i 1): chunk for i, chunk in enumerate(chunks)} def replace_citation(match): nums match.group(1).strip() refs [] for n in re.split(r[,\s], nums): if n in valid_ids: chunk valid_ids[n] refs.append(f[{n}]({chunk[source_path]}#{chunk[section]})) else: refs.append(f[{n}: 无效引用]) return .join(refs) answer re.sub(r\[\[([0-9,\s])\]\], replace_citation, answer) return answer渲染之后用户看到的不再是一堆裸编号而是能跳转到原始 PDF 对应页面 / Markdown 文件对应章节的锚点。这一步对个人知识库的使用体验提升非常明显——你可以很方便地打开原文核对确认模型有没有断章取义。5.4 评估引用质量三个指标而不是一个引用质量不是有没有引用这么简单。我评估一个知识库回答时会看三个指标指标含义怎么数引用存在率回答的句子里有多少句带了引用带引用的句子 / 总句子数引用准确率引用编号对应的片段是否真的支持这句话人工抽查核对内容覆盖质量每个关键论点的引用是否足够、是否来自最新版本看版本元数据命中情况第二个指标最容易被忽视。模型可能给出了引用但引用的片段其实不支持这段话这在多文档问答里特别常见。解决方法是让 prompt 要求逐句列出依据片段的关键原文——不是让模型摘要而是让模型把片段里的关键句子直接摘出来再放回引用链接旁边。这样人工核对速度会快很多。5.5 实际翻车引用编号越多越容易乱我在实测中发现当 prompt 里塞了 8 个以上编号片段时模型输出的引用编号会开始错乱经常引到不存在的编号甚至自相矛盾地引用两个内容相反的片段。这个问题的根因不是模型笨而是上下文太长之后注意力被稀释。我的解决办法是默认只带 5 个有效片段如果 5 个不够就在 prompt 里让模型先回答哪几个片段和问题直接相关后再决定带哪些。少而精的引用比多而全的引用可靠得多。6. 整套落地我的技术栈、流程与踩坑清单6.1 本地可跑的轻量技术栈先说整体选型思路个人知识库没必要一上来就上大而重的服务我选择的是本地可跑、逻辑透明、拆换零件容易的轻量方案。很多人用现成的 Dify、FastGPT 这类平台搭知识库优点是快缺点是流水线被框架包住了出问题很难排查。我的建议是先自己把流水线用手写代码跑通再去决定要不要迁移到平台。我在 Mac 上搭的这套全部都能本地跑文档解析Marker处理 PDF表格和标题识别比普通解析器干净Markdown 笔记直接解析为文本块。存储SQLite存文档元数据、分块记录、父子映射向量库用Chroma个人规模足够。向量模型BGE-M3中文和多语言表现都比早期 OpenAI embedding 模式更适合本地文档场景。稀疏检索rank_bm25jieba自定义词典。大模型Ollama跑本地量化模型如Qwen2.5-14B-Instruct或类似量级。如果机器允许也可以用云端 API但引用校验和检索逻辑是一样的。这套组合的优点是每一个零件都可以单独替换。比如向量库从 Chroma 换到 Qdrant只需要改一个接口层BM25 换成 SPLADE 也只需要替换融合函数。框架的 lock-in 远小于 Dify。6.2 最简入库流程可以直接抄作业入库流程我建议严格按六步走顺序不要乱解析文档提取纯文本 标题层级 来源路径保存为一个 JSON 中间格式。计算全文哈希和库里已存在的文档比对一样就跳过。按标题层级切父块再在父块内切子块。为每个子块生成 embedding写入向量库metadata 里带上doc_id、version、parent_chunk_id。对每个子块做 jieba 分词写入 BM25 倒排索引。开启一个新事务把 docs 表的新版本记录和 chunks 表的映射一次性提交。中间格式是最容易被跳过的但它其实是整个流水线的缓冲层。有了这个 JSON 格式后续要换分块策略、换 embedding 模型都不需要重新解析 PDF只需要重跑中间格式之后的环节。我试过几次换模型都是靠这个中间格式省下了大量时间。6.3 踩过的坑坑 1更新文档后旧向量没删干净。这个前面说过了但值得再强调一遍。向量库本身没有版本概念如果你只在 SQLite 里删了旧 doc 记录而忘了同步删向量旧内容依然会被检索到而且你会花很长时间才能意识到问题出自向量残留。坑 2BM25 忘记配中文分词。直接用英文默认分词跑中文 BM25检索结果基本不可用。我后来做了两件事加载 jieba 的默认词典以及维护一个自定义词典文件把高频出现的型号、缩写、人名全放进去。词典的更新频率可能比你想的高只要新文档里出现新术语就得往里加。坑 3引用编号超过 5 个之后模型开始胡引用。前文提过。解决方案是收缩 top_k同时加一个后处理脚本把上下文里不存在的引用编号全部标记为无效引用而不是直接删掉——直接删会让回答内容失去对应的锚点用户更难判断。坑 4正文里的图片被解析丢掉了。很多人会问RAG 知识库能存图片吗。我实测下来本地工具对图表类 PDF 的解析非常不稳定。我的临时方案是不做图像理解只把图片的引用位置图片前后的文字描述保留下来让模型在回答中提示详见原文档第几页图 X。这对个人知识库来说成本最低暂时够用。6.4 几类查询的实测对照这是我知识库里三类典型查询的表现。数据来自几十条人工标注问题不算严谨的 benchmark但能说明问题。查询类型纯向量纯 BM25混合 RRF精确型号/编号CH605-2 温度范围经常漏掉关键片段稳定命中最好自然语义问法设备过热怎么办很好一般很好组合式更新到 3.2 后哪个参数变了一般中上最好混合检索带来的提升在组合式查询上最明显它既要求精确匹配版本号又要求语义理解参数变化单一检索系统很难同时满足。这也是我最终落定向量 BM25 RRF 融合的根本原因。6.5 运行了大半年之后的几个体会写到最后还是忍不住说点体己话。搭这套知识库我花在修版本残留上的时间比写检索代码的时间还多。一开始我也天真地以为 RAG 的核心是 embedding 模型选得好不好、prompt 写得妙不妙后来发现真正决定知识库能不能长久用下去的是数据变更时的那套秩序——谁负责失效、谁负责回滚、谁负责告诉模型这不是当前版本。父子分块和混合检索是分块/召回工程里的性价比之王。这两个功能解决了检索粒度和精确匹配两个大难题而且实现成本都很低关键在于你有没有真的在数据层把它们存清楚。至于可引用回答我把它排在最后一位不是因为不重要而是因为它是一个质检环节——没有前面三块的支撑引用链再漂亮回答内容本身不靠谱引用也救不回来。如果这篇文章对你有一点启发建议你从今天开始做一件事把你知识库里所有文档统一加上版本元数据和 doc_id 映射。这一步做完了后续加父子分块、混合检索、引用渲染都只是时间问题。等到你想把 Obsidian 笔记、微信公众号收藏的文章都汇进来的时候你会发现当初多花的那点时间都值了。