做推荐或广告系统的同学大概都见过一种排序实现:把出价直接乘进主分数里。跑起来很快,但相关性会退化成事后补丁。本文把这条链路拆成三段------召回、过滤、排序,给出一个可复现的实现。文中的代码是演示用的精简版本,域名统一用 your-domain.test 占位。

一、为什么相关性要排在出价之前
先说结论:相关性过滤必须先于竞价发生,否则出价会污染整个候选集。
朴素竞价的做法是谁出价高谁靠前。这在候选集里全是相关商品时没问题,一旦混进不匹配的候选,就会出现荒谬结果:一个人在找跑鞋,系统因为手机壳的出价更高,把手机壳推到了前面。对用户来说这是噪声,对平台来说这是长期价值的透支。
正确顺序是两段式:
- 召回层------把自然语言需求结构化,拉出一批可能相关的候选
- 过滤层------按相关性判据把不匹配的剔除
- 排序层------在留下的候选里做竞价与效果预估
三者顺序不能换。换了,收益是短期的,代价是长期的。
二、召回层:把需求结构化
过去召回入口是关键词。用户必须先把需求翻译成搜索词,系统再按词去匹配。现在入口更常见的是自然语言的场景描述,所以先要有一层结构化。
intent_schema.py ------ 需求结构化(演示用)
from dataclasses import dataclass, field, asdict
@dataclass
class Intent:
scene: str # 场景,例如「长途飞行」
goal: str # 要办成什么事
constraints: dict = field(default_factory=dict) # 预算、尺寸、时间窗
tolerance: float = 0.5 # 可放宽程度,用于召回扩缩
raw_text: str = ""
def parse(raw_text, extractor, defaults=None):
"""把一句自然语言解析成 Intent。extractor 可以是模型,也可以是规则。"""
d = dict(defaults or {})
got = extractor(raw_text) or {}
d.update(got)
it = Intent(
scene=d.get("scene", "unknown"),
goal=d.get("goal", ""),
constraints=d.get("constraints", {}),
tolerance=float(d.get("tolerance", 0.5)),
raw_text=raw_text,
)
if not it.goal:
raise ValueError("goal 为空,禁止进入召回:缺目标的召回等于随机抽样")
return it
这里有个坑要提前说:goal 缺失时必须直接失败,不能让请求往下走。一个没有目标的召回请求,等价于把全量候选随机抽样,后面的过滤层会承受全部压力,而日志里只会显示「召回量偏高」,看不出真实原因。
另一个细节是 tolerance。它决定召回是收紧还是放宽:值小的时候只捞严格匹配的候选,值大的时候允许跨品类补足。这个参数通常按场景配置,不跟用户走------同一句需求,在标品场景和非标场景里,合适的扩缩幅度差得很远。
三、过滤层:相关性判据与阈值
过滤层的关键不是算法,而是阈值从哪来。把阈值全局写死,是常踩的错法,因为不同品类的容忍度差异极大。
relevance_gate.py ------ 相关性过滤(演示用)
def relevance_score(intent, item, weight=None):
"""输出 0~1 的相关性分值。weight 按品类注入,不写死在函数里。"""
w = weight or {"scene": 0.4, "goal": 0.4, "attr": 0.2}
s = 0.0
s += w"scene" * (1.0 if item.get("scene") == intent.scene else 0.0)
s += w"goal" * float(item.get("goal_hit", 0.0))
s += w"attr" * float(item.get("attr_match", 0.0))
return round(s, 4)
def gate(candidates, intent, weight=None, floor=None):
"""返回 (通过, 被拦)。floor 缺省时按品类配置注入。"""
if floor is None:
raise ValueError("floor 未注入:全局写死阈值会让窄品类全被拦掉")
passed, blocked = \[\], \[\]
for it in candidates:
sc = relevance_score(intent, it, weight)
(passed if sc >= floor else blocked).append({**it, "relevance": sc})
return passed, blocked
两个细节值钱。一是权重按品类注入,不由函数内部决定。二是门限 floor 必须显式传入,缺省直接报错------这类静默降级排查起来很费时。
还有一点建议:把被拦下的候选单独落盘。它有两个用途。一是排查误拦,尤其是窄品类;二是给阈值调参提供样本。只留通过集,等于丢掉了模型反馈里信息量很高的那一部分。
四、排序层:从预测点击切到预测成交
过滤之后才是竞价。这里的目标函数也值得说一句:只优化点击,长周期一定跑偏。
rank_objective.py ------ 目标切换与回归校验(演示用)
def bid_score(pred_ctr, pred_cvr, bid, lam=0.3):
"""把点击与成交的目标揉在一起;lam 是可调的长期项权重。"""
return bid * (1 - lam) * pred_ctr + bid * lam * pred_ctr * pred_cvr
def rank(passed, predict, bid_of, lam=0.3):
scored = \[\]
for it in passed:
p_ctr, p_cvr = predict(it)
scored.append({**it, "score": bid_score(p_ctr, p_cvr, bid_of(it), lam)})
return sorted(scored, key=lambda x: x"score", reverse=True)
lam 不是拍脑袋定的,它应该来自离线评估:把同一批请求分别按纯点击目标和混合目标排序,比较两者在成交指标上的差异。只看点击指标验证成交目标,属于口径错配,上线后会发现排序偏好整体反转。
评估时建议固定三组对照:召回通过率、相关性通过率、成交口径下的排序一致性。前两项描述链路的健康度,第三项描述目标是否真的生效。三者缺一项,调参就会变成盲调。
五、踩坑记录
坑一:把出价写进主排序函数。 相关性会顺势退化成事后补丁,日志里也看不出来。修法是把出价留在收尾那一段,前三段一律不看价格。
坑二:相关性阈值用全局固定值。 窄品类的候选会被整体拦掉,表现为「某些类目永远召不回来」。阈值必须按品类注入,并且要能被单独调。
坑三:用点击指标验证成交目标。 离线口径错配,上线后才发现排序偏好完全反了。评估口径要和目标函数对齐。
坑四:把同一套权重平移到所有品类。 场景权重、目标权重、属性权重三者的比例,在标品与非标品之间差别很大。照搬的结果是某个类目被系统性压死。
六、这套链路的适用边界
需要说清楚的是,这套实现适合做链路雏形与归因排查,不适合直接拿去上线。
其一,它没有处理冷启动。新候选没有历史交互,预测值会偏保守,需要单独的开采策略。
其二,它假设结构化字段是齐的。字段缺失时要走降级路径,而不是用默认值填充------默认值会把噪声伪装成信号。
其三,目标函数的权重是静态的。真实系统里 lam 会随业务阶段调整,写死之后模型会在策略变化时静默失效。
把这三条补齐之后,这条链路才算有资格进评审。
如果团队也在做召回与排序的拆分,评论区说一声,可以把字段口径表补充完整。