工业界搜索引擎-2-相关性

定义与分档

工业界做搜索相关性的流程一般为:

  • 指定相关性标注规则 -> 人工标注数据 -> 做监督学习训练模型 -> 部署到线上做推理

数据很关键, 很多算法层面的优化对比与更多的数据支持, 数据支持会非常容易地使指标涨很多.

数据

指定标注规则

  • 一般是搜索产品和搜索算法团队来制定相关性标注的规则.
  • 人为将(q, d) 之间的相关性划分为4个或5个档位.
  • 相关性分档规则很重要! 如果标注规则有漏洞, 迟早有一天需要把标注规则推倒重来, 那么之前积累的数据就都得丢弃, 非常耗费时间和财力.

标注数据

产品和算法团队监督和指导标注团队的工作, 累积数十万数百万的(q, d)数据. 一旦标注过程中发现新问题且按已有规则无法解决, 那么产品和算法团队就需要添加新的规则.

标注的流程:

  1. 算法团队抽取待标注样本: 从搜索日志中随机抽取n条查询词. 有高频查询词, 也有中,低频查询词
  2. 给定其中的一个q, 从搜索结果中抽取k篇文档, 组成二元组(q,d1),(q,d2)...(q,dk). 尽可能使4个相关性档位样本数量维持平衡.
  3. 不可以直接取搜索结果页排名topk的文档, 否则高档位文档过多, 低挡位文档过少.
  4. 由产品和算法团队监督标注过程和验收结果. 遇到难以界定的档位的(q,d), 需要产品和算法团队做界定和解释.
  5. 一条样本至少两人标注, 两人标注的结果需要有一致性, 标注不一致或者小于某个阈值会直接丢弃.
  6. 产品团队会对标注进行抽查, 将产品团队的标注记为ground truth, 判断标注团队的准确率, 只有大于某个阈值才被接受.
  7. (实用经验)抽查的时候可以事先"埋雷", 产品团队预先标注一批数据, 然后将数据埋到标注数据中. 标注团队工作结束需要抽查的时候, 只需要看埋的雷的准确率即可.

意义: 只要标注质量合格, 那么标注的数据越多, 那么训练出的模型效果就越好(好的非常明显).

相关性相关问题

需求匹配

相关性是指d能否满足q的需求或是q提出的问题, 与文本是否匹配并无关系.(需求匹配, 而非字面匹配)

  • 文本不匹配但是相关: q=谁掌握芯片制造的尖端技术, d=全球最先进的光刻机都由荷兰ASML公司制造...
  • 文本匹配但是不相关: q=巴伦西亚旅游, d=我去巴伦西亚旅游, 吃到了最好最正宗的西班牙海鲜饭, 回来研究了一番, 这个视频给大家介绍西班牙海鲜饭的做法...

多意图

查询词可能有多意图, 文档d只需要命中一种意图就算相关.

比如: q=黑寡妇

那么d里面无论是漫威电影里的黑寡妇, 又或者是蜘蛛, 无论用户的意图是什么, 这些都算相关.

上位词和下位词

搜索上位词出现下位词,判定相关; 搜索下位词出现上位词, 判定不相关.

比如你搜NLP相关模型出现bert, 算搜上位词出现下位词, 判定相关;

搜索bert结果出现NLP相关模型的泛答, 算搜下位词出现上位词, 判定不相关.

丢词判定

判定大前提: 丢词之后看q的主要需求是否发生变化

  • 丢掉核心词, 判定不相关(主要需求发生变化, 例如: q=情人节餐厅, d=情人节礼物)
  • 丢重要限定词, 判定不相关(主要需求发生变化, 例如: q=初中物理考点, d=高中物理考点)
  • 丢不重要限定词, 判定相关(主需求不变, 例如: q=精彩好莱坞电影, d=好莱坞电影top10)

相关性标注应该仅考虑相关性

相关性标注过程中应该仅考虑相关性, 而不应该考虑内容质量和时效性等其他因素.

举个例子:

  1. q=什么药物可以治疗新管, d=一种虚假广告, 声称某种草药可以治疗新管, 并用某种不合常理的逻辑讲解了原理.
  2. q=上海落户政策, d=一篇过时的文章, 介绍了2015年上海落户的政策.

