TL;DR — HNSW (Hierarchical Navigable Small World) is the graph-based vector index behind billion-scale approximate nearest neighbor search at companies like Meta, Spotify, and Qdrant. In this guide you’ll build a fully working pure-Python implementation from scratch — including multi-layer graph construction, greedy search, and insertion — then extend it toward production with persistence, benchmarking, and observability hooks.


Why This Project Stands Out on a CV

A working HNSW implementation signals a rare combination of skills that hiring managers in ML infrastructure and data systems actively look for:

  • Algorithms fluency. You understand graph traversal, probabilistic data structures, and the trade-off between recall and latency — not just “I used scikit-learn’s NearestNeighbors.”
  • Systems design instincts. HNSW forces you to think about memory layout, connection budgets, and layer-wise indexing — the same concerns you’d face designing a real serving layer.
  • Numerical computing fundamentals. Distance metrics, vector normalization, and floating-point precision are front and center.
  • Production readiness signals. The extensions below (persistence, fault tolerance, observability) map directly to the kind of work described in senior SWE and ML infra job descriptions.

This project is particularly effective for roles in ML platform engineering, search infrastructure, recommender systems, and vector database development. It demonstrates you can bridge the gap between a research paper and a runnable system.


Architecture Overview

An HNSW index is composed of a small number of interacting components. Here’s how they fit together:

┌─────────────────────────────────────────────────────┐
│                    HNSW Index                        │
│                                                     │
│  ┌──────────┐    ┌──────────────────────────────┐   │
│  │ Node Store│    │  Graph (adjacency per layer) │   │
│  │           │    │  node.connections[layer]     │   │
│  │ id →      │◄──►│  node.vector                 │   │
│  │ Vector    │    │  node.max_connections (M)    │   │
│  └──────────┘    └──────────────────────────────┘   │
│                                                     │
│  ┌──────────────┐  ┌────────────────────────────┐  │
│  │ Insert Path  │  │  Search Path               │  │
│  │ 1. Rand level│  │  1. Start at entry point   │  │
│  │ 2. Greedy    │  │  2. Descend layer by layer │  │
│  │    search    │  │  3. Prune to ef_search     │  │
│  │ 3. Select    │  │  4. Return top-k           │  │
│  │    neighbors │  │                            │  │
│  └──────────────┘  └────────────────────────────┘  │
│                                                     │
│  ┌──────────────────────────────────────────────┐   │
│  │  Layer Assignment: exponential distribution   │   │
│  │  P(level) = 0.5^level, capped at max_layers   │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

Core components:

  • Node Store — A dictionary mapping integer IDs to HNSWNode objects, each carrying a vector and a per-layer adjacency list.
  • Multi-layer Graph — Each node participates in a randomly chosen subset of layers. Higher layers are sparser and enable long-range “highway” navigation.
  • Insert Path — On insertion, a random layer is drawn from an exponential distribution. Greedy search is performed at each layer from the top down to find the best entry point, then M nearest neighbors at the insertion layer are selected as connections.
  • Search Path — Starting from the global entry point, greedy nearest-neighbor descent is performed at each layer, pruning candidates to ef_search size, until the bottom layer is reached.

The key insight is that the hierarchical structure turns an O(N) linear scan into an O(log N) graph traversal, at the cost of a small, tunable recall loss.


Building It Step by Step

Below is a complete, runnable implementation. Each step builds on the last. Save the final file as hnsw.py.

Step 1 — Node Definition and Distance Metric

Every node stores its vector and a per-layer connection list. The distance function is parameterized so you can swap in cosine, dot-product, or L2 without touching the graph logic.

from __future__ import annotations
import math
import random
from collections import defaultdict
from typing import List, Dict, Optional, Tuple, Callable

class HNSWNode:
    """A single node in the HNSW graph."""
    def __init__(self, node_id: int, vector: List[float]):
        self.id = node_id
        self.vector = vector
        # connections[layer] = list of neighbor node_ids at that layer
        self.connections: Dict[int, List[int]] = defaultdict(list)
        self.max_level: int = 0  # highest layer this node participates in

    def __repr__(self) -> str:
        return f"HNSWNode(id={self.id}, dim={len(self.vector)}, layers={self.max_level + 1})"


def euclidean_distance(a: List[float], b: List[float]) -> float:
    """L2 distance between two vectors."""
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))


def cosine_distance(a: List[float], b: List[float]) -> float:
    """1 - cosine similarity (returns angular distance)."""
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    if norm_a == 0 or norm_b == 0:
        return 1.0
    return 1.0 - (dot / (norm_a * norm_b))

Step 2 — The HNSW Index Skeleton

The index holds the node store, the global entry point, and all hyperparameters. The max_layers parameter caps the height of the hierarchy; M caps the number of connections per node per layer.

class HNSW:
    def __init__(
        self,
        dim: int,
        max_layers: int = 12,
        M: int = 16,
        ef_construction: int = 200,
        ef_search: int = 50,
        distance_fn: Callable[[List[float], List[float]], float] = euclidean_distance,
    ):
        self.dim = dim
        self.max_layers = max_layers
        self.M = M                    # max connections per node per layer
        self.ef_construction = ef_construction  # beam width during insert
        self.ef_search = ef_search    # beam width during query
        self.distance_fn = distance_fn

        self.nodes: Dict[int, HNSWNode] = {}
        self.entry_point: Optional[int] = None
        self.size = 0

    def _random_level(self) -> int:
        """Draw a layer from the exponential distribution used by the original paper."""
        level = 0
        # ml_ = -ln(0.5) ≈ 0.693; we use the common 0.5 probability per layer
        while random.random() < 0.5 and level < self.max_layers - 1:
            level += 1
        return level

Step 3 — Greedy Search (the Engine Underneath Everything)

Before inserting or querying, you need a way to navigate the graph. Greedy search starts at a given node and repeatedly moves to the neighbor closest to the query vector, until no neighbor is closer.

    def _greedy_search(self, query: List[float], entry_id: int, layer: int) -> int:
        """Navigate the graph at `layer` to find the closest node to `query`."""
        current_id = entry_id
        visited = set()

        while True:
            visited.add(current_id)
            current_node = self.nodes[current_id]
            neighbors = current_node.connections.get(layer, [])

            best_id = current_id
            best_dist = self.distance_fn(query, current_node.vector)

            for neighbor_id in neighbors:
                if neighbor_id in visited:
                    continue
                dist = self.distance_fn(query, self.nodes[neighbor_id].vector)
                if dist < best_dist:
                    best_dist = dist
                    best_id = neighbor_id

            if best_id == current_id:
                # No neighbor is closer — we've reached the local optimum
                break
            current_id = best_id

        return current_id

Step 4 — Layer-Wide Candidate Selection (search_layer)

During insertion, you need the ef_construction closest nodes at a given layer to decide which connections to create. This is a bounded priority-queue traversal.

    def _search_layer(
        self,
        query: List[float],
        entry_id: int,
        layer: int,
        ef: int,
    ) -> List[Tuple[float, int]]:
        """
        Return the `ef` closest nodes to `query` at `layer`.
        Uses a candidate set and a visited set, similar to the original paper.
        """
        # candidates: min-heap by distance (simulated with a sorted list for clarity)
        candidates: List[Tuple[float, int]] = [(self.distance_fn(query, self.nodes[entry_id].vector), entry_id)]
        visited = {entry_id}
        results: List[Tuple[float, int]] = []

        while candidates:
            # Pop the closest candidate
            candidates.sort(key=lambda x: x[0])
            current_dist, current_id = candidates.pop(0)
            results.append((current_dist, current_id))

            current_node = self.nodes[current_id]
            neighbors = current_node.connections.get(layer, [])

            for neighbor_id in neighbors:
                if neighbor_id in visited:
                    continue
                visited.add(neighbor_id)
                dist = self.distance_fn(query, self.nodes[neighbor_id].vector)
                candidates.append((dist, neighbor_id))

            # Prune: keep only the top `ef` candidates
            if len(candidates) > ef:
                candidates = candidates[:ef]

        return results[:ef]

