ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG重排瓶颈:LLM驱动的UPR相关性判定与工程实践

RAG重排瓶颈:LLM驱动的UPR相关性判定与工程实践

1. 检索之后那一步,才是决定答案质量的分水岭

做 RAG 系统的人大概都有过这种体验:向量库搭好了,embedding 模型挑了又挑,chunk 大小调了又调,召回率指标看着还不错,可最后喂给大模型的上下文里,总混着两三条"看起来像那么回事、其实根本答非所问"的段落。模型被这些噪声一干扰,输出的答案就开始跑偏,要么漏掉关键信息,要么干脆把不相关的内容缝合进答案里。

我以前处理这类问题的思路很粗暴——加大召回数量,指望在更多候选里总能包含正确的那条,然后靠大模型的上下文窗口硬吃下去。结果就是 token 消耗飙升,延迟变长,而准确率的提升却远不成正比。真正让我转变思路的,是意识到RAG 的效果瓶颈往往不在"召回得够不够多",而在"排序得准不准"。检索返回的 top-k 里,真正有价值的那一段经常排在第 6、第 7 位,而你只取了 top-3,它就被截掉了。

UPR 这篇工作切的就是这个位置——用 LLM 来做检索之后的 re-rank。名字叫 UPR,核心思路是用一个语言模型直接对召回的候选段落做相关性打分并重排,而不是依赖传统的 cross-encoder 或者 BM25 那种字面匹配的打分器。它要解决的问题非常具体:在检索和生成之间那道狭窄的缝隙里,把"对的那一段"顶上来,把"错的那一段"压下去。

这篇内容适合两类人看。一类是已经跑通过基础 RAG 链路、正在为准确率瓶颈发愁的工程师,你会在这里看到 re-rank 环节的完整设计思路和参数取舍逻辑;另一类是想搞明白 LLM 除了"生成"还能"判断"的读者,UPR 展示了一个很有意思的视角——同一个模型,换个用法,就变成了一个质量相当能打的排序器。下面我把这套方法的内部结构、实操细节和我自己踩过的坑,一层层拆开讲。

2. UPR 的核心机制:LLM 是怎么变成打分器的

2.1 为什么传统 re-rank 到这一步就吃力了

先说清楚 re-rank 在整条链路里的位置。以检索到生成的顺序看:查询进来,先经过召回层(通常是向量相似度检索或者 BM25),捞回 20 到 100 个候选;然后进入 re-rank 层,把这批候选按与查询的真实相关性重新排一遍;最后取前 3 到 5 个塞进 prompt,交给生成模型出答案。

召回层的任务可以概括为"宁可多捞,不能漏掉",所以它必须快,代价是粗。向量相似度只看语义空间的远近,它分不清"这段文字讲的是同一个主题"和"这段文字真正回答了这个问题"之间的差别。举个例子,查询是"某个配置项在并发场景下会导致什么问题",召回层很可能把一篇大篇幅介绍该配置项定义和用法的文档排在前面,因为它和查询在语义上高度接近。但用户真正要的是"问题",那篇讲定义的文档其实没用。

传统 re-rank 的解法是引入 cross-encoder。它把查询和候选拼在一起送进模型,输出一个相关性分数。这比双塔式的向量相似度准得多,因为查询和文档在模型内部发生了充分的交互。但 cross-encoder 有个绕不开的局限:它的能力边界被训练数据锁死了。你在某个领域的标注数据上训练,它就只在这个领域的相关性判断上表现好;换一个领域、换一种查询风格,判别能力就明显下滑。而且它对"需要推理才能判断的相关性"天然偏弱——比如查询涉及一个隐含前提,或者候选段落的相关性要靠几步逻辑推演才能确认,纯判别式的打分器很难处理。

这就是 UPR 的切入点。它不再训练一个专门的打分器,而是借用大语言模型本身的语义理解与推理能力来完成相关性判断。

2.2 把"相关性"翻译成模型能回答的问题

