第 3 章 第一部分:预训练数据

文本提取与清洗——魔鬼藏在细节里

HTML 提取、语言识别、启发式过滤、PII 处理与敏感内容过滤

文本提取 数据清洗 PII 过滤

“数据清洗不性感,但它决定了你的模型是优秀还是平庸。”

这一章可能是本书中最”脏活累活”的一章,但也是最能拉开差距的一章。因为绝大多数团队在这一步犯的错,比他们意识到的要多得多。


3.1 从 HTML 到纯文本:第一步就充满陷阱

为什么文本提取是第一步也是最容易出错的一步

当你从 Common Crawl 获取到一批网页数据时,你拿到的是原始的 HTML。你需要做的第一件事是把 HTML 转换成干净的纯文本——去掉 HTML 标签、CSS、JavaScript、导航栏、侧边栏、广告、页脚、Cookie 弹窗。

这听起来像是一个简单的工程任务。事实上,它是整个数据管线中信息损失最大的一步——你在这一步做的决策,直接决定了后续所有步骤能看到的内容。

2026 年 2 月的一篇论文《Beyond a Single Extractor》(arXiv: 2602.19548)揭示了一个令人吃惊的发现:文本提取是 LLM 预训练数据策展中一个被严重低估的环节。 尽管互联网内容的多样性极高,现有的标准做法是对所有 Common Crawl 网页使用同一个提取器。但论文发现,不同提取器在不同类型的网页上表现差异巨大——没有任何单一提取器在所有类型的网页上都是最优的。

主流提取工具对比

目前常用的 HTML 文本提取工具有以下几个:

Trafilatura

  • 最流行的选择,也是 FineWeb 使用的提取器
  • 基于启发式规则 + 机器学习的混合方法
  • 优点:对结构良好的文章页面(新闻、博客、维基百科)效果很好
  • 缺点:对非标准页面(论坛、电商、社交媒体)容易遗漏内容或引入噪声
  • 处理速度:中等

Resiliparse

  • 由 Chatnoir/Web Archive 团队开发
  • 基于 C++ 实现,速度极快
  • 优点:处理速度是 trafilatura 的 5-10 倍,适合大规模处理
  • 缺点:提取质量略低于 trafilatura,对复杂布局的适应性差一些
  • FineWeb 在早期测试中发现,用 resiliparse 替换 trafilatura 会导致下游模型在某些 benchmark 上下降 2-3 个点

jusText

  • 较老的工具,基于段落级别的分类
  • 优点:简单可靠,对”正文 vs 噪声”的区分比较清晰
  • 缺点:对复杂布局支持差,不能处理嵌套结构
  • 适合作为辅助验证工具

readability(Mozilla)

  • 从 Firefox 阅读模式衍生
  • 专门设计用于提取”文章主体”
  • 优点:对新闻和博客文章效果极佳
  • 缺点:对非文章类页面(如论坛帖子列表、产品页面)可能完全提取不到内容

一个案例:同一个网页的不同输出

让我用一个实际例子来说明差异。考虑一个典型的技术博客页面,包含:

  • 导航栏(首页/关于/联系)
  • 文章标题和正文
  • 代码块(用 <pre><code> 标记)
  • 侧边栏(推荐阅读、分类标签)
  • 评论区(20 条评论)
  • 页脚(版权信息)

不同提取器的输出差异:

内容区域TrafilaturaResiliparsejusTextreadability
文章正文✅ 完整✅ 完整✅ 完整✅ 完整
代码块⚠️ 大部分保留⚠️ 可能丢失格式❌ 常被过滤✅ 保留
导航栏❌ 过滤⚠️ 部分残留❌ 过滤❌ 过滤
侧边栏❌ 过滤⚠️ 部分残留❌ 过滤❌ 过滤
评论区⚠️ 取决于设置✅ 保留⚠️ 部分保留❌ 过滤
页脚❌ 过滤⚠️ 部分残留❌ 过滤❌ 过滤

量化评测:WebMainBench 基准

上述对比是定性的。2025 年,OpenDataLab 发布了 WebMainBench——一个包含 7,887 个人工标注网页、覆盖 5,434 个独特域名和 46 种语言的标准化评测基准。它在 HTML 标签级别进行标注,为工具对比提供了量化依据:

工具WebMainBench ROUGE-N F1技术路线
Dripper (0.6B SLM)0.878小语言模型序列标记,需 GPU
MinerU-HTML0.840SLM-powered,需 GPU
magic-html0.714启发式规则
trafilatura0.640启发式 + ML,CPU 友好

两个值得注意的发现:

  1. SLM-powered 工具(Dripper、MinerU-HTML)大幅领先传统工具——Dripper 的 F1 比 trafilatura 高出 37%,这不是微小的差距,而是范式级的提升。
  2. 多语言性能下降:SIGIR 2025 的一项复现研究发现,现有提取工具在非英语语言上的表现显著下降。如果你的目标语言不是英语,需要额外的验证和适配。
  3. 模型驱动提取器对结构化内容的保留远超启发式方法 :AICC(Ma et al., 2025)的对照实验表明,仅改变提取器、其他管线步骤不变,模型驱动提取器(Dripper/Pulpie)对比启发式方法(trafilatura)在关键结构化内容的保留上有数量级差异:
内容类型Trafilatura(启发式)模型驱动(Dripper/Pulpie)
代码块保留相似度0.130.91(+600%)
公式保留相似度0.610.94(+54%)

实验下游验证:模型在 13 个 benchmark 上平均 +1.08 个百分点。这直接量化了前面提到的”代码块过滤是灾难性的”定性警告——代码块保真度从 0.13 提升到 0.91,意味着模型驱动提取器能让模型看到几乎完整的技术内容,而启发式提取器几乎丢光了所有代码。对于技术文档和代码类网页尤其关键。

