Product Quantization: Vector Compression for ANN Search

Michael BrenndoerferJanuary 28, 202662 min read

Part of Language AI Handbook

Explains how Product Quantization compresses embeddings up to 100x using learned codebooks and asymmetric distance computation for scalable vector search.

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

Product Quantization

In the previous two chapters, we explored IVF indexes and HNSW graphs, two strategies that reduce the number of comparisons needed to find approximate nearest neighbors. But there is another bottleneck we have not addressed: even when you only compare a query against a small fraction of your index, each comparison still requires loading a full 768-dimensional float32 vector from memory and computing a dot product. When your index holds tens of millions of embeddings, that memory footprint alone can exceed the capacity of a single machine.

Product Quantization (PQ) attacks this problem from a different angle: rather than reducing the number of comparisons, PQ reduces the cost and size of each comparison. It compresses each embedding from hundreds of floating-point numbers down to a handful of small integers, achieving compression ratios of 10x to 100x while preserving enough geometric structure to compute useful approximate distances. The resulting index can fit in RAM where a full-precision index would require a distributed cluster.

Think of PQ as giving each vector a compact postal address rather than GPS coordinates. Instead of storing the precise latitude and longitude of every building in a city, you store only the zip code and block number. You lose some precision, but you gain the ability to store every address in the city in a pocket notebook. When someone asks "which buildings are near this location?", you can narrow the search to a few zip codes and find good candidates without ever loading the full coordinate database.

The insight that makes PQ so powerful is the combination of two ideas. First, the high-dimensional space can be factored into independent lower-dimensional subspaces, where each subspace is much easier to quantize accurately than the full space. Second, distances in the full space decompose exactly across those subspaces, so you can estimate the full distance by summing independently precomputed per-subspace distances. This second property is what allows the entire expensive computation to happen once per query rather than once per database vector, cutting the inner loop to a series of simple memory reads.

To understand why those two ideas combine to produce such dramatic speedups, you need to understand the geometry of quantization, why the product decomposition matters, and how the resulting lookup-table machinery achieves both memory compression and fast distance estimation. This chapter covers all three: how PQ codebooks are learned, how distances are approximated efficiently with Asymmetric Distance Computation, and how PQ combines with IVF to give you both reduced comparisons and reduced memory at scale.

The practical stakes are real. A production RAG system indexing 500 million document chunks at 768 dimensions needs roughly 1.5 TB of RAM just to hold the raw embeddings, which forces a distributed deployment. The same index with PQ at M=96M = 96 fits in 48 GB on a single server. This reduction can turn a distributed system that requires an infrastructure team into one that a single engineer can deploy and operate.

Historical Context

Product Quantization was introduced by Herve Jegou, Matthijs Douze, and Cordelia Schmid in their 2011 IEEE Transactions on Pattern Analysis and Machine Intelligence paper "Product Quantization for Nearest Neighbor Search." The key innovation was not quantization itself (vector quantization had been studied for decades in signal processing) but the product decomposition: by splitting vectors into independent subspaces and quantizing each independently, they transformed an intractable high-dimensional clustering problem into a collection of tractable low-dimensional ones. The paper also introduced Asymmetric Distance Computation (ADC), the lookup-table technique that makes PQ practical for large-scale retrieval. Their FAISS library, released by Meta AI Research in 2017, brought these ideas to production scale and is now the standard implementation used by essentially all major vector databases. The original PQ paper has accumulated thousands of citations and is widely regarded as one of the most practically impactful papers in large-scale information retrieval.

The Core Idea: Divide and Quantize

The fundamental challenge of compressing a high-dimensional vector is that you cannot simply round each floating-point coordinate to an integer independently. A 768-dimensional embedding lives in a geometric space where distance relationships encode semantic meaning. Crude per-coordinate quantization destroys those relationships because it treats every dimension as equally and independently important, ignoring the geometric correlations that give the embedding its meaning.

PQ sidesteps this problem with a clever observation. You do not need to quantize the full vector at once. Instead, you split the vector into several shorter subvectors and quantize each subspace independently. Each subspace is small enough that you can learn a good codebook for it, and the independently learned codebooks capture local structure in each region of the full space.

The key insight is that while quantizing all 768 dimensions at once would require an astronomically large codebook to represent the space faithfully, quantizing small slices of, say, 96 dimensions each is a tractable problem. K-means clustering can solve it reliably with a modest number of centroids. And because the subspaces do not overlap, distances in the full space decompose exactly into sums of per-subspace distances, which is the property that makes efficient distance estimation possible.

Think of the subspace decomposition like dividing a long sentence into short phrases for translation. Translating a 100-word sentence directly is difficult because you have to hold the entire meaning in mind at once. Breaking it into five 20-word phrases lets you translate each phrase independently without losing the overall meaning, and the translations can be reassembled afterward. PQ applies the same divide-and-conquer logic: handle each segment of the vector independently, then reassemble the distance estimate by summing up the per-segment contributions.

Product Quantization

Product Quantization is a vector compression method that splits each dd-dimensional vector into MM subvectors of dimension d∗=d/Md^* = d/M, then replaces each subvector with the index of its nearest centroid in a learned codebook of KK entries. The full vector is represented by MM small integers. The subvector dimension is:

d∗=d/Md^* = d / M

If each subspace index uses nbits bits, so that K=2nbitsK = 2^{\text{nbits}}, the complete PQ code has length

L=Mlog⁡2K=M⋅nbitsL = M \log_2 K = M \cdot \text{nbits}

bits. FAISS therefore stores ⌈M⋅nbits/8⌉\lceil M \cdot \text{nbits}/8 \rceil bytes per vector. Relative to a float32 vector, the compression ratio is

compression ratio=32dM⋅nbits.\text{compression ratio} = \frac{32d}{M \cdot \text{nbits}}.

where:

  • dd: the dimensionality of the original vector
  • MM: the number of subspaces (subvector segments)
  • d∗=d/Md^* = d/M: the dimensionality of each subvector
  • KK: the number of centroids (codewords) in each subspace codebook
  • LL: the total number of bits in the compressed code

To understand why this works, it helps to define a codebook. A codebook for a subspace is a finite set of representative points, or centroids, learned from your training data. When you encode a subvector, you are essentially saying: "instead of storing these exact floating-point coordinates, I will store only the address of the centroid that is closest to this subvector." This is a form of lossy compression, similar in spirit to how a palette of 256 colors can represent a rich image. The quality of the approximation depends on how well the centroids cover the space of typical subvectors encountered in your data. A good codebook positions its centroids where data points cluster, so that most subvectors are close to their assigned centroid and the reconstruction error stays small.

Let us make this concrete. Suppose your embeddings are 768-dimensional float32 vectors. You decide to use M=8M = 8 subspaces and K=256K = 256 centroids per subspace. Here is what happens:

  1. Each 768-dim vector is split into 8 segments of 96 dimensions each.
  2. For each segment, you maintain a codebook of 256 representative centroids (learned from your data).
  3. Each segment of a database vector is replaced by the index of its closest centroid, a value from 0 to 255 that fits in a single byte.
  4. The original vector, which needed 768×4=3,072768 \times 4 = 3{,}072 bytes as float32, is now stored in just 8×1=88 \times 1 = 8 bytes.

That is a 384x compression ratio. The name "product quantization" comes from the fact that the full codebook is the Cartesian product of the MM subspace codebooks, giving you KM=2568≈1.8×1019K^M = 256^8 \approx 1.8 \times 10^{19} distinct representable vectors from only 8×256=2,0488 \times 256 = 2{,}048 stored centroids. The economy is striking: a small set of stored centroids spans a space large enough to represent any realistic high-dimensional embedding collection with low reconstruction error. You are exploiting the redundancy of the embedding space, where nearby vectors tend to have similar structure in each subspace, to represent the full diversity of vectors with far fewer numbers than you would need if every dimension were independent.

