拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RAG生产环境实战:分块、召回与重排的6个关键结论

RAG生产环境实战:分块、召回与重排的6个关键结论

1. 为什么我要把 RAG 的坑一个个踩给你看

RAG 这个词在过去一年里被聊烂了。随便打开一个技术社区,满屏都是“RAG 从入门到精通”“三行代码搭建你的知识库”。但我真正把一套 RAG 系统推到生产环境、每天扛着几千次真实用户查询之后,才发现那些教程里轻描淡写带过的环节——分块怎么切、召回怎么调、重排到底值不值得上——才是决定这套系统能不能用的命门。

我做的项目背景不复杂:一个内部技术文档知识库,大概两万多篇文档,格式混杂,有 Markdown、PDF 导出的纯文本、Confluence 页面导出的 HTML 残留,还有一些历史遗留的 Word 文档。用户是公司内部的技术支持和售前,他们问的问题五花八门,从“这个接口的超时时间怎么配”到“某某功能在哪个版本引入的”都有。上线之前我信心满满,觉得 embedding 加向量库加 LLM 一套组合拳打下来,效果不会差。上线之后 hit rate 惨不忍睹,用户反馈“答非所问”的比例高得让我怀疑人生。

后面花了大概六周时间,从分块策略开始一层层往下挖,把召回和重排的每个环节都拆开做对比实验,才慢慢把 hit rate 从最初的不到 40% 拉到 85% 以上。这个过程里积累下来的结论,有些和主流教程的说法一致,有些则完全相反。我把它们整理成 6 个实战结论,每个结论背后都有具体的实验数据和踩坑记录。如果你正在做 RAG 项目,或者正准备开始做,这些经验应该能帮你少走至少一个月的弯路。

这篇文章适合谁看?如果你已经跑通过一个 demo 级别的 RAG 流程,但对生产环境的效果不满意,那这篇就是写给你的。如果你还在选框架的阶段,也可以看看,因为我会讲到不同框架在分块和重排上的默认行为差异,这些差异会直接影响你后续的调优空间。我不会花篇幅解释 RAG 是什么,也不打算贴大段代码,重点放在“为什么这么做”和“做了之后效果差多少”上。

2. 分块策略:切得越细不一定越好,但切得不对一定很糟

2.1 固定长度分块为什么在真实文档上翻车

最开始我用的是最朴素的做法:按字符数固定长度切,512 个字符一块,重叠 50 个字符。这个方案在教程里出镜率最高,实现也最简单,LangChain 的CharacterTextSplitter和RecursiveCharacterTextSplitter都能直接干这事。我拿几十篇文档跑了一遍,embedding 建库,召回测试,发现一个很诡异的现象:有些明明包含答案的文档,召回阶段就是找不到。

我把那些失败的 case 一个个拉出来看,发现问题出在切分边界上。技术文档里经常有配置表格和代码块,固定长度切分可不管这些结构,一刀下去正好把一张配置表切成两半。上半截在 chunk A 里,下半截在 chunk B 里,用户问“这个参数默认值是多少”,embedding 匹配到的是 chunk A,里面只有参数名没有默认值,LLM 拿到这个 chunk 自然答不出来。更隐蔽的问题是,有些文档的标题和正文被切开了,标题单独成了一个 chunk,正文成了另一个 chunk,标题那个 chunk 因为太短,embedding 质量很差,基本等于噪声。

我统计了一下,在固定长度分块下,因为边界切断导致信息不完整的 chunk 占比大概在 15% 到 20% 之间。这个比例听起来不高,但考虑到召回是 top-k 选取,这些“残废 chunk”会挤占正常 chunk 的位置,实际影响远大于 20%。

注意:固定长度分块不是不能用,但它只适合那种结构非常均匀的文本,比如纯叙述性的文章。一旦文档里有表格、代码、列表、标题层级,固定长度分块就会制造大量信息碎片。

2.2 按语义和结构分块的具体操作

后面我换成了基于文档结构的分块策略。核心思路很简单:先解析文档的结构,按照标题层级切分,然后在每个标题对应的内容块内部,再根据段落和代码块做二次切分。具体操作上,Markdown 文档最好办,直接用标题层级做分割,#一级标题下的内容作为一个大块,如果这个大块超过阈值(我设的是 800 字符),再按##二级标题切,以此类推。PDF 导出的文本麻烦一些,需要先用正则把类似“第 X 章”“1.1 节”这样的标题模式识别出来,再按这些模式切分。

