RECOMMENDATION SYSTEM ARCHITECTURE

图文帖子 推荐系统
全链路技术架构

从用户发帖到内容推荐,覆盖异步处理、多路召回、排序打散的完整工程方案

10K+
日活用户
1M+
日发帖量
<50ms
推荐延迟
向下滚动
01 — OVERVIEW

全链路总览

一篇帖子从发布到被另一个用户刷到,经历了"内容入库 → 理解打标 → 画像构建 → 多路召回 → 排序打散 → 队列推送"的完整链路。整体分为异步处理在线服务两大部分。

系统架构全景
Client (iOS/Android)
埋点上报 · 推荐拉取
↕ HTTP / WebSocket
Go API / BFF
发帖 · 行为上报 · 推荐接口
Python NLP
分词 · 打标签 · 分类
推荐引擎
召回 · 排序 · 打散 · 去重
↕ 读写各类存储
MySQL
真相源
Elasticsearch
召回索引
Redis
画像 / 队列 / 去重
ClickHouse
行为分析
Kafka
异步消息
两条核心路径

推荐系统的数据流不是单向的——它分为写入路径(内容入库 + 行为采集,异步、离线)和读取路径(用户刷推荐,在线、实时)。理解这两条路径是理解整个系统的关键。

W
写入路径(异步 · 不阻塞用户)
发帖/行为Go APIKafkaNLP/统计MySQL ES Redis ClickHouse
用户发帖 → MySQL 存原始数据 → Kafka 通知下游 → NLP 打标签 → ES 建索引 → 质量分计算
用户行为 → Kafka → ClickHouse 存日志 → 定时更新画像写入 Redis
R
读取路径(在线 · P99 < 200ms)
用户刷新BFFRedis Queue不够?Redis Profile ES Query排序打散回写队列
用户下拉 → BFF 从 Redis 队列 LPOP 20 条 → 返回客户端
队列不够?→ 读 Redis 画像 + ES 多路召回 → 排序 → 打散 → Bloom 去重 → RPUSH 回队列
01

用户发帖

Go 接口接收帖子内容,写入 MySQL,同时往 Kafka 发送一条消息通知下游异步处理。接口立即返回发布成功,不阻塞用户。

Go API MySQL Kafka
02

内容理解 & 标签计算

Python 标签服务从 Kafka 消费新帖子消息,对文本做分词、关键词提取、主题分类,给帖子打上标签。结果写回 MySQL 并同步到 ES 倒排索引。

Python NLP Elasticsearch
03

质量分计算

发布时根据内容特征算初始分,上线后根据互动数据(点赞率、完读率、负向信号)定时更新。两个阶段的分数按曝光量动态加权合并。

定时任务 ClickHouse
04

行为采集 & 用户画像

客户端埋点上报用户行为,经 Kafka 落入 ClickHouse。定时任务读取行为数据,关联帖子标签,加权汇总出每个用户的兴趣向量,写入 Redis。

ClickHouse Redis
05

多路召回 → 排序 → 打散

用户请求推荐时,并发执行多路召回(兴趣匹配、热门、关注流、随机探索),合并去重后排序打分,再做结果打散保证多样性。

Go 并发 ES Boost Redis
06

待推队列 & 无限刷

排序打散后的结果写入 Redis 待推队列,前端每次取 20 条。队列快用完时异步预加载下一批,实现无限滚动的无缝体验。

Redis List Bloom Filter
技术栈一览
Go
BFF 接口、推荐引擎、召回排序。高并发低延迟,goroutine 天然适合并发多路召回。
Python
NLP 标签服务、离线画像计算。丰富的 NLP 生态(jieba/HuggingFace),适合算法迭代。
MySQL
帖子、用户、标签的真相源。所有其他存储从它同步。ACID 保证数据不丢。
Elasticsearch
召回引擎。倒排索引 + BM25 + Boost,毫秒级多条件组合检索。
Redis
推荐系统的"瑞士军刀"——画像 Hash、队列 List、去重 Bloom、热门池 ZSet、缓存。
ClickHouse
行为日志分析。列式存储,亿级数据聚合查询秒出。画像计算、指标分析的后盾。
Kafka
异步消息中枢。削峰填谷、系统解耦、数据可重放。所有事件通过它流转。
核心设计原则
1
离线计算 + 在线读取
所有"重"计算(打标签、画像、质量分)都在离线/异步完成,在线服务只读预计算好的结果。 用户请求推荐时,不做任何耗时计算——只从 Redis 读画像、ES 读索引、Redis 写队列。
2
异步解耦
发帖不等打标签完成就返回;行为上报不等入库就返回。所有耗时操作通过 Kafka 解耦, 上游只管发消息,下游按自己的节奏消费。任何单个组件挂了不会拖垮全链路
3
多存储协同
没有"一个数据库搞定一切"——MySQL 保证一致性、ES 做快速检索、Redis 做极速读写、 ClickHouse 做聚合分析、Kafka 做异步传输。每种存储只做它最擅长的事
4
多级降级保底
个性化推荐失败时不返回空白——逐级降级到热门、精选、历史重推。 任何存储故障都有兜底方案:ES 挂了用缓存存量,Redis 挂了降级为无个性化热门流。 用户体验永远不归零
5
数据驱动迭代
每一次改动都通过 A/B 测试验证。北极星指标 → 效果/体验/生态指标 → 系统诊断指标, 层层拆解。指标异常时要能定位到具体模块,而不是"不知道哪里出了问题"。
一次推荐请求的生命周期
0ms 用户下拉 → 客户端发送 GET /api/recommend?count=20
5ms BFF 从 Redis 队列 LPOP 20 条 → 检查剩余量
10ms 队列够 → 直接返回 20 条给客户端(快路径,<20ms 完成)
10ms 同时检查水位线 → 低于阈值 → 异步触发补充(不阻塞本次响应)
50ms (异步)读 Redis 画像 → 并发 4 路 ES 召回 → 合并 ~500 条候选
120ms (异步)排序打分 → 打散 → Bloom 去重 → 取 Top 200 → RPUSH 入队列
Done 下次用户再刷 → 队列已备好 200 条 → 又是 <20ms 秒回

关键点:用户感知的延迟只有"快路径"的 10~20ms(Redis LPOP)。召回+排序的 100~200ms 是异步完成的,用户无感知。

规模参考 — 这套架构能扛多大?
1~100 万
DAU 覆盖范围
单机 → 小集群即可
< 200ms
推荐接口 P99 延迟
队列命中时 < 20ms
5 亿+
日行为事件量
ClickHouse 轻松扛
~45 GB
Redis 内存(百万 DAU)
画像+队列+去重+缓存
渐进式演进:这套架构的核心优势是可以从小做起—— 起步时 MySQL + Redis + 简单规则就能跑;有了数据后加 ES 做召回;用户量上来加 ClickHouse 做分析、Kafka 做解耦。 不需要一步到位,每个组件都是"什么时候痛了什么时候加"。
后续章节导航

接下来的每一章深入讲解一个模块。你可以按顺序学习完整链路,也可以跳到你最关心的部分。

02 异步处理流水线
03 用户画像构建
04 多路召回策略
05 内容质量评估
06 排序与打分
07 结果打散
08 已读去重
09 待推队列与无限刷
10 存储架构
11 衡量指标体系
12 演进路线图
02 — ASYNC PIPELINE

异步处理流水线

所有耗时的计算都在后台异步完成,在线服务只需要读取预计算好的结果,保证推荐请求的实时性。

数据流向:发帖链路
用户发帖
Go API
Kafka
Python 标签服务
MySQL + ES
数据流向:行为链路
用户行为
Go API
Kafka
ClickHouse
定时任务
Redis 画像
📨

Kafka 消息队列

异步解耦和削峰填谷。Go 接口收到请求后立即扔进队列,下游按自己的节奏消费。即使流量暴增也不会压垮下游服务。前期规模较小时也可用 Redis Stream 替代。

📊

ClickHouse 行为存储

列式存储,特别适合"最近 7 天用户 A 点赞了哪些帖子"这类聚合查询。一天几千万条行为日志,按时间分区存储,查询速度极快。前期可用 MongoDB 替代。

🏷️

Python 标签服务

独立微服务,从 Kafka 消费新帖子消息。使用 jieba 分词 + 关键词匹配做内容分类。与 Go 后端通过消息队列解耦,互不影响。后期可升级为分类模型或大模型打标签。

行为链路详解:采集什么数据

行为数据是推荐系统的燃料。采集得越全、越细,用户画像就越准。行为分为两大类:用户主动触发的显式行为和系统被动记录的隐式行为

👆

显式行为(用户主动操作)

有明确的接口调用,后端天然就能捕获。这类行为信号强,权重高。

👍 点赞
推荐权重:5
明确的正向信号,说明内容得到认可
收藏
推荐权重:4
比点赞更强,说明用户想回头看
💬 评论
推荐权重:5
参与度最高的行为之一
🔗 分享
推荐权重:6
最强正向信号,愿意推荐给别人
关注作者
推荐权重:6
长期兴趣信号,影响关注流召回
🚫 举报 / 不感兴趣
推荐权重:-5
强烈负向信号,同类内容降权
👁️

隐式行为(系统被动记录)

用户没有主动操作,但客户端可以通过埋点捕捉。信号较弱但数据量大,是画像构建的重要补充。

⏱️ 停留时长
推荐权重:1~3(按时长分档)
停留 <2s 视为划过(负信号)
>5s 视为有效浏览
>15s 视为深度阅读
📸 图片滑动完成率
推荐权重:2~3
滑到最后一张图 = 完读
只看第 1 张就走 = 低兴趣
图文帖的核心隐式信号
📖 正文展开
推荐权重:2
用户主动点"展开全文"
说明标题/首图吸引了 TA
且愿意花时间细看
💬 查看评论区
推荐权重:2
点开评论区但没评论
说明内容引发了好奇心
介于浏览和互动之间
🔍 点击作者主页
推荐权重:3
对作者感兴趣的信号
虽然没关注但有探索意图
可加强该作者内容的推荐
快速划过
推荐权重:-1
停留 <1s 直接划走
单次不说明什么
但连续划过同类 = 降权信号

行为日志数据结构

每条行为日志写入 ClickHouse 时的字段结构。客户端攒一批(比如 5-10 条)后统一上报,减少网络请求。

behavior_event.json
{
  "user_id":        "10086",
  "post_id":        "p_20260205_83721",
  "event_type":     "view",           // view | like | collect | comment | share | follow | report | dislike
  "duration_ms":    8200,             // 停留时长(毫秒),仅 view 类型有值
  "image_progress": 0.75,             // 图片浏览进度 0~1,看了 4 张中的 3 张 = 0.75
  "text_expanded":  true,             // 是否点击了"展开全文"
  "comment_viewed": true,             // 是否查看了评论区
  "author_clicked": false,            // 是否点击了作者主页
  "source":         "recommend",      // 来源:recommend | follow | search | hot | profile
  "position":       7,                // 在推荐列表中的位置(第几条)
  "timestamp":      "2026-02-05T14:32:18.000Z",
  "device":         "iOS",            // iOS | Android
  "session_id":     "sess_a8f3c2e1"   // 同一次打开 App 共用一个 session
}

数据量估算

~50
人均每日行为事件数
浏览 40 + 互动 10
~500K
日总行为事件量
10K 用户 × 50 事件
~200B
单条事件大小
JSON 压缩后约 200 字节
~3GB
月存储量
ClickHouse 压缩后更小

客户端埋点注意事项

📦

批量上报

客户端不要每个事件都立即发请求,攒 5-10 条或每隔 5 秒批量上报一次。减少网络开销,降低服务端 QPS。App 退到后台时立即上报剩余事件,防止数据丢失。

⏱️

停留时长计算

帖子进入可视区域开始计时,离开可视区域或用户切后台时停止。需要处理锁屏、来电话、切 App 等中断场景,避免统计出离谱的停留时长(比如 2 小时)。超过 5 分钟的一律截断。

🔄

Session 管理

用户每次打开 App 生成一个 session_id,所有事件都带上。用于分析单次会话的行为路径,比如用户这次刷了多少帖子、在哪里流失、哪类内容让 TA 停下来互动。

优先级建议:第一版只采集显式行为(点赞、评论、收藏、分享)+ 停留时长就够了。这几个数据拿到后就能构建出基本可用的用户画像。图片滑动进度、正文展开这些精细的隐式行为可以放到第二版再加,需要客户端配合做更多埋点工作。
03 — USER PROFILE

用户画像构建

用户画像是推荐系统的核心数据。通过统计用户近期行为,建立兴趣标签的权重向量。

标签体系是基础。用户画像和内容画像共用同一套标签,一般分 2 层:一级分类(10-20 个)如美食、旅行、摄影,二级分类(每个一级下 3-10 个)如烘焙、探店、家常菜。从现有数据中提炼,不要凭空想象。

画像计算:从行为数据到兴趣向量

拿到一堆行为数据后,通过以下 4 步算出用户的兴趣画像。核心思路是:找到用户互动过的帖子 → 拿到帖子的标签 → 按行为类型和时间加权 → 汇总归一化

S1

拉取用户近期行为

