1. 为什么我要把 RAG 的坑一个个踩给你看
RAG 这个词在过去一年里被聊烂了,但真正在生产环境里跑过一轮的人都知道,Demo 和落地之间隔着一整个太平洋。我最初接触 RAG 的时候,想法特别简单:把文档切一切、塞进向量库、检索几条丢给大模型,不就完事了吗?结果第一版上线之后,用户反馈直接把我打醒——答非所问、关键信息漏检、同一个问题换个问法就找不到答案,甚至有时候检索出来的内容跟问题八竿子打不着。
后来我花了将近三个月时间,把一个企业知识库场景的 RAG 系统从“勉强能用”打磨到“业务方愿意主动推广”,中间经历了分块策略反复调整、召回率从 60% 出头拉到 90% 以上、重排模型换了三轮的过程。这篇文章就是把这三个月里最核心的六个实战结论整理出来,不聊虚的,只讲我在真实项目里验证过的东西。
如果你正在做 RAG 相关的项目,不管是用 LangChain、LlamaIndex 还是自己手搓流程,不管你是刚入门想搞清楚分块到底怎么切,还是已经跑通了 Demo 但效果怎么调都上不去,这篇内容应该都能帮你省掉不少试错时间。我会从分块、召回、重排三个维度展开,每个结论都附带我实际用过的参数、踩过的坑和最终的解决方案。
2. 分块策略决定了 RAG 系统的天花板
2.1 固定长度分块为什么在真实场景里最容易翻车
刚开始做 RAG 的时候,我用的就是最朴素的固定长度分块,按 512 个 token 一刀切,重叠 50 个 token。这个方案在技术文档这种结构规整的语料上表现还行,但一换到企业内部的会议纪要、产品需求文档、客服对话记录,问题就全暴露出来了。
最典型的问题是语义截断。比如一份产品需求文档里写着“用户下单后需要在 30 分钟内完成支付,否则订单自动取消,库存回滚”,如果刚好切在“否则订单自动取消”后面,那后半句“库存回滚”就跑到下一个块里去了。检索的时候只召回了前半句,大模型给出的回答就是“订单会自动取消”,但用户真正关心的库存回滚逻辑丢了。
我后来统计了一下,在固定长度分块方案下,大约有 18% 的块存在语义截断问题,其中又有将近三分之一直接影响了最终回答的准确性。这个比例在 Demo 阶段你可能感觉不到,因为测试问题都是精心设计的,但一到真实用户手里,各种边界情况全冒出来了。
注意:固定长度分块不是不能用,但它只适合语料本身结构非常规整、段落之间独立性强的场景。如果你的文档里有大量跨段落的逻辑关系,趁早换策略。
2.2 语义分块的实际效果和代价
后来我转向了语义分块,核心思路是根据句子之间的语义相似度来决定切分点。具体做法是先把文档拆成句子级别,然后用一个轻量级的嵌入模型计算相邻句子的余弦相似度,当相似度低于某个阈值时就认为这里是一个语义边界。
我用的阈值是 0.75,嵌入模型选的是 bge-small-zh-v1.5,在中文场景下性价比很高。这个方案的效果确实比固定长度好很多,语义截断的问题从 18% 降到了 5% 左右。但代价也很明显:处理速度慢了将近 4 倍,因为每个句子都要算嵌入,而且阈值需要针对不同语料做调整。
这里有个经验:语义分块最适合用在对回答精度要求极高、且文档量不算特别大的场景。如果你的知识库有几十万份文档,全量做语义分块的时间成本可能会让你崩溃。我的做法是分层处理——核心文档用语义分块,边缘文档用固定长度加人工规则,这样在效果和效率之间找到一个平衡点。
2.3 递归分块加元数据标注的实战组合
经过几轮折腾之后,我最终稳定下来的方案是递归分块加元数据标注。递归分块的逻辑是按层级切分:先按文档的自然结构(标题、章节)切,如果某个章节还是太长,再按段落切,段落还长就按句子切。这样能最大程度保留文档的原始结构信息。
但真正让效果提升的,是元数据标注。我在每个块上都附加了以下信息:
- 来源文档标题和章节路径:帮助重排模型判断块的权威性
- 块在文档中的位置索引:用于后续的上下文扩展
- 块类型标签:区分正文、表格、列表、代码块等
- 时间戳:对于时效性强的知识库,时间越新的块权重越高
这些元数据在召回和重排阶段都会用到。比如当检索到两个内容相似的块时,来自核心产品文档的块会比来自会议纪要的块权重更高;表格类型的块在回答数据类问题时会被优先考虑。
实测下来,递归分块加元数据标注的方案,在检索准确率上比纯固定长度分块提升了将近 25 个百分点,而且处理速度只比固定长度慢了 30% 左右,性价比非常高。
2.4 分块大小的动态调整逻辑
关于块的大小,我试过 256、512、768、1024 四种规格,最终发现没有万能的最优值,必须根据文档类型动态调整。
技术文档和产品手册,块大小设在 512 到 768 之间效果最好,因为这类文档段落本身比较完整,太小的块会丢失上下文,太大的块会引入噪声。客服对话记录和会议纪要,块大小要小一些,256 到 384 比较合适,因为这类语料本身就很碎片化,大块反而会稀释关键信息。法律合同和规章制度,块大小可以放到 1024,因为条款之间的逻辑关联性很强,切太碎会破坏条款的完整性。
我现在的做法是在分块之前先对文档做一次分类,根据文档类型自动选择对应的分块参数。这个分类可以用简单的规则实现,比如根据文档的段落平均长度、标题层级深度、是否包含大量列表等特征来判断。
3. 召回环节的优化空间远比你想的大
3.1 向量召回的天生缺陷与混合召回的必要性
向量召回的核心问题是它擅长捕捉语义相似性,但对精确匹配无能为力。我遇到过很多这样的情况:用户问“XX 功能的超时时间是多少”,向量召回返回了一堆关于“XX 功能”的通用描述,但真正写着“超时时间:30 秒”的那个块却没被召回来,因为那个块里“超时时间”这几个字和用户问题的语义相似度反而不如那些泛泛而谈的段落高。
这就是纯向量召回的盲区。解决办法是引入关键词召回,也就是常说的 BM25 或 TF-IDF,做混合召回。我的做法是同时跑向量召回和 BM25 召回,各取前 20 条,然后用 RRF 做融合。
RRF 的全称是 Reciprocal Rank Fusion,翻译过来叫倒数排名融合。它的核心思想特别简单:不看每条结果的绝对分数,只看它在各自召回列表里的排名。排名越靠前的结果,融合后的分数越高。公式是:
RRF_score(d) = Σ 1 / (k + rank_i(d))其中 k 是一个平滑常数,通常取 60,rank_i(d) 是文档 d 在第 i 个召回列表中的排名。
这个方法的妙处在于它不需要对向量相似度和 BM25 分数做归一化,因为这两种分数的量纲完全不一样,直接加权求和很难调参。RRF 只看排名,天然规避了这个问题。我实测下来,混合召回加 RRF 融合,比纯向量召回的命中率提升了将近 30%。
3.2 RRF 融合参数 k 的取值实验
RRF 公式里的 k 值直接影响融合效果。k 越大,排名靠前的结果优势越不明显;k 越小,头部结果的权重越突出。我做了几组对比实验,用的是 500 条真实用户查询和对应的标注数据。
| k 值 | 召回率@10 | 召回率@20 | MRR |
|---|---|---|---|
| 20 | 0.72 | 0.81 | 0.68 |
| 40 | 0.76 | 0.84 | 0.71 |
| 60 | 0.78 | 0.86 | 0.73 |
| 80 | 0.77 | 0.85 | 0.72 |
| 100 | 0.75 | 0.83 | 0.70 |
从数据来看,k 取 60 的时候各项指标都是最优的。但这不是一个铁律,k 的最优值跟你的召回列表长度有关。一般来说,k 取召回列表长度的 1 到 2 倍比较合理。如果你的向量召回和 BM25 召回各取 50 条,那 k 可以设在 50 到 100 之间,然后在这个范围内做网格搜索。
提示:RRF 融合的时候,两个召回列表的长度最好保持一致。如果一个取 20 条另一个取 100 条,排名靠后的那些结果基本就是陪跑的,融合后很难进入最终候选集。
3.3 查询改写对召回率的提升
用户的问题往往很口语化,直接拿去做向量召回效果不一定好。比如用户问“这个东西怎么弄”,向量模型根本不知道“这个东西”指的是什么。查询改写就是解决这个问题的。
我用的查询改写方案分两步:第一步是用大模型把用户问题改写成多个不同角度的查询,第二步是对改写后的查询分别做召回,最后合并结果。举个例子,用户问“报销流程是什么”,大模型会改写成“报销的步骤和所需材料”、“费用报销的审批流程”、“报销申请怎么提交”等几个变体,每个变体分别去召回,最后用 RRF 融合。
这个方案把召回率从 78% 拉到了 88% 左右。但要注意,查询改写会增加延迟,因为要多跑几次召回。我的优化做法是只对短查询和模糊查询做改写,对于已经足够明确的长查询直接跳过改写环节。
3.4 元数据过滤在召回阶段的妙用
前面提到的元数据标注,在召回阶段能发挥很大作用。最典型的场景是时间过滤和来源过滤。
比如用户问“最新的报销政策是什么”,如果知识库里有 2022 年和 2024 年两个版本的报销政策,纯向量召回可能会把两个版本都召回来,而且 2022 年的版本因为表述更详细,排名可能还更靠前。这时候就可以在召回阶段加一个时间过滤条件,只召回 2024 年之后的文档块。
来源过滤也是类似的逻辑。如果用户明确问的是“产品手册里怎么说的”,那就可以把来源限定在产品手册相关的文档块上,排除会议纪要、邮件往来等来源。
元数据过滤的实现方式取决于你用的向量库。Milvus、Qdrant、Weaviate 这些主流向量库都支持在检索时附加过滤条件。我的经验是把过滤条件分成硬过滤和软过滤两类:硬过滤是必须满足的条件,比如时间范围、文档类型;软过滤是加分项,比如来源权威性,通过调整权重来实现而不是直接排除。
4. 重排是 RAG 系统从能用变好用的关键一步
4.1 为什么召回之后必须加重排
召回阶段的目标是“宁可错杀一千,不可放过一个”,所以通常会召回比较多的候选块,比如 top 50 甚至 top 100。但这些候选块里真正跟问题相关的可能只有三五个,剩下的都是噪声。如果直接把 50 个块丢给大模型,一方面会超出上下文窗口,另一方面噪声会干扰大模型的判断,导致回答质量下降。
重排的作用就是对召回结果做精排,把最相关的块排到最前面,同时过滤掉明显不相关的块。我实测下来,不加重的 RAG 系统,最终回答准确率大概在 65% 左右;加了重排之后,准确率能拉到 85% 以上。这个提升幅度非常惊人,基本上重排是 RAG 系统从“能用”到“好用”的必经之路。
4.2 交叉编码器重排模型的选型对比
重排模型主要分两类:一类是交叉编码器,把查询和文档拼在一起输入模型,直接输出相关性分数;另一类是双编码器,查询和文档分别编码后算相似度。交叉编码器的效果明显更好,但速度慢,因为每个查询-文档对都要跑一次模型。
我对比了几个常用的重排模型:
| 模型 | 中文效果 | 推理速度 | 显存占用 | 适用场景 |
|---|---|---|---|---|
| bge-reranker-base | 良好 | 快 | 低 | 中小规模知识库 |
| bge-reranker-large | 优秀 | 中等 | 中等 | 对精度要求高的场景 |
| Cohere Rerank | 优秀 | 快(API) | 无 | 不想自己部署 |
| MonoT5 | 良好 | 慢 | 高 | 学术研究 |
我最终选的是 bge-reranker-base,因为它在中文场景下的效果已经足够好,而且推理速度快,单张消费级显卡就能跑。如果对精度有极致要求,可以上 bge-reranker-large,但延迟会增加不少。
这里有个经验:重排模型不需要用最大的,因为召回阶段已经把候选集缩小到了几十条,重排模型只需要在这个小集合里做精细排序。用 base 版本和 large 版本的效果差距通常在 2 到 3 个百分点,但速度差距可能是两三倍。
4.3 重排阶段的截断策略
重排之后要决定把多少个块送给大模型。送太多会超上下文窗口,送太少可能漏掉关键信息。我的做法是动态截断:先设定一个分数阈值,只保留重排分数高于阈值的块;然后再设一个数量上限,比如最多 8 个块。
阈值的设定需要根据重排模型的分数分布来定。我通常会把重排分数做一次归一化,然后取 0.3 作为阈值。也就是说,归一化分数低于 0.3 的块直接丢弃。在实际项目中,这个策略平均每次会保留 4 到 6 个块,既能覆盖关键信息,又不会引入太多噪声。
还有一个技巧是相邻块合并。如果重排后发现第 3 块和第 4 块在原文中是相邻的,可以把它们合并成一个更大的块再送给大模型,这样能保留更完整的上下文。
4.4 重排与召回的协同调优
重排和召回不是独立的,它们的参数需要协同调优。我踩过的一个坑是:召回阶段取了 top 100,重排后只保留 top 5,结果发现有些问题的最佳答案在召回阶段排在第 80 位,重排后虽然排到了第 3 位,但因为截断策略只保留 top 5,差点被丢掉。
后来我调整了策略:召回阶段取 top 50,重排后保留 top 10,然后再根据分数阈值做二次过滤。这样既保证了召回率,又不会让重排阶段的计算量太大。
另外,重排模型的训练数据最好和你的业务场景匹配。如果用的是通用重排模型,在某些垂直领域可能效果不佳。我的做法是用业务数据对重排模型做少量微调,只需要几百条标注数据就能带来明显的效果提升。
5. 六个实战结论的完整复盘
5.1 结论一:分块策略没有最优解,只有最适合当前语料的解
我试过的分块方案不下十种,从最简单的固定长度到复杂的语义分块,每一种都有它的适用场景。固定长度分块适合结构规整的技术文档,语义分块适合逻辑紧密的长文本,递归分块加元数据标注则是通用性最强的方案。
关键是要根据你的语料特点来选择,而不是盲目追求最先进的方案。我的建议是先用递归分块加元数据标注作为基线,然后针对效果不好的部分做定向优化。比如发现表格类内容的检索效果差,就单独为表格设计分块策略。
5.2 结论二:混合召回是提升召回率性价比最高的手段
纯向量召回的天花板很明显,尤其是在需要精确匹配的场景下。混合召回加 RRF 融合,实现成本不高,但效果提升立竿见影。我的项目里,光是加上 BM25 召回和 RRF 融合,召回率就从 68% 拉到了 82%。
RRF 的 k 值建议从 60 开始调,根据你的召回列表长度做微调。查询改写是另一个提升召回率的利器,但要注意控制延迟。
5.3 结论三:重排是 RAG 系统的质量守门员
重排环节的投入产出比非常高。一个 base 版本的重排模型,推理成本不高,但能把最终回答准确率提升 20 个百分点。重排模型的选型不需要追求最大最强,base 版本在大多数场景下已经够用。
截断策略要动态调整,不要固定送 top k 个块。分数阈值加数量上限的组合策略,在实际项目中表现最稳定。
5.4 结论四:元数据是贯穿分块、召回、重排的隐形线索
元数据标注看起来是个脏活累活,但它的价值贯穿整个 RAG 流程。分块阶段附加的元数据,在召回阶段可以用来做过滤,在重排阶段可以用来调整权重。我强烈建议在分块阶段就把元数据标注做好,后面会省很多事。
5.5 结论五:参数调优要靠数据驱动,不能凭感觉
我见过太多人调 RAG 参数全靠感觉,觉得块大小 512 不够就改成 1024,觉得召回 top 10 不够就改成 top 20。这种做法效率极低。正确的做法是构建一个评估集,用真实用户查询和标注答案,然后系统地做对比实验。
我的评估集有 500 条查询,覆盖了事实型、对比型、总结型等不同问题类型。每次调整参数后都跑一遍评估集,看各项指标的变化。虽然构建评估集需要花时间,但它是后续所有优化的基础。
5.6 结论六:RAG 系统的优化是永无止境的迭代过程
没有一劳永逸的 RAG 系统。用户的问题在变,知识库的内容在变,业务的需求也在变。我现在的做法是每周跑一次评估,监控各项指标的变化,发现异常就及时排查。
同时要建立用户反馈机制,让用户能方便地标记回答质量。这些反馈数据是后续优化的宝贵素材。我项目里的很多优化灵感,都来自于用户的负面反馈。
6. 常见问题排查与避坑指南
6.1 召回率突然下降的排查思路
召回率突然下降通常有几个原因:知识库更新导致向量分布变化、嵌入模型版本变更、召回参数被误改。我的排查顺序是:先确认知识库是否有大规模更新,再检查嵌入模型是否一致,最后核对召回参数配置。
如果知识库更新导致的,需要重新跑一遍全量嵌入。如果嵌入模型变更导致的,需要评估新旧模型的效果差异,必要时回滚。参数误改的情况比较少见,但一旦发生很难发现,建议把关键参数配置化并加版本管理。
6.2 重排后结果反而变差的可能原因
重排后结果变差,最常见的原因是重排模型和业务场景不匹配。通用重排模型在某些垂直领域可能表现不佳,比如医疗、法律等专业领域。解决办法是用业务数据做微调,或者换一个在该领域表现更好的重排模型。
另一个可能的原因是截断策略太激进。如果重排后只保留 top 3,而正确答案原本排在第 5 位,重排后虽然升到了第 4 位,但还是被截断了。这时候需要放宽截断策略。
6.3 大模型回答不准确但检索结果看起来没问题
这种情况通常是上下文组装的问题。检索到的块虽然相关,但可能缺少关键信息,或者块之间的顺序不对导致大模型理解偏差。我的做法是检查送给大模型的完整上下文,看看信息是否完整、逻辑是否连贯。
还有一个可能是大模型本身的能力问题。同样的上下文,不同的大模型给出的回答质量差异很大。如果检索结果没问题但回答质量差,可以考虑换一个更强的大模型。
6.4 系统延迟过高的优化方向
RAG 系统的延迟主要来自三个环节:查询改写、召回、重排。查询改写如果用了大模型,延迟会比较高,可以考虑用更小的模型或者缓存常见查询的改写结果。召回阶段的延迟主要来自向量检索,可以通过建立索引、减少召回数量来优化。重排阶段的延迟和候选块数量成正比,可以通过减少召回数量来降低重排的计算量。
我的优化经验是:先定位延迟瓶颈在哪个环节,然后针对性优化。不要盲目地到处优化,那样可能收效甚微。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 召回率下降 | 知识库更新 | 检查更新日志 | 重新嵌入 |
| 召回率下降 | 模型变更 | 核对模型版本 | 回滚或重新评估 |
| 重排后变差 | 模型不匹配 | 人工评估重排结果 | 微调或换模型 |
| 重排后变差 | 截断太激进 | 检查截断参数 | 放宽截断 |
| 回答不准 | 上下文缺失 | 检查组装逻辑 | 调整组装策略 |
| 延迟过高 | 查询改写慢 | 分段计时 | 换小模型或缓存 |
6.5 知识库更新后的增量处理策略
知识库不可能一次性建好就不动了,后续的更新是常态。全量重新嵌入的成本太高,我采用的是增量处理策略:新文档走完整的分块和嵌入流程,修改过的文档先删除旧的向量再插入新的向量,删除的文档直接移除对应的向量。
这个策略的关键是要维护好文档 ID 和向量 ID 的映射关系。我用的方案是在向量库里把文档 ID 作为元数据存储,更新时先根据文档 ID 删除旧向量,再插入新向量。
提示:增量处理的时候要注意向量库的索引重建。有些向量库在大量删除操作后索引会退化,需要定期做一次全量重建来保持检索性能。
6.6 多轮对话场景下的 RAG 特殊处理
多轮对话场景比单轮问答复杂得多,因为用户的问题可能依赖上一轮的上下文。比如用户先问“报销流程是什么”,接着问“那需要哪些材料”,第二个问题里的“那”指代的是报销流程,如果直接拿“那需要哪些材料”去做召回,效果肯定很差。
我的处理方式是在查询改写阶段做指代消解,把“那需要哪些材料”改写成“报销流程需要哪些材料”。这个改写可以用大模型来做,把对话历史一起传给大模型,让它输出一个完整的、不依赖上下文的查询。
另外,多轮对话场景下,历史轮次的检索结果也可以作为当前轮次的补充上下文。但要注意控制历史上下文的长度,避免引入太多噪声。
7. 一些关于工具选型和工程化的个人体会
7.1 向量库选型:不要只看性能指标
选向量库的时候,很多人只看检索速度和召回率这些硬指标。但实际项目中,易用性、可维护性、生态兼容性同样重要。我用过 Milvus、Qdrant、Chroma 和 FAISS,最后在中小规模项目里选了 Qdrant,因为它的过滤功能最完善,元数据过滤用起来很顺手。大规模项目还是 Milvus 更合适,分布式部署和水平扩展能力更强。
Chroma 适合快速原型验证,但生产环境不太推荐,主要是稳定性和并发能力有限。FAISS 性能很好,但它本质上是个库不是服务,需要自己封装服务层,工程化成本比较高。
7.2 嵌入模型:中文场景下的实际体验
中文嵌入模型我试过 text2vec、m3e、bge 几个系列。综合效果和速度,bge 系列是目前最均衡的选择。bge-large-zh 效果最好但速度慢,bge-small-zh 速度快但效果稍逊,bge-base-zh 是折中选择。
如果对效果有极致要求,可以用 bge-large-zh 做离线嵌入,用 bge-small-zh 做在线查询嵌入。但要注意,离线嵌入和在线查询必须用同一个模型,否则向量空间不一致,检索结果会完全乱掉。
7.3 大模型选型:不要盲目追求最大参数
RAG 场景下,大模型的主要任务是根据检索到的上下文生成回答。这个任务对模型的要求是理解能力强、指令遵循好、幻觉少,而不是参数越大越好。我试过用 70B 参数的模型和 13B 参数的模型做对比,在 RAG 场景下,13B 模型经过适当的提示词优化后,效果并不比 70B 差多少,但推理成本低了一个数量级。
我的建议是先用中等规模的模型做基线,如果效果不达标再考虑升级。同时要重视提示词的优化,一个好的提示词能让中等模型发挥出接近大模型的效果。
7.4 评估体系的建立:没有评估就没有优化
最后再强调一下评估体系的重要性。我见过太多团队做 RAG 全靠感觉调参,今天觉得这个参数好就改一下,明天觉得那个参数好又改一下,最后系统越来越乱,效果反而下降了。
建立评估体系不需要很复杂,初期可以用 Excel 手工标注,有 100 到 200 条查询就能开始。关键是要覆盖不同类型的查询,并且定期更新评估集。我现在的评估集是每两周更新一次,把线上表现不好的查询补充进去,保持评估集的时效性和代表性。
这个内容后续还可以这样扩展:把 RAG 和 Agent 结合起来,让系统能自主决定什么时候需要检索、什么时候直接回答;或者引入 GraphRAG 的思路,利用知识图谱来增强检索的推理能力。这些方向我都在探索中,等有成熟经验了再整理出来分享。