
1. 项目概述从双塔到DSSM推荐系统的基石演进如果你在推荐系统、搜索广告或者内容匹配领域摸爬滚打过一阵子那么“双塔模型”这个词对你来说一定不陌生。它就像这个领域的“Hello World”简单、经典却又无处不在。今天我们不聊那些花里胡哨的复杂模型就从一个最基础、最核心的模型说起——微软的DSSMDeep Structured Semantic Models也就是后来被大家广泛称为“双塔模型”的鼻祖。这个模型在2013年由微软研究院提出初衷是为了解决搜索中的语义匹配问题比如让搜索引擎理解“苹果手机”和“iPhone”在语义上是相近的。后来大家发现这个架构简直是“万金油”尤其是在推荐系统中用来做用户和物品的匹配效果出奇地好结构又清晰于是它就火了。但DSSM有个“先天不足”这也是标题里点出的“无特征交互”。简单说它的两个“塔”用户侧和物品侧是独立学习的只在最后一步才碰面算个相似度。这就像两个人在相亲前各自在家准备了一大堆自我介绍学历、收入、爱好但直到见面那一刻才真正开始交流中间缺少了“你爱看电影我正好是影评人”这种提前的、深入的“特征交叉”过程。这个缺陷在推荐场景下尤其是面对高维稀疏特征时会显得力不从心。后来像美团这样的公司在实际业务中深刻体会到了这一点并在此基础上做了大量改进让双塔模型真正在工业界的大规模推荐场景中落地生根。这篇文章我就以一个过来人的视角带你彻底拆解DSSM这个经典双塔模型。我们不仅会看懂它的原理和实现更重要的是我会结合自己踩过的坑详细分析它“无特征交互”这个核心局限到底意味着什么以及像美团这样的团队是如何通过工程和算法上的“组合拳”来弥补这个缺陷让它焕发第二春的。无论你是刚入门的新手还是想重温基础的老兵相信都能从中获得一些实实在在的启发。2. DSSM双塔模型的核心原理与架构拆解2.1 模型的设计哲学语义空间的向量化匹配DSSM的核心思想非常直观将不同来源的、异构的信息比如用户的搜索词和网页的标题正文映射到一个共同的、低维的语义向量空间。在这个空间里语义相近的实体它们的向量表示也彼此接近。衡量接近程度的方法通常就是计算两个向量的余弦相似度。为什么这个思想如此强大因为它解决了信息检索和推荐中的一个根本问题语义鸿沟。用户表达的需求查询词、点击行为和系统提供的物品文档、商品、视频之间在原始特征层面往往是异构的、稀疏的、高维的。直接匹配效率低下且效果差。DSSM通过深度学习网络将它们都压缩成一个稠密的、富有语义信息的向量通常称为“embedding”或“表征”匹配过程就从复杂的特征工程简化为高效的向量运算。这种“双塔”结构一边是用户塔User Tower负责学习用户表征另一边是物品塔Item Tower负责学习物品表征。两塔相互独立仅在顶层通过一个相似度函数进行交互。这种设计的优势非常明显服务性能极高线上推理时可以预先计算好所有物品的向量并存入向量数据库如Faiss, Milvus。当用户请求到来时只需实时计算一次用户向量然后通过近似最近邻搜索ANN快速找到最相似的物品。这完美契合了工业级推荐系统对低延迟、高并发的苛刻要求。灵活性好用户侧和物品侧的特征可以完全不同处理网络也可以根据数据特点独立设计。例如用户侧可能包含密集的统计特征和序列行为特征而物品侧则包含文本描述、多模态特征等。便于解耦和迭代可以单独优化用户塔或物品塔而不影响另一端。2.2 经典DSSM的网络结构详解我们以最经典的、用于搜索语义匹配的DSSM为例拆解其网络结构。原始论文中输入是字符级的n-gram这里我们将其泛化为更通用的特征处理流程。输入层Input Layer用户侧/查询侧Query原始输入是搜索词。论文中先将词转换为字符级的trigram3-gram例如“apple”会转换为“#ap”, “app”, “ppl”, “ple”, “le#”。这样做的好处是能缓解未登录词OOV问题并且向量空间大小约5万维远小于词表空间。在现代实践中更常用的是词嵌入Word Embedding或直接使用预训练模型如BERT的输出作为初始表示。物品侧/文档侧Document原始输入是网页的标题、URL和正文。处理方式与查询侧类似生成一个高维的稀疏向量。表示层Representation Layer 这就是“塔”的部分。输入的高维稀疏向量通过多个全连接层Dense Layer进行非线性变换和降维。Query - FC1 (激活函数) - FC2 (激活函数) - ... - Query Embedding (128维) Document - FC1 (激活函数) - FC2 (激活函数) - ... - Doc Embedding (128维)每一层通常使用tanh或ReLU作为激活函数。这个过程的本质是特征抽象和压缩将原始的、具体的特征逐步提炼为蕴含高级语义的稠密向量。匹配层Matching Layer 计算两个塔输出的向量之间的余弦相似度作为匹配得分。similarity(Q, D) cosine(Q_emb, D_emb) (Q_emb · D_emb) / (||Q_emb|| * ||D_emb||)余弦相似度的值域在[-1, 1]之间值越大表示越相关。损失函数Loss Function DSSM通常使用负采样对数损失Negative Sampling Log Loss也称为“Pairwise Loss”。对于一个查询Q有一个相关的正样本文档D和随机采样或精心构造的多个不相关负样本文档D-。模型的目标是最大化正样本对的相似度同时最小化负样本对的相似度。 常用的形式是Softmax交叉熵损失将Query与正样本D及所有负样本D-的相似度进行Softmax归一化然后最小化正样本的负对数似然。L -log( exp(sim(Q, D)) / (exp(sim(Q, D)) ∑ exp(sim(Q, D-))) )注意这里隐藏着一个关键点也是后续改进的源头。损失函数只作用于最终的相似度得分而相似度得分仅仅是两个独立向量的简单函数点积或余弦。这意味着在模型学习的过程中梯度只会通过这个得分反向传播回两个塔的顶层向量而无法促使两个塔在中间层特征上进行任何直接的、复杂的交互学习。这就是“无特征交互”的数学本质。2.3 “无特征交互”的利与弊为什么微软当初要设计这样一个“无交互”的架构除了前面提到的线上服务优势在模型训练上也有其考量训练效率高双塔独立可以使用“in-batch negative”等技巧进行高效训练。即在一个训练批次batch内将其他样本的物品自然地作为当前用户Query的负样本极大提升了负样本的利用效率和训练速度。缓解过拟合对于稀疏特征过早地进行复杂交叉容易引入噪声并导致过拟合。独立的塔结构相当于一种正则迫使模型先学习到各自侧稳健的个体表征。然而其弊端在推荐系统场景下被放大信息损失用户“年轻白领”和物品“便携咖啡机”之间的关联可能来自于“便携”适合通勤和“咖啡机”白领提神的隐性交叉。在双塔中“年轻白领”被编码成一个固定的向量它需要与所有物品咖啡机、书籍、衣服的向量都匹配良好这迫使用户向量必须是一个“通用”的表示无法针对特定类型的物品进行精细化调整导致一些细粒度的、动态的偏好信息丢失。冷启动问题一个新物品即使它的特征如“新潮设计”、“轻奢”与某类用户如“追求时尚的年轻人”高度相关但由于缺乏交互数据它的向量在训练初期是粗糙的很难通过简单的向量相似度被正确的用户发现。表达能力上限点积或余弦相似度是一种相对简单的匹配函数。对于“用户喜欢某物品”这种复杂决策其背后的逻辑可能涉及多特征的非线性、高阶组合。简单的向量内积难以刻画这种复杂关系。这就引出了下一个核心问题既然经典双塔有这些局限工业界是如何让它继续担当大任的3. 工业界进化美团对双塔模型的改进实践美团在推荐、广告、搜索等业务中深度应用并改进了双塔模型。他们的工作不是推翻重来而是在经典架构上做“加法”核心思路是在保持双塔线上高效服务优势的前提下尽可能在训练阶段引入更丰富的交互信息和更精细的结构设计。下面我结合公开资料和个人理解梳理几个关键的改进方向。3.1 改进一特征工程的深化与Embedding化经典DSSM的输入是简单的n-gram而现代推荐系统的特征是海量且多元的。美团的改进首先体现在特征处理上。1. 全域特征Embedding化用户侧不仅有人口属性年龄、性别、城市更有丰富的行为序列最近点击/购买/浏览的商品ID序列、搜索词序列、上下文特征时间、地理位置、网络环境、用户统计特征历史LTV、品类偏好。物品侧商品ID、品类ID、店铺ID、价格、销量、文本描述、多模态特征图片、视频。处理方式对于所有稀疏ID类特征UserID, ItemID, CategoryID全部通过Embedding层映射为稠密向量。对于序列特征使用Pooling如Sum Pooling、RNN如GRU或Transformer进行聚合。对于数值特征进行归一化或分桶后Embedding化。2. 引入交叉特征Cross Feature 虽然塔内无交互但可以在特征输入塔之前人工构造一些重要的交叉特征。例如将“用户历史点击的品类”和“当前物品的品类”进行交叉生成一个“用户-物品品类匹配度”的特征作为标量输入到塔中。这相当于将部分重要的特征交互判断前置到了特征工程阶段让模型有机会学到这种关联。实操心得这一步非常依赖业务经验。不要盲目交叉所有特征那会导致特征爆炸和过拟合。应该从业务逻辑出发思考哪些交叉是显而易见的强信号。例如在外卖推荐中“用户历史常点口味辣/甜”和“菜品口味”的交叉就至关重要。3.2 改进二训练技巧的革新1. 负样本构造的艺术 双塔模型的效果一半靠架构一半靠负样本。随机负采样太简单模型学不到精细的区分能力。美团在实践中会采用多种负样本混合策略随机负采样基础操作保证广泛性。Batch内负采样In-batch Negative高效但可能因为Batch内数据分布偏差导致“易负样本”过多。困难负样本挖掘Hard Negative Mining这是提升模型区分度的关键。例如对于点击了“iPhone 15”的用户不仅用“洗衣机”做负样本更要用“华为Mate 60”或“小米14”这类同品类、同价位段的竞品做负样本。迫使模型学习更细微的差异。通常会在训练过程中动态地从上一轮模型的召回结果中选取那些得分高但未被点击的物品作为困难负样本。2. 多任务学习Multi-Task Learning, MTL 这是弥补“无特征交互”导致信息损失的一剂猛药。单一的点击率CTR预估任务可能无法学习到用户完整、多面的兴趣。美团会引入多个辅助任务共同训练双塔。主任务点击/转化预估。辅助任务可以是停留时长预测、点赞/收藏预测、完播率预测视频、购买品类预测等。 这些任务共享用户塔和物品塔的底层参数但顶层有各自的小塔Tower或直接使用主塔的向量。通过多任务学习用户和物品的向量被迫编码更多元、更通用的信息从而提升了主任务的效果和模型的泛化能力。3. 序列建模增强用户塔 用户的行为序列是动态兴趣的最佳体现。简单的Pooling会丢失序列信息和长期依赖。美团会在用户塔中引入更强大的序列模型使用Transformer/GRU等结构对用户行为序列物品ID序列、品类序列进行建模得到融合了时序信息的用户兴趣表征。结合注意力机制Target Attention在计算用户向量时让用户的历史行为序列对当前候选物品产生注意力。例如当前候选是一个“芝士蛋糕”那么用户历史中“甜品”、“烘焙”相关的行为权重会自动增大。这就在用户塔内部实现了一种软性的、动态的特征交互用户向量不再是静态的而是随着候选物品的不同而微调。3.3 改进三模型结构的微创新在保持双塔主体结构不变的前提下对塔本身或交互方式进行微调。1. 塔结构的复杂化 经典DSSM的塔是几层全连接。现在可以根据特征类型设计更复杂的子网络。例如用户特征可以分成几组画像组、统计组、序列组。每组特征先通过一个子网络Sub-network进行初步融合然后再将各组的输出拼接起来送入塔的后续层。这相当于在塔内做了特征分组和早期融合提升了特征表达能力。2. 交互函数的升级 将简单的余弦相似度或点积替换为更复杂的匹配函数。双线性匹配Bilinearscore u_emb * W * i_emb其中W是一个可学习的参数矩阵。这相当于在交互时引入了一个转换空间比点积更灵活。基于神经网络的匹配器在双塔输出向量后不直接计算相似度而是将两个向量拼接concat或按位乘element-wise product等操作后送入一个小的多层感知机MLP来预测得分。这个MLP就是一个小型的“交互网络”。interaction_input concat(u_emb, i_emb, u_emb * i_emb) # 常见操作 score MLP(interaction_input)这种方式在训练阶段引入了复杂的特征交互能够捕捉非线性、高阶的组合关系。但它的代价是线上服务时无法将用户和物品向量分离计算必须实时进行联合推理这会极大增加线上延迟违背了双塔的初衷。重要提示这就是工业界面临的经典权衡Trade-off。美团的策略通常是“训练和推理解耦”训练阶段使用复杂的交互网络如MLP充分学习特征交互追求最优的模型表达能力。推理/服务阶段丢弃顶层的交互MLP只保留双塔部分。将用户塔和物品塔输出的向量即u_emb和i_emb分别存为索引。线上用简单的点积或余弦相似度进行快速检索。核心思想让双塔输出的向量u_emb和i_emb本身就蕴含了通过复杂交互网络学习到的“交互知识”。这样既在训练时利用了交互又保持了线上服务的高效。这需要精心设计训练任务和损失函数确保简单点积与复杂MLP打分的一致性。4. 从理论到实践构建一个可用的双塔召回模型理解了原理和改进方向我们动手搭建一个简化但完整的双塔召回模型这里以电商商品推荐为例。4.1 数据准备与特征定义假设我们有以下数据用户特征user_id稀疏age数值gender稀疏city稀疏clicked_items_seq物品ID序列变长。物品特征item_id稀疏category_id稀疏price数值。我们的目标是给定一个用户从海量商品中召回Top-K个最相关的商品。第一步构建训练样本训练数据通常来自点击日志。一条正样本是(用户特征, 点击的物品特征, label1)。我们需要为每条正样本构造负样本。在初期可以采用“随机采样”“曝光未点击”的方式。4.2 模型构建使用TensorFlow/Keras示例这里我们构建一个融合了前述部分改进思想的模型。import tensorflow as tf from tensorflow.keras import layers, Model def build_two_tower_model(user_vocab_sizes, item_vocab_sizes, emb_dim64, hidden_units[128, 64]): 构建双塔模型 user_vocab_sizes: dict 用户侧稀疏特征的词表大小如{user_id: 10000, city: 500} item_vocab_sizes: dict 物品侧稀疏特征的词表大小如{item_id: 50000, category_id: 200} # 用户塔输入 user_inputs {} user_emb_list [] # 1. 处理稀疏ID特征 for feat_name, vocab_size in user_vocab_sizes.items(): inp layers.Input(shape(1,), namefuser_{feat_name}) user_inputs[feat_name] inp emb layers.Embedding(input_dimvocab_size, output_dimemb_dim, namefuser_{feat_name}_emb)(inp) emb layers.Flatten()(emb) # 从 (batch, 1, emb_dim) 压平为 (batch, emb_dim) user_emb_list.append(emb) # 2. 处理数值特征如年龄 age_input layers.Input(shape(1,), nameuser_age) user_inputs[age] age_input # 数值特征可以归一化后直接连接也可以分桶后Embedding。这里简单处理直接连接。 age_normalized layers.BatchNormalization()(age_input) user_emb_list.append(age_normalized) # 3. 处理序列特征历史点击序列- 简化版使用平均池化 seq_input layers.Input(shape(None,), nameuser_click_seq) # 变长序列 user_inputs[click_seq] seq_input seq_emb layers.Embedding(input_dimitem_vocab_sizes[item_id], output_dimemb_dim, mask_zeroTrue)(seq_input) # 使用全局平均池化忽略mask掉的部分0值 seq_pooled layers.GlobalAveragePooling1D()(seq_emb) user_emb_list.append(seq_pooled) # 用户塔拼接所有特征嵌入然后通过MLP user_concat layers.Concatenate()(user_emb_list) user_dense user_concat for i, units in enumerate(hidden_units): user_dense layers.Dense(units, activationrelu, namefuser_fc_{i})(user_dense) user_embedding layers.Dense(emb_dim, activationNone, nameuser_embedding)(user_dense) # 最终用户向量 user_embedding tf.math.l2_normalize(user_embedding, axis-1) # L2归一化方便计算余弦相似度 # 物品塔输入 item_inputs {} item_emb_list [] for feat_name, vocab_size in item_vocab_sizes.items(): inp layers.Input(shape(1,), namefitem_{feat_name}) item_inputs[feat_name] inp emb layers.Embedding(input_dimvocab_size, output_dimemb_dim, namefitem_{feat_name}_emb)(inp) emb layers.Flatten()(emb) item_emb_list.append(emb) price_input layers.Input(shape(1,), nameitem_price) item_inputs[price] price_input price_normalized layers.BatchNormalization()(price_input) item_emb_list.append(price_normalized) # 物品塔 item_concat layers.Concatenate()(item_emb_list) item_dense item_concat for i, units in enumerate(hidden_units): item_dense layers.Dense(units, activationrelu, namefitem_fc_{i})(item_dense) item_embedding layers.Dense(emb_dim, activationNone, nameitem_embedding)(item_dense) item_embedding tf.math.l2_normalize(item_embedding, axis-1) # 匹配得分计算 # 训练阶段使用点积计算得分已归一化点积即余弦相似度 dot_score layers.Dot(axes1, normalizeFalse)([user_embedding, item_embedding]) # 可以将点积得分通过一个Sigmoid输出为概率用于CTR预估 output layers.Dense(1, activationsigmoid, namescore)(dot_score) # 构建模型 model Model(inputs{**user_inputs, **item_inputs}, outputsoutput) return model, user_inputs, item_inputs, user_embedding, item_embedding # 假设词表大小 user_vocab {user_id: 10000, city: 500, gender: 3} item_vocab {item_id: 50000, category_id: 200} model, user_inputs, item_inputs, user_emb, item_emb build_two_tower_model(user_vocab, item_vocab) model.summary()4.3 训练与负采样策略# 模拟一个训练批次的数据 batch_size 1024 # 假设我们已经将特征处理成了字典格式 train_user_data { user_user_id: np.random.randint(0, 10000, batch_size), user_city: np.random.randint(0, 500, batch_size), user_gender: np.random.randint(0, 3, batch_size), user_age: np.random.randn(batch_size, 1) * 10 30, # 模拟年龄 user_click_seq: tf.keras.preprocessing.sequence.pad_sequences( [np.random.randint(1, 50000, np.random.randint(5, 20)) for _ in range(batch_size)], paddingpost ) # 模拟变长序列 } train_item_data { item_item_id: np.random.randint(0, 50000, batch_size), item_category_id: np.random.randint(0, 200, batch_size), item_price: np.random.rand(batch_size, 1) * 1000 } # 标签假设都是正样本 labels np.ones((batch_size, 1)) # 使用 in-batch negative 损失函数 # TensorFlow推荐使用 tf.keras.losses.CategoricalCrossentropy(from_logitsTrue) 配合特殊的采样器 # 更常见的实践是使用 TensorFlow Recommenders (TFRS) 库它内置了双塔模型和高效的负采样损失。 # 这里为简化我们假设已经构造好了包含正负样本的数据对。 # 编译模型 model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) # 训练 (这里只是示意实际数据需构造正负样本对) # model.fit(x{**train_user_data, **train_item_data}, ylabels, epochs10, batch_size256)4.4 线上服务与向量检索训练完成后我们需要将模型拆开用于线上服务。# 1. 提取用户塔模型和物品塔模型 user_tower Model(inputsuser_inputs, outputsuser_emb) item_tower Model(inputsitem_inputs, outputsitem_emb) # 2. 离线计算所有物品的向量存入向量数据库如Faiss all_items_features ... # 加载所有物品的特征 all_item_vectors item_tower.predict(all_items_features, batch_size1024) # 构建Faiss索引 (以FlatIP为例因为向量已归一化内积即余弦相似度) import faiss dimension all_item_vectors.shape[1] index faiss.IndexFlatIP(dimension) # Inner Product index index.add(all_item_vectors.astype(float32)) # 3. 线上服务 def online_recall(user_features, top_k100): # 实时计算用户向量 user_vector user_tower.predict(user_features)[0].astype(float32).reshape(1, -1) # 在Faiss中搜索最相似的物品 distances, indices index.search(user_vector, top_k) # indices 即为召回的商品ID列表 return indices[0], distances[0] # 模拟一个用户请求 online_user_feat {k: np.array([v]) for k, v in train_user_data.items()} # 取第一个用户 recalled_ids, scores online_recall(online_user_feat, top_k10) print(f召回的商品ID: {recalled_ids}) print(f相似度得分: {scores})5. 避坑指南与进阶思考在实际项目中应用双塔模型你会遇到很多论文里不会写的坑。这里分享几个关键点。5.1 特征处理中的“暗礁”序列特征的长度与填充用户行为序列长度差异巨大。直接截断会丢失信息填充过多会引入噪声并浪费计算。一个技巧是保留最近N个行为并对更早的行为进行抽样或聚合。对于超长序列可以考虑使用两阶段模型或用SIMSearch-based Interest Model这类模型先进行兴趣搜索。数值特征的尺度价格、年龄等数值特征如果不做处理其尺度会严重影响模型。一定要做归一化如Min-Max或标准化Z-Score。更高级的做法是进行分桶binning然后做Embedding让模型学习非线性的关系。冷启动ID的Embedding对于新用户或新物品其ID在训练集中未出现。必须为它们设置一个默认的Embedding如全零向量或所有已知ID向量的均值并在训练时加入适当的正则化如Dropout来增强泛化。5.2 训练过程中的“陷阱”负样本的“质量”与“数量”这是影响效果的最大因素之一。纯随机负样本会使模型过于简单。必须引入困难负样本。一个实用的策略是每轮训练后用当前模型为每个正样本生成一批“高得分但未被点击”的样本作为下一轮的困难负样本加入训练池。In-batch Negative的偏差Batch内负采样假设Batch内的物品是随机分布的。但如果你的数据是按用户或时间组织的Batch内物品分布会严重有偏例如一个Batch全是同一品类的商品。这会导致模型学到错误的关联。解决方案是全局负采样与in-batch负采样结合或者在数据shuffle上下功夫。归一化与温度系数对双塔输出的向量进行L2归一化是标准操作这使相似度计算稳定在[-1,1]。但在计算Softmax损失时直接使用点积得分可能会因为向量维度高而导致logits值过大使得Softmax过于“自信”。通常需要引入一个温度系数Temperatureτscore (u_emb · i_emb) / τ。τ是一个需要调的超参数用于控制分布的平滑程度。5.3 线上服务的“性能墙”向量索引的选择Faiss的IndexFlatIP精确搜索在物品量少于百万级时可行。超过百万必须使用近似最近邻搜索ANN索引如IVFFlat,HNSW。选择索引需要在召回率、速度和内存之间权衡。HNSW通常能提供较好的均衡。用户向量的实时更新用户向量能否实时更新反映了兴趣变化完全实时计算对性能挑战大。折中方案是近实时更新例如每5分钟根据用户最新行为重新计算一次向量或使用增量更新技术。多路召回与融合双塔模型通常只作为召回层的一路。线上系统需要融合热门召回、协同过滤召回、标签召回、双塔向量召回等多路结果送入后续的排序模型。如何设定各路的召回数量配额是一个需要AB实验调优的问题。5.4 超越经典双塔未来的方向双塔模型因其简洁高效依然是工业界召回的基石。但业界也在不断探索其边界蒸馏与量化用更复杂、交互更充分的教师模型如深度兴趣网络DIN来指导双塔学生模型的训练让双塔向量“模仿”教师模型学到的复杂交互知识这是提升双塔上限的有效手段。同时对向量进行量化如PQ量化能在几乎不损失精度的情况下大幅减少内存占用和加速检索。多模态双塔随着视频、图文等内容推荐兴起物品塔需要处理图像、文本、音频等多模态特征。如何设计一个能融合多模态信息的物品塔是一个热门方向。图神经网络与双塔结合将用户-物品交互图的信息通过图神经网络GNN编码进用户和物品的初始特征中再输入双塔可以引入丰富的协同信号和高阶关系信息。双塔模型的故事远未结束。从微软的DSSM出发到美团等大厂的一系列改进它展示了一个经典模型如何在工程需求的打磨下不断进化。理解它的“无特征交互”这一核心假设既是理解其局限性的钥匙也是激发我们对其进行改造和创新的起点。在实际工作中没有最好的模型只有最合适的架构。双塔模型以其在性能和效果间取得的绝佳平衡无疑将在推荐系统的舞台上继续扮演不可或缺的角色。