第 5 章 第二部分:预训练数据工程

安全过滤与去重

Perspective/Detoxify/CSAM安全过滤、SHA256精确去重、MinHash+LSH模糊去重、SimHash、Suffix Array句级去重、训练-评测集13-gram去污染

安全过滤 去重 MinHash LSH SimHash Suffix Array 去污染

去重让训练效果好 1.5 倍,去污染让评测可信,安全过滤让模型合规。

这三件事,一件都不能省。


5.1 安全过滤:合规底线

5.1.1 为什么要做安全过滤

预训练数据来自全网,包含:

  • 毒性内容:辱骂、攻击性言论
  • 色情内容:成人内容(可能涉及违法)
  • 暴力内容:极端暴力、自残引导
  • CSAM:儿童性虐待材料(法律红线,发现必须报告)
  • 仇恨言论:种族、性别、宗教歧视

如果不做过滤:

  1. 法律风险:CSAM、仇恨言论在多国违法
  2. 模型行为失控:模型会复制有毒模式
  3. 合规审查失败:模型无法上架
  4. 品牌损害:用户用 prompt 引导出有毒输出,舆论危机

5.1.2 主流工具

工具类型速度准确率推荐场景
Perspective APIGoogle API慢(API限速)高大规模过滤
Detoxify开源本地快中-高工业级首选
ShieldGemma开源本地中(GPU)高高准确率需求
OpenAI Moderation APIAPI慢极高最终复核
自训练分类器本地快可控定制需求

5.1.3 Detoxify 实战

from detoxify import Detoxify

# 加载多语种版本(支持英/法/西/俄等多语)
model = Detoxify('multilingual', device='cuda:0')

def detect_toxicity(text):
    """
    检测文本毒性
    返回各维度概率
    """
    if not text or len(text) > 10000:
        text = text[:10000]  # 长文本截断
    
    results = model.predict(text)
    # results 形如:
    # {'toxicity': 0.85, 'severe_toxicity': 0.20, 
    #  'obscene': 0.30, 'identity_attack': 0.75,
    #  'insult': 0.80, 'threat': 0.10, 
    #  'sexual_explicit': 0.05}
    
    return results

# 推荐阈值
TOXICITY_THRESHOLDS = {
    'toxicity': 0.7,           # 毒性 >0.7 拒绝
    'severe_toxicity': 0.3,    # 严重毒性 >0.3 拒绝
    'identity_attack': 0.5,    # 身份攻击 >0.5 拒绝
    'threat': 0.4,             # 威胁 >0.4 拒绝
    'sexual_explicit': 0.6     # 性露骨 >0.6 拒绝
}

def is_safe(text):
    scores = detect_toxicity(text)
    for dim, threshold in TOXICITY_THRESHOLDS.items():
        if scores.get(dim, 0) > threshold:
            return False, dim
    return True, 'pass'

5.1.4 CSAM 过滤的工程实现

CSAM(儿童性虐待材料)是法律红线,任何疑似内容必须立即移除并报告:

def csam_filter(text, image_hashes=None):
    """
    CSAM 过滤的工程实现
    文本:用关键词 + hash 双路
    图片:用 PhotoDNA hash 比对
    """
    # 1. 关键词检测(粗筛)
    CSAM_KEYWORDS = [...]  # 不在此列出,可用 NCMEC 提供的清单
    text_lower = text.lower()
    for kw in CSAM_KEYWORDS:
        if kw in text_lower:
            # 触发后必须记录、移除、并按法规报告
            log_csam_incident(text)
            return False, 'csam_keyword'
    
    # 2. 图片 PhotoDNA 比对
    if image_hashes:
        known_csam_hashes = load_ncmec_database()  # NCMEC 提供的已知 CSAM hash 库
        for img_hash in image_hashes:
            if img_hash in known_csam_hashes:
                log_csam_incident(text, img_hash)
                return False, 'csam_image_match'
    
    return True, 'pass'

踩坑经验:CSAM 处理必须严格遵守当地法规(中国《未成年人保护法》、美国 NCMEC 报告义务等)。建议咨询合规法务后再上线流水线。

5.2 为什么必须去重

