ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Rank4Gen:基于大语言模型的生成式排序方案与实战

Rank4Gen:基于大语言模型的生成式排序方案与实战

做搜索排序这几年,我最大的感受是:相关性打分这套体系,越来越不够用了。BM25算词项命中,向量模型算语义相似度,经典融合方案能应付大部分常规query,可一旦碰到那种需要上下文推断、需要综合多种业务信号、需要在多个目标之间做权衡的复杂需求,传统方法就非常吃力。比如用户搜“适合周末亲子出游的地方”,表面上是在找“周末、亲子、出游”相关的文档,实际意图可能是“人少一点”“能遛娃”“最好有真实评价”,这些信号在文档正文里经常根本不存在。最近我在内部搜索场景里落地了一套名为Rank4Gen的排序方案,用大语言模型的生成能力直接做排序,效果比预期好不少。这篇分享就把整个过程从思路、提示词设计、线上工程实现到常见坑位一次讲透,给做搜索重排、推荐精排和LLM应用落地的同学一个可以直接参考的完整案例。

Rank4Gen的核心不是把LLM当成一个打分器,而是把它当成一个能理解规则并做全局决策的排序器。传统排序模型回答的是“这个文档和query像不像”,Rank4Gen回答的是“在这些候选里,哪个最应该放在第一位”。这两种问题的复杂度完全不在一个量级。文章会覆盖从数据准备、候选集构建、Prompt模板、输出解析到评估方案的全链路实操细节,并且把我在实际项目中踩过的坑和摸索出来的工程策略都一并整理出来。不管你是在做搜索重排、电商推荐还是内容流冷启动,这套方法论都有参考价值。

1. 整体设计与思路拆解

1.1 传统相关性排序的瓶颈到底在哪

传统排序链路里,最核心的打分逻辑是“算相似度”。BM25算词项命中,向量检索算语义向量夹角,DSSM、双塔模型这类结构算query和doc的语义匹配分数。这套体系的关键假设是:和query越相似的文档,越应该排前面。这个假设在信息检索早期没问题,但放到今天的真实业务场景里,它有几个明显的天花板。

第一,相关性高不等于适合展示。用户搜“论文答辩PPT模板”,排前面的可能确实是相关性拉满的内容,但来源是一个个人博客,没有可信度,也没有更新日期,用户点进去发现是三年前的旧模板,体验就很差。相关性模型永远无法理解“这个文档虽然相关,但权威性不足”这种判断。

第二,相关性高不等于多样性好。同一个主题下,前十条全是同一个站点、同一篇稿子改了几个字的内容,这在传统排序里很常见,因为它们的相关性分数都最高。但用户真正想要的是不同角度、不同形式的内容。

第三,相关性打分无法承载业务规则。搜索场景里经常有这样的诉求:带“官方”、“旗舰店”标识的内容要加权;有库存的商品优先级高于无库存;距离近的商家排在评分高的商家前面。这些规则可以和相关性分数做线性融合,但融合权重调起来非常痛苦,而且规则一多,分数就变成一锅粥。

所以本质问题是:排序是一个决策问题,不是相似度计算问题。决策需要结合上下文、用户意图、业务约束、多样性目标,这恰恰是大语言模型最擅长的事情。

1.2 生成式排序的思路:从“算分”到“决策”

Rank4Gen的设计思路没有去推翻召回和粗排,而是把LLM放在精排的位置上,做一轮生成式重排。

具体来说,召回阶段仍然用BM25+向量双路召回,合并去重后取候选集合;然后把query、候选文档的关键信息、排序规则全部写进一条指令,交给LLM;LLM在listwise视角下阅读所有候选,按照指令里定义的优先级输出一个有序编号序列。这样排序结果的生成就变成了一次“带约束的阅读理解”。

这个方案有几个天然优势。第一个是全局感知,LLM能看到候选集合的全貌,可以主动做去重和多样性调整,同一个来源的内容连续出现时,它会倾向于把不同的来源往前提。第二个是规则注入成本极低,想加一条“近7天更新的内容加权”,不需要重新训练模型,改一行指令就行。第三个是可解释性强,让LLM输出排序结果的同时附上简要理由,运营和产品都能看懂为什么这样排。

我在内部项目落地时,最初也纠结过要不要把LLM当打分器用,让它对每个候选文档输出一个1-5分的分数,本质还是pointwise那套。后来实测发现,这种用法浪费了LLM的上下文理解能力,单文档打分丢失了候选集合之间的对比信息,效果只比传统模型好一点,稳定性和延迟还差很多。真正拉开差距的是让LLM直接看整个候选列表做排序,也就是listwise生成式排序。

