Chapter 8 · Section 8.3 · 65 min

Deduplication

Open a random shard of a web crawl and count how often each paragraph appears. Most appear once. A few appear thousands of times. Cookie banners, "All rights reserved" footers, product descriptions copied across a hundred shops, news wires syndicated to a thousand local papers, and the same license text at the bottom of every page of a forum. When Lee et al. (2022) looked inside C4, a dataset that had already been filtered and exact-deduplicated at the level of three-sentence spans, they found one 61-word sentence repeated 61,036 times.

A model sees each copy as a fresh training example. So a document that appears 10,000 times is trained on for 10,000 epochs while the rest of the corpus gets one, and the model does what any learner does with something it sees 10,000 times. It memorizes it. This section is about finding those copies, exact ones, near ones, shared substrings, and paraphrases, at the scale of billions of documents, and about the special kind of duplicate that silently ruins evaluations, the benchmark question that also lives in the training set.

Why duplicates hurt

There are three separate costs, and they call for slightly different tools.

Wasted compute and a distorted mixture. Every repeated token is compute spent on something the model has already learned, and heavy repetition quietly changes the data mixture of §8.4, since whatever is copied most gets the most weight. Hernandez et al. (2022) measured how bad this can be. Repeating just 0.1 percent of the data 100 times (so those copies made up 10 percent of the tokens) degraded an 800M-parameter model to the performance of a 400M-parameter one, even though the other 90 percent of the tokens were unique.

Memorization and regurgitation. Text that is seen many times is reproduced verbatim. Lee et al. (2022) found that about 1.9 percent of the tokens a model trained on raw C4 generated without a prompt were part of verbatim copies of training text, and that training on deduplicated data cut this to between 0.1 and 0.2 percent, roughly tenfold. Verbatim regurgitation is a privacy problem (phone numbers, addresses) and a copyright problem, and it also means the model spent capacity storing strings rather than learning to generalize.

Leakage between training and evaluation. If a validation document, or a benchmark question, also sits in the training set, the measured loss or accuracy is a memory test. Lee et al. found that 4.6 percent of C4's validation documents had a near-duplicate in its training set, and 14.4 percent for the news dataset RealNews. We return to this in the last part of the section.

Duplicates come in four kinds, and each needs its own detector.

kindexampledetectorcost per document
exactthe same page crawled twice, maybe with different whitespacenormalize, hash, hash setone hash
nearthe same article with a different header, ads, or a few edited wordsMinHash plus LSH bandingabout 100 hashes
substringunique pages that share a long boilerplate paragraphsuffix array over the whole corpusa few bytes per token
semantica paraphrase, a translation, the same tutorial rewrittenembeddings plus clusteringone embedding

Exact deduplication with hashing

The cheapest case first. Two documents are exact duplicates if they are equal after a normalization that removes differences nobody cares about (case, runs of whitespace, sometimes punctuation and digits). Compute a hash of each normalized document, keep the first document for each hash, and drop the rest. A hash set does this in one pass, in time linear in the corpus.

How many bits does the hash need? If nn documents get independent uniform hh-bit hashes, each of the (n2)≈n2/2\binom{n}{2} \approx n^2/2 pairs collides with probability 2−h2^{-h}, so the expected number of false collisions is

E[collisions]≈n22h+1.\mathbb{E}[\text{collisions}] \approx \frac{n^2}{2^{h+1}}.

In plain words, collisions grow with the square of the corpus size, the birthday paradox. For n=1010n = 10^{10} documents, a 64-bit hash gives 1020/265≈2.710^{20}/2^{65} \approx 2.7 expected collisions, so a couple of unrelated documents would be wrongly dropped. A 128-bit hash makes it about 10−1910^{-19}. Pipelines use 64-bit hashes when a rare false drop is acceptable and 128 bits otherwise.

Python · cell 1

