推荐系统:从数学原理到 MVP 实战

推荐系统是 AI 在工业界最广泛的落地场景之一——抖音的视频流、小红书的首页瀑布流、淘宝的“猜你喜欢”、Netflix 的影片排序、Spotify 的每日推荐歌单,背后都是推荐系统,贡献了这些平台超过 80% 的用户消费内容。核心数学方法包括:协同过滤利用用户-物品交互矩阵计算相似度(余弦、皮尔逊相关系数);矩阵分解(SVD/ALS)将稀疏评分矩阵分解为用户向量 × 物品向量,是 Netflix Prize 冠军方案的核心;深度排序模型(Wide&Deep、DeepFM、DIN)用神经网络自动学习特征交叉;双塔模型分别编码用户和物品,内积打分后用 ANN 近似最近邻做召回;多目标优化(MMOE、PLE)同时优化点击率、完播率、分享率等多个目标。本文从数学基础出发,结合类似小红书的作品推荐场景,完整讲解协同过滤、矩阵分解、特征工程原理,并给出基于 PostgreSQL 的 MVP 数据库设计。

一、示例数据——类小红书作品平台

为了让所有算法都有具体数字可算,我们先建立一套完整的示例数据。

1.1 用户数据

user_id昵称年龄性别城市兴趣标签注册天数
U1001小美24成都美食, 探店, 穿搭180
U1002老王35北京科技, 数码, 编程365
U1003阿杰28广州健身, 美食, 旅行90
U1004小雪22上海穿搭, 美妆, 追星30
U1005大叔42深圳钓鱼, 汽车, 美食500

1.2 作品数据

item_id标题类别作者发布距今点赞收藏评论标签
I2001成都Top10火锅测评美食吃货达人3h120045089美食,火锅,成都,探店
I2002iPhone17全面评测科技数码博主6h3500800220科技,数码,iPhone,评测
I2003三亚5天自由行攻略旅行旅行家12h28001500156旅行,三亚,攻略,海边
I2004今秋必买的5件外套穿搭时尚编辑2h80060045穿搭,秋装,外套,时尚
I2005家庭健身30天计划健身健身教练24h150090078健身,减脂,家庭,计划
I2006重庆小面做法教程美食厨房达人8h60035030美食,做饭,重庆,面食
I20072026年最值得买的手机科技数码博主48h80002000500科技,手机,排行,推荐
I2008广州早茶必吃榜美食吃货达人5h90040060美食,早茶,广州,探店
I2009秋季显瘦穿搭公式穿搭时尚博主1h30020015穿搭,显瘦,秋装,女装
I2010野钓鲫鱼技巧分享钓鱼钓鱼老李10h40025035钓鱼,鲫鱼,野钓,户外
I2011React vs Vue 2026对比编程程序员20h1100700120编程,前端,React,Vue
I2012周杰伦演唱会现场娱乐追星girls4h50001200350娱乐,周杰伦,演唱会,追星

1.3 用户历史行为

user_iditem_id行为停留(秒)时间
U1001I2001点赞+收藏452h前
U1001I2006点赞305h前
U1001I2004收藏60昨天
U1001I2009点击浏览201h前
U1002I2002点赞+收藏+评论1203h前
U1002I2007点赞90昨天
U1002I2011收藏806h前
U1003I2005点赞+收藏504h前
U1003I2008点赞35今天
U1003I2003收藏55昨天
U1004I2004点赞403h前
U1004I2012点赞+收藏+评论901h前
U1005I2010点赞+收藏60今天
U1005I2001点赞40昨天

二、推荐算法的数学基础

2.1 用户-物品评分矩阵(协同过滤的基石)

用户行为转化为隐式评分(implicit rating):
点赞 = 2 分,收藏 = 3 分,评论 = 4 分,点击浏览 = 1 分,多种叠加

行为加权公式:
r(u,i) = w_click × I_click + w_like × I_like + w_collect × I_collect + w_comment × I_comment
其中 w_click=1, w_like=2, w_collect=3, w_comment=4
构建评分矩阵 R(5×12):

U1001 对 I2001: 点赞+收藏 → 2+3 = 5
U1001 对 I2006: 点赞 → 2
U1001 对 I2004: 收藏 → 3
U1001 对 I2009: 浏览 → 1
U1002 对 I2002: 点赞+收藏+评论 → 2+3+4 = 9
U1002 对 I2007: 点赞 → 2
U1002 对 I2011: 收藏 → 3
... 类推 ...

I2001I2002I2003I2004I2005I2006I2007I2008I2009I2010I2011I2012
U10015??3?2??1???
U1002?9????2???3?
U1003??3?5??2????
U1004???2???????9
U10052????????5??
"?" 就是推荐系统要预测的值——预测每个用户对未交互作品的可能评分,然后推荐预测分最高的。

