做RAG项目这几年下来,我最大的体会是:大多数人把精力全放在提示词工程和大模型调参上,结果效果还是不稳定,最后排查下来,十有八九是召回侧出了问题。生成模型再聪明,检索回来的内容不对,输出照样是一本正经的胡说八道。这篇是RAG深度解析系列的第四篇,我把视角从生成侧挪到检索侧,好好聊聊召回和重排这两个环节:它们的职责边界是什么,选型逻辑怎么定,具体实现怎么做,以及我在生产环境里踩过的那些真正影响效果的坑。
1. 召回与重排:决定RAG天花板的两个环节
很多刚接触RAG的人会以为,把文档切好塞进向量库,用户提问后用相似度搜索捞几条出来拼进上下文,这就完事了。实际跑起来才发现,检索质量对最终答案的影响,远比想象中大。与其说RAG是"大模型问答",不如说它是"先检索、后生成"的两段式系统:生成侧决定回答的下限,检索侧决定回答的上限。
1.1 "召回决定上限,生成决定下限"到底怎么理解
我习惯用一个图书馆找书的类比。生成模型像一个经验丰富的图书管理员,他再会整理、再会综述,前提是有人把正确的书放到他桌上。如果找书的人(召回模块)从书架上扒拉下来一堆无关的旧杂志,管理员也只能硬着头皮从这些垃圾材料里编答案。提示词写得再花哨,模型也没办法从它没见过、没被喂进来的信息里变出正确答案。
所以在实际项目中,我会先盯检索链路,再谈生成侧优化。一旦发现某个知识库问答效果忽好忽坏,第一反应不是换大模型,而是看看top几条召回结果里到底有没有真正相关的内容。很多"幻觉"问题的根源,其实是上下文里混进了大量不相关甚至互相矛盾的片段,模型被带着跑偏了。
1.2 召回和重排的职责边界:一个负责"广撒网",一个负责"精打捞"
召回阶段的首要目标是别漏掉。文档库动辄几万、几十万个分块,要在几十毫秒内从里面捞出可能相关的候选,这一阶段算法必须够快、覆盖要够广,精度可以粗糙一些。召回重排的典型分工是:召回阶段先从全库捞回几十到上百条候选,重排阶段再对这批候选做精细打分,挑出最靠前的几条送入模型。召回负责"把对的东西留在大名单里",重排负责"把大名单里真正对的东西顶到最前面"。
这里有个容易误解的点:很多人觉得只要向量检索够好,就不需要重排。实际不是这样。向量召回本质用的是双塔编码——查询和文档各自编码成一个向量,用向量距离近似相关性。这种结构为了速度牺牲了交互精度;而重排用的交叉编码器,让查询和文档在模型内部逐字交互,精度上了一个台阶,但速度慢得多。所以两阶段不是叠床架屋,而是"速度"和"精度"这对矛盾的经典解法。
2. 召回选型:向量检索、稀疏检索与混合检索的取舍
召回侧的第一个纠结,就是到底用哪种检索方式。我在项目里见过三种典型方案:纯向量检索、纯BM25关键词检索、向量+BM25混合检索。结论先放这里:如果你的文档包含大量专有名词、编号、人名或精确术语,纯向量检索大概率不够,混合检索是更稳妥的基线方案。
2.1 纯向量检索的优势与硬伤
向量检索的原理,是把文本映射到高维语义空间,语义相近的句子在空间里距离更近。它的强项是"意会"——用户问"这个系统会不会卡",文档里写的是"高并发场景下的延迟表现",两者字面不重叠,但语义能搭上,这种场景向量检索完胜。
但纯向量检索有几个硬伤,我在实际项目里反复撞到。第一,它对专有名词和精确ID不敏感。用户问"订单号A10087的状态",向量模型很容易在空间里被语义相近的"订单状态查询"带偏,而精确的"10087"这个信息反而被平均到向量里,变成了噪声。第二,少数派的冷门实体召回很差,因为向量模型在训练时对低频词的表征本来就不够稳定。第三,数字、版本号、型号这类token,向量检索经常算不清相似度。
2.2 BM25这种"老古董"为什么还死不掉
BM25是经典的关键词检索算法,核心思路是:文档里某个词出现得越多、这个词在整个文档集里越稀有,那这篇文档跟查询的相关性越高。它不理解语义,只做字面匹配,但恰恰是这种"死板"让它非常可靠:用户搜"订单号A10087",它能精确匹配到包含这个ID的文档,粒度和可解释性都一流。
实际项目里,BM25的最大价值是兜底和互补。向量检索漏掉的精确匹配内容,往往要靠BM25捞回来。而且它不依赖GPU、没有模型部署成本,库小的时候直接内存里跑,速度飞快,非常适合作为保底方案。很多团队把BM25当"上古技术"弃用,我反而觉得它是整个检索链路里最稳的一根拐杖。
2.3 混合检索与RRF分数融合的正确姿势
混合检索就是把向量检索和BM25的结果合并。听上去简单,合并细节才是关键。两家打分体系完全不一样——向量分数是余弦相似度,数值范围约在-1到1之间且集中在零点附近;BM25分数则取决于词频和文档长度,可能是几十分甚至上百分的数。直接相加就是拿公斤和摄氏度做加法,没有任何意义。
我推荐用RRF(Reciprocal Rank Fusion)融合,它不看原始分数,只看排名。公式很朴素:对每条文档,把它在多个检索结果里的排名位置换算成分数,然后求和。具体做法是:对文档在每个召回列表里的名次r,贡献分是1 / (k + r),k是平滑常数,通常取60。排在越前面的文档得分越高,两个列表都排前列的文档,融合后总分自然最高。
def rrf_fusion(ranked_lists, k=60): scores = {} for hits in ranked_lists: for rank, doc_id in enumerate(hits, start=1): scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank) return sorted(scores.items(), key=lambda x: x[1], reverse=True)RRF最大的优点是稳定、不用调权重,因为排名融合天然规避了不同检索器分数量纲的问题。我一般会两头各取前50条做融合,宁可召回多一点,让重排去收敛。
2.4 分块策略是隐形的召回杀手
做过几个项目之后我越来越确定:检索效果不好,很多时候不是检索算法的问题,而是上游分块把信息切碎了。分块太大,单块包含大量无关内容,向量被"稀释",语义向量反映的是整块的平均味道,命中率自然低;分块太小,完整的信息被拦腰截断,比如一个方案说明的"前置条件"和"操作步骤"被分到两块,检索时只捞到其中一块,答案就是不完整的。
我的通用起点是:普通技术文档按固定窗口分块,块大小256~512个token,重叠50个token左右。重叠的意义是防止关键信息恰好落在切分边界上被截断。如果是结构化文档,优先按标题和章节边界切;如果是表格,建议一行或一个完整记录作为独立块。分块策略没有银弹,但一定要在评测集上对比验证,而不是拍脑袋定数字。
3. 重排阶段:交叉编码器的工作原理与工程边界
重排是整个检索链路里最容易被低估的一环。它做的工作,可以从两个"塔"的结构差异来理解。
3.1 双塔编码器和交叉编码器的本质区别
向量检索用的双塔结构,查询和文档分别过同一个编码器,产出两个向量,再算相似度。缺点是查询和文档在编码过程中没有交互——模型只能看到"查询的语义"和"文档的语义",却看不到"这两段文本放在一起是否匹配"。很多细微的匹配关系(比如否定词、条件限定、"除了A之外"这种特殊结构)在独立编码时很容易丢失。
交叉编码器则直接把查询和文档拼接成一句话,比如[CLS] 查询文本 [SEP] 文档文本 [SEP],一起送进模型做自注意力。模型内部每个token都能"看到"另一边的token,能捕捉逐字的交互证据,匹配精度显著提升。代价也很直观:每处理一对查询-文档对,都要完整过一遍模型,无法像双塔那样预计算向量。所以它只能用来重排少量候选,不可能在全库范围扫描。
3.2 开源重排模型的选型与部署成本
市面上开源的重排模型大多基于交叉编码器结构,参数量从几亿到几十亿不等。实际项目中我并不一味追大模型,重排环节吃的是推理延迟,模型越大,单个查询-文档对的延迟越高。常见的做法是,召回100条候选,重排阶段用中小尺寸模型(参数量控制在1亿到4亿这个档位),保证总延迟在几百毫秒内。
部署上有两个建议。第一是批量推理,把待重排的候选拼成一个batch一次过模型,比一条一条跑快很多。第二是精排和粗排分层:如果候选项特别多(比如多路召回合并后有三四百条),可以先用一个更小的重排模型粗筛到50条,再用更大更准的模型精排到5~10条。分层重排的收益在文档量级变大后会非常明显。
3.3 重排的时机:什么时候必须上,什么时候可以省
不是所有场景都值得加重排。我的判断标准很简单:候选集越大、文档越相似、对答案准确性要求越高,越必须重排。三路以上召回必须重排。理由很直接——多路召回把不同语义的信号混在一起,向量觉得靠前的和BM25觉得靠前的往往不是同一批文档,没有重排统一打分,最终进入上下文的顺序基本靠运气。
反过来,如果你的文档库很规整、查询类型很固定,且单路向量召回就能稳定命中(用评测集验证过hit rate足够高),可以暂时不上重排。但我要提醒一句:这种"稳定"在文档库规模扩大后很容易被打破,重排迟早要补上。所以架构上最好预留重排接口,别把代码写死成"召回后直接拼上下文"。
4. 一条可复现的召回+重排Pipeline实现细节
理论讲再多,不如一条能跑通的链路。下面是我在模拟项目X里用的实现思路,基于一套完全虚构的运维知识库,核心逻辑可以直接迁移到你的场景。
4.1 从文档加载到分块入库
第一步是加载文档并分块。我用的是递归字符分割的思路:先按段落切,段落太长再按句子结合长度控制切。分块后给每一块生成唯一ID,顺便记录来源文档名和章节路径,方便后面追溯答案出处。
def split_document(doc_name, content, chunk_size=300, overlap=50): # 伪代码:递归分块逻辑 paragraphs = content.split("\n\n") chunks = [] buf = [] buf_len = 0 for para in paragraphs: if buf_len + len(para) > chunk_size and buf: text = "\n".join(buf) chunks.append({"doc": doc_name, "text": text, "chunk_id": f"{doc_name}_{len(chunks)}"}) # 简单重叠:保留末尾一段 buf = buf[-1:] buf_len = len(buf[0]) buf.append(para) buf_len += len(para) if buf: chunks.append({"doc": doc_name, "text": "\n".join(buf), "chunk_id": f"{doc_name}_{len(chunks)}"}) return chunks分块之后,把每块文本向量化后写入向量库。同时维护一个BM25用的倒排索引。这里我强烈建议把两步索引建在同一套分块ID上,否则后面融合时对不上号。向量ID、BM25文档ID、原始分块文本,必须三处一致,这是整个链路的基石。
4.2 构建双路召回并做RRF融合
查询进来后,并行跑两路检索:向量检索取top 50,BM25检索取top 50,然后做RRF融合。向量检索这一步有个小细节:查询文本直接拿去编码,效果通常一般。我更习惯先做一次轻度改写,把口语化的提问翻译成更接近文档表达习惯的检索词。这个技巧后面专门讲,先按下不表。
融合完成后的候选列表,我一般保留前100条交给重排,数量太少怕漏,太多则重排耗时明显上升。这一步的耗时预算大概在80~150毫秒,向量检索和BM25并发执行,基本都能满足。
4.3 接入交叉编码器重排
重排阶段把RRF融合后的前100条,跟原始查询拼接成查询+文档对,批量喂给重排模型。模型输出的相关性分数从低到高排序,取前8条作为最终上下文。这里有个经验值:进入大模型上下文的块数通常不要超过8~10条,一是上下文窗口有限,二是相关性排在后面的块往往是噪声,喂多了反而干扰生成。
def rerank(query, candidates, top_n=8): pairs = [[query, cand["text"]] for cand in candidates] # 伪代码:批量预测相关性分数 scores = rerank_model.predict(pairs, batch_size=16) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [cand for cand, score in ranked[:top_n]]重排结果要不要进行"最低分过滤"?我在项目里是做的。如果重排模型给出的最高分都低于某个阈值,说明候选集整体可能都不相关,这时候与其硬塞给大模型,不如触发兜底逻辑(比如提示用户换个问法,或者直接告知没有查到相关内容)。阈值需要在评测集上标定,不能拍脑袋。
4.4 上下文拼装与生成
重排选出的块,按相关性从高到低拼进提示词,同时带上来源信息,让大模型在回答时引用出处的块编号。拼装时我会在两个块之间加一个明确分隔标记,并且在提示词里写清楚"只依据给定材料回答,材料中没有的内容要明确说不知道"。这一步看似和检索无关,但上下文的结构清晰度直接影响模型是否忠实于材料。
拼装完的上下文加问题一起发给大模型,整个链路量化下来:检索总延迟约250毫秒左右,生成另算。我实测下来,这个延迟水平在多数知识库问答场景都是可以接受的。
5. 效果评测:召回率、命中率与nDCG怎么算
做检索优化最怕的就是"感觉变好了"。我在前期项目里吃过不少亏——凭肉眼翻几条召回结果,看着像那么回事就上线,结果用户反馈一塌糊涂。后来我老老实实搭了一套离线评测,才发现很多直觉判断都是错的。
5.1 先建一套带标注的评测集
评测集是检索优化的地基。我从线上真实用户日志里抽样了数百个问题,人工为每个问题标注出"应该在知识库里哪几个文档分块里能找到答案"。标注工作很枯燥,但无可替代。没有标签,后面所有指标都是空的。
考虑到标注成本,我的做法是:每个问题不只标一个正确答案块,而是标1~3个相关块,允许一定的主观判断差异。更重要的是,我刻意在评测集里保留了那些容易翻车的类型——专有名词查询、数字版本查询、口语化表达、同义改写,每类至少几十条。这样评测结果才能暴露真正的短板,而不是被简单问题掩盖。
5.2 四个核心指标的理解与计算
我在RAG召回评测里最常用的指标是这几个,各有侧重,建议配合使用:
| 指标 | 关注点 | 直观理解 |
|---|---|---|
| Recall@k | 前k条是否覆盖答案 | 正确答案有没有进入候选名单,这是底线 |
| Hit Rate | 前k条是否包含至少一个相关块 | 用户这次提问有没有"救回来"的可能 |
| MRR | 第一个答案出现的位置 | 正确答案排名越靠前越好,影响生成质量 |
| nDCG | 整个排序质量加权 | 不仅看有没有,还看排序是否合理 |
Hit Rate和Recall@k看似相似,区别在于:Hit Rate是二值判断——前k条里只要有任意一条相关就算命中;Recall@k则计算相关块被召回的覆盖率。比如一个问题标注了3个相关块,前5条召回了其中1个,Hit Rate是100%,Recall@5却只有约33%。如果文档库里同一信息存在多个等价表述,召回率会偏低但Hit Rate很好,这种情况实际上并不影响问答效果。
5.3 评测结果怎么指导调优
拿到指标后,我会先看Hit Rate@20和@50。如果@20已经接近95%以上,说明召回把正确答案都捞进来了,瓶颈在重排——重排的排序质量拖累了最终效果。如果@50都很低,那问题出在召回本身,可能是分块太碎、向量模型领域不匹配、或者查询改写不够。用这种分解方式,我能很快定位到该优化哪一段。
nDCG这种排序质量的指标,主要用于重排模型的对比评测。同一个候选集,分别用不同重排模型排序,算nDCG@10,差距一目了然。我在项目里对比过粗排模型和精排模型,结论是:精排模型在nDCG上的优势,最终会转化为大模型引用正确文档的概率提升,这个转化非常明显。
6. 生产环境里的坑与调优心得
最后这部分,我想把实战里最常遇到的几个问题集中讲透。这些坑在教科书和官方文档里几乎不会出现,但每个都会实打实影响线上效果。
6.1 召回数量k和重排数量r的配合
召回100条、重排取8条,这个数字组合看着随意,实际上需要根据文档库规模和数据形态来定。文档块总数在几万这个量级时,top 100候选大体够用;如果库涨到几十万块,建议把召回扩到200条,否则可能漏掉真正的答案。重排取多少条,取决于生成侧对上下文长度的要求和场景容忍度。
判断这个组合合不合理,最直接的方法是看"召回到重排的转化率":如果重排后进入最终上下文的8条里,经常只有两三条是真正相关的,说明召回数量或分块粒度有问题;如果前8条里五六条都高度相关,说明候选质量和排序都健康。把这种分布情况记录下来,比死记参数更有价值。
6.2 向量模型更新后必须重建索引
这个坑我踩得很惨。当时换了更强的向量模型,只重新编码了新增文档,老索引完全没动。结果线上检索质量断崖式下跌——新旧模型产出的向量空间根本不是一回事,新文档和老文档的向量在数学上不可比,相似度计算完全失真。
所以,替换向量模型的那一天,必须全量重算所有分块的向量,并且建议灰度放量:先处理一部分文档,对比新旧索引在同一批评测问题上的召回表现,确认无异常再全量切换。同理,文档更新频繁的业务,最好建立增量更新机制,但增量更新绝不能跨越向量模型版本。
6.3 查询改写:低成本高回报的隐藏技巧
用户提问是口语化的自然语言,文档是书面化技术描述,两者之间天然有一道鸿沟。比如用户问"数据库连不上咋排查",文档里写的可能是"数据库连接异常处理"。向量模型虽然能理解一定语义,但改写后的查询命中率明显更高。
我的做法是用一个大模型做查询理解,把原始问题改写成更适合检索的形式:补全缺失的实体名、把口语换成文档常用术语、识别并保留核心专有名词。改写本身是个轻量调用,延迟可控。注意一点:改写后的查询只用于检索,不要直接把改写结果当作用户问题展示,否则体验很怪。
6.4 延迟、缓存和降级兜底
检索链路一旦加上重排,延迟会比单路向量检索翻几倍。线上要求响应快时,我对热门问题做结果缓存——相同或近似的问题直接命中缓存,跳过完整检索。缓存键建议做归一化处理,比如小写化、去标点,命中率会高很多。
兜底策略也很重要。重排服务偶发超时,不能整个接口挂掉,我的做法是把超时捕获后降级为"召回结果直接按RRF分数排序进上下文",虽然精度差一些,但保证服务可用。同理,向量库如果暂时不可用,BM25单路召回的降级方案也必须能顶上。稳定性的优先级永远高于单次回答的完美度。
做RAG项目越久,我越觉得检索链路才是真正拉开效果差距的地方。模型可以换、提示词可以调,但召回和重排的架构和细节,决定了一个知识库问答系统能走多远。个人经验是:先搭好评测集,用数据驱动的方式一步步优化召回和重排,这条路虽然前期辛苦,但每一步的改进都能稳稳地反映到最终答案质量上。