AI Knowledge Tree

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 O(log⁡n)O(\log n) 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 ℓ\ell drawn from a geometric distribution with parameter 1/log⁡(M)1/\log(M) for some constant MM. A node lives in every layer from 0 up to ℓ\ell. Each layer has fewer nodes than the one below: roughly n0=nn_0 = n at the bottom, n1=n0/Mn_1 = n_0/M, n2=n1/Mn_2 = n_1/M, and so on. The top layer has O(1)O(1) nodes.

Each layer is a small-world graph: each node connects to its MM nearest neighbors at that layer.

Search

Start at the entry point in the top layer. At each layer:

  1. Greedy walk: from the current node, move to the neighbor closest to the query; repeat until no neighbor is closer.
  2. Drop to the next layer, using the current best as the entry point.

Continue until the bottom layer. Return the top-kk neighbors found.

The top-layer walk takes a few hops to reach the right region; each subsequent layer refines locally. Total search cost is O(log⁡n)O(\log n) hops, each of which is an MM-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 xx:

  1. Sample its level ℓ\ell.
  2. Starting from the top, greedy-search down to layer ℓ+1\ell + 1 to find a good entry point.
  3. At each layer from ℓ\ell down to 0, run a beam search with ef_construction candidates; connect xx to the MM best.
  4. If the new connections exceed any existing neighbor's degree budget MM, prune that neighbor's worst edge.

Construction is O(nlog⁡n)O(n \log n) 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

KnobEffect
MM (neighbors per node, default 16)More edges = higher recall and memory
ef_construction (default 100-200)More expensive build, higher index quality
ef_searchHigher recall per query, slower query

Memory: each node stores up to MM edges per layer; total ~2M2 M pointers per node. For M=16M = 16 and n=109n = 10^9, memory budget is ∼\sim200 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: MM edges per node × nn nodes is heavy.
  • No principled hyperparameter selection: MM and ef_construction are 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.

python · numpy
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 ℓ\ell drawn so that the probability of reaching at least layer jj is 1/Mj1/M^{j} for M=16M = 16. With n=109n = 10^9 nodes at layer 00, how many nodes are expected at layers 11, 22, and 33, and roughly how many layers does the index have?

Solution

Step 1. A node reaches layer jj with probability 1/Mj1/M^{j}, so the expected count at layer jj is n/Mjn / M^{j}. With M=16M = 16:

n1=10916≈6.25×107,n2=109256≈3.9×106,n3=1094096≈2.4×105.n_1 = \frac{10^9}{16} \approx 6.25 \times 10^7, \quad n_2 = \frac{10^9}{256} \approx 3.9 \times 10^6, \quad n_3 = \frac{10^9}{4096} \approx 2.4 \times 10^5.

Step 2. The top layer is the largest jj with at least about one node, so Mj≲nM^{j} \lesssim n, giving j≲log⁡Mn=log⁡16109=9ln⁡10/ln⁡16≈7.5j \lesssim \log_M n = \log_{16} 10^9 = 9 \ln 10 / \ln 16 \approx 7.5.

Step 3. The index has roughly 88 layers. Each upper layer is MM times sparser, so the greedy descent from the top reaches the query's region in O(log⁡Mn)O(\log_M n) hops, which is the source of HNSW's logarithmic search cost.

Exercise 2. An HNSW index over n=2×108n = 2 \times 10^8 vectors of dimension d=256d = 256 (float32) uses M=32M = 32. Estimate the memory for the raw vectors and for the graph edges (store each edge as a 4-byte node id, about 2M2M edges per node across layers), and say which dominates.

Solution

Step 1. Raw vectors cost n⋅d⋅4=2×108⋅256⋅4=2.048×1011n \cdot d \cdot 4 = 2 \times 10^8 \cdot 256 \cdot 4 = 2.048 \times 10^{11} bytes, about 205205 GB.

Step 2. Edges cost n⋅2M⋅4=2×108⋅64⋅4=5.12×1010n \cdot 2M \cdot 4 = 2 \times 10^8 \cdot 64 \cdot 4 = 5.12 \times 10^{10} bytes, about 5151 GB.

Step 3. The raw vectors dominate at roughly 205205 GB versus 5151 GB of edges, a total near 256256 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 {10,50,200}\{10, 50, 200\}. Confirm that a larger beam does not lower recall@10 and that it visits more nodes, the recall-for-work trade ef controls.

Solution
python · numpy
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 = mr

Recall@10 climbs from about 0.620.62 at ef =10= 10 to about 0.980.98 at ef =200= 200, while nodes visited rise from roughly 160160 to roughly 1,0801{,}080 of 3,0003{,}000. 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. [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.↩