「上传PDF,然后聊天」大概是过去一年里被问得最多的RAG需求。很多朋友跑通了Quickstart,建好了向量库,也把PDF塞了进去,结果问了几天就发现:回答越来越不对劲,改了第三版的文档,库里还留着第一版;问一个专有名词,答非所问;好不容易找到答案,翻遍全文都不知道它出自哪一页。
如果你也卡在这一步,说明你缺的不是一个能用的大模型聊天框,而是一个真正能用的个人RAG知识库。这篇文章不聊Demo级实现,只讲四件我认为决定RAG知识库「能不能长期用」的事:版本治理、父子分块、混合检索和可引用回答。适合已经跑通过基础RAG流程、想进一步把准确率和可用性做上去的读者。
1. 先搞清楚一个问题:为什么"上传PDF聊天"会越用越废
个人知识库和玩具项目最大的区别,不在技术栈,而在数据是会变的。你手上那份PDF不是静态教材,可能是上周还在改的方案、每季度更新的规范、或者自己整理的学习笔记。今天问了三个问题还正常,明天更新了一版文档,库里的旧内容和新内容混在一起,检索结果就开始飘。
我把这个阶段的典型症状归成三类,你可以对照一下:
| 症状 | 根因 | 对策 |
|---|---|---|
| 同问题换了几个说法,答案前后矛盾 | 库里存在多个版本的同一文档,语义检索同时命中 | 文档版本治理 |
| 问一句话里的某个专有名词,答非所问 | 语义向量检索对精确词匹配不敏感 | 混合检索(BM25 + 向量) |
| 答案看着对,但找不到出处页码 | 分块与生成过程丢失了原文位置信息 | 父子分块 + 溯源元数据记录 |
先说第一个问题背后的逻辑。做完embedding之后,一段文本变成了几百维向量,语义相近的内容在向量空间里靠在一起。这对「意思相同、说法不同」的场景极其友好,比如你问「怎么配置代理」,文档里写的是「设置转发规则」,它能match上。可它同时带来了一个副作用:向量空间里没有「版本」的概念。同一个PDF,第三版内容改了30%,向量化之后与旧版之间的语义距离可能仍然很近,甚至因为上下文重复而比正确答案更近,于是检索排序把旧版内容排到前面。
第二个问题,专有名词。你把一份技术规范转成向量,问「BGE-M3 模型的维度是多少」,模型找到的可能是一段「BGE系列模型支持多语言检索」的泛泛表述。因为向量检索的本质是「语义相似度」,它不理解「BGE-M3」是个需要精确匹配的实体。这种场景下,传统的关键词检索反而一击即中——这就是为什么后来几乎所有面向个人知识库的方案,都从纯向量检索退回到混合检索。
第三个问题最隐蔽,也最影响信任感。简单RAG里,检索命中的是一段截断过的文本,系统把这段文本喂给LLM生成回答,回答本身完全依赖LLM对这段文本的理解。可一旦检索粒度、分块结构安排得不合理,LLM拿到的上下文可能已经被截得七零八落,或者干脆把两段不相干的内容拼在一起,答案自然没法追溯到清晰出处。
所以,别急着继续优化prompt。先把数据层的这三个问题解决掉,后面所有事都会顺很多。
2. 版本治理:被绝大多数个人RAG忽略的"文档状态机"
先说一个反直觉的结论:个人知识库比企业知识库更需要版本治理。企业一般有规范的文档管理系统,上传文档的权限、审批、归档都相对可控。个人库恰恰是最随意的——你会反复改方案、导出新PDF、覆盖旧文件,然后无意识地把所有文件都扔进库里。
我在自己的知识库里维护了一张文档状态表,本质上就是一个状态机。每次文档入库,它必须经过这几个状态:
| 状态 | 含义 | 触发操作 |
|---|---|---|
| raw | 原始文件已接收,未经处理 | 文件落地,计算hash |
| cleaning | 正在做内容清洗与结构解析 | 提取文本、页眉页脚去除、目录识别 |
| chunking | 正在分块 | 按父块/子块策略切分 |
| indexed | 已向量化并写入检索索引 | 写入ES或向量库 |
| archived | 已被新版本替换 | 从活跃索引中移出,保留历史索引 |
| deleted | 手动删除 | 连同所有块和向量联动删除 |
不要小看这个状态表,它能帮你避免我看过最多的翻车现场:某天心血来潮重新解析了全部PDF,把数据库里的旧版本向量清掉,重新灌入新向量,结果之前所有测试都跟着变了。这不是升级,是回到起点。
具体操作上,我的流程是这样。
第一,入库时算一个文件指纹。用文件的sha256,加上文件名、文件大小和修改时间作为唯一标识。下次再扫描目录时,只有指纹变了才需要重新解析;指纹没变的直接跳过。这一步能省掉90%的重复计算。
第二,新版本入库,旧版本不是简单覆盖,而是标记为archived。我最初就是直接删旧向量,后来发现回答里偶尔会冒出旧版本的内容——因为索引删除和新增之间有时间差,检索恰好撞上。改成「先标记过期,再异步删除」之后,这个问题再没出现过。
# 文档入库时的指纹校验 import hashlib import os from pathlib import Path def file_fingerprint(path: Path): stat = os.stat(path) with open(path, "rb") as f: digest = hashlib.sha256(f.read(65536)).hexdigest() return { "sha256": digest, "size": stat.st_size, "mtime": stat.st_mtime, } # 入库前先比对指纹,如果一致则跳过 fp = file_fingerprint(Path("docs/v3.pdf")) if existing := db.get_doc_by_fp(fp): print("文档未变化,跳过:", path) else: # 新文档,走 raw -> cleaning -> chunking -> indexed ...数据库里再存一条current_version字段,检索时只查current_version = true的向量,这样即使旧数据暂时没删干净,也不会被命中。
第三,也是我一直强调的:联动删除。你删掉一份PDF,不光要删掉这份文档在主库里的原始记录,还要删掉它拆出来的所有父块、子块,以及这些块在向量库里的全部嵌入向量。漏删任何一块,它都会在检索时以「幽灵内容」的形式出现,最恶心的是你还找不到来源——因为原文件已经没了。
提示:建议不要把「删除」做成物理删除,而是先软删(标记deleted)再异步清理。原因很简单:个人资料库经常有误删的情况,软删给你留一条后悔的退路。
版本治理的最后一层,是自己的使用习惯。我的做法是每个季度做一次「版本收敛」:把明确过期的文档归档,把同一主题的多份PDF合并成一份精华版,再把旧文件从扫描目录里移走。这个习惯比任何代码都管用,因为个人知识库的混乱,90%来自「不断丢新文件进去,从来不清理」。
3. 父子分块:检索用小块,生成用大块
RAG领域有个共识:分块策略决定检索效果的上限。我见过太多项目,embedding模型换了一个又一个,检索召回率还是上不去,最后回头一看,问题出在分块上——每块512字符太短,语义被切得稀碎;每块4096字符太长,向量表示被稀释,检索精度掉得厉害。
父子分块(Parent Document Retrieval)的思路,是同时建立两个层级的文本块:
- 子块:用于检索的小块,通常几百字符,保证命中精度。
- 父块:用于生成回答的大块,通常一两千字符,保证上下文完整。
检索时用子块去和用户query算相关度,命中了之后,拿对应的父块去喂给LLM。也就是说,搜索的粒度小,生成的上下文大。这样既避开了小块上下文不足导致答非所问,又避免了整篇文档向量化后检索精度下降的老问题。
我在实践中用的参数是这样调的:
| 参数 | 我的默认值 | 说明 |
|---|---|---|
| 子块长度 | 256-512 token | 太小语义不全,太大精度下降 |
| 父块长度 | 1024-2048 token | 能覆盖完整段落,又不至于超窗口 |
| 子块重叠(overlap) | 64 token | 防止边界处语义被切断 |
| 子块-父块映射 | 一个子块对应一个父块 | 命中子块则携带父块进上下文 |
实际做的时候,每个子块除了自身的文本和向量,还要保存几个元数据字段:doc_id(文档ID)、doc_version(文档版本)、parent_chunk_id(父块的ID)、page_start/page_end(起始页码)。这些字段在后面做可引用回答时是刚需。
class SubChunk(BaseModel): chunk_id: str doc_id: str doc_version: str parent_chunk_id: str page_start: int page_end: int text: str class ParentChunk(BaseModel): parent_chunk_id: str doc_id: str doc_version: str text: str生成回答时,我拼接上下文的方式是:把命中的多个子块分别找到自己的parent_chunk_id,然后按父块去重(同一父块只保留一份),再拼进prompt里。这一步看着简单,代码里却很值得注意——不做父块去重的话,一次检索命中5个子块,可能其中3个属于同一个父块,上下文里就会重复出现一大段内容,既浪费token又把太多无关信息塞进去。
实际实现时还会遇到一个细节:子块和父块不是简单按长度切出来的,而是按语义单元切。比如一个markdown文档里,一个二级标题+若干段落就是一个自然父块;一个PDF里,一个"章节说明+正文若干页"也适合做父块。理想的切分器应该先识别标题层级,再按层级聚合,而不是单纯按字符数一刀切。
注意:如果文档是PDF,尤其要保留好页码信息。很多PDF解析库提取文本时可以按页分段,但跨页的段落会被切开。我的做法是先按页提取,再把跨页连续的文本合并成自然段落,最后按段落聚合父块。
父子分块带来的直接好处是:检索命中的内容比之前「准」了。我自己的经验是,纯小块方案下命中率大概在60%左右,切换到父子分块后,问题命中率提升到85%以上,生成回答时上下文也明显更连贯了。
4. 混合检索:向量负责找意思,BM25 负责找名字
聊到检索,很多人默认"RAG = 向量检索"。这不怪你,因为一开始的RAG教程全是这么教的。但真实世界里,只有向量检索的RAG知识库,在个人文档场景下会废掉。
为什么?向量检索擅长理解语义,但它对「精确字符串」不敏感。个人知识库里有大量场景是精确匹配:文件名、编号、型号、版本号、人名、缩写词、路径。这些词如果在embedding时被当成普通语义token处理,检索时极容易找不到。
混合检索的思路很直接:向量检索(dense retrieval)和关键词检索(sparse retrieval)同时跑,然后融合排序结果。我把这个组合叫做「向量找意思,BM25找名字」。
- 向量检索负责把「我记不太清怎么表达,但大意是……」的问题召回。
- BM25负责把「我记得有一个词叫xxx」的问题精确命中。
具体落地时,我有两种方式,看你的基础设施:
方式一:Elasticsearch(或OpenSearch)一个索引搞定
ES本身支持BM25全文检索,同时它的kNN插件也支持向量检索。查询时写一个bool查询,把两个子查询组合起来,再用RRF公式做融合。
{ "query": { "rrf": { "queries": [ { "match": { "title": { "query": "BGE-M3 维度", "boost": 1.0 } } }, { "knn": { "vector_field": { "vector": [0.1, 0.2, ...], "k": 10, "num_candidates": 50 } } } ], "rank_constant": 60 } } }RRF(Reciprocal Rank Fusion)公式不复杂:对每个文档,计算它在多个检索结果中排名的倒数之和,score = Σ 1/(k + rank_i),k通常取60。这样即使一个文档只在其中一个检索里排名很高,也能被带起来。
实际操作:如果你不想单独搭ES,pgvector配合PostgreSQL的全文检索(ts_vector)也能实现类似效果,但性能和调参空间会差一些。个人知识库文档量在几千篇以内,这条路线完全够用。
方式二:向量库 + 独立关键词检索
用FAISS/Qdrant做向量存储,同时用SQLite FTS5或者Meilisearch做关键词索引。查询时两边各自取Top N,然后用RRF在应用层融合,再取融合后的Top K。
两种方式我都跑过,ES方案更省事,调优空间也多;应用层融合方案更灵活,适合你已经在用自研pipeline的场景。
混合检索解决的是「召回」问题,但别忘了还有「重排」(rerank)。这也是搜索质量里最容易被低估的一环。粗排(召回)从全库里拉出候选,重排再对这些候选做精细排序。很多项目召回做得不错,但因为没做重排,候选里明明有正确答案却排在后面,LLM压根没看到。
我做重排用的是交叉编码器(cross-encoder),最常用的就是bge-reranker-base。它会同时把query和候选文档传给模型,计算一个更精细的相关性分数,再按分数从高到低排序。这一步通常能把top5的准确率再提升10-20个百分点。
重排跟混合检索的关系是:混合检索负责把不同类型的内容都捞回来,重排负责在这些「捞回来的」内容里挑出真正最相关的。没有重排的RAG,等于靠一个粗筛工直接给LLM喂饭,偶尔会嚼到石头。
| 层级 | 技术 | 目标 | 典型Top N |
|---|---|---|---|
| 召回 | BM25 + 向量 | 从全库找到候选文档 | 50-100 |
| 粗排 | 融合排序 | 从候选中选出一批高相关文档 | 20-30 |
| 精排 | cross-encoder重排 | 选出最终喂给LLM的上下文 | 5-8 |
如果你使用的是云端大模型API,重排环节建议放在本地做,原因很简单:本地跑一次bge-reranker只要几十毫秒,比多请求一轮API快得多,而且重排结果更可控。
5. 可引用回答:把出处和版本焊死在答案上
很多人以为RAG做到「回答正确」就是终点,但我自己的经验是:回答正确如果给不出位置信息,本质上和猜没什么区别。尤其是知识库里的文档来源复杂时,回答「对的」容易,回答「被引用的对」才难。
可引用回答的实现,核心在于把「溯源」两个字贯穿始终。我把它分成三件事:
第一,检索结果必须带足溯源元数据。在父子分块阶段,每个子块和父块都保存了doc_id、doc_version、page_start、page_end。检索到候选块之后,对应的doc_id和页码信息要跟着一起进prompt。这一步要提前规划,因为一旦检索时丢掉了元数据,后面再想找回就难了。
第二,生成时拼接带编号的上下文。我用Prompt模板把检索到的内容编码成带编号的文本块,再让LLM回答时标注编号:
CONTEXT_PROMPT = """ # 文档列表 {context_with_ids} # 要求 请仅根据上文信息回答问题。回答时在相关句末使用方括号标注来源编号, 例如:[1](第5页)表示该信息来自文档1的第5页。 如果你不确定,请明确回答“资料中未找到相关信息”。 用户问题:{question} """实际返回时,LLM给出的回答就长这样:
关于BGE-M3的向量维度,官方文档给出的是1024维[1](第8页)。 该模型同时支持稠密检索与稀疏检索,可在大多数RAG框架中直接使用[1](第9页)。这里有一点值得注意:引用编号的准确性,取决于进prompt的上下文质量。如果混合检索和重排工作没做好,context里塞了5份文档,其实只有第2份和第3份跟问题相关,那LLM即便标了[1][4],那也是「看着有出处、实际是幻觉」。所以溯源是系统全局的工程,不是prompt层能兜底的。
第三,处理"资料中没有"的情况。这个兜底逻辑我必须在prompt里明确写,因为LLM面对一个确实没有答案的问题时,默认行为是编造一个合理答案。加上这条之后,它至少会在不确定时诚实回答,而不是假装知道。
我遇到的另一个常见需求是:如果库里同一个主题有多个版本,引用时希望能显示版本。做法是把doc_version也渲染进上下文:
[3] 《XX方案》v3.2,第10页 [7]《XX方案》v2.0,第4页这样回答如果引用了旧版本的内容,读者一眼就能看出「这条信息是旧版」,而不是被当成最新结论。
提示:如果检索到的内容来自archived状态的历史版本,我建议在prompt里额外加一条规则——默认优先使用当前版本,除非当前版本确实没有相关信息,才允许参考历史版本,并在回答中标注「历史版本v2.0」。这条规则能极大减少新旧版本混答的问题。
给LLM留出诚实退路的最后一块拼图,是置信度阈值。我的做法是计算重排后第一名的得分,如果低于一个阈值,干脆不调用LLM,直接返回「知识库中未找到相关内容」。阈值需要根据你的语料调,我这边一般取0.3-0.5,你可以先用一批测试问题试出来。
6. 几点不成熟的心得
这套体系我前后迭代了快两个月才稳定下来。让我试着用一句话串起来:版本治理决定知识库不被污染,父子分块决定检索精度,混合检索决定召回率,可引用回答决定信任感。四者缺一个,知识库都会在长期使用中慢慢变成「一间堆满书但找不到书的房间」。
给想动手的同学一个起始路径:
- 先把文档入库流程加上指纹校验和版本状态字段,这是后面所有优化的地基。
- 再改分块逻辑,从「一刀切」升级成父子分块,观察命中率和上下文完整度。
- 然后加BM25,做混合检索,先跑通RRF,再调重排。
- 最后把溯源元数据带进prompt,实现带编号的可引用回答。
每一步改动都能独立验证效果。我个人实测下来,版本治理做得越早,后面返工越少;父子分块给人的惊喜最大,一次改动就能让不少「答非所问」变成「直接命中」;混合检索和重排则能把准确率再推一个台阶。
最后分享一个小习惯:每周花十分钟,把扫描目录里新增的文档批量走一遍入库流程,顺手归档一批过期文件。别小看这个动作,它跟写那些代码一样重要——知识库的长期可用性,从来不是靠一次完美搭建,而是靠日复一日的维护习惯。