从 ClickHouse 查询该用户最近 7 天(可配置)的所有行为记录。时间窗口不宜太长,否则画像会被过时的兴趣拖累。

step1_query.sql
SELECT post_id, event_type, duration_ms, image_progress, timestamp
FROM   behavior_events
WHERE  user_id = '10086'
  AND  timestamp >= now() - INTERVAL 7 DAY
  AND  event_type IN ('like', 'collect', 'comment', 'share', 'view', 'follow', 'dislike')
ORDER BY timestamp DESC
S2

关联帖子标签 + 计算行为权重

每条行为记录通过 post_id 关联到帖子的标签。然后根据行为类型赋予不同的基础权重——用户主动操作的权重远高于被动浏览。

行为类型 → 基础权重映射

🔗 分享 W=6
👍 点赞 W=5
💬 评论 W=5
⭐ 收藏 W=4
👁️ 完读 W=3
👁️ 浏览 W=1
🚫 不喜欢 W=-5
浏览 vs 完读的区别:浏览(view)指帖子出现在屏幕上,但用户可能只是划过。完读指图片滑到最后一张(image_progress ≥ 0.9)或停留时长超过阈值。完读的权重是普通浏览的 3 倍,因为它代表了真实的内容消费。
S3

时间衰减:越近的行为越重要

用户的兴趣会随时间变化。3 天前点赞的"美食"帖子和 1 小时前点赞的"美食"帖子,对当前画像的贡献应该不同。通过时间衰减函数来实现:

time_decay = 1.0 / (1 + hours_ago × 0.02)

衰减系数 0.02 时的衰减效果

1 小时前
0.98
12 小时前
0.81
1 天前
0.68
3 天前
0.41
7 天前
0.23

衰减系数可调:0.02 适合兴趣变化较慢的社区,0.05 适合热点驱动、兴趣变化快的产品。

// 每条行为对某个标签的贡献 =
score = behavior_weight × time_decay
// 例:3 天前的一个点赞 → 5 × 0.41 = 2.05
// 例:1 小时前的一个浏览 → 1 × 0.98 = 0.98
S4

汇总 & 归一化

把每条行为算出来的 score 按标签累加,然后归一化到 0~1 之间(所有标签权重之和 = 1),就得到了最终的兴趣向量。

完整数值示例

以用户 10086 为例,假设最近 7 天有以下 8 条行为记录,看看怎么一步步算出 TA 的画像。

📋 原始行为数据(最近 7 天)

# 时间 行为 帖子标签 行为权重 W hours_ago 时间衰减 D score = W × D
1 2 小时前 👍 点赞 美食烘焙 5 2 0.96 4.81
2 5 小时前 ⭐ 收藏 美食探店 4 5 0.91 3.64
3 8 小时前 👁️ 完读 旅行攻略 3 8 0.86 2.59
4 1 天前 👍 点赞 摄影风光 5 24 0.68 3.38
5 1 天前 👁️ 浏览 美食家常菜 1 26 0.66 0.66
6 3 天前 🔗 分享 美食烘焙 6 72 0.41 2.47
7 5 天前 👍 点赞 旅行露营 5 120 0.29 1.47
8 6 天前 👁️ 浏览 宠物 1 144 0.26 0.26

📊 按一级标签汇总 score

每条行为的 score 分配给帖子的所有标签(一篇帖子有多个标签时,每个标签都累加)。下面按一级标签汇总:

🍜 美食 11.58
#1 点赞 4.81 + #2 收藏 3.64
+ #5 浏览 0.66 + #6 分享 2.47
✈️ 旅行 4.06
#3 完读 2.59 + #7 点赞 1.47
📸 摄影 3.38
#4 点赞 3.38
🐱 宠物 0.26
#8 浏览 0.26
总分 = 11.58 + 4.06 + 3.38 + 0.26 = 19.28

✅ 归一化 → 最终画像

每个标签的 score 除以总分,得到 0~1 的权重比例:

美食
0.60
11.58 / 19.28
旅行
0.21
4.06 / 19.28
摄影
0.18
3.38 / 19.28
宠物
0.01
0.26 / 19.28
写入 Redis:HSET user_profile:10086 美食 0.60 旅行 0.21 摄影 0.18 宠物 0.01
为什么美食这么高?不只是因为行为次数多(4 次 vs 旅行 2 次),更因为美食相关的行为类型更强(点赞+收藏+分享),且时间更近(2/5 小时前 vs 5 天前)。这就是"行为权重 × 时间衰减"组合的效果——它同时反映了兴趣强度和兴趣时效性。

完整伪代码

user_profile_builder.py
// 定时任务:每小时执行一次
for user in getAllActiveUsers():

    behaviors = queryClickHouse("最近 7 天的行为", user.id)
    profile = {}

    for behavior in behaviors:

        post_tags = getPostTags(behavior.post_id)

        // 不同行为给不同权重
        weight = {
            点赞: 5,
            收藏: 4,
            看完: 3,
            浏览: 1
        }[behavior.type]

        // 越近的行为权重越大
        decay = 1.0 / (1 + hours_ago * 0.02)

        for tag in post_tags:
            profile[tag] += weight * decay

    normalize(profile)  // 归一化到 0~1
    writeToRedis("user_profile:{user.id}", profile)
输出示例:user_profile:10086 → {美食: 0.35, 旅行: 0.25, 摄影: 0.20, 宠物: 0.12, 运动: 0.08}
存储在 Redis Hash 中,在线服务直接读取,微秒级响应。MySQL 存一份备份防止 Redis 故障丢失。

业界画像维度参考

不同平台的标签体系规模差异很大,但核心逻辑一致。以下是业界典型的画像维度设置,供你参考定义自己的标签层级。

平台类型 一级标签数 二级标签数 总标签量级 特点
小红书(图文社区) ~20 每级 5-15 200-500 生活方式导向,标签精细且重叠度高
抖音(短视频) ~30 每级 10-30 1000+ 内容形态多,标签体系庞大,多层级
今日头条(资讯) ~25 每级 10-20 500-1000 新闻分类明确,时效性标签权重高
你的 App(初期建议) 10-15 每级 3-8 50-120 先粗后细,从数据中验证再扩展

画像 Demo:三类典型用户

以下是三个不同兴趣偏好的用户画像示例,展示实际数据结构和权重分布。

🍳

用户 A · 美食达人

兴趣集中型 · 日活跃 40min
美食
0.42
旅行
0.18
生活
0.14
摄影
0.10
其他
0.16
二级标签:烘焙 0.18 · 探店 0.12 · 家常菜 0.08 · 零食 0.04
🎨

用户 B · 兴趣广泛

兴趣分散型 · 日活跃 25min
摄影
0.23
旅行
0.20
数码
0.17
影视
0.14
其他
0.26
特点:权重分散,无明显主导兴趣,推荐打散需求更高
🆕

用户 C · 新注册用户

冷启动阶段 · 注册第 1 天
游戏
0.50
数码
0.30
未知
0.20
仅浏览了 5 条帖子,画像极不稳定。需要大量探索流量来发现真实兴趣。虚线 = 待探索维度。

标签体系 Demo

以下是适合图文社区 App 的标签体系示例。一级分类控制在 12 个,每个下设 3-8 个二级标签,总量约 70 个。

🍜 美食
烘焙 家常菜 探店 食谱 零食测评 饮品
✈️ 旅行
国内游 出境游 攻略 酒店民宿 露营
📸 摄影
人像 风光 街拍 后期修图 器材
🐱 宠物
异宠 宠物用品 宠物医疗
👗 时尚
穿搭 美妆 护肤 潮牌 发型
💻 数码
手机 电脑 智能家居 数码测评
🏃 运动
健身 跑步 球类 瑜伽
🎮 游戏
手游 PC/主机 电竞 攻略
画像维度数量建议:用户画像中实际参与推荐召回的标签,取权重最高的 3-5 个即可。长尾标签(权重 < 0.05)对推荐质量贡献极小,可以不传给 ES。标签体系本身可以大,但每个用户的"有效画像"维度控制在 5 个以内最高效。
04 — MULTI-CHANNEL RECALL

多路召回

召回是推荐的第一步——从百万级内容池中快速筛选出几百条候选帖子。多路并发执行,用 Go 的 goroutine 天然适合。每路独立运行、互不依赖,某一路超时或失败不影响其他路。

五路并发召回
🎯
兴趣匹配
60% · ES Boost
🔥
热门召回
15% · Redis ZSet
👥
关注流
15% · MySQL/Redis
🎲
随机探索
5% · 反兴趣采样
🆕
新帖探索
5% · 冷启动
为什么要"多路"?单一召回通道会导致结果同质化。兴趣匹配只能推用户已知的偏好(信息茧房),热门召回只能推大众内容(缺个性化),关注流只覆盖社交关系。多路组合才能兼顾 个性化 + 多样性 + 新鲜度 + 社交

并发调度:Go 骨架代码

五路召回通过 goroutine 并发执行,统一设置 50ms 超时。任何一路超时或出错都不阻塞整体,只是少一路候选而已。

recall/multi_recall.go
func MultiRecall(ctx context.Context, user *Profile) ([]Post, error) {
    ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
    defer cancel()

    type result struct {
        posts []Post
        ch    string
    }
    ch := make(chan result, 5)

    channels := []struct {
        name string
        fn   func(context.Context, *Profile) ([]Post, error)
    }{
        {"interest", recallByInterest},  // 60% — ES Boost
        {"hot",      recallByHot},       // 15% — Redis ZSet
        {"follow",   recallByFollow},    // 15% — MySQL/Redis
        {"explore",  recallByExplore},   // 5%  — 反兴趣探索
        {"newpost",  recallByNewPost},   // 5%  — 冷启动
    }

    for _, c := range channels {
        go func(name string, fn func(context.Context, *Profile) ([]Post, error)) {
            posts, err := fn(ctx, user)
            if err != nil {
                log.Warn("recall channel %s failed: %v", name, err)
                posts = nil  // 失败不影响其他路
            }
            ch <- result{posts, name}
        }(c.name, c.fn)
    }

    var all []Post
    for i := 0; i < len(channels); i++ {
        r := <-ch
        all = append(all, r.posts...)
    }
    return mergeAndDedup(all), nil
}
R1

🎯 兴趣匹配召回 — 主力通道 60%

从用户画像中取标签权重,映射为 ES Bool 查询的 Boost 值。一次查询覆盖所有兴趣维度,命中多标签的帖子分数叠加自动排前面。

数据流向
Redis 用户画像
构造 Boost 查询
ES 倒排索引
返回 Top 500
es_boost_query.json
{
  "query": {
    "bool": {
      "should": [
        { "term": { "tags": { "value": "美食", "boost": 3.5 }}},
        { "term": { "tags": { "value": "旅行", "boost": 2.5 }}},
        { "term": { "tags": { "value": "摄影", "boost": 2.0 }}},
        { "term": { "tags": { "value": "宠物", "boost": 1.2 }}}
      ],
      "minimum_should_match": 1,
      "filter": [
        { "range": { "publish_time": { "gte": "now-48h" }}},
        { "range": { "quality_score": { "gte": 0.5 }}},
        // 粗筛已读:传最近 1h 内看过的 ID(量小,几十条)
        { "bool": { "must_not": [
          { "terms": { "post_id": ["recent_seen_id_1", "..."] }}
        ]}}
      ]
    }
  },
  "size": 500  // 超量召回,预留已读过滤损耗
}
Boost 是什么?ES 的加权打分机制。默认命中一个标签得 1 分,设置 boost: 3.5 后命中就得 3.5 分。多标签命中时分数叠加。帖子 A 同时命中美食(3.5)+旅行(2.5) = 6.0 分,比只命中美食的帖子 B(3.5 分) 排名更靠前。
为什么 size 是 500 而不是 200?因为后续还有 Bloom Filter 做全量已读过滤,会扔掉一部分。超量召回 2~3 倍,过滤后才够用。这里的 filter 中只传最近 1 小时内看过的少量 ID(几十条),做一层粗筛减少浪费,全量精筛交给后面的 Bloom Filter。
R2

🔥 热门召回 — 全站热点 15%

抓住全站的热门内容,保证用户不会错过爆款。离线定时任务计算热度分,写入 Redis ZSet,在线时一行命令取 Top N。

离线:热度分计算(定时任务 · 每 10 分钟)

从 ClickHouse 查最近 24h 的互动数据,按加权公式打分,写入 Redis 有序集合。

hot_score = (点赞数 × 1 + 评论数 × 3 + 收藏数 × 5) / time_decay
cron/hot_score.sql
-- ClickHouse: 聚合最近 24h 各帖子的互动数
SELECT post_id,
       countIf(event_type = 'like')    AS likes,
       countIf(event_type = 'comment') AS comments,
       countIf(event_type = 'collect') AS collects,
       min(publish_time)               AS pub_time
FROM   behavior_events
WHERE  timestamp >= now() - INTERVAL 24 HOUR
GROUP BY post_id

在线:一行 Redis 命令

