TB级文本去重实战:从单机 OOM 到 Spark / Ray 分布式架构的工程演进

TB级文本去重实战:从单机 OOM 到 Spark / Ray 分布式架构的工程演进

阅读决策摘要

适合谁 :需要构建离线训练语料去重、并对删除结果承担审计责任的数据平台团队。默认策略 :精确去重后,以字符 5-gram + MinHash LSH 找候选、真实 Jaccard 验证、连通分量选代表。不自动覆盖:热点桶、短文、超长文只保留并报告覆盖缺口。

实施顺序:先在固定标注小样本上量化候选召回与删除精度;再跑完整 Spark 基线;仅当签名阶段是实测瓶颈时,用 Ray 替换签名产物;最后才接入增量索引和发布事务。任何阶段超出预算,应中止或进入已声明覆盖边界的降级路径。

1. 真正拖垮任务的,往往不是 MinHash

设想一个每天接收网页正文、知识库文章和论坛帖子的训练语料平台。历史数据已经达到 2 TB,新增数据来自多个采集器,同一篇文章可能以不同 URL、不同页脚和不同时间戳反复出现。

最初的实现把全文读进 Python,为每篇文章构造字符切片集合,再把所有签名放进内存索引。单机 OOM 后换成 Spark,任务仍然卡住:一个模板桶包含数十万篇文档,collect_list 把它们聚到一个执行器,自连接又放大为数十亿候选对。

增加机器只能延缓问题。必须先回答三件事:什么叫重复,哪些候选值得比较,失败时能否重跑而不改变删除结果。

实际平台还会遇到这些约束:

场景 容易犯的错误 可执行的约束
中文网页、英文文章混合 对所有语言直接 lower、删除标点 按数据域固定规范化版本,保留原文
大量导航页和模板页 认为桶越大代表重复越多 统计桶大小,超限进入隔离队列
同文不同授权来源 随意保留最小 ID 先按授权和质量选代表,再用 ID 打破平局
训练集与测试集混合 去重后才随机划分 固定评估集,检测训练对评估的污染
持续增量采集 只比较新批次内部 新增签名同时查询历史有效索引
中途失败、重试 覆盖公共输出目录 运行独立目录、阶段产物与发布指针分离

本文的默认目标是文档级字符近重复。段落级复用、翻译文本和语义改写属于不同检测任务,不混进一个阈值里。

2. 先把删除语义说清楚

2.1 精确重复与近重复分别处理

精确去重以规范化后的正文为依据,SHA-256 用作分组索引。保留原文与规范化文本,必要时对摘要相同的记录再校验规范化字节。哈希碰撞概率极低,但摘要不是数学上的全文相等证明。

近重复使用字符 5-gram 集合:

S ( d ) = { d i : i + 5 } , J ( A , B ) = ∣ A ∩ B ∣ ∣ A ∪ B ∣ S(d)=\{di:i+5\},\qquad J(A,B)=\frac{|A\cap B|}{|A\cup B|} S(d)={di:i+5},J(A,B)=∣A∪B∣∣A∩B∣

集合会丢失切片频次。因此一个重复段落出现十次与出现一次可能拥有相近集合。长度比、模板比例和重复率是独立质量特征,不应假装 Jaccard 已经覆盖它们。

基线选择 Jaccard 阈值 0.8,短于 200 字符的文档只执行精确去重,超过 100,000 字符的文档进入长文隔离路径。两个长度边界都是启动参数,必须用语料标注校准。隔离不等于删除。

2.2 LSH 找候选,不作最终裁决

MinHash 用多个最小哈希值估计 Jaccard。理想独立条件下,签名估计的标准误差约为:

s ( 1 − s ) / m \sqrt{s(1-s)/m} s(1−s)/m

当 s = 0.8 , m = 128 s=0.8,m=128 s=0.8,m=128 时约为 0.035,不应称作"极高精度的精确判断"。实际实现还受哈希函数与输入分布影响。

将 m = b r m=br m=br 个签名位置切为 b b b 个 band,每个 band 有 r r r 行。理想碰撞概率为:

P ( s ) = 1 − ( 1 − s r ) b P(s)=1-(1-s^r)^b P(s)=1−(1−sr)b

配置 签名位置数 s=0.7 时碰撞概率 s=0.8 时碰撞概率 s=0.9 时碰撞概率
b=16, r=8 128 61.3% 94.7% 99.99%
b=32, r=4 128 99.98% >99.9999% >99.9999%

第二组配置召回更积极,也会生成更多低相似度候选。阈值 0.8 不是 LSH 的硬边界。同桶可能误报,不同桶也可能漏报。datasketch 官方文档明确说明存在假阳性与假阴性。1

整体成本近似为签名计算、band 展开、Shuffle、候选验证和图归并之和。LSH 不保证总复杂度为 O(n),单个大小为 k 的桶仍可能产生 k ( k − 1 ) / 2 k(k-1)/2 k(k−1)/2 对。

2.3 连通分量是业务策略,不是相似度传递定律

若 A 与 B 相似度达到 0.8,B 与 C 也达到 0.8,A 与 C 可能不足 0.8。

  • 簇级去重:对已验证边求连通分量,每簇保留一个代表。吞吐与管理较方便,但接受链式合并。
  • 代表约束去重:每个被删除文档必须与最终保留代表达到阈值。需要迭代匹配和更严格的删除证据,不能把连通分量输出直接当成结果。

