ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

RAG检索后重排:LLM无监督Listwise滑窗排序落地

RAG检索后重排:LLM无监督Listwise滑窗排序落地

RAG 做得久了会发现一个挺尴尬的事实:决定最终回答质量的,往往不是你 prompt 写得多花哨,而是检索回来的那几段文本到底对不对。召回阶段为了速度,几乎必然要把"精准"让给"覆盖",Top-K 里混进三五个不相关的段落是常态。而把这些噪声挑出去、把真正能回答问题的内容顶到最前面,就是re-rank(检索后重排)要干的活。UPR 这类工作提出了一件很有意思的事:不用标注数据、不用训练 cross-encoder,直接让LLM读 query 和候选文档,靠 prompt 把顺序重新排一遍。这篇就围绕这个思路往下拆——它到底怎么工作的、prompt 该怎么写、滑窗排序怎么实现、参数怎么定、踩过哪些坑,以及在你自己的 RAG 链路里怎么把它接进去。适合已经跑通过基础 RAG、正被"召回不精准"折磨的中高级读者,也适合刚接触重排、想搞清楚 listwise 和 pairwise 差在哪的同学。

1. 先把问题摆清楚:UPR 到底在补哪块短板

1.1 召回阶段为什么天生就"不准"

很多人第一次搭 RAG 时会有一个错觉:只要向量库建得好、embedding 模型选得新,检索结果就该是准的。实际跑起来才知道,Top-10 里能用的往往只有三到五条,剩下的要么是同主题但不回答问题,要么是关键词撞上了但语义完全跑偏。

根子在于召回阶段的架构约束。向量检索是典型的双塔结构:query 单独编码成一个向量,文档单独编码成一个向量,最后靠余弦相似度比一下。这个设计的关键特征是query 和文档在编码阶段完全没有见过对方,两者的交互被压缩成了一次向量内积。信息损失是结构性的,不是换个更大的 embedding 模型就能解决的。

传统 BM25 的问题更直白,它只看词频和逆文档频率,同义词、指代、隐含意图统统无能为力。用户问"这个接口超时了怎么办",文档里写的是"请求响应延迟过高的排查路径",字面上一个词都不重合,BM25 直接判零分。

再加上工程上还要叠一层近似最近邻检索(HNSW、IVF 之类),为了毫秒级响应牺牲一部分精度;文本分块(chunking)又会把一段完整语义从中间切断。这几层损耗叠在一起,召回阶段天然就是"高召回、低精度"的定位——宁可多捞点,也别漏掉。多捞的这部分噪声,就是重排阶段存在的全部理由。

1.2 重排干的是"精读",不是"粗筛"

如果打个比方,召回像是图书馆管理员按书名关键词给你抱来二十本书,重排则是你坐下来一本本翻目录,判断哪三本真的能解决你的问题。前者要快、要广,后者要慢、要准,两者的评价标准根本不同。

从技术路径上讲,重排是从"表示型匹配"切换到"交互型判断"。召回阶段 query 和文档各算各的向量,重排阶段则把 query 和文档放在同一个上下文里一起读,让模型判断"这段内容能不能支撑回答这个问题"。这也是 cross-encoder 一直比双塔效果好的原因——它允许 query 的每一个词去和文档的每一个词做注意力交互。

问题在于,cross-encoder 需要标注数据训练。你得有一批 (query, 正例文档, 负例文档) 的三元组,得跑训练流程,得维护模型版本。对于中小团队或者长尾业务场景,这份标注成本经常高到不划算。

UPR 这类思路的价值就在这里:它把 LLM 当成一个零样本的 cross-encoder 来用。模型在预训练阶段已经见过海量的"问题—答案"共现,对"什么内容能回答什么问题"这件事有相当强的先验判断力。你只要把判断标准用 prompt 说清楚,它就能直接给出相关性排序,整个过程不需要一次梯度更新。

1.3 四条技术路线的取舍对照

把可选方案摊开对比,选择就清晰了。下面这张表是我自己在选型时整理的,延迟数据基于百条量级的候选集、单卡或单次 API 调用的粗略量级,具体数值会随硬件和模型规模浮动,但相对关系是稳的。

