从 Embedding Demo 到生产级推荐系统
很多推荐系统文章,一上来就是:
- 协同过滤
- 深度学习
- CTR / CVR 预估
- Wide&Deep / DIN / Transformer
看完你只有一个感觉:
我现在就算想做,也完全不知道第一步该干嘛。
但真实世界里,推荐系统几乎从来不是一开始就复杂的。
它通常从一个非常简单的版本开始,然后一年一年慢慢长大。
这篇文章不讲算法推导,只讲一件事:
如果你今天要做推荐系统,可以怎样一步一步把它做出来。
我们从 最小可用版本(MVP) 开始。
0️⃣ 推荐系统最小闭环
无论多复杂的推荐系统,本质只有一条链路:
text
用户 → 用户向量
内容 → 内容向量
相似度 → 召回
排序 → 返回
所有推荐系统,本质只是在不断优化三件事:
1️⃣ 内容如何向量化(Embedding)
2️⃣ 如何找到"可能喜欢的内容"(Recall)
3️⃣ 如何从候选中挑最好的(Rank)
我们先做一个 完全不依赖用户行为 的推荐系统。
1️⃣ 第一版:只有 Embedding 的推荐系统
这是几乎所有推荐系统的起点。
没有点击
没有用户画像
没有模型训练
只有一句话:
找和用户当前想看的内容最像的内容
换句话说:
语义搜索 = 推荐系统 v1
很多公司第一版推荐,就是这样上线的。
1.1 内容准备
假设我们做一个类似小红书的信息流:
python
contents = [
{"id":1, "text":"日本东京旅行攻略|涩谷+浅草一日路线"},
{"id":2, "text":"MacBook 提升效率的10个工具"},
{"id":3, "text":"新手如何开始健身"},
{"id":4, "text":"上海咖啡馆探店合集"},
{"id":5, "text":"Python 自动化办公技巧"},
]
1.2 内容向量化(Content Embedding)
第一步:把所有内容变成向量。
python
from sentence_transformers import SentenceTransformer, CrossEncoder
bi_encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
doc_vectors = bi_encoder.encode(
[c["text"] for c in contents],
normalize_embeddings=True
)
数据库现在变成:
text
内容 → 向量(768维)
这一步在真实公司中不是脚本,而是:
一个长期运行的离线 pipeline
通常包含:
- 新内容实时入库
- 批量重新 embedding
- 向量版本管理
- 索引定期重建
1.3 建立向量索引(Vector Index)
使用 FAISS 构建向量检索:
python
import faiss
dim = doc_vectors.shape[1]
index = faiss.IndexFlatIP(dim) # 使用 Inner Product(点积)
index.add(doc_vectors)
很多人第一次看到这里会问:
为什么用 点积(Inner Product) ,而不是 Cosine 相似度?
这背后其实是向量检索里一个非常重要的知识点。
1️⃣ 三种最常见的向量相似度
向量检索通常有三种距离/相似度:
| 方法 | 直觉理解 | FAISS 支持 |
|---|---|---|
| L2 距离 | 空间距离 | IndexFlatL2 |
| 点积(Inner Product) | 方向 + 长度 | IndexFlatIP |
| Cosine 相似度 | 只看方向 | (可用IP实现) |
推荐系统 & 语义搜索中最常用的是:
Cosine 相似度
因为我们关心的是:
语义方向是否一致,而不是向量有多长。
2️⃣ Cosine 相似度在计算什么?
Cosine 相似度本质是:
text
cos(θ) = A·B / (|A| |B|)
它衡量的是:
两个向量夹角的余弦值
直觉理解:
| 角度 | Cosine | 含义 |
|---|---|---|
| 0° | 1 | 非常相似 |
| 90° | 0 | 无关 |
| 180° | -1 | 完全相反 |
所以:
text
东京旅游 → 日本旅行攻略 ✔
东京旅游 → Python教程 ✖
我们只关心 方向一致。
3️⃣ 为什么工程里不用直接算 Cosine?
看公式:
text
cos(θ) = A·B / (|A| |B|)
它包含三步计算:
1️⃣ 计算点积
2️⃣ 计算向量长度
3️⃣ 再做除法
这在大规模检索中 非常慢。
而 FAISS 的设计目标是:
在千万级向量中毫秒级搜索
所以工程上做了一个非常巧妙的优化:
4️⃣ 关键技巧:向量归一化
在 embedding 时我们做了这一步:
python
normalize_embeddings=True
这一步会把所有向量变成:
python
|A| = 1
|B| = 1
也叫 L2 Normalization。
此时 Cosine 公式变成:
css
cos(θ) = A·B
🎉 Cosine 相似度 = 点积
这就是为什么我们可以使用:
python
faiss.IndexFlatIP
来实现 Cosine 搜索。
5️⃣ 一句话总结工程做法
真实系统中几乎都会采用:
text
先做向量归一化 → 再用点积检索
原因只有一个:
点积检索可以被高度优化,速度极快
这就是推荐系统里一个非常经典的小技巧:
text
Cosine 相似度(理论)
=
Normalized 向量 + 点积(工程实现)
6️⃣ 向量长度其实是"强度信号"
前面我们做了向量归一化:
python
normalize_embeddings=True
这一步会把所有向量长度变成 1。
但你可能会问:
那向量的"长度"还有用吗?
答案是:
非常有用,只是被我们暂时隐藏了。
向量的两部分信息
一个向量其实包含两种信息:
text
向量 = 方向(兴趣类型) + 长度(兴趣强度)
| 组成 | 表示什么 |
|---|---|
| 方向 | 喜欢什么 |
| 长度 | 喜欢程度 |
Cosine 相似度只使用:
text
方向
但在真实推荐系统里:
强度信息非常重要
7️⃣ 深度用户 vs 轻度用户
想象两个用户:
text
用户A:每天刷 2 小时
用户B:每周刷 10 分钟
他们的兴趣方向可能完全一样:
text
都喜欢:旅游 + 健身
但对平台来说,这两种用户完全不同:
| 用户类型 | 商业价值 |
|---|---|
| 深度用户 | 高留存、高广告价值 |
| 轻度用户 | 容易流失 |
如果我们不做归一化,用户向量通常这样生成:
text
user_vec = sum(clicked_item_vectors)
那么:
python
用户A 向量长度 >> 用户B 向量长度
向量长度自然就变成了:
用户活跃度 / 兴趣强度
8️⃣ 广告系统特别依赖"向量长度"
在广告推荐里,长度甚至可能代表:
text
付费能力
转化概率
商业价值
举个典型场景:
广告系统的目标不是点击,而是:
text
eCPM = CTR × CVR × 出价
如果两个用户兴趣一样:
| 用户 | Cosine 相似度 | 向量长度 |
|---|---|---|
| 用户A | 高 | 很长 |
| 用户B | 高 | 很短 |
广告系统更愿意把广告给谁?
向量更长的用户
因为他更可能:
- 停留更久
- 点击更多
- 产生转化
所以在广告排序中常见做法是:
text
score = 向量点积 × 用户价值系数
甚至直接使用 未归一化向量做点积。
9️⃣ 推荐系统里的一个经典取舍
现在你会看到一个很真实的工程选择:
| 场景 | 是否归一化 |
|---|---|
| 语义搜索 / 内容召回 | ✅ 归一化(只看兴趣方向) |
| 广告 / 商业排序 | ❌ 不归一化(需要兴趣强度) |
这也是为什么很多推荐系统会同时保存:
text
normalized embedding
raw embedding
一个用于 召回 ,一个用于 排序 / 商业化。
🔚 小总结
text
Cosine 相似度 → 找"像不像"
向量长度 → 衡量"有多重要"
这就是向量在推荐系统里的完整信息。
现在你已经拥有了一个:
毫秒级语义搜索引擎
1.4 用户 Query → 推荐
用户打开 App 输入:
python
query = "东京旅游"
系统执行:
python
query_vec = bi_encoder.encode([query], normalize_embeddings=True)
scores, ids = index.search(query_vec, k=3)
返回:
东京旅行攻略
上海咖啡馆
健身入门
🎉 恭喜,你已经上线了第一个推荐系统。
虽然它现在叫:
语义召回系统
但很多公司第一版推荐就是这样上线的。
2️⃣ 第二版:加入用户行为(真正的推荐开始)
第一版的问题非常明显:
它只知道用户"现在想搜什么"
不知道用户"平时喜欢什么"
于是推荐系统第一次发生质变:
引入用户向量(User Embedding)
2.1 用户行为数据开始出现
最重要的数据登场:
text
曝光 impression
点击 click
点赞 like
收藏 favorite
停留时长 dwell time
示例:
text
用户A:
点击:东京旅行、上海咖啡馆
点赞:东京旅行
长停留:健身入门
推荐系统第一次开始"理解用户"。
2.2 用行为生成用户向量
最简单但极其有效的方法:
用户向量 = 看过内容向量的平均值
python
def build_user_vector(history_ids):
vecs = [doc_vectors[i] for i in history_ids]
return np.mean(vecs, axis=0)
用户A向量 ≈
text
旅行 + 咖啡 + 健身 的兴趣融合
这是推荐系统的第一个核心里程碑。
前面我们写了一个最简单版本:
python
user_vec = mean(clicked_items)
但真实系统不会把所有行为看成一样重要。
因为在推荐系统里:
不同用户行为 = 不同强度的喜欢
1️⃣ 行为埋点是怎么收集的?
客户端会埋点上报用户行为,例如:
python
user_behaviors = [
{"user": "user_A", "content_id": 7, "action": "click"},
{"user": "user_A", "content_id": 10, "action": "like"},
{"user": "user_A", "content_id": 13, "action": "like"},
{"user": "user_A", "content_id": 15, "action": "collect"},
{"user": "user_A", "content_id": 19, "action": "click"},
{"user": "user_A", "content_id": 23, "action": "collect"},
]
这些数据每天都会源源不断进入数据仓库。
这一步叫:
text
User Behavior Logging
推荐系统真正的燃料开始出现了。
2️⃣ 不同行为代表不同"喜欢程度"
直觉上:
| 行为 | 喜欢程度 |
|---|---|
| 曝光 | 很弱 |
| 点击 | 有兴趣 |
| 点赞 | 比较喜欢 |
| 收藏 | 非常喜欢 |
| 分享 | 超级喜欢 |
所以工程中第一件事就是:
给每种行为设定权重
一个典型的权重表:
python
ACTION_WEIGHT = {
"impression": 0.1,
"click": 1,
"like": 3,
"collect": 5,
"share": 8,
}
注意:
这些权重通常来自 经验 + A/B 实验。
3️⃣ 加权用户向量(Weighted User Embedding)
现在我们不再简单平均,而是做加权求和:
python
def build_user_vector(behaviors):
vecs = []
weights = []
for b in behaviors:
item_vec = doc_vectors[b["content_id"]]
w = ACTION_WEIGHT[b["action"]]
vecs.append(item_vec * w)
weights.append(w)
return np.sum(vecs, axis=0) / np.sum(weights)
用户向量现在变成:
text
用户兴趣 = Σ(内容向量 × 行为权重) / 权重和
这一步非常关键:
用户向量开始包含"兴趣强度"。
4️⃣ 为什么"收藏"权重很高?
想象两个用户:
text
用户A:点了10次旅游
用户B:收藏了1篇旅游
哪个更喜欢旅游?
大多数情况下:
收藏 > 多次点击
因为收藏意味着:
- 愿意以后再看
- 有长期价值
- 兴趣更确定
这就是推荐系统里经典的一句话:
text
显式反馈 > 隐式反馈
| 类型 | 行为 |
|---|---|
| 显式反馈 | like / collect / share |
| 隐式反馈 | click / dwell |
5️⃣ 加入时间衰减(非常重要)
再进一步,工程里几乎一定会加入:
时间衰减(Time Decay)
因为:
三个月前喜欢 ≠ 今天喜欢
典型做法:
python
import math
def time_decay(days):
return math.exp(-days / 30) # 半衰期≈30天
最终真实用户向量更像这样:
scss
weight = ACTION_WEIGHT[action] * time_decay(days)
用户向量开始变得又懂强度,又懂时间。
🔚 小总结
用户向量 ≠ 点击平均值
用户向量 = 行为强度 × 时间权重 × 内容向量
从这一刻起,推荐系统真正开始"理解用户"。
2.3 首页推荐诞生
推荐逻辑发生关键变化:
| v1 | v2 |
|---|---|
| query | user_vector |
| 搜索 | 首页推荐 |
python
scores, ids = index.search(user_vec, k=20)
推荐系统真正诞生。
3️⃣ 第三版:召回不够了 → Rank 出现
问题来了:
向量搜索返回的 Top20
并不一定最适合展示
原因:
相似 ≠ 会点击
于是推荐系统进入经典架构:
text
Recall → Rank
3.1 召回层(Recall)
目标只有一个:
从千万内容 → 找出 200 条候选
特点:
- 快
- 粗
- 只负责"可能喜欢"
常见召回方式:
| 召回方式 | 说明 |
|---|---|
| 向量召回 | embedding 相似 |
| 热门召回 | 当前热门 |
| 关注召回 | 关注的人发的 |
| 协同过滤 | 相似用户喜欢 |
真实系统是 多路召回:
text
向量召回
热门召回
关注召回
协同过滤召回
最后:
text
多路召回 → merge → 200条候选
3.2 排序层(Rank)
排序层才真正回答:
用户会不会点?
输入特征:
text
用户特征:
年龄 / 兴趣标签 / 活跃度
内容特征:
类别 / 热度 / 发布时间
上下文特征:
时间 / 设备 / 网络
输出:
python
P(click)
第一代排序模型通常是:
text
LR / GBDT / Wide&Deep
python
score = rank_model.predict(user, item, context)
上线后 CTR 常见提升:
+30% ~ +80%
4️⃣ 第四版:用户兴趣是动态的
昨天你在看旅游
今天你在看 MacBook
兴趣不是固定的。
于是系统继续进化:
长期兴趣 + 短期兴趣
4.1 长期兴趣(Long-term)
来自:
text
过去3个月行为
稳定。
4.2 短期兴趣(Session)
来自:
text
最近10次点击
最近30分钟行为
非常重要。
工程中常见做法:
python
user_vector = 0.7 * short_term + 0.3 * long_term
推荐开始变得"像读心术"。
5️⃣ 第五版:真正的生产级推荐系统
到这里,系统已经能长期运行并持续迭代。
真实推荐系统通常长这样:
离线层:
内容 embedding pipeline
用户 embedding pipeline
模型训练
特征仓库
在线层:
用户请求 →
多路召回 →
特征服务 →
排序模型 →
重排 →
返回
继续进化的方向:
- 探索 vs 利用(避免信息茧房)
- 冷启动(新用户 / 新内容)
- 多目标优化(点击 + 停留 + 转化)
- A/B 实验平台
一个成熟推荐系统:
通常需要 1--2 年持续演进
结尾
推荐系统从来不是从复杂开始的。
它通常从一句非常朴素的话开始:
找相似内容 → 加入用户 → 加入排序 → 持续进化
从 embedding demo 到工业级系统,没有跳跃。
只有:
持续迭代。
下一步:AI 开始进入推荐系统
到这里,我们已经把推荐系统从一个 embedding demo,一步一步做成了一个可以长期运行的工程系统:
- 有用户行为
- 有用户向量
- 有多路召回
- 有排序模型
其实,很多公司的推荐系统第一阶段就停在这里,并持续优化很多年。
但当系统继续发展,会慢慢遇到新的问题:
- 人工设计的特征越来越多,越来越难维护
- 排序模型效果提升开始变慢
- 召回策略越来越复杂
这时,一个新的阶段就会自然出现:
AI 开始逐渐进入推荐系统。
或许下一篇我们会聊:
《推荐系统是如何逐渐引入 AI 的》
从传统工程推荐,走向真正的 AI 推荐。