recall/hot.go
func recallByHot(ctx context.Context, user *Profile) ([]Post, error) {
    // 全站热门 Top 50
    ids, _ := redis.ZRevRange(ctx, "hot:global", 0, 49)

    // 也可按类目热门取,保证多样性
    // cats := randomPick(allCategories, 3)
    // for _, c := range cats {
    //     catIds, _ := redis.ZRevRange(ctx, "hot:cat:"+c, 0, 9)
    //     ids = append(ids, catIds...)
    // }

    return batchGetPosts(ctx, ids)
}
性能极佳。Redis ZREVRANGE 是 O(logN+M) 操作,微秒级返回。这路是五路中最快的,通常在 1ms 内完成。
R3

👥 关注流召回 — 社交关系 15%

把用户关注的人的最新发帖拉出来。这一路的内容用户本身有关注意愿,点击率通常较高。

推模式 vs 拉模式

写扩散(推模式) 读扩散(拉模式) ✅
原理 发帖时写入每个粉丝的收件箱 请求时实时拉取关注列表的帖子
读速度 极快(直接读收件箱) 较快(合并 N 个作者)
写成本 大 V 发帖时写放大严重 无额外写操作
适用 粉丝数较少的产品 通用,中小规模首选
recall/follow.go(拉模式)
func recallByFollow(ctx context.Context, user *Profile) ([]Post, error) {
    // 1. 从 Redis/MySQL 拿关注列表
    followIDs, _ := redis.SMembers(ctx, "following:"+user.ID)

    // 2. 每个作者的最近发帖(Redis List 缓存)
    var all []string
    for _, authorID := range followIDs {
        ids, _ := redis.LRange(ctx, "author_posts:"+authorID, 0, 9)
        all = append(all, ids...)
    }

    // 3. 按发布时间排序,取 Top 50
    posts := batchGetPosts(ctx, all)
    sort.Slice(posts, func(i, j int) bool {
        return posts[i].PublishTime.After(posts[j].PublishTime)
    })
    if len(posts) > 50 { posts = posts[:50] }
    return posts, nil
}
优化:作者发帖列表缓存。每个作者发帖时往 Redis List author_posts:{author_id} 头部 LPUSH 新帖 ID,保留最近 20 条。这样在线拉取就不用查 MySQL,直接从 Redis 取。
R4

🎲 随机探索 — 打破信息茧房 5%

不是纯随机,而是反兴趣探索——在用户画像中不存在的标签类目里挑优质内容。这样探索的方向才有价值,而不是漫无目的。

离线:构建探索候选池(定时任务 · 每小时)

从 ES 按条件筛选,按一级类目分桶,每桶取若干条,写入 Redis Set。

ES 按类目分桶查询
quality_score ≥ 0.6
每桶取 100 条
Redis Set
recall/explore.go
func recallByExplore(ctx context.Context, user *Profile) ([]Post, error) {
    // 1. 找到用户画像中【不存在】的类目(反兴趣)
    userTags := getUserTagSet(user)         // {美食, 旅行, 摄影}
    allTags  := getAllCategories()           // {美食, 旅行, 摄影, 健身, 数码, 穿搭, ...}
    blindTags := difference(allTags, userTags) // {健身, 数码, 穿搭, ...}

    // 2. 随机选 3 个盲区类目
    picked := randomPick(blindTags, 3)

    // 3. 从每个类目的探索池随机取 5 条
    var ids []string
    for _, tag := range picked {
        catIds, _ := redis.SRandMemberN(ctx, "explore_pool:"+tag, 5)
        ids = append(ids, catIds...)
    }

    return batchGetPosts(ctx, ids)  // ~15 条
}
纯随机 vs 反兴趣探索。纯随机从全站池子里抽,大概率抽到用户不感兴趣的内容,点击率极低。反兴趣探索在盲区里挑高质量内容,命中率高得多。如果用户点了"健身"相关的帖子,下次这个标签就会进入画像,被兴趣匹配通道正式接管。
R5

🆕 新帖探索 — 冷启动 5%

新帖没有互动数据,不会被兴趣匹配和热门召回捞到——这就是"冷启动"问题。这一路专门给曝光不足的新帖一个初始曝光机会。

离线:构建新帖候选池(定时任务 · 每 30 分钟)

cron/new_post_pool.sql
-- 找出曝光不足的新帖
SELECT post_id
FROM   posts
WHERE  publish_time >= now() - INTERVAL 24 HOUR  -- 最近一天发布
  AND  impression_count < 100            -- 曝光不足
  AND  quality_score >= 0.5              -- 质量兜底,不推垃圾

-- 结果写入 Redis: SADD new_post_pool {post_id}
-- 帖子曝光超过阈值后从池中移除: SREM new_post_pool {post_id}
recall/newpost.go
func recallByNewPost(ctx context.Context, user *Profile) ([]Post, error) {
    // 从新帖候选池随机取 15 条
    ids, _ := redis.SRandMemberN(ctx, "new_post_pool", 15)
    return batchGetPosts(ctx, ids)
}
新帖生命周期
发布 · 进入冷启动池
获得初始曝光 · 产生互动
曝光 > 100 · 移出冷启动池
被正常召回通道接管
冷启动的本质。没有曝光就没有互动数据,没有互动数据就永远不会被兴趣匹配和热门通道捞到——死循环。新帖探索通道就是打破这个循环的"第一把火",给新内容一个被用户看到的机会。

已读过滤:分层策略

用户看过的帖子不能再推。但在 ES 查询中传全量已读 ID 代价太大,所以采用两层过滤的策略:

两层过滤
五路召回结果
第一层:ES must_not 粗筛
第二层:Bloom Filter 精筛
干净的候选集
L1

第一层:ES 粗筛

在 ES 查询的 filter 中传最近 1 小时内看过的 ID(量小,几十条),用 must_not.terms 排除。这层成本低,能过滤掉最热门的重复。

L2

第二层:Bloom Filter 精筛

召回合并后,逐条过 Redis 中的 Bloom Filter(每用户一个)。全量已读记录,微秒级判断,内存极小。详见 Section 08 已读去重

为什么不在 ES 里直接过滤全量已读?一个中度活跃用户 30 天可能看了上万条。把这么大的 ID 列表塞进 ES 查询会导致查询体积膨胀、网络传输变慢、ES 匹配耗时急剧上升。terms 查询的 ID 数量也有上限(默认 65536)。Bloom Filter 是概率数据结构,无法嵌入 ES 查询。所以只能分层:ES 做轻量粗筛,应用层做全量精筛。

降级策略

当精准召回 + 已读过滤后结果不够时,逐级放宽条件,保证用户永远有内容可刷:

精准匹配+高质量
放宽时间窗口(7d)
全站热门
随机兜底
重推优质老内容
多路召回的容错优势。即使兴趣匹配这路挂了(ES 超时),热门 + 关注流 + 探索仍能撑起一整页推荐。用户体验只是"推荐不够精准"而不是"没有内容"。
05 — QUALITY SCORE

内容质量分

质量分帮助系统判断一篇帖子值不值得推荐。分为发布时的初始分和上线后根据互动数据更新的反馈分,两者按曝光量动态加权合并。

质量分贯穿整个推荐链路。召回阶段用它做底线过滤(quality_score ≥ 0.5),排序阶段用它作为打分因子,热度计算也依赖它。一个帖子的质量分越高,被推出去的机会越多。

初始分:发布时立即计算

帖子发布后,异步消费 Kafka 消息时同步计算初始分。不依赖任何互动数据,纯粹基于内容本身的特征。

初始分 = 文本长度分 × 0.3 + 图片分 × 0.3 + 文本质量分 × 0.2 + 作者信用分 × 0.2
D1

文本长度分 — 权重 30%

太短的帖子(标题党)和太长的帖子(可能是复制粘贴)都扣分。50~500 字是图文帖子的"甜蜜区间"。

< 10 字 0.1
10~50 字 0.5
50~500 字 1.0
500~2000 字 0.7
> 2000 字 0.4
D2

图片分 — 权重 30%

图文帖子的图片是核心内容载体。数量和质量都影响评分。

图片数量映射

0 张 0.2
1~2 张 0.6
3~9 张 1.0
> 9 张 0.7

额外加分项:首图宽度 ≥ 1080px 加 0.1 · 有封面图加 0.05。分数 clamp 到 [0, 1]。

D3

文本质量分 — 权重 20%

检测文本是否有"实质内容",过滤掉垃圾帖、广告帖、纯表情帖等低质量内容。

基础分 1.0,命中以下规则则扣分:

🚫 含广告关键词 -0.5
🔁 大量重复字符 -0.3
😀 纯表情/特殊符号 -0.4
📋 疑似复制粘贴 -0.3
✅ 含话题标签 # +0.1
✅ 分段有结构 +0.1
前期用规则,后期上模型。初期用关键词匹配 + 正则规则检测,成本低、可解释。数据量上来后可以训练一个文本分类模型(如 FastText),甚至用大模型打质量标签,效果会好很多。
D4

作者信用分 — 权重 20%

基于作者的历史表现给加成或惩罚。优质创作者的新帖天然获得更高初始分。

作者信用分 = 历史平均反馈分 × 0.5 + 发帖频率分 × 0.2 + 违规扣分 × 0.3
历史帖子平均互动率高 加分
持续稳定发帖 加分
被举报/帖子被删除 扣分
短时间内大量灌水 扣分

反馈分:上线后根据真实互动更新

帖子获得曝光后,用户的真实互动行为是最可靠的质量信号。定时任务每 30 分钟从 ClickHouse 统计最新互动数据并重新计算反馈分。

反馈分 = 互动率 × 0.4 + 完读率 × 0.3 - 负向信号率 × 0.3
👍

互动率

正向互动次数 / 总曝光量

-- 正向互动 = 点赞 + 评论 + 收藏 + 分享
互动率 = (likes + comments + collects + shares) / impressions

典型值:优质帖 5~15% · 普通帖 1~5% · 低质帖 < 1%

📖

完读率

图片浏览到最后一张的比例

-- image_progress ≥ 0.9 或停留 > 阈值
完读率 = finish_view_count / total_view_count

完读率高说明内容有吸引力 · 封面党的完读率通常很低

⚠️

负向信号率

用户用行为"投票"了不喜欢

-- 负向 = 举报 + 不感兴趣 + 快速划走(<1s)
负向率 = (reports + dislikes + quick_swipes) / impressions

负向率 > 10% 是强烈的降权信号

cron/feedback_score.sql
-- 每 30 分钟跑一次,统计所有帖子最新互动数据
SELECT
    post_id,
    countIf(event_type IN ('like','comment','collect','share')) AS positive,
    countIf(event_type = 'view')                                AS impressions,
    countIf(event_type = 'view' AND image_progress >= 0.9)      AS finish_views,
    countIf(event_type IN ('report','dislike'))                  AS negatives,
    countIf(event_type = 'view' AND duration_ms < 1000)         AS quick_swipes
FROM behavior_events
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY post_id
HAVING impressions >= 10  -- 曝光太少的不更新,避免统计波动
反馈分更新流程
定时任务触发
ClickHouse 聚合查询
计算反馈分
写回 MySQL
同步到 ES 索引

两阶段动态合并

初始分和反馈分的权重随曝光量动态变化。新帖以初始分为主(因为还没有足够的互动数据),随着曝光增加逐步过渡到以反馈分为主。

曝光量 < 100: 质量分 = 初始分 × 0.8 + 反馈分 × 0.2 ← 初始分为主,反馈样本不够
曝光量 100~1000: 质量分 = 初始分 × 0.3 + 反馈分 × 0.7 ← 反馈数据开始可靠
曝光量 > 1000: 质量分 = 初始分 × 0.1 + 反馈分 × 0.9 ← 几乎完全由用户"投票"决定
为什么不直接用反馈分?因为新帖曝光太少时,统计数据有很大随机性。一篇新帖被 5 个人看了,1 个人点赞,互动率 20%——这不代表它比互动率 8% 的万曝帖子好。曝光量越大,反馈分才越可信。初始分在早期起到"兜底"作用。

低质量内容处理

质量分不仅决定排名高低,低于特定阈值还会触发降权甚至屏蔽。

质量分区间 处理策略 场景
0.7 ~ 1.0 正常推荐,排序加权 优质内容,进入所有召回通道
0.5 ~ 0.7 正常召回,排序中性 普通内容,不加权也不降权
0.3 ~ 0.5 召回过滤掉,仅搜索/个人主页可见 低质内容,不主动推荐
< 0.3 标记审核,可能自动隐藏 疑似垃圾/违规内容
新帖冷启动:每篇新帖子不管初始分多少,都保证至少被 100 个人看到(通过召回阶段的"新帖探索"通道),然后再根据互动反馈决定是继续推还是降权。这避免了"初始分低 → 没曝光 → 没互动 → 分更低"的死循环。
质量分的消费方。质量分写入 MySQL 后同步到 ES 索引的 quality_score 字段。召回阶段用 filter: quality_score ≥ 0.5 做底线过滤,排序阶段用它作为加权因子(详见 Section 06 排序打分),热门召回的热度公式也用它做衰减(低质量帖即使互动多也上不了热门)。
06 — RANKING

排序打分

召回拿到几百条候选帖子后,需要通过综合打分决定先后顺序。排在前面的先推给用户,排序质量直接决定推荐效果。