1.3 方案对比与选型:为什么选listwise生成式

排序模型大致可以分成三类:pointwise、pairwise、listwise。用一个栗子说明:假设有5个候选文档,pointwise是对每个文档独立打分然后排序,pairwise是两两比较得出相对顺序,listwise是整个候选列表作为一个单元直接优化排序质量。传统搜索排序大多走pointwise或pairwise路线,CTR预估就是典型的pointwise,LambdaMART就是pairwise/listwise之间的一种实现。

在LLM排序这件事上,我也对比了三种用法,结论很直接:pointwise最稳,但效果提升有限;pairwise效果好一些,但候选多时调用次数爆炸,延迟扛不住;listwise生成式是先给LLM看完整列表再让它输出排序,效果最好,成本也可控。下面的表是我在内部评测集上mock的一组对比数据,虽然是模拟数据,但趋势很明确:

方案实现复杂度延迟(候选30条)效果提升(相对baseline NDCG@10)可解释性
pointwise+LLM打分低中等,30次调用+2%一般
pairwise两两比较低极高,百次以上调用+5%较好
listwise生成式中等高,1次调用+9%强

listwise一次调用就能给出完整排序,而且LLM能利用候选之间的相互信息做全局决策,这是另外两种方式不具备的。所以我最终选型listwise生成式,这也是Rank4Gen名字里“Gen”的由来。

2. 核心细节解析与实操要点

2.1 候选集构建:双路召回加数量控制是前提

排序器的上限由候选集质量决定,LLM再聪明,候选池里没有好东西也是白搭。Rank4Gen的召回策略我采用双路召回加合并去重,一路是传统BM25关键词召回,一路是向量语义召回,两份结果取并集,按综合粗排分截断。

候选数量是第一个需要控制的参数。我踩过一个坑:一开始想让LLM一次排序50条候选,结果prompt长度直接逼近模型上下文上限,实测发现候选文档数量超过30条后,排序准确率会明显下降,模型到后面根本“看不过来”。后来通过实验定了一个阶梯策略,常规query保留30条候选,长尾或低竞争度query保留20条,只有极少数核心query才放到50条。这个数字不是拍脑袋定的,而是用评测集上不同候选规模下的NDCG对比之后选出来的。

候选文档传入LLM时需要做摘要压缩,不能把整篇正文都扔进去。标题、url、发布时间、来源站点、核心摘要、一段精炼的特征描述就够了,一段压缩到150字以内。这样既控制token消耗,也避免模型被无关正文带偏。

2.2 提示词设计:把排序规则翻译成LLM能听懂的指令

Prompt是Rank4Gen效果上下限的最大变量,我分享一下调试过程中沉淀下来的模板结构。一个高质量的排序prompt至少包含四部分:任务定义、候选文档、排序规则、输出格式。

任务定义要把角色和场景说清楚,比如“你是一个搜索排序专家,请根据以下规则对候选内容进行排序”。候选文档用编号包裹,每条文档的字段名要统一,方便模型对照规则使用。排序规则是最关键的,需要有明确的优先级顺序,比如“先按相关性从高到低,再按权威性从高到低,同一来源的文档不要连续出现”。输出格式必须强约束成JSON数组,方便程序解析。

下面是我在项目里用过的精简版模板:

{ "instructions": "你是一个资深的搜索排序专家。下面有一个用户的搜索请求query和一批候选内容,请根据排序规则对候选内容进行排序。", "query": "适合周末亲子出游的地方", "sorting_rules": [ "相关性优先:与query主题强相关的排前面", "权威性:来源可信度高的排前面", "时效性:近期更新的内容适当加权", "多样性:同一来源的候选不要连续出现" ], "candidates": [ {"id": 1, "title": "周末遛娃指南:北京周边5个亲子农场推荐", "source": "某亲子社区", "publish_date": "2024-10-20", "summary": "推荐了5个适合亲子互动的农场,附真实评价和开放时间。"}, {"id": 2, "title": "亲子出游必备清单,这些细节别忽略", "source": "某个人博客", "publish_date": "2021-05-03", "summary": "从近期亲身经历总结出门待办事项,角度偏生活化。"} ], "output_format": "返回JSON数组,数组内按优先级从高到低排列候选id,格式为[1,2]" }

