Approximate Nearest Neighbor Search
Nearest neighbor search is everywhere: recommendation systems, image search, document similarity. Exact search is too slow at scale — approximate is the practical solution.
The Problem
Given a query point, find the closest points in a dataset of N dimensions.
Why Exact Search Fails
| Dataset Size | Exact (Brute Force) | What Takes Time |
|---|---|---|
| 10K points | 1 ms | Fine for small datasets |
| 1M points | 100 ms | Slow for real-time |
| 1B points | 100 seconds | Unusable |
Exact k-NN: O(N×D) per query (N = points, D = dimensions).
Approximate Nearest Neighbor (ANN)
Trade a small accuracy loss for massive speedup.
| Metric | Exact | ANN | Ratio |
|---|---|---|---|
| Recall | 100% | 90-99% | Acceptable |
| Queries/sec | 10 | 10,000 | 1000× faster |
| Memory | Full dataset | Index | 2-5× dataset size |
LSH (Locality-Sensitive Hashing)
Hash similar points to the same bucket with high probability.
Random Projection LSH
import numpy as np
class RandomProjectionLSH:
def __init__(self, dim, num_tables=10, hash_len=8):
self.num_tables = num_tables
self.hash_len = hash_len
# Random projection vectors
self.tables = []
for _ in range(num_tables):
r = np.random.randn(dim, hash_len)
self.tables.append(r)
self.buckets = [{} for _ in range(num_tables)]
def _hash(self, table_idx, vector):
r = self.tables[table_idx]
projection = vector @ r # Dot product
return tuple((projection > 0).astype(int)) # Binary hash
def insert(self, item_id, vector):
for t in range(self.num_tables):
h = self._hash(t, vector)
if h not in self.buckets[t]:
self.buckets[t][h] = []
self.buckets[t][h].append(item_id)
def query(self, vector, k=10):
candidates = set()
for t in range(self.num_tables):
h = self._hash(t, vector)
if h in self.buckets[t]:
candidates.update(self.buckets[t][h])
# Now compute exact distances only among candidates
return candidates| Property | Value |
|---|---|
| Query time | Sub-linear (depends on bucket size) |
| Accuracy | 70-95% (adjustable via num_tables) |
| Dimensions | Good for 50-500 |
| Memory | O(N) + table overhead |
Product Quantization (PQ)
Compresses vectors by splitting them into sub-vectors and quantizing each.
How It Works
Original vector (128 dims):
[x1, x2, ..., x128]
Split into 8 sub-vectors of 16 dims each:
[x1...x16], [x17...x32], ..., [x113...x128]
For each of the 8 sub-spaces, learn a codebook of 256 centroids:
Sub-space 0: [c0_0, c0_1, ..., c0_255]
Sub-space 1: [c1_0, c1_1, ..., c1_255]
...
Sub-space 7: [c7_0, c7_1, ..., c7_255]
Encode each vector as 8 bytes (one centroid index per sub-space):
Original (128 × 4 bytes = 512 bytes)
→ Encoded (8 × 1 byte = 8 bytes)
→ 64× compression!class ProductQuantizer:
def __init__(self, dim, num_subvectors=8, num_centroids=256):
self.M = num_subvectors # Number of sub-vectors (m)
self.K = num_centroids # Number of centroids per sub-vector (k*)
self.Ds = dim // self.M # Dimension per sub-vector (d*)
self.codebooks = None
def fit(self, vectors):
"""Learn centroids via k-means per sub-space."""
n = vectors.shape[0]
subspaces = vectors.reshape(n, self.M, self.Ds)
self.codebooks = []
for m in range(self.M):
sub_vectors = subspaces[:, m, :]
# K-means with K centroids
centroids = self._kmeans(sub_vectors, self.K)
self.codebooks.append(centroids)
def encode(self, vector):
"""Encode as M centroid indices."""
sub_vectors = vector.reshape(self.M, self.Ds)
codes = []
for m in range(self.M):
# Nearest centroid
dists = np.linalg.norm(self.codebooks[m] - sub_vectors[m], axis=1)
codes.append(np.argmin(dists))
return codes # M bytes| Property | Value |
|---|---|
| Compression | 10-100× |
| Query speed | 100-1000× faster than brute force |
| Accuracy | 80-95% (with ADC distance) |
| Works with | IVF, HNSW |
HNSW (Hierarchical Navigable Small World)
The most popular ANN algorithm. Builds a multi-layer graph.
Layer 3 (top): ●──●──● (few nodes, long connections)
│
Layer 2: ●──●──●──●──● (more nodes)
│ │ │ │ │
Layer 1 (bottom): ●──●──●──●──●──●──● (all nodes, short connections)Search Algorithm
- Start at top layer (few nodes)
- Greedily navigate to nearest node
- Descend to next layer
- Repeat until bottom layer
- At bottom layer, expand search
def search(hnsw_graph, query, ef=100):
"""HNSW search with ef = number of candidates to explore."""
current = hnsw_graph.entry_point
# Phase 1: Coarse search through layers (top → bottom)
for layer in reversed(range(1, len(hnsw_graph.layers))):
current = greedy_search(hnsw_graph, query, current, layer)
# Phase 2: Fine search at bottom layer
candidates = set()
candidates.add(current)
visited = set()
results = MinHeap()
results.push(current, distance(query, current.vector))
while candidates and len(visited) < ef:
node = candidates.pop()
visited.add(node)
for neighbor in hnsw_graph.bottom_layer[node]:
if neighbor not in visited:
d = distance(query, neighbor.vector)
results.push(neighbor, d)
candidates.add(neighbor)
return results.k_smallest(k)| Property | HNSW | PQ | LSH | |
IVF (Inverted File Index)
Like LSH but uses k-means clustering.
How It Works
- Cluster all vectors into K clusters (k-means)
- For each cluster, store a list of vectors assigned to it
- Query: find nearest cluster centroids, search only those clusters
class IVF:
def __init__(self, n_clusters=100):
self.n_clusters = n_clusters
self.centroids = None
self.lists = None
def fit(self, vectors, ids):
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=self.n_clusters)
labels = kmeans.fit_predict(vectors)
self.centroids = kmeans.cluster_centers_
self.lists = [[] for _ in range(self.n_clusters)]
for idx, label in enumerate(labels):
self.lists[label].append((vectors[idx], ids[idx]))
def query(self, vector, k=10, nprobe=10):
# Find nprobe nearest clusters
dists = np.linalg.norm(self.centroids - vector, axis=1)
nearest = np.argsort(dists)[:nprobe]
# Search those clusters exhaustively
candidates = []
for cluster_id in nearest:
candidates.extend(self.lists[cluster_id])
# Exact search among candidates
candidates.sort(key=lambda x: np.linalg.norm(x[0] - vector))
return candidates[:k]faiss (Facebook AI Similarity Search)
The standard library for ANN.
import faiss
import numpy as np
# Generate data
d = 128 # Dimension
n = 1000000 # 1M vectors
xb = np.random.random((n, d)).astype('float32')
# Build index
index = faiss.IndexFlatL2(d) # Exact search (baseline)
# index = faiss.IndexIVFFlat(index, d, 100) # IVF (faster)
# index = faiss.IndexHNSWFlat(d, 32) # HNSW (fast, accurate)
# index = faiss.IndexPQ(d, 8, 8) # Product Quantization (compressed)
index.train(xb)
index.add(xb)
# Search
xq = np.random.random((1, d)).astype('float32')
D, I = index.search(xq, k=10) # 10 nearest neighborsIndex Types in faiss
| Index | Type | Speed vs Exact |
|---|---|---|
| IndexFlatL2 | Exact (brute force) | 1× |
| IndexIVFFlat | IVF + exact | 10-100× |
| IndexIVFPQ | IVF + PQ | 100-1000× |
| IndexHNSWFlat | HNSW | 100-1000× |
| IndexBinaryFlat | Binary vectors | 40× compression |
Evaluation
| Metric | What It Measures |
|---|---|
| Recall@k | Fraction of true nearest neighbors found |
| Queries per second (QPS) | Throughput |
| Index build time | Training cost |
| Memory usage | RAM required |
Typical Parameters
| Algorithm | Parameter | Effect |
|---|---|---|
| HNSW | efSearch (100-500) | Higher = more accurate, slower |
| HNSW | M (16-48) | Higher = more accurate, more memory |
| IVF | nprobe (10-100) | Higher = more accurate, slower |
| PQ | M (8-32) | Higher = more accurate, larger index |
| LSH | num_tables (5-20) | Higher = more accurate, larger |
| Recall | Speed vs Exact |
|---|---|
| 90% | 1000× faster |
| 95% | 100× faster |
| 99% | 10× faster |
Approximate search is the difference between “impossible at scale” and “instant.”
Evaluation Metrics for ANN
Quality metrics for approximate nearest neighbor search balance accuracy against speed and memory. Recall@k measures the fraction of true nearest neighbors (from brute-force k-NN) that appear in the approximate result set — a standard target is 95%+ recall at 10-100 neighbors. Queries per second (QPS) measures throughput under concurrent load. Index build time and memory footprint determine whether an algorithm scales to billion-scale datasets. The trade-off is intuitive: higher recall requires more memory and slower queries. The winning approach in the ANN Benchmarks consistently combines multiple algorithms: IVF for coarse partitioning followed by HNSW for refinement within each partition.
Practical Applications
ANN powers recommendation systems at major technology companies. When a user views a product, the system encodes it into an embedding vector and queries the ANN index for the nearest neighbors — products that are most similar in embedding space. Spotify uses ANN for music recommendation, Pinterest for visual similarity search, and Uber for ride-matching optimization. In biology, ANN accelerates genomic sequence alignment by finding similar DNA sequences in large databases. In computer vision, ANN enables real-time image retrieval across billions of photographs, where the query is an image embedding from a convolutional neural network. In each case, the ability to find approximate nearest neighbors in milliseconds rather than seconds or minutes makes the application feasible at scale.
Frequently Asked Questions
What is the difference between exact and approximate nearest neighbor search? Exact nearest neighbor search guarantees finding the true closest points by exhaustive comparison, which is O(n) per query and impractical for large datasets. Approximate nearest neighbor search returns the closest points with high probability, sacrificing perfect accuracy for orders-of-magnitude speedup — typically 10-1000x faster than exact search.
Which ANN algorithm is best? No single algorithm dominates across all metrics. HNSW offers the best speed-recall tradeoff for in-memory datasets up to millions of vectors. IVF provides better memory efficiency. Milvus and other vector databases combine multiple algorithms and let users choose based on their workload characteristics. Benchmark on your specific data distribution and query pattern.
How do I choose the number of nearest neighbors? The optimal k depends on the application. For recommendation, k=10-100 is typical. For classification, use cross-validation. For similarity search, the number of results users want to see determines k. Higher k values require more computation and memory but provide more comprehensive results.
Graph Algorithms Guide — Hash Tables Guide — Searching Algorithms Guide