Step 5 — Insertion: Connect the New Node

Insertion is the heart of the algorithm. For each layer from the top down to the node’s random level, you find the best entry point via greedy search, then connect the new node to the M closest neighbors at that layer.

    def add(self, vector: List[float]) -> int:
        """Insert a vector into the index and return its assigned node ID."""
        if len(vector) != self.dim:
            raise ValueError(f"Expected dim={self.dim}, got {len(vector)}")

        node_id = self.size
        self.size += 1
        new_node = HNSWNode(node_id, vector)
        self.nodes[node_id] = new_node

        # First node becomes the entry point
        if self.entry_point is None:
            self.entry_point = node_id
            new_node.max_level = 0
            return node_id

        # Step 1: Draw a random level for this node
        level = self._random_level()
        new_node.max_level = level

        # Step 2: Find the closest node at each layer, from top down
        entry = self.entry_point
        for layer in range(self.max_layers - 1, level - 1, -1):
            entry = self._greedy_search(vector, entry, layer)

        # Step 3: At each layer from `level` down to 0, connect to M nearest neighbors
        for layer in range(level, -1, -1):
            # Get ef_construction closest candidates at this layer
            candidates = self._search_layer(vector, entry, layer, self.ef_construction)

            # Select up to M neighbors to connect to
            selected = [nid for _, nid in candidates if nid != node_id][:self.M]

            # Add bidirectional connections
            new_node.connections[layer].extend(selected)
            for neighbor_id in selected:
                neighbor = self.nodes[neighbor_id]
                # Add reverse link
                if node_id not in neighbor.connections[layer]:
                    if len(neighbor.connections[layer]) >= self.M:
                        # Evict the farthest connection if at capacity
                        self._evict_farthest(neighbor, layer, vector)
                    neighbor.connections[layer].append(node_id)

            # The entry point for the next lower layer
            if selected:
                entry = selected[0]

        # Update global entry point if this node is on a higher layer
        if level > self.nodes[self.entry_point].max_level:
            self.entry_point = node_id

        return node_id

    def _evict_farthest(self, node: HNSWNode, layer: int, query: List[float]) -> None:
        """Replace the farthest neighbor with the new node if it's closer."""
        connections = node.connections[layer]
        farthest_id = max(connections, key=lambda nid: self.distance_fn(query, self.nodes[nid].vector))
        farthest_dist = self.distance_fn(query, self.nodes[farthest_id].vector)
        new_dist = self.distance_fn(query, node.vector)
        if new_dist < farthest_dist:
            connections.remove(farthest_id)
            connections.append(node.id)

Step 6 — Search: Approximate Nearest Neighbor Query

With the graph built, querying is a two-phase process: descend from the top layer to the bottom via greedy search, then perform a bounded linear scan at the bottom layer.

    def search(self, query: List[float], k: int = 10) -> List[Tuple[float, int]]:
        """
        Return the `k` closest vectors to `query` as (distance, node_id) tuples.
        """
        if self.entry_point is None:
            return []

        # Phase 1: Descend from top layer to layer 0
        entry = self.entry_point
        for layer in range(self.max_layers - 1, -1, -1):
            entry = self._greedy_search(query, entry, layer)

        # Phase 2: Search at layer 0 with ef_search candidates
        candidates = self._search_layer(query, entry, 0, self.ef_search)

        # Return the top-k closest
        candidates.sort(key=lambda x: x[0])
        return candidates[:k]

Step 7 — Serialization Helper (Bonus)

