摘要
9461 条文档、5000 个源、3 个随机种子:精确哈希只合并了 54.2–54.6% 的重复,SimHash(汉明 ≤3)57.7–58.1%,MinHash-128 加 16×8 分段 LSH 做到 93.8–93.9%,向量余弦 0.95 达到 100% 且零误并。但余弦阈值松到 0.90,纯度掉到 0.648–0.661,3182–3331 条文档被并错,检索命中率从 1.000 掉到 0.570–0.605。去重前三轮 top-10 平均只有 5.89–6.35 个不同源。
一、重复 chunk 是怎么拖垮 RAG 的
知识库做久了都会遇到同一件事:同一个段落以四五种形态躺在库里。官网原文一份、公众号转载一份、PDF 导出时换了换行的一份、运营加了页眉版权声明的一份。切完 chunk 入库,这些变体各自占一个向量位置。
后果不是"多占点存储",而是检索结果被同一条信息填满。你取 top-10 喂给模型,其中 6 条在说同一件事,模型能用的信息只有 4 条;token 花了,覆盖面却窄了。更隐蔽的是评估:离线指标看起来不错,因为"命中了相关文档",但命中的是同一段话的五个副本。
去重因此是入库管线的固定动作。问题是选哪种方法、阈值定在哪。这篇文章把四种常见做法放进同一个合成语料,用同一套真值量它们的召回、误并与成本。
二、实验设置
语料是构造出来的,重复关系在生成时就确定,不需要人工标注:
- 5000 个源段落,每段 60 个 token,从 2000 词的词表按 Zipf 分布抽样,模拟真实文本的长尾词频;
- 一部分源派生 0~3 个变体:完全拷贝、改 5% token、改 20% token、加 10 个固定套话前缀;
- 每条文档配一个 64 维语义向量:先取 300 个话题中心,源向量 = 话题中心 + 0.4×各自偏移,变体再加按改动幅度放大的噪声。这样"同一源的变体"余弦在 0.97 以上,"同话题的不同源"余弦约 0.93——语义去重的两难就藏在这两个数之间;
- 最后得到 9461 条文档、5000 个真实源,重复 4461 条。
四种方法各自用并查集把文档连成簇:
| 方法 | 判定方式 |
|---|---|
| md5 精确哈希 | 文本完全一致 |
| SimHash-64 | token 与 bigram 按位投票,汉明距离 ≤3 视为重复 |
| MinHash-128 + LSH | 16 段 × 8 行分段,同段同桶出候选,估计 Jaccard ≥0.8 接受 |
| 向量余弦 | 阈值 0.95 与 0.90 各跑一遍,全量暴力比对 |
真值给了两个指标:纯度是每个簇里占多数的那个源的比例,召回是每个源里落在同一个簇的比例。另外统计"被并错的文档数"和耗时。3 个种子各跑一轮,下表给区间。环境 Python 3.11.5 + numpy 2.4.6,Windows 下要设PYTHONIOENCODING=utf-8。
需要先说清楚边界:语义向量是合成的,噪声幅度由改动幅度决定,真实 embedding 对改写的稳定性要用你自己的数据量。下面的绝对值不代表任何具体模型,但方法之间的相对关系是稳的。
三、结果一:字面去重抓不住改写
表 1:四种方法的召回、误并与耗时(3 轮区间)
| 方法 | 簇数 | 误并文档 | 纯度 | 召回 | 耗时 |
|---|---|---|---|---|---|
| md5 精确 | 9263–9333 | 0 | 1.0000 | 0.5420–0.5463 | 20–24ms |
| SimHash-64 | 8930–8996 | 0–1 | 0.9999–1.0000 | 0.5774–0.5814 | 1100–1119ms |
| MinHash+LSH | 5567–5578 | 3–20 | 0.9979–0.9997 | 0.9379–0.9388 | 489–543ms |
| 余弦 0.95 | 5000 | 0 | 1.0000 | 1.0000 | 496–625ms |
精确哈希的 54.2–54.6% 不是它做得差,而是它只能碰到"完全一样"的那部分——真值里约一半变体本来就有改动。这个数应该当作 baseline:任何"更聪明的去重"都要跟它比。
SimHash 只比它高 3.5 个百分点(57.7–58.1%),代价是慢 50 倍。原因是汉明距离对 token 级改动的响应是非线性的:改 5% 的 token 会让若干 bigram 特征整体翻转,投票后若干位直接翻面。汉明 ≤3 这个阈值对"整段改写"太紧,放宽又会误并,所以它在"整段拷贝为主"的场景才有价值。
MinHash 是字面方法里的赢家:93.8–93.9% 召回,误并只有 3–20 条。分段 LSH 把候选对从全量两两比对压到了同桶内,耗时 489–543ms,比 SimHash 还快。
四、结果二:语义去重最准,但阈值是把刀
表 2:同一套向量、只改阈值(3 轮区间)
| 阈值 | 簇数 | 误并文档 | 纯度 | 召回 | 检索命中率 |
|---|---|---|---|---|---|
| 0.95 | 5000 | 0 | 1.0000 | 1.0000 | 1.000–1.000 |
| 0.90 | 3037–3081 | 3182–3331 | 0.6479–0.6614 | 1.0000 | 0.570–0.605 |
0.95 那行几乎完美:5000 个簇正好对上 5000 个源,召回 100%,一条都没并错。因为它比"同源的变体"(0.97 以上)低,又比"同话题的不同源"(约 0.93)高,刚好卡在两个分布中间。
阈值挪到 0.90,召回还是 1.0000,看起来更好,但簇数从 5000 掉到 3037–3081,3182–3331 条文档被并进了别人的簇。纯度 0.648–0.661 的意思是:被保留的那些代表文档里,大约三分之一代表的是一个已经混了别的源的大杂烩。
最能说明问题的是检索命中率:去重后的语料上重跑 200 条查询,目标源还在不在 top-10 里——1.000 掉到 0.570–0.605。被误并的源整个从索引里消失了,不是排在后面,是没了。
这就是语义去重的本质:召回和误杀是同一个旋钮。字面方法误并了你至少能查文本,语义方法误并了没有任何痕迹,只能靠阈值的事前校准。
五、结果三:去重对检索的实际收益
同一批 200 条查询,比较去重前后 top-10 里有多少个不同源:
原始语料上,三轮的 top-10 平均只有 5.89–6.35 个不同源——一半的位置被同一源的变体占着。MinHash 去重后升到 9.23–9.34,余弦 0.95 去重后是 10.00,正好填满。
这也解释了为什么"入库去重"的收益比想象中大:它不是省存储,是把 top-k 的有效信息密度从六成提到满。
六、核心代码
MinHash 的分段 LSH 是整条链路里最容易写错的部分:
defdedup_minhash(sigs):dsu=DSU(len(sigs))forbandinrange(LSH_BANDS):lo,hi=band*LSH_ROWS,(band+1)*LSH_ROWS buckets={}fori,sinenumerate(sigs):key=s[lo:hi].tobytes()forjinbuckets.get(key,()):est=float((sigs[j][lo:hi]==s[lo:hi]).sum())/LSH_ROWSifest>=JACCARD_ACCEPT:dsu.union(j,i)buckets.setdefault(key,[]).append(i)returndsu这里故意只拿命中的那一段去估 Jaccard。整段 128 个签名都算一遍更准,但要乘以候选对数;命中段本身就是个无偏估计,够用来做接受判定。
向量去重则是全量比对,作为"语义上限"的参照:
defdedup_vector(vecs,thresh):dsu=DSU(len(vecs))sim=vecs @ vecs.Tforiinrange(len(vecs)):forjinnp.nonzero(sim[i]>=thresh)[0]:ifj>i:dsu.union(int(i),int(j))returndsu九千多条还能这么写,上百万条就得换 ANN 索引;而且阈值 0.90 配 ANN 的近似召回,误并只会更多。
七、避坑指南
- 别只报召回,不报纯度:本实验 0.90 的召回是 1.0000,看起来完美,误并三千多条。
- 阈值要用"困难负样本"校准:拿同话题不同源的那批文档定阈值,别只拿已知重复对调参,那样一定偏松。
- LSH 的段数行数不是随便填:b 段 r 行的阈值约等于
(1/b)^(1/r),16×8 对应 0.79 左右,和你想要的 Jaccard 阈值要对上。 - SimHash 用整段文本当特征会钝:本实验加了 bigram 才到 58%,只留 unigram 会更差。
- 按源去重会丢信息:同源的不同变体可能有不同细节,粗暴去重会连"另一个角度"一起删。
- 去重后要重算索引统计:簇数变了,平均文档长度、IDF 都变,评估口径不重算就不可比。
- 余弦别用未归一化的向量:范数差异会被当成相似度。
八、结论
在这套语料上,字面方法的天花板在 54%–94% 之间,具体取决于你能不能接受 LSH 的工程复杂度;语义方法的召回能到 100%,但它把"误杀"藏进了阈值里。先拿精确哈希报一个 baseline,再决定要不要为剩下的 40% 上 MinHash 或向量去重;语义去重的阈值必须用同话题负样本校准,并且上线前要量它把检索命中率打下去多少。
实验脚本:demos/near_dup_dedup_lab.py,输出留档:demos/near_dup_dedup_lab.transcript.txt。换语料时建议先跑一遍精确哈希看 baseline,再决定阈值。