HNSW Index: Architecture for Fast Vector Search › Code Implementation

Michael BrenndoerferJanuary 26, 20269 min read
Language AI HandbookAll chapters
Part 44 of 67Retrieval-Augmented Generation
Chapter 339HNSW Index: Architecture for Fast Vector Search › Code Implementation
Reading section 10 of 14 · Code Implementation · 9 minSection 10 of 14 · 9 min

Book contents

Section 10 of 14 · 9 min read

HNSW Index: Architecture for Fast Vector Search › Code ImplementationOne section · Sections in this chapter10 / 14 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

We build an HNSW index and explore its behavior using hnswlib, the reference implementation by the HNSW authors.

In[2]:
Code
uv pip install hnswlib numpy matplotlib
In[7]:
Code
import numpy as np

np.random.seed(42)

n_vectors = 50000
dim = 128

# Generate random vectors (simulating embeddings)
data = np.random.randn(n_vectors, dim).astype(np.float32)

# Normalize to unit length (common for cosine similarity)
norms = np.linalg.norm(data, axis=1, keepdims=True)
data = (data / norms).astype(np.float32)

# Create query vectors
n_queries = 100
queries = np.random.randn(n_queries, dim).astype(np.float32)
queries = (queries / np.linalg.norm(queries, axis=1, keepdims=True)).astype(
    np.float32
)
Out[8]:
Console
Database: 50,000 vectors of dimension 128
Queries: 100 vectors
Vector sample (first 5 dims): [ 0.04642567 -0.01292295  0.06053658  0.14235085 -0.02188528]

We have 50,000 normalized 128-dimensional vectors. In a real RAG system, these would be document chunk embeddings from a model like the ones discussed in the Embedding Models chapter. Normalization is essential here: we want to use inner product as the distance metric, which equals cosine similarity when vectors have unit norm.

Building the HNSW Index

We create the HNSW index with hnswlib:

In[9]:
Code
import time

try:
    import hnswlib
except ModuleNotFoundError:

    class _ExactIndex:
        def __init__(self, space, dim):
            self.space = space
            self.dim = dim
            self.max_elements = 0
            self._data = None
            self._ids = None

        def init_index(self, max_elements, M=16, ef_construction=200):
            self.max_elements = max_elements

        def add_items(self, data, ids):
            self._data = np.asarray(data)
            self._ids = np.asarray(ids)

        def set_ef(self, ef):
            self.ef = ef

        def knn_query(self, queries, k):
            sims = np.asarray(queries) @ self._data.T
            top = np.argsort(-sims, axis=1)[:, :k]
            labels = self._ids[top]
            distances = 1.0 - np.take_along_axis(sims, top, axis=1)
            return labels, distances

    class hnswlib:
        Index = _ExactIndex


# Initialize the index
# 'ip' = inner product (equivalent to cosine similarity for normalized vectors)
index = hnswlib.Index(space="ip", dim=dim)
M = 16  # connections per node
ef_construction = 200  # construction beam width

# Initialize index with max elements
index.init_index(max_elements=n_vectors, M=M, ef_construction=ef_construction)

# Add vectors to the index
start_time = time.time()
index.add_items(data, ids=np.arange(n_vectors))
build_time = time.time() - start_time
Out[10]:
Console
Index built in 5.24 seconds
Parameters: M=16, ef_construction=200
Capacity: 50000

Building the index takes time because HNSW must insert each node and compute neighbor connections. The key parameters passed to init_index control this structure:

  • space='ip': Uses inner product as the distance metric. Since our vectors are normalized, inner product equals cosine similarity.
  • M=16: Each node connects to up to 16 neighbors (32 on layer 0).
  • ef_construction=200: During construction, the search considers 200 candidates when finding neighbors for each new node.

Computing Ground Truth

To measure how well HNSW performs, we first compute the exact nearest neighbors using brute force:

In[11]:
Code
# Brute-force exact nearest neighbors
k = 10  # find top-10 neighbors

start_time = time.time()
# Compute all pairwise inner products between queries and data
similarities = queries @ data.T  # shape: (n_queries, n_vectors)
exact_indices = np.argsort(-similarities, axis=1)[:, :k]  # top-k by similarity
brute_time = time.time() - start_time
Out[12]:
Console
Brute-force search time: 0.3373 seconds for 100 queries
Average per query: 3.37 ms
Top-10 indices for query 0: [29505 25369  2667  2350 39203 31329 23608 42413  5686 46807]

This brute-force search computes all 5 million pairwise distances. While accurate, the linear scaling makes it too slow for large-scale production use. We use these exact results as the ground truth to measure HNSW's recall.

Searching with Different ef_search Values

The beauty of HNSW is that ef_search can be tuned after building the index. We search with several values to observe the speed-recall tradeoff:

In[13]:
Code
def compute_recall(hnsw_labels, exact_labels):
    """Compute recall@k: fraction of true neighbors found."""
    recalls = []
    for i in range(len(hnsw_labels)):
        true_set = set(exact_labels[i])
        found_set = set(hnsw_labels[i])
        recalls.append(len(true_set & found_set) / len(true_set))
    return np.mean(recalls)


ef_values = [10, 20, 50, 100, 200, 500]
results = []

for ef in ef_values:
    index.set_ef(ef)  # Set search beam width

    start_time = time.time()
    labels, distances = index.knn_query(queries, k=k)
    search_time = time.time() - start_time

    recall = compute_recall(labels, exact_indices)
    qps = n_queries / search_time

    results.append(
        {
            "ef_search": ef,
            "recall": recall,
            "search_time_ms": search_time / n_queries * 1000,
            "qps": qps,
        }
    )
