☰
RAG 入库去重实测:SimHash 只抓到 58% 的重复,语义去重阈值松到 0.90 让检索命中率掉到 0.57
2026/11/1 1:57:38 网站建设 项目流程

摘要

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-64token 与 bigram 按位投票,汉明距离 ≤3 视为重复
MinHash-128 + LSH16 段 × 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–933301.00000.5420–0.546320–24ms
SimHash-648930–89960–10.9999–1.00000.5774–0.58141100–1119ms
MinHash+LSH5567–55783–200.9979–0.99970.9379–0.9388489–543ms
余弦 0.95500001.00001.0000496–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.95500001.00001.00001.000–1.000
0.903037–30813182–33310.6479–0.66141.00000.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 里有多少个不同源:

是

否

0.95

0.90

9461 条入库文档

字面完全相同?

md5 合并
召回 0.5420-0.5463

Shingle 特征

MinHash-128 + 16x8 LSH
召回 0.9379-0.9388

向量余弦

阈值

召回 1.0000 / 纯度 1.0000

纯度 0.648-0.661
误并 3182-3331 条

去重语料
top10 不同源 9.23-10.00

检索命中率 0.570-0.605

原始语料上,三轮的 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,再决定阈值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询