本文代码实现第一种策略,并保留验证边作为证据。它不承诺每篇被删文档与代表都直接相似。法律文书、产品规格和高价值知识库通常需要第二种策略或人工复核。

3. 把流水线拆成能独立重跑的阶段

#mermaid-svg-7d7bIYwcqJGmU3K2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7d7bIYwcqJGmU3K2 .error-icon{fill:#552222;}#mermaid-svg-7d7bIYwcqJGmU3K2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7d7bIYwcqJGmU3K2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .marker.cross{stroke:#333333;}#mermaid-svg-7d7bIYwcqJGmU3K2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7d7bIYwcqJGmU3K2 p{margin:0;}#mermaid-svg-7d7bIYwcqJGmU3K2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .cluster-label text{fill:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .cluster-label span{color:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .cluster-label span p{background-color:transparent;}#mermaid-svg-7d7bIYwcqJGmU3K2 .label text,#mermaid-svg-7d7bIYwcqJGmU3K2 span{fill:#333;color:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .node rect,#mermaid-svg-7d7bIYwcqJGmU3K2 .node circle,#mermaid-svg-7d7bIYwcqJGmU3K2 .node ellipse,#mermaid-svg-7d7bIYwcqJGmU3K2 .node polygon,#mermaid-svg-7d7bIYwcqJGmU3K2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .rough-node .label text,#mermaid-svg-7d7bIYwcqJGmU3K2 .node .label text,#mermaid-svg-7d7bIYwcqJGmU3K2 .image-shape .label,#mermaid-svg-7d7bIYwcqJGmU3K2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-7d7bIYwcqJGmU3K2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .rough-node .label,#mermaid-svg-7d7bIYwcqJGmU3K2 .node .label,#mermaid-svg-7d7bIYwcqJGmU3K2 .image-shape .label,#mermaid-svg-7d7bIYwcqJGmU3K2 .icon-shape .label{text-align:center;}#mermaid-svg-7d7bIYwcqJGmU3K2 .node.clickable{cursor:pointer;}#mermaid-svg-7d7bIYwcqJGmU3K2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .arrowheadPath{fill:#333333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7d7bIYwcqJGmU3K2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7d7bIYwcqJGmU3K2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7d7bIYwcqJGmU3K2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7d7bIYwcqJGmU3K2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .cluster text{fill:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 .cluster span{color:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7d7bIYwcqJGmU3K2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7d7bIYwcqJGmU3K2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-7d7bIYwcqJGmU3K2 .icon-shape,#mermaid-svg-7d7bIYwcqJGmU3K2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7d7bIYwcqJGmU3K2 .icon-shape p,#mermaid-svg-7d7bIYwcqJGmU3K2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7d7bIYwcqJGmU3K2 .icon-shape .label rect,#mermaid-svg-7d7bIYwcqJGmU3K2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7d7bIYwcqJGmU3K2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7d7bIYwcqJGmU3K2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7d7bIYwcqJGmU3K2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 固定输入快照
规范化与精确去重
短文与长文分流
MinHash 签名
Band 索引与桶统计
普通桶候选
热点桶隔离
精确 Jaccard 验证
连通分量与代表选择
审计映射与质量门禁
发布不可变快照

Spark 负责大规模 Join、窗口排序、候选边和结果表。先用 Spark 跑通全部阶段,只有确认签名阶段是瓶颈后才替换为 Ray。两个引擎之间以 Parquet 和 manifest 交接,不在任务中同步向另一引擎逐条 RPC。

Ray 的对象存储、零拷贝和 spilling 有各自适用范围,不能理解为 Python 无序列化开销,也不能把 spilling 当成工作进程堆内存的保护措施。34

生产中建议记录以下 manifest:

json 复制代码
{
  "run_id": "corpus-20260930-attempt-01",
  "input_snapshot": "immutable-source-snapshot-id",
  "normalization_version": "nfc-whitespace-v1",
  "shingle": "unicode-character-5gram-v1",
  "minhash": {"num_perm": 128, "seed": 42, "bands": 16, "rows": 8},
  "threshold": 0.8,
  "max_bucket": 500,
  "policy": "verified-edge-connected-components-v1",
  "code_commit": "replace-with-real-commit",
  "dependency_lock_sha256": "replace-with-real-lock-hash",
  "status": "BUILDING"
}

不同规范化版本、种子、签名算法或字节序的索引禁止混用。源快照必须冻结文件清单和版本,不能只记录一个持续被写入的目录。

4. 可执行的 Spark 基线

4.1 环境、输入和运行方式

下面以 Python 3.11、Java 17 和 PySpark 3.5.x API 为基线。可使用以下固定版本创建复现环境,版本固定不代表它们是最新版本或已通过贵公司的安全审核。

text 复制代码
# requirements.txt
pyspark==3.5.6
datasketch==1.6.5
numpy==1.26.4

输入为 Parquet,每行需要四列:doc_id: string、text: string、quality: double、source_priority: int。doc_id 必须在输入快照内唯一且跨重跑稳定,可由采集系统生成。禁止用 monotonically_increasing_id() 生成长期身份。后两列越大越优先,授权不合规的数据应在进入流水线前排除,而不是降低一点分数继续竞争。

本地运行:

