一、为什么要做重排阶段?

大家平时刷新闻的时候,是不是经常遇到这种情况:点开一个App,首页刷下来全是同一个明星的八卦,或者连续几条都是类似“某地发生车祸”的报道。看第一条还有点兴趣,第二条第三条就彻底不想看了。这就是推荐系统只关注了点击率,忽略了用户感受的结果。

在推荐系统的流程里,召回阶段从海量文章里捞出一批候选,粗排和精排阶段用复杂的模型给每篇文章打分,分数高的排前面。但精排模型往往只关注单个物品与用户的匹配度,比如点击概率、停留时长等,很少考虑物品之间的相互关系。于是就会出现上面说的“同质化”问题。重排阶段就是补这个窟窿的——在精排结果的基础上,再次调整顺序,让列表整体更符合用户的真实体验。

重排不一定需要复杂的深度学习模型,很多时候用一些简单的规则或者算法就能明显改善效果。今天我们就来聊聊几种常见又实用的重排方法,全部用Python实现,代码里加了详细注释,方便大家直接拿来用。

二、重排阶段的常见痛点

做重排之前,得先知道用户到底在烦什么。根据用户反馈和数据分析,主要痛点有这几个:

  • 内容同质化:连续N条新闻主题、来源、甚至标题风格都类似,用户很快就腻了。
  • 旧闻反复推荐:有些新闻虽然点击率高,但已经过了一天甚至更久,用户早就看过了,再推就是浪费。
  • 用户疲劳:同一个作者的新闻连续出现,或者同一类型的新闻刷屏,用户会产生抵触情绪。
  • 利益冲突:广告插入生硬、置顶内容与用户兴趣不符,导致整体体验下降。

重排的优化目标就是解决这些痛点,让用户觉得“这个App懂我”,而不是“这个App在硬塞给我内容”。

三、用户体验优化的具体方法

下面介绍四种常见方法,每种都配有Python示例,假设我们已经有一个精排过的新闻列表,每个新闻是一个字典,包含id、标题、得分、发布时间、类别等字段。

3.1 基于MMR的多样性重排

MMR(最大边际相关性)是一种经典算法,目标是在相关性和多样性之间找平衡。它的核心思想是:每次从候选列表里选一个新闻加到结果里,选的时候既要考虑它本身的相关性得分,又要考虑它和已选新闻的相似度,相似度高的会被惩罚。

# 技术栈: Python 3.8+
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

def mmr_rerank(candidates, topk, lambda_param=0.5):
    """
    MMR重排算法
    :param candidates: list of dict, 每个元素包含 'id', 'score', 'embedding' (向量)
    :param topk: 最终需要返回的数量
    :param lambda_param: 控制多样性和相关性的权重, 0表示只考虑多样性, 1只考虑相关性
    :return: list of dict, 重排后的列表
    """
    if len(candidates) <= topk:
        return candidates  # 不够就原样返回

    # 提取所有嵌入向量,用于计算相似度
    embeddings = np.array([c['embedding'] for c in candidates])
    # 计算候选列表两两之间的余弦相似度矩阵
    sim_matrix = cosine_similarity(embeddings)

    selected_indices = []  # 已选中的索引
    candidate_indices = list(range(len(candidates)))  # 待选的索引

    # 第一步:选得分最高的作为第一个
    best_idx = np.argmax([c['score'] for c in candidates])
    selected_indices.append(best_idx)
    candidate_indices.remove(best_idx)

    # 循环选择剩余topk-1个
    for _ in range(topk - 1):
        mmr_scores = []
        for idx in candidate_indices:
            # 计算当前候选与已选所有新闻的最大相似度
            max_sim = max([sim_matrix[idx][sel] for sel in selected_indices])
            # MMR公式: λ * 得分 - (1-λ) * 最大相似度
            mmr = lambda_param * candidates[idx]['score'] - (1 - lambda_param) * max_sim
            mmr_scores.append(mmr)
        # 选MMR值最大的候选
        best_mmr_idx = candidate_indices[np.argmax(mmr_scores)]
        selected_indices.append(best_mmr_idx)
        candidate_indices.remove(best_mmr_idx)

    return [candidates[i] for i in selected_indices]

# 使用示例
if __name__ == '__main__':
    # 模拟一些新闻数据,每个新闻有一个随机生成的128维embedding
    import random
    news_list = []
    for i in range(20):
        news_list.append({
            'id': i,
            'title': f'新闻{i}',
            'score': random.random(),  # 精排得分
            'embedding': np.random.rand(128)  # 用随机向量模拟文本embedding
        })
    # 执行MMR重排,取前5个
    reranked = mmr_rerank(news_list, topk=5, lambda_param=0.6)
    for news in reranked:
        print(f"id:{news['id']}, score:{news['score']:.3f}")