2.2 余弦相似度(衡量用户或物品的相似程度)

cos(A, B) = (A · B) / (||A|| × ||B||) = Σᵢ(AᵢBᵢ) / (√Σᵢ Aᵢ² × √Σᵢ Bᵢ²)

取值范围 [-1, 1],越接近 1 越相似
对于非负评分矩阵,范围为 [0, 1]
计算用户 U1001 和 U1003 的相似度:

找共同交互过的物品:只需看两人都有评分的列
但 U1001 和 U1003 没有直接重叠的 item...

换一种方式:把没交互过的填 0,然后算完整向量的余弦相似度
U1001 = [5, 0, 0, 3, 0, 2, 0, 0, 1, 0, 0, 0]
U1003 = [0, 0, 3, 0, 5, 0, 0, 2, 0, 0, 0, 0]

A · B = 5×0 + 0×0 + 0×3 + 3×0 + 0×5 + 2×0 + 0×0 + 0×2 + 1×0 + 0×0 + 0×0 + 0×0 = 0
cos(U1001, U1003) = 0(没有共同交互→直接余弦=0)

计算用户 U1001 和 U1004 的相似度:
U1001 = [5, 0, 0, 3, 0, 2, 0, 0, 1, 0, 0, 0]
U1004 = [0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 9]

A · B = 3×2 = 6
||A|| = √(25+9+4+1) = √39 = 6.245
||B|| = √(4+81) = √85 = 9.220
cos(U1001, U1004) = 6 / (6.245×9.220) = 6/57.58 = 0.104

计算物品 I2001 和 I2008 的相似度(Item-Based):
I2001 列 = [5, 0, 0, 0, 2](被 U1001=5, U1005=2 评分)
I2008 列 = [0, 0, 2, 0, 0](被 U1003=2 评分)
cos(I2001, I2008) = 0(没有共同用户评过→0)

在真实系统中,数据量大了之后重叠率提高,余弦相似度才有意义。

2.3 矩阵分解(Matrix Factorization)——填充评分矩阵的关键

核心思想:将稀疏的 m×n 评分矩阵 R 分解为两个低秩矩阵的乘积

R ≈ P × Qᵀ

P: m×k 用户矩阵(每个用户用 k 维向量表示)
Q: n×k 物品矩阵(每个物品用 k 维向量表示)
k: 隐因子维度(通常 10~200)

预测评分:r̂(u,i) = Pᵤ · Qᵢ = Σⱼ Pᵤⱼ × Qᵢⱼ

优化目标:
min Σ_(u,i)∈观测到的评分 (r(u,i) - Pᵤ·Qᵢ)² + λ(||Pᵤ||² + ||Qᵢ||²)
    ↑ MSE 损失                     ↑ L2 正则化防过拟合
完整数值示例——k=2 的矩阵分解(ALS 算法手算)

简化评分矩阵(取 3 个用户×4 个物品):
RI2001I2004I2006I2008
U1001532?
U1003???2
U10052???
k=2 维隐因子。随机初始化:
P = [[0.5, 0.3],  ← U1001 的隐向量
     [0.2, 0.8],  ← U1003
     [0.6, 0.1]]  ← U1005

Q = [[0.4, 0.7],  ← I2001 的隐向量
     [0.9, 0.2],  ← I2004
     [0.3, 0.5],  ← I2006
     [0.8, 0.6]]  ← I2008

初始预测 r̂ = P × Qᵀ:
r̂(U1001, I2001) = 0.5×0.4 + 0.3×0.7 = 0.20+0.21 = 0.41(实际=5,差距大!)
r̂(U1001, I2004) = 0.5×0.9 + 0.3×0.2 = 0.45+0.06 = 0.51(实际=3)
r̂(U1001, I2008) = 0.5×0.8 + 0.3×0.6 = 0.40+0.18 = 0.58(这是预测值!)

SGD 优化一步(lr=0.01, λ=0.02):
对 r(U1001,I2001)=5,当前 r̂=0.41,误差 e=5-0.41=4.59

更新 P_{U1001}:
P[0][0] += lr×(e×Q[0][0] - λ×P[0][0]) = 0.01×(4.59×0.4 - 0.02×0.5) = 0.01×1.826 = 0.01826
P[0][0]_new = 0.5 + 0.01826 = 0.518
P[0][1] += 0.01×(4.59×0.7 - 0.02×0.3) = 0.01×3.207 = 0.03207
P[0][1]_new = 0.3 + 0.03207 = 0.332