bash 复制代码
python -m pip install -r requirements.txt
spark-submit --master 'local[4]' pipeline.py ./raw ./run-001

run-001 必须是新目录。集群执行时将输入、输出和 checkpoint 换为共享文件系统路径。S3 还需要与 Hadoop 版本匹配的 S3A 依赖、凭据提供器和适配的提交器,不能只替换字符串就视作完成生产部署。

4.2 核心模块:core.py

规范化选择 NFC 与空白合并,保留大小写和标点。NFKC、lower 和正文抽取可能合理,但会改变重复定义,应另建版本。字符切片按 Python Unicode code point,不是用户可见字素。

python 复制代码
# core.py
import hashlib
import re
import struct
import unicodedata
from datasketch import MinHash

NUM_PERM, BANDS, ROWS, SEED = 128, 16, 8, 42
NGRAM = 5
MIN_NEAR, MAX_CHARS = 200, 100_000
THRESHOLD = 0.8


def normalize(text):
    return re.sub(r"\s+", " ", unicodedata.normalize("NFC", text)).strip()


def shingles(text):
    return {text[i:i + NGRAM] for i in range(max(0, len(text) - NGRAM + 1))}


def signature(text):
    # 逐切片 update,不构造全文切片集合,重复切片不改变最小值。
    m = MinHash(num_perm=NUM_PERM, seed=SEED)
    for i in range(len(text) - NGRAM + 1):
        m.update(text[i:i + NGRAM].encode("utf-8"))
    return [int(v) for v in m.hashvalues]


def band_keys(values):
    if len(values) != BANDS * ROWS:
        raise ValueError("signature length mismatch")
    keys = []
    for band in range(BANDS):
        chunk = values[band * ROWS:(band + 1) * ROWS]
        # 固定大端序;band 序号属于键的一部分。
        payload = struct.pack(">I", band) + struct.pack(">" + "Q" * ROWS, *chunk)
        keys.append(hashlib.sha256(payload).hexdigest())
    return keys


def jaccard(a, b):
    x, y = shingles(a), shingles(b)
    return len(x & y) / len(x | y) if x and y else 0.0

签名阶段避免构造集合,但复杂度仍与文本长度及签名维度有关。验证阶段会构造两个集合,因此需要单文档长度上限以及经过测量的执行器并发上限。

4.3 完整流程:pipeline.py

这个版本不用 collect_list 聚合全文,也不依赖 GraphFrames 的额外 JVM 包。它先统计桶大小,对普通桶生成完整候选对,验证后再用分布式标签传播计算连通分量。标签传播适合流程验证与中等图规模,TB 级正式上线应对这一阶段单独压测,必要时替换为已验证的大图连通分量实现。

python 复制代码
# pipeline.py
import sys
from pyspark.sql import SparkSession, Window, functions as F, types as T
from core import (
    normalize, signature, band_keys, jaccard,
    MIN_NEAR, MAX_CHARS, THRESHOLD,
)

src, root = sys.argv[1:3]
spark = (SparkSession.builder.appName("auditable-text-dedup")
         .config("spark.sql.adaptive.enabled", "true")
         .config("spark.sql.adaptive.skewJoin.enabled", "true")
         .config("spark.sql.shuffle.partitions", "200")
         .getOrCreate())
spark.sparkContext.setCheckpointDir(root + "/checkpoint")
MAX_BUCKET, MAX_ROUNDS = 500, 100


def save(df, name):
    path = root + "/" + name
    df.write.mode("errorifexists").parquet(path)
    return spark.read.parquet(path)


raw = spark.read.parquet(src).select(
    "doc_id", "text", "quality", "source_priority")
# 任意空字段或重复 ID 都中止,而不是悄悄丢弃身份冲突。
invalid = raw.filter(
    F.col("doc_id").isNull() | (F.length("doc_id") == 0)
    | F.col("text").isNull() | F.col("quality").isNull()
    | F.isnan("quality") | F.col("source_priority").isNull())
if invalid.limit(1).count():
    raise ValueError("invalid input fields")
if raw.groupBy("doc_id").count().filter("count > 1").limit(1).count():
    raise ValueError("doc_id must be unique")

norm = F.udf(normalize, T.StringType())
docs = (raw.withColumn("norm_text", norm("text"))
        .withColumn("n_chars", F.length("norm_text"))
        .withColumn("digest", F.sha2("norm_text", 256)))
empty = save(docs.filter("n_chars = 0"), "empty_quarantine")
docs = save(docs.filter("n_chars > 0"), "normalized")

order = [F.desc("source_priority"), F.desc("quality"), F.asc("doc_id")]
# 分区包含规范化文本:摘要碰撞不会被当成精确重复。
w = Window.partitionBy("digest", "norm_text").orderBy(*order)
ranked = docs.withColumn("exact_rank", F.row_number().over(w))
# 从已排序的代表表回连,避免用无序聚合选择代表。
exact_reps = ranked.filter(F.col("exact_rank") == 1).select(
    "digest", "norm_text", F.col("doc_id").alias("exact_rep"))
exact_map = save(ranked.join(exact_reps, ["digest", "norm_text"])
                 .select("doc_id", "exact_rep"), "exact_map")
unique = save(ranked.filter(F.col("exact_rank") == 1)
              .drop("exact_rank"), "exact_unique")