方案是否需要训练相对效果单次延迟量级主要短板
BM25 直接排序否基准毫秒无语义,同义改写全丢
双塔向量召回否(预训练)比 BM25 好毫秒query/doc 无交互
训练式 Cross-Encoder是通常最好十毫秒到百毫秒标注成本高,领域迁移难
LLM 无监督重排否接近甚至超过中小 cross-encoder百毫秒到秒级贵、慢、输出不稳定

看这张表就能明白,LLM 重排不是要取代谁,它填的是一个特定的空档:你有语义判断的需求,但没有标注数据,或者标注数据的性价比不划算。典型场景包括冷启动的新业务、多语言混杂的知识库、更新极快的文档集——这些场景下训练一个 cross-encoder 的投入产出比很难看,而 prompt 调一调就能上,试错成本低得多。

代价也很实在:延迟从毫秒级跳到百毫秒甚至秒级,成本按 token 计费上去,输出格式还可能不听话。所以后面几章的重点,全在"怎么把这个代价控制住"。

2. UPR 的核心设计:无监督 prompt 怎么把顺序排出来

2.1 Pointwise、Pairwise、Listwise 三条路

LLM 重排的实现方式,按"一次喂多少东西给模型"可以分成三类,这三类的差异远比想象中大。

Pointwise是最直觉的做法:query 加上一条文档,让模型打一个相关性分数(0 到 10,或者"相关/不相关"二分类),然后按分数排序。优点是实现简单、每条文档独立处理、可以并行、天然支持增量。缺点也很明显——分数之间不可比。同一批文档,模型给 A 打 7 分给 B 打 6 分,并不意味着 A 比 B 更相关,可能只是这两次调用时模型的打分尺度飘了。实践中经常出现一堆文档全是 7 分或者全是 8 分的情况,区分度极差。

Pairwise是让模型比较两条文档:"针对这个问题,A 和 B 哪个更相关?"输出 A 或 B。它的判断更稳定,因为比较是相对的,不需要模型建立一个绝对尺度。但代价是复杂度 O(n²)——20 条文档要比较 190 次,100 条要比较 4950 次,成本直接失控。工程上一般配合锦标赛排序或者快速排序式的分治来降低比较次数,但仍然比 pointwise 贵一个数量级。

Listwise折中得最好:一次把一批文档(比如 10 到 20 条)连同 query 一起塞进去,让模型直接输出重排后的顺序。它充分利用了模型在一次上下文内的相对比较能力,调用次数是 O(n/w),w 是窗口大小。这也是目前这类工作里主流的选择。

范式调用复杂度分数可比性输出稳定性适合场景
PointwiseO(n)差中候选极多、需要并行、做粗筛
PairwiseO(n²)好高候选少(<20)、要求高精度
ListwiseO(n/w)好中(受位置偏差影响)主流选择,10 到 100 条候选

顺带说一句,这三者并不互斥。比较务实的组合是:先用 pointwise 或向量相似度把 200 条砍到 30 条,再用 listwise 精排到最后 5 条。分层过滤,每一层用最合适的工具。

2.2 "无监督"这三个字到底指什么

看到"无监督"很容易误解成"完全不用管数据"。准确的说法是:不做参数更新,不依赖人工标注的相关性标签。

模型用的是现成的通用大模型,可能经过指令微调,但没有在你的业务数据上继续训练。整个"学习"过程完全发生在 prompt 内部——你通过指令描述、few-shot 示例、输出格式约束,把"什么样的内容算相关"这个标准传达给模型。判断力来自预训练阶段积累的语言知识和世界知识,不是来自你的标注集。

这个定义的边界要说清楚。实际落地时你仍然需要一小批带标注的评测集来做两件事:一是验证这套方案在你的领域到底有没有效果(有些垂直领域的相关性标准非常特殊,通用模型判断不了),二是在多个 prompt 模板之间做选择。这批标注不用来训练,只用来评估,量级大概几百条 query 就够,成本完全可以接受。

