中文预训练语料去重:从 MinHash 到段落级指纹
整理一次中文网页语料去重的实践:为什么文档级 MinHash 不够用,段落级指纹怎么做,以及如何验证去重没有误伤高质量内容。
中文网页语料的重复,比英文更隐蔽。同一篇新闻会被几十个站点转载,每个站点加上自己的导航栏、版权声明和“相关阅读”,文档级别的相似度因此被稀释,MinHash 往往判断它们不是重复。结果是模型在同一段文字上训练了几十遍,而评估集里恰好有它的变体。
这篇笔记记录一次去重流程的改造,以及我用来确认改造有效的几项检查。它属于 Evidence-First LLM Systems 里说的那类工作:每一步都要留下可以复查的证据。
文档级 MinHash 的盲区
MinHash 估计的是两篇文档 shingle 集合的 Jaccard 相似度:
当一篇 800 字的正文被嵌进 3000 字的页面模板里,正文部分完全相同,但 可能只有 0.2 左右,远低于常用的 0.8 阈值。问题不在算法,而在比较的单位太大。
常见的补救办法有三种:
- 先抽正文,再做文档级去重。效果取决于正文抽取器,而中文站点的模板差异很大。
- 降低阈值。误伤会急剧增加,尤其是格式高度相似的法律文书和产品说明。
- 把比较单位缩小到段落,只对“足够长、足够独特”的段落计算指纹。
我最后选了第三种,并保留第一种作为预处理。
段落级指纹
切分与过滤
段落按换行切分,然后丢掉三类段落:
- 少于 50 个汉字的短段落,它们大多是导航、标题或按钮文字;
- 在全语料中出现次数超过阈值的高频段落,比如“本文来源于网络,如有侵权请联系删除”;
- 字符种类过少的段落,通常是乱码或表格残片。
计算指纹
中文没有空格,shingle 用字符级 n-gram 比用分词结果更稳定,也不受分词器版本影响:
from datasketch import MinHash
def paragraph_signature(text: str, n: int = 5, num_perm: int = 128) -> MinHash:
m = MinHash(num_perm=num_perm)
for i in range(len(text) - n + 1):
m.update(text[i : i + n].encode("utf-8"))
return m
n = 5 是在召回和误报之间的折中:n 太小,常见短语会让不相关的段落互相匹配;n 太大,一个错别字就会让重复段落逃过检测。
从段落重复到文档决策
段落命中之后,还要决定整篇文档的去留。我用的规则是:
| 重复段落占比 | 处理方式 |
|---|---|
| 低于 30% | 保留整篇 |
| 30% 到 70% | 删除重复段落,保留其余部分 |
| 高于 70% | 整篇丢弃,只保留簇内质量分最高的一份 |
去重不是越多越好。一个看起来很漂亮的去重率,可能意味着你把所有格式规整的高质量文档当成了模板删掉了。
怎么确认没有误伤
这一步最容易被跳过。我的做法是抽样复查,而且按被删除的原因分层抽样,而不是在全部被删文档里随机抽:
- 每个处理分支各抽 200 条,人工标注“确实重复”或“误伤”;
- 单独检查被删比例最高的十个域名,确认不是某个站点的模板触发了大面积误判;
- 在下游做一次小规模对照训练,比较去重前后在保留评估集上的困惑度。
如果某个分支的误伤率明显偏高,就回头调整那个分支的阈值,而不是调整全局参数。每一次调整都要能回答:改了什么、为什么改、改完之后哪个数字变了。
还没解决的问题
段落级指纹对改写式重复无能为力。洗稿文章换了措辞但保留了结构,字符 n-gram 几乎没有重叠。这类重复需要语义层面的检测,比如用句向量做近邻搜索,但成本高出一个数量级。我会在后续笔记里记录这部分的实验。