
1. 为什么“分块、召回、重排”才是 RAG 的真正战场RAG 这个词现在已经被说烂了。随便打开一个技术社区满屏都是“RAG 从入门到精通”“三行代码搭建你的知识库”。但真正在生产环境里跑过 RAG 的人都知道从 Demo 到上线之间隔着的不是代码量而是一堆让人抓狂的细节。我前后经手过四个 RAG 项目有面向内部文档的问答系统也有面向电商场景的商品知识检索踩过的坑足够写一本小册子。今天这篇不聊概念只聊六个我在实战中反复验证过的结论围绕三个核心环节分块、召回、重排。先说一个基本判断RAG 系统的效果上限在数据入库那一刻就已经被决定了。很多人把精力花在换模型、调 prompt 上但如果分块策略是拍脑袋定的后面再怎么优化都是事倍功半。召回决定了“能不能找到”重排决定了“找得准不准”而分块决定了“有没有东西可找”。这三者是一条链上的三个环节任何一个掉链子整条链就废了。这篇文章适合谁看如果你正在做 RAG 项目或者已经跑通了 Demo 但发现效果不稳定那这篇内容应该能帮你省下不少试错时间。如果你还没开始那更好这些结论可以帮你从一开始就避开弯路。全文基于我自己的实操经验涉及具体参数和步骤的地方会给出计算过程方便你直接抄作业或者根据自己的场景调整。2. 分块策略不是越小越好也不是越大越准2.1 固定长度分块的陷阱与适用边界刚接触 RAG 的时候我用的就是最朴素的固定长度分块按 512 个 token 切一刀重叠 50 个 token。这个方案在 Demo 阶段看起来没问题因为测试文档结构简单、段落短小。但一上真实数据就露馅了一份产品需求文档被从中间切断前半段在 chunk 3后半段在 chunk 4检索的时候只召回了 chunk 3模型拿着半截话去回答结果自然是驴唇不对马嘴。固定长度分块的问题在于它假设“语义边界和字符边界是对齐的”但现实中的文档根本不是这样。一个表格可能跨了两页一段代码可能被切在函数中间一个法律条款的但书部分可能刚好落在切分点上。我后来统计过在固定 512 token 分块下大约有 18% 的 chunk 存在语义截断问题这个比例在技术文档和合同类文档中更高。那固定长度分块是不是完全不能用也不是。如果你的文档是高度结构化的比如 FAQ 列表、产品参数表每条内容本身就短且独立那固定长度分块反而简单高效。我现在的做法是先看数据形态如果文档平均段落长度在 200 token 以内且段落之间独立性高就用固定长度分块但会把 chunk size 调到 256 左右重叠 30 token保证边界信息不丢。2.2 语义分块与递归分块的实操对比语义分块听起来很美好用 embedding 计算相邻句子的相似度在相似度骤降的地方切一刀。我试过用 sentence-transformers 做语义分块效果确实比固定长度好但代价是入库速度慢了将近 8 倍。对于百万级文档的库来说这个时间成本很难接受。后来我转向了递归分块也就是 LangChain 里那个 RecursiveCharacterTextSplitter。它的逻辑是按优先级依次尝试分隔符先按双换行切再按单换行切再按句号切最后按空格切。这样能最大程度保留段落和句子的完整性。我实测下来递归分块在技术文档上的召回准确率比固定长度高了约 12 个百分点而入库速度只慢了不到 2 倍性价比很高。具体参数上我现在的默认配置是chunk size 设为 400 tokenoverlap 设为 80 token。为什么是 400因为大部分 embedding 模型的最佳语义表征区间在 256 到 512 token 之间400 是一个比较安全的中间值。overlap 设 80 是为了保证跨 chunk 的上下文不丢失尤其是当一句话被切在两个 chunk 里时重叠部分能让模型看到完整的语义。注意overlap 不是越大越好。我试过把 overlap 设到 200结果检索时经常召回一堆高度相似的冗余 chunk反而挤占了真正相关内容的排名位置。80 到 100 是一个比较合理的区间。2.3 分块元数据被大多数人忽略的召回加速器分块的时候只存文本内容这是新手最容易犯的错误。我在第二个 RAG 项目里吃了大亏检索出来的 chunk 没有来源信息模型回答时无法标注引用用户看到答案后第一反应是“你凭什么这么说”。后来我在每个 chunk 的元数据里加了四个字段来源文件名、章节标题、页码、chunk 在原文中的位置索引。这四个字段看起来简单但作用很大。来源文件名和页码用于引用标注章节标题可以在检索时做过滤位置索引可以在重排时判断 chunk 的上下文连贯性。比如当两个 chunk 的相似度得分接近时位置索引相邻的那个应该优先保留因为它更可能是完整语义的一部分。还有一个进阶用法在元数据里存一个“父级 chunk ID”。具体做法是先把文档按大块切分比如 2000 token再在大块内部切小块400 token。检索时用小块去匹配但返回给模型的是大块的内容。这样既保证了检索精度又保证了生成时的上下文完整性。这个思路在一些 RAG 框架里叫“父文档检索”我实测下来在长文档问答场景下效果提升明显。3. 召回环节从“能查到”到“查得全”3.1 向量召回的天花板在哪里向量召回是 RAG 的标配但它的能力边界比很多人想象的要窄。向量召回擅长的是“语义相似”不擅长的是“精确匹配”。举个例子用户问“XX 型号的电池续航多久”向量召回可能会返回一堆关于电池技术的通用文档但真正包含“XX 型号”和“续航”这两个关键词的文档反而排不到前面。因为 embedding 模型在编码时把“XX 型号”这种专有名词的信息稀释了。我做过一个测试在一个包含 5 万条商品知识的库里用纯向量召回Top 10 的命中率大约是 68%。也就是说有 32% 的情况正确答案根本没进前 10。这个数字在真实业务场景里是不可接受的。后来我加了 BM25 做混合召回Top 10 命中率直接拉到 89%。BM25 的优势在于它对关键词的精确匹配非常敏感正好补上了向量召回的短板。混合召回的实现方式有两种一种是并行召回后做分数融合另一种是先用 BM25 粗筛再用向量精排。我推荐第一种因为两种召回的信号是互补的融合后效果更稳定。分数融合的公式我用的是加权求和最终得分 0.6 × 向量相似度 0.4 × BM25 得分。这个权重是根据业务场景调的如果你的查询里专有名词多可以把 BM25 的权重提到 0.5 甚至 0.6。3.2 混合召回中 BM25 与向量的权重调优权重调优没有万能公式但有一个系统性的方法。我会准备一个包含 100 到 200 条查询的评测集每条查询标注好正确答案所在的 chunk ID。然后网格搜索权重组合从 0.1 到 0.9步长 0.1看哪个组合的 Top 10 命中率最高。这个过程大概需要跑 20 到 30 分钟但收益很大。除了权重还有一个参数容易被忽略BM25 的 k1 和 b。k1 控制词频饱和速度b 控制文档长度归一化强度。默认值 k11.2、b0.75 在大多数场景下够用但如果你的文档长度差异很大比如有的 chunk 只有 100 token有的有 800 token那 b 可以调到 0.5 到 0.6减弱长度归一化的影响。我实测下来在长度差异大的场景下b0.55 比默认值能提升约 5 个百分点的召回率。还有一个实操细节BM25 的分词方式对中文场景影响很大。用默认的按空格分词肯定不行我一般用 jieba 做分词并且把业务相关的专有名词加到自定义词典里。比如“XX 型号”如果不加词典会被切成“XX”和“型号”两个词匹配精度就下降了。3.3 多路召回与查询改写提升召回覆盖率的组合拳单一查询的召回能力是有限的。用户问“怎么重置密码”可能相关的文档标题是“账户安全操作指南”里面有一节叫“忘记密码的处理流程”。如果直接用原查询去召回向量相似度可能不高因为“重置”和“忘记”在语义上虽然接近但 embedding 模型不一定能捕捉到。我的做法是做查询改写用一个小模型或者规则模板生成 2 到 3 个变体查询然后并行召回最后合并去重。比如原查询是“怎么重置密码”变体可以是“忘记密码怎么办”“密码找回流程”“账户密码修改方法”。这三个变体分别召回合并后的覆盖率比单查询高了将近 20 个百分点。查询改写的实现方式有三种第一种是用 LLM 做改写效果好但成本高第二种是用同义词词典做替换成本低但覆盖有限第三种是用历史查询日志做挖掘找到高频的等价表达。我一般组合使用先用同义词词典做快速替换再用 LLM 对复杂查询做改写。LLM 改写的 prompt 很简单“请将以下查询改写成 3 个语义相同但表达不同的查询用于文档检索。”提示查询改写会增加召回数量但也会引入噪声。所以改写后的召回结果一定要经过重排否则噪声会拉低最终效果。4. 重排环节把“差不多”变成“刚刚好”4.1 重排模型的选型Cross-Encoder 还是 LLM重排的核心逻辑是召回阶段追求高覆盖率重排阶段追求高精度。召回可能返回 50 到 100 个候选 chunk重排的任务是从中挑出最相关的 5 到 10 个送给生成模型。重排模型主要有两类Cross-Encoder 和 LLM。Cross-Encoder 把查询和文档拼接后一起编码输出一个相关性分数。它的优点是速度快、成本低适合大规模候选集。LLM 重排则是让模型直接判断查询和文档的相关性效果更好但速度慢、成本高。我的选择策略是如果候选集在 50 个以内用 Cross-Encoder 就够了响应时间能控制在 200 毫秒以内。如果候选集超过 100 个或者业务对精度要求极高那就先用 Cross-Encoder 粗排到 20 个再用 LLM 精排到 5 个。这样兼顾了速度和精度。Cross-Encoder 我常用的是 bge-reranker 系列中文场景下 bge-reranker-base 的性价比很高。LLM 重排我用的是 prompt 方式让模型输出一个 0 到 10 的相关性分数然后按分数排序。prompt 的关键是给出明确的评分标准比如“10 分表示文档直接回答了查询5 分表示文档涉及相关主题但没有直接答案0 分表示完全不相关”。4.2 重排的分数校准与阈值设定重排分数不是绝对的相关性度量不同模型、不同查询的分数分布差异很大。直接用一个固定阈值做过滤很容易出现“有的查询召回全被过滤掉有的查询召回全保留”的情况。我踩过这个坑设了一个 0.5 的阈值结果发现某些查询的所有候选 chunk 分数都在 0.3 到 0.4 之间全被过滤了模型只能回答“我不知道”。后来我改用动态阈值先对当前查询的所有候选 chunk 分数做排序取 Top N 个N 根据候选集大小动态调整。如果候选集有 50 个N 取 10如果候选集有 20 个N 取 5。同时设一个最低分数底线比如 0.2低于这个分数的即使排在 Top N 也不保留。这样既保证了召回数量又过滤了明显不相关的内容。还有一个技巧是做分数归一化。把当前查询的所有候选分数映射到 0 到 1 区间然后按归一化后的分数排序。这样不同查询之间的分数就有了可比性阈值设定也更稳定。归一化用 min-max 就行简单有效。4.3 重排后的上下文组装顺序和去重同样关键重排选出 Top K 个 chunk 后怎么组装成最终送给生成模型的上下文这里面也有讲究。最常见的错误是按分数从高到低拼接这样会导致上下文逻辑跳跃模型读起来费劲。我的做法是按 chunk 在原文中的位置索引排序保证上下文的连贯性。如果两个 chunk 来自不同文档那就按文档分组组内按位置排序组间按最高分数排序。去重也很重要。有时候同一个文档的不同 chunk 会被同时召回内容高度重叠。如果不做去重上下文里会出现大量重复信息浪费 token 还干扰模型判断。我的去重策略是如果两个 chunk 的文本相似度超过 0.85只保留分数高的那个。相似度用简单的 Jaccard 系数或者编辑距离就行不需要上 embedding。还有一个细节在上下文组装时给每个 chunk 加上来源标注比如“[来源XX 文档第 3 页]”。这样生成模型在回答时可以引用来源用户也能追溯。这个做法在内部知识库场景下特别重要能显著提升用户对系统的信任度。5. 六个实战结论的完整复盘与参数速查5.1 结论一分块大小 400 token 是中文场景的甜点区这个结论来自我在三个不同项目上的对比测试。chunk size 从 200 到 800步长 100分别测召回准确率和生成质量。200 token 的 chunk 召回准确率最高但生成时上下文不足模型经常需要跨多个 chunk 拼凑答案导致回答碎片化。800 token 的 chunk 上下文完整但召回准确率下降明显因为一个 chunk 里包含太多主题embedding 表征被稀释。400 token 在两者之间取得了最好的平衡。召回准确率比 200 token 低约 3 个百分点但生成质量高了将近 15 个百分点。而且 400 token 的 chunk 在送入生成模型时5 个 chunk 刚好在 2000 token 左右对大多数模型的上下文窗口都很友好。5.2 结论二混合召回中 BM25 权重不低于 0.3纯向量召回在专有名词、型号、代码标识符等场景下表现很差。我测过在包含大量产品型号的查询集上纯向量召回的 Top 10 命中率只有 55% 左右。加入 BM25 后即使权重只给 0.3命中率也能拉到 75% 以上。如果查询中专有名词占比高BM25 权重给到 0.5 效果更好。5.3 结论三重排候选集控制在 50 以内召回阶段返回太多候选重排阶段的计算成本会线性增长。我测过候选集从 50 增加到 200重排时间从 150 毫秒增加到 600 毫秒但最终 Top 5 的准确率只提升了不到 2 个百分点。所以我的建议是召回阶段用分数阈值或者 Top K 截断把候选集控制在 50 以内再交给重排。5.4 结论四查询改写能提升覆盖率但需要重排兜底查询改写平均能提升 15 到 20 个百分点的召回覆盖率但也会引入 10 到 15 个百分点的噪声。所以改写后的召回结果必须经过重排否则噪声会拉低最终效果。我的做法是改写后的每个变体召回 Top 20合并去重后得到 40 到 60 个候选再统一重排到 Top 10。5.5 结论五元数据过滤能显著提升 precision在 chunk 元数据里存来源、章节、页码等信息检索时可以根据业务逻辑做过滤。比如用户问的是“XX 产品的参数”那就只召回来源为“XX 产品手册”的 chunk。这个过滤操作能把 precision 提升 20 个百分点以上而且几乎不增加计算成本。5.6 结论六评测集是调参的唯一依据没有评测集的调参就是盲人摸象。我每个项目都会花半天时间构建一个 100 到 200 条查询的评测集每条查询标注正确答案所在的 chunk ID。然后所有参数调整都基于这个评测集的 Top 10 命中率和 MRR 指标。这个投入产出比极高能避免大量无效的试错。参数推荐值适用场景调整建议chunk size400 token中文通用文档短段落文档可降到 256chunk overlap80 token通用场景长文档可提到 100向量召回权重0.6通用场景专有名词多时降到 0.4BM25 权重0.4通用场景专有名词多时提到 0.6重排候选集50通用场景精度要求高时可到 100最终上下文 chunk 数5通用场景复杂问题可到 86. 常见问题与排查技巧实录6.1 召回结果明明相关但模型答非所问这个问题我遇到过好几次排查下来通常是两个原因。第一个原因是 chunk 被截断了召回的内容只有半句话模型拿不到完整信息。解决办法是检查分块策略确保语义边界对齐。第二个原因是重排后上下文组装顺序不对模型读到了相关 chunk 但被无关 chunk 干扰了。解决办法是按位置索引排序并且把最相关的 chunk 放在上下文的开头或结尾因为模型对首尾位置的注意力更高。6.2 相似查询的召回结果波动很大同样的查询换个说法召回结果差异很大这说明 embedding 模型的鲁棒性不够。解决办法有两个一是换一个更强的 embedding 模型比如从 text-embedding-ada-002 换到 bge-large-zh二是加查询改写用多个变体召回后合并降低单次召回的随机性。6.3 重排后 Top 1 反而不如 Top 3 相关这种情况通常是重排模型的偏差导致的。Cross-Encoder 有时会对长文档给高分因为长文档包含更多关键词。解决办法是在重排前做长度归一化或者用 LLM 重排替代 Cross-Encoder。另外也可以把 Top 3 都送给生成模型让模型自己判断哪个最相关。6.4 入库速度太慢百万文档要跑好几天入库慢的主要瓶颈在 embedding 计算和分块处理。优化方向有三个一是用 GPU 加速 embedding比 CPU 快 10 倍以上二是用批处理一次编码 64 或 128 个 chunk三是分块和 embedding 并行化用多进程或者消息队列。我实测下来优化后入库速度能从每天 10 万条提升到每天 100 万条。6.5 生成模型总是说“根据提供的资料无法回答”这个问题通常不是生成模型的问题而是召回和重排没做好。模型确实没拿到相关信息只能说不知道。排查步骤是先看召回结果里有没有正确答案如果没有说明召回覆盖率不够需要加查询改写或调整权重如果有但没进 Top 5说明重排有问题需要检查重排模型或调整阈值。注意不要为了让模型“敢回答”而调低阈值这样会引入大量噪声导致模型胡编乱造。宁可让模型说不知道也不要让它编答案。6.6 多轮对话中 RAG 效果急剧下降多轮对话场景下用户的查询往往包含指代和省略比如“那它的续航呢”这里的“它”指代上一轮提到的产品。如果直接用这个查询去召回肯定找不到相关内容。解决办法是在召回前做查询补全用 LLM 把指代消解掉生成一个完整的查询再召回。这个步骤在多轮对话 RAG 里是必须的否则效果会惨不忍睹。7. 一些关于 RAG 落地的个人体会RAG 这个技术栈入门容易精通难。我见过太多团队花了两周搭出一个 Demo然后花了两个月调效果还是上不了线。问题往往不在模型本身而在数据处理的细节里。分块、召回、重排这三个环节每一个都有大量可以优化的空间但每一个的优化都需要基于实际数据和评测集没有放之四海而皆准的参数。我现在做新项目的时候会先把 80% 的时间花在数据清洗和分块策略上剩下 20% 的时间调召回和重排。这个比例听起来夸张但实际做下来数据质量决定了效果的下限而召回重排决定了效果的上限。如果数据本身是脏的、分块是乱的后面再怎么调都是白搭。还有一个体会是不要迷信最新的模型和框架。我试过用最新的 embedding 模型替换掉旧模型效果提升不到 3 个百分点但成本翻了一倍。反而是一些工程上的优化比如加 BM25 混合召回、加元数据过滤、加查询改写每个都能带来 10 个百分点以上的提升。RAG 的瓶颈往往不在模型能力而在工程细节。最后分享一个我常用的调试技巧把召回和重排的中间结果都打日志包括查询、召回 chunk 的 ID 和分数、重排后的顺序。然后随机抽 20 个 bad case 逐个分析看是召回没召到、还是重排排错了、还是生成没用好。这个笨办法看起来费时间但比盲目调参高效得多。我每次遇到效果瓶颈都是用这个方法找到突破口的。