相关性推荐落地:item-to-item相似度与召回排序实践
做内容站或电商站的技术同学,大概都遇到过这种场面:运营在群里甩来一张截图,说首页那个“相关性推荐”模块推出来的东西驴唇不对马嘴,点进去一看,一篇讲咖啡器具的文章底下挂着三款手机壳。开发觉得委屈——相似度明明算出来了,分数也不低;运营觉得开发不懂业务。这类扯皮在很多网站上都发生过,根子往往不在算法写得好不好,而在于一开始就没搞清楚相关性推荐到底是为了解决什么问题。如果目标模糊,后面所有的分词、向量、召回、排序都是在错误的方向上加精度。
相关性推荐,说白了就是“你看的这个东西,跟另外哪些东西是一路的”。它要回答的问题非常朴素:用户当前正在看的这个页面,接下来最有可能愿意看什么、买什么、点什么。它不猜用户是什么人,也不管用户昨天看过什么,只看眼前这一件“东西”和库里其他“东西”的关系。能做的事很实在:把用户从“看完就走”变成“再看一个”,把无人问津的库存内容重新推到台前,把纯靠人肉排的运营位从重复劳动里解放出来。适合谁看?我建议三类人认真读一读:一是刚接手推荐模块、不知道怎么定指标的后端和算法同学;二是被“推荐不准”反复投诉、想知道问题出在哪个环节的产品和运营;三是技术负责人,需要判断这个模块值不值得投入、投入多少合适。
1. 先把问题说清楚:相关性推荐到底在跟什么较劲
1.1 信息过载下的选择瘫痪是怎么形成的
一个正常运营两三年的内容站,SKU 或文章量级通常在几万到几十万之间。这个数量级有个尴尬之处:它已经远远超出人工编排的覆盖范围,但又没有大到必须上复杂推荐系统的程度。人工运营位一天能维护的条目,往多了算几十条,而站内可分发的内容有几万条,覆盖率不到千分之一。剩下的内容基本靠自然流量碰运气,碰上的继续涨,碰不上的永远躺着。
用户侧的困境同样明显。类目导航的层级越做越深,因为内容越来越多,运营只能不断加分类;但用户点进三级类目之后,面对的是一个几十条甚至上百条的平铺列表,没有任何筛选和引导。这时候用户的真实状态不是“我要挑一个”,而是“我看不过来,随便点一个或者直接关掉”。选择瘫痪的本质不是选项太多,而是选项之间没有区分度,用户无法在短时间内判断哪个更符合自己的需求。
相关性推荐介入的正是这个环节。它不改变供给总量,而是改变供给的呈现顺序:把跟当前内容最相关的那几条提到最前面,让用户在有限注意力里看到最可能感兴趣的东西。这个动作听起来简单,但它把“用户自己在一堆东西里找”变成了“系统替用户先筛一遍”,决策成本直接降下来了。
1.2 它和“猜你喜欢”根本不是一回事
很多团队在这个地方栽跟头:把相关性推荐当个性化推荐做,一上来就要用户画像、要长期兴趣建模、要跨端打通。方向没错,但顺序错了,成本也完全不是一个量级。
我把两者的差别列成一张表,方便对照:
| 维度 | 相关性推荐 | 个性化推荐 |
|---|---|---|
| 输入信号 | 当前物品的标签、内容、行为共现 | 用户历史行为、画像、上下文 |
| 计算粒度 | 物品到物品(item-to-item) | 用户到物品(user-to-item) |
| 冷启动表现 | 新用户来了就能用 | 新用户几乎无解 |
| 可解释性 | 强,能说清“因为你看的是A” | 弱,经常说不清为什么推这个 |
| 缓存友好度 | 高,相似度矩阵可离线算好 | 低,每个用户一套结果 |
| 工程成本 | 中小团队一周能跑通 | 需要特征平台和实时链路 |
理解这张表的关键在最后两行。物品到物品的相似度是离线可预计算的,十万条内容两两算一遍看着吓人,但实际只需要为每条内容保留 Top 50 个邻居,存储量是可控的。算完之后在线只需要一次键值查询,响应时间能做到个位数毫秒。而个性化推荐要为每个用户实时算一遍,链路长、依赖多、出故障的概率高得多。
所以我的建议很明确:先把相关性推荐做扎实,再考虑个性化。相关性推荐是地基,它产出的物品相似关系,后面做个性化召回时照样要用。跳过这一步直接上个性化,大概率会得到一个既不准也不好维护的系统。
1.3 什么样的网站真的需要它,什么样的不需要
不是所有网站都值得投入。判断标准其实就一条:站内是否存在“内容量超出人工编排能力,且内容之间存在可被描述的关系”的情况。
典型需要的情况:电商站的商品详情页、内容站的文章详情页、视频站的播放页、文档站的词条页、课程平台的课程页。这些页面的共同特征是,用户到达时已经表达了一个明确的兴趣点,此时给他“关于这个兴趣点的更多选择”,接受度天然就高。
不太需要的情况:单页工具型产品、内容量在几百条以内且关系很弱的站点、用户停留时间本身就以秒计的落地页。给这类站点硬上推荐模块,收益覆盖不了维护成本,还容易拖慢首屏。
还有个容易被忽略的判断依据:看跳出率的结构。如果详情页的跳出率明显高于站内其他页面,且跳出用户的行为路径高度集中于“看完当前页就离开”,那基本可以确认是“下一跳缺失”导致的流失,相关性推荐能直接对上这个症状。反之,如果跳出主要发生在首页或者落地页,那是入口和分发的问题,得先解决那边。
2. 相关性推荐解决的六类具体问题
2.1 找得到:搜索意图与内容供给之间的错位
站内搜索有个经典现象:用户搜的词,站内没有完全匹配的内容,但有一堆语义相近的。比如用户搜“降噪耳机续航”,站内商品标题写的是“无线蓝牙耳机 长待机”,关键词对不上,搜索结果空空如也。用户不会认为是自己搜错了,只会觉得这个站没有他要的东西,然后离开。
相关性推荐在这个场景里扮演的是兜底角色。搜索无结果或者结果很少的时候,用当前查询的语义向量去物品库里做最近邻检索,把语义相近的内容补上来。同样,搜索结果页的排序也可以引入相关性分数,让“标题含关键词但内容不搭”的条目往下沉。
这里有个实操细节值得说:搜索兜底不要直接用热门填充。搜“降噪耳机续航”给推一堆手机壳,用户不但不会点,还会对站点的专业度产生怀疑。宁可只给三条真相关的,也不要给十条凑数的。
2.2 留得住:跳出率背后的“下一跳”断档
详情页跳出率高,很多时候不是内容不好,而是用户看完之后没有明确的下一步。人的注意力是有惯性的,刚看完一篇讲某款相机镜头的评测,此时他对“同价位镜头对比”“该镜头的实拍样张”“配套机身推荐”这类内容的接受度是最高的。如果页面上什么都没有,这个惯性就断了,用户切走的成本几乎为零。
相关性推荐模块在这里的作用,是在注意力惯性的窗口期内给一个低成本的继续动作。这个窗口很短,我的经验是要让模块在首屏或次屏可见,且点击路径不超过一次。有些站把它放在页面最底部,用户得滚三屏才能看到,效果自然差——滚到底部的用户本来就已经准备离开了。
衡量的指标不能只看模块本身的点击率,还要看二跳率和人均页面浏览深度。模块点击率高但二跳之后立刻跳出,说明推荐的东西吸引力够但承接不住,问题在下游页面而不是推荐本身。
2.3 卖得动:关联购买与客单价的提升逻辑
电商场景下,相关性推荐最直接的商业价值体现在关联购买上。用户看一台咖啡机,配套的滤纸、清洁片、咖啡豆就是天然的关联项。这类推荐的说服力来自使用场景的连贯性,而不是价格促销。用户不是因为便宜才买滤纸,是因为他意识到“买了机器还得有耗材”。
这里必须提醒一个常见误区:把“同类商品”当成“关联商品”。用户看 A 品牌的入门款咖啡机,你给他推 B 品牌的同价位咖啡机,这是替代关系,等于在把他往外送。真正该推的是互补品和上下游。这个判断靠内容标签能做出六七成,剩下靠共购买数据补:如果两个商品在历史订单里高频出现在同一笔交易中,那它们就是互补的;如果高频出现在不同订单里但同一个人前后买过,那更可能是替代关系。这两种关系的推荐策略完全相反。
客单价的提升不是靠硬推贵的,而是靠凑单逻辑。用户买一件 89 元的商品,包邮门槛 99 元,此时推荐 15 元的配件,转化率远高于推荐 300 元的升级款。这个逻辑在相关性推荐里体现为“价格带约束”,需要在重排阶段就把不符合当前价格区间的候选过滤掉。
2.4 冷启动:新内容如何拿到第一批曝光
新上线的商品或文章,没有点击、没有收藏、没有共现数据,纯行为驱动的推荐系统对它完全无感。这就是马太效应的源头:头部内容因为有点击所以获得更多曝光,新内容因为没曝光所以永远没有点击。
相关性推荐的内容侧信号恰好能解决这个问题。新文章一上线就有标题、标签、正文、类目,这些信息不依赖任何用户行为,可以直接算出它跟已有内容的相似度,从而挂到相应的详情页下面。也就是说,新内容从第一天起就有位置可去,只是位置的好坏需要靠后续行为数据来调。
我做过一个观察:在中型内容站,纯行为信号驱动的推荐,新内容平均要 7 到 10 天才能获得稳定的曝光量;引入内容相似度兜底之后,这个周期能压缩到 1 到 2 天。对于有时效性的内容(比如节气、活动、热点解读),这个时间差基本决定了内容有没有价值。
2.5 长尾:沉睡的腰部内容怎么被重新激活
站内内容通常呈金字塔分布:头部 5% 拿走 80% 的流量,腰部和尾部大量内容几乎无人问津。但这些内容不是没价值,是没有被发现的路由。用户不会主动去翻三年前的旧文章,但如果他在看一篇相关主题的新文章时,旁边恰好挂着那篇旧文的链接,他是有可能点进去的。
相关性推荐提供的正是这种“路由”能力。它把内容之间的语义关系建成一张图,任何一次访问都可能成为某个长尾节点的入口。这个过程不需要额外生产成本,纯粹是存量价值的再分配。
不过要注意,长尾内容被激活之后,质量参差的问题会暴露出来。有些旧文的内容已经过时,标题还写着“最新”,用户点进去发现信息陈旧,反而损伤信任。我的处理办法是在候选集里加一条时效过滤规则:超过一定时限的内容,在进入推荐池之前先做一次人工或半自动的复核,不符合当前情况的直接排除,而不是让它靠相似度混进来。
2.6 省人力:把运营从重复编排里解放出来
这一条经常被低估,但对中小团队来说可能是最实际的价值。人工维护“相关推荐”这类模块,本质是在做一件高度重复的事:每上新一批内容,就得手动去配关联。内容量一大,运营根本配不过来,最后要么放弃,要么用一套固定的通用列表糊弄过去。
把这部分工作交给相关性计算,运营的角色就从“逐条配置”变成“定规则、抽查、处理异常”。人力从 O(内容量) 降到 O(规则数),这个量级变化对小团队来说是决定性的。
我一般的做法是保留人工干预的口子:系统自动生成候选,运营可以对特定条目做置顶、屏蔽、替换。既不失去自动化带来的效率,也不至于完全失控。关键是干预要有记录,哪些条目被人工改过、改的原因是什么,这些数据积累下来,反过来能帮你发现算法的系统性偏差。
3. 相关性是怎么算出来的:核心链路的拆解
3.1 三路信号:内容、行为、上下文
相关性计算本质上是在定义“两个东西有多像”,而“像”这件事可以从三个角度看,每一路信号解决不同的问题。
内容信号:标题、正文、标签、类目、属性。这路信号的优势是覆盖全、不依赖行为数据,新内容一上线就能算;劣势是无法表达“语义之外的用户偏好”,比如两篇文章字面很像但读者群体完全不同。
行为信号:共点击、共收藏、共购买、同一次会话内的先后访问。这路信号直接反映真实偏好,效果好得多,但有严重的冷启动和稀疏性问题:新内容没数据,小众内容数据太少,算出来的共现频次全是噪声。
上下文信号:时间、位置、设备、来源渠道。比如早上推的内容和深夜推的内容,用户接受度不一样;从搜索引擎来的用户和站内老用户,行为路径也不一样。这路信号通常不参与相似度计算,而是在重排阶段做调整。
我的经验配比是:内容信号占四到五成作为基础,行为信号占三到四成作为修正,上下文占一到两成做微调。这个比例不是固定的,行为数据积累得越多,行为信号的权重可以越高;数据稀疏的业务线,内容信号的权重就得往上提。
3.2 从词袋到向量:相似度算法的取舍
最朴素的做法是词袋加余弦相似度。原理不复杂:把每条内容表示成一个词频向量,两两算夹角余弦,值越接近 1 越相似。优点是实现简单、可解释、算得快;缺点是完全丢失语序和语义,“手机壳”和“手机”在词袋模型里可能因为共享一个词而算出很高的相似度。
我拿一个真实的小例子演示一下这个过程,也顺带说明为什么纯内容相似度往往不够用。假设语料只有三篇内容,要算 A 和 B 的相似度:
- A:轻薄 / 笔记本 / 续航
- B:笔记本 / 电池 / 续航
先算 IDF(N=3):轻薄出现 1 次,IDF = ln(3/1) ≈ 1.099;笔记本出现 3 次,IDF = ln(3/3) = 0;续航出现 2 次,IDF = ln(3/2) ≈ 0.405;电池出现 1 次,IDF ≈ 1.099。
A 的向量:轻薄 1.099,笔记本 0,续航 0.405 B 的向量:笔记本 0,电池 1.099,续航 0.405
余弦 = (0.405×0.405) / (1.171×1.171) ≈ 0.12
这个 0.12 说明了两个问题。第一,“笔记本”这个词因为在三篇里都出现,IDF 被压成了 0,完全失去了区分度——它恰恰是 A 和 B 最该共享的核心词。第二,最终相似度低得离谱,而人类一眼就能看出这两篇讲的是同一件事。这就是词袋模型的局限:它只能捕捉字面重合,捕捉不到语义。
改进方向有几个。用 BM25 替代 TF-IDF,能缓解长文本的权重失衡;引入词向量或句向量做语义相似度,能解决同义不同词的问题,代价是需要模型和额外的推理开销;引入行为共现作为主信号,内容相似度只做兜底,这是工程上最务实的方案。
算法选型上我的建议是:不要追求单一路径的极致。内容相似度负责召回广度,行为共现负责精度,两路结果合并去重,效果通常比单独优化任何一路都好。
3.3 召回、排序、重排的三段式落地
在线服务不可能每次都全库扫描,必须拆成三段。
召回阶段要快,目标是从几十万条里捞出几百条候选。常见做法是离线把每条内容的 Top N 邻居算好存到键值存储里,在线一次查询直接拿到。N 一般取 50 到 200,取决于后续排序环节的算力。这个阶段的评价指标是召回率:真正相关的内容有没有被捞进来。捞不进来的,后面再怎么排都没用。
排序阶段要对候选打分。基础分可以来自离线相似度,再叠加实时特征:当前内容的实时热度、候选内容的近期点击率、类目匹配度、时效性。这阶段可以上模型,也可以先用加权规则跑起来,看效果再决定要不要升级。
重排阶段处理业务约束:去重(同一作者、同一品牌最多出两条)、打散(避免连续三条都是同一类目)、过滤(下架、缺货、违规内容)、位置分配。很多推荐效果差,问题不在排序分数,而在重排规则写得太粗暴。
3.4 权重融合与打分公式的实操配置
给一个可以直接拿来改的配置示例,权重值只是起点,实际要按业务调:
# 候选打分:权重融合示例 WEIGHTS = { "content_sim": 0.45, # 内容相似度,0~1 "co_click": 0.30, # 行为共现归一化分,0~1 "category": 0.15, # 类目匹配,1 或 0 "freshness": 0.10, # 时效衰减,0~1 } def score(cand, ctx): s = ( WEIGHTS["content_sim"] * cand.content_sim + WEIGHTS["co_click"] * cand.co_click_norm + WEIGHTS["category"] * (1.0 if cand.cate == ctx.cate else 0.0) + WEIGHTS["freshness"] * decay(cand.publish_ts, ctx.now) ) # 价格带约束:偏离当前页商品价格带超过 2 倍的直接降权 if ctx.price_band and not in_band(cand.price, ctx.price_band, 2.0): s *= 0.3 return sdecay函数用指数衰减就够:exp(-λ * days),λ 取 0.02 到 0.05 之间。0.02 意味着 35 天衰减到一半,适合内容更新慢的站点;0.05 意味着 14 天衰减一半,适合资讯类。这个参数不要拍脑袋定,去看站内内容点击量的半衰期,对齐着设就行。
排序之后的最终展示分还要考虑位置偏差校正。同一个条目放在第一位和第五位,点击率能差三到五倍。如果训练或调参时不做校正,模型会学成“位置越靠前越好”,而不是“内容越相关越好”。简单的做法是用历史数据统计各位置的基准点击率,把实际点击率除以基准,得到归一化的相关性反馈。
4. 实操:把一个最小可用的相关性推荐跑起来
4.1 数据准备与埋点设计
上线之前先把数据打点确认清楚,这一步偷懒,后面所有分析都是空中楼阁。最少要采四个字段:
- 曝光事件:模块 ID、请求 ID、条目 ID、位置序号、时间戳。没有曝光数据,你连点击率的分母都算不出来。
- 点击事件:请求 ID、条目 ID、位置、时间戳。用请求 ID 关联曝光,才能确认这次点击来自哪一次曝光。
- 停留/转化事件:点击后的停留时长、是否发生目标行为(加购、下单、读完、收藏)。
- 上下文:来源页面 ID、设备类型、是否登录。
有个细节必须一开始就定好:请求 ID 要贯穿一次页面渲染的全部曝光。否则同一屏里出了五条推荐,你分不清用户点的是哪一条对应的哪一次曝光。
注意:曝光打点不要只在服务端返回时打,用户没滚动到模块位置就不算真正看到。比较稳妥的做法是前端做可见性判定,模块进入视口超过一定时长再上报曝光。否则算出来的点击率会系统性偏低。
4.2 离线相似度矩阵的构建流程
离线任务一般每天跑一次,数据量大的话按类目分片跑。流程是固定的几步:
- 拉取全量内容元数据(ID、标题、标签、类目、正文摘要)。
- 文本清洗:去 HTML 标签、去停用词、繁简统一、全半角统一。
- 分词并构建词表,计算每条内容的 TF-IDF 向量。
- 按类目分桶,桶内两两计算余弦相似度。分桶能把计算量从 O(n²) 降到 Σ O(ni²),效果非常显著。
- 每类目取 Top 100 邻居,按阈值过滤(分数低于 0.15 的直接丢弃,避免噪声)。
- 融合行为共现分数:把过去 30 天的共点击矩阵按同一套 ID 对齐,做归一化后加权合并。
- 结果写入键值存储,键为内容 ID,值为邻居列表(含分数)。
# 离线相似度计算的骨架 import math from collections import defaultdict def build_similarity(docs, top_k=100, min_score=0.15): # docs: {item_id: [term, term, ...]} df = defaultdict(int) for terms in docs.values(): for t in set(terms): df[t] += 1 n = len(docs) idf = {t: math.log(n / c) for t, c in df.items()} vecs = {} for iid, terms in docs.items(): tf = defaultdict(int) for t in terms: tf[t] += 1 vecs[iid] = {t: (1 + math.log(c)) * idf[t] for t, c in tf.items()} result = {} ids = list(vecs) for i in range(len(ids)): a = vecs[ids[i]] norm_a = math.sqrt(sum(v * v for v in a.values())) or 1.0 sims = [] for j in range(len(ids)): if i == j: continue b = vecs[ids[j]] norm_b = math.sqrt(sum(v * v for v in b.values())) or 1.0 dot = sum(v * b.get(t, 0.0) for t, v in a.items()) s = dot / (norm_a * norm_b) if s >= min_score: sims.append((ids[j], s)) sims.sort(key=lambda x: -x[1]) result[ids[i]] = sims[:top_k] return result这段代码在几十万条内容上跑会慢,生产环境要用矩阵运算或者分片并行。但用它验证逻辑、跑小数据集完全够用,我一般先用它把效果跑通,再考虑性能优化。
4.3 在线服务的缓存与降级策略
在线接口的要求很简单:快、稳、有兜底。展开说三层。
第一层是本地缓存。相似度结果变化不频繁,可以在服务进程内做一层 LRU 缓存,命中率通常能到 90% 以上,单次查询几百微秒就能返回。
第二层是共享缓存。本地没命中就去查共享的键值存储,响应在个位数毫秒。这里要注意设置合理的超时,别让一次慢查询拖垮整个页面渲染。
第三层是降级。缓存全部失效、上游不可用时,不能返回空白。降级策略按优先级排:同类目热门 > 同标签热门 > 全站热门。降级一定要有监控埋点,否则你会长期在降级状态下运行而毫不知情。
注意:降级用的“热门池”要按类目细分,不能用全站一个大热门池。全站热门池在垂直类目页面上的相关性极差,属于典型的“模块还在但等于没有”。
还有一个容易踩的坑:相似度矩阵是每天更新的,但内容是有增删的。如果新内容当天入库但矩阵还没刷新,它作为“被推荐方”没问题(矩阵里有它),但作为“当前页面”去查邻居就会查空。处理方式是给新内容做一个临时的兜底查询,用它的标签实时去检索同类目内容,等第二天矩阵刷新后自动切回正常链路。
4.4 效果验证:A/B 实验怎么设才不算白做
上线前先想清楚衡量什么。至少准备三层指标:
| 层级 | 指标 | 说明 |
|---|---|---|
| 模块层 | 曝光点击率、人均点击次数 | 判断模块本身有没有吸引力 |
| 站外层 | 页面停留时长、二跳率、人均浏览页数 | 判断推荐有没有带动整体消费 |
| 业务层 | 转化率、客单价、次留 | 判断商业价值 |
样本量要提前算,别跑两天看数据差不多就下了结论。用这个简化公式估算每组的曝光需求:
n = 16 × p(1-p) / Δ²
假设基线点击率 3%,希望检测出 0.3 个百分点的绝对提升,Δ = 0.003,代入得 16 × 0.03 × 0.97 / 0.000009 ≈ 51700。也就是每组大概需要 5.2 万次曝光,两组加起来 10 万出头。日曝光一万的站点,实验要跑十天以上。很多团队跑到第三天觉得数据“明显更好”就全量了,后面大概率会被打回原形。
实验期间要锁住变量:不要同时改版式、不要同时调价格、不要同时上大促。如果有不可避免的变化,就在分流时做分层,把影响控制在小范围内。
5. 踩坑记录与排查速查表
5.1 用热门冒充相关性,短期好看长期伤
这是最常见的偷懒做法:模块标题写着“相关推荐”,实际返回的是全局销量榜。上线初期数据往往还不错,因为热门内容本身点击率就高,但过一两周就会看到问题——用户在任何一个页面看到的都是同一批内容,模块的边际点击率会快速衰减,而且用户会逐渐形成“这个位置是广告”的认知,直接无视。
判断方法很简单:统计模块内条目的重复曝光率。如果同一批内容在 80% 的页面里都出现,那它就不是相关性推荐,是换了个位置的热门榜。健康的分布应该是长尾的,Top 100 条目覆盖的曝光不超过总量的 30%。
5.2 相似度分数很漂亮,点击率却很难看
这种情况我遇到过好几次,原因通常有三类。
第一类是相似但无用。两篇文章主题确实一样,但一篇是入门科普,一篇是深度技术分析,用户看完入门篇之后对深度篇的接受度并不高。这说明“内容相似度”不等于“消费递进关系”。解决办法是引入用户行为序列:统计“看完 A 之后实际看 B”的频次,用它修正内容相似度。入门到入门、入门到进阶、进阶到实战,这些路径的强度是不一样的。
第二类是同质化过重。推出来的五条都是同一款商品的不同颜色,或者同一作者的三篇文章。用户觉得“没得选”。这要在重排阶段加规则:同品牌、同作者、同主图的内容最多出一条。
第三类是位置和展示形式不匹配。同样的内容,用纯文字列表展示和用带图卡片展示,点击率能差一倍以上。如果模块本身视觉上不吸引人,再准的推荐也没人点。这种情况优化算法是南辕北辙,得回去改样式。
5.3 数据侧的几个隐蔽陷阱
位置偏差:前面提过,不做校正会让模型误以为位置靠前就是相关。表现是模型上线后总是把同一批内容排在前面,形成自我强化。
数据泄漏:用全量历史数据训练,包含了实验期之后的数据,线下指标虚高。做时间切分,训练集一定在测试集之前。
反馈闭环失真:推荐系统只给用户看候选池里的内容,池子外的内容永远拿不到反馈,于是池子外的内容永远被认为“不相关”。这就是所谓的曝光偏差。缓解办法是在候选里强制掺入一定比例的非热门内容,作为探索位。
ID 对齐失败:内容侧和行为侧用的是两套 ID,或者中间做过迁移导致 ID 变更,共现矩阵算出来全是零。这个坑非常隐蔽,表现是“行为信号完全不起作用”。上线前一定要抽样核对两边的 ID 能不能对上。
5.4 问题排查速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 模块全程返回同一批内容 | 降级策略触发,或相似度矩阵未更新 | 查降级监控、查离线任务最近一次成功时间 |
| 新内容从不出现 | 矩阵未刷新,或内容未进入召回池 | 核对新内容的入库时间与矩阵刷新时间 |
| 点击率骤降 | 上游内容变更、过滤器误杀 | 对比变更前后候选集数量与分布 |
| 推荐结果与当前页完全无关 | ID 对齐错误、类目映射错误 | 抽样打印当前页 ID 与其邻居 ID |
| 某些类目永远无推荐 | 该类目内容太少,走不到阈值 | 查类目内容量,考虑跨类目召回 |
| 线上效果远差于离线评估 | 位置偏差未校正、数据泄漏 | 用线上日志重算离线指标做对比 |
6. 一些容易被忽略的边界与取舍
6.1 相关性和多样性之间的拉扯
纯粹追求相关性,结果一定是同质化。用户的兴趣是有宽度的,连续看五条一模一样的东西,从第三条开始就疲劳了。所以实际系统里必须人为引入多样性。
常见做法是在重排阶段做 MMR(最大边际相关)式的贪心选排:每选一条,就把它和已选条目的相似度作为惩罚项扣减剩余候选的分数。公式大致是score_i - λ × max_sim(i, 已选集合),λ 取 0.2 到 0.4 之间。λ 越大越多样,但相关性损失也越大。
这个参数没有普适最优值,得看场景。商品详情页的关联推荐,λ 要小一点,因为用户在这个位置就是奔着强关联去的;资讯流的推荐位,λ 可以大一些,用户对新鲜感的容忍度更高。
6.2 它解决不了的问题,别硬让它解决
这点必须说清楚,否则会导致对系统能力的错误预期。
相关性推荐解决不了供给质量问题。站内内容本身写得差、商品本身评价低,推荐得再准也只是把差东西更快地送到用户眼前,反而加速用户流失。这类问题得回到生产端解决。
它解决不了用户的探索需求。“我也不知道想看什么,随便逛逛”这种状态,本质上需要的是多样性探索和惊喜感,而相关性推荐的逻辑是收敛而不是发散。这类需求要靠榜单、专题、编辑精选去承接。
它也替代不了搜索。用户明确知道自己要什么的时候,搜索的效率远高于推荐。相关性推荐的正确定位是搜索的补充,不是替代。
6.3 一个容易被低估的投入:评估与标注
大部分团队在算法和工程上投入很多,在评估上投入很少,最后陷入“上线了但不知道好不好、改了也不知道有没有变好”的状态。我的建议是,项目一开始就留出 10% 到 15% 的精力做评估体系。
最基础的是抽样人工标注:随机抽 200 个页面,看每个页面推荐出的内容是否真的相关,打三档分(相关、勉强、不相关)。这个动作一次只要几个小时,但它能给你一个不依赖点击数据的客观基线,也能在线上指标异常时快速定位是数据问题还是内容问题。
稍微进阶一点可以做离线评估集:固定一批查询和对应的正确结果,每次算法迭代都在这批数据上跑一遍,看召回率和准确率的变化。这套东西建起来之后,迭代速度会明显加快,因为你有了一个不用等线上数据的快速反馈通道。
我在实际项目里的体会是,相关性推荐这个模块的成败,八成取决于前期有没有把“解决什么问题”想清楚,两成取决于算法选得好不好。见过太多团队花三个月调模型,最后发现真正的问题是新内容进不了召回池、或者降级策略一直在生效。先把数据链路打通、把降级和监控做扎实、把评估基线建起来,再回头优化算法,顺序反了就是白费力气。另外分享一个很实用的小技巧:给模块加一个内部调试参数,在 URL 上带上就能看到每条推荐的来源、分数构成和命中的规则。运营报问题的时候让他带上这个链接,能省掉大量来回沟通。