召回决定上限,排序决定下限。召回负责"找到候选",排序负责"挑出最好的"。即使召回结果很优,排错了序也会导致用户看到的第一屏全是低质量内容。业界有"召回定天花板,排序定地板"的说法。

业界排序架构:多级漏斗

大厂的排序不是一步到位,而是分多级逐步精细化。每一级候选数量减少,模型复杂度增加。

排序漏斗
召回 ~500 条
粗排 → ~200 条
精排 → ~50 条
重排 → 最终序

粗排(Pre-Ranking)

快速过一遍,淘汰明显不好的。用轻量级模型或规则打分,耗时要求 < 10ms。我们当前方案就在这一级。

适用:中小规模产品 · 规则公式 · 特征简单

🧠

精排(Fine-Ranking)

用复杂模型(DNN/DeepFM)对粗排 Top 200 精细打分。特征更多、模型更复杂、预测更准,但推理耗时也更长(10~30ms)。

适用:中大规模 · 深度模型 · 需要 GPU 推理服务

🔀

重排(Re-Ranking)

在精排结果上做业务调整:打散、去重、运营置顶、广告插入。不改变打分,只调整顺序和插入特殊内容。

适用:所有规模 · 规则驱动 · Section 07 打散就在这一步

我们当前在哪一级?当前规模(万级 DAU)不需要精排,用一个规则公式做粗排就够了。等效果瓶颈出现(点击率上不去、A/B 实验没空间)再引入精排模型。下面详细讲当前的规则排序方案。

当前方案:多因素加权排序

把每篇帖子的多个维度分数归一化后加权求和,得到最终得分。简单、可解释、好调优。

最终得分 =
    兴趣匹配度 × 0.35 ← 最关键的因子
  + 质量分 × 0.25 ← Section 05 的 quality_score
  + 时效分 × 0.20 ← 新鲜度,越新越高
  + 热度分 × 0.15 ← 近期互动热度
  + 来源加成 × 0.05 ← 关注流加分

五个排序因子详解

F1

兴趣匹配度 — 权重 35%(最高)

衡量帖子内容与用户兴趣画像的匹配程度。来自 ES Boost 查询的 _score 字段。

计算逻辑
// ES 返回的 _score 是 Boost 叠加分
// 例:帖子命中 美食(3.5) + 旅行(2.5) → _score = 6.0
// 归一化到 0~1:
兴趣匹配度 = min(es_score / max_possible_score, 1.0)

// max_possible_score = 所有标签 boost 之和
// 如果用户有 4 个标签 {3.5, 2.5, 2.0, 1.2},
// 则 max = 9.2,得 6.0 / 9.2 ≈ 0.65
非兴趣通道的帖子怎么办?热门召回、关注流、随机探索拿到的帖子没有 ES _score。这些帖子的兴趣匹配度需要单独计算——用帖子标签和用户画像标签做交集,命中的标签权重求和再归一化。如果完全不命中(随机探索通道),兴趣匹配度为 0,但其他因子可以撑住。
F2

质量分 — 权重 25%

直接取 Section 05 计算好的 quality_score,已经是 0~1 区间,无需再归一化。质量分保证了"匹配用户兴趣但内容很烂"的帖子不会排前面。

F3

时效分 — 权重 20%

新帖优先。用户更希望看到新鲜内容,而不是三天前的旧帖。用时间衰减函数把发布时间映射为 0~1 的分数。

时效分 = 1.0 / (1 + hours_ago × 0.05)

衰减效果

刚发布 1.00
4 小时前 0.83
12 小时前 0.63
24 小时前 0.45
48 小时前 0.29
衰减系数 0.05 的含义。系数越大衰减越快,越偏重新内容。0.05 是一个温和的值——24 小时前的帖子还有 0.45 分,不至于完全被压下去。如果你的产品是新闻类(时效性极强),可以把系数调到 0.1~0.2。
F4

热度分 — 权重 15%

来自 Section 04 热门召回中计算好的 hot_score,归一化后直接使用。体现"大家都在看"的从众效应,适度的热门加成可以提升用户的"发现感"。

// 从 Redis 取预计算好的热度分,归一化
raw_hot := redis.ZScore(ctx, "hot:global", postID)
热度分 = min(raw_hot / hot_score_p99, 1.0)
// 用 P99 分位数做归一化上界,避免极端值
F5

来源加成 — 权重 5%

帖子来自哪个召回通道,给予不同加成。关注流的帖子有社交关系加持,用户本身有主动关注意愿,点击率天然偏高。

👥 关注流 1.0
🎯 兴趣匹配 0.7
🔥 热门 0.5
🎲 随机探索 0.3
🆕 新帖 0.3

关键步骤:归一化

五个因子的原始值量纲完全不同——ES _score 可能是 0~10,质量分是 0~1,热度分可能是 0~10000。如果不归一化,绝对值大的因子会淹没其他因子。

Min-Max 归一化

norm = (x - min) / (max - min)

用当前这批候选的最大最小值做归一化。简单直观,适合当前规模。

分位数归一化(进阶)

norm = x / P99

用全局 P99 分位数做归一化。更稳定,不受单次召回极端值影响。需要离线统计 P99。

Go 实现代码

ranking/score.go
type RankWeights struct {
    Interest  float64  // 0.35
    Quality   float64  // 0.25
    Freshness float64  // 0.20
    Hot       float64  // 0.15
    Source    float64  // 0.05
}

func RankPosts(posts []Post, user *Profile, w RankWeights) []Post {
    // 1. 批量获取各项数据
    hotScores := redis.ZMScore(ctx, "hot:global", postIDs...)

    // 2. 计算各因子的 min/max(用于归一化)
    var minES, maxES float64 = math.MaxFloat64, 0
    for _, p := range posts {
        minES = min(minES, p.ESScore)
        maxES = max(maxES, p.ESScore)
    }

    // 3. 对每篇帖子打分
    for i := range posts {
        p := &posts[i]

        interest  := normalize(p.ESScore, minES, maxES)
        quality   := p.QualityScore  // 已经是 0~1
        freshness := 1.0 / (1 + p.HoursAgo() * 0.05)
        hot       := min(hotScores[i] / hotP99, 1.0)
        source    := sourceBonus(p.RecallChannel)

        p.RankScore = interest  * w.Interest +
                      quality   * w.Quality +
                      freshness * w.Freshness +
                      hot       * w.Hot +
                      source    * w.Source
    }

    // 4. 按分数降序排列
    sort.Slice(posts, func(i, j int) bool {
        return posts[i].RankScore > posts[j].RankScore
    })
    return posts
}

权重调优方法

初始权重靠经验设定,上线后根据线上指标反馈调整。核心指标是点击率(CTR)人均阅读时长

线上现象 诊断 调优方向
点击率低 推的内容不匹配用户兴趣 提高兴趣匹配度权重
点进去就退出 封面党多,内容质量不行 提高质量分权重
内容都是旧帖 时效分太低或衰减太慢 提高时效分权重 / 加大衰减系数
内容太相似 兴趣权重太高导致同质化 降低兴趣权重,提高热门/来源权重
热门帖霸屏 热度权重太高 降低热度分权重
A/B 实验是必须的。任何权重调整都应通过 A/B 实验验证,而不是拍脑袋。将流量分成两组,一组用新权重一组用旧权重,跑 3~7 天后对比 CTR、阅读时长、负反馈率等指标。

演进路线:从规则到模型

V1

V1 · 规则公式排序(当前)

手动设置权重,加权求和。简单、可解释、可快速迭代。适合 DAU < 10 万的产品。

Go 内存计算
V2

V2 · LR/GBDT 粗排

用逻辑回归或 GBDT 模型学习权重。输入特征不变,但权重由模型从数据中学习,比人调更精准。需要搭建训练流水线。

Python 训练 Go 推理
V3

V3 · 深度模型精排

引入 DNN/DeepFM/DIN 等深度模型。可以捕捉特征之间的交叉关系,效果显著提升。需要 GPU 推理服务和特征工程平台。

TensorFlow/PyTorch GPU 推理
07 — DIVERSIFICATION

结果打散

排序后同类内容可能扎堆出现。打散保证推荐列表的多样性,避免用户审美疲劳。打散是排序之后、入队之前的最后一步调整。

为什么排序后会扎堆?因为排序公式中"兴趣匹配度"权重最高(35%),而用户的第一兴趣标签权重又远大于其他标签。结果就是 Top 10 里可能 7 条都是同一个类目(比如美食)。用户连刷 7 条美食帖,即使每条都很好,体验也很差——这就是"信息茧房"的微观表现。

打散效果对比

打散前(同类扎堆)

美食
美食
美食
美食
美食
旅行
旅行
摄影
摄影
宠物
热门
热门

用户刷了 5 条美食后已经腻了,可能直接关掉 App

打散后(交错分布)

美食
旅行
美食
摄影
宠物
美食
热门
旅行
美食
摄影
美食
热门

每隔 1~2 条换个类目,保持新鲜感,用户刷得更久

打散维度

打散不只看类目,实际要考虑多个维度。多维度之间有冲突时,按优先级依次处理。

P1

类目打散 — 最高优先级

同一个一级类目(美食/旅行/摄影)的帖子不能连续超过 N=2 条。这是最核心的打散规则。

P2

作者打散 — 次高优先级

同一个作者的帖子不能连续出现。即使某个大 V 发了 10 篇优质帖,也要穿插其他作者的内容。连续推同一个人会给用户"这是广告"的感觉。

P3

来源打散 — 可选

不同召回通道的帖子要穿插。如果前 10 条全是"兴趣匹配"的结果,关注流和探索的帖子就被挤没了。保证每 5 条内至少有 1 条来自非兴趣通道。

P4

视觉打散 — 进阶

两篇帖子可能属于不同类目,但封面图很像(比如都是蓝天白云)。视觉打散需要计算图片特征相似度,成本较高,后期再做。

算法实现:滑动窗口打散

核心思路:遍历排序后的列表,用一个滑动窗口检查最近 N 条的类目/作者是否重复。如果重复就跳过放入"待插队列",最后把跳过的帖子插回空位。

scatter/scatter.go
const (
    maxSameCategory = 2   // 同类目最多连续 2 条
    maxSameAuthor   = 1   // 同作者不连续(最多 1 条)
)

func Scatter(ranked []Post) []Post {
    result  := make([]Post, 0, len(ranked))
    skipped := make([]Post, 0)

    for _, post := range ranked {
        if shouldSkip(result, post) {
            skipped = append(skipped, post)
        } else {
            result = append(result, post)
        }
    }

    // 把跳过的帖子插回结果中的"安全位置"
    result = insertSkipped(result, skipped)
    return result
}

func shouldSkip(result []Post, post Post) bool {
    n := len(result)

    // 规则 1: 类目不连续超过 2 条
    if n >= maxSameCategory {
        allSame := true
        for i := n - maxSameCategory; i < n; i++ {
            if result[i].Category != post.Category {
                allSame = false
                break
            }
        }
        if allSame && result[n-1].Category == post.Category {
            return true
        }
    }

    // 规则 2: 同作者不连续
    if n >= maxSameAuthor && result[n-1].AuthorID == post.AuthorID {
        return true
    }

    return false
}

func insertSkipped(result, skipped []Post) []Post {
    // 对跳过的帖子,找到第一个不违反规则的位置插入
    for _, post := range skipped {
        inserted := false
        for i := 0; i < len(result); i++ {
            if canInsertAt(result, i, post) {
                result = insertAt(result, i, post)
                inserted = true
                break
            }
        }
        if !inserted {
            result = append(result, post) // 放末尾兜底
        }
    }
    return result
}

业界进阶算法

上面的滑动窗口是最简单的规则打散。业界还有更数学化的多样性算法:

📊

MMR(Maximal Marginal Relevance)

每次选下一条帖子时,同时考虑相关性(和用户兴趣的匹配度)和多样性(和已选帖子的差异度),取两者加权最高的。

MMR = λ × 相关性 - (1-λ) × max(与已选相似度)

λ=0.7 偏重相关性 · λ=0.3 偏重多样性 · 复杂度 O(n²)

🎯

DPP(Determinantal Point Process)

数学上最优雅的多样性方法。构造一个核矩阵,用行列式的值衡量一个子集的"质量×多样性"联合概率。抖音/快手早期多样性用的就是 DPP。

优点:全局最优 · 数学保证 · 缺点:计算成本高,需要近似算法 · 适合精排后的 Top 50

规则打散(当前方案)

滑动窗口 + 分类/作者约束。简单直接,性能极佳(O(n)),适合中小规模。缺点是规则是"硬"约束,不能像 MMR/DPP 那样连续地平衡相关性和多样性。

推荐:先用规则打散上线,效果不够再升级 MMR

打散的代价与度量

打散必然牺牲排序分数——把排序靠前的帖子往后挪了。关键是找到"相关性"和"多样性"的平衡点。

打散过度

用户刷到的帖子和兴趣不匹配,点击率下降。为了多样性牺牲了太多相关性。

表现:CTR 下降 · 人均阅读时长减少

打散不足

用户觉得推荐"千篇一律",审美疲劳后离开。短期 CTR 可能还行,但长期留存下降。

表现:次日留存下降 · 刷新次数减少