这段代码里,我们使用了预先计算好的新闻embedding(可以用NLP模型生成)。lambda_param是关键参数:值越大越看重相关性,越小越看重多样性。实际应用中可以根据业务调参,比如在新闻推荐中0.5到0.7通常效果比较好。

3.2 基于个性化点击率的加权重排

精排的得分通常是模型预测的点击概率,但不同用户对新闻的偏好还存在差异。我们可以利用用户历史点击的新闻类别、来源等信息,对候选新闻进行微调。比如用户最近常看科技类,那么科技类新闻适当提权,财经类适当降权。

# 技术栈: Python 3.8+
def personalized_weight_rerank(candidates, user_profile, boost_factor=0.2):
    """
    基于用户画像的加权重排
    :param candidates: list of dict, 每个元素包含 'id', 'score', 'category', 'source'
    :param user_profile: dict, 用户画像, 例如 {'prefer_category': {'科技':1.5, '体育':0.8}, 'prefer_source': {'人民日报':1.2}}
    :param boost_factor: 加权幅度, 越大权重影响越大
    :return: list of dict, 重排后的列表(按调整后得分降序)
    """
    for news in candidates:
        # 基础得分来自精排
        base_score = news['score']
        # 根据类别加权
        category_weight = user_profile.get('prefer_category', {}).get(news.get('category'), 1.0)
        # 根据来源加权
        source_weight = user_profile.get('prefer_source', {}).get(news.get('source'), 1.0)
        # 综合调整: 新得分 = 基础得分 * (1 + boost_factor * (加权乘积 - 1))
        # 这样可以保证权重在1附近浮动,不至于过度扭曲
        new_score = base_score * (1 + boost_factor * (category_weight * source_weight - 1))
        news['adjusted_score'] = new_score

    # 按调整后得分降序排序
    candidates.sort(key=lambda x: x['adjusted_score'], reverse=True)
    return candidates

# 模拟数据
if __name__ == '__main__':
    mock_news = [
        {'id': 1, 'score': 0.9, 'category': '科技', 'source': '36氪'},
        {'id': 2, 'score': 0.85, 'category': '体育', 'source': '腾讯体育'},
        {'id': 3, 'score': 0.8, 'category': '科技', 'source': '澎湃新闻'},
    ]
    # 用户画像:偏好科技,不偏好体育;喜欢36氪
    user_pref = {
        'prefer_category': {'科技': 1.3, '体育': 0.7},
        'prefer_source': {'36氪': 1.2}
    }
    reranked = personalized_weight_rerank(mock_news, user_pref, boost_factor=0.3)
    for n in reranked:
        print(f"id:{n['id']}, 原分:{n['score']}, 调整分:{n['adjusted_score']:.3f}")

这个方法简单实用,用户画像可以从在线存储(如Redis)中实时获取。注意boost_factor不宜太大,否则会出现某条新闻因为来源偏好而碾压其他所有候选的情况。

3.3 基于时效性的新鲜度加权

新闻的时效性非常关键,一条12小时前的新闻和一条1小时前的新闻,即使点击率一样,用户也更倾向于看最新的。我们可以用时间衰减函数来调整得分。

# 技术栈: Python 3.8+
import datetime
import math

def time_decay_rerank(candidates, decay_hours=4, min_boost=0.5):
    """
    时效性加权重排,采用指数衰减
    :param candidates: list of dict, 每个元素包含 'id', 'score', 'publish_time' (datetime对象)
    :param decay_hours: 衰减半衰期(小时),即经过这个时间后权重减半
    :param min_boost: 最低权重,防止非常旧的新闻被完全抹杀
    :return: list of dict, 按新得分降序
    """
    now = datetime.datetime.now()
    for news in candidates:
        time_diff = (now - news['publish_time']).total_seconds() / 3600  # 小时数
        # 指数衰减: weight = 2^(-time_diff / decay_hours)
        weight = math.pow(2, -time_diff / decay_hours)
        # 确保不低于最小值
        weight = max(weight, min_boost)
        news['time_weighted_score'] = news['score'] * weight

    candidates.sort(key=lambda x: x['time_weighted_score'], reverse=True)
    return candidates

# 模拟
if __name__ == '__main__':
    from datetime import timedelta
    now = datetime.datetime.now()
    mock = [
        {'id': 1, 'score': 0.9, 'publish_time': now - timedelta(hours=1)},   # 1小时前
        {'id': 2, 'score': 0.88, 'publish_time': now - timedelta(hours=6)},  # 6小时前
        {'id': 3, 'score': 0.85, 'publish_time': now - timedelta(hours=24)}, # 1天前
    ]
    reranked = time_decay_rerank(mock, decay_hours=4, min_boost=0.3)
    for n in reranked:
        print(f"id:{n['id']}, 原分:{n['score']}, 时间权重:{n['time_weighted_score']:.3f}")