对于1这种算是内容质量低, 但是相关性上并没有问题, 因此q和d依然算具有相关性.
对于2这种算是时效性低, 但是相关性上也没有问题, 因此q和d算相关.

总而言之, 相关性训练的数据应该只关注相关性, 一旦考虑了其他因素, 模型就得去学习其他繁杂的因素, 关注点就不再是纯相关性了, 效果可能就不好了.

评价指标

  • pointwise: 单独评价每一个(q,d)二元组, 判断预测的相关性分数和真实标签的相似度.
  • pairwise: 对比(q, d1)和(q, d2), 判断两者的顺序是否正确.
  • listwise: 对比(q, d1), (q, d2)...(q, dk), 判断前k的整体关系正确程度.

流程:

离线

  1. 实现标注数据, 划分训练集和测试集.
  2. 离线部分: 完成训练之后, 计算测试集的AUC和PNR.

线上评测

  • 一个搜索session中, 对于用户搜索q, 搜索页面会出现文档d1, d2...dk.
  • 我们首先从搜索记录中抽一批session, 其中覆盖高中低频的查询词.
  • 对每个session, 我们取排序最高的k篇文档(k的取值依据用户平均浏览深度, 比如20).
    • 由于高频查询词靠前文档指标过高, 我们就需要扩大k值(比如取k=40).
    • 与之对比, 低频查询词就可以设置较小k, 比如k=20.
  • 人工标注相关性分数, y1,y2...yk.(因为结果是线上实时结果, 所以只能等评估的时候人工标注; 每次评估都要标注, 所以一般作为月度评估.)
  • 对于每一个session我们都会有一个DCG分数, 作为此session的评价指标.
  • 对所有session的DCG取平均, 就可以来评价线上相关性模型.

pointwise(AUC)

  1. 测试集相关性转化为二分类问题. 高,中两档合并记为标签1;低,无两档合并记为标签0.
  2. 相关性模型输出预测值 $ p \in [0, 1] $.
  3. 用AUC对模型进行评价.

传统机器学习内容, 不多看了

pairwise(PNR)

  1. 依据模型估计的相关性分数p对文档排序, 由于估计会出错, 所以真实相关性不一定降序.
  2. 假设我们有n个打分, 那么就存在 $ C_n^2 $个pair.
  3. 记算其中正序对数量和逆序对数量, $ PNR = \frac{正序对数量}{逆序对数量} $.

存在问题: 即使PNR相同, 其内部排序其实还是可能存在问题. 比如下图中, 虽然PNR相同, 但是右边的预测结果就比左边要好, 这是因为右边结果中, 高相关文档排在了前面. 使用Listwise的判定指标也可以达到相似目的.

listwise(DCG)

  1. 依据模型估计的相关性分数p对文档降序排序, 把文档记作$ d_1, d_2, ...d_n $.
  2. 假设按这个排序之后, 真实相关性分数为$ y_1, y_2, ...y_n $.
  3. 理想情况: y1>=y2>=y3...>=yn, 即预估顺序和真实顺序相同, 此时listwise指标最大化.(pairwise指标也是最大化)
  4. 假如出现逆序对, pairwise和listwise的指标都会下降; 假如已经存在逆序对, 逆序对出现前后对pairwise无影响, 但是对listwise有影响.

CG(Cumulative Gain)

\[ CG@k = \sum_{i=1}^k y_i \]

CG最大化的情况即相关性最高的k篇文档排在前面即可, 需注意交换前k篇文档的顺序不会改变CG, 这也是CG的缺点.

缺点: CG没能关注前k篇文档的内部顺序.

DCG(Discounted Cumulative Gain)

DCG相对CG的优化: 前面的文档更容易被用户看到, 所以它们理应更重要, 所以它们权重应该更大.

\[ DCG@k = \sum_{i=1}^k \frac{y_i}{log_2(i+1)} \]

DCG的最优解必须保证前k篇文档在前面的同时, 前k篇文档内部顺序也得正确, 一旦前k出现逆序对, DCG指标都会下降.

