大模型语料去重工程实战:从 MinHash-LSH 的概率曲线到 10TB 语料流水线
执行摘要:预训练语料的质量工程里,去重是投入产出比最高、也最容易被做错的一步。多数团队的做法停在"算个 SHA256 丢进 Set",这只能消灭字节级相同的拷贝,对模板页、镜像站、转载、机翻转写这类占比往往超过 20% 的近重复(near-duplicate)几乎无效。真正可用的方案是一个三层漏斗:归一化精确去重 → MinHash-LSH 近重复召回 → embedding 语义去重兜底。本文拆开 MinHash 的无偏估计性质与 LSH 分带那条 S 形概率曲线,给出可直接落地的签名、分桶、候选校验与连通分量代码,并列出生产环境真正会踩的坑:数据倾斜、阈值拍脑袋、去重后的分布偏斜。
一、先把问题定义清楚:你要杀掉的是哪一种"重复"
去重不是一个动作,而是三类性质完全不同的目标:
| 类型 | 典型来源 | 判定手段 | 语料占比(经验值) |
|---|---|---|---|
| 精确重复 | 同一文件被多次抓取、CDN 镜像 | 归一化后哈希 | 3%–10% |
| 近重复 | 模板页、转载、版本号改动的文档 | MinHash-LSH / SimHash | 15%–30% |
| 语义重复 | 同一事件的不同报道、机翻转写 | embedding 聚类 | 5%–15% |
为什么它值得单独做一期工程?三个直接后果:
- 记忆化与泛化退化。同一段文本在训练集中出现几十次,模型倾向于逐字复述而非理解,这在 2023 年之后的多篇重复数据研究里被反复验证——重复文档比例升高,下游任务的 few-shot 表现会明显下滑,且这种损伤无法靠加数据补回来。
- 评测污染。如果 benchmark 的测试集文本混进了训练集,你看到的指标提升有一部分是幻觉。
- 算力浪费。10TB 语料里 20% 是近重复,意味着大约 20% 的训练 FLOPs 买了零信息量。
关键认知是:这三类目标必须分层处理。试图用 MinHash 解决语义重复,或者用 embedding 解决精确重复,都是把 O(N) 的事情做成 O(N²)。
二、第一层:精确去重,归一化比哈希函数更重要
朴素做法是全文 SHA256,它只认字节完全一致。而真实抓取的语料里,同一篇文档的两次抓取可能相差:URL 参数、广告位 DOM、空白字符、Unicode 全角/半角、HTML 实体编码、时间戳。把"归一化"做扎实,命中率往往能从个位数提升到两位数百分比。
import hashlib, re, unicodedata
_WS = re.compile(r"\s+")
def normalize(text: str) -> str:
t = unicodedata.normalize("NFKC", text) # 全角→半角,兼容字符合并
t = t.lower()
t = re.sub(r"<script[\s\S]*?</script>|<style[\s\S]*?</style>", " ", t) # 去掉非正文
t = re.sub(r"<[^>]+>", " ", t) # HTML 标签
t = _WS.sub(" ", t).strip() # 空白折叠
return t
def exact_key(text: str) -> str:
return hashlib.blake2b(normalize(text).encode("utf-8"), digest_size=16).hexdigest()
注意一个常被忽略的点:归一化函数一旦改动,整条流水线的历史哈希全部失效。所以务必把归一化器的版本号写进产物路径(dedup_v3/...),否则你会发现新旧语料混在一起时去重比例突然"变好",其实只是键空间不一致导致的假阳性。
文档级精确去重仍有盲区:一篇 100KB 的license 文本被嵌进 5000 个仓库 README,每个 README 整体哈希都不同。这类场景需要子序列级去重——用 suffix array 或 rolling hash 找出长度超过阈值(如 50 tokens / 100 字节)的公共子串并删除。这是 GPT-3 与 RefinedWeb 采用的策略,代价是 O(N log N) 的内存与时间,一般只保留给"高价值、低体量"的清洗阶段,而不是全量语料。
三、MinHash:把集合相似度压缩成一个可比较的签名
把文档看成 token n-gram 的集合(shingle)。两篇文档的相似度用 Jaccard 度量:
J(A, B) = |A ∩ B| / |A ∪ B|
对 10TB 语料两两计算 Jaccard 是 O(N²),完全不可行。MinHash 的精妙之处在于一个概率恒等式:设 h 是从元素到整数的随机哈希,令 hmin(S) = min{ h(x) | x ∈ S },则
P( hmin(A) == hmin(B) ) = J(A, B)
也就是说,一次"最小值碰撞"就是 Jaccard 相似度的一个无偏伯努利样本。取 k 个独立哈希,得到 k 维签名,签名维度上的重合率就是 J(A,B) 的估计,标准误约 1/sqrt(k):k=128 时误差约 ±0.088,k=256 时约 ±0.062。工程上 128–256 是性价比区间。
实现上不需要真的做 k 次置换,用 universal hashing 即可((a * x + b) mod p 再对大素数取模,或用 numpy 一次性向量化):
import numpy as np
MERSENNE = (1 << 61) - 1 # 大素数,模乘不会溢出 int64
MAX_HASH = (1 << 32) - 1
def minhash_signature(shingles, k=128, seed=42):
rng = np.random.default_rng(seed)
a = rng.integers(1, MERSENNE, size=k, dtype=np.int64)
b = rng.integers(0, MERSENNE, size=k, dtype=np.int64)
hv = np.array([int.from_bytes(
hashlib.blake2b(s.encode(), digest_size=8).digest(), "little") % MERSENNE
for s in set(shingles)], dtype=object)
# 向量化:(a*h + b) mod p mod 2^32,逐维取最小
sig = np.empty(k, dtype=np.uint32)
for i in range(k):
vals = [(int(a[i]) * int(h) + int(b[i])) % MERSENNE for h in hv]
sig[i] = min(v & MAX_HASH for v in vals)
return sig
def shingles(text, n=5):
toks = normalize(text).split()
return {" ".join(toks[i:i+n]) for i in range(max(1, len(toks) - n + 1))}
shingle 的 n 是第二个关键超参:n 太小(如 1-gram)会让任意两篇中文文档都显得相似;n 太大(如 13-gram)则对局部改写过于敏感,召回塌方。英文 5-gram、中文先分词后 3–5 gram(或直接按字 5-gram 免分词)是常见选择。
四、LSH 分带:一条你能用手拧的概率曲线
有了 k 维签名,仍然不能两两比对。LSH 的做法是把签名切成 b 个 band,每个 band r 行(k = b × r),对每个 band 把整段签名哈希成一个桶键。只要两个文档在任意同一个 band 里签名完全相同,就进入候选对。
单个 band 完全相同的概率是 s^r,b 个 band 至少命中一个的概率:
P(candidate) = 1 - (1 - s^r)^b
这是关键:它是一条以 s ≈ (1/b)^(1/r) 为拐点的 S 形曲线。给定目标阈值 t,你可以反解 b、r。例如 k=128、想要 t≈0.8:
| b × r | 拐点 (1/b)^(1/r) | s=0.5 命中 | s=0.8 命中 | s=0.95 命中 |
|---|---|---|---|---|
| 64 × 2 | 0.125 | 0.98 | ≈1.0 | ≈1.0 |
| 32 × 4 | 0.575 | 0.49 | 0.90 | ≈1.0 |
| 16 × 8 | 0.756 | 0.10 | 0.66 | 0.99 |
| 8 × 16 | 0.868 | 0.004 | 0.20 | 0.90 |
import itertools, hashlib
def lsh_candidates(sigs, b, r):
buckets = {}
for idx, sig in sigs.items():
for band in range(b):
chunk = sig[band*r:(band+1)*r].tobytes()
key = (band, hashlib.blake2b(chunk, digest_size=8).digest())
buckets.setdefault(key, []).append(idx)
pairs = set()
for members in buckets.values():
if 1 < len(members) <= 200: # 巨型桶直接丢弃,见第六节
for x, y in itertools.combinations(sorted(members), 2):
pairs.add((x, y))
return pairs
必须强调一个实战纪律:LSH 只是召回,不是判定。 上表可以看到 s=0.5 时 32×4 仍有 49% 概率进桶——如果你直接把 LSH 命中当作重复删除,会误杀大量正常文档。正确做法是:LSH 生成候选对 → 用签名重合率(或精确 Jaccard)复算 → 超过阈值才判定重复。这一步把精度从"概率性的"拉回"确定性的",代价只是多一次 O(候选对数) 的计算。
五、MinHash 还是 SimHash:不要凭直觉选
SimHash 把文档压成一个 64/128 位指纹,用汉明距离度量相似(通常 ≤3 判定重复)。它的优势是存储极小、单机可处理十亿级;劣势是它本质上对"超高相似"(>0.95)敏感,对 Jaccard 0.6–0.9 这个近重复的主战场召回很差,且阈值与 Jaccard 无直观对应关系。
MinHash 的签名更大(128 × 4B = 512B/文档),但阈值有明确语义(就是 Jaccard)、召回曲线可解析调节,这也是为什么 RefinedWeb、Dolma、SlimPajama、FineWeb 这一代开源语料清一色选择 MinHash/LSH。结论很直白:做预训练语料去重,选 MinHash;SimHash 留给网页爬虫的 URL 级快速判重。
六、生产流水线:三层漏斗 + 分布式落地
单机的 MinHash 只能处理百万级文档。10TB 语料(约 30 亿文档)必须分布式,核心思路是把"分桶"翻译成一次 groupBy:
from pyspark.sql import functions as F
# sig 列:array<int>,长度 k=128;b=32, r=4
b, r, thr = 32, 4, 0.80
bands = df.select(
"doc_id",
F.explode(F.sequence(F.lit(0), F.lit(b - 1))).alias("band")
).select(
"doc_id", "band",
F.xxhash64(F.concat_ws("|", F.slice(F.col("sig"), F.col("band") * r + 1, r))).alias("bucket")
)
# 桶内自连接 → 候选对
cand = (bands.alias("l").join(bands.alias("r"), ["band", "bucket"])
.filter(F.col("l.doc_id") < F.col("r.doc_id"))
.select(F.col("l.doc_id").alias("a"), F.col("r.doc_id").alias("b")).distinct())
# 复算签名重合率,确定性判定
scored = (cand.join(sig_df.alias("x"), F.col("a") == F.col("x.doc_id"))
.join(sig_df.alias("y"), F.col("b") == F.col("y.doc_id"))
.withColumn("j_hat",
F.size(F.array_intersect("x.sig", "y.sig")) / F.lit(k)))
dup_edges = scored.filter(F.col("j_hat") >= thr).select("a", "b")
最后一步是连通分量:重复往往成团(一个模板页产生几百个变体),逐对删除会删错代表。把候选边拉回 driver(或按边集做一轮 union-find),找出每个连通分量,保留一个代表(如最长/最早/源权重最高者),其余标记删除。这一步不需要图计算框架——候选边集通常只有千万级,单机 union-find 几秒跑完。
生产环境三个必踩的坑:
- 数据倾斜。导航页、"404 模板"、生成的 SEO 页面会产生包含数十万成员的巨型桶,一次
groupBy直接 OOM。处置:对每个 band 的桶做计数过滤(1 < len <= 200),或对全网高频 shingle 建立黑名单,在签名前剔除(等价于停用词)。 - 分桶必须全局。如果你按 shard 分别分桶,跨 shard 的重复永远发现不了。band 键里不要混入分区 id。
- 去重粒度的边界。按文档去重安全但召回低;按段落去重更彻底却可能破坏文章结构。折中做法是文档级为主,对超长文档(>32k tokens)再做一次段落级。
七、验收:别用"删了多少"当指标
去重流水线最容易自欺的地方是汇报"删除了 23% 的数据"——这个数字本身毫无意义,删得太狠同样能凑出漂亮比例。建议的验收清单:
- 人工抽样精度/召回。随机抽 300 对"判定为重复"的和 300 对"漏网的高相似"样本人工看,目标 precision ≥ 0.95、recall ≥ 0.8。
- 分布校验。按 domain / 语言 / 来源统计去重前后占比,任何一类下降超过 30% 都要回溯——这通常意味着归一化或阈值对该类文本有系统性偏见(代码、法律文本、诗歌是最常见的误杀对象)。
- 与 benchmark 去污染分开做。评测集去污染用的是 n-gram(常见 13-gram)精确匹配,是"宁可错杀"的语义;语料去重是"宁可放过"。两者混用会同时把两件事做坏。
- 小模型验证是最强信号。用去重前后各训一个 1B 模型,在固定评测集上对比。这是唯一能证明你这条流水线真的有价值的证据,也比任何离线指标都有说服力。
八、结论
去重这件事的技术含量不在哈希函数,而在概率曲线的调节与工程纪律。MinHash 给你一个无偏的相似度估计,LSH 把 O(N²) 变成 O(N) 的召回,而真正决定成败的是:有没有在召回之后做确定性复核、有没有处置倾斜桶、有没有在删完之后校验数据分布。判断一个团队是否真的掌控了这条流水线,不看它用了什么算法,而看它能不能回答一个量化问题——在语料工程里这个问题是:"把阈值从 0.8 挪到 0.85,会多召回多少对,其中多少是真重复"。给出这条曲线,比任何"我们做了去重"的陈述都有分量。

发表评论 取消回复