eligible = unique.filter(
    (F.col("n_chars") >= MIN_NEAR) & (F.col("n_chars") <= MAX_CHARS))
save(unique.filter("n_chars < %d" % MIN_NEAR), "short_exact_only")
save(unique.filter("n_chars > %d" % MAX_CHARS), "long_quarantine")

sig_udf = F.udf(signature, T.ArrayType(T.LongType(), False))
band_udf = F.udf(band_keys, T.ArrayType(T.StringType(), False))
sigs = save(eligible.select("doc_id", "norm_text", "n_chars")
            .withColumn("signature", sig_udf("norm_text"))
            .drop("norm_text"), "signatures")
index = save(sigs.select("doc_id", "n_chars",
             F.explode(band_udf("signature")).alias("bucket")), "band_index")
stats = save(index.groupBy("bucket").count(), "bucket_stats")
hot = stats.filter(F.col("count") > MAX_BUCKET).select("bucket")
hot_ids = save(index.join(hot, "bucket").select("doc_id").distinct(),
               "hot_doc_ids")
normal = index.join(hot, "bucket", "left_anti")
a, b = normal.alias("a"), normal.alias("b")
candidates = save(a.join(b,
    (F.col("a.bucket") == F.col("b.bucket"))
    & (F.col("a.doc_id") < F.col("b.doc_id")))
    .select(F.col("a.doc_id").alias("src"),
            F.col("b.doc_id").alias("dst"))
    .distinct(), "candidates")

# Jaccard(A,B) <= min(|A|,|B|)/max(|A|,|B|)。
# 用不同切片数作安全上界过滤,不能用字符长度冒充集合大小。
card_udf = F.udf(lambda t: len({t[i:i+5] for i in range(len(t)-4)}), T.IntegerType())
texts = eligible.select("doc_id", "norm_text").withColumn(
    "card", card_udf("norm_text"))
left = texts.select(F.col("doc_id").alias("src"),
                    F.col("norm_text").alias("ta"), F.col("card").alias("ca"))
right = texts.select(F.col("doc_id").alias("dst"),
                     F.col("norm_text").alias("tb"), F.col("card").alias("cb"))
verify = candidates.join(left, "src").join(right, "dst").filter(
    F.least("ca", "cb") >= THRESHOLD * F.greatest("ca", "cb"))
j_udf = F.udf(jaccard, T.DoubleType())
scores = save(verify.withColumn("similarity", j_udf("ta", "tb"))
              .select("src", "dst", "similarity"), "pair_scores")
edges = save(scores.filter(F.col("similarity") >= THRESHOLD), "verified_edges")

# 包含孤立顶点,避免没有候选的文档从输出中消失。
labels = unique.select(F.col("doc_id").alias("id"),
                       F.col("doc_id").alias("label")).checkpoint(eager=True)
links = edges.select("src", "dst").union(
    edges.select(F.col("dst").alias("src"), F.col("src").alias("dst")))
links = links.checkpoint(eager=True)
for round_no in range(MAX_ROUNDS):
    messages = (links.join(labels, links.src == labels.id)
                .select(F.col("dst").alias("id"), "label"))
    nxt = (labels.union(messages).groupBy("id")
           .agg(F.min("label").alias("label"))).checkpoint(eager=True)
    changed = (labels.alias("old").join(nxt.alias("new"), "id")
               .filter(F.col("old.label") != F.col("new.label"))
               .limit(1).count())
    labels.unpersist()
    labels = nxt
    if changed == 0:
        break
else:
    raise RuntimeError("components did not converge; do not publish")
labels = save(labels, "components")

members = unique.join(labels, unique.doc_id == labels.id).drop("id")
# 热点文档作为强制保留项,即使普通桶中已出现验证边,也不自动删除。
marked = members.join(hot_ids.withColumn("is_hot", F.lit(True)),
                      "doc_id", "left").fillna(False, subset=["is_hot"])
cw = Window.partitionBy("label").orderBy(*order)
near_map = save(marked.withColumn("winner", F.first("doc_id").over(cw))
    .withColumn("near_rep", F.when(F.col("is_hot"), F.col("doc_id"))
                .otherwise(F.col("winner")))
    .select("doc_id", "near_rep", "label", "is_hot"), "near_map")

mapping = save(exact_map.join(
    near_map.select(F.col("doc_id").alias("exact_rep"), "near_rep", "label", "is_hot"),
    "exact_rep").select("doc_id", "exact_rep", "near_rep", "label", "is_hot"),
    "deletion_mapping")
kept_ids = mapping.select(F.col("near_rep").alias("doc_id")).distinct()
final = save(docs.join(kept_ids, "doc_id").drop("norm_text"), "kept")
if mapping.count() != docs.count():
    raise RuntimeError("mapping coverage mismatch")
if mapping.select("near_rep").distinct().count() != final.count():
    raise RuntimeError("representative missing")
print({"input_nonempty": docs.count(), "kept": final.count(),
       "empty_quarantine": empty.count(), "component_rounds": round_no + 1})
spark.stop()