还有一个隐含前提:模型判断的相关性标准,得和你的业务标准基本对齐。举个反例,做法律条文检索时,"相关"可能指的是"同一法条体系下的适用条款",而通用模型理解的"相关"更偏向"话题相同"。这种偏差靠 prompt 描述能缓解一部分,但缓解不了根本性的口径差异。这种情况下,老老实实标数据训 cross-encoder 更靠谱。

2.3 prompt 模板的四种写法与实测差异

模板设计是整个方案里最"手艺活"的部分,直接影响效果上限。我试过四类写法,差异相当明显。

第一种,二分类问答题。直接问"以下文档能否回答该问题?只回答是或否"。优点是输出极短、解析简单、token 省。缺点是信息量只有一位,当候选集里有一半文档都是"是"的时候完全无法区分。

第二种,分级打分。给 0 到 10 分,并简单描述每个分档的含义(比如 8 到 10 表示直接包含答案,4 到 7 表示部分相关,0 到 3 表示无关)。比二分类细腻,但仍然有前面说的尺度漂移问题。缓解办法是把评分标准写得非常具体,用可观察的特征来描述每一档,而不是用"非常相关""比较相关"这种模糊词。

第三种,直接排序。把候选文档编号后一起给模型,要求"按相关性从高到低输出编号列表"。这就是 listwise 的典型形态,效果好、调用少。关键约束是只让模型输出编号,绝不让它复述原文——一旦让它输出内容,它就可能改写、压缩甚至编造,而编号到原文的映射关系一旦断了,整个排序结果就没法用。

第四种,带解释的思维链。让模型先说明每条文档为什么相关或不相关,再给排序。效果通常最好,因为显式推理过程能抑制模型的随意性。代价是输出 token 翻好几倍,延迟和成本都上去了。我的建议是:调模板阶段用这种方式做诊断,看清楚模型到底在关注什么;稳定后就换成纯编号输出,节省成本。

你是一个检索结果重排器。下面有一个用户问题和 {n} 条候选文档, 每条文档都有编号。请判断每条文档对回答该问题的帮助程度, 并按帮助程度从高到低输出文档编号。 要求: 1. 只输出编号,用英文逗号分隔,例如:3,1,4,2 2. 必须输出全部 {n} 个编号,不许遗漏、不许重复 3. 不要输出任何解释、不要复述文档内容 4. 若多条文档帮助程度相同,保持它们原本的先后顺序 用户问题: {query} 候选文档: {documents}

这段模板有三个细节值得单独拎出来说。第一,明确要求"输出全部编号、不许遗漏",因为模型在窗口内文档较多时非常容易漏掉一两条——漏掉的文档你没法确定它是被判为最不相关,还是模型单纯忘了。第二,明确"相同相关度保持原顺序",这一句能显著降低输出的抖动。第三,"不要复述文档内容"这句必须写上,否则模型有相当概率自作聪明地总结一番。

2.4 零样本能 work 的底层逻辑与它的边界

为什么一个没在排序任务上训练过的模型,光靠 prompt 就能把顺序排个八九不离十?

根本原因在于预训练语料的统计规律。互联网文本中充满了"问题—解答"的自然共现结构:论坛的提问与回复、百科的条目与定义、文档的章节标题与正文。模型在这个分布上训练了几千亿 token,实际上已经隐式学到了"什么样的文本片段能够回答什么样的问题"这个映射。相关性判断对它来说不是新任务,只是已有能力的一次调用。

指令微调进一步强化了这一点。经过指令对齐的模型更擅长理解"请判断相关性"这类元任务描述,也更愿意遵守输出格式约束。

但这条逻辑有它的边界,而且边界挺硬。模型判断的是"语义上的回答可能性",不是"事实上的正确性",也不是"业务上的适用性"。它看到一段话在语言层面很像在回答这个问题,就会给高分,哪怕这段内容其实是过时的、错误的、或者来自一个不该被引用的来源。所以在需要强时效、强权威性的场景里,LLM 重排只能作为一环,还得配上时间过滤、来源权重这些规则。

