做 RAG 项目的人应该都有同感:demo 里跑得挺顺,一到真实文档库上就原形毕露——检索慢、召回不准、生成答案还时常带着幻觉。我们团队从最初的单路向量检索开始,一步步折腾到多粒度召回 + 重排 + 上下文压缩生成,最后沉淀出了一套内部叫 M-RAG 的实现方案。这里 M 我们取的是 Multi-granularity,核心就三件事:让检索更快、让生成更强、让资源占用更高效。这篇文章不聊空泛的概念,我会把实际踩过的坑、调过的参数、跑过的对照试验都摊开来讲。正在做 RAG 应用、或者被知识库问答的延迟和准召率折磨的同行,可以直接参考。
1. 传统RAG慢在哪:我先把一条请求拆成延迟账本
在优化 RAG 之前,最好先搞清楚时间到底花在哪个环节。我之前遇到过一个典型场景:文档库大概 50 万段,向量索引用 HNSW,生成模型是 7B 量级。用户问一个问题,端到端耗时经常超过 4 秒。一开始我以为是大模型生成慢,后来做了分环节计时,发现事情没那么简单。
1.1 一个典型的 RAG 请求,时间都烧在哪些地方
一条线上 RAG 请求的完整链路大致是:用户 query 进来,先做 embedding,然后去向量数据库做 ANN 检索,取回 top-K 候选片段,拼接成 prompt 后交给大模型生成。如果按 50 万段文档库、HNSW 默认参数来算,query embedding 大约 30ms,ANN 检索大约 50ms,真正的大头反而往后走:取回 20 段候选后,如果不做重排直接拼进 context,prompt 可能就有三四千 token,7B 模型 prefill 阶段要消耗几百毫秒到一秒,生成阶段每出一个 token 又要十几毫秒。如果回答是 500 字左右,生成阶段 3-5 秒都不奇怪。
你以为这就是全部?还有一个隐形杀手是重复计算。用户在一个会话里连续追问,每一轮都把同样的历史片段重新 embedding、重新检索、重新拼 prompt,完全没利用会话上下文。我统计过,这种场景下大约 40% 的检索请求其实是在重复查相似片段。RAG 慢,很多时候不是某一个点特别夸张,而是整条链路里没有一层做“减负”。
1.2 为什么传统 RAG 经常“召不准”
慢只是表象,更让业务方抓狂的是召回质量。很多团队图省事只用一个向量检索,但 embedding 模型对专有名词、缩写、精确数字并不敏感。比如文档里写的是“TC-PO43”,用户问“磷酸盐浓度指标”,向量检索出来的 top1 可能是一段讲水质分类的内容,而真正包含这个指标定义的段落排在十几名开外。
另一个常见问题是分块粒度。很多人喜欢统一切 512 token 的 chunk,但实际场景里一个段落可能只有 80 token,切完反倒把完整语义拆散了;而一个技术方案说明可能有 2000 token,512 一刀下去,关键结论和背后的推导过程被分到了两个 chunk,无论怎么召回都只能拿到半段信息。单粒度索引 + 单路检索,本质是把“找得准”这件事全部押注在 embedding 模型的余弦相似度上,这当然不稳定。
1.3 我们想要的“更强”,不只是 ROUGE 指标变好看
“更强”我理解成两个层面:一是答得准,二是答得有据可查。答得准需要检索结果覆盖跨段落的信息,比如一个问题涉及两份文档里的不同章节,单路 top-K 截断很容易只带回其中一份。答得有据可查,则要求在生成阶段保留可追溯的片段编号或引用标记,否则即使模型从检索结果里“蒙”对了答案,你也没法跟用户解释这个结论是怎么来的。这也是我们后来坚持在 M-RAG 里加入重排和引用溯源的原因。
2. M-RAG 的架构设计:多粒度索引、混合召回和轻量重排是怎么配合的
M-RAG 整体可以拆成四条主线:多粒度索引负责“把信息切成好找的形态”,混合召回负责“把候选尽量找全”,轻量重排负责“把真正有用的排到前面”,最后是生成阶段的上下文压缩和引用控制。下面逐个说。
2.1 多粒度索引:不再迷信“统一切 chunk”
我们最终把文档库索引拆成三层:段落级、句子级和关键词级。段落级用于回答“这个问题大概在讲什么”,句子级用于回答“具体细节是哪句话”,关键词级则用于处理专有名词和编号检索。三层索引不是平行建的,而是保持父子映射关系。
举个例子,一份设备操作手册,段落级索引存完整章节,句子级索引把每个章节拆成若干句子,并记录每个句子属于哪个段落。检索时可以先在段落级召回候选,再根据命中情况下沉到句子级找精确表述。关键词级索引则单独抽取出文档里的型号、编号、单位、异常码这类实体,建立倒排结构。这样做的代价是索引体积会膨胀,但由于句子和关键词不需要重复存向量,用 doc id 关联即可,实际增量可以控制在 20% 以内。
很多人担心多粒度会让召回逻辑变复杂,其实关键只在于层级之间的“回跳”机制:句子级命中时,要能立刻反查到所属父段落,并把父段落一起带回候选池;段落级命中时,也可以把段落内的高亮句子作为附件返回。这个设计让后续重排阶段拥有比传统 top-K 更完整的上下文。
2.2 混合召回:BM25 和向量检索各管一段
我们不是简单粗暴地把 BM25 和向量检索的结果拼在一起,而是先加了一道轻量路由。路由层会先判断 query 的类型:如果是规范性提问,比如“XX 型号的额定电压是多少”,优先走关键词召回和 BM25;如果是语义型提问,比如“这两份方案在容灾策略上有什么异同”,则提高向量检索的权重。路由本身不需要大模型,用一个几千样本训练的二分类器就够,线上推理开销可以忽略。
混合召回的候选池构造也要有技巧。假设最终要保留 20 个候选给重排,我们通常让向量检索出 top50,BM25 出 top30,过滤掉重复 doc id 之后,再按一个简单公式合并:
score_ensemble = 0.6 * normalized_vector_score + 0.4 * normalized_bm25_score这里 normalized 是为了把两个分数压到 0-1 区间。向量分数可以用余弦相似度做 min-max 归一化,BM25 分数由于绝对值波动大,更推荐先做 rank 归一化,也就是用“1 - 当前排名/总候选数”作为分数。直接比较原始分数很容易让 BM25 的绝对值得分主导合并结果,这是我踩过最典型的坑之一。
2.3 轻量重排:不用大模型也能大幅提升准召率
候选池有了,接下来是重排。很多方案直接用大模型对候选做 rerank,效果好但成本高。我们选择了一条更务实的路线:训练一个轻量级交叉编码器,base 模型用 300M 参数左右的中文 sentence model,在领域数据上做微调。
交叉编码器和普通双塔编码器的本质区别在于,它会将 query 和文档片段拼接在一起输入模型,得到更精确的相关性分数,代价是推理速度慢。但我们的场景是只对 top50 候选做重排,而不是对全库做召回,所以整体延迟可以接受。实测单条候选对的重排耗时大约 6-8ms,50 条候选只需要 300-400ms。相比直接塞给大模型做 rerank,这个成本低了一个数量级。
重排模型的训练数据来源,我们优先从线上日志里挖:把用户点了“有帮助”的回答所对应的检索片段作为正样本,把那些用户继续追问或直接转人工的片段作为难负样本。此外再补充一部分模板生成的规则负样本。训练 loss 用交叉熵,输入格式是[CLS] query [SEP] passage [SEP]。这部分数据量做到两三万条之后,重排效果已经明显好过单纯靠向量相似度。
2.4 生成阶段的上下文压缩和引用溯源
检索和重排都做完了,还剩最后一步:把候选片段拼给大模型时,不能一股脑全塞进去。我们参考了“先压缩再生成”的思路,用一个小型抽取模型先从候选片段里删掉与问题无关的句子,再把压缩后的文本交给生成模型。这个小型抽取模型可以直接复用重排模型的 encoder 部分,输出每个句子的相关概率,挑出超过阈值的句子作为保留内容。
同时,每个保留的句子会带上源文档层级 id,生成时我们会在 prompt 里明确要求模型在观点后标注来源角标,比如“根据文档A第2章内容[1]”。这样最终答案自带引用编号,业务方拿到结果可以直接跳转到原文。这一步看似只是格式要求,实际上倒逼模型更依赖检索结果,因为我们在测试中发现,加了引用约束后幻觉率下降了 30% 以上,模型会倾向于“只说自己能看到的内容”。
3. 从原型到可上线:M-RAG 核心环节的落地细节
架构设计说完了,我挑几个最容易踩坑的落地环节展开讲。
3.1 分块策略:别再用固定 token 数切片
我强烈建议按文档结构分块,而不是按 token 数硬切。最稳妥的做法是先用解析器把 Markdown、PDF 里的标题层级还原出来,以标题为边界形成候选块。如果一个标题下内容太长,再按段落边界二次拆分;如果一个标题下内容太短,就和下一个同级标题合并。这样分出来的 chunk 在语义上是相对完整的。
具体到切分参数,我们内部用的是“标题优先 + 段落兜底”:章节块最长不超过 1500 token,句子块最长不超过 200 token。你可能会问,为什么不定成 512 或 1024?因为经过对比验证,1500 token 左右的段落块在召回阶段足够承载一个完整知识点,又不至于让重排模型输入过长导致显存压力变大。句子块控制在 200 token 以内,是为了让精排阶段能快速过滤不相关部分。
3.2 BM25 参数为什么不能照抄默认值
BM25 有两个关键参数:k1 控制词频饱和度,b 控制文档长度归一化的强度。默认值通常是 k1=1.5、b=0.75,但这在中文文档上常常不是最优。我们的实践是 k1 在 1.2 到 2.0 之间搜索,b 在 0.5 到 0.8 之间搜索。直接给你结论:对于技术文档库这种长文档居多、专有名词多的场景,k1 取 1.5 没问题,但 b 建议调低到 0.6 左右,因为长文档里的关键词出现次数多,b 太大会过度惩罚长文档,导致长文里真正命中的段落被压到很后面。
另外,中文场景一定要做自定义分词。用默认的按单个汉字切分会引入大量噪声。我们用的方案是通用词典 + 领域词典叠加,把项目里的产品型号、专业缩写提前加入词典。这一步直接影响 BM25 召回质量,甚至比对 embedding 模型做微调还见效。
3.3 向量检索的 TopK 和距离阈值怎么定
向量检索的 TopK 不能拍脑袋。我们的经验是先设定一个“重排阶段能承受的上限”,倒推召回数量。如果重排模型一次处理 50 个候选要 400ms,业务要求检索阶段不超过 800ms,那召回 top50 是安全的。如果机器配置不够,可以把召回数量压缩到 30,但要同步提高混合召回里的 BM25 权重,保证不丢长尾命中的结果。
关于距离阈值,我不建议设置太死。HNSW 的 ef_search 参数比阈值更值得调,ef_search 越大,召回越全但延迟越高。我们用 50 万段索引时,ef_search 从默认的 40 调到 128,召回率提升了接近 7 个百分点,延迟只增加了 20ms。阈值则只用来过滤明显不相关的候选,比如归一化余弦相似度低于 0.35 的直接丢掉,避免把噪声送进重排。
3.4 语义缓存:把重复提问的时延直接打到 500ms 以内
M-RAG 上线后我们做了一次压测,发现纯检索和重排优化已经能压掉 1 秒多,但生成阶段的延迟还是高。后来加入两层缓存,效果非常明显。
第一层是会话级缓存:同一个会话里如果用户追问“那第二点呢”,直接把上一轮的检索结果和生成上下文继承过来,跳过重新检索。第二层是语义缓存:对用户 query 做 embedding,和近 24 小时内的历史 query 计算相似度,如果相似度超过 0.92,就复用之前的生成结果。这两层缓存叠加后,命中场景下的端到端延迟从平均 2.8 秒降到了 800ms 以内。缓存失效策略用的是“时间衰减 + 文档更新触发”,文档库有增量更新时,涉及 doc id 的缓存立即失效。
3.5 重排模型训练时最值得注意的样本构造问题
重排模型训练数据不是越多越好,关键是难负样本的比例。我们第一批训练数据里负样本全是随机从文档库抽的,模型学得很好但线上效果一般,后来才发现问题:随机负样本太简单,模型没有见过“看着相似其实不相关”的对抗样本。
改成难负样本策略之后,效果才有明显提升。难负样本的来源有两个:一是用 BM25 召回到 top20 但没有被人工标注为相关的内容;二是用 embedding 模型算出来相似度很高但答案无法回答当前问题的片段。这两类样本能让重排模型学会区分“表面相关”和“真实相关”。经验比例是正样本:难负样本:随机负样本 = 1:2:2,这样训练出来的模型最皮实。
4. 线上运行中的常见问题与排查技巧
即使架构设计到位,线上还是会遇到各种奇怪问题。我把这几类问题整理成一张速查表,方便大家对照排查。
| 常见现象 | 典型原因 | 处理手段 |
|---|---|---|
| 检索结果明显不对,但延迟正常 | 分词词典缺专业词,或向量索引未更新 | 检查自定义词典和索引同步任务 |
| 重排后 top1 反而变差 | 重排模型没吃够难负样本 | 构造难负样本重新训练,不要只加数据量 |
| 生成答案引用错误文档 | 压缩阶段把关键限定条件句子过滤掉了 | 降低抽取阈值,或把与数字、否定词相关的句子强制保留 |
| 端到端时延时高时低 | 语义缓存失效策略太激进 | 延长缓存时间,按文档更新事件精准失效 |
| 多层级索引导致存储膨胀过大 | 句子向量重复存储 | 去掉句子级向量,改用父子映射 + 段落级向量 |
4.1 为什么加了重排之后,准召率反而下降了
这个问题我遇到不止一次。原因是重排模型的训练集和真实 query 分布不一致。比如训练数据全来自业务侧提问,但线上用户经常用口语化表达,模型没见过就乱打分。解决办法是定期从线上收集低置信度 query,人工标注一部分,混合进训练集做增量训练。注意不要全量重训,而是在原有模型基础上用较小学习率做 2-3 个 epoch 的微调,避免灾难性遗忘。
4.2 压缩阶段把答案给“压缩没了”
我们最初设计了一个比较激进的抽取阈值,分数低于 0.6 的句子统统丢掉。结果某些需要上下文推导的问题,模型把前提条件的句子丢掉了,生成时只能靠猜。后来我们加了一条保护规则:只要句子中包含数字、否定词、条件词(如“除非”“仅当”)就强行保留。这个规则上线后,因为压缩导致的关键信息丢失问题减少了六成。
4.3 缓存导致回答过时怎么办
语义缓存最怕的是文档更新了,但缓存还在返回旧内容。常规做法是定期全量清理,但这样缓存命中率会下跌。我们的方案是给每个文档维护一个版本号,缓存记录中带上涉及的所有 doc id 版本号。一旦版本号变化,相关缓存条目自动失效。这样既保证数据新鲜度,又不需要清空全部缓存。
4.4 一组我们自己的实测对照数据
最后放一组我们内部压测的对照数据,环境是 4 卡推理机器,文档库 50 万段,50 并发压测,平均长度约 300 字回答。传统单路向量 RAG 的平均端到端时延是 3.6 秒,M-RAG 在未命中缓存时是 1.9 秒,命中缓存时是 0.7 秒。召回准确率(人工判断 top5 包含可用答案的比例)从 68% 提升到 86%,生成答案引用缺失率从 22% 降到 4%。这里时延下降主要靠重排压缩减少了传给大模型的 token 数量,准确率提升主要靠多粒度索引和混合召回。
最后说点实际感受
M-RAG 这套方案并不是什么高深的学术创新,更像是一系列工程取舍的组合拳。我个人最大的体会是,RAG 优化一定要先做链路计时,再谈具体技术选型。很多人一上来就换大模型或者微调 embedding,其实先把索引切分、混合召回和缓存做好,往往就能解决一半问题。另一个心得是别怕多一套重排组件,它带来的时延增加通常远小于你少传几千个无关 token 给大模型节省的时间。如果你也在做 RAG,建议先从自己的真实 query 日志里拉一批难例,拿这些难例逐条看当前检索结果,你会发现真正的优化线索全在失败样本里。