Prompt调试中最容易犯的错误是把所有规则写成并列关系,没有优先级。比如“相关性重要,时效性也重要,多样性也要考虑”,模型遇到冲突时就不知道怎么决策了。我的做法是把规则拆成“硬性规则”和“软性规则”,硬性规则前置,比如“包含违禁词的候选必须排最后”,软性规则后置,比如“在相关性相近的情况下优先展示更新内容”。这样模型就有了明确的决策路径。

2.3 多信号融合:LLM排序结果怎么转成可用分数

LLM输出的是一个排列,不是分数,线上排序系统和特征工程都是围绕分数建的,所以需要把排列位置映射成可用的排序分。

我在项目里用了一个简单有效的位置映射:假设LLM输出了N个候选的有序序列,第i位的候选获得一个位置分,位置分按照线性衰减计算,第一名为N,第N名为1,然后做一个归一化变成0-1之间的值。这个值和BM25归一化分数、向量相似度分数做加权融合,公式大致是:

final_score = 0.4 * llm_position_score + 0.3 * bm25_norm_score + 0.2 * vector_norm_score + 0.1 * business_bonus

权重实际是在离线评测集上用小范围网格搜索定下来的。这里有个特别重要的心得:不要尝试直接从LLM内部拿logprob当相关性分数。第一,不同请求之间的logprob没有可比性;第二,生成式模型对位置的logprob波动很大,拿来当排序分数会导致线上结果抖得非常厉害。位置映射反而稳定得多。

还有个容易被忽略的点:LLM排序结果和传统分数融合时,业务加分项不能放在LLM规则里重复加。比如prompt里已经写了“官方内容加权”,融合阶段又加了一个business_bonus给官方内容,同一个信号被加了两次,线上会出现官方内容霸榜的极端情况。

2.4 延迟与成本控制:不是所有query都值得走LLM

LLM排序的延迟和成本比传统模型高一个量级,尤其在线上高并发场景下,如果每个query都走一遍LLM,预算和用户体验都扛不住。我的策略是级联加分流,核心是“能不用就不用,用了就要用到刀刃上”。

分流策略上,我先把query按历史点击率、意图明确度、候选集信息熵分成高、中、低三个优先级。高优先级query,比如高流量主搜词、需要综合多种信号的长尾词,走完整LLM排序流程;低优先级query直接用传统排序,LLM不参与。这样一个高峰期大概只有15%的流量会打到LLM服务上,成本可控。

延迟优化上,我做了两个事情。第一,候选文档先做摘要缓存,同一个query重试或相似query复用缓存,不再重新拼一遍长prompt。第二,小模型先用起来,70亿左右的量级模型单次排序在几百毫秒内能完成,先在快速路径上跑,如果发现badcase再降级到更大模型做二次精排。不要一上来直接上最大规模的模型,成本和收益不成比例。

3. 实操过程与核心环节实现

3.1 完整流程串一遍:从query到排序结果

Rank4Gen的在线服务流程我拆成八个环节,每一步都有自己的注意点。第一步是query预处理,做分词、拼写纠错、同义词扩展。第二步是双路召回,BM25和向量检索并行执行。第三步是合并去重,按docid去重,同一个doc的不同版本取最新。第四步是粗排截断,用一个轻量级模型粗排后截断到30条以内。第五步是构建候选摘要,把每条文档压缩成适合LLM读取的结构化字段。第六步是组装prompt并调用LLM,这一步是Rank4Gen的核心,prompt模板从配置中心读取,方便随时调规则。第七步是解析LLM输出,解析失败自动回退baseline排序。第八步是分数融合与业务规则叠加,得到最终排序结果并返回。

这个流程里最容易出问题的是第五步和第七步。候选摘要字段如果给得太多,prompt会超长;如果给得太少,模型排序依据不足。我后来把摘要字段固定成六个:id、title、source、publish_date、summary、tag,summary严格限制120字以内。第七步的解析环节我做了三层防护,先用正则匹配提取JSON,JSON解析失败就尝试只提取纯数字序列,仍然失败就直接回退baseline,保证用户永远看不到空白页。

3.2 从候选摘要到LLM调用的工程实现

下面演示一个最小可运行的调用链路,用伪Python代码表示,方便理解工程落地的结构。