更新 Q_{I2001}:
Q[0][0] += 0.01×(4.59×0.5 - 0.02×0.4) = 0.01×2.287 = 0.02287
Q[0][0]_new = 0.4 + 0.02287 = 0.423
Q[0][1] += 0.01×(4.59×0.3 - 0.02×0.7) = 0.01×1.363 = 0.01363
Q[0][1]_new = 0.7 + 0.01363 = 0.714

经过 100~500 轮迭代后,P 和 Q 收敛,所有 "?" 都能被预测出来。
最终 r̂(U1001, I2008) ≈ 3.8 → 小美可能会喜欢广州早茶!
最终 r̂(U1003, I2001) ≈ 2.5 → 阿杰对成都火锅有轻微兴趣

2.4 标签匹配度(基于内容的推荐)

Jaccard 相似度:
J(A, B) = |A ∩ B| / |A ∪ B|

A = 用户的兴趣标签集合
B = 作品的标签集合
计算小美(U1001)与每个作品的标签匹配度:
小美标签 = {美食, 探店, 穿搭}

作品作品标签交集并集Jaccard
I2001 火锅测评{美食,火锅,成都,探店}{美食,探店}=250.40
I2004 秋装外套{穿搭,秋装,外套,时尚}{穿搭}=160.17
I2008 广州早茶{美食,早茶,广州,探店}{美食,探店}=250.40
I2009 显瘦穿搭{穿搭,显瘦,秋装,女装}{穿搭}=160.17
I2002 iPhone{科技,数码,iPhone,评测}{}=070.00
I2006 重庆小面{美食,做饭,重庆,面食}{美食}=160.17
小美标签匹配度最高的是 I2001 和 I2008(都包含"美食+探店")。

2.5 热度分计算

Wilson 置信区间公式(Reddit/Hacker News 使用):
score = (p̂ + z²/(2n) - z√(p̂(1-p̂)/n + z²/(4n²))) / (1 + z²/n)
z = 1.96(95% 置信度)

简化版热度公式(实际工业常用):
hot_score = (w₁×likes + w₂×collects + w₃×comments) / (time_hours + 2)^1.5
时间衰减因子让新内容有机会出头
各作品热度分计算(w₁=1, w₂=2, w₃=3):

I2001: (1200 + 2×450 + 3×89) / (3+2)^1.5 = 2367/11.18 = 211.7
I2002: (3500 + 2×800 + 3×220) / (6+2)^1.5 = 5760/22.63 = 254.5
I2007: (8000 + 2×2000 + 3×500) / (48+2)^1.5 = 13500/353.6 = 38.2 ← 48h 前发的,时间衰减严重
I2009: (300 + 2×200 + 3×15) / (1+2)^1.5 = 745/5.20 = 143.3 ← 虽然绝对数值低,但 1h 内发的
I2012: (5000 + 2×1200 + 3×350) / (4+2)^1.5 = 8450/14.70 = 574.8 ← 热度最高!

热度排名:I2012(574.8) > I2002(254.5) > I2001(211.7) > I2009(143.3) > I2007(38.2)

三、推荐系统核心架构:召回 → 粗排 → 精排 → 重排

为什么不能直接对所有物品打分?
假设 100 万个用户 × 100 万个作品,对每个(用户,作品)对做精排打分:
10¹² 次计算,每次 1ms → 总共 31.7 年!不可能实时完成。
所以必须分阶段:召回(快速筛选到千级) → 排序(精细打分)
四级漏斗架构:

全部作品池: ~1,000,000 篇
      │
      ▼ 多路召回(< 10ms)
候选集: ~2,000 篇(各路合并去重)
      │
      ▼ 粗排(简单模型,< 20ms)
粗排结果: ~500 篇
      │
      ▼ 精排(LightGBM / 深度模型,< 50ms)
精排结果: ~50 篇
      │
      ▼ 重排(业务规则,< 10ms)
最终展示: ~20 篇

总延迟: < 100ms

3.1 多路召回详解

以小美(U1001)打开首页为例,6 路并行召回:

① 标签匹配召回(200 篇)
SQL: WHERE tags 与用户标签有交集 ORDER BY Jaccard DESC LIMIT 200
→ I2001(0.40), I2008(0.40), I2004(0.17), I2009(0.17), I2006(0.17)

② 协同过滤召回(200 篇)
找与小美相似的用户(U1004)喜欢但小美没看过的 → I2012
矩阵分解预测分 top → I2008(3.8), I2003(2.1)

③ 关注作者召回(50 篇)
小美关注了"吃货达人" → I2001, I2008
小美关注了"时尚编辑" → I2004

④ 热度召回(100 篇)
全站热度 top → I2012(574.8), I2002(254.5), I2001(211.7)

⑤ 好友动态召回(50 篇)
小美好友 U1004 最近互动了 → I2012, I2004

