Hierarchical navigable small world in Python
A multilayer navigable small world graph whose sparse upper layers guide greedy search in the dense bottom layer.
- File
hnsw.py, 128 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hnsw/. The authors are listed in prov/ZFDM4P.jsonl.
import heapq
import math
import random
from collections.abc import Callable, Sequence
Vector = Sequence[float]
def euclidean(x: Vector, y: Vector) -> float:
return math.sqrt(sum((a - b) * (a - b) for a, b in zip(x, y)))
class HNSW:
"""Hierarchical navigable small world graph (Malkov and Yashunin).
Each element gets a top level floor(-ln U * mL) with mL = 1/ln M, so Pr[level >= j] = M^-j.
Layer l > 0 keeps at most M links per element and layer 0 at most m_max0.
"""
def __init__(
self,
dist: Callable[[Vector, Vector], float] = euclidean,
M: int = 8,
ef_construction: int = 32,
m_max0: int | None = None,
heuristic: bool = True,
rng: random.Random | None = None,
) -> None:
if M < 2:
raise ValueError("M must be at least 2")
self.dist = dist
self.M = M
self.m_max0 = m_max0 if m_max0 is not None else 2 * M
self.ef_c = ef_construction
self.mL = 1 / math.log(M)
self.heuristic = heuristic
self.rng = rng if rng is not None else random.Random()
self.points: list[Vector] = []
self.links: list[list[list[int]]] = [] # links[i][l] = neighbors of i at layer l
self.entry: int | None = None
self.top = -1
def _d(self, q: Vector, i: int) -> float:
return self.dist(q, self.points[i])
def search_layer(self, q: Vector, entry: list[int], ef: int, layer: int) -> list[tuple[float, int]]:
"""Beam search on one layer; return up to ef pairs (distance, id), nearest first."""
visited = set(entry) # @line L1
cand = [(self._d(q, e), e) for e in entry]
heapq.heapify(cand)
best = [(-d, e) for d, e in cand]
heapq.heapify(best)
while len(best) > ef:
heapq.heappop(best)
while cand: # @line L2
dc, c = heapq.heappop(cand) # @line L3
if dc > -best[0][0]: # @line L4
break
for e in self.links[c][layer]: # @line L5
if e in visited:
continue
visited.add(e)
de = self._d(q, e)
if len(best) < ef or de < -best[0][0]: # @line L6
heapq.heappush(cand, (de, e))
heapq.heappush(best, (-de, e))
if len(best) > ef:
heapq.heappop(best)
return sorted((-d, e) for d, e in best) # @line L7
def search(self, q: Vector, k: int, ef: int = 32) -> list[tuple[float, int]]:
"""Return up to k pairs (distance, id), nearest first."""
if self.entry is None:
return []
ep = [self.entry] # @line L8
for layer in range(self.top, 0, -1):
ep = [self.search_layer(q, ep, 1, layer)[0][1]]
w = self.search_layer(q, ep, max(ef, k), 0) # @line L9
return w[:k] # @line L10
def _select(self, x: Vector, cands: list[tuple[float, int]], m: int) -> list[int]:
"""Neighbor choice: the m nearest, or the heuristic that keeps e only if it is nearer to x
than to every neighbor already kept (Malkov and Yashunin, Algorithm 4 without extensions)."""
cands = sorted(cands)
if not self.heuristic:
return [e for _, e in cands[:m]]
out: list[int] = []
for de, e in cands:
if len(out) >= m:
break
if all(de < self.dist(self.points[e], self.points[s]) for s in out):
out.append(e)
return out
def random_level(self) -> int:
u = 1.0 - self.rng.random() # uniform in (0, 1]
return math.floor(-math.log(u) * self.mL) # @line L11
def insert(self, x: Vector, level: int | None = None) -> int:
"""Insert x and return its id. The level may be fixed for tests."""
lvl = self.random_level() if level is None else level
i = len(self.points)
self.points.append(x)
self.links.append([[] for _ in range(lvl + 1)])
if self.entry is None:
self.entry, self.top = i, lvl
return i
ep = [self.entry]
for layer in range(self.top, lvl, -1): # @line L12
ep = [self.search_layer(x, ep, 1, layer)[0][1]]
for layer in range(min(self.top, lvl), -1, -1): # @line L13
w = self.search_layer(x, ep, self.ef_c, layer) # @line L14
m_max = self.m_max0 if layer == 0 else self.M
nbrs = self._select(x, w, self.M if layer > 0 else min(self.M, m_max))
self.links[i][layer] = list(nbrs)
for e in nbrs:
self.links[e][layer].append(i)
if len(self.links[e][layer]) > m_max: # @line L15
pe = self.points[e]
pool = [(self.dist(pe, self.points[f]), f) for f in self.links[e][layer]]
self.links[e][layer] = self._select(pe, pool, m_max)
ep = [e for _, e in w] # @line L16
if lvl > self.top: # @line L17
self.entry, self.top = i, lvl
return i
test_hnsw.py 67 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hnsw/. The authors are listed in prov/ZFDM4P.jsonl.
import math
import random
from hnsw import HNSW, euclidean
PTS = [(0, 0), (4, 0), (8, 1), (1, 5), (5, 6), (9, 5), (2, 9), (7, 9)]
LEVELS = [2, 0, 1, 0, 1, 0, 0, 0]
def build_example() -> HNSW:
h = HNSW(M=2, ef_construction=4, m_max0=3, heuristic=False)
for p, lv in zip(PTS, LEVELS):
h.insert(p, level=lv)
return h
def test_example() -> None:
h = build_example()
assert h.entry == 0 and h.top == 2
assert sorted(h.links[0][1]) == [2, 4]
assert h.search((6, 7), 1, ef=2) == [(math.sqrt(2), 4)]
def test_level_distribution() -> None:
h = HNSW(M=4, rng=random.Random(1))
levels = [h.random_level() for _ in range(40000)]
for j in range(4):
frac = sum(lv >= j for lv in levels) / len(levels)
assert abs(frac - 4.0**-j) < 0.01
def test_exact_without_pruning() -> None:
rng = random.Random(21)
for _ in range(500):
n = rng.randint(1, 25)
d = rng.randint(1, 3)
pts = [tuple(rng.uniform(-5, 5) for _ in range(d)) for _ in range(n)]
h = HNSW(M=rng.randint(2, 4), ef_construction=rng.randint(1, 8), m_max0=n, rng=rng,
heuristic=rng.random() < 0.5)
for p in pts:
h.insert(p)
q = tuple(rng.uniform(-6, 6) for _ in range(d))
k = rng.randint(1, 4)
got = h.search(q, k, ef=n)
assert [a for a, _ in got] == sorted(euclidean(q, p) for p in pts)[:k]
def test_invariants_and_recall() -> None:
rng = random.Random(5)
pts = [tuple(rng.random() for _ in range(4)) for _ in range(600)]
h = HNSW(M=6, ef_construction=40, rng=rng)
for p in pts:
h.insert(p)
for i, layers in enumerate(h.links):
for lv, nb in enumerate(layers):
assert len(nb) <= (h.m_max0 if lv == 0 else h.M)
assert i not in nb
assert all(len(h.links[e]) > lv for e in nb)
hits = 0
for _ in range(100):
q = tuple(rng.random() for _ in range(4))
want = {i for _, i in sorted((euclidean(q, p), i) for i, p in enumerate(pts))[:10]}
hits += len(want & {i for _, i in h.search(q, 10, ef=50)})
assert hits / 1000 >= 0.9
What the next level needs
- the tests did not run in this build