NDCG

\[ NDCG@k = \frac{DCG@k}{IDCG@k} \]

其中IDCG为最优DCG的取值, 因此NDCG永远在0和1之间.

看起来做了归一化还不错, 但是却存在问题:

比如:

  • 召回返回一批相关性极低的文档, 但是此时排序做的好, 因此NDCG可以很高, 比如0.95.
  • 可是即使NDCG很好, 文档的相关性却很差, 即使说这其实是召回导致的结果, 并不是排序的问题.
  • 与之对比, DCG的指标就可以反映出这个问题, 一旦全局相关性差, 通过DCG指标就可以看出, 而NDCG就看不到这种信息.

因此: DCG可以看到最终结果的文档相关性; 而NDCG只能看到排序部分的结果有多趋近于最优排序, 即仅关注排序效果.

文本匹配

背景以及搜索引擎链路

在深度学习技术成熟以前, 当时搜索引擎只能用文本匹配来做相关性.

无论召回还是排序, 都需要计算查询词和文档的相关性.

  1. 召回
    • 打分量: 数万
    • 模型: 文本匹配分 + 线性模型 或者 双塔bert(推理代价不大)
  2. 粗排
    • 打分量: 数千
    • 模型: 双塔bert或者单塔交叉bert
  3. 精排
    • 打分量: 数百
    • 模型: 单塔交叉bert

传统搜索引擎有几十种人工设计的文本匹配分数, 作为线性模型或者树的特征, 然后使用模型预测相关性.

词匹配分数(词频匹配)

tf-idf

  1. tf->term frequency(词频): 即词t在文档d中出现的次数.
  • 存在问题1: 文档越长, tf越大, 这并不合理.

  • 问题1解决方案: 取文档长度加权即可.

  • 存在问题2: 词频加权一视同仁, 比如一个文档中出现"the cat", "the"和"cat"会被同等对待. 然而"the"出现的频率非常高, 几乎所有文档里面都会出现, 与之对比, "cat"包含的信息量远大于"the". 所以理应给"cat"更高的权重.

  • 问题2解决方案: 同时考虑idf

  1. idf->inverse document frequency

idf只取决于文档数据集

\[ idf_t = log\frac{N}{df_t} \]

其中df_t为词t在多少文档里面出现过.

对于人工智能论文, "深度学习"的idf就很小, 因为人工智能论文会高强度出现"深度学习";
与之对比, 维基百科里"深度学习"的idf就很大.

idf衡量term重要性, idf越大就代表term越重要.

  1. tf-idf

两者结合就是tf-idf, 其形式有很多种, 比如

\[ TFIDF(q,d) = \sum_{t \in Q} \frac{tf_{t,d}}{l_d} \cdot idf_t \]

又或者

\[ TFIDF(q,d) = \sum_{t \in Q} log(1+tf_{t,d}) \cdot idf_t \]

BM25(Okapi Best Match 25)

tf-idf的一个变体.

\[ BM25 = \sum_{t \in Q} \frac{tf_{t,d} \cdot (k+1)}{tf_{t,d} + k \cdot (1 - b + b \cdot \frac{l_d}{mean(l_d)})} \cdot ln(1 + \frac{N-df_t+0.5}{df_t+0.5}) \]

其中l_d为文档长度, k,b为参数, 通常 $ k \in [1.2, 2] $, b=0.75.

缺点

tf-idf和BM25都隐含了词袋模型的假设, 即只考虑词频而不考虑上下文和词顺序.

比如"黑/衬衫/白/裤子"和"白/裤子/黑/衬衫"就是完全一样的分数.

LSA和LDA也类似.

而最新的深度学习模型, 从久远的RNN到目前的BERT,GPT, 效果都远优于词袋模型.

词距分数(term proximity)

举例: Q = 亚马逊/雨林

d = 我在亚马逊买了一本书, 介绍了东南亚热带雨林的...

虽然Q和d文本匹配, 但是两者并不相关(q的需求没有得到满足)

同样, 如果使用tf-idf或者BM25, 这些都会导致错误的结论.