注意几个关键差异:

  1. 代码块的处理:对于技术内容,代码块是核心信息。jusText 基于”短行密集”的判断,经常把代码块当作”非正文”过滤掉。这对于训练代码能力是灾难性的。

  2. 评论区的处理:评论区是否保留是一个两难选择。保留评论区可以增加多样性(对话风格的内容),但也引入了大量低质量噪声。readability 直接过滤评论区,trafilatura 可以通过配置选择。

  3. 导航和侧边栏的残留:resiliparse 比较激进地保留内容,可能导致”首页 / 关于 / 联系”这样的导航文本污染到训练数据中。虽然单条影响微小,但在万亿 token 规模下,这些噪声的累积效应不可忽视。

选择建议

对于大规模预训练数据处理:

  • 传统方案首选 trafilatura——纯 CPU 环境下质量和速度的最佳平衡
  • 新兴高性能替代:Dripper——如果有 GPU 资源,Dripper 在质量上大幅领先(WebMainBench F1: 0.878 vs 0.640),单 A100 吞吐量 3.08 页/秒,且成本仅为 LLM 直接提取的 4.42%
  • 如果处理速度是瓶颈,可以考虑 resiliparse 作为初筛,trafilatura 或 Dripper 作为精筛
  • 对于技术类网页,注意检查代码块的保留情况

对于特殊类型的网页:

  • 论坛/社区:标准提取器效果都不好,可能需要定制解析器
  • 电商页面:产品描述通常嵌在复杂的 DOM 结构中,建议跳过或用专门的爬虫
  • SPA(单页应用):静态 HTML 中可能几乎没有内容,需要 JavaScript 渲染

前沿方向:

方向 1:多提取器智能调度。 《Beyond a Single Extractor》(2026)提出了一个新思路:根据网页类型选择不同的提取器。先用一个轻量分类器判断网页类型(文章、论坛、产品页等),然后调度最适合该类型的提取器。这种方法在下游模型评估中取得了显著提升。

方向 2:SLM-powered 提取——范式转变。 2026 年,上海 AI 实验室等团队提出 Dripper,开辟了一个全新范式:用小语言模型(SLM)将 HTML 文本提取重新定义为受约束的序列标记任务。Dripper 使用 Qwen3-0.6B(仅 6 亿参数)通过”简化 HTML + 映射 HTML”双分支策略,将输入 token 减少 84.4%。效果上,Dripper 在 WebMainBench 上 F1 达到 0.8779,远超 trafilatura (0.6402),接近 GPT-5 (0.9024),而推理成本仅为 LLM 直接提取的 4.42%。更关键的是,用 Dripper 清洗的 630 亿 token 语料训练 1B 模型,在 13 个 benchmark 上平均得分 50.82%,优于 trafilatura (49.74%)、FineWeb (49.61%) 和 RefinedWeb (49.13%)。这意味着文本提取工具的选择,可以在不改变任何其他管线步骤的情况下,直接提升下游模型约 1 个百分点。

方向 3:多提取器 Union 组合——多样性胜过单点最优。 同一研究(EACL 2026 Findings)更激进的策略是:对多个提取器结果取并集。在 26.7% 的域名中,60%+ 的存活页面仅来自一个提取器——不同提取器捕获的内容大量不重叠。实验表明,trafilatura + jusText + resiliparse 三提取器 Union 后,DCLM-Baseline 的 token 增量高达 71%,同时维持基准性能。关键设计原则:更严格的阈值 + 多提取器 Union > 更宽松的阈值 + 单提取器——Union 不是简单引入重复,Union 后仍需严格过滤。对结构化内容的影响尤其显著:WikiTQ 差 10pp、HumanEval 差 3pp。Dripper 作为高质量单提取器,可与规则提取器组合实现 Union 策略,兼顾质量与多样性。

方向 4:Pulpie——帕累托最优的轻量级替代方案。 2026 年 7 月,Feyn Inc 发布了 Pulpie 系列模型,采用了不同于 Dripper 的技术路线。Dripper 基于自回归解码器架构做序列标记,而 Pulpie 使用 encoder 架构——对 HTML 的每个 block 做单次 forward pass 判别 content/boilerplate,无需逐 token 生成:

维度Dripper 0.6BPulpie Orange Small 210M
WebMainBench ROUGE-5 F10.8640.862
参数量600M210M(1/3)
吞吐量(NVIDIA L4)0.68 页/秒13.7 页/秒(~20×)
清洗 10 亿页成本~$159,000~$7,900(~1/20)

Dripper 与 Pulpie 代表了 SLM 提取的两条技术路线:自回归序列标注(精度略微占优)vs encoder 逐块分类(成本/速度大幅占优)。对于预算有限的中小团队,Pulpie 是极具吸引力的替代方案——pip install pulpie 即可使用,模型已在 HuggingFace 开源(feyninc/pulpie-orange-small-v1)。

踩坑记录

坑 1:JavaScript 渲染页面

越来越多的网站使用 JavaScript 框架(React、Vue、Angular),页面内容通过 JavaScript 动态加载。Common Crawl 的 WARC 文件存储的是原始 HTTP 响应,不包含 JavaScript 渲染后的内容。对于这些页面,所有提取工具拿到的都是空壳。

影响范围:这在 Common Crawl 2020 年之后的快照中越来越严重,估计有 15-25% 的现代网页存在这个问题。

应对策略:

  • 对于 Common Crawl,目前无好的解决方案——这些页面基本只能放弃
  • 如果自建爬虫,可以使用 Playwright 或 Puppeteer 进行 JavaScript 渲染
  • 识别这些空壳页面并过滤掉,避免它们作为”正常”短文本混入训练数据

坑 2:编码问题

网页的字符编码声明不总是正确的。一个 HTML 页面可能声明 charset=utf-8,但实际内容是 GBK 编码。这会导致中文内容变成乱码。