Codebook Learning

The quality of PQ depends entirely on how well the codebooks capture the structure of your data. A good codebook should have its centroids positioned where data points cluster in each subspace. Poorly positioned centroids (for instance, those uniformly distributed in a region where real data is sparse) lead to high quantization error and degraded recall. This is why PQ always requires a training phase: the codebooks must be fitted to the distribution of your embeddings before you can compress and search them effectively.

The training process is conceptually simple: for each subspace, collect all the corresponding subvectors from your training data, and run k-means clustering to find KK representative centroids. The tricky part is scale. You need enough training vectors to populate all KK centroids with reasonable coverage, which in practice means at least several hundred times KK training examples per subspace. For K=256K = 256, this means at least 50,000 to 100,000 training vectors, a number easily obtained from a typical corpus but worth planning for in advance.

Think of codebook training as photographing a neighborhood to build a visual catalog. If you photograph only one street, your catalog will misrepresent the full variety of buildings. If you photograph a representative sample of every street and neighborhood, your catalog will cover the full range of architectural styles that a visitor might encounter. PQ codebooks work the same way: a training set drawn from the same distribution as your final index will produce centroids that are well-positioned for the actual data, while a mismatched training set will leave gaps in the coverage and inflate quantization error.

An important practical consideration is that the training set should match the distribution of vectors you will index. If your index will contain embeddings from a news corpus, train the codebooks on a sample from that corpus, not from a generic Wikipedia dump. The closer the match between training and indexing distributions, the lower the average quantization error will be, and the better your recall.

Training with K-Means

For each subspace m∈{1,…,M}m \in \{1, \ldots, M\}, you extract the mm-th segment from every training vector to form a dataset of NN subvectors in Rd∗\mathbb{R}^{d^*}. The goal is to find a compact set of centroids that together describe the typical shapes this subvector takes across your data. You then run k-means clustering on this dataset, seeking KK centroids Cm={cm,1,cm,2,…,cm,K}\mathcal{C}_m = \{c_{m,1}, c_{m,2}, \ldots, c_{m,K}\} that minimize the total squared distance between each subvector and its nearest centroid, called the quantization error:

Lm=∑i=1Nmin⁡k∈{1,…,K}∥xi(m)−cm,k∥2\mathcal{L}_m = \sum_{i=1}^{N} \min_{k \in \{1,\ldots,K\}} \left\| x_i^{(m)} - c_{m,k} \right\|^2

where:

  • Lm\mathcal{L}_m: the quantization loss (total reconstruction error) for subspace mm
  • NN: the number of training vectors
  • xi(m)x_i^{(m)}: the mm-th subvector of training vector ii, a point in Rd∗\mathbb{R}^{d^*}
  • cm,kc_{m,k}: the kk-th centroid in the codebook for subspace mm
  • min⁡k∈{1,…,K}\min_{k \in \{1,\ldots,K\}}: the assignment step, selecting the nearest centroid for each subvector

This is standard k-means, as discussed in the context of IVF coarse quantization in the previous chapter. The difference here is that you run it MM separate times, each time on a lower-dimensional subspace, which makes the clustering much faster and more reliable than trying to cluster in the full 768-dimensional space. In high dimensions, k-means is notoriously difficult: the curse of dimensionality makes all points appear roughly equidistant from one another, and finding meaningful cluster boundaries becomes unreliable. By restricting each k-means run to d∗d^* dimensions, PQ turns a hard high-dimensional clustering problem into MM tractable low-dimensional ones. This is one of the most practically important aspects of the design: the decomposition is a compression trick and a strategy for making the learning problem statistically well-posed.

The k-means algorithm iterates between two steps: (1) assign each training subvector to the nearest centroid, and (2) update each centroid to the mean of all subvectors assigned to it. These two steps alternate until the centroid positions converge. The loss Lm\mathcal{L}_m is guaranteed to decrease or stay the same on every iteration, so the algorithm always terminates. However, k-means can converge to local optima, so it is common to run it multiple times with different random initializations and keep the result with the lowest total loss. In practice, one good initialization strategy is k-means++, which places the initial centroids far from one another to improve convergence speed and quality.

After training, the codebooks {C1,C2,…,CM}\{\mathcal{C}_1, \mathcal{C}_2, \ldots, \mathcal{C}_M\} are fixed. They are the only data structure you need to store alongside the compressed codes. At search time, you do not need to keep the original training vectors in memory at all. The codebooks, which total only M×K×d∗M \times K \times d^* floating-point values, are loaded once and held in cache throughout the search session. For typical parameters, this is on the order of a few megabytes, an entirely negligible overhead compared to the index itself.

Out[3]:
Visualization
Scatter plot of 600 data points in a 2D PQ subspace, grouped into 8 color-coded Voronoi clusters with star-shaped centroids labeled c0 through c7, illustrating k-means codebook learning.
K-means clustering result in a single 2D PQ subspace, showing K=8 learned centroids (star markers, labeled c0 through c7) and 600 data points colored by their assigned Voronoi cell. The compact, well-separated clusters confirm that k-means reliably finds representative centroids in low-dimensional subspaces. Close proximity of each point to its centroid indicates low quantization error, which directly translates to more accurate approximate distance computations during ADC search.

Encoding Database Vectors

Once the codebooks are trained, encoding a database vector xx is straightforward. The process visits each subspace in order, extracts the corresponding slice of the vector, and finds the nearest centroid in that subspace's codebook. Split xx into MM subvectors, then for each subspace find the nearest centroid:

km=argmin⁡k∥x(m)−cm,k∥2k_m = \underset{k}{\operatorname{argmin}} \left\| x^{(m)} - c_{m,k} \right\|^2

where:

  • kmk_m: the centroid index assigned to the mm-th subvector, i.e., the value stored in the compressed code
  • x(m)x^{(m)}: the mm-th subvector of database vector xx
  • cm,kc_{m,k}: the kk-th centroid in the codebook for subspace mm
  • argmin⁡k\underset{k}{\operatorname{argmin}}: the operator that returns the index kk minimizing the squared distance, selecting the nearest centroid to subvector x(m)x^{(m)}

The encoded vector is the tuple (k1,k2,…,kM)(k_1, k_2, \ldots, k_M), storing MM integers each in the range [0,K−1][0, K-1]. With K=256K = 256, each fits in one byte (uint8). The intuition is that instead of storing a full floating-point subvector, you store only the address of the nearest representative in a shared codebook. This is a drastic reduction from d∗×4d^* \times 4 bytes down to 1 byte per subspace.

This encoding step only happens once at index build time. After encoding, the original floating-point vectors can be discarded entirely if memory is the primary concern, and only the compact integer codes and the codebook centroids need to be retained. At query time, every distance computation will refer exclusively to these codes and the precomputed lookup tables derived from the codebooks. The encoding process scales linearly with the number of database vectors: encoding 100 million vectors requires 100 million independent nearest-centroid lookups across all subspaces, and these can be parallelized trivially since each vector is encoded independently of all others.

The time complexity of encoding a single vector is O(M⋅K⋅d∗)O(M \cdot K \cdot d^*): for each of the MM subspaces, you compute the distance from the subvector to each of the KK centroids and select the minimum. With d∗=d/Md^* = d/M, this simplifies to O(K⋅d)O(K \cdot d), which is the same order as a single full-precision distance computation multiplied by KK. In practice, the encoding is fast enough that it is never the bottleneck in an indexing pipeline; the bottleneck is almost always generating the embeddings themselves.

Approximate Distance Computation

Encoding achieves compression, but the real payoff comes from how PQ enables fast approximate distance computation at query time. Without a clever distance estimation strategy, compressed codes would be useless: you would have to decompress every vector back to full precision before comparing it to a query, negating all the memory savings. PQ avoids this by exploiting the subspace decomposition to precompute everything that depends on the query, so that the per-vector comparison reduces to a handful of integer-indexed memory reads.

