面試|海量文字去重~simhash
simhash演算法是google發明的,專門用於海量文字去重的需求,所以在這裡記錄一下simhash工程化落地問題。
下面我說的都是工程化落地步驟,不僅僅是理論。
背景
網際網路上,一篇文章被抄襲來抄襲去,轉載來轉載去。
被抄襲的文章一般不改,或者少量改動就發表了,所以判重並不是等於的關係,而是相似判斷,這個判別的演算法就是simhash。
simhash計算
給定一篇文章內容,利用simhash演算法可以計算出一個雜湊值(64位整形)。
判別兩篇文章是相似的方法,就是兩個simhash值的距離<=3,這裡距離計算採用漢明距離,也就是2個simhash做一下異或運算,數一下位元位=1的有N位,那麼距離就是N。
現在問題就是,如何計算文字的simhash?
分詞+權重
首先需要將文章作分詞,得到若干個(片語,權重)。
分詞我們知道很多庫都可以實現,最常見的就是結巴分詞。權重是怎麼得來的呢?
權重一般用TF/IDF演算法,TF表示片語在本文章內的頻次比例,出現越多則對這篇文章來說越重要,文章分詞後TF可以立馬計算出來。
IDF是片語在所有文章中的出現比例,出現越多說明片語對文章的區分度越低越不重要,但是IDF因為需要基於所有文章統計,所以一般是離線去批量計算出一個IDF字典。
結巴分詞支援載入IDF詞典並且提供了一個預設的詞典,它包含了大量的片語以及基於海量文字統計出來的IDF詞頻,基本可以拿來即用,除非你想自己去挖掘這樣一個字典。如果文章產生的分詞在IDF字典裡不存在,那麼會採用IDF字典的中位數作為預設IDF,所謂中庸之道。
所以呢,我建議用結巴分詞做一個分詞服務,產生文章的(分詞,權重),作為simhash計算的輸入。
TOP N片語參與計算
文章分詞產生的片語太多,一般我們取TF/IDF最大的N個,這個N大家看心情定。
對於每個片語,套用一個雜湊演算法(比如times33,murmurhash…)把片語轉換成一個64位的整形,對應的二進位制就是01010101000…這樣的。
接下來,遍歷片語雜湊值的64位元,若對應位置是0則記為-權重,是1就是+權重,可以得到一個寬度64的向量:[-權重,+權重,-權重,+權重…]。
然後將TOP N片語的向量做加法,得到一個最終的寬64的向量。
生成simhash
接下來,遍歷這個寬度64的向量,若對應位置<0則記錄為0,>0則記錄為1,從而又變成了一個64位元的整形,這個整形就是文章的simhash。
海量simhash查詢
抽屜原理
之前說過,判定2篇文章相似的規則,就是2個simhash的漢明距離<=3。
查詢的複雜性在於:已有100億個文章的simhash,給定一個新的simhash,希望判斷是否與已有的simhash相似。
我們只能遍歷100億個simhash,分別做異或運算,看看漢明距離是否<=3,這個效能是沒法接受的。
優化的方法就是”抽屜原理”,因為2個simhash相似的標準是<=3位元的差異,所以如果我們把64位元的simhash切成4段,每一段16位元,那麼不同的3位元最多散落在3段中,至少有1段是完全相同的。
同理,如果我們把simhash切成5段,分別長度 13bit、13bit、13bit、13bit、12bit,因為2個simhash最多有3位元的差異,那麼2個simhash至少有2段是完全相同的。
建立索引
對於一個simhash,我們暫時決定將其切成4段,稱為a.b.c.d,每一段16位元,分別是:
a=0000000000000000,b=0000000011111111,c=1111111100000000,d=111111111111111。
因為抽屜原理的存在,所以我們可以將simhash的每一段作為key,將simhash自身作為value追加索引到key下。
假設用redis做為儲存,那麼上述simhash在redis裡會存成這樣:
key:a=0000000000000000 value(set結構):
{000000000000000000000000111111111111111100000000111111111111111}
key:b=0000000011111111 value(set結構):
{000000000000000000000000111111111111111100000000111111111111111}
key:c=1111111100000000 value(set結構):
{000000000000000000000000111111111111111100000000111111111111111}
key:d=111111111111111 value(set結構):
{000000000000000000000000111111111111111100000000111111111111111}
也就是一個simhash會按不同的段分別索引4次。
判重
假設有一個新的simhash希望判重,它的simhash值是:
a=0000000000000000,b=000000001111110,c=1111111100000001,d=111111111111110
它和此前索引的simhash在3段中一共有3位元的差異,符合重複的條件。
那麼在查詢時,我們對上述simhash做4段切割,然後做先後4次查詢:
用a=0000000000000000 找到了set集合,遍歷集合裡的每個simhash做異或運算,發現了漢明距離<=3的重複simhash。
用b=000000001111110 沒找到set集合。
用c=1111111100000001 沒找到set集合。
用d=d=111111111111110 沒找到set集合。
優化效果
經過上述索引與查詢方式,其實可以估算出優化後的查詢計算量。
假設索引庫中有100億個simhash(也就是2^34個simhash),並且simhash本身是均勻離散的。
一次判重需要遍歷4個redis集合,每個集合大概有 2^32 / 2^16個元素,也就是26萬個simhash,比遍歷100億次要高效多了。
圖片左側表示了一個simhash索引了4份,右側表示查詢時的分段4次查詢。
權衡時間與空間
假設分成5段索引,分別命名為:a.b.c.d.e。
根據抽屜原理,至多3位元的差異會導致至少有2段是相同的,所以一共有這些組合需要索引:
a,b
a,c
a,d
a,e
b,c
b,d
b,e
c,d
c,e
d,e
一個simhash需要索引10份,一個集合的大小是2^34 / 2^(26)=256個。
一次查詢需要訪問10次集合,每個集合256個元素,一共只需要異或計算2560次,基本上查詢效能已不再是瓶頸。
但是也可以知道,因為冗餘的索引份數從4份變成了10份,所以其實是在犧牲空間換取時間。
對應的,這麼大量的儲存空間,再繼續使用redis也是不可能的事情,需要換一個依靠廉價磁碟的分散式儲存。
儲存選型
毫無疑問選擇hbase,特別適合SCAN遍歷集合。
rowkey設計:4位元組的segment+1位元組的段標識flag+8位元組的simhash。
切4段,索引一段需要16位元;切5段,索引2段需要13+13位元;所以用4位元組的segments來存段落。
1位元組的抽屜標識,比如是切4段則標識是1,2,3,4;切5段則可以是1,2,3,4,5,6,7,8,9,10,分別代表(a,b),(a,c),(a,d),(a,e),(b,c) …
然後最後追加上simhash自身作為區分值,這樣在查詢時只需要指定segment+flag做4/10次SCAN操作,進行異或運算即可。
推薦閱讀: