1. 从一次翻车现场说起:RAG 到底难在哪
去年秋天我接手了一个企业知识库项目,文档量大概四万份,格式从 PDF、Word 到 Confluence 导出的 HTML 都有。当时团队里所有人都觉得这事不难——LangChain 或者 Spring AI 的 RAG 模板一套,向量库一接,大模型一挂,demo 当天就跑通了。演示的时候老板问了一个问题,系统答得头头是道,大家都很满意。
上线第三天,客服部门反馈说问“差旅报销标准是多少”,系统给出的答案是三年前已经废止的旧版本。又过了两天,技术部门问“接口超时重试策略怎么配”,系统把五份不同产品的文档混在一起拼了个四不像的答案。最离谱的一次,有人问“年假怎么算”,它引用了一份员工手册里关于“年假申请流程”的段落,但完全没提计算规则——因为计算规则在另一份文档里,而分块的时候正好被切断了。
这就是 RAG 落地最真实的模样:demo 十分钟,上线三个月。问题从来不在“能不能跑通”,而在“跑通之后能不能用”。分块策略、召回质量、重排逻辑,这三个环节里任何一个偷懒,最后都会以“答非所问”的形式暴露在用户面前。
我把这几个月踩过的坑和调优过程整理成六条实战结论,每一条都对应一个具体的决策点和背后的取舍逻辑。如果你正在做 RAG 项目,或者准备把现有的原型推向生产环境,这些经验应该能帮你少走一些弯路。文章会涉及 embedding 模型选型、分块参数计算、召回策略对比、重排模型部署等具体操作,也会给出可以直接参考的配置和代码片段。
2. 分块不是切菜:策略选择与参数计算
2.1 为什么固定长度分块几乎总是错的
刚开始做的时候,我用的是最朴素的办法:按 512 个 token 切,重叠 50 个 token。这个方案在技术文档上勉强能用,但遇到结构化内容就完蛋。比如一份产品规格书,表格里的参数和表头被切到两个块里,召回的时候只拿到半张表,模型只能瞎猜。再比如法律条款,一条完整的免责声明被从中间切开,前半段说“本公司不承担以下责任”,后半段列了五种情形,结果只召回了后半段,意思完全反了。
固定长度分块的根本问题在于:它假设文本的语义边界和 token 数量边界是一致的。但真实文档里,一个完整的语义单元可能只有 30 个 token(比如一个术语定义),也可能有 800 个 token(比如一段完整的操作步骤)。用同一个长度去切所有内容,必然会在某些地方切断语义。
我后来换成了递归字符分块,按优先级依次尝试用段落、换行、句号、逗号来切分。LangChain 的RecursiveCharacterTextSplitter就是这个思路,但默认参数需要根据你的文档类型调整。对于中文技术文档,我把分隔符优先级设成了["\n\n", "\n", "。", ";", ","],块大小设成 400,重叠 80。这个 400 不是拍脑袋来的——我统计了文档里所有完整段落的 token 分布,中位数在 380 左右,取 400 能覆盖大部分自然段落。
2.2 语义分块的实际效果与代价
递归分块解决了大部分问题,但还有一类场景搞不定:跨段落的逻辑关联。比如一份故障排查指南,第一段描述现象,第二段分析原因,第三段给出解决方案。按段落切分后,这三段变成了三个独立的块,召回时可能只命中其中一段,模型就缺少了完整的上下文。
这时候可以考虑语义分块。基本思路是先用 embedding 模型把每个句子向量化,然后计算相邻句子的余弦相似度,在相似度骤降的地方切分。我用的阈值是 0.75——低于这个值就认为语义发生了转折。实测下来,语义分块在叙述性文档上效果很好,能把完整的因果链保留在同一个块里。
但代价也很明显。第一是计算成本,四万份文档全部做句子级 embedding,用 text-embedding-3-small 跑了一遍,花了将近四十美元。第二是分块数量不稳定,有的块只有两句话,有的块有十几句,导致后续召回时长度差异很大。第三是对于列表型内容(比如参数表、步骤清单),语义相似度一直很高,反而不会切分,最后得到一个超长的块。
我的折中方案是混合策略:先用递归分块做粗切,然后对每个块做一次语义完整性检查。具体做法是计算块内首句和末句的 embedding 相似度,如果低于 0.6,说明这个块可能跨越了语义边界,就把它拆成两个。这个检查的成本很低,因为只需要对边界句子做 embedding,不需要全量计算。
2.3 分块参数的计算过程与实测数据
块大小和重叠长度这两个参数,我前前后后调了十几轮。下面这张表是不同参数组合在测试集上的召回命中率对比,测试集包含 200 个问题,每个问题都有标注的标准答案所在文档。
| 块大小 | 重叠长度 | 召回命中率 | 平均块数量 | 备注 |
|---|---|---|---|---|
| 256 | 32 | 61% | 18700 | 块太碎,上下文丢失严重 |
| 512 | 50 | 74% | 9400 | 基线方案,表格和条款容易切断 |
| 400 | 80 | 82% | 12100 | 中文技术文档的最佳平衡点 |
| 600 | 100 | 76% | 8100 | 块太大,噪声增多 |
| 800 | 150 | 68% | 6200 | 召回精度明显下降 |
最终我选了 400/80 这组参数。这里有个细节:重叠长度不是越大越好。我试过把重叠加到 150,召回命中率反而降了 3 个百分点。原因是重叠部分会产生大量近似重复的块,在向量空间里形成密集区域,导致检索时容易命中这些“冗余块”而不是真正相关的块。
另外,不同文档类型应该用不同的参数。产品手册和 API 文档适合 300/60,因为内容密度高、术语多;培训材料和操作指南适合 500/100,因为需要更完整的上下文。我在系统里做了一个文档类型识别,根据文件路径和元数据自动选择分块参数,这个改动让整体命中率又提升了 5 个百分点。
注意:分块之后一定要做一次人工抽检。我随机抽了 50 个块逐条阅读,发现大约 8% 的块存在语义不完整的问题。这个比例在可接受范围内,但如果超过 15%,说明分块策略需要重新调整。
3. 召回环节的取舍:向量、关键词还是混合
3.1 纯向量召回的三个致命盲区
向量召回是 RAG 的默认方案,但它有三个场景几乎必然失效。
第一个是精确匹配场景。用户问“错误码 E5023 是什么意思”,向量模型会把 E5023 编码成一个语义向量,但错误码本身没有语义,它就是一个标识符。结果就是召回了一堆包含其他错误码的文档,唯独漏掉了 E5023 的那份。我试过用 text-embedding-3-large,在这个场景下的命中率只有 43%。
第二个是长尾术语。公司内部有很多自研系统的代号,比如“天枢平台”“玄武网关”,这些词在通用 embedding 模型的训练数据里根本不存在,编码出来的向量是随机的。用户搜“天枢平台的鉴权方式”,向量召回完全找不到相关文档。
第三个是否定和排除条件。用户问“哪些接口不需要鉴权”,向量模型会把“不需要鉴权”和“需要鉴权”编码成非常相似的向量,因为它们在语义上高度相关。结果就是召回了一堆讲鉴权流程的文档,但用户要的是例外清单。
3.2 混合召回的实现与权重调优
解决这三个盲区最直接的办法是引入关键词召回。我用的是 BM25 算法,在 Elasticsearch 里建了一个倒排索引,和向量库并行检索。BM25 对精确匹配和长尾术语的效果非常好,E5023 这个场景下命中率直接拉到 91%。
但 BM25 也有自己的问题:它不理解同义词。用户搜“登录失败”,文档里写的是“认证不通过”,BM25 就匹配不上。所以最终方案是混合召回,把向量得分和 BM25 得分加权融合。
权重的确定过程是这样的:我先用网格搜索跑了一遍,向量权重从 0.3 到 0.9,步长 0.1,BM25 权重取补数。测试集上的结果如下:
| 向量权重 | BM25 权重 | 命中率 | 适用场景 |
|---|---|---|---|
| 0.9 | 0.1 | 71% | 语义问答为主 |
| 0.7 | 0.3 | 83% | 通用场景最佳 |
| 0.5 | 0.5 | 79% | 术语密集场景 |
| 0.3 | 0.7 | 68% | 精确检索为主 |
最终我选了 0.7/0.3 这组。但这里有个坑:向量得分和 BM25 得分的量纲不一样。向量相似度通常在 0.6 到 0.95 之间,而 BM25 得分可能从 0 到 30 不等。直接加权会导致 BM25 主导结果。我的做法是先对两组得分分别做 min-max 归一化,然后再加权。归一化之后,0.7/0.3 的权重才真正生效。
3.3 召回数量与上下文窗口的平衡
召回多少个块合适?这个问题我纠结了很久。召回太少,可能漏掉关键信息;召回太多,上下文窗口塞不下,而且噪声会干扰模型判断。
我的做法是分两步:先召回 top-50,然后用重排模型精排到 top-5。为什么是 50 而不是 20 或 100?因为我统计了测试集里标准答案在召回列表中的排名分布。结果显示,92% 的标准答案出现在前 50 名里,而前 20 名只覆盖了 78%。从 50 增加到 100,覆盖率只提升了 3 个百分点,但检索延迟翻了一倍。
top-5 的选择依据是上下文窗口。我用的模型上下文是 8K token,每个块平均 400 token,5 个块就是 2000 token,加上系统提示词和用户问题,总共不到 3000 token,留足了生成空间。如果块更大或者需要更多上下文,可以放宽到 top-8,但超过 8 个块之后,模型对中间位置的块注意力明显下降,这是“迷失在中间”现象的典型表现。
实操心得:召回阶段不要过早做截断。我见过有些方案在向量检索时就设 top-10,然后直接送给模型。这样做的问题是,重排模型没有足够的候选集来发挥价值。正确的做法是召回阶段放宽到 50 甚至 100,把精排的压力交给重排模型。
4. 重排模型:从锦上添花到不可或缺
4.1 为什么需要重排
向量召回和 BM25 都是基于“相似度”的检索,它们衡量的是查询和文档在向量空间或词频空间上的接近程度。但“相似”不等于“相关”。用户问“如何重置密码”,一篇讲“密码强度要求”的文档在向量空间里和问题的距离可能很近,但它并不是用户想要的答案。
重排模型的作用就是引入一个更精细的相关性判断。它通常是一个交叉编码器,把查询和每个候选文档拼接在一起输入模型,输出一个相关性分数。因为查询和文档在模型内部做了充分的交互,所以判断精度远高于双编码器的向量召回。
我用的重排模型是 bge-reranker-v2-m3,部署在一张 T4 显卡上,延迟大概 80 毫秒处理 50 个候选块。这个延迟在可接受范围内,因为向量召回本身也要 50 到 100 毫秒。
4.2 重排前后的效果对比
下面这组数据来自我的测试集,对比了不同方案在 top-5 命中率上的表现:
| 方案 | top-5 命中率 | 平均延迟 |
|---|---|---|
| 纯向量召回 | 62% | 45ms |
| 向量 + BM25 混合 | 74% | 90ms |
| 混合召回 + 重排 | 89% | 170ms |
| 混合召回 + 重排 + 查询改写 | 93% | 210ms |
重排带来的提升是决定性的,从 74% 到 89%,十五个百分点的差距。这十五个点意味着什么?意味着每 100 个问题里,少了 15 个答非所问的情况。对于企业知识库来说,这个提升直接决定了用户会不会继续用这个系统。
查询改写是另一个提升点。用户的问题往往口语化、有歧义,比如“那个报错怎么解决”。查询改写模块会把这个问题扩展成“错误码 报错 解决方案 排查步骤”,然后再去检索。我用的是一个小模型做 few-shot 改写,成本很低,但效果很明显。
4.3 重排模型的部署与调优细节
重排模型部署有几个坑要注意。
第一是批处理大小。bge-reranker-v2-m3 在 T4 上,batch size 设成 8 时延迟最低,设成 16 反而变慢,因为显存不够导致频繁换页。我实测下来,batch size 8 的吞吐量是 batch size 16 的 1.4 倍。
第二是分数阈值。重排模型输出的分数是 logits,不是概率。我一开始直接拿分数排序,后来发现有些低分块其实也包含关键信息。我的做法是设一个动态阈值:取 top-5 的平均分,然后保留所有分数高于平均分 0.8 倍的块。这样既能保证精度,又不会漏掉边缘相关的块。
第三是模型选择。bge-reranker-v2-m3 支持多语言,中文效果很好。但如果你的场景是纯英文,Cohere 的 rerank 模型效果更好,只是需要调 API,有数据出境的合规问题。本地部署的话,bge 系列是目前最稳妥的选择。
注意:重排模型不是万能的。如果召回阶段完全没有命中相关文档,重排也变不出来。我遇到过一个问题,标准答案在召回列表里排第 87 位,重排后只升到了第 12 位,还是进不了 top-5。后来发现是分块的时候把关键段落切碎了,调整分块参数后才解决。所以分块、召回、重排是一个链条,任何一个环节出问题,后面的环节都补不回来。
5. 六个实战结论的完整复盘
5.1 结论一:分块策略决定召回上限
这是我最深刻的体会。分块做不好,后面怎么调召回和重排都是事倍功半。我做过一个对比实验:同一套召回和重排配置,分块参数从 512/50 改成 400/80,top-5 命中率从 74% 直接跳到 82%。八个百分点的提升,没有改任何模型,没有加任何算力,只是把块切得更合理了。
具体来说,分块要关注三个维度:语义完整性、长度一致性、元数据保留。语义完整性靠递归分块和语义检查来保证;长度一致性靠参数调优来控制;元数据保留最容易被忽略——每个块必须带上来源文档、章节标题、页码这些信息,否则重排和生成阶段无法做引用溯源。
5.2 结论二:混合召回是生产环境的标配
纯向量召回在 demo 阶段够用,但生产环境一定会遇到精确匹配和长尾术语的问题。混合召回不是可选项,是必选项。BM25 的引入成本很低,Elasticsearch 本身就支持,关键是要做好分数归一化和权重调优。
我现在的默认配置是向量 0.7、BM25 0.3,但这个权重不是固定的。系统会根据查询类型动态调整:如果查询里包含错误码、版本号、专有名词,BM25 权重自动升到 0.5;如果是自然语言问句,向量权重保持 0.7。这个动态调整逻辑用简单的规则就能实现,不需要机器学习模型。
5.3 结论三:重排的投入产出比最高
如果只能在一个环节加资源,我会选重排。向量召回和 BM25 都是“粗筛”,重排是“精挑”。从 74% 到 89% 的提升,只需要一张 T4 显卡和 80 毫秒延迟,这个投入产出比在 RAG 系统里是最高的。
但重排模型的选择要注意:不要用太大的模型。我试过 bge-reranker-v2-gemma,效果确实好一点,但延迟从 80 毫秒涨到了 400 毫秒,吞吐量降了五倍。对于在线服务来说,这个代价太大了。m3 版本在效果和延迟之间取得了很好的平衡。
5.4 结论四:查询改写是低成本高回报的补充
查询改写是我最后加上的模块,但效果出乎意料。用户的问题往往很短、很模糊,直接拿去检索效果很差。用一个小模型做 few-shot 改写,把问题扩展成包含同义词和相关术语的查询,召回命中率能提升 4 到 5 个百分点。
改写的 prompt 我调了很多版,最终稳定下来的格式是:给定用户问题,输出三个改写版本,分别侧重同义词扩展、术语标准化、意图澄清。然后把三个版本分别检索,结果合并去重。这个方案比单次改写效果更好,成本也只是多两次检索。
5.5 结论五:评估体系比调优技巧更重要
没有评估体系,调优就是盲人摸象。我一开始靠感觉调参数,改来改去不知道哪个方案更好。后来建了一个 200 题的测试集,每道题标注了标准答案所在的文档和段落,每次改动都跑一遍评估,看命中率和 MRR 的变化。
测试集的构建也有讲究。不能只挑简单问题,要覆盖各种类型:事实型、对比型、否定型、多跳型。我现在的测试集里,事实型占 40%,对比型占 25%,否定型占 15%,多跳型占 20%。这个分布和真实用户问题的分布基本一致。
5.6 结论六:RAG 是一个系统工程,没有银弹
最后一个结论可能有点泄气,但这是事实:RAG 没有一招制胜的方案。分块、召回、重排、改写、评估,每个环节都要投入精力。我见过太多团队在向量库选型上纠结两周,却在分块策略上花了两小时。这是本末倒置。
如果让我重新做一遍这个项目,我会把时间分配成这样:分块策略 30%,召回方案 25%,重排部署 20%,评估体系 15%,查询改写 10%。这个分配不一定适合所有场景,但至少反映了各个环节的相对重要性。
6. 常见问题与排查技巧实录
6.1 召回命中率突然下降怎么排查
这是最常见的问题。我的排查顺序是这样的:
第一步,检查文档更新。如果最近有新文档入库,可能是新文档的分块参数和旧文档不一致,导致向量空间分布变化。解决办法是统一分块参数,重新索引。
第二步,检查查询分布。如果用户最近开始问一些新类型的问题,而测试集没有覆盖,命中率下降是正常的。解决办法是更新测试集,补充新类型的问题。
第三步,检查 embedding 模型。如果模型版本更新了,向量空间会发生变化,旧索引和新查询不匹配。解决办法是锁定模型版本,或者全量重建索引。
第四步,检查重排模型。如果重排模型的分数分布发生了变化,可能是模型文件损坏或者配置被改。解决办法是回滚到上一个稳定版本。
6.2 模型回答引用错误文档怎么办
这个问题通常出在重排环节。重排模型把不相关的块排到了前面,生成模型就照着这些块编答案。解决办法有两个:一是提高重排模型的精度,换更大的模型或者增加训练数据;二是在生成阶段加一个引用校验,让模型在回答时标注每个事实的来源块,如果来源块和问题不相关,就拒绝回答。
我用的方案是第二种。在 prompt 里明确要求模型对每个关键事实标注来源,然后在后处理阶段检查来源块的相关性分数。如果分数低于阈值,就把这个事实从回答里删掉,或者标记为“不确定”。
6.3 多跳问题怎么处理
多跳问题是指需要综合多个文档才能回答的问题。比如“A 系统的接口超时时间是多少,这个时间在 B 系统的配置里怎么改”。标准答案分散在两个文档里,单次召回很难同时命中。
我的解决方案是迭代检索。第一轮先召回和 A 系统相关的块,提取出超时时间这个关键信息;然后用这个信息作为新的查询,去检索 B 系统的配置文档。这个方案需要模型具备一定的推理能力,我是在生成阶段让模型输出一个“需要进一步检索”的标记,然后触发第二轮检索。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 召回命中率下降 | 文档更新或查询分布变化 | 检查最近入库文档和用户查询日志 | 统一分块参数,更新测试集 |
| 回答引用错误文档 | 重排精度不足 | 检查重排分数分布 | 换更大模型或加引用校验 |
| 多跳问题答不全 | 单次召回覆盖不足 | 分析标准答案的文档分布 | 迭代检索或查询分解 |
| 延迟突然升高 | 索引膨胀或模型负载高 | 检查索引大小和 GPU 利用率 | 重建索引或扩容 |
| 同义词搜不到 | 纯向量召回盲区 | 测试同义词查询 | 引入 BM25 混合召回 |
6.5 几个容易被忽略的细节
第一个细节是文档预处理。PDF 里的表格、图片、页眉页脚,如果不做处理直接抽取文本,会引入大量噪声。我的做法是用布局分析工具先把文档切成文本块、表格块、图片块,表格单独做结构化处理,图片走 OCR 或者多模态 embedding。
第二个细节是元数据设计。每个块除了文本内容,还要存来源文件、章节路径、创建时间、文档类型这些元数据。这些信息在重排和生成阶段非常有用。比如可以根据文档类型调整重排权重,技术文档的权重要高于会议纪要。
第三个细节是缓存策略。高频问题的检索结果可以缓存,避免重复计算。我用 Redis 做了一层查询缓存,命中率大概 30%,延迟从 170 毫秒降到了 20 毫秒。缓存失效策略是按文档更新事件触发,有新文档入库就清空相关缓存。
第四个细节是监控告警。RAG 系统需要监控的指标包括:召回命中率、重排分数分布、生成答案的引用准确率、端到端延迟。这些指标要设阈值告警,一旦异常就自动回滚到上一个稳定配置。
7. 后续可以继续深挖的方向
这套系统跑到现在大概半年,整体命中率稳定在 90% 左右,用户满意度比上线初期提升了很多。但还有几个方向我觉得值得继续投入。
第一个是 GraphRAG。现在的方案是把文档切成独立的块,块与块之间的关联丢失了。如果用知识图谱把实体和关系抽出来,检索的时候可以沿着图谱遍历,多跳问题的效果会好很多。我试过微软的 GraphRAG 方案,在小型数据集上效果不错,但索引成本很高,四万份文档跑了一遍花了两天。
第二个是 Agentic RAG。现在的流程是线性的:召回、重排、生成。如果引入 Agent 机制,让模型自己决定什么时候检索、检索什么、要不要多轮检索,灵活性会高很多。AgentScope 2.0 在这方面做了一些探索,我还在评估阶段。
第三个是 embedding 模型的持续优化。通用 embedding 模型在垂直领域的效果有限,用领域数据做微调可以显著提升召回质量。我收集了大概五千对查询-文档对,准备做一轮微调实验,目标是让 top-50 召回覆盖率从 92% 提升到 96%。
这些方向都需要投入时间和算力,但 RAG 这个领域就是这样,没有一劳永逸的方案,只有持续迭代。我现在的心态是:先把基础的分块、召回、重排做扎实,再考虑上层的高级玩法。基础不牢,再花哨的架构也是空中楼阁。