Product Quantization — 6 KB to 96 bytes
Product Quantization splits each vector into m subvectors and replaces each with the 1-byte id of its nearest prototype from a 256-entry codebook, shrinking a 6 KB vector to ~96 bytes (~64×) and finally making the memory axis a live knob — at the cost of lossy, approximate distances.
HNSW's RAM bill is the old 6 TB wall in disguise. PQ chops each vector into chunks and stores only which of 256 prototypes each chunk is closest to — 6 KB becomes ~96 bytes. The memory axis is finally a knob we can turn. But the codes are lossy, so recall drops — which raises the question of how to get HNSW-or-better speed AND PQ's tiny footprint at the same time.
Scene 08
Product Quantization — 6 KB to 96 bytes
- Watch
- Try it
- Predict
- Capture
Here's the move that finally shrinks memory. Take one song's vector — 1,536 numbers, stored as 4-byte floats, so 6,144 bytes (~6 KB) each. Slice it into m equal chunks. For each chunk, we trained a little catalogue of 256 typical chunk-shapes once, up front (the same k-means idea that drew IVF's cells, run separately inside each slice of the vector). Now we just store, per chunk, which of those 256 shapes it's nearest to — a number from 0 to 255, which fits in a single byte. Watch the chunks get rounded one by one and the byte counter fall: 6,144 bytes collapses to 96. That's a ~64× shrink. Distances later get estimated from those stored numbers using a small precomputed lookup table.
Highlighted lines are the ones running in the diagram right now.
def encode(vector, codebooks): # m codebooks, 256 eachchunks = split(vector, m)codes = bytearray(m)for j, chunk in enumerate(chunks):best = argmin(dist(chunk, proto)for proto in codebooks[j] # 256 protos)codes[j] = best # 0..255, one bytereturn codes # m bytes total
def search(query, codes_table):chunks = split(query, m) # query stays full-precisionlut = [[dist(chunks[j], proto) for proto in codebooks[j]]for j in range(m) # 256-wide table per chunk]scores = []for codes in codes_table: # every stored vectord = sum(lut[j][codes[j]] for j in range(m))scores.append(d) # summed table lookupsreturn argsort(scores)
def topK(query, k=10):shortlist = search(query)[:N] # N >> k, cheap codesif not rerank_enabled:return shortlist[:k]rescored = [(exact_dist(query, full_vectors[i]), i)for i in shortlist # only N exact reads]return [i for _, i in sorted(rescored)][:k]
Where this sits in Build a vector database (Pinecone / Weaviate / pgvector style)
Scene 08 of 15, in the Compress & pick act — PQ shrinks 6 KB to 96 bytes, IVFPQ combines both, and the trilemma forces a choice.. Split each vector into subvectors and store the 1-byte id of each one's nearest prototype: 6 KB → ~96 bytes (~64×). The memory axis finally becomes a knob — at the cost of lossy distances.
Up next. PQ shrank memory but, on its own, still scans codes for every vector. IVF skipped most vectors but stored them full-size. The obvious move is to combine them: use IVF cells to skip most of the data, and PQ to compress what remains. That combination — IVFPQ — is the billion-scale workhorse.
All 15 scenes in Build a vector database (Pinecone / Weaviate / pgvector style) · Every curriculum