⑥ 地域召回(50 篇)
小美在成都 → I2001(成都火锅) 加权

合并去重:[I2001, I2002, I2003, I2004, I2005, I2006, I2007, I2008, I2009, I2010, I2011, I2012]

3.2 精排:LightGBM 打分模型

精排模型的任务:预测用户点击每个候选作品的概率 P(click)

输入特征(4 类):
用户特征:年龄, 性别, 城市, 注册天数, 活跃度
物品特征:类别, 点赞数, 收藏数, 评论数, 发布时长, 热度分
交叉特征:标签Jaccard匹配度, 地域是否匹配, 是否关注作者, 协同过滤预测分
上下文特征:当前时间(早/中/晚), 请求场景(首页/搜索/相关)
小美的精排打分过程(特征 → 预测):

候选作品标签匹配地域匹配热度(归一化)时效性关注作者协同过滤分→ P(click)
I2001 成都火锅0.401.00.370.9510.600.89
I2008 广州早茶0.400.00.220.9210.550.72
I2009 显瘦穿搭0.170.00.251.0000.400.65
I2004 秋装外套0.170.00.180.9810.500.63
I2006 重庆小面0.170.00.150.9000.450.51
I2012 周杰伦0.000.01.000.9500.700.48
I2003 三亚旅行0.000.00.650.8500.500.35
I2002 iPhone0.000.00.440.9000.050.12
I2011 React编程0.000.00.200.7500.020.06
I2010 野钓鲫鱼0.000.00.100.8500.010.04

3.3 重排:业务规则调整

精排结果 → 业务规则过滤和调整:

规则 1:去掉已看过的
小美已交互过 I2001, I2004, I2006, I2009 → 排除

规则 2:同类别打散(不超过连续 2 个)
I2008(美食) → I2012(娱乐) → I2003(旅行) → ...

规则 3:插入 10% 探索内容
在第 5 位插入一个小美从未接触过的类别 → I2005(健身)

最终推荐给小美的首页:
┌─────────────────────────────────────────┐
│ 1. 广州早茶必吃榜 (美食)   ← 标签+关注 │
│ 2. 周杰伦演唱会现场 (娱乐) ← 好友+热门 │
│ 3. 三亚5天自由行攻略 (旅行) ← 协同过滤 │
│ 4. 重庆小面做法教程 (美食)  ← 标签匹配 │
│ 5. 家庭健身30天计划 (健身)  ← 探索发现 │
└─────────────────────────────────────────┘
同一时刻老王(U1002)的首页完全不同:
1. React vs Vue 2026对比 (编程) ← 关注作者
2. 2026年最值得买的手机 (科技) ← 标签匹配
3. iPhone17全面评测 (科技) ← 标签+关注
4. 成都Top10火锅测评 (美食) ← 探索发现
5. 三亚5天自由行攻略 (旅行) ← 全站热门

相同的系统、相同的作品池,因为用户特征不同而输出完全不同的推荐

四、更多推荐场景

4.1 相关推荐(看完一篇后)

小美看完 I2001(成都火锅) 后,"猜你喜欢":

同作者作品:I2001 作者=吃货达人 → I2008(广州早茶)

Item-to-Item 内容相似度(Jaccard):
I2001 标签={美食,火锅,成都,探店}
I2008 标签={美食,早茶,广州,探店} → J=|{美食,探店}|/|{美食,火锅,成都,探店,早茶,广州}|=2/6=0.33
I2006 标签={美食,做饭,重庆,面食} → J=|{美食}|/7=0.14

协同过滤:喜欢 I2001 的 U1005 还喜欢了 I2010(但钓鱼跟小美无关→低分)

最终:I2008(同作者+内容相似) → I2006(内容相似) → I2009(小美喜欢穿搭,跨类推荐)

4.2 搜索推荐(搜索结果个性化排序)

小美搜索"火锅":

全文检索命中 → I2001(成都火锅), I2050(北京火锅), I2051(自制火锅底料)

对小美的个性化排序(在检索结果上叠加推荐特征):
I2001: BM25=8.5, 地域匹配(成都)=1, 标签匹配=0.40 → 综合分 9.2 ← 排第一
I2051: BM25=7.2, 地域匹配=0, 小美收藏过做饭类=0.3 → 综合分 7.5
I2050: BM25=7.8, 地域匹配=0, 无其他加分 → 综合分 7.0

若老王搜同样的"火锅":
I2050(北京火锅): 地域匹配(北京)=1 → 排第一(对老王来说北京火锅更相关)

4.3 新用户冷启动

新用户 U1006 刚注册,零历史行为:

阶段一:注册引导
弹出兴趣选择:"请选择至少 3 个感兴趣的话题"
U1006 选了:科技、健身、汽车