The same idea works below the document level. C4 removed every three-sentence span that appeared more than once, and Dolma removes repeated paragraphs (Soldaini et al. 2024). At that granularity the set of hashes no longer fits in memory on one machine, which is where a Bloom filter helps. It is a bit array of mm bits and kbk_b hash functions (a separate count from MinHash's kk below). To insert an item, set the kbk_b bits it hashes to. To query, report "seen" if all kbk_b bits are set. It never misses an item it has seen, but it can report a false "seen".

∂ The false positive rate of a Bloom filter, and the best number of hash functions

After inserting nn items, each with kbk_b hashes, a given bit has been missed by all kbnk_bn hashes with probability

(1−1m)kbn≈e−kbn/m,\Big(1 - \frac1m\Big)^{k_bn} \approx e^{-k_bn/m},

using (1−1/m)m≈e−1(1 - 1/m)^m \approx e^{-1}. A new item is a false positive when all its kbk_b bits are already set, which (treating bits as independent, accurate for large mm) has probability

pfp≈(1−e−kbn/m)kb.p_{\text{fp}} \approx \big(1 - e^{-k_bn/m}\big)^{k_b} .

To find the best kbk_b for a given ratio m/nm/n, write x=e−kbn/mx = e^{-k_bn/m}, the fraction of bits still zero, so kb=−(m/n)ln⁡xk_b = -(m/n)\ln x, and ln⁡pfp=kbln⁡(1−x)=−(m/n)ln⁡x ln⁡(1−x)\ln p_{\text{fp}} = k_b\ln(1 - x) = -(m/n)\ln x\,\ln(1 - x). Minimizing pfpp_{\text{fp}} means maximizing g(x)=ln⁡xln⁡(1−x)g(x) = \ln x\ln(1-x), which is positive on (0,1)(0, 1) and symmetric under x↔1−xx \leftrightarrow 1-x. Its derivative is g′(x)=ln⁡(1−x)/x−ln⁡x/(1−x)g'(x) = \ln(1-x)/x - \ln x/(1-x), which vanishes when xln⁡x=(1−x)ln⁡(1−x)x\ln x = (1-x)\ln(1-x). That holds at x=1/2x = 1/2, and nowhere else inside (0,1)(0, 1), because the difference ψ(x)=xln⁡x−(1−x)ln⁡(1−x)\psi(x) = x\ln x - (1-x)\ln(1-x) has ψ′(x)=2+ln⁡(x(1−x))\psi'(x) = 2 + \ln\big(x(1-x)\big), which changes sign only once on (0,1/2)(0, 1/2), so ψ\psi starts at 0, dips, and climbs back to 0 only at x=1/2x = 1/2 (and by the antisymmetry ψ(1−x)=−ψ(x)\psi(1-x) = -\psi(x) the same holds on the other side). So the only stationary point is the maximum at x=1/2x = 1/2, half the bits set, which gives

kb⋆=mnln⁡2,pfp=2−kb⋆=(2−ln⁡2)m/n≈0.6185 m/n.k_b^\star = \frac{m}{n}\ln 2, \qquad p_{\text{fp}} = 2^{-k_b^\star} = \big(2^{-\ln 2}\big)^{m/n} \approx 0.6185^{\,m/n}.

So a 1 percent false positive rate needs m/n=ln⁡(100)/(ln⁡2)2≈9.6m/n = \ln(100)/(\ln 2)^2 \approx 9.6 bits per item, whatever the size of the items. Storing 64-bit hashes instead would take 64 bits per item.

In plain words, about 10 bits per paragraph buy a 1 percent false-drop rate, roughly six times less memory than storing hashes. DCLM's baseline dataset was deduplicated with a Bloom filter of this kind (Dolma's BFF tool), which removes repeated paragraphs and near-duplicate documents in a single pass and came within a fraction of a point of a MinHash plus suffix-array pipeline in their ablations (Li et al. 2024).

Warm-up

Exact deduplication with normalization