4.4 这份代码保证什么,成本在哪里

  1. 每条非空输入都有映射,孤立顶点不会遗漏。空文本单独隔离。
  2. 同桶生成候选,只有精确验证通过的边进入图。
  3. 精确重复和簇代表都有确定排序,重分区不会改变代表选择。
  4. 短文和超长文暂不近重复删除。热点成员强制保留,热点候选未穷举,必须单独报告覆盖缺口。
  5. 超过传播轮数立即失败,没有"迭代到 100 轮就算完成"的静默截断。
  6. 普通桶完整两两比较,桶大小 500 时最多产生 124,750 对。全局候选仍可能巨大,这只是单桶保护,不是总预算。

上线时增加全局候选对预算、单分区验证预算和中间产物磁盘预算。超出预算应中止或进入有明确覆盖声明的降级路径。大图标签传播的成本与直径有关,频繁 checkpoint 也会产生大量 IO。这里选择透明实现方便复现,不宣称它能直接高效处理数亿顶点。

另外,unique 代表的是精确重复类。一个热点精确代表被保留,不意味着该类中所有精确副本都要保留。热点保护仅限制近重复删除。

5. 热点桶不能靠随机加盐"修好"

随机把一个桶拆到多个 salt 后,只比较相同 salt,确实降低了运行时间,也把跨 salt 候选丢掉了。若原始相似对被均匀分散到 q 个分区,单次随机拆分大约只保留 1/q 的共分区机会,不能继续宣称召回不变。

完整处理应将桶按稳定 ID 排序,再拆成块,枚举所有块对。块大小 250 的概念过程如下:

python 复制代码
# 算法示意,不是可直接提交的分布式调度代码。
for i in range(number_of_blocks):
    for j in range(i, number_of_blocks):
        emit_comparison_task(bucket_id, i, j)

同块使用上三角,跨块使用完整笛卡尔积。任务键与产物路径由 run_id + bucket_id + block_i + block_j 决定,便于重试。这样控制单任务内存,不会减少整体二次复杂度。

对于数十万成员的桶,先检查其成因:正文抽取失败、统一页脚、验证码页面、错误消息或真实的大规模重复。模板剥离属于新的文本表示版本,应保留改前改后的证据。若采用采样、代表比较或增加分桶条件,必须重新评估召回,不能作为等价优化。

6. Ray 只替换签名生成阶段

如果 Spark 运行画像显示 Python 签名阶段占用大部分时间,且候选 Join 与图阶段可接受,可以让 Ray 读取 Spark 的 eligible 表,写出相同签名 schema,然后 Spark 继续执行 band 阶段。

先将 pipeline 中的 eligible 写为独立 Parquet 产物。Ray 输入必须含 doc_id、norm_text、n_chars,且已经做完精确去重和长短文分流。

python 复制代码
# ray_signatures.py
import sys
import pyarrow as pa
import ray
from core import signature


def make_signatures(batch):
    # 一批只返回紧凑签名,不携带全文到下游。
    ids = batch.column("doc_id").to_pylist()
    texts = batch.column("norm_text").to_pylist()
    lengths = batch.column("n_chars").to_pylist()
    return pa.table({
        "doc_id": pa.array(ids, type=pa.string()),
        "n_chars": pa.array(lengths, type=pa.int32()),
        "signature": pa.array([signature(t) for t in texts],
                              type=pa.list_(pa.int64())),
    })


if __name__ == "__main__":
    # 本地调用;集群改用 address="auto",并把依赖装入所有工作节点。
    ray.init()
    ds = ray.data.read_parquet(sys.argv[1],
                              columns=["doc_id", "norm_text", "n_chars"])
    out = ds.map_batches(make_signatures, batch_format="pyarrow",
                         batch_size=16, num_cpus=1)
    out.write_parquet(sys.argv[2])
    ray.shutdown()

Ray API 以官方 map_batches 文档为依据。3 另行锁定实际验证过的 ray[data] 与 PyArrow 版本。输出目录同样须按运行隔离,代码不提供已有目录的事务替换保证。

batch_size=16 是保守启动值。即使 Arrow 输入零拷贝,to_pylist() 和签名生成仍分配 Python 对象。调优时观察 worker RSS、对象存储占用、spilling、CPU 利用率和读写吞吐,按长文本比例调节批大小,而不是只看文档数量。4

若迁移到 Ray 只省了少量签名时间,却增加了一次完整落盘和两套集群运维,保留 Spark 单引擎更合适。GPU 加速也需要具体算子和相同输出验证,给函数配置 num_gpus=1 不会把 Python MinHash 自动变成 CUDA 实现。

7. 做容量规划,再调整资源

7.1 从文档数推导中间结果

"2 TB"不足以决定集群大小。平均文档 4 KiB 与 40 KiB 对应的文档数相差十倍。先测文档数、字符长度分位数、压缩比例、精确重复率、桶分布和候选密度。

以 1 亿篇精确去重后的文档、128 维 uint64 签名、16 个 band 为示例:

产物 简单估算 说明
签名值 102.4 GB 仅裸签名,未含 ID、行开销与压缩
band 行数 16 亿行 每篇展开 16 行
band 键与 16 字节 ID 约 76.8 GB 假定键为 32 字节二进制,本例代码用 hex 字符串会更大
候选与验证边 无法由字节数直接确定 由桶大小分布与跨 band 去重决定

代码用 LongType 和字符串键便于理解。生产优化可压缩 band 键与签名存储,但必须有碰撞分析和兼容版本,不要只把 uint64 强转 signed int64 后期待所有算法都保持一致。

7.2 Spark 资源按任务工作集分配

