Skip to content

Exact dedup: paragraph hashes, Bloom screen, sort-merge confirm

Moduledata.03 · build · Python · Pass 3 · 3 to 4 h
You buildpython/corpus/dedup.py: paragraphs, paragraph_hash, bloom_rate, exact_dedup (a stage), STATS_KEYS
Contractcourse/contracts/py/corpus/dedup.pyi · the Bloom sizing and hash rules: formats/bloom.md
Testscourse/tests/data.03/ (what they check: section 4); fixtures course/fixtures/data.03/ (200 documents with planted duplicates)
Needsdata.02 Doc and compose · M06.3 FNV-1a and SplitMix64 · reading: S-M06b (false-positive rate, optimal kk)
Used bynear dedup (data.04) runs on what this stage keeps; it joins the registry with its batch
MilestoneMS-corpus
Optional depthLee et al., Deduplicating Training Data Makes Language Models Better (2022, free); Wenzek et al., CCNet (2019), section 3.1 (paragraph-level dedup, free); Broder and Mitzenmacher, “Network Applications of Bloom Filters: A Survey” (2004, free); Kirsch and Mitzenmacher, “Less Hashing, Same Performance” (2006, free)
  • Exact dedup keeps the first occurrence of every paragraph, in input order, and removes the later ones; a document that loses every paragraph is dropped (test_hand_example_three_documents, test_fixture_duplicate_counts).
  • A Bloom filter answers “definitely new” or “maybe seen” in about 10 bytes per item, a tenth of what a set of hashes costs. Its “maybe” is only a candidate (test_zero_false_drops_with_a_tiny_filter).
  • A sort-merge over the candidates’ exact hashes confirms which are real repeats, so no unique paragraph is ever dropped, whatever the filter size (test_random_corpora_match_the_set_oracle).
  • The filter is sized from the corpus config’s bloom_bytes_per_item: p=e−8B(ln⁡2)2p = e^{-8B(\ln 2)^2} and m≈8BNm \approx 8BN bits for NN paragraphs (test_bloom_is_sized_from_bytes_per_item).
  • Dedup must read its whole input before it can emit anything; it spools to disk, not to memory, and cleans up after itself (test_the_spool_is_removed).
Terminal window
ol start data.03 # stubs python/corpus/dedup.py
ol tests data.03 # read the test catalog first
ol check data.03 # needs data.02 and M06.3 passing (or --ref-deps)
ol diff data.03 # after passing: your code against the reference

The Python Bloom screen uses the hash functions from M06.3 and follows the shared bit-sizing rules. The standalone Rust Bloom module has its own tests and does not cross into this stage.


After data.02 every document is clean, English, and plausibly prose, and many of them say the same thing. Web pages share navigation and license footers; dataset mirrors repeat whole files; story collections reuse an opening paragraph. A model trained on that sees the repeated text again and again: Lee et al. found that deduplicating C4 removed text that models otherwise reproduced verbatim, and trained faster to the same loss. Repeated rare strings are also the ones a model memorizes, which matters for privacy (ethics.02). The obvious fix, a Python set of every paragraph seen, costs about 90 bytes per paragraph (a 32-byte digest as a bytes object is 65 bytes, plus its slot in the set), so a billion paragraphs need 90 GB. A compact Python Bloom screen does the screening in about 10 bytes each. This module defines its local use and shows what false positives mean for correctness.

A paragraph is a piece of the text between blank lines; a blank line is empty or holds only spaces and tabs. Each piece is stripped of white space at both ends, and empty pieces are skipped. A single line break stays inside a paragraph. After data.02’s normalization, paragraphs are separated by exactly \n\n.

Comparing paragraphs by their text would mean storing the text. Instead compare their SHA-256 digests: paragraph_hash(p) = sha256(p.encode("utf-8")).digest(), 32 bytes. Equal paragraphs have equal digests; unequal ones collide with probability about 2−2562^{-256}, which for our purposes is never. A paragraph’s repeat is any occurrence whose digest occurred earlier in the input, in an earlier document or earlier in the same one.

Why paragraphs and not whole documents? Duplicates are rarely whole documents: a copied story with a new title, or a page with a shared footer, differs as a document and repeats as paragraphs. Why not sentences or substrings? Shorter units remove ordinary phrases (“Once upon a time, there was a little girl.”) from unrelated stories; finding repeated substrings of any length needs a suffix array (Lee et al.), a different tool. Paragraphs are the CCNet compromise.

A Bloom filter (ds.08, formats/bloom.md) is a bit array of mm bits and kk hash functions. Inserting an item sets its kk bits; asking about an item answers “present” when all kk bits are set.

SymbolMeaningType
NNparagraphs in the input (the items the filter will hold)integer
mmbits in the filterinteger
kkhash functions (bits per item)integer
BBbloom_bytes_per_item: filter bytes per paragraph, so m=8BNm = 8BNfloat, >0> 0
ppthe false-positive rate: the chance that a never-inserted item is reported presentfloat in (0,1)(0, 1)

Two facts from S-M06b drive this module:

  1. No false negatives. An inserted item’s bits stay set, so the filter never says “new” about something it has seen.
  2. False positives. With the optimal k=(m/N)ln⁡2k = (m/N)\ln 2, the rate is p=e−(m/N)(ln⁡2)2p = e^{-(m/N)(\ln 2)^2}. Solving for the memory per item gives m/N=−ln⁡p/(ln⁡2)2m/N = -\ln p / (\ln 2)^2. formats/bloom.md sizes a filter that way, so for a budget of BB bytes per item (m/N=8Bm/N = 8B bits) ask for

p=e−8B(ln⁡2)2,m=⌈−Nln⁡p(ln⁡2)2⌉≈8BN.p = e^{-8B(\ln 2)^2}, \qquad m = \left\lceil \frac{-N \ln p}{(\ln 2)^2} \right\rceil \approx 8BN .

bloom_rate(B) computes that pp: B=10B = 10 gives p≈2.0×10−17p \approx 2.0 \times 10^{-17}; B=1B = 1 gives p≈0.021p \approx 0.021; B=0.25B = 0.25 gives p≈0.38p \approx 0.38.

The screen pass hashes every paragraph in input order and asks the filter: if it answers “present”, the digest becomes a candidate; otherwise it is inserted. By fact 1, every digest that occurs twice or more becomes a candidate at its second occurrence. By fact 2, some digests that occur once become candidates too: the false positives. So the candidates are a superset of the repeated digests, and a small one: (repeated digests) + (about pNpN false positives).

The confirm pass reads the input again and records an occurrence (h,i,j)(h, i, j) for every paragraph whose digest hh is a candidate, where ii is the document’s index and jj the paragraph’s index within it. Sort the occurrences: equal digests become adjacent, and within one digest the order is input order (by ii, then jj). Then merge, walking runs of equal hh:

  • a run of length 1 is a false positive: that paragraph is unique, keep it;
  • a run of length r≥2r \ge 2 is a confirmed repeat: keep its first occurrence and mark the other r−1r - 1 for removal.

Sorting is what makes this scale: an external sort handles candidate lists larger than memory, which a hash map of counts does not. Here the candidates fit in a Python list.

The stage consumes an iterator, which can be read once, but the algorithm needs the input three more times. So it spools: the first pass writes every document as one JSON line to a temporary file and counts NN; the screen, confirm, and emit passes reread that file. Memory holds the filter, the candidate set, and the candidate occurrences, never the documents. The cost is that nothing is emitted until the input is exhausted: dedup is a barrier in the otherwise streaming pipeline. The spool lives in a tempfile.TemporaryDirectory (under spool_dir when given), which is removed when the generator finishes or is closed early, because the spool is a full copy of the corpus.

