爬虫数据清洗实战:构建文本去重引擎的完整方案
2026/9/9 18:17:12 网站建设 项目流程

做爬虫时间久了,你会发现真正麻烦的往往不是“怎么把数据抓下来”,而是“抓到之后怎么处理”。最常见的一个污染源就是重复文本:同一个新闻被几十个网站转载,同一篇商品描述在不同店铺反复出现,同一条公告被改了标题又发一遍。如果你把这些数据直接喂给下游的搜索、推荐、舆情分析,结果就是一堆冗余项在计算里反复出现,指标全被稀释,模型训练还会被重复样本带偏。

我早期做爬虫项目时,去重就用一个set装URL,后来发现完全不够用。很多网站会把同一篇文章挂在不同的路径下,URL对不上,正文却一模一样;还有更狠的,把正文改几个字、插一段广告、换一下段落顺序就当成新文章发布。这类“伪新内容”才是爬虫数据质量的头号杀手。这篇文章就基于我反复折腾出来的一套方案,详细拆解怎么构建一个文本资源去重引擎,从精确去重一路做到语义级去重,全是可直接落地的工程实践。

1. 先盘清楚需求:精确去重和语义去重解决的其实是两个不同的问题

1.1 一个典型场景:新闻聚合爬虫里发生了什么

假设你在做一个新闻聚合系统,每天要从几百个站点抓取几万篇文章。看起来每篇文章都有自己的URL、标题、发布时间,数据量很可观。但只要你抽样比对正文,就会发现重复率远超预期。常见的情况有这么几类:

  • 同一篇通稿被A、B、C三家网站原封不动转载,连标点符号都没变。
  • 某网站转载时加了一行“本文来源:XXX,版权归原作者所有”。
  • 某自媒体把新闻改了标题,正文里删掉两段、再塞进一段自己的评论。
  • 更恶劣的是批量洗稿,同义替换、段落打乱,机器生成的痕迹非常重。

第一类问题,用精确去重就能解决;后面几类,哈希算法完全失效,必须上语义判断。很多初学者的误区就是“只做了MD5就去重”,或者反过来“一上来就搞Embedding”,其实这两者不是替代关系,而是配合关系,各管一段。

1.2 精确去重与语义去重的边界划分

我习惯这样划分两类技术的职责:

  • 精确去重负责拦截“字节级完全相同”的内容。输入的文本经过规范化后,通过哈希或过滤器判断是否已经存在。它速度快、内存占用低、容易分布式,但只对完全一致或基本一致的文本有效。
  • 语义去重负责识别“文本不同但含义接近”的内容。它需要把文本映射成可比较的向量或指纹,再通过距离/相似度判断是否属于同一信息。它能拦下同义词改写、段落重排、插播广告等清洗手段,但计算成本高,还有误杀风险。

这两层判断在落地时是有先后顺序的。精确去重先跑一遍,能把绝大多数一模一样的重复挡在门外,避免进入高成本的语义计算流程;语义去重再对剩余内容做相似度判断,识别那些“形不同而神似”的重复项。整体效率要高很多。

1.3 目标与技术选型对照

本文要构建的去重引擎,我用一组目标来约束它:

  • 支持千万级文本量的单机去重,内存可控在10GB以内。
  • 精确层平均单条判断耗时小于1毫秒。
  • 语义层能处理每天几万条新增内容的增量去重。
  • 对外提供统一的is_duplicate(text)接口,上游调用方不关心内部逻辑。

基于这些目标,精确层我用了Redis Set + 布隆过滤器,语义层用了SimHash指纹 + 轻量级向量双方案。下面的章节逐个拆解。

2. 精确去重引擎:从哈希摘要到Redis的亿级判重

2.1 最简单的做法:全文MD5,为什么它能挡住90%重复

精确去重的核心思路是对文本做摘要,用摘要是否出现过判断是否重复。最朴素的做法是算全文哈希:

import hashlib def text_digest(text: str) -> str: normalized = text.strip() return hashlib.sha1(normalized.encode("utf-8")).hexdigest()

然后把摘要存在一个集合里,新来的文本算完摘要,查一下是否存在。这个方案能挡住所有“字节级相同”的重复,对新闻转载、商品描述复制这类场景非常有效。我在项目里用过SHA-1而不是MD5,虽然MD5更快,但SHA-1在安全性和分布均匀性上更稳妥,反正计算量差别不大。

门槛在于,这个方案要求文本必须“完全一致”。很多转载网站会在正文前后自动追加版权横幅、推荐位等动态内容,导致正文每次抓取都有细微差别。这时候就必须引入一个非常重要的前置步骤:文本规范化。

