用户搜「无线耳机」,系统要在一毫秒内从千万条商品里挑出最相关的十条、并按最可能点击的顺序排好。召回(Recall)负责「别漏」,排序(Ranking)负责「排对」。搜索结果的质量上限,几乎完全由排序决定。
排序学习(Learning to Rank, LTR) 是用机器学习自动学习排序规律的方法族。它和普通分类回归的关键区别在于:优化目标是「一组结果的相对顺序」,而不是单条样本的绝对标签。本文讲清它的范式、特征、模型、评估与工程落地。
搜索系统的漏斗结构
三级漏斗
工业搜索普遍采用多级漏斗,逐级收窄候选、逐级提高计算量:
全部文档 (10^7)
│ 召回(Recall):向量/倒排,毫秒级
▼
候选集 (10^3)
│ 粗排(Pre-ranking):轻量模型,几毫秒
▼
精选集 (10^2)
│ 精排(Ranking / LTR):复杂模型,几十毫秒
▼
最终结果 (10^1)
│ 重排(Re-ranking):多样性、业务规则
▼
展示给用户
每一级的目标不同:
| 阶段 | 目标 | 常用方法 | 核心指标 |
|---|---|---|---|
| 召回 | 别漏相关文档 | 倒排索引、向量检索 | Recall@K |
| 粗排 | 快速砍掉明显不相关 | 双塔、轻量 GBDT | 与精排的一致性 |
| 精排 | 精确排好顺序 | LTR(LambdaMART/深度) | NDCG@K |
| 重排 | 多样性、去重、业务 | 规则 + MMR | 多样性、覆盖率 |
为什么需要多级
精排模型通常只有几十毫秒预算,无法对上千候选逐个打分。漏斗的本质是用便宜的方法把候选缩到精排能承受的规模。粗排与精排的一致性很重要——粗排若漏掉了精排会打高分的文档,精排再准也没用。
排序学习的三种范式
LTR 按「如何把排序问题转成可优化目标」分三类:
| 范式 | 训练单位 | 损失 | 代表方法 | 优点 | 缺点 |
|---|---|---|---|---|---|
| Pointwise | 单个文档 | 分类/回归 | LR、GBDT | 简单,复用现成模型 | 忽略文档间相对关系 |
| Pairwise | 文档对 | 相对序 | RankNet、RankSVM | 直接学相对序 | 对数量 O(n²) |
| Listwise | 整个列表 | 列表级 | LambdaMART、ListNet | 直接优化排序指标 | 实现复杂 |
Pointwise
把「相关度」当标签做回归或分类,最省事:
# pointwise:把 relevance 当标签做回归
from sklearn.ensemble import GradientBoostingRegressor
model = GradientBoostingRegressor(n_estimators=300, learning_rate=0.05)
model.fit(X_train, y_train) # y 是相关度标签(如 0/1/2/3)
scores = model.predict(X_candidates)
缺点是同一条文档在不同查询下地位不同,pointwise 无法表达。实践中常作为基线或粗排。
Pairwise
学习「文档 A 应排在文档 B 之前」,把排序转成对分类:
对同一查询下相关度不同的文档对 (A, B),若 rel(A) > rel(B),
则构造样本 (x_A - x_B, label=+1),训练二分类器。
推理时对每个候选打分再排序。Pairwise 直接关注相对序,效果通常优于 pointwise,但样本量随候选数平方增长,需要采样。
Listwise 与 LambdaMART
Listwise 直接把整个列表作为优化单位。LambdaMART 是工业界最成功的实现:它把 NDCG 这类「不可微」的排序指标,通过 lambda 梯度 转化为每个文档的梯度权重,再交给 GBDT 拟合。
核心思想是:排序指标交换两个文档位置带来的增益变化,就是它们应得的梯度大小。位置越靠前、增益越大的交换,权重越高。这让模型直接朝「提升 NDCG」的方向优化,而不是优化一个代理损失。
import lightgbm as lgb
# LightGBM 的 lambdarank 目标
train = lgb.Dataset(X_train, label=y_train, group=group_train)
params = {
"objective": "lambdarank",
"metric": "ndcg",
"ndcg_eval_at": [5, 10],
"learning_rate": 0.05,
"num_leaves": 63,
"min_data_in_leaf": 20,
}
model = lgb.train(params, train, num_boost_round=500)
group 参数是关键:它告诉 LightGBM 哪些样本属于同一个查询,排序只在组内比较。组信息搞错,模型就完全学错——这是 LTR 训练最常见的错误。
特征工程
排序模型的效果,七成靠特征。特征通常分四类:
| 类别 | 示例 | 说明 |
|---|---|---|
| 查询-文档匹配 | BM25、向量相似度、命中词数 | 相关性核心 |
| 文档质量 | 点击率、转化率、权威度 | 先验质量 |
| 查询特征 | 长度、是否含品牌词、意图分类 | 查询侧上下文 |
| 上下文特征 | 设备、地域、时间 | 个性化 |
| 交叉特征 | 查询类目 × 文档类目、品牌匹配 | 强信号,需在线计算 |
| 行为特征 | 历史点击率、收藏率 | 依赖曝光量,有冷启动问题 |
| 时效特征 | 文档年龄、近 7 天热度 | 新闻、促销类场景关键 |
特征穿越(Feature Leakage)
特征穿越是 LTR 的头号陷阱:训练时用了「预测时刻还拿不到」的特征,导致离线指标虚高、上线暴跌。
典型例子:
- 用「文档的最终点击率」作特征——训练时这是历史统计,上线时未来点击还没发生。
- 用「全天的曝光量」作特征——但请求发生在当天早上,全天数据尚不存在。
- 用「用户是否最终下单」作特征——这是标签本身。
防御手段:
- 时间点对齐:特征值只能取「该请求发生时刻之前」已产生的数据,用时间窗口严格切分。
- 点对点回放(Point-in-time correctness):特征存储(Feature Store)要支持按时间点取历史特征,而不是取最新值。
- 离线在线一致性校验:用同一批请求,分别走离线特征管道与在线特征管道,比对特征值是否一致。
特征存储
特征在线离线一致性的问题,靠特征存储(Feature Store) 解决:一份特征定义,同时供离线训练与在线服务,保证口径一致。这与 推荐系统 里的特征工程是同一套体系。
模型与损失
GBDT 系
LambdaMART(LightGBM/XGBoost 的 lambdarank 目标)是工业界的默认选择:训练快、对特征尺度不敏感、可解释性尚可。绝大多数搜索精排的第一版都用它。
神经排序模型
当特征规模大、交叉复杂时,深度模型有优势:
- DIN/DIEN:对用户历史行为做注意力建模,捕捉兴趣演化。
- 双塔 + 交叉:双塔做召回,交叉层(如 DCN、DeepFM)做精排。
- 基于 Transformer 的重排:把「查询 + 候选列表」一起输入,用自注意力建模候选之间的相互影响,直接输出排列。
多目标
搜索不只优化点击,还要兼顾转化、时长、满意度。常见做法是多任务学习:共享底层特征,多个塔分别预测点击、转化、时长,最终分数由加权公式融合:
final_score = w1 · pCTR + w2 · pCVR + w3 · dwell_time - w4 · p_skip
权重 w 由业务目标决定,也可通过在线实验调优。
评估指标
离线指标
| 指标 | 含义 | 适用 |
|---|---|---|
| NDCG@K | 归一化折损累计增益 | 有分级相关度(0/1/2/3) |
| MRR | 第一个相关结果的排名倒数均值 | 问答、只有一个正确答案 |
| MAP | 平均精度均值 | 二值相关度 |
| Recall@K | 前 K 个里命中的相关文档比例 | 召回阶段 |
NDCG 是排序最常用的指标。它考虑两件事:相关度越高越好、位置越靠前越好。折损因子 1/log2(rank+1) 让靠后的结果贡献递减:
import numpy as np
def ndcg_at_k(rels, k):
"""rels: 按当前排序排列的相关度列表"""
rels = np.asarray(rels[:k], dtype=float)
gains = 2 ** rels - 1 # 分级增益
discounts = 1 / np.log2(np.arange(2, len(rels) + 2))
dcg = np.sum(gains * discounts)
# 理想排序(相关度降序)的 DCG
ideal = np.sort(rels)[::-1]
idcg = np.sum((2 ** ideal - 1) / np.log2(np.arange(2, len(ideal) + 2)))
return dcg / idcg if idcg > 0 else 0.0
MRR 与 MAP
当相关度只有「相关/不相关」二值时,NDCG 退化为更简单的指标:
def mrr(ranked_lists):
"""第一个相关结果的排名倒数,取均值"""
scores = []
for rels in ranked_lists:
for i, r in enumerate(rels, start=1):
if r > 0:
scores.append(1.0 / i)
break
else:
scores.append(0.0)
return sum(scores) / len(scores)
def average_precision(rels):
"""单个查询的平均精度"""
hits, prec_sum = 0, 0.0
for i, r in enumerate(rels, start=1):
if r > 0:
hits += 1
prec_sum += hits / i
return prec_sum / max(hits, 1)
选择原则:有分级相关度用 NDCG,只有单一正确答案用 MRR,二值且关注全部相关文档用 MAP。评测集的分级标注成本高,若拿不到分级标签,MRR/MAP 是务实的替代。
离线与在线的鸿沟
离线 NDCG 涨了,线上未必涨。原因包括:离线用的相关度标注与实际用户行为不一致、模型改变了曝光分布(位置偏差)、离线数据是旧策略产生的(反馈回路)。
因此必须做在线实验(A/B),看真实指标:点击率、转化率、停留时长、首条点击位置。离线指标只用于快速筛选候选模型,最终决策必须来自在线实验。
训练数据构造与位置偏差
从日志到样本
搜索模型没有现成的标注,样本来自线上日志:曝光、点击、下单等行为。一次查询的样本组织成「一个 group」,group 内的文档带不同相关度标签:
查询 q1 → [doc_A: click=1, order=1, pos=1]
[doc_B: click=1, order=0, pos=2]
[doc_C: click=0, order=0, pos=3]
标签常按行为强度分级:未点击=0、点击=1、点击并转化=2、人工标注相关=3。人工标注质量高但贵,通常只用于评测集;训练用行为标签,量大但有噪声。
位置偏差
用户倾向于点击靠前的结果,哪怕它并不更相关。直接用点击当标签,模型会学成「排前面的就是好的」——这是位置偏差(Position Bias)。
纠偏有两类做法:
- 逆倾向加权(Inverse Propensity Weighting, IPS):给每个点击样本乘上
1/propensity,propensity是该位置被点击的固有概率。位置越靠后,权重越大,抵消位置带来的偏置。 - 点击模型:用 EM 类算法(如 PBM、UBM)从点击数据中同时估计「文档相关性」与「位置偏置」,得到去偏的相关性标签。
# IPS:按位置倾向加权
propensity = {1: 1.0, 2: 0.7, 3: 0.5, 4: 0.35} # 由随机曝光实验估计
weights = [1.0 / propensity[row["pos"]] for _, row in df.iterrows()]
model.fit(X, y, sample_weight=weights)
倾向值靠随机打散实验(小流量把结果随机排序,观察各位置的真实点击率)估计,不能拍脑袋。
在线服务架构
请求链路
一次搜索请求的典型链路:
1. 查询解析:分词、纠错、意图识别、查询改写
2. 多路召回:倒排 + 向量 + 规则,各取 Top-N
3. 合并去重:RRF 或加权融合
4. 特征拉取:文档特征(缓存)+ 用户特征(实时)+ 交叉特征(计算)
5. 粗排:轻量模型砍到几百
6. 精排:LTR 模型打分
7. 重排:多样性、去重、业务规则
8. 返回:拼装结果、埋点
延迟预算
精排阶段的延迟预算是硬约束,通常 20~50ms。开销主要来自三处:
- 特征拉取:文档静态特征可预加载到内存/Redis;用户实时特征需低延迟 KV 存储;交叉特征在线计算,要控制在 O(1)。
- 模型推理:GBDT 推理快(微秒级/样本),深度模型需批处理或量化。
- 网络往返:把召回、特征、模型服务尽量同机房部署,减少跨区调用。
一个实用优化是特征缓存:同一查询短期内重复出现时,特征与结果可缓存;同一用户会话内的用户特征可复用。
冷启动
新文档没有历史行为特征,模型天然给低分,永远得不到曝光——这是马太效应。对策:
- 内容特征兜底:用文本相关性、类目匹配等静态特征给新文档一个合理的初始分。
- 探索流量:留一小部分流量给新文档,收集行为后再交由模型正常排序。
- 时效加权:对新鲜内容给予一定加权,避免老内容长期霸榜。
与向量检索、LLM 的结合
向量召回 + LTR 精排
现代搜索普遍采用混合召回:倒排索引(精确词匹配)+ 向量检索(语义匹配)各取一批候选,合并去重后交给 LTR 精排。向量召回负责「换个说法也能找到」,倒排负责「精确词必中」。
def hybrid_recall(query, k=200):
lexical = inverted_index.search(query, topk=k) # BM25
semantic = vector_index.search(embed(query), topk=k) # ANN
# 用 RRF(倒数排名融合)合并,无需调权重
fused = {}
for rank, doc in enumerate(lexical):
fused[doc] = fused.get(doc, 0) + 1 / (60 + rank)
for rank, doc in enumerate(semantic):
fused[doc] = fused.get(doc, 0) + 1 / (60 + rank)
return [d for d, _ in sorted(fused.items(), key=lambda x: -x[1])][:k]
向量检索的基础设施与索引选型,可参考 向量数据库 与 Embedding 与重排服务 。
LLM 作为重排器
大模型可以充当零样本重排器:把查询与候选文档一起喂给模型,让它给出相关性打分或直接排序。它不需要标注数据,且能理解复杂语义,适合冷启动或长尾查询。
代价是延迟与成本。工程上通常只对精排后的 Top-20~50 用 LLM 重排,而不是全量。这与 混合检索 RAG 里的重排思路一致:LLM 重排放在漏斗末端,用最少调用换最大质量提升。
工程落地要点
- 训练数据要反映真实分布:用线上日志构造样本,注意位置偏差,常用逆倾向加权(IPS) 或点击模型纠偏。
- 组信息必须正确:LTR 训练时
group划分错误会让模型完全学错。 - 线上打分要控制延迟:精排预算通常 20~50ms,特征计算要预取与缓存。
- 冷启动:新文档没有历史特征,需用内容特征兜底,否则永远排不上去。
- 定期重训:用户行为与商品库持续变化,模型需按天/周重训,配合在线评估防劣化。
- 可解释性:给运营提供「为什么这条排在前面」的归因,便于排查与调优。
- A/B 实验平台:任何排序改动都要能灰度与回滚,实验分流要保证同用户同组。
- 特征监控:监控关键特征的分布漂移,特征异常往往先于指标恶化出现。
- 兜底策略:模型服务不可用时回退到规则排序或 BM25,保证搜索不整体挂掉。
- 离线评测集冻结:评测集一旦定版不再改动,否则指标无法跨版本比较。
小结
排序学习把「排对顺序」变成一个可学习的优化问题:召回负责别漏、粗排负责筛量、精排用 LTR 负责精度、重排负责多样性。选范式时,pointwise 做基线,pairwise 关注相对序,listwise(LambdaMART)直接优化 NDCG 是工业默认。落地三件要事:防特征穿越、保离线在线一致、以在线实验为准。它与向量检索、LLM 重排的结合,正在把传统 LTR 推向语义化——检索侧的语义能力依赖向量索引,推荐场景的排序与多目标优化与搜索精排高度相通,两者的特征体系与评估方法可以互相借鉴。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。