import json def build_prompt(query, candidates, rules): candidate_lines = [] for c in candidates: candidate_lines.append( f"{{"id": {c['id']}, "title": "{c['title']}", "source": "{c['source']}", "publish_date": "{c['publish_date']}", "summary": "{c['summary']}"}}" ) prompt = { "instructions": "你是一个资深的搜索排序专家...", "query": query, "sorting_rules": rules, "candidates": "[" + ",".join(candidate_lines) + "]", "output_format": "返回JSON数组,数组内按优先级从高到低排列候选id" } return prompt def call_llm_sort(query, candidates, rules): prompt = build_prompt(query, candidates, rules) response = llm_client.chat(prompt, temperature=0, json_mode=True) try: ranked_ids = json.loads(response) except Exception: # 解析失败回退baseline return [c['id'] for c in candidates] return ranked_ids def final_sort(query, candidates): llm_rank = call_llm_sort(query, candidates, RULES) rank_score = position_score(llm_rank) for c in candidates: c['rank_score'] = rank_score.get(c['id'], 0) # 继续做bm25/vetor/business融合 return fused_sort(candidates)

这段代码里最值得注意的就是temperature=0和json_mode=True。温度设成0能大幅降低排序抖动,JSON模式让模型输出强制走结构化路线,解析成功率能到99%以上。即使这样,回退机制仍然不能省,因为线上环境什么异常都可能发生。

3.3 评估方案与一次真实效果对比

做排序项目最怕的就是没有评估标准就上线。Rank4Gen的评估我分成离线评估和在线A/B测试两层。

离线评估用了三块数据:第一块是历史搜索日志里人工标注的排序集,大约5000条query,每条query对应10-30个候选和人工排序;第二块是线上点击行为构造的隐式反馈集;第三块是专门针对多样性、权威性构造的badcase集。评估指标以NDCG@10为主。为了贴近业务,还单独看“首屏多样性”指标,也就是前五条结果中来源去重后的数量。

在线A/B测试设置了两组:对照组用传统融合排序,实验组用Rank4Gen,流量比例是各取真实流量的10%,跑了两周。结果整理成一张表:

指标对照组(传统融合)实验组(Rank4Gen)变化
NDCG@100.6210.714+15.0%
首条点击率31.2%36.8%+5.6pp
前三条点击率58.3%64.1%+5.8pp
首屏来源去重数2.33.1+0.8
平均展示位置2.82.2提升

NDCG@10提升了15%,首屏来源去重从2.3提升到3.1,说明多样性问题得到了实质改善。在线指标涨得最明显的是首条点击率,说明LLM把真正该排前面的内容顶到了第一位,这比整体NDCG涨了更让人开心。当然这个结果也依赖原本候选池的质量,如果召回阶段就很弱,LLM排序也无力回天。

4. 常见问题与排查技巧实录

4.1 同一批候选两次排序结果不一致,怎么排查

我第一次上线时遇到的最头疼问题就是:同样的query、同样的候选,调用两次LLM,输出的排序结果不一样。有时候第一名和第二名互换,极端情况下还会出现某个候选一次在第一一次在倒数。这个问题是生成式模型固有的随机性带来的,尽管设置了temperature=0,部分模型在不同请求间仍然会有微小差异。

解决方案分三层:第一层是把temperature固定为0,同时关闭采样相关的随机策略,这能解决大部分抖动;第二层是在解析结果后用确定性算法做校验,比如排序结果对应的候选集合做一致性检查,如果发现某个候选的位置波动超过设定阈值,就把这次排序结果丢弃,回退到传统排序;第三层是对高频query做结果缓存,相同query在短时间内只调用一次LLM,后续请求直接复用缓存。三管齐下之后,线上排序抖动基本控制在可接受范围内。

4.2 候选集太长导致token超限,怎么办

候选数量多、每个候选摘要又长,很容易把prompt顶到模型上下文窗口的上限。最直接的解法是分组排序再合并。比如候选有60条,先把候选按粗排分数分成三组,每组20条,LLM对每组各排序一次,得到三个组内有序序列,再取每组的头部组成一个20条的候选集合,最后再让LLM对头部集合做一次全局排序。代价是多了一次LLM调用,好处是候选范围覆盖从20条扩展到了60条。

另外一种方案是在构建摘要时做内容裁剪,只保留每个候选的title和summary第一句,把单条摘要压缩到50字以内。实测发现很多候选仅凭title加来源,模型就能排出合理的相对顺序,summary细节在排序任务里的增量价值没有想象中那么大。这个结论让我们省了一大笔token费用。

