Skip to content
Charles Shao
Go back

过滤与重排:推荐链路最不性感、却最影响体感的最后一道闸门

views

前面几篇走完了推荐漏斗的召回排序。老板(排序模型)打完分、排出 Top-N 之后,还有最后一道工序——过滤与重排。这是推荐链路里最不性感、但最影响产品体感的两段:算法论文几乎不讲,但生产环境 30% 的迭代来自这里。延续「录像带出租店」的比喻,这次的主角是店里的「老板娘」:老板按「你会喜欢的概率」排好了片,老板娘负责把「已看过、不合规、太单调」的问题一次性收拾干净,再把最终结果摆到你眼前。

一句话分工:过滤管「不能推什么」,重排管「怎么排得既准又不无聊」。

Table of contents

Open Table of contents

一、为什么独立成两段

过滤为什么独立

核心理由:硬规则不应污染模型。

反模式: 让排序模型学"不要推已看过的"

        模型把 80% 容量浪费在学这个"硬规则"

        真正的"软偏好"反而学不好

正确的姿势:硬规则用 if-else(过滤层),软偏好用模型(排序层),两者职责清晰。

重排为什么独立

核心理由:精排追求「准」,会丢「多样性」。

精排单点最优:    [A1, A2, A3, A4, A5, A6, A7, A8, A9, A10]
                 全是同一作者/类目, 单点 CTR 最高
                 但用户两屏后就会划走

重排修正:        [A1, B1, A2, C1, A3, B2, ...]
                 单点略降, 体验大涨

精排和重排的目标根本不同:精排是单点 CTR 最大,重排是整页/整 session 留存最大

过滤与重排的分工:排序输出的 Top-N 先经过滤层(去重/黑名单/频控/风控等硬规则,管「不能推什么」),再经重排层(多样性/打散/强插/listwise,管「怎么排得既准又不无聊」),最后呈现给用户

过滤与重排是漏斗的收尾两段:硬规则剔除交给过滤,体感优化交给重排,各司其职。


二、过滤层(Filter)

七大过滤类别

类别例子数据来源
去重已看 / 已点过用户曝光历史(Bloom Filter)
黑名单拉黑作者 / 不感兴趣 tag用户偏好 KV
频控同作者 1h 不超过 3 个实时计数器
业务规则未成年人不看恐怖类用户属性 + 类目映射
合规风控下架视频 / 风控命中内容审核系统
库存商品库存 = 0(电商)库存服务
质量标题党 / 低俗压制内容质量分

优先级 — 去重 > 黑名单 > 频控 > 其他

过滤优先级链:候选依次经过去重(必过)、黑名单(必过)、风控(必过)、频控(可降级)、业务规则(可降级)、质量阈值(可降级),产出通过候选。必过=一票否决,可降级=候选不足时放宽

过滤链的关键设计:必过规则一票否决(过不了直接扔),可降级规则在候选不足时放宽(避免空结果)。

过滤的位置选择

位置优势劣势
召回前节省后续算力各召回路重复实现,维护难
召回后(主流)集中维护,可加复杂规则召回浪费一些算力
排序后处理实时性最高的信号浪费排序算力

主流做法召回后做主过滤,排序后做「动态实时过滤」(如视频刚被下架)。


三、过滤的工程实现

1. 去重 — Bloom Filter

用户曝光历史可能 1 千万条
精确存储: 1KW × 8 byte = 80MB / 用户  ❌

Bloom Filter:
  - 1KW 元素, 0.1% 假阳率 → 仅需 ~17MB / 用户
  - O(1) 查询
  - 缺点: 有假阳(可能误过滤一些没看过的)

2. 黑名单 — Set / Bitmap

用户拉黑了 50 个作者,用 hash set 存储,查询 O(1)。

3. 频控 — Sliding Window Counter

key: user_id:author_id
value: [t1, t2, t3, ...]  最近 N 次曝光时间

查询: 过滤 < 1h 前的, count() < 3 ?
存储: Redis ZSET (sorted set)

4. 风控 — 双重缓存

全局风控池: bloom filter + 定时刷新 (10 分钟)
紧急下架:   pubsub 实时通知 → 内存 set

5. 实时性要求

信号实时性实现
已看过滤1-5 分钟Kafka → Flink → Redis
风控下架< 30 秒Pub/Sub 主动推送
频控准实时滑动窗口

过滤层的「陷阱」

陷阱 1:候选被过滤光

召回 1000 → 各路过滤 → 剩 5 个    ❌ 排序没法选

解决:召回阶段就要给冗余(多召回 30~50%);过滤层要监控「过滤后剩余量」,触发降级。

陷阱 2:Bloom Filter 假阳损失——false positive rate = 0.1% 意味着 0.1% 的好物品被误过滤。看似小,但对长尾物品来说是致命的(它本来就少有曝光机会)。

陷阱 3:频控配置随业务变——短视频「同作者 1h 不超过 3 个」、长视频「同作者 7d 不超过 1 个」、直播「同主播随时可重复」。


四、重排层(Rerank)概述

重排的「四件套」