The central design choice in PQ distance computation is to invest computation upfront, before scanning the database, and then amortize that investment across every subsequent comparison. This is the same philosophy behind building an index: you do expensive work once so that repeated queries are cheap. The lookup table construction is the "index build" of the distance computation, and it reduces the inner loop from hundreds of floating-point multiplications to a small number of addition operations.

Think of the lookup table as a price list prepared before going shopping. Instead of computing the cost of each item from scratch as you encounter it in the store, you write down the price of each category of item before you enter. Once inside, you just look up prices and add them, without doing any arithmetic. PQ's lookup tables serve exactly this purpose: they precompute the cost (distance contribution) of every possible centroid assignment, so the inner loop is nothing but lookups and additions.

The key insight is that the query is fixed for an entire search, but the database vectors are what you scan. Any computation that depends only on the query can be done once and reused for every database vector. The subspace decomposition makes this possible: the per-subspace query-to-centroid distances depend only on the query and the codebook, neither of which changes during a scan. So you build the lookup tables once and then spend zero arithmetic in the inner loop.

Asymmetric Distance Computation

When a query vector qq arrives, you want to estimate ∥q−xi∥2\| q - x_i \|^2 for each database vector xix_i. If both qq and xix_i were quantized, you would call this Symmetric Distance Computation (SDC). But PQ uses a smarter approach called Asymmetric Distance Computation (ADC) that keeps the query in full precision.

The key insight that makes ADC possible is that squared Euclidean distance decomposes across subspaces exactly when the subvectors partition the dimensions without overlap. This follows from the fact that for non-overlapping index sets S1,S2,…,SMS_1, S_2, \ldots, S_M that together cover all dimensions, ∥u∥2=∑m=1M∥u(m)∥2\|u\|^2 = \sum_{m=1}^{M} \|u^{(m)}\|^2 where u(m)u^{(m)} is the restriction of uu to the dimensions in SmS_m. Applied to u=q−xiu = q - x_i, this gives the exact decomposition of squared Euclidean distance into a sum of per-subspace squared distances:

∥q−xi∥2=∑m=1M∥q(m)−xi(m)∥2\| q - x_i \|^2 = \sum_{m=1}^{M} \left\| q^{(m)} - x_i^{(m)} \right\|^2

where:

  • qq: the full query vector in Rd\mathbb{R}^d
  • xix_i: the ii-th database vector in Rd\mathbb{R}^d
  • q(m)q^{(m)}: the mm-th subvector of qq, a slice of dimensions corresponding to subspace mm
  • xi(m)x_i^{(m)}: the mm-th subvector of xix_i
  • MM: the number of subspaces, where the sum telescopes the full distance into independent per-subspace distances

This decomposition holds exactly because the subvectors partition the dimensions with no overlap, so the squared norms add up to the total squared norm. This is the key property that makes PQ work. If the subspaces shared dimensions, the decomposition would fail and you could not independently precompute per-subspace distances. The non-overlap requirement is therefore a mathematical necessity rather than a convenience: the entire ADC framework rests on this identity, and any architecture that violates it would require fundamentally different distance estimation logic.

Instead of computing ∥q(m)−xi(m)∥2\| q^{(m)} - x_i^{(m)} \|^2 exactly (which would require decompressing xix_i), you approximate it as ∥q(m)−cm,km(i)∥2\| q^{(m)} - c_{m, k_m^{(i)}} \|^2, the distance from the query's mm-th segment to the centroid that represents xix_i's mm-th segment. The approximation is good whenever the centroid is close to the actual subvector it stands in for, which is precisely what the k-means training is designed to ensure.

Before scanning the database, you precompute a lookup table for each subspace. For each subspace mm and each centroid index kk, you store:

Tm[k]=∥q(m)−cm,k∥2for all k∈{0,…,K−1}T_m[k] = \left\| q^{(m)} - c_{m,k} \right\|^2 \quad \text{for all } k \in \{0, \ldots, K-1\}

where:

  • Tm[k]T_m[k]: the precomputed squared distance from the query's mm-th subvector to the kk-th centroid of subspace mm
  • q(m)q^{(m)}: the mm-th subvector of the query, kept in full float32 precision
  • cm,kc_{m,k}: the kk-th centroid in the codebook for subspace mm
  • KK: the number of centroids per subspace (typically 256, one per possible byte value, since each code fits in a single byte)

This gives you MM tables, each with KK entries. Building all tables costs O(M⋅K⋅d∗)O(M \cdot K \cdot d^*) operations, which for typical parameters is in the tens of thousands of multiplications. Think of each table TmT_m as a translation layer: given any centroid index for subspace mm, it instantly tells you how far the query is from that centroid, without any further arithmetic. Once these tables are built, the expensive floating-point work is entirely finished. Every subsequent comparison against a database vector is purely a matter of reading integers and summing precomputed values.

Now, the approximate distance to any database vector xix_i (stored as code (k1(i),…,kM(i))(k_1^{(i)}, \ldots, k_M^{(i)})) is found by summing the precomputed table entries corresponding to each centroid index. No floating-point arithmetic is needed at this stage because all the expensive distance computations happened during the table-building step. The approximate distance is therefore:

d^(q,xi)=∑m=1MTm ⁣[km(i)]\hat{d}(q, x_i) = \sum_{m=1}^{M} T_m\!\left[k_m^{(i)}\right]

where:

  • d^(q,xi)\hat{d}(q, x_i): the approximate squared distance from query qq to database vector xix_i
  • Tm[⋅]T_m[\cdot]: the precomputed lookup table for subspace mm (built once per query)
  • km(i)k_m^{(i)}: the centroid index stored in the mm-th byte of xix_i's compressed code
  • ∑m=1M\sum_{m=1}^{M}: the summation operator that accumulates MM table lookups, replacing all dd floating-point multiplications with MM integer-indexed array lookups. This is the key computational saving: instead of operating on raw floats, we simply add precomputed values

This requires MM table lookups and MM additions. For M=8M = 8, each distance computation requires 8 array lookups and 8 additions. This is enormously faster than computing a full dot product over 768 dimensions and changes which hardware can sustain the required throughput. With ADC, the bottleneck shifts from floating-point compute to memory bandwidth, and even the memory access pattern is cache-friendly: the lookup tables are tiny enough to reside entirely in L1 or L2 cache across the entire scan.

Out[4]:
Visualization
Flow diagram of ADC showing a query vector split into subvectors, lookup tables built from codebooks, and a database vector's integer codes summed via table lookups to produce an approximate distance.
Asymmetric Distance Computation (ADC) overview. The query vector (top left) is split into M subvectors, each compared against the corresponding subspace codebook to populate M lookup tables (built once per query). Each database vector, stored as compact integer centroid codes, then computes an approximate distance via M table lookups and additions, replacing hundreds of floating-point multiplications with a handful of integer-indexed memory reads.

To read the diagram above: the query vector enters from the top left in full float32 precision. It is sliced into MM subvectors, each of which is compared against the corresponding subspace codebook (the purple box in the center) to populate the lookup tables shown in green on the right. This table-building phase is the only moment when floating-point arithmetic occurs. Meanwhile, each database vector arrives as a compact sequence of integer centroid codes. To compute an approximate distance, those codes simply index into the precomputed lookup tables, and the resulting values are summed. The entire comparison path for a database vector, from reading its bytes to producing a distance estimate, involves no multiplication and no subtraction: only integer-indexed array reads and additions.

Why ADC Is Faster Than It Looks

