Codebooks that add up
Keep M small codebooks of K vectors. Approximate every data point by choosing one vector from each and adding them. You store 2,048 vectors and get 2⁶⁴.
1The sum trick
The code for is the tuple : bits. With and that is 8 bytes.
- 2,048 vectors stored ()
- 2⁶⁴ points reachable ()
- 8 B per code
Drag the yellow point Three codebooks of four arrows each: big, medium, small. Pick one arrow from each and chain them. Only 12 arrows are stored, yet 4 × 4 × 4 = 64 grey points are reachable.
Now the smallest interesting case with data: two codebooks, A and B, four arrows each.
Drag the arrow tips Each mint dot is Aᵢ + Bⱼ, so moving one arrow moves four dots at once. Then switch to the product constraint and press Fit: the sums are forced onto an axis-aligned grid, which wastes points on a tilted cloud.
2Two things to take from playing with it
Decoding is trivial. Given the code, is table lookups and vector additions. The decoder never sees the giant implicit codebook.
Encoding got hard. To find the best code we would have to consider all combinations: the same we called impossible. The big search has not gone away. It has moved from the decoder to the encoder.
3Restrict: product quantization
The Product button is product quantization (PQ; Jégou, Douze and Schmid, 2011). Split the dimensions into blocks. Codebook only has non-zeros in block . Codewords from different codebooks are then perpendicular, and the error separates:
Each term depends on one index only, so each is minimised separately: independent nearest-neighbour searches over entries. That is the whole encoder. Training is k-means in each block.
def pq_encode(X, codebooks): # M arrays of shape (K, d/M)
blocks = np.split(X, len(codebooks), axis=1)
return np.stack([nearest(B, C) for B, C in zip(blocks, codebooks)], axis=1)
def pq_decode(codes, codebooks):
return np.concatenate([C[codes[:, m]] for m, C in enumerate(codebooks)], axis=1)
The price is what you saw. The implicit codebook is a grid aligned with the blocks. When dimensions are correlated across blocks, and in embeddings they always are, much of the grid sits where no data lives.
OPQ (Ge et al., 2013) learns a rotation first, tilting the grid toward the data. It helps a great deal; the grid is still a grid.