HNSW vs IVF: Vector Index Benchmarks for Production Similarity Search

Comprehensive benchmarks comparing HNSW and IVF vector indexes across recall, latency, memory usage, and build time for real-world embedding search workloads

#vector-search#hnsw#ivf#embeddings
Cover image for the article: HNSW vs IVF: Vector Index Benchmarks for Production Similarity Search

Vector similarity search underpins modern AI applications - from semantic search and recommendations to RAG pipelines. The choice of index algorithm determines your system's recall, latency, and memory profile. HNSW and IVF are the two dominant approaches, each with distinct tradeoffs.

I benchmarked both algorithms across multiple dataset sizes, dimensionalities, and hardware configurations to provide concrete guidance for production deployments.

Algorithm Overview

HNSW (Hierarchical Navigable Small World) builds a multi-layer graph where each node connects to its approximate nearest neighbors. Search traverses from the top layer down, narrowing the candidate set at each level.

IVF (Inverted File Index) partitions the vector space into clusters using k-means, then searches only the closest clusters during query time. Variants include IVF-Flat (exact search within clusters) and IVF-PQ (product quantization for compression).

Chart

Benchmark Setup

ParameterConfiguration
DatasetsSIFT-1M, GloVe-1.2M, Custom embeddings (5M, 25M)
Dimensions128, 384, 768, 1536
HardwareAWS r6i.4xlarge (128GB RAM), g5.xlarge (A10G GPU)
LibrariesFAISS 1.7.4, hnswlib 0.7.0, pgvector 0.5.1
MetricsRecall@10, QPS, P95 latency, memory, build time

Core Benchmarks: 1M Vectors, 768 Dimensions

This represents a typical RAG or semantic search workload with OpenAI-style embeddings:

IndexRecall@10QPS (single thread)P95 LatencyMemoryBuild Time
HNSW (M=32, ef=128)0.9821,8502.1ms6.2 GB14 min
HNSW (M=64, ef=256)0.9959204.3ms9.8 GB28 min
IVF-Flat (nlist=1024, nprobe=32)0.9613,2001.4ms3.1 GB8 min
IVF-Flat (nlist=4096, nprobe=128)0.9881,1003.8ms3.2 GB12 min
IVF-PQ (nlist=1024, m=48)0.9248,5000.5ms0.8 GB22 min
IVF-PQ (nlist=4096, m=96)0.9524,2000.9ms1.4 GB35 min

Scaling Analysis: 25M Vectors

At larger scale, the tradeoffs shift significantly:

IndexRecall@10QPSMemoryBuild Time
HNSW (M=32, ef=128)0.978680156 GB6.2 hours
HNSW (M=16, ef=64)0.9511,45082 GB3.1 hours
IVF-Flat (nlist=16384, nprobe=64)0.9721,20078 GB45 min
IVF-PQ (nlist=16384, m=64)0.94112,0008.2 GB2.1 hours
IVF-HNSW-PQ (hybrid)0.9635,80012 GB3.5 hours

At 25M vectors, HNSW's memory overhead becomes the dominant constraint. IVF-PQ uses 19x less memory with acceptable recall loss.

Implementation: HNSW with FAISS

import faiss
import numpy as np
import time

class HNSWIndex:
    def __init__(self, dim: int, M: int = 32, ef_construction: int = 200):
        self.dim = dim
        self.index = faiss.IndexHNSWFlat(dim, M)
        self.index.hnsw.efConstruction = ef_construction

    def build(self, vectors: np.ndarray):
        """Build index from numpy array of vectors."""
        start = time.time()
        self.index.add(vectors)
        build_time = time.time() - start
        print(f"Built HNSW index: {len(vectors)} vectors in {build_time:.1f}s")
        return build_time

    def search(self, query: np.ndarray, k: int = 10, ef_search: int = 128):
        """Search with configurable ef parameter."""
        self.index.hnsw.efSearch = ef_search
        distances, indices = self.index.search(query.reshape(1, -1), k)
        return indices[0], distances[0]

    def batch_search(self, queries: np.ndarray, k: int = 10, ef_search: int = 128):
        """Batch search for throughput benchmarking."""
        self.index.hnsw.efSearch = ef_search
        distances, indices = self.index.search(queries, k)
        return indices, distances

Implementation: IVF with Product Quantization