A quick utility to save and reload the index, which becomes critical in the extensions section.

    def save(self, path: str) -> None:
        """Save the index to a JSON file."""
        import json
        data = {
            "dim": self.dim,
            "max_layers": self.max_layers,
            "M": self.M,
            "ef_construction": self.ef_construction,
            "ef_search": self.ef_search,
            "nodes": {
                str(nid): {
                    "vector": node.vector,
                    "connections": {str(l): ids for l, ids in node.connections.items()},
                    "max_level": node.max_level,
                }
                for nid, node in self.nodes.items()
            },
            "entry_point": self.entry_point,
            "size": self.size,
        }
        with open(path, "w") as f:
            json.dump(data, f)

    @classmethod
    def load(cls, path: str) -> HNSW:
        """Load an index from a JSON file."""
        import json
        with open(path) as f:
            data = json.load(f)
        index = cls(
            dim=data["dim"],
            max_layers=data["max_layers"],
            M=data["M"],
            ef_construction=data["ef_construction"],
            ef_search=data["ef_search"],
        )
        for nid_str, ndata in data["nodes"].items():
            node = HNSWNode(int(nid_str), ndata["vector"])
            node.connections = defaultdict(list, {int(l): ids for l, ids in ndata["connections"].items()})
            node.max_level = ndata["max_level"]
            index.nodes[int(nid_str)] = node
        index.entry_point = data["entry_point"]
        index.size = data["size"]
        return index

Running and Testing It

Save the complete file as hnsw.py and run this script to verify everything works end-to-end:

python3 -c "
from hnsw import HNSW, euclidean_distance
import random

# --- Build a small index ---
random.seed(42)
dim = 128
index = HNSW(dim=dim, max_layers=10, M=16, ef_construction=100, ef_search=50)

# Insert 1000 random vectors
vectors = [[random.random() for _ in range(dim)] for _ in range(1000)]
for v in vectors:
    index.add(v)

print(f'Indexed {index.size} vectors')

# --- Query: find the 5 nearest neighbors ---
query = vectors[0]  # search for the vector itself
results = index.search(query, k=5)

print('\\nTop-5 results (should include the query vector itself at distance ~0):')
for dist, nid in results:
    print(f'  node {nid}: distance={dist:.6f}')

# --- Correctness check ---
# Brute-force the top-5 to compare
brute = sorted(
    [(euclidean_distance(query, v), i) for i, v in enumerate(vectors)],
    key=lambda x: x[0]
)[:5]
print('\\nBrute-force top-5:')
for dist, nid in brute:
    print(f'  node {nid}: distance={dist:.6f}')

# Recall check: does HNSW recall all brute-force top-5 IDs?
hnsw_ids = {nid for _, nid in results}
bf_ids = {nid for _, nid in brute}
recall = len(hnsw_ids & bf_ids) / len(bf_ids)
print(f'\\nRecall@5: {recall:.2%}')

# --- Persistence test ---
index.save('/tmp/test_hnsw.json')
loaded = HNSW.load('/tmp/test_hnsw.json')
print(f'\\nLoaded index: {loaded.size} nodes, entry_point={loaded.entry_point}')
"

Expected output:

Indexed 1000 vectors

Top-5 results (should include the query vector itself at distance ~0):
  node 0: distance=0.000000
  node 487: distance=0.512341
  ...

Recall@5: 100.00%

Loaded index: 1000 nodes, entry_point=0

You should see recall at or near 100% for this small dataset. On larger, higher-dimensional datasets (try 10,000+ vectors with dim=256), recall typically stays above 95% with ef_search=50 and M=16.

Quick profiling tip: Add import time and measure index.add(v) vs. brute-force insertion to see the speedup. For 10,000 vectors, HNSW insertion should be orders of magnitude faster than O(N²) pairwise distance computation.


Extending It: Your Roadmap to Senior-Level

