近重复相似度阈值到底怎么定:我拿 200 组真值已知的中文文本对校准了一遍

0 阅读11分钟

我手上有一批中文文本要判「是不是近乎重复」,用 MinHash 打分,阈值拍在 0.80,结果同一批对隔几天再跑,判成重复的条数会在 84 到 99 之间来回跳。分数抖、判据跟着抖,我分不清是数据真的在边界上,还是我参数没选够。

所以我做了一件更笨但更可控的事:自己造一批「真值事先算得出来」的文本对,把阈值和签名维度当成两个可以拧的旋钮,逐档量误差落在哪里。下面这些读数都来自我这台机器上的一次完整运行,查询日 2026-10-16,脚本原文贴在文中,你可以照着重跑。

先把三个最常被问到的说清楚:相似度阈值到底定多少?为什么不能照抄英文资料给的数?32 维签名够不够?

我先把两个词定住,后面所有读数都按这个口径。近重复指的是两段文本的字符三元组集合重合度达到我设的判据,不指意思相近,也不指抄袭认定。相似度阈值指的是我把估计值卡在哪个数上判「算重复」;它与真值是两个量,真值由三元组集合的真实交集比并集算出,估计值由签名逐位相等的比例算出。

你可能会问这篇的答案成立条件
相似度阈值到底定多少我测到的错判合计最小值在 0.70,不是 0.80仅对我这批 200 对、字符三元组、128 维签名
为什么不能照抄英文资料给的数片段单位不同,误差水平就不同,抄过来的数不是你的数换片段长度或文档长度都要重新量
32 维签名够不够不够。32 维在 0.80 这个判据上,200 对里有 14 对结论会翻我这里的「够」指错判可控,不代表任何效果

我为什么自己造语料,而不是找一批真实文本

用真实文本做这件事,第一个卡住的地方是「真值从哪来」。你没有标准答案,就只能拿另一个估计器当参照,那等于拿一个不准的尺子去校另一把不准的尺子,最后的结论只在两把尺子同时错的时候才「对得上」。

我自己造语料,是为了让真值变成一个可以精确算出来的数,而不是一个可以商量的数。做法是:固定种子 20261016 生成一张 2048 个汉字的字表;每一对文本从同一段 160 字的底稿出发,按比例做逐位替换,替换比例从 0.5% 对数铺到 60%;两边的字符三元组集合取真实交集比并集,得到的数就是这对文本的真值。全文的「相似度」都指这个量,不指语义相似,也不指编辑距离。

200 组对按这个方式生成后,真值散布在 0.0194 到 0.9874 之间,中位数 0.7363,六个区间的分布是 [0,0.1) 15 对、[0.1,0.3) 27 对、[0.3,0.5) 26 对、[0.5,0.7) 27 对、[0.7,0.9) 56 对、[0.9,1.0) 49 对。我不是随手造一堆几乎一样的文本,那样整批真值都挤在 0.9 以上,阈值往哪边挪都看不出差别。

口径放在这里:数据源是我自己写的造语料脚本加 200 组对,项目名就叫 200 对基准,字段是文本对的真实 Jaccard,查询日 2026-10-16,样本量 200 对。局限也很清楚——真值 0.8 以上的对有 105 对,接近一半,所以这篇对「高相似区」的读数比对「低相似区」的可信度更高;0.1 以下的只有 15 对,那一段的误差读数别当真。

200 组对跑完,误差落在哪几个数上

我先只拧一个旋钮:签名维度。片段固定为字符三元组,每个维度配一条独立的哈希盐,估计值取「两边签名逐位相等的比例」。四个维度各跑一遍 200 对,读数如下。

签名维度平均误差偏差上界95 分位偏差
320.05050.27460.1318
640.03420.14170.0896
1280.02520.09870.0588
2560.01730.06250.0440