半衰期decay_hours根据新闻类型灵活设置:突发事件半衰期可以设为1小时,一般新闻可以设6小时。min_boost防止权重降到0导致某些重要但稍旧的新闻彻底无缘展示。

3.4 结合业务规则的硬约束

除了算法调整,有时候还必须加入硬规则。比如置顶一条重要公告、在特定位置插入广告、或者要求同一个作者的新闻不能连续出现。这些规则可以在重排最后阶段强制执行。

# 技术栈: Python 3.8+
def apply_rules_rerank(candidates):
    """
    应用业务规则,比如禁止同一作者连续出现、强制置顶某条新闻
    :param candidates: list of dict, 已按某种得分排好
    :return: list of dict, 调整后的列表
    """
    # 规则1: 强制置顶id为-1的公告(假设公告在候选里)
    pinned_ids = [-1]
    pinned_items = [c for c in candidates if c['id'] in pinned_ids]
    others = [c for c in candidates if c['id'] not in pinned_ids]

    # 规则2: 如果同一作者连续出现超过2条,则间隔至少隔1条其他新闻
    result = []
    last_author = None
    same_author_count = 0
    for item in others:
        author = item.get('author', 'unknown')
        if author == last_author:
            same_author_count += 1
            if same_author_count >= 2:
                # 需要插入一个其他作者的新闻,简单做法:从后面找一个不同作者的调过来
                # 这里为了演示,直接跳过这个,使用一个占位方案
                # 实际工程中可以维护一个双端队列
                continue  # 跳过,后续可以补回(示例简化)
        else:
            same_author_count = 1
            last_author = author
        result.append(item)

    # 把置顶的插到最前面
    result = pinned_items + result
    return result

# 模拟
if __name__ == '__main__':
    mock = [
        {'id': -1, 'author': 'system', 'title': '重要公告'},
        {'id': 10, 'author': '张三', 'title': 'A'},
        {'id': 11, 'author': '张三', 'title': 'B'},
        {'id': 12, 'author': '李四', 'title': 'C'},
    ]
    final = apply_rules_rerank(mock)
    for n in final:
        print(f"id:{n['id']}, author:{n['author']}, title:{n['title']}")

注意,这里的规则示例为了简洁做了简化,实际工程中需要更复杂的队列管理。但核心思想就是:在最终列表上施加约束,保证用户体验的底线。

四、应用场景分析

上面几种方法可以组合使用,效果更佳。常见场景如下:

  • 新闻信息流:MMR + 时效性加权是标配,尤其对短时爆发的事件,必须控制同质化。
  • 个性化资讯App:加上用户画像加权,让每个用户看到的内容排序都不同,提升留存。
  • 电商推荐(类似新闻):商品知识性内容也需要多样性和新鲜度,比如一个用户看了手机,不能连续推手机壳、贴膜等关联产品,适当引入MMR。
  • 视频/短视频推荐:同样适用,但需要将embedding换成视频特征。

五、技术优缺点

MMR方法

  • 优点:原理清晰,效果显著,能有效打破多样性瓶颈。
  • 缺点:需要计算embedding相似度,数据量大时耗时长;lambda_param需要手动调参。

个性化加权

  • 优点:对留存和点击率提升明显,计算简单。
  • 缺点:用户画像的获取依赖历史数据,冷启动用户效果差;过分依赖画像可能导致“信息茧房”。

时效性加权

  • 优点:实现简单,显著提升新鲜感。
  • 缺点:对非时效性内容(如深度报道、科普文)不友好,会过早沉没。

硬规则约束

  • 优点:保证业务底线,防止奇怪情况。
  • 缺点:规则写死,可能限制优化空间,需要定期评估有效性。

六、注意事项

  1. 数据一致性:重排时使用的embedding、用户画像等需要保证与精排阶段的数据源一致,否则会出现矛盾。
  2. 实时性:重排是在线环节,对延迟敏感。MMR如果候选量很大(比如几百条),可以用近似最近邻加速相似度计算,或者只对top-N做二次重排。
  3. 冷启动:新用户没有历史行为,个性化加权可以降权或回退到仅使用MMR+时效性。
  4. A/B测试:任何重排策略上线前都要做A/B实验,观察点击率、阅读时长、访问深度等指标,避免只看单一指标。
  5. 可解释性:虽然重排是后处理,但最好能记录每个新闻的最终得分构成,方便排查问题。

七、文章总结

重排阶段虽然只是推荐系统整个流程里很小的一步,但却是直接影响用户眼前体验的关键环节。通过MMR控制多样性、个性化加权实现千人千面、时效性加权保持新鲜感、硬规则兜底保证质量,这几种方法配合使用,可以显著提升用户满意度。代码实现上也不复杂,用Python就能快速验证。希望大家在自己的推荐系统里试试这些方法,让用户刷新闻时少一些“审美疲劳”,多一些“惊喜”。