Write exact_dedup(docs) that returns the indices of the documents to keep, in their original order, keeping the first occurrence of each document. Two documents are duplicates if they are equal after lowercasing, removing every character that is not a letter, digit, underscore, or whitespace, and collapsing every run of whitespace to one space with no leading or trailing space. Hash the normalized text with hashlib.sha1 (or compare normalized strings directly).

Hidden tests, hints, and AI help, from $1.99 a month.

Near duplicates and the Jaccard similarity

Exact hashing misses the most common case. Two crawls of the same news article differ in a timestamp, a "related stories" box, and a cookie notice. Hashes of the two texts are completely different, because a good hash changes completely when one character changes. We need a similarity that degrades gracefully with small edits.

The standard choice treats a document as a set of short overlapping pieces called shingles, usually word nn-grams with nn around 5. "the cat sat on the mat" has the four 3-word shingles "the cat sat", "cat sat on", "sat on the", and "on the mat". Changing one word destroys at most nn shingles, however long the document is, so a long document with a few edits keeps most of its shingles. Two shingle sets A\mathcal{A} and B\mathcal{B} are compared with the Jaccard similarity

J(A,B)=∣A∩B∣∣A∪B∣∈[0,1].J(\mathcal{A}, \mathcal{B}) = \frac{\lvert\mathcal{A}\cap\mathcal{B}\rvert}{\lvert\mathcal{A}\cup\mathcal{B}\rvert} \in [0, 1].

In plain words, the fraction of all shingles that appear in either document which appear in both. (Here JJ is the Jaccard similarity, not an objective, and calligraphic letters are sets.)

A worked example by hand. Take "the cat sat on the mat" and "the cat sat on a mat" with 2-word shingles. The first has the five shingles "the cat", "cat sat", "sat on", "on the", and "the mat". The second has "the cat", "cat sat", "sat on", "on a", and "a mat". They share three, and the union has seven, so J=3/7≈0.43J = 3/7 \approx 0.43. One edited word in a six-word sentence is a big edit. In a 500-word article, one edited word with 5-word shingles removes at most 5 of about 496 shingles from each side, so J≥491/501≈0.98J \ge 491/501 \approx 0.98.

Pipelines usually call two documents near-duplicates when JJ is above roughly 0.7 to 0.8. Computing JJ exactly is easy for one pair. The problem is the number of pairs. A corpus of 10910^9 documents has 5×10175\times10^{17} of them. We need two tricks, one that makes each comparison cheap (MinHash) and one that avoids comparing most pairs at all (LSH).

MinHash, derived

