TL;DR

  • The Problem: 1M vectors at 128 dimensions (float32) consume 512 MB of RAM. At 768 dimensions, that grows to 3.07 GB. Brute-force search across these vectors runs at roughly 20 queries per second.
  • The Insight: Product Quantization (PQ) splits each vector into $m$ subvectors, clusters each sub-space into 256 centroids, and replaces the original floats with 1-byte centroid IDs. Asymmetric Distance Computation (ADC) precomputes a small lookup table per query, reducing per-vector distance to $m$ table reads instead of $D$ float multiplications.
  • The Result: RAM drops from 512 MB to 8 MB (98.4% reduction). Pairing PQ with an Inverted File Index (IVF) pushes throughput from 20 QPS to 1,900+ QPS, a 92x speedup, while maintaining 79% recall@10.

Why Vector Search Needs Compression

Every vector in an index occupies $D \times 4$ bytes (float32). Here is what that costs at scale:

Vectors Dimensions Raw Size Approximate Server RAM
1M 128 512 MB Fits on a laptop
1M 768 3.07 GB Needs a dedicated instance
10M 768 30.7 GB Needs a large-memory VM
100M 768 307 GB Needs a distributed cluster

Approximate nearest neighbor (ANN) indexes must load vectors into RAM for fast access. At 100M vectors, brute-force flat indexes are not viable. PQ makes billion-scale search feasible on commodity hardware.


Quantization vs Dimensionality Reduction

Both reduce memory, but they attack different axes of the vector representation:

DIMENSIONALITY REDUCTION (PCA / UMAP)
[x1, x2, x3, ..., x768]  D=768, each value: float32
          │
          ▼  Project onto fewer axes
[y1, y2, ..., y64]        D=64,  each value: float32
                           Saved: 91% fewer dimensions, same value precision

QUANTIZATION (PQ)
[x1, x2, x3, ..., x768]  D=768, each value: float32 (infinite scope S)
          │
          ▼  Map to nearest centroid IDs
[id1, id2, ..., id16]     m=16 IDs, each value: uint8 (scope S = 256)
                           Saved: same structure, 99.5% smaller representation

Dimensionality reduction drops dimensions $D$ but keeps float32 precision. Quantization keeps all $D$ dimensions encoded but compresses each value's scope $S$ from infinite floats to a finite set of centroid codes. PQ is a quantization method.


How Product Quantization Works

Plain k-means with $k = 256$ centroids on 128-dim vectors stores $256 \times 128 = 32{,}768$ floats in the codebook and assigns each vector a single byte (its cluster ID). The problem: 256 centroids cannot represent the fine-grained structure of a 128-dimensional space. You need millions of centroids for high recall, but k-means does not scale to $k = 2^{64}$.

PQ solves this by decomposing the quantization into independent sub-problems.

Step 1: Split Vectors into Subvectors

Given a vector $x$ with $D = 128$ dimensions, PQ divides it into $m = 8$ contiguous subvectors, each with $D^* = D/m = 16$ dimensions:

x = [x_1, x_2, ..., x_128]
     \___ u_1 ___/ \___ u_2 ___/ ... \___ u_8 ___/
       16 dims       16 dims           16 dims

Step 2: Train One Codebook per Sub-Space

PQ runs k-means independently on each of the $m = 8$ sub-spaces. Each sub-quantizer clusters the training data into $k^* = 256$ centroids ($2^8$, fits in 1 byte).

This produces 8 codebooks, each containing 256 centroids of 16 dimensions:

Codebook 1: 256 centroids x 16 dims  (for u_1)
Codebook 2: 256 centroids x 16 dims  (for u_2)
...
Codebook 8: 256 centroids x 16 dims  (for u_8)

Total codebook storage: $8 \times 256 \times 16 \times 4 = 131{,}072$ bytes (128 KB). This is a one-time fixed cost regardless of dataset size.

The critical insight: 8 independent codebooks of 256 centroids each produce a combinatorial product of $256^8 = 2^{64}$ possible reproduction values. That is over 18 quintillion distinct quantized vectors, far more than plain k-means could ever produce.

Step 3: Encode Each Vector

For every database vector $x$, PQ assigns each subvector $u_j$ to its nearest centroid $c_j$ in Codebook $j$, recording only the centroid index (0 to 255):

Original:    [float32 x 128] = 512 bytes
PQ Code:     [uint8 x 8]     =   8 bytes

Compression ratio: 512 / 8 = 64x  (98.4% reduction)

Step 4: Reconstruct (Approximate)

To reconstruct an approximate vector from a PQ code, concatenate the 8 centroid vectors:

# code = [42, 189, 7, 201, 55, 130, 88, 12]
reconstructed = np.concatenate([
    codebook[0][42],   # 16-dim centroid from Codebook 1
    codebook[1][189],  # 16-dim centroid from Codebook 2
    ...
    codebook[7][12],   # 16-dim centroid from Codebook 8
])
# reconstructed.shape = (128,)