四档读数的方向很一致:维度翻一倍,平均误差大致降到六成到七成。从 32 维换到 256 维,维度多了 8 倍,平均误差从 0.0505 降到 0.0173,偏差上界从 0.2746 降到 0.0625。真正扎眼的是 32 维那一列的偏差上界:0.2746,意味着有一对文本的真值离判据很远,估计值却能偏出四分之一个身位。

代价我也量了:同一批 200 对乘 2 篇文本,单核跑完的耗时是 32 维 1.17 秒、64 维 2.47 秒、128 维 4.76 秒、256 维 9.60 秒。256 维把平均误差压到 128 维的三分之二,时间正好翻一倍。这几行耗时是同一台机器上的一次读数,换一次再跑会有几成浮动,比值比单次读数本身可信。

到这里可以得到一条能用的结论:如果我没量过这批数据,我不会把维度从 128 降到 32 去换速度。128 维的 95 分位偏差是 0.0588,偏差上界 0.0987,这两个数决定了判据两侧至少要留出 0.1 的余量,才不会被偏差吃掉。

阈值扫一遍:错判分两种,代价不一样

维度定在 128,接着扫阈值。这里有两种错,我不把它们混在一个数里:把真值不到阈值的一对判成重复,是假阳;把真值够阈值的一对漏掉,是假阴。两种错的代价在工程上从来不等价,前者可能让你误删一份原件,后者可能让同一份东西在库里存两遍。

阈值判重复条数其中真非重复(假阳)漏掉的真重复(假阴)错判合计
0.50135415
0.60121235
0.70107314
0.7599538
0.8084358
0.85704711
0.90528513

这张表里我最想指出的是 0.80 那一行。它是很多资料的默认值,也是我最初拍下来的值,而它在这批数据上的错判合计是 8;0.70 那一行是 4,正好少一半。往下走到 0.90,假阳反而涨到 8——阈值越高,被推进「疑似重复」的样本越少,可一旦推进去,里面混着真值并不高的对,比例就上来了。

所以「阈值调高更安全」这句话在这批数据上不成立,它只是把错从假阴挪到了假阳,合计还变多了。

再补一个读数,它跟判据的稳定性有关。128 维不动,只换主种子,0.80 这个判据上判成重复的条数是:种子 20261016 得 84 对、种子 1 得 80 对、种子 999 得 84 对。200 对里晃 4 对,也就是 2%。这个幅度比我预想的小,说明在这批数据上,判据对盐的取值没有那么敏感,真正敏感的是维度和阈值。

签名怎么算才可跨进程核对

上面所有读数都建立在一件事上:签名换个进程重算,结果还得是同一个。我把 128 个维度的盐由同一个主种子派生出来,每个维度得到一条独立的盐,码是这样:

# salts.py -- 每个维度一条独立盐;换进程重算,盐不变
import hashlib, json, random

BASE_SEED = 20261016

def build_salts(n_dim, base_seed=BASE_SEED):
    rng = random.Random(base_seed)
    return [rng.getrandbits(64).to_bytes(8, "big") for _ in range(n_dim)]

def sig(shingles, salts):
    out = []
    bs = [s.encode("utf-8") for s in shingles]
    for salt in salts:
        m = None
        for b in bs:
            v = int.from_bytes(hashlib.blake2b(salt + b, digest_size=8).digest(), "big")
            if m is None or v < m:
                m = v
        out.append(m)
    return out

这里有个细节值得说清楚:我没有让 128 个维度共享同一个基哈希再去异或种子。那种写法更省计算,但短集合上,最小值容易被种子本身的大小决定,而不是被内容决定。我上一轮改掉的就是这个写法。改成每维独立派生之后,同一批 20 对文本上,偏差幅度的上界从 0.0797 降到 0.0312,平均偏差幅度从 0.0237 降到 0.0134。

第二件事是「按用途名派生随机流」。同一段流程里,分块、抽样、签名各需要一份随机数,如果它们共用一条流,任何一处多取一个数,后面所有取数的位置就整体错位。我现在的写法是拿用途名派生,代码如下:

