Product Quantization

AI-polished: This post originated from notes and questions while working through the topic in detail. AI was used to organize the notes, connect the ideas, and polish them into a coherent blog post.

Product Quantization — From 40 GB of Vectors to 160 MB

Dense retrieval introduces a scaling problem beyond search latency: memory.

Suppose a vector database contains:

10,000,000 vectors

Each vector contains:

1024 dimensions

and each dimension is represented using:

float32 = 4 bytes

The raw storage required for the vectors is:

[ 10,000,000 \times 1024 \times 4 ]

[ = 40,960,000,000\text{ bytes} ]

or approximately:

40.96 GB

And this is only the vector data.

At hundreds of millions or billions of vectors, keeping every vector in full precision becomes increasingly expensive.

This is one of the problems Product Quantization (PQ) tries to solve.

The central idea is:

Instead of storing every floating-point value of every vector, split the vectors into smaller pieces, approximate each piece using a learned representative vector, and store only the ID of that representative.


1. Split Each Vector into Subvectors

Suppose PQ is configured with:

m = 16

Our original vector contains 1024 dimensions.

Therefore each vector is divided into 16 subvectors:

[ 1024 / 16 = 64 ]

Each subvector contains 64 dimensions.

Conceptually:

Original 1024-D vector

┌────────┬────────┬────────┬────── ... ──────┬────────┐
│   S1   │   S2   │   S3   │                 │  S16   │
│  64-D  │  64-D  │  64-D  │                 │  64-D  │
└────────┴────────┴────────┴────── ... ──────┴────────┘

Do this for all database vectors:

Vector 1 → [S1][S2][S3] ... [S16]
Vector 2 → [S1][S2][S3] ... [S16]
Vector 3 → [S1][S2][S3] ... [S16]
...

Nothing has been compressed yet.

We have simply divided the original vector space into smaller subspaces.


2. Learn Representative Centroids

Now take only the first subvector position across many training vectors:

Vector 1 → S1 ─┐
Vector 2 → S1  │
Vector 3 → S1  │
Vector 4 → S1  ├──→ K-means
...            │
Vector N → S1 ─┘

Every S1 is a 64-dimensional vector.

Suppose PQ is configured with:

k = 256

Run K-means with:

K = 256

K-means groups these 64-dimensional subvectors into 256 clusters.

Each cluster gets a representative center:

Centroid

Conceptually:

                   S1 subvectors

                • • •
              • • • •
                • •
                 ↓
            Centroid #0


                           • •
                         • • • •
                           •
                           ↓
                      Centroid #1


               ...

                      Centroid #255

For subspace S1, we therefore learn:

Centroid 0
Centroid 1
Centroid 2
...
Centroid 255

These 256 centroid vectors collectively form a:

Codebook

Therefore:

All S1 subvectors
       ↓
     K-means
       ↓
256 representative centroids
       ↓
    Codebook 1

3. Build One Codebook per Subspace

Repeat this independently for every subvector position.

All S1 subvectors
       ↓
K-means
       ↓
256 centroids
       ↓
Codebook 1


All S2 subvectors
       ↓
K-means
       ↓
256 centroids
       ↓
Codebook 2


All S3 subvectors
       ↓
K-means
       ↓
256 centroids
       ↓
Codebook 3


...


All S16 subvectors
       ↓
K-means
       ↓
256 centroids
       ↓
Codebook 16

Our PQ model therefore contains:

16 codebooks
×
256 centroids per codebook

Each centroid is itself a 64-dimensional representative vector.

These centroids are learned during PQ training/index construction.

They are not rediscovered when a query arrives.


4. Compress a Database Vector

Now that the codebooks exist, the database vectors can be compressed.

Take one database vector:

1024-D vector

[S1][S2][S3] ... [S16]

Consider S1.

It contains 64 float32 values.

Compare it against the 256 centroids in Codebook 1:

S1
 │
 ├── distance → centroid 0
 ├── distance → centroid 1
 ├── distance → centroid 2
 │
 │
 └── distance → centroid 255

Suppose its nearest centroid is:

Centroid #73

We now approximate:

S1 ≈ Centroid #73

Instead of storing the original 64-dimensional subvector, store:

73

This is the PQ code for S1.


5. Repeat for All 16 Subvectors

Suppose the nearest centroids are:

S1  → centroid 73
S2  → centroid 191
S3  → centroid 22
S4  → centroid 5
...
S16 → centroid 8

The original vector containing:

1024 float32 values

is now represented as:

[73, 191, 22, 5, ..., 8]

These 16 numbers are its PQ codes.

They mean:

S1  ≈ centroid 73  from Codebook 1
S2  ≈ centroid 191 from Codebook 2
S3  ≈ centroid 22  from Codebook 3
...
S16 ≈ centroid 8   from Codebook 16

The original values are no longer stored in the codes-only representation.

Instead, we store instructions for approximating the original vector using the learned codebooks.


6. Why Does This Compress So Well?

Recall:

k = 256

Therefore each codebook contains 256 possible centroid IDs:

0 ... 255

Since:

[ 256 = 2^8 ]

a centroid ID requires:

8 bits = 1 byte

Each vector contains 16 subvectors.

Therefore:

16 subvectors
×
1 byte per centroid ID
=
16 bytes/vector

Compare that with the original vector.

Before PQ

1024 dimensions
×
4 bytes per float32
=
4096 bytes/vector

After PQ

16 centroid IDs
×
1 byte
=
16 bytes/vector

Therefore:

[ 4096 / 16 = 256 ]

The PQ codes are:

256× smaller

than the original vectors in this example.


7. Back to the 10-Million-Vector Example

Originally:

[ 10,000,000 \times 1024 \times 4 ]

gives:

40.96 GB

After PQ:

[ 10,000,000 \times 16 \times 1 ]

gives:

160,000,000 bytes
≈ 160 MB

Conceptually:

10 million
1024-D
float32 vectors

        ↓

     40.96 GB

        ↓

       PQ
 m=16, k=256

        ↓

10 million
× 16 centroid IDs
× 1 byte

        ↓

      ~160 MB

That is approximately:

256× compression

for the stored vector codes.

The codebooks themselves also consume memory, but they are small compared with storing millions of full vectors.


8. But How Do We Search Compressed Vectors?

This is the other half of Product Quantization.

Suppose a query arrives.

The embedding model produces the normal full-precision query vector:

Query

1024-D float32 vector

An important point:

The query does not normally need to be converted into PQ codes.

Instead, we keep the query at full precision.

This is called:

Asymmetric Distance Computation — ADC

Why asymmetric?

Because the two sides have different representations:

Query                    Database

Full precision           PQ compressed
1024-D vector             16 centroid IDs

9. Split the Query into the Same Subspaces

The query is divided exactly like the database vectors:

Query 1024-D vector

┌────────┬────────┬────────┬────── ... ──────┬────────┐
│   Q1   │   Q2   │   Q3   │                 │  Q16   │
│  64-D  │  64-D  │  64-D  │                 │  64-D  │
└────────┴────────┴────────┴────── ... ──────┴────────┘

So:

Query
 ↓
16 × 64-D subvectors

But unlike database vectors, these query subvectors are not replaced by centroid IDs.


10. Build a Distance Lookup Table for the Query

Take Q1.

Codebook 1 already contains:

256 centroids

Calculate the distance between Q1 and every centroid in Codebook 1:

Q1 → centroid 0   = distance
Q1 → centroid 1   = distance
Q1 → centroid 2   = distance
...
Q1 → centroid 255 = distance

This gives:

256 distances

Do the same for Q2 using Codebook 2:

Q2 → centroid 0
Q2 → centroid 1
...
Q2 → centroid 255

Again:

256 distances

Repeat for all 16 query subvectors.

The result is:

16 subspaces
×
256 centroid distances
=
4096 distances

This is the per-query distance lookup table.

Conceptually:

                 Centroid IDs

             0    1    2    ...   255

Q1          d10  d11  d12   ...   d1,255
Q2          d20  d21  d22   ...   d2,255
Q3          d30  d31  d32   ...   d3,255
...
Q16         ...                   ...

If each distance is stored as float32:

[ 4096 \times 4 = 16,384\text{ bytes} ]

or:

16 KB

per query.

This table is computed once when the query arrives.

It is then reused while scoring database vectors.


11. Score a Compressed Database Vector

Recall one of our compressed database vectors:

[73, 191, 22, 5, ..., 8]

This tells us:

Subspace 1  → centroid 73
Subspace 2  → centroid 191
Subspace 3  → centroid 22
Subspace 4  → centroid 5
...
Subspace 16 → centroid 8

Now use those IDs to access the query’s distance table.

Lookup:

Q1  → centroid 73
Q2  → centroid 191
Q3  → centroid 22
Q4  → centroid 5
...
Q16 → centroid 8

Suppose those distances are:

0.12
0.31
0.08
0.21
...
0.14

Add them:

0.12
+ 0.31
+ 0.08
+ 0.21
...
+ 0.14

    ↓

Approximate distance between
the query and database vector

The database vector never needs to be reconstructed into its original 1024 floating-point values just to perform this scoring.


12. Why 16 Lookups and 15 Additions?

The database vector contains:

16 PQ codes

Each code selects one distance from the lookup table.

Therefore scoring one database vector requires:

16 table lookups

Those 16 distance values must then be summed.

Adding 16 numbers requires:

15 additions

Therefore:

16 lookups, 15 additions per database vector

after the per-query lookup table has been constructed.

This is one reason PQ can make large-scale vector search extremely efficient.


13. Why Keep the Query at Full Precision?

We compressed the database because there might be:

10,000,000

database vectors.

But at search time there may be only:

1 query vector

Compressing 10 million vectors saves enormous amounts of memory.

Compressing the single query saves almost nothing.

Worse, quantizing the query would throw away additional information.

So we keep:

Query
 ↓
Full precision

while using:

Database
 ↓
Compressed PQ representation

Hence:

              ASYMMETRIC

Query                       Database

Full precision              Compressed
1024-D                      PQ codes
   │                           │
   │                           │
   └───────────┬───────────────┘
               │
               ▼
       Approximate distance

This is Asymmetric Distance Computation.


14. What Was Precomputed and What Happens at Query Time?

This distinction makes the whole process easier to remember.

Index / Training Time

Do this ahead of time:

Database vectors
       ↓
Split into 16 subvectors
       ↓
Run K-means independently
for each subspace
       ↓
Learn 16 codebooks
       ↓
256 centroids/codebook
       ↓
Map every database subvector
to nearest centroid
       ↓
Store PQ codes

So by search time:

Database vector
=
[73, 191, 22, ..., 8]

is already available.


Query Time

Now:

Query
  ↓
Embedding
  ↓
1024-D full-precision vector
  ↓
Split into 16 subvectors
  ↓
For each query subvector,
calculate distance to
256 corresponding centroids
  ↓
16 × 256 distance table
  ↓
4096 distances

Then for each candidate database vector:

Read its 16 PQ codes
        ↓
16 distance-table lookups
        ↓
15 additions
        ↓
Approximate distance

15. The Complete PQ Lifecycle

Putting everything together:

                    INDEXING / TRAINING
                           │
                           ▼
              10 million 1024-D vectors
                           │
                           ▼
                  Split each into
                  16 × 64-D subvectors
                           │
                           ▼
             K-means per subvector position
                           │
                           ▼
                16 × 256 centroids
                           │
                           ▼
                    16 codebooks
                           │
                           ▼
              Map database subvectors
               to nearest centroids
                           │
                           ▼
                    Store IDs only
                           │
                           ▼
                   16 bytes/vector
                           │
                           ▼
                      ~160 MB


                       QUERY TIME
                           │
                           ▼
                    Query embedding
                           │
                           ▼
                 1024-D float32 vector
                           │
                           ▼
                    Split into 16
                           │
                           ▼
             Distance from each query
              subvector to its 256
                   codebook centroids
                           │
                           ▼
               16 × 256 lookup table
                           │
                           ▼
                   4096 distances
                           │
                           ▼
               Database PQ code
              [73,191,22,...,8]
                           │
                           ▼
                    16 lookups
                           │
                           ▼
                    15 additions
                           │
                           ▼
              Approximate Q ↔ D distance

16. What Did We Sacrifice?

PQ isn’t lossless compression.

Originally:

S1 = exact 64 floating-point values

After PQ:

S1 ≈ centroid #73

Many different original subvectors can map to the same centroid.

That means some information has been discarded.

This is quantization error.

Therefore:

Full-precision vectors
        ↓
More memory
        ↓
More precise distances


PQ-compressed vectors
        ↓
Much less memory
        ↓
Approximate distances
        ↓
Possible retrieval recall loss

The more aggressively vectors are compressed, the greater the potential distortion.

So PQ represents a classic systems trade-off:

Memory + search efficiency vs vector precision + retrieval recall


17. PQ vs IVF — Don’t Mix Them Up

These two ideas are easy to confuse because they are often used together.

IVF asks:

Where should I search?

It partitions the vector space into regions.

Query
 ↓
Find promising IVF clusters
 ↓
Search only vectors in those clusters

It reduces the search space.

PQ asks:

How can I store and score those vectors cheaply?

Full vector
 ↓
Subvectors
 ↓
Centroid IDs
 ↓
Compact representation

It reduces the storage and distance-computation cost.

Therefore:

IVF
 ↓
Reduce WHERE we search


PQ
 ↓
Reduce WHAT we store
and make distance scoring cheap

And they can be combined:

IVF
 ↓
Find promising region
 ↓
PQ
 ↓
Cheaply score compressed vectors
 ↓
Top candidates

This gives architectures such as IVF-PQ.


18. The Four Numbers Worth Remembering

For:

10 million vectors
1024 dimensions
float32
m = 16
k = 256

Original storage

[ 10M \times 1024 \times 4 ]

≈ 40.96 GB

PQ storage — codes only

[ 10M \times 16 \times 1 ]

≈ 160 MB

Per-query lookup table

[ 16 \times 256 ]

4096 distance entries

With float32 distances:

≈ 16 KB

Scoring one database vector

16 lookups
15 additions

19. The Mental Model

If all the implementation details are forgotten later, remember this:

                    DATABASE

1024-D float32 vector
         ↓
Split into 16 pieces
         ↓
Each piece is 64-D
         ↓
K-means learned 256
representatives for each position
         ↓
Find nearest representative
for each piece
         ↓
Store its ID
         ↓
[73,191,22,...,8]
         ↓
16 bytes instead of 4096 bytes


                     QUERY

1024-D full-precision vector
         ↓
Split into same 16 pieces
         ↓
Compare each piece against
its 256 learned centroids
         ↓
Build distance lookup table
         ↓
Use DB's centroid IDs
to select distances
         ↓
Add 16 distances
         ↓
Approximate query ↔ document distance

Or in two sentences:

At indexing time, PQ splits database vectors into subvectors, learns representative centroids for each subspace using K-means, and replaces each database subvector with the ID of its nearest centroid.

At query time, the query remains full precision; its distances to the learned centroids are calculated once, and the stored PQ codes are then used as cheap lookup-table indices to approximate the distance to each database vector.

That is Product Quantization end to end.