Algorithm Search⌘K

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
diskann.pyRawGitHub
# 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

Read the full page about DiskANN