从 TB 到可用:大模型预训练数据去重管道的工程实战
8TB RedPajama 模糊去重 CPU 跑 120 小时,GPU 跑 7.5 小时——去重不是学术问题,是工程瓶颈。本文以 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 + LSH | O(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\nvs\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.5h | 1.3 TB |
| 启发式过滤 | 45% | 0.3h | 0.9 TB |
| 模糊去重 | 35% | 4.0h | 0.7 TB |
| 模型质量过滤 | 25% | 1.0h | 0.5 TB |
| 语义去重 | 15% | 3.5h | 0.3 TB |
核心结论:从 2TB 到 0.3TB,删掉了 85% 的原始数据,保留下来的 15% 是最高信息密度的语料。训练成本从 2TB 的全量训练降到 0.3TB 的精选训练——GPU 时间节省 6-7 倍,而模型效果持平甚至更好(SemDeDup 论文验证)。
七、踩坑清单
| # | 问题 | 表现 | 修复 |
|---|---|---|---|
| 1 | Unicode 归一化缺失 | 相同文本哈希不同 | 统一 NFC 归一化 |
| 2 | 空白字符不一致 | 肉眼相同文档不去重 | re.sub(r'\s+', ' ', text.strip()) |
| 3 | map_batches 跨 batch 重复 | 全局去重失效 | 用 unique() 或全局聚合 |
| 4 | MinHash 参数不整除 | LSH 静默结果错误 | num_hashes % num_buckets == 0 |
| 5 | GPU OOM 无报错 | 管道卡住不动 | 监控 nvidia-smi,设 RMM 内存池 |
| 6 | 网络带宽瓶颈 | GPU 集群去重远慢于预期 | 确保 ≥25Gbps 节点互联 |
| 7 | datasketch 版本 bug | 多线程漏检重复 | 升级到 1.0.3+ |
| 8 | 过滤阈值一刀切 | 特定领域数据被误删 | 分域名设置不同阈值 |
结论:去重是数据工程的 ROI 之王
大模型训练的投入产出比,在数据管道阶段就已经决定了。一个经过三层去重 + 质量过滤的 0.3TB 语料,训练出的模型效果通常优于在 2TB 未处理语料上训练的模型——因为你把同样的 GPU 时间花在了更高信息密度的数据上。
三层去重不是”锦上添花”,而是预训练数据工程的必要投入。精确去重解决明面重复,模糊去重解决变体重复,语义去重解决信息冗余——三层缺一不可。而 Ray Data + NeMo Curator 的组合,让你用 10 小时 GPU 时间完成过去需要数周的 CPU 去重工作。
下一步建议:如果你还没建过去重管道,从精确去重开始,验证效果后再逐步加入模糊和语义去重。每一层的收益都是边际递减的,但每一层的工程复杂度是边际递增的——渐进式建设比一步到位更稳妥。