如何度量多样性?常用指标:ILS(Intra-List Similarity)——推荐列表中任意两条帖子的平均相似度,越低表示越多样。还有类目覆盖率——推荐列表覆盖了多少个不同类目。理想状态是 CTR 不降的前提下,ILS 尽可能低。

特殊内容的处理

📌

运营置顶

运营可以手动将某篇帖子"置顶"到推荐列表前几条。置顶帖不参与打散,直接插入指定位置。但一次最多 1~2 条置顶,不能滥用。

💰

广告插入

如果有广告系统,广告帖在打散之后按固定规则插入(如每 8 条推荐插 1 条广告)。广告不影响推荐内容的打散逻辑,是独立叠加的。

🆕

探索帖位置

随机探索和新帖探索的帖子,不宜全放在列表末尾(用户可能刷不到)。应在打散时保证每 10 条中至少穿插 1 条探索帖,给它们足够的曝光。

打散的最终目标。让用户在刷推荐流时,每一屏都有"惊喜感"——既有熟悉的兴趣内容,也有不一样的新发现。好的打散让用户觉得"这个 App 很懂我,又总能给我新东西"。
08 — DEDUPLICATION

已读去重

用户刷过的帖子不能再推。布隆过滤器用极小的内存实现海量已读记录的快速判断。这是推荐体验的底线——重复推送会让用户觉得"这推荐系统有 bug"。

去重发生在哪一步?在 Section 04 多路召回中我们讲过,去重分两层:第一层在 ES 查询中粗筛最近 1 小时的已读 ID(量小),第二层在召回合并后用 Bloom Filter 精筛全量已读。本节重点讲第二层——Bloom Filter 的原理和实现。

Bloom Filter 原理

Bloom Filter 是一个概率数据结构,用 一个 bit 数组 + K 个哈希函数 来判断"某个元素是否在集合中"。它的核心特性:判定不存在时一定不存在判定存在时可能误判

W

写入过程:标记已读

用户看了帖子 post_123 → 用 K 个哈希函数分别计算 → 得到 K 个位置 → 把这些位置的 bit 设为 1。

post_123
hash₁ → 位置 3
hash₂ → 位置 11
hash₃ → 位置 23

bit 数组:将位置 3、11、23 设为 1

R

查询过程:判断是否已读

要推帖子 post_456 → 用同样的 K 个哈希计算位置 → 检查这些位置的 bit 是否全部为 1

全部为 1 → "可能已读"

这些位可能是被其他元素标记的(哈希碰撞),所以存在误判的可能。误判的结果是少推一条帖子给用户——可接受。

有任一个 0 → "一定没读过"

只要有一个位是 0,就说明这个元素一定没被写入过。这条帖子可以放心推给用户——绝对不会漏判

Bloom Filter bit 数组可视化

蓝色 = 被标记为 1(对应某个已读帖子的哈希位)。灰色 = 0(未被标记)。

参数设计:要多大的 bit 数组?

Bloom Filter 的误判率由两个参数决定:bit 数组长度 m哈希函数个数 K。对于 n 个元素,有精确的数学公式。

// 误判率公式
误判率 p (1 - e^(-K×n/m))^K
// 最优哈希函数个数
Kopt = (m/n) × ln(2)

实际场景参数估算

场景 已读数 n 目标误判率 bit 数组 m 内存 哈希数 K
轻度用户(30天) 1,000 1% 9,585 bit ~1.2 KB 7
中度用户(30天) 10,000 1% 95,851 bit ~12 KB 7
重度用户(30天) 50,000 1% 479,253 bit ~59 KB 7

1% 误判率意味着每推 100 条未读帖子中,大约有 1 条被误判为"已读"而被跳过——完全可接受。

总内存估算。假设 10K DAU,平均每人 12 KB → 总共约 120 MB Redis 内存。这是非常小的开销。即使 100K DAU 也才 1.2 GB。

Redis 实现

Redis 4.0+ 支持 Bloom Filter 模块(RedisBloom),提供原生命令。如果 Redis 版本不支持,可以用 Redis Bitmap 手动实现。

方案 A:RedisBloom 模块命令
# 创建 Bloom Filter(每用户一个)
# 预期 10000 条已读,误判率 1%
BF.RESERVE  seen:user:10086  0.01  10000

# 用户看了一篇帖子 → 标记已读
BF.ADD      seen:user:10086  post_456

# 推荐时判断帖子是否已读
BF.EXISTS   seen:user:10086  post_789    # → 0(未读) / 1(可能已读)

# 批量判断(一次检查多篇帖子)
BF.MEXISTS  seen:user:10086  post_1 post_2 post_3 post_4 post_5
方案 B:Redis Bitmap 手动实现(无需 RedisBloom 模块)
func MarkSeen(ctx context.Context, userID, postID string) {
    key := "seen:" + userID
    for i := 0; i < numHashFuncs; i++ {
        pos := hashN(postID, i) % bitArraySize
        redis.SetBit(ctx, key, pos, 1)
    }
}

func IsSeen(ctx context.Context, userID, postID string) bool {
    key := "seen:" + userID
    for i := 0; i < numHashFuncs; i++ {
        pos := hashN(postID, i) % bitArraySize
        bit, _ := redis.GetBit(ctx, key, pos).Result()
        if bit == 0 {
            return false  // 有一个 0 就一定没读过
        }
    }
    return true  // 全部为 1,可能已读(有误判风险)
}

// 批量过滤:在线推荐时使用 Pipeline 减少 RTT
func FilterSeen(ctx context.Context, userID string, posts []Post) []Post {
    var unseen []Post
    for _, p := range posts {
        if !IsSeen(ctx, userID, p.ID) {
            unseen = append(unseen, p)
        }
    }
    return unseen
}

滚动时间窗口:定期重建

Bloom Filter 不支持删除元素(把某个位设回 0 会影响其他元素)。随着已读帖子越来越多,bit 数组逐渐被填满,误判率会持续上升。解决方案:滚动时间窗口

双 Bloom Filter 轮换
BF-A(当前周期)
新已读写入 BF-A
查询时同时查 BF-A + BF-B
到期后
删除 BF-B
BF-A 变成 BF-B · 新建空 BF-A
dedup/rolling_bloom.go
// 双 Bloom Filter 轮换
// 每 15 天轮换一次 → 实际覆盖 15~30 天的已读记录

func currentBFKey(userID string) string {
    period := time.Now().Unix() / (15 * 86400)  // 15 天一个周期
    return fmt.Sprintf("seen:%s:%d", userID, period)
}

func previousBFKey(userID string) string {
    period := time.Now().Unix() / (15 * 86400) - 1
    return fmt.Sprintf("seen:%s:%d", userID, period)
}

func MarkSeen(ctx context.Context, userID, postID string) {
    // 只写入当前周期的 BF
    redis.Do(ctx, "BF.ADD", currentBFKey(userID), postID)
}

func IsSeen(ctx context.Context, userID, postID string) bool {
    // 同时查当前和上一周期(覆盖 15~30 天)
    cur, _ := redis.Do(ctx, "BF.EXISTS", currentBFKey(userID), postID)
    if cur == 1 { return true }
    prev, _ := redis.Do(ctx, "BF.EXISTS", previousBFKey(userID), postID)
    return prev == 1
}

// 清理:设置 key 的 TTL,过期自动删除
// BF.RESERVE 后执行 EXPIRE seen:user:10086:123 2592000 (30天)
为什么用双 BF 而不是直接删重建?如果只有一个 BF,轮换时会有一个"空窗期"——新 BF 里什么已读记录都没有,之前看过的帖子全部会被重新推荐。双 BF 保证在任何时刻,至少有 15 天的已读覆盖。

去重方案对比

Bloom Filter 不是唯一选择。根据规模和需求,有几种方案可以选。

方案 内存 / 用户 查询速度 支持删除 误判 适用场景
Redis Set ~500 KB O(1) 支持 DAU < 1K · 精确去重
Redis Bitmap ~60 KB O(K) 不支持 ~1% 无 RedisBloom 模块时
Bloom Filter ✅ ~12 KB O(K) 不支持 ~1% 万级 DAU · 推荐场景首选
Cuckoo Filter ~15 KB O(1) 支持 ~3% 需要删除能力时
Redis Set 为什么不行?假设用户看了 10000 条帖子,每个 ID 平均 20 字节,Redis Set 需要 ~500 KB/人(含 Set 内部开销)。10K DAU = 5 GB。这还能接受。但如果 100K DAU 就是 50 GB——Redis 内存成本不可忽视。Bloom Filter 用 12 KB 存同样的数据量,差了 40 倍。

多级去重策略(推荐架构总览)

在完整的推荐链路中,去重不止一层:

L1

召回时粗筛 — ES must_not

传最近 1 小时内看过的 ID(几十条)给 ES,用 must_not.terms 排除。成本低,减少下游浪费。

L2

合并后精筛 — Bloom Filter(本节)

五路召回合并后,逐条过 Bloom Filter。覆盖全量已读(30 天),微秒级判断。这是去重的主力。

L3

多路间去重 — 内存 Set

同一篇帖子可能被多路召回同时捞到(兴趣匹配 + 热门都命中了)。合并时用 Go 内存 map 按 post_id 去重,取分数最高的那个。

L4

待推队列去重 — 入队前校验

每次新批推荐结果写入待推队列前,和队列中已有的帖子做去重。避免用户快速刷新时,两批结果中有重复。

四层去重的效果。L1 粗筛减少召回浪费 → L2 Bloom Filter 过滤全量已读 → L3 去掉多路重复 → L4 避免批次间重复。四层配合,确保用户永远不会看到重复帖子(除非 30 天后 Bloom Filter 轮换导致的极少量重推,用户早已忘记了)。
09 — PUSH QUEUE

待推队列与无限刷

待推队列是推荐系统和客户端之间的"缓冲池"——一次召回 200 条缓存在 Redis,用户每次取 20 条。核心命题:如何保证用户永远刷不光?

队列整体架构
Client (App)
pull 20 →
← response
Gateway / BFF
取数 + 兜底检查
LPOP 20 →
← items
Redis List
per-user · TTL = 24h
len < watermark? → async trigger
Recall + Rank Pipeline
异步触发 · 算 200 条
── 200 items →
RPUSH 入队
↑ 写回 Redis List

每个用户一条 Redis List,key = rec:queue:{uid},LPOP 消费、RPUSH 补充,天然 FIFO 顺序。

队列元素设计

队列中不只存 post_id,还需要携带预计算的排序分召回来源,方便后续取出时做轻量重排和日志归因。

// 队列元素结构(JSON 序列化存入 Redis)
type QueueItem struct {
    PostID      int64   `json:"pid"`
    Score       float64 `json:"s"`      // 排序综合分(已算好)
    RecallFrom  string  `json:"rf"`     // 召回来源: interest/hot/follow/explore/cold
    EnqueueTime int64   `json:"et"`     // 入队时间戳(用于过期淘汰)
    Quality     float64 `json:"q"`      // 质量分快照
}
无限滚动时序
T=0 用户打开 App → 队列为空 → 触发首次召回 → 算 200 条入队 → 返回前 20 条
第 5 屏 已消费 100 条 → 剩余 100 条 → 暂时不触发
第 8 屏 已消费 160 条 → 剩余 40 条 → ⚡ 触发低水位预加载 → 后端异步召回新一批 200 条追加
第 10 屏 新内容已补好 → 无缝继续刷 → 用户零感知
循环 每次接近尾部自动补充 → 无限循环 → 永远有内容可刷
水位线(Watermark)补充机制

"什么时候补"比"补多少"更关键。业界普遍采用双水位线策略:

const (
    HighWatermark = 200  // 每次补充目标量
    LowWatermark  = 40   // 触发补充的阈值
    PageSize      = 20   // 每次客户端取的条数
    MinServe      = 5    // 最少保证能返回的条数(否则进兜底)
)

// 拉取逻辑
func Pull(ctx context.Context, uid int64) ([]QueueItem, error) {
    // 1. 从队列头部取 PageSize 条
    items, err := redis.LPopN(ctx, queueKey(uid), PageSize)
    if err != nil {
        return nil, err
    }

    // 2. 检查剩余长度,决定是否补充
    remaining := redis.LLen(ctx, queueKey(uid))

    if remaining < LowWatermark {
        // 异步触发补充,不阻塞当前请求
        go asyncRefill(ctx, uid, HighWatermark)
    }

    // 3. 如果取出的数量不够,走兜底逻辑
    if len(items) < MinServe {
        fallbackItems := fallbackPool(ctx, uid, PageSize-len(items))
        items = append(items, fallbackItems...)
    }

    return items, nil
}
为什么是 40 而不是 0?因为异步补充需要时间(通常 200~500ms),40 条 ≈ 用户再刷 2 屏的缓冲。 如果等到 0 才补,用户就会看到"加载中"——这是体验上的 P0 事故。
核心问题:如何保证刷不光?

用户刷了 2000 条之后,个性化召回确实会逐渐枯竭。业界的答案是多级降级(Fallback Ladder)——逐步放宽策略,保证永远有东西可看。

