从 TB 到可用:大模型预训练数据去重管道的工程实战

8TB RedPajama 模糊去重 CPU 跑 120 小时,GPU 跑 7.5 小时——去重不是学术问题,是工程瓶颈。本文以 Ray Data + NeMo Curator 为骨架,给出从精确去重到语义去重的三层实战方案、GPU 加速配置和踩坑经验。

工程实践 已修订 数据管道 去重 Ray Data NeMo Curator GPU加速 预处理

引言:去重不是可选项,是第一道工程关

做过预训练数据工程的人都知道一个残酷的事实:你拿到的原始语料,可能有 30%-70% 是重复的。

RedPajama-v2 的统计数据显示,其 8TB 原始语料在精确去重后保留了约 60% 的文档,模糊去重后再砍掉约 20%。Common Crawl 的 WET 文件跨 dump 重复率更高——同一个新闻稿被 50 个站点转载,在语料里就出现 50 次。如果你不做去重直接喂给模型,后果是:模型记住重复文本而非学习模式、长尾分布被高频内容劫持、训练 token 浪费在冗余数据上。

去重有三个层次,工程复杂度逐层递增:

层级方法复杂度典型召回率
精确去重SHA-256 / MD5 哈希O(N)仅完全相同文档
模糊去重MinHash + LSHO(N·H)近似重复、改写稿
语义去重Embedding + K-Means 聚类O(N·D + N·K)不同措辞同义内容

其中 H 是哈希函数数量,D 是嵌入维度,K 是聚类中心数。从精确到语义,计算成本呈指数增长,但每一层都有不可替代的价值。本文给出在 Ray Data 上构建三层去重管道的完整工程方案。

一、精确去重:5 分钟搭完但别低估它

精确去重最简单,也最容易掉坑。

1.1 标准实现

import hashlib
import ray

ds = ray.data.read_parquet("s3://my-bucket/raw-corpus/")

def exact_dedup(batch):
    """对每个 batch 计算文本哈希,标记重复"""
    seen = set()
    keep = []
    for text in batch["text"]:
        h = hashlib.sha256(text.encode("utf-8")).hexdigest()
        if h not in seen:
            seen.add(h)
            keep.append(True)
        else:
            keep.append(False)
    batch["keep"] = keep
    return batch

deduped = ds.map_batches(exact_dedup).filter(lambda row: row["keep"])

但这个实现有问题——seen 集合是 per-batch 的,跨 batch 的重复检测不到。Ray Data 的 map_batches 保证同一 batch 内的去重,但不保证全局去重。

1.2 全局精确去重的正确姿势

# 方案 A:先算哈希列,再全局去重
def add_hash(batch):
    batch["hash"] = [
        hashlib.md5(t.encode("utf-8")).hexdigest()
        for t in batch["text"]
    ]
    return batch

ds_with_hash = ds.map_batches(add_hash)
# Ray Data 的 drop_duplicates 支持按列全局去重
deduped = ds_with_hash.unique("hash")
# 方案 B:用 NeMo Curator 的 GPU 加速精确去重
from nemo_curator.modules import ExactDuplicates

exact_dedup = ExactDuplicates(
    id_field="doc_id",
    text_field="text",
)
# 在 GPU Dask 集群上运行,1TB 数据精确去重约 30 分钟
deduped = exact_dedup(dataset)

踩坑记录:

  • 坑 1:Unicode 归一化不一致。同一个文本 UTF-8 和 NFC 编码的哈希不同。预处理时务必统一 unicodedata.normalize("NFC", text)。
  • 坑 2:空白字符差异。\r\n vs \n、行首行尾空格、连续空格——这些差异导致”肉眼相同”的文本哈希不同。去重前先做 re.sub(r'\s+', ' ', text.strip())。
  • 坑 3:MD5 碰撞概率在大规模数据下不可忽略。1 亿文档用 MD5 有约 10^-18 的碰撞概率,数学上安全,但如果你对安全性有要求,用 SHA-256。

二、模糊去重:MinHash + LSH 的工程调优

精确去重只能删除完全相同的文档。但互联网上更常见的是”近似重复”——同一篇新闻被不同站点转载时去掉了作者署名、改了标题、加了站点水印。MinHash + LSH 是处理这类问题的工业标准。

2.1 MinHash 原理速览

