爬虫数据清洗实战:构建文本去重引擎的完整方案

爬虫数据清洗实战:构建文本去重引擎的完整方案 做爬虫时间久了你会发现真正麻烦的往往不是“怎么把数据抓下来”而是“抓到之后怎么处理”。最常见的一个污染源就是重复文本同一个新闻被几十个网站转载同一篇商品描述在不同店铺反复出现同一条公告被改了标题又发一遍。如果你把这些数据直接喂给下游的搜索、推荐、舆情分析结果就是一堆冗余项在计算里反复出现指标全被稀释模型训练还会被重复样本带偏。我早期做爬虫项目时去重就用一个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(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注意布隆过滤器有一个比较麻烦的特性它不删除元素也没有办法更新。如果你需要做“重新抓取后更新指纹”的场景必须用带计数功能的扩展版本或者定期重建过滤器。我在项目里的做法是引入版本号每天生成一个新的布隆过滤器查重时先查昨天的再查今天的历史版本保留一周后回收。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数组存内存每来一条就和历史向量做一次余弦相似度。但数据量到十万级以上全量遍历会越来越慢这时要引入近似最近邻索引。我推荐用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 阈值怎么定先算一遍真实数据的分布语义去重里最容易被忽视、也最容易翻车的就是相似度阈值的设置。定得太高放过洗稿定得太低误杀大量正常文章。我强烈建议不要在第一天就拍脑袋定阈值而是先拿一批真实数据算一遍相似度分布。具体做法是随机抽取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、要不要上向量模型。一上来就上大模型只会让系统又慢又贵还未必解决实际问题。另外去重结果一定要有可观测性。我把精确层命中数、语义层命中数、疑似重复候选数、误杀恢复数都做成了指标每天盯一遍。只要这些数字出现异常波动往往意味着某个网站改版了、某个模板变了或者某个新数据源有问题。去重引擎做到最后其实就是用规则对付噪音用向量对付改写用人工对付边界三者缺一不可。