LLM 的大规模文本去重技术

Liao Jiayi Liao Jiayi

LLM document 的去重在大规模场景要做好还是比较复杂。

两种思路,粗筛走 MinHashLSH,精筛走 Embedding 去重。

MinHashLSH 路线

朴素思想

  1. 直接对每个 doc 做 n-gram (然后把一个 doc 看作是多个 n-gram 的集合)
  2. 定义 K 个 Hash function,对每个 n-gram 做 hash,拿到 doc 到 hash 的集合
  3. 每个 hash function 对所有 n-gram 做 hash,取最小值,得到一个 length=k 的 minhash 集合
  4. LSH 分桶,k 维切成 b 个 band,每个 band 整体 hash 一次,对应一个 band_hash
  5. 按 band_idx 和 band_hash 进行 groupBy,相同的 band_hash 表示两个 doc 至少在一个 band 里相似,加入比较的 candidates,没有相同 band_hash 的不需要再处理了
  6. 对相同 band_hash 的 doc 做一个连通图处理,假设边是 (u,v),接下来通过多轮的连通处理,首先定一批节点,每个节点负责一批 doc_id,每一轮:
    1. (u,v) 的边发送给 u 和 v 的两个节点
    2. 每个节点内会产生新的连通,即新的 (u,v),同时每个节点的数据不断累积,doc_id 对应的簇(doc_id 集合)会越来越大
  7. 连通多轮之后(一般 2-3 轮就够了),做决策,哪些 doc_id 要留下
  8. 遍历原始数据,留下不重复的 doc_id

用 codex 做了一个流程图:

这里要注意一个点:最后每个 doc_id 等价于多个 band_hash 值的集合,所以判断两者 doc_id 重复是通过 Jaccard 相似度来判断。而通常为了计算性能考虑,不一定会把一个 doc_id 的所有连通图都打通来计算完整的 Jaccard,比如 BigCode(https://huggingface.co/blog/zh/dedup) 里只要有一个 band_hash 相同就认为这是重复的。所以这里会引入一个概率关系:

真实 Jaccard 被判为候选重复的概率
0.50 2.4%
0.60 14%
0.70 51%
0.80 94%
0.85 99.6%

Spark

Spark 是最简单直接,也是早期最多人用的方法。基于 (band_idx, band_hash) 进行 groupBy,一样的值放在一起比较,阿里云的 Serverless Spark 实现参考 https://help.aliyun.com/zh/emr/emr-serverless-spark/use-cases/minhash-lsh-based-large-scale-text-duplication-scheme,但是这个连通图涉及到数据 shuffle,不建议。

NeMo Curator(NVIDIA 开源,基于 Ray)

ref: https://docs.nvidia.com/nemo/curator/curate-text/process-data/deduplication/fuzzy

  • 本质也是 follow 朴素思想,但是换成了 GPU 库,LSH 的时候每次只处理了 5 个 band(中间结果落 parquet)
  • minhash 换成了 cuDF,连通图换成了 cuGraph 的多 GPU 弱连通算法(同时对重复的校验没有那么严格)

Data-Juicer 实现(Ray 对朴素思想的实现 + 热点优化)

ref: https://github.com/datajuicer/data-juicer/blob/main/data_juicer/ops/deduplicator/ray_bts_minhash_deduplicator.py#L689

  1. 遍历数据,算 band hash 然后先每个 actor 内自行合并一次,得到一个单机的 2 级连通图,让所有 doc_id 指向最小的 doc_id
  2. 按 doc_id 做一次 reshuffle,每个 actor 有自己的一批 doc_id,每个 edge 发送到两个 actor
  3. 每个 actor 经过多轮 reshuffle 后,会有一个 doc_id->簇 的映射,此时决定哪些是重复值

这种 case 对于 doc_id 簇过大的情况怎么办(比如 Licence Text):有一个 proxy 的思路(rebalance),大致思想是不要把完整簇放到一个节点里,每个节点放一个属于自己的小簇,再把每个小簇的根节点作为 proxy 连到真正的根节点,同时每次真正根节点的变化可能带来 actor 的通信。

SimHash

以文本为例:

  1. 对文本分词或提取特征。
  2. 给每个特征计算普通哈希。
  3. 根据词频、TF-IDF 等给特征赋权重。
  4. 将各特征的哈希按位加权投票。
  5. 每一位的总值大于 0 记为 1,否则记为 0,得到固定长度的 SimHash。

但是 simhash 这里有个问题,因为 bit 数比较少,很容易受到一些关键词的影响(比如代码里的 Python、 README 等),所以当前大模型文本里用 MinHashLSH 还是要多一些。

Embedding 路线

Kmeans

比较容易的是复用 Faiss 的方法(同时也支持 GPU),Faiss 里的 Kmeans 原理如下:

  1. 抽样数据,训一个 Kmeans Cluster,得到 k 个 cluster centroids
  2. 遍历旧数据,算 cluster,分发,在每一个 centroids 里做 embedding 去重(相当于是一个矩阵乘法)
  3. 如果 k 个 centroids 还是比较大,继续做 k 个 cluster(两次肯定是够了)

Embedding Similarity

另一个就是构建分布式的 Embedding Service,通过建索引的方式,常见索引有两类:

1. HNSW:类似跳表数组

  • 思路:Embedding 写入时,动态构建 Graph,与此同时根据写入的 emb 数量,会有多层的子 subgraph 结构,产生多个 Layer,每个 level=n+1 的 subgraph 可以认为是 level=n 这层 graph 的若干个中心点的连接
  • 特点:写入慢(建图),查询快(跳表结构直接快速定位);

2. IVF:倒排索引的思路

  • 思路:Embedding 写入时,会抽样跑 kmeans cluster,训出若干个 centroids,然后对于每个 cluster 有一批 embedding 数据,类似一个倒排索引的样子,然后查询时直接定位到 cluster 再从这个倒排里算 similarity
  • 特点:写入快(一旦训完 kmeans 就是一个追加写),查询慢(遍历倒排)且有热点问题

IVF 有很多变种,上面讲的是最常见的 IVF-FLAT,可以想象,如果单 cluster 数量过多,带来的查询时间会线性增长。另一种是 IVF-PQ,基本思想是在 IVF-FLAT 基础上,每个 cluster 的倒排数组里,对于每个 embedding,将 m-dim 的 embedding 切成 n 段,每一段单独做 kmeans cluster,然后得到一个固定大小 K 的 codebook,每个 embedding 可以当做是对 K 个 codebook 的距离(length=n)。

  • IVF-PQ = IVF-Flat + 每个 cluster 里存储一个 PQ 码本(这样大大减小了 similarity 计算量)

IVF-PQ 的查询很快(多级索引),但是同时也丢失了 embedding 原值,此时要在磁盘上维护一个 vector_id -> embedding 的映射(用 memmap 来读,性能也很快)。

实测

今天用 FAISS 单机 DB 测了一下,14M 个 document embedding(384d),

  • IVF-FLAT: 训 kmeans 1min,写入 5min 左右(50k/s),查询用 nprobe=4 的话 p99 4ms;
  • HNSW:写入 52min,查询是 0.7ms;

场景:

  • 如果是一次性离线去重,和 IVF 一样的思路,聚类后在聚类内部两两比较就可以了;
  • 如果是不断有新数据进来,可以依赖 Embedding Cluster 来完成去重;

补充

datasketch

一个很有意思的开源项目,支持了各种去重能力以及外挂 embedding DB 的能力,适合单机玩玩,数据量不大的话这个就够了。