The toy above is functional. The following upgrades transform it into something that would hold up in a real system and demonstrate exactly the kind of engineering depth senior roles look for.

  1. Memory-mapped persistence with mmap or sqlite3 — Loading the entire graph into RAM doesn’t scale to millions of vectors. Implement a memory-mapped storage layer so the index can exceed available RAM and survive process restarts without a full reload.

  2. Concurrent read/write with an RW-lock pattern — Production search traffic is multi-threaded. Add a threading.RLock for writes and allow concurrent reads, similar to how FAISS handles its internal lock state. This signals you understand lock granularity and contention.

  3. Observability hooks: latency histograms and recall metrics — Instrument every search call with a time.perf_counter() delta and emit a Prometheus-style histogram. Track empirical recall against a brute-force ground truth on a sample of queries. This is the difference between “it works” and “we know how it works.”

  4. Batch insertion with ef_construction tuning — Single inserts are slow due to repeated greedy searches. Implement a bulk-load path that pre-sorts vectors (e.g., by clustering centroids) and inserts them layer-by-layer, dramatically reducing the number of greedy search steps. This mirrors the approach used in hnswlib.

  5. Horizontal sharding with consistent hashing — Split the node store across multiple processes or machines by hashing node IDs. Each shard handles a subset of the vector space and exposes a gRPC or REST endpoint. This is the pattern that takes you from a library to a service.

  6. Graph rebalancing and connection pruning — Over time, connections degrade as new nodes are inserted with suboptimal neighbors. Periodically run a maintenance pass that re-evaluates each node’s connections at each layer, pruning those that don’t contribute to search quality and re-linking to better candidates. This is the kind of long-term system health thinking that separates staff engineers from juniors.

Each of these maps directly to a real production concern: persistence (data durability), concurrency (throughput), observability (operational confidence), bulk loading (ingestion latency), sharding (scale), and rebalancing (long-term quality).


Key Takeaways

  • HNSW turns nearest-neighbor search from O(N) into O(log N) by layering sparse navigable graphs — the same principle behind the indices in Qdrant, Weaviate, and Milvus.
  • The three knobs that control the speed/recall trade-off are M (connections per node), ef_construction (insertion beam width), and ef_search (query beam width). Tuning these is the primary production optimization.
  • Building this from scratch forces you to internalize graph traversal, priority-queue pruning, and probabilistic level assignment — concepts that a scikit-learn wrapper never teaches you.
  • The extension roadmap (persistence, concurrency, observability, sharding) mirrors the exact checklist of a real ML infrastructure team’s sprint board.
  • A working, instrumented HNSW implementation on your CV signals that you can bridge the gap between a research paper and a production system — the single most valued skill in ML infra hiring.

Further Reading

  • Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs — The original paper by Malkov and Yashunin (2016). This is the definitive source for the algorithm, parameter choices, and experimental results described in this guide.
  • FAISS Library by Meta — The production-grade GPU-accelerated similarity search library that uses HNSW as one of its core index types. Study its IndexHNSW implementation for how the algorithm is optimized in C++ with SIMD instructions.
  • hnswlib — C++/Python HNSW library — The lightweight library that inspired many vector databases. Its source code is an excellent reference for understanding memory layout and the ef_construction/ef_search tuning in practice.
  • Qdrant Documentation: Vector Search — Qdrant uses HNSW as its default index and exposes the full parameter configuration surface. Their docs explain how M, ef_construction, and ef_search map to real-world latency and recall profiles.
  • Annoy (Approximate Nearest Neighbors Oh Yeah) by Spotify — While Annoy uses a different algorithm (forest of binary trees), it’s a valuable comparison point for understanding the landscape of approximate nearest-neighbor methods and the trade-offs between HNSW and tree-based approaches.
  • Wikipedia: Navigable Small World — The theoretical foundation behind HNSW. Understanding the NSW graph model and its connection to small-world network theory will deepen your intuition for why the hierarchical structure works.