代码块和表格要特殊处理。我的做法是,在切分之前先把代码块和表格用占位符标记出来,切分的时候保证这些占位符不被切断,切完之后再把原始内容替换回去。这样能保证一个完整的代码块或表格始终在同一个 chunk 里。表格如果特别大,比如超过 1000 字符,我会在表头行做切分,并且给每个子表都补上表头,避免出现“有数据没表头”的情况。

这里有个细节值得展开说:chunk 的大小到底设多少合适。我做了对比实验,分别用 256、512、800、1200 四个档位跑召回测试。结果 512 和 800 的表现最好,256 太碎,召回时经常只能拿到片段信息,LLM 拼不出完整答案;1200 又太大,一个 chunk 里塞了太多不相关的内容,embedding 的语义焦点被稀释了。最终我定的是 600 到 800 字符之间浮动,具体根据文档结构微调,标题层级多的文档用 600,叙述性强的用 800。

2.3 重叠窗口设多少才合理

重叠窗口这个参数,很多教程直接说“设 10% 到 20%”,但我在实验里发现,重叠窗口的大小应该和 chunk 大小以及文档的叙述密度挂钩。对于 600 字符的 chunk,我设 80 字符的重叠,大概 13%。这个重叠量能保证跨 chunk 的句子不会被完全切断,但又不会引入太多冗余信息。

但有一种情况需要加大重叠:文档里有大量跨段落的逻辑连接,比如“基于上述配置,我们还需要注意以下几点”。这种句子如果被切断,后半截单独成 chunk 就完全没有意义。我的处理方式是,在分块之前先做一遍句子边界检测,保证切分点落在句子结束符之后。Python 里可以用nltk或者简单的正则来做这件事,效果立竿见影。加了句子边界对齐之后,因为切断导致语义不完整的 chunk 比例从 15% 降到了 5% 以下。

还有一个反直觉的发现:重叠窗口不是越大越好。我试过把重叠设到 200 字符,结果召回时经常出现同一个信息被多个 chunk 重复命中,top-k 里一半都是冗余内容,反而挤掉了其他相关文档的位置。所以重叠窗口要克制,够用就行。

3. 召回环节:embedding 模型选型只是第一步

3.1 embedding 模型排行靠不靠谱

网上有很多 embedding 模型排行的榜单,MTEB 之类的。我一开始也是照着榜单选,用了某个排名很靠前的通用模型。结果在实际数据上跑下来,效果并没有比榜单上排名低几位的小模型好多少。后来我意识到一个问题:榜单的评测数据集和你的业务数据分布可能差很远。通用榜单上的模型是在新闻、网页、论文这些数据上训的,而我的数据是技术文档,里面有大量专有名词、缩写、版本号,这些 token 在通用模型的词表里可能根本就不存在,或者 embedding 质量很差。

我的建议是,不要迷信榜单,至少拿三到四个候选模型在你的真实数据上做召回测试。测试方法很简单:准备 50 到 100 个查询和对应的标准答案文档,分别用不同模型建库,看 top-5 召回里有多少能命中标准答案。这个测试花不了半天时间,但能帮你省掉后面几周的调优。

另外,embedding 模型的维度也是个考量点。高维模型(比如 1024 维、1536 维)理论上表达能力更强,但存储和检索成本也更高。我实测下来,在文档量不超过十万这个量级,768 维和 1024 维的召回效果差异很小,但检索速度快了将近一倍。所以如果你的文档量不大,没必要追求最高维度。

3.2 混合召回:向量加关键词不是简单叠加

纯向量召回有个天然缺陷:对精确匹配不敏感。用户问“v2.3.1 版本的超时时间”,向量召回可能给你返回一堆讲超时机制的文档,但就是漏掉了那个明确写了“v2.3.1 超时时间默认 30 秒”的 chunk。因为 embedding 把“v2.3.1”这个关键信息稀释掉了。

我的解决方案是混合召回:向量召回和关键词召回各跑一遍,然后合并结果。关键词召回用 BM25 或者简单的 TF-IDF 都行,重点是把那些包含精确术语、版本号、错误码的 chunk 捞回来。合并的时候不能简单拼接,我用的做法是给两路召回的结果分别打分,然后做加权融合。向量召回的分数归一化到 0 到 1,BM25 的分数也归一化,权重我设的是向量 0.7、关键词 0.3。这个权重是根据实验调的,关键词权重再高就会引入太多噪声,再低又起不到补充作用。

