很多人一听到 “RAG 知识库”,第一反应就是把 PDF 丢给大模型,然后像聊天一样问问题。这个思路我一开始也走过,但做着做着就发现它根本算不上知识库,充其量是个“文档问答玩具”。真正的个人 RAG 知识库,要想在长期使用中不翻车,必须把版本治理、父子分块、混合检索、可引用回答这四件事踏踏实实落地,否则你攒了多年的资料只会越放越乱,答案也变得越来越像幻觉。这篇文章就把我从零搭到能用的完整经验拆开讲,适合那些已经试过简单 RAG 但效果不理想,或者准备认真打理个人资料库的朋友。
1. 先把这事说清楚:个人知识库到底在解决什么问题
1.1 “上传 PDF 聊天”为什么不够用
先说个扎心的事实:把几份 PDF 塞进向量库,接上大模型聊天,这个流程我在半天内就能跑通。但你真用起来就会发现三个问题。
第一,文档会更新。你今天导入的《项目架构设计 v3》,下周变成了 v5。旧版的块还在向量库里,新版又加了进去,同一个问题检索出来的内容自相矛盾,大模型也不管,它会把两个版本的内容揉在一起,给你一个看似合理实则缝合的答案。我最早踩这个坑的时候,一度以为是 Embedding 模型不行,后来一查,是旧版本没被过滤掉。
第二,分块粒度很难拿捏。切小了,上下文不够,大模型不知道这段在讲什么;切大了,噪声太多,向量检索的语义容易被淹没。我试过固定 1000 字符切块,结果把“需求文档”和“接口定义”切在同一个块里,问接口字段时,回答里经常混进需求背景的废话。
第三,很多资料靠纯向量检索根本捞不着。个人知识库里全是专业术语、项目代号、英文缩写,比如“NGW-P42 降噪方案”“Q3 预算修正案”。向量检索擅长找“意思相近”的,可碰到这种精确字符串、编号类关键词,它经常翻车。我一开始只用了 Embedding 余弦相似度,测了 20 条问题,召回命中率只有大概 70%,剩下的全漏在关键词上。
所以,个人 RAG 知识库真正要解决的不是“怎么连大模型”,而是“怎么治理知识”。版本治理管的是知识的生命周期,父子分块管的是知识的粒度,混合检索管的是知识的召回,可引用回答管的是知识的可信度。这四件事是一个闭环,缺一件,整体体验就崩。
1.2 个人项目的技术选型逻辑
谈完问题,说下选型。个人知识库不需要一上来就上 Elasticsearch、Milvus 这类重型组件,我用的是“文件系统 + SQLite + 本地 Embedding 模型”的组合,轻量、可控、好备份。向量存储一开始用过 Chroma,后来换成了 SQLite 加 vec 扩展,原因是我想把“文档表、版本表、块表、元数据”全部捏在一个文件里,备份和迁移都方便。Chroma 虽然也支持 metadata 过滤,但版本治理的逻辑写起来没有 SQL 那么顺手。
Embedding 模型选了 BGE 系列的中文模型,维度适中,本地跑得起,中文效果比早期一些通用模型稳不少。LLM 部分你可以用本地模型,也可以接 API,这不影响整体架构——关键是把知识库的检索与治理链路做结实,模型只是个“阅读理解器”。
这里我多说一句:个人项目最怕“架构膨胀”。你不需要微调模型,不需要搞分布式向量库,更不需要上 RAG 框架全家桶。核心链路就一条:文档入库 → 版本登记 → 分块 → 向量化 + 关键词索引 → 检索融合 → 组装上下文 → 生成回答 → 解析引用。能用一个 SQLite 文件解决的事,不要拆成三个服务。
2. 版本治理:让知识库可回滚、可追责、不打架
2.1 版本治理到底要管理什么
做版本治理之前,得先想清楚一个问题:知识库里的“版本”是文档的版本,还是知识条目的版本?我的做法是用“逻辑文档 ID”来标记同一份资料的迭代关系,一个 doc_id 对应一份持续更新的资料,每次内容变化就产生一个新的 version 号。
举个例子,你的《季度复盘模板》这个文件,不论它放在哪个目录、被改名成“Q4 复盘模板”还是“复盘模板 final”,只要它逻辑上是同一份资料,doc_id 就应该一样。版本号从 1 开始递增,内容变了就加 1。这样你在检索时,永远只查最新 active 版本,但历史版本都还在库里,随时可以翻出来对比或者回滚。
我当时的数据表结构大致是这样的:
CREATE TABLE documents ( doc_id TEXT PRIMARY KEY, doc_name TEXT, current_version INTEGER, file_path TEXT, updated_at TEXT ); CREATE TABLE doc_versions ( id INTEGER PRIMARY KEY, doc_id TEXT, version INTEGER, file_hash TEXT, status TEXT DEFAULT 'active', created_at TEXT, UNIQUE(doc_id, version) ); CREATE TABLE chunks ( chunk_id TEXT PRIMARY KEY, doc_id TEXT, version INTEGER, parent_id TEXT, content TEXT, parent_content TEXT, source TEXT, embedding BLOB );documents 表负责记录“这份资料现在的最新版本号”,doc_versions 表负责记录每个版本的状态和文件指纹,chunks 表存的是具体的分块内容和向量。每次检索时强制加一个过滤条件:status = 'active'。这样,哪怕旧版本块还在库里,也不会被捞出来污染回答。
2.2 更新、回滚与检索联动的完整流程
版本更新的流程我建议做成“扫描 → 比对 → 重建块 → 切换状态”四步。
第一步,定时扫描你指定的资料目录,对每个文件计算 SHA-256 指纹。第二步,拿指纹跟 doc_versions 表里最新版本的 file_hash 对比,如果一致,说明文件没变,直接跳过;如果不一致,说明内容有更新。第三步,为新版本重新分块、重新生成 Embedding,保持 parent_id 等结构不变但 chunk_id 要体现版本号,比如用doc1_v3_chunk01这种命名,避免跟旧版本块混淆。第四步,把旧版本的 status 改成 superseded(已取代),新版本设为 active。
这里有个容易踩的坑:有些人是直接更新同一批 chunk_id,把旧向量覆盖掉。这样做的后果是,如果有历史问答引用了旧版本的 chunk_id,你再追溯过去就变成了一堆新内容。我建议旧版本块一律保留,只改状态字段。个人知识库的体量一般不大,多存几个历史版本的向量不会造成存储灾难,但带来的可追溯性收益非常明显。
回滚操作就更简单了:把当前 active 版本改成 archived,把目标版本改成 active 即可。关键是检索层只认 status = 'active',所以你不需要删数据,也不需要重建向量库。有一次我把一份资料从 v5 回滚到 v3,搜索问答立刻回到了旧版本的口径,全程没动一行向量数据。
我还做了一个小设计:在 doc_versions 表里加了一个 status 字段的同时,又加了一个remark字段用于记录“为什么更新”和“为什么回滚”。比如“根据评审意见修订”“误更新,回滚”。别小看这个字段,三个月后再翻回来,你会感谢自己写了这行字。
2.3 版本治理的实操经验
有几个细节是在实际使用中慢慢发现的。
第一,文件指纹不要只用文件名,要连文件内容一起算。因为很多人喜欢把 v3 的内容另存为“最终版 v3 终稿”,文件名变了内容没变,如果只比对文件名,会导致重复入库。我用 SHA-256 做内容级判断,名字怎么改都不怕。
第二,扫描目录时不要用“最后修改时间”做唯一判断。有一次我从网盘同步文件,所有文件的修改时间都被统一刷新了,结果整个知识库被判定为“全部更新”,触发了一次全量重建。从那以后,我只用文件内容 Hash 做变更判断,修改时间只作为展示信息。
第三,版本切换后一定要“冷启动”检索确认。更新完版本后,我会构造几条和旧版本强相关的问题去问,如果回答内容已经切到新版本的描述,说明状态切换生效了。如果还是旧版的内容,90% 是检索条件忘了加 active 过滤。
第四,如果需要保留多个并行版本,不要用 active 一个状态死磕。比如你同时维护“方案 A”和“方案 B”两个可行性版本,我的做法是引入一个 active_group 字段,而不是布尔型的 is_active。检索时可以按组过滤,也可以全部放开让混合检索去排序。这个灵活度在你后期做横向比较时非常好用。
3. 父子分块:用粒度换精度,用上下文换准确
3.1 为什么分块不是“切成几段”这么简单
分块这事,看起来就是个文本切割,实际上藏着 RAG 最核心的权衡。块太小,比如 200 字符,检索的时候很容易命中一个具体句子,但这个句子脱离上下文后就失去了语义支撑。块太大,比如整段章节,向量化后特征被稀释,检索时相似度变得平淡无奇,什么都能匹配上,什么都不精准。
我早期最典型的失败案例是:把一份 50 页的产品手册按每 2000 字符切块,问“导出功能支持哪些格式”时,明明答案在 PDF 的第 30 页,检索出来的却是第 15 页那段包含很多“格式”字样的内容。原因就是第 15 页的块太大,语义太杂,跟“导出格式”的相似度反而超过真正包含答案的小段落。
后来我改成了“父子分块”策略,一句话概括就是:用小步长把文档切成精细的子块用于检索,同时保留更大粒度的父块用于提供上下文。检索命中子块后,再带着子块所在的父块内容一并交给大模型。
3.2 父子分块的原理与参数选择
先打个比方:你到图书馆找资料,先看索引卡片(子块)定位到具体页码,再找到那一页阅读整页内容(父块)。索引卡片只负责“快速定位”,整页内容才负责“完整理解”。
我的参数经验是,子块设置在 500 字符左右,重叠 50 到 100 字符。父块则按文档结构来切,优先用 Markdown 标题、PDF 书签、章节序号这些天然边界。如果文档没有结构,就用 1500 到 2000 字符兜底做父块。子块和父块的关系在库里用 parent_id 关联,同时在 chunk 表里冗余存储了一份 parent_content,这样检索命中子块后,不需要再回表查父块内容,直接用冗余字段就能拼 Prompt,省一次 IO 也省一次拼接逻辑。
具体操作时我用了“结构优先,长度兜底”的原则:先尝试按二级标题切父块,如果某个标题下内容太长,再按段落边界或长度切分,但保留章节路径作为前缀。这个方法对技术文档、周报、会议纪要特别有效。会议纪要按“议题”切父块,每个议题下的发言片段按 500 字符切子块,检索效果比我一开始等长切块好了不止一星半点。
3.3 检索时如何用父子关系重组上下文
检索阶段的流程是:先把用户问题向量化,在子块集合里召回 top 20 个子块,然后根据 parent_id 做去重聚组,选出 top 4 到 6 个父块作为上下文。聚组的作用是把命中的子块还原成完整的章节,避免上下文碎片化。
但这里有个细节需要注意:父块去重时,我建议不要简单按相似度排序取前 N。更有用的做法是“命中子块数量优先”——如果有三个子块都指向同一个父块,说明这个父块和问题高度相关,应该优先进入上下文。我实际排序规则是:先按命中子块数降序,再按子块相似度最高分降序。这样选出来的父块通常更贴合问题的整段语境。
另外,父块虽然大,但不能无限大。我的经验是把父块内容上限控制在 1800 到 2400 字符,超出部分截断。因为个人知识库调用的模型上下文有限,你不能把所有父块都塞进去。截断策略也很有讲究,不要只保留前 800 字符,那样会把答案可能出现的后半段丢掉。我的做法是优先保留子块命中的那一段原文,再加上前文 400 字符和后文 400 字符,形成一个“局部加宽”的上下文窗口。这个小改动,让很多原本“差一口气”的回答变得完整。
3.4 父子分块的避坑心得
做父子分块最容易踩的坑有两个。
一个是“父块切错边界”。如果父块跨了两个章节,检索结果就会把不相关的内容混进来。一定要在入库阶段把结构信息记录下来,比如父块的章节标题。我遇到过最离谱的情况是:一个父块里包含了“方案背景”和“预算表”两个部分,问预算相关问题时,答案里总出现背景废话。后来我用标题边界切父块,问题就消失了。
另一个坑是“子块和父块的关系断裂”。如果你在清洗文档或者做二次分块时,不小心改了子块顺序或者重新切分,parent_id 对不上,检索就废了。我的缓解办法是:每个子块生成时记录它在原文档中的字符偏移量,这样即使父块调整,也能通过偏移量重新对齐。虽然个人项目很少用到这么精细的修复逻辑,但存个偏移量字段成本极低,建议一开始就加上。
还有一点:Markdown 表格、代码块这类特殊格式,在分块时最好整块保留,不要拦腰切断。我的做法是把代码块、表格先标记为“不可分割元块”,分块时优先在元块边界处切,实在要跨块就把整个元块复制到相邻块中,宁可小块内容重叠,也不要让代码在中间断掉。这个经验在处理技术笔记时特别重要。
4. 混合检索:关键词和向量不是二选一
4.1 单靠向量检索为什么会漏
纯向量检索的本质是“语义相似度匹配”,它把问题映射到高维语义空间,找最接近的文本段。这对“意思相近但表达不同”的情况非常有效,比如问“如何提高模型准确率”,能匹配到“优化指标表现”这类表达不一样的段落。
但碰上精确匹配场景,向量检索就抓瞎了。个人知识库里大量内容是项目代号、版本号、变量名、专有缩写。比如你的资料里写了“PAAS-3 模块已完成联调,LCP 参数调整至 0.8”,你问“LCP 参数当前是多少”,向量检索可能因为“LCP”和“参数”在其他段落频繁出现,把真正相关的段落排到了很后面。而 BM25 这类稀疏检索,恰恰是根据词频和逆文档频率做精确匹配,对“代号 + 关键词”的组合非常敏感。
所以,单靠向量检索的个人知识库,常见症状是:问“什么意思”类问题效果还行,问“某个值是多少”“某个代号是什么”就经常答非所问。混合检索就是要把这两种互补的检索能力组合起来。
4.2 稀疏检索与稠密检索的实现要点
我的实现分成两条线:稠密线就是子块 Embedding 的余弦相似度检索;稀疏线用的是 BM25,对父块或子块做了分词索引。中文场景下,BM25 的分词很关键。我用 jieba 做了一个自定义词典,把项目里的专有名词、代号全部加进去,避免“LCP”被切成“L”“CP”。
稀疏线的数据来源我建议用父块而不是子块。因为 BM25 对文本长度不敏感,父块包含的上下文更多,关键词匹配时不容易误伤。子块太小,BM25 匹配到但缺少上下文,后续处理一样要回到父块,不如直接在父块上做稀疏检索。实际操作中,我把父块文本做了关键词索引,查询时先对问题分词,再在父块集合里算 BM25 分数。
工具上,个人项目用rank_bm25就够了,不需要上 Lucene 或 Tantivy。它俩一个管语义,一个管字面,互不干扰。有两套索引之后,做融合检索之前,我先各自取 top 50 的候选块,再进入融合排序。
4.3 融合策略:RRF 的实战参数与效果
融合策略我没有用复杂的评分归一化,而是用了经典的 RRF(Reciprocal Rank Fusion)。公式很简单:
score(chunk) = sum over each retriever of 1 / (k + rank_in_retriever)k 是一个常数,我实测下来取 60 效果最稳。这个公式的好处是它只关心排名,不关心每个检索引擎的原始分数量纲。向量检索的相似度和 BM25 的分数根本不是同一个尺度,直接加权平均很容易被某一方的极端值带偏,RRF 天然免疫这个问题。
有一次我对比了三种方案:纯向量、向量 + BM25 分数归一化加权融合、向量 + BM25 的 RRF 融合。在一套 30 条测试问题上,纯向量的 hit@5 是 70%,加权融合提升了大概 6 个百分点,RRF 直接拉到了 83%。后来我调了子块参数和 Embedding 模型,整体 hit@5 稳定在 90% 以上。这里 hit@5 的含义是:正确答案所在的父块,在前 5 个推荐结果中出现。
融合之后,我会再做一步去重和排序修正。如果同一个父块既被向量检索命中,又被 BM25 命中,说明它是“语义 + 字面”双高,应该优先进入上下文。实现时只需要在 RRF 分数上加一个小的 bonus,比如 0.1,不要加太多,否则破坏排名稳定性。
4.4 混合检索的调参经验与评估方法
我强烈建议每个 RAG 项目都建一套自己的“小评测集”。不需要多,20 到 30 条问题即可,关键是要覆盖三种类型:纯语义题、纯关键词题、语义 + 关键词混合题。比如语义题是“这个项目的核心目标是什么”,关键词题是“LCP 参数配置在哪一章”,混合题是“预算修正后 Q3 的指标怎么调”。
每跑一次调整,就把这套评测集过一遍,计算 hit@5 和 MRR。我见过很多人调 RAG 全靠“感觉”,今天觉得结果差不多了,明天换一批文档又翻车。用一个小评测集做回归,虽然不能保证百分百覆盖,但至少能让你在调参时不至于开盲盒。
调参顺序上,我建议先调分块参数,再调 Embedding 模型,最后调融合权重。因为分块是地基,分块不合理,后面怎么调都是徒劳。分块稳定之后,换更强的 Embedding 模型收益最明显。等这两步都固定了,再看融合策略的边际收益。千万不要一上来就调 RRF 的 k 值,那样容易陷入“局部最优”,换个语料就失灵。
5. 可引用回答:让每个结论都有出处
5.1 引用不是装饰,是知识库的信任基石
如果只把 RAG 当成“问答游戏”,那回答里带不带引用确实无所谓。但作为知识库,引用是必须的。原因很简单:大模型回答是会“一本正经胡说八道”的,哪怕检索正确,生成阶段也可能把内容改得面目全非。有了引用,你才能快速判断——这个说法到底是我资料里的原话,还是大模型自己脑补的。
可引用回答给我的实际帮助有两个。第一个是排查阶段的高效定位:当回答不对劲时,点开引用直接看原文,立刻判断是检索漏了、生成长歪了,还是资料本身就有矛盾。第二个是长期使用的信任积累:我查自己的知识库时,看到引用标注反而更放心,因为我知道每条信息都能回溯到原始文档的具体位置。
5.2 检索结果如何携带来源信息
要让回答可引用,第一步是在检索阶段就给每个上下文块绑定“来源身份证”。我的 chunk 表里存了 doc_id、version、chunk_id、source(比如“《产品手册》v3 第 2.3 节”)。当混合检索选出最终上下文后,每个父块都带完整的来源字段。
然后把这些来源编号后拼进 Prompt。我的做法是给每段上下文分配一个 [1]、[2] 这样的编号,编号后面紧跟来源说明和正文内容。Prompt 里会明确告诉模型:回答时必须用 [n] 标注你参考的来源编号,禁止编造不存在的编号。下面是我用的 Prompt 模板片段。
你是我的个人知识库助手。我会给你若干资料片段,每个片段前标注了编号和来源。 请只基于这些资料回答我的问题,并在回答中使用 [n] 标注对应来源。 如果资料中没有相关信息,请直接说明“知识库中未找到相关内容”,不要编造。 资料片段: [1] 来自《智能家居网关调试记录》v2,章节:网络配置: <内容> [2] 来自《传感器固件升级说明》v1,章节:版本兼容性: <内容> 问题:{query} 回答:这个模板里最关键的一句是“如果资料中没有相关信息,请直接说明”。没有这句约束,模型喜欢硬着头皮把检索结果里模棱两可的内容扩写成答案。加了这个约束之后,我的知识库里“未找到”这种诚实回答明显变多,但对用户来说,这比假装知道要好得多。
5.3 引用解析与前端展示的完整闭环
模型生成了带 [n] 编号的回答之后,还需要一个后处理步骤:把 Markdown 输出里的 [n] 解析出来,跟本次检索的来源列表做映射。解析时我用正则匹配\[\d+\],但要预留一个容错——有些模型喜欢输出[1]和【1】混用,我会在解析前统一把中文括号、方括号转成半角格式。
解析完成后,我返回给前端的数据结构大致是这样:
{ "answer": "根据资料,LCP 参数当前设置为 0.8 [1]。", "sources": [ { "index": 1, "doc_name": "PAAS 联调记录", "version": 3, "chunk_id": "paas_v3_chunk18", "excerpt": "LCP 参数调整至 0.8", "url": "local://docs/paas_v3.pdf#page=18" } ] }前端拿到这个结果,就把 answer 里的 [1] 替换成带下划线、可点击的引用标签。点击标签后,展示对应的来源信息,包括文档名、版本号、原文片段、文件本地路径。这样,任何一次回答都能做到“点开即溯源”。
有一点要提醒:引用定位时,展示的 excerpt 应该是命中的子块原文,而不是整个父块。因为父块太长,用户点开引用后发现是一大段,反而找不到那条支撑信息。我存储子块时已经把原文完整记录在 content 字段里,所以展示时直接取子块内容,精确到句子级别。
5.4 版本治理与引用的联动
可引用回答和版本治理结合后,效果会进一步提升。我的做法是:引用标签里不仅展示文档名和版本号,还在旁边加一个“是否存在更新版本”的角标。如果当前引用的是 v2,而知识库里已经有 v3 的 active 内容,界面就会提示“该文档已有更新版本,请确认是否需要参考最新内容”。
这个角标的实现很简单,检索时 source 里带了 doc_id 和 version,前端查一下 documents 表的 current_version,就能对比出来。但它带来的体验区别是巨大的——尤其是在项目资料频繁更新的场景下,用户能看到自己引用的旧内容有什么风险,避免拿过期信息做决策。
另一个联动点是“追溯阅读”:当用户点了引用标签,我会顺带展示这条内容的历史版本演变,比如 v1 写的是“参数设为 1.0”,v2 改成了“参数设为 0.8”,v3 补充了“取值范围 0.5-1.0”。这个功能不是必须的,但做出来后,知识库就不再是静态的快照,而是一条有生命的信息流。我是在一次复盘时才意识到这个价值的——当时需要搞清楚一个参数是什么时候改的,翻了半天聊天记录,后来发现知识库的版本历史里全都有,只是之前没展示出来。
6. 常见问题与排查实操
6.1 版本更新后仍检索到旧内容
这个问题的根源几乎都是同一个:检索时没有过滤 active 状态。很多人是把所有块一股脑塞进向量库,更新版本时把旧块删了又重建,但因为删得不彻底,或者向量库索引没刷新,旧内容还是出现了。
排查步骤我建议这样:先确认 doc_versions 表里的状态是否正确;再检查检索 SQL 里有没有带 status 过滤条件;最后看是不是用了缓存。有一次我调了半天,发现是向量库的索引文件没保存,重启后旧数据又回来了。个人项目一定要记得给向量索引做持久化,不能只存内存。
如果确认是过滤器的问题,解决方式是先保证检索层强制带 active 条件,再考虑数据清理。我的代码里,检索函数签名直接自带 version_filter 参数,默认取 status='active',想查历史版本时显式传入版本号才能放开限制。这种“默认安全”的设计,能避免很多低级错误。
6.2 父子分块后上下文仍然很乱
上下文乱,优先怀疑父块的边界切错了。你可以打印一批命中的父块,看看里面是不是混了不同章节的内容。我遇到过一个案例:一份文档用 PDF 导出时,书签结构丢失了,导致“第二章”和“第三章”标题在文本里连成一行,分块时把两个章节切进了同一个父块。
解决办法分两步。第一步,入库前做“结构修复”,如果原始文档有标题层级,尽量用标题边界切父块;如果结构丢失,就按段落和启发式规则(比如“第X章”“一、二、三”这类模式)识别标题。第二步,如果实在识别不出来,可以把父块上限降到 1000 字符左右,减少跨章节的概率。
还有一个常见原因是子块和父块没对齐。比如父块切在 5000 字符处,子块切在 500 字符处,两者不是整数倍关系,导致某个子块跨了两个父块边界。我的解决办法是:子块在切割时如果跨越了父块边界,就强制在边界处断开,宁可少一点重叠,也不让子块和父块关系产生多对多。
6.3 混合检索后结果反而变差
如果你加上 BM25 之后,效果还不如纯向量,先别急着换融合公式,建议做一次“单通道对比”:只用向量跑一遍 top 5 结果,再用纯 BM25 跑一遍 top 5 结果,看两边重叠度如何。
如果两边几乎完全重叠,说明你的资料本身语义和关键词高度一致,混合检索提升有限,这时不要强行加 BM25。如果两边重叠度低但融合后依然差,多半是 RRF 的 k 值太小,导致排名靠后的 BM25 结果获得了过高权重。k 值我实测在 40 到 80 之间比较稳,低于 30 容易引入噪声。
还有一类情况是:BM25 索引没有和版本治理联动,检索时捞进了大量 superseded 的内容。BM25 打分对高频词很敏感,旧版本的重复内容会把真正相关的块挤下去。我的解决方式是把 BM25 索引也按 status 过滤,或者干脆只对 active 块建索引。
6.4 引用编号解析失败或对应错乱
这是生成阶段最常见的问题。模型有时会输出“[1] 和 [2] 都提到”,有时会把引用编号放在句首,有时甚至用中文括号“(1)”。解析规则太死,这些情况就全废了。
我的解析策略是先归一化:把所有中文括号、全角方括号替换成半角[],然后匹配\[\d+\]。匹配完之后,再做一次校验:如果回答里出现了[n]但 n 超出了 sources 的数量,就把这个引用丢弃,并在 answer 里保留原文不做替换。宁可让引用缺失,也不能把一个错误的引用指向不相干的内容。
另外,Prompt 里可以用一个技巧降低解析失败率:明确要求“引用编号只能出现在句子末尾的句号之前,例如‘xxx 为 0.8 [1]。’”虽然模型不完全听话,但多少能改善一点。如果条件允许,也可以在生成后用一个小模型做“引用纠正”,但个人项目我觉得性价比不高。
6.5 知识库膨胀与备份策略
版本治理加上父子块冗余,数据量会膨胀得比想象中快。一份 10MB 的 PDF,切块后加上 Embedding 向量和反复的版本历史,轻松变成几百 MB 的 SQLite 文件。这个体量个人项目还能接受,但如果长期不清理,查询速度还是会明显下降。
我的处理方式是把 superseded 超过 3 个版本的旧块定期归档到单独的chunks_history表里,doc_versions 表保留元数据但不保留向量。查询历史版本时,如果发现向量没了,可以临时重新生成,或者直接提示用户“该版本已归档,无法直接检索”。这个折中方案既控制了主表体积,又不丢历史。
备份就简单了:SQLite 是单文件,我做了个定时任务,把数据库文件复制到另一个盘,再配一份同步到网盘。因为包含向量数据,文件比较大,我建议备份时做增量复制,而不是每次全量覆盖。我用的是 rsync + 文件锁,备份前先确认没有正在写入的进程,避免拷到一半的脏数据。
6.6 实操问题速查表
| 问题现象 | 可能原因 | 排查顺序 | 解决建议 |
|---|---|---|---|
| 更新后仍答旧内容 | active 过滤失效 | 查状态字段 → 查检索 SQL | 检索默认强制 active |
| 上下文混杂不同章节 | 父块切错边界 | 打印父块内容 → 查标题结构 | 结构优先切块,标题识别 |
| 加 BM25 后效果变差 | k 值太小或索引未过滤 | 单通道对比 → 查索引状态 | k 设 60,索引按 active 建 |
| 引用编号解析失败 | 输出格式混杂 | 看原始输出 → 检查正则 | 统一半角括号 + 容错解析 |
| 数据库增长过快 | 旧版本块未清理 | 统计版本数量 → 查归档情况 | 归档超 3 版旧块 |
| 检索结果飘忽不定 | 评测集缺失 | 建 30 条 QA 评测集 | 用 hit@5 做回归测试 |
做个人 RAG 知识库这段时间,我最大的体会是:这个东西难不在某一个单点技术,难在把版本、分块、检索、引用四件事串成一条稳定的流水线。任何一个环节松了,用户感受到的都是“这个知识库在胡说八道”。如果你一开始也想不清楚怎么做,我建议先用最小闭环跑通:SQLite 存文档和块、一个 Embedding 模型、一个 BM25、一个带引用的 Prompt,加起来可能就几百行代码。跑通了再逐步加上版本治理和父子分块,每加一环都用评测集做一下回归,你会发现整个系统的可靠性是层层叠出来的。
最后分享一个小技巧。我在做引用展示时,给每条来源都加了一个本地文件路径的超链接,点击后直接跳转到本机的 PDF 阅读器并翻到对应页码。这个设计一开始只是为了自己方便,后来发现它对知识库的“可信感”提升极大——因为答案不再是一个漂浮在聊天窗口里的文字,而是一个能直接带你回到原始文档的路径。如果你正在搭自己的知识库,强烈建议把这个细节加上,成本很低,体验飞跃。