我手上有一批中文文本要判「是不是近乎重复」,用 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 分位偏差 |
|---|---|---|---|
| 32 | 0.0505 | 0.2746 | 0.1318 |
| 64 | 0.0342 | 0.1417 | 0.0896 |
| 128 | 0.0252 | 0.0987 | 0.0588 |
| 256 | 0.0173 | 0.0625 | 0.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.50 | 135 | 4 | 1 | 5 |
| 0.60 | 121 | 2 | 3 | 5 |
| 0.70 | 107 | 3 | 1 | 4 |
| 0.75 | 99 | 5 | 3 | 8 |
| 0.80 | 84 | 3 | 5 | 8 |
| 0.85 | 70 | 4 | 7 | 11 |
| 0.90 | 52 | 8 | 5 | 13 |
这张表里我最想指出的是 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 辅助创作,文中代码与数据经人工实测与复核。