Locality-sensitive hashing
Hash similar points to the same bucket with high probability, with MinHash for Jaccard and SimHash for cosine.
Locality-sensitive hashing (LSH) is the simplest family of approximate nearest-neighbor methods: design a hash function whose collision probability is monotone in the similarity between two points[1]. Similar points land in the same bucket with high probability; dissimilar points land in different buckets. Lookup is then just a bucket fetch.
The defining property
A family of hash functions is -locality sensitive for a distance if:
- For : .
- For : .
With and , the gap between and can be amplified by combining several hashes (next section).
MinHash for Jaccard similarity
For set-valued data (documents as sets of -shingles, users as sets of liked items), Jaccard similarity is .
MinHash picks a random permutation of the universe and defines . The key fact:
The collision probability equals the Jaccard similarity, by symmetry of permutations over union.
In practice, instead of true permutations, use universal hash functions and take the minimum hash value of items in under each. A document is summarized by a length- MinHash signature; estimated Jaccard is the fraction of matching positions.
SimHash for cosine similarity
For vector-valued data with cosine similarity, SimHash uses random hyperplanes:
A standard result:
where is the angle between and . Smaller angle (more similar) means higher collision probability. With random hyperplanes, each point gets a -bit signature; Hamming distance between signatures estimates angular distance.
-stable LSH for
For Euclidean distance, use random projections plus quantization:
with from a -stable distribution (Gaussian for , Cauchy for ), uniform in , and bucket width . Points within roughly distance of each other collide with high probability; distant points do not.
Amplification: AND-OR construction
A single hash has weak collision guarantees. Combine multiple:
- AND: concatenate hashes into one composite. Two points collide only if all component hashes agree. The collision probability of similar points becomes ; dissimilar becomes . The ratio grows.
- OR: build hash tables with independent composites. Two points collide if they agree in any table. The collision probability becomes [2].
Tuning and trades recall against precision and against the size of the candidate set retrieved.
Limits
- Memory: tables of entries each, plus the original vectors.
- Recall ceiling: high-recall regimes (>95%) require expensive AND-OR combinations; graph methods (HNSW) tend to dominate at high recall.
- Tuning: , , and all need calibration on the actual data distribution.
LSH is conceptually elegant and memory-light at moderate recall, which made it the workhorse before graph methods matured.
Check it on arrays
import numpy as np
rng = np.random.default_rng(0)
# Cosine LSH (SimHash): k random hyperplanes per signature
n, d = 5000, 64
X = rng.normal(size=(n, d))
X /= np.linalg.norm(X, axis=1, keepdims=True) # unit norm
queries = rng.normal(size=(50, d)); queries /= np.linalg.norm(queries, axis=1, keepdims=True)
def simhash(X, normals):
return (X @ normals.T > 0).astype(np.uint8)
def signatures_match(sig_a, sig_b):
return (sig_a == sig_b).sum(axis=-1)
# Single table, vary k (signature length)
def recall_for_k(k_bits, table_count):
recall_total = 0.0; frac_total = 0.0; count = 0
for query in queries:
true_top10 = np.argsort(-X @ query)[:10] # by cosine similarity
# OR over `table_count` tables
candidate_mask = np.zeros(n, dtype=bool)
for _ in range(table_count):
normals = rng.normal(size=(k_bits, d))
H = simhash(X, normals)
hq = simhash(query[None, :], normals)[0]
# Match if signature agrees on ALL bits
same = (H == hq).all(axis=1)
candidate_mask |= same
frac_total += candidate_mask.sum() / n # average over EVERY query
if candidate_mask.sum() < 10: continue
cand_idx = np.where(candidate_mask)[0]
ann_top10 = cand_idx[np.argsort(-(X[cand_idx] @ query))[:10]]
recall_total += len(set(ann_top10) & set(true_top10)) / 10
count += 1
return recall_total / max(count, 1), frac_total / len(queries)
print(f"{'k_bits':>8} {'L tables':>8} {'recall@10':>10} {'cand. fraction':>15}")
for k_bits in [4, 8, 12]:
for L in [1, 4, 16]:
r, frac = recall_for_k(k_bits, L)
print(f"{k_bits:>8} {L:>8} {r:>10.3f} {frac:>15.4f}")Longer signatures cut the candidate set; more tables (OR amplification) boost recall.
Exercises
Exercise 1. A SimHash family has collision probability for a similar pair and for a dissimilar pair. Form an AND composite of bits, then an OR over such composites. Compute the final collision probability for each pair and explain why the gap widened.
Solution
Step 1. AND concatenates independent bits, so a composite collides only if all agree: . The similar pair becomes ; the dissimilar pair becomes .
Step 2. OR over tables collides if any one composite matches, so the miss probability is and the collision probability is . For the similar pair:
For the dissimilar pair:
Step 3. The similar pair now collides with probability about while the dissimilar pair sits near . AND pushed both probabilities down but hit the small far harder (the ratio grew); OR then lifted the survivor back toward without much helping the already-tiny . The combination sharpens a soft monotone signal into a near-threshold.
Exercise 2. Two documents have MinHash signatures of length that agree in positions. Estimate their Jaccard similarity and the standard error of that estimate. Why does a longer signature give a tighter estimate?
Solution
Step 1. Each position is an independent Bernoulli trial that matches with probability exactly , so the fraction of agreements is an unbiased estimator: .
Step 2. The agreement count is Binomial, so the estimator has variance . Plugging the estimate, the standard error is
Step 3. The standard error scales as . Doubling the signature length shrinks it by a factor , so longer signatures trade memory and hashing cost for a more precise Jaccard estimate.
Exercise 3 (code). Verify the SimHash collision identity . Build pairs at several known angles in a high-dimensional space, draw many random hyperplanes, and check that the empirical collision rate matches .
Solution
import numpy as np
rng = np.random.default_rng(0)
d = 64
def unit(v):
return v / np.linalg.norm(v)
# Build an orthonormal pair (x, y0) so cos(theta) x + sin(theta) y0 has angle theta to x
x = unit(rng.normal(size=d))
y0 = rng.normal(size=d)
y0 = unit(y0 - (y0 @ x) * x)
normals = rng.normal(size=(200_000, d)) # random hyperplanes
for theta in [np.pi/6, np.pi/3, np.pi/2, 2*np.pi/3]:
y = np.cos(theta) * x + np.sin(theta) * y0
hx = (normals @ x) > 0
hy = (normals @ y) > 0
collide = (hx == hy).mean()
predicted = 1 - theta / np.pi
print(f"theta={theta:.3f} empirical={collide:.3f} predicted={predicted:.3f}")
assert abs(collide - predicted) < 0.01At the collision rate is about , and at it is about . The empirical rate tracks to within sampling error, which is why Hamming distance between SimHash signatures estimates angular distance.
The next lesson covers an inverted-index approach (IVF) that scales to billions of vectors.
References
- [1]Indyk, P., & Motwani, R. (1998). "Approximate nearest neighbors: towards removing the curse of dimensionality". STOC. doi:10.1145/276698.276876. Introduced the locality-sensitive family and its collision-probability guarantee.↩
- [2]Gionis, A., Indyk, P., & Motwani, R. (1999). "Similarity search in high dimensions via hashing". VLDB. The AND-OR table construction that amplifies the gap between and .↩