You might wonder: if you are scanning millions of vectors, does building the lookup tables at query time even matter? Yes, significantly. Consider the arithmetic:

  • Without PQ: Computing ∥q−xi∥2\| q - x_i \|^2 for one vector requires 768 multiplications, 768 subtractions, and 767 additions. For 10 million vectors, that is roughly 1.5×10101.5 \times 10^{10} floating-point operations.
  • With PQ (ADC): The lookup table build costs 8×256×96≈200,0008 \times 256 \times 96 \approx 200{,}000 operations, performed once. Each of the 10 million comparisons then costs 8 lookups and 8 additions, totaling 80,000,00080{,}000{,}000 additions. The overall compute is about 200x less.

The memory bandwidth savings are equally dramatic. Instead of reading 3,072 bytes per vector, you read only 8 bytes, a 384x reduction that substantially improves cache efficiency. On modern hardware, memory bandwidth is often the binding constraint for large-scale search: the CPU can perform arithmetic faster than it can fetch data from RAM. By compressing each vector to 8 bytes, PQ ensures that the entire database can cycle through the CPU's cache hierarchy far more efficiently, and the lookup tables themselves (only a few kilobytes) stay warm in L1 cache across the entire scan. This cache-resident lookup table is one of the most elegant aspects of the ADC design: it turns a memory-bound workload into one that is bottlenecked by the tiny, fast cache rather than the large, slow DRAM.

Out[5]:
Visualization
Log-scale line chart comparing floating-point operation counts for flat exhaustive search versus ADC total, scan-only, and table-build costs across M values from 4 to 96, showing ADC is 200x cheaper.
Floating-point operation counts for computing approximate distances over 10 million 768-dimensional vectors, comparing exhaustive flat search (red dashed line) against ADC across numbers of subspaces M on a log-scale y-axis. ADC total cost (blue) and scan-only cost (green) fall two to three orders of magnitude below the flat baseline, while the one-time table-build cost (orange) remains negligible at all tested M values. Even at M=4, ADC reduces total operations by roughly 200x, confirming that the lookup-table strategy makes PQ practical at billion-vector scale.

Worked Example

Let us trace through PQ with a tiny example to make the mechanics concrete before looking at code. Suppose your embeddings are 4-dimensional (just to keep numbers small) and you use M=2M = 2 subspaces, K=4K = 4 centroids each.

Working through a small example is valuable for understanding the mechanics and for building intuition about the error properties of PQ. In this example we will see exactly where approximation error enters and why it tends to be small for true nearest neighbors while still being large enough to occasionally produce ranking errors for borderline cases.

Training data (6 vectors):

Six training vectors split into two 2-dimensional subspaces for the worked PQ example.
VectorDim 1-2 (subspace 1)Dim 3-4 (subspace 2)
a[0.1, 0.2][0.8, 0.9]
b[0.0, 0.3][0.7, 0.8]
c[0.9, 0.8][0.1, 0.2]
d[0.8, 0.9][0.0, 0.1]
e[0.5, 0.5][0.5, 0.4]
f[0.4, 0.6][0.6, 0.5]

Notice that the training data has a clear geometric structure: vectors a and b have small values in subspace 1 and large values in subspace 2, while c and d are the reverse. Vectors e and f occupy a middle region. K-means in each subspace will naturally discover these clusters, and the resulting centroids will be positioned to cover the data distribution efficiently. Each centroid ends up representing a cluster of points, and the centroid's coordinates are the mean of all the points in its cluster.

After k-means in subspace 1, suppose we get centroids c1,0=[0.05,0.25]c_{1,0} = [0.05, 0.25], c1,1=[0.85,0.85]c_{1,1} = [0.85, 0.85], c1,2=[0.45,0.55]c_{1,2} = [0.45, 0.55].

After k-means in subspace 2, suppose we get centroids c2,0=[0.75,0.85]c_{2,0} = [0.75, 0.85], c2,1=[0.05,0.15]c_{2,1} = [0.05, 0.15], c2,2=[0.55,0.45]c_{2,2} = [0.55, 0.45].

Step 1: Encode database vectors. We find the nearest centroid in each subspace for each vector:

Encoding vector a = [0.1, 0.2, 0.8, 0.9]:

  • Subspace 1: [0.1,0.2][0.1, 0.2] is closest to c1,0=[0.05,0.25]c_{1,0} = [0.05, 0.25], so k1=0k_1 = 0.
  • Subspace 2: [0.8,0.9][0.8, 0.9] is closest to c2,0=[0.75,0.85]c_{2,0} = [0.75, 0.85], so k2=0k_2 = 0.
  • Code for a: (0,0)(0, 0).

The code (0,0)(0, 0) is just two integers, but it carries meaningful geometric information: it tells us that this vector lives in the region of subspace 1 near centroid 0 and the region of subspace 2 near centroid 0. Any query that is also near those centroids will receive a low approximate distance estimate, correctly reflecting the true proximity.

Encoding vector c = [0.9, 0.8, 0.1, 0.2]:

  • Subspace 1: [0.9,0.8][0.9, 0.8] is closest to c1,1=[0.85,0.85]c_{1,1} = [0.85, 0.85], so k1=1k_1 = 1.
  • Subspace 2: [0.1,0.2][0.1, 0.2] is closest to c2,1=[0.05,0.15]c_{2,1} = [0.05, 0.15], so k2=1k_2 = 1.
  • Code for c: (1,1)(1, 1).

Encoding vector e = [0.5, 0.5, 0.5, 0.4]:

  • Subspace 1: [0.5,0.5][0.5, 0.5] is closest to c1,2=[0.45,0.55]c_{1,2} = [0.45, 0.55], so k1=2k_1 = 2.
  • Subspace 2: [0.5,0.4][0.5, 0.4] is closest to c2,2=[0.55,0.45]c_{2,2} = [0.55, 0.45], so k2=2k_2 = 2.
  • Code for e: (2,2)(2, 2).

Step 2: Build lookup tables for query. Suppose q=[0.12,0.18,0.82,0.87]q = [0.12, 0.18, 0.82, 0.87]:

Build lookup tables by computing the distance from each query subvector to every centroid:

  • T1[0]=∥[0.12,0.18]−[0.05,0.25]∥2=0.0049+0.0049=0.0098T_1[0] = \| [0.12, 0.18] - [0.05, 0.25] \|^2 = 0.0049 + 0.0049 = 0.0098
  • T1[1]=∥[0.12,0.18]−[0.85,0.85]∥2≈0.5329+0.4489=0.9818T_1[1] = \| [0.12, 0.18] - [0.85, 0.85] \|^2 \approx 0.5329 + 0.4489 = 0.9818
  • T1[2]=∥[0.12,0.18]−[0.45,0.55]∥2≈0.1089+0.1369=0.2458T_1[2] = \| [0.12, 0.18] - [0.45, 0.55] \|^2 \approx 0.1089 + 0.1369 = 0.2458
  • T2[0]=∥[0.82,0.87]−[0.75,0.85]∥2=0.0049+0.0004=0.0053T_2[0] = \| [0.82, 0.87] - [0.75, 0.85] \|^2 = 0.0049 + 0.0004 = 0.0053
  • T2[1]=∥[0.82,0.87]−[0.05,0.15]∥2≈0.5929+0.5184=1.1113T_2[1] = \| [0.82, 0.87] - [0.05, 0.15] \|^2 \approx 0.5929 + 0.5184 = 1.1113
  • T2[2]=∥[0.82,0.87]−[0.55,0.45]∥2≈0.0729+0.1764=0.2493T_2[2] = \| [0.82, 0.87] - [0.55, 0.45] \|^2 \approx 0.0729 + 0.1764 = 0.2493

Step 3: Compute approximate distances. Now use the lookup tables to estimate the distance from qq to each encoded database vector:

  • Distance to a (code (0,0)(0, 0)): T1[0]+T2[0]=0.0098+0.0053=0.0151T_1[0] + T_2[0] = 0.0098 + 0.0053 = 0.0151
  • Distance to c (code (1,1)(1, 1)): T1[1]+T2[1]=0.9818+1.1113=2.0931T_1[1] + T_2[1] = 0.9818 + 1.1113 = 2.0931
  • Distance to e (code (2,2)(2, 2)): T1[2]+T2[2]=0.2458+0.2493=0.4951T_1[2] + T_2[2] = 0.2458 + 0.2493 = 0.4951