另一个边界是长度。模型对长文档的判断力明显弱于短文档,超过一定长度后中间部分的信息会被稀释。这一点直接决定了后面要讲的截断策略。

3. 落地实现:从最小可跑通版本开始

3.1 整条链路的骨架

先给一个完整的处理流程,从召回结果进来到最终 Top-K 出去:

  1. 召回阶段拿到 N 条候选(建议 N 在 30 到 100 之间,太少没有重排空间,太多成本扛不住)
  2. 每条文档做长度截断,控制单条 token 数
  3. 按窗口大小 w 切分成若干组,相邻组之间保留重叠
  4. 每组构造 listwise prompt,调用模型拿到组内重排结果
  5. 用滑动窗口的方式把各组的局部排序合并成一个全局排序
  6. 取最终 Top-K,交给下游的生成环节

这里最容易被忽略的是第 2 步和第 3 步的顺序关系。必须先截断再分窗,不能反过来。先分窗再截断会导致某些窗口里全是超长文档的头几百字,另一些窗口里全是尾部的几百字,判断质量的方差会非常大。

关于截断长度,我的经验值是单条文档控制在 150 到 250 个 token 之间(中文大概 100 到 200 字)。这个数字的来源是权衡:一方面,回答一个具体问题通常不需要读完整篇文档,开头的摘要段和结论段信息密度最高;另一方面,窗口内要放 10 到 20 条文档,单条太长会让总上下文迅速逼近模型上限,而且长上下文本身会稀释注意力。

3.2 滑窗排序的具体实现与参数计算

滑窗排序的核心思想是:一次只能比较 w 条,那就让这些窗口互相重叠,逐步把整体顺序"磨"出来。

def sliding_window_rerank(query, docs, window=20, step=10, llm_call=None): """ docs: 已按初始顺序排好的文档列表(初始顺序通常是向量相似度) window: 每个窗口内的文档数 step: 窗口滑动步长,通常取 window // 2 """ n = len(docs) if n <= window: return llm_call(query, docs) # 一次性排完 # 关键:从列表尾部向头部推进 start = n - window order = list(range(n)) # 当前全局顺序,存的是原始下标 while True: end = start + window window_items = [docs[i] for i in order[start:end]] ranked = llm_call(query, window_items) # 返回重排后的下标列表 order[start:end] = ranked if start == 0: break start = max(0, start - step) return [docs[i] for i in order]

为什么从后往前推?因为滑动窗口的合并逻辑是"后一个窗口的结果会作为前一个窗口的输入"。列表越靠前的位置越重要(最终只取 Top-5 或 Top-10),让最重要的位置在最后一次窗口处理中被决定,此时模型已经吸收了后面所有文档的信息,判断依据最充分。如果从前往后排,头部顺序在第一次就被定死了,后续窗口再怎么调整也影响不到它。

参数该怎么算?举一个具体例子:召回 100 条,窗口 20,步长 10。第一次处理下标 80 到 99,第二次 70 到 89,一直到 0 到 19。总共 9 次调用。如果用步长 5,就是 17 次调用,效果略好但成本几乎翻倍。步长取窗口的一半是个经验平衡点,重叠 50% 已经足够让相邻窗口的信息传递过去。

窗口大小本身也有讲究。太小(比如 5)模型判断很准但调用次数爆炸;太大(比如 50)单次上下文压力大、位置偏差明显、而且模型在长列表里的排序质量会明显下降。10 到 20 是比较稳的区间,具体取多少要看模型的长上下文能力和单条文档的长度。

3.3 输出解析的鲁棒性处理

理论很美好,实际调用十次里总有一两次输出不听话。常见情况包括:返回了带括号的[3, 2, 1]、用顿号分隔、编号后面跟了句解释、少输出一个编号、输出了超出范围的编号、甚至返回中文数字。

解析器必须写得足够宽容,同时又要有明确的兜底策略。核心原则是:解析失败时保留原顺序,绝不能让排序结果变得更差。

