01 / The idea
Find the small neighborhood before exact review.
If there are n documents, an all-pairs comparison has roughly n(n - 1) / 2 pairs. A hash set can tell us whether one shingle exists, but it cannot
by itself tell us which of millions of documents share enough shingles to inspect.
MinHash compresses a set of shingles into a signature. The probability that one row picks the same minimum hash for two documents equals their Jaccard similarity. Equal rows become evidence, not a claim of identity.
LSH goes one step further: split the signature into bands. Documents that match every row inside any one band share a bucket and become candidates. Most unrelated documents never need an exact set comparison.
02 / Name the rule
Jaccard asks how much two sets overlap.
J(A, B) = |A ∩ B| ÷ |A ∪ B|
This lesson uses three-word shingles: adjacent words become one set member, repeated shingles collapse, and punctuation and case are normalized away. The ferry documents share 9 shingles in a union of 12, so their exact Jaccard similarity is 75%.
For each of k deterministic hash rows, hash every shingle and keep the smallest value.
The fraction of equal minima estimates Jaccard. With 24 rows, this example happens to match 18
rows: a 75% estimate. More rows usually make the estimate steadier, while using more rows costs
more signature space and hashing work.
Make shingles
Represent a document by the distinct local word sequences it contains.
Keep minima
Use every row’s smallest shingle hash as one compact sample of the set.
Surface candidates
Require all rows in one band to match before exact review spends more time.
Why is a candidate not a duplicate?Approximation has two kinds of miss
Two documents can collide in a band by chance: that is a false positive, and exact review removes it. Two genuinely similar documents can fail to share a complete band: that is a false negative, and more rows, different bands, or a lower threshold can change the tradeoff.
With b bands of r rows and similarity s, the rough
chance of sharing at least one band is 1 - (1 - sr)b. It is a tuning curve, not a guarantee:
hash collisions, small sets, and token policy still matter. With this lesson’s 6 bands of
4 rows, a pair at similarity 0.5 becomes a candidate about 32% of the time, at 0.75 (the
ferry pair) about 90%, and at 0.8 about 96%. The curve’s steep part sits near (1/b)1/r, about 0.64 here.
03 / Follow one operation
Compare 24 numbers instead of the archive text.
The animation follows the ferry notice and its edited copy as they become shingles, then signatures, then one LSH candidate. The exact score stays visible so you can compare the compressed evidence with the set calculation it is estimating.
Find likely copies before exact comparison.
morning-ferry
morning-ferry-copy
9 of 12 unique shingles are shared (highlighted); exact Jaccard is 75.0%.
Make word shingles
Turn each document into a set of three-word shingles. The ferry pair shares 9 of its 12 unique shingles. Word order inside each shingle still matters; punctuation and case do not.
Reduced motion: choose a scene to see its completed state.
Read this scene
Turn each document into a set of three-word shingles. The ferry pair shares 9 of its 12 unique shingles. Word order inside each shingle still matters; punctuation and case do not.
Turn each document into a set of three-word shingles. The ferry pair shares 9 of its 12 unique shingles. Word order inside each shingle still matters; punctuation and case do not.
Watch and Step through replay the same shingle, signature, and band evidence. Try it runs the TypeScript model on your edited pair.
04 / Read the shape
The signature is the reusable boundary.
Basic form tokenizes, creates unique shingles, and builds a MinHash signature. In the wild compares exact and estimated Jaccard, then uses band buckets to mark a candidate. At the call site indexes a batch and returns candidate pairs without an all-pairs exact scan.
Normalize printable ASCII text into unique word shingles, then keep the minimum 32-bit hash for each of 24 deterministic rows (minHashValues). Equal signature rows estimate the Jaccard similarity of the underlying sets.
export type MinHashErrorCode =
| 'bad-options'
| 'empty-document-list'
| 'too-many-documents'
| 'bad-document-id'
| 'duplicate-document-id'
| 'bad-document-text'
| 'too-many-shingles'
| 'no-shingles';
export class MinHashError extends Error {
readonly code: MinHashErrorCode;
constructor(code: MinHashErrorCode, message: string) {
super(message);
this.name = 'MinHashError';
this.code = code;
}
}
export interface Document {
id: string;
text: string;
}
export interface MinHashOptions {
shingleSize?: number;
permutations?: number;
bands?: number;
}
export interface MinHashConfig {
readonly shingleSize: number;
readonly permutations: number;
readonly bands: number;
readonly rowsPerBand: number;
}
export interface Signature {
id: string;
shingles: string[];
values: number[];
}
export interface PairComparison {
left: string;
right: string;
sharedShingles: number;
unionShingles: number;
matchingRows: number;
exactJaccard: number;
estimatedJaccard: number;
candidate: boolean;
}
export interface BandBucket {
band: number;
key: string;
ids: string[];
}
export interface SearchResult {
signatures: Signature[];
buckets: BandBucket[];
candidates: string[];
}
function validateOptions(options: MinHashOptions): MinHashConfig {
const shingleSize = options.shingleSize ?? 3;
const permutations = options.permutations ?? 24;
const bands = options.bands ?? 6;
if (
!Number.isInteger(shingleSize) ||
shingleSize < 2 ||
shingleSize > MAX_SHINGLE_SIZE ||
!Number.isInteger(permutations) ||
permutations < 8 ||
permutations > MAX_PERMUTATIONS ||
!Number.isInteger(bands) ||
bands < 1 ||
bands > MAX_BANDS ||
permutations % bands !== 0 ||
permutations / bands > MAX_ROWS_PER_BAND
)
throw new MinHashError(
'bad-options',
`Use shingles 2-${MAX_SHINGLE_SIZE}, ${8}-${MAX_PERMUTATIONS} permutations, and bands that divide permutations with at most ${MAX_ROWS_PER_BAND} rows.`
);
return { shingleSize, permutations, bands, rowsPerBand: permutations / bands };
}
function validateText(text: string): void {
if (
typeof text !== 'string' ||
text.length === 0 ||
text.length > MAX_TEXT_LENGTH ||
!ASCII_TEXT.test(text)
)
throw new MinHashError(
'bad-document-text',
`Document text must be 1-${MAX_TEXT_LENGTH} printable ASCII characters.`
);
}
function validateDocument(document: Document): void {
if (typeof document.id !== 'string' || !DOCUMENT_ID.test(document.id))
throw new MinHashError('bad-document-id', 'Document ids must be lowercase slugs.');
validateText(document.text);
}
function validateDocuments(documents: Document[]): void {
if (documents.length === 0)
throw new MinHashError('empty-document-list', 'At least one document is required.');
if (documents.length > MAX_DOCUMENTS)
throw new MinHashError(
'too-many-documents',
`At most ${MAX_DOCUMENTS} documents are accepted.`
);
const ids = new Set<string>();
for (const document of documents) {
validateDocument(document);
if (ids.has(document.id))
throw new MinHashError('duplicate-document-id', 'Document ids must be unique.');
ids.add(document.id);
}
}
// FNV-1a plus a final avalanche. ASCII shingles make this 32-bit hash match Go byte for byte.
function hash32(value: string, seed: number): number {
let hash = (0x811c9dc5 ^ Math.imul(seed + 1, 0x9e3779b9)) >>> 0;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
hash ^= hash >>> 16;
hash = Math.imul(hash, 0x85ebca6b) >>> 0;
hash ^= hash >>> 13;
hash = Math.imul(hash, 0xc2b2ae35) >>> 0;
hash ^= hash >>> 16;
return hash >>> 0;
}
export function tokenize(text: string): string[] {
validateText(text);
return text.toLowerCase().match(WORD) ?? [];
}
export function makeShingles(text: string, shingleSize = 3): string[] {
if (!Number.isInteger(shingleSize) || shingleSize < 2 || shingleSize > MAX_SHINGLE_SIZE)
throw new MinHashError(
'bad-options',
`Shingle size must be a whole number from 2 to ${MAX_SHINGLE_SIZE}.`
);
const words = tokenize(text);
const shingles = new Set<string>();
for (let index = 0; index + shingleSize <= words.length; index += 1)
shingles.add(words.slice(index, index + shingleSize).join(' '));
if (shingles.size > MAX_TEXT_LENGTH)
throw new MinHashError(
'too-many-shingles',
`A document may contain at most ${MAX_TEXT_LENGTH} shingles.`
);
return [...shingles].sort();
}
function setJaccard(
left: string[],
right: string[]
): { shared: number; union: number; score: number } {
const rightSet = new Set(right);
const leftSet = new Set(left);
let shared = 0;
for (const shingle of leftSet) if (rightSet.has(shingle)) shared += 1;
const union = new Set([...leftSet, ...rightSet]).size;
// signature() refuses empty shingle sets, so union is never 0 here; if it were, report 0
// rather than calling two empty documents identical.
return { shared, union, score: union === 0 ? 0 : shared / union };
}
// The MinHash step: for each of the deterministic hash rows, keep the smallest hash over the
// document's shingles. Two documents agree on a row with probability equal to their Jaccard
// similarity. A document with no shingles has no minimum, so it is refused.
function minHashValues(shingles: string[], permutations: number): number[] {
if (shingles.length === 0)
throw new MinHashError(
'no-shingles',
'A document needs at least as many words as the shingle size to form one shingle.'
);
return Array.from({ length: permutations }, (_, seed) =>
shingles.reduce((minimum, shingle) => Math.min(minimum, hash32(shingle, seed)), 0xffffffff)
);
}
function bandKey(values: number[], start: number, length: number): string {
return values.slice(start, start + length).join(':');
} type MinHashErrorCode string
const (
BadOptions MinHashErrorCode = "bad-options"
EmptyDocumentList MinHashErrorCode = "empty-document-list"
TooManyDocuments MinHashErrorCode = "too-many-documents"
BadDocumentID MinHashErrorCode = "bad-document-id"
DuplicateDocumentID MinHashErrorCode = "duplicate-document-id"
BadDocumentText MinHashErrorCode = "bad-document-text"
NoShingles MinHashErrorCode = "no-shingles"
)
type MinHashError struct {
Code MinHashErrorCode
Message string
}
func (e *MinHashError) Error() string { return e.Message }
type Document struct {
ID string
Text string
}
type MinHashOptions struct {
ShingleSize int
Permutations int
Bands int
}
type MinHashConfig struct {
ShingleSize int
Permutations int
Bands int
RowsPerBand int
}
type Signature struct {
ID string
Shingles []string
Values []uint32
}
type PairComparison struct {
Left string
Right string
SharedShingles int
UnionShingles int
MatchingRows int
ExactJaccard float64
EstimatedJaccard float64
Candidate bool
}
type BandBucket struct {
Band int
Key string
IDs []string
}
type SearchResult struct {
Signatures []Signature
Buckets []BandBucket
Candidates []string
}
func validateOptions(options MinHashOptions) (MinHashConfig, error) {
shingleSize := options.ShingleSize
if shingleSize == 0 {
shingleSize = defaultShingle
}
permutations := options.Permutations
if permutations == 0 {
permutations = defaultPermutation
}
bands := options.Bands
if bands == 0 {
bands = defaultBand
}
if shingleSize < 2 || shingleSize > maxShingleSize ||
permutations < 8 || permutations > maxPermutations ||
bands < 1 || bands > maxBands || permutations%bands != 0 ||
permutations/bands > maxRowsPerBand {
return MinHashConfig{}, &MinHashError{
Code: BadOptions,
Message: fmt.Sprintf("use shingles 2-%d, 8-%d permutations, and bands that divide permutations with at most %d rows", maxShingleSize, maxPermutations, maxRowsPerBand),
}
}
return MinHashConfig{ShingleSize: shingleSize, Permutations: permutations, Bands: bands, RowsPerBand: permutations / bands}, nil
}
func validSlug(id string) bool {
if len(id) < 1 || len(id) > 32 || id[0] < 'a' || id[0] > 'z' {
return false
}
for _, char := range []byte(id[1:]) {
if !(char >= 'a' && char <= 'z') && !(char >= '0' && char <= '9') && char != '-' {
return false
}
}
return true
}
func validateText(text string) error {
if len(text) < 1 || len(text) > maxTextLength {
return &MinHashError{Code: BadDocumentText, Message: fmt.Sprintf("document text must be 1-%d printable ASCII characters", maxTextLength)}
}
for _, char := range []byte(text) {
if char != '\t' && char != '\n' && char != '\r' && (char < 0x20 || char > 0x7e) {
return &MinHashError{Code: BadDocumentText, Message: fmt.Sprintf("document text must be 1-%d printable ASCII characters", maxTextLength)}
}
}
return nil
}
func validateDocument(document Document) error {
if !validSlug(document.ID) {
return &MinHashError{Code: BadDocumentID, Message: "document ids must be lowercase slugs"}
}
return validateText(document.Text)
}
func validateDocuments(documents []Document) error {
if len(documents) == 0 {
return &MinHashError{Code: EmptyDocumentList, Message: "at least one document is required"}
}
if len(documents) > maxDocuments {
return &MinHashError{Code: TooManyDocuments, Message: fmt.Sprintf("at most %d documents are accepted", maxDocuments)}
}
seen := map[string]bool{}
for _, document := range documents {
if err := validateDocument(document); err != nil {
return err
}
if seen[document.ID] {
return &MinHashError{Code: DuplicateDocumentID, Message: "document ids must be unique"}
}
seen[document.ID] = true
}
return nil
}
func hash32(value string, seed int) uint32 {
hash := uint32(0x811c9dc5) ^ (uint32(seed+1) * uint32(0x9e3779b9))
for _, char := range []byte(value) {
hash ^= uint32(char)
hash *= uint32(0x01000193)
}
hash ^= hash >> 16
hash *= uint32(0x85ebca6b)
hash ^= hash >> 13
hash *= uint32(0xc2b2ae35)
hash ^= hash >> 16
return hash
}
func bandKey(values []uint32, start, length int) string {
parts := make([]string, length)
for index := range parts {
parts[index] = fmt.Sprintf("%d", values[start+index])
}
return strings.Join(parts, ":")
}
func Tokenize(text string) ([]string, error) {
if err := validateText(text); err != nil {
return nil, err
}
lower := strings.ToLower(text)
words := []string{}
start := -1
for index := 0; index <= len(lower); index++ {
isWord := index < len(lower) && ((lower[index] >= 'a' && lower[index] <= 'z') || (lower[index] >= '0' && lower[index] <= '9'))
if isWord && start < 0 {
start = index
} else if !isWord && start >= 0 {
words = append(words, lower[start:index])
start = -1
}
}
return words, nil
}
func MakeShingles(text string, size int) ([]string, error) {
if size < 2 || size > maxShingleSize {
return nil, &MinHashError{Code: BadOptions, Message: fmt.Sprintf("shingle size must be a whole number from 2 to %d", maxShingleSize)}
}
words, err := Tokenize(text)
if err != nil {
return nil, err
}
unique := map[string]bool{}
for index := 0; index+size <= len(words); index++ {
unique[strings.Join(words[index:index+size], " ")] = true
}
shingles := make([]string, 0, len(unique))
for shingle := range unique {
shingles = append(shingles, shingle)
}
sort.Strings(shingles)
return shingles, nil
}
func setJaccard(left, right []string) (int, int, float64) {
rightSet := map[string]bool{}
leftSet := map[string]bool{}
for _, shingle := range right {
rightSet[shingle] = true
}
for _, shingle := range left {
leftSet[shingle] = true
}
shared := 0
for shingle := range leftSet {
if rightSet[shingle] {
shared++
}
}
unionSet := map[string]bool{}
for shingle := range leftSet {
unionSet[shingle] = true
}
for shingle := range rightSet {
unionSet[shingle] = true
}
union := len(unionSet)
// Signature refuses empty shingle sets, so union is never 0 here; if it were, report 0
// rather than calling two empty documents identical.
if union == 0 {
return shared, union, 0
}
return shared, union, float64(shared) / float64(union)
}
// The MinHash step: for each of the deterministic hash rows, keep the smallest hash over the
// document's shingles. Two documents agree on a row with probability equal to their Jaccard
// similarity. A document with no shingles has no minimum, so it is refused.
func minHashValues(shingles []string, permutations int) ([]uint32, error) {
if len(shingles) == 0 {
return nil, &MinHashError{Code: NoShingles, Message: "a document needs at least as many words as the shingle size to form one shingle"}
}
values := make([]uint32, permutations)
for seed := range values {
values[seed] = ^uint32(0)
for _, shingle := range shingles {
hash := hash32(shingle, seed)
if hash < values[seed] {
values[seed] = hash
}
}
}
return values, nil
} Reading the TypeScriptMinimum values, not random samples
Each row starts at the largest unsigned 32-bit value. Every shingle hash can lower it, so the final array contains one minimum per row. Equal positions across two arrays are the MinHash estimate.
Reading the GoSame ASCII normalization and hash
The Go implementation scans the same ASCII word boundaries and performs the same 32-bit avalanche. The fixture output therefore agrees row for row without pretending that production hash libraries share an implementation.
What is refusedMake token policy explicit
Both versions accept at most 32 documents and 2,048 printable ASCII characters per
document. IDs are unique lowercase slugs. A document with fewer words than the shingle
size has no shingles, so it has no minimum to keep: both versions refuse it with no-shingles rather than score two empty sets as identical. Shingle size, row count, and band count are
bounded; changing them changes the signature and the candidate curve.
05 / Try a decision
Candidate first, confirmation second.
Banding is where a similar pair can slip through. Decide what one small edit does to the ferry pair before you trust the index to find it.
Then open Try it above and edit either notice. If the signatures share a band, route the pair to exact review. If they don’t, the index skips it for this configuration, and a skip is not proof that two documents share nothing.
Operational rule: LSH chooses who deserves attention. It does not authorize a merge, deletion, or publication decision.
06 / Follow the cost
Spend a little memory to avoid quadratic review.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Make shingles for one document | O(L) | O(s) | Tokenize L input characters and keep s unique word shingles. |
| Build one MinHash signature | O(k·s) | O(k) | Hash each of s shingles for k rows and keep one minimum per row. |
| Index n documents with LSH | O(n·k·s + p) | O(n·(k + s)) | The shown search builds each signature (O(k·s) apiece), places it in b band buckets, and enumerates the p pairs that share a bucket, which grows quadratically in bucket size. It keeps every signature’s shingles too. Candidate verification is a separate step. |
| Verify c candidates | O(c·s) | O(s) | Compare only the pairs LSH surfaced, using exact shingle Jaccard if needed. |
| Compare every pair exactly | O(n²·s) | O(s) | The baseline LSH is trying to avoid when an archive grows. |
Let L be input length, s unique shingles, k signature rows, b bands, and c candidates. The implementation’s search builds signatures and buckets; a production system should verify only the
returned candidates rather than calculate every exact pair as a diagnostic.
More rows lower sampling noise but make signatures larger. More rows per band make a collision stricter; more bands make one more likely. Tune against labeled examples, and keep a recall check so “fewer candidates” is not mistaken for “better search.”
07 / Give it a real job
Shortlist the pairs, then let a stricter check decide.
Cleaning training data for language models is one job it does at scale. In “Deduplicating Training Data Makes Language Models Better” (ACL 2022), Lee and colleagues built MinHash signatures from 5-grams with 9,000 hash values, split into 450 bands of 20. A pair that shared a band still counted as a duplicate only if its edit similarity was above 0.8. That is this lesson’s shape at a larger size: the index owns the shortlist, and a slower, exact measure owns the verdict.
What the index leaves out is meaning. Two notices that say the same thing in different words share few shingles, and banding will rarely put them together.
This runs in a batch job over the archive or the crawl, on a server; nothing in a component computes signatures.
08 / Make the call
Choose the representation before the similarity score.
Use MinHash and LSH when documents are naturally sets of shingles and you need to narrow a large near-duplicate search. When the question is whether one exact line was copied, Rabin–Karp finds it without any estimate. Use Levenshtein distance when a short string needs an explainable edit count. Use Jaro–Winkler when aligned names and a prefix effect are the question. Use HyperLogLog when you need a count of distinct values, not which documents resemble one another.
For semantic similarity, embeddings and an approximate nearest-neighbor index may fit better. MinHash sees shared tokens; it does not understand that “ferry” and “boat” might mean something similar.
09 / Take the idea with you
Explain a near-duplicate search without saying “MinHash.”
“Break each document into short runs of words. Shuffle all possible runs into a random order and note which of a document’s runs comes first: two documents pick the same first run about as often as they share runs. Do that a couple of dozen ways, file each document under small groups of those picks, and only compare documents that land in the same file.”
Before moving on, open the lab, change dawn to noon in Document B, and
predict how many shingles the pair still shares, and whether it stays a candidate. Then compare
and check.
Connections to follow nextRelated lessons
- Hash set holds each document’s shingles and answers the exact overlap question MinHash estimates.
- Rabin–Karp rolling-hash search finds an exact copied line, where MinHash finds a likely near copy.
- HyperLogLog is another small sketch of a big set, for counting distinct items instead of comparing documents.