TL;DR — This post walks you through building a fully functional HNSW (Hierarchical Navigable Small World) vector index in pure Python, from greedy graph insertion to beam-search k-NN retrieval. You will walk away with a runnable, testable project that demonstrates systems engineering skills—algorithm design, complexity analysis, and benchmarking—that hiring managers actively look for in senior roles.
In a market where every candidate can paste a pre-trained model into a notebook, the differentiator is systems depth: understanding how data structures scale, how algorithms trade off accuracy for latency, and how to measure that in production. An HNSW index is a perfect vehicle for showing exactly that. It is the core algorithm behind many modern vector databases (Weaviate, Qdrant, and even Milvus), yet it is compact enough to implement from scratch in a weekend. By the end of this guide you will have a working, pure-Python HNSW implementation that you can drop into a GitHub repo, write a short blog post about, and discuss confidently in interviews.
Why This Project Stands Out on a CV
Building an HNSW index from scratch signals a rare combination of skills that hiring managers prize:
- Algorithmic mastery: You are implementing a sophisticated graph-based nearest-neighbor algorithm that sits at the intersection of computer science theory and practical engineering. This demonstrates you can read a paper (Malkov & Yashunin, 2016), translate it into code, and reason about its correctness.
- Systems thinking: You will grapple with real-world concerns like memory layout, cache efficiency, and the trade-off between search speed and recall. These are the same problems faced by teams building FAISS or Annoy, just at a smaller scale.
- Benchmarking and validation: The project forces you to measure performance—insertion throughput, query latency, recall@k—and present those numbers. Engineers who can prove their code works, not just assert it, are rare.
- Portfolio narrative: You can frame this as a stepping stone to larger systems: “I built the core indexing engine that powers vector search, then extended it with persistence and sharding.” That story maps directly to senior roles at companies working on retrieval-augmented generation (RAG), recommendation systems, or multimodal search.
In short, this project moves you from “I used a library” to “I understand the library’s engine.”
Architecture Overview
The HNSW index is a layered graph where each layer is a small-world network. The bottom layer (layer 0) contains every vector; higher layers contain exponentially fewer vectors, acting as “highways” for rapid navigation. The core components are:
- Node: A vector plus its ID and a list of neighbors per layer.
- Graph: A container that stores nodes and provides insertion and search entry points.
- Greedy Insertion: When inserting a new vector, you first select its maximum layer (via an exponential distribution), then descend the graph from the top entry point, making local moves toward the new vector at each layer, and linking it to
Mnearest neighbors at each layer. - Beam Search: For retrieval, you maintain a dynamic set of candidate nodes (the “beam”). At each step, you expand the most promising candidate by examining its neighbors, keeping the best
efcandidates until you converge.
A simplified text diagram:
Layer 2: [A] ---- [B] (entry points)
| |
Layer 1: [C] -- [D] -- [E]
| | |
Layer 0: [F] [G] [H] [I] [J] (all vectors)
Search starts at the top (e.g., node A), moves to the nearest neighbor in the current layer, and repeats until it reaches a local minimum. It then drops to the layer below and continues, refining the candidate set until it reaches layer 0, where a final beam search returns the k nearest neighbors.
Building It Step by Step
Below is a complete, runnable implementation. You can copy it into a single hnsw.py file and run it immediately.
Step 1: Setup and Data Structures
We need only the standard library and NumPy for vector operations.
import numpy as np
import random
import math
from collections import defaultdict
from heapq import nlargest
Define the Node class to hold a vector and its neighbors per layer.
class Node:
def __init__(self, node_id: int, vector: np.ndarray):
self.id = node_id
self.vector = vector
self.neighbors = defaultdict(list) # layer -> list of node IDs
def add_neighbor(self, layer: int, neighbor_id: int):
if neighbor_id not in self.neighbors[layer]:
self.neighbors[layer].append(neighbor_id)
def get_neighbors(self, layer: int) -> list:
return self.neighbors.get(layer, [])
Step 2: The HNSW Graph Class
The HNSWGraph class manages nodes, the maximum layer, and the entry point.
class HNSWGraph:
def __init__(self, M: int = 16, M_max: int = 16, ef_construction: int = 200):
"""
M: number of links per node per layer (except layer 0)
M_max: maximum links at layer 0 (often M * 2)
ef_construction: beam size during insertion
"""
self.M = M
self.M_max = M_max
self.ef_construction = ef_construction
self.nodes = {} # id -> Node
self.max_layer = -1
self.entry_point = None
self._id_counter = 0
def _random_layer(self) -> int:
"""Return a layer number with exponential decay."""
return int(math.floor(-math.log(random.random()) * self.M))
def _distance(self, a: np.ndarray, b: np.ndarray) -> float:
return np.linalg.norm(a - b)
def _search_layer(self, query: np.ndarray, entry_id: int, ef: int, layer: int) -> list:
"""
Beam search at a single layer.
Returns list of (node_id, distance) candidates, sorted by distance.
"""
visited = {entry_id}
candidates = [(self._distance(query, self.nodes[entry_id].vector), entry_id)]
result = [] # dynamic set of best nodes found so far
while candidates:
# Pop the closest candidate
dist, curr_id = candidates.pop(0)
# If this candidate is farther than the worst in result, stop
if result and dist > result[-1][0]:
break
# Expand neighbors
for neighbor_id in self.nodes[curr_id].get_neighbors(layer):
if neighbor_id not in visited:
visited.add(neighbor_id)
n_dist = self._distance(query, self.nodes[neighbor_id].vector)
if len(result) < ef or n_dist < result[-1][0]:
candidates.append((n_dist, neighbor_id))
candidates.sort(key=lambda x: x[0])
# Keep only the best ef candidates
if len(candidates) > ef:
candidates = candidates[:ef]
# Update result
if len(result) < ef:
result.append((n_dist, neighbor_id))
result.sort(key=lambda x: x[0])
elif n_dist < result[-1][0]:
result[-1] = (n_dist, neighbor_id)
result.sort(key=lambda x: x[0])
return result
def insert(self, vector: np.ndarray) -> int:
"""Insert a new vector into the graph."""
node_id = self._id_counter
self._id_counter += 1
new_node = Node(node_id, vector)
self.nodes[node_id] = new_node
# Determine the layer for the new node
layer = self._random_layer()
if self.entry_point is None:
self.entry_point = node_id
self.max_layer = layer
return node_id
# Start from entry point
curr_id = self.entry_point
# Search from top layer down to layer+1
for l in range(self.max_layer, layer, -1):
curr_id = self._search_layer(vector, curr_id, 1, l)[0][0]
# Insert at each layer from min(layer, max_layer) down to 0
for l in range(min(layer, self.max_layer), -1, -1):
# Beam search to find nearest neighbors
neighbors = self._search_layer(vector, curr_id, self.ef_construction, l)
# Select M (or M_max for layer 0) closest neighbors
if l == 0:
M = self.M_max
else:
M = self.M
# Link new node to selected neighbors
for dist, neighbor_id in neighbors[:M]:
new_node.add_neighbor(l, neighbor_id)
self.nodes[neighbor_id].add_neighbor(l, node_id)
# Update entry point for next layer
if neighbors:
curr_id = neighbors[0][0]
# Update max layer and entry point if necessary
if layer > self.max_layer:
self.max_layer = layer
self.entry_point = node_id
return node_id
def knn_search(self, query: np.ndarray, k: int, ef: int = 50) -> list:
"""
Search for k nearest neighbors.
Returns list of (node_id, distance).
"""
if self.entry_point is None:
return []
# Start from top layer
curr_id = self.entry_point
for l in range(self.max_layer, 0, -1):
curr_id = self._search_layer(query, curr_id, 1, l)[0][0]
# Final beam search at layer 0
candidates = self._search_layer(query, curr_id, max(ef, k), 0)
return candidates[:k]
Step 3: Testing the Implementation
Create a test script to insert random vectors and measure recall.
# test_hnsw.py
import numpy as np
from hnsw import HNSWGraph
# Generate random 128-dimensional vectors
np.random.seed(42)
data = np.random.rand(1000, 128).astype(np.float32)
query = np.random.rand(1, 128).astype(np.float32)
# Build index
graph = HNSWGraph(M=16, ef_construction=200)
for i, vec in enumerate(data):
graph.insert(vec)
# Search
k = 5
results = graph.knn_search(query[0], k, ef=100)
print("HNSW results:", results)
# Ground truth via brute force
distances = np.linalg.norm(data - query[0], axis=1)
true_indices = np.argsort(distances)[:k]
print("True indices:", true_indices.tolist())
Run it:
python test_hnsw.py
You should see the HNSW results closely matching the brute-force ground truth.
Running and Testing It
To run the project locally:
- Clone the repository (or copy the files).
- Install dependencies:
pip install numpy. - Run the test script:
python test_hnsw.py.
The script will output the top-5 nearest neighbors found by HNSW and by brute force. Compare the two lists; with ef=100 and M=16, you should see high overlap (often >90% recall). For a more rigorous evaluation, write a benchmark that varies ef and M, measuring recall@k, query latency (using time.perf_counter), and insertion throughput (vectors per second). Plot these metrics—engineers who can visualize the accuracy-latency trade-off stand out in interviews.
Extending It: Your Roadmap to Senior-Level
A pure-Python toy is impressive, but to signal production readiness, add these upgrades:
- Persistence with SQLite: Store the graph (nodes, neighbors, vectors) in a SQLite database with a binary blob column for vectors. This gives you crash recovery and incremental loading without external dependencies.
- Horizontal Scaling via Sharding: Shard the vector space using a hash on the ID or a learned clustering (e.g., k-means). Each shard runs its own HNSW instance; a coordinator fan-outs queries to all shards and merges results. This mirrors how Elasticsearch scales vector search.
- Observability with Prometheus: Expose metrics (insertion rate, query latency, beam size distribution) via a Prometheus endpoint. Use Grafana dashboards to visualize the accuracy-latency trade-off in real time. This shows you care about SRE concerns.
- Fault Tolerance with Replication: Replicate each shard to a secondary node using a simple leader-follower protocol (write-ahead log shipped over TCP). On failure, promote the follower automatically. This demonstrates you can build resilient systems.
- Benchmarking with ann-benchmarks: Integrate your index into the ann-benchmarks framework. This lets you compare your implementation against FAISS, Annoy, and Scikit-learn on standardized datasets, providing credibility.
- Quantization for Memory Efficiency: Apply product quantization (PQ) or scalar quantization to compress vectors from 4 bytes per dimension to 1 byte. This reduces memory footprint by 75%, making the index viable for billion-scale datasets—a key concern at companies like Pinecone.
Each upgrade addresses a real production problem: persistence prevents data loss, sharding enables scale, observability ensures reliability, replication guards against failure, benchmarking proves correctness, and quantization optimizes cost.
Key Takeaways
- HNSW is a layered small-world graph that balances speed and accuracy through hierarchical navigation.
- Greedy insertion with beam search builds the graph efficiently; the key parameters are
M(links per node) andef_construction(beam size). - A pure-Python implementation is feasible in ~150 lines and serves as a strong portfolio signal.
- The project’s real value lies in its extensibility: add persistence, scaling, and observability to demonstrate senior-level systems thinking.
- Benchmark against brute force and known libraries (FAISS, Annoy) to validate correctness and measure trade-offs.
Further Reading
- The original HNSW paper: Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs — read this to understand the algorithm’s theoretical foundations.
- The foundational Navigable Small World paper: Navigable Small-World Graphs for Approximate Nearest Neighbor Search — the precursor that inspired HNSW.
- The ann-benchmarks repository: https://github.com/erikbern/ann-benchmarks — use this framework to compare your implementation against state-of-the-art libraries.
- FAISS documentation: https://github.com/facebookresearch/faiss — study how a production library implements HNSW and other indexing strategies.
- Milvus architecture guide: https://milvus.io/docs/architecture-overview.md — see how HNSW fits into a full-scale vector database.