HNSW graph index
Build a hierarchical small-world graph that supports logarithmic-time greedy search.
HNSW (Hierarchical Navigable Small World, Malkov & Yashunin 2016) is the graph-based ANN index that dominates the recall-QPS curve at moderate to large scale. The idea: build a multi-layer graph where each upper layer is a sparse "express lane" to long-range neighbors, and lower layers refine. A greedy traversal from the top reaches near-optimal neighbors in steps[1].
Small-world graphs
A small-world graph combines two kinds of edges:
- Short-range: connects each node to its local neighbors. Lots of edges, low average degree, no long jumps.
- Long-range: a few edges per node to far-away points. Few edges, but they make any pair of nodes reachable in a small number of hops.
The combination has the property of social networks: high local clustering plus six-degrees-of-separation diameter. Greedy navigation ("at each hop, go to the neighbor closest to the target") works well in these structures.
Hierarchical layers
HNSW stacks small-world graphs in layers. Each node is assigned a level drawn from a geometric distribution with parameter for some constant . A node lives in every layer from 0 up to . Each layer has fewer nodes than the one below: roughly at the bottom, , , and so on. The top layer has nodes.
Each layer is a small-world graph: each node connects to its nearest neighbors at that layer.
Search
Start at the entry point in the top layer. At each layer:
- Greedy walk: from the current node, move to the neighbor closest to the query; repeat until no neighbor is closer.
- Drop to the next layer, using the current best as the entry point.
Continue until the bottom layer. Return the top- neighbors found.
The top-layer walk takes a few hops to reach the right region; each subsequent layer refines locally. Total search cost is hops, each of which is an -way distance computation.
For higher recall, run the bottom-layer search with a beam: keep a
priority queue of ef_search candidates instead of just the best. Larger
ef_search boosts recall.
Construction
To insert a new node :
- Sample its level .
- Starting from the top, greedy-search down to layer to find a good entry point.
- At each layer from down to 0, run a beam search with
ef_constructioncandidates; connect to the best. - If the new connections exceed any existing neighbor's degree budget , prune that neighbor's worst edge.
Construction is in distance evaluations. The result is a single static index; HNSW supports incremental insert but not delete (deletes are handled by tombstones with periodic rebuild).
Trade-offs
| Knob | Effect |
|---|---|
| (neighbors per node, default 16) | More edges = higher recall and memory |
ef_construction (default 100-200) | More expensive build, higher index quality |
ef_search | Higher recall per query, slower query |
Memory: each node stores up to edges per layer; total ~ pointers per node. For and , memory budget is 200 GB, which is large. Quantized variants (HNSW + PQ) cut this.
Recall-QPS dominance
HNSW dominates the recall-QPS Pareto curve at moderate to large scale (typically up to a few hundred million vectors). Above that, memory constraints make IVF-PQ more practical.
Limits
- Memory: edges per node × nodes is heavy.
- No principled hyperparameter selection: and
ef_constructionare tuned empirically. - No exact recall guarantees.
In production, FAISS, hnswlib, ScaNN, and managed vector DBs all expose HNSW or HNSW + quantization. The next lesson surveys what the major vector DBs do.
Check it on arrays
A toy HNSW with a single layer (greedy search on a small-world graph). The hierarchical structure improves logarithmically but is the same idea.
import numpy as np
rng = np.random.default_rng(0)
n, d, M = 5000, 32, 16
X = rng.normal(size=(n, d))
queries = rng.normal(size=(50, d))
# Build a "small-world" graph: connect each node to its M nearest neighbors
def build_graph(X, M):
n = len(X)
edges = [[] for _ in range(n)]
for i in range(n):
d2 = ((X - X[i])**2).sum(axis=1)
d2[i] = np.inf
neighbors = np.argsort(d2)[:M]
edges[i].extend(neighbors.tolist())
return edges
edges = build_graph(X, M)
# Greedy beam search: keep the top-`ef` best-so-far candidates; stop when no
# neighbor of any frontier node is closer than the worst kept candidate.
# Crucially, the loop terminates instead of visiting every reachable node.
def greedy_search(query, edges, X, k=10, ef=100, n_starts=5, rng=rng):
starts = rng.choice(len(X), size=n_starts, replace=False)
visited = set(int(s) for s in starts)
# Top-ef best (distance, node) seen so far, ascending by distance
best = sorted((np.sum((X[s] - query)**2), int(s)) for s in starts)
# Frontier of nodes whose neighbors are worth expanding
frontier = list(best)
while frontier:
frontier.sort()
d2_curr, node = frontier.pop(0)
# Stop expanding if the current frontier node is farther than the worst
# kept candidate, i.e. no improvement is possible from here.
if len(best) >= ef and d2_curr > best[-1][0]:
break
for nb in edges[node]:
if nb in visited: continue
visited.add(nb)
d2 = np.sum((X[nb] - query)**2)
# Only push into best (and the frontier) if it improves the top-ef.
if len(best) < ef or d2 < best[-1][0]:
best.append((d2, nb)); best.sort(); best = best[:ef]
frontier.append((d2, nb))
return [n for _, n in best[:k]]
# Recall@10
def true_knn(q, k=10):
return np.argsort(((X - q)**2).sum(axis=1))[:k].tolist()
# Measure both recall and how many nodes the search actually visits
# (compared to the brute-force n=5000).
def greedy_search_counted(query, edges, X, k=10, ef=100, n_starts=5, rng=rng):
starts = rng.choice(len(X), size=n_starts, replace=False)
visited = set(int(s) for s in starts)
best = sorted((np.sum((X[s] - query)**2), int(s)) for s in starts)
frontier = list(best)
while frontier:
frontier.sort()
d2_curr, node = frontier.pop(0)
if len(best) >= ef and d2_curr > best[-1][0]: break
for nb in edges[node]:
if nb in visited: continue
visited.add(nb)
d2 = np.sum((X[nb] - query)**2)
if len(best) < ef or d2 < best[-1][0]:
best.append((d2, nb)); best.sort(); best = best[:ef]
frontier.append((d2, nb))
return [n for _, n in best[:k]], len(visited)
recalls, visits = [], []
for q in queries:
nn, v = greedy_search_counted(q, edges, X, k=10, ef=100)
truth = set(true_knn(q, 10))
recalls.append(len(set(nn) & truth) / 10)
visits.append(v)
print(f"toy small-world graph recall@10: {np.mean(recalls):.3f}")
print(f"avg nodes visited per query: {np.mean(visits):.0f} / {len(X)} "
f"(brute force would touch all {len(X)})")The single-layer small-world graph reaches ~88% recall@10 while visiting roughly 16% of the database, sub-linear in n. HNSW's layered structure adds logarithmic-time top-down navigation that pushes this further at scale.
Exercises
Exercise 1. HNSW assigns each node a level drawn so that the probability of reaching at least layer is for . With nodes at layer , how many nodes are expected at layers , , and , and roughly how many layers does the index have?
Solution
Step 1. A node reaches layer with probability , so the expected count at layer is . With :
Step 2. The top layer is the largest with at least about one node, so , giving .
Step 3. The index has roughly layers. Each upper layer is times sparser, so the greedy descent from the top reaches the query's region in hops, which is the source of HNSW's logarithmic search cost.
Exercise 2. An HNSW index over vectors of dimension (float32) uses . Estimate the memory for the raw vectors and for the graph edges (store each edge as a 4-byte node id, about edges per node across layers), and say which dominates.
Solution
Step 1. Raw vectors cost bytes, about GB.
Step 2. Edges cost bytes, about GB.
Step 3. The raw vectors dominate at roughly GB versus GB of edges, a total near GB. The vectors are the bigger cost, which is why HNSW combined with PQ (quantizing the vectors while keeping the graph) is the standard way to fit large graph indices in memory.
Exercise 3 (code). Take the toy single-layer small-world graph and sweep the
beam width ef over . Confirm that a larger beam does not lower
recall@10 and that it visits more nodes, the recall-for-work trade ef controls.
Solution
import numpy as np
rng = np.random.default_rng(0)
n, d, M = 3000, 32, 16
X = rng.normal(size=(n, d))
queries = rng.normal(size=(40, d))
def build_graph(X, M): # connect each node to its M nearest
edges = [[] for _ in range(len(X))]
for i in range(len(X)):
d2 = ((X - X[i]) ** 2).sum(1); d2[i] = np.inf
edges[i] = np.argsort(d2)[:M].tolist()
return edges
edges = build_graph(X, M)
def greedy(query, ef, k=10, n_starts=5, rng=rng):
starts = rng.choice(n, size=n_starts, replace=False)
visited = set(int(s) for s in starts)
best = sorted((np.sum((X[s] - query) ** 2), int(s)) for s in starts)
frontier = list(best)
while frontier:
frontier.sort()
dc, node = frontier.pop(0)
if len(best) >= ef and dc > best[-1][0]:
break
for nb in edges[node]:
if nb in visited:
continue
visited.add(nb)
d2 = np.sum((X[nb] - query) ** 2)
if len(best) < ef or d2 < best[-1][0]:
best.append((d2, nb)); best.sort(); best = best[:ef]
frontier.append((d2, nb))
return [i for _, i in best[:k]], len(visited)
def true_knn(q, k=10):
return set(np.argsort(((X - q) ** 2).sum(1))[:k].tolist())
prev = None
for ef in [10, 50, 200]:
recs, vis = [], []
for q in queries:
nn, v = greedy(q, ef)
recs.append(len(set(nn) & true_knn(q)) / 10); vis.append(v)
mr = float(np.mean(recs))
print(f"ef={ef}: recall@10={mr:.3f} visited={np.mean(vis):.0f}/{n}")
if prev is not None:
assert mr >= prev - 1e-9 # larger beam never hurts recall
prev = mrRecall@10 climbs from about at ef to about at ef , while nodes visited rise from roughly to roughly of
. The beam width is the query-time recall knob: spend more distance
computations, miss fewer true neighbors.
The next lesson surveys the production vector databases that wrap these algorithms.
References
- [1]Malkov, Y. A., & Yashunin, D. A. (2018). "Efficient and robust approximate nearest neighbor search using hierarchical navigable small world graphs". IEEE TPAMI, 42(4), 824-836. arXiv:1603.09320. The HNSW index, its layer construction, and greedy search.↩