混合召回之后,hit rate 大概提升了 8 到 10 个百分点。这个提升在精确查询场景下尤其明显,比如用户问某个具体错误码的含义,纯向量召回经常翻车,混合召回基本都能命中。

3.3 top-k 设多少:召回数量和质量的权衡

top-k 这个参数,我见过有人设 3,有人设 20。设太小,相关文档可能没进候选集;设太大,噪声太多,重排阶段压力大,而且 LLM 的上下文窗口也塞不下。我的经验是,如果后面有重排环节,top-k 可以设大一点,比如 20 到 30,让重排模型去筛。如果没有重排,top-k 设 5 到 8 比较合适。

但这里有个隐藏问题:top-k 设大了之后,召回结果里的噪声比例会上升。我统计过,top-20 的召回结果里,真正相关的 chunk 平均只有 4 到 6 个,也就是说噪声率高达 70% 以上。这些噪声如果直接喂给 LLM,轻则答案质量下降,重则 LLM 被误导给出错误答案。所以重排环节在 top-k 较大的情况下几乎是必须的。

4. 重排:最值得投入的环节,但别指望它解决所有问题

4.1 重排模型到底在做什么

重排(rerank)的本质是一个精排模型,它把召回阶段拿到的候选 chunk 和用户查询一起输入,输出一个相关性分数,然后按分数重新排序。和 embedding 召回的区别在于,embedding 是双塔结构,查询和文档分别编码,最后算余弦相似度,速度快但精度有限;重排模型通常是交叉编码器,查询和文档拼在一起过一遍模型,精度高但速度慢。

我用的重排模型是一个轻量级的交叉编码器,推理延迟在 50 毫秒左右(batch size 为 1 的情况下)。对于在线查询来说,这个延迟完全可以接受。加上重排之后,top-5 的命中率从 65% 左右提升到了 85% 以上,提升幅度是所有优化手段里最大的。

但重排不是万能的。如果召回阶段压根没把相关文档捞回来,重排模型再强也没用。我遇到过好几次,用户查询里的关键术语在 embedding 时被淹没了,召回 top-30 里一个相关的都没有,重排自然也无从下手。所以召回和重排是接力关系,召回负责“不漏”,重排负责“去噪”,两者缺一不可。

4.2 重排模型的选型和部署考量

重排模型的选择比 embedding 模型更依赖业务场景。通用重排模型在开放域问答上表现不错,但在技术文档这种垂直领域,效果会打折扣。我试过两个方案:一个是直接用开源的通用重排模型,另一个是在自己的数据上做少量微调。微调之后的模型在 top-3 命中率上比通用模型高了大概 6 个百分点,但微调需要标注数据,成本不低。

如果不想微调,还有一个折中方案:用 LLM 做重排。具体做法是把查询和候选 chunk 一起塞给 LLM,让 LLM 输出相关性排序。这个方案的好处是不需要额外训练,坏处是延迟高、成本高。我实测下来,用 LLM 重排的延迟在 500 毫秒以上,对于在线查询来说有点勉强,但如果你的场景是离线批处理或者对延迟不敏感,这个方案可以考虑。

部署方面,重排模型对显存的要求比 embedding 模型高。我用的模型大概需要 2GB 显存,如果并发量高,需要做 batch 推理或者多实例部署。这里有个坑:重排模型的 batch 推理和 embedding 不一样,因为每个查询对应的候选 chunk 数量不同,batch 的构造比较麻烦。我的做法是固定每个查询取 top-20 做重排,这样 batch 大小固定,推理效率最高。

4.3 重排之后的截断策略

重排之后,你需要决定取前几个 chunk 喂给 LLM。取太少,信息可能不完整;取太多,LLM 上下文被占满,而且噪声也会进来。我的做法是动态截断:从重排结果的第一名开始累加 chunk 的 token 数,直到接近 LLM 上下文窗口的 60% 就停。为什么是 60%?因为还要留出空间给系统提示词、用户查询和 LLM 的回答。如果第一名 chunk 就特别长,那就只取第一名。

