网络数据采集中的去重策略
从内容指纹到语义指纹,去重是大规模采集系统里最容易被低估的工程难题。本文整理几种主流方案的工程实现与代价。
做大规模采集的同学都有个体会:100 亿条数据里,可能有 30 亿是重复的。重复带来存储浪费、统计偏差、模型污染。去重看似简单——MD5 一下不就完了?真做起来远不是那么回事。
为什么简单 MD5 不够
简单 MD5 的问题:
- 微小变化就不同:标题多一个空格、正文多一个标点,MD5 完全不同。
- 不能识别"近似重复":同一新闻不同媒体转载,正文 95% 相同但 MD5 不同。
- 不能识别"语义重复":两篇文章讲同一件事但表述不同,MD5 完全不同。
真实场景里,"完全相同"只占重复的 30%,"近似相同"占 50%,"语义相同"占 20%。简单 MD5 只解决了 30%。
四层去重体系
| 层级 | 方法 | 召回率 | 精度 | 成本 |
|---|---|---|---|---|
| L1 | URL 去重 | 低 | 极高 | 极低 |
| L2 | SimHash / MinHash | 中 | 高 | 低 |
| L3 | 内容指纹(标题+正文 hash) | 中高 | 高 | 中 |
| L4 | 语义指纹(embedding + 近邻) | 高 | 中 | 高 |
L1:URL 去重
最便宜的去重。同一 URL 抓过的就不再抓。实现:Bloom filter 或 Redis SET。坑在于 URL 归一化——query 参数顺序、fragment、大小写、 trailing slash 都要处理。
from urllib.parse import urlparse, urlunparse, parse_qsl
def normalize_url(u):
p = urlparse(u)
q = sorted(parse_qsl(p.query)) # query 排序
return urlunparse((p.scheme.lower(), p.netloc.lower(), p.path.rstrip('/'),
'', '&'.join(f"{k}={v}" for k,v in q), ''))
L2:SimHash
SimHash 是 Google 提出的"近似重复检测"。它把文档 hash 成 64 位指纹,相似文档的 Hamming 距离小(通常 < 3)。优点是查询快——在海量指纹里找近邻可以分桶。
def simhash(tokens, hashbits=64):
v = [0] * hashbits
for t in tokens:
h = hash(t)
for i in range(hashbits):
if h & (1 << i): v[i] += 1
else: v[i] -= 1
fp = 0
for i in range(hashbits):
if v[i] > 0: fp |= (1 << i)
return fp
def hamming(a, b):
return bin(a ^ b).count('1')
SimHash 的优势:1) 指纹固定 64 位,存储省;2) 分桶后近邻查询 O(1)。劣势:对短文本不友好——100 字以下的文本 SimHash 误判率较高。
L3:MinHash + LSH
MinHash 估计 Jaccard 相似度,配合 LSH(Locality Sensitive Hashing)做近邻查询。适合"集合相似"场景,比如"两篇文章的词集合有多重叠"。MinHash 比 SimHash 在长文本上精度更高,但存储与查询成本略高。
L4:语义指纹
用 embedding 模型把文章编码成向量,再用近邻搜索找相似。优点是能识别"讲同一件事但表述不同"的语义重复。缺点:
- 计算成本高(每篇文章要过模型)。
- 近邻查询需要向量索引(Faiss / Milvus)。
- 阈值难定——相似度 0.85 算重复吗?
我们的做法:L4 只对"L1–L3 没去重掉"的疑似重复样本做,作为补充层。
工程化:分层流水线
新文章 → L1 URL 去重
├─ 已抓过 → 丢弃
└─ 未抓 → L2 SimHash
├─ 海明距离 < 3 → 丢弃
└─ 否则 → L3 MinHash
├─ Jaccard > 0.8 → 丢弃
└─ 否则 → L4 向量
├─ 余弦 > 0.92 → 丢弃
└─ 入库
分层后,L1 拦掉 70%,L2 拦掉 20%,L3 拦掉 7%,L4 拦掉 3%。每层成本递增但量递减,整体 ROI 最高。
几个工程细节
- Bloom filter 的误判率:1 亿 URL 用 1GB 内存可做到 0.1% 误判。误判的代价是"少抓一篇文章",可接受。
- SimHash 分桶:把 64 位指纹切成 4 段 16 位,每段建倒排。Hamming < 3 的两个指纹至少有一段完全相同——这段倒排里查就行。
- 增量去重:每条新文章实时去重,不要批处理。批处理会让重复数据"先入库再去重",造成下游污染。
- 去重的"漏报"比"误报"危险:误报只是少抓一条,漏报是让下游看到重复。调阈值时偏向"宁可误报"。
给团队的简短建议
- 分层去重,从便宜的 L1 开始。
- L2 SimHash 用 64 位 + 分桶,性能足够。
- L4 向量去重只对疑似样本做,不要全量。
- 去重阈值要定期审计——业务变了阈值可能要调。
去重是采集系统里"看不见的基础设施"。它不出彩,但做不好会让所有下游数据都失真。把它做扎实,是数据团队的基本功。