从DSSM到工业级双塔模型:推荐系统召回层的演进与实战
1. 项目概述:从双塔到DSSM,推荐系统的基石演进
如果你在推荐系统、搜索广告或者内容匹配领域摸爬滚打过一阵子,那么“双塔模型”这个词对你来说一定不陌生。它就像这个领域的“Hello World”,简单、经典,却又无处不在。今天我们不聊那些花里胡哨的复杂模型,就从一个最基础、最核心的模型说起——微软的DSSM(Deep 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):原始输入是搜索词。论文中先将词转换为字符级的trigram(3-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. 交互函数的升级: 将简单的余弦相似度或点积,替换为更复杂的匹配函数。
- 双线性匹配(Bilinear):
score = 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个最相关的商品。
第一步:构建训练样本训练数据通常来自点击日志。一条正样本是:(用户特征, 点击的物品特征, label=1)。我们需要为每条正样本构造负样本。在初期,可以采用“随机采样”+“曝光未点击”的方式。
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_dim=64, 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,), name=f'user_{feat_name}') user_inputs[feat_name] = inp emb = layers.Embedding(input_dim=vocab_size, output_dim=emb_dim, name=f'user_{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,), name='user_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,), name='user_click_seq') # 变长序列 user_inputs['click_seq'] = seq_input seq_emb = layers.Embedding(input_dim=item_vocab_sizes['item_id'], output_dim=emb_dim, mask_zero=True)(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, activation='relu', name=f'user_fc_{i}')(user_dense) user_embedding = layers.Dense(emb_dim, activation=None, name='user_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,), name=f'item_{feat_name}') item_inputs[feat_name] = inp emb = layers.Embedding(input_dim=vocab_size, output_dim=emb_dim, name=f'item_{feat_name}_emb')(inp) emb = layers.Flatten()(emb) item_emb_list.append(emb) price_input = layers.Input(shape=(1,), name='item_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, activation='relu', name=f'item_fc_{i}')(item_dense) item_embedding = layers.Dense(emb_dim, activation=None, name='item_embedding')(item_dense) item_embedding = tf.math.l2_normalize(item_embedding, axis=-1) # ========== 匹配得分计算 ========== # 训练阶段:使用点积计算得分(已归一化,点积即余弦相似度) dot_score = layers.Dot(axes=1, normalize=False)([user_embedding, item_embedding]) # 可以将点积得分通过一个Sigmoid输出为概率,用于CTR预估 output = layers.Dense(1, activation='sigmoid', name='score')(dot_score) # 构建模型 model = Model(inputs={**user_inputs, **item_inputs}, outputs=output) 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)], padding='post' ) # 模拟变长序列 } 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_logits=True) 配合特殊的采样器 # 更常见的实践是使用 TensorFlow Recommenders (TFRS) 库,它内置了双塔模型和高效的负采样损失。 # 这里为简化,我们假设已经构造好了包含正负样本的数据对。 # 编译模型 model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy']) # 训练 (这里只是示意,实际数据需构造正负样本对) # model.fit(x={**train_user_data, **train_item_data}, y=labels, epochs=10, batch_size=256)4.4 线上服务与向量检索
训练完成后,我们需要将模型拆开,用于线上服务。
# 1. 提取用户塔模型和物品塔模型 user_tower = Model(inputs=user_inputs, outputs=user_emb) item_tower = Model(inputs=item_inputs, outputs=item_emb) # 2. 离线计算所有物品的向量,存入向量数据库(如Faiss) all_items_features = ... # 加载所有物品的特征 all_item_vectors = item_tower.predict(all_items_features, batch_size=1024) # 构建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_k=100): # 实时计算用户向量 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_k=10) print(f"召回的商品ID: {recalled_ids}") print(f"相似度得分: {scores}")5. 避坑指南与进阶思考
在实际项目中应用双塔模型,你会遇到很多论文里不会写的坑。这里分享几个关键点。
5.1 特征处理中的“暗礁”
- 序列特征的长度与填充:用户行为序列长度差异巨大。直接截断会丢失信息,填充过多会引入噪声并浪费计算。一个技巧是保留最近N个行为,并对更早的行为进行抽样或聚合。对于超长序列,可以考虑使用两阶段模型,或用SIM(Search-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出发,到美团等大厂的一系列改进,它展示了一个经典模型如何在工程需求的打磨下不断进化。理解它的“无特征交互”这一核心假设,既是理解其局限性的钥匙,也是激发我们对其进行改造和创新的起点。在实际工作中,没有最好的模型,只有最合适的架构。双塔模型以其在性能和效果间取得的绝佳平衡,无疑将在推荐系统的舞台上继续扮演不可或缺的角色。