The ranking is: a is closest, then e, then c. This matches the true ranking based on the raw vectors (which you can verify by computing exact distances from qq to each vector). Vector a, which shares a similar geometric structure to the query in both subspaces, receives the lowest approximate distance. Vector c, which is far in both subspaces, receives the highest.

Step 4: Verify the approximation error. The true distance from qq to aa is:

∥[0.12,0.18,0.82,0.87]−[0.1,0.2,0.8,0.9]∥2=0.0004+0.0004+0.0004+0.0009=0.0021\| [0.12, 0.18, 0.82, 0.87] - [0.1, 0.2, 0.8, 0.9] \|^2 = 0.0004 + 0.0004 + 0.0004 + 0.0009 = 0.0021

The approximate distance was 0.0151, so the absolute error is 0.013. This is larger than the true distance itself, but the relative ranking is correct. The approximation overestimates the distance to aa because both centroids are slightly displaced from aa's actual position, and both displacements add positive contributions to the estimated distance. However, because qq is also close to aa's centroids, the lookup table values T1[0]T_1[0] and T2[0]T_2[0] are still much smaller than the values for the other centroids, so the ranking is preserved. This is the fundamental property that makes PQ useful: even though the absolute distances are approximate, the relative ordering among the closest candidates is preserved well enough for top-KK retrieval to succeed with high probability.

Implementation with FAISS

FAISS, Meta's library for efficient similarity search, provides production-grade PQ implementations. Let us build a complete example that compresses a collection of embeddings and measures both memory savings and recall quality.

FAISS handles all the details of codebook training, vector encoding, and ADC internally. You interact with it through a clean index API that is nearly identical to the exact search API, which means you can swap PQ into an existing pipeline with minimal code changes. The main decisions you need to make are the number of subspaces MM and the number of bits per code, which together determine the compression ratio and the expected recall.

Setup and Data Generation

First, install FAISS if needed:

In[10]:
Code
# Install faiss (CPU version)
# uv pip install faiss-cpu numpy

import faiss
import numpy as np

# Reproducibility
np.random.seed(42)

# Simulate a realistic embedding distribution
# (embeddings are not uniformly distributed; they cluster)
d = 128  # dimension (use 128 for fast illustration; real embeddings are 768)
N_train = 50000  # vectors for codebook training
N_db = 100000  # database vectors to index
N_query = 1000  # query vectors
K_true = 10  # top-K neighbors to retrieve

# Generate clustered data to mimic real embedding distributions
n_clusters = 50
cluster_centers = np.random.randn(n_clusters, d).astype(np.float32)


def sample_clustered(n, centers, spread=0.5):
    assignments = np.random.randint(0, len(centers), size=n)
    noise = np.random.randn(n, d).astype(np.float32) * spread
    return centers[assignments] + noise


train_vectors = sample_clustered(N_train, cluster_centers)
db_vectors = sample_clustered(N_db, cluster_centers)
query_vectors = sample_clustered(N_query, cluster_centers)

# Normalize so exact cosine and squared-L2 rankings are equivalent
faiss.normalize_L2(train_vectors)
faiss.normalize_L2(db_vectors)
faiss.normalize_L2(query_vectors)

mem_flat = db_vectors.nbytes / (1024**2)

We generate clustered data rather than uniform noise because real embedding distributions are highly non-uniform. Embeddings cluster around semantic concepts, and k-means will find better codebooks when the training data matches this structure. Using uniform random vectors would give artificially optimistic recall results because the centroids would be evenly spread across a space with no real geometric structure, making quantization trivially good.

Building a Brute-Force Baseline

Before compressing anything, establish ground truth nearest neighbors using an exact flat index:

In[12]:
Code
# Flat index: exact squared-L2 search, no compression
index_flat = faiss.IndexFlatL2(d)
index_flat.add(db_vectors)

# Get ground-truth top-K neighbors for all queries
_, ground_truth = index_flat.search(query_vectors, K_true)

mem_flat = db_vectors.nbytes / (1024**2)

The flat index is the exact baseline: it stores all vectors in full float32 precision and performs exhaustive search. This gives us ground-truth nearest neighbors to measure recall against the compressed indexes. Every comparison we make to PQ recall uses these results as the gold standard.

Now build a compressed PQ index. The key parameters are MM (number of subspaces) and the number of bits per code (which determines K=2bitsK = 2^{\text{bits}}):

In[15]:
Code
import time

# PQ parameters
M = 8  # number of subspaces; d must be divisible by M
nbits = 8  # bits per subcode; K = 2^8 = 256 centroids per subspace

# Create and train the PQ index using the metric derived above
index_pq = faiss.IndexPQ(d, M, nbits, faiss.METRIC_L2)

# Training: learn the codebooks from training data
t0 = time.time()
index_pq.train(train_vectors)
train_time = time.time() - t0

# Add database vectors (they are encoded and compressed during add)
index_pq.add(db_vectors)

bytes_per_vec_pq = (M * nbits + 7) // 8
mem_pq = N_db * bytes_per_vec_pq / (1024**2)

The compression ratio shows how dramatically PQ reduces memory usage compared to storing full float32 vectors. A higher ratio means more vectors fit in the same amount of RAM, enabling larger indexes on a single machine.

Measuring Recall

Compression always trades some accuracy for space. Let us measure how much recall the PQ index loses compared to exact search:

In[18]:
Code
def compute_recall_at_k(retrieved, ground_truth, k):
    """Fraction of ground truth top-k found in retrieved top-k."""
    recalls = []
    for i in range(len(ground_truth)):
        gt_set = set(ground_truth[i][:k])
        ret_set = set(retrieved[i][:k])
        recalls.append(len(gt_set & ret_set) / k)
    return np.mean(recalls)


# Search with PQ index
t0 = time.time()
_, pq_results = index_pq.search(query_vectors, K_true)
pq_search_time = time.time() - t0

# Search with flat index (timed for comparison)
t0 = time.time()
_, flat_results = index_flat.search(query_vectors, K_true)
flat_search_time = time.time() - t0

recall_pq = compute_recall_at_k(pq_results, ground_truth, K_true)
recall_flat = compute_recall_at_k(flat_results, ground_truth, K_true)
flat_search_time_ms = flat_search_time * 1000
pq_search_time_ms = pq_search_time * 1000

Combining IVF and PQ

As we covered in the previous chapter on IVF indexes, coarse quantization partitions the space into cells so you only search a subset of vectors. Combining IVF with PQ gives you both reduced comparisons and compressed storage. This combination is called IVFPQ and is the most widely deployed configuration in production vector databases.

The two techniques address orthogonal bottlenecks. IVF reduces the number of vectors you compare, while PQ reduces the cost of each comparison. Together they multiply their benefits: if IVF reduces the comparison count by a factor of 50 and PQ reduces the per-comparison cost by a factor of 100, the combined speedup is roughly 5,000x compared to exhaustive full-precision search. That is why IVFPQ is a standard configuration in production vector databases, including Pinecone and Weaviate. Milvus also supports it for large-scale indexes.

In[21]:
Code
# IVFPQ: coarse quantizer (IVF) + fine quantizer (PQ)
nlist = 256  # number of IVF clusters (Voronoi cells)
nprobe = 16  # cells to search at query time

# Build a flat quantizer for the coarse IVF level
coarse_quantizer = faiss.IndexFlatL2(d)

# IVFPQ combines IVF partitioning with PQ compression
index_ivfpq = faiss.IndexIVFPQ(
    coarse_quantizer, d, nlist, M, nbits, faiss.METRIC_L2
)
index_ivfpq.nprobe = nprobe