去重是预训练数据工程的强制环节,三大理由:

5.2.1 去重后效果好

Lee et al. (2022) 的实验:在 C4 上训练 1.5B 模型,去重 vs 不去重:

数据训练tokensMMLUTriviaQA
C4 原版(含重复)1T24.332.5
C4 去重后0.7T28.138.7

用 70% 的数据量,效果反而更好——因为去重让模型更高效利用每个 token。

5.2.2 防过度记忆

不去重的话,模型会逐字记住重复出现的内容:

  • 同一篇文章出现 100 次 → 模型能逐字复述
  • 但这是”死记硬背”,不是”理解能力”
  • 评测时模型在记忆样本上虚高,在新样本上崩塌

5.2.3 防模板主导

不去重的话,少量高度模板化的内容会主导训练:

  • 网站页脚、cookies声明、SEO 模板
  • 这些内容模式高度重复,模型容易”过拟合”这些模式
  • 生成时频繁吐出”cookies policy”、“click here”等模板短语

5.3 精确去重:SHA256

最简单的去重是精确匹配——对整篇文档做 hash,相同 hash 直接删。

5.3.1 实现

import hashlib

def exact_dedup(documents):
    """
    基于SHA256的精确去重
    适合"完全相同的文档"
    """
    seen_hashes = set()
    unique_docs = []
    
    for doc in documents:
        # 标准化:去前后空格、合并连续空白
        text = ' '.join(doc['text'].split())
        
        # 计算SHA256
        doc_hash = hashlib.sha256(text.encode('utf-8')).hexdigest()
        
        if doc_hash not in seen_hashes:
            seen_hashes.add(doc_hash)
            unique_docs.append(doc)
        else:
            doc['dedup_reason'] = 'exact_match'
    
    return unique_docs

# 统计:CC 中精确去重通常能去掉 5-15% 的重复

5.3.2 精确去重的局限

精确去重只能抓”完全相同”的,对以下情况无效:

  • 微小改动:多了空格、换行符不同 → hash 不同
  • 转载修改:媒体转载时改了标题或段落顺序 → hash 不同
  • 模板填充:同一个模板填了不同数据 → hash 不同
  • 抄袭改写:内容大致相同但表述略改 → hash 不同

工业级必须用模糊去重补足。

5.4 MinHash + LSH:模糊去重的事实标准

MinHash + LSH(Locality-Sensitive Hashing)是大规模去重的事实标准。

5.4.1 核心原理

MinHash 的核心思想:用一组哈希函数的”最小值”近似集合的 Jaccard 相似度。

对于两个集合 A、B:

P(min(h(A)) == min(h(B))) = Jaccard(A, B) = |A∩B| / |A∪B|

用 k 个哈希函数,得到 k 个最小值,组成签名(signature)。两个签名的相同位数比例 ≈ Jaccard 相似度。

5.4.2 n-gram + MinHash 流程

import mmh3  # MurmurHash3,速度比 hashlib 快100倍
import numpy as np
from datasketch import MinHash, MinHashLSH

def minhash_dedup(documents, num_perm=128, threshold=0.8):
    """
    MinHash + LSH 模糊去重
    
    参数说明:
    - num_perm: 排列数,影响精度与速度
      推荐 128(生产)/ 256(高精度)
    - threshold: 相似度阈值
      推荐 0.8(保留高质量)/ 0.7(更激进)
    """
    # Step 1: 为每篇文档生成 MinHash 签名
    minhashes = {}
    for doc_id, doc in enumerate(documents):
        m = MinHash(num_perm=num_perm)
        
        # 用 3-gram 作为 shingle
        words = doc['text'].split()
        for i in range(len(words) - 2):
            ngram = ' '.join(words[i:i+3])
            m.update(ngram.encode('utf-8'))
        
        minhashes[doc_id] = m
    
    # Step 2: LSH 索引
    # LSH 把 num_perm 个 hash 分成 b 个 band,每个 band r 个 hash
    # 工业推荐:b=32, r=4(即 num_perm=128)
    # 这意味着两个文档如果有 4 个 hash 完全相同,就被候选为相似
    lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
    
    for doc_id, m in minhashes.items():
        lsh.insert(str(doc_id), m)
    
    # Step 3: 找出所有相似对
    duplicates = set()
    for doc_id, m in minhashes.items():
        similar_docs = lsh.query(m)
        # 保留 doc_id 最小的,其余标记为重复
        for sim_id in similar_docs:
            sim_id_int = int(sim_id)
            if sim_id_int > doc_id:
                duplicates.add(sim_id_int)
    
    return [doc for i, doc in enumerate(documents) if i not in duplicates]

