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:
- Training: k-means partitions the dataset into
nlistVoronoi cells (e.g. 1,024). Within each cell, PQ encodes the residual vectors (vector minus cell centroid). - Indexing: Each vector is assigned to its nearest cell. The PQ code of its residual is stored in that cell's inverted list.
- Querying: The coarse quantizer finds the
nprobenearest 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
nprobefrom 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).