UPR 最巧妙的地方,也是我认为整个方法最值得借鉴的一点,是它没有让模型直接输出一个 0 到 1 的分数——那是回归任务,语言模型干这个不在行。它把相关性判断转化成一个生成式的、可判定的问题。

具体做法是构造一个模板,把查询和候选段落一起塞进去,然后让模型回答"这个段落是否包含回答查询所需的信息"。模型的输出被约束在一个很小的集合里,比如"是"或"否",或者更进一步,输出一个有限的等级(比如高度相关、部分相关、不相关)。这个离散输出再被映射回一个数值分数,用于排序。

这个转化看起来简单,实际上是整个方法能不能work的关键分水岭。我见过不少尝试用 LLM 打分的项目,直接让模型"输出 0-10 的相关性分数",结果非常不稳定——同一个输入跑两次可能给 7 分和 9 分,分数分布还严重偏斜,模型倾向于给所有东西打中等分,导致排序几乎没有区分度。改成分类式判断之后,输出的确定性大幅提升,因为模型面对的是一个它擅长的、边界清晰的选择题,而不是一个它不擅长的连续值估计。

下面是一个我实际用过的 prompt 结构,逻辑上贴合 UPR 的思路:

你是一个检索结果相关性判断器。给定一个查询和一段候选文本, 判断该候选文本是否包含回答查询所必需的信息。 查询:{query} 候选文本:{passage} 请只输出以下之一: - 相关:候选文本直接包含或能推出回答查询所需的信息 - 不相关:候选文本与查询无关,或仅有主题上的相似 输出:

关键约束是最后那句"只输出以下之一"。很多人在这一步会加一堆解释性要求,比如"请说明理由",结果模型输出一长段分析,你还得再写一个解析器去提取结论,徒增不稳定因素。如果确实需要理由(调试阶段有用),我的建议是把理由和判定拆成两次调用,或者用结构化的输出格式强制分离,不要让它们混在一段自由文本里。

2.3 分数映射与排序:离散判定怎么变成可比的分数

模型给出"相关/不相关"之后,还需要把这些判定转成能排序的分数。最直接的做法是二值化:相关的记 1,不相关的记 0,然后按分数降序排列。这在候选数量不多时够用,但当候选有 30 条、其中 12 条都被判为"相关"时,这 12 条内部就没法区分先后了,你还是得靠原始召回分数兜底。

UPR 这类方法通常会引入更细的等级划分,把判断结果分成 3 到 5 档。常见的分档方式是:

判定等级含义映射分数
完全相关直接包含答案或核心依据1.0
部分相关包含部分信息,需要结合其他段落0.7
主题相关讨论同一主题但不回答问题0.3
不相关与查询无关0.0

这个表格背后的逻辑值得说一句:"主题相关但答非所问"和"彻底无关"必须分开。前者在 RAG 里是最危险的噪声,因为它在向量空间里离查询很近,很容易被召回并排在前面,但喂给生成模型只会造成干扰。给它 0.3 而不是 0,是为了在排序时让它沉下去,同时保留它在某些边缘情况下作为补充信息的可能。

实际排序时,我的经验是不要完全抛弃原始召回分数。比较稳妥的融合方式是加权,比如最终分数 = 0.7 × LLM 判定分 + 0.3 × 归一化后的向量相似度。这个权重不是拍脑袋定的,我用过几组不同配比实测下来,LLM 判定占大头是必要的(否则 re-rank 就失去意义了),但完全清零召回分数会丢掉一个有用的信号——向量相似度在判断"语义领域是否匹配"这件事上其实相当可靠,只是不擅长判断"是否真正回答了问题"。两者互补。

3. 上下文窗口与批处理:UPR 落地时最现实的两道坎

3.1 一次判一条还是一次判一批,这是道取舍题

