信息流这个东西,听起来高大上,其实说白了就是我们每天刷的那些内容。你刷短视频、刷购物推荐、刷新闻,背后都有一套排序逻辑在决定先给你看什么、后给你看什么、甚至干脆不给你看什么。排在前面的,用户容易看到,点击率高,商家也开心;排在后面的,基本就是“陪跑”。但真实世界里的推荐系统往往有一个尴尬局面:本来是想让每个人看到不一样的东西,结果热门内容像滚雪球一样越滚越大,长尾商品(就是那些虽然小众但可能很有特色、只适合一部分人的东西)连个露脸的机会都没有。尤其在引入了一个叫“最大边际相关算法”的东西之后,这个问题有时反而更明显了。今天我们就来聊聊怎么在有限的曝光窗口里,设计一套多样性约束策略,既不让热门内容霸屏,又能让长尾商品有机会被看见。
一、问题到底是怎么冒出来的
1.1 先说说最大边际相关算法是干嘛的
最大边际相关算法,英文简称MMR,它的核心思想很简单:在挑选内容时,既要考虑内容本身跟你的相关度,还要考虑这条内容跟已经选出来的内容之间有多“像”。如果太像了,即使很相关,也会被降权。打个比方,你去餐厅吃饭,菜单上有一堆菜,你最喜欢吃辣,但MMR会提醒你:既然已经点了一道麻婆豆腐,那么再点一道水煮鱼虽然你也爱吃,但可能有点重复,不如试试别的味道。
用公式表达大概是这样:MMR = 相关度 - alpha * 最大相似度。alpha是一个参数,控制多样性多强。调大alpha,内容之间的相似度惩罚就变重,结果就会更分散。
1.2 为什么MMR引入了,热门还是挤压长尾
问题在于MMR的相似度衡量通常依赖内容特征。而热门内容往往被点击多、交互多,特征也学得比较“扎实”,跟用户的历史行为相似度计算出来很高。于是MMR在权衡时,热门内容的相关度优势太大,即使扣掉相似度惩罚,它们的综合得分依然碾压长尾商品。另外,信息流场景里有“有限曝光窗口”这个约束——不是无限给你推,可能这个用户今天一共就看50条,你必须在50条里排布内容。如果前30条都被热门占了,后20条长尾商品用户可能根本没刷到,多样性约束等于失效。
举个例子:一个美妆用户,她喜欢口红,也喜欢小众眼影。热门口红因为品牌大、销量高,模型给它打了高分。MMR虽然知道她最近看过很多口红,应该推点别的,但口红的相关度太高了,惩罚完依然排在前面。于是她刷了30条,全是热门口红,唯一想看的小众眼影排在第48位,她早就没耐心了。
二、多样性约束策略的核心思路
要解决这个问题,不能只靠调MMR里的alpha,因为全局调参容易误伤。根本思路是把“多样性”从软性惩罚升级成“硬性约束”,在重排阶段直接规定一些规则,比如:
- 同一个类目最多连续出现几次
- 同一个商品最多出现在整个信息流的哪个位置区间
- 长尾商品至少占多少比例
- 热门内容的总曝光条数设置一个上限
这些约束要在MMR的基础之上做,相当于给MMR套上一个“笼子”,让它在笼子里跳舞。
2.1 连续数量限制
限制连续曝光的同类目数量。比如同一类目最多连续出现3条,第4条必须换类别。这个方法简单粗暴,非常有效,适合信息流分页加载的场景。因为用户滑动时,连续看到重复类目最容易疲劳。
2.2 长尾保底配额
给长尾商品一个最低曝光比例。比如,每一屏(比如10条内容)里必须有至少2条是长尾商品。注意,这里的“长尾”要提前定义好,比如根据销量、热度分位数来划分。这个策略能保证长尾商品永远有位置,但缺点是如果你定义的长尾质量不行,可能会牺牲短期点击率。
2.3 分桶排序
把热门和长尾先分成两个桶,各自先按MMR排序,然后按照某种模式(比如“热-热-长-热-长-热”)交错融合。这种策略比较精细,可以灵活控制节奏,但需要设计好模式,避免用户觉得突兀。
2.4 滑动窗口内的类目分布约束
假设曝光窗口是N条,统计窗口内每个类目的占比。比如N=50,类目“口红”的占比不能超过30%。每加入一条内容,就检查一遍当前窗口内的分布,如果超了就跳过,换其他类目。
三、实战设计:一个带多样性约束的信息流重排模块
我们用一个简单的Python示例来演示。技术栈是纯Python + 少量数据结构,不依赖第三方库,方便理解核心逻辑。假设我们现在已经得到了重排前的候选列表,每个候选包含商品ID、类目、是否是长尾、以及原始相关度得分(这个得分已经经过MMR融合)。我们在重排阶段加入多样性约束,确保长尾不被挤压。
# 技术栈:Python 3.9+(无第三方依赖)
def diversity_rerank(candidates, window_size=10, max_continuous=3,
long_tail_min_per_window=2, hot_max_ratio=0.6):
"""
带多样性约束的信息流重排。
参数:
candidates: list of dict,每个dict包含:
- id: 商品ID
- category: 类目名
- is_long_tail: 是否长尾商品
- score: 相关度得分(已经包含MMR的多样性惩罚)
window_size: 滑动窗口大小,假设每次系统给用户展示一屏
max_continuous: 同一类目最大连续出现次数
long_tail_min_per_window: 每个窗口内长尾商品的最低数量
hot_max_ratio: 每个窗口内热门商品的最大占比
返回:
重新排序后的list
"""
# 先按得分降序排序,作为初始序列
sorted_cands = sorted(candidates, key=lambda x: -x['score'])
result = [] # 最终的结果列表
window = [] # 当前滑动窗口(用于统计约束)
for cand in sorted_cands:
# 检查是否满足约束,如果不满足则先跳过,放到后面待定区
# 待定区中如果后续没有更合适的,最后再尝试放入(兜底策略)
# 这里为了展示约束逻辑,我们用一个简单的两层循环
placed = False
for i in range(len(sorted_cands)):
# 从头开始找,跳过已经放过的元素
if cand['placed']:
continue
candidate = cand['data']
# 约束1:同一类目连续次数不超过max_continuous
if len(result) >= max_continuous:
last_cats = [r['category'] for r in result[-max_continuous:]]
if last_cats == [candidate['category']] * max_continuous:
continue
# 约束2:计算把candidate加入后,当前窗口分布是否满足
# 先模拟加入后的窗口(只考虑最近window_size条)
temp_window = (result + [candidate])[-window_size:]
# 统计长尾数量
long_tail_count = sum(1 for r in temp_window if r['is_long_tail'])
# 统计热门(非长尾)数量及占比
hot_count = sum(1 for r in temp_window if not r['is_long_tail'])
hot_ratio = hot_count / len(temp_window)
# 检查是否满足约束
if long_tail_count < long_tail_min_per_window:
continue
if hot_ratio > hot_max_ratio:
continue
# 满足所有约束,放置
result.append(candidate)
cand['placed'] = True
placed = True
break
# 如果找不到合适位置,就先暂存,后续再处理
# 这里为了简化,我们直接加在末尾(真实场景可能需要更精细的回落策略)
# 注意:如果直接加在末尾可能会破坏窗口分布,所以这里我们只在确实无解时使用
if not placed:
# 再加一个兜底:尝试重新计算整个窗口,如果违背约束则放到末尾并标记
result.append(candidate)
cand['placed'] = True
# 返回结果,只保留数据部分
return result
等等,上面这段代码有个小问题:我们在循环里修改了sorted_cands中的字典,需要先给每个候选加一个placed标记。而且嵌套循环的写法不够高效,但为了清晰演示还是可以的。不过我们需要更完整的示例,包括生成候选数据和调用函数。下面我们重新写一个完整可运行的版本,并加上更清晰的注释。
# 技术栈:Python 3.9+(无第三方依赖)
import random
from collections import Counter, deque
# ---------- 模拟候选数据 ----------
# 假设我们已经有了一个经过MMR重排的候选列表,每个元素是:
# {'id': 商品ID, 'category': 类目, 'is_long_tail': 是否长尾, 'score': 综合得分}
# 我们生成20个候选,其中8个是长尾,12个是热门
def make_candidates():
cands = []
categories = ['口红', '眼影', '粉底', '腮红', '眉笔']
# 生成热门商品(非长尾),分数高一些
for i in range(12):
cands.append({
'id': f'hot_{i}',
'category': random.choice(categories),
'is_long_tail': False,
'score': random.uniform(0.8, 1.0) # 高分
})
# 生成长尾商品,分数偏低(但在MMR中已略有多样性加权)
for i in range(8):
cands.append({
'id': f'tail_{i}',
'category': random.choice(categories),
'is_long_tail': True,
'score': random.uniform(0.5, 0.7) # 低分
})
# 为了模拟真实情况,将候选列表随机打乱
random.shuffle(cands)
return cands
# ---------- 带约束的重排函数 ----------
def constrained_rerank(candidates, window_size=10, max_continuous=3,
long_tail_min=2, hot_max_ratio=0.6):
"""
在有限曝光窗口内,用硬性规则约束多样性。
参数含义:
window_size: 滑动窗口大小,每次展示的一屏条数
max_continuous: 同一类目最多连续出现的条数
long_tail_min: 每个窗口内长尾商品的最低条数
hot_max_ratio: 每个窗口内热门商品的最大占比(热门指非长尾)
"""
# 第一步:按得分降序排序(初始排序)
sorted_cands = sorted(candidates, key=lambda x: -x['score'])
# 给每个候选加一个标记,记录是否已经被放置到结果中
for cand in sorted_cands:
cand['_placed'] = False
result = [] # 最终排序结果
# 我们反复扫描排序后的列表,每次尝试选择一个最合适(序列最靠前且满足约束)的候选
# 为了避免无限循环,设置一个最大尝试次数,最多就是候选总数
for _ in range(len(sorted_cands)):
# 如果所有候选都已放置,跳出
if all(c['_placed'] for c in sorted_cands):
break
# 遍历候选,找到第一个满足约束且未放置的
for cand in sorted_cands:
if cand['_placed']:
continue
item = {
'id': cand['id'],
'category': cand['category'],
'is_long_tail': cand['is_long_tail'],
'score': cand['score']
}
# 检查约束1:同一类目连续出现次数
if len(result) >= max_continuous:
# 看最近max_continuous条的类目
recent_cats = [r['category'] for r in result[-max_continuous:]]
# 如果最近max_continuous条都是同一个类目,而且当前候选也是该类目,就不能放
if all(cat == recent_cats[0] for cat in recent_cats) and recent_cats[0] == item['category']:
continue # 非法,跳过
# 检查约束2:加入后,当前窗口内的长尾数量和热门占比
# 计算加入后的窗口内容(取最近window_size条)
simulated = (result + [item])[-window_size:]
long_tail_count = sum(1 for r in simulated if r['is_long_tail'])
hot_count = len(simulated) - long_tail_count
hot_ratio = hot_count / len(simulated)
# 长尾条数不能少于long_tail_min
if long_tail_count < long_tail_min:
continue
# 热门占比不能超过hot_max_ratio
if hot_ratio > hot_max_ratio:
continue
# 所有约束都满足,放置该候选
result.append(item)
cand['_placed'] = True
break
else:
# 如果没有找到符合条件的候选,说明约束设置太严格或数据不满足
# 这时只能“破例”放置一个未放置的候选,否则会死循环
# 我们选择第一个未放置的放在末尾(真实系统需要更复杂的策略,比如放松约束)
for cand in sorted_cands:
if not cand['_placed']:
result.append({
'id': cand['id'],
'category': cand['category'],
'is_long_tail': cand['is_long_tail'],
'score': cand['score']
})
cand['_placed'] = True
break
return result
# ---------- 运行示例 ----------
if __name__ == "__main__":
random.seed(42) # 固定种子,便于复现
candidates = make_candidates()
print("=== 初始候选列表(前10条)===")
for c in candidates[:10]:
print(f"{c['id']:>8} | 类目:{c['category']:<4} | 长尾:{c['is_long_tail']} | 得分:{c['score']:.2f}")
reranked = constrained_rerank(candidates, window_size=10, max_continuous=3,
long_tail_min=2, hot_max_ratio=0.6)
print("\n=== 重排后的结果(前20条)===")
for i, item in enumerate(reranked, 1):
print(f"{i:>2}. {item['id']:>8} | 类目:{item['category']:<4} | 长尾:{item['is_long_tail']} | 得分:{item['score']:.2f}")
# 验证约束是否满足
window = reranked[:10]
long_tail_count = sum(1 for r in window if r['is_long_tail'])
hot_count = 10 - long_tail_count
print(f"\n第一个窗口(前10条)统计:长尾{long_tail_count}条,热门{hot_count}条,热门占比{hot_count/10:.1f}")
这个示例里我们实现了两个核心约束:连续类目限制和窗口内热门占比限制。运行后你会看到,虽然初始分数高的热门商品排前面,但在重排时系统会有意跳过一些热门,插入长尾商品。实际工程中,window_size、max_continuous、long_tail_min、hot_max_ratio这些参数都需要通过线上实验来调优。
四、不同约束策略的技术优缺点
刚才我们写代码时用了组合约束,接下来分析一下几种常见策略的优缺点。
4.1 连续数量限制
优点:实现简单,几乎不增加计算量,对用户体验改善明显,因为它直接打击了“刷屏感”。缺点:只控制连续,不控制整体分布。比如类目A和类目B交替出现,但A的数量占到80%,连续限制无法解决这个问题。
4.2 窗口占比约束
优点:能精准控制一个滑动窗口内各类目或热门/长尾的比例,更贴近“有限曝光窗口”这个业务需求。缺点:窗口大小不好定。窗口太小,长尾配额太紧,可能没有足够的长尾候选;窗口太大,约束反应慢,用户可能已经翻了好几屏才看到效果。
4.3 分桶交错
优点:可控性强,可以明确按“热-热-长-热-长”的模式固定节奏,业务方容易理解。缺点:僵化,如果用户兴趣变化或者候选池不足,交错模式会让内容显得机械。需要动态调整模式,增加了复杂性。
4.4 动态阈值
根据用户对多样性的偏好程度动态调整约束强度。比如新用户喜欢新鲜感,就多给长尾;老用户如果频繁点击长尾,就提高长尾配额。优点:个性化。缺点:需要实时计算用户偏好,工程复杂度高,而且如果阈值调得太激进,可能牺牲短期点击率。
五、需要注意的坑和实战建议
5.1 别把长尾当垃圾
长尾商品不一定质量差,只是它不适合所有人。在设计约束时,要确保长尾商品本身的相关度得分不是太低,最好设置一个最低分阈值,避免为了多样性硬塞“毫不相关”的东西。不然用户刷到一条完全没兴趣的长尾,反而会反感。
5.2 约束要放在MMR之后再执行吗?
我们示例中是放在MMR得分之后做硬性约束。更合理的做法是:先把候选集做一次MMR排序,得到初始序列;然后执行多样性约束重排。约束重排时,如果跳过某个热门,会导致得分顺序变化,所以最好在一个统一的打分框架内完成。工程上可以先用MMR召回,再用约束重排模块微调。
5.3 评估效果不能只看点击率
加入多样性约束后,短期点击率很可能下降,因为热门内容的点击率高。但长期来看,用户会因为看到更多新鲜内容而提高留存和满意度。所以评估指标要包括:长尾商品的曝光率、长尾商品的点击率、用户人均浏览时长、多样性的熵值等。不能只顾着一棵树的点击率,忘了整个森林的生态。
5.4 兜底策略很重要
真实环境中,候选池可能不够大,或者某一类长尾商品特别少。这时候约束条件可能永远无法满足,你的重排函数就会陷入死循环。所以必须设计兜底策略,比如允许“破坏”一个最弱的约束,或者把未放置的候选直接追加到末尾。我们上面的代码里就用了“破例放置”的兜底,但更优雅的方式是逐步放松约束,比如先把long_tail_min从2降到1,再不行就降为0。
5.5 参数要跟着业务走
不同产品对多样性的需求不一样。电商平台可能希望长尾商品别太离谱,新闻资讯可能更希望打破信息茧房。所以参数没有一个万能值,必须做A/B测试。可以给每个参数设几个档位:激进、标准、保守,然后分流量实验。
5.6 别忘了实时反馈
信息流的曝光窗口是有限的,但用户的行为是连续变化的。如果用户在刷的过程中对长尾商品点了赞或加了购物车,那么下次重排时应该提高这个长尾的权重。这些反馈要实时写进候选数据的得分里,不然多样性约束就是“死”的。
六、总结
信息流重排引入最大边际相关算法,本来是为了平衡相关性和多样性,但如果相关度得分差距太大,热门内容依然会像巨石一样碾过长尾商品。解决这个问题的关键不是提升MMR里的alpha,而是把多样性从“软性惩罚”变成“硬性约束”。我们可以用组合策略:在滑动窗口内同时限制连续类目数量、长尾最低配额、热门最高占比,并且通过动态参数和兜底逻辑来适应真实流量。注意,多样性约束不是为了平均主义,而是为了在有限曝光窗口内,给每个值得被看到的商品一个被看见的机会。就像开一家实体店,你不可能把最畅销的货品全堆在门口,总得给那些有特色的冷门货留几个货架,哪怕它们不能立刻赚钱,但店铺的吸引力恰恰来自这种丰富感。
最后给你留个思考:如果你的信息流里既有视频、图文,又有商品卡片,这种跨内容类型的多样性应该如何设计约束?也许你可以把“类目”换成“内容形式”,在滑动窗口内限制连续出现相同形式内容的次数,试试看效果。
评论
围绕“信息流重排阶段引入最大边际相关算法后,热门内容过度挤压长尾商品,如何在有限曝光窗口内设计多样性约束策略?”参与讨论