← Back

Product Quantization (PQ)

9 min read

Product Quantization (PQ) is a lossy compression technique that compresses high dimensional vectors for efficiently storing in DRAM — tackling the storage amplification problem in vector search. By splitting vectors into subspaces and clustering each subspace independently, PQ can compress vectors by up to 32×32\times or more, then answer nearest-neighbor queries with table lookups instead of full floating-point scans.

This post has three parts:

  1. Storage — why raw vectors don't fit a DRAM and how they are compressed using the PQ algorithm.
  2. Lookup — how to query this compressed store using Asymmetric Distance Computation (ADC).
  3. Failure mode — the error that lookup introduces, and why PQ is biased.

1. Storage: Compressing Vectors into Codes

Numbers

Before compression:

After compression:

Why raw storage fails

When serving similarity search at scale, keeping raw floating-point vectors in memory becomes financially and operationally untenable.

For a production-scale dataset:

The raw memory footprint of a single vector is:

Raw Vector Size=D×4 bytes=128×4=512 bytes\text{Raw Vector Size} = D \times 4\text{ bytes} = 128 \times 4 = 512\text{ bytes}

For 1 Billion1 \text{ Billion} vectors:

Total Raw Memory=109×512 bytes=5.12×1011 bytes≈512 GB\text{Total Raw Memory} = 10^9 \times 512\text{ bytes} = 5.12 \times 10^{11}\text{ bytes} \approx 512\text{ GB}

Under a standard cluster limit (such as a 256 GB256\text{ GB} RAM instance), that database does not fit in DRAM.

The bit-budget idea

Product Quantization compresses each vector into a short sequence of integer indices under a bit budget (kk):

Instead of storing 88 raw floats (32 bytes32\text{ bytes}) per segment, store only the 8-bit8\text{-bit} index of the nearest codebook centroid.

In effect, we store 8(k) bits(1 byte) per segment and for a 128D vector, we split it into 16(M) segments and hence the resulting space is 16 bytes per 128D vector.

Compression ratio:

A 512 GB512\text{ GB} raw dataset now fits on a 256 GB256\text{ GB} machine with headroom for indexes and caches.

Training the codebooks (offline)

Unlike ZIP-style compression, PQ is data-dependent. Centroids must sit in the high-density regions of your data.

The training set is a representative sample (e.g. 100,000100{,}000 vectors) drawn from the database. Then:

  1. Decompose: Split every training vector into MM sub-vectors of dimension dd.
  2. Cluster: Run K-MeansK\text{-Means} in each subspace.
  3. Fix K=2kK = 2^k: For k=8k=8, each subspace learns K=256K=256 centroids.
  4. Save sub-codebooks: The cluster centers become Cm\mathcal{C}_m for subspace mm: Cm={cm,0,cm,1,…,cm,2k−1}⊂Rd\mathcal{C}_m = \{ \mathbf{c}_{m,0}, \mathbf{c}_{m,1}, \dots, \mathbf{c}_{m, 2^k - 1} \} \subset \mathbb{R}^d

The master codebook is the Cartesian product:

C=C1×C2×⋯×CM\mathcal{C} = \mathcal{C}_1 \times \mathcal{C}_2 \times \dots \times \mathcal{C}_M

These codebooks are trained once, then reused for every encode and every query.

Encoding one vector (worked example)

Trace encoding a single 4D vector (D=4D = 4) with M=2M = 2 halves (d=2d = 2) and a toy bit budget k=2k = 2:

o=[0.15, 0.85, 0.45, −0.25]\mathbf{o} = [0.15, \ 0.85, \ 0.45, \ -0.25]

Split into subspaces:

Use trained sub-codebooks (output of K-Means on the training set — not invented from o\mathbf{o} itself). With k=2k = 2, each codebook has 2k=42^k = 4 centroids:

Pick the nearest centroid in each half (squared Euclidean distance):

For o1=[0.15, 0.85]\mathbf{o}_1 = [0.15, \ 0.85] vs C1\mathcal{C}_1:

For o2=[0.45, −0.25]\mathbf{o}_2 = [0.45, \ -0.25] vs C2\mathcal{C}_2:

What gets stored: throw away the floats; keep only the index pair:

code(o)=[1, 1]  ⟹  binary 01012\text{code}(\mathbf{o}) = [1, \ 1] \implies \text{binary } \mathbf{0101}_2
Size
Original128 bits128\text{ bits} (44 floats × 3232 bits)
Stored code4 bits4\text{ bits} (22 indices × 22 bits)
Reduction32×32\times

That is the storage contract: RAM holds codes + codebooks, not the original vectors.


2. Lookup: Finding Neighbors with ADC

Storage solved the DRAM problem. Lookup answers: given a query, which stored codes are closest?

The query and the compressed database

Query:

q=[0.2, 0.8, 0.4, −0.3]\mathbf{q} = [0.2,\ 0.8,\ 0.4,\ -0.3]

Split like storage did:

Three documents in the compressed DB (only codes are present at search time):

IDOriginal vector (not in RAM)Stored code
A[0.15, 0.85, 0.45, −0.25][0.15,\ 0.85,\ 0.45,\ -0.25] (from Storage)[1, 1]
Bsome vector that quantized to far centroids[2, 2]
Csome vector that quantized to a mixed pair[1, 0]

Decoding a code into a stand-in vector

oˉA=[0.1, 0.9, 0.5, −0.2]\bar{\mathbf{o}}_A = [0.1,\ 0.9,\ 0.5,\ -0.2] is not a rewrite of A's original floats. It is the decode of the only thing left for A: code [1, 1].

From the Storage encode step:

Half of ANearest centroidIndex kept
[0.15, 0.85][0.15,\ 0.85]c1,1=[0.1, 0.9]\mathbf{c}_{1,1} = [0.1,\ 0.9]1
[0.45, −0.25][0.45,\ -0.25]c2,1=[0.5, −0.2]\mathbf{c}_{2,1} = [0.5,\ -0.2]1

Lookup reconstructs by concatenating those codebook entries:

[1, 1]  →  c1,1 ∥ c2,1=[0.1, 0.9] ∥ [0.5, −0.2]=[0.1, 0.9, 0.5, −0.2]=oˉA[1,\ 1] \;\rightarrow\; \mathbf{c}_{1,1}\,\|\,\mathbf{c}_{2,1} = [0.1,\ 0.9]\,\|\,[0.5,\ -0.2] = [0.1,\ 0.9,\ 0.5,\ -0.2] = \bar{\mathbf{o}}_A

Same decode for the others:

Why ADC adds two LUT values

Squared distance over all 4 coordinates is the sum of the two half-distances:

distance2(q,oˉA)=(0.2−0.1)2+(0.8−0.9)2⏟first half+(0.4−0.5)2+(−0.3−(−0.2))2⏟second half=0.02+0.02=0.04\begin{aligned} \text{distance}^2(\mathbf{q}, \bar{\mathbf{o}}_A) &= \underbrace{(0.2-0.1)^2 + (0.8-0.9)^2}_{\text{first half}} + \underbrace{(0.4-0.5)^2 + (-0.3-(-0.2))^2}_{\text{second half}} \\ &= 0.02 + 0.02 \\ &= 0.04 \end{aligned}

So 0.02 + 0.02 is not an arbitrary trick — it is the full stand-in distance, split across subspaces. ADC precomputes every half-distance into look-up tables, then scores each code with lookup + lookup + add.

LUT₁ (distances from q1\mathbf{q}_1 to C1\mathcal{C}_1):

LUT₂ (distances from q2\mathbf{q}_2 to C2\mathcal{C}_2):

Scoring codes and returning neighbors

DocumentCodeADC score
A[1, 1]LUT₁[1] + LUT₂[1] = 0.02 + 0.02 = 0.04
B[2, 2]0.36 + 1.69 = 2.05
C[1, 0]0.02 + 0.25 = 0.27

Ranked by ascending score:

RankReturned?DocumentScore
1yes (top-1 and top-2)A0.04
2yes (top-2 only)C0.27
3noB2.05

“Match” here means lowest ADC score against stand-ins, not exact distance to the original floats.


3. Failure Mode: The Error Lookup Introduces

Lookup returned A because ADC reported distance 0.04. That number is distance to the stand-in oˉA=[0.1, 0.9, 0.5, −0.2]\bar{\mathbf{o}}_A = [0.1,\ 0.9,\ 0.5,\ -0.2], not to the true vector [0.15, 0.85, 0.45, −0.25][0.15,\ 0.85,\ 0.45,\ -0.25].

True distance from q\mathbf{q} to real A:

What ranking used (ADC)What we actually wanted
0.04 (query ↔ stand-in)0.01 (query ↔ true A)

ADC was 4×4\times too large. In this tiny DB, A still won — but the score the system trusted was biased. Heuristic PQ ranks “query ↔ centroid reconstruction,” while the user asked for “query ↔ real vector.” Vectors far from their centroids are systematically mis-scored, and classic PQ has no error bound on how bad that gap can get.

On datasets like MSong, relative distance error can exceed 50%, and recall can fall below 60%60\% even after re-ranking.

How later systems harden this

Methods like RaBitQ (used in systems such as Turbopuffer) still compress, but they correct for the gap between the real vector and its stand-in instead of treating the ADC score as truth. In this example: do not trust 0.04 when A was actually at 0.01. That correction yields an unbiased estimate and much stabler recall (often >90%>90\%) without silent ranking collapses.