import re def parse_ranking(raw_text, n): """从模型输出中提取编号序列,失败返回 None""" # 抓取所有独立的数字 nums = re.findall(r'\b\d+\b', raw_text) seq = [] seen = set() for s in nums: idx = int(s) # 编号从 1 开始,过滤越界和重复 if 1 <= idx <= n and idx not in seen: seq.append(idx) seen.add(idx) if not seq: return None # 补齐漏掉的编号,追加到末尾,保持它们原有的相对顺序 for i in range(1, n + 1): if i not in seen: seq.append(i) return seq

这段代码有两个设计取舍值得说明。第一,用\b\d+\b抓独立数字而不是按分隔符切分,因为模型的分隔符太不统一了,逗号、顿号、空格、换行都出现过,用分隔符切必然漏。第二,漏掉的编号补在末尾而不是丢弃。因为一条文档被漏掉,很可能是模型觉得它最不相关所以没提,但也可能是单纯数错了。补在末尾是对这两种情况都比较安全的处理——如果真的不相关,它在末尾是正确位置;如果是模型数错了,它本来在窗口里的位置也不会太靠前。

还有一个更高阶的鲁棒性问题:编号到内容的映射必须由代码维护,不能交给模型。模型输出的只是数字,你拿数字去索引原始列表。如果让模型在输出里带上文档摘要,它一改写你就没法回溯了,整个重排结果直接作废。

3.4 成本与延迟的实测账

这部分是真正决定方案能不能上线的地方。拿 100 条候选、窗口 20、步长 10 算,是 9 次调用。每次调用的输入 token 大概是:prompt 模板 200 token,query 50 token,20 条文档 × 200 token = 4000 token,合计约 4250 token。9 次就是约 38000 输入 token。输出方面,纯编号输出每次不到 100 token,总共可以忽略。

延迟上,如果走本地部署的中等规模模型、批量推理,单次调用可能在 300 到 800 毫秒,串行 9 次就是 3 到 7 秒。这个延迟对交互式问答来说太慢了。三个优化方向:

第一,减少候选数。召回 100 条重排到 Top-10,和召回 40 条重排到 Top-10,最终效果差不了太多,因为第 40 到 100 名之间本来就极少有真正相关的内容。先做一层便宜的粗筛(向量相似度阈值或者 pointwise 打分),把候选砍到 30 到 40 条,再上 listwise,成本直接降一半以上。

第二,窗口间并行。严格来说,从后往前的滑窗是串行的,因为后一个窗口要用前一个窗口的输出。但如果你能接受轻微的质量损失,可以把"滑动窗口"换成"分段窗口加全局合并":把 40 条分成 4 段互不重叠的 10 条,并行排完,再对每段的第一名做一次小规模的全局重排。这样 4 次调用可以全部并行,延迟降到单次量级。

第三,缓存与降级。高频 query 的重排结果可以缓存,命中率在客服类场景里通常不低。另外设置超时降级——LLM 调用超过阈值就退回原始向量顺序,保证链路不会因为重排环节卡死。

4. 效果评估与调参:怎么证明它真的变好了

4.1 指标选择与评测集构造

重排任务的评估指标,nDCG@k 是最常用的,因为它同时考虑了相关性的等级和位置——把高度相关的文档排到前面,得分就高。MRR 更简单,只看第一条相关文档出现在第几位,适合只有一个正确答案的场景。Recall@k 衡量的是前 k 条里覆盖了多少相关文档,适合"宁多勿漏"的场景。

指标关注点适用场景注意事项
nDCG@10排序质量 + 相关性等级通用排序评估需要多级相关性标注
MRR@10首个正确结果的位置问答、事实检索只看第一条,忽略其余
Recall@k前 k 条覆盖率需要下游再筛排序质量差不影响该指标
Hit Rate@k前 k 条是否命中快速验证粒度粗,不适合调参

评测集怎么来?我的做法是从线上真实日志里随机抽 200 到 300 条 query,覆盖长尾和头部。然后对每条 query,把召回的前 20 条文档拿出来,人工标注相关性等级(0 无关、1 部分相关、2 高度相关)。这个活很枯燥,200 条 × 20 篇大概要一两天。但这一步省不掉——没有评测集,所有调参都是盲调,你根本不知道自己改 prompt 之后是变好了还是变差了。