MinHash 的核心思想:对文档的 n-gram 集合做多次独立哈希,取每次的最小值组成签名向量。两个文档的签名向量相同的比例,近似等于它们的 Jaccard 相似度。LSH(Locality-Sensitive Hashing)则把签名向量分段,只有足够多段相同的文档对才被候选为相似——避免 O(N²) 的全量比较。

2.2 生产级配置

from nemo_curator.modules import FuzzyDuplicates

fuzzy_dedup = FuzzyDuplicates(
    id_field="doc_id",
    text_field="text",
    num_hashes=260,        # MinHash 签名长度,越多越精确
    num_buckets=20,        # LSH 分桶数,越多漏检越少
    hash_method="md5",     # 或 "sha256"
    seed=42,
)

# 关键参数选择指南:
# - num_hashes: 128 → 快但粗糙;260 → 平衡;512 → 精确但慢
# - num_buckets: 10 → 粗筛;20 → 推荐;50 → 精细但内存大
# - Jaccard 阈值通常设 0.7-0.8,即 70-80% n-gram 重叠视为重复

2.3 GPU 加速:16 倍提速的工程细节

NVIDIA NeMo Curator 利用 RAPIDS 库(cuDF、cuML、cuGraph)在 GPU 上跑 MinHash 计算和 LSH 分桶,8TB RedPajama-v2 模糊去重从 CPU 的 120 小时缩短到 GPU 的 7.5 小时。

from nemo_curator import get_client
import dask_cuda

# 启动 GPU 集群
client = get_client(
    cluster_type="gpu",
    n_workers=8,           # 8 张 GPU
    set_torch_to_use_rmm=True  # RAPIDS 内存管理
)

# 性能对比(8TB RedPajama-v2 子集)
# CPU (16 cores x 8 nodes): ~120 小时
# GPU (A100 x 8 nodes):     ~7.5 小时
# 加速比: 16x

踩坑记录:

  • 坑 4:num_hashes 和 num_buckets 必须满足 num_hashes % num_buckets == 0,否则 LSH 分桶不均匀,报错且不报错的情况下结果也不对。
  • 坑 5:GPU 内存不足时不要硬撑。NeMo Curator 会自动 fallback 到 CPU,但 fallback 过程可能卡住不报错。监控 nvidia-smi 的 GPU 内存使用率,确保 cuDF 操作不会 OOM。
  • 坑 6:跨节点模糊去重需要大量网络 Shuffle。如果集群网络带宽不够(<25Gbps),去重时间会从 7.5 小时膨胀到 20+ 小时。网络是瓶颈,不是 GPU。

2.4 datasketch:轻量级替代方案

如果你的数据量在 1000 万文档以下,不需要 GPU 集群,datasketch 是更轻量的选择:

from datasketch import MinHash, MinHashLSH
import string

def get_minhash(text, num_perm=128):
    m = MinHash(num_perm=num_perm)
    # 归一化:小写 + 去标点
    normalized = text.lower().strip().translate(
        str.maketrans('', '', string.punctuation)
    )
    for word in normalized.split():
        m.update(word.encode('utf8'))
    return m

def deduplicate_batch(texts, threshold=0.8):
    lsh = MinHashLSH(threshold=threshold, num_perm=128)
    minhashes = []
    for i, text in enumerate(texts):
        mh = get_minhash(text)
        minhashes.append(mh)
        lsh.insert(f'doc_{i}', mh)

    keep = []
    for i, mh in enumerate(minhashes):
        candidates = lsh.query(mh)
        # LSH 只保证候选,需二次验证 Jaccard
        is_dup = any(
            minhashes[int(c.split('_')[1])].jaccard(mh) >= threshold
            for c in candidates if c != f'doc_{i}'
        )
        keep.append(not is_dup)
    return keep

注意:datasketch 1.0.3 之前的版本在多线程 LSH.query() 中有静默 bug,会漏检重复。务必用 1.0.3+。

三、语义去重:Embedding 聚类,砍掉「换皮内容」

精确去重和模糊去重都基于文本层面的相似度。但有一类重复更隐蔽:内容完全不同措辞,但语义等价。比如同一段知识被 Wikipedia、知乎、CSDN 用完全不同的措辞写了三遍——MinHash 抓不到,但对模型来说信息增益为零。

3.1 NeMo Curator 语义去重

