RAG 落地做了几个月,我最大的感受是:demo 跑通和业务能跑是两个物种。尤其是当你搭建了一个看似完善的知识库问答系统,领导随手问一个具体数字,模型却一本正经地给出了一个原文里根本不存在的答案——那一刻你才知道,问题往往不在生成模型,而在检索侧根本没有把证据捞对。
我们团队在三个真实项目里反复迭代了分块、召回、重排的策略,有制度问答、产品手册问答,也有代码库问答。这篇文章就是把落地过程中沉淀下来的 6 个实战结论写出来。不聊太虚的原理,只讲改了什么、效果涨了多少、在什么条件下会失效。如果你正在做企业内部知识库、长文档问答,或者本地 RAG 方案,这里面的经验大概率能帮你少走几轮弯路。
1. 项目背景:先定位瓶颈,再谈分块、召回、重排
1.1 初版系统的失败方式
我们接到的项目分三类:制度文本问答、产品手册 QA、代码知识问答。第一版流程很标准:解析文档为纯文本,按固定字符大小分块,向量化后放入向量库,问问题时取 top 5 相似块,直接塞给大模型生成回答。
表面上看链路完整,实际上问题处处都在。举三个当初印象很深的例子:
- 制度问答里,用户问“年假和病假累计规则”,两个条款分别在不同章节,固定分块把一个章节从中间切开,召回时只拿回了其中一半。模型看到半截条款,只能靠“脑补”把答案补完整,补出来的自然是编的。
- 产品手册里大量表格,固定分块算法把表格从中间断开,检索出来的块只有半张表,数字对不上几乎是必然的。
- 代码问答里,接口文档和实现代码混杂,固定分块把函数签名和函数实现拆成了两块。用户问某个接口参数怎么传,召回结果里根本没有函数签名。
归结起来就是同一个病根:信息单元被切碎了。大模型没见过完整的信息单元,那么无论生成能力多强,都难以还原真实答案。
1.2 用命中率把“效果差”变成可优化指标
那段时间我们反复调整模型、改提示词模板,投入大,提升小。直到有一天我们决定不看生成结果,回头专门检验检索链路本身,才有了转机。
具体做法很简单:
- 挑选 50 个真实业务问题;
- 对每个问题,人工标注出标准答案所在的 chunk 编号;
- 固定检索参数,看 top 5 结果里是否包含了目标 chunk,统计命中比例。
这个指标就是常说的 hit rate。我们第一版系统的 hit rate 只有 52%。换句话说,用户问 10 个问题,有一半的情况下正确答案所在的段落根本不在这 5 个候选里。生成模型在剩下一半的候选里翻来翻去,能答对才怪。
后来我们把 hit rate 从 52% 提升到 82%,最终问答质量肉眼可见地上了一个台阶。所以第一个建议是:做 RAG 优化,不要先盯着模型答案,先建立一套带人工标注的检索评估集,把问题量化。
1.3 三个环节的链条顺序
分块、召回、重排,这三个环节不是孤立的,而是漏斗关系。分块决定信息的边界,召回决定候选集合的覆盖度,重排决定最终进入生成上下文的段落。
我的理解是:分块是一条链路的天花板。如果分块已经把一个表格从中间切断,那么后面召回和重排做得再好,也很难拼回一个完整的表。只有分块把语义单元完整地保留下来,召回和重排的优化才有意义。所以接下来的顺序就是先分块,再召回,最后重排。
2. 分块这一步,决定了后面所有环节的天花板
2.1 固定大小分块为什么容易挂
早期的做法非常简单,就是按字符数或 token 数硬切,比如每块 500 个字符,相邻之间重叠 100 个字符。这种做法看着客观,其实忽略了语义边界。
法律文本里的例外条款、表格里的一行、代码里的一个函数,都可能横跨好几个固定分块。最典型的场景是检索回来的块末尾是半句话——大模型看到半句话,会下意识地补齐,你在生成的文字层面很难察觉,但答案已经偏了。
合理分块思路其实是在三个约束条件里找平衡:
- 语义边界完整,尽量保持段落、条款、函数不被切开;
- 长度不能太长,否则向量表征会被局部关键词稀释;
- 长度也不能太小,否则上下文不足,模型无法理解概念。
单纯按字符去切,本质上是在赌“语义边界恰好落在某个字符位置”,这个概率并不高。
2.2 重叠设多少合适:10%-20% 的经验值
固定分块方案里有一个 overlap 参数,用来缓解边界截断问题。我们一开始用 chunk_size=500、overlap=100,相当于 20% 的重叠,效果中等。后来调成 overlap=50,也就是 10% 重叠,准确率和索引体积之间取得了更好的平衡。
重叠的作用是给边界两侧保留线索。比如一个操作步骤的最后一个关键词正好落在块 A 的结尾、块 B 的开头,如果两块完全不重叠,那关键词的上下文就被拆散了。有 10%-20% 的重叠,能明显减少这种边界损失。
但重叠并不是越多越好。我见过有人把 overlap 调到 30%、40%,结果索引体积膨胀明显,而且向量库里出现了大量高度相似的重复块。召回的时候,top 5 里可能出现 3 块内容几乎一样,等于候选集合的有效覆盖度反而降低了,后续重排的去重压力也更大。
2.3 结构化分块:跟着文档骨骼走
真正做到生产环境后,我强烈建议放弃“纯字符切分”,改用“结构感知切分”。几乎所有真实文档都有骨架,Markdown 的标题层级、PDF 里的目录和章节标题、表格的行列结构、代码里的函数和类,这些本身就是天然的语义边界。
我们调整后的策略是这样的:
- Heading 型文档:先用标题层级做递归切分,把每个二级标题下的内容作为一个大块;如果这个块还是太长,再按段落做二级切分,而不是硬性按字符数。
- 表格型文档:按行切成“表头 + 数据行”的记录块,或者把每个表格块连同表头一起打包,保证检索回来的表格是完整的。
- 代码型文档:按函数、类、import 依赖切块,函数体尽量带上前面的注释和调用关系,这样检索一个接口用法时,能看到完整签名。
举一个代码问答场景的例子:之前我们用固定 800 字符的 chunk,一个 50 行的函数被拆成 3 块,检索经常只拿到中间那段,缺了函数签名。换成按函数边界切块后,每个函数作为一个独立 chunk,接口类问题的回答准确率瞬间改观——因为模型看到了完整的“输入-输出-实现”,而不是拼凑出来的片段。
3. 召回:纯向量只是 demo 级别的基线
3.1 为什么纯向量搜索会漏掉“显而易见的答案”
很多 RAG 项目的第一步是“向量化 + 相似度检索”,我一开始也这么干,但实测下来,纯向量检索在不少场景里就是个花架子。
向量检索擅长语义相似,比如“怎么请假”和“请假流程”这类说法不同但意思相近的表达。但它在精确匹配上很弱:数字、错误码、版本号、商品型号、人名、缩写,这些信息在向量空间里的距离表达并不一定比关键词更准。
最典型的还有否定句。文档里写“不支持远程调用”,你问“支持远程调用吗”,向量语义上两句话高度相关,模型很容易把否定当成肯定来理解。而关键词检索至少会把这个高度相关的文本块原本地返回,让重排阶段再做精细判断。
在我们的代码问答和工程手册场景里,纯向量检索的 hit rate 比混合检索平均低 12-18 个百分点。这说明单靠语义向量,很难覆盖真实的业务查询。
3.2 BM25 + 向量混合:融合方式可以很简单
既然纯向量不够,我们的做法是引入稀疏检索,也就是传统的 BM25。它不依赖语义空间,直接按关键词出现情况打分,对精确名词和编号类问题极其有效。
流程就是两条路并行:向量检索取 top N,BM25 取 top N,然后把两个结果做融合。我用的融合算法是 RFF 变体,也就是倒数排名融合。核心逻辑是:同时被两种方案命中的文档,融合得分天然高于只被一种方案命中的文档。
def rff_fusion(sparse_results, dense_results, k=60): scores = {} for rank, doc_id in enumerate(sparse_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(dense_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return dict(sorted(scores.items(), key=lambda x: x[1], reverse=True))这个实现简单到不能再简单,不需要训练,不需要调复杂的权重。实测下来,混合检索的 hit rate 比纯向量高 10-15 个百分点,代价只是多维护一个倒排索引、多一次查询,工程成本可以接受。
3.3 元数据过滤:投入产出比最高的召回优化
这是我最想强调的一点,也是最容易被忽略的一点。很多 RAG 项目把文档一股脑塞进一个 collection,没有字段区分,结果用户问“2023 年的版本号”,系统把 2020 年的旧手册也召回来了,而且因为语义相近,排名还挺高。
我们在制度问答和产品手册场景里给每个 chunk 打了元数据标签,包括文档类型、日期、版本、章节路径、权限级别,然后查询时先做过滤器再检索。
实际操作中有两类做法:
- 在向量库层面用 metadata filter,比如只查 doc_type = "policy" 且 year = 2023;
- 在应用层面解析 query,抽取年份、部门、产品线,转成过滤条件。
我们初期吃了不少亏,因为过滤条件写得不够仔细,比如忘了限制版本,导致旧版本文档被召回,hit rate 直接掉了 15%。后来把过滤条件做细,干扰项少了约 30%,业务满意度明显提升。元数据过滤往往比换一个 embedding 模型带来的提升更大,而且代价极低。
4. 重排:把 top 20 变成 top 3 的决定性一环
4.1 双编码器和交叉编码器的区别
向量召回用的 embedding 模型是双编码器,query 和 document 分别过一遍模型,各自映射成一个向量,再算余弦相似度。这种架构快,但代价是 query 和 document 之间没有交互,很多细微的相关性判断会被几何距离吞噬。
重排阶段用的是交叉编码器,把 query 和 document 拼在一起进入同一个 Transformer,最后输出一个相关分。计算量比双编码器大得多,但相关性判断的准确性也高得多。
用生活化类比来说:双编码器像相亲平台只看双方资料卡打分,交叉编码器是一对一深聊之后再做判断。后者费时间,但判断更靠谱。
4.2 实测重排流程与效果
我们最终跑的流程是:混合检索产出 top 20 候选,喂给交叉编码器逐一打分,再取 top 3 进入生成上下文。候选不到 20 个的时候,重排延迟在 200 毫秒级别,可以接受。
在合同问答数据集上,我们有意对比过几个配置:
| 配置 | top 3 命中率 |
|---|---|
| 纯向量直接取 top 3 | 0.66 |
| 混合检索后直接取 top 3 | 0.74 |
| 混合检索 + 重排再取 top 3 | 0.83 |
这组数据很直观:重排带来的提升约 9 个百分点,相对提升接近 12%。这个数字可能因场景而异,但在我们做过的几个项目里,重排几乎一定是 hit rate 提升最大的单点杠杆。
当然,重排有一个前提:候选集合里得真的有正确答案。如果 top 20 里根本没有目标 chunk,重排再怎么打分也救不回来,所以重排永远是在前面环节之上的增强。
4.3 重排实施中的几个坑
- 候选数别设太小。我们一开始重排只取 top 3,后来改成混合检索 top 20 再重排到 top 3,效果差异非常大。候选数太小会让正确结果根本没机会进入后置环节。
- 记得先做去重。如果同一来源的多个 chunk 内容近似,会同时出现在候选里,重排分数可能分散在近似内容上。我们在进入重排前做了去重,同一个章节最多保留 2 条候选,最终进入生成上下文的文本多样性更好。
- 重排 score 不等于事实正确性。交叉编码器输出的是相关性,不是“这句话是否真实”。如果模型生成了事实错误,还是要靠文档内容校验,不能指望重排自动纠正。
- 延迟与效果的平衡要看场景。在线问答对延迟敏感,候选数量可以从 top 30 降回 top 20;离线或者半离线场景则可以放宽到 top 50,让重排慢慢挑。
5. 六个实战结论:先记住结论再动手
这篇文章标题叫“6 个实战结论”,这里集中列一下,方便你直接对照。
| 结论 | 一句话 | 对应环节 |
|---|---|---|
| 1 | 固定大小分块必须是最后的选择,优先结构化分块 | 分块 |
| 2 | 重叠设 10%-20%,不要贪多 | 分块 |
| 3 | 混合检索是基线,纯向量只配做原型 | 召回 |
| 4 | 元数据过滤往往比换 embedding 模型更有效 | 召回 |
| 5 | 重排是 hit rate 提升最大的单点杠杆 | 重排 |
| 6 | hit rate 和用户满意度不完全相等,需要贴近业务评估集 | 评估 |
逐条展开说一下。
第一,结构化分块降低的是整个工程链路的复杂度。模型不用再靠猜,检索也能更精准,重排时也不用面对碎片化的候选内容。你只是跑 demo,怎么切都行;一旦要上生产,先把分块切对,再优化其他环节。
第二,重叠不是越大越好。在 1 万个 chunk 的基础量上,把重叠从 10% 调到 30%,索引规模可能膨胀上千条,重排阶段还会反复看到近似内容。我们最终把重叠控制在块长的 10%-20%,收益稳定且资源开销可控。
第三,纯向量检索的定位应该是快速验证。正式业务环境里,强烈建议加一路 BM25 或者别的稀疏检索。RFF 融合实现简单,又能同时兼顾语义相关和字面匹配,性价比很高。
第四,元数据过滤是很多人忽略的优化手段。把 query 中抽出的部门、时间、文档类型作为预筛条件,等于告诉检索器“别去无关空间里瞎找”。这个操作对 hit rate 的提升很直接,而且不会给系统增加推理负担。
第五,重排模型直接使用开源的 cross encoder 就行,完全不需要自己训练。类似 bge-reranker 这类的模型可以直接接入,配合混合检索输出 top 20 再重排到 top 3,是比较通用且效果稳定的方案。
第六,最重要的一点:真实业务场景里,hit rate 提升也不代表用户满意度一定提升。你需要按业务分段建立各自的评估集,定义 hit rate、MRR 等指标的期望值,持续回放对比。否则你只是在优化一个“测试集分数”,很可能偏离用户真正的问题分布。
6. 本地 RAG 工具链与工程化的细节
6.1 基于 Ollama 的本地部署经验
有些数据不允许出内网,我们就在内网搭过基于 Ollama 的本地 RAG 方案。整体流程不算复杂:拉取模型、启动服务、加载 embedding 模型、建立索引、起一个轻量 API 处理查询。
但有两个细节值得记录。第一,embedding 模型要选对。多语言和领域词典的覆盖度,直接决定了中文环境下命名实体和数字相关的检索效果。第二,Ollama 默认的上下文窗口不一定够用,需要在模型文件里显式设置 num_ctx,否则输入超长会被静默截断。
类似这样的配置片段可以用:
FROM llama3 PARAMETER num_ctx 8192 PARAMETER temperature 0.2真实项目中,本地 RAG 最容易出问题的不是模型能力,而是配置碎片化。模型版本、模型文件路径、向量库版本这些都需要强制记录,方便复现。我们曾因为一台机器上 Ollama 缓存了旧版模型,导致本地结果和其他环境对不上,排查了很久。
6.2 框架的边界:LangChain 和 LangChain4j
工具框架方面,LangChain 和 LangChain4j 都只是工具链,不是方案本身。LangChain4j 在 Java 生态里用得比较多,封装了向量存储、retriever 等接口,方便集成。
但我个人的实际感受是,框架默认的 RetrievalQA 把分块策略和调用细节藏在内部,一旦出了问题,排查链路会很痛苦。所以用框架时,我会建议把核心链路自己掌握:分块代码自己写,检索交互可以走框架的 API,但日志一定从自己的流程里打印出来,而不是依赖框架内部的隐式行为。
6.3 可观测性:把每次请求的证据链记录下来
这是我认为 RAG 工程化中最容易被低估的事情。模型答错了,最可怕的不是答错本身,而是你不知道它到底看了哪段文本做出这个判断。
我们后来给系统加了完整日志,每条请求记录四样东西:query、top 20 候选 ID、重排后的 top 3 列表、最终生成结果。有了这份日志,用户反馈某个问题答错时,几分钟内就能定位是检索没命中,还是生成阶段把内容改写了。
有一段时间我们被一连串“简单问题答错”困扰,日志一查才发现,元数据过滤条件被 query 解析到了错误的部门,也就是过滤条件本身错了,而不是检索模型不行。这类的坑,没有日志几乎没法排。
6.4 GraphRAG 与本体 RAG 什么时候值得尝试
GraphRAG 和本体 RAG 最近讨论很多。从一个落地执行者的角度看,如果只是单文档问答,知识图谱和本体带来的提升有限,但维护成本明显更高。文档里本来就是一个章节一个主题,线性 RAG 足够覆盖。
真正适合 GraphRAG 的场景是跨多文档、需要回答实体之间的关系、或者做全库层面的总结。这时传统 RAG 确实容易“只见树木不见森林”,GraphRAG 的全局能力能补上这块短板。
但代价很现实:建图耗时、查询链路复杂、知识更新时要定期同步图谱。我们在一个中型文档库上试过一次,把所有实体都展开建图,结果后续文档一变,同步图的时间比问答收益还多。Ontology RAG 更偏向一个领域建模方案,预先定义概念和关系,让召回结果不偏离领域框架。如果知识点之间是靠关系连接而不是靠相似度连接,确实值得试一下。
我的建议是:默认基线继续用“结构化分块 + 混合检索 + 重排”,只有当你在测试集上明确证明 GraphRAG 或本体方案带来额外收益时才引入,不要为了追热词而增加复杂度。
最后分享一个小习惯:当评估集命中率卡住不再提升时,不要盲目调参,先回头翻那 10% 的失败样本。绝大多数情况,你会在分块结果里发现证据被切没了,或者某个元数据过滤条件挡错了路。把失败样本倒回分块阶段看一眼,往往比重新测十组参数更有用。这也是我把分块放在全文最前面讲的原因——它虽然不起眼,却是整个 RAG 系统真正的起点。