重排四件套:精排输出 Top-100 依次经过 ① 多样性(MMR/DPP,避免同质化)、② 打散(按作者/类目/标签分散位置)、③ 业务规则/强插(广告位/运营内容/新作扶持)、④ Listwise Rerank(可选,考虑上下文位置的 listwise 模型),产出最终 Top-N

重排四件套:从精排的 Top-100 到最终 Top-10,逐层把「单点最优」修成「整页体感最优」。

重排的核心张力

维度精排倾向重排修正
单点 CTR越高越好牺牲一些
多样性不关心强制提升
新颖性不关心留 explore 空间
业务诉求模型不知道强插实现
用户长期价值短视兼顾

重排是「精排的对手」,不是「精排的下游」。 它必须打破精排的局部最优,换全局最优。


五、多样性 — MMR / DPP

为什么需要多样性

精排输出: [A、A、A、A、B、A、A、A、C、A]   ← 8 个 A
用户体验: 第 3 个 A 后就开始划走
留存:     ↓

同类内容连续出现 ≥ 3 次,留存指标显著下降(各家平台都在公开演讲里印证过)。

MMR(Maximal Marginal Relevance)

思路:每选一个,在「相关性」和「与已选 list 的相似性」之间权衡。

def mmr(candidates, lambda_=0.7):
    selected = []
    while len(selected) < K:
        best = None
        best_score = -inf
        for c in candidates:
            if c in selected:
                continue
            relevance = c.score                                    # 精排分
            sim_to_selected = max(sim(c, s) for s in selected)     # 与已选最相似度
            mmr_score = lambda_ * relevance - (1 - lambda_) * sim_to_selected
            if mmr_score > best_score:
                best, best_score = c, mmr_score
        selected.append(best)
    return selected

DPP(Determinantal Point Process)

思路:用矩阵的行列式来度量「集合的多样性」。行列式越大,集合越分散。

P(S) ∝ det(L_S)   (S ⊆ candidates)

L_S = 子集 S 对应的核矩阵
    = relevance × similarity 的组合

直觉:行列式大 = 向量近似线性无关 = 多样性高;单个 item 的 relevance 高 + 整体相似度低 → det 大。

工程实现要点

相似度怎么算:emb 余弦相似度(最准,但需要 emb)、类目重叠(简单)、作者相同(强约束)、tag Jaccard(平衡)。

多样性的「度」怎么调λ 越大 = 越偏精排分(不多样),λ 越小 = 越多样(可能损失 CTR)。典型值 λ = 0.5 ~ 0.7,通过 AB 调。


六、打散(Spread)

和多样性的区别:多样性是「内容差异」,打散是「位置约束」。

三种打散

① 同作者打散

原: [A1, A2, A3, A4, B1, B2, B3, ...]    ← 4 个 A 挤在一起
后: [A1, B1, A2, B2, A3, B3, A4, ...]    ← 间隔出现

典型贪心实现:

def spread_by_author(items, max_consecutive=1, min_gap=3):
    result = []
    last_seen = {}  # author -> last position
    pending = []
    for item in items:
        a = item.author
        if a in last_seen and len(result) - last_seen[a] < min_gap:
            pending.append(item)  # 暂时跳过
        else:
            result.append(item)
            last_seen[a] = len(result) - 1
    # 把 pending 重新插入合适位置
    return result + pending

② 同类目打散:一屏(前 N 个)内最多 K 个同类目。配置示例:短视频 6 屏内最多 2 个美食。

③ 标签 / 主题打散:最细粒度的打散,需要 tag 体系完整。

打散的「度」

打散通常 -2% 短期 CTR,但 +3~5% 七日留存。是典型的「短期换长期」。


七、业务规则与强插

强插场景

场景例子
广告位第 4、第 8 强插
运营内容编辑精选、活动卡
新作扶持新作者 / 新内容流量倾斜
关注流关注作者新视频高优
通知 / 弹窗系统通知插入

强插架构

def merge_with_rules(rec_list, ad_slots, op_slots, boost_pool):
    """
    rec_list:   重排好的推荐列表 (10 个)
    ad_slots:   广告必须出现的位置 [4, 8]
    op_slots:   运营卡 [(2, op_card_xxx)]
    boost_pool: 新内容池, 强插概率 30%
    """
    result = []
    for pos in range(10):
        if pos in op_positions:
            result.append(op_card)
        elif pos in ad_positions:
            result.append(ads.next())
        elif random.random() < 0.3 and pos > 0 and boost_pool:
            result.append(boost_pool.next())
        else:
            result.append(rec_list.pop(0))
    return result

强插的「度」

强插的总占比通常 ≤ 25%。超过这个数体验显著下降。

频控与冷却

广告频控:
  - 同广告 24h 内最多 3 次
  - 用户关闭广告后, 24h 内不再出现
  - 用户付费后, 广告占比降至 5%

八、Listwise Rerank — PRM / PEAR

为什么需要 Listwise

精排是 pointwise:对每个 item 独立打分。但实际上,item 的 CTR 受周围 item 影响

[A、B、C]: A 的 CTR = 5%
[A、X、Y]: A 的 CTR = 7%   ← 因为 X、Y 比 B、C 差, A 反而被衬托