把 UPR 的思路搬到生产环境,第一个撞上的问题就是成本。假设召回 50 条候选,一条一次 LLM 调用,就是 50 次请求。如果每次请求平均 800 token、耗时 1.5 秒,串行跑完要一分多钟——这个延迟对任何在线问答系统都是不可接受的。

所以核心问题变成:能否把多个候选段落放进同一次调用里,让模型一次性给出所有判定?答案是能,但代价和风险都要看清楚。

批处理的收益很直观:50 条候选如果按 10 条一批,只需要 5 次调用,延迟和成本直接降一个数量级。但这里有个隐蔽的陷阱——批内位置偏差。我在实测中发现,当同一个 prompt 里放了 8 到 10 个候选段落后,模型对排在前面和排在后面的段落判定标准会出现轻微漂移,尤其当某条候选特别长、占用了大量上下文时,后面的短候选容易被"草率处理"。

我的处理办法是限制单批的 token 总量而不是条数。按条数切批看似整齐,实际会出问题:一批里全是长文档时,上下文被撑爆,模型后半段的判断质量断崖下跌。改成按累计 token 数切,比如每批控制在 3000 token 以内(不含模板部分),批与批之间的判定质量就稳定多了。这个数字不是固定的,得看你的模型上下文窗口和候选段落的平均长度来定,一般取窗口容量的 25% 到 35% 比较安全。

还有一个细节:批内顺序要打乱。如果候选本来就按召回分数排序,同一批里前面的总是高分候选,模型可能形成"前面的就是相关"的惯性。把每批内部的顺序随机化,能有效削弱这种偏差。这个技巧不增加任何成本,但对判定质量有可观测的提升。

3.2 输出解析的健壮性,比 prompt 写得好不好更重要

第二个现实问题是解析。你要求模型"只输出相关或不相关",但总有那么几次,它会输出"该段落与查询相关,因为它提到了……",或者"不相关。"带个句号,甚至中英文混着来。如果你的解析器是严格的字符串相等判断,这些情况全部会被当成解析失败。

我在这上面吃过一次不小的亏。早期版本的解析逻辑写得很脆,用output == "相关"来判断,上线后大概有 7% 的请求解析失败,失败后我默认当作"不相关"处理,结果是一部分本该排在前面的段落被误杀,答案质量莫名其妙地掉。排查了两天才定位到解析器上。

后来我把解析层重写成"容错提取"的逻辑,思路是这样的:

def parse_judgement(raw_output, valid_labels): text = raw_output.strip().lower() # 优先匹配完整标签 for label in valid_labels: if label in text: return label # 退化:关键词命中 if any(k in text for k in ["不相关", "无关", "否", "no"]): return "不相关" if any(k in text for k in ["相关", "有关", "是", "yes"]): return "相关" # 兜底:交给调用方决定,而不是静默失败 raise ValueError(f"无法解析判定结果: {raw_output[:50]}")

注意最后那个raise。解析失败绝不能静默降级成某个默认判定,必须把异常抛出来让上层感知。上层收到异常后,可以选择重试这一次调用、或者对这一条候选直接回退到原始召回分数。静默降级是最糟的选项,因为它会让错误无声地渗透到最终答案里,而你完全无从定位。

提示:如果你的判定标签是"相关/不相关"这种反义词对,务必在匹配时先判断"不相关"。因为"不相关"这个字符串本身包含"相关"两个字,按顺序匹配会全部误判成"相关"。我见过不止一个项目栽在这个字符包含关系上。

4. 判定质量的边界:UPR 在哪些查询上会失灵

4.1 需要多跳推理的查询,单段落判定天然不足

UPR 的判定单元是"单个段落对查询的相关性"。这个设计在绝大多数事实型查询上表现很好,但有一类查询会让它系统性失灵:答案需要跨多个段落拼接才能形成。