bash 复制代码
spark-submit \
  --master yarn \
  --deploy-mode cluster \
  --executor-cores 4 \
  --executor-memory 12g \
  --conf spark.executor.memoryOverhead=6g \
  --conf spark.sql.shuffle.partitions=2000 \
  --conf spark.sql.adaptive.enabled=true \
  --conf spark.sql.adaptive.skewJoin.enabled=true \
  --conf spark.sql.adaptive.advisoryPartitionSizeInBytes=134217728 \
  pipeline.py hdfs:///corpus/raw-snapshot hdfs:///corpus/runs/run-001

这是 YARN 的资源示例,不是 2 TB 通用最优参数。生产版应移除代码中硬编码的 shuffle.partitions=200,改为从环境或参数读取,否则代码设置会覆盖提交配置。

AQE 可以优化部分 Join 和分区行为,不能消除 collect_list 的单桶工作集,也不能把二次候选生成变成线性操作。5 Python worker、Arrow 缓冲区和原生库使用堆外/额外进程内存,要一起计入容器预算。执行器核数越多,同时验证的长文对越多,RSS 可能先于 JVM 堆耗尽。

优化顺序建议为:前置精确去重,减少无用字段,物化重用结果,观察桶与候选规模,调整并发与分区,最后评估换引擎。不要把所有中间表无差别 cache()。

8. 增量去重与实时链路

8.1 增量索引需要匹配同一个算法身份

每天新增一批时,至少完成新新候选和新旧候选两类比较。旧索引只保留有效成员,并记录代表映射、算法版本和快照 ID。若旧文档后来因授权撤回而失效,还需修复其代表关系,不能只删正文而保留悬空映射。

每批步骤为:固定历史版本,生成新增签名,Join 历史 band 索引,验证候选,按"历史优先"或"质量优先"明确选择代表,写出新索引快照,校验后发布。在线结果在离线核验之前标记为 provisional。

8.2 Redis SETNX 加 Kafka commit 存在丢数据窗口

以下逻辑不具有端到端恰好一次语义:

text 复制代码
Redis 写入"已经见过" -> Kafka 发往下游 -> 提交消费 offset

Redis 成功后进程崩溃,下游尚未收到数据。重放消息时看到 Redis 已存在,就丢弃了本该输出的文档。只把 send() 改成等待 ACK 不能修复 Redis 写入后的崩溃窗口。

Kafka 内部的事务可覆盖 Kafka 输出与消费 offset,不能自动覆盖外部 Redis 或数据库。6 两条常见路线是:在 Kafka Streams 内使用状态存储与事务语义,或者数据库事务加 outbox,Kafka 采用至少一次投递、下游幂等。

8.3 数据库 outbox:把"决定"和"待发送事件"一起提交

下面是 PostgreSQL 的精确去重入站核心,可用于需要审计的日常新增链路。完整系统还需批量 outbox relay、死信处理与消费端幂等。

sql 复制代码
CREATE TABLE exact_seen (
    algorithm_version text NOT NULL,
    digest text NOT NULL,
    representative_id text NOT NULL,
    PRIMARY KEY (algorithm_version, digest)
);
CREATE TABLE dedup_outbox (
    event_id text PRIMARY KEY,
    payload jsonb NOT NULL,
    sent_at timestamptz
);
python 复制代码
# ingest.py:需要 psycopg 3,conn 使用默认事务模式。
import hashlib
import json
from core import normalize


def ingest(conn, doc):
    digest = hashlib.sha256(normalize(doc["text"]).encode()).hexdigest()
    version = "nfc-whitespace-v1"
    # 重试同一内容得到稳定事件 ID。
    event_id = hashlib.sha256((version + ":" + digest).encode()).hexdigest()
    with conn.transaction():
        with conn.cursor() as cur:
            cur.execute("""
                INSERT INTO exact_seen VALUES (%s, %s, %s)
                ON CONFLICT DO NOTHING RETURNING representative_id
            """, (version, digest, doc["doc_id"]))
            inserted = cur.fetchone()
            if inserted:
                cur.execute("""
                    INSERT INTO dedup_outbox(event_id, payload)
                    VALUES (%s, %s::jsonb) ON CONFLICT DO NOTHING
                """, (event_id, json.dumps({"event_id": event_id, "doc": doc})))
    # 数据库事务成功后,调用方才可以提交 Kafka 消费 offset。

Relay 读取未发送事件,等待 Kafka ACK 后再标记 sent_at。若在 ACK 后、更新数据库前崩溃,会重复发送,消费端必须按 event_id 幂等。这样得到的是可恢复的至少一次链路,不是跨数据库和 Kafka 的天然恰好一次。

此实现按摘要判断精确重复,并以最先成功入库的文档为代表,与离线质量优先策略不同。并发到达顺序不确定,若要求最终代表确定,应让实时链路只作临时抑制,离线统一重新选择代表。对极端碰撞敏感的业务需增加规范化字节校验。

9. 发布、恢复与删除审计

9.1 重试不能覆盖已发布结果

每个 attempt 写自己的不可变目录。阶段成功后记录输入 manifest、行数、schema、算法配置和校验摘要,恢复只能复用配置与输入快照完全匹配的阶段。