# Train (learns IVF centroids and PQ codebooks jointly)
t0 = time.time()
index_ivfpq.train(train_vectors)
ivfpq_train_time = time.time() - t0

index_ivfpq.add(db_vectors)

The training time reflects learning both the IVF coarse centroids and the PQ codebooks. With nlist=256 IVF clusters and M=8 PQ subspaces, training is fast because each subspace operates in only d/M dimensions.

In[24]:
Code
# Search and measure recall
t0 = time.time()
_, ivfpq_results = index_ivfpq.search(query_vectors, K_true)
ivfpq_search_time = time.time() - t0

recall_ivfpq = compute_recall_at_k(ivfpq_results, ground_truth, K_true)

The summary table shows the trade-offs across all three methods. IVFPQ achieves the fastest search time because it combines both fewer comparisons (IVF) and cheaper comparisons (PQ), while maintaining recall comparable to pure PQ. Its PQ codes have the same size as those in the flat PQ index, although the inverted lists also store vector identifiers and coarse-index structures. That overhead is usually small relative to the full-precision vectors that PQ replaces.

Visualizing the Recall-Compression Trade-off

The number of subspaces MM controls the compression-accuracy trade-off, but the direction depends on what is held fixed. In the sweep below, nbits=8 is fixed. Every additional subspace therefore adds another byte to the stored code. Increasing MM gives the product codebook KMK^M possible reconstructions and usually improves recall, but it also produces a longer code and therefore less compression.

If the total code length is held fixed instead, increasing MM requires fewer bits and fewer centroids per subspace. That is a different experiment, and larger MM is not automatically better. Always state whether nbits or the total bit budget is fixed when comparing PQ configurations.

Let us sweep several values of MM at fixed nbits:

In[29]:
Code
m_values = [1, 2, 4, 8, 16, 32]
recalls = []
compression_ratios = []
valid_m_values = []

for m in m_values:
    if d % m != 0:
        continue
    valid_m_values.append(m)
    idx = faiss.IndexPQ(d, m, nbits, faiss.METRIC_L2)
    idx.train(train_vectors)
    idx.add(db_vectors)
    _, res = idx.search(query_vectors, K_true)
    rec = compute_recall_at_k(res, ground_truth, K_true)
    recalls.append(rec)
    bytes_per = (m * nbits + 7) // 8
    ratio = (d * 4) / bytes_per
    compression_ratios.append(ratio)
Out[7]:
Visualization
Line chart showing PQ code size increasing from 1 to 32 bytes as M increases from 1 to 32 at fixed nbits=8; tick labels show compression falling from 512x to 16x for 128-dimensional float32 vectors.
Code size for a 128-dimensional float32 vector as M increases at fixed nbits=8. Each additional subspace adds one byte, so the compression ratio falls from 512x at M=1 to 16x at M=32.
Line chart showing measured Recall@10 increasing as M rises from 1 to 32 at fixed K=256, with M=8 highlighted as the example configuration.
Recall@10 in a deterministic 64-dimensional synthetic benchmark as M increases at fixed K=256. The longer codes provide more representational capacity, so recall rises from M=1 through M=32. The highlighted M=8 point matches the configuration used in the FAISS example.

The two plots make the direction explicit. At fixed nbits, increasing MM improves the approximation by spending more bits per vector. Higher recall is not free: the scan performs more table lookups and the index stores more bytes. For real 768-dimensional embeddings, configurations such as M=48M = 48 or M=96M = 96 use 48 or 96 bytes per vector when nbits=8, corresponding to 64x or 32x compression relative to float32.

Key Parameters

Understanding PQ's parameters helps you make good configuration decisions. The parameters do not have universal optimal values; the right choices depend on your data, your memory budget, and your latency and recall requirements. A good approach is to start with the commonly recommended defaults, measure recall on a representative query set, and then adjust from there.

The key parameters for Product Quantization with FAISS are:

  • M: Number of subspaces (subvector segments). At fixed nbits, more subspaces produce a longer code, less compression, and generally higher recall. For 768-dimensional embeddings, values between 32 and 96 are common starting points, but the choice should be reported together with nbits or the resulting bytes per vector.
  • nbits: Number of bits per subcode, which determines the codebook size as K=2nbitsK = 2^{\text{nbits}}. Using nbits=8 gives 256 centroids per subspace and one byte of storage per subspace. Using nbits=4 halves the storage but reduces the codebook from 256 to 16 centroids, increasing quantization error.
  • nlist: Number of IVF coarse clusters (Voronoi cells) in IVFPQ. More clusters means finer partitioning and faster search, but requires more training data and longer training time. A common rule of thumb is nlist≈N\text{nlist} \approx \sqrt{N} where NN is the number of database vectors.
  • nprobe: Number of IVF cells to search at query time. Higher values improve recall at the cost of search speed. The recall vs. nprobe curve (shown in the plots above) lets you choose the operating point that fits your latency budget.
  • Training set size: The number of training vectors used to learn the codebooks. At least a few hundred times KK per subspace is recommended for reliable k-means convergence. For nbits=8 (K=256K = 256), this means at least 50,000 training vectors in practice.

PQ Accuracy Trade-offs in Depth

Understanding when PQ works well and when it struggles helps you configure it appropriately. The accuracy of PQ is not a fixed property of the algorithm but a function of how well the learned codebooks match your data distribution, how many subspaces you use, and whether the geometric structure of your embeddings aligns with the product decomposition that PQ assumes.

The fundamental source of error is the quantization step: by replacing each subvector with its nearest centroid, you introduce a residual error that corrupts the distance estimate. Whether this matters for recall depends on how large the residual errors are relative to the distances separating the true nearest neighbors from their non-neighbor competitors. If the true nearest neighbor is much closer to the query than the second-closest vector, PQ can afford substantial quantization error without disrupting the ranking. If many vectors are nearly equidistant from the query, even small quantization errors can flip the ranking and cause true neighbors to be missed.

The Quantization Error

When we compress a vector using PQ, the stored centroid indices only approximate the original subvectors. The quantization error measures how much information is lost: it is the total squared distance between each original subvector and the centroid used to represent it. Minimizing this error during codebook training (via k-means) directly improves the quality of approximate distance computations at search time. When quantization error is large, the centroids are poor representatives of the data points they are assigned to, and the approximate distances computed by ADC will deviate substantially from the true distances.

For a vector xx encoded as code (k1,…,kM)(k_1, \ldots, k_M), the quantization error is:

ϵ(x)=∑m=1M∥x(m)−cm,km∥2\epsilon(x) = \sum_{m=1}^{M} \left\| x^{(m)} - c_{m, k_m} \right\|^2

where:

  • ϵ(x)\epsilon(x): the total quantization error for vector xx, measuring how far the compressed representation deviates from the original
  • x(m)x^{(m)}: the original (uncompressed) mm-th subvector of xx
  • cm,kmc_{m, k_m}: the centroid assigned to represent x(m)x^{(m)}, i.e., the nearest centroid found during encoding
  • ∥x(m)−cm,km∥2\left\| x^{(m)} - c_{m, k_m} \right\|^2: the per-subspace reconstruction error for subspace mm

This reconstruction error is always non-negative. It does not, however, simply get added to every ADC distance. The difference between the approximate and true distances also contains a signed cross-term, derived below, so ADC can either overestimate or underestimate an individual distance. Smaller reconstruction error still tends to make the approximate distances more accurate and improve recall.

To understand how quantization error affects the approximate distance, consider that ADC computes the distance from the full-precision query qq to the centroid representing xx, rather than to xx itself. This means the result differs from the true distance by correction terms that depend on the quantization of xx. Specifically, the approximate distance can be written as:

d^(q,x)=∑m=1M∥q(m)−cm,km∥2(ADC uses centroid, not x)=∑m=1M∥(q(m)−x(m))+(x(m)−cm,km)∥2(add and subtract x(m))=∑m=1M∥q(m)−x(m)∥2+2∑m=1M⟨q(m)−x(m), x(m)−cm,km⟩+∑m=1M∥x(m)−cm,km∥2(expand squared norm)=d(q,x)+ϵqcorr+ϵxcorr(collect terms)\begin{aligned} \hat{d}(q, x) &= \sum_{m=1}^{M} \left\| q^{(m)} - c_{m,k_m} \right\|^2 && \text{(ADC uses centroid, not } x \text{)} \\ &= \sum_{m=1}^{M} \left\| (q^{(m)} - x^{(m)}) + (x^{(m)} - c_{m,k_m}) \right\|^2 && \text{(add and subtract } x^{(m)} \text{)} \\ &= \sum_{m=1}^{M} \left\| q^{(m)} - x^{(m)} \right\|^2 + 2\sum_{m=1}^{M} \langle q^{(m)} - x^{(m)},\, x^{(m)} - c_{m,k_m} \rangle + \sum_{m=1}^{M} \left\| x^{(m)} - c_{m,k_m} \right\|^2 && \text{(expand squared norm)} \\ &= d(q, x) + \epsilon_q^{\text{corr}} + \epsilon_x^{\text{corr}} && \text{(collect terms)} \end{aligned}

where:

  • d^(q,x)\hat{d}(q, x): the approximate distance returned by ADC
  • d(q,x)=∑m=1M∥q(m)−x(m)∥2d(q, x) = \sum_{m=1}^{M} \left\| q^{(m)} - x^{(m)} \right\|^2: the true squared Euclidean distance between qq and xx
  • ϵqcorr=2∑m=1M⟨q(m)−x(m), x(m)−cm,km⟩\epsilon_q^{\text{corr}} = 2\sum_{m=1}^{M} \langle q^{(m)} - x^{(m)},\, x^{(m)} - c_{m,k_m} \rangle: a cross-term arising from the interaction between the true displacement and the quantization error of xx
  • ϵxcorr=∑m=1M∥x(m)−cm,km∥2\epsilon_x^{\text{corr}} = \sum_{m=1}^{M} \left\| x^{(m)} - c_{m,k_m} \right\|^2: the quantization error of database vector xx, i.e., the total squared distance between xx and its assigned centroids

The algebraic expansion above is more than a curiosity. It shows precisely which quantities control the quality of the approximation. The term d(q,x)d(q, x) is the true distance, which is what we want. The non-negative term ϵxcorr\epsilon_x^{\text{corr}} depends only on how well xx is quantized, while the signed cross-term ϵqcorr\epsilon_q^{\text{corr}} also depends on the query and can reinforce or offset it. ADC therefore has no universal overestimation guarantee for individual query-vector pairs. When the cross-term is small in expectation, lower reconstruction error makes the approximate distances more stable and reduces ranking mistakes among nearby candidates.

Out[8]:
Visualization
Heatmap of mean reconstruction error for M from 2 to 32 and K from 16 to 256, with annotated values decreasing as either M or K increases.
Mean per-vector reconstruction error across combinations of subspaces M (rows) and centroids per subspace K (columns) in the deterministic 64-dimensional synthetic benchmark. At fixed K, increasing M lowers error but lengthens the code; at fixed M, increasing K lowers error but requires more bits per subcode. Moving down or right therefore spends a larger total bit budget.

What Hurts Recall

Several factors degrade PQ recall. Understanding these failure modes helps you diagnose poor performance and choose the right remediation:

  • Too few code bits: At fixed nbits, a small MM gives each vector a short code. A 128-dimensional vector with M=2M=2 and nbits=8, for example, receives only 16 total bits, so each 64-dimensional subvector must be represented by one of just 256 centroids. Increasing MM lowers this distortion by spending more bits per vector.
  • Insufficient training data: K-means needs at least K×K \times a few hundred training points per subspace to converge well. With K=256K = 256 and M=8M = 8, you need on the order of 200,000 training vectors minimum. With fewer, some centroids will be initialized in empty regions and never attract any training points, leaving voids in the codebook coverage.
  • Non-uniform distributions: If your embeddings have strong correlations between dimensions that span subspace boundaries, independent per-subspace quantization misses that structure. Optimized PQ (OPQ) applies a rotation to the embedding space before quantization to reduce such correlations, at the cost of a rotation step at query time.
  • Mismatched preprocessing or metric: Cosine search requires consistent normalization of training, database, and query vectors. For unit-normalized vectors, exact cosine and squared-Euclidean rankings agree; without that normalization, changing metrics changes the neighbor ordering that the index is trying to preserve.

Symmetric vs. Asymmetric Distance Computation

ADC keeps the query in full precision, which is why it outperforms Symmetric Distance Computation (SDC), where both the query and database vectors are quantized before comparison. With SDC, you accumulate quantization error from both sides: the query contributes its own per-subspace reconstruction errors on top of those from the database vector. Looking at the error expansion above, SDC adds an additional cross-term and an additional quantization error term for the query, making the approximation systematically worse.

In practice, ADC is almost always preferred. The only advantage of SDC is that you do not need to keep the codebooks in memory during search, which rarely matters compared to the recall cost. The memory overhead of the codebooks is negligible compared to the index itself: for M=96M = 96, K=256K = 256, d∗=8d^* = 8, the codebooks occupy only 96×256×8×4=786,43296 \times 256 \times 8 \times 4 = 786{,}432 bytes, less than 1 MB. There is virtually never a practical reason to accept the accuracy penalty of SDC when the codebooks are so cheap to store. ADC is always the right choice unless you are operating under severe memory constraints at truly extraordinary scale.

PQ for Scale

The real power of PQ shows when you scale to billions of vectors. Let us put the numbers in perspective.

Most discussions of PQ focus on the compression ratio, but the more important question is what compression enables that would otherwise be impossible. At a billion vectors, even the question of which machine architecture to use changes depending on whether you can fit the index in RAM. If you cannot, you are forced into a hybrid architecture where part of the index lives on disk or on remote machines, and you pay latency costs for every remote access. If you can, you get sub-millisecond in-process lookups with no network round-trips. PQ is often the technology that decides which side of that divide you fall on.

For a billion-vector index with 768-dimensional float32 embeddings:

  • Flat index: 109×768×4=3.0710^9 \times 768 \times 4 = 3.07 TB. This requires a distributed fleet of machines and terabytes of RAM.
  • PQ with M=96, nbits=8: 109×96×1=9610^9 \times 96 \times 1 = 96 GB (96 bytes/vector, since each of the 96 subspaces stores one byte). This fits on a single server with 128 GB RAM.
  • PQ with M=48, nbits=8: 109×48×1=4810^9 \times 48 \times 1 = 48 GB (48 bytes/vector). This fits on a workstation.

The difference is not incremental: it is the difference between a system that requires purpose-built infrastructure and one that runs on commodity hardware. A researcher studying billion-scale retrieval can now run experiments on a single rented cloud instance instead of managing a cluster. An engineering team can ship a production retrieval system without a dedicated infrastructure team. PQ makes these deployments feasible.

Out[9]:
Visualization
Log-scale bar chart of memory footprint in GB for five index configurations at 1 billion vectors, comparing flat float32 storage exceeding 3 TB against PQ variants with M=96 to M=12, with reference lines at 48 GB, 128 GB, and 1 TB.
Memory footprint in GB (log-scale y-axis) for a one-billion-vector index of 768-dimensional float32 embeddings across five storage configurations: full-precision flat storage at over 3 TB, and PQ with M=96, 48, 24, and 12 subspaces (nbits=8). Bar labels show exact memory in GB or TB. PQ reduces memory by one to two orders of magnitude relative to the flat index. Horizontal reference lines mark 48 GB (workstation), 128 GB (server), and 1 TB (cluster node), illustrating that PQ with M=48 or below fits on a single commodity server while flat storage requires a distributed cluster.