5.4.3 工程参数详解

# 推荐的工业级参数配置
MINHASH_CONFIG = {
    'num_perm': 128,          # 排列数
    'shingle_size': 3,        # n-gram 阶数
    'shingle_unit': 'word',   # word 或 char(中文推荐 char)
    'threshold': 0.8,         # 相似度阈值
    
    # LSH 参数(与 num_perm 关联)
    # 推荐 b*r = num_perm
    # b=32, r=4 → num_perm=128
    # b=64, r=4 → num_perm=256
    # b=16, r=8 → num_perm=128(更严格,false positive少)
    'bands': 32,
    'rows': 4,
    
    # 内存控制
    'batch_size': 100000,     # 批处理大小
    'storage': 'disk',        # 大规模数据用磁盘存储
    'storage_config': {
        'type': 'leveldb',    # 大规模推荐 LevelDB 或 Redis
        'path': '/tmp/lsh_index'
    }
}

5.4.4 参数选择对比

num_permthreshold速度准确率内存推荐场景
640.8极快中低大规模粗筛
1280.8快高中工业首选
2560.9中极高高精细去重
5120.95慢极高极高学术研究

5.4.5 LSH 的 band/rows 权衡

LSH 把 num_perm 个 hash 函数分成 b 个 band,每个 band r 个 hash。两个文档被判定为候选的条件是:至少一个 band 内的 r 个 hash 完全相同。

候选概率与 Jaccard 相似度 J 的关系:

P(candidate | J) = 1 - (1 - J^r)^b

不同 (b, r) 的曲线对比:

(b, r)J=0.5 候选率J=0.8 候选率J=0.9 候选率
(32, 4)0.040.830.998
(16, 8)0.0010.200.85
(64, 4)0.310.991.0

踩坑经验:(b=32, r=4) 是经典默认配置,适合 threshold=0.8。如果想更严格地抓”近似重复”,用 (b=16, r=8);如果想抓”高度相似”,用 (b=64, r=4)。

5.5 SimHash:另一种思路

SimHash(Charikar, 2002)是 Google 用来去重网页的算法,思路与 MinHash 不同。

5.5.1 原理

  1. 对每个 token 计算一个 64-bit hash
  2. 对每个 bit 位,如果 hash 在该位为 1,token 权重 +1;为 0,权重 -1
  3. 累加所有 token 的权重,得到一个 64 维向量
  4. 把向量的每个分量做 sign 函数(>0 取 1,<0 取 0),得到 64-bit SimHash 签名
  5. 两篇文档的相似度 = 1 - Hamming 距离 / 64
import mmh3
import numpy as np

def simhash(text, hash_bits=64):
    """SimHash 签名生成"""
    tokens = text.split()
    weighted_sum = np.zeros(hash_bits)
    
    for token in tokens:
        # 用 MurmurHash3 生成 64-bit hash
        h = mmh3.hash64(token)[0]  # 取第一个64位hash
        
        # 对每一位累加
        for i in range(hash_bits):
            bit = (h >> i) & 1
            weighted_sum[i] += 1 if bit else -1
    
    # sign 函数生成签名
    signature = 0
    for i in range(hash_bits):
        if weighted_sum[i] > 0:
            signature |= (1 << i)
    
    return signature

def hamming_distance(h1, h2):
    """汉明距离"""
    xor = h1 ^ h2
    return bin(xor).count('1')

# 两篇文档相似度判定
def is_similar(text1, text2, threshold=3):
    """
    Hamming 距离 ≤ threshold 视为相似
    推荐 threshold=3(64-bit hash 上,约0.95相似度)
    """
    h1 = simhash(text1)
    h2 = simhash(text2)
    return hamming_distance(h1, h2) <= threshold

