Skip to main content

Deduplicating a Training Corpus with Hashes

Level 11: Data Engineering for AImedium28 minexact-hash deduplicationROW_NUMBER with tiebreakersMinHash and LSHdedup at read vs materialized dedupwindow functionsworkspace scriptsidempotency

Dedup a crawled corpus by exact content hash with ROW_NUMBER and a composite tiebreaker, materialize the result idempotently, and place fuzzy and semantic dedup as the same idea at higher cost.

The first real stage of a training pipeline is throwing things away

A training corpus starts as a crawl, and a crawl of the open web is mostly the same pages over and over: mirrors, syndication feeds, CDN caches, aggregators republishing an article verbatim, and the same forum thread reachable at four different URLs. Deduplication removes 20 to 30 percent of raw web data, before any other quality work happens. Falcon's RefinedWeb pipeline took roughly 1 billion crawled pages down to 2.8 TB of clean text, and dedup did a large share of that reduction.

The reason this matters is not disk space. A document that appears four times is seen four times during training, which quietly reweights the corpus toward whatever happens to be mirrored the most. And a duplicate is one of the ways evaluation text sneaks into training data, which is how a benchmark score stops meaning anything.

Exact dedup is a hash plus a keep rule

An exact duplicate is not "two documents that look alike". It is two documents whose bytes are identical, which the pipeline knows because the extract stage already stamped every document with a content hash. Two rows with the same content_sha256 hold the same content, full stop, no matter how different their URLs look.

So exact dedup in SQL is two decisions and nothing else:

  1. Group by the hash. That is the duplicate set.
  2. Pick one survivor per group. That is the keep rule, and it is a business decision, not a technical one.

The keep rule needs to be deterministic, which means it must break every tie it can hit. "Keep the earliest crawl" is not enough on its own, because two mirrors can be captured on the same day, and if two rows tie on the ordering column then which one survives depends on how the engine happened to scan the table. That is why the canonical form carries a second ordering column:

ROW_NUMBER() OVER (
  PARTITION BY content_sha256      -- the duplicate set
  ORDER BY crawl_dt, doc_id        -- the keep rule, plus the tiebreaker
) AS rn

Then WHERE rn = 1 keeps exactly one row per hash. Every row gets a number inside its own hash group, the lowest number goes to the earliest crawl, and doc_id settles the days where two copies arrived together.

You have written this query before. SQL Level 4 graded it on a customer staging table, keeping one latest row per email, and this course has graded the same keep-one twice since: Level 8's append-only bronze ranks redeliveries and keeps rn = 1 in the read view, and Level 9's read-side dedup does it again over an at-least-once stream. It is the single most-cited SQL task in the interview corpus behind this course: windowed dedup with ROW_NUMBER and a composite tiebreaker. Nothing about it changes because the rows are now web pages destined for a model. That is the whole point of this level: the AI-era stage is the pattern you already own, pointed at a new table.

Fuzzy and semantic dedup are the same idea, at higher cost

Exact hashing only catches byte-identical copies. Change one timestamp in a footer and the hash changes completely, so the two curation stages that follow relax the definition of "same":

Table
Three definitions of duplicate at increasing cost. Every pipeline runs exact dedup; fuzzy is common; semantic is a budget decision.
stagewhat counts as a duplicatehow it is computedcost
exactidentical bytesGROUP BY a content hash, keep oneone pass, cheap enough to always run
fuzzymostly the same wordsMinHash signatures, LSH buckets, then Jaccard overlap inside a bucketmany hashes per document plus a bucketed comparison
semanticthe same meaning in different wordsembed every document, cluster the vectors, keep one per clusteran embedding call per document plus a clustering pass
Three definitions of duplicate at increasing cost. Every pipeline runs exact dedup; fuzzy is common; semantic is a budget decision.

Fuzzy dedup (MinHash plus LSH plus a Jaccard threshold) is the workhorse of the three: MinHash turns each document into a small signature, LSH puts similar signatures in the same bucket so you never compare every pair, and Jaccard overlap makes the final call inside a bucket. None of that is expressible in a graded SQL exercise, and it does not need to be. What an interviewer wants to hear is the ladder: exact first because it is nearly free, fuzzy next because near-duplicates are the bulk of the remaining waste, semantic only if the corpus and the budget justify it.

Materialized dedup versus dedup at read

Level 9 already made you choose between absorbing duplicates at the write and absorbing them at the read, and Level 8 graded the read-side half of it directly, so you know both sides of this tradeoff. What curation adds is a reason the choice is not close: read amplification. A training job scans the corpus tens of times, so materializing the survivors into their own table means paying the window function once at build time instead of paying it on every scan. Dedup at read would charge that cost to every reader, forever, on a table whose contents stop changing the moment the crawl is processed.

So curation pipelines materialize. The practice below writes exactly that table, and it has to survive being run twice, which is the part that catches people: a dedup job that duplicates its own output on a rerun is a joke that writes itself.

Common mistake: deduplicating on the URL instead of on the content hash. The URL is the one thing every mirror changes. https://blog.example.com/parquet-guide and https://cdn.example.net/cache/parquet-guide are different strings holding the same bytes, and a URL-keyed dedup keeps both while a hash-keyed dedup keeps one.