When combined with IVFPQ, you get the full stack: IVF reduces the number of vectors you compare (by a factor of nlist/nprobe\text{nlist} / \text{nprobe}), and PQ reduces the cost of each comparison. Production systems such as Pinecone and Weaviate use variants of IVFPQ as compressed index formats, as does Milvus. The upcoming chapters on hybrid search and reranking will show how these approximate indexes fit into a larger retrieval pipeline, where you use PQ for first-pass retrieval and then apply a full-precision reranker to the top candidates.

Limitations and Practical Implications

Product Quantization is not a free lunch. Understanding its limitations helps you anticipate where it will work well and where it will require additional engineering to meet accuracy requirements.

The core limitation is the accuracy-compression trade-off: any time you compress, you lose information, and that information loss translates to missed neighbors. For applications where recall below 95% is unacceptable, PQ alone may not suffice, and you will need to pair it with a post-processing reranking step using the original full-precision vectors. This two-stage architecture, retrieve with PQ and then rerank with exact distances, is now standard in production RAG systems. The reranker takes the top candidates from the PQ search (say, the top 100) and re-scores them using the original embeddings, recovering most of the recall loss at a fraction of the full-scan cost. We will cover this in detail in the chapter on reranking.

A second limitation is the static codebook. Once trained, the codebooks are fixed. If your data distribution shifts significantly, for example if you swap out your embedding model or index a new domain, the old codebooks become suboptimal and you need to retrain from scratch. Unlike HNSW graphs, which can be incrementally updated by inserting new nodes without retraining, PQ codebooks reflect a snapshot of your data distribution at training time. For systems that periodically replace their embedding model (which happens every time a better model is released), this means a full index rebuild, including codebook retraining and re-encoding all vectors. The rebuild process is well-parallelized and fast in practice, but it requires planning and downtime management.

Training cost is another consideration often overlooked in benchmarks. Running k-means with K=256K = 256 over 50,000 training vectors in each of M=96M = 96 subspaces is fast per subspace, but the overall training can take minutes on a CPU for large MM. This is not prohibitive for most pipelines, but it means you cannot rebuild the index on every data update. Systems that require low-latency index updates (such as real-time document indexing) need to queue updates and batch-rebuild periodically, or maintain a separate uncompressed buffer for recently added vectors that is merged into the main PQ index at regular intervals.

The product structure assumption is a more fundamental limitation. PQ assumes that the dimensions of your embeddings are organized such that the correlations between dimensions mostly fall within subspaces rather than across them. In practice, most embedding models produce representations where correlations do span subspace boundaries. Optimized PQ (OPQ) addresses this by learning a rotation matrix that redistributes the variance so that more of the structure can be represented within the chosen subspaces. This improves recall at a given compression ratio, but adds the cost of applying the rotation at both encoding and query time. It is especially useful when the original dimension ordering splits strongly correlated features across subspace boundaries.

Finally, PQ distance estimates are always approximate. The error is bounded by the quantization quality, which depends on the total bit budget, the number of training samples, the intrinsic dimensionality of your data, and how well the data distribution matches the product structure assumed by independent per-subspace quantization. Datasets where nearby vectors share strong cross-subspace correlations tend to see lower recall for a given compression ratio. The heatmap in the previous section gives you a way to measure this: if you see high quantization error at fixed KK, consider increasing MM, increasing KK, or applying OPQ. If the total code length must remain fixed, tune MM and nbits together rather than treating either parameter in isolation.

Despite these limitations, PQ remains one of the most practical tools in the retrieval engineer's toolkit. No other technique achieves comparable compression ratios with comparable retrieval quality. It reduces a previously intractable problem, fitting billions of high-dimensional vectors in RAM, to a manageable one by recognizing that distances decompose across subspaces. Every major production vector database in use today relies on some form of PQ or a closely related technique, and the original 2011 paper's core design choices remain essentially unchanged in modern implementations.

Summary

Product Quantization compresses high-dimensional vectors into compact codes by splitting each vector into MM subvectors and replacing each with the index of its nearest centroid in a learned codebook:

  • Codebook learning runs k-means independently in each of the MM subspaces on a sample of your training data. Each subspace gets KK centroids (typically K=256K = 256 for one-byte codes). The codebooks must match your data distribution; mismatched codebooks inflate quantization error and reduce recall.
  • Encoding replaces each subvector with its centroid index, compressing a float32 vector of dd dimensions (which requires d×4d \times 4 bytes) to MM bytes (one byte per subspace when nbits=8), giving a compression ratio of 4d/M4d/M. Encoding happens once at index build time and the result is stored as compact integer codes.
  • Asymmetric Distance Computation precomputes a lookup table of distances from the query to all centroids in each subspace, then reduces each database comparison to MM table lookups and additions. The lookup tables fit in CPU cache, making ADC limited by memory bandwidth rather than arithmetic throughput.
  • The recall-compression trade-off is governed by the total code length M⋅nbitsM \cdot \text{nbits}. At fixed nbits, more subspaces mean more bytes, less compression, lower reconstruction error, and generally higher recall. For 768-dimensional embeddings with nbits=8, M=48M = 48 and M=96M = 96 correspond to 48-byte and 96-byte codes respectively.
  • IVFPQ combines coarse partitioning (IVF) with compression (PQ), achieving both fewer comparisons and smaller memory footprint. This is the dominant configuration in production vector search systems, used by Pinecone, Weaviate, Milvus, and the FAISS library.
  • At scale, PQ is what makes billion-vector indexes feasible on a single server, transforming a distributed-computing problem into one that runs on commodity hardware. The practical difference between a 3 TB flat index and a 48 GB PQ index is the difference between a cluster deployment and a single machine.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about Product Quantization.

Product Quantization Quiz

Question 1 of 80 of 8 completed
A 768-dimensional float32 embedding is compressed with M=8 subspaces and nbits=8. How many bytes does the compressed code occupy?

Comments

1 comment

  1. Eric YiMember

    higher M - more subspaces, should mean less compression -> able to represent more distinct values -> less quantization error and higher accuracy

    1. Michael BrenndoerferMember

      Hi Eric, you are right. Thanks for flagging - I corrected the article.

      With nbits = 8, every additional subspace adds one byte. Therefore:

      • Larger MM --> longer code --> less compression

      • Larger MM → KMK^M possible reconstructions --> usually lower quantization error and higher recall

      • Smaller MM --> shorter code --> more compression but lower accuracy

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026productquantization, author = {Michael Brenndoerfer}, title = {Product Quantization: Vector Compression for ANN Search}, year = {2026}, url = {https://mbrenndoerfer.com/writing/product-quantization-vector-compression-ann-search}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-30} }
APAAcademic
Michael Brenndoerfer (2026). Product Quantization: Vector Compression for ANN Search. Retrieved from https://mbrenndoerfer.com/writing/product-quantization-vector-compression-ann-search
MLAAcademic
Michael Brenndoerfer. "Product Quantization: Vector Compression for ANN Search." 2026. Web. September 30, 2026. <https://mbrenndoerfer.com/writing/product-quantization-vector-compression-ann-search>.
CHICAGOAcademic
Michael Brenndoerfer. "Product Quantization: Vector Compression for ANN Search." Accessed September 30, 2026. https://mbrenndoerfer.com/writing/product-quantization-vector-compression-ann-search.
HARVARDAcademic
Michael Brenndoerfer (2026) 'Product Quantization: Vector Compression for ANN Search'. Available at: https://mbrenndoerfer.com/writing/product-quantization-vector-compression-ann-search (Accessed: September 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). Product Quantization: Vector Compression for ANN Search. https://mbrenndoerfer.com/writing/product-quantization-vector-compression-ann-search

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.