解决方案

要避免这类错误, 就需要用到词距.

词距: q中两个词在文档d中出现的位置间隔了多少个词; 词距越小, 则q和d越相关, 否则越不相关.

OkaTP

  1. 记词t在文档d中出现的位置集合为O(t,d).
  2. 假设t出现在文档d的27,84,98几个位置, 则O(t,d) = {27,84,98}. 可以发现|O(t,d)| = tf(t,d)

词距计算如下:

\[ tp(t,t',d) = \sum_{o \in O(t,d)}\sum_{o' \in O(t', d)} \frac{1}{(o-o')^2} \]

查询词在文档中出现次数越多, 相距越近, 词句分tp(t,t',d)就越高

OkaTP:

\[ OkaTP = \sum_{t,t' \in Q, t \neq t'} \frac{tp_{t,t',d} \cdot (k+1)}{tp_{t,t',d} + k \cdot (1 - b + b \cdot \frac{l_d}{mean(l_d)})} \cdot min(idf_t, idf_{t'}) \]

OkaTP同时考虑了词频和词距, 是一个很好的匹配分数.

总结

tf-idf, BM25等分数依据词频, tp等分数除了词频还考虑了词距, 是一步一步演变过来的, 但是依然远远比不上能够解决语义问题的深度学习模型.

不过在召回的海选阶段, 文本匹配因为计算快, 可能还是可以有一定的使用.

两种BERT模型

现代搜素引擎一般都是用bert来计算q和d的相关性.

搜素引擎里存在两种bert模型:

  • 交叉bert模型(单塔): 准确性好, 推理代价大, 通常用于链路下游(粗排和精排).
  • 双塔bert模型(双塔): 虽然不够准确, 但是推理代价小, 常用于链路上游(召回和粗排).

交叉bert模型

本质思想是将查询词, 文档标题, 文档内容之类的所有信息全部丢到一个text里面, 不同类别用[sep]做分隔符. 在这基础之上, 自注意力就可以同时对查询词和文档做交叉.

然后就是bert的基本流程:

  1. 首先tokenization, 将文本信息进行切分, 可以按字密度, 也可以按词密度.
    • 字密度: 按汉字/字符作为token.(词表小;实现容易, 第一版bert可以使用, 可以作为好的baseline).
    • 字词混合密度: 做分词, 将分词结果作为tokens.(词表大;难以实现;效果好;序列长度短可以减小推理时间).

bert的计算量是token数量的超线性函数(线性和平方之间, fc线性, attention平方), 为了控制推理成本一般会对token数量限制. 如果文档超出token数量限制, 则会被截断或者做摘要.

  1. 切分出来的token做embedding, token_embedding和position_embedding就还是老样子, segment embedding这里会区分不同类别信息, 比如查询词和文档内容就依赖segment embedding区分.

  2. 训练,推理,计算.

然而交叉bert的计算量很大, 对于每一个(q,d)二元组都需要进行推理, 精排有几百二元组, 粗排则是有几千, 代价很大, 因此需要做推理降本.

redis:

  • 常见推理降本方法是空间换时间, 比如用redis这样的k,v数据库缓存<(q,d),score>二元组.
  • 其中(q,d)作为key, score作为value存在redis里; 如果命中缓存, 则可以避免计算.
  • 因为用户搜索大部分集中在部分查询词里, 因此这样的方式可以大幅减小计算量.
  • 不过因为内存还是存在大小限制, 因此需要进行内存清理, 比如用LRU机制.

模型量化:

另一种推理降本方案, 模型参数一般是float32, 我们可以将其压缩为int8来加速推理, 虽然会导致精度丢失. 量化技术就是让丢失的精度尽量小.

  • 训练后量化(post-training quantization, PTQ); 训练不变, 全部训练完了再压缩float32到int8.
  • 训练中量化(quantization-aware training, QAT); 训练量化一起做, feedforward的时候用低精度计算, backpropagation用浮点数.

文本摘要技术:

利用文本摘要减小序列长度从而减小推理时间.

  • 文档长度超出上限的时候使用摘要替换文档.
  • 在文档发布的时候计算摘要, 可以是抽取式, 也可以是生成式(大模型).

双塔bert模型

本质就是双塔, 只是用户塔和物品塔替换成了bert.

然后就是和推荐系统一样, 查询词塔计算量很小, 线上可以实时计算; 文档塔计算量大, 线上计算的话代价不可接受, 因此都是离线计算: 文档发布的时候做一次推理, 然后把文档的向量存起来, 等线上需要文档向量的时候直接读取就行, 不需要再计算.

bert的训练

bert的训练区别于传统NLP, 其存在四个过程: 预训练, 后预训练, 微调, 蒸馏.

传统的NLP任务一般就是预训练+微调就结束了, 这里多了后预训练和蒸馏两步; 后预训练是一种新技术, 对效果的提升非常大; 蒸馏则是为了提升推理效率而做的改进.

后预训练

听说最开始是百度开始做的, 效果很好.

其分为三个步骤:

  1. 从搜索日志中挑选十亿对(q,d).
  2. 自动生成标签: 将用户行为x映射到相关性分数y_hat.(对最终效果影响很大, 需要注意)
  3. 用(q,d,y_hat)训练模型

挑选(q,d)

  1. 搜索日志记录用户每次搜索的查询词q和搜索引擎返回的文档d.
  2. 依据搜索日志抽取查询词q, 这里需要为非均匀抽样从而覆盖一定比例的高中低频q.
  3. 给定q和搜索日志记录到的返回文档d1,d2,...dn以及模型估计的相关性分数(精排打分而非真实分数).
  4. 依据相关性分数, 抽取n篇文档的一个子集, 均匀覆盖各个相关性档位.

生成相关性分数

  1. 步骤1依据搜索日志选出十亿对(q,d).
  2. 对搜索日志做统计, 得出(q,d)的点击率和各种交互率, 记作x.
  3. 由于y和x之间是存在某种关系的(比如用户点击多说明文档和查询词相关).
  4. 因此我们可以人工标注几万条数据来训练一个模型t,使得y_hat = t(x).
    • 需要注意这里的t一般是gbdt类的小模型, 几万条样本足够训练这个小模型.
    • t的输入只能是点击率交互率这类x包含的信息, 而不应该包含文本信息.
    • 绝对不可以用bert打分, 否则会成反馈回路(bert打分训练t,t打分训练bert).
  5. 对于我们获取到的十亿样本, 我们可以用t打分, 得到十亿条(q,d,y_hat).

用生成的数据做监督学习

基于前面生成的数据来做监督学习, 具体有三个任务.

  1. 回归任务, 起到保值作用, 有利于AUC.
  2. 排序任务, 起到保序作用, 有利于正逆序比.
  3. 预训练MLM任务, 防止数据清洗(这是因为后预训练数据量很大,达到十亿的量级,数据很容易被清洗,因此需要加上预训练任务).

总结

后预训练为什么有效:

  1. 大幅增加了有标签文本的数量(百万到十亿).
  2. 用户行为x和y确实是有关系(这个本身就是后预训练的假设前提).

说白了就是受到数据标注问题的限制, 后预训练能够提供更多的数据支持, 哪怕提供的数据有噪声, 也足够很大程度得提升模型的性能了.

不过个人感觉后预训练很大程度受到中间模型t的影响, t的性能好可以直接导致10亿数据的质量提升, 从而模型后预训练效果就好, 反之如果t性能不行, 就会导致巨量数据质量低下, 从而很大程度影响模型最终效果. 大概这也是为什么up说后预训练较困难吧.

微调

  • 微调是监督学习的形式, 用人工标注的数据进行训练, 使得模型可以估计q和d的相关性.
  • 可以把这个任务看作回归任务, 也可以看作是排序任务.
  • 回归任务让预测值p拟合y, 起到"保值"的作用(p越接近y越好); 排序任务让p的序拟合y的序, 起到"保序"的作用(p和y的远近无所谓, 只要求排列顺序越相似越好)

回归任务

  • 存在数据(q1,d1,y1),...(qn,dn,yn).
  • 模型预测(qi,di)的相关性为pi.
  • 我们通过最小化损失函数$ \frac{1}{n} \sum^n_{i=1}loss(y_i,p_i) $来使pi尽量接近yi.

损失函数可以有:

均方差: $ MSE_Loss(y_i, p_i) = \frac{1}{2}(y_i-p_i)^2 $.

交叉熵: $ CE_Loss(y_i, p_i) = -y_i \cdot ln(p_i) - (1-y_i) \cdot ln(1-p_i)$

排序任务

  • 存在数据(q1,d1,y1),...(qn,dn,yn).
  • 模型预测(qi,di)的相关性为pi.
  • 如果存在yi > yj, 损失函数应该鼓励pi-pj尽量大(即模型预测的序和真实序一致).
    • 如果pi >= pj(模型预测正确), (i,j)为正序对.
    • 如果pi < pj(模型预测错误), (i,j)为逆序对.
    • 模型应该鼓励正序对, 惩罚逆序对.

Pairwise Logistic Loss: $ \sum_{(i,j):y_i>y_j} ln[1+exp(-\gamma \cdot (p_i-p_j))]$.

总结

  • 微调部分可以将预测相关性的任务看作回归任务也可以看作排序任务.
  • 回归任务可以用MSE或者交叉熵, 有利于提升AUC.
  • 排序任务可以用Pairwise Logistic Loss, 有利于提升正逆序比.
  • 也可以同时用AUC和正逆序比做评价指标, 这时就应该同时使用两类损失函数.

注意,不可以把相关性预测看作多分类任务, 因为相关性之间是有序的而不是无序的, 需要特别注意.

蒸馏

背景

  • 用户每搜索一个词, 排序就需要用相关性BERT模型对数百数千对(q,d)打分.
  • bert模型越大, 计算量就越大, 但是预测也越准.
  • 精排通常用4-12层交叉bert, 粗排用2-4层交叉bert(或者双塔bert).

为什么要蒸馏

那么问题来了, 下面两种方法, 谁的性能更好呢?

  1. 直接训练小模型(2-12层).
  2. 训练48层大模型, 然后从48层模型蒸馏小模型.

结论就是训练大模型然后从大模型蒸馏小模型的结果要比直接训练小模型更好.

UP分享的工业界经验:

  • 48层对比12层, AUC提高2%以上.
  • 48层蒸馏12层, AUC几乎无损.
  • 48层蒸馏4层, AUC损失0.5%.

也就是说, 48层bert和蒸馏之后的4层bert效果差的不算很多, 即使说参数量少了快100倍; 然而如果不是蒸馏而是直接去训练4层bert的话, 效果会差到几乎不能用.

如何做蒸馏

  • 首先48层模型依然是流程走一遍: 预训练, 后预训练, 微调, 作为teacher.
  • teacher越大, 本身越准确, 蒸馏出的模型也相对越准确. 具体用多大的teacher取决于公司算力.
  • 准备几亿对(q,d), 用teacher给(q,d)打分得到$ (q,d,\tilde{y}) $.
    • 蒸馏的数据量一般越大越好.
    • 数据量少于1亿, 蒸馏的AUC损失会很大.
    • 数据量大于10亿, 边际效益很小.
  • 用生成的$ (q,d,\tilde{y}) $做监督学习训练student.
    • 只训练一个epoch(一亿样本2epoch不如两亿样本一个epoch).
    • 与微调相同, 同时用回归和排序任务.

小技巧

  1. student先预热再蒸馏.
  • 预热即先做预训练, 后预训练, 微调来训练student来达到初始化student的目的.
  • 预热之后再用蒸馏的数据训练会比直接初始化student要更好.
  1. 不要逐层蒸馏.
  • 逐层蒸馏即让student中间层拟合teacher中间层.
  • 用相同的算力, 直接拟合 $ $会优于逐层蒸馏.
  1. 多级蒸馏和单级蒸馏谁更好?(没定论)
  • 多级蒸馏即: 48层->12层->4层
  • 单级蒸馏即: 48层->4层

有争议, 不过更多人倾向于单级蒸馏, 与其增加蒸馏次数, 不如增加数据量.

整体流程: