DiskANN in Python
A graph index built with robust pruning that is stored on SSD and answers queries with few random reads.
- File
diskann.py, 89 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/diskann/. The authors are listed in prov/KR79N8.jsonl.
"""The Vamana graph of DiskANN: greedy search, robust pruning and graph construction."""
import math
import random
from typing import Callable, Optional, Sequence
Point = Sequence[float]
def euclidean(a: Point, b: Point) -> float:
return math.dist(a, b)
class Vamana:
def __init__(self, points: Sequence[Point],
dist: Callable[[Point, Point], float] = euclidean) -> None:
self.points = list(points)
self.dist = dist
self.adj: list[list[int]] = [[] for _ in self.points]
self.start = self.medoid()
def d(self, i: int, x: Point) -> float:
return self.dist(self.points[i], x)
def medoid(self) -> int:
n = len(self.points)
if n == 0:
return -1
return min(range(n), key=lambda i: (sum(self.d(i, p) for p in self.points), i))
def greedy_search(self, q: Point, k: int, L: int,
start: Optional[int] = None) -> tuple[list[int], set[int]]:
"""Returns the k nearest of the final list and the set of expanded (read) nodes."""
s = self.start if start is None else start
cand = {s} # @line L1
visited: set[int] = set()
while True:
open_ = [p for p in cand if p not in visited] # @line L2
if not open_:
break
p = min(open_, key=lambda i: (self.d(i, q), i)) # @line L3
visited.add(p)
cand.update(self.adj[p]) # @line L4
if len(cand) > L:
cand = set(sorted(cand, key=lambda i: (self.d(i, q), i))[:L])
return sorted(cand, key=lambda i: (self.d(i, q), i))[:k], visited # @line L5
def robust_prune(self, p: int, cands: set[int], alpha: float, R: int) -> None:
pts = self.points
v = (set(cands) | set(self.adj[p])) - {p} # @line L6
out: list[int] = []
while v: # @line L7
best = min(v, key=lambda i: (self.dist(pts[p], pts[i]), i)) # @line L8
out.append(best)
if len(out) == R: # @line L9
break
v = {x for x in v
if alpha * self.dist(pts[best], pts[x]) > self.dist(pts[p], pts[x])} # @line L10
self.adj[p] = out
def build(self, R: int, L: int, alpha: float, rng: Optional[random.Random] = None) -> None:
"""Vamana: a random R-regular start, then two passes (alpha = 1, then alpha)."""
rng = rng or random.Random()
n = len(self.points)
for i in range(n): # @line L11
others = [j for j in range(n) if j != i]
self.adj[i] = rng.sample(others, min(R, len(others)))
for a in (1.0, alpha):
order = list(range(n))
rng.shuffle(order)
for i in order: # @line L12
_, visited = self.greedy_search(self.points[i], 1, L)
self.robust_prune(i, visited, a, R) # @line L13
for j in self.adj[i]: # @line L14
if i in self.adj[j]:
continue
if len(self.adj[j]) + 1 > R:
self.robust_prune(j, set(self.adj[j]) | {i}, a, R) # @line L15
else:
self.adj[j].append(i)
def build_full(self, alpha: float) -> None:
"""Slow preprocessing: prune each point against all others with no degree limit."""
n = len(self.points)
for i in range(n):
self.adj[i] = []
self.robust_prune(i, set(range(n)), alpha, n)
test_diskann.py 65 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/diskann/. The authors are listed in prov/KR79N8.jsonl.
import math
import random
from diskann import Vamana
PTS = [(0, 0), (2, 0), (4, 0), (6, 0), (0, 3), (3, 3), (6, 3), (1, 6), (5, 6)]
def test_example() -> None:
v = Vamana(PTS)
assert v.start == 5
v.build_full(1.5)
assert v.adj[0] == [1, 4, 5]
assert v.adj[5] == [4, 6, 1, 7, 8, 3]
res, visited = v.greedy_search((5.5, 5), 1, 1, start=0)
assert res == [8] and visited == {0, 5, 8}
def test_edge_cases() -> None:
v = Vamana([])
assert v.start == -1
v = Vamana([(1, 1)])
v.build(R=3, L=5, alpha=1.2, rng=random.Random(0))
assert v.adj == [[]]
assert v.greedy_search((0, 0), 1, 3) == ([0], {0})
def test_alpha_shortcut_and_approximation() -> None:
rng = random.Random(79)
for _ in range(500):
n = rng.randint(2, 14)
pts = [(rng.uniform(0, 10), rng.uniform(0, 10)) for _ in range(n)]
alpha = rng.choice([1.2, 1.5, 2.0, 3.0])
v = Vamana(pts)
v.build_full(alpha)
# alpha-shortcut property of full robust pruning
for p in range(n):
for x in range(n):
if x != p and x not in v.adj[p]:
assert any(alpha * math.dist(pts[y], pts[x]) <= math.dist(pts[p], pts[x])
for y in v.adj[p])
# Theorem: greedy search with L = 1 is (alpha + 1)/(alpha - 1)-approximate.
q = (rng.uniform(-2, 12), rng.uniform(-2, 12))
best = min(math.dist(q, p) for p in pts)
res, _ = v.greedy_search(q, 1, 1, start=rng.randrange(n))
assert math.dist(q, pts[res[0]]) <= (alpha + 1) / (alpha - 1) * best + 1e-9
def test_vamana_build() -> None:
rng = random.Random(8)
pts = [(rng.gauss(0, 1), rng.gauss(0, 1), rng.gauss(0, 1)) for _ in range(300)]
v = Vamana(pts)
v.build(R=8, L=20, alpha=1.2, rng=rng)
assert all(len(a) <= 8 and len(set(a)) == len(a) and i not in a for i, a in enumerate(v.adj))
hits = 0
for _ in range(100):
q = (rng.gauss(0, 1), rng.gauss(0, 1), rng.gauss(0, 1))
want = min(range(300), key=lambda i: math.dist(q, pts[i]))
res, visited = v.greedy_search(q, 1, 20)
hits += res == [want]
assert len(visited) < 300
assert hits >= 90
What the next level needs
- the tests did not run in this build