Here is the idea that makes near-duplicate detection possible at web scale, due to Broder (1997), who built it to cluster 30 million pages of an AltaVista web crawl into groups of near-identical documents. (Broder's own sketch keeps the smallest few values under one permutation. The version with kk independent permutations that we derive here behaves the same way and is the one pipelines use.)

Put every possible shingle in a random order, a random permutation π\pi of the universe of shingles. For a set A\mathcal{A}, let hπ(A)=min⁡x∈Aπ(x)h_\pi(\mathcal{A}) = \min_{x\in\mathcal{A}}\pi(x) be the position of whichever of its shingles comes first in that random order. The claim is

P[hπ(A)=hπ(B)]=J(A,B).\mathbb{P}\big[h_\pi(\mathcal{A}) = h_\pi(\mathcal{B})\big] = J(\mathcal{A}, \mathcal{B}).

In plain words, the probability that two sets have the same first element under a random ordering is exactly their Jaccard similarity.

The proof takes three sentences, so let's go slowly. Look at the union A∪B\mathcal{A}\cup\mathcal{B} and ask which of its elements comes first under π\pi. Because π\pi is a uniformly random permutation, every element of the union is equally likely to be that first one, each with probability 1/∣A∪B∣1/\lvert\mathcal{A}\cup\mathcal{B}\rvert. If the first element of the union lies in the intersection, it is in both sets and is the first of each, so hπ(A)=hπ(B)h_\pi(\mathcal{A}) = h_\pi(\mathcal{B}). If it lies in only one set, say A\mathcal{A}, then it is A\mathcal{A}'s minimum but not in B\mathcal{B}, and B\mathcal{B}'s minimum is a different element with a larger position, so the minima differ. The minima agree exactly when the first element of the union is in the intersection, which happens with probability ∣A∩B∣/∣A∪B∣\lvert\mathcal{A}\cap\mathcal{B}\rvert/\lvert\mathcal{A}\cup\mathcal{B}\rvert. □\square

One permutation gives one coin flip whose probability of heads is JJ. Use kk independent permutations π1,…,πk\pi_1, \dots, \pi_k, store the signature (hπ1(A),…,hπk(A))\big(h_{\pi_1}(\mathcal{A}), \dots, h_{\pi_k}(\mathcal{A})\big) for every document, and estimate the similarity by the fraction of positions where two signatures agree,

J^=1k∑i=1k1[hπi(A)=hπi(B)].\hat J = \frac1k\sum_{i=1}^k \mathbb{1}\big[h_{\pi_i}(\mathcal{A}) = h_{\pi_i}(\mathcal{B})\big].

Each indicator is a Bernoulli variable with mean JJ and variance J(1−J)J(1-J), and they are independent because the permutations are, so

E[J^]=J,Var⁡(J^)=J(1−J)k,sd⁡(J^)=J(1−J)k.\mathbb{E}[\hat J] = J, \qquad \operatorname{Var}(\hat J) = \frac{J(1-J)}{k}, \qquad \operatorname{sd}(\hat J) = \sqrt{\frac{J(1-J)}{k}} .

In plain words, the estimate is unbiased, and its error shrinks like 1/k1/\sqrt{k}, largest at J=1/2J = 1/2 and zero when the documents are identical or disjoint.

Two consequences matter in practice. First, the signature has a fixed size. A 100-word tweet and a 100,000-word book both become kk integers, so similarity costs O(k)O(k) regardless of length. Second, kk is a precision dial. With k=128k = 128, at J=0.8J = 0.8 the standard deviation is 0.16/128≈0.035\sqrt{0.16/128} \approx 0.035, so a pair at the threshold is estimated anywhere from about 0.73 to 0.87 (two standard deviations). Pinning JJ to within ±0.02\pm 0.02 at 95 percent confidence would need k≥(1.96/0.02)2⋅0.16≈1,537k \ge (1.96/0.02)^2\cdot 0.16 \approx 1{,}537 hashes. That is expensive, which is why MinHash is used to propose candidates, and the final decision is often an exact comparison of the shingle sets, affordable because there are few candidates.

Real implementations never build a permutation of all possible shingles. They hash each shingle to an integer xx and apply kk random affine hash functions, hi(x)=(aix+bi) mod ph_i(x) = (a_i x + b_i) \bmod p for a large prime pp, taking the minimum over the shingles. These functions are not exactly min-wise independent, and the gap can show. Applied directly to consecutive integers such as 0, 1, 2, and so on, affine hashes give visibly biased estimates. Applied to ids that are themselves hashes of the shingle text, which look random, they behave as derived (Broder et al. 1998 studied exactly how close to min-wise a family must be). Let's check the whole derivation numerically, with ids that come from hashing.

Python · cell 2

Each shingle here is a single "word", which is fine, since MinHash only sees sets of integers. The mean estimate sits at the true J=0.5J = 0.5 for every kk, and the spread follows J(1−J)/k\sqrt{J(1-J)/k}, halving each time kk is multiplied by four.

Interview

MinHash signatures and Jaccard estimation

Implement two functions. minhash_signatures(id_sets, a, b, p) takes a list of n arrays of non-negative integer shingle ids (one array per document, each nonempty) and integer arrays a and b of shape (k,), and returns an integer array of shape (n, k) whose entry [d, i] is min⁡x(aix+bi) mod p\min_x (a_i x + b_i) \bmod p over the ids x of document d. estimate_jaccard(sig1, sig2) returns the fraction of positions where two signatures agree. Use int64 arithmetic (the tests keep every product below 2632^{63}). Do it without a Python loop over the k hash functions.

Hidden tests, hints, and AI help, from $1.99 a month.

LSH banding and the S-curve

MinHash made each comparison cheap. Now we avoid most comparisons. The trick is the same amplification you met for random hyperplanes in §7.4 and §1.3, now with MinHash values as the bits.

Split each signature of length kk into bb bands of rr consecutive values, with k=brk = br. For each band, hash its rr values into a bucket. Two documents become a candidate pair if they land in the same bucket in at least one band. Only candidate pairs are ever compared.

Let's derive the probability that a pair with Jaccard similarity ss becomes a candidate. Each MinHash value agrees with probability ss, independently. A band of rr values matches only if all rr agree, an AND,

P[one band matches]=sr.\mathbb{P}[\text{one band matches}] = s^r .

The bands use disjoint hash functions, so they are independent. The pair is missed only if every band fails, and becomes a candidate if any band matches, an OR,

P[candidate]=1−(1−sr)b.\mathbb{P}[\text{candidate}] = 1 - \big(1 - s^r\big)^b .

In plain words, rows make each band strict, so only very similar pairs match a given band, and many bands give a similar pair many chances. The result is a steep S-curve. Pairs above some similarity almost always become candidates and pairs below it almost never do.

Where is the step? A clean way to locate it is to ask when the expected number of matching bands, bsrb s^r, equals one. That gives the usual rule of thumb

s⋆≈(1b)1/r.s^\star \approx \Big(\frac1b\Big)^{1/r}.

The point where the curve crosses one half is slightly lower. Setting (1−sr)b=1/2(1 - s^r)^b = 1/2 and using ln⁡(1−y)≈−y\ln(1 - y) \approx -y for small y=sry = s^r gives s1/2≈(ln⁡2/b)1/rs_{1/2} \approx (\ln 2/b)^{1/r}. More rows make the step steeper, and more bands move it to the left.

Two worked examples. FineWeb computed k=112k = 112 hashes of 5-word shingles, arranged as b=14b = 14 bands of r=8r = 8 (Penedo et al. 2024), aiming at pairs above about 75 percent similarity. The rule of thumb gives s⋆=14−1/8≈0.72s^\star = 14^{-1/8} \approx 0.72. At s=0.8s = 0.8, a band matches with probability 0.88=0.1680.8^8 = 0.168, and the pair is a candidate with probability 1−0.83214≈0.921 - 0.832^{14} \approx 0.92. At s=0.5s = 0.5, 0.58=0.00390.5^8 = 0.0039 and the probability falls to 1−0.996114≈0.0531 - 0.9961^{14} \approx 0.053. Lee et al. (2022) computed k=9,000k = 9{,}000 hashes of 5-word shingles, arranged as b=450b = 450 bands of r=20r = 20 (their paper writes the formula with the letters swapped, so watch out when reading it), a much steeper curve, with s⋆=450−1/20≈0.74s^\star = 450^{-1/20} \approx 0.74.

How do bands and rows shape the probability that a pair becomes a candidate?
The curve is 1 − (1 − s^r)^b. The pink area below your threshold is false positives (extra comparisons), the orange area above it is false negatives (duplicates that are never found).
Loading interactive figure…

The two kinds of error have different costs, and this is what should drive the choice of bb and rr. A false positive (a dissimilar pair that becomes a candidate) costs one extra comparison, after which a check of the estimated or exact Jaccard throws it out. A false negative (a true near-duplicate that never becomes a candidate) is lost forever, and the copy stays in the corpus. So pipelines set the curve's step a little below the similarity they actually care about, and verify candidates afterwards.

Python · cell 3
Interview

LSH banding, candidate pairs, and recall at a threshold

Implement lsh_candidates(sigs, b, r). sigs is an integer array of shape (n, k) with k == b * r. Split each row into b bands of r consecutive columns (band j is columns j*r to j*r + r - 1), bucket documents by the exact tuple of values in each band, and return the set of all candidate pairs (i, j) with i < j that share a bucket in at least one band. Also implement candidate_probability(s, b, r), the probability that a pair with Jaccard s becomes a candidate (it must work for NumPy arrays of s). Raise ValueError if k != b * r.

Hidden tests, hints, and AI help, from $1.99 a month.

Choosing bb and rr is itself an interview question, and a good one, because it forces you to say what you are trading. With a budget of kk hashes there are only a few factorizations k=brk = br, and each fixes a curve. A principled choice weighs the probability mass of false positives below the threshold against false negatives above it.

Core

Choose bands and rows for a target threshold

Write choose_bands(k, threshold, fn_weight=1.0, fp_weight=1.0) that returns the pair (b, r) with b * r == k minimizing

fp_weight∫0tP(s) ds+fn_weight∫t1(1−P(s)) ds,\text{fp\_weight}\int_0^{t}P(s)\,ds + \text{fn\_weight}\int_t^1\big(1 - P(s)\big)\,ds,

where P(s)=1−(1−sr)bP(s) = 1 - (1 - s^r)^b and tt is the threshold. Integrate numerically on a grid of at least 1,000 points (the trapezoid rule or a mean over a uniform grid times the interval length is fine). Consider every divisor rr of kk.

Hidden tests, hints, and AI help, from $1.99 a month.

From candidate pairs to a deduplicated corpus

LSH hands us candidate pairs. Three steps turn them into a decision about which documents to keep.

  1. Verify. For each candidate, compare the full signatures (or the exact shingle sets, when they are cheap to fetch) and keep only pairs with estimated JJ above the threshold. This removes the false positives of banding.
  2. Cluster. Treat verified pairs as edges of a graph and find its connected components with union-find. Every component is one group of near-duplicates.
  3. Keep one. From each component, keep one document (the first crawled, the longest, or the one with the best quality score) and drop the rest.

The clustering step has a subtlety worth naming. Similarity is not transitive. A can be 0.8 similar to B and B 0.8 similar to C while A and C are only 0.6 similar, and connected components will still put A and C in one cluster. On real web data this chaining is mostly harmless (long chains of slowly drifting copies are themselves a kind of duplication), and every large pipeline accepts it, because the alternatives (clique finding, or keeping one document per pair) are far more expensive.

Here is the whole pipeline on a toy corpus with planted duplicates.

Python · cell 4

The four stories land in one cluster and the two recipes do not, which is worth understanding. The story with two substituted words has a true Jaccard of 0.68 with the original, just under the 0.7 threshold, and it merged anyway because 128 hashes estimate 0.68 with a standard deviation of about 0.04. The two recipes differ in a single word, yet their Jaccard is only 0.65, because a 16-word text has just 14 shingles and one changed word destroys 3 of them. The same edit is a bigger fraction of a short document, so short documents need either shorter shingles or a different threshold. Try it yourself in the explorer below, which runs the same pipeline on a slightly larger corpus.

Which near-duplicates does MinHash with LSH actually catch?
Upper triangle, true Jaccard of the shingle sets. Lower triangle, the MinHash estimate. A dark outline marks a candidate pair from banding. Pairs whose estimate passes the threshold are merged.
Loading interactive figure…
Interview

Near-deduplicate a toy corpus

Implement near_dedup(texts, k=128, b=32, r=4, threshold=0.6, n=3, seed=0) that returns the sorted list of indices to keep. Shingle each text into its set of word n-grams (lowercase, words are re.findall(r"\w+", ...); a text with fewer than n words is one shingle), compute MinHash signatures with k affine hashes (ax+b) mod p(a x + b) \bmod p with p=231−1p = 2^{31} - 1 (draw a in [1,p)[1, p) and b in [0,p)[0, p) from np.random.default_rng(seed), and map each shingle to an id with zlib.crc32(s.encode()) & 0x7FFFFFFF), find candidates with LSH banding, keep candidate pairs whose estimated Jaccard is at least threshold, merge them with union-find, and keep the smallest index in each cluster.

Hidden tests, hints, and AI help, from $1.99 a month.

How does this scale? Computing signatures is embarrassingly parallel, one pass over the data. Banding is a big group-by on (j,band hash)(j, \text{band hash}), which distributed systems do well. Union-find over billions of edges is the only step that needs care, and libraries such as Hugging Face's datatrove (used for FineWeb) split it into stages that each fit on a cluster.

Substring deduplication with suffix arrays

Document-level near-dedup asks whether two whole documents are similar. It misses unique documents that share a long passage. A forum post with a 200-word legal disclaimer appended, or ten thousand product pages with the same paragraph of shipping terms, each have plenty of unique text, so their Jaccard with each other is low, yet the shared passage is seen ten thousand times.

Lee et al. (2022) attacked this with a suffix array, a sorted list of every suffix of the entire concatenated corpus. The key fact is simple. If a substring of length LL occurs at two different places, then the two suffixes starting there share a prefix of length at least LL, and in sorted order, suffixes that share a long prefix sit next to each other. So every repeated substring of length at least LL shows up as a pair of adjacent suffixes whose longest common prefix (LCP) is at least LL. One linear scan of the LCP array finds them all.

Python · cell 5

The 13-word shipping paragraph is found as one repeated span, even though the two pages it sits on are different products. The six adjacent pairs are the same repeat seen from different starting points, since every suffix of a repeated span is itself repeated, and real tools merge them into maximal spans before removing anything. Lee et al. removed every span of at least 50 tokens that occurred more than once, which they called ExactSubstr. On C4 it removed 7.2 percent of the tokens, and 77 percent of the documents that MinHash flagged as near-duplicates also contained such a 50-token verbatim match. The real suffix array for a corpus of nn tokens is built in O(n)O(n) time with algorithms such as SA-IS, and it costs several bytes per token of memory and disk, which is why it is run on shards or on datasets of a few hundred billion tokens rather than on everything.

Removing spans from the middle of a document has a cost of its own. It can leave a document that reads oddly, with a paragraph missing. Most recent pipelines prefer line-level or paragraph-level exact dedup with a Bloom filter (Dolma, DCLM), which catches most boilerplate at a fraction of the cost, and keep suffix arrays for measuring memorization and contamination.

Semantic deduplication

The last kind of duplicate shares no words. A blog post that rewrites a Wikipedia article in its own words, a tutorial translated from another language, a thousand slightly different "top ten tips for productivity" listicles. Shingles cannot see these, but embeddings can (§7.3).

SemDeDup (Abbas et al. 2023) is the standard recipe. Embed every document with a pretrained encoder, normalize, run k-means to group the corpus into clusters (so the expensive comparisons stay inside a cluster), and within each cluster remove one document from every pair whose cosine similarity exceeds 1−ε1 - \varepsilon. Within each group of duplicates they keep the item farthest from the cluster centroid, which favors the least typical example. On 440 million image-text pairs from LAION, they removed half the data with less than half a point of performance loss, halving training time. Tirumala et al. (2023) combined this with a step that also removes "prototypical" documents near cluster centers (D4) and reported faster training for language models.

Semantic dedup needs more care for text than for images. Two documents can be close in embedding space and still teach different things, for example two proofs of the same theorem or the same API used in two programs. It is used with conservative thresholds, and it works best as a way to thin out clusters of low-value templated content.

Benchmark contamination

The duplicates that do the most damage are not the ones in the training set. They are the ones shared between the training set and the test set. Benchmarks are published on the web, discussed on forums, and pasted into homework sites, so a crawl will contain many of them. A model that has seen a benchmark's questions with answers scores higher on that benchmark without being more capable, and nobody can tell from the score alone.

The standard detector is word nn-gram overlap. GPT-3 (Brown et al. 2020) flagged a test example as contaminated if any of its 13-grams appeared in the training data (after lowercasing and removing punctuation), and later work uses similar rules with different nn. The test is asymmetric on purpose. We ask what fraction of a test item's nn-grams appear anywhere in the training data, which a hash set of training nn-grams answers in one pass. Long nn avoid false alarms on common phrases ("one of the most important"), and normalization (lowercase, strip punctuation) avoids misses from formatting.

Overlap checks catch verbatim leaks but miss paraphrased or translated ones, and they say nothing about leaks introduced later, for example by synthetic data generated with a model that had seen the benchmark (§8.4). Two habits help. Decontaminate before training, by removing training documents that overlap any benchmark you plan to report. And put a unique canary string in your own benchmarks (BIG-bench did this) so that you can later test whether a model has seen it. The broader science of contamination, including membership inference and benchmarks built to be robust to it, is a large part of how the field evaluates models it did not train itself.

Core

An n-gram contamination check

Write contaminated(train_docs, test_items, n=8, min_frac=0.0) that returns the sorted list of indices of test items that are contaminated. Normalize every text by lowercasing and taking words with re.findall(r"\w+", text). Build the set of all word n-grams of the training documents. A test item is contaminated if it has at least one n-gram and the fraction of its n-grams found in the training set is greater than min_frac (so with the default, any single overlapping n-gram counts). Test items shorter than n words are never contaminated.

Hidden tests, hints, and AI help, from $1.99 a month.

How real pipelines combine them

No pipeline uses every tool, and the choices tell you what each team worried about.

pipelineexactnearsubstringnotes
C4 (Raffel et al. 2020)three-sentence spansnonenonespan dedup removes repeated sentences inside otherwise unique pages
Lee et al. (2022), on C4 and othersyesMinHash, 450 bands of 20suffix array, 50 tokensthe study that showed dedup reduces memorization
RefinedWeb (Penedo et al. 2023)yesMinHashsuffix arrayfuzzy and exact-substring dedup together, on web data alone
FineWeb (Penedo et al. 2024)no separate stepMinHash, 14 bands of 8, per snapshotnoneglobal dedup across snapshots made results worse
Dolma (Soldaini et al. 2024)URL and paragraph, Bloom filternonenoneparagraph dedup catches boilerplate cheaply
DCLM (Li et al. 2024)Bloom filter on paragraphs and documentsBloom filter, 13-gram overlapnoneone Bloom-filter pass replaced MinHash plus suffix arrays

The FineWeb row deserves a sentence. Deduplicating all 96 snapshots against each other removed up to 90 percent of the oldest snapshots, and the model trained on the globally deduplicated data was worse than one trained on per-snapshot dedup. In one 2013 snapshot, the 10 percent that global dedup kept trained a worse model than the 90 percent it removed. The data that survived global dedup in older snapshots turned out to be low quality, since good pages tend to be recrawled and get removed as duplicates of a later copy while spam is unique. More dedup is not automatically better. What matters is what is left.

In PyTorch

On a GPU, signatures for a batch of documents are one broadcasted operation, and semantic dedup is a matrix multiply between normalized embeddings. Here are both, checked against the NumPy versions.

🔥 In PyTorchfor a machine with PyTorch

Read more

Watch next

🎬 Deduplication of Large-scale Text Datasets for Pretraining of Language Models · Ai2 · starts at 18:22A DCLM author compares exact and fuzzy dedup with MinHash, suffix arrays, and Bloom filters, backed by real ablations.
🎬 Lecture 13 — Minhashing | Mining of Massive Datasets | Stanford University · Artificial Intelligence - All in OneThe classic Stanford Mining of Massive Datasets lecture on why a MinHash signature estimates Jaccard similarity.
🎬 Lecture 14 — Locality Sensitive Hashing | Stanford University · Artificial Intelligence - All in OneContinues straight from MinHash into banding and the S-curve that sets the near-duplicate threshold.