class IVFPQIndex:
    def __init__(self, dim: int, nlist: int = 4096, m: int = 64, nbits: int = 8):
        self.dim = dim
        self.nlist = nlist
        # Coarse quantizer
        quantizer = faiss.IndexFlatL2(dim)
        self.index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, nbits)

    def build(self, vectors: np.ndarray):
        """Train and build IVF-PQ index."""
        start = time.time()
        # Train on a subset for large datasets
        train_size = min(len(vectors), 500_000)
        train_vectors = vectors[np.random.choice(len(vectors), train_size, replace=False)]
        self.index.train(train_vectors)
        self.index.add(vectors)
        build_time = time.time() - start
        print(f"Built IVF-PQ index: {len(vectors)} vectors in {build_time:.1f}s")
        return build_time

    def search(self, query: np.ndarray, k: int = 10, nprobe: int = 64):
        """Search with configurable nprobe."""
        self.index.nprobe = nprobe
        distances, indices = self.index.search(query.reshape(1, -1), k)
        return indices[0], distances[0]

Recall vs Latency Tradeoffs

The key tuning parameters for each algorithm:

HNSW: ef_search parameter

ef_searchRecall@10Latency (P95)QPS
320.9210.8ms4,200
640.9581.2ms3,100
1280.9822.1ms1,850
2560.9954.3ms920
5120.9988.7ms460

IVF-Flat: nprobe parameter

nprobeRecall@10Latency (P95)QPS
80.8910.4ms8,500
160.9320.7ms5,400
320.9611.4ms3,200
640.9812.8ms1,600
1280.9885.2ms780

Decision Framework

Use this framework to choose your index:

CriteriaBest ChoiceReason
Recall > 0.99 requiredHNSW (high ef)Better recall ceiling
Memory constrainedIVF-PQ10-20x less memory
High throughput (>10K QPS)IVF-PQBetter parallelization
Frequent updatesHNSWNo retraining needed
Dataset > 50M vectorsIVF-PQ or hybridHNSW memory prohibitive
Low latency (< 1ms P95)IVF-PQFastest absolute latency
Cold start / fast buildsIVF-Flat3-10x faster index build

Hybrid Approach: IVF-HNSW

FAISS supports a hybrid where IVF's coarse quantizer uses HNSW for faster cluster assignment:

def build_hybrid_index(vectors: np.ndarray, dim: int):
    """IVF with HNSW coarse quantizer - best of both worlds."""
    nlist = int(np.sqrt(len(vectors)))  # Rule of thumb

    # HNSW as coarse quantizer
    quantizer = faiss.IndexHNSWFlat(dim, 32)
    quantizer.hnsw.efConstruction = 200

    # IVF-PQ with HNSW quantizer
    index = faiss.IndexIVFPQ(quantizer, dim, nlist, 64, 8)

    # Train
    train_size = min(len(vectors), 1_000_000)
    train_data = vectors[:train_size]
    index.train(train_data)
    index.add(vectors)

    return index

Production Deployment Recommendations

# Configuration for common workload sizes
CONFIGS = {
    "small": {  # &#x3C; 1M vectors
        "index": "HNSW",
        "params": {"M": 32, "ef_construction": 200, "ef_search": 128},
        "expected_memory": "~6GB per 1M vectors (768d)",
    },
    "medium": {  # 1M - 10M vectors
        "index": "IVF-HNSW-PQ",
        "params": {"nlist": 8192, "m": 64, "nprobe": 64},
        "expected_memory": "~1.5GB per 1M vectors (768d)",
    },
    "large": {  # > 10M vectors
        "index": "IVF-PQ",
        "params": {"nlist": 65536, "m": 96, "nprobe": 128},
        "expected_memory": "~0.5GB per 1M vectors (768d)",
    },
}

Key Takeaways

  • HNSW delivers the highest recall but at significant memory cost (6-10GB per million 768-dim vectors). Choose it when recall matters more than infrastructure cost.
  • IVF-PQ is the scaling champion. At 25M+ vectors, its 20x memory advantage makes it the only practical choice for single-node deployments.
  • The hybrid IVF-HNSW approach offers an excellent middle ground - near-HNSW recall with IVF-level memory efficiency.
  • Tuning matters more than algorithm choice. A well-tuned IVF can outperform a default HNSW configuration. Invest time in parameter sweeps.
  • Plan for growth. If you are at 1M vectors today and expect 10M in a year, start with IVF-PQ rather than migrating later under pressure.

The best vector index is the one that matches your specific requirements for recall, latency, memory, and update frequency. There is no universal winner.

Comments

    No comments yet. Be the first to share your thoughts.