我是如何用单机实现亿级规模题库去重的?
背景
最近工作中遇到了一个问题:如何对大规模题库去重?公司经过多年的积累,有着近亿道题目的题库,但是由于题目来源不一导致题库中有很多重复的题目,这些重复的题目在检索时,除了增加搜索引擎的计算量外,并不会提高准确率。此外由于题目过多,搜索引擎往往采取了截断策略,只对一部分题目进行计算,这导致了某些正确的题目反而得不到计算,拍搜准确率甚至不增反降。所以对于一个搜索引擎来说,虽然初期增加题目数量往往可以大幅提高拍搜准确率,但是当题目量大到一定程度时,反而会由于计算量跟不上导致准确率下降。如何尽可能的去除重复题目显得尤为重要。
一些尝试方案
比较MD5值
对每道题目计算其MD5值作为签名,这样在新增题目时,只要判断题库中是否有相同的MD5值即可。
这种方案只适用于两道题目一模一样的情况,而现实中题目往往不只是这样。
- “A比B大10"与"B比A小10”
- “小红买10本书”与“小明买10本书”
- “今天空气温度为10度”与“今天的空气温度为10度”
这些应该是重复题,但是MD5值不同,没法去重。
利用最长公共子序列和最小编辑距离算法
利用最长公共子序列算法与最小编辑距离算法计算两个题目的相似度,如果相似度大于一定比例,例如大于90%,就认为是重复的题目。
这个方法理论上可行,但是计算量太大。假如文档数为N,平均文档长度为M,那么计算量大致为:$ O(N2*M2) $ 。
假设N=1000万,M=200,则计算量约为 $ 4*10^{18} $ ,笔者线下可用机器有限,没有这么大的计算能力。但是如果能够把相似的题目归拢到一起,然后去比较这一小撮题目中两两相似程度,这个还是可行的。
Jaccard相似度
为此,我特意看了两本书:《信息检索导论》的19.6章节以及《大数据-互联网大规模数据挖掘与分布式处理》的3.2与3.3节。这里面讲述了如何计算两个集合的Jaccard相似度:$ \frac{|A \cap B|}{|A \cup B|} $ 。这个公式对于去重来说没什么卵用,因为计算量还是那么大。所以这两本书还特意介绍了与其等价的算法:转换成随机全排列,基于概率算法去计算Jaccard的近似值。这个转换的证明本文不赘述,有兴趣的小伙伴直接去看这两本书。但是这里面有一个有意思的问题也是计算Jaccard相似度最关键的一步:如何对一个超级大的N生成一个0~N-1随机全排列?我这里给出一个近似算法,学过初等数论的小伙伴应该对下面的定理不陌生。
- 定理: $ y = (a*x+b) \mod n $ ,如果a与n互质(即a与n的最大公约数为1),当x取遍0n-1时,y取遍0n-1。
证明:假如存在两个数 $ x_1 $ 和 $ x_2 $,使得 $ y_1 = (a * x_1 + b) \mod n = y_2 = (a * x_2 + b) \mod n $ ,则 $ (a * x_1 + b) \% n = (a * x_2 + b) \% n $ ,得出 $ (a * x_1 + b - a * x_2 - b) \% n = 0 $ ,继而得到 $ a * (x_1 - x_2) \% n = 0 $。由于a与n互质,最大公约数为1,所以得出 $ x_1 - x_2 = k * n $ ,即 $ x_1 = x_2 + k * n $。当 $ x_1 $ 和 $ x_2 $ 都小于n时,k只能等于0,即 $ x_1 = x_2 $。这就说明当x取遍0n-1时,其余数肯定不重复,由于余数的取值范围也是0n-1,所以结论得证。
这样,当我们知道n时,只要找到与n互质的100或者200个数就行,甚至可以找到小于n的100个或者200个素数(素数筛法大家自行百度),然后再随机生成100次到200次b,就能构造出一批这样的函数。
例如,a = 3,b = 4, n = 8
x = 0 y = 4
x = 1 y = 7
x = 2 y = 2
x = 3 y = 5
x = 4 y = 0
x = 5 y = 3
x = 6 y = 6
x = 7 y = 1
虽然这个概率算法能够降低一些计算量,但是我还是不能够接受。因为我们现在的关键问题是找出相似的一小撮,并在这一小撮中进行更精细化的判断策略,怎么找到这一小撮咧?
利用线上拍搜日志进行挖掘
正所谓具体情况具体分析,不能一味追求高科技却忽略现实条件。比如百度也有去重策略,但是其最后应用到线上的并不是Jaccard相似度,而是找文档中最长的几个句子,根据这几个句子是否一样判断两个文档是否重复,而且准确率出奇的好。所以,我们也要具体问题具体分析。
观察一下拍搜流程,检索日志中会记录每次搜索结果中几个匹配程度最高的文档id,那么我就可以认为这几个文档是一个小簇,没有必要再重新聚簇。此外由于拍搜的优化策略极多,准确率极高,这比我自己再重新发明一个聚簇算法要省事并且效果好。有这么好的日志在手,就要充分利用起来。接下来我就详细说说我是如何实现去重策略的。
日志格式如下:
[[1380777178,0.306],[1589879284,0.303],[1590076048,0.303],[1590131395,0.303],[1333790406,0.303],[1421645703,0.303],[1677567837,0.303],[1323001959,0.303],[1440815753,0.303],[1446379010,0.303]]
这是一个json数组,每个数组中有题目的ID和其得分。
日志选取
选取题目ID得分比较高的日志作为候选日志。这么选取是因为线上的图像识别不能保证百分百准确,如果图片质量特别差,那么根据识别内容检索到的题目之间差别较大,可能根本不是一类。
聚簇
初始集合建立
对于每条日志,由排在第一位的ID作为簇标识,其它元素作为簇中的元素。
集合求并
看如下样例:
A -> B,C,D
E -> C,D,F
由于两个集合中有相同的ID,我们推测这两个集合其实属于一个簇,如何实现两个集合的并?利用并查集算法(自行百度之,参加过编程竞赛的小伙伴应该都不陌生,我写的一个样例代码:https://github.com/haolujun/Algorithm/tree/master/union_find_set ),并查集能够出色的完成集合合并操作。例如,可以利用并查集的join操作完成两条日志的合并。
union_find_set.join(A,B)
union_find_set.join(A,C)
union_find_set.join(A,D)
union_find_set.join(E,C)
union_find_set.join(E,D)
union_find_set.join(E,F)
调用完操作后,我们会发现A,B,C,D,E,F都属于同一个集合。
集合元素限制
在实际测试时发现,某些集合中的题目数量可能会达到百万,这种情况出现是因为聚类过程中的计算偏差导致的。比如:A与B相似,B与C相似,我们会把A,B,C放到一个簇中,但是实际上A和C可能不相似,这是聚类过程中非常容易出现的问题。簇过大会加大后面的精细计算的计算量,这是一个比在大题库中去重稍简单的问题,但是也非常难。考虑到题库中重复题目不会太多,可以对每个集合大小设置上限元素数目,如果两个将要合并的集合元素总数大于上限,则不将这两个集合合并,这个利用并查集也非常容易实现。
精细计算
如何判断两个题目是否重复
现在得到的簇是一个经过拍搜的结果聚合的,但是拍搜有一个问题就是检索使用的文字是由OCR识别生成的,其中难免会有识别错误,搜索引擎为了能容忍这种错误,加入了一定的模糊策略,这导致簇中的结果并不完全相似,所以精细计算是必要的。那么如何比较两个题目是否是重复的呢?特别是对于数学题这种数字和运算符、汉字混合的题目,该如何办?经过长时间分析发现,不能够把数字、字母与汉字同等比较。数字、字母如果不相等,那么八成这两道题是不同的;如果数字、字母相同那么汉字描述部分可以允许一些差异,但是差异也不要太大。这就得到了我最后的精细去重策略:分别提取题目的汉字和数字、字母、运算符,数字、字母、运算符完全相等并且汉字部分的相似度(可以使用最小编辑距离或者最长公共子序列)大于80%,就可以认为两道题目相同。
“A比B大10"与"B比A小10” 数字与字母组成的字符串不相等,不认为重复
“小红买10本书”与“小明买10本书” 数字字母相同,汉字相似度大于80%,认为重复
“今天空气温度为10度”与“今天的空气温度为10度” 数字字母相同,汉字相似度大于80%,认为重复
虽然这个策略不能百分百去重所有重复题,但是能确保它能去重大部分重复题。
保留哪些题目,去除哪些题目?
考虑到搜索引擎在存储倒排是按照题目ID大小进行排序的(存放ID与ID之间的差值),所以留下小的ID去掉大的ID非常必要,这个不难实现。
周期性迭代
我们的去重算法是基于日志进行的去重,那么可以每次去重一部分,上线后再捞取一段时间内的日志进行去重,这样不断的迭代进行。
计算量还大么?
根据单机的计算量,一次捞取一定数量的日志进行去重,单机就可以完成,不需要集群,不需要分布式。
结语
聪明的小伙伴可能发现,我投机取巧了。我并没有直接对题库去蛮力去重,而是从拍搜日志下手,增量的一步步的实现题库去重,只要迭代次数足够,可以最终去重所有题目,并且每次去重可以实实在在看到效果,可以更方便调整策略细节。所以,在面对一个问题时,换一个角度可能会有更简单的做法。