Searching without decoding
Compression is half the story. The other half is that a query can be scored against codes directly, and this is where additive codebooks repay their expensive encoder.
1Asymmetric distance computation
A query arrives and we want its inner product with millions of compressed vectors. Because the reconstruction is a sum, so is the inner product:
- Once per query: compute the table . That is 2,048 dot products.
- Per database vector: lookups and additions. No reconstruction, no -dimensional arithmetic.
This is asymmetric distance computation (ADC): the query stays exact, the database is compressed.
ADC The pink cells are the entries the code picks. The search cost is the same lookups for PQ, RQ and additive codes.
def adc_search(q, codes, codebooks, norms_sq, topk=10):
M = codes.shape[1]
luts = np.stack([C @ q for C in codebooks]) # (M, K), once per query
ip = luts[np.arange(M), codes].sum(1) # M lookups per vector
dist = norms_sq - 2 * ip # ||q||² is the same for everyone
return np.argsort(dist)[:topk]
2The norm term
- The first term is the same for every candidate.
- The middle one is the table sum.
- The last, , contains all the pairwise terms again.
For PQ those are zero (perpendicular blocks). For additive codes they are not, so the standard fix is to compute at encoding time and store it, quantized to one extra byte. Eight bytes become nine.
For inner-product search on normalized embeddings, which is Marginalia’s case, you do not need it at all.
3Three tricks production systems stack on top
- Fast-scan With (4-bit codes) each table has 16 entries. Quantized to 8 bits, a whole table fits in one SIMD register and a single shuffle instruction does 16 to 32 lookups at once.
- IVF A coarse k-means first, so that only a few lists are scanned per query. It is literally one more quantization stage, used for routing.
- Re-ranking Take the top 100 by ADC and re-score them with exact vectors from cheaper storage. This hides most of the quantization error from the final ranking.
import faiss
d = 384
index = faiss.index_factory(d, "IVF4096,RQ16x8_Nqint8") # 16 B code + 1 B norm
faiss.extract_index_ivf(index).rq.max_beam_size = 4 # the beam B
index.train(train_vectors) # k-means for IVF, then the RQ codebooks
index.add(all_vectors)
index.nprobe = 32 # lists scanned per query
D, I = index.search(queries, 100) # a shortlist; re-rank it with exact vectors
# alternatives: "OPQ16_384,IVF4096,PQ16x8" "IVF4096,LSQ16x8_Nqint8"
# "IVF4096,PRQ2x8x8_Nqint8" "IVF4096,PQ32x4fs" (fast-scan)