2.2 文本规范化:比哈希本身更影响去重效果

规范化是指在做哈希之前,把同一内容的不同表现形式统一起来。这一层做得好不好,直接决定去重率。我常用的规范化步骤包括:

  • 去除首尾空白字符和不可见字符。
  • 统一换行符为\n,去除多余空行。
  • 全角英文字符和数字转半角,中文标点与英文标点尽量统一。
  • 可选:小写化、去除HTML标签残留。

一个简单的实现:

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标准化会把全角字母数字转成半角,但不会过度修改中文。对中文文本来说,标点符号的处理需要谨慎,不要把所有中文标点都替换掉,因为这会改变内容的语义表达,也会造成不同原文的碰撞。规范化规则应该由业务方确认后固化下来,不要频繁调整,否则历史指纹会失效。

2.3 布隆过滤器:用几十MB内存换千万级去重能力

当数据量涨到千万级以上,直接把全部哈希值存在内存里就有点吃不消了。一个SHA-1摘要40个字符,存1000万条就是400MB以上,而且还要考虑Set结构本身的开销,实际占用可能翻倍。这时候布隆过滤器是更好的选择。

布隆过滤器的原理不复杂:用一个位数组和若干个哈希函数,写入时把每个哈希函数计算的位都置1,查询时检查这些位是否全部为1。只要有任何一个位是0,说明元素肯定不存在;如果全部是1,说明大概率存在。它用“可能误判存在”换取了极低的内存占用。

Python实现选型上,我建议优先用pybloom_live或直接基于redis的bitmap实现,避免自己造轮子。自建内存版本可以参考:

from pybloom_live import BloomFilter import hashlib bf = BloomFilter(capacity=10_000_000, error_rate=0.001) def add_bf(digest: str): bf.add(digest) def maybe_exists_bf(digest: str) -> bool: return digest in bf

注意布隆过滤器有一个比较麻烦的特性:它不删除元素,也没有办法更新。如果你需要做“重新抓取后更新指纹”的场景,必须用带计数功能的扩展版本,或者定期重建过滤器。我在项目里的做法是引入版本号:每天生成一个新的布隆过滤器,查重时先查昨天的,再查今天的,历史版本保留一周后回收。

2.4 Redis版去重:精确与近似的折中

如果你的爬虫系统本身就部署了Redis,直接使用Redis的Set或HyperLogLog做去重会更省事。Set可以精确判断元素是否存在,但内存占用较高;HyperLogLog内存占用极低,但只能统计基数,不能做“是否存在”的判断,所以实际去重场景很少用它。

我最终的精确层方案是“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实现不复杂,核心步骤是:

  • 对文本分词,拿到带权重的关键词列表。
  • 每个词做哈希得到64位二进制串。
  • 如果是1,对应维度加权重;如果是0,对应维度减权重。
  • 所有词贡献累加后,正数取1,负数取0,得到64位指纹。

用第三方库可以直接做:

from simhash import Simhash hash1 = Simhash("这是一条被改写的新闻正文,讲述某地发生的重要事件") hash2 = Simhash("这是一个被改写的新闻正文,讲述某地发生的重要事件") distance = hash1.distance(hash2) print(distance) # 数值越小越相似,一般 <= 3 算重复

SimHash的优点是速度极快、内存占用小,非常适合海量文档的粗筛。它的缺点是只捕捉词袋层面的差异,对语义理解几乎没有:同义词替换会被它放过,因为“汽车”和“车辆”是两个完全不同的词哈希。所以SimHash适合做“低配版语义去重”,能拦住段落重排、插播广告这类混入方式,但对真正的同义改写比较吃力。

3.3 语义向量方案:嵌入模型+余弦相似度的工程化

要让去重引擎真正理解语义,需要把文本转换成向量,然后比较余弦相似度。我使用的是轻量级的sentence-transformers模型,它在embedding句子和短文档时效果不错,而且能直接输出定长向量。

from sentence_transformers import SentenceTransformer model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") text1 = "苹果公司发布了新款手机,价格比上一代贵了不少。" 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对中文短文档的效果不错,单条文本向量化大约几十毫秒,速度可以接受。如果数据量特别大、硬件紧张,可以退回去用SimHash先粗筛,只有SimHash判定相似度较高的文本才进入向量层二次确认。这个“粗筛+精排”的思路,能大大降低向量计算的压力。

向量存储方面,数据量不大的时候直接用numpy数组存内存,每来一条就和历史向量做一次余弦相似度。但数据量到十万级以上,全量遍历会越来越慢,这时要引入近似最近邻索引。我推荐用annoyfaiss,这两者都能在牺牲极小精度的情况下做到毫秒级相似检索。以annoy为例,构建索引和查询都很方便:

from annoy import AnnoyIndex dim = 384 index = AnnoyIndex(dim, metric="angular") # 添加向量 index.add_item(0, vec1) index.add_item(1, vec2) index.build(10) # 10棵树,树越多精度越高、内存越大 # 查询相似 neighbors = index.get_nns_by_vector(vec1, 10, include_distances=True)

注意annoy的angular距离和余弦相似度是有换算关系的,构建索引时用angular,查询时返回的距离越小越相似。实际使用中,get_nns_by_vector返回的距离需要转换成相似度来看,或者直接设定一个距离阈值。

3.4 阈值怎么定:先算一遍真实数据的分布

语义去重里最容易被忽视、也最容易翻车的,就是相似度阈值的设置。定得太高,放过洗稿;定得太低,误杀大量正常文章。我强烈建议不要在第一天就拍脑袋定阈值,而是先拿一批真实数据算一遍相似度分布。

具体做法是:随机抽取1000篇最新抓取的文章,两两配对计算相似度,然后把结果按区间分布统计。你通常会看到两个明显的峰:一个集中在0.95~1.0,是字节级重复或轻度改写的;另一个集中在0.5~0.7,是正常文章的随机相似度。阈值可以选在两个峰之间的低谷处。

我项目的实测经验大致如下:

场景推荐余弦相似度阈值说明
新闻转载、通稿0.92 ~ 0.95同一信息源的改写幅度较小
商品描述0.86 ~ 0.90商家经常调整描述词,但核心信息一致
用户评论、公告0.82 ~ 0.88表达方式差异较大,需要放宽

需要特别提醒的是,阈值不是一成不变的,不同业务、不同语料都要单独调。而且每当你更换embedding模型,所有历史向量的分布都会变化,必须重新评估阈值,不能沿用旧值。

4. 双路引擎的架构设计与数据流:单机也能跑出稳定效果

4.1 整体流程:从下载、解析到去重的完整管线

把精确和语义两层串起来,我推荐下面这个流程:

原始HTML -> 正文抽取 -> 文本清洗与规范化 -> 精确去重(布隆过滤器 + Redis Set) -> 语义去重(SimHash粗筛 + 向量精排) -> 写入已去重库

每一步都有它的职责。正文抽取决定了后续文本的质量,如果这里抽到的是一堆导航、页脚、广告代码,后面所有环节都会受影响;清洗和规范化让哈希能对“同一内容”稳定生成同一个摘要;精确去重快速拦截完全相同的文本;语义去重处理那些“形不同而神似”的重复;最后被判定为新增内容的数据才允许入库。

4.2 精确层和语义层怎么配合,才不浪费算力

两层不是简单的“精确失敗再进语义”,还需要考虑成本和数据特点。

我在生产环境里的策略是:

  • 对每条新文本,先规范化。
  • 精确层判断是否“绝对重复”。是,直接丢弃。
  • 精确层未命中时,先做SimHash粗筛。因为SimHash计算很快,可以先把海明距离小于某个较大阈值(比如≤6)的候选集找出来。
  • 如果SimHash没有找到候选,直接认定为新增,不再进入向量层。
  • 如果SimHash找到候选,再把这些候选文本做向量化,用余弦相似度做最终判断。

这样做最直接的好处是,真正走到向量层的数据量非常少。我跑过的项目里,大约只有2%~5%的文本会进入向量精排,剩余的95%以上在精确层和SimHash层就能搞定,整机负载低了很多。

4.3 增量更新与历史指纹管理

去重引擎必须处理增量更新的问题。每天都有新增文本,而历史指纹会越来越大。如果不做管理,内存和查询时间都会被拖垮。

我采用的方案是“按天分桶”:

  • 每天的精确层哈希存到独立的Redis Key或独立的布隆过滤器快照里。
  • 语义层的向量也按天写入独立的索引文件。
  • 查重时先查当天桶,再查前一天桶,最多往前查7天。

这个做法有一个业务假设:绝大多数重复内容会在发布后的48小时内被抓到。只要重复内容在7天之内出现过,就会被识别;超过7天的系统不会太在意,因为对搜索、推荐、榜单来说,一周前已经处理过的旧文章再重复出现,本身也基本影响不大了。如果你需要全量历史去重,那就要跑一次全量构建,而不是增量流程。

增量更新还有一个好处:不管是布隆过滤器还是向量索引,都需要定期重建来清理增长。按天分桶之后,重建某一天的桶不会影响其他数据。

4.4 性能指标实测

下面是我在单机环境(16GB内存、8核CPU、SSD)下做过的一组实测数据,供参考:

指标数值备注
精确层单条判断耗时0.1ms ~ 0.5ms本地布隆 + Redis确认
SimHash单条计算耗时1ms ~ 2msJieba分词为主要耗时
向量化单条耗时30ms ~ 80ms取决于文本长度
向量索引检索耗时1ms ~ 10ms使用annoy索引查询
全流程平均单条耗时2ms ~ 5ms95%以上不需走向量层

这套配置下,单机日处理量能达到几十万条文本的增量全流程去重,对大多数爬虫项目完全够用。

5. 踩坑实录:去重引擎最容易翻车的五个细节

5.1 编码与乱码:去重前必须做的字符清理

中文爬虫最容易遇到的就是编码问题。有的页面是GBK,有的是UTF-8,还有的页面头声明和实际编码不一致。如果不清洗干净就做哈希,同样的内容因为编码不同会得到完全不同的摘要,去重直接失效。

我踩过的坑是:某个站点返回的页面里带有大量\u3000全角空格和\xa0不间断空格,两条一模一样的正文,一条有这些特殊字符,一条没有,哈希完全对不上。后来的处理策略是:在规范化函数里统一用NFKC标准化,先转换字符宽度和兼容字符,再显式替换掉特殊空格。

text = text.replace("\u3000", " ").replace("\xa0", " ")

5.2 阈值误杀的代价比想象中大

语义去重最怕的不是漏过重复,而是把正常文章误判为重复丢弃。曾经有个项目,为了“更高效地清洗数据”,把语义相似度阈值从0.90调高到0.95,结果一周之后发现不少独立成文但内容主题接近的稿件全部被吞了。尤其是同一行业的新闻,比如“某某公司发布财报”这类事件性报道,不同媒体写的角度不同,但核心关键词高度重合,很容易被误判。

我的经验是:阈值宁低勿高,漏判可以靠人工或后续规则补救,误杀直接造成数据损失,而且很难追溯。对拿不准的相似候选,可以丢到一个人工审核队列里,而不是直接丢弃。

5.3 模板噪音会让语义判断失真

爬虫抽出来的正文里,经常夹带“网友评论”“热门评论”“相关推荐”这些栏目名,甚至还有网站的统计代码残留。这些模板噪音会影响SimHash和向量计算的准确性。比如两篇完全不同的新闻,正文里都带着同一个版权声明,部分相似度会被这些噪音拉高,容易造成误判。

解决思路是建立“噪音词库”和“模板区块识别”:在规范化阶段把频繁出现的页脚、导航、广告词直接剥离。更极端的做法是用正文抽取算法(比如针对中文的通用抽取规则)先提取主内容区域,再做去重判断。

5.4 向量模型更新导致指纹不统一

当我决定升级embedding模型时,遇到过一个严重的兼容性问题:新旧模型产出的向量维度都不同,旧索引完全没法用。如果只是把旧向量全部删除重新计算,数据量太大;如果不删除,新旧向量混在一起,相似度比较结果就会失真。

我的做法是:切换模型时,先在测试环境用新旧模型分别向量化一批样本,确认两者相似度分布一致,再准备一个回滚期。回滚期内保留旧模型索引,新模型索引并行构建,构建完成后由开关控制切换。这个流程虽然笨,但能保证线上服务不中断。

5.5 纠错机制:允许“误杀”,但要为“被误杀”留后路

一个去重引擎如果只做丢弃,没有纠错机制,早晚会出问题。我的项目里最终加了一个兜底方案:所有被语义层判定为重复的文本,不会直接丢弃,而是存入一个duplicate_candidates表,保留原始文本、被判定重复的两条文本ID、相似度、判定时间。

人工审核时只要打开这个表,就能看到为什么被判重,然后把误判的条目恢复并加入白名单。白名单里的内容在精确层和语义层都会被跳过,防止同样的误判反复发生。

最后再分享一点个人体会

去重引擎真正难的不是算法本身,而是它跟数据质量紧密绑定。同样的阈值、同样的指纹机制,换一个数据源就可能完全失效。我自己的迭代路径是:先做精确去重,跑通整个流程,积累起一批真实重复数据之后,再根据失败case决定要不要上SimHash、要不要上向量模型。一上来就上大模型,只会让系统又慢又贵,还未必解决实际问题。

另外,去重结果一定要有可观测性。我把精确层命中数、语义层命中数、疑似重复候选数、误杀恢复数都做成了指标,每天盯一遍。只要这些数字出现异常波动,往往意味着某个网站改版了、某个模板变了,或者某个新数据源有问题。去重引擎做到最后,其实就是用规则对付噪音,用向量对付改写,用人工对付边界,三者缺一不可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询