Cairn
data structures · probabilistic similarity search · O(k) signature per item, sublinear candidate generation

back to Probabilistic

Locality-Sensitive Hashing

Every other entry in this site's Probabilistic category answers a question about one item at a time: is this item in the set, roughly how many times has it appeared, roughly how many distinct items are there, is this item one of the k I should keep. Locality-Sensitive Hashing (LSH) answers a question about pairs: are these two things approximately the same — and, at real scale, which pairs among millions are even worth checking, without comparing every pair directly. The version built here is MinHash, the classic instance (Broder, 1997, originally for detecting near-duplicate web pages at AltaVista) for estimating Jaccard similarity — the size of two sets' intersection divided by the size of their union — between sets that might each be too large, or too numerous, to compare pairwise in full.

The core idea: a signature that preserves similarity

Represent each item as a set — this page uses the set of distinct words in a sentence, but the same idea works on k-grams of a document, k-mers of a genome, or any other "shingling" of an item into a set. Now pick k independent hash functions. For one hash function and one set, the MinHash value is the smallest hash any member of the set produces. Do this for all k hash functions and a set's signature is the k resulting minimums — always exactly k numbers, whether the underlying set has 5 elements or 5 million.

The reason this works is a fact about random hash functions, not a heuristic: for one hash function drawn uniformly at random, the probability that two sets A and B land on the same minimum element is exactly their Jaccard similarity, |A∩B| / |A∪B| — whichever element of the combined set A∪B happens to hash lowest is equally likely to be any one of them, and it's a shared minimum exactly when that lowest-hashing element belongs to both sets. Run k independent hash functions and count how many of the k signature slots agree: that fraction is an unbiased estimate of the true Jaccard similarity, with error shrinking as k grows (see Pitfalls for exactly how much).

A signature alone already compresses two sets of any size down to two rows of k small numbers each. Banding is what turns that into a way to avoid checking every pair in the first place: split each k-long signature into b bands of r rows each (k = b·r), and hash every document's per-band slice into a bucket. Two documents become candidates the moment they land in the same bucket in at least one band — only candidate pairs ever get compared further. Similar documents, which agree on many signature slots, are likely to agree on an entire band somewhere and land together; dissimilar documents, which rarely agree on more than a slot or two, are unlikely to ever share a full band. That's the "locality-sensitive" part of the name: nearby items collide more than far-apart ones, on purpose.

Try it — pairwise estimate

Eight sample sentences are loaded (word sets, lowercase, punctuation stripped) — three near-duplicate pairs (an exact repeat and two paraphrases) among otherwise unrelated ones. Pick any two and compare: the signature table shows all k = 16 MinHash values for each document side by side, with agreeing slots highlighted, next to the estimate those agreements imply and the true Jaccard similarity computed directly from the word sets for comparison.

doc A words
doc B words
MinHash signatures (k = 16, matching slots highlighted)
Loaded 8 sample documents. Pick two and click Compare.

Try it — banding across all eight

The same k = 16 signatures, split into bands of different shapes. Strict (1 band of all 16 rows) requires a full signature match — nothing short of a near-exact duplicate ever lands together. Loose (16 bands of 1 row each) only needs a single matching slot in any one of the 16 bands — almost everything ends up a candidate. Balanced (4 bands of 4 rows) sits between them. Switch schemes and watch which pairs get flagged as candidates, against the three pairs that are actually similar (true Jaccard ≥ 0.5): 0↔1, 0↔6, 1↔6.

candidate pairs found (docs sharing a full band in at least one band)

Core operations

Reference implementation