5.5.2 MinHash vs SimHash

特性MinHashSimHash
度量Jaccard 相似度Cosine 相似度
签名长度128-256 bit64 bit
速度快极快
准确率高中-高
大规模LSH成熟较复杂
推荐场景文档级去重网页级去重

工业实践:MinHash 用于”文档级近似重复”,SimHash 用于”网页级近似重复”。两者可以互补使用。

5.5.3 语义去重:从 n-gram 到 LLM 嵌入

MinHash 和 SimHash 都基于 token/char n-gram 的浅层匹配。它们在以下场景中会失效:

  • 模板污染:同模板不同内容(如导航栏相同但正文不同)——n-gram 方法无法区分
  • 语义重复:同语义不同表述(如转载时改写措辞)——n-gram 重叠度低,导致漏检
  • 短文本扰动:极短重复片段嵌入长文本——n-gram 信噪比过低

SemHash-LLM(Fang et al., arXiv:2607.01601v1, 2026-07-02)是 2026 H2 的代表性工作,提出了一套多粒度语义去重框架:

  1. 语义投影哈希(SPH):将 LLM 嵌入蒸馏为紧凑二进制码,克服 SimHash 无法捕捉语义相似性的缺陷
  2. 注意力加权 MinHash:利用 Transformer 注意力权重抑制模板噪声(导航栏、广告),强调内容核心
  3. 级联过滤:SPH 粗筛 → 注意力 MinHash 精筛 → LLM 裁决(<1% 神经成本),将 LLM 验证控制在可接受范围

⚠️ SemHash-LLM 为 2026 年 7 月预印本,尚未经同行评议。但其核心思路——用 LLM 嵌入空间判定”语义等价”而非”n-gram 重叠”——代表了 2026 H2 语义去重的重要趋势。

工业趋势信号:NeMo Curator 26.04 已将语义去重作为内置模块(默认启用),后端从 SentenceTransformers 切换为 vLLM,默认模型从 all-MiniLM-L6-v2 升级为 google/embeddinggemma-300m。这标志着工业界已开始从”n-gram 重叠”向”语义等价”过渡。

工程定位:语义去重计算成本显著高于 MinHash(需 GPU + 嵌入推理),建议在 MinHash 粗筛后用语义方法做精筛——而非替代 MinHash。

5.6 Suffix Array:句级去重

文档级去重抓不到”句子级”的重复。例如:

  • 文章 A 中有一段是 Wikipedia 内容
  • 文章 B 中也有同一段 Wikipedia 内容
  • 文章 A 和 B 整体不同,文档级去重抓不到

Suffix Array 用于解决这类句级/段落级重复。

5.6.1 原理

Suffix Array 是字符串所有后缀的有序索引,能在 O(N log N) 时间内找到所有重复子串。

import numpy as np

def suffix_array(text):
    """构建后缀数组(简化版)"""
    suffixes = [(text[i:], i) for i in range(len(text))]
    suffixes.sort()
    return [s[1] for s in suffixes]

def find_duplicate_substrings(texts, min_length=100):
    """
    找出所有重复的子串
    min_length: 最小重复长度(字符数)
    """
    # 把所有文本拼成一个大字符串
    # 用分隔符标记文档边界
    SEP = '\x00'
    combined = SEP.join(texts)
    
    # 构建后缀数组和 LCP(最长公共前缀)数组
    sa = suffix_array(combined)
    
    # 计算LCP数组
    lcp = compute_lcp(combined, sa)
    
    # 找出 LCP >= min_length 的位置
    duplicates = []
    for i in range(1, len(lcp)):
        if lcp[i] >= min_length:
            # 找到重复子串
            start = sa[i]
            end = start + lcp[i]
            duplicate = combined[start:end]
            duplicates.append(duplicate)
    
    return duplicates

5.6.2 工业实现:Google re2

工业级实现不用 Python,而是用 C++ 后缀数组库(如 sdsl):

# 推荐工具:datasketch 的 suffix_array 模块
# 或 Lee et al. (2022) 开源的 ccnet 实现