from nemo_curator.stages.text.deduplication.semantic import (
    TextSemanticDeduplicationWorkflow
)
from nemo_curator.backends.experimental.ray_data import RayDataExecutor

workflow = TextSemanticDeduplicationWorkflow(
    input_path="s3://my-bucket/fuzzy-deduped/",
    output_path="./results/semantic-dedup/",
    cache_path="./sem_cache",
    model_identifier="sentence-transformers/all-MiniLM-L6-v2",
    n_clusters=1000,       # K-Means 聚类中心数
    eps=0.07,              # 余弦相似度阈值(1 - eps 为保留阈值)
    id_field="doc_id",
    perform_removal=True,
)

executor = RayDataExecutor()
results = workflow.run(executor)

3.2 关键参数调优

语义去重的核心超参是 n_clusters 和 eps:

参数影响推荐值
n_clusters聚类粒度。太少则聚类内差异大、误删多;太多则同语义文档分到不同簇、漏检多数据量的 0.1%-1%
eps相似度阈值。0.05 保守(只删极相似的),0.1 激进(可能误删)0.07(SemDeDup 论文推荐)

关键洞察:SemDeDup(Abbas et al., 2023, arXiv:2303.09540)发现,语义去重可以在不影响模型性能的前提下删除约 50% 的数据——这意味着你可以把训练成本砍半,效果不变。

3.3 语义去重的成本账

阶段1 亿文档估算GPU 需求
Embedding 生成~4 小时4x A100
K-Means 聚类~1 小时2x A100
相似度计算 + 删除~2 小时2x A100
总计~7 小时

对比模糊去重的 7.5 小时,语义去重增加了 Embedding 生成开销,但换来的是更深层的冗余消除。在语料紧张的团队,这 7 小时 GPU 时间换回的是数周训练时间的节省。

四、质量过滤:去重之后,还要去掉「垃圾」

去重解决了冗余问题,但没解决质量问题。去重后还有大量低质量内容需要过滤。

4.1 启发式过滤规则

from nemo_curator.filters import (
    WordCountFilter,
    RepeatedLinesFilter,
    UrlRatioFilter,
    NonAlphaNumericFilter,
)

# 30+ 启发式过滤器,按需组合
pipeline_filters = [
    WordCountFilter(min_words=50, max_words=100000),
    RepeatedLinesFilter(max_repeated_line_fraction=0.3),
    UrlRatioFilter(max_url_ratio=0.2),
    NonAlphaNumericFilter(max_non_alpha_numeric_ratio=0.25),
]

for f in pipeline_filters:
    dataset = ScoreFilter(f)(dataset)

4.2 模型质量分类器

启发式规则只能过滤”明显的垃圾”。要区分”普通网页”和”教科书质量内容”,需要分类器:

from nemo_curator.classifiers import QualityClassifier

quality_clf = QualityClassifier(
    model_path="nvidia/quality-classifier-deberta",
    batch_size=256,
    device="cuda",
)

# 二分类:高质量 vs 低质量
dataset = dataset.filter(
    lambda doc: quality_clf(doc["text"]) > 0.5
)

Teacher-Student 策略:用 GPT-4 标注 5000 条数据(“高质量” / “低质量”),微调一个小分类器(如 DeBERTa),然后跑全量数据。成本从 GPT-4 全量标注的数万美元降到分类器推理的几百美元 GPU 时间。

五、Ray Data 管道编排:让 CPU 和 GPU 各司其职

实际生产中,去重管道的不同阶段有不同的计算特征——精确去重是 CPU 密集的哈希计算,模糊去重的 MinHash 可以 GPU 加速,Embedding 生成必须 GPU——Ray Data 的流式执行引擎能自动编排异构资源。

5.1 端到端管道架构

import ray

# Step 1: 读取原始语料
ds = ray.data.read_parquet("s3://corpus/raw/")

# Step 2: CPU 预处理(归一化、哈希)
ds = ds.map_batches(normalize_text, compute="cpu")
ds = ds.map_batches(add_hash_column, compute="cpu")

# Step 3: 精确去重(CPU)
ds = ds.unique("hash")

# Step 4: 启发式质量过滤(CPU)
ds = ds.filter(heuristic_quality_check)