PRM(Personalized Re-ranking Model)

输入: [item_1, item_2, ..., item_N]  (精排 top-N)
模型: Transformer Encoder

输出: 重排后的顺序

学习目标: 使 list 整体 NDCG 最大化

关键设计:Self-attention 让每个 item 感知整个 list 的上下文;加 position embedding 学位置偏置;通常作为精排后的「二次排序」。

PEAR(Pinterest 2022)

核心改进:同时建模「用户已交互」和「重排候选」,做更长的 attention。

Listwise Rerank 的工程难点

难点说明
训练样本需要 list-level 标签(整 list 的留存),很难拿
反事实「如果重排成不同顺序,用户会怎么反应」无法离线评估
延迟Transformer 推理比 GBDT 重
稳定性上下文依赖,小改动影响大

不是所有平台都做 listwise rerank。多数还是 MMR/DPP + 业务规则。


九、生态与平衡

重排的「超出推荐」职责

维度例子工具
新作者扶持新人前 10 条强保底曝光流量阶梯
长尾内容曝光保留 N% 的位置给冷门配额机制
创作者多样性不让头部创作者垄断流量作者分布平滑
用户分群体验新用户多探索,老用户多利用用户画像 + 重排策略
品类生态防止单品类称霸全平台类目占比约束

这些不是算法,是产品策略。 但工程实现都在重排层。

生态目标的量化

平台健康度 = w1 · 新作者活跃率
           + w2 · 长尾内容曝光占比
           + w3 · 品类基尼系数 (反向)
           + w4 · 用户多样性熵

每一项都需要重排层主动优化,不能寄希望于精排。


十、recsys-mini 的现状 + 改造方案

现状

模块状态
过滤没有独立模块,在 popular.py / itemcf.py 内部做「已看过滤」
重排完全没有,直接用 LightGBM 排序后的 Top-N
多样性 / 打散 / 业务规则均没有

Step 1:抽出过滤层

把散落在各召回器内部的「已看过滤」抽成独立的过滤链:

class BaseFilter:
    def filter(self, user_id: int,
               candidates: List[Tuple[int, float]]
               ) -> List[Tuple[int, float]]:
        raise NotImplementedError

class FilterChain:
    def __init__(self, filters: List[BaseFilter]):
        self.filters = filters

    def apply(self, user_id, candidates):
        for f in self.filters:
            candidates = f.filter(user_id, candidates)
        return candidatessrc/filter/base.py
class SeenFilter(BaseFilter):
    def __init__(self, user_history: dict[int, set[int]]):
        self.user_history = user_history

    def filter(self, user_id, candidates):
        seen = self.user_history.get(user_id, set())
        return [(i, s) for i, s in candidates if i not in seen]src/filter/seen_filter.py

收益:从 ItemCF / Popular 内部去掉「已看过滤」,职责清晰,后续可以加更多 filter(黑名单 / 频控)。

Step 2:加重排层 — MMR 多样性

def mmr_rerank(scored_items: List[Tuple[int, float]],
               item_genres: dict[int, set[str]],
               top_n: int,
               lambda_: float = 0.7,
               ) -> List[Tuple[int, float]]:
    selected = []
    selected_genres = set()
    candidates = scored_items.copy()

    while len(selected) < top_n and candidates:
        best, best_idx, best_score = None, None, -float("inf")
        for idx, (item, score) in enumerate(candidates):
            genres = item_genres.get(item, set())
            overlap = len(genres & selected_genres) / max(len(genres), 1)
            mmr_score = lambda_ * score - (1 - lambda_) * overlap
            if mmr_score > best_score:
                best, best_idx, best_score = (item, score), idx, mmr_score
        selected.append(best)
        selected_genres |= item_genres.get(best[0], set())
        candidates.pop(best_idx)
    return selectedsrc/rerank/mmr.py

Step 3:端到端评估对比

在离线评估里加入两个对照——「不重排」(Top-10 精排直出)vs「MMR 重排」(Top-10 多样性优化),报告四组指标:

指标含义预期变化
Recall@10 / NDCG@10准度略降(0~3%)
ILD@10(Intra-List Diversity)多样性涨 30%+
Coverage@10覆盖率涨 5-10%

这正是重排「短期准度换长期体验」的量化体现,评估方法详见全链路离线评估


一句话总结

过滤 = 把「不能推的」硬剔除,职责简单但坑多;重排 = 把「模型最优」修成「用户体感最优」,是产品策略落地的最后一道闸门。

这两段算法含量低、工程含量高、产品含量极高——是真正区分「会做模型」和「懂业务」的分水岭


到这里,推荐漏斗的四段(召回 → 融合 → 排序 → 重排)就全串起来了。最后一篇回到工程师最关心的问题——全链路离线评估:这一整套系统到底好不好、怎么用一套可信的离线指标把它量出来。


views
Share this post on:

Previous Post
推荐全链路离线评估:单看召回 HitRate 或排序 AUC 都会骗你
Next Post
PM2 进程管理:Node.js 应用守护、配置文件与批量调度