文中基线使用 errorifexists,故重跑必须换目录。真正的恢复版本应通过阶段 manifest 决定复用,而不是看目录存在就跳过。写失败遗留的目录不能视为成功阶段。

对象存储的目录 rename 往往不具备文件系统式原子语义。发布使用 Iceberg/Delta 等事务表,或控制库中的条件更新,把 current_snapshot 从旧 ID 指向通过验收的新 ID。读者仅消费已发布 manifest 指向的产物。

9.2 删除记录必须能追溯到规则与证据

至少保留:doc_id、exact_rep、near_rep、component、规则版本、输入快照、运行 ID、删除原因以及验证边产物位置。

doc_id != exact_rep 表示精确重复删除,doc_id == exact_rep && doc_id != near_rep 表示簇策略删除。热点保留、短文仅精确处理、长文隔离和空文本隔离分别单列。

簇策略删除的文档可能只有到代表的路径证据,没有直接相似度。审计接口必须如实显示这一区别,不能把某条路径上的 0.87 标成该文档与代表的相似度。

PII 处理会改变文本表示。身份证、电话号码和代码常量不能仅靠一套宽泛正则全部替换后就声称"完成隐私治理"。若脱敏发生在去重后,它可能让不同文档变成相同输出,须增加发布前精确重复检查,并明确是否允许这一收敛。

10. 验收应验证正确性,也验证漏检

10.1 小样本暴力基准

取若干千篇具有代表性的文档,在长度上限内对每个无序对计算真实 Jaccard。将暴力结果作为候选召回基准,不能只在 LSH 找到的候选里抽样。

定义:

R e c a l l c a n d i d a t e = 真实近重复且进入候选的对数 全部真实近重复对数 Recall_{candidate}=\frac{\text{真实近重复且进入候选的对数}}{\text{全部真实近重复对数}} Recallcandidate=全部真实近重复对数真实近重复且进入候选的对数

验证边精度与最终删除精度也要单独统计。即使边精度很高,连通分量链式合并仍可能让删除策略不符合业务预期。

标注集应包含模板占主导、事实数字不同、日期更新、同标题不同正文、中英混排、短文、Unicode 等价字符与代码文本。人工标注同时区分"字符近重复"和"业务上应该删除",两者不是一回事。

10.2 最小回归检查

python 复制代码
# test_core.py:python -m unittest test_core.py
import unittest
from core import normalize, signature, band_keys, jaccard


class CoreTests(unittest.TestCase):
    def test_unicode_normalization(self):
        self.assertEqual(normalize("e\u0301  x"), normalize("é x"))
        self.assertNotEqual(normalize("API"), normalize("api"))

    def test_signature_and_band_identity(self):
        text = "生产级文本去重需要保留完整证据。" * 30
        self.assertEqual(signature(text), signature(text))
        self.assertEqual(len(set(band_keys(signature(text)))), 16)

    def test_collision_is_not_similarity(self):
        self.assertEqual(jaccard("abcdefghijkl", "abcdefghijkl"), 1.0)
        self.assertLess(jaccard("abcdefghijkl", "QRSTUVWXYZAB"), 0.8)

    def test_similarity_is_not_transitive(self):
        # 集合层面的反例,证明图连通不等价于代表直接相似。
        a, b, c = set(range(10)), set(range(11)), set(range(1, 11))
        score = lambda x, y: len(x & y) / len(x | y)
        self.assertGreaterEqual(score(a, b), 0.9)
        self.assertGreaterEqual(score(b, c), 0.9)
        self.assertLess(score(a, c), 0.9)


if __name__ == "__main__":
    unittest.main()

集成测试还必须覆盖:候选为空、输入全空、孤立顶点、热点强制保留、图不收敛、重复 doc_id 拒绝、改变分区数后代表一致、executor 被杀后重算一致、发布前失败不改变 current 指针、outbox ACK 后崩溃产生可幂等的重复事件。

10.3 性能报表不填虚构的提升倍数

指标 用途 记录方式
输入有效字节与文档数 统一吞吐分母 同时记录压缩与解压口径
各阶段 wall time 找主要瓶颈 包括读写、checkpoint 与重试
band 行数、最大桶、桶分位数 发现倾斜 记录隔离文档比例
候选对数、验证通过率 衡量 LSH 放大 跨 band 去重后计数
worker RSS、GC、spill、Shuffle 区分内存与 IO 瓶颈 按节点和任务分位数
召回、删除精度、覆盖缺口 防止性能靠少算获得 按语言、长度与来源分层
CPU-hours、存储 IO 与成本 比较 Spark/Ray 同一快照、同一输出语义

至少固定输入快照、依赖、阈值、热点策略和资源,重复多次并报告中位数与波动。Spark 和 Ray 比较不仅看耗时,还要核对签名、候选、代表映射的一致性。首次读冷数据与命中缓存的任务不能混为一组成绩。

11. 一条更稳妥的工程演进路线

先做小样本暴力验证,确认重复定义与删除政策。接着跑完整 Spark 基线,检查每条输入去向和热点覆盖缺口。再逐步扩大数据量,观察 band 索引膨胀、候选密度和图收敛,提前测出磁盘与内存预算。

只有运行画像证明签名阶段值得迁移,才引入 Ray。热点处理、历史索引、发布事务和评估污染检测应作为独立可验收阶段,而不是被"换成分布式架构"一笔带过。