阶段二:初始推荐策略
40% → 选的标签类别热门(科技、健身、汽车 top 内容)
30% → 全站近 24h 热门
20% → 同城市+同年龄段的群体热门
10% → 随机探索

阶段三:快速学习(前 50 次交互)
U1006 点击了 I2002(iPhone) 停留 120s 点赞 → 确认喜欢科技
U1006 看到 I2012(周杰伦) 没点击 → 对娱乐不感兴趣
→ 第二次打开 App 推荐已经明显不同
→ 交互 50 次后基本和老用户推荐精度持平

4.4 负反馈处理

小美看到 I2005(健身) → 长按 → "不感兴趣"

选择原因→不同的处理策略:
"内容不感兴趣" → "健身"类别在小美的画像中降权 30%
"看过类似的了" → 仅去重这条,不影响类别权重
"内容质量差" → 全局降低该作品分数,影响所有用户
"不想看这个作者" → 屏蔽该作者的所有作品

长期效果:连续 3 次对健身类点"不感兴趣" → 该类别几乎不再推
恢复机制:若小美某天主动搜索"瑜伽" → 系统重新为健身类加回权重

五、PostgreSQL MVP 数据库设计

5.1 核心表结构

-- ========== 用户表 ==========
CREATE TABLE users (
    user_id     VARCHAR(20) PRIMARY KEY,
    nickname    VARCHAR(50) NOT NULL,
    age         SMALLINT,
    gender      VARCHAR(4),            -- '男','女','未知'
    city        VARCHAR(50),
    tags        TEXT[],                 -- PostgreSQL 数组类型: {'美食','探店','穿搭'}
    register_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- ========== 作品表 ==========
CREATE TABLE items (
    item_id     VARCHAR(20) PRIMARY KEY,
    title       VARCHAR(200) NOT NULL,
    category    VARCHAR(50) NOT NULL,
    author      VARCHAR(100) NOT NULL,
    tags        TEXT[],                 -- {'美食','火锅','成都','探店'}
    likes       INT DEFAULT 0,
    collects    INT DEFAULT 0,
    comments    INT DEFAULT 0,
    hot_score   DOUBLE PRECISION DEFAULT 0,  -- 热度分(定时计算)
    published_at TIMESTAMPTZ DEFAULT NOW(),
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- ========== 行为日志表 ==========
CREATE TABLE behavior_logs (
    id          BIGSERIAL PRIMARY KEY,
    user_id     VARCHAR(20) NOT NULL,
    item_id     VARCHAR(20) NOT NULL,
    action      VARCHAR(20) NOT NULL,   -- 'expose','click','like','collect','comment','share','dislike'
    duration_ms INT DEFAULT 0,          -- 停留时长(毫秒)
    context     VARCHAR(20),            -- 'feed','search','related','category'
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 按时间分区(大数据量时性能关键)
CREATE INDEX idx_behavior_user_time ON behavior_logs (user_id, created_at DESC);
CREATE INDEX idx_behavior_item ON behavior_logs (item_id);

-- ========== 用户-物品评分表(预计算,定时更新) ==========
CREATE TABLE user_item_scores (
    user_id     VARCHAR(20) NOT NULL,
    item_id     VARCHAR(20) NOT NULL,
    score       DOUBLE PRECISION NOT NULL, -- 隐式评分: click=1, like=2, collect=3, comment=4
    updated_at  TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (user_id, item_id)
);

-- ========== 用户关系表 ==========
CREATE TABLE user_follows (
    follower_id VARCHAR(20) NOT NULL,
    followee_id VARCHAR(20) NOT NULL,    -- followee 可以是用户也可以是作者名
    follow_type VARCHAR(10) DEFAULT 'author',  -- 'author','user','friend'
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (follower_id, followee_id)
);

-- ========== 协同过滤相似度表(定时计算) ==========
CREATE TABLE user_similarity (
    user_a      VARCHAR(20) NOT NULL,
    user_b      VARCHAR(20) NOT NULL,
    similarity  DOUBLE PRECISION NOT NULL,  -- 余弦相似度
    updated_at  TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (user_a, user_b)
);

CREATE TABLE item_similarity (
    item_a      VARCHAR(20) NOT NULL,
    item_b      VARCHAR(20) NOT NULL,
    similarity  DOUBLE PRECISION NOT NULL,
    updated_at  TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (item_a, item_b)
);

-- ========== 矩阵分解隐因子表(模型训练后写入) ==========
CREATE TABLE user_factors (
    user_id     VARCHAR(20) PRIMARY KEY,
    factors     DOUBLE PRECISION[] NOT NULL,  -- k 维向量: {0.518, 0.332, ...}
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE item_factors (
    item_id     VARCHAR(20) PRIMARY KEY,
    factors     DOUBLE PRECISION[] NOT NULL,
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- ========== 负反馈表 ==========
CREATE TABLE user_dislikes (
    user_id     VARCHAR(20) NOT NULL,
    item_id     VARCHAR(20),             -- NULL 表示屏蔽作者
    author      VARCHAR(100),            -- 屏蔽的作者
    reason      VARCHAR(30),             -- 'not_interested','seen_similar','low_quality','block_author'
    category    VARCHAR(50),             -- 不感兴趣的类别
    created_at  TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_dislike_user ON user_dislikes (user_id);

-- ========== 曝光去重表(避免重复推荐) ==========
CREATE TABLE user_exposures (
    user_id     VARCHAR(20) NOT NULL,
    item_id     VARCHAR(20) NOT NULL,
    exposed_at  TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_exposure_user_time ON user_exposures (user_id, exposed_at DESC);
-- 定期清理 7 天前的曝光记录
-- DELETE FROM user_exposures WHERE exposed_at < NOW() - INTERVAL '7 days';

5.2 插入示例数据

-- 用户
INSERT INTO users (user_id, nickname, age, gender, city, tags) VALUES
('U1001', '小美', 24, '女', '成都', '{"美食","探店","穿搭"}'),
('U1002', '老王', 35, '男', '北京', '{"科技","数码","编程"}'),
('U1003', '阿杰', 28, '男', '广州', '{"健身","美食","旅行"}'),
('U1004', '小雪', 22, '女', '上海', '{"穿搭","美妆","追星"}'),
('U1005', '大叔', 42, '男', '深圳', '{"钓鱼","汽车","美食"}');

-- 作品
INSERT INTO items (item_id, title, category, author, tags, likes, collects, comments,
                   published_at) VALUES
('I2001', '成都Top10火锅测评', '美食', '吃货达人',
 '{"美食","火锅","成都","探店"}', 1200, 450, 89, NOW() - INTERVAL '3 hours'),
('I2002', 'iPhone17全面评测', '科技', '数码博主',
 '{"科技","数码","iPhone","评测"}', 3500, 800, 220, NOW() - INTERVAL '6 hours'),
('I2003', '三亚5天自由行攻略', '旅行', '旅行家',
 '{"旅行","三亚","攻略","海边"}', 2800, 1500, 156, NOW() - INTERVAL '12 hours'),
('I2004', '今秋必买的5件外套', '穿搭', '时尚编辑',
 '{"穿搭","秋装","外套","时尚"}', 800, 600, 45, NOW() - INTERVAL '2 hours'),
('I2005', '家庭健身30天计划', '健身', '健身教练',
 '{"健身","减脂","家庭","计划"}', 1500, 900, 78, NOW() - INTERVAL '24 hours'),
('I2006', '重庆小面做法教程', '美食', '厨房达人',
 '{"美食","做饭","重庆","面食"}', 600, 350, 30, NOW() - INTERVAL '8 hours'),
('I2007', '2026年最值得买的手机', '科技', '数码博主',
 '{"科技","手机","排行","推荐"}', 8000, 2000, 500, NOW() - INTERVAL '48 hours'),
('I2008', '广州早茶必吃榜', '美食', '吃货达人',
 '{"美食","早茶","广州","探店"}', 900, 400, 60, NOW() - INTERVAL '5 hours'),
('I2009', '秋季显瘦穿搭公式', '穿搭', '时尚博主',
 '{"穿搭","显瘦","秋装","女装"}', 300, 200, 15, NOW() - INTERVAL '1 hour'),
('I2010', '野钓鲫鱼技巧分享', '钓鱼', '钓鱼老李',
 '{"钓鱼","鲫鱼","野钓","户外"}', 400, 250, 35, NOW() - INTERVAL '10 hours'),
('I2011', 'React vs Vue 2026对比', '编程', '程序员',
 '{"编程","前端","React","Vue"}', 1100, 700, 120, NOW() - INTERVAL '20 hours'),
('I2012', '周杰伦演唱会现场', '娱乐', '追星girls',
 '{"娱乐","周杰伦","演唱会","追星"}', 5000, 1200, 350, NOW() - INTERVAL '4 hours');

-- 行为日志
INSERT INTO behavior_logs (user_id, item_id, action, duration_ms, context) VALUES
('U1001', 'I2001', 'like', 45000, 'feed'),
('U1001', 'I2001', 'collect', 45000, 'feed'),
('U1001', 'I2006', 'like', 30000, 'feed'),
('U1001', 'I2004', 'collect', 60000, 'feed'),
('U1001', 'I2009', 'click', 20000, 'feed'),
('U1002', 'I2002', 'like', 120000, 'feed'),
('U1002', 'I2002', 'collect', 120000, 'feed'),
('U1002', 'I2002', 'comment', 120000, 'feed'),
('U1002', 'I2007', 'like', 90000, 'feed'),
('U1002', 'I2011', 'collect', 80000, 'feed'),
('U1003', 'I2005', 'like', 50000, 'feed'),
('U1003', 'I2005', 'collect', 50000, 'feed'),
('U1003', 'I2008', 'like', 35000, 'feed'),
('U1003', 'I2003', 'collect', 55000, 'feed'),
('U1004', 'I2004', 'like', 40000, 'feed'),
('U1004', 'I2012', 'like', 90000, 'feed'),
('U1004', 'I2012', 'collect', 90000, 'feed'),
('U1004', 'I2012', 'comment', 90000, 'feed'),
('U1005', 'I2010', 'like', 60000, 'feed'),
('U1005', 'I2010', 'collect', 60000, 'feed'),
('U1005', 'I2001', 'like', 40000, 'feed');

-- 关注关系
INSERT INTO user_follows (follower_id, followee_id, follow_type) VALUES
('U1001', '吃货达人', 'author'),
('U1001', '时尚编辑', 'author'),
('U1002', '数码博主', 'author'),
('U1002', '程序员', 'author'),
('U1003', '健身教练', 'author'),
('U1003', '吃货达人', 'author'),
('U1004', '时尚博主', 'author'),
('U1004', '追星girls', 'author'),
('U1001', 'U1004', 'friend'),
('U1004', 'U1001', 'friend');

5.3 核心查询——各路召回的 SQL 实现

-- ========== 1. 标签匹配召回 ==========
-- 利用 PostgreSQL 数组交集 & 运算符
SELECT i.item_id, i.title, i.category,
       ARRAY_LENGTH(i.tags & u.tags, 1) AS match_count,
       -- Jaccard = 交集 / 并集
       ARRAY_LENGTH(i.tags & u.tags, 1)::FLOAT
         / ARRAY_LENGTH(i.tags | u.tags, 1) AS jaccard
FROM items i
CROSS JOIN (SELECT tags FROM users WHERE user_id = 'U1001') u
WHERE i.tags && u.tags  -- && = 有交集
  AND i.item_id NOT IN (
      SELECT item_id FROM user_exposures
      WHERE user_id = 'U1001' AND exposed_at > NOW() - INTERVAL '24 hours'
  )
ORDER BY jaccard DESC
LIMIT 200;


-- ========== 2. 协同过滤召回(User-Based) ==========
-- 找相似用户喜欢的、目标用户没看过的
SELECT DISTINCT bl.item_id, us.similarity
FROM user_similarity us
JOIN behavior_logs bl ON bl.user_id = us.user_b
  AND bl.action IN ('like', 'collect', 'comment')
WHERE us.user_a = 'U1001'
  AND bl.item_id NOT IN (
      SELECT item_id FROM behavior_logs WHERE user_id = 'U1001'
  )
ORDER BY us.similarity DESC
LIMIT 200;


-- ========== 3. 热度召回 ==========
-- 热度分定时计算好存在 items.hot_score 字段
SELECT item_id, title, hot_score
FROM items
WHERE published_at > NOW() - INTERVAL '72 hours'
  AND item_id NOT IN (
      SELECT item_id FROM user_exposures
      WHERE user_id = 'U1001' AND exposed_at > NOW() - INTERVAL '24 hours'
  )
ORDER BY hot_score DESC
LIMIT 100;


-- ========== 4. 关注作者召回 ==========
SELECT i.item_id, i.title, i.author
FROM items i
JOIN user_follows uf ON uf.followee_id = i.author
WHERE uf.follower_id = 'U1001'
  AND uf.follow_type = 'author'
  AND i.published_at > NOW() - INTERVAL '7 days'
ORDER BY i.published_at DESC
LIMIT 50;


-- ========== 5. 矩阵分解预测分(向量点积) ==========
-- PostgreSQL 中计算两个数组的点积
CREATE OR REPLACE FUNCTION dot_product(a DOUBLE PRECISION[], b DOUBLE PRECISION[])
RETURNS DOUBLE PRECISION AS $$
DECLARE
    result DOUBLE PRECISION := 0;
BEGIN
    FOR idx IN 1..ARRAY_LENGTH(a, 1) LOOP
        result := result + a[idx] * b[idx];
    END LOOP;
    RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

-- 对候选集中的每个 item 计算预测评分
SELECT if2.item_id,
       dot_product(uf.factors, if2.factors) AS predicted_score
FROM user_factors uf
CROSS JOIN item_factors if2
WHERE uf.user_id = 'U1001'
  AND if2.item_id = ANY(ARRAY['I2003','I2005','I2008','I2012'])  -- 候选 item 列表
ORDER BY predicted_score DESC;


-- ========== 6. 热度分定时更新(Job: 每 10 分钟) ==========
UPDATE items SET
    hot_score = (likes + 2.0 * collects + 3.0 * comments)
                / POWER(EXTRACT(EPOCH FROM NOW() - published_at)/3600 + 2, 1.5),
    updated_at = NOW()
WHERE published_at > NOW() - INTERVAL '7 days';


-- ========== 7. 隐式评分更新(Job: 每小时) ==========
INSERT INTO user_item_scores (user_id, item_id, score)
SELECT user_id, item_id,
       SUM(CASE action
           WHEN 'click'   THEN 1
           WHEN 'like'    THEN 2
           WHEN 'collect' THEN 3
           WHEN 'comment' THEN 4
           WHEN 'share'   THEN 5
           ELSE 0
       END) AS score
FROM behavior_logs
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY user_id, item_id
ON CONFLICT (user_id, item_id)
DO UPDATE SET score = EXCLUDED.score, updated_at = NOW();

5.4 API 接口设计

推荐服务需要对外暴露的接口:

接口触发时机说明
GET /api/recommend/feed?userId=&page=&size=打开首页 / 下拉刷新信息流推荐(最核心)
GET /api/recommend/related?userId=&itemId=&size=查看作品详情页相关推荐 / "猜你喜欢"
GET /api/recommend/search?userId=&query=&page=搜索框提交搜索结果个性化排序
GET /api/recommend/category?userId=&category=&page=进入分类页类别下个性化排序
POST /api/recommend/feedback点击"不感兴趣"负反馈处理
POST /api/behavior/log每次用户交互行为埋点上报
行为埋点 Body 示例:
{
  "userId": "U1001",
  "itemId": "I2008",
  "action": "like",
  "durationMs": 45000,
  "context": "feed",
  "timestamp": "2026-03-28T14:30:00Z"
}

六、数据流转全景

从用户操作到推荐结果的完整数据链路:

用户在 App 上的操作
  │ 实时上报行为日志
  ▼
┌──────────────────┐  ┌──────────────────────────────┐
│ behavior_logs 表 │──→│ 定时特征计算任务 │
│ (PostgreSQL) │  │ │
│ 曝光/点击/点赞/ │  │ · 隐式评分更新(每小时) │
│ 收藏/评论/停留 │  │ · 热度分更新(每10分钟) │
└──────────────────┘  │ · 用户/物品相似度(每天) │
                      └──────────────┬───────────────┘
                                   │ 特征数据
                                   ▼
                      ┌──────────────────────────────┐
                      │ 模型训练(每天凌晨) │
                      │ │
                      │ 1. 矩阵分解 → user/item_factors│
                      │ 2. LightGBM 排序模型 │
                      │ 3. 回写 PostgreSQL │
                      └──────────────┬───────────────┘
                                   │ 模型/因子
                                   ▼
                      ┌──────────────────────────────┐
                      │ 在线推荐服务 │
                      │ │
                      │ 召回 → 粗排 → 精排 → 重排 │
                      │ 响应时间目标: < 100ms │
                      └──────────────────────────────┘

七、推荐系统评估指标

离线指标(模型训练时评估):
Precision@K = 推荐的 K 个中用户实际点击的比例
Recall@K = 用户实际点击的物品被推荐出来的比例
NDCG@K = 考虑排序位置的增益(排在前面加分更多)
AUC = 正样本排在负样本前面的概率

在线指标(A/B 测试时观察):
CTR = 点击次数 / 曝光次数(核心指标)
人均使用时长(ultimate 指标)
点赞率、收藏率、评论率(互动深度)
内容多样性(覆盖的类别数 / 总类别数)
NDCG@5 计算示例:

推荐 5 个作品给小美,实际她点击了位置 1, 3, 5 的作品

DCG@5 = rel₁/log₂(2) + rel₂/log₂(3) + rel₃/log₂(4) + rel₄/log₂(5) + rel₅/log₂(6)
= 1/1 + 0/1.585 + 1/2 + 0/2.322 + 1/2.585
= 1 + 0 + 0.5 + 0 + 0.387 = 1.887

理想排序 IDCG@5(把 3 个正样本排最前)= 1/1 + 1/1.585 + 1/2 + 0 + 0
= 1 + 0.631 + 0.5 = 2.131

NDCG@5 = DCG/IDCG = 1.887/2.131 = 0.886
(0.886 > 0.8 算不错的推荐质量)

已阅读当前小节,可返回首页继续浏览其它主题。