# ccnet 的句级去重命令
python -m ccnet.dedup \
    --input data.jsonl \
    --output dedup.jsonl \
    --min-length 100 \
    --method suffix_array

踩坑经验:Suffix Array 的内存复杂度是 O(N)(N 是所有文本字符数)。10TB 文本需要约 20TB 内存——只能用分布式版本(如 Spark 上的实现)。

5.7 训练-评测集去污染

去污染(Decontamination)是去重的反向应用——不是去除训练集内部的重复,而是去除训练集中与评测集重叠的部分。

5.7.1 为什么必须去污染

如果训练集里包含 MMLU 的测试题,模型在 MMLU 上”刷榜”成绩会虚高,但实际能力并不强。

评测集题目数估计污染率
MMLU14,0425-15%
GSM8K1,31910-20%
HumanEval16420-40%
TriviaQA95K15-25%

不去污染,benchmark 成绩不可信。

5.7.2 13-gram 去污染

工业标准做法是13-gram 重叠检测:

def decontaminate(train_texts, eval_texts, n=13):
    """
    13-gram 去污染
    训练集中如果包含与评测集任一13-gram重叠的内容,整篇删除
    """
    # Step 1: 构建评测集的 13-gram 集合
    eval_ngrams = set()
    for text in eval_texts:
        words = text.lower().split()
        for i in range(len(words) - n + 1):
            ngram = ' '.join(words[i:i+n])
            eval_ngrams.add(ngram)
    
    # Step 2: 检查训练集
    clean_train = []
    contaminated = 0
    for text in train_texts:
        words = text.lower().split()
        is_contaminated = False
        for i in range(len(words) - n + 1):
            ngram = ' '.join(words[i:i+n])
            if ngram in eval_ngrams:
                is_contaminated = True
                break
        
        if not is_contaminated:
            clean_train.append(text)
        else:
            contaminated += 1
    
    print(f'Contaminated: {contaminated} / {len(train_texts)}')
    return clean_train

# 主流评测集清单(必须全部去污染)
EVAL_SETS = [
    'mmlu', 'gsm8k', 'human_eval', 'trivia_qa',
    'natural_questions', 'arc_challenge', 'arc_easy',
    'winogrande', 'hellaswag', 'piqa', 'siqa',
    'boolq', 'openbookqa', 'math', 'mbpp',
    'copa', 'wsc', 'rte', 'cb', 'wic'
]

5.7.3 为什么是 13-gram?

13 是经验值:

  • 太短(如 5-gram):会误杀大量”巧合”重叠
  • 太长(如 50-gram):会漏掉”轻微改写”的污染

Brown et al. (2020) 的实验表明:13-gram 是检测”原文泄露”和”过度匹配”的合理平衡点。

5.7.4 去污染的踩坑

坑1:只去题目,没去题目周围的解释

CC 抓取的 benchmark 解读文章里,题目和解释一起出现。如果只匹配题目,会漏掉。

解决:用题目+答案+解释的拼接做 13-gram 匹配。

坑2:评测集翻译版未去污染

某团队用英文 MMLU 做了去污染,但训练数据里有中文版 MMLU 翻译,结果中文 MMLU 成绩虚高。

解决:所有语言的评测集都要做去污染。

坑3:评测集更新后没重新去污染

某团队去污染后,MMLU 升级了版本,新题被训练数据覆盖,结果模型在新版 MMLU 上崩盘。

解决:评测集每次更新都要重新做去污染。

5.8 去重的工程实现:分布式架构

TB 级数据的去重必须分布式实现:

┌──────────────────────────────────────────────────────────────┐
│                    数据分片层 (Spark)                        │
│   按 hash(shingle) 分桶,相同 shingle 进入同一桶             │
└──────────────────────────────┬───────────────────────────────┘
                               │
              ┌────────────────┼────────────────┐
              ▼                ▼                ▼
        ┌──────────┐    ┌──────────┐    ┌──────────┐
        │ Shard 1  │    │ Shard 2  │    │ Shard N  │
        │ LSH Index│    │ LSH Index│    │ LSH Index│
        └────┬─────┘    └────┬─────┘    └────┬─────┘
             │               │               │
             └───────────────┼───────────────┘
                             ▼
