據(jù)清洗實戰(zhàn):構建文本去重引擎的完整方案)
做爬蟲時間久了你會發(fā)現(xiàn)真正麻煩的往往不是“怎么把數(shù)據(jù)抓下來”而是“抓到之后怎么處理”。最常見的一個污染源就是重復文本同一個新聞被幾十個網站轉載同一篇商品描述在不同店鋪反復出現(xiàn)同一條公告被改了標題又發(fā)一遍。如果你把這些數(shù)據(jù)直接喂給下游的搜索、推薦、輿情分析結果就是一堆冗余項在計算里反復出現(xiàn)指標全被稀釋模型訓練還會被重復樣本帶偏。我早期做爬蟲項目時去重就用一個set裝URL后來發(fā)現(xiàn)完全不夠用。很多網站會把同一篇文章掛在不同的路徑下URL對不上正文卻一模一樣還有更狠的把正文改幾個字、插一段廣告、換一下段落順序就當成新文章發(fā)布。這類“偽新內容”才是爬蟲數(shù)據(jù)質量的頭號殺手。這篇文章就基于我反復折騰出來的一套方案詳細拆解怎么構建一個文本資源去重引擎從精確去重一路做到語義級去重全是可直接落地的工程實踐。1. 先盤清楚需求精確去重和語義去重解決的其實是兩個不同的問題1.1 一個典型場景新聞聚合爬蟲里發(fā)生了什么假設你在做一個新聞聚合系統(tǒng)每天要從幾百個站點抓取幾萬篇文章??雌饋砻科恼露加凶约旱腢RL、標題、發(fā)布時間數(shù)據(jù)量很可觀。但只要你抽樣比對正文就會發(fā)現(xiàn)重復率遠超預期。常見的情況有這么幾類同一篇通稿被A、B、C三家網站原封不動轉載連標點符號都沒變。某網站轉載時加了一行“本文來源XXX版權歸原作者所有”。某自媒體把新聞改了標題正文里刪掉兩段、再塞進一段自己的評論。更惡劣的是批量洗稿同義替換、段落打亂機器生成的痕跡非常重。第一類問題用精確去重就能解決后面幾類哈希算法完全失效必須上語義判斷。很多初學者的誤區(qū)就是“只做了MD5就去重”或者反過來“一上來就搞Embedding”其實這兩者不是替代關系而是配合關系各管一段。1.2 精確去重與語義去重的邊界劃分我習慣這樣劃分兩類技術的職責精確去重負責攔截“字節(jié)級完全相同”的內容。輸入的文本經過規(guī)范化后通過哈?;蜻^濾器判斷是否已經存在。它速度快、內存占用低、容易分布式但只對完全一致或基本一致的文本有效。語義去重負責識別“文本不同但含義接近”的內容。它需要把文本映射成可比較的向量或指紋再通過距離/相似度判斷是否屬于同一信息。它能攔下同義詞改寫、段落重排、插播廣告等清洗手段但計算成本高還有誤殺風險。這兩層判斷在落地時是有先后順序的。精確去重先跑一遍能把絕大多數(shù)一模一樣的重復擋在門外避免進入高成本的語義計算流程語義去重再對剩余內容做相似度判斷識別那些“形不同而神似”的重復項。整體效率要高很多。1.3 目標與技術選型對照本文要構建的去重引擎我用一組目標來約束它支持千萬級文本量的單機去重內存可控在10GB以內。精確層平均單條判斷耗時小于1毫秒。語義層能處理每天幾萬條新增內容的增量去重。對外提供統(tǒng)一的is_duplicate(text)接口上游調用方不關心內部邏輯?;谶@些目標精確層我用了Redis Set 布隆過濾器語義層用了SimHash指紋 輕量級向量雙方案。下面的章節(jié)逐個拆解。2. 精確去重引擎從哈希摘要到Redis的億級判重2.1 最簡單的做法全文MD5為什么它能擋住90%重復精確去重的核心思路是對文本做摘要用摘要是否出現(xiàn)過判斷是否重復。最樸素的做法是算全文哈希import hashlib def text_digest(text: str) - str: normalized text.strip() return hashlib.sha1(normalized.encode(utf-8)).hexdigest()然后把摘要存在一個集合里新來的文本算完摘要查一下是否存在。這個方案能擋住所有“字節(jié)級相同”的重復對新聞轉載、商品描述復制這類場景非常有效。我在項目里用過SHA-1而不是MD5雖然MD5更快但SHA-1在安全性和分布均勻性上更穩(wěn)妥反正計算量差別不大。門檻在于這個方案要求文本必須“完全一致”。很多轉載網站會在正文前后自動追加版權橫幅、推薦位等動態(tài)內容導致正文每次抓取都有細微差別。這時候就必須引入一個非常重要的前置步驟文本規(guī)范化。2.2 文本規(guī)范化比哈希本身更影響去重效果規(guī)范化是指在做哈希之前把同一內容的不同表現(xiàn)形式統(tǒng)一起來。這一層做得好不好直接決定去重率。我常用的規(guī)范化步驟包括去除首尾空白字符和不可見字符。統(tǒng)一換行符為\n去除多余空行。全角英文字符和數(shù)字轉半角中文標點與英文標點盡量統(tǒng)一??蛇x小寫化、去除HTML標簽殘留。一個簡單的實現(xiàn)import re import unicodedata def normalize_text(text: str) - str: text unicodedata.normalize(NFKC, text) text re.sub(r\s, , text) text re.sub(r[ \t], , text) return text.strip()值得說明的是NFKC標準化會把全角字母數(shù)字轉成半角但不會過度修改中文。對中文文本來說標點符號的處理需要謹慎不要把所有中文標點都替換掉因為這會改變內容的語義表達也會造成不同原文的碰撞。規(guī)范化規(guī)則應該由業(yè)務方確認后固化下來不要頻繁調整否則歷史指紋會失效。2.3 布隆過濾器用幾十MB內存換千萬級去重能力當數(shù)據(jù)量漲到千萬級以上直接把全部哈希值存在內存里就有點吃不消了。一個SHA-1摘要40個字符存1000萬條就是400MB以上而且還要考慮Set結構本身的開銷實際占用可能翻倍。這時候布隆過濾器是更好的選擇。布隆過濾器的原理不復雜用一個位數(shù)組和若干個哈希函數(shù)寫入時把每個哈希函數(shù)計算的位都置1查詢時檢查這些位是否全部為1。只要有任何一個位是0說明元素肯定不存在如果全部是1說明大概率存在。它用“可能誤判存在”換取了極低的內存占用。Python實現(xiàn)選型上我建議優(yōu)先用pybloom_live或直接基于redis的bitmap實現(xiàn)避免自己造輪子。自建內存版本可以參考from pybloom_live import BloomFilter import hashlib bf BloomFilter(capacity10_000_000, error_rate0.001) def add_bf(digest: str): bf.add(digest) def maybe_exists_bf(digest: str) - bool: return digest in bf注意布隆過濾器有一個比較麻煩的特性它不刪除元素也沒有辦法更新。如果你需要做“重新抓取后更新指紋”的場景必須用帶計數(shù)功能的擴展版本或者定期重建過濾器。我在項目里的做法是引入版本號每天生成一個新的布隆過濾器查重時先查昨天的再查今天的歷史版本保留一周后回收。2.4 Redis版去重精確與近似的折中如果你的爬蟲系統(tǒng)本身就部署了Redis直接使用Redis的Set或HyperLogLog做去重會更省事。Set可以精確判斷元素是否存在但內存占用較高HyperLogLog內存占用極低但只能統(tǒng)計基數(shù)不能做“是否存在”的判斷所以實際去重場景很少用它。我最終的精確層方案是“Redis Set 本地布隆過濾器”組合所有新增文本的哈希先寫入本地布隆過濾器快速擋住絕大多重復。未命中過濾器的再去Redis Set里確認是否真不存在。確認新增后哈希寫入Redis Set和本地布隆過濾器。這樣一來Redis的請求量下降了好幾倍同時保證了“寧可多查一次也不能漏判”的精確性。布隆過濾器的誤判存在只影響性能不影響正確性因為誤判后還會去Redis確認。3. 語義級去重讓“改幾個字、換順序、多段插播”的重復稿也能被識別3.1 為什么哈希失效了內容農場和AI洗稿的常見手法精確去重處理不了這樣一類文本核心信息完全一致但表面文字被做了手腳。我見過的手法包括同義詞替換把“汽車”改成“車輛”“購買”改成“購置”。段落重排原文是1-2-3-4洗稿后變成4-1-3-2。插入噪音正文中間插入一段“更多相關資訊請關注XXX”之類的引導語。首段改寫開頭幾句換成自己的話后面整段復制。這些改寫后的文本哈希值完全不一樣你去重引擎直接放行但實際上它們傳達的資訊是同一個。這時候必須做語義級別的判斷。3.2 不急著上大模型先用SimHash做指紋語義去重第一步我建議先從SimHash開始。SimHash的核心思想是把文本轉換成一個64位的指紋然后用海明距離衡量兩個文本的相似度。海明距離越小文本越相似。一般經驗是海明距離≤3基本可以判定為重復內容。SimHash實現(xiàn)不復雜核心步驟是對文本分詞拿到帶權重的關鍵詞列表。每個詞做哈希得到64位二進制串。如果是1對應維度加權重如果是0對應維度減權重。所有詞貢獻累加后正數(shù)取1負數(shù)取0得到64位指紋。用第三方庫可以直接做from simhash import Simhash hash1 Simhash(這是一條被改寫的新聞正文講述某地發(fā)生的重要事件) hash2 Simhash(這是一個被改寫的新聞正文講述某地發(fā)生的重要事件) distance hash1.distance(hash2) print(distance) # 數(shù)值越小越相似一般 3 算重復SimHash的優(yōu)點是速度極快、內存占用小非常適合海量文檔的粗篩。它的缺點是只捕捉詞袋層面的差異對語義理解幾乎沒有同義詞替換會被它放過因為“汽車”和“車輛”是兩個完全不同的詞哈希。所以SimHash適合做“低配版語義去重”能攔住段落重排、插播廣告這類混入方式但對真正的同義改寫比較吃力。3.3 語義向量方案嵌入模型余弦相似度的工程化要讓去重引擎真正理解語義需要把文本轉換成向量然后比較余弦相似度。我使用的是輕量級的sentence-transformers模型它在embedding句子和短文檔時效果不錯而且能直接輸出定長向量。from sentence_transformers import SentenceTransformer model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) text1 蘋果公司發(fā)布了新款手機價格比上一代貴了不少。 text2 蘋果新機正式推出售價較前代有顯著上漲。 text3 今天天氣很好適合出門散步。 vec1 model.encode(text1) vec2 model.encode(text2) vec3 model.encode(text3) from sklearn.metrics.pairwise import cosine_similarity print(cosine_similarity([vec1], [vec2])) # 高相似度 print(cosine_similarity([vec1], [vec3])) # 低相似度實測下來paraphrase-multilingual-MiniLM-L12-v2對中文短文檔的效果不錯單條文本向量化大約幾十毫秒速度可以接受。如果數(shù)據(jù)量特別大、硬件緊張可以退回去用SimHash先粗篩只有SimHash判定相似度較高的文本才進入向量層二次確認。這個“粗篩精排”的思路能大大降低向量計算的壓力。向量存儲方面數(shù)據(jù)量不大的時候直接用numpy數(shù)組存內存每來一條就和歷史向量做一次余弦相似度。但數(shù)據(jù)量到十萬級以上全量遍歷會越來越慢這時要引入近似最近鄰索引。我推薦用annoy或faiss這兩者都能在犧牲極小精度的情況下做到毫秒級相似檢索。以annoy為例構建索引和查詢都很方便from annoy import AnnoyIndex dim 384 index AnnoyIndex(dim, metricangular) # 添加向量 index.add_item(0, vec1) index.add_item(1, vec2) index.build(10) # 10棵樹樹越多精度越高、內存越大 # 查詢相似 neighbors index.get_nns_by_vector(vec1, 10, include_distancesTrue)注意annoy的angular距離和余弦相似度是有換算關系的構建索引時用angular查詢時返回的距離越小越相似。實際使用中get_nns_by_vector返回的距離需要轉換成相似度來看或者直接設定一個距離閾值。3.4 閾值怎么定先算一遍真實數(shù)據(jù)的分布語義去重里最容易被忽視、也最容易翻車的就是相似度閾值的設置。定得太高放過洗稿定得太低誤殺大量正常文章。我強烈建議不要在第一天就拍腦袋定閾值而是先拿一批真實數(shù)據(jù)算一遍相似度分布。具體做法是隨機抽取1000篇最新抓取的文章兩兩配對計算相似度然后把結果按區(qū)間分布統(tǒng)計。你通常會看到兩個明顯的峰一個集中在0.95~1.0是字節(jié)級重復或輕度改寫的另一個集中在0.5~0.7是正常文章的隨機相似度。閾值可以選在兩個峰之間的低谷處。我項目的實測經驗大致如下場景推薦余弦相似度閾值說明新聞轉載、通稿0.92 ~ 0.95同一信息源的改寫幅度較小商品描述0.86 ~ 0.90商家經常調整描述詞但核心信息一致用戶評論、公告0.82 ~ 0.88表達方式差異較大需要放寬需要特別提醒的是閾值不是一成不變的不同業(yè)務、不同語料都要單獨調。而且每當你更換embedding模型所有歷史向量的分布都會變化必須重新評估閾值不能沿用舊值。4. 雙路引擎的架構設計與數(shù)據(jù)流單機也能跑出穩(wěn)定效果4.1 整體流程從下載、解析到去重的完整管線把精確和語義兩層串起來我推薦下面這個流程原始HTML - 正文抽取 - 文本清洗與規(guī)范化 - 精確去重布隆過濾器 Redis Set - 語義去重SimHash粗篩 向量精排 - 寫入已去重庫每一步都有它的職責。正文抽取決定了后續(xù)文本的質量如果這里抽到的是一堆導航、頁腳、廣告代碼后面所有環(huán)節(jié)都會受影響清洗和規(guī)范化讓哈希能對“同一內容”穩(wěn)定生成同一個摘要精確去重快速攔截完全相同的文本語義去重處理那些“形不同而神似”的重復最后被判定為新增內容的數(shù)據(jù)才允許入庫。4.2 精確層和語義層怎么配合才不浪費算力兩層不是簡單的“精確失敗再進語義”還需要考慮成本和數(shù)據(jù)特點。我在生產環(huán)境里的策略是對每條新文本先規(guī)范化。精確層判斷是否“絕對重復”。是直接丟棄。精確層未命中時先做SimHash粗篩。因為SimHash計算很快可以先把海明距離小于某個較大閾值比如≤6的候選集找出來。如果SimHash沒有找到候選直接認定為新增不再進入向量層。如果SimHash找到候選再把這些候選文本做向量化用余弦相似度做最終判斷。這樣做最直接的好處是真正走到向量層的數(shù)據(jù)量非常少。我跑過的項目里大約只有2%~5%的文本會進入向量精排剩余的95%以上在精確層和SimHash層就能搞定整機負載低了很多。4.3 增量更新與歷史指紋管理去重引擎必須處理增量更新的問題。每天都有新增文本而歷史指紋會越來越大。如果不做管理內存和查詢時間都會被拖垮。我采用的方案是“按天分桶”每天的精確層哈希存到獨立的Redis Key或獨立的布隆過濾器快照里。語義層的向量也按天寫入獨立的索引文件。查重時先查當天桶再查前一天桶最多往前查7天。這個做法有一個業(yè)務假設絕大多數(shù)重復內容會在發(fā)布后的48小時內被抓到。只要重復內容在7天之內出現(xiàn)過就會被識別超過7天的系統(tǒng)不會太在意因為對搜索、推薦、榜單來說一周前已經處理過的舊文章再重復出現(xiàn)本身也基本影響不大了。如果你需要全量歷史去重那就要跑一次全量構建而不是增量流程。增量更新還有一個好處不管是布隆過濾器還是向量索引都需要定期重建來清理增長。按天分桶之后重建某一天的桶不會影響其他數(shù)據(jù)。4.4 性能指標實測下面是我在單機環(huán)境16GB內存、8核CPU、SSD下做過的一組實測數(shù)據(jù)供參考指標數(shù)值備注精確層單條判斷耗時0.1ms ~ 0.5ms本地布隆 Redis確認SimHash單條計算耗時1ms ~ 2msJieba分詞為主要耗時向量化單條耗時30ms ~ 80ms取決于文本長度向量索引檢索耗時1ms ~ 10ms使用annoy索引查詢全流程平均單條耗時2ms ~ 5ms95%以上不需走向量層這套配置下單機日處理量能達到幾十萬條文本的增量全流程去重對大多數(shù)爬蟲項目完全夠用。5. 踩坑實錄去重引擎最容易翻車的五個細節(jié)5.1 編碼與亂碼去重前必須做的字符清理中文爬蟲最容易遇到的就是編碼問題。有的頁面是GBK有的是UTF-8還有的頁面頭聲明和實際編碼不一致。如果不清洗干凈就做哈希同樣的內容因為編碼不同會得到完全不同的摘要去重直接失效。我踩過的坑是某個站點返回的頁面里帶有大量\u3000全角空格和\xa0不間斷空格兩條一模一樣的正文一條有這些特殊字符一條沒有哈希完全對不上。后來的處理策略是在規(guī)范化函數(shù)里統(tǒng)一用NFKC標準化先轉換字符寬度和兼容字符再顯式替換掉特殊空格。text text.replace(\u3000, ).replace(\xa0, )5.2 閾值誤殺的代價比想象中大語義去重最怕的不是漏過重復而是把正常文章誤判為重復丟棄。曾經有個項目為了“更高效地清洗數(shù)據(jù)”把語義相似度閾值從0.90調高到0.95結果一周之后發(fā)現(xiàn)不少獨立成文但內容主題接近的稿件全部被吞了。尤其是同一行業(yè)的新聞比如“某某公司發(fā)布財報”這類事件性報道不同媒體寫的角度不同但核心關鍵詞高度重合很容易被誤判。我的經驗是閾值寧低勿高漏判可以靠人工或后續(xù)規(guī)則補救誤殺直接造成數(shù)據(jù)損失而且很難追溯。對拿不準的相似候選可以丟到一個人工審核隊列里而不是直接丟棄。5.3 模板噪音會讓語義判斷失真爬蟲抽出來的正文里經常夾帶“網友評論”“熱門評論”“相關推薦”這些欄目名甚至還有網站的統(tǒng)計代碼殘留。這些模板噪音會影響SimHash和向量計算的準確性。比如兩篇完全不同的新聞正文里都帶著同一個版權聲明部分相似度會被這些噪音拉高容易造成誤判。解決思路是建立“噪音詞庫”和“模板區(qū)塊識別”在規(guī)范化階段把頻繁出現(xiàn)的頁腳、導航、廣告詞直接剝離。更極端的做法是用正文抽取算法比如針對中文的通用抽取規(guī)則先提取主內容區(qū)域再做去重判斷。5.4 向量模型更新導致指紋不統(tǒng)一當我決定升級embedding模型時遇到過一個嚴重的兼容性問題新舊模型產出的向量維度都不同舊索引完全沒法用。如果只是把舊向量全部刪除重新計算數(shù)據(jù)量太大如果不刪除新舊向量混在一起相似度比較結果就會失真。我的做法是切換模型時先在測試環(huán)境用新舊模型分別向量化一批樣本確認兩者相似度分布一致再準備一個回滾期。回滾期內保留舊模型索引新模型索引并行構建構建完成后由開關控制切換。這個流程雖然笨但能保證線上服務不中斷。5.5 糾錯機制允許“誤殺”但要為“被誤殺”留后路一個去重引擎如果只做丟棄沒有糾錯機制早晚會出問題。我的項目里最終加了一個兜底方案所有被語義層判定為重復的文本不會直接丟棄而是存入一個duplicate_candidates表保留原始文本、被判定重復的兩條文本ID、相似度、判定時間。人工審核時只要打開這個表就能看到為什么被判重然后把誤判的條目恢復并加入白名單。白名單里的內容在精確層和語義層都會被跳過防止同樣的誤判反復發(fā)生。最后再分享一點個人體會去重引擎真正難的不是算法本身而是它跟數(shù)據(jù)質量緊密綁定。同樣的閾值、同樣的指紋機制換一個數(shù)據(jù)源就可能完全失效。我自己的迭代路徑是先做精確去重跑通整個流程積累起一批真實重復數(shù)據(jù)之后再根據(jù)失敗case決定要不要上SimHash、要不要上向量模型。一上來就上大模型只會讓系統(tǒng)又慢又貴還未必解決實際問題。另外去重結果一定要有可觀測性。我把精確層命中數(shù)、語義層命中數(shù)、疑似重復候選數(shù)、誤殺恢復數(shù)都做成了指標每天盯一遍。只要這些數(shù)字出現(xiàn)異常波動往往意味著某個網站改版了、某個模板變了或者某個新數(shù)據(jù)源有問題。去重引擎做到最后其實就是用規(guī)則對付噪音用向量對付改寫用人工對付邊界三者缺一不可。