Guide · System design
Near-Duplicate Detection: Shingles, MinHash, SimHash and LSH at Scale
28 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Near duplicate detection finds documents that are almost the same, such as one news story republished with a new headline. It compares small overlapping word runs, not whole files.
- Shingles are those runs, for example every 5 words in a row. Jaccard similarity is the share of shingles two documents have in common, from 0 (nothing shared) to 1 (identical).
- MinHash squeezes each document into a short list of numbers, called a signature, whose matching positions estimate Jaccard similarity. SimHash squeezes it into one 64-bit number where similar documents tend to differ in few bits.
- LSH (locality-sensitive hashing) banding puts likely matches into the same bucket, a shared slot in a lookup table, so you compare only a few candidate pairs instead of every pair. You then check each candidate and group the matches.
- Beginners can stop after the MinHash section. Engineers then get banding maths, a measured catch in datasketch 2.0.0 (a Python library) where threshold 0.8 missed many true pairs, SimHash bit tables and a tested pipeline.
What goes wrong when the same story shows up six times in a feed?
Readers scroll past the same news six times and stop trusting the feed. Near duplicate detection is the step that spots these copies, even when every copy differs a little from the others.
Picture a common setup. A news app in India pulls about 2 lakh (200,000) articles a day from 300 publishers, and keeps an archive of 1 crore (10 million) articles. The setup is made up, but the problem is real.
One news agency story about heavy rain in coastal Karnataka reaches the app from six sites. One adds "Also read" links. Another changes the headline and one word in the body. A third copies it with different spacing and capital letters.
To a computer, these are six different documents. The app needs to show one card and keep the others as alternate sources. The rest of this guide builds that step, from the simplest check to a pipeline that handles the full archive.
Which words do you need before learning near-duplicate detection?
You need seven words. Each one is tied to the rain story.
- Document: one piece of text you want to compare, here one article body.
- Set: a collection where each item counts once. Two documents become two sets of word runs.
- Hash: a function that turns any text into a fixed-size number. The same text always gives the same number.
- Shingle: a run of k words in a row. With k = 3, "heavy rain is likely" gives "heavy rain is" and "rain is likely".
- Jaccard similarity: shared shingles divided by all distinct shingles in either document. It runs from 0 to 1.
- Signature or fingerprint: a small summary of a document, built from hashes, that you compare instead of the full text.
- Candidate pair: two documents that a fast filter says might be near-duplicates. A slower exact check confirms or rejects them.
What is near duplicate detection and how does it work?
Near duplicate detection is a pipeline. It turns each document into a set of shingles, compresses that set into a small signature, and uses signatures to find and confirm pairs above a similarity threshold.
The point is to avoid comparing every document with every other one. For 1 crore articles that is 49,999,995,000,000 pairs, about 50 lakh crore (50 trillion). At the 47 microseconds per pair our exact check took, that is about 75 years on one core.
The pipeline below is what the rest of this guide builds and tests. Each stage throws away work the next stage would waste time on.
A near-duplicate pipeline, stage by stage
Drag to exploreNotice that only candidate pairs reach the exact check. The cheap stages on the left decide how much work the slow stage on the right has to do.
Diagram connections:
- New articles to Normalise text (raw text)
- Normalise text to Exact hash check (clean text)
- Exact hash check to Shingles (not seen before)
- Shingles to MinHash signature (set of shingles)
- MinHash signature to LSH band buckets (signature)
- LSH band buckets to Candidate pairs (same bucket)
- Candidate pairs to Verify similarity (check each pair)
- Verify similarity to Group into clusters (confirmed pairs)
- Group into clusters to One card per story (clusters)
- New articles: About 2 lakh a day in the made-up app
- Normalise text: Lower case, one space between words
- Exact hash check: Standard hash (SHA-256) of clean text drops copies
- Shingles: Every 5 words in a row become one item
- MinHash signature: 128 numbers per article
- LSH band buckets: 16 bands of 8 numbers each
- Candidate pairs: Shared at least one band bucket
- Verify similarity: Exact Jaccard on shingles, keep 0.8 or more
- Group into clusters: Union-find (merges groups) joins confirmed pairs
- One card per story: Others kept as alternate sources
Two families of signatures exist. MinHash goes back to Andrei Broder's 1997 paper on resemblance and containment, and estimates Jaccard similarity. SimHash builds on Moses Charikar's 2002 paper and gives one fingerprint where similar documents tend to differ in few bits, especially long ones.
Why do exact hashes and all-pairs checks both fail?
An exact hash catches only perfect copies, and checking all pairs is far too slow. Most teams try both first, so it helps to see each fail on the rain story.
The naive exact hash
The naive first try is to hash each article and drop repeats. Normalising first, meaning lower case and single spaces, does catch the copy with different spacing and capitals. That is worth keeping as a cheap first pass.
But change one word and the hash changes completely, because a good hash is designed to look random. The copy that says "Sunday evening" instead of "Sunday" sails through. So does the one with "Also read" links.
In our test pipeline of 1,20,000 (120,000) synthetic articles, the exact pass removed 6,667 copies that differed only in case and spacing. It found none of the 13,333 edited copies.
The naive all-pairs check
The second naive try is to compute Jaccard similarity for every pair. That is correct, but the pair count grows with the square of the archive. Even 1,20,000 articles make 7,199,940,000 pairs.
Near duplicate detection fixes both problems. Shingles make small edits cause small changes, and signatures with LSH make most pairs never get compared.
How do shingles turn a document into a set you can compare?
Slide a window of k words across the text and keep each window as one item in a set. A one-word edit then changes only the k shingles that cover that word, and every other shingle still matches.
Here is the idea on two short sentences that differ in one word. It is plain Python with no libraries.
def shingles(text, k=3):
words = text.lower().split()
return {" ".join(words[i:i + k]) for i in range(len(words) - k + 1)}
def jaccard(a, b):
return len(a & b) / len(a | b)
x = "the monsoon session of parliament begins on monday with a debate on the data bill"
y = "the monsoon session of parliament begins on tuesday with a debate on the data bill"
sx, sy = shingles(x), shingles(y)
print(len(sx), len(sy), len(sx & sy)) # shingles in x, in y, shared
print(round(jaccard(sx, sy), 3))It prints 13 13 10, then 0.625. Each sentence has 13 three-word shingles. Changing "monday" to "tuesday" breaks the 3 shingles that contain it, so 10 are shared out of 16 distinct ones.
Choosing the shingle size k
Small k makes unrelated documents look alike, and large k makes small edits look big. We measured both effects on synthetic 300-word articles built from real English words, with 200 trials per cell.
| Shingle size k | 1 word changed | 10 words changed | Two unrelated articles |
|---|---|---|---|
| 1 (single words) | 0.987 | 0.882 | 0.239 |
| 3 | 0.980 | 0.821 | 0.037 |
| 5 | 0.967 | 0.729 | 0.001 |
| 9 | 0.942 | 0.583 | 0.000 |
With single words, unrelated articles scored 0.239, because common words like "the" and "of" appear everywhere. With k = 5, unrelated pairs fell to about 0.001, while one changed word still left 0.967.
The arithmetic explains the 10-word column. A 300-word article has 296 five-word shingles. Ten scattered edits break up to 50 of them and create up to 50 new ones, so Jaccard is about 246 out of 346, or 0.71.
So "97 percent of the words are the same" can mean a Jaccard of 0.73. Keep that gap in mind when you pick a threshold later.
The Mining of Massive Datasets course slides give the same caveat: pick k large enough, or most documents will have most shingles. For very short text such as titles, word shingles are too few. Character shingles, runs of characters instead of words, give more items to compare.
How does MinHash estimate Jaccard similarity?
MinHash applies the same random hash function to every shingle of both documents and keeps only the smallest value from each. With an ideal random hash, the chance the two smallest values match equals the Jaccard similarity.
Repeat that with 128 different hash functions and the share of matches is a good estimate. Why does it work? Imagine mixing both shingle sets together and picking the one with the smallest hash. Every shingle is equally likely to be picked. Pick one that sits in both documents, and the two minimums match.
The chance of picking a shared shingle is shared shingles divided by all distinct shingles. That is the Jaccard similarity. Broder's paper proves this for random permutations, which here means randomly reordering all possible shingles.
The wedding guest list picture
Think of two families planning one joint wedding reception, each with its own guest list. The shared guests are the shingles both documents have.
- Each family's guest list is one document's set of shingles.
- A lucky draw that shuffles every name on both lists is one hash function.
- The first name drawn from each family's own list is that document's minimum.
- Both families get the same first name only when the overall first name is a shared guest.
- Running 128 separate draws and counting matches is the MinHash signature comparison.
Where the picture breaks: a real hash function is not a perfect shuffle. As the next test shows, a weak one can quietly push every estimate upwards.
MinHash in NumPy
This version uses 128 hash functions of the form a times x plus b, keeping the top 32 bits. NumPy, the Python library for fast number arrays, does all 128 at once. We ran it on NumPy 2.5.4.
import hashlib
import numpy as np
def shingle_ids(text, k=3):
"""Hash each k-word shingle to a 64-bit number."""
words = text.lower().split()
ids = {int.from_bytes(hashlib.blake2b(" ".join(words[i:i + k]).encode(),
digest_size=8).digest(), "little")
for i in range(len(words) - k + 1)}
return np.fromiter(ids, dtype=np.uint64)
def make_hashes(n=128, seed=7):
"""n hash functions h(x) = top 32 bits of (a*x + b), a odd, wrapping at 2**64."""
rng = np.random.default_rng(seed)
a = rng.integers(0, 2**63, size=n, dtype=np.uint64) * np.uint64(2) + np.uint64(1)
b = rng.integers(0, 2**63, size=n, dtype=np.uint64)
return a, b
def minhash(ids, a, b):
with np.errstate(over="ignore"):
h = np.right_shift(np.outer(a, ids) + b[:, None], np.uint64(32))
return h.min(axis=1) # one minimum per hash function
x = "the monsoon session of parliament begins on monday with a debate on the data bill"
y = "the monsoon session of parliament begins on tuesday with a debate on the data bill"
a, b = make_hashes()
sx, sy = minhash(shingle_ids(x), a, b), minhash(shingle_ids(y), a, b)
print(sx.shape, round(float(np.mean(sx == sy)), 3))It prints (128,) 0.609. The signature is 128 numbers, and 61 percent of positions match, against a true Jaccard of 0.625. Short texts give noisy estimates, which the next table measures.
Our first version used a weaker formula, a times x plus b modulo a large prime, with numbers too small to mix well. Across 300 seeds it estimated 0.558 for a pair whose true Jaccard was 0.462.
The draws were lopsided. The 5 smallest of 296 shingle numbers won 20 percent of the 128 draws, against about 2 percent for a fair hash. The fixed version above averaged 0.463.
How many hash functions do you need?
The error shrinks with the square root of the number of hash functions: four times the functions gives half the error. For one pair with true Jaccard 0.462, we repeated the estimate with 300 different random seeds. We also ran datasketch 2.0.0, an open-source Python library for MinHash and LSH.
| Hash functions | Our NumPy std | datasketch 2.0.0 std | Theory | Signature size in datasketch |
|---|---|---|---|---|
| 16 | 0.125 | 0.117 | 0.125 | 64 bytes |
| 64 | 0.063 | 0.062 | 0.062 | 256 bytes |
| 128 | 0.044 | 0.045 | 0.044 | 512 bytes |
| 256 | 0.031 | 0.031 | 0.031 | 1,024 bytes |
Std is the standard deviation, the typical distance of an estimate from its average. At 128 hash functions, most estimates land within about 0.09 of the truth, two standard deviations. That is fine for finding candidates, which you then check exactly.
The sizes assume datasketch's default storage in version 2.0.0: 4 bytes per value. The datasketch MinHash docs put the sampling error of a 128-value signature at about 0.03 to 0.04. Our 0.044 sits at the top of that band, because pairs near 0.5 are the noisiest.
Beginners can stop here: you know what a near-duplicate is, how shingles and Jaccard measure it, and how MinHash makes it cheap. The rest covers finding pairs at scale, thresholds, SimHash and a full pipeline.
How does LSH banding find candidate pairs without comparing everything?
LSH banding cuts each 128-number signature into b bands of r numbers and hashes each band into a bucket. Two documents become a candidate pair if any one band lands in the same bucket.
A pair with Jaccard s matches one whole band with chance s to the power r. It misses all b bands with chance one minus that, to the power b. So the chance of becoming a candidate is 1 minus (1 minus s to the r) to the b.
LSH banding on one 128-number signature
Drag to exploreNotice that a pair needs only one matching band to become a candidate. More rows per band make each match rarer; more bands give more chances.
Diagram connections:
- MinHash signature to Split into 16 bands (128 numbers)
- Split into 16 bands to Hash each band (16 groups of 8)
- Hash each band to 16 bucket tables (16 bucket keys)
- 16 bucket tables to Same bucket in any band? (look up each key)
- Same bucket in any band? to Candidate pair (Yes, at least one)
- Same bucket in any band? to Never compared (No band matched)
- MinHash signature: 128 numbers for one article
- Split into 16 bands: Each band holds 8 numbers
- Hash each band: Band 1 goes to table 1, band 2 to table 2
- 16 bucket tables: One table per band
- Same bucket in any band?: Compared with articles already stored
- Candidate pair: Sent to the exact Jaccard check
- Never compared: Most pairs end here at no cost
Plot that chance against s and you get an S-shaped curve. Below the bend almost nothing becomes a candidate; above it almost everything does. Choosing b and r moves the bend.
def candidate_chance(s, b, r):
"""Chance that a pair with Jaccard s matches in at least one of b bands of r rows."""
return 1 - (1 - s ** r) ** b
for s in (0.5, 0.7, 0.8, 0.9):
print(s, round(candidate_chance(s, b=16, r=8), 3), round(candidate_chance(s, b=9, r=13), 3))It prints four rows. With 16 bands of 8, a pair at 0.8 becomes a candidate with chance 0.947. With 9 bands of 13, the same pair gets only 0.399.
| Bands x rows | s = 0.5 | s = 0.7 | s = 0.8 | s = 0.9 | Curve crosses 0.5 at |
|---|---|---|---|---|---|
| 32 x 4 | 0.873 | 1.000 | 1.000 | 1.000 | 0.38 |
| 16 x 8 | 0.061 | 0.613 | 0.947 | 1.000 | 0.67 |
| 9 x 13 | 0.001 | 0.084 | 0.399 | 0.929 | 0.82 |
| 5 x 25 | 0.000 | 0.001 | 0.019 | 0.311 | 0.92 |
We checked the formula with datasketch's MinHashLSH index. With 16 bands of 8, pairs averaging Jaccard 0.628 became candidates 36 percent of the time; the formula predicted 33 percent.
The Mining of Massive Datasets slides work the classic case of 20 bands of 5 rows. A pair at 0.8 is found 99.965 percent of the time, while a pair at 0.3 slips through about 4.74 percent of the time.
How do you pick a similarity threshold for near duplicate detection?
Pick the threshold from labelled examples of your own data, then choose bands that catch pairs at that threshold, and confirm every candidate with an exact check. The threshold is a product decision first and a maths decision second.
For the news app, two articles that differ only in a headline and an "Also read" line are duplicates. Two articles about the same rain, written separately, are not. Separately written articles share some phrases, but far fewer 5-word runs than a copy does. A labelled sample, described below, shows where the line sits for your data.
The datasketch threshold is soft
datasketch's MinHashLSH takes a threshold argument and picks b and r for you. Its source code tries every pair of b and r. It keeps the pair with the smallest area of false positives below the threshold plus false negatives above it, weighted equally by default.
Pause and predict: you build the index with threshold=0.8 and 128 hash functions. What share of true pairs with Jaccard between 0.80 and 0.85 will a query return? A natural guess is almost all of them.
In our 1,20,000-article run, it returned 56.6 percent of the 2,020 such pairs. Across all pairs at 0.8 or above, recall was 79.5 percent. Recall is the share of true pairs you actually find.
What just happened? datasketch chose 9 bands of 13 rows, whose curve crosses one half at about 0.82. A pair sitting exactly at the threshold has only about a 40 percent chance of being returned.
The datasketch LSH docs say this plainly: the curve is a soft threshold, and sets slightly above it are occasionally missed. Our run shows how large "occasionally" can be when many pairs sit near the line.
| Index setting | Bands x rows | Candidate pairs | Confirmed pairs | Recall | Recall at J 0.80 to 0.85 |
|---|---|---|---|---|---|
| threshold=0.8 | 9 x 13 | 5,437 | 4,760 | 79.5% | 56.6% |
| params=(16, 8) | 16 x 8 | 8,238 | 5,940 | 99.2% | 97.7% |
Setting the curve's bend below your real threshold cost about 2,800 extra candidate checks and bought back almost all the missed pairs. Each exact check took microseconds; the whole verify step took 0.39 seconds.
You can do this two ways. Pass params=(16, 8) to pin the bands yourself, or pass weights=(0.3, 0.7) to make misses count more. With 128 values and threshold 0.8, those weights gave 11 bands of 11 rows. That curve still returns only 63 percent of pairs at exactly 0.8, so print the curve before trusting it.
A threshold-setting procedure
Sample pairs at every level
Take 200 candidate pairs spread across Jaccard 0.5 to 1.0 from your own data. Use a loose index, such as 32 bands of 4, to find them.
Label them by hand
Mark each pair duplicate or not, using your product's rule. For the news app, the rule is whether a reader would call it the same story.
Read off the threshold
Find the Jaccard value where labels flip from mostly not duplicate to mostly duplicate. That is your threshold.
Choose bands below it
Pick b and r so the S-curve crosses one half a little under your threshold. Check the chance at the threshold itself is above 0.9.
Always verify candidates
Confirm every candidate with exact Jaccard on the stored shingles, or with the signature estimate if shingles are too big to keep.
The 2007 web crawling paper by Manku, Jain and Das Sarma did this kind of labelling for SimHash. They found precision and recall both near 0.75 at their chosen setting. Precision is the share of reported pairs that are really duplicates.
What is SimHash and how is it different from MinHash?
SimHash turns a whole document into one 64-bit fingerprint, where near-duplicates differ in only a few bit positions. MinHash keeps 128 separate numbers; SimHash keeps 64 bits, or 8 bytes.
To build it, hash every feature, such as a word or shingle, to 64 bits. For each bit position, add the feature's weight if that bit is 1 and subtract it if it is 0. The sign of each total becomes one bit of the fingerprint.
import hashlib
from collections import Counter
def simhash(text, k=3, bits=64):
words = text.lower().split()
features = Counter(" ".join(words[i:i + k]) for i in range(len(words) - k + 1))
totals = [0] * bits
for feature, weight in features.items():
h = int.from_bytes(hashlib.blake2b(feature.encode(), digest_size=8).digest(), "little")
for i in range(bits):
totals[i] += weight if (h // 2**i) % 2 else -weight
# a positive total gives a 1 bit; zero or negative gives a 0 bit
return sum(2**i for i, t in enumerate(totals) if max(t, 0))
def hamming(x, y):
return bin(x ^ y).count("1")
x = "the monsoon session of parliament begins on monday with a debate on the data bill"
y = "the monsoon session of parliament begins on tuesday with a debate on the data bill"
z = "the city corporation has started a drive to clear blocked drains before the monsoon"
print(f"{simhash(x):016x}")
print(hamming(simhash(x), simhash(y)), hamming(simhash(x), simhash(z)))It prints the fingerprint 0ddcdfbea0832796 in hexadecimal, then 11 33. The one-word edit moved the fingerprint 11 bits, and the unrelated sentence moved it 33. Hamming distance is the number of bit positions that differ.
Charikar's paper explains why this works. In the random-hyperplane version, each bit records which side of a random direction a vector falls on. Two vectors agree on one bit with chance 1 minus their angle divided by pi. Similar documents point in similar directions, so they share most bits.
SimHash therefore roughly tracks the angle between feature-count vectors, which is cosine similarity, not Jaccard. On our synthetic articles with 3-word features, the angle formula predicted the mean distance closely: 8.1 bits predicted, 7.8 measured, for 10 changed words.
SimHash is strict on short documents
The 15-word example shows the catch. Every feature counts for a lot in a short text, so one changed word flips many bits. On 300-word articles, we measured how often an edited copy stayed within 3 bits.
- 1 word changed: mean distance 2.3 bits, and 82 percent of copies stayed within 3 bits.
- 3 words changed: mean 4.1 bits, and 39 percent stayed within 3 bits.
- 10 words changed: mean 7.8 bits, and under 2 percent stayed within 3 bits.
- Unrelated articles: mean 26.8 bits, and none came within 3 bits.
On 2,000-word articles, 10 changed words gave a mean of 2.0 bits, and 87 percent stayed within 3. Longer documents dilute each edit, which suits whole web pages.
Feature choice matters as much. When we used single words weighted by count, unrelated synthetic articles sometimes scored 0 bits apart. Every synthetic article draws from one word list, so this is a worst case, but common words can swamp real text too.
Manku and co-authors weighted their features and used standard text cleaning, including stop-word removal, before computing 64-bit SimHash. Stop words are very common words such as "the" and "of".
How do you search 64-bit SimHash fingerprints within 3 bits?
Split the 64 bits into blocks and index each block exactly. If two fingerprints differ in at most 3 bits, those bits can touch at most 3 blocks, so with 4 or more blocks at least one block matches exactly.
This is the pigeonhole principle, and the tables in Manku and co-authors' paper rest on it. They stored about 8 billion fingerprints and needed to find any within 3 bits of a new one.
Finding fingerprints within 3 bits with block tables
Drag to exploreNotice that each table needs an exact match on only one block. Three flipped bits cannot spoil all four blocks, so the true match is always looked up somewhere.
Diagram connections:
- New 64-bit fingerprint to Cut into 4 blocks (split)
- Cut into 4 blocks to Table for block 1 (look up block 1)
- Cut into 4 blocks to Tables for blocks 2-4 (look up blocks 2-4)
- Table for block 1 to About 16 hits per table (fingerprints with block 1)
- Tables for blocks 2-4 to About 16 hits per table (fingerprints with 2, 3 or 4)
- About 16 hits per table to XOR and count bits (exact distance check)
- New 64-bit fingerprint: SimHash of the incoming article
- Cut into 4 blocks: Bits 1-16, 17-32, 33-48 and 49-64
- Table for block 1: Key: block 1 of every stored fingerprint
- Tables for blocks 2-4: Same idea, one table per block
- About 16 hits per table: With 2 to the 20 stored, 64 in total
- XOR and count bits: XOR marks differing bits; keep 3 or fewer
We built it on 2 to the 20, or 10,48,576 (1,048,576), random fingerprints. Then we planted 2,000 queries 0 to 3 bits from a stored fingerprint and 1,000 queries exactly 4 bits away.
- 4 tables, one per 16-bit block: every planted query within 3 bits was found, and none at 4 bits. Each query checked 64.9 fingerprints on average and took 0.11 milliseconds.
- 20 tables, as in the paper: blocks of 11, 11, 11, 11, 10 and 10 bits, keyed on every choice of 3 blocks. Each query checked 0.9 fingerprints and took 0.05 milliseconds.
- Brute force with NumPy over all 10,48,576 fingerprints took 4.36 milliseconds per query. It XORs each pair, which marks the differing bits, then counts them.
The trade-off is memory against work per query. Twenty tables hold twenty copies of the index; four tables do more checks per query. The paper's own designs range from 4 to 20 tables for the same reason.
Should you use MinHash or SimHash for near duplicate detection?
Use MinHash with LSH when you want a tunable Jaccard threshold and can afford about 512 bytes per document. Use SimHash when documents are long, storage per document must be tiny, and you only need very close copies.
| Question | MinHash with LSH | SimHash |
|---|---|---|
| What it estimates | Jaccard similarity of shingle sets | Cosine similarity of weighted features |
| Size per document here | 128 values, 512 bytes in datasketch 2.0.0 | 64 bits, 8 bytes |
| How you find matches | Band buckets, tuned with b and r | Block tables for a small bit distance |
| Easy to loosen the threshold | Yes, change b and r | Harder; more bits need more tables or more checks |
| Original paper | Broder, 1997 | Charikar, 2002; tables from Manku et al., 2007 |
| Good fit | Mixed lengths, a threshold like 0.8 | Long pages, very close copies |
For the news app, MinHash wins. Articles are a few hundred words, the team wants copies with edited headlines and extra links, and 1 crore signatures need about 5 GB. The next section builds that.
How do you dedupe millions of documents cheaply?
Run the cheap stages first: an exact hash pass, MinHash signatures computed in parallel, and an LSH index. Then check candidates exactly, group pairs into clusters with union-find (a structure that merges groups, explained below), and keep one document per cluster.
from datasketch import MinHash, MinHashLSH
def shingles(text, k=5):
words = text.lower().split()
return {" ".join(words[i:i + k]) for i in range(len(words) - k + 1)}
def signature(text):
m = MinHash(num_perm=128) # default scheme is "affine32" in datasketch 2.0.0
m.update_batch([s.encode() for s in shingles(text)])
return m
base = ("heavy rain is likely over coastal karnataka and goa for the next three days "
"the weather office said on sunday and fishermen have been advised not to "
"venture into the sea until thursday as wind speeds may touch 55 kmph "
"schools in two districts will stay shut on monday officials said")
articles = {
"agency-001": base,
"site-a-417": base.replace("sunday", "sunday evening"),
"site-b-092": base + " also read the full forecast on our weather page",
"site-c-310": ("the city corporation has started a drive to clear blocked drains "
"before the monsoon and residents can report problems on the helpline "
"officials said the work will finish by the end of the month"),
}
lsh = MinHashLSH(num_perm=128, params=(16, 8)) # 16 bands of 8 values
sigs = {}
for doc_id, text in articles.items():
sigs[doc_id] = signature(text)
print(doc_id, "candidates:", sorted(lsh.query(sigs[doc_id])))
lsh.insert(doc_id, sigs[doc_id])
for doc_id in ("site-a-417", "site-b-092"):
print(doc_id, round(sigs["agency-001"].jaccard(sigs[doc_id]), 2),
round(len(shingles(base) & shingles(articles[doc_id]))
/ len(shingles(base) | shingles(articles[doc_id])), 2))This runs on datasketch 2.0.0, the latest release as of 27 October 2026. The third article matches both earlier ones; the unrelated drain story matches nothing. It then prints two lines, the estimate then the exact Jaccard: 0.88 and 0.83 for the "Sunday evening" copy, 0.8 and 0.84 for the "Also read" copy. The estimate can land on either side of the truth.
Querying before inserting means each new article is checked only against older ones. That is how a daily job adds today's 2 lakh articles to the existing index.
What our 1,20,000-article run cost
We ran the full pipeline on one machine with 2 CPU cores, using datasketch 2.0.0 and Python 3.13. The articles were synthetic: 1,00,000 originals and 20,000 copies. A third changed only spacing and case, a third had light edits of 1 to 8 words, and a third heavy edits of 30 to 60 words.
- Exact pass: 6,667 copies removed in about 10 seconds.
- Shingling 1,13,333 articles took 29 seconds of plain Python.
- MinHash with 128 values took 90 seconds, about 0.8 milliseconds per article on one core.
- Building the 16-band index took 21 seconds and querying every article took 3.5 seconds.
- Exact checks on 8,238 candidates took 0.39 seconds. Union-find formed 4,118 clusters and removed 5,316 more copies.
- The heavy edits fall well below Jaccard 0.8, so they were kept on purpose. Recall on the 5,987 true pairs at 0.8 or above was 99.2 percent.
Signature work grows with the number of articles, not with its square. At 0.8 milliseconds per article, 1 crore articles need about 8,000 core-seconds, roughly 2.2 hours on one core. That is an estimate from our run, not a measurement. Candidate pairs grow with copies per story: one story copied 1,000 times makes 4,99,500 pairs by itself.
Signatures for 1 crore articles take 1 crore times 512 bytes, which is 5.12 GB. The band tables add more on top, so shard them, meaning split the work by band across machines. Each band's table is independent.
Union-find turns pairs into clusters
Pairs are not enough, because copy A may match B and B may match C. Union-find is a small data structure that tracks groups and merges two groups in one quick step. After all confirmed pairs, each group is one story.
Then pick a keeper per cluster with a rule your product owns. The news app keeps the earliest publish time from the original agency, and shows the rest as alternate sources.
When one document contains another
Jaccard punishes size differences. We pasted a 120-word story into a 1,020-word page; all 116 of the story's shingles were on the page, but Jaccard was only 0.114.
Broder's paper defines containment for this case: shared shingles divided by the shingles of the document you are asking about. For the story inside the page, it was 1.0. datasketch provides MinHashLSHEnsemble, an index built for containment search, when you need to find these.
The release notes say the new default fixes an over-estimation bias on large sets. We hit two errors on 2.0.0: comparing a legacy sketch with an affine32 one, and inserting both into one index.
How do you explain near-duplicate detection in a system design interview?
Start from the product rule for "same", then show the cost problem, then the pipeline. Interviewers want to hear why all-pairs fails and how you would tune and check the filter.
Define a duplicate
Say what the product treats as the same, such as one news story with a new headline. Pick Jaccard on 5-word shingles as the measure.
Show why naive fails
An exact hash misses one-word edits. All pairs for 1 crore articles is about 50 trillion comparisons.
Sketch the pipeline
Normalise, exact hash pass, shingles, 128-value MinHash, LSH bands, exact check on candidates, union-find clusters, one keeper each.
Tune with numbers
Explain the S-curve, 1 minus (1 minus s to the r) to the b, and say you set its bend below the threshold, then verify.
Cover scale and change
Signatures are 512 bytes each, so 1 crore is about 5 GB. Shard bands across machines and query before insert for daily batches.
Mention SimHash
Offer SimHash with block tables when storage must be 8 bytes per document and only very close copies matter.
Questions people ask
- What is near duplicate detection?
- It is finding documents that are almost, but not exactly, the same. Typical cases are a news story republished with a new headline, a product listing copied with small changes, or the same web page under two addresses. Systems usually compare overlapping word runs called shingles, estimate similarity with MinHash or SimHash, and use LSH to avoid checking every pair.
- What is the difference between MinHash and SimHash?
- MinHash estimates Jaccard similarity, the share of shingles two documents have in common, using a signature of many numbers, often 128. SimHash makes one 64-bit fingerprint that tracks cosine similarity, and near-duplicates differ in a few bits. MinHash is easier to tune for a threshold like 0.8. SimHash is smaller and suits long documents and very close copies.
- What Jaccard threshold should I use for near-duplicates?
- There is no universal value. Label about 200 pairs from your own data by hand and see where the labels flip. In our tests on 300-word articles, ten changed words gave a Jaccard of about 0.73 with 5-word shingles, so 0.8 catches only light edits. Then set the LSH bands so the curve bends a little below your threshold.
- How many hash functions does MinHash need?
- 128 is a common default and gives a typical error of about 0.044 for a pair near 0.5 similarity. The error falls with the square root of the count, so 256 halves the variance but doubles memory. Because LSH candidates are checked exactly afterwards, 128 is usually enough for the filter itself.
- Why does MinHashLSH miss some pairs above my threshold?
- LSH is a soft threshold. The chance a pair is returned follows an S-curve set by the bands and rows. In datasketch 2.0.0, threshold=0.8 with 128 values chose 9 bands of 13 rows, which by the formula returns only about 40 percent of pairs at exactly 0.8. Pin params or raise the false-negative weight to catch them.
- Is near-duplicate detection the same as semantic similarity search?
- No. Near-duplicate detection compares the actual words, so it finds copies and light edits of the same text. Semantic search compares meaning, usually with vectors from a trained model, and can match two differently written articles on the same topic. Many systems run both: dedup first to remove copies, then semantic search over what is left.
- Can I run near-duplicate detection on 1 crore documents on one machine?
- Often yes for a batch job. In our run, MinHash took about 0.8 milliseconds per 300-word article on one core, so 1 crore (10 million) articles need roughly 2.2 core-hours. Signatures take about 5.12 GB at 512 bytes each. Split the work across cores, and shard band tables if memory runs short.
Sources and further reading
- Broder, On the resemblance and containment of documents, Compression and Complexity of SEQUENCES 1997, IEEE (DOI page)
- Broder, On the resemblance and containment of documents, full text PDF (Princeton course archive copy)
- Charikar, Similarity estimation techniques from rounding algorithms, STOC 2002, ACM (DOI page)
- Charikar, Similarity estimation techniques from rounding algorithms, full text PDF (Princeton course archive copy)
- Manku, Jain and Das Sarma, Detecting near-duplicates for web crawling, WWW 2007, ACM (DOI page)
- Manku, Jain and Das Sarma, Detecting near-duplicates for web crawling, full text PDF (WWW 2007 conference site)
- Stanford CS246 (Mining of Massive Datasets course), lecture slides: finding similar items and LSH
- datasketch 2.0.0 docs: MinHash LSH, soft threshold, bands and weights
- datasketch 2.0.0 docs: MinHash, permutation schemes and accuracy
- datasketch 2.0.0 docs: LSH Ensemble for containment search
- datasketch release notes, v2.0.0 (5 July 2026): affine32 default scheme
- datasketch 2.0.0 source: lsh.py, the (b, r) optimiser