搜索排序这行干久了都会碰到一个很典型的矛盾你用一堆特征维度的排序模型把点击率做到某个瓶颈之后再继续堆特征收益会越来越小。用户的历史行为明明就在日志里躺着但你不知道怎么把“这个人最近三天内反复比较过什么类型的商品”这种信息真正用起来。我最近在整理个性化搜索排序的调研笔记时重新把基于层级RNN加查询感知注意力做个性化搜索的方案完整过了一遍并且在模拟数据集上做了复现实验。这篇博文就是这次调研和复现的总结。这套方案的核心并不复杂把用户的历史交互组织成“会话级别”再到“用户级别”的层级结构用双向GRU分别做编码再用当前查询来给每个历史行为算注意力权重最后把查询表征与用户表征拼起来打分。它能解决的问题很具体面对同一个查询词不同用户得到的排序结果应该不一样因为他们的历史兴趣不同。适合谁看如果你在做搜索排序、推荐系统的用户表征学习或者准备用行为序列替代部分人工特征工程这篇总结应该能帮你省一些摸索时间。1. 项目定位与整体设计思路1.1 个性化搜索到底在解决什么问题先看一个具体场景。某电商平台有两个用户一个最近一周反复浏览机械键盘、轴体和键帽另一个一直在看成品电脑整机和显示器。某天两人都在搜索框里输入“键盘”这个词。通用排序模型看到的是同样的Query、同样的商品池返回的排序结果几乎一样。但实际需求可能完全不同前者大概率想要客制化配件后者可能想要一套带数字区的办公键盘。这就是个性化搜索要解决的差异问题。传统Learning to Rank方案里不是没有用户特征但通常是把用户的统计特征比如历史点击类目分布、购买力区间、活跃时段作为特征拼进树模型或深度模型。这种做法有个问题统计特征丢失了顺序信息。用户先看了A再看了B最后买了C这个“先后顺序”对当前意图的判断很重要但统计特征把它压成了一个计数或占比。另外统计特征很难表达短期兴趣和长期兴趣的交叠关系。行为序列建模就是为了把这些信息重新捡回来。用户的每一次点击、收藏、加购、下单本质上都是他在特定时间窗口内意图的“快照”。把一串行为丢进序列模型模型自己学习怎么把这些快照组合成一个向量这个向量就是用户的兴趣表征。这套方案里行为不是拼成一条长序列直接过一遍RNN而是先按会话分组再在用户层级把会话编码结果组合起来原因是用户行为天然的层次化特征。一个用户一天可能产生几十次到几百次交互行为但这些行为不是均匀分布的。他可能上午花十分钟看键盘配件下午又花半小时看显示器评测。如果把这堆行为揉成一条扁平序列模型很难区分“这是两次独立需求”还是“同一需求下的连续探索”。层级结构先区分会话边界再对每个会话单独建模最后在用户维度把会话加权组合这更符合真实行为模式。1.2 为什么是层级RNN而不是单一序列模型如果只是把用户所有行为按时间排序成一条长序列然后塞进LSTM或者GRU会有两个实际困难。第一是序列长度问题。活跃用户的近期行为可能超过几百条RNN在超长序列上存在严重的梯度传播衰减后面发生的事情会覆盖掉前面的信息。第二是行为结构问题。扁平序列把“会话边界”抹掉了模型无法知道哪些行为属于同一次需求探索过程。层级结构分别处理会话内编码和会话间编码长度上互相解耦结构上保留了需求边界。选择GRU而不是LSTM主要考虑参数量和训练稳定性。GRU只有两个门更新门和重置门比LSTM少一个门参数量更小在同样的训练数据规模下更不容易过拟合。论文和工程复现里都用双向GRU来编码会话内行为序列原因在于用户在一个会话里的行为顺序虽然有先后但前后的上下文都会影响行为含义。例如用户先点了“机械键盘轴体”再返回去点“键盘外壳”如果只按正向顺序编码前面的轴体行为在最终隐状态里可能被后一个行为覆盖。双向结构能保留两个方向的信息。Transformer是另一个常被拿来对比的方案。它在长序列建模上确实很强注意力机制可以直接建模任意两个行为之间的依赖关系。但在这个场景里用户历史行为序列首先存在明显的会话结构直接用Transformer需要额外引入位置编码和会话分割向量复杂度反而上去了。其次Transformer的推理耗时和内存占用在序列较长时增长明显线上排序服务的延迟预算通常很紧张。最后很多应用场景的交互数据量并没有大到能充分训练深层Transformer的程度。所以RNN作为编码器在这个任务上仍然是很务实的选择尤其是序列长度被会话切分之后大幅缩短RNN的收敛速度和稳定性优势都回来了。1.3 查询感知注意力解决了什么问题单纯用历史行为编码用户表征还不够。用户在不同查询下的兴趣权重应该是动态变化的。举个简单的例子用户近期既看了键盘又看了显示器现在他搜“键盘”那么历史里的键盘相关行为应该获得更高权重如果他搜“显示器”权重应该偏向显示器行为。查询感知注意力做的就是这件事当前查询词以向量形式参与注意力权重的计算让用户表征随查询变化。如果不用查询感知注意力而是把用户历史行为的平均向量作为固定表征后果是什么用户表征对所有查询都一样。排序模型在综合Query特征和User特征时无法区分当前查询与用户哪部分历史兴趣更相关。这么做的实际效果等同于给所有商品加了同一个用户偏置分个性化能力会被明显削弱。查询感知注意力的操作并不复杂把当前查询编码成一个Query向量让每个历史行为编码后的隐状态向量分别与Query向量做交互通常是拼接后过一个前馈层算分然后对分数做Softmax得到权重再对历史行为隐状态做加权求和。结果是同一个用户在不同查询下得到了不同的用户表征。这才是“个性化”落到实处的关键一步。2. 技术方案核心拆解2.1 会话划分与层级模型结构整个模型分成三个层级行为层、会话层、用户层。下面逐一拆开。行为层的输入是一条会话内的交互记录每次交互表示成一个向量通常由几个部分拼接而成行为对应的商品ID Embedding、行为类型点击、收藏、加购、下单等的Embedding、时间间隔特征、位置特征等。商品ID Embedding可以从全量商品库中预训练得到行为类型Embedding是维度很小的向量比如8维或16维。这些向量按行为发生顺序排列输入到双向GRU。双向GRU的每个时间步有两个隐状态输出一个来自正向处理一个来自反向处理拼接后作为这个行为在当前会话内的编码结果。会话向量怎么得到最简单的做法是取两个方向最后一个时间步的隐状态拼接。但更常用的做法是额外加一个注意力层把会话内所有行为的状态加权平均得到会话向量。为什么推荐后者因为用户在一个会话内也可能中途换意图比如本想买键盘点了几个轴体后发现客制化太贵转而去看薄膜键盘。这种情况下会话的最后一个行为不一定能代表整个会话的目标加权平均更稳妥。用户层的输入是一串会话向量。会话之间同样有先后顺序最近会话的重要性一般会随时间衰减但又不能说旧会话完全没用。用户层同样用双向GRU来编码。把每个会话向量按时间顺序输入得到会话序列的隐状态序列。关键一步来了用查询向量对会话隐状态做注意力加权得到最终的用户表征向量。会话级的注意力权重计算会用到内容相似度信息。单个会话编码向量已经表达了该时间段内用户的需求主题查询向量则表达当前搜索意图。两者相似度越高这个会话对当前搜索结果的影响应该越大。这种设计能自然处理一个用户长期兴趣稳定、短期兴趣多变的情况长期稳定的高频兴趣会在多个会话中出现和查询的相似度总体偏高短期热点兴趣只有在相关查询出现时才会被赋予高权重。2.2 查询向量的构建方式查询感知注意力要求当前查询必须有一个向量表示。查询词可能是一个词、几个词也可能包含一些干扰信息。构建查询向量最简单的方式是把查询直接过一个Embedding层查表适用于高频且短小的查询词更通用的是把查询词做分词后取词向量平均或者先通过一个小型编码器比如单向GRU把查询词序列编码成固定向量。有一个实际工程里的细节值得注意查询向量不是只用来算注意力权重它还会参与到后续的打分中。所以查询向量的质量对效果影响很明显。如果用平均词向量需要对低频词和未登录词做特殊处理。实践中最稳妥的方式是先用全量查询日志训练一套查询词向量再做分词后加权平均权重可以简单用词频的逆文档频率。这种方式在离线实验中能获得稳定的基线效果。另外一个可选优化是让查询向量进入会话编码的初始状态。把查询词处理成一段上下文信息注入到会话级GRU的初始隐状态里让模型在编码会话时就知道当前查询是什么。这个做法会在一定程度上增强查询与会话编码的交互但如果查询信息已经通过注意力机制参与加权注入初始状态带来的增益通常有限。考虑到实现复杂度一般建议先只做注意力注入把初始状态注入作为备选优化项。2.3 从用户表征到相关性打分最终打分阶段把三块信息汇合起来查询向量、用户表征向量、候选商品向量。拼接之后通过两层全连接网络输出一个标量分数。在训练阶段这个分数和真实点击/购买标签做损失计算在推理阶段这个分数可以直接用于排序。要注意的点是这里用户表征已经是查询感知的结果也就是说候选商品的排序分数不仅取决于商品与查询的匹配程度还取决于它与用户近期兴趣的匹配程度。具体到模型内部用户表征向量里已经包含了历史行为中与当前查询最相关的部分信息全连接层只需要学习怎么把商品向量和用户表征向量做交互。有些实现会把商品ID建模成双塔结构左边用户塔输出用户向量右边商品塔输出商品向量最后用点积或余弦相似度计算得分。这套方案的离线实验里用点积做打分的效果不如直接拼接多层全连接。原因是全连接层能学习更复杂的非线性交互点积只建模了线性交互。不过双塔结构在召回阶段有巨大的速度优势所以常见做法是用层级RNN方案做精排阶段的个性化重排序用双塔或者物品协同过滤方式做召回。这是工程上比较合理的分工。还有一个细节是行为类型权重。点击、收藏、加购、下单四种行为对用户意图的指示强度完全不同。如果只在行为向量里拼上行为类型的Embedding模型能学到一定区分但也可以用“行为类型对应的权重系数乘到隐状态上”这种先验方式。实际实验表明先验权重可以帮助模型在训练数据不足时更快收敛但对数据量充足的场景增益不大。建议在小流量冷启动阶段使用先验权重数据积累起来后再改为Embedding方式让模型自己学。3. 实操与落地细节3.1 数据准备与序列构建数据是一切的基础实际复现这个方案时我最花时间的反而不是模型代码而是把线上日志预处理成可训练的样本序列。第一步是确定行为类型范围。一般会把点击、收藏、加购、下单四类行为纳入曝光但未点击的行为要不要放进去如果放进去隐状态里会出现大量与用户兴趣无关的噪声而且曝光日志量通常是点击日志的几十倍训练成本会剧增。我的做法是只保留点击及更深度的交互行为但会额外记录一个“当前会话曝光未点击商品”的特征用于后面做负样本采样。第二步是时间窗口划分。用户历史序列不可能无限长需要截断。经过多组对比近7天的行为对排序效果贡献最明显14天提升有限30天基本饱和。截断的好处有两方面减少计算量强制让模型聚焦在近期兴趣上。太久远的历史行为即便包含一些长期兴趣信息也会被近期的活跃行为淹没。第三步是会话划分。会话边界的定义会直接影响模型结构的效果。工业界常用的会话切分规则是相邻行为间隔超过30分钟视为新会话。在移动端场景App退到后台超过一定时间再回来也通常切为新会话。这个阈值需要根据产品形态调内容型产品可能15分钟就够电商类因为用户会反复比价可以放宽到30分钟甚至1小时。第四步是样本构造。训练样本以“查询用户候选商品”为一条标签是该商品在展示后是否被点击或购买。正样本来自实际有点击行为的展示日志负样本最基础的做法是从同一次请求未点击的商品中随机采样。补充一种更有效的采样方式查所有用户在当前查询下点击过的商品作为正样本池再混入热门但没被当前用户点击的商品做负样本这样可以增加负样本难度模型更容易学到区分性。3.2 模型实现的工程要点我用PyTorch实现了一遍核心结构这里给出关键模块的伪代码方便有需要的同学对照。class BehaviorEncoder(nn.Module): # 输入: 会话内行为序列 [batch, seq_len, feature_dim] # 输出: 会话向量 [batch, hidden_dim * 2] def __init__(self, input_dim, hidden_dim): super().__init__() self.gru nn.GRU(input_dim, hidden_dim, bidirectionalTrue, batch_firstTrue) def forward(self, behavior_seq, mask): # 处理变长序列时需要pack_padded_sequence packed nn.utils.rnn.pack_padded_sequence(behavior_seq, mask.sum(1).cpu(), batch_firstTrue, enforce_sortedFalse) output, _ self.gru(packed) output, _ nn.utils.rnn.pad_packed_sequence(output, batch_firstTrue) # 用平均池化得到会话向量, 比取最后时刻更稳 session_vec (output * mask.unsqueeze(-1)).sum(1) / mask.sum(1).unsqueeze(-1) return session_vec class SessionLevelEncoder(nn.Module): # 输入: 会话向量序列 [batch, num_sessions, hidden_dim * 2] # 输出: 每个会话的上下文向量 [batch, num_sessions, hidden_dim * 2] def __init__(self, input_dim, hidden_dim): super().__init__() self.gru nn.GRU(input_dim, hidden_dim, bidirectionalTrue, batch_firstTrue) def forward(self, session_seqs, session_mask): packed nn.utils.rnn.pack_padded_sequence(session_seqs, session_mask.sum(1).cpu(), batch_firstTrue, enforce_sortedFalse) output, _ self.gru(packed) output, _ nn.utils.rnn.pad_packed_sequence(output, batch_firstTrue) return output class QueryAwareAttention(nn.Module): # 输入: 查询向量query_vec [batch, query_dim], encoder_outputs [batch, seq_len, hidden] # 输出: 加权上下文向量 [batch, hidden] def __init__(self, hidden_dim): super().__init__() self.query_proj nn.Linear(query_dim, hidden_dim) self.score_layer nn.Linear(hidden_dim * 2, 1) def forward(self, query_vec, encoder_outputs, mask): query_context self.query_proj(query_vec).unsqueeze(1) # [batch, 1, hidden] concat torch.cat([encoder_outputs, query_context.expand_as(encoder_outputs)], dim-1) scores self.score_layer(concat).squeeze(-1) # [batch, seq_len] scores scores.masked_fill(~mask, float(-inf)) weights torch.softmax(scores, dim-1) context (encoder_outputs * weights.unsqueeze(-1)).sum(1) return context工程上有几个容易踩坑的细节。第一个是变长序列的掩码处理。用户历史会话数不一样每个会话内的行为数也不一样PyTorch的pack_padded_sequence要求序列按长度降序排列或者设置enforce_sortedFalse。第一次实现时图省事直接对padding位置用零向量做输入结果模型学到的注意力权重在padding位置上经常出现异常高值。后来统一改用mask masked_fill(负无穷)的方式把padding位置的分数强制置为负无穷Softmax后权重归零。这一步是必须的否则用户表征会被无效信息污染。第二个是会话数量的平衡。长尾用户可能过去7天只有1个会话头部用户可能有几十个会话。直接按实际会话数训练会导致batch内序列长度差异巨大计算效率很低。我采用的方式是设定最大会话数N比如20超过的取最近N个不足的用零向量补齐并在mask中标记为无效。同样的策略也用于会话内的行为序列长度通常截断到50。第三个是Embedding的稠密化。商品ID稀疏性极高全量商品可能上千万直接做Embedding会产生巨大的参数量。建议先对商品做热门过滤只保留7天内有交互的商品ID建表其余映射到一个统一的UNK向量。这样参数量可以压缩掉80%。商品的其他属性类目、品牌、价格带如果有也可以拼成辅助Embedding这对冷门商品的表征学习很有帮助。第四个是训练时负样本的构建。最简单的做法是随机采样但随机负样本和正样本的区分度过高模型很快收敛但对线上困难样本的判别力不足。我采用的是“随机负样本热门未点击负样本”混合采样比例控制在1:1实验结果显示线上指标有明显的提升。这个技巧在很多推荐和搜索项目里都适用成本不高很建议直接试。3.3 训练策略与离线指标评估训练时输入样本的组织方式我建议采取“一个用户的历史行为只加载一次”的重组方式。具体说把按查询和用户组合的原始日志先按用户分组把同一个用户的全部历史行为一次性取出来编码一次得到用户表征和各个会话表征然后这个用户在这一批次里涉及到的多个查询样本可以复用这份表征来并行计算注意力权重。这样能让历史行为编码的GPU计算量压缩到原来的几十分之一数值稳定性也更好。模型使用二元交叉熵损失。训练轮数上我观察到这个模型在训练集上收敛得比普通LR快但验证集上的最佳表现出现在第4到第6轮之间。后续继续训练会出现典型的过拟合现象——训练损失持续下降验证集的AUC和NDCG反而停滞或略下降。因此及早做模型快照保存、按验证集效果选轮数是训练阶段的一个重要操作。优化器建议使用AdamW学习率初始1e-3batch size可以设到256或512。如果发现训练loss震荡明显优先降低学习率到5e-4而不是调整模型结构。很多人一上来就调隐层维度或者换激活函数结果问题出在learning rate上。梯度裁剪也很重要GRU的梯度范数容易在长序列上暴涨设个5.0的max_grad_norm能省掉大量排查时间。离线指标我主要看三个AUC、NDCG10和MRR。AUC反映整体区分度NDCG10反映头部排序质量MRR反映第一个相关结果的命中情况。个性化搜索任务里NDCG10的涨幅通常比AUC更明显因为模型把真正相关的商品推到了更靠前的位置。如果只盯着AUC涨跌有时会错过一些排序质量的真实变化。4. 踩坑实录与调参心得4.1 训练不稳定梯度爆炸和注意力坍缩我复现的第一版模型训练到第2000步左右loss突然跳高两个数量级然后再也回不到正常水平。排查过程很简单先看梯度范数果然在某个batch上梯度达到了几百。原因是一个超长会话里包含了大量高维稀疏特征加上RNN本来就容易梯度累积最后导致参数被一次性冲坏。解决办法是梯度裁剪加上对行为序列长度做更严格的截断。后来我还把Embedding的初始化范围从均匀分布调小到[-0.03, 0.03]数值稳定性又上了一个台阶。另一个问题是注意力坍缩。查询感知注意力理论上应该让历史行为权重与当前查询相关但实际训练早期会出现权重分布接近均匀的情况。原因在于查询向量和隐状态之间的前馈层在初始化阶段输出接近零所有行为平分注意力。这不算错误但如果持续坍缩说明查询向量没有有效参与计算。一个有效的缓解方法是给查询向量和会话状态的拼接层加一个残差连接或层归一化让梯度更顺畅地回传到查询编码器。我试验后发现对score_layer的输入做LayerNorm能明显加快注意力权重的分化速度。4.2 数据稀疏用户怎么处理用户历史行为少的场景比如新用户或7天内只有几次点击的低活跃用户这个模型的表现可能会比简单的统计特征模型还差。原因在于序列太短RNN学不出有效隐状态注意力加权的结果几乎退化成平均向量而且因为缺少有效的用户表征底层排序模型还得依赖其他特征。我在工程上采取了一套混合策略。对历史行为不足的用户不在线上走完整的层级RNN链路而是退回统计特征分支用逻辑回归或浅层模型预测偏置分。对历史行为数达到阈值比如5条的用户正常走个性化模型。这个降级策略看起来简单但能避免模型对小样本用户输出不可靠的高偏置分数。另一种思路是对冷启动用户做分组聚类用同类用户的行为补齐序列。这个方法会在工程上增加不少复杂度数据量不充足时不建议一开始就试。4.3 点击偏置噪声的影响个性化搜索数据中有一个隐蔽问题用户的点击行为高度依赖当前的排序结果。也就是说排在前面曝光位置上的商品天然有更高的点击概率这和商品本身与用户兴趣的相关性无关。如果模型直接用原始展示日志训练它会学习到“只要被排前面就该给高分”这会造成反馈循环个性化带来的增益会被系统偏置抵消掉一部分。通常用两种方式缓解。一是引入位置特征把商品在展示列表中的位置作为特征拼进打分网络预测时强制位置特征为统一值比如0这样模型学习到的位置影响不会带入推理。二是对点击样本做个加权的逆倾向校正热门位置样本降权低曝光位置样本提权。前者实现简单我实际用的是第一种方案离线指标提升明显AB测试正收益也更显著。第二种方案对数据量和倾向预估准确性要求更高暂时没有大规模采用。4.4 效果提升的差异化手段我把这个模型和几种基线方案做过对比。基线一是不带用户历史的通用排序模型基线二是把用户历史行为ID直接拼成平均向量作为特征的LR模型基线三是把全部用户历史行为作为一条长序列过单向GRU。数据一致的前提下层级RNN查询感知注意力的版比基线二高了约2.4个百分点AUC比基线三高了约1.2个百分点AUC。NDCG10的提升更明显说明注意力机制确实把更相关的物品往前提了。实验中也发现加宽全连接层的宽度比加深层数更有效。两层512维的全连接就能达到很好的效果继续加到2048维收益递减但推理耗时几乎翻倍。在线推理必须考虑时间成本我建议离线实验多跑几组维度配置直接看收益曲线不要凭经验直接选最大维度。最后再分享一个实操细节如果要在自己的数据上快速验证这套方案是否适用不必一开始就跑完整训练。可以先用预训练好的商品向量把用户行为序列的平均向量算出来再用当前查询的商品表征算一个相似度加权分对比现有排序分做一次简单的线性融合。如果这个融合分能带来可见的NDCG提升说明这个方向的个性化语义信号确实存在再花力气搭完整模型也不迟。我自己在多个项目里都是先做这个快速验证确认有正收益后才投入完整工程实现——这种做法能少走很多弯路。