一个推荐系统是如何“长大”的(工程演进)

发布时间:2026/5/21 21:53:28

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

相关新闻