Interview nuance: when you write the ROW_NUMBER, say the tiebreaker out loud. "I partition by the content hash and order by crawl date, then doc_id, so the result is deterministic even when two copies land on the same day" is the sentence that separates someone who has run this in production from someone who has seen it on a slide. Then add where the output goes, materialized table or view, and why.

On a real platform this differs. Here the corpus is 17 rows in SQLite and the hash is a column somebody already filled in. NVIDIA's NeMo Curator runs this stage over JSONL or Parquet shards on object storage with the hashing done in the extract stage, and at that scale the dedup is distributed: hash, shuffle rows that share a hash to the same worker, keep one per group. Spark's dropDuplicates makes the first decision, group by the hash, and skips the second: it keeps whichever copy the engine happened to hold, and you get no say. That is why production jobs write the row_number window over a Delta table instead, exactly as you did here. Of the two it is the only one that lets you name the keep rule.

Sample data for this example
CREATE TABLE web_documents (
  doc_id         TEXT,
  url            TEXT,
  content_sha256 TEXT,     -- first 16 hex chars of the document's content hash
  word_count     INTEGER,  -- byte-identical copies necessarily share a word count
  crawl_dt       TEXT      -- YYYY-MM-DD, the crawl that captured this copy
);
INSERT INTO web_documents (doc_id, url, content_sha256, word_count, crawl_dt) VALUES
  ('doc_001', 'https://research.example.edu/papers/vectors',      '1a4d7e02b93c58f6', 4120, '2026-01-03'),
  ('doc_014', 'https://news.example.com/ai-policy',               '3f1c9a7d55b0e28a', 1970, '2026-01-05'),
  ('doc_012', 'https://scrape.example.co/spark-tuning',           'c05e71ab3d9f4820', 3310, '2026-01-09'),
  ('doc_011', 'https://mirror.example.org/parquet-guide',         '8b24e6c0119fa7d3', 2380, '2026-01-18'),
  ('doc_002', 'https://blog.example.com/parquet-guide',           '8b24e6c0119fa7d3', 2380, '2026-01-18'),
  ('doc_006', 'https://news.example.com/quarterly-earnings',      '62f8d1c4470ae9b3',  880, '2026-01-27'),
  ('doc_005', 'https://docs.example.io/spark-tuning',             'c05e71ab3d9f4820', 3310, '2026-02-02'),
  ('doc_008', 'https://wiki.example.org/wiki/Data_lake',          '4e90ba61f2d8c073', 5450, '2026-02-06'),
  ('doc_003', 'https://mirror.example.org/ai-policy',             '3f1c9a7d55b0e28a', 1970, '2026-02-11'),
  ('doc_016', 'https://standards.example.org/spec/json-lines',    '9e0c4b7a2f38d156', 2960, '2026-02-15'),
  ('doc_007', 'https://cdn.example.net/cache/parquet-guide',      '8b24e6c0119fa7d3', 2380, '2026-02-20'),
  ('doc_013', 'https://journal.example.edu/issue/44/etl-history', 'ab182f6c4d70e935', 7240, '2026-02-25'),
  ('doc_009', 'https://aggregator.example.net/p/ai-policy',       '3f1c9a7d55b0e28a', 1970, '2026-03-02'),
  ('doc_017', 'https://blog.example.dev/posts/orc-vs-parquet',    '5c8a3e14d6b09f27', 1840, '2026-03-05'),
  ('doc_010', 'https://help.example.com/kb/billing',              '77c3e5d089ab146f',  510, '2026-03-09'),
  ('doc_004', 'https://forum.example.com/thread/9912',            'd7a3f0928c1b6e45',  640, '2026-03-14'),
  ('doc_015', 'https://forum.example.com/thread/9912?page=1',     'd7a3f0928c1b6e45',  640, '2026-03-20');
Worked example (SQL)
-- Every content hash that shows up more than once, with how many copies the crawl captured and
-- the window of dates they arrived over. These four groups are what the dedup stage collapses.
SELECT content_sha256,
       COUNT(*) AS copies,
       COUNT(DISTINCT crawl_dt) AS crawl_days,
       MIN(crawl_dt) AS first_crawled,
       MAX(crawl_dt) AS last_crawled
FROM web_documents
GROUP BY content_sha256
HAVING COUNT(*) > 1
ORDER BY copies DESC, content_sha256;

Apply

Your turn

The task this lesson builds to.

Write a query that returns the surviving document for each content hash, as (doc_id, content_sha256, crawl_dt), sorted by doc_id, over web_documents(doc_id, url, content_sha256, word_count, crawl_dt).

The survivor is the copy with the earliest crawl_dt, and when two copies share that date the lower doc_id wins. Return exactly one row per content_sha256.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 2 bonus drills.

Write a script that fills web_documents_dedup with exactly one row per content_sha256, using the same keep rule as the apply: earliest crawl_dt wins, and the lower doc_id breaks a shared date.

web_documents_dedup already exists with the same five columns as web_documents and starts empty. Carry the survivor's url and word_count across unchanged, and leave web_documents itself untouched.

The curation job reruns whenever the week's crawl is re-processed, so your script is graded twice against the same corpus. After the second run web_documents_dedup must still hold exactly one row per content hash.

3 hints and 5 automated checks are waiting in the workspace.