1. 程式人生 > >面試|海量文字去重~simhash

面試|海量文字去重~simhash

simhash演算法是google發明的,專門用於海量文字去重的需求,所以在這裡記錄一下simhash工程化落地問題。

下面我說的都是工程化落地步驟,不僅僅是理論。

背景

網際網路上,一篇文章被抄襲來抄襲去,轉載來轉載去。

被抄襲的文章一般不改,或者少量改動就發表了,所以判重並不是等於的關係,而是相似判斷,這個判別的演算法就是simhash。

simhash計算

給定一篇文章內容,利用simhash演算法可以計算出一個雜湊值(64位整形)。

判別兩篇文章是相似的方法,就是兩個simhash值的距離<=3,這裡距離計算採用漢明距離,也就是2個simhash做一下異或運算,數一下位元位=1的有N位,那麼距離就是N。

現在問題就是,如何計算文字的simhash?

640

分詞+權重

首先需要將文章作分詞,得到若干個(片語,權重)。

分詞我們知道很多庫都可以實現,最常見的就是結巴分詞。權重是怎麼得來的呢?

權重一般用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=00000000111111沒找到set集合。

用c=111111110000000沒找到set集合。

用d=d=11111111111111沒找到set集合。

優化效果

經過上述索引與查詢方式,其實可以估算出優化後的查詢計算量。

假設索引庫中有100億個simhash(也就是2^34個simhash),並且simhash本身是均勻離散的。

一次判重需要遍歷4個redis集合,每個集合大概有 2^32 / 2^16個元素,也就是26萬個simhash,比遍歷100億次要高效多了。

640

圖片左側表示了一個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操作,進行異或運算即可。

推薦閱讀:

面試|return 和finally那些事兒

擴充套件RDD API三部曲第一部回顧基礎

Spark SQL如何實現mysql的union操作

幾張動態圖捋清Java常用資料結構及其設計原理

640