举个具体的场景。查询是"某任务的配置项 A 和配置项 B 同时修改时会有什么影响"。答案分散在两条文档里:一条讲 A 的作用和取值范围,一条讲 B 的约束条件,没有任何单独一条文档直接回答"同时修改的影响"。这时候 UPR 对这两条文档分别判定,很可能都给出"部分相关"——因为它们各自都只涉及查询的一部分。结果是这两条都拿不到最高分,反而是一条笼统介绍该任务配置体系的总览文档可能被判为"完全相关",因为它提到了 A 和 B 两个词。

这个偏差不是模型能力问题,是判定单元的粒度问题。段落级判定看不到段落之间的互补关系。我目前的应对办法有两个方向:

第一个是把判定粒度上移,对高价值的查询先做一次段落聚类,把可能互补的候选合并成一个更大的块再判定。代价是 token 消耗增加,所以只对分数在前 30% 的候选做这一步。

第二个更实用:在生成阶段而不是排序阶段解决。也就是说,接受 re-rank 只能做到"把相关的都选进来",别指望它选出"唯一正确的那一条",然后把 top-k 的 k 值适当放大(比如从 3 提到 5),让生成模型承担拼接的工作。这个思路是承认分工——re-rank 负责过滤明显不相关的,生成负责组织信息。

4.2 否定式与条件式查询,是判定器的传统弱区

还有一类查询我需要单独拎出来说:带否定或者强条件的查询。比如"哪些情况不需要开启这个选项"、"如果不满足某个前置条件会怎样"。这类查询在向量空间里和它的反面表述("哪些情况需要开启")极其接近,召回层经常把反面文档捞进来,而 UPR 的判定器也容易被带偏。

我在测试集上专门统计过这个子类的准确率,比整体准确率低大约 15 到 20 个百分点。改进手段是在 prompt 里显式强化对否定词的注意,比如加一句"注意查询中的否定表述,候选文本若描述的是相反情形,应判为不相关"。这句话加进去之后,这个子类的准确率有明显回升,但对整体没有副作用。

注意:这类针对性的 prompt 补丁不要无脑堆。每加一条约束,都在消耗模型的注意力,加多了会让它在普通查询上的判断变得犹豫。我的原则是:先把失败案例归类,只针对高频且高影响的失败类型加补丁,且每加一条都要在固定测试集上回归验证。

4.3 候选段落本身质量差时,re-rank 救不回来

这一点必须说透,因为它决定了 UPR 的能力上限。re-rank 是在已有候选里重新排序,它不能创造候选。如果召回的 50 条里压根没有包含答案的那一段,UPR 判得再准也没用,它最多只能从一堆垃圾里挑出相对不那么垃圾的。

我踩过这个坑:有一阵子答案准确率上不去,我一直在调 re-rank 的 prompt 和分档,折腾了好几天没起色。后来把失败案例的召回结果 dump 出来一看,超过四成的失败案例,正确段落根本就没被召回。这种情况下 re-rank 再怎么优化都是无效功。

所以正确的排查顺序应该是:先看召回层有没有把正确段落捞回来,再看 re-rank 有没有把它排上来,最后看生成层有没有用好它。三层分开评估,才能知道瓶颈在哪。我自己习惯在每次评估时同时输出两个指标:召回结果里包含正确段落的比例(召回命中率),以及最终 top-k 里包含正确段落的比例(重排后命中率)。这两个数字的差距,直接告诉你 re-rank 环节贡献了多少价值。

5. 一套可落地的 UPR 式 re-rank 实现路径

5.1 先用小样本把判定 prompt 调到稳定,再谈工程化

我强烈建议的顺序是:先别管批处理、别管并发、别管缓存,用 50 到 100 条带标注的查询-候选对,把判定 prompt 调到一个可接受的稳定水平。

调 prompt 时我关注的不是"最高分案例判得对不对",而是边界案例的一致性。具体做法是把同一批样本跑三遍,统计三次判定结果相同的比例。如果某条样本三次判定里出现了两种结果,这条就是不稳定样本,值得单独拎出来分析——它往往暴露了 prompt 里的一个模糊地带。我的经验是,一个成熟的判定 prompt 应该在边界样本上做到 90% 以上的三次一致率,做不到就继续改措辞,不要急着上量。

