AI Knowledge Tree

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 H\mathcal{H} of hash functions is (r1,r2,p1,p2)(r_1, r_2, p_1, p_2)-locality sensitive for a distance dd if:

  • For d(x,y)≤r1d(x, y) \le r_1: Pr⁡h∼H(h(x)=h(y))≥p1\Pr_{h \sim \mathcal{H}}(h(x) = h(y)) \ge p_1.
  • For d(x,y)≥r2d(x, y) \ge r_2: Pr⁡h∼H(h(x)=h(y))≤p2\Pr_{h \sim \mathcal{H}}(h(x) = h(y)) \le p_2.

With r1<r2r_1 < r_2 and p1>p2p_1 > p_2, the gap between p1p_1 and p2p_2 can be amplified by combining several hashes (next section).

MinHash for Jaccard similarity

For set-valued data (documents as sets of kk-shingles, users as sets of liked items), Jaccard similarity is J(A,B)=∣A∩B∣/∣A∪B∣J(A, B) = |A \cap B|/|A \cup B|.

MinHash picks a random permutation π\pi of the universe and defines hπ(A)=min⁡a∈Aπ(a)h_\pi(A) = \min_{a \in A}\pi(a). The key fact:

Pr⁡π(hπ(A)=hπ(B))=J(A,B).\Pr_\pi(h_\pi(A) = h_\pi(B)) = J(A, B).

The collision probability equals the Jaccard similarity, by symmetry of permutations over union.

In practice, instead of true permutations, use kk universal hash functions and take the minimum hash value of items in AA under each. A document is summarized by a length-kk 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:

hw(x)=sign⁡(w⊤x),w∼N(0,I).h_w(x) = \operatorname{sign}(w^\top x), \qquad w \sim \mathcal{N}(0, I).

A standard result:

Pr⁡w(hw(x)=hw(y))=1−θ(x,y)π,\Pr_w(h_w(x) = h_w(y)) = 1 - \frac{\theta(x, y)}{\pi},

where θ\theta is the angle between xx and yy. Smaller angle (more similar) means higher collision probability. With kk random hyperplanes, each point gets a kk-bit signature; Hamming distance between signatures estimates angular distance.

pp-stable LSH for ℓ2\ell_2

For Euclidean distance, use random projections plus quantization:

hw,b,r(x)=⌊w⊤x+br⌋,h_{w, b, r}(x) = \left\lfloor \frac{w^\top x + b}{r}\right\rfloor,

with ww from a pp-stable distribution (Gaussian for ℓ2\ell_2, Cauchy for ℓ1\ell_1), bb uniform in [0,r][0, r], and bucket width rr. Points within roughly distance rr 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 kk hashes into one composite. Two points collide only if all kk component hashes agree. The collision probability of similar points becomes p1kp_1^k; dissimilar becomes p2kp_2^k. The ratio (p1/p2)k(p_1/p_2)^k grows.
  • OR: build LL hash tables with independent composites. Two points collide if they agree in any table. The collision probability becomes 1−(1−pk)L1 - (1 - p^k)^L[2].

Tuning kk and LL trades recall against precision and against the size of the candidate set retrieved.

Limits

  • Memory: LL tables of nn 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: kk, LL, and rr 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

python · numpy
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 p1=0.9p_1 = 0.9 for a similar pair and p2=0.4p_2 = 0.4 for a dissimilar pair. Form an AND composite of k=5k = 5 bits, then an OR over L=10L = 10 such composites. Compute the final collision probability for each pair and explain why the gap widened.

Solution

Step 1. AND concatenates kk independent bits, so a composite collides only if all kk agree: p→pkp \to p^k. The similar pair becomes 0.95=0.590490.9^5 = 0.59049; the dissimilar pair becomes 0.45=0.010240.4^5 = 0.01024.

Step 2. OR over LL tables collides if any one composite matches, so the miss probability is (1−pk)L(1 - p^k)^L and the collision probability is 1−(1−pk)L1 - (1 - p^k)^L. For the similar pair:

1−(1−0.59049)10=1−0.4095110≈1−0.00013≈0.99987.1 - (1 - 0.59049)^{10} = 1 - 0.40951^{10} \approx 1 - 0.00013 \approx 0.99987.

For the dissimilar pair:

1−(1−0.01024)10=1−0.9897610≈1−0.9022≈0.0978.1 - (1 - 0.01024)^{10} = 1 - 0.98976^{10} \approx 1 - 0.9022 \approx 0.0978.

Step 3. The similar pair now collides with probability about 0.99990.9999 while the dissimilar pair sits near 0.0980.098. AND pushed both probabilities down but hit the small p2p_2 far harder (the ratio (p1/p2)k(p_1/p_2)^k grew); OR then lifted the survivor p1kp_1^k back toward 11 without much helping the already-tiny p2kp_2^k. The combination sharpens a soft monotone signal into a near-threshold.

Exercise 2. Two documents have MinHash signatures of length k=200k = 200 that agree in 150150 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 J(A,B)J(A, B), so the fraction of agreements is an unbiased estimator: J^=150/200=0.75\hat{J} = 150/200 = 0.75.

Step 2. The agreement count is Binomial(k,J)(k, J), so the estimator has variance J(1−J)/kJ(1 - J)/k. Plugging the estimate, the standard error is

0.75⋅0.25200=9.375×10−4≈0.031.\sqrt{\frac{0.75 \cdot 0.25}{200}} = \sqrt{9.375 \times 10^{-4}} \approx 0.031.

Step 3. The standard error scales as 1/k1/\sqrt{k}. Doubling the signature length shrinks it by a factor 2\sqrt{2}, so longer signatures trade memory and hashing cost for a more precise Jaccard estimate.

Exercise 3 (code). Verify the SimHash collision identity Pr⁡w(hw(x)=hw(y))=1−θ(x,y)/π\Pr_w(h_w(x) = h_w(y)) = 1 - \theta(x, y)/\pi. Build pairs at several known angles θ\theta in a high-dimensional space, draw many random hyperplanes, and check that the empirical collision rate matches 1−θ/π1 - \theta/\pi.

Solution
python · numpy
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.01

At θ=π/3\theta = \pi/3 the collision rate is about 0.667=1−(π/3)/π0.667 = 1 - (\pi/3)/\pi, and at θ=π/2\theta = \pi/2 it is about 0.50.5. The empirical rate tracks 1−θ/π1 - \theta/\pi 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. [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. [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 p1p_1 and p2p_2.↩