这个动态截断策略比固定取 top-3 或 top-5 灵活得多。我对比过,动态截断在答案完整率上比固定 top-3 高了大概 12 个百分点,因为有些问题确实需要多个 chunk 的信息才能回答完整。

5. 六个实战结论的完整复盘

5.1 结论一:分块质量决定召回上限

这是我最深刻的体会。分块没做好,后面所有环节都是在补救,而且补不回来。我做过一个对比实验:同一批文档,一组用固定长度分块,一组用结构感知分块,其他环节完全一样。结果结构感知分块的召回 hit rate 比固定长度高了 22 个百分点。这个差距不是靠调 embedding 模型或者加重排能弥补的。

所以如果你刚开始做 RAG,我建议你把至少 40% 的精力花在分块上。具体来说,先分析你的文档结构,找出标题、表格、代码块、列表这些结构元素的分布规律,然后针对性地设计切分规则。不要怕规则复杂,规则越贴合文档结构,效果越好。

5.2 结论二:embedding 模型要在真实数据上选

榜单只能做初筛,最终选型一定要在你的真实数据上跑召回测试。我当时的候选模型有四个,榜单排名从高到低,但在我的数据上,排名第三的那个模型反而表现最好。原因很简单:那个模型的训练数据里技术文档占比高,对专有名词的编码质量更好。

测试方法我再强调一遍:准备 50 到 100 个查询和对应的标准答案文档,分别建库,看 top-5 召回命中率。这个测试的成本很低,但收益极高。

5.3 结论三:混合召回是性价比最高的优化

向量召回加关键词召回,实现简单,效果提升明显。我大概花了半天时间就把混合召回接进去了,hit rate 直接涨了 8 到 10 个百分点。关键词召回不需要额外的模型,用 BM25 或者 Elasticsearch 自带的检索功能就行。如果你的向量库本身支持稀疏向量(比如 Milvus 的 sparse vector),那就更方便了,可以在同一个查询里同时做稠密和稀疏检索。

5.4 结论四:重排要上,但别指望它救场

重排的提升幅度很大,但前提是召回阶段已经把相关文档捞回来了。如果召回 top-30 里一个相关的都没有,重排模型再强也白搭。所以正确的顺序是:先把分块和召回做好,再考虑加重排。我见过有人分块一塌糊涂,直接上重排,结果效果提升有限,还以为是重排模型不行。

5.5 结论五:top-k 和截断策略要联动设计

top-k 设多少,取决于后面有没有重排,以及 LLM 的上下文窗口有多大。有重排的情况下,top-k 可以设 20 到 30;没有重排,设 5 到 8。截断策略我推荐动态截断,按 token 数累加,而不是固定取前几名。这样能保证在上下文窗口允许的范围内,尽可能多地塞入相关信息。

5.6 结论六:评估要贯穿始终,不能只看最终答案

RAG 系统的评估不能只看最终 LLM 输出的答案对不对,因为答案错了你根本不知道是哪个环节的问题。我的做法是分阶段评估:分块阶段看 chunk 的完整率,召回阶段看 hit rate 和 MRR,重排阶段看 NDCG,最后才看端到端的答案准确率。每个阶段都有量化指标,出了问题能快速定位。

我建了一个简单的评估流水线,每次调整参数就跑一遍,记录各项指标的变化。这个流水线大概花了我一天时间搭建,但后面每次调优都靠它来决策,省了大量拍脑袋的时间。

6. 常见问题与排查技巧实录

6.1 召回结果里明明有相关文档,但 LLM 就是答不对

这个问题我遇到过好几次,排查下来通常是两个原因。第一个原因是 chunk 里的信息不完整,比如答案需要结合两个 chunk 的内容才能拼出来,但截断策略只取了其中一个。解决办法是检查截断策略,确保相关 chunk 都被包含进来。第二个原因是 LLM 的提示词没有引导它正确使用上下文。我后来在系统提示词里加了一句“如果上下文中没有明确答案,请直接说不知道,不要编造”,答案准确率提升了不少。

6.2 查询里的专有名词总是被忽略

这是纯向量召回的通病。解决办法就是上混合召回,用关键词召回把包含专有名词的 chunk 捞回来。另外,在 embedding 之前可以对查询做一次术语提取,把专有名词单独拎出来做一次精确匹配,匹配到的 chunk 直接加权。这个做法我试过,对版本号、错误码这类查询效果很好。