The emit pass yields each document with its marked paragraphs removed: unchanged (the same Doc, exact text and meta) when it lost nothing, with the remaining paragraphs joined by \n\n when it lost some, and not at all when it lost every paragraph. Output order is input order. With stats={} passed in, the stage fills docs_in, paragraphs, bloom_bits (mm), candidates, false_positives, duplicate_paragraphs, docs_dropped, and docs_out once the output is exhausted; the corpus manifest’s dedup.exact_dropped and the datasheet report them.

Three documents, B=10B = 10:

DocText
A (s:0)The cat sat. (P1), blank line, The dog ran. (P2)
B (s:1)The dog ran. (P2), blank line, A bird sang. (P3)
C (s:2)The cat sat. (P1)

Spool. N=5N = 5 paragraphs. The filter has m=⌈5⋅80.0⌉=400m = \lceil 5 \cdot 80.0 \rceil = 400 bits and k=round(400/5⋅ln⁡2)=55k = \text{round}(400/5 \cdot \ln 2) = 55 hashes. The digests begin h(P1) = 84549cfa..., h(P2) = 0ea960d6..., h(P3) = c2c47b92....

Screen, in input order:

Occurrence (i,j)(i, j)ParagraphFilter saysAction
(0, 0)P1newinsert
(0, 1)P2newinsert
(1, 0)P2presentcandidate h(P2)
(1, 1)P3newinsert
(2, 0)P1presentcandidate h(P1)

Confirm. The occurrences of the two candidates are (h(P1),0,0)(h(P1), 0, 0), (h(P2),0,1)(h(P2), 0, 1), (h(P2),1,0)(h(P2), 1, 0), (h(P1),2,0)(h(P1), 2, 0). Sorted (0e... before 84...): (h(P2),0,1)(h(P2), 0, 1), (h(P2),1,0)(h(P2), 1, 0), (h(P1),0,0)(h(P1), 0, 0), (h(P1),2,0)(h(P1), 2, 0). Two runs of length 2: remove (1,0)(1, 0) and (2,0)(2, 0). No run of length 1, so no false positives.

Emit. A loses nothing and comes out unchanged. B loses P2 and comes out as A bird sang.. C loses its only paragraph and is dropped. Stats: 3 documents in, 5 paragraphs, 400 bits, 2 candidates, 0 false positives, 2 duplicate paragraphs, 1 document dropped, 2 out.

With B=0.25B = 0.25 the filter would have ⌈5⋅2⌉=10\lceil 5 \cdot 2 \rceil = 10 bits and might report P3 as present on first sight. P3 would become a candidate, its run in the confirm pass would have length 1, and it would be kept: the output is identical, only false_positives grows.

STATS_KEYS: tuple[str, ...]
def paragraphs(text: str) -> list[str]: ...
def paragraph_hash(p: str) -> bytes: ...
def bloom_rate(bloom_bytes_per_item: float) -> float: ...
def exact_dedup(docs: Iterable[Doc], bloom_bytes_per_item: float = 10, *,
spool_dir: str | Path | None = None,
stats: dict[str, Any] | None = None) -> Iterator[Doc]: ...

In a pipeline, bind the options: compose(build_filters(cfg["filters"]), functools.partial(exact_dedup, bloom_bytes_per_item=cfg["dedup"]["bloom_bytes_per_item"])). The private Python filter is sized for max(N, 1) at bloom_rate(B). Use insert and contains; its m is the bit count reported in stats.