# span.py -- 按用途名派生独立的流,换进程读数不变
import hashlib, random

def rng_for(purpose, seed=20261001):
    h = hashlib.blake2b(purpose.encode(), digest_size=8).digest()
    return random.Random(seed ^ int.from_bytes(h, "big"))

def first3(purpose):
    r = rng_for(purpose)
    return [round(r.random(), 4) for _ in range(3)]

print("chunk ", first3("chunk"))
print("sample", first3("sample"))
print("sign  ", first3("sign"))

当次两个进程的运行输出,环境变量分别是 PYTHONHASHSEED=1 与 PYTHONHASHSEED=2:

chunk  [0.2176, 0.1465, 0.9074]
sample [0.6832, 0.3976, 0.8091]
sign   [0.9864, 0.7688, 0.589]

两个进程逐字一致。作为对照,我把派生函数里的 hashlib.blake2b 换成内置 hash,其余一行不动,同一段代码在两个进程里给出的是两组完全不同的数:进程一 chunk 段是 [0.6366, 0.1441, 0.0911],进程二变成 [0.0526, 0.0543, 0.0934]。差别只有那一行,落盘的分块结果就再也没法回查。

你把我这套读数核一遍的四步

第一步,造语料。按 2048 字表、160 字底稿、替换比例 0.5% 到 60% 对数铺开、200 对、种子 20261016 生成,再逐个算三元组的真实交集比并集。你要核的中间量是六个区间的分布,应当依次落到 15、27、26、27、56、49 对。

第二步,算签名。片段用字符三元组,维度分别取 32、64、128、256 四档各跑一遍,盐用上面那段 build_salts,估计值是两边签名逐位相等的比例。你要核的中间量是四档的平均误差,应当依次落在 0.0505、0.0342、0.0252、0.0173。

第三步,扫阈值。维度固定在 128,阈值从 0.50 走到 0.90 共七档,每一档分别数假阳和假阴。你要核的中间量是 0.70 那行的错判合计 4 与 0.80 那行的 8。

第四步,换种子。维度仍是 128,主种子换成 1 和 999 各跑一遍,数 0.80 判据上判成重复的条数,应当落在 80 到 84 之间。

四步都能对上,说明你我量的是同一个东西;哪一步对不上,差别多半出在片段单位或文本长度上,而不是出在 MinHash 本身。

这篇的边界,以及我没有做的事

先说清楚的边界。其一,样本 200 对,是我自己按替换比例造的,不是从任何语料库抽的,所以这些误差值描述的是「这类构造出的文本」,换真实语料一定会变,我不认为这些数可以照搬到别的数据集上。其二,全篇只测了字符三元组这一种片段单位,二元的、词级的、五元的都没测,换片段单位误差水平会变,这一点我这轮没量。其三,阈值扫出来的 0.70 是这批数据上的最小值,不是通用建议;它只说明在相似度这个旋钮上,先量再定,比抄一个默认值更省事。其四,我给的都是估计器相对真值的偏差,不涉及任何业务系统的采集、存储或发布环节,也不构成对任何工具或平台的选择建议。

再说我没做的。我没有拿两套不同的实现互相对照,全程只有一个自己写的实现,所以这篇量的是「这套参数之间的关系」,不是「哪个库更准」。所有读数取自 2026-10-16 的一次完整运行,同一天连跑两次读数一致;我没有做跨机器、跨版本的复测。按用途派生流那一段,我只验了同一个解释器的两个进程,没验跨语言或跨版本的行为。

最值钱的一条,是我把「阈值」和「维度」分开量之后才看见的:一开始我以为分数抖是哈希不稳定的锅,量下来才发现 128 维本身就带着 0.0252 的平均误差,阈值压在 0.80 时,判据两侧各只剩不到 0.05 的余量,抖是必然的。把余量留出来,或者把维度提上去,都比在阈值上反复试数更管用。

本文部分内容包含 AI 辅助创作,文中代码与数据经人工实测与复核。