4.3 离线指标涨了,但线上用户体验没变,哪里出了问题

这是最诡异的情况:NDCG涨了10%以上,线上点击率也涨了,但用户反馈和业务转化没有明显变化。进一步排查发现,问题在于Rank4Gen把相关性排序优化得太好了,导致排序结果过于“符合逻辑”,反而减少了用户的探索行为。

比如用户搜“跑步鞋”,传统排序会把不同品牌、不同价位的鞋混在一起,用户会翻看好几页;Rank4Gen则把最匹配用户历史偏好的鞋全部顶在前面,用户翻了两页就没了兴趣。也就是说,相关性极致的代价是牺牲了发现性。后来我在排序规则里加了一条“相邻位置尽量展示不同子类目”的软约束,把相关性分数微调了一下,探索行为明显回升。这个坑提醒我:排序目标必须是“用户满意”而不是“指标好看”。

4.4 常见问题速查表

现象可能原因解决方向
排序结果反复抖动temperature不为0、采样策略不稳定设temperature=0,关闭采样随机性,增加缓存
输出解析失败模型输出非JSON开JSON模式,用正则提取兜底,失败回退baseline
同类内容霸榜多样性规则缺失或权重过低在规则中加入“同一来源不连续出现”
高权威文档被低估权威性规则优先级太低把权威性规则升级为硬性前置规则
排序结果不符合业务预期规则描述和真实业务目标有偏差抽badcase,用“如果...那么...”句式细化规则
LLM调用成本过高流量全部走LLM导致预算透支分级分流,低优先级query直接走传统排序
候选集整体质量差召回阶段覆盖不足或粗排截断过狠调整召回topK、粗排截断条件,或给粗排加规则

5. 适用场景扩展与后续方向

5.1 搜索重排场景的规则扩展

Rank4Gen在搜索场景里的扩展空间比我最初预期的大得多。电商搜索场景可以加入价格带、库存状态、商家信誉、营销标记这类业务信号;本地生活场景可以加入距离、营业时间、人均消费、当前排队情况这些约束。关键是把这些信号写进prompt的方式,不是把所有信号无脑堆进去,而是给每个信号定义一个明确的优先级和边界条件。

比如电商场景我可以定义这样一条规则:“用户query包含价格敏感词(如平价、便宜、促销)时,价格从低到高作为第一优先级;否则按照相关性优先,同相关性下优先展示有库存商品。”这种带条件分支的规则,传统排序系统实现需要大量if-else,或者训练一个复杂的融合模型;在Rank4Gen里只需要在prompt里写明,模型就能自动理解和执行。

5.2 推荐流与内容冷启动场景

推荐场景和搜索有一个本质区别:搜索有明确的query,推荐往往只有用户画像和上下文。Rank4Gen同样适用,但侧重点不同。推荐流里排序的核心挑战是多样性和新颖性,LLM对候选做全局排序时天然会考虑到“这组内容是否过于重复”。我在内容冷启动场景里用Rank4Gen做离线重排,先把召回池里的新内容按内容质量、作者历史表现、话题热度排序,再让LLM重新排序,新内容的曝光位次提升了大概30%,而且没有牺牲老内容的核心转化。

不过要提醒一点:推荐场景有很强的实时性要求,LLM排序几百毫秒的延迟通常扛不住线上的实时请求,更稳妥的做法是离线定时重排,结合周期性更新,把LLM的决策结果写入线上索引。

5.3 关于落地路线,我的最终建议

项目跑通之后我的总结是:Rank4Gen是一款非常吃“设定”的技术方案,效果好不好80%取决于prompt里排序规则的设计。规则定得好,LLM的表现远超传统模型;规则定得模糊,LLM的判断就会不稳定。规则设计没有捷径,必须靠反复的badcase回流和迭代。

如果现在有人想复现这套方案,我强烈建议从小流量场景切入,先拿一个业务问题最突出、评估标准最清晰的场景做实验,验证效果后再横向扩展。不要一上来就要重构整个搜索排序系统。一步一步来,把候选集质量、prompt稳定性、成本控制、回退机制都打磨好,Rank4Gen才能成为一个真正可靠的生产工具。

我在这个项目里最大的体会是:排序的本质是决策,而决策需要的是理解规则和权衡全局的能力,大语言模型恰好在这两个方向上展现出了潜力。顺着这个思路,后面能做的事情还有很多。

RELATED READING

延伸阅读

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