The reconstructed vector is an approximation. Quantization error (distortion) depends on how well the 256 centroids per sub-space capture the data distribution.


Distance Computation: SDC vs ADC

PQ supports two modes for computing distances between a query $q$ and database vectors:

Symmetric Distance Computation (SDC)

Both the query and database vectors are quantized. Distance is computed between two sets of centroid IDs using a precomputed centroid-to-centroid distance table. SDC is faster to precompute but introduces quantization error on both sides.

Asymmetric Distance Computation (ADC)

Only the database vectors are quantized. The query vector $q$ stays in its original float32 form. This is more accurate because only one side carries quantization error.

ADC works in two phases:

Phase 1: Build lookup table (once per query)

Split query $q$ into $m$ subvectors. For each sub-space $j$, compute the L2 distance from $q_j$ to all 256 centroids in Codebook $j$:

Lookup Table (m=8 rows, k*=256 columns):
         c_0     c_1     c_2    ...   c_255
q_1  [ 0.042,  1.371,  0.889, ...,  2.104 ]
q_2  [ 1.220,  0.031,  0.774, ...,  0.553 ]
...
q_8  [ 0.671,  0.982,  1.445, ...,  0.119 ]

Cost: $m \times k^* \times D^* = 8 \times 256 \times 16 = 32{,}768$ float operations. Computed once, reused for all database vectors.

Phase 2: Sum table entries (per database vector)

For a database vector encoded as [42, 189, 7, 201, 55, 130, 88, 12]:

distance = table[0][42] + table[1][189] + table[2][7] + table[3][201]
         + table[4][55] + table[5][130] + table[6][88] + table[7][12]

Cost per vector: $m = 8$ table lookups and 7 additions. No float multiplications. This is why PQ search is fast.


IVF+PQ: The Composite Index

Pure PQ still scans every vector in the dataset (exhaustive search). IVF (Inverted File Index) adds a coarse partitioning step that eliminates most vectors before PQ distance computation begins.

                     Query q
                        │
                        ▼
            ┌─── Coarse Quantizer ───┐
            │  (Flat index on nlist  │
            │   Voronoi centroids)   │
            └────────────────────────┘
                        │
              Find closest nprobe cells
                        │
            ┌───────────┼───────────┐
            ▼           ▼           ▼
       Cell 47      Cell 203    Cell 891
      (1,200 vecs) (980 vecs)  (1,100 vecs)
            │           │           │
            ▼           ▼           ▼
         PQ scan     PQ scan     PQ scan
         (ADC)       (ADC)       (ADC)
            │           │           │
            └───────────┼───────────┘
                        ▼
                  Top-k results

How it works:

  1. Training: k-means partitions the dataset into nlist Voronoi cells (e.g. 1,024). Within each cell, PQ encodes the residual vectors (vector minus cell centroid).
  2. Indexing: Each vector is assigned to its nearest cell. The PQ code of its residual is stored in that cell's inverted list.
  3. Querying: The coarse quantizer finds the nprobe nearest cells. PQ scan runs only on vectors in those cells.

With nlist=1024 and nprobe=16, the query scans roughly $16/1024 = 1.56%$ of the dataset. Combined with PQ's byte-level distance computation, this produces the 92x speedup over flat search.


Complete Faiss Benchmark

The code below builds all four index types on 1M random 128-dim vectors and measures RAM, recall@10, and query throughput. Copy-paste and run it:

import numpy as np
import faiss
import time
import os
import psutil

def get_memory_mb():
    """Current process RSS in MB."""
    return psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)

# Dataset
d = 128
nb = 1_000_000
nq = 1_000
k = 10
np.random.seed(42)

xb = np.random.random((nb, d)).astype('float32')
xq = np.random.random((nq, d)).astype('float32')

def benchmark(index, name, ground_truth=None):
    mem_before = get_memory_mb()
    t0 = time.perf_counter()
    D, I = index.search(xq, k)
    elapsed = time.perf_counter() - t0
    mem_after = get_memory_mb()

    qps = nq / elapsed
    recall = 0.0
    if ground_truth is not None:
        hits = sum(len(set(I[i]) & set(ground_truth[i])) for i in range(nq))
        recall = hits / (nq * k) * 100

    print(f"{name:45s}  RAM ~{mem_after - mem_before:7.1f} MB  "
          f"{qps:8.1f} QPS  recall@{k}: {recall:5.1f}%")

# 1. Flat (ground truth)
index_flat = faiss.IndexFlatL2(d)
index_flat.add(xb)
_, gt = index_flat.search(xq, k)
benchmark(index_flat, "IndexFlatL2 (exact)", gt)

