很多人第一次接触 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( title=old_doc["title"], source_path=old_doc["source_path"], version=new_version, content_hash=new_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_size=child_size, overlap=40, respect_sentence_boundary=True, ) for child in child_chunks: records.append({ "parent_chunk_id": f"para_{parent_id}", "parent_text": parent_text, "child_text": child, }) return records切子块时我加了一个参数:respect_sentence_boundary=True。意思是尽量在句号处切断,而不是硬按字数切。硬切会导致一句话被腰斩,向量语义被割裂。这个细节对中文尤其重要,因为中文没有天然空格,分词边界本来就靠算法,硬切 300 字很可能把一句话劈成两半。
父块怎么切?优先按 Markdown 标题结构切,利用##、###这些标题把文档切成有逻辑边界的章节。如果遇到没有标题结构的纯 PDF,就用规则:段落间距加字号变化。实在不行才退回"固定字数切父块"。
3.4 实测参数:子块 200~400 字,父块 1500~3000 字
下面这组数据来自我用 200 多份中文技术文档做的一个小规模对比测试,场景是文档问答。数值不绝对,但方向有参考价值。
| 分块策略 | 召回命中准确率 | 回答完整度(人工评分 1-5) | 平均单次检索 token 消耗 |
|---|---|---|---|
| 单层 300 字,无父块 | 78% | 3.1 | 2100 |
| 单层 1500 字 | 62% | 3.6 | 4800 |
| 父子分块(子块 300,父块 2000) | 81% | 4.2 | 3200 |
| 父子分块(子块 500,父块 2500) | 76% | 3.9 | 3600 |
从结果看,父子分块并不总是"绝对高分"——它只是让你在检索精度和上下文完整度之间不再必须做取舍。子块设 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,怎么合?最稳妥的方案是 RRF(Reciprocal Rank Fusion,倒数排名融合),不需要考虑两个系统分数尺度是否一致。向量检索给的是余弦相似度(0~1),BM25 给的是绝对分数(可能到十几),把它们硬加在一起没有意义。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(), key=lambda x: x[1], reverse=True)k是一个平滑常数,经验值取 60。它的作用是让早期排名带来的分数差异不要太大:第一名和第二名的差距,远小于第一百名和第二百名的差距。这个公式在多个搜索引擎集成场景里被反复验证过,稳定、省心、不需要调权重。
4.4 落地实现:中文分词是 BM25 的生死线
BM25 在英文场景直接按空格切词就行,中文不行。如果直接按字符切,跑出来的结果基本是垃圾。中文分词必须上分词器,我在本地用的是jieba,配合一个自定义词典,把设备型号、产品名、专有缩写都收进去。自定义词典的作用非常大——不把"CH605-2"作为一个词分出来,jieba 可能把它切成"CH605"/"-"/"2",BM25 匹配率直接崩掉。
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_k=top_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, start=1): 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_bm25+jieba自定义词典。 - 大模型:
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 记录而忘了同步删向量,旧内容依然会被检索到,而且你会花很长时间才能意识到问题出自向量残留。
坑 2:BM25 忘记配中文分词。直接用英文默认分词跑中文 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 笔记、微信公众号收藏的文章都汇进来的时候,你会发现,当初多花的那点时间,都值了。