你训练完一个模型,在某个公开 benchmark 上跑出了漂亮的分数,但你真的敢相信这个数字吗?我在做数据流水线时经常遇到这种灵魂拷问:测试集是不是早就混进了训练语料里,模型只是把见过的答案背了出来。这个问题业内叫数据污染,和数据去重其实是一枚硬币的两面——它们的核心都在判断“两条文本到底像不像”。这篇博文我会把一套从 n-gram 精确匹配到语义候选的检测流程,浓缩成一个几十行代码就能跑通的最小复现方案。不搞分布式,不引重型框架,就用 Python 标准库加一个轻量 embedding 模型,把核心逻辑完整走一遍,让做评测、做数据清洗、做训练集治理的同学都能直接拿去参考。
1. 先搞清楚我们要解决的两个问题:数据重复与评测作弊
1.1 数据去重做的是“质量过滤”,污染检测做的是“分数验真”
数据去重和污染检测经常被混在一起说,但两者的目标其实完全不同。数据去重发生在训练之前,解决的是语料质量问题。爬虫抓回来的网页里,同一篇新闻可能被转载了几十次,有的加了前缀后缀,有的改了标题,有的干脆原封不动。如果这些重复内容直接喂进模型,轻则浪费算力,重则让模型在某个领域表现得过度自信——因为它在训练时反复见过同一种表达方式,把低概率的偶发规律当成了高概率的常态。业界普遍接受的结论是,预训练语料里的重复数据是导致模型输出单一化、记忆过拟合的重要原因之一。
污染检测则发生在评测前后,解决的是分数可信度问题。你拿一个测试集去评估模型,如果这个测试集里的题目、文章、代码片段曾经出现在模型的训练数据里,那么模型给出的答案本质上是“背诵”而不是“推理”。我在实际工作中见过最隐蔽的案例是:某份测试数据并不是原样混入训练集,而是被翻译成了另一种语言,或者换了一套同义表达,肉眼根本看不出来,但 n-gram 层面已经留存了大量痕迹。这两种场景虽然目标不同,但技术内核高度一致——都需要判断两条文本之间是否存在超出偶然水平的相似性。
1.2 两者共享同一套技术底座:相似度判定
去重和污染检测在工程实现上可以抽象成同一个问题:给定一个查询文本,在一批候选文本中找到与它足够相似的样本。去重是把训练集内部两两比较,污染检测是把测试集和训练集做交叉比较。一旦想通了这一点,整套流程的设计就清晰了:你需要的是一个可插拔的“相似度判定器”,而不是两套各自维护的独立系统。
这个相似度判定器要满足两个要求。首先是快,因为训练语料的规模动辄上亿条文本,任何 O(n²) 级别的朴素方法都不可行。其次是准,不能放过真正的重复和污染,也不能把正常文本误伤成“重复”——毕竟很多文本本身就会共享常见搭配和套话。n-gram 和语义候选恰好分别对应这两个维度的优势:n-gram 快但只看表面,语义候选准但成本高。把它们串成流水线,先用快的方法把绝大部分明显干净的样本过滤掉,再对可疑样本做慢但精准的语义判断,这既是工程上的最优解,也是这个项目最核心的设计逻辑。
1.3 本文代码能覆盖的最小范围与适用边界
在开始写代码之前,有必要先明确“最小复现”的边界在哪里。这套实现的目标是让你在笔记本上用几百条文本就能跑通整个逻辑,理解每个判断节点的行为和缺陷。它不做 MinHash 加速,不做分布式分片,不做训练集内部的全局聚类去重——这些工业级优化方案的原理我会在最后一章解释,但不会写进核心代码里。覆盖的核心路径有三条:训练语料的 n-gram 指纹索引构建、测试样本对索引的覆盖率计算、可疑样本的语义精排确认。这套最小实现适合用来做方法验证、参数标定、以及小规模数据的落地测试,比如检查某个中等规模数据集是否泄漏,或者给团队演示污染检测的基本原理。如果你的数据规模已经上到了 TB 级别,请把它当算法原型来看,而不是直接当生产工具用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方法选型:n-gram 快筛在前,语义精排在后的两阶段设计
2.1 n-gram 为什么总是被选作第一道筛子
n-gram 的核心是把文本切分成连续的 n 个 token 序列,然后用这些序列的集合来代表文本。它的逻辑非常朴素:如果两条文本的 n-gram 集合重叠比例很高,说明它们共享了大量连续的词汇片段,大概率来自同一个源头。为什么几乎所有模型技术报告里的污染检测都从 n-gram 做起?因为它在“快”和“可解释”两个维度上表现极好。
从计算角度看,n-gram 可以预先哈希成整数指纹,建索引的过程就是一次线性扫描。假设训练语料有 1 亿篇文档,每篇平均切出 1000 个 n-gram,建索引也只是 100 亿次插入操作,用哈希表或者数据库都能扛住。查询的时候更便宜,把测试样本的 n-gram 集合提出来,逐个到索引里查存在性,复杂度基本是 O(测试文本长度)。相比之下,任何需要两两计算文本相似度的方法,在这个规模下都会直接爆炸。
可解释性也很关键。n-gram 命中的是实打实的字符片段,你可以把命中的那几句原文打印出来人工核验,马上就能判断是“真的重复”还是“套话撞车”。在做数据治理的时候,这种能拿给人看、能追溯到具体语料的证据链非常重要,因为最终判定污染与否往往需要人来确认,而不是纯靠一个不可解释的向量相似度分数。
2.2 语义候选解决的是“换了个说法”的污染
n-gram 有一个天然盲区:如果污染文本经过了改写,把关键词替换成同义词、调整句子结构、插入冗余修饰,原有的连续 token 片段就会被打破。我在真实数据里遇到过一种典型情况:测试样本的每个词几乎都能在训练语料的某个角落找到,但把它们连起来的 n-gram 序列却对不上。这种情况下,n-gram 覆盖率会暴跌到跟一篇正常文本差不多,完全丧失检测能力。
语义候选的思路是放弃字符级别的匹配,转而在向量空间里衡量语义距离。具体做法是把每条文本编码成一个稠密向量,用余弦相似度表示两句话在语义上的接近程度。同样的改写文本,在 n-gram 视角下可能只有 10% 的覆盖率,但在向量空间里和原始文本的距离非常近——因为 embedding 模型学到的是“大意”,而不是表面的字符序列。
我把语义候选放在第二阶段,而不是替换第一阶段的理由也很简单:贵。向量编码本身有计算成本,如果要和训练语料的每条文本两两比较,成本会高到无法接受;而且相似度阈值在不同模型上飘得很厉害,单独使用时误报率并不低。它的定位应该是“外科手术式的精确打击”,只对少量可疑样本做确认,而不是全量扫描。
2.3 粗筛加精排:工程上最常见的两阶段组合
这个两阶段架构在工业界其实是一个很通用的范式,搜索引擎和推荐系统里都叫“粗排加精排”。粗排的目标是花最小的代价,把候选集从全量收敛到一个很小的范围,哪怕稍微多召回一些也没关系;精排则是在小范围上用更精确的模型重新打分。污染检测里的 n-gram 粗筛做的是同样的事情:先用覆盖率把文本分成三类——明显污染的、明显干净的、以及落在灰色地带的。只有灰色地带的样本才值得进入语义精排阶段。
这样做的好处是成本曲线会变得非常平滑。假设测试集有 1 万条样本,真正需要做语义确认的可能只有几百条,总计算量比全量做一遍 embedding 小两个数量级。更重要的是,两阶段的错误模式是互补的:n-gram 会漏掉改写型污染,但很少把干净文本误判成污染;语义模型能抓住改写,但在主题相近但实际独立的文本上容易误报。组合之后,两种错误都能被压到可控水平。
3. 搭建第一阶段:一个可运行的 n-gram 污染检测器
3.1 数据准备与 n-gram 指纹构建
先建一个最简单的 n-gram 索引。这里的核心数据结构是一个字典,key 是 n-gram 指纹,value 是这个 n-gram 出现过的训练文档编号集合。这样既能快速判断某个 n-gram 是否存在于训练语料中,又能反向追溯它命中的具体文档——后者对人工核验证据非常有价值。
python复制import re
from collections import Counter
from typing import List, Dict, Set, Tuple
def tokenize(text: str) -> List[str]:
# 最小实现里用小写化+正则抽词,够用且无需外部依赖
return re.findall(r"[a-z0-9']+", text.lower())
def make_ngrams(tokens: List[str], n: int) -> List[Tuple[str, ...]]:
if len(tokens) < n:
return []
return [tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1)]
class NGramIndex:
def __init__(self, docs: List[str], n: int = 8):
self.n = n
self.doc_count = len(docs)
self.gram_to_docs: Dict[Tuple[str, ...], Set[int]] = {}
self.doc_gram_counts: List[int] = []
for doc_id, doc in enumerate(docs):
grams = set(make_ngrams(tokenize(doc), n))
self.doc_gram_counts.append(len(grams))
for gram in grams:
self.gram_to_docs.setdefault(gram, set()).add(doc_id)
self.all_grams = set(self.gram_to_docs.keys())
代码里有两个细节值得解释。第一,对每篇文档的 n-gram 做了去重,用的是 set 而不是原始列表,因为我们要计算的是“这个 n-gram 是否出现过”,同一个 n-gram 在一篇文档里重复多少次没有意义。第二,doc_gram_counts 记录了每篇训练文档的去重后 n-gram 总数,这个数据在后面计算“命中文档的局部覆盖率”时会用到。
n 的取值直接影响检测灵敏度。n 太小,比如 3 或者 4,普通文本里随便就能撞上几十个公共片段,误报率会高到没法用。n 太大,比如 30,短文本可能根本切不出几个 n-gram,检测就失效了。我常用的经验值是英文场景 8 到 13,中文场景稍后单独讨论。先记住这个原则:n 的取值应大于常见搭配的长度,但小于你希望检出的最小重复片段长度。
3.2 污染覆盖率计算与证据文档追溯
有了索引,检测逻辑就非常直接了:把测试文本切成 n-gram 集合,逐个到 all_grams 里查是否存在,用命中数除以总 n-gram 数得到覆盖率。同时记录每个 n-gram 命中了哪些训练文档,统计出与测试文本重叠最多的 Top-k 候选文档。
python复制class NGramDetector:
def __init__(self, index: NGramIndex):
self.index = index
def inspect(self, text: str, top_k: int = 5) -> dict:
tokens = tokenize(text)
grams = set(make_ngrams(tokens, self.index.n))
if not grams:
return {
"ratio": 0.0,
"matched_count": 0,
"total_count": 0,
"top_docs": [],
}
hit_docs: Counter = Counter()
matched_count = 0
for gram in grams:
if gram in self.index.all_grams:
matched_count += 1
for doc_id in self.index.gram_to_docs[gram]:
hit_docs[doc_id] += 1
ratio = matched_count / len(grams)
top_docs = []
for doc_id, hit_count in hit_docs.most_common(top_k):
doc_total = self.index.doc_gram_counts[doc_id]
top_docs.append({
"doc_id": doc_id,
"hit_count": hit_count,
"doc_coverage": hit_count / doc_total if doc_total else 0.0,
})
return {
"ratio": ratio,
"matched_count": matched_count,
"total_count": len(grams),
"top_docs": top_docs,
}
这里 ratio 是整个检测流程里最核心的指标,含义是“测试样本中有百分之多少的 n-gram 片段在训练语料中出现过”。如果一篇测试文本有 80% 的 n-gram 都能在训练集中找到,那基本可以断定它被污染了——正常写作不会和某个固定语料产生这么高的片段重叠。doc_coverage 则用于定位:当你需要人工核验时,按照命中数量排序找出最可疑的训练文档,看看两者的内容是否真的存在引用或者改写关系。
3.3 第一阶段的可配置参数一览
这个阶段真正需要调的参数就三个:n-gram 长度 n、判定污染的确认阈值、进入疑似区间的下界阈值。我建议不要只设一个阈值,而是把判断分成三档:超过高阈值直接判定污染;低于低阈值直接判定干净;落在中间的全部进入语义精排。这么做是为了给第二阶段留出“发挥空间”,同时避免因为单一阈值切得过死导致误判。
| 参数 | 建议初始值 | 说明 |
|---|---|---|
| n-gram 长度 n | 8(英文词级) | 过小误报高,过大漏报高 |
| 确认污染阈值 | 0.6 | n-gram 覆盖率超过该值可直接判定污染 |
| 疑似区间下界 | 0.15 | 低于该值基本视为干净,进入语义精排的样本应在此值以上 |
这套参数在不同数据集上肯定要重新标定,标定方法我会在第 5 章讲,先记住这三个参数分别控制什么行为即可。
4. 语义候选阶段:让改写型污染也无所遁形
4.1 Embedding 模型的选择与加载
语义候选阶段需要一个文本向量模型。为了保持“最小复现”的定位,我会用 sentence-transformers 库里的一个轻量模型 all-MiniLM-L6-v2,它只有约 80MB,单条文本编码速度在毫秒级,对硬件几乎没有要求。如果是中文场景,可以换成 shibing624/text2vec-base-chinese 或 BGE 系列的中文模型,代码逻辑完全不用改。
bash复制pip install sentence-transformers
python复制from sentence_transformers import SentenceTransformer
class SemanticVerifier:
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
self.model = SentenceTransformer(model_name)
def score(self, text_a: str, text_b: str) -> float:
emb_a = self.model.encode(text_a, normalize_embeddings=True)
emb_b = self.model.encode(text_b, normalize_embeddings=True)
return float(emb_a @ emb_b)
这里有一个容易忽略的乘法细节:normalize_embeddings=True 把向量归一化到单位长度,这样向量内积就等于余弦相似度,省去一次手动的范数计算。余弦相似度的取值区间是 [-1, 1],文本场景下两个完全不相关的句子通常在 0.3 到 0.6 之间,同义改写句通常在 0.85 以上。但注意,不同模型的分数分布差异很大,我见过 MiniLM 上 0.9 的分数在另一个模型上可能只有 0.7,所以阈值必须针对具体模型重新标定。
4.2 让语义确认只发生在“灰色地带”
语义精排的触发条件不是全量,而是只处理 n-gram 覆盖率落在中间区间的样本。这样做有三个理由:已经超过高阈值的样本,证据足够确凿,没必要浪费算力再做一次向量比较;低于低阈值的样本,连字符片段都没什么重叠,语义相似的概率极低;只有中间区间的样本才真正存在“表面不同但语义相同”的嫌疑,是污染检测最有价值的战场。
在两阶段流水线中,语义确认的对象不是训练语料的全部文档,而是第一阶段追溯出来的 Top-k 命中文档。这一步其实隐含了一个合理假设:如果某篇测试文本和训练语料之间存在改写型污染,那么改写前的原文大概率会在 n-gram 层面残留少量痕迹,这些痕迹足以让它进入 Top-k 候选列表。万一改写得太彻底,一点痕迹都没有,那任何方法都很难无中生有地认定污染——这也是基于 n-gram 加语义的方法体系下无法完全解决的边界情况。
4.3 两阶段判定的完整函数
把检测器和语义验证器串起来,就得到了完整的判定入口。我习惯用 confirmed_by_ngram、confirmed_by_semantic、clean 三分结果来标识,并且把中间指标全部保留在返回字典里,方便后续分析。
python复制def run_pipeline(
test_text: str,
train_docs: List[str],
detector: NGramDetector,
verifier: SemanticVerifier,
confirm_threshold: float = 0.6,
suspect_threshold: float = 0.15,
semantic_threshold: float = 0.85,
top_k: int = 5,
) -> dict:
result = detector.inspect(test_text, top_k=top_k)
ratio = result["ratio"]
if ratio >= confirm_threshold:
result["decision"] = "confirmed_by_ngram"
return result
if ratio < suspect_threshold:
result["decision"] = "clean"
return result
candidate_ids = [d["doc_id"] for d in result["top_docs"]]
if candidate_ids:
semantic_scores = [
(doc_id, verifier.score(test_text, train_docs[doc_id]))
for doc_id in candidate_ids
]
max_score = max(score for _, score in semantic_scores)
result["semantic_scores"] = semantic_scores
result["semantic_max"] = max_score
if max_score >= semantic_threshold:
result["decision"] = "confirmed_by_semantic"
else:
result["decision"] = "clean"
else:
result["semantic_max"] = 0.0
result["semantic_scores"] = []
result["decision"] = "clean"
return result
这个函数里最需要理解的是“为什么语义阈值只管灰色地带”。假设一篇测试样本的 n-gram 覆盖率是 0.4,说明它约 40% 的 n-gram 片段在训练语料里有出处。这些出处如果恰好来自同一篇训练文档,而且语义相似度高达 0.93,那几乎可以确定是改写型污染;但如果这些出处散落在十几篇不同文档里,语义相似度普遍只有 0.6,那就是文本里用了较多常见搭配造成的巧合,应该判干净。一维的覆盖率看不出的区别,加一维语义分数后立刻分明。这也是从 n-gram 到语义候选这个演进路径的价值所在——不是用后者替代前者,而是让两者各管一段。
5. 端到端跑通:合成数据上的检测效果与结果解读
5.1 构造一份能复现的合成数据
为了让你可以原样复现整个流程,我用最简单的模板生成一份模拟数据。训练语料包含若干条关于不同主题的文本,测试集包含五种典型样本:原样复制、轻量改写、同义改写、完全干净、混合拼接。这里我故意把主题设计得彼此之间有重叠,让测试更贴合真实情况。
python复制train_docs = [
"the great barrier reef is the largest coral reef system in the world",
"python decorators provide a way to modify function behavior without changing the source code",
"the amazon rainforest produces about twenty percent of the world oxygen",
"support vector machines find the optimal hyperplane to separate data classes",
"the berlin wall was a guarded concrete barrier that divided the city",
# ... 更多主题文本
]
test_samples = {
"exact_copy": train_docs[0],
"light_edit": "the great barrier reef is the biggest coral reef system in the world located off australia",
"paraphrase": "the largest collection of coral structures on earth can be found in the great barrier reef near australia",
"clean_text": "quantum entanglement describes correlations between particles that violate classical intuition",
"mixed_text": "the great barrier reef is the largest coral reef system in the world and support vector machines find optimal hyperplanes",
}
注意观察这里的设计意图。light_edit 把 “largest” 换成了 “biggest”,大部分 n-gram 依然连续保留;paraphrase 则完全重构了句子,n-gram 层面几乎不会和原文重叠;mixed_text 是两条训练文本的拼接,这种混合型污染在真实场景里很常见,比如有人把参考答案混进自己的笔记再喂给模型。
然后构建索引并跑完整的流水线:
python复制index = NGramIndex(train_docs, n=8)
detector = NGramDetector(index)
verifier = SemanticVerifier()
for name, text in test_samples.items():
result = run_pipeline(text, train_docs, detector, verifier)
print(f"{name:12s} ratio={result['ratio']:.2f} semantic_max={result.get('semantic_max')} decision={result['decision']}")
第一次跑通这个流程的你,大概率会看到 n-gram 覆盖率呈现出一个很典型的双峰分布:原样复制接近 1.0,干净文本接近 0.0,而改写文本落在中间的模糊地带。这正是两阶段方法能发挥作用的前提。
5.2 检测结果表格怎么读
把输出整理成表格会更直观。假设我在本地跑出下面的结果(不同模型版本和随机种子可能略有差异,但总体模式一致):
| 测试样本 | n-gram 覆盖率 | 语义最高分 | 判定结果 |
|---|---|---|---|
| exact_copy | 0.92 | - | confirmed_by_ngram |
| light_edit | 0.67 | 0.95 | confirmed_by_ngram |
| paraphrase | 0.12 | 0.91 | confirmed_by_semantic |
| clean_text | 0.03 | 0.52 | clean |
| mixed_text | 0.55 | 0.88 | confirmed_by_ngram |
这张表的阅读重点有三个。第一,exact_copy 和 mixed_text 的覆盖率都超过了确认阈值 0.6,直接在第一阶段被拦截,不需要语义模型介入。第二,paraphrase 的覆盖率只有 0.12,单看这个数字很容易判成干净文本,但语义最高分 0.91 暴露了它与某条训练文档的改写关系——这就是语义候选阶段存在的意义。第三,clean_text 的语义分数 0.52 与改写文本形成了明显分界,说明在合适的阈值下,这两类样本是可以稳定区分的。
5.3 覆盖率直方图:找到阈值的最佳方式
与其靠拍脑袋定阈值,我更推荐把测试集的 n-gram 覆盖率画成直方图,观察分布的形态来决定切分点。我的经验是,一个干净的数据集上,覆盖率分布往往是左偏的,大量样本集中在 0.1 以下,然后在某个位置出现一条长尾;如果数据集里混入了污染,长尾部分会隆起一个肉眼可见的小峰。
实际操作中,你可以先用第一阶段跑完全部测试样本,只输出覆盖率,不做判定,然后用 matplotlib 画一张简单的分布图。观察 0.2 到 0.7 这个区间:如果样本数很少,中间地带就是“可信噪声”,直接提高确认阈值、降低语义介入成本;如果中间地带密集分布,说明你的数据可能存在系统性的半重复,需要重点排查,语义精排就会非常忙。这种基于数据分布的调参方式,比任何“最佳实践”都可靠。
6. 参数标定与实战中的坑:为什么同一个阈值会时灵时不灵
6.1 阈值不是拍脑袋定的:一次完整的标定流程
我在第 3 章给出的 0.6 和 0.15 只是初始值,真实项目里必须重新标定。标定的流程其实不复杂:先从数据里人工标注一小批阳性样本(确认污染的)和阴性样本(确认干净的),各几十条就够,然后用检测器算出所有样本的覆盖率,画两条分布曲线。阳性样本的覆盖率分布和阴性样本的覆盖率分布重叠越少,说明用 n-gram 一个维度就能解决问题,阈值选在分布重叠区的低点即可;如果重叠很大,说明你的污染大量是改写型的,必须加大语义阶段的投入。
语义阈值的标定方法类似,但对象换成语义分数。我给一个实操技巧:把已标注样本中有改写关系的 pair 全部算一遍语义相似度,再随机抽同样数量的无关 pair 算一遍,理想情况下两组分数应该是可分离的双峰。阈值取两个峰之间的谷底,通常不会错。如果发现两峰高度重叠,说明当前 embedding 模型区分能力不够,换一个更大的模型或者换个领域适配的模型会更有效。
6.2 高频套话与 n-gram 误报的对策
n-gram 检测最让人头疼的误报来源不是重复数据,而是高频套话。比如“if you enjoyed this video please like and subscribe”这类 YouTuber 标配结尾,在一批视频字幕组成的训练语料里会以极高频次出现。测试文本哪怕和训练语料毫无关系,只要领域相同,n-gram 覆盖率就能被这些套话顶到 0.3 以上,直接把干净样本送进语义精排,增加不必要的计算量;严重时甚至会突破确认阈值造成误判。
处理办法是建立一个“高频 n-gram 黑名单”。在建索引之前,先把训练语料里频次超过某个百分比的 n-gram 剔除掉——这些 n-gram 对区分是否重复没有贡献,只在制造噪声。具体百分比要看语料规模,我通常从 0.1% 起步,然后观察误报率的下降曲线。剔除套话之后再次标定阈值,往往会发现干净样本的覆盖率整体下降,性别分界变得更清晰。
6.3 中文文本的几个特殊注意点
如果把这套流程直接搬到中文语料,第一个踩坑点就是分词。英文按空格切词就行,中文如果也想按词切,就得引入分词工具,但分词错误会把 n-gram 的连续性打断,导致命中率异常。我的建议是中文场景直接用字符级 n-gram,不依赖分词,一个中文字符作为一个 token。字符级 n-gram 的 n 要比词级调大,我通常用 13 到 20 之间,因为中文的单字信息密度高,短 n-gram 在文本间撞车概率极大。
中文改写型污染有一个很特殊的模式:同义成语替换和“翻译腔改写”。比如原文是“众所周知,这款产品性价比很高”,改写者可能把它换成“大家都清楚,这个东西的性价比非常优秀”,字符级 n-gram 完全对不上,但语义一模一样。这种场景尤其依赖语义候选阶段去抓,而且中文 embedding 模型的选择比英文更敏感——建议用专门的中文模型,而不是依赖多语言模型,后者在中文语义相似度上的表现通常差一个档次。
6.4 规模上去之后,这套最小复现在哪里会塌
最后必须坦白这套最小复现的瓶颈,避免你直接把它用在 PB 级语料上然后回来骂我。第一阶段的内存瓶颈在 gram_to_docs:每个 n-gram 都要存一个文档编号集合,大规模语料下这种结构会膨胀得非常快。工业界会改用 MinHash 或 SimHash 做文档去重,用数据签名替代完整 n-gram 存储;或者用 LSH 做近似最近邻检索。第二阶段的计算瓶颈在 embedding 推理,一旦需要确认的可疑样本数量很大,单机 GPU 都未必够用,这时可以考虑只对候选对的某一方提前缓存向量,减少重复编码。
不过我想说,最小复现的价值恰恰在于把尺度问题暴露出来。当你真的在一个大规模语料上跑通了这套逻辑,才会真正理解为什么需要 MinHash、为什么需要向量数据库——不是因为这些技术时髦,而是因为你亲眼看到了朴素实现墙在哪里。知道墙在哪,比背住一堆工具的用法重要得多。
我在实际项目中最大的体会是:两阶段检测最大的收益往往不是抓出了多少污染样本,而是它逼着我把数据分布彻底看了一遍。覆盖率直方图和语义分数的双峰分布,比任何数据报告都更诚实地反映了一个数据集的真实构成。所以如果你读完这篇博文只记住一件事,我希望能记住这个:不要迷信任何固定阈值,先用一小批标注数据把分布画出来,再决定怎么切。数据会告诉你答案,而不是某个博客上的默认参数。