有个降本技巧:不用全部人工标。先用当前最强的方案(比如带 CoT 的 listwise 重排)跑一遍,把它给出的顺序作为弱标签,人工只做抽查修正明显错误的。这种做法叫"模型辅助标注",用在内部评测上完全可以,只要抽查比例够(20% 以上)。

4.2 关键超参的影响实测量级

调参这件事,纸上推演不如实测。下面这几个参数的敏感度,我在不同数据集上都反复验证过:

参数常见取值影响方向敏感度
窗口大小 w10 / 20 / 30太小判断准但调用多,太大质量下降中高
步长 stepw/4 / w/2越小重叠越多、效果略好、成本上升中
单条截断长度100 / 200 / 400 token太短丢信息,太长稀释注意力高
候选总数 N30 / 50 / 100超过 50 后边际收益递减明显中
输出格式纯编号 / 编号+解释带解释略好但贵数倍低

最值得投入精力的是截断长度,它的敏感度最高。截断到 100 token 时,很多需要具体参数或代码示例才能回答的问题会被误判为不相关;截断到 400 token 时,20 条文档就是 8000 token,上下文压力大,且模型在长文本里的关键信息定位会变差。200 token 左右是多数场景的甜点区。

窗口大小的敏感度次之。10 和 20 之间的效果差通常在 1 到 3 个 nDCG 点,20 到 30 之间差得更少。所以如果延迟敏感,可以放心用 10。

4.3 位置偏差:最容易被忽略的系统性问题

有一个现象值得单独拎出来:把同一批文档打乱顺序重新送进去,模型给出的排序结果经常不一样,而且倾向于保留原始顺序。这是典型的 position bias——模型对列表开头和结尾的文档关注度更高,中间部分容易被忽略。

这个问题在 UPR 这类无监督方案里尤其要命,因为你的初始顺序来自向量相似度,本来就携带了信息。如果模型只是"轻微打乱"原顺序,你就没法判断它到底是真的做了相关性判断,还是单纯在附和输入。

诊断方法很简单:拿一批文档,跑两次,一次用向量相似度顺序作为输入,一次用完全随机的顺序作为输入,比较两次输出的一致性和各自的 nDCG。如果随机顺序输入的版本明显更差,说明模型严重依赖输入顺序;如果两者接近,说明它确实在做独立判断。

缓解手段有三个。一是双向排序:正序跑一次,逆序跑一次,把两次的排名取平均(类似 Borda 计数)。成本翻倍但能显著削弱位置偏差。二是窗口内文档随机化:每次调用前打乱窗口内顺序,用编号映射回来,多跑几次取平均。三是在 prompt 里显式提醒"文档顺序与相关性无关,请独立判断"。第三种最便宜,效果也最有限,但作为基础配置值得加上。

5. 常见问题与排查实录

5.1 问题速查表

现象大概率原因排查动作处理方式
排序结果和原顺序几乎一样位置偏差 / 文档本身区分度低随机顺序重跑对比双向排序、prompt 加提示
输出编号缺失或重复窗口过大、约束不够强检查解析日志降窗口、解析器补齐、prompt 强调
部分 query 效果明显变差领域相关性标准与通用理解不符抽样看这些 query 的标注加 few-shot 示例、做规则兜底
长文档判断明显失误关键信息被截断对比不同截断长度增大截断、按段落切分后重排
延迟高到无法接受候选数太多、串行调用统计各环节耗时粗筛降 N、窗口并行、加缓存
结果在不同批次间抖动模型温度非零检查推理参数温度设 0、固定 seed

5.2 三个踩过的坑

坑一:以为重排一定能让指标变好。早期我在一个技术文档库上直接上了 listwise 重排,结果 nDCG 反而掉了两个点。排查下来发现,这个库的文档标题和摘要写得非常规范,向量召回的初始排序已经相当准,而 LLM 在判断时被文档正文里的扩展内容干扰了——它倾向于给"讲得更全面"的文档打高分,而不是"直接回答问题"的那篇。教训是:上线前一定要有 A/B 对比,别默认新方案就是更好的。后来加了"优先选择直接包含答案的文档,而非背景介绍类内容"这句约束,才把损失补回来。