TestKINDChecksWhy it matters downstream
test_hand_example_three_documentsunitthe section 3 example: A unchanged, B as P3, C dropped, the countsyou and the test agree on “first occurrence”
test_fixture_duplicate_countsconformance200 documents with planted copies: paragraph, repeat, and dropped counts and the sha256 of every kept text, all fixed by the generatorthe counts in the manifest and the datasheet
test_zero_false_drops_with_a_tiny_filterpropertyat B=0.25B = 0.25 (over 50 false positives) the output equals the set oracle, and candidates = repeated digests + false positivesa Bloom positive never drops a unique paragraph
test_random_corpora_match_the_set_oracleproperty40 seeded corpora at BB = 0.1, 1, 10: equal to the set oraclecorrectness does not depend on the filter size
test_paragraph_split_rulesunitblank lines with spaces or tabs, single line breaks, stripping, empty pieceswhat counts as a repeat
test_a_repeat_inside_one_document_is_removedboundarya repeated footer inside one document goes, the document staysrepeats are positional, not per document
test_untouched_documents_keep_their_exact_textunita document with no repeats comes back equal, line breaks and meta includeddedup changes only what it dedups
test_bloom_is_sized_from_bytes_per_itemunitbloom_rate values; m=⌈−Nln⁡p/(ln⁡2)2⌉m = \lceil -N \ln p / (\ln 2)^2 \rceil within 1 of 8BN8BN on the fixturethe memory knob of the config means what it says
test_stats_name_every_keyunitevery STATS_KEYS entry, also for an empty inputthe manifest and datasheet fields
test_the_spool_is_removedunitno spool left after a full read or an early close()the spool is a full copy of the corpus
test_exact_dedup_is_a_stageunitcomposes with functools.partial; partial documents joined by \n\nthe pipeline wiring of CorpusBuild
PitfallSymptomCaught by
1. Keeping the last occurrence instead of the firstthe output depends on what came later; reruns with appended sources rewrite earlier documentstest_hand_example_three_documents (mutant s01)
2. Hashing whole documentscopies with a new title or footer survive; repeat counts far too lowtest_fixture_duplicate_counts (mutant s03)
3. Dropping every Bloom positive without confirmingwith a small filter, unique paragraphs vanish; nothing reports ittest_zero_false_drops_with_a_tiny_filter (mutant s04)
4. Merging occurrences without sorting themequal digests are not adjacent; repeats look unique and survivetest_fixture_duplicate_counts (mutant s05)
5. Splitting paragraphs only on \n\na blank line holding a space hides a paragraph boundary; repeats stick to their neighbourstest_paragraph_split_rules (mutant s07)
6. Rebuilding every document from its paragraphsuntouched documents change (line breaks collapse); later hashes no longer match the filter stage’s outputtest_untouched_documents_keep_their_exact_text (mutant s09)
7. Sizing the filter by documents, not paragraphsa filter several times too small; the false-positive rate is far above the budgettest_bloom_is_sized_from_bytes_per_item (mutant s10)
8. A spool that outlives the stageevery run leaves a full copy of the corpus in the temp directorytest_the_spool_is_removed (mutant s13)
DirectionModuleHow it uses this
Backdata.02Doc, compose, and the normalized text whose paragraphs are separated by exactly one blank line
BackM06.3FNV-1a and SplitMix64 supply the shared Bloom hash rules
Forwarddata.04near dedup (MinHash, LSH) runs on what this stage keeps, so exact copies never reach the more expensive pass
Forwarddata.06the manifest’s dedup.exact_dropped comes from these stats

If you skip this module, the near-dedup stage has to remove exact copies itself, at MinHash cost.

Your pieceProduction equivalentWhat it addsWhere to look
exact_dedupLee et al.’s suffix-array dedup; datatrove’s exact-substring stages (datatrove/pipeline/dedup/exact_substrings.py)repeated substrings of any length (50 tokens and up), across the whole corpusgoogle-research/deduplicate-text-datasets
paragraph hashesCCNet’s paragraph dedup; Dolma’s bff (a Bloom filter deduplicator)sharded hash sets over Common Crawl snapshots; a Rust Bloom filter over billions of paragraphsallenai/bff
sort-merge confirmexternal merge sort; Spark dropDuplicatescandidate lists larger than memory, spilled and merged from disksq.spark
the screencounting Bloom filters, cuckoo filtersdeletions; better space at low false-positive ratesFan et al., “Cuckoo Filter” (2014)