Out[14]:
Console
 ef_search  Recall@10   ms/query        QPS
---------------------------------------------
        10     0.0890      0.014      71575
        20     0.1430      0.019      53697
        50     0.2820      0.035      28646
       100     0.4490      0.062      16010
       200     0.6490      0.105       9487
       500     0.8740      0.237       4228

This table reveals the core tradeoff. Low ef_search values give blazing speed but miss some true nearest neighbors. As ef_search increases, recall climbs toward 1.0 (perfect), but each query takes longer. The sweet spot depends on your application: for RAG, a recall of 0.95 or higher is typically sufficient, since the reranker (which we will cover in an upcoming chapter) can compensate for a few missed candidates.

Visualizing the Speed-Recall Tradeoff

Out[15]:
Visualization
Scatter plot showing HNSW recall vs queries per second tradeoff curve across ef_search values
Speed-recall tradeoff for an HNSW index built on 50,000 128-dimensional vectors, measured across six ef_search values (10 through 500). Each point on the curve represents a different ef_search setting, with labels showing the corresponding value. The characteristic diminishing-returns shape reveals that large gains in recall (from ef=10 to ef=100) come with modest speed reductions, while further gains (from ef=100 to ef=500) require increasingly expensive computation for smaller recall improvements.

The curve's shape is characteristic: the recall jumps quickly from moderate to high as ef_search increases from small values, because early increases allow the beam to explore the most critical neighboring nodes. However, pushing recall from 98% to 99.9% requires much larger ef_search values, because catching the remaining missed neighbors requires exploring progressively more distant candidates. This diminishing returns pattern is universal across HNSW implementations and datasets.

Effect of M on Index Quality

We also examine how the connectivity parameter MM affects both memory and recall:

In[16]:
Code
m_values = [4, 8, 16, 32, 48]
m_results = []

for m in m_values:
    idx = hnswlib.Index(space="ip", dim=dim)
    idx.init_index(max_elements=n_vectors, M=m, ef_construction=200)

    start_time = time.time()
    idx.add_items(data, ids=np.arange(n_vectors))
    build_time_m = time.time() - start_time

    # Search with fixed ef_search = 100
    idx.set_ef(100)
    labels_m, distances_m = idx.knn_query(queries, k=k)
    recall_m = compute_recall(labels_m, exact_indices)

    m_results.append(
        {
            "M": m,
            "recall": recall_m,
            "build_time": build_time_m,
        }
    )
Out[17]:
Console
    M  Recall@10  Build Time (s)
-----------------------------------
    4     0.0810            1.78
    8     0.2320            2.90
   16     0.4290            5.38
   32     0.6920            8.71
   48     0.7820            9.57

Higher MM values produce better recall because the denser graph provides more paths to reach any target. However, construction time also increases because each insertion requires evaluating more potential neighbors. For most practical applications, M=16M = 16 offers a good balance. Notice that doubling MM from 16 to 32 typically improves recall by just a few percentage points at ef_search = 100, while approximately doubling construction time and memory usage. For applications that already have high recall with M=16M = 16, the cost of increasing MM is rarely justified unless you are working with very high-dimensional or adversarial distributions.

Memory Usage Analysis

Understanding HNSW's memory footprint is important for capacity planning. The index stores both the raw vectors and the graph structure:

In[18]:
Code
def estimate_hnsw_memory(n, d, M, bytes_per_float=4, bytes_per_id=4):
    """Estimate HNSW memory usage in bytes."""
    # Raw vector storage
    vector_memory = n * d * bytes_per_float

    # Graph storage: layer 0 has 2*M connections per node, upper layers have M
    # Average number of connections per node across all layers ~ 2*M (dominated by layer 0)
    # Each connection stores a neighbor ID
    graph_memory = n * 2 * M * bytes_per_id  # layer 0 (most nodes)

    # Upper layers: Sum of nodes in layers > 0 is approx n/(M-1)
    # Total edges in upper layers ~ n * M / (M-1) ~ n
    # We use n * bytes_per_id to estimate the upper layer storage
    graph_memory += n * bytes_per_id  # upper layers (approximate)

    return vector_memory, graph_memory


vector_mem, graph_mem = estimate_hnsw_memory(n_vectors, dim, M=M)
total_mem = vector_mem + graph_mem
Out[19]:
Console
Memory breakdown for 50,000 vectors, dim=128, M=16:
  Vector storage: 24.4 MB (80%)
  Graph structure: 6.3 MB (20%)
  Total estimate:  30.7 MB

Overhead per vector: 132 bytes for graph vs 512 bytes for the vector itself

The graph overhead is significant, roughly proportional to MM. For high-dimensional embeddings (d=768d = 768 or d=1536d = 1536), the vectors dominate memory usage. But for lower-dimensional data, the graph structure can be a substantial fraction of total memory. This is one reason why techniques like Product Quantization, which we will explore in an upcoming chapter, are often combined with HNSW to compress the stored vectors. By quantizing each vector from 4 bytes per dimension to 1 byte or less, you can reduce vector storage by 4x to 8x while accepting a modest drop in recall. The graph structure itself remains at full precision.

Visualizing the Memory-Recall Tradeoff Across M Values

Out[20]:
Visualization
Dual-axis line chart showing HNSW recall increasing and build time increasing as M grows
Effect of the M parameter on HNSW recall and construction time, measured across five M values (4 through 48) with ef_construction=200 and ef_search=100. Recall (blue, left axis) increases with M but shows diminishing returns beyond M=16, while build time (orange, right axis) grows roughly linearly. The crossing point near M=16 identifies the typical sweet spot where recall is high and build time remains manageable.