L1
个性化召回(正常模式)
兴趣匹配 + 关注 + 热门 + 探索,按标准链路走。覆盖用户 90% 的浏览时间。
L2
放宽兴趣范围
从精准兴趣标签 → 上位类目。例:用户喜欢"街头摄影"→ 放宽为"摄影"整个类目。 或降低 ES Boost 中 should 子句的权重,让更多非精确匹配的内容进入召回池。
L3
全局热门兜底
放弃个性化,直接从全局热门池拉取。按时间分桶(24h / 72h / 7d),优先近期热门。 这一层几乎不可能耗尽——每天都有新热门产生。
L4
运营精选池
运营人工策划的优质内容集合,定期更新。高质量保底,不依赖算法,确保内容调性。
L5
历史优质重推
从用户已读列表中选出"高互动 + 距今 >30 天"的内容重新推荐。 用户记忆衰减后重看优质内容,体验可接受。加标签"精选回顾"区分。
L6
跨域内容探索
引入用户从未接触过的品类。例:一直看美食的用户,插入旅行内容试探。 风险最高但也是拓展用户兴趣的机会——需控制比例 ≤5%。
降级触发 — 代码实现
// 异步补充逻辑:分级降级
func asyncRefill(ctx context.Context, uid int64, target int) {
    var items []QueueItem
    var filled int

    // L1: 标准个性化召回
    items = personalizedRecall(ctx, uid, target)
    filled += len(items)
    enqueue(ctx, uid, items)

    if filled >= target {
        return
    }

    // L2: 放宽兴趣范围(上位类目 + 降低匹配阈值)
    items = relaxedRecall(ctx, uid, target-filled)
    filled += len(items)
    enqueue(ctx, uid, items)

    if filled >= target {
        return
    }

    // L3: 全局热门兜底(按时间桶)
    items = globalHotRecall(ctx, uid, target-filled)
    filled += len(items)
    enqueue(ctx, uid, items)

    if filled >= target {
        return
    }

    // L4: 运营精选池
    items = editorialPool(ctx, target-filled)
    filled += len(items)
    enqueue(ctx, uid, items)

    // L5 & L6 类似,省略...
    // 最终兜底: 如果所有层级都不够(几乎不可能),记录告警
    if filled < MinServe {
        metrics.EmptyQueueAlert(uid)
    }
}
"为什么刷不光" — 各层级容量分析
层级 候选池大小 更新频率 耗尽条件 是否可能耗尽
L1 个性化 取决于兴趣宽度,通常 1k~50k 实时(新内容持续入库) 兴趣极窄 + 日活高 可能(深度用户)
L2 放宽兴趣 L1 的 5~10 倍 实时 类目本身内容少 低概率
L3 全局热门 全站 Top 10k(按时间桶滚动) 小时级 刷完全站所有热门 几乎不可能
L4 运营精选 500~2000(人工维护) 日/周级 长时间不更新 不会(有 L3 前置)
L5 历史重推 用户自身已读池(>30d) 随时间自然增长 新用户无历史 不会
L6 跨域探索 全站内容 实时 不可能
数学上不可能刷光:假设全站日均新增 1 万条内容,用户每天刷 500 条。即使不考虑存量, 每天净增 9500 条未读内容。L3 层以下的候选池是在持续膨胀的。
队列清洗 — 已入队内容的失效处理

内容入队后不是一劳永逸的。以下情况需要处理已入队但"变质"的内容:

内容被删除/隐藏
不做主动清洗(代价太高),在取出时做懒过滤——取 20 条时实际取 25 条,过滤掉已删除的,补足 20 条返回。
作者被封禁
同上,懒过滤。封禁时只更新内容状态表,不遍历所有用户队列。
内容入队后时效过期
队列整体 TTL = 24h(用户隔天重新召回)。单条也检查 enqueueTime:入队 >6h 的热点内容丢弃(热度可能已过)。
用户兴趣突变
用户连续负反馈(不喜欢/举报同一类目 ≥3 次)→ 清空当前队列,强制重新召回。这是唯一的主动清洗场景。
// 懒过滤:取出时过滤无效内容
func pullWithFilter(ctx context.Context, uid int64, need int) []QueueItem {
    result := make([]QueueItem, 0, need)
    maxAttempts := 3 // 最多额外取 3 轮,防止死循环

    for attempt := 0; len(result) < need && attempt < maxAttempts; attempt++ {
        // 多取一些,预留过滤空间
        fetchSize := (need - len(result)) * 5 / 4 + 2
        raw, _ := redis.LPopN(ctx, queueKey(uid), fetchSize)

        for _, item := range raw {
            // 检查内容是否仍有效
            if isContentDeleted(item.PostID) {
                continue
            }
            // 检查是否过期(热点内容 6h,普通内容不限)
            if item.RecallFrom == "hot" && time.Since(item.EnqueueTime) > 6*time.Hour {
                continue
            }
            result = append(result, item)
            if len(result) >= need {
                break
            }
        }

        // 如果队列空了,退出
        if len(raw) < fetchSize {
            break
        }
    }
    return result
}
多设备并发问题

同一用户多设备同时刷推荐(手机 + 平板),会导致队列竞争:

方案 A:共享队列(业界主流)
两台设备 LPOP 同一个队列。Redis LPOP 是原子操作,天然不会重复取。 缺点:设备 A 刷了 5 页后,设备 B 打开时队列已被消耗过半,可能直接触发补充。 优点:简单,已读去重天然一致。
方案 B:设备隔离队列
key = rec:queue:{uid}:{device_id},各自独立。 缺点:内存翻倍,已读去重需要在 Bloom Filter 层统一,可能推重复内容。 优点:各设备体验独立。
推荐做法:共享队列 + Bloom Filter 全局去重。多设备场景在图文社区中占比 <5%,不值得为此增加架构复杂度。
新用户冷启动队列

新用户没有兴趣画像,L1 个性化召回几乎无法工作。冷启动队列的组成:

// 冷启动队列配比
func coldStartRefill(ctx context.Context, uid int64, target int) []QueueItem {
    items := make([]QueueItem, 0, target)

    // 40% — 全局热门(安全牌,大众口味)
    hot := globalHotRecall(ctx, uid, target*40/100)
    items = append(items, tag(hot, "hot")...)

    // 25% — 注册时选择的兴趣(如果有)
    if interests := getRegisterInterests(uid); len(interests) > 0 {
        interest := interestRecall(ctx, uid, interests, target*25/100)
        items = append(items, tag(interest, "cold_interest")...)
    } else {
        // 没有选择兴趣 → 用运营精选补位
        editorial := editorialPool(ctx, target*25/100)
        items = append(items, tag(editorial, "editorial")...)
    }

    // 20% — 同城/地域内容(基于 IP 或注册城市)
    local := localRecall(ctx, uid, target*20/100)
    items = append(items, tag(local, "local")...)

    // 15% — 探索内容(多样化品类,帮助快速建立画像)
    explore := diverseExplore(ctx, target*15/100)
    items = append(items, tag(explore, "explore")...)

    return items
}
关键设计:冷启动阶段每一条互动都非常宝贵——用户点赞/收藏/跳过的行为会被实时捕获, 下一次补充时 L1 就能基于这些信号做个性化召回。通常 3~5 屏后即可过渡到正常模式。
Redis 内存估算
参数 说明
单条 QueueItem JSON ~120 Bytes pid(8) + score(8) + rf(10) + et(8) + q(8) + JSON 开销
每用户队列上限 200 条 120B × 200 = 24 KB
同时在线用户数(DAU 高峰) 100 万
总内存占用 ~24 GB 加上 Redis 内部数据结构开销 ×1.5 ≈ 36 GB
TTL 策略 24 小时 非活跃用户队列自动过期,实际占用远低于峰值

优化:可以用 MessagePack 替代 JSON 序列化(体积减少 30~40%),或用 Redis Stream 替代 List(支持 Consumer Group 模式)。

关键监控指标
空队列率
Pull 时队列为空的比例。P0 指标,目标 < 0.1%。超过则说明补充机制失效。
补充延迟 P99
从触发补充到入队完成的时间。目标 < 500ms。超过则需优化召回链路或加缓存。
降级比例
L2~L6 层级被触发的比例。L3+ 占比过高说明内容池不足或画像太窄。
懒过滤丢弃率
取出后被过滤掉的比例。>10% 说明内容下架频繁或队列 TTL 设置不合理。
双重保险:前端设阈值提前请求(剩余 2 屏时触发) + 后端每次取数据时检查剩余量自动补充。 即使前端预加载因网络问题没发出,后端也能兜底。再加上多级降级策略,从架构上消灭了"刷光"的可能性
10 — STORAGE ARCHITECTURE

存储架构总览

推荐系统是"多存储协同"的典型场景——没有任何单一数据库能满足所有需求。每种存储解决一类问题,组合起来构成完整的数据底座。

MySQL
帖子信息 帖子标签 帖子统计 用户信息 标签体系 关键词表
ClickHouse
用户行为日志(按时间分区)
Elasticsearch
帖子召回索引(ID+标签+质量分)
Redis
用户画像 Hash 待推队列 List 布隆过滤器 热门池 ZSet 缓存
Kafka
异步消息队列(发帖 + 行为上报)
选型对照表
存储 数据内容 读写特点 选型理由 可替代方案
MySQL 帖子信息、标签体系、用户信息 低频写、结构化查询 ACID 事务保证,强一致性 PostgreSQL
ClickHouse 用户行为日志(日千万~亿条) 高频追加写、聚合分析 列式存储,聚合查询秒级 MongoDB(前期)/ StarRocks
Elasticsearch 帖子召回索引 高频读、多条件组合检索 倒排索引 + BM25 + Boost Redis Set(前期)/ Milvus(向量)
Redis 画像、队列、布隆、热门池、缓存 极高频读写、微秒级 数据结构丰富,延迟极低 —(核心组件,不可替代)
Kafka 异步消息(发帖事件、行为上报) 高吞吐、顺序消费 削峰 + 解耦 + 可重放 Redis Stream / Pulsar / NATS
MySQL — 结构化数据的"真相源"

MySQL 是整个系统的 Source of Truth——所有其他存储的数据都是从 MySQL 同步/衍生过去的。

-- 核心表结构