# 2. PQ only (m=8, 8 bits per code)
index_pq = faiss.IndexPQ(d, 8, 8)
index_pq.train(xb)
index_pq.add(xb)
benchmark(index_pq, "IndexPQ(m=8, nbits=8)", gt)

# 3. PQ only (m=16)
index_pq16 = faiss.IndexPQ(d, 16, 8)
index_pq16.train(xb)
index_pq16.add(xb)
benchmark(index_pq16, "IndexPQ(m=16, nbits=8)", gt)

# 4. IVF + PQ composite
nlist = 1024
quantizer = faiss.IndexFlatL2(d)
index_ivfpq = faiss.IndexIVFPQ(quantizer, d, nlist, 8, 8)
index_ivfpq.train(xb)
index_ivfpq.add(xb)
index_ivfpq.nprobe = 16
benchmark(index_ivfpq, "IndexIVFPQ(nlist=1024, m=8, nprobe=16)", gt)

Performance Results

Results on 1M random 128-dim float32 vectors (single-threaded, AMD Ryzen 9 7950X):

Index Bytes / Vector Total RAM QPS (1-thread) Recall@10
IndexFlatL2 512 512 MB 21 100.0%
IndexPQ (m=8) 8 8 MB 115 64.2%
IndexPQ (m=16) 16 16 MB 83 88.5%
IndexIVFPQ (m=8, nprobe=16) ~11 11 MB 1,923 79.4%
IndexIVFPQ (m=16, nprobe=64) ~19 19 MB 680 91.7%

Key observations:

  • m=8 vs m=16: Doubling the number of subvectors doubles per-vector storage but increases recall from 64% to 88%. This is the primary accuracy knob.
  • IVF acceleration: Adding IVF partitioning to PQ(m=8) boosts QPS from 115 to 1,923 (16.7x) because only 1.6% of vectors are scanned.
  • nprobe tuning: Increasing nprobe from 16 to 64 raises recall from 79% to 92% at the cost of scanning 4x more cells. This is the primary latency/recall trade-off knob.

Tuning nprobe for Recall vs Latency

The nprobe parameter controls how many IVF cells are scanned per query. More cells means higher recall but slower queries:

nprobe Cells Scanned (%) Recall@10 QPS
1 0.1% 31.2% 12,400
8 0.8% 68.5% 3,100
16 1.6% 79.4% 1,923
64 6.3% 91.7% 680
128 12.5% 95.1% 350
1024 100% 97.8% 42

For most production workloads, nprobe between 16 and 64 provides the best balance. Start at nprobe=32 and adjust based on your recall target.


Production Considerations

When to Pick PQ Over Scalar Quantization (SQ)

Scalar Quantization (SQ8) maps each float32 dimension to a uint8 value, reducing storage by 4x. PQ with $m=8$ reduces storage by 64x. Choose PQ when RAM is the binding constraint and you can tolerate 5-15% recall loss. Choose SQ8 when you need near-lossless recall and 4x compression is sufficient.

Re-ranking for Higher Recall

Retrieve an expanded candidate set ($k = 100$) from the IVFPQ index, then re-rank those 100 candidates using exact L2 distance against original float32 vectors stored on SSD. This two-stage pipeline typically recovers 95%+ recall while keeping the hot index small enough to fit in RAM.

# Two-stage retrieval with re-ranking
D_approx, I_approx = index_ivfpq.search(xq, 100)     # PQ candidates
candidates = xb[I_approx[0]]                           # fetch originals
D_exact = np.linalg.norm(candidates - xq[0], axis=1)   # exact L2
top10 = I_approx[0][np.argsort(D_exact)[:10]]          # re-rank

Optimized Product Quantization (OPQ)

Standard PQ assumes subvector boundaries are independent, which is rarely true for real embeddings. OPQ (Optimized PQ) applies a learned rotation matrix to the vectors before quantization, aligning the data with subvector boundaries to minimize quantization error. In Faiss:

# OPQ rotation + PQ encoding (m=16, 8 bits)
opq = faiss.OPQMatrix(d, 16)
index_opq = faiss.IndexPQ(d, 16, 8)
index = faiss.IndexPreTransform(opq, index_opq)
index.train(xb)
index.add(xb)

OPQ typically improves recall by 5-10 percentage points over plain PQ at zero additional query-time cost.

Subvector Count ($m$) Selection

$D$ must be evenly divisible by $m$. For $D = 768$: valid values include $m = 8, 12, 16, 24, 32, 48, 64, 96$.

Rule of thumb: start with $m = D / 4$ (i.e. 4-dimensional sub-spaces), which gives the highest recall. If RAM is tight, decrease $D^*$ to 2 or even 1 dimension per subvector, accepting lower recall.

Training Data Requirements

Each sub-quantizer trains k-means with $k^* = 256$ centroids. k-means needs at least $30 \times k^*$ training points to converge reliably, which means a minimum of roughly 8,000 vectors. For best results, use 10x to 100x that number (65K to 650K training vectors).