Product Quantization (PQ)
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 or more, then answer nearest-neighbor queries with table lookups instead of full floating-point scans.
This post has three parts:
- Storage — why raw vectors don't fit a DRAM and how they are compressed using the PQ algorithm.
- Lookup — how to query this compressed store using Asymmetric Distance Computation (ADC).
- Failure mode — the error that lookup introduces, and why PQ is biased.
1. Storage: Compressing Vectors into Codes
Numbers
Before compression:
- Raw 1D vector = 4 bytes
- 128D vector = 128 x 4 = 512 bytes
After compression:
- 128D vector = 16 bytes
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:
- Total Vectors (): (1 Billion vectors)
- Dimensionality (): (standard size for lightweight embedding models)
- Data Type: float ( per coordinate)
The raw memory footprint of a single vector is:
For vectors:
Under a standard cluster limit (such as a 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 ():
- Set per segment (exactly ).
- An space addresses centroid IDs (
00000000…11111111). - Split a vector into sub-vectors, each of dimension:
Instead of storing raw floats () per segment, store only the 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:
- Compressed vector:
- 1 Billion vectors:
- Ratio: reduction
A raw dataset now fits on a 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. vectors) drawn from the database. Then:
- Decompose: Split every training vector into sub-vectors of dimension .
- Cluster: Run in each subspace.
- Fix : For , each subspace learns centroids.
- Save sub-codebooks: The cluster centers become for subspace :
The master codebook is the Cartesian product:
These codebooks are trained once, then reused for every encode and every query.
Encoding one vector (worked example)
Trace encoding a single 4D vector () with halves () and a toy bit budget :
Split into subspaces:
Use trained sub-codebooks (output of K-Means on the training set — not invented from itself). With , each codebook has centroids:
-
:
- (index
0) - (index
1) - (index
2) - (index
3)
- (index
-
:
- (index
0) - (index
1) - (index
2) - (index
3)
- (index
Pick the nearest centroid in each half (squared Euclidean distance):
For vs :
- vs :
- vs : (nearest → index
1) - vs :
- vs :
For vs :
- vs :
- vs : (nearest → index
1) - vs :
- vs :
What gets stored: throw away the floats; keep only the index pair:
| Size | |
|---|---|
| Original | ( floats × bits) |
| Stored code | ( indices × bits) |
| Reduction |
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:
Split like storage did:
Three documents in the compressed DB (only codes are present at search time):
| ID | Original vector (not in RAM) | Stored code |
|---|---|---|
| A | (from Storage) | [1, 1] |
| B | some vector that quantized to far centroids | [2, 2] |
| C | some vector that quantized to a mixed pair | [1, 0] |
Decoding a code into a stand-in vector
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 A | Nearest centroid | Index kept |
|---|---|---|
1 | ||
1 |
Lookup reconstructs by concatenating those codebook entries:
- True A (gone):
- Stand-in (what lookup scores):
Same decode for the others:
[2, 2]→[1, 0]→
Why ADC adds two LUT values
Squared distance over all 4 coordinates is the sum of the two half-distances:
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 to ):
0→ ·1→ ·2→ ·3→
LUT₂ (distances from to ):
0→ ·1→ ·2→ ·3→
Scoring codes and returning neighbors
| Document | Code | ADC 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:
| Rank | Returned? | Document | Score |
|---|---|---|---|
| 1 | yes (top-1 and top-2) | A | 0.04 |
| 2 | yes (top-2 only) | C | 0.27 |
| 3 | no | B | 2.05 |
- Top-1 returns A
- Top-2 returns A, then C
- B is not returned
“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 , not to the true vector .
True distance from to real A:
- Differences:
- Squared distance:
| What ranking used (ADC) | What we actually wanted |
|---|---|
0.04 (query ↔ stand-in) | 0.01 (query ↔ true A) |
ADC was 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 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 ) without silent ranking collapses.
Related reads
- RaBitQ — the unbiased-bounds fix to PQ's failure mode
- Storage Amplification in Vector Search — the problem PQ exists to solve
- DRAM — the expensive tier PQ compresses vectors to fit