Semantic Deduplication for LLM Pretraining
Beginner Introduction
Imagine studying for an exam with a massive textbook where 30% of the content is the same paragraph copied with tiny variations (different city names, different product names, but identical structure). You’d waste hours re-reading the same ideas instead of learning new things. That’s exactly the problem with web-scale training data for LLMs.
When we scrape the internet to build training corpora (CommonCrawl, C4, LAION), we get billions of documents. Many are near-duplicates: not identical copies (those are easy to catch with hashing), but semantically equivalent content:
- Thousands of hotel booking pages from the same template, differing only by hotel name
- Product listings that differ only by product name/price
- News articles syndicated across hundreds of outlets with minor rewording
Traditional “exact” deduplication (MinHash/LSH) catches documents that share large chunks of identical text but completely misses semantic duplicates — documents that say the same thing in slightly different words or structures.
Semantic deduplication uses neural network embeddings to catch these. The core idea:
- Turn every document into a dense vector (embedding) using a pretrained model
- Documents with very similar embeddings are semantically similar
- Remove or downweight the redundant ones
The payoff is enormous: you can often remove 30-50% of your data with no performance loss — or even better performance — halving your training cost.
The Four Key Papers
| Paper | Year | Key Idea |
|---|---|---|
| SemDeDup | 2023 | The foundational method: cluster embeddings, remove near-duplicates within clusters |
| D4 | 2023 | Extends SemDeDup by adding a “diversification” step after deduplication |
| SoftDedup | 2024 | Replaces hard removal with soft reweighting using cheap n-gram models |
| FineWeb | 2024 | Large-scale practical report from HuggingFace on dedup at 15T+ token scale |
Paper 1: SemDeDup — The Foundation
Full Title: SemDeDup: Data-efficient learning at web-scale through semantic deduplication Authors: Amro Abbas, Kushal Tirumala, Daniel Simig, Surya Ganguli, Ari S. Morcos (Meta AI / Stanford) arXiv: 2303.09540 | Code: github.com/facebookresearch/SemDeDup
Abstract
Progress in machine learning has been driven in large part by massive increases in data. However, large web-scale datasets such as LAION are largely uncurated beyond searches for exact duplicates, potentially leaving much redundancy. Here, we introduce SemDeDup, a method which leverages embeddings from pre-trained models to identify and remove semantic duplicates: data pairs which are semantically similar, but not exactly identical. Removing semantic duplicates preserves performance and speeds up learning. Analyzing a subset of LAION, we show that SemDeDup can remove 50% of the data with minimal performance loss, effectively halving training time. Moreover, performance increases out of distribution. Also, analyzing language models trained on C4, a partially curated dataset, we show that SemDeDup improves over prior approaches while providing efficiency gains.
The Algorithm
+-------------------------------------------------------------+
| SemDeDup Pipeline |
| |
| 1. Embed all documents with pretrained model (CLIP/OPT) |
| 2. K-Means cluster the embeddings (K=50,000) |
| 3. Sort each cluster by distance to centroid |
| 4. Within each cluster, compute pairwise cosine sim |
| 5. Flag points where max_sim > (1 - eps) for removal |
| 6. Keep the "harder" (far-from-centroid) example |
+-------------------------------------------------------------+
Step-by-Step Implementation
Step 1: Compute Embeddings
import numpy as np
# For vision: use CLIP embeddings (512-dim)
# For text: use OPT/sentence-transformer embeddings
# Store as memory-mapped numpy array for efficiency
embeddings = np.memmap(
"embeddings.mmap",
dtype="float32",
mode="w+",
shape=(dataset_size, 512)
)
# Fill with your model's embeddings...Step 2: K-Means Clustering with FAISS
import faiss
# SemDeDup uses spherical K-Means (cosine distance)
d = 512 # embedding dimension
K = 50000 # number of clusters
kmeans = faiss.Kmeans(
d, K,
niter=100,
verbose=True,
gpu=True, # use GPU acceleration
spherical=True, # cosine distance (normalize to unit sphere)
seed=1234
)
kmeans.train(embeddings)
# Assign every point to its nearest cluster
distances, cluster_assignments = kmeans.index.search(embeddings, 1)Step 3: Sort Clusters by Distance to Centroid
# For each cluster, sort documents by distance to centroid
# "keep_hard" = keep examples FARTHEST from centroid (default, best)
for cluster_id in range(K):
mask = cluster_assignments.flatten() == cluster_id
cluster_indices = np.where(mask)[0]
cluster_distances = distances[mask].flatten()
# Sort DESCENDING: hardest (farthest) examples first
sorted_order = np.argsort(-cluster_distances)
sorted_cluster = cluster_indices[sorted_order]
np.save(f"sorted_clusters/cluster_{cluster_id}.npy", sorted_cluster)Step 4 & 5: Identify Semantic Duplicates (Core Logic)
import torch
def semdedup_cluster(embeddings_cluster, eps_list=[0.06, 0.07]):
"""
Find semantic duplicates within a single cluster.
Args:
embeddings_cluster: (N, D) tensor of embeddings for this cluster
eps_list: list of epsilon thresholds to evaluate
Returns:
dict mapping eps -> boolean mask (True = duplicate to remove)
"""
# Compute full pairwise cosine similarity matrix
# (embeddings should be L2-normalized for this to be cosine sim)
sim_matrix = embeddings_cluster @ embeddings_cluster.T # (N, N)
sim_matrix.fill_diagonal_(0.0) # ignore self-similarity
# Take UPPER TRIANGULAR only
# This ensures we only compare each pair once, and
# because the cluster is sorted (hard->easy), the "later"
# (easier/more redundant) example gets flagged
triu = torch.triu(sim_matrix, diagonal=1)
# For each point: what's the max similarity to any EARLIER point?
M = torch.max(triu, dim=0)[0] # shape: (N,)
# Flag duplicates at each epsilon threshold
results = {}
for eps in eps_list:
# If max_sim > (1 - eps), this point is a semantic duplicate
results[eps] = (M > 1 - eps)
return results
# Example usage
cluster_embs = torch.tensor(embeddings[sorted_cluster_indices])
cluster_embs = cluster_embs / cluster_embs.norm(dim=1, keepdim=True) # L2 normalize
duplicate_flags = semdedup_cluster(cluster_embs, eps_list=[0.03, 0.06, 0.07, 0.1])Epsilon Controls How Much Gets Removed
| eps | ~Data Retained | Aggressiveness |
|---|---|---|
| 0.001 | ~80% | Very conservative (only exact semantic dupes) |
| 0.03 | ~72% | Light dedup |
| 0.07 | ~63% | Moderate |
| 0.124 | ~50% | Aggressive — sweet spot for LAION |
| 0.26 | ~20% | Very aggressive |
Key Hyperparameters
| Hyperparameter | Value / Choice | Notes |
|---|---|---|
| Embedding model | OA-CLIP (ViT-B/32 or B/16) | Pre-trained, frozen; used only for computing embeddings |
| Embedding dimension | 512 | Standard CLIP embedding size |
| Number of K-Means clusters | 50,000 | Balances granularity vs. compute; clustering is done with FAISS |
| K-Means iterations | 100 | Standard convergence setting |
| K-Means distance | Cosine (spherical K-Means) | Kmeans_with_cos_dist: True — normalizes to unit sphere |
| Similarity metric for dedup | Cosine similarity | Computed as dot product of normalized embeddings |
| Epsilon (eps) | Varies: 0.001 to 0.26 | Lower = more aggressive pruning; ~0.06-0.07 for 50% retention |
| Which example to keep | ”hard” (default) | Keep the example farthest from cluster centroid; alternatives: “easy”, “random” |
| Largest cluster size | 10,000,000 | Clusters larger than this are split into sub-chunks for memory management |
| Data storage | numpy memmap (float32) | Enables efficient I/O for web-scale datasets |
| Parallelization | SLURM + submitit | Designed for multi-node HPC clusters |
Key Results
- LAION (vision): Remove 50% of data → minimal ImageNet accuracy loss, improved OOD performance
- C4 (text): Consistent efficiency gains on 125M and 1.3B models
- Which to keep: “Hard” examples (farthest from centroid) > “Random” > “Easy”
Why It Works
The key insight is that keeping hard examples preserves diversity. Examples near cluster centroids tend to be prototypical/redundant (the “average” of their cluster). Examples far from centroids are unique, edge-case content that carries more information per token.
Critical Design Choices
- Clustering before pairwise comparison: Without clustering, computing all-pairs similarity for hundreds of millions of examples is infeasible (O(n^2)). K-Means reduces the problem to within-cluster comparisons only.
- Keeping “hard” examples: Farthest from centroid = atypical = more unique information. Validated experimentally, especially for OOD performance.
- Upper triangular matrix trick: By sorting examples by difficulty and using the upper triangular portion, the algorithm deterministically removes later (easier/more redundant) examples in favor of earlier (harder) ones.
- Pre-trained embeddings, not task-specific: Uses general-purpose pretrained model (CLIP), making it task-agnostic.
- Complementary to exact dedup: Designed to run after exact deduplication. Catches “soft” duplicates that hash-based methods miss.
Paper 2: D4 — Adding Diversification
Full Title: D4: Improving LLM Pretraining via Document De-Duplication and Diversification Authors: Kushal Tirumala*, Daniel Simig*, Armen Aghajanyan, Ari S. Morcos (Meta AI) arXiv: 2308.12284 | No public code (but uses SemDeDup codebase)
Abstract
Over recent years, an increasing amount of compute and data has been poured into training large language models (LLMs), usually by doing one-pass learning on as many tokens as possible randomly selected from large-scale web corpora. While training on ever-larger portions of the internet leads to consistent performance improvements, the size of these improvements diminishes with scale, and there has been little work exploring the effect of data selection on pre-training and downstream performance beyond simple de-duplication methods such as MinHash. Here, we show that careful data selection (on top of de-duplicated data) via pre-trained model embeddings can speed up training (20% efficiency gains) and improves average downstream accuracy on 16 NLP tasks (up to 2%) at the 6.7B model scale. Furthermore, we show that repeating data intelligently consistently outperforms baseline training (while repeating random data performs worse than baseline training).
The Core Insight
SemDeDup removes duplicates but doesn’t actively diversify. D4 asks: after cleaning up duplicates, can we additionally select the most diverse subset?
The answer is yes, using a two-stage pipeline:
+--------------------------------------------------------------+
| D4 = SemDeDup -> Re-Cluster -> SSL Prototypes |
| |
| Stage 1: SemDeDup (dedup) |
| - Cluster -> remove near-duplicates within clusters |
| - This removes "duplicate-driven clusters" |
| |
| * CRITICAL: Re-cluster the cleaned data * |
| - SemDeDup cleaned up the corrupt clusters |
| - Re-clustering now gives MUCH better topic clusters |
| |
| Stage 2: SSL Prototypes (diversify) |
| - Within each new cluster, REMOVE points closest to |
| centroid (most prototypical/redundant) |
| - KEEP points farthest from centroid (most diverse) |
+--------------------------------------------------------------+
What Are “Duplicate-Driven Clusters”?
The authors embedded web documents, clustered them, and manually inspected the resulting clusters. They discovered extremely dense clusters filled with templated/near-duplicate content that MinHash failed to remove:
- Nike shoe advertisements auto-generated from a template with minor modifications
- USGS topographic map pages with identical boilerplate
- Hotel booking site pages with identical structure but different hotel names
- University info pages with templated text
These corrupt k-means clustering by wasting cluster assignments on redundant text rather than topically coherent groupings. This is the key motivation for D4.
Complete Implementation
import faiss
import numpy as np
import torch
def d4_pipeline(
embeddings, # (N, D) numpy array, L2-normalized
R_dedup=0.75, # SemDeDup keeps 75% of data
R_proto=0.33, # SSL Prototypes keeps 33% of remaining
K=11000, # number of K-Means clusters
semdedup_eps=0.07, # SemDeDup epsilon threshold
):
"""
Full D4 pipeline: Deduplication + Diversification
Overall selection ratio: R = R_dedup * R_proto ~ 0.25 (keep 25%)
"""
N, D = embeddings.shape
# ============ STAGE 1: SemDeDup ============
print("Stage 1: Initial clustering for SemDeDup...")
kmeans1 = faiss.Kmeans(D, K, niter=20, spherical=True, verbose=True)
# Subsample for centroid calculation if dataset is huge
sample_size = min(N, 100_000_000)
sample_idx = np.random.choice(N, sample_size, replace=False)
kmeans1.train(embeddings[sample_idx])
# Assign all points to clusters
distances1, assignments1 = kmeans1.index.search(embeddings, 1)
assignments1 = assignments1.flatten()
distances1 = distances1.flatten()
# Run SemDeDup within each cluster
keep_mask = np.ones(N, dtype=bool)
for cluster_id in range(K):
cluster_idx = np.where(assignments1 == cluster_id)[0]
if len(cluster_idx) <= 1:
continue
# Sort by distance to centroid (descending = hard first)
cluster_dists = distances1[cluster_idx]
sort_order = np.argsort(-cluster_dists)
cluster_idx_sorted = cluster_idx[sort_order]
# Get embeddings for this cluster
cluster_embs = torch.tensor(embeddings[cluster_idx_sorted])
# Pairwise cosine similarity (upper triangular)
sim = cluster_embs @ cluster_embs.T
sim.fill_diagonal_(0.0)
triu = torch.triu(sim, diagonal=1)
M = torch.max(triu, dim=0)[0]
# Flag duplicates
duplicates = (M > 1 - semdedup_eps).numpy()
keep_mask[cluster_idx_sorted[duplicates]] = False
# Surviving indices after SemDeDup
deduped_idx = np.where(keep_mask)[0]
print(f"SemDeDup: {N} -> {len(deduped_idx)} ({len(deduped_idx)/N:.1%} kept)")
# ============ CRITICAL: RE-CLUSTER ============
print("Re-clustering cleaned data...")
embeddings_clean = embeddings[deduped_idx]
kmeans2 = faiss.Kmeans(D, K, niter=20, spherical=True, verbose=True)
sample_size2 = min(len(deduped_idx), 100_000_000)
sample_idx2 = np.random.choice(len(deduped_idx), sample_size2, replace=False)
kmeans2.train(embeddings_clean[sample_idx2])
distances2, assignments2 = kmeans2.index.search(embeddings_clean, 1)
assignments2 = assignments2.flatten()
distances2 = distances2.flatten()
# ============ STAGE 2: SSL Prototypes ============
print("Stage 2: SSL Prototypes (diversification)...")
final_idx = []
for cluster_id in range(K):
cluster_mask = assignments2 == cluster_id
cluster_positions = np.where(cluster_mask)[0]
if len(cluster_positions) == 0:
continue
# Sort by distance to centroid: KEEP the FARTHEST (most diverse)
cluster_dists = distances2[cluster_positions]
sort_order = np.argsort(-cluster_dists) # descending
# Keep top R_proto fraction (the furthest from centroid)
n_keep = max(1, int(len(cluster_positions) * R_proto))
kept = cluster_positions[sort_order[:n_keep]]
# Map back to original dataset indices
final_idx.extend(deduped_idx[kept].tolist())
final_idx = np.array(final_idx)
print(f"D4 Final: {N} -> {len(final_idx)} ({len(final_idx)/N:.1%} kept)")
return final_idxWhy the Re-Clustering Step Matters
This is D4’s key contribution. Without re-clustering:
- The initial clusters are corrupted by duplicate-driven clusters (thousands of templated pages in dense clusters)
- SSL Prototypes would operate on these corrupt clusters and make poor decisions
- With re-clustering after SemDeDup, the clusters become topically coherent
Embedding Choice Surprise
D4 uses a tiny OPT-125M model for embeddings (last-token, last-layer, 768-dim). Surprisingly:
- At small training scales (125M), SentenceTransformers outperform OPT embeddings
- At large training scales (6.7B), OPT embeddings outperform SentenceTransformers
- Reason: SentenceTransformers (max context 256-384 tokens) disproportionately prune long documents due to truncation
Key Results
6.7B OPT model, 100B tokens, R = 0.25:
- ~20% efficiency gains on validation perplexity (reaches same PPL 18-22% faster)
- +2% improvement in average 0-shot downstream accuracy across 16 NLP tasks
- Efficiency gains increase with model scale (projected ~22% for 175B models)
Landmark finding — smart data repetition beats single-epoch training:
| Strategy | Total Tokens | Selected Tokens | Epochs | Non-Web PPL | Instruct PPL |
|---|---|---|---|---|---|
| Random (baseline) | 40B | 40B | 1 | 16.27 +/- 0.012 | 14.19 +/- 0.003 |
| Random (repeated) | 40B | 20B | 2 | 16.39 (+0.12) | 14.37 (+0.18) |
| D4 (repeated) | 40B | 20B | 2 | 16.10 (-0.17) | 13.85 (-0.34) |
Randomly repeating data hurts; D4-selected repeated data beats fresh random data.
Key Hyperparameters
| Parameter | Value | Rationale |
|---|---|---|
| Embedding model | OPT 125M (last-token, last-layer, 768-dim) | Outperforms SentenceTransformers at large training scales |
| Number of clusters K | 11,000 | ~sqrt(100M sampled docs); robust to change (1K-1M tested) |
| K-Means iterations | 20 | Standard |
| R_dedup | 0.75 | Highest SemDeDup ratio that still improves PPL |
| R_total = R_dedup x R_proto | 0.25 (best) | Select 25% from 4x oversized source |
| Model scales tested | 8M, 125M, 1.3B, 6.7B OPT | Efficiency gains increase with scale |
Conceptual Framework
- SemDeDup = local deduplication (removes near-duplicates within clusters)
- SSL Prototypes = global diversification (keeps diverse outliers, removes prototypical/redundant points)
- D4 = SemDeDup → re-cluster → SSL Prototypes = local dedup + global diversification
The name “D4” stands for Document De-Duplication and Diversification.
Paper 3: SoftDedup — A Radically Different Approach
Full Title: SoftDedup: an Efficient Data Reweighting Method for Speeding Up Language Model Pre-training Authors: Nan He, Weichen Xiong, Hanwen Liu, Yi Liao, Lei Ding, Kai Zhang, Guohua Tang, Xiao Han, Wei Yang (Tencent AI Lab) arXiv: 2407.06654 | No public code
Abstract
The effectiveness of large language models (LLMs) is often hindered by duplicated data in their extensive pre-training datasets. Current approaches primarily focus on detecting and removing duplicates, which risks the loss of valuable information and neglects the varying degrees of duplication. To address this, we propose a soft deduplication method that maintains dataset integrity while selectively reducing the sampling weight of data with high commonness. Central to our approach is the concept of “data commonness”, a metric we introduce to quantify the degree of duplication by measuring the occurrence probabilities of samples using an n-gram model. Empirical analysis shows that this method significantly improves training efficiency, achieving comparable perplexity scores with at least a 26% reduction in required training steps. Additionally, it enhances average few-shot downstream accuracy by 1.77% when trained for an equivalent duration.
The Key Insight: Don’t Delete — Reweight
SemDeDup and D4 are hard deduplication — they permanently remove data. SoftDedup argues this is suboptimal:
- Information loss: deleted documents might contain unique information
- Threshold sensitivity: picking the right eps is arbitrary
- Binary thinking: duplication is a spectrum, not binary
Instead, SoftDedup reweights training samples: common/duplicated content gets lower sampling probability, rare/unique content gets higher probability. Nothing is deleted.
The “Data Commonness” Metric
The genius is using a cheap n-gram model instead of neural embeddings:
For each document x with tokens w_1, ..., w_N:
commonness(x) = geometric_mean(P(w_i | w_{i-3}, w_{i-2}, w_{i-1}))
= exp(sum(log P(w_i | context)) / N)
High commonness = the document is made of common n-gram patterns
Low commonness = the document contains rare/unique text
Formal Framework
Hard deduplication uses a binary indicator I(x) in {0, 1}:
L = sum_{x in D} I(x) * log P(x|theta)
Soft deduplication replaces this with a continuous weight W(x) in (0, 1):
L = sum_{x in D} W(x) * log P(x|theta)
where W(x) is proportional to 1/p(x) (inverse of commonness)
Approximate Sampling at Scale
Assigning individual weights to billions of samples is impractical. The solution:
- Sort all M samples by ascending commonness
- Divide into K segments based on K quantiles
- Assign a single weight to each segment:
W_k = C * (1/p_k)^T
Where:
- T is a temperature hyperparameter controlling weight disparity
- C is a normalization constant ensuring all weights sum to 1
Complete Implementation
import subprocess
import math
import numpy as np
from tqdm import tqdm
# ==========================================
# Step 1: Train n-gram model with KenLM
# ==========================================
def train_ngram_model(corpus_file, output_model, order=4):
"""Train a KenLM n-gram model with Kneser-Ney smoothing."""
subprocess.run([
"lmplz", # KenLM training binary
"-o", str(order), # 4-gram
"--prune", "0", "0", "0", "1", # pruning thresholds
"--text", corpus_file,
"--arpa", output_model
], check=True)
# Convert to binary for faster querying
binary_model = output_model.replace(".arpa", ".binary")
subprocess.run([
"build_binary", output_model, binary_model
], check=True)
return binary_model
# ==========================================
# Step 2: Score each document's commonness
# ==========================================
import kenlm
def compute_commonness(model_path, documents):
"""
Compute data commonness for each document.
Returns array of commonness scores (geometric mean of per-token probs).
"""
model = kenlm.Model(model_path)
scores = []
for doc in tqdm(documents, desc="Scoring commonness"):
# KenLM's score() returns log10 probability
log10_prob = model.score(doc, bos=True, eos=True)
num_tokens = len(doc.split()) + 1 # +1 for </s>
# Geometric mean = 10^(log10_prob / N)
commonness = 10 ** (log10_prob / num_tokens)
scores.append(commonness)
return np.array(scores)
# ==========================================
# Step 3: Partition and assign weights
# ==========================================
def softdedup_weights(commonness_scores, K=20, disparity=10.0):
"""
Assign sampling weights based on commonness.
Args:
commonness_scores: array of commonness values per document
K: number of quantile partitions
disparity: max/min weight ratio (10 = 10x difference)
Returns:
weights: per-document sampling weights (sum to 1)
"""
N = len(commonness_scores)
# Sort by commonness (ascending = rarest first)
sorted_idx = np.argsort(commonness_scores)
# Divide into K equal segments
segment_size = N // K
segment_ids = np.zeros(N, dtype=int)
for k in range(K):
start = k * segment_size
end = (k + 1) * segment_size if k < K - 1 else N
segment_ids[sorted_idx[start:end]] = k
# Get the median commonness of each segment
segment_commonness = np.zeros(K)
for k in range(K):
mask = segment_ids == k
segment_commonness[k] = np.median(commonness_scores[mask])
# Compute temperature T to achieve desired disparity
p_min = segment_commonness[0] # rarest segment
p_max = segment_commonness[-1] # most common segment
T = math.log(disparity) / math.log(p_max / p_min)
# Compute raw weights per segment: W_k = (1/p_k)^T
raw_weights = np.array([(1.0 / p) ** T for p in segment_commonness])
# Normalize
raw_weights /= raw_weights.sum()
# Map segment weights back to per-document weights
doc_weights = raw_weights[segment_ids]
doc_weights /= doc_weights.sum()
return doc_weightsKey Results (1.3B LLaMA-style model, 40B tokens)
SoftDedup vs. HardDedup (MinHashLSH) on RedPajama CC:
| Task | Baseline | HardDedup | Delta | SoftDedup | Delta |
|---|---|---|---|---|---|
| NQ Open (1-shot) | 4.13 | 4.60 | +0.47 | 5.37 | +1.24 |
| SQuADv2 (1-shot) | 11.51 | 12.95 | +1.44 | 14.66 | +3.15 |
| Trivia QA (1-shot) | 15.89 | 17.71 | +1.82 | 16.39 | +0.50 |
| WebQuestions (1-shot) | 3.30 | 5.71 | +2.41 | 3.40 | +0.10 |
| LAMBADA openai (1-shot) | 46.07 | 43.64 | -2.43 | 48.52 | +2.45 |
| LAMBADA standard (1-shot) | 36.91 | 37.65 | +0.74 | 40.89 | +3.98 |
| PIQA (1-shot) | 65.34 | 66.00 | +0.66 | 66.70 | +1.36 |
| Social IQa (1-shot) | 88.00 | 87.90 | -0.10 | 89.60 | +1.60 |
| WinoGrande (1-shot) | 52.88 | 53.99 | +1.11 | 54.38 | +1.50 |
| HellaSwag (1-shot) | 34.93 | 35.54 | +0.61 | 36.51 | +1.58 |
| ARC easy (2-shot) | 57.24 | 57.79 | +0.55 | 59.89 | +2.65 |
| ARC challenge (2-shot) | 25.17 | 25.09 | -0.08 | 26.28 | +1.11 |
| Average | 36.78 | 37.38 | +0.60 | 38.55 | +1.77 |
SoftDedup improves on all 12 tasks vs. baseline (avg +1.77%), while HardDedup only improves on 9/12 (avg +0.60%). SoftDedup is 3x more effective than hard deduplication.
The killer feature: SoftDedup works even on already-deduplicated data. On Falcon RefinedWeb (the most aggressively deduplicated public dataset), SoftDedup still provides 26-39% training step savings.
Cost: Nearly Free
| Component | Cost |
|---|---|
| N-gram training (40B tokens) | 4 CPU cores x 5 hours |
| Commonness scoring | 4 CPU cores x 2 hours |
| Total | ~28 CPU-hours |
| Compare: GPU training budget | >=930 V100 GPU-hours |
Key Hyperparameters
| Parameter | Value/Choice | Rationale |
|---|---|---|
| N-gram order (n) | 4 | Empirically chosen |
| Smoothing | Kneser-Ney | Standard choice for handling sparse n-gram counts |
| Toolkit | KenLM | Efficient, CPU-based n-gram LM training |
| Number of partitions (K) | 20 (default), tested 10/20/50/100 | More partitions → better downstream accuracy; diminishing returns |
| Temperature (T) | Set for 10-fold max/min weight ratio | 10-fold disparity consistently best across all metrics |
| Commonness metric | Geometric mean of n-gram probs | Eliminates length bias |
| Tokenizer | Same as pre-training LLM (LLaMA) | Ensures consistency |
SemDeDup vs SoftDedup
| Dimension | SemDeDup | SoftDedup |
|---|---|---|
| Approach | Hard removal (binary keep/discard) | Soft reweighting (continuous weights) |
| Similarity metric | Embedding-based cosine similarity | N-gram occurrence probability |
| Requires | Pre-trained embedding model + GPU | Only CPU-based n-gram model |
| Data loss | Yes — removes samples entirely | No — all samples retained |
| Threshold | Requires tuning a similarity cutoff | No threshold needed (continuous) |
| Computational cost | High (embedding all data, clustering, pairwise sim) | Very low (n-gram training + scoring on CPU) |
| Captures | Semantic/meaning-level duplication | Surface-level / lexical redundancy |
| Complementarity | Not designed to stack with other methods | Explicitly designed as a complement |
Paper 4: FineWeb — Dedup at Massive Scale
Full Title: The FineWeb Datasets: Decanting the Web for the Finest Text Data at Scale Authors: Guilherme Penedo, Hynek Kydlicek, Loubna Ben Allal, Anton Lozhkov, Margaret Mitchell, Colin Raffel, Leandro von Werra, Thomas Wolf (HuggingFace) arXiv: 2406.17557 | Code: github.com/huggingface/datatrove | Dataset: hf.co/datasets/HuggingFaceFW/fineweb
What FineWeb Is
FineWeb is a 15 trillion token English web dataset from 96 CommonCrawl snapshots. It’s the most thorough publicly documented data curation pipeline for LLM pretraining. While not purely a dedup paper, it provides invaluable practical lessons about deduplication at scale.
MinHash Deduplication Configuration
FineWeb chose a compute-efficient MinHash setup:
- 5-grams for shingling
- 112 hash functions
- Split into 14 buckets of 8 hashes each
- Documents match if they share 8 minhashes in at least 1 of 14 buckets
- Formula: Match probability =
1 - (1 - s^8)^14
Match probability at different similarity levels:
| n-gram Similarity | Match Probability |
|---|---|
| 70% | 56% |
| 75% | 77% |
| 80% | 92% |
| 85% | 98.8% |
This is notably lighter than RefinedWeb’s 9,000 hashes / 450 buckets / 20 per bucket — but much cheaper to compute. HuggingFace argues the savings outweigh the slightly fuzzier threshold.
Critical Finding: Per-Snapshot > Global Dedup
FineWeb deduplicates each CommonCrawl snapshot independently, not globally. They tested several global dedup strategies and found them all harmful:
| Method | Tokens Removed | Performance |
|---|---|---|
| Individual snapshot MinHash (baseline) | — | Best |
| URL dedup (global) | 71.5% | Worse |
| Line dedup (global) | 77.8% | Worse |
| Line dedup w/ min words (global) | 85% | Worse |
| 3-line span dedup (global) | 80.9% | Worse |
Why does global dedup hurt? Oldest snapshots lose up to 90% of data when deduplicated against many newer snapshots. The remaining data is actually worse quality — ads, incoherent keyword lists, badly formatted text. Global dedup removes large “good” duplicate clusters (common quality content replicated across crawls) while keeping unique low-quality content.
Scale Sensitivity of Dedup
One of FineWeb’s most important insights: you can’t measure dedup impact at small scale. They simulated drawing subsets from a dataset where every document is repeated 100 times:
- At 1B tokens: almost all documents appear unique (sampling is too sparse)
- At 100B tokens: some documents start showing 2-4 repetitions
- At 1T tokens: majority repeated 8+ times
Lesson: If you’re doing ablations at 28B tokens, you literally cannot see the effects of deduplication. FineWeb ran their dedup evaluations at 350B tokens.
FineWeb-Edu: Quality Filtering as Alternative to Dedup
FineWeb also introduces FineWeb-Edu, which uses an LLM-based educational quality classifier (trained on Llama3 annotations) to filter documents. A score threshold of 3/5 yields the best results, and FineWeb-Edu outperforms all other public datasets — showing that quality filtering and dedup are complementary strategies.
Results:
- MMLU: 33% → 37% = +12% relative improvement
- ARC: 46% → 57% = +24% relative improvement
- Matches Matrix’s final MMLU with ~10x fewer tokens
Practical Implementation with Datatrove
# Minimal example of MinHash dedup with datatrove
from datatrove.pipeline.dedup import (
MinhashDedupSignature,
MinhashDedupBuckets,
MinhashDedupCluster,
MinhashDedupFilter,
)
# Step 1: Compute MinHash signatures
signature_step = MinhashDedupSignature(
output_folder="signatures/",
n_grams=5,
num_buckets=14,
hashes_per_bucket=8,
)
# Step 2: Find matching pairs via LSH buckets
bucket_step = MinhashDedupBuckets(
input_folder="signatures/",
output_folder="buckets/",
)
# Step 3: Cluster matched documents
cluster_step = MinhashDedupCluster(
input_folder="buckets/",
output_folder="clusters/",
)
# Step 4: Filter (keep one per cluster)
filter_step = MinhashDedupFilter(
input_folder="clusters/",
output_folder="deduplicated/",
)Grand Synthesis: Comparing All Four Approaches
Method Comparison Matrix
| SemDeDup | D4 | SoftDedup | FineWeb | |
|---|---|---|---|---|
| Year | 2023 | 2023 | 2024 | 2024 |
| Team | Meta AI | Meta AI | Tencent AI Lab | HuggingFace |
| Approach | Hard removal (embedding clusters) | Hard removal (dedup + diversify) | Soft reweighting (n-gram scores) | Hard removal (MinHash LSH) |
| Signal | Cosine sim in embedding space | Embedding clusters + centroid dist | N-gram occurrence probability | N-gram Jaccard similarity |
| Embedding model needed? | Yes (CLIP/OPT) | Yes (OPT 125M) | No (CPU n-gram only) | No (hash-based) |
| GPU required for dedup? | Yes | Yes | No | No |
| Removes data? | Yes | Yes | No (reweights) | Yes |
| Computational cost | High | High | Negligible (~28 CPU-hrs for 40B tokens) | Moderate |
| Best data reduction | ~50% with minimal loss | ~75% (select 25%) | N/A (keeps everything) | ~50-70% per snapshot |
| Captures semantic duplication | Yes (strong) | Yes (strong) | Partial (surface-level) | No (only string-level) |
| Captures templated content | Yes | Yes+ (better with re-clustering) | Yes (common n-grams) | Only if strings overlap |
| Works on already-deduped data? | Limited gains | Limited gains | Yes, big gains | N/A |
| Scale tested | LAION (440M), C4 | 600M docs, up to 6.7B model | 40B tokens, 1.3B model | 15T tokens, 1.71B model |
| Open code? | Yes | No | No | Yes (datatrove) |
The Redundancy Spectrum
These methods target different types of redundancy:
EXACT DUPLICATES <----- MinHash/FineWeb -----> NEAR-DUPLICATES
|
+------------+
v v
SemDeDup SoftDedup
(semantic sim) (n-gram frequency)
|
v
D4 (dedup +
diversify)
- MinHash (FineWeb): catches string-level near-duplicates (high Jaccard similarity in n-gram sets)
- SemDeDup: catches meaning-level duplicates (paraphrases, same idea in different words)
- D4: catches duplicates AND actively promotes diversity by removing prototypical/boring examples
- SoftDedup: catches statistical redundancy (content made of very common n-gram patterns) — orthogonal to all of the above
Recommended Pipeline: Combining Them All
Raw CommonCrawl
|
v
[1] MinHash/LSH Dedup (per-snapshot) <- FineWeb approach
| Removes: exact + near-exact string dupes
| Cost: moderate (CPU)
v
[2] SemDeDup (embedding-based) <- SemDeDup approach
| Removes: semantic duplicates (paraphrases, templates)
| Cost: high (GPU for embeddings, FAISS clustering)
v
[3] Re-cluster + SSL Prototypes <- D4 extension (optional)
| Removes: prototypical/redundant content
| Promotes: diverse, informative content
| Cost: moderate (CPU clustering)
v
[4] SoftDedup reweighting <- SoftDedup approach
| Reweights: remaining statistical redundancy
| Cost: negligible (~28 CPU-hours)
v
Training-ready dataset
End-to-End Implementation
A complete, practical pipeline combining the key ideas:
"""
Complete semantic deduplication pipeline for LLM pretraining data.
Combines ideas from SemDeDup, D4, SoftDedup, and FineWeb.
"""
import numpy as np
import torch
import faiss
import kenlm
import subprocess
from pathlib import Path
from tqdm import tqdm
# ================================================================
# STAGE 1: MinHash Deduplication (FineWeb-style)
# ================================================================
# Use datatrove for this (pip install datatrove)
# See FineWeb section above for code
# ================================================================
# STAGE 2: SemDeDup (Embedding-based Semantic Dedup)
# ================================================================
class SemDeDup:
"""
Semantic deduplication using pretrained model embeddings.
Based on Abbas et al. (2023).
"""
def __init__(
self,
embedding_dim: int = 768,
n_clusters: int = 50000,
kmeans_niter: int = 100,
eps: float = 0.07, # duplicate threshold
keep_strategy: str = "hard", # "hard", "easy", or "random"
max_cluster_size: int = 50000, # for memory management
):
self.embedding_dim = embedding_dim
self.n_clusters = n_clusters
self.kmeans_niter = kmeans_niter
self.eps = eps
self.keep_strategy = keep_strategy
self.max_cluster_size = max_cluster_size
def embed_documents(self, documents, model, tokenizer, batch_size=64):
"""
Embed documents using a pretrained model.
For text: use a sentence transformer or OPT model.
For images: use CLIP.
"""
embeddings = []
model.eval()
with torch.no_grad():
for i in range(0, len(documents), batch_size):
batch = documents[i:i+batch_size]
inputs = tokenizer(
batch,
padding=True,
truncation=True,
max_length=512,
return_tensors="pt"
).to(model.device)
outputs = model(**inputs)
# Use mean pooling of last hidden state
emb = outputs.last_hidden_state.mean(dim=1)
# L2 normalize
emb = emb / emb.norm(dim=1, keepdim=True)
embeddings.append(emb.cpu().numpy())
return np.vstack(embeddings).astype("float32")
def cluster(self, embeddings):
"""Run spherical K-Means clustering with FAISS."""
print(f"Clustering {len(embeddings)} embeddings "
f"into {self.n_clusters} clusters...")
kmeans = faiss.Kmeans(
self.embedding_dim,
self.n_clusters,
niter=self.kmeans_niter,
verbose=True,
gpu=torch.cuda.is_available(),
spherical=True,
seed=42,
)
kmeans.train(embeddings)
distances, assignments = kmeans.index.search(embeddings, 1)
return distances.flatten(), assignments.flatten(), kmeans.centroids
def deduplicate_cluster(self, cluster_embeddings):
"""
Find duplicates within a single cluster.
Returns: boolean mask where True = keep, False = remove.
"""
N = len(cluster_embeddings)
if N <= 1:
return np.ones(N, dtype=bool)
embs = torch.tensor(cluster_embeddings)
# Process in chunks if cluster is very large
if N > self.max_cluster_size:
keep = np.ones(N, dtype=bool)
chunk_size = self.max_cluster_size
for start in range(0, N, chunk_size):
end = min(start + chunk_size, N)
chunk_embs = embs[start:end]
sim = chunk_embs @ embs.T
sim[torch.arange(end-start),
torch.arange(start, end)] = 0
triu = torch.triu(sim[:, start:end], diagonal=1)
M = torch.max(triu, dim=0)[0]
keep[start:end] = (M <= 1 - self.eps).numpy()
return keep
# Full pairwise similarity
sim = embs @ embs.T
sim.fill_diagonal_(0.0)
# Upper triangular: later (more prototypical) items get flagged
triu = torch.triu(sim, diagonal=1)
M = torch.max(triu, dim=0)[0]
keep = (M <= 1 - self.eps).numpy()
return keep
def run(self, embeddings):
"""
Full SemDeDup pipeline.
Args:
embeddings: (N, D) numpy array, should be L2-normalized
Returns:
keep_indices: array of indices to keep
"""
N = len(embeddings)
# Step 1: Cluster
distances, assignments, centroids = self.cluster(embeddings)
# Step 2: Process each cluster
keep_mask = np.ones(N, dtype=bool)
removed_count = 0
for cluster_id in tqdm(range(self.n_clusters), desc="SemDeDup"):
cluster_idx = np.where(assignments == cluster_id)[0]
if len(cluster_idx) <= 1:
continue
# Sort by distance to centroid
cluster_dists = distances[cluster_idx]
if self.keep_strategy == "hard":
sort_order = np.argsort(-cluster_dists)
elif self.keep_strategy == "easy":
sort_order = np.argsort(cluster_dists)
else: # random
sort_order = np.random.permutation(len(cluster_idx))
sorted_idx = cluster_idx[sort_order]
sorted_embs = embeddings[sorted_idx]
# Find duplicates
cluster_keep = self.deduplicate_cluster(sorted_embs)
# Map back to global indices
removed_in_cluster = sorted_idx[~cluster_keep]
keep_mask[removed_in_cluster] = False
removed_count += len(removed_in_cluster)
keep_indices = np.where(keep_mask)[0]
print(f"SemDeDup: {N} -> {len(keep_indices)} "
f"({len(keep_indices)/N:.1%} kept, "
f"{removed_count} removed)")
return keep_indices, assignments, centroids
# ================================================================
# STAGE 3: D4 Diversification (Optional, after SemDeDup)
# ================================================================
class D4Diversifier:
"""
Post-SemDeDup diversification via re-clustering + SSL Prototypes.
Based on Tirumala et al. (2023).
"""
def __init__(self, embedding_dim=768, n_clusters=11000,
proto_keep_ratio=0.5):
self.embedding_dim = embedding_dim
self.n_clusters = n_clusters
self.proto_keep_ratio = proto_keep_ratio
def run(self, embeddings, original_indices):
"""
Re-cluster cleaned data and apply SSL Prototypes.
Args:
embeddings: (M, D) embeddings of already-deduped data
original_indices: mapping back to original dataset indices
Returns:
selected_original_indices: final selected indices
"""
M = len(embeddings)
# Re-cluster the cleaned data
print(f"D4: Re-clustering {M} documents...")
kmeans = faiss.Kmeans(
self.embedding_dim, self.n_clusters,
niter=20, spherical=True, verbose=True,
gpu=torch.cuda.is_available()
)
kmeans.train(embeddings)
distances, assignments = kmeans.index.search(embeddings, 1)
distances = distances.flatten()
assignments = assignments.flatten()
# SSL Prototypes: keep FARTHEST from centroid (most diverse)
selected = []
for cluster_id in range(self.n_clusters):
cluster_mask = assignments == cluster_id
cluster_positions = np.where(cluster_mask)[0]
if len(cluster_positions) == 0:
continue
cluster_dists = distances[cluster_positions]
sort_order = np.argsort(-cluster_dists)
n_keep = max(1, int(len(cluster_positions)
* self.proto_keep_ratio))
kept = cluster_positions[sort_order[:n_keep]]
selected.extend(kept.tolist())
selected = np.array(selected)
result = original_indices[selected]
print(f"D4: {M} -> {len(result)} ({len(result)/M:.1%} kept)")
return result
# ================================================================
# STAGE 4: SoftDedup Reweighting
# ================================================================
class SoftDedup:
"""
Soft deduplication via n-gram commonness reweighting.
Based on He et al. (2024).
"""
def __init__(self, ngram_order=4, n_partitions=20, disparity=10.0):
self.ngram_order = ngram_order
self.n_partitions = n_partitions
self.disparity = disparity
self.model = None
def train_ngram_model(self, corpus_file,
output_dir="/tmp/softdedup"):
"""Train KenLM n-gram model."""
Path(output_dir).mkdir(exist_ok=True)
arpa_path = f"{output_dir}/model.arpa"
binary_path = f"{output_dir}/model.binary"
print(f"Training {self.ngram_order}-gram model...")
subprocess.run([
"lmplz",
"-o", str(self.ngram_order),
"--text", corpus_file,
"--arpa", arpa_path,
], check=True)
subprocess.run([
"build_binary", arpa_path, binary_path
], check=True)
self.model = kenlm.Model(binary_path)
return binary_path
def score_documents(self, documents):
"""Compute commonness score for each document."""
assert self.model is not None, "Train model first!"
scores = np.zeros(len(documents))
for i, doc in enumerate(
tqdm(documents, desc="Scoring commonness")
):
log10_prob = self.model.score(doc, bos=True, eos=True)
n_tokens = len(doc.split()) + 1
if n_tokens > 0:
scores[i] = 10 ** (log10_prob / n_tokens)
return scores
def compute_weights(self, commonness_scores):
"""Compute per-document sampling weights."""
import math
N = len(commonness_scores)
K = self.n_partitions
sorted_idx = np.argsort(commonness_scores)
segment_ids = np.zeros(N, dtype=int)
segment_size = N // K
for k in range(K):
start = k * segment_size
end = (k + 1) * segment_size if k < K - 1 else N
segment_ids[sorted_idx[start:end]] = k
seg_commonness = np.array([
np.median(commonness_scores[segment_ids == k])
for k in range(K)
])
p_min, p_max = seg_commonness[0], seg_commonness[-1]
if p_max > p_min and p_min > 0:
T = math.log(self.disparity) / math.log(p_max / p_min)
else:
T = 1.0
raw_weights = np.array([
(1.0 / max(p, 1e-10)) ** T for p in seg_commonness
])
raw_weights /= raw_weights.sum()
doc_weights = raw_weights[segment_ids]
doc_weights /= doc_weights.sum()
return doc_weights
# ================================================================
# PUTTING IT ALL TOGETHER
# ================================================================
def full_dedup_pipeline(
documents, # list of text strings
embeddings, # (N, D) numpy array, L2-normalized
semdedup_eps=0.07,
use_d4=True,
d4_proto_ratio=0.5,
use_softdedup=True,
):
"""
Full dedup pipeline combining all methods.
Returns:
selected_indices: indices of documents to keep
weights: per-document sampling weights (if softdedup enabled)
"""
N = len(documents)
print(f"Starting dedup pipeline on {N} documents")
# --- Stage 1: Assume MinHash already done upstream ---
# --- Stage 2: SemDeDup ---
semdedup = SemDeDup(
embedding_dim=embeddings.shape[1],
eps=semdedup_eps,
keep_strategy="hard",
)
keep_idx, assignments, centroids = semdedup.run(embeddings)
# --- Stage 3: D4 Diversification (optional) ---
if use_d4:
diversifier = D4Diversifier(
embedding_dim=embeddings.shape[1],
proto_keep_ratio=d4_proto_ratio,
)
keep_idx = diversifier.run(embeddings[keep_idx], keep_idx)
# --- Stage 4: SoftDedup Reweighting (optional) ---
weights = None
if use_softdedup:
soft = SoftDedup(
ngram_order=4, n_partitions=20, disparity=10.0
)
selected_docs = [documents[i] for i in keep_idx]
with open("/tmp/softdedup_corpus.txt", "w") as f:
for doc in selected_docs:
f.write(doc.replace("\n", " ") + "\n")
soft.train_ngram_model("/tmp/softdedup_corpus.txt")
commonness = soft.score_documents(selected_docs)
weights = soft.compute_weights(commonness)
print(f"\n=== Pipeline Complete ===")
print(f"Original: {N} documents")
print(f"Selected: {len(keep_idx)} documents ({len(keep_idx)/N:.1%})")
if weights is not None:
print(f"Weight range: {weights.min():.6f} to {weights.max():.6f}"
f" (ratio: {weights.max()/weights.min():.1f}x)")
return keep_idx, weightsRules of Thumb for Implementation
If you’re just starting out (simplest → most complex):
- Start with MinHash — Use
datatroveordatasketchPython libraries. Cheapest, most battle-tested. - Add SoftDedup on top — Train a 4-gram KenLM model, score documents, reweight. Costs almost nothing (~7 CPU-hours for 40B tokens) and stacks with everything.
- Add SemDeDup if you have GPU budget — Embed with a sentence transformer, cluster with FAISS, dedup within clusters. The official code is a good starting point.
- Add D4 diversification if going all-in — Re-cluster after SemDeDup, apply SSL Prototypes. Best results but most complex.
Quick reference:
- eps = 0.07 is a good starting SemDeDup threshold for text (removes ~35-40%)
- K = sqrt(N) clusters is a reasonable default for K-Means
- Keep “hard” examples (farthest from centroid) — this is the default and best strategy
- SoftDedup with K=20, 10x disparity works well out of the box
- Don’t over-deduplicate — FineWeb showed that aggressive global dedup can hurt
- Measure at scale — dedup effects are invisible below ~100B tokens