┌──────────────────────────────────────────────────────────────┐
│                    合并去重层                                │
│   各分片内去重后,再做跨分片的二阶段去重                     │
└──────────────────────────────┬───────────────────────────────┘
                               ▼
┌──────────────────────────────────────────────────────────────┐
│                    去重结果存储                              │
│   输出: unique_docs.parquet + dedup_report.json             │
└──────────────────────────────────────────────────────────────┘

5.8.1 推荐工具栈

工具用途
datasketch单机 MinHash+LSH,适合 <100GB
Spark + datasketch分布式 MinHash,适合 TB 级
ccnet (Facebook)工业级流水线,含句级去重
BigQuery用 SQL 实现 hash 比对(云上)
DuckDB单机 TB 级 SQL 查询
NeMo Curator Semantic DedupGPU 语义去重(vLLM + EmbeddingGemma-300M),TB 级
FED (arXiv:2501.01046)GPU 加速 MinHash,>100GB 场景,滚动哈希实现数量级加速

5.9 去重的工程参数总览

# 推荐的工业级去重参数
DEDUP_CONFIG = {
    # 精确去重
    'exact_dedup': {
        'enabled': True,
        'hash_algorithm': 'sha256',
        'normalization': 'lowercase_strip_whitespace'
    },
    
    # MinHash 模糊去重(文档级)
    'minhash_dedup': {
        'enabled': True,
        'num_perm': 128,
        'shingle_size': 3,
        'shingle_unit': 'word',  # 中文用 char
        'threshold': 0.8,
        'lsh_bands': 32,
        'lsh_rows': 4
    },
    
    # SimHash 网页级去重(可选,与 MinHash 互补)
    'simhash_dedup': {
        'enabled': False,  # 一般用 MinHash 就够了
        'hash_bits': 64,
        'hamming_threshold': 3
    },
    
    # Suffix Array 句级去重
    'suffix_array_dedup': {
        'enabled': True,
        'min_duplicate_length': 100,  # 字符
        'implementation': 'ccnet'     # 或 spark-suffix-array
    },
    
    # 评测集去污染
    'decontamination': {
        'enabled': True,
        'ngram_size': 13,
        'eval_sets': EVAL_SETS,
        'match_method': 'exact_ngram',  # 或 'minhash'
        'action': 'remove_doc'          # 或 'mask_span'
    },
    
    <!-- edit: 2026-07-24 新增语义去重配置(基于 NeMo Curator 26.04 TextSemanticDeduplicationWorkflow) -->
    # 语义去重(可选,需 GPU)
    'semantic_dedup': {
        'enabled': False,               # 默认关闭,仅在 GPU 可用时开启
        'backend': 'vllm',              # NeMo Curator 26.04+
        'model': 'google/embeddinggemma-300m',
        'threshold': 0.95,              # Cosine 相似度阈值
        'stage': 'post_minhash'         # 在 MinHash 精筛后使用,控制 GPU 成本
    }
}

5.10 本课总结

  • 安全过滤:Detoxify 是工业首选,CSAM 必须严格处理
  • 精确去重:SHA256,去掉 5-15% 完全重复
  • MinHash+LSH:模糊去重事实标准,参数 (128, 32, 4, 0.8)
  • SimHash:网页级去重的备选,速度极快
  • Suffix Array:句级去重,抓段落级重复
  • 语义去重(前瞻):SemHash-LLM 等多粒度方法利用 LLM 嵌入空间判定语义等价,NeMo Curator 26.04 已内置支持
  • 去污染:13-gram 比对,所有评测集必须做

课后练习

  1. 在 1 万篇文档上实现 MinHash 去重,统计去重率
  2. 调参对比 (b=32,r=4) vs (b=16,r=8) 在你的数据上的去重率
  3. 实现 13-gram 去污染,检查你的数据集对 MMLU 的污染率
  4. 思考:你的数据流水线最薄弱的去重环节是哪一环?

下一章我们将进入隐私脱敏与文档打标——把 PII 信息挡在门外,给每篇文档贴上多维标签,为后续配比打基础。