应对策略:

  • 不要盲目信任 HTTP header 和 HTML meta 中的编码声明
  • 使用 chardet 或 cchardet 做编码检测
  • 对于中文数据,建立一个简单的中文字符比例检查:如果声称是中文但中文字符比例 < 50%,很可能是编码问题

坑 3:无限滚动和分页

很多内容站使用”无限滚动”或深度分页来展示内容。Common Crawl 通常只爬取了第一页,导致长列表内容被截断。

影响:对于论坛类网站,可能只拿到了前几条帖子,丢失了大量高质量讨论。


3.2 语言识别与过滤

为什么语言识别是必要的

预训练数据通常需要按语言组织。即使是多语言模型,也需要知道每段数据是什么语言,以便控制语言配比。而 Common Crawl 中不同语言的数据混杂在一起,网页的 HTML lang 属性又不可靠(大量网页的 lang 属性缺失或错误)。

主流语言识别工具

fastText lid(lid.176.bin / lid.176.ftz)

  • Meta 发布的语言识别模型,支持 176 种语言
  • 目前的事实标准(de facto standard)
  • 优点:速度极快(可以在 CPU 上每秒处理数万条文本)、准确率高(对主流语言 >95%)
  • 缺点:
    • 对短文本(< 20 个词)准确率显著下降
    • 对混合语言文本只返回一个语言标签
    • 某些相似语言(如印尼语和马来语、塞尔维亚语和克罗地亚语)容易混淆
    • 模型是在”理想”的单语文本上训练的,对真实世界的嘈杂文本泛化不够

GlotLID

  • 支持更多语言(2000+),特别是低资源语言
  • FineWeb-2 使用它来做多语言数据的语言识别
  • 比 fastText lid 在低资源语言上更准确

CLD3(Compact Language Detector 3, Google)

  • 基于神经网络的语言识别
  • 优点:对短文本的准确率比 fastText 好
  • 缺点:速度较慢,支持的语言数量有限

langdetect / lingua

  • 其他可选方案,各有优缺点
  • lingua 在短文本上表现最好,但速度最慢

阈值选择:一个被低估的决策

语言识别工具通常返回一个置信度分数。你需要设定一个阈值来决定:置信度高于多少才认定文本属于该语言?

这个看似微小的决策,影响可能出乎意料地大。

以中文数据为例:

置信度阈值保留的数据量数据质量典型问题
0.5100%(基准)中混入较多中日、中英混合的低质量内容
0.65~92%中-高大部分明显的混合内容被过滤
0.7~88%高少量中英混合的技术文章被误过滤
0.8~78%很高较多含英文术语的技术内容被误过滤
0.9~60%极高但偏窄大量正常内容被过滤,多样性严重下降

关键发现:

  • 阈值从 0.5 提高到 0.65,数据量只减少 8%,但质量提升显著——这是高性价比的过滤
  • 阈值从 0.7 提高到 0.8,数据量减少 10%,但开始误杀有价值的中英混合技术文章
  • 阈值超过 0.8 后,损失远大于收益

推荐做法:

  • 对于中英文数据,置信度阈值 0.65-0.70 是一个好的起点
  • 对于日文、韩文等与中文字符重叠的语言,需要额外的判断逻辑(不能只依赖置信度分数)
  • 建议在设定阈值后,人工审查阈值边界附近(±0.05)的样本,感受一下什么被保留了、什么被过滤了

混合语言文本的处理

真实世界中大量文本是多语言混合的:

  • 学术论文中的术语:中文论文中频繁出现英文专有名词(“Transformer”、“attention mechanism”、“fine-tuning”)
  • 代码注释:中文开发者写的英文变量名 + 中文注释
  • 社交媒体:中英混合的口语表达(“这个 feature 很 nice”)
  • 翻译内容:机器翻译网站的中日/中韩混合内容

处理策略:

  1. 对于学术/技术领域的中英混合:这些通常是高质量内容,应该保留。可以通过以下方式避免误过滤:

    • 检查英文部分是否主要是专业术语
    • 如果中文比例 > 60% 且英文部分主要是名词和术语,归类为中文
  2. 对于代码注释的混合:

    • 如果是代码文件,用代码处理管线而非自然语言管线
    • 如果是包含代码块的技术博客,尊重文档整体的语言标记
  3. 对于劣质机器翻译的混合:这些通常是低质量内容,应该过滤掉。特征:

    • 中文和另一种语言交替出现,且不是自然的代码切换
    • 语法不自然,类似直译
    • HTML 的 lang 属性与实际内容不匹配

3.3 启发式过滤规则

语言识别之后,需要用启发式规则进一步过滤低质量内容。这是数据清洗的”主战场”,也是细节最密集的地方。

行级过滤

行级过滤检查文档中的每一行,移除明显的噪声行。

常见规则:

def should_remove_line(line: str) -> bool:
    """判断一行文本是否应该被移除"""
    
    # 1. 空行或极短行(< 5 个字符)
    if len(line.strip()) < 5:
        return True
    
    # 2. 导航文本模式
    nav_patterns = [
        r'^(首页|关于|联系我们|登录|注册|返回顶部)',
        r'^(Home|About|Contact|Login|Sign up)',
        r'^\s*[\|/]\s*',  # 分隔符开头
    ]
    for pattern in nav_patterns:
        if re.match(pattern, line.strip()):
            return True
    
    # 3. 高比例特殊字符
    special_char_ratio = len(re.findall(r'[^\w\s]', line)) / max(len(line), 1)
    if special_char_ratio > 0.5:
        return True
    
    # 4. Cookie/隐私提示
    cookie_patterns = ['cookie', '隐私政策', 'privacy policy', 'consent']
    if any(p in line.lower() for p in cookie_patterns):
        return True
    
    return False