坑二:prompt 里的文档编号和代码里的索引错位。有一次线上反馈排序结果完全随机,排查了半天发现是 prompt 里文档编号从 1 开始,而解析后的代码直接拿返回值当下标用,整体偏移了一位。最后一条永远排第一,第一条永远被丢弃。编号映射一定要写单测,这是最容易出低级错误又最难发现的地方。

坑三:忽略了下游生成环节的实际需求。重排的目标是"把能回答问题的文档排前面",但下游的生成模型需要的往往是"信息互补的一组文档",而不是"五篇说同一件事的文档"。纯粹按相关性排序,很容易得到五篇高度重复的内容。解决办法是在重排之后加一层去重(按内容相似度做 MMR 式的多样性重排),或者在 prompt 里加一条"避免选择内容高度重复的文档"。这个问题不在重排指标里体现,只有端到端评测才会暴露。

6. 工程落地时的几个取舍建议

6.1 什么时候该上、什么时候别上

这套方案不是万能药,有些场景上了纯属浪费。推荐上的场景:知识库内容更新频繁、来不及标注训练数据;多语言或多来源混合、cross-encoder 难覆盖;候选集规模中等(几十到一两百条)、延迟容忍度在秒级;对可解释性有要求(能通过改 prompt 快速调整判断口径)。

不建议上的场景:QPS 很高且延迟要求百毫秒以内的在线搜索;候选集动辄上千条(成本完全不可控);领域相关性标准极其特殊(法条、医疗指南这类,通用模型判断不了);已经有高质量标注数据、训好的 cross-encoder 表现稳定(没必要为了新技术栈替换掉成熟方案)。

还有一类特殊情况值得单独说:如果你只是想让 Top-5 更准,候选只有 10 到 20 条,其实 pointwise 打分加排序就够了,成本低得多,效果差距不大。listwise 和滑窗那套复杂度,是给候选多、要求高的场景准备的。

6.2 混合策略往往比纯 LLM 更实用

纯 LLM 重排的两个软肋是成本和稳定性。比较务实的做法是把它当成流水线中的一环,而不是全部。

一个我实际用过的三段式组合:第一段用向量相似度召回 60 条;第二段用轻量规则做硬过滤(时间范围、来源白名单、语言匹配),砍到 40 条;第三段用 listwise 滑窗重排到 Top-8。规则层负责那些模型不擅长的确定性判断,模型层负责语义层面的细腻区分。两层各司其职,比让模型什么都干要稳得多。

另外,把 LLM 的打分结果和向量相似度做加权融合也是个好办法。最终得分 = α × 归一化后的向量相似度 + (1-α) × 归一化后的 LLM 排名得分,α 取 0.3 到 0.5。这样即使在 LLM 判断失误的个别 case 上,向量相似度还能兜一层底,整体方差会明显降低。

6.3 模板迭代的节奏

模板不要一次写死。我的做法是建一个小型的模板库,每个模板改一个变量(比如有没有 few-shot、约束措辞的强弱、输出格式),在固定评测集上跑分,记录每次改动的增益。这个过程中会积累出一些反直觉的发现——比如有时候删掉一句看似重要的约束,效果反而更好,因为过度的约束会让模型把注意力放在格式上而不是内容上。

迭代节奏上,第一轮先把基础流程跑通、跑出基线分;第二轮集中调截断长度和窗口大小这两个高敏感参数;第三轮再打磨 prompt 措辞。顺序别搞反,很多人在第一步就开始死磕 prompt 文案,其实参数没调对,模板再好也发挥不出来。

最后分享一个我在实际项目里的体会:这套方案真正的价值不在于它比训练式重排好多少,而在于它把一个原本需要几周标注加训练的环节,压缩成了半天调 prompt 就能跑通的最小闭环。先跑起来、先看到效果、先拿到评测数据,然后再决定要不要投入资源做专门的模型——这个决策顺序,比任何单项技术的选择都重要。

返回列表