← Applied algorithms
Text, names, and changes When exact comparison does not scale

MinHash and locality-sensitive hashing

Find likely copies before you compare every pair.

A newsroom archives thousands of articles. A copied press release may differ only in a date or byline, but comparing every article with every other article is a quadratic pile of work. Edit distance reads characters; MinHash turns each document into a small fingerprint of word sets, and locality-sensitive hashing (LSH) puts likely neighbors in the same buckets.

The output is a candidate list, not a verdict. Exact review still decides whether two documents are the same story, and the tokenization, shingle size, hash family, and false-negative tolerance belong to the application. Let’s watch one pair of ferry notices go through it.

TypeScriptGoOne document index, two implementations.

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.

Set

Make shingles

Represent a document by the distinct local word sequences it contains.

Hash

Keep minima

Use every row’s smallest shingle hash as one compact sample of the set.

Bucket

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.

MinHash / LSH

Find likely copies before exact comparison.

DOCUMENT FINGERPRINT · 24 ROWS · 6 BANDS 75.0% exact / 75.0% estimated

morning-ferry

across the harbor

morning-ferry-copy

across the harbor

9 of 12 unique shingles are shared (highlighted); exact Jaccard is 75.0%.

01/ 03
Make word shingles

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.

TypeScriptReading
minhash.ts
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(':');
}
GoAlongside
minhash.go
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.

The ferry pair matches 18 of 24 rows: bands 1, 3 and 5 agree on all four rows, and bands 2, 4 and 6 each differ in two. Suppose a later edit changes one row in each of bands 1, 3 and 5, leaving 15 of 24 rows equal. Is the pair still an LSH candidate?

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.

MinHash and LSH: time and extra space
OperationTimeExtra spaceWhat it assumes
Make shingles for one documentO(L)O(s)Tokenize L input characters and keep s unique word shingles.
Build one MinHash signatureO(k·s)O(k)Hash each of s shingles for k rows and keep one minimum per row.
Index n documents with LSHO(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 candidatesO(c·s)O(s)Compare only the pairs LSH surfaced, using exact shingle Jaccard if needed.
Compare every pair exactlyO(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.