这里有个反直觉的发现:增加判定等级的细度数,不一定会提升排序质量,反而可能降低稳定性。我试过把等级从三档扩到五档(完全相关、较相关、部分相关、弱相关、不相关),结果模型在中间三档之间的区分变得很随机,三遍一致率掉到 70% 出头。退回三档之后,一致率回到 92%,而最终答案准确率几乎没有损失。等级划分的粒度要匹配模型实际的判别能力,超出这个能力的细分只会引入噪声。

5.2 缓存与短路:把平均成本打下来的两个手段

判定逻辑稳定之后,接下来是工程优化。两个最有效的手段:

第一是查询级缓存。大量重复查询(尤其在客服、文档问答场景)会命中相同的候选集合,把"查询 hash + 候选段落 hash"作为 key 缓存判定结果,命中率往往能到 20% 到 40%。缓存的有效期设置要看知识库的更新频率,我个人习惯设 24 小时,配合知识库更新时主动清空。

第二是短路策略。不是所有候选都值得送进 LLM 判定。原始召回分数极低的候选(比如相似度低于某个阈值,或者排在召回列表后 20%),可以直接判为不相关,跳过 LLM 调用。这一刀切下去能砍掉相当一部分调用量,而对最终排序质量的影响很小,因为那些候选本来就几乎不可能进入 top-k。阈值怎么定?我用的是分位数而不是绝对值——把召回分数从高到低排,只对前 60% 到 70% 送进判定,剩下的直接丢弃。用分位数而不是绝对阈值,是为了让策略在不同查询、不同分数分布下都保持一致的行为。

这两个手段叠加之后,我实测的平均单次查询 LLM 调用次数从 50 次降到了 12 次左右,延迟从不可接受降到了可用范围。

5.3 评估时别只看最终答案,要拆开看每一层

最后说一下评估。如果只盯最终答案的准确率,你很难知道改进到底来自哪里,也很难判断下一步该往哪优化。我的做法是建立一张分层评估表,每次迭代都完整跑一遍:

评估层指标说明
召回层召回命中率候选集合里是否包含正确段落
重排层top-k 命中率排序后前 k 条是否包含正确段落
重排层排序提升度正确段落的平均排名变化
生成层答案正确率最终输出是否回答了问题
系统层平均延迟、平均调用次数成本与响应速度

这张表最有价值的一列是"排序提升度"——正确段落在重排前后的平均排名变化。如果这个数字很大(比如从平均第 9 位提到第 2 位),说明 re-rank 环节确实在干活,值得继续投入;如果提升度很小,说明你的瓶颈不在这一层,该去查召回或者生成。我见过不少团队在 re-rank 上反复优化却收效甚微,根因就是他们的召回命中率本身就不高,重排再准也只是在有限的好候选里腾挪。

顺便提一个我在实际使用中形成的小习惯:把每次失败案例的完整链路快照存下来——原始查询、召回列表及分数、LLM 判定结果、重排后顺序、最终答案。这些快照是优化时最宝贵的材料。盯着聚合指标猜问题,远不如翻十条具体的失败快照来得高效。我很多次都是在翻快照时突然发现"哦,原来这条是被解析器误伤了"或者"这条正确段落压根没进候选"。这种发现靠指标是看不出来的。

UPR 这套思路真正的价值,不在于它用了多大的模型,而在于它把"相关性判断"这件原本需要专门训练的事,转化成了一个通用模型能直接回答的问题。这个视角可以迁移到很多地方——凡是你需要一个判别器但标注数据又不够的场景,都值得想想能不能把它改写成生成式的判定问题。当然前提是,你得接受它带来的延迟和成本,并且在工程层面把批处理、解析容错、缓存短路这些"脏活"做扎实,否则再好的判定思路也落不了地。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进