# Step 5: 模糊去重(GPU)
# 使用 ActorPool 模式复用 GPU 上的 MinHash 计算实例
ds = ds.map_batches(
    gpu_minhash_dedup,
    compute="gpu",
    num_gpus=1,
    concurrency=4,  # 4 个 GPU 并行
)

# Step 6: 语义去重 Embedding(GPU)
ds = ds.map_batches(
    generate_embeddings,
    compute="gpu",
    num_gpus=1,
    batch_size=512,
)

# Step 7: 写出
ds.write_parquet("s3://corpus/curated/")

5.2 Block 大小调优

Ray Data 底层基于 Apache Arrow Block,Block 大小直接影响管道效率:

ctx = ray.data.DataContext.get_current()
ctx.target_min_block_size = 1 * 1024 * 1024   # 1 MiB
ctx.target_max_block_size = 128 * 1024 * 1024  # 128 MiB

经验法则:

  • Block 太小(<1 MiB):调度开销大,GPU 利用率低
  • Block 太大(>256 MiB):OOM 风险,流式调度失效
  • 最佳实践:CPU 阶段用 64 MiB,GPU 阶段用 128 MiB

5.3 监控:Data Observability

去重管道跑完不等于没问题。你需要监控:

# Prometheus 关键指标
- dedup_exact_removed_ratio    # 精确去重移除率(正常 20-40%)
- dedup_fuzzy_removed_ratio    # 模糊去重移除率(正常 10-25%)
- dedup_semantic_removed_ratio # 语义去重移除率(正常 30-50%)
- quality_filter_pass_rate     # 质量过滤通过率(正常 30-60%)
- pipeline_throughput_tokens   # 管道吞吐量 tokens/s

报警规则:

  • 精确去重移除率 >60%:上游数据源可能有严重重复爬取问题
  • 质量过滤通过率 <20%:过滤规则可能过于严格,检查误删率
  • 管道吞吐量突然下降:检查网络带宽或 GPU 利用率

六、性能基准:三层去重的成本与收益

在一个 5 亿文档(约 2TB 原始语料)的真实场景下:

阶段文档保留率耗时(8x A100)存储成本
原始语料100%-2.0 TB
精确去重65%0.5h1.3 TB
启发式过滤45%0.3h0.9 TB
模糊去重35%4.0h0.7 TB
模型质量过滤25%1.0h0.5 TB
语义去重15%3.5h0.3 TB

核心结论:从 2TB 到 0.3TB,删掉了 85% 的原始数据,保留下来的 15% 是最高信息密度的语料。训练成本从 2TB 的全量训练降到 0.3TB 的精选训练——GPU 时间节省 6-7 倍,而模型效果持平甚至更好(SemDeDup 论文验证)。

七、踩坑清单

#问题表现修复
1Unicode 归一化缺失相同文本哈希不同统一 NFC 归一化
2空白字符不一致肉眼相同文档不去重re.sub(r'\s+', ' ', text.strip())
3map_batches 跨 batch 重复全局去重失效用 unique() 或全局聚合
4MinHash 参数不整除LSH 静默结果错误num_hashes % num_buckets == 0
5GPU OOM 无报错管道卡住不动监控 nvidia-smi,设 RMM 内存池
6网络带宽瓶颈GPU 集群去重远慢于预期确保 ≥25Gbps 节点互联
7datasketch 版本 bug多线程漏检重复升级到 1.0.3+
8过滤阈值一刀切特定领域数据被误删分域名设置不同阈值

结论:去重是数据工程的 ROI 之王

大模型训练的投入产出比,在数据管道阶段就已经决定了。一个经过三层去重 + 质量过滤的 0.3TB 语料,训练出的模型效果通常优于在 2TB 未处理语料上训练的模型——因为你把同样的 GPU 时间花在了更高信息密度的数据上。

三层去重不是”锦上添花”,而是预训练数据工程的必要投入。精确去重解决明面重复,模糊去重解决变体重复,语义去重解决信息冗余——三层缺一不可。而 Ray Data + NeMo Curator 的组合,让你用 10 小时 GPU 时间完成过去需要数周的 CPU 去重工作。

下一步建议:如果你还没建过去重管道,从精确去重开始,验证效果后再逐步加入模糊和语义去重。每一层的收益都是边际递减的,但每一层的工程复杂度是边际递增的——渐进式建设比一步到位更稳妥。