注意:行级过滤要保守。宁可留一些噪声,也不要误删有价值的内容。特别是:

  • 代码行可能看起来像”高特殊字符比例”——不要过滤
  • 数学公式可能包含大量特殊符号——不要过滤
  • 短行可能是诗歌或列表项——阈值不要设太高

文档级过滤

文档级过滤检查整篇文档的统计特征。

核心指标和推荐阈值:

指标过低阈值推荐范围过高阈值说明
文档长度(字符)< 100200-50000> 200000太短通常是碎片,太长可能是数据库 dump
平均行长度< 1020-200> 500过短可能是列表,过长可能是无换行的脏数据
字母/数字占比< 0.50.6-0.95> 0.99过低可能是乱码,过高可能是纯表格数据
重复行比例-< 0.3> 0.5高重复通常意味着模板化内容
句号/句尾标点比例< 0.010.02-0.15-过低可能不是连贯文本

中文特有的过滤规则:

def chinese_document_filter(text: str) -> bool:
    """中文文档的额外过滤规则,返回 True 表示保留"""
    
    # 1. 中文字符比例检查
    chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
    total_chars = len(text)
    chinese_ratio = chinese_chars / max(total_chars, 1)
    
    if chinese_ratio < 0.3:  # 中文字符太少,可能不是真正的中文内容
        return False
    
    # 2. 常见垃圾模式
    spam_patterns = [
        r'点击这里下载',
        r'免费领取',
        r'加微信',
        r'扫码关注',
        r'阅读全文请',
    ]
    spam_count = sum(1 for p in spam_patterns if re.search(p, text))
    if spam_count >= 3:  # 命中多个垃圾模式
        return False
    
    # 3. 段落结构检查
    paragraphs = [p for p in text.split('\n') if len(p.strip()) > 20]
    if len(paragraphs) < 2:  # 至少要有 2 个有实质内容的段落
        return False
    
    return True

C4 的过滤规则:哪些有用,哪些有害

C4(Colossal Clean Crawled Corpus)是 T5 模型的训练数据,其过滤规则曾被广泛参考。但 FineWeb 团队在 2024 年的研究中发现,C4 的一些过滤规则实际上是有害的。