-- 帖子主表
CREATE TABLE posts (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    author_id   BIGINT NOT NULL,
    title       VARCHAR(200),
    content     TEXT,
    image_urls  JSON,               -- 图片列表
    status      TINYINT DEFAULT 1,  -- 1=正常 2=隐藏 3=删除
    quality_score FLOAT DEFAULT 0,  -- 质量分(定期更新)
    created_at  DATETIME NOT NULL,
    updated_at  DATETIME NOT NULL,
    INDEX idx_author (author_id),
    INDEX idx_created (created_at),
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 帖子标签关联表(多对多)
CREATE TABLE post_tags (
    post_id     BIGINT NOT NULL,
    tag_id      INT NOT NULL,
    weight      FLOAT DEFAULT 1.0,  -- 标签权重(NLP 提取的置信度)
    source      ENUM('nlp', 'manual', 'user'),
    PRIMARY KEY (post_id, tag_id),
    INDEX idx_tag (tag_id)
) ENGINE=InnoDB;

-- 帖子统计表(独立出来,高频更新不影响主表)
CREATE TABLE post_stats (
    post_id       BIGINT PRIMARY KEY,
    view_count    INT DEFAULT 0,
    like_count    INT DEFAULT 0,
    comment_count INT DEFAULT 0,
    share_count   INT DEFAULT 0,
    collect_count INT DEFAULT 0,
    updated_at    DATETIME NOT NULL
) ENGINE=InnoDB;
关键设计:统计表和主表分离。帖子互动是高频更新(每秒可能几千次),如果和 content TEXT 字段在同一行, 每次更新都要锁定大行,严重影响读性能。分离后统计表的行很小(~50 Bytes),更新非常轻量。
Elasticsearch — 召回引擎的心脏

ES 不存完整内容,只存召回需要的字段——越精简越快。完整内容通过 post_id 回查 MySQL + 缓存获取。

// ES Index Mapping
PUT /posts_recall
{
  "settings": {
    "number_of_shards": 3,          // 按数据量估算
    "number_of_replicas": 1,
    "refresh_interval": "5s"        // 近实时,5s 延迟可接受
  },
  "mappings": {
    "properties": {
      "post_id":       { "type": "long" },
      "author_id":     { "type": "long" },
      "tags":          { "type": "keyword" },        // 标签数组,精确匹配
      "category":      { "type": "keyword" },        // 一级类目
      "quality_score": { "type": "float" },          // 质量分
      "hot_score":     { "type": "float" },          // 热度分
      "created_at":    { "type": "date" },
      "status":        { "type": "byte" },
      "image_count":   { "type": "integer" },
      "text_length":   { "type": "integer" },
      "author_level":  { "type": "byte" }            // 作者等级
    }
  }
}
数据同步方式
MySQL → Kafka(binlog CDC)→ ES Consumer。新帖发布后 5~10s 内进入 ES 可被召回。 也可用 Canal/Debezium 监听 binlog,推到 Kafka,再由消费者写入 ES。
分片策略
单分片建议 10~50GB。100 万帖子约 500MB(字段精简),3 分片绰绰有余。 千万级可按 created_at 按月建索引(posts_recall_202601),用 alias 统一查询。
查询优化
filter 上下文(status=1, created_at 范围)走缓存不计分;should 上下文(tags boost)走评分。 terminate_after: 500 限制每个分片最多扫描 500 条,防止慢查。
Redis — 推荐系统的"瑞士军刀"

Redis 在推荐系统中承担了 5 种完全不同的角色,充分利用其丰富的数据结构:

角色 数据结构 Key 设计 内存估算(100 万 DAU) TTL
用户画像 Hash profile:{uid} ~2 GB(每人 ~2KB) 7 天(离线 Job 刷新)
待推队列 List rec:queue:{uid} ~36 GB(每人 200 条) 24 小时
已读去重 Bitmap / BloomFilter bf:read:{uid} ~6 GB(每人 ~6KB) 30 天(滚动窗口)
热门池 ZSet hot:pool:{bucket} ~50 MB(共享,非 per-user) 随桶轮转
帖子缓存 String / Hash post:cache:{pid} ~1 GB(Top 10 万热帖) 1 小时(LRU 淘汰)
// 用户画像 Hash 结构示例
HSET profile:10086
    "interest_tags"     '["摄影","美食","旅行"]'
    "interest_weights"  '{"摄影":0.85,"美食":0.6,"旅行":0.3}'
    "category_dist"     '{"生活":0.4,"技术":0.35,"娱乐":0.25}'
    "active_hours"      '[10,12,14,20,21,22]'
    "avg_read_time"     "45"
    "last_active"       "1738700000"
    "follow_authors"    '[2001,2002,2003]'
内存总计:画像 2GB + 队列 36GB + 布隆 6GB + 热门 0.05GB + 缓存 1GB ≈ 45 GB。 一台 64GB 内存的 Redis 即可承载 100 万 DAU。超过则做 Redis Cluster 分片(按 uid 哈希)。
ClickHouse — 行为日志的分析引擎

用户每一次曝光、点击、点赞、收藏、跳过……都是宝贵的信号。ClickHouse 负责存储 + 离线分析

-- 行为日志表
CREATE TABLE user_actions (
    event_date  Date,
    event_time  DateTime,
    uid         UInt64,
    post_id     UInt64,
    action      Enum8(
        'impression' = 1,
        'click' = 2,
        'like' = 3,
        'collect' = 4,
        'comment' = 5,
        'share' = 6,
        'dislike' = 7,
        'report' = 8,
        'read_finish' = 9
    ),
    duration_ms UInt32,             -- 停留时长
    recall_from LowCardinality(String),  -- 召回来源
    position    UInt16,             -- 在列表中的位置
    device      LowCardinality(String),
    app_version LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)  -- 按月分区
ORDER BY (uid, event_date, post_id)
TTL event_date + INTERVAL 90 DAY  -- 自动清理 90 天前数据
SETTINGS index_granularity = 8192;
为什么列式
推荐系统的分析查询几乎都是 "某个维度的聚合"——按用户聚合算画像、按帖子聚合算热度、按时间段聚合算 CTR。 列式存储只读取需要的列,IO 减少 10~100 倍,聚合速度碾压行式存储。
典型查询
计算某用户近 7 天的兴趣分布、某帖子的完读率趋势、某召回通道的转化漏斗——这些都是 ClickHouse 的强项, 亿级数据秒级出结果。
数据量级
100 万 DAU × 每人日均 500 次曝光 = 5 亿条/天。ClickHouse 列式压缩后约 5~10GB/天, 90 天保留 ≈ 500GB~1TB 磁盘。单机可扛,超过可 Cluster 分布式。
-- 画像更新 Job(每天凌晨跑):近 7 天兴趣标签分布
SELECT
    uid,
    arrayJoin(post_tags) AS tag,
    countIf(action = 'click') * 1.0
        + countIf(action = 'like') * 3.0
        + countIf(action = 'collect') * 5.0
        + countIf(action = 'share') * 4.0
        - countIf(action = 'dislike') * 2.0 AS weighted_score
FROM user_actions
    LEFT JOIN post_tag_map USING (post_id)  -- 维表 JOIN
WHERE event_date >= today() - 7
GROUP BY uid, tag
HAVING weighted_score > 0
ORDER BY uid, weighted_score DESC;
Kafka — 异步解耦的中枢神经

Kafka 是推荐系统的"神经系统"——所有事件通过它传递,各消费者按需订阅。

post.publish
分区: 6 保留: 7d
生产者:发帖服务
消费者:NLP 打标签 → ES 索引写入 → 冷启动池更新
user.action
分区: 12 保留: 3d
生产者:客户端上报(通过 BFF)
消费者:ClickHouse 写入 → 实时画像更新 → 热度分计算
post.stat.update
分区: 6 保留: 1d
生产者:统计聚合服务(每分钟)
消费者:ES 热度分更新 → Redis 热门池更新
content.moderate
分区: 3 保留: 7d
生产者:审核服务
消费者:ES 状态更新 → MySQL 状态更新 → 缓存失效
为什么用 Kafka 而不是直接写?削峰:发帖高峰期直接写 ES + ClickHouse 会打满,Kafka 做缓冲; ②解耦:新增消费者(如增加一个推送服务)不需改发帖代码; ③可重放:消费失败可重置 offset 重新消费,数据不丢失。
数据流转全景
用户发帖
AppBFFMySQL 主表Kafka post.publish
→ NLP Worker → MySQL(标签)ES
→ 冷启动池 Worker → Redis ZSet
→ 审核 Worker → MySQL(status)ES
用户行为
AppBFFKafka user.action
ClickHouse Writer(批量写入)
→ 实时画像 Worker → Redis Hash
→ 热度计算 Worker → Redis ZSet + ES hot_score
用户刷推荐
AppBFFRedis Queue LPOP
队列不够 → 触发召回
Redis Profile + ES Query + Redis Bloom
→ 排序 → 打散 → Redis Queue RPUSH
数据一致性策略

推荐系统对一致性的要求是最终一致——允许 5~10 秒的延迟,但不允许永久不一致。

MySQL → ES
通过 Kafka 异步同步。如果消费失败,Kafka 重试保证最终写入。 兜底:每天凌晨全量对账 Job,对比 MySQL 和 ES 的 post_id 集合,修补缺失/多余的数据。
MySQL → Redis
画像:离线 Job 每天全量刷新(ClickHouse 计算 → Redis 写入)。 缓存:写穿透(write-through)或旁路缓存(cache-aside),TTL 兜底过期。
删帖/封禁
MySQL 更新 status → Kafka 广播 → ES 更新 / Redis 缓存删除。 队列里的脏数据靠取出时懒过滤(Section 09 已讲)。
容灾降级 — 各存储挂了怎么办?
故障场景 影响范围 降级策略 恢复时间
MySQL 不可用 新帖无法发布,详情页兜底缓存 推荐链路不受影响(不直接依赖 MySQL) 主从切换 < 30s
ES 不可用 召回失败,无法补充新内容 消费现有队列存量 + 全局热门池兜底 集群恢复 ~分钟级
Redis 不可用 全链路受影响(画像/队列/布隆全丢) P0 故障。降级为无个性化热门流 + 内存布隆过滤器 Sentinel/Cluster 自动切换 < 10s
Kafka 不可用 新帖延迟入库、行为日志丢失 本地磁盘暂存 + 恢复后重放 Controller 选举 ~秒级
ClickHouse 不可用 画像无法更新(用旧画像) 无直接影响,画像 Redis 有 7 天 TTL 可容忍 ~小时级
优先级排序:Redis > ES > Kafka > MySQL > ClickHouse。Redis 是唯一挂了就"全盘崩"的组件, 必须做高可用(Sentinel / Cluster + 持久化 + 定期快照备份)。
11 — METRICS

衡量指标体系

推荐系统的好坏不是拍脑袋评价的——需要一套完整的指标体系,从北极星指标到诊断指标,层层拆解,用数据驱动每一次迭代。

指标金字塔
NORTH STAR
DAU × 人均时长
↓ ↓ ↓
效果指标(核心)
CTR / 互动率
完读率 / 时长
体验指标
多样性 / 新鲜度
负反馈率
生态指标
覆盖率 / 公平性
创作者活跃度
↓ ↓ ↓
系统诊断指标(技术层)
召回延迟 / 空队列率 / 降级比例 / Bloom 误判率 / ...
North Star 北极星指标

北极星指标是整个推荐团队唯一的终极目标,所有优化最终都要看它有没有涨。

NORTH STAR METRIC
DAU × 人均浏览时长
综合了"来多少人"和"每人待多久"两个维度。单独看 DAU 可能被营销拉动,单独看时长可能被少数重度用户拉高。
为什么不是 DAU?DAU 只衡量"来没来",不衡量"来了之后推荐好不好"。 一个推荐很差的 App 也能靠推送把用户骗来,但用户打开后马上走——DAU 高但时长低。 乘积才能反映真实的用户价值。
核心效果指标

这些是推荐系统直接优化的对象——A/B 测试中主要看这些指标的变化。

点击率 CTR
~15%
点击数 / 曝光数。反映兴趣匹配准确度。
互动率 Engagement
~8%
(点赞+评论+收藏+分享)/ 曝光数。比 CTR 更深层。
完读率 Finish Rate
~40%
看完全部图片或文字的比例。反映内容质量匹配。
人均浏览时长
25min
单次 Session 的平均停留时间。综合反映推荐效果。
CTR 的坑
位置偏差(Position Bias):第 1 条帖子的 CTR 天然比第 10 条高 3~5 倍,因为用户一定会看到第 1 条。 正确做法是分位置统计 CTR,或用 IPW(Inverse Propensity Weighting)校正。

曝光定义:什么算"曝光"?画面露出 50% 且停留 >500ms 才算有效曝光。 否则用户快速划过的内容也算曝光,会稀释 CTR。
互动率的分层
不同互动行为的价值差异巨大,业界通常使用加权互动率

WeightedEngagement = (Like×1 + Comment×3 + Collect×5 + Share×8) / Impressions

分享是最强的正信号——用户愿意用自己的社交货币为内容背书。 评论次之(需要思考和输入成本)。点赞最轻(无脑操作)。
体验指标 — 护栏(Guardrail)

体验指标是"护栏"——不一定要最大化,但不能突破底线。如果为了提升 CTR 而牺牲多样性,长期会导致用户疲劳、流失。

指标 公式 基线 红线 说明
多样性 ILS 1 - avg(sim(i,j)) 0.75 < 0.5 推荐列表中内容的两两相似度均值的补。越接近 1 越多样。
新鲜度 推荐列表中 24h 内发布的占比 >30% < 15% 过低说明在推老内容,用户感觉 App "没有新东西"。
负反馈率 (不喜欢+举报)/ 曝光数 < 2% > 5% 用户主动表达"不想看"。飙升说明推荐严重跑偏。
重复率 用户看到重复内容的比例 < 0.5% > 2% Bloom Filter 误判或跨 Session 去重失效。
指标冲突的经典案例:只优化 CTR 会导致信息茧房——用户只看同类内容, 短期 CTR 上涨但多样性下降,长期用户流失。所以 A/B 实验必须同时看效果指标 + 护栏指标, 任何一个护栏突破红线就不能上线。
生态指标 — 平台健康度

推荐不只是"给用户推内容",还要维护平台的内容生态——创作者发帖的积极性、内容的公平分发。

推荐覆盖率
>60%
过去 7 天被推荐过的帖子 / 全部帖子。过低说明大量内容被"埋没",伤害创作者积极性。
基尼系数
< 0.6
帖子曝光量的分布均匀度。越接近 0 越公平。>0.8 说明流量严重集中在少数头部帖子(马太效应)。
创作者满意度
NPS
创作者的发帖频率、是否持续活跃。推荐不公平会导致中腰部创作者"出走"。
"杀鸡取卵"警告:如果推荐只给头部创作者导流,中小创作者发帖得不到曝光就会停止创作, 内容池枯竭后头部内容也撑不住——这就是为什么覆盖率和基尼系数如此重要。
A/B 测试 — 指标驱动迭代的核心机制

推荐系统的每一次改动(换召回策略、调排序权重、改打散窗口……)都必须通过 A/B 测试验证。"我觉得好"没有意义,数据说好才是好

全量用户
── 按 uid 哈希分桶 →
对照组 A (50%) → 现有策略
实验组 B (50%) → 新策略
↓ 收集 7~14 天数据 ↓
统计检验
t-test / Mann-Whitney · p-value < 0.05 · 效果量 > MDE
✅ 全量上线
⚠️ 延长观测
❌ 回滚
样本量估算
检测 CTR 从 15% → 15.5%(相对提升 3.3%),需要每组 ~5 万用户,跑 7 天。 DAU 10 万以上才能做有意义的 A/B 测试。更小的产品可以用 Bandit 算法替代。
常见陷阱
新奇效应:新策略上线头几天指标偏高(用户好奇),需至少跑 7 天让效应消退。 辛普森悖论:总体 CTR 提升但某子群体下降——需分人群看。 P-hacking:跑了 20 个指标总有一个 p<0.05,需做 Bonferroni 校正。
离线评估指标 — 上线前的"沙盘推演"

A/B 测试成本高(占用线上流量 + 等待时间长)。在上线前,用离线数据集做评估可以快速淘汰差策略。

指标 含义 公式直觉 适用场景
Precision@K 推荐的 Top K 中用户真正交互的比例 推了 20 条,用户点了 3 条 → 15% 召回 + 排序
Recall@K 用户感兴趣的内容中被推荐 Top K 覆盖的比例 用户喜欢 10 条,推的 20 条里命中 3 条 → 30% 召回
NDCG@K 考虑排序位置的相关度——排在前面的越相关越好 用户点的帖子排在第 1 位比第 20 位得分高很多 排序
MAP 所有相关结果位置的平均精度 综合考虑所有正样本的排序位置 整体评估
AUC 模型区分正负样本的能力 随机选一正一负样本,正样本分数更高的概率 CTR 预估模型
离线 vs 在线的 gap:离线指标好不代表线上一定好。 因为离线数据只包含"过去策略推荐过的内容"的反馈,对新策略推出的新内容没有信号—— 这就是经典的 off-policy evaluation 问题。所以离线评估用于初筛,最终结论以 A/B 为准。
系统诊断指标 — 技术层监控

当业务指标下降时,需要通过技术指标定位问题出在哪个环节。

召回延迟 P99
ES 查询 + 多路合并的端到端延迟。目标 < 200ms。飙升说明 ES 负载过高或网络问题。
空队列率
Pull 时队列为空。P0,目标 < 0.1%。影响面:用户看到空白或 loading。
降级触发率
L2~L6 层级的触发比例。L3+ 过高说明个性化召回池枯竭或画像更新异常。
Bloom 误判率
已读去重误判导致的有效内容被过滤。理论 < 1%,实际监控看过滤比例的波动。
ES 缓存命中率
ES query cache + request cache 的命中率。过低说明查询模式太随机,考虑加应用层缓存。
画像新鲜度
Redis 中画像的平均最后更新时间距今。>48h 说明画像 Job 异常。
指标异常诊断框架

当北极星指标下降时,按以下路径逐层排查:

北极星下降(DAU × 时长 ↓)
DAU 下降?非推荐问题(市场/推送/外部因素)
人均时长下降?
CTR 下降?
召回延迟升高? → ES/Redis 性能问题
降级率升高? → 内容池不足 or 画像过期
新鲜度下降? → 新帖入库管道异常
某通道 CTR 骤降? → 定位具体通道排查
CTR 正常但互动率下降?
完读率下降? → 质量分模型需调优
点赞/评论下降? → 可能是产品交互变更
CTR/互动都正常但时长下降?
多样性下降? → 打散策略失效 → scatter 模块
负反馈率上升? → 某类内容触怒用户 → 质量过滤
空队列率上升? → 补充机制故障 → queue 模块
核心原则:好的指标体系不是"看板上有很多数字",而是每个数字都能 actionable—— 指标异常时你知道该查什么、该改什么。如果一个指标涨了跌了你不知道该干嘛,那这个指标就是噪音。
12 — EVOLUTION ROADMAP

迭代演进路线

推荐系统不是一步到位的工程——从"能用"到"好用"到"智能",需要分阶段演进。每个阶段有明确的目标、技术栈、团队要求和判断"该不该进入下一阶段"的信号。

PHASE 1 · 规则驱动(1-2 个月)
跑通全链路 · 验证可行性
这个阶段的目标不是"做好推荐",而是让整条数据管道通起来。从帖子发布到用户看到推荐,中间的每一个环节都跑通。推荐效果"凑合"就行。
Go Python jieba ES Redis
Phase 1 详解
做什么
① jieba 分词 + TF-IDF 提取关键词做标签
② 简单规则画像:用户近 N 天点赞最多的标签 = 兴趣标签
③ ES bool query + should boost 做召回
④ 手写公式排序(兴趣×0.4 + 质量×0.3 + 新鲜度×0.2 + 热度×0.1)
⑤ 简单滑动窗口打散
⑥ Redis List 做待推队列 + Redis Bitmap 做已读去重
不做什么
❌ 不搞机器学习模型
❌ 不搞实时特征
❌ 不搞 A/B 测试平台
❌ 不搞向量召回
这些都是后面阶段的事,过早引入只会增加复杂度、拖慢进度。
团队要求
1 个后端工程师(Go/Java)+ 1 个数据工程师(Python,兼 NLP 和数据管道)。 不需要算法工程师。
预期效果
CTR ~10%(比纯时间线好一些)。用户能感知到"推的内容和我有关"。 推荐覆盖率 ~40%。
常见踩坑
数据管道不稳定:Kafka 消费延迟、ES 写入失败、Redis 内存溢出——Phase 1 大部分时间在修管道而不是优化推荐。 标签质量差:jieba 分词出来的关键词噪声大,"的""了""在"等停用词没过滤干净。 冷启动无解:新用户没有画像,推啥都不准——先用热门兜底。
进入 Phase 2 的信号:数据管道稳定运行 >30 天,ClickHouse 积累了 >1000 万条行为日志, 有明确的"标签不准"或"排序不好"的业务痛点。
PHASE 2 · 简单模型(3-6 个月)
引入机器学习 · 用数据说话
Phase 1 积累的数据终于派上用场了。用这些数据训练简单模型,替换手写规则,效果会有质的飞跃
LightGBM scikit-learn 协同过滤 BERT 蒸馏
Phase 2 详解
标签升级
jieba 关键词 → 文本分类模型。用人工标注的 5000~10000 条数据训练 FastText 或轻量 BERT 分类器, 标签准确率从 ~60% 提升到 ~85%。也可以用 LLM API 批量打标(成本可控时)。
排序升级
手写公式 → LightGBM / XGBoost 排序模型
训练数据:ClickHouse 中的行为日志(点击=正样本,曝光未点击=负样本)。
特征:用户画像特征 + 帖子特征 + 上下文特征(时间、设备),通常 50~200 维。
效果:CTR 模型 AUC 从 0.5(随机)→ 0.65~0.72,线上 CTR 提升 20~40%。
召回升级
新增协同过滤召回通道:基于 item-based CF("看了 A 的人也看了 B"), 用 Spark 或 Python 离线计算 item-item 相似度矩阵,存到 Redis,线上查询。 对兴趣标签匹配的一个很好的补充——能发现"标签不同但用户群重叠"的内容。
团队要求
新增 1 个算法工程师(懂传统 ML,会特征工程和模型训练)。 后端工程师负责模型上线(模型推理服务 + 特征查询)。
常见踩坑
特征穿越:训练时不小心用了未来的数据(比如用帖子最终的点赞数预测是否会被点击)。 样本偏差:只有被推荐过的帖子才有行为数据,模型会对"没推过的好内容"视而不见。 线上线下不一致:离线 AUC 很好但线上效果不涨——通常是特征工程的线上线下计算口径不一致。
进入 Phase 3 的信号:LightGBM 模型的 AUC 在 0.70+ 趋于饱和,增加新特征带来的提升越来越小。 DAU >50 万,有 GPU 资源预算,有意愿招聘深度学习工程师。
PHASE 3 · 深度模型(6-12 个月)
精细化个性化 · 深度特征交叉
树模型擅长低阶特征交叉,但搞不定高阶隐式交互。深度模型能自动学习"用户的哪些属性和帖子的哪些属性之间存在微妙关联"。
双塔模型 DeepFM 多任务学习 GPU 训练
Phase 3 详解
召回:双塔模型
User Tower 编码用户特征 → 128 维向量,Item Tower 编码帖子特征 → 128 维向量。 内积 = 匹配度。帖子向量离线写入向量数据库(Milvus / Faiss), 线上用用户向量做 ANN(近似最近邻)检索,毫秒级返回 Top-K 最相似帖子。
优势:能捕捉语义层面的相似性(标签不同但"感觉"相似的内容),突破标签匹配的天花板。
排序:DeepFM
FM 部分学习二阶特征交叉(类似 LightGBM 做的事),DNN 部分学习高阶隐式交叉。 两部分共享 Embedding 层,联合训练。
输入:用户特征(画像、行为序列)+ 帖子特征(标签、质量分、统计)+ 上下文(时间、位置、设备)。
输出:pCTR(预估点击率),用于排序。
多任务学习
单一优化 CTR 会导致"标题党"内容排在前面。多任务模型(MMoE / PLE)同时预估:
pCTR × 0.4 + pLike × 0.3 + pCollect × 0.2 + pFinish × 0.1 = 最终排序分。
确保推荐的内容不仅"被点击",还"被认可"。
基础设施
特征仓库(Feature Store):统一管理线上线下特征,解决特征一致性问题。
模型推理服务:TensorFlow Serving / Triton Inference Server,P99 延迟 <10ms。
向量数据库:Milvus / Faiss,存储帖子 Embedding,支持 ANN 检索。
训练平台:GPU 集群 + 数据管道 + 实验管理(MLflow / W&B)。
团队要求
算法团队 2~3 人(召回/排序/特征各一人),工程团队 2~3 人(推理服务/特征平台/数据管道)。
进入 Phase 4 的信号:深度模型效果趋于稳定,单纯提升模型已经很难再涨指标。 开始出现"模型很好但产品指标不涨"的困境——问题不在模型,在系统的其他部分。
PHASE 4 · 高级能力(12 个月+)
系统化与智能化 · 推荐科学
从"做推荐"到"做推荐系统"——关注的不再是单个模型的 AUC,而是整个系统的闭环能力和平台的长期健康。
A/B 平台 实时特征 强化学习 知识图谱
Phase 4 详解
A/B 测试平台
自建分流平台,支持多层嵌套实验(召回层实验 × 排序层实验互不干扰)。 自动计算统计显著性,自动告警护栏指标突破红线。 好的 A/B 平台让迭代速度提升 3~5 倍——从"一个月上一个实验"到"每周 3~5 个并行实验"。
实时特征
从"昨天的画像"到"最近 5 分钟的行为"。用 Flink / Kafka Streams 做流式计算:
用户最近 10 分钟点了 3 篇美食帖 → 实时将"美食"兴趣权重拉到最高 → 下次补充队列时优先召回美食。
效果:捕捉用户的实时意图(Session 内兴趣),CTR 再提升 5~10%。
多目标优化
不再单纯优化用户指标,同时考虑:
用户价值:CTR、互动率、留存率
创作者价值:覆盖率、公平分发、创作者留存
平台价值:广告收入、商业化转化
三者之间有冲突(给用户推广告降体验,不推广告没收入),需要 Pareto 优化找平衡点。
强化学习探索
Contextual Bandit / RL 替换固定的探索策略。 模型自动学习"什么时候该推兴趣内容、什么时候该探索新品类", 在 Exploitation(利用已知兴趣)和 Exploration(探索未知兴趣)之间动态平衡。
LLM 增强
用大语言模型理解帖子的深层语义(不只是标签,还有情感、风格、受众定位)。 用 LLM 生成推荐理由("因为你最近关注了街头摄影")提升用户信任感。 用 LLM 做内容审核和质量评估,减少人工标注成本。
四阶段全景对比
Phase 1 Phase 2 Phase 3 Phase 4
召回 ES Boost + 协同过滤 + 双塔向量召回 + Graph / RL 探索
排序 手写公式 LightGBM DeepFM / DIN 多任务 + 多目标
标签 jieba 关键词 FastText 分类 BERT Embedding LLM 语义理解
画像 规则聚合 离线 ClickHouse Job + 近实时特征 实时流式特征
实验 无(直接上线) 手动 A/B 简单 A/B 平台 多层自动化 A/B
团队 2 人 3~4 人 5~6 人 8~10+ 人
预期 CTR ~10% ~15% ~20% ~22%+
关键基础设施 ES + Redis + Kafka + ClickHouse + 离线训练 + GPU + 特征仓库 + 向量库 + 流计算 + A/B 平台 + MLOps
演进中的常见误区
一上来就搞深度学习
没有数据积累(<100 万行为日志),深度模型训不出来。没有特征工程经验,深度模型的输入都不对。 先用规则 + 树模型把基线做好,深度模型才有的比较。
忽视数据管道
80% 的推荐问题其实是数据问题——标签不准、画像过期、行为日志丢失、特征线上线下不一致。 投入在管道质量上的 ROI 远高于模型调参。
没有 A/B 测试就上线
"我觉得这个策略更好"不是上线的理由。离线指标好也不是理由。 只有线上 A/B 测试证明的结果才能全量。否则就是在赌。
过度优化短期指标
只盯着 CTR 涨不涨,忽略多样性、覆盖率、创作者活跃度。 短期 CTR 涨了,但 3 个月后用户觉得"推的东西越来越无聊"就流失了。
跳阶段
每个阶段的产出是下一阶段的前置条件。跳过 Phase 1 直接搞模型 = 没有训练数据。 跳过 Phase 2 直接搞深度 = 没有 baseline 对比。 捷径就是最远的弯路。
核心原则:数据是模型的粮食,管道是数据的血管。Phase 1 把数据链路打好、把行为日志积累起来, 就是在为后续所有阶段做最好的准备。先让整条链路通了,比任何一个环节做到极致都重要。