How does an HNSW index search its layers to find nearest neighbours?
answer
- walk the graph, do not scan it
- sparse top layer, dense bottom layer
- greedy until no neighbour is closer
- layer 0 holds every vector
- a beam of candidates, not one
basics
~20 sHNSW stacks proximity graphs. Search starts at one entry point in the sparse top layer and greedily hops to closer neighbours, dropping a layer at each local minimum, then runs a widened beam search over the dense bottom layer.
solid answer
~60 sHNSW — Hierarchical Navigable Small World — stores the vectors as nodes in a graph whose edges join close vectors, and stacks several such graphs. Every vector lives in layer 0; each higher layer is an exponentially smaller random sample, so its edges span long distances. A query enters at the single top-layer entry point and does a **greedy walk**: look at the current node's neighbours, move to whichever is closer to the query, stop when none is closer, then use that node as the entry point one layer down. The upper layers are a coarse-to-fine zoom that lands you in the right neighbourhood in a handful of hops. At layer 0 the search switches from greedy to **best-first beam search**, keeping a candidate list of size `ef` and a visited set, and stops when the nearest unexplored candidate is farther than the worst result held. The top k of that list is returned. It is approximate: a greedy walk can settle in a local minimum, and a wider beam is what buys the recall back.
code
python · 28 linesimport heapq
def search_layer(graph, vectors, query, entry_points, ef, dist):
candidates = [(dist(query, vectors[p]), p) for p in entry_points]
heapq.heapify(candidates)
best = [(-d, p) for d, p in candidates]
heapq.heapify(best)
visited = set(entry_points)
while candidates:
d, c = heapq.heappop(candidates)
if len(best) >= ef and d > -best[0][0]:
break
for n in graph.get(c, ()):
if n in visited:
continue
visited.add(n)
dn = dist(query, vectors[n])
if len(best) < ef or dn < -best[0][0]:
heapq.heappush(candidates, (dn, n))
heapq.heappush(best, (-dn, n))
if len(best) > ef:
heapq.heappop(best)
return [p for _, p in sorted((-d, p) for d, p in best)]
vectors = {0: (0.0, 0.0), 1: (1.0, 0.0), 2: (0.0, 1.0), 3: (2.0, 2.0), 4: (3.0, 0.5)}
graph = {0: [1, 2], 1: [0, 3], 2: [0, 3], 3: [1, 2, 4], 4: [3]}
sq = lambda a, b: (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
print(search_layer(graph, vectors, (2.5, 1.5), [0], ef=3, dist=sq))go deeper
Be able to say that HNSW is an approximate index built as a graph of vectors, that search walks from neighbour to neighbour instead of scanning everything, and that it trades a little accuracy for a lot of speed.
Explain the two phases in order: greedy single-candidate descent from the sparse top layer down, then beam search with a candidate list at layer 0. Say why the upper layers exist — long-range navigation — and that layer 0 holds every vector.
Show that you know the result is approximate and that the beam width at layer 0 is the recall lever you actually reach for in production. Be ready to describe how you would measure the recall shortfall against a brute-force ground truth on a sample.
Frame the hierarchy as one point on the recall-latency-memory triangle: it buys sub-linear hop count and needs no training pass, at the cost of a memory-resident graph. Be ready to argue when a graph index is the wrong shape for the workload at all.
## What the index is for Exact nearest-neighbour search over an embedding collection means comparing the query with every stored vector. That cost is linear in the number of vectors and becomes untenable long before a catalogue reaches tens of millions of rows. An approximate nearest-neighbour (ANN) index accepts a small probability of missing a true neighbour in exchange for search cost that grows far more slowly with corpus size. HNSW — Hierarchical Navigable Small World — is the graph-based member of that family, and it is the default index in most vector stores because it gives high recall at low latency without a training pass and while accepting new vectors one at a time. ## A proximity graph, and why a plain one is not enough The base idea is a graph: each stored vector is a node, and edges connect vectors that are near one another. Searching such a graph is a walk rather than a scan. Start at some node, measure the query against the current node's neighbours, step to whichever is closest, and repeat. This is greedy hill-climbing on distance, and it visits only a tiny fraction of the nodes. A purely local proximity graph fails in two ways. If every edge is short, a query starting far away needs an enormous number of hops to cross the space. And a greedy walk can arrive at a node where no neighbour is closer to the query, yet the true nearest neighbour sits elsewhere — a local minimum. The "navigable small world" property is the fix: mix in some long-range edges so the graph has short average path length while retaining local clustering, exactly the property that makes social networks navigable in few hops. ## The layer hierarchy HNSW gets its long-range structure from layers. When a vector is inserted, it is assigned a maximum layer drawn from an exponentially decaying random distribution: nearly all vectors stop at layer 0, a small fraction also appear in layer 1, a much smaller fraction in layer 2, and so on. Layer 0 therefore contains every vector, and each layer above is a sparse random sample of the one below. Because the upper layers hold so few nodes, their edges connect points that are far apart in the embedding space. The structure is best understood as a skip list generalised to a metric space: the top layers let you cross the space in long jumps, the lower layers refine position with short ones. The randomised level assignment matters — it needs no global view of the data, so insertion stays a local operation and the index can grow incrementally. ## Greedy descent, then beam search A query proceeds in two phases. **Descent.** Start at the fixed entry point in the topmost non-empty layer with a candidate list of size one. Greedily hop to closer neighbours until the current node has no neighbour closer to the query. That node becomes the entry point for the layer below, and the same greedy walk repeats there. Each layer down has more nodes and shorter edges, so the walk zooms in. **Beam search.** At layer 0 the algorithm widens. It runs a best-first search with a dynamic candidate list of size `ef`, popping the closest unvisited candidate, expanding its neighbours, keeping a visited set so no node is scored twice, and maintaining the `ef` best results found so far. It terminates when the closest unexplored candidate is farther than the worst of the current best set — at that point no unexplored path can improve the answer within the beam. The best k of that list is the result. The beam is what turns a fragile greedy walk into a reliable one: with `ef` parallel candidate paths, a single dead end no longer ends the search. `ef` must be at least k, and raising it trades latency for recall. ## Why it is approximate, and what that costs Nothing in the procedure proves the true nearest neighbour was reached. The walk explores a small connected region of the graph, and the true neighbour can be unreachable from that region within the beam budget. In practice a well-built HNSW graph reaches recall in the high nineties at a fraction of the work of an exhaustive scan, and the shortfall is measured empirically against a brute-force ground truth rather than derived. The search cost grows roughly logarithmically with corpus size — the layer hierarchy is exactly the mechanism that keeps hop count from scaling with n. The price is paid elsewhere: the graph edges must be resident in memory for random-access traversal, and the graph has to be built before any of this works. ## What interviewers listen for The strong answer names the two distinct phases — greedy single-candidate descent through the layers, then widened beam search at layer 0 — and explains why the hierarchy exists at all (long-range navigation) rather than reciting "it is like a skip list" with no mechanism behind it. Saying plainly that the result is approximate, and that the beam width is the recall lever, separates someone who has tuned an index from someone who has read its README.
- Why assign each vector's maximum layer at random instead of clustering the data first?A random, exponentially decaying level assignment needs no global view of the dataset, so inserting a vector stays a purely local operation and the index can grow incrementally without a training pass. Clustering would give a data-aware hierarchy but would have to be recomputed as the distribution drifts, and it would couple insertion to a global structure. The randomised scheme gives the logarithmic layer sizes that make navigation work, which is all the hierarchy is actually for.
- Does the greedy descent through the upper layers guarantee you reach the true nearest region?No. Greedy descent stops at a local minimum in each layer, and that local minimum need not be the globally closest node in that layer. The hierarchy makes a bad landing unlikely rather than impossible, and the beam search at layer 0 is the real safety net — it explores several candidate paths at once, so one dead end does not end the query. This is exactly why the index is described as approximate.
- What terminates the beam search at the bottom layer?The search keeps a priority queue of unexplored candidates and a set of the best results found so far. It stops when the nearest unexplored candidate is farther from the query than the worst result currently held, and the result set is already full. At that point no path still open can improve the answer within the beam width, so continuing only burns distance computations.
Like finding a house in an unfamiliar country: take the motorway network to the right region, the trunk roads to the right town, then walk the streets — and at street level check several parallel routes rather than committing to the first turn that looks right.
saying these in an interview costs you the question
- Describes HNSW as exact nearest-neighbour search
- Claims every layer stores all of the vectors
- Describes a tree traversal instead of a graph walk
- Says greedy descent alone returns the top-k results
- Confuses the layers with data clusters or partitions