1. 论文在解决什么问题:搜索个性化的核心难点
搜索个性化这个方向,说白了就是解决一个很日常的痛点:同一个查询词,不同用户想要的结果是完全不同的。比如我搜"Python",一个刚入门的用户想看到的是教程和安装指南,而一个做后端开发的用户想看到的是高级并发编程、性能调优的文章。关键词层面完全一样,但背后的意图和个人背景差了十万八千里。基于关键词的排序系统再强,在这种场景下也拿不出真正的个性化结果,因为它根本不知道站在屏幕前的是谁。
这篇论文的核心切入点就在这里。它认为,要真正做好搜索结果的个性化,不能只盯着当下这一次查询,还要把用户过去的所有行为串起来看。这里的"过去"还不是简单地说"最近一小时点了什么",而是包含了一个更细的层次:用户今天这个会话里做了什么、过去一周甚至一个月的多个会话里做了什么。这两个层次的信号,对"当下这个查询应该返回什么"的贡献方式是完全不同的。
这种"多层信号"的直觉并不新鲜,工业界早年也有不少做法,比如给用户建一个长期兴趣画像,再叠加短期行为特征,最后用规则把两个分数揉在一起。但这类方法的问题在于:短期和长期是两套独立的模块,各自输出分数之后再合并,没有一个统一的模型来学习"当前查询到底更应该依赖长期记忆还是短期记忆"。而且一旦行为序列变长,传统基于统计的画像方法会迅速退化,丢失序列里的时序结构。
1.1 意图不是"一个人的兴趣",而是"一组不断变化的子意图"
我觉得这篇论文最值得琢磨的一个观点是:用户兴趣不是静态分布,而是一组不断变化的子意图的切换。你可以把用户想象成一个在多条任务线之间来回切换的人。上午在查"MacBook对比ThinkPad",下午开始搜"Java内存模型",晚上又切回"MacBook外接显示器"。如果模型只维护一个全局用户向量,那这个向量会被三条任务线搅成一锅粥,哪条线的结果都排不好。
Hierarchical RNN的层次结构,本质上就是为了应对这种子意图切换而设计的。低层的query级RNN负责跟踪"当前会话里正在进行的任务线",高层的session级RNN负责识别"这个用户长期在各个任务线之间的切换模式"。这样当用户从"Java内存模型"切回"MacBook外接显示器"时,模型不用从头开始理解,session级模块早就把这条任务线的上下文存着了。
1.2 传统个性化方案的两个致命短板
在细聊架构之前,值得花点时间说说这篇论文瞄准的两个老问题。第一个是"单层序列建模的短视"。很多经典做法是拿用户最近的一次会话或最近N条点击记录,喂给一个RNN或者后来的Transformer,生成一个向量当作"当前兴趣"。这个做法对短期意图有效,但一旦用户的历史跨越多个会话,单层模型会因为序列太长而遗忘早期的关键信息,或者被近期的大量噪声行为淹没。
第二个问题是"用户向量与当前查询脱节"。很多模型无论用户搜什么,都用同一个用户向量去影响排序。这会带来一个很尴尬的局面:用户明明只在随手浏览美食内容,长期向量里却全是健身相关的偏好,这时候搜"蛋白质补充",模型反而把健身内容排在美食内容前面。用户向量必须随查询动态地"变焦",聚焦到和当前查询相关的部分,而不是拿一个笼统的整体画像去套所有查询。
这篇论文的做法,就是用层次RNN解决第一个问题,再用query-aware attention解决第二个问题。这两个设计是一套组合拳,单独拆开都有先例,但组合在一起并且针对搜索场景做适配,是这篇工作最核心的贡献。
2. 模型架构拆解:层次RNN如何把"长期"和"短期"分开建模
直接说结论:这个模型的本质,是把用户的搜索行为组织成"会话内序列"和"跨会话序列"两个层级,分别用RNN编码,然后把两个层级的表征拼起来,作为用户当前时刻的个性化上下文。
这里用RNN而不是更"时髦"的注意力模型,有一个很重要的原因:搜索行为天然是时序依赖的。用户上一个查询的成败,直接影响下一个查询的措辞和点击行为。RNN的循环结构天然适合刻画这种"上一步影响下一步"的链式依赖。而纯Transformer架构虽然能捕捉长距离依赖,但在"行为序列"这种噪声极大、长度较短、顺序极强的数据上,反而不如RNN迭代式地积累状态来得稳健。
2.1 第一层:query级RNN捕获会话中的即刻意图
先看低层。所谓query级RNN,操作的粒度是用户单次会话内的一个个行为事件。一次会话,可以简单理解为用户从打开搜索到离开搜索之间的一段连续操作。在这段时间里,用户可能连续搜了"最佳咖啡机""意式咖啡机测评""德龙还是飞利浦",然后点了几个链接。每个查询、每次点击,都是一个行为事件,这些事件按时间排列成一个序列。
这个序列被依次输入到第一层RNN中。RNN的隐藏状态会随着每个行为事件逐步更新,最终这个会话结束时,我们得到一个能概括"用户这个会话在干什么"的向量。这个向量代表了短期意图——用户此刻正在关心的事情、正在做的决策。
低层RNN的一个关键实现细节是:它不能只把查询词编码进去,还要把"用户在查询词上的行为反馈"编码进去。也就是说,用户搜了一个词之后,是点击了某个文档还是直接放弃、在哪个文档上停留了很久,这些反馈信息需要和查询词本身融合在一起,再作为RNN每个时间步的输入。否则模型只看到查询词,就丢失了"哪个结果真的吸引了用户"这个最有价值的信号。
2.2 第二层:session级RNN聚合跨会话的长期兴趣
第二层RNN的输入,是第一层每个会话结束时产出的那个会话表征向量。也就是说,高层的RNN把"一个个会话向量"当作新的序列元素来对待。用户的第1个会话、第2个会话、第3个会话……依次进入高层RNN。高层RNN的隐藏状态,自然而然就积累了"这个用户跨越很长一段时间的稳定兴趣模式"。
这个分层设计妙在哪里?它让两层RNN各司其职,不需要用一个单层模型去同时兼顾"这一刻想干什么"和"长期一贯喜欢什么"。单层模型要同时做到这两件事,隐藏状态的容量根本不够,而且梯度在很长的时间步上传导会迅速衰减。分层之后,低层只需要保留会话内的短期信息,高层只需要处理会话级别的变化规律,每一层的序列长度都大幅缩短,训练难度显著下降,泛化能力反而更好。
用户长期的兴趣在这里不是静止的。每次新的会话结束,都会产生一个新的会话向量,高层RNN的状态就会往前推进一步。这意味着用户的长期画像会随着行为不断演化——一个用户最近半年开始关注母婴用品,高层RNN的状态会慢慢从电子数码主导,漂移到母婴内容主导。整个过程是连续的、平滑的,不需要显式地做兴趣迁移的规则判定。
2.3 两层的管道关系:什么时候用短期,什么时候用长期
两层各自产出表征之后,怎么组合?论文的思路是:不要固定地加权,而是让"当前查询"来决定各需要多少。具体来说,在生成下一个结果的个性化打分时,模型会同时参考低层最近的会话隐藏状态、高层当前的会话级隐藏状态,以及当前正在处理的查询的表示。这三者通过注意力机制动态融合,而不是简单拼接或相加。
这里要特别强调一下时序对齐的问题。论文里的做法是:当用户正在第k个会话中执行第t个查询时,低层RNN的当前隐藏状态表示"这个会话从开始到t时刻的短期上下文",而高层RNN的输出表示"从用户第一次使用搜索到现在为止所有已完成会话的长期上下文"。注意高层RNN在用户正在进行的这个会话尚未结束时,不会被更新——它只见过已经完成的会话。这个设计很严谨,防止了信息泄漏:模型不会拿"未来的行为"来预测"当下的点击"。
3. Query-aware Attention:让用户表征随查询动态变化
前面提到的组合方式,就是这篇论文的另一个主角:query-aware attention。先说一个我在实际读这篇论文时的感受:这个注意力机制本身并不复杂,但它解决的问题非常精准,而且实现上有很多容易被忽略的坑。
3.1 静态用户向量的问题:一个比喻
想象你和朋友一起去逛书店。朋友对你很了解,知道你喜欢推理小说、偶尔看科幻。如果你问他"推荐一本书",他心里有一个大致的匹配范围。但如果你问他"我想找一本适合通勤路上看的轻松小说",他一定不会把上次推荐的硬核科幻翻出来。你问的问题变了,他会从对你的了解中取出不同的部分来回应你。
静态用户向量的问题,就像是你这个朋友只会背诵一份固定的"你的画像清单",不管你问什么,他都从这份清单的同一位置开始想。query-aware attention要做的事,就是让模型根据当前这个问题,动态地从用户的历史行为库中"检索"最相关的一段记忆。
3.2 注意力分数怎么算:具体到公式层面
注意力机制的具体形式可以这样理解:我们有用户的若干个历史行为表征(可能是多个会话的向量,也可能是多个历史点击文档的向量),这些向量构成"记忆库"里的key-value项。当前查询的表征作为query(这里"query"是注意力机制里的习惯叫法,正好和搜索里的"查询词"同名,容易混淆,下文用"查询向量"指代搜索词、用"检索向量"指代注意力机制里的query)。然后对记忆库里的每一项计算一个相关分数,分数高的项在最终输出中占更大的权重。
计算相关分数的典型方式是内积或者加性注意力。假设当前会话某个历史行为的表征为h_i,查询向量为q,那么分数可以写为:
s_i = v^T tanh(W_1 h_i + W_2 q)
其中W_1、W_2是两个权重矩阵,v是待学习的向量。得到的s_i经过softmax归一化,形成权重α_i。最终的用户个性化表征u_query = Σ α_i h_i。
这个计算过程的意义是:用户在搜"Java内存模型"的时候,模型会从他的历史行为里挑出和编程相关的会话,赋予它们更高的权重;那些关于咖啡机的历史,权重自然被压低。注意这里的权重不是一一对文档排名的直接权重,而是先作用在"用户表征"上,让用户表征向当前查询相关的方向偏移,再用这个动态表征去影响最终的排序打分。
3.3 注意力加在哪里:一个容易踩坑的细节
我在复现类似模型时栽过跟头的地方是:注意力到底加在高层的session表征上,还是低层的query表征上,还是两边都加?论文的做法是主要在跨会话的层次上做注意力——对用户的历史会话向量做加权。因为低层表征已经反映了当前会话的即时上下文,和当前查询的"最近距离"是天然相关的,不用再做注意力来拉近距离。而高层表征涵盖了大量"历史杂讯",必须靠查询向量去筛选。这个经验的背后逻辑是:越是长范围的记忆,越需要按需检索;越是短范围的上下文,序列位置本身就提供了足够的对齐信息。
4. 输入表征、训练目标与损失设计
模型框架有了,但要让它在真实数据上跑起来,还有很多工程细节决定成败。这一节聊聊输入表征怎么构造、训练样本怎么来、损失函数怎么设计。这些内容论文里有,但我更想结合工程直觉说说"为什么这么设计"。
4.1 查询与文档的编码方式
查询词和文档不能直接作为字符串喂给RNN,要做表征。比较经典的做法是学习一个词向量表,把查询词里的每个token映射成向量,然后平均或按词频加权得到查询向量。文档的处理类似,根据它被点击时的标题和摘要做编码。
这里有一个实战中非常重要的细节:查询词往往很短,容易有拼写噪声,而且词汇和文档标题用词差异很大,直接做词级别匹配效果非常差。更好的做法是把查询向量和文档向量都投影到一个共享的语义空间再算相关性。论文的模型结构里,其实隐含了这种共享空间设计——查询向量和其他行为事件向量会经过同一个参数矩阵做变换,再进入注意力计算。这保证了查询和历史行为之间可以在同一个空间里做比较。
4.2 训练样本构造:正样本和负样本怎么选的博弈
训练目标可以理解为"预测下一个行为"。给定用户当前的行为序列(从第一个会话到当前会话第t-1个时刻),模型需要预测用户在第t个时刻会点击哪个文档。这个问题的样本构造涉及一个非常关键的选择:负样本怎么选。
最朴素的做法是拿一批随机文档当负样本。但随机负样本太容易区分了,模型很快就能学会"见过的高频文档就是正样本、没见过的冷门文档就是负样本",根本学不到个性化的语义。实践中常用的做法是batch内负采样:同一个batch里其他样本的正例文档,作为当前样本的负例。这样负样本全是"其他用户的真实点击",难度高得多,模型被逼着去学习真正的用户偏好差异,而不是文档热度。
4.3 损失函数的选择:交叉熵和pairwise的区别
最常见的损失是交叉熵损失,把点击预测当作多分类问题,正样本对应的文档分数要比所有负样本高。它用softmax把得分转成概率,然后最大化正样本概率。这里温度系数(temperature)的设定很影响训练效果——温度越低,softmax越接近argmax,模型会更激进地去压正负样本得分差;温度太高,梯度又会太平滑,学不动。我自己的经验是初始温度设在1.0附近,如果收敛太慢再往0.5方向调。
另一种做法是pairwise排序损失,也就是让正样本得分比负样本得分至少高出margin。这类损失在搜索排序里更直观,因为它直接优化"顺序"而不是"分类概率"。论文偏重序列预测框架,所以主体用的是交叉熵思路,但工程落地时很多团队会把排序损失作为辅助任务一起训练,效果会更稳。
5. 实验设计与结果解读:提升到底来自哪里
看一篇和搜索相关的论文,最忌讳的是只看最后那个准确率数字,不看清楚提升的来源。这篇论文的实验部分,我认为至少回答了三层问题:和谁比、去掉哪个模块会怎样、模型在不同场景下谁的增益更大。
5.1 对照组怎么设置:单层RNN和静态向量是核心靶子
论文的对比实验,靶子其实很明确。一类是传统的个性化基线,比如基于TF-IDF匹配用户历史查询、用协同过滤算用户相似度这类老方法;另一类是单层序列模型,也就是不区分会话层级,把所有行为拉平放进一个RNN。除此之外,还应该对比"不带query-aware attention"的层次模型——也就是把跨会话表征做简单平均,或者直接用最后一个时刻的隐藏状态。
从我读过的同领域文献来看,这类对比的结果通常有很强的规律性:层次模型一致优于单层模型,而query-aware attention带来的提升幅度往往视数据稀疏度而定。行为数据越丰富、用户历史长度越长,注意力的增益越明显。因为长历史里包含的干扰信息多,不做筛选的话,模型等于在拿一大堆无关行为去稀释真正的用户兴趣。
5.2 消融实验的意义:确认两个模块都不可或缺
消融实验是这类论文的标配,但它给我的启发比单纯看效果数字更大。把query-aware attention拿掉,替换成固定的平均池化,效果会掉一截,这说明"动态聚焦"确实是必要的。把高层RNN拿掉,只保留低层会话内建模,效果掉的更多,这印证了长期跨会话信号在个性化里的重要性。
值得琢磨的是"掉多少"。如果数据集中每个用户的会话数都很少,那么高层RNN几乎学不到跨会话的模式,掉幅不明显。这提示我们:层次模型对数据量有门槛,绝不是数据越少越能靠架构硬撑,它对用户历史长度的要求其实是刚性需求。做工程选型时,如果你的产品大部分用户只有一两次搜索历史,这套架构的前期收益会相当有限。
5.3 几个值得复现的细节发现
论文里还有一些容易被忽略的发现,我用自己的复现经验来判断,确实站得住:
第一,查询词的所有历史点击,比只取点击过的文档本身更有信息量。用户在某次查询后点击了什么内容,反映的是"带意图的探索路径",比零散的兴趣标签含金量高。
第二,会话的切分方式对结果影响非常大。按30分钟无操作切分会话,和按50分钟切分会话,效果能差出一个显著区间。因为会话切分直接决定了低层序列的粒度,切得太粗,短期意图里混入无关的旧任务;切得太细,一个完整任务被拦腰截断,序列的连贯性受损。
第三,时间衰减和高层RNN是互补的。很多人以为RNN本身就能建模时间衰减,但实验显示,在行为序列里额外加入"停留时长""点击位置"等信息作为特征拼接入RNN输入,仍然有稳定的提升。RNN擅长建模顺序,但不擅长记录"确切的时间间隔"和"行为强度",这些必须靠特征工程补。
6. 从论文到工程落地:我的实践心得与踩坑记录
论文读完是一回事,把它搬进一个真实的搜索系统是另一回事。下面这些内容不是论文里写的,而是我在类似模型的落地实践中遇到的真实问题,写给想复现这套思路的同学参考。
6.1 行为稀疏用户如何兜底
最现实的问题:大量新用户、低频用户根本没有足够的历史会话,层次RNN对他们来说是空中楼阁。我的经验是把用户分层处理,行为丰富的用户走完整的层次模型,行为稀疏的用户退化为低层会话内模型,甚至连低层都不用,直接走关键词相关性排序。退化逻辑不能做得太复杂,一个简单的阈值判断即可:用户在近90天内的有效会话数小于3,就不进深度模型通道。
还有一种做法是跨用户冷启动,把相似用户群的会话当成"伪历史会话"补充进来。这个方向效果时有起伏,但成本高,我建议先不做,等主路径稳定了再考虑。
6.2 在线推理的算力预算
层次模型的推理链路天然比单层模型长。在线环境里,你不能让用户等上几百毫秒才出结果。常规的优化思路是:把用户表征的更新拆成离线+在线两步。离线阶段,每天或每小时批量跑一遍用户的层次RNN,把session级表征缓存起来;在线阶段,只有当用户在当前会话里产生了新的行为事件时,才增量更新低层状态,并重新计算attention加权后的动态用户表征。这样在线部分的计算量被压缩到只依赖当前会话的长度和用户历史会话的缓存向量,时延可控。
如果对时延更敏感,还可以把attention计算提前量化。比如用近似的最近邻检索,先在历史session向量里粗筛出top-K个候选,再对top-K做标准attention。精度损失很小,但省掉了大量内积计算。这是一个工程技巧,论文不会写,但实践里经常用。
6.3 这套架构除了搜索还能迁移到哪里
最后说点我自己的判断。这套"层次序列+查询感知注意力"的思路,可迁移的场景远比搜索广泛。凡是涉及"用户当前任务 + 长期兴趣"交织的问题,都可以套这个框架:
- 短视频推荐:用户这次刷视频的即时兴趣(低层),和他长期的内容偏好(高层),正好对应两级RNN。
- 商品推荐:用户当前购物车里的需求(低层会话)和跨季度的购买习惯(高层会话)天然分层。
- 客服机器人:当前对话里的多轮上下文和用户过去所有工单的历史,也是典型的双层结构。
在这些场景里,query-aware attention里的"查询"换成"当前正在浏览的商品""当前对话的最新一句",机制完全不用改。
我的体会是,这类架构落地的关键从来不在于把模型推得更复杂,而是在于把数据按正确的粒度切开、把计算按延迟预算拆开。论文给出的是框架和方向,真正让它产生价值的是在你自己的数据上反复验证、反复调参会话切分阈值和负样本策略的过程。这个过程没有捷径,但跑通了之后,提升往往是实打实的。