6.3 重排之后延迟太高

重排模型的推理延迟和候选数量成正比。如果 top-k 设了 30,重排延迟可能到 100 毫秒以上。解决办法有两个:一是降低 top-k,比如降到 15 到 20;二是用更轻量的重排模型,或者对重排模型做量化。我试过把重排模型量化到 INT8,延迟降了大概 40%,精度损失不到 1 个百分点,性价比很高。

6.4 文档更新后召回效果变差

文档更新后,需要重新做分块和 embedding。如果只更新了少量文档,可以只重建这些文档的索引,但要注意 chunk ID 的映射关系不能乱。我用的向量库支持按文档 ID 删除和插入,更新流程是:先删除旧文档的所有 chunk,再插入新文档的 chunk。这个过程要加锁,避免更新过程中有查询进来读到不完整的数据。

6.5 多轮对话场景下召回不稳定

多轮对话里,用户的后续查询往往省略了主语,比如第一轮问“v2.3.1 的超时时间”,第二轮问“那 v2.4.0 呢”。如果直接把第二轮查询拿去召回,embedding 匹配到的可能是完全不相关的内容。解决办法是做查询改写,把第二轮查询补全成“v2.4.0 的超时时间是多少”,再拿去召回。查询改写可以用 LLM 来做,成本不高,但效果提升明显。

问题现象可能原因排查方法解决方案
召回有相关文档但答案不对chunk 信息不完整或截断不当检查截断后的 chunk 内容调整截断策略,确保信息完整
专有名词被忽略纯向量召回对精确匹配不敏感检查召回结果是否包含关键词上混合召回,加关键词检索
重排延迟高top-k 过大或模型太重测量各阶段延迟降低 top-k 或量化重排模型
文档更新后效果变差索引未同步更新检查索引中文档版本重建索引,加更新锁
多轮对话召回不稳定查询省略主语导致语义漂移对比原始查询和改写后查询做查询改写,补全语义

6.6 一个容易被忽略的细节:chunk 的元数据

每个 chunk 除了文本内容,还应该带上元数据,比如来源文档 ID、标题层级、位置信息。这些元数据在召回和重排时可以用来做过滤和加权。比如用户问的是某个特定模块的问题,你可以根据元数据里的模块信息做过滤,只召回该模块下的 chunk。这个做法能显著降低噪声,我实测下来 hit rate 能再提升 5 个百分点左右。

元数据的另一个用途是溯源。当 LLM 给出答案后,你可以根据 chunk 的元数据告诉用户答案来自哪篇文档的哪个章节,这对技术文档场景来说非常重要,用户需要知道答案的出处才能信任它。

7. 我踩过的那些坑和最后的体会

说几个具体的坑。第一个坑是过度依赖默认参数。LangChain 的RecursiveCharacterTextSplitter默认 chunk size 是 1000,我一开始没改,直接用默认值跑,效果很差。后来改成 600 到 800 才正常。所以框架的默认参数一定要根据你的数据调,不能拿来就用。

第二个坑是忽略了 embedding 的归一化。有些 embedding 模型输出的向量没有做 L2 归一化,直接算余弦相似度会有偏差。我后来在入库之前统一做一次归一化,召回稳定性好了很多。这个细节在文档里经常不写,但实际影响不小。

第三个坑是重排模型的输入格式。不同的重排模型对输入格式要求不一样,有的要求 query 和 document 用特定分隔符隔开,有的要求分别传入。我一开始没注意,直接把拼接后的文本塞进去,结果重排分数完全乱套。后来查了模型文档才发现问题。

最后一个体会:RAG 系统的调优是一个系统工程,没有哪个单点优化能解决所有问题。分块、召回、重排、截断、提示词,每个环节都要照顾到,而且环节之间会相互影响。我建议你建一个评估集,每次只改一个变量,看指标变化,这样才能知道每个改动的真实效果。拍脑袋调参在 RAG 里基本等于浪费时间。

另外,不要追求一步到位。我一开始就想把 hit rate 拉到 90% 以上,结果每个环节都调得很激进,反而引入了新的问题。后来我改成渐进式优化,先把分块做好,再把召回做好,最后加重排,每一步都稳扎稳打,最终效果反而更好。RAG 这件事,慢就是快。

返回列表