C4 的过滤规则回顾:

  1. 只保留以句号、问号、感叹号或引号结尾的行
  2. 移除包含”坏词”(bad words)列表中词语的任何页面
  3. 移除少于 3 个句子的页面
  4. 移除任何包含 { 的页面(本意是过滤代码,但也过滤了数学内容)
  5. 使用 langdetect 只保留英文内容
  6. 去重

问题分析:

规则 1(句尾标点)的问题:

  • 列表项(如”• 第一步:安装依赖”)不以句号结尾,但可能是有价值的内容
  • 标题行不以句号结尾
  • 代码注释不以句号结尾
  • 影响:导致大量结构化内容(教程、文档、FAQ)被误过滤

规则 2(坏词过滤)的问题:

  • C4 的坏词列表非常激进——包含了一些在医学、法律、性教育等正当领域中正常使用的词汇
  • 整个页面因为包含一个”坏词”就被删除——即使这个词出现在完全正常的上下文中(如医学文献讨论某种疾病)
  • FineWeb 估计这个规则误杀了约 10-15% 的优质内容

规则 4(过滤 {)的问题:

  • 这个规则的本意是过滤代码,但同时过滤了:
    • 包含 JSON 示例的技术文章
    • 包含数学公式的页面(LaTeX 使用 {})
    • 包含模板语法的技术文档

FineWeb 的改进:

  • 不再使用 C4 的坏词列表做页面级过滤,改为更精细的行级/段落级处理
  • 不再要求行必须以标点结尾
  • 不再过滤包含 { 的页面
  • 用质量分类器替代简单的规则过滤

认知变化:从”严格过滤”到”适度过滤”

认知演变时间线:
2019: "过滤越严格越好" (C4 的设计理念)
2020: "严格过滤确实有效" (GPT-3 采用类似策略)
2022: "等等,过滤太严可能有问题" (开始有人注意到误杀)
2024: "过度过滤确实有害" (FineWeb 的系统性证据)
      → C4 的一些规则导致约 10-15% 的优质内容被误过滤
      → 过滤后数据集的多样性明显下降
2025: "适度过滤 + 质量分类器" (第二代方法论成熟)
      → 用宽松的启发式规则做初筛
      → 用模型(质量分类器)做精筛
      → 对被过滤掉的数据做抽样检查,评估误杀率
2026: "质量分类器的'质量幻觉'" (第三代方法论出现)
      → Apple 研究发现分类器过滤的"质量幻觉":过滤后模型在
        下游任务上表现更好,但在高质量数据本身的语言建模能力
        并未提升——分类器可能捕捉的是分布差异而非真正的语言质量
      → 第三代方案:"分类器集成 + 智能绕过 + LLM 改写"
      → 将数据保留率从 10% 恢复到 40%,同时维持质量(MMLU +14%)

这条时间线揭示了一个深层趋势:过滤策略正在从”二分法”(保留或丢弃)演变为”恢复策略”(尝试拯救被错误丢弃的数据)。

三代数据清洗方法论

上面的时间线其实对应了三代清洗方法论的演进:

代际代表核心方法数据保留率MMLU 提升
Gen1FineWeb / C4启发式规则过滤~35%基线
Gen2DCLMfastText 质量分类器~10%+9%
Gen3Nemotron-CC分类器集成 + 智能绕过 + LLM 改写~40%+14%

Gen2 的问题:虽然 MMLU 提升了 9%,但代价是丢弃了 90% 的数据。Apple 的”数据质量幻觉”研究(arXiv: 2510.00866)解释了为什么——分类器并不是真正在度量”语言质量”,而是在捕捉与参考数据集的分布差异。

Gen3 的核心创新在于三个环节:

  1. 分类器集成:用多个分类器投票,而非依赖单一模型
  2. 智能绕过(smart bypass):对被过滤的内容给予”第二次机会”——如果内容在其他维度上有价值(如包含稀缺领域知识),可以绕过质量过滤
  3. LLM 改写:对中等质量的内容,用大模型进行改写提升,而不是简单丢弃

NVIDIA 的 Nemotron-CC(2024)是 Gen3 的代表性工作,开源社区也有了可复现的实现(如 fineweb-pipeline)。不过需要注意,Gen3 的 LLM 改写环节在大规模应用时成本较高,需要根据实际预算权衡。


3.4 PII(个人身份信息)处理

为什么 PII 处理不可跳过

训练数据中不可避免地包含个人身份信息(Personally Identifiable Information, PII):

  • 电子邮件地址:网页上大量公开的联系邮箱
  • 电话号码:商业网站、分类广告
  • 物理地址:商户信息、个人页面
  • 身份证号:中国特有的问题——某些政府公示、法律文书中出现
  • 银行卡号:泄露数据、钓鱼页面
  • 姓名+关联信息:简历、个人博客

如果不处理 PII,模型可能:

  1. 记忆并泄露真实个人信息——用户问”张三的电话号码是多少”,模型可能真的回答出来
  2. 产生法律风险——GDPR、中国个人信息保护法等法规对 PII 的处理有明确要求
  3. 生成看似真实的 PII——模型可能在生成文本时输出训练数据中记忆的真实电话号码或邮箱

PII 检测方法

基于正则表达式的检测:

import re

PII_PATTERNS = {
    # 邮箱
    'email': re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'),
    
    # 中国手机号
    'phone_cn': re.compile(r'(?<!\d)1[3-9]\d{9}(?!\d)'),
    
    # 中国身份证号(18位)
    'id_card_cn': re.compile(
        r'(?<!\d)[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])'
        r'(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)'
    ),
    
    # 国际电话号码(简化版)
    'phone_intl': re.compile(r'\+\d{1,3}[-.\s]?\d{4,14}'),
    
    # 银行卡号(16-19位数字)
    'bank_card': re.compile(r'(?<!\d)\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4,7}(?!\d)'),
    
    # IP 地址
    'ip_address': re.compile(
        r'(?<!\d)(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}'
        r'(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?!\d)'
    ),
}

def detect_pii(text: str) -> dict:
    """检测文本中的 PII,返回各类型的匹配列表"""
    results = {}
    for pii_type, pattern in PII_PATTERNS.items():
        matches = pattern.findall(text)
        if matches:
            results[pii_type] = matches
    return results

基于 NER 的检测:

  • 使用命名实体识别模型检测人名、组织名、地址等
  • 优点:能捕获正则表达式无法匹配的非结构化 PII(如”住在北京海淀区中关村南大街5号”)
  • 缺点:速度较慢,需要 GPU,且会有较多假阳性

混合方案(推荐):

经典的做法是两步走:正则表达式做第一遍快速扫描(高速、高召回),NER 模型做第二遍精确确认(低速、高精度)。NVIDIA NeMo-Curator 提供了一个开箱即用的 PII 检测管线。

但实践中,两步方案面临一个顽固问题:歧义实体。一个 11 位数字是手机号还是订单号?一个地名是 PII 中的住址还是普通的地理描述?RECAP 框架(Centific, 2025)提出了更精细的三阶段管道:

阶段 1:基础混合检测(正则 + LLM/NER)
  → 广覆盖,但可能有多标签和误报
  → 例如:检测到 "13812345678" 同时匹配手机号和订单号模式

阶段 2:上下文消歧(LLM 分析语境选择最合适标签)
  → 解决"11 位数字到底是手机号还是订单号"的问题
  → 根据前后文语境("请拨打"vs"订单编号")选择正确标签

阶段 3:歧义解决与误报过滤(规则合并 + 上下文验证)
  → 消除短数字的误报,合并重叠的检测结果
  → 最终输出高置信度的 PII 标注

RECAP 支持 13 种语言、300+ PII 实体类型,在中文(zh_SG)上 F1 达到 0.76,相比零样本 LLM 提升 17%,相比微调 NER 提升 82%。

一个更深层的隐忧:隐式记忆泄露。 即使你做了完善的 PII 检测和替换,模型仍然可能通过隐式记忆泄露个人信息。ACL 2025 Findings 上的 R.R.(Recollect and Rank)攻击 展示了一种两步隐私窃取方法——攻击者可以从 LLM 中重建训练数据中的 PII 实体,即使这些实体在原始数据中已被处理过。这强化了一个结论:PII 处理是必要的但不充分的,后训练阶段的隐私对齐同样不可或缺。

处理策略:替换 vs 删除 vs 掩码

检测到 PII 后,有三种处理方式:

替换(Replacement)——推荐

原文:请联系张先生,手机 13812345678,邮箱 zhang@example.com
处理后:请联系[人名],手机 [电话号码],邮箱 [邮箱地址]
  • 优点:保留了文本的语义结构,模型仍然能学到”联系方式的语境”
  • 缺点:需要为每种 PII 设计合理的替换标记

删除(Deletion)

原文:请联系张先生,手机 13812345678,邮箱 zhang@example.com
处理后:请联系,手机 ,邮箱
  • 优点:最简单
  • 缺点:可能破坏句子结构,产生不自然的文本

掩码(Masking)

原文:请联系张先生,手机 13812345678,邮箱 zhang@example.com
处理后:请联系张先生,手机 138****5678,邮箱 z***@example.com
  • 优点:保留了部分信息(类型和格式),比完全删除更自然
  • 缺点:实现较复杂,且部分掩码可能仍然有隐私风险

对模型能力的影响:

研究表明,PII 替换对模型的通用能力几乎没有影响(< 0.5% benchmark 差异)。但如果过于激进地删除所有看似 PII 的内容,可能会:

  • 损失模型对地址、电话格式的理解(影响信息提取任务)
  • 损失模型对邮件写作格式的理解

建议:使用替换策略,保留 PII 的类型信息但移除具体值。

中文 PII 的特殊挑战

中国手机号的特殊性:

  • 11 位纯数字,以 1 开头,第二位是 3-9
  • 但 11 位数字在其他场景也很常见(如订单号、编号)
  • 需要上下文判断:如果前面有”电话”、“手机”、“联系”等关键词,更可能是手机号

身份证号的验证:

  • 18 位,最后一位是校验码
  • 可以用校验码验证来减少误报
  • 但注意:验证通过的身份证号也可能是虚构的(如小说中编造的)
def validate_cn_id_card(id_number: str) -> bool:
    """验证中国身份证号的校验码"""
    if len(id_number) != 18:
        return False
    
    weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
    check_codes = '10X98765432'
    
    try:
        total = sum(int(id_number[i]) * weights[i] for i in range(17))
        expected = check_codes[total % 11]
        return id_number[-1].upper() == expected
    except (ValueError, IndexError):
        return False

3.5 敏感内容过滤

问题定义

预训练数据中不可避免地包含”有害”内容:色情、暴力、仇恨言论、歧视性内容、虚假信息等。完全不处理这些内容,模型可能学到并生成类似的有害输出;但过度过滤又会导致数据多样性下降,甚至影响模型在合法领域的理解能力。

分类器方案

fastText 分类器:

  • 训练一个 fastText 分类器来区分安全/不安全内容
  • 优点:速度极快,可以处理万亿 token 级别的数据
  • 缺点:精度有限,对上下文理解不够

专用 NSFW 检测器:

  • 使用在大量标注数据上训练的专门模型
  • 如 Jigsaw Toxicity Classifier、OpenAI Moderation API
  • 精度更高,但速度较慢

LLM-based 检测:

  • 用大模型(如 Llama Guard)来评估内容安全性
  • 最高精度,但成本最高
  • 通常只用于抽样检查或最终验证,不用于全量数据过滤

阈值困境

敏感内容过滤的核心困难是阈值的设定。设得太松,有害内容混入训练数据;设得太严,正常内容被误杀。

一些反直觉的误杀案例:

  • 医学内容:描述人体解剖、性传播疾病、药物副作用的专业内容,经常被 NSFW 分类器标记为”不安全”
  • 法律内容:描述犯罪事实、暴力行为的法律文书和新闻报道
  • 文学作品:包含暴力或性描写的经典文学作品(如莎士比亚的部分作品)
  • 安全教育:讨论网络安全、毒品危害、性教育的正当内容
  • 历史记录:描述战争、种族歧视历史的教育材料

推荐策略:多级过滤

第一级(粗筛):fastText 分类器
- 高置信度(>0.95)直接过滤——明确的色情/暴力/极端内容
- 中等置信度(0.7-0.95)标记为"需复审"
- 低置信度(<0.7)通过

第二级(精筛):对"需复审"的内容
- 使用更精准的模型(如 Llama Guard)二次判断
- 或用规则补充判断(如检查是否来自医学/法律/教育类网站)

第三级(抽检):
- 定期从"通过"的数据中抽样人工检查
- 定期从"被过滤"的数据中抽样检查误杀率
- 根据抽检结果调整阈值

一个重要的设计哲学

宁可在后训练阶段用 RLHF/DPO 教模型”不输出有害内容”,也不要在预训练阶段把模型对”有害内容”的理解能力也一并删掉。

预训练阶段适度包含一些”边缘”内容(如医学文献中的敏感描述),有助于模型理解这些领域。模型的”安全性”更适合在后训练阶段通过对齐技术来实现,而非在数据层面”一刀切”。


3.6 清洗管线的组装与评估

管线的标准流程

将上述各步骤串联起来,一个完整的文本清洗管线通常是:

原始 HTML (WARC)
    │
    ▼
[1] 文本提取 (trafilatura / resiliparse)
    │
    ▼
[2] 编码修复与标准化 (UTF-8 统一)
    │
    ▼
[3] 语言识别 (fastText lid, 阈值 0.65-0.70)
    │
    ▼
[4] 行级过滤 (导航文本、广告、Cookie 提示)
    │
    ▼
[5] 文档级过滤 (长度、字符比例、重复行)
    │
    ▼
[6] PII 处理 (检测 + 替换)
    │
    ▼
[7] 敏感内容过滤 (多级过滤策略)
    │
    ▼
[8] 标准化 (统一换行符、去除多余空白、Unicode 规范化)
    │
    ▼
干净的纯文本

各步骤的数据损耗

一个典型的 Common Crawl 快照处理后的数据保留率:

步骤输入输出保留率累计保留率
原始 CC 快照100%--100%
文本提取100%85%85%85%
语言识别(中文)85%8%~9%~8%
行级过滤8%7.2%90%~7.2%
文档级过滤7.2%5.5%76%~5.5%
PII 处理5.5%5.4%98%~5.4%
敏感内容过滤5.4%5.0%93%~5.0%

也就是说,从一个 Common Crawl 快照的原始数据到最终可用的中文训练数据,保留率大约是 5%。对于英文来说,由于英文内容占比更大,保留率会更高(约 15-20%)。

质量评估

如何知道你的清洗管线是好是坏?

方法 1:人工抽样评估

  • 从清洗后的数据中随机抽取 500-1000 条,人工逐条评估
  • 评估维度:语言正确性、信息完整性、噪声残留、格式规范性
  • 目标:好样本比例 > 90%

方法 2:与已知高质量数据对比

  • 计算清洗后数据与维基百科等高质量数据在统计特征上的相似度
  • 特征包括:词频分布、句子长度分布、语言困惑度分布

方法 3:下游训练评估(最终标准)

  • 用清洗后的数据训练一个小模型(如 400M 参数,100B token)
  • 在标准 benchmark 上评估
  • 与其他数据集训练的同等模型对比

方法 4:消融测试

  • 逐步移除清洗管线中的各个步骤,观察最终模型表现的变化
  • 确认每个步骤都在贡献正面价值
  • 如果某个步骤移除后模型反而更好,说明该步骤可能过度过滤了

方法 5:种子数据优化验证

  • 传统质量分类器的一个根本问题是:种子数据的选择依赖人工经验,具有主观性。不同的人对”高质量”的定义不同,选出的正负样本也不同,训练出的分类器自然也不同
  • Ultra-FineWeb(清华大学刘知远团队, 2025)提出了一种高效验证策略:以最小计算成本快速评估不同种子数据选择对 LLM 训练效果的影响,从而优化种子选择本身
  • 核心思路:不再”凭经验选种子 → 训分类器 → 祈祷效果好”,而是”系统搜索种子空间 → 快速验证 → 选最优种子”
  • 实验证明在英文 1 万亿 token + 中文 1200 亿 token 数据集上均有显著效果提升

⚠️ 质量分类器的”质量幻觉”警示

在使用上述方法时,请注意 Apple 研究团队的一个重要发现(arXiv: 2510.00866):基于分类器的质量过滤(CQF)存在”质量幻觉”——虽然过滤后数据训练的模型在下游任务上表现更好,但模型在高质量数据集本身的语言建模性能并未提升。这意味着分类器可能捕捉的是分布差异而非真正的语言质量。不要盲目信赖分类器的分数,要结合多种评估方法交叉验证。


3.7 显微镜案例:一条脏数据是怎么逃过所有过滤的

让我用一个详细的案例来展示数据清洗的复杂性。

这条数据:一个看似正常的中文技术文章页面,约 2000 字,讨论的是数据库索引优化。语言流畅,技术准确,各项统计指标都在正常范围内。

但实际上:这个页面是一个 SEO 垃圾站点,它做了以下操作:

  1. 从一篇高质量的技术博客上抄袭了主要内容
  2. 在文章末尾隐藏了大量关键词堆砌(用白色文字在白色背景上)
  3. 文章中间插入了一个伪装成技术链接的推广链接
  4. 页面的 meta description 和实际内容不匹配(SEO 手段)

为什么它逃过了各步过滤:

过滤步骤结果原因
文本提取✅ 通过trafilatura 成功提取了正文,但也保留了隐藏的关键词堆砌(这些词在 HTML 中存在,只是用 CSS 隐藏了)
语言识别✅ 通过主体内容是正常中文,置信度 0.92
行级过滤✅ 通过没有明显的导航文本或广告模式
文档级过滤✅ 通过长度、字符比例、重复率都在正常范围
敏感内容过滤✅ 通过内容是技术主题,没有敏感词

如何才能抓住它:

  1. CSS 感知的文本提取:在提取文本时考虑 CSS 样式,过滤掉 display:none、visibility:hidden、color:white; background:white 等隐藏内容。trafilatura 的最新版本对此有一定支持。
  2. 重复检测:如果在去重步骤中与原始的高质量文章进行比对,可以发现这是一个近似副本。
  3. 质量分类器:一个好的质量分类器可能会给关键词堆砌部分打低分(如果它能看到完整的提取文本的话)。

启示:没有完美的过滤管线。每一条规则都有死角。关键是多层防御——通过不同机制的叠加来最大化覆盖率,并通过定期的人工抽样来发现盲区。


动手环节:实现一个迷你文本清洗管线

目标:动手搭建一个简化版的文本清洗管线,体验从原始文本到干净语料的全流程。

练习 1:行级和文档级过滤器

"""
迷你文本清洗管线
实现本章介绍的行级过滤、文档级过滤和 PII 检测
"""

import re
from dataclasses import dataclass, field
from typing import List

@dataclass
class CleaningResult:
    original_text: str
    cleaned_text: str
    removed_lines: List[str] = field(default_factory=list)
    pii_found: List[dict] = field(default_factory=list)
    passed: bool = True
    reason: str = ""

def line_filter(line: str) -> bool:
    """行级过滤,返回 True 表示保留"""
    stripped = line.strip()

    # 空行保留(作为段落分隔)
    if not stripped:
        return True

    # 极短行
    if len(stripped) < 3:
        return False

    # 导航文本
    nav_patterns = [
        r'^(首页|关于我们|联系我们|登录|注册|返回顶部|下一页|上一页)',
        r'^(Home|About|Contact|Login|Sign up|Next|Previous)',
        r'^\s*[|/]\s*$',
    ]
    for pattern in nav_patterns:
        if re.match(pattern, stripped, re.IGNORECASE):
            return False

    # Cookie/隐私提示
    cookie_words = ['cookie', '隐私政策', 'privacy policy', '我们使用cookies']
    if any(w in stripped.lower() for w in cookie_words):
        return False

    # 高比例特殊字符(但排除代码行)
    if not stripped.startswith(('```', '    ', '\t')):
        special = len(re.findall(r'[^\w\s\u4e00-\u9fff]', stripped))
        if special / max(len(stripped), 1) > 0.6:
            return False

    return True

def document_filter(text: str) -> tuple:
    """文档级过滤,返回 (passed, reason)"""
    if len(text) < 100:
        return False, "文档过短 (<100 字符)"
    if len(text) > 200000:
        return False, "文档过长 (>200K 字符)"

    lines = text.split('\n')
    non_empty = [l for l in lines if l.strip()]

    # 平均行长
    if non_empty:
        avg_line_len = sum(len(l) for l in non_empty) / len(non_empty)
        if avg_line_len < 5:
            return False, f"平均行长过短 ({avg_line_len:.1f})"

    # 重复行比例
    if len(non_empty) > 3:
        unique_ratio = len(set(non_empty)) / len(non_empty)
        if unique_ratio < 0.5:
            return False, f"重复行比例过高 (unique={unique_ratio:.2f})"

    # 中文字符比例(如果目标是中文数据)
    chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
    if chinese_chars / max(len(text), 1) < 0.2:
        return False, f"中文字符比例过低 ({chinese_chars/max(len(text),1):.2f})"

    return True, "通过"

def detect_pii(text: str) -> list:
    """简易 PII 检测"""
    patterns = {
        "email": r'[\w.+-]+@[\w-]+\.[\w.]+',
        "cn_phone": r'(?<!\d)1[3-9]\d{9}(?!\d)',
        "cn_id": (r'(?<!\d)[1-9]\d{5}(?:19|20)\d{2}'
                  r'(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)'),
    }
    findings = []
    for pii_type, pattern in patterns.items():
        for match in re.finditer(pattern, text):
            findings.append({
                "type": pii_type,
                "value": match.group()[:4] + "****",  # 只展示前4字符
                "position": match.start(),
            })
    return findings

def clean_document(text: str) -> CleaningResult:
    """完整的清洗流程"""
    result = CleaningResult(original_text=text, cleaned_text="")

    # Step 1: 文档级过滤
    passed, reason = document_filter(text)
    if not passed:
        result.passed = False
        result.reason = reason
        return result

    # Step 2: 行级过滤
    lines = text.split('\n')
    cleaned_lines = []
    for line in lines:
        if line_filter(line):
            cleaned_lines.append(line)
        else:
            result.removed_lines.append(line.strip())

    result.cleaned_text = '\n'.join(cleaned_lines)

    # Step 3: PII 检测
    result.pii_found = detect_pii(result.cleaned_text)

    return result

# === 测试 ===
test_docs = [
    # 正常文档
    """人工智能的发展已经进入了大模型时代。
预训练技术让模型具备了通用的语言理解能力。
数据质量对模型性能有决定性影响。
从 Common Crawl 到精选语料,每一步都需要精心处理。
请联系 zhangsan@example.com 获取更多信息。
电话:13812345678""",

    # 含噪声的文档
    """首页 | 关于我们 | 联系我们
本站使用cookies来提升您的体验
深度学习是机器学习的一个重要分支。
它通过多层神经网络来学习数据的表示。
点击这里下载 >>> 点击这里下载 >>> 
@#$%^&*()_+
登录 | 注册""",

    # 太短的文档
    """你好世界""",
]

for i, doc in enumerate(test_docs):
    print(f"\n{'='*50}")
    print(f"文档 {i+1}:")
    result = clean_document(doc)
    print(f"  通过: {result.passed}")
    if not result.passed:
        print(f"  原因: {result.reason}")
    else:
        print(f"  移除行数: {len(result.removed_lines)}")
        if result.removed_lines:
            for line in result.removed_lines[:3]:
                print(f"    - '{line}'")
        if result.pii_found:
            print(f"  发现 PII: {len(result.pii_found)} 处")
            for pii in result.pii_found:
                print(f"    - {pii['type']}: {pii['value']}")

练习 2:PII 替换策略实现

扩展上面的 PII 检测,实现本章推荐的替换策略(而非删除):

def replace_pii(text: str) -> str:
    """将 PII 替换为类型占位符"""
    replacements = {
        "email": "[邮箱地址]",
        "cn_phone": "[手机号码]",
        "cn_id": "[身份证号]",
    }
    patterns = {
        "email": r'[\w.+-]+@[\w-]+\.[\w.]+',
        "cn_phone": r'(?<!\d)1[3-9]\d{9}(?!\d)',
        "cn_id": (r'(?<!\d)[1-9]\d{5}(?:19|20)\d{2}'
                  r'(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)'),
    }
    result = text
    for pii_type, pattern in patterns.items():
        result = re.sub(pattern, replacements[pii_type], result)
    return result

# 测试
test = "请联系张先生,手机 13812345678,邮箱 zhang@example.com"
print(f"原文: {test}")
print(f"替换: {replace_pii(test)}")
# 预期输出: 请联系张先生,手机 [手机号码],邮箱 [邮箱地址]

练习 3:清洗效果评估

对你的清洗管线做一个简单的评估:准备 20 条样本(10 条干净、10 条脏),检查管线的准确率和误杀率。

思考题:

  1. 你的行级过滤器有哪些可能的误杀场景?(提示:代码行、诗歌、数学公式)
  2. 如果要处理万亿 token 的数据,你的管线需要做哪些优化才能在合理时间内完成?

本章要点回顾

  1. 文本提取是被低估的关键步骤——不同提取工具在同一网页上的输出差异可达 2-3 个 benchmark 点。传统方案中 trafilatura 是最佳选择,但 SLM-powered 工具(Dripper)已在 WebMainBench 上大幅领先(F1: 0.878 vs 0.640),代表了文本提取的范式转变
  2. 语言识别的阈值选择需要精细调优——0.65-0.70 是中文的推荐范围;混合语言文本需要特殊处理,不能一刀切
  3. C4 的过滤规则教训——过度过滤会误杀 10-15% 的优质内容。当前最佳实践从”宽松启发式初筛 + 模型精筛”演进到第三代”分类器集成 + 智能绕过 + LLM 改写”
  4. 质量分类器存在”质量幻觉”——Apple 研究发现分类器可能捕捉的是分布差异而非真正的语言质量,不要盲目信赖分类器分数
  5. PII 处理用替换而非删除——三阶段管道(检测 → 消歧 → 验证)比传统两步方案更精确;同时注意隐式记忆泄露风险
  6. 敏感内容过滤用多级策略——粗筛 + 精筛 + 抽检,避免一刀切误杀医学/法律/教育类正当内容
  7. 从 Common Crawl 到可用的中文训练数据,保留率约 5%——大部分数据在语言过滤这一步就被排除了
  8. 没有完美的清洗管线,多层防御 + 定期抽样是关键