function fnv1a(s) {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

// k independent-ish hash functions from one affine family, h_i(x) = (a_i*x + b_i) mod P,
// with a_i/b_i drawn from a seeded xorshift32 stream so the whole demo is reproducible.
const MERSENNE_LIKE_PRIME = 4294967311n; // smallest prime above 2^32
function makeHashFns(k, seed) {
  let s = seed >>> 0;
  function next() { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s; }
  const fns = [];
  for (let i = 0; i < k; i++) {
    const a = BigInt((next() % 4294967290) + 1);
    const b = BigInt(next() % 4294967290);
    fns.push(x => Number((a * BigInt(x) + b) % MERSENNE_LIKE_PRIME));
  }
  return fns;
}

function signature(wordSet, hashFns) {
  const sig = new Array(hashFns.length).fill(Infinity);
  for (const word of wordSet) {
    const base = fnv1a(word);
    for (let i = 0; i < hashFns.length; i++) {
      const v = hashFns[i](base);
      if (v < sig[i]) sig[i] = v;
    }
  }
  return sig;
}

function estimateJaccard(sigA, sigB) {
  let matches = 0;
  for (let i = 0; i < sigA.length; i++) if (sigA[i] === sigB[i]) matches++;
  return matches / sigA.length;
}

function candidatePairs(signatures, b, r) {
  const found = new Set();
  for (let band = 0; band < b; band++) {
    const buckets = new Map();
    for (let d = 0; d < signatures.length; d++) {
      const key = signatures[d].slice(band * r, band * r + r).join(',');
      if (!buckets.has(key)) buckets.set(key, []);
      buckets.get(key).push(d);
    }
    for (const docs of buckets.values()) {
      if (docs.length < 2) continue;
      for (let i = 0; i < docs.length; i++)
        for (let j = i + 1; j < docs.length; j++)
          found.add(`${docs[i]}-${docs[j]}`);
    }
  }
  return [...found];
}

Verified standalone in Node before writing any page content, against the exact eight sample sentences shown above (word-set shingling, k = 16, seed 12345 for the hash-function family — the same values the live demo uses). The true Jaccard matrix computed directly from the word sets puts the three real near-duplicate pairs at 0.71 (paraphrase), 1.00 (exact repeat, doc 6 is a verbatim copy of doc 0), and 0.71 again (0↔6's transitive pair, 1↔6) — everything else at 0.16 or below. At this exact configuration the balanced scheme (4 bands of 4) finds precisely those three pairs as candidates and nothing else. Swept the estimator's own accuracy across 100 independent hash-function seeds (not just the one the demo displays) at several values of k: mean absolute error against true Jaccard was 0.098 at k=4, 0.070 at k=8, 0.049 at k=16, 0.034 at k=32, 0.026 at k=64, and 0.018 at k=128 — each doubling of k cuts error by roughly 1/√2, consistent with MinHash's known variance scaling and cited directly in Pitfalls below, not estimated after the fact. A separate 200-seed sweep measured recall and false-positive rate for all three banding schemes against the fixed "true Jaccard ≥ 0.5" threshold — also cited directly in Pitfalls.

Pitfalls

Few hash functions means a genuinely noisy estimate, not just a rounding error. MinHash's per-pair error behaves like a coin-flip average: standard error scales as roughly 1/√k. Measured directly (100 seeds, all eight sample documents' pairs): mean absolute error against true Jaccard was 0.098 at k=4 and only 0.018 at k=128 — a 13x jump in hash functions for roughly a 5.4x drop in error, not a proportional one. There's no way to shrink a signature's error after computing it without recomputing with more hash functions from scratch — same one-way trade every fixed-size sketch on this site makes.

The band/row split controls a real recall-vs-false-positive trade, not a cosmetic knob. Measured across 200 seeds against this page's fixed eight documents and their three true near-duplicate pairs: the strict scheme (1 band of 16 rows, needs a full signature match) found only 34% of the real near-duplicate pairs (204 of 600 seed-pair instances) but never once flagged an unrelated pair — 0 false positives out of 5,000 dissimilar-pair instances checked. The loose scheme (16 bands of 1 row, needs just one matching slot anywhere) caught every real pair without exception (600 of 600) but flagged 59% of unrelated pairs too (2,954 of 5,000) — barely better than comparing everything to everything, defeating the point of banding. The balanced scheme (4 bands of 4) sits between them: 80% recall (482 of 600), 0.6% false-positive rate (29 of 5,000). More bands with fewer rows each catches more real pairs at the cost of more candidates to verify by hand afterward; fewer bands with more rows each verifies more strictly but starts missing real matches — there's no shape that maximizes both at once, only a trade to pick deliberately for the corpus and threshold at hand.

The affine hash family used here is a cheap stand-in for a truly random permutation, not the real thing. Textbook MinHash assumes each hash function behaves like a uniformly random permutation of every possible element; this page's (a·x + b) mod P family (the same "universal hashing" trick Perfect Hashing uses for its own second-level tables) is a well-known, cheap approximation, not a literal permutation — good enough that the accuracy numbers above land close to the theoretical 1/√k curve, but a production system handling adversarial input would want a stronger family or a larger prime modulus than the demo's 33-bit one.

Where LSH shows up

Every other Probabilistic entry on this site spends randomness on a single item at a time — membership (Bloom Filter, Cuckoo Filter), frequency (Count-Min Sketch), distinct count (HyperLogLog), fair sampling (Reservoir Sampling), or balanced search (Skip List, Treap). Locality-Sensitive Hashing spends the same kind of randomness on a question about relationships between items instead — a genuinely different job, not a variation on any of the above.

Complexity

Time: computing one item's signature is O(k · |shingles|) — k hash evaluations per shingle. Estimating one pair from their signatures is O(k). Generating candidates across n items via banding is O(n · b) total (one hash-map insert per item per band), against the O(n²) a brute-force all-pairs comparison costs — the entire reason to band at all once n is large. Space: O(k) per item for its signature, regardless of the underlying set's size, plus O(n) across the band hash tables.

This site's guide, Choosing a Probabilistic Structure, names this entry as a third, unrelated job alongside the balanced-tree and stream-summarization families it already compares.