TB 级去重系统的完成标准是:每篇删除都有规则与证据,每个漏检范围都有记录,每次失败都能恢复,每个性能数字都可以在相同快照上复核。

参考资料与实现边界

  1. datasketch MinHash LSH:候选检索的概率行为与假阳性、假阴性。
  2. Spark ML 特征处理与 MinHashLSH:Spark 内置算子以非零向量索引表示集合,与本文自定义字符签名不是可直接互换的表示。
  3. Ray Data map_batches:批格式、批大小与计算资源参数。
  4. Ray Data 性能调优:对象存储与 worker heap 内存边界。
  5. Spark 3.5.6 配置文档:AQE、分区和执行资源配置。
  6. Kafka 消息投递语义:事务、投递保证与外部系统边界。

本文不复用原稿中无法在相同语料、相同算法和相同资源下核验的"2--3 倍""15 倍""4.8 小时"等成绩,也不把未核实的 Data-Juicer 配置项当成可执行接口。正式项目应保存依赖锁文件、镜像摘要、代码提交、输入 manifest 与测试报告。交付检查:6 个 Python 代码块通过语法解析,规范化、精确 Jaccard 与固定字节序 band 键通过基础断言。当前环境缺少 datasketch 且无法联网安装,因此完整单元测试未执行成功,MinHash 依赖集成、Spark/Ray 端到端运行、TB 级集群、对象存储及实时链路压测均待部署环境验证。

新增案例:热点模板页、恢复与跨引擎验收

固定小样本如何校准

在一次冻结的 5,000 篇样本上,覆盖中英文、Unicode 等价字符、模板页、日期更新、代码和短文;先暴力计算受控无序对真实 Jaccard,再分别标注"字符近重复"和"业务应删除"。下面仅展示报告格式,不能当作通用成绩:

分层 候选召回 验证边精度 决策
正文 >= 200 字 96.8% 98.9% 使用 5-gram、0.80、16×8
模板占主导 99.4% 41.2% 热点隔离,修复抽取器后重跑
短文 不评估 --- 仅精确去重
代码文本 89.1% 93.0% 独立数据域重新校准

真实事故中,访问受限页面被抽取器写成 182,416 篇正文;一个 bucket 有 176,903 成员,完整候选上界为 15,647,247,253 对。热点语义是:文档一旦命中任何热点桶,就从所有 band 自动删除路径移除,并以 HOT_QUARANTINE 映射到自身。报告必须将其计作近重复覆盖缺口。

Spark / Ray 输出一致性校验

Ray 只替换签名阶段:输入为 Spark 固化的 eligible Parquet,输出固定为 doc_id、n_chars、signature,且所有 worker 从相同 wheel 或不可变镜像导入算法。切换前逐级比对签名、band、候选、验证边与最终映射:

python 复制代码
def assert_same(left_path, right_path, keys):
    left, right = spark.read.parquet(left_path), spark.read.parquet(right_path)
    if left.select(*keys).exceptAll(right.select(*keys)).limit(1).count():
        raise AssertionError('left result differs')
    if right.select(*keys).exceptAll(left.select(*keys)).limit(1).count():
        raise AssertionError('right result differs')

assert_same(spark_signature_path, ray_signature_path,
            ['doc_id', 'n_chars', 'signature'])

失败重跑与发布指针

一次 run-042 在写完 verified_edges 后 executor 被终止。恢复器只在输入快照、算法配置、schema、代码提交和锁文件哈希全部匹配时复用该阶段;否则创建新的 run。验收后才用条件更新切换读者可见指针:

sql 复制代码
UPDATE dedup_publication
SET current_snapshot = :new_run, updated_at = now()
WHERE dataset = :dataset AND current_snapshot = :expected_old_run;

影响零行意味着并发发布,调用方重新读取指针,绝不覆盖。审计记录至少包含 doc_id、exact_rep、near_rep、component_id、decision、rule_version、input_snapshot、run_id、evidence_path、is_hot。路径 d-991→d-502→d-101 必须说明是组件路径,而非首尾直接相似。

相关推荐
努力努力再努力wz1 小时前
【CUDA入门系列】CUDA 执行模型与性能优化:SM 调度、Occupancy、内存访问与 Bank Conflict
性能优化·架构
施棠海1 小时前
多 Agent 团队编排系统的设计与实测:agent-workflow
python·ai·架构
流浪0011 小时前
大模型技术全景(十三):记忆管理 Memory
llm·memory·记忆
智码看视界2 小时前
Edge AI 全栈实战 ④:向量数据库 + RAG 让 IoT 诊断拥有“记忆“
flink·向量数据库·rag·全栈开发·edgeai·iot诊断
IT研究室2 小时前
最新大数据毕业设计选题推荐-基于大数据的商场购物中心分布运营数据可视化与分析-大数据-Spark-Hadoop-Bigdata
大数据·信息可视化·数据分析·spark·课程设计
deepdata_cn2 小时前
从集中式到分布式:Data Mesh颠覆传统数据架构的底层逻辑
分布式·data mesh
500843 小时前
React Native for OpenHarmony 实战:三方库 react-native-device-uptime 的鸿蒙化适配指南
javascript·分布式·react native·react.js·harmonyos
xcl09259 小时前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
灯澜忆梦10 小时前
【RabbitMQ #10】 | MQ可靠性
分布式·rabbitmq