大模型语料去重工程实战:从 MinHash-LSH 的概率曲线到 10TB 语料流水线

执行摘要:预训练语料的质量工程里,去重是投入产出比最高、也最容易被做错的一步。多数团队的做法停在"算个 SHA256 丢进 Set",这只能消灭字节级相同的拷贝,对模板页、镜像站、转载、机翻转写这类占比往往超过 20% 的近重复(near-duplicate)几乎无效。真正可用的方案是一个三层漏斗:归一化精确去重 → MinHash-LSH 近重复召回 → embedding 语义去重兜底。本文拆开 MinHash 的无偏估计性质与 LSH 分带那条 S 形概率曲线,给出可直接落地的签名、分桶、候选校验与连通分量代码,并列出生产环境真正会踩的坑:数据倾斜、阈值拍脑袋、去重后的分布偏斜。

一、先把问题定义清楚:你要杀掉的是哪一种"重复"

去重不是一个动作,而是三类性质完全不同的目标:

类型典型来源判定手段语料占比(经验值)
精确重复同一文件被多次抓取、CDN 镜像归一化后哈希3%–10%
近重复模板页、转载、版本号改动的文档MinHash-LSH / SimHash15%–30%
语义重复同一事件的不同报道、机翻转写embedding 聚类5%–15%

为什么它值得单独做一期工程?三个直接后果:

  1. 记忆化与泛化退化。同一段文本在训练集中出现几十次,模型倾向于逐字复述而非理解,这在 2023 年之后的多篇重复数据研究里被反复验证——重复文档比例升高,下游任务的 few-shot 表现会明显下滑,且这种损伤无法靠加数据补回来。
  2. 评测污染。如果 benchmark 的测试集文本混进了训练集,你看到的指标提升有一部分是幻觉。
  3. 算力浪费。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 × 20.1250.98≈1.0≈1.0
32 × 40.5750.490.90≈1.0
16 × 80.7560.100.660.99
8 × 160.8680.0040.200.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% 的数据"——这个数字本身毫无意义,删得太狠同样能凑出漂亮比例。建议的验收清单:

  1. 人工抽样精度/召回。随机抽 300 对"判定为重复"的和 300 对"漏网的高相似"样本人工看,目标 precision ≥ 0.95、recall ≥ 0.8。
  2. 分布校验。按 domain / 语言 / 来源统计去重前后占比,任何一类下降超过 30% 都要回溯——这通常意味着归一化或阈值对该类文本有系统性偏见(代码、法律文本、诗歌是最常见的误杀对象)。
  3. 与 benchmark 去污染分开做。评测集去污染用的是 n-gram(常见 13-gram)精确匹配,是"宁可错杀"的语义;语料去重是"宁可放过"。两者混用会同时把两件事做坏。
  4. 小模型验证是最强信号。用去重前后各训一个 1B 模型,在固定评测集上对比。这是唯一能证明你这条流水线真的有价值的证据,也比任何离线指标都有说服力。

八、结论

去重这件事的技术含量不在哈希函数,而在概率曲线的调节与工程纪律。MinHash 给你一个无偏的相似度估计,LSH 把 O(N²) 变成 O(N) 的召回,而真正决定成败的是:有没有在召回之后做确定性复核、有没有处置倾斜桶、有没有在删完之后校验数据分布。判断一个团队是否真的掌控了这条流水线,不看它用了什么算法,而看它能不能回答一个量化问题——在语料工程里这个问题是:"把阈值从 0.8 挪到 0.85,会多召回多少对,其中多少是真重复"。给出这条曲线,比任何"我们做了去重"的陈述都有分量。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部