Skip to content

Quantised indices

Thirteen more estimators, almost all of them the same indices you already have over compressed vectors. They exist for one reason: memory. A float32 index stores dim floats per vector, and on a few million cells at 512 dimensions that stops being a rounding error. QgIndex is the odd one out and spends its codes on speed instead, see below.

Nothing about the surface changes. Same fit / kneighbors / save, same padding rules, float32 and float64 both supported.

The one thing to internalise

A quantised index reports the codec's estimate of a distance, not the distance. It's close enough to rank on, which is what an index is for. It is not something to feed anywhere the absolute value matters without checking it against ExhaustiveIndex first. QgIndex is the exception, see below.

This also means recall is the only honest way to compare two of them. A distance-ratio comparison between a quantised index and an exact one conflates the retrieval error with the codec's, and the crate's own benchmark tables recompute every ratio from the original vectors for exactly that reason.

The other constraint: none of them support Manhattan. Every codec here is built on inner products, which is what makes the integer arithmetic work. Asking for it raises rather than falling back.

What each codec does

BF16

ExhaustiveBf16Index, IvfBf16Index.

Storage drops to bf16, which keeps float32's exponent range and throws away mantissa bits from roughly the third significant digit on. Half the memory, no overflow traps, and nothing structural in the index changes. The cheapest quantisation to reason about and the one with the least to go wrong, which also makes it the smallest saving.

Two things do change. Distances are computed in float32, so the values get widened back on every comparison and the query comes out around 2x slower at 32 dimensions and 4x slower at 128. And the codec error is not negligible: recall lands near 0.98 on Euclidean and 0.89 on cosine. Under IVF it does not hide inside the cell pruning either, since plain IVF already reaches 1.0 on the correlated and low-rank generators, so the codec error is the whole error.

SQ8

ExhaustiveSq8Index, IvfSq8Index, HnswSq8uIndex.

One byte per dimension, with per-dimension offsets and a single scale shared across all of them. The shared scale is the whole trick: it makes the integer code distance preserve the ordering of the float one, so the scan runs entirely on u8 kernels.

The memory saving is about 3.5x at 32 dimensions and 3.9x at 128, so "a quarter" is the high-dimensional case rather than the general one. The speed depends on which index you put it under. Exhaustive SQ8 is slower than the float scan at 32 dimensions, by up to 1.5x, and only edges ahead at 128. Under IVF it wins consistently, 1.15 to 1.5x on every matched nlist/nprobe pairing.

The recall cost is the thing to measure. At a fixed nprobe IvfSq8Index gives up around 0.07 against plain IVF on gaussian Euclidean data, 0.21 on the low-rank generator and 0.26 on cosine.

HnswSq8uIndex is the interesting one. The graph is built and searched entirely on codes, so there's no float copy sitting around for re-ranking and no mismatch between the edges and the distances that traverse them. Build and query are both faster than a plain HnswIndex.

It is the usual starting point, not a free win. The graph edges aren't compressed, so the whole index lands at 0.44 to 0.80 of a plain HNSW depending on m and dimensionality rather than at a quarter, and at matched m, ef_construction and ef_search it gives up 0.07 recall on Euclidean and 0.26 to 0.33 on cosine. Its recall tracks ExhaustiveSq8Index almost exactly, which tells you the loss is the codec rather than the graph. Measure it against HnswIndex before taking the memory.

Two knobs worth knowing. quant_drop_ratio trims a fraction from each tail of every dimension before the range is fixed, so a handful of outliers can't stretch the range and waste code levels. quant_sample_rows caps how many rows the calibration looks at.

PQ and OPQ

ExhaustivePqIndex, IvfPqIndex, ExhaustiveOpqIndex, IvfOpqIndex, SoarPqIndex, SoarOpqIndex.

Each vector is cut into m subvectors, and each subvector is replaced by the id of its nearest sub-codebook centroid. A vector costs m bytes. At dim=512 and m=64 that's 64 bytes against 2 KB, and the recall reflects it.

This is a method for high-dimensional embedding spaces: the whole point is exploiting correlation between coordinates, and a PCA output has already spent most of that. The benchmark tables don't settle where the crossover with ExhaustiveSq8Index sits, since the PQ family is only run at 256 dimensions and up and SQ8 only at 32 and 128. Measure it if you are near the boundary.

m has to divide dim, and dim has to be at least 32. n_pq_centroids defaults to 256, which is what makes a code fit in a byte; it cannot exceed 256 and cannot exceed your sample count, so a small dataset needs it lowered.

OPQ adds a learned orthogonal rotation before the split. Plain PQ splits on whatever axis order the data arrived in, so a space with variance concentrated in a few coordinates gets subspaces of wildly unequal difficulty. The rotation spreads it, and it buys recall everywhere it was measured: +0.01 to +0.19 over plain PQ depending on the generator.

It isn't free at either end. Build costs 3 to 5x (opq_iters is the knob), and the rotation has to be applied to every query, which runs from 1.1x the query time at 256 dimensions to 3.1x at 768. It also stores a dim x dim matrix, so the index is larger than the plain PQ equivalent.

The IVF variants learn the codes on the residual from the cell centroid rather than on the vector, so the sub-codebooks only have to cover within-cell spread. That's why IvfPqIndex reaches better recall than ExhaustivePqIndex at the same m, on top of scanning fewer candidates. It holds on every generator in the tables, and costs about 2.2x the build time.

The SOAR variants spill every vector into a second cell chosen so its residual points somewhere the first one doesn't, fixing IVF's main failure of a true neighbour sitting just over a cell boundary. Twice the posting-list entries, which is exactly what quantisation makes affordable. Read that trade against query time, not against nprobe: at a fixed nprobe a spilled index scans about twice the candidates, so comparing at equal nprobe flatters it and answers nothing.

Unlike the unquantised SoarIndex, here the doubling really does show up in the index size, because the codes are the storage: SoarPqIndex runs about 2x an IvfPqIndex at the same m. SoarOpqIndex is the largest index of the PQ family, not the smallest, since it pays for the spilling and the rotation matrix both. An IvfOpqIndex at twice the m is smaller and quicker to build at matching memory.

RaBitQ graphs

HnswRaBitQIndex, QgIndex.

Two graph indices on RaBitQ codes, pulling in opposite directions.

HnswRaBitQIndex links an ordinary HNSW on exact distances, then encodes every vector and drops it. What stays resident is the topology plus one code of ex_bits + 1 bits per coordinate, and queries answer from the codes alone. On the benchmark runs at 50k by 256 that is 11 to 28 MB against 49 MB of raw data.

ex_bits is the dial, and since quantisation happens after linking it moves accuracy and size without touching the graph. On the correlated generator at 256 dimensions and m=16 recall tops out near 0.78 at ex_bits=1, 0.93 at 3, 0.98 at 5 (the default) and 0.996 at 8. Below 5 raising ef_search stops helping early: the ceiling is the codec, not the beam. Distances are estimates, so re-rank against your own vectors if you need exact ones.

QgIndex is the SymphonyQG-style quantised graph. A Vamana graph where every vertex also stores its neighbours' one-bit codes in the fast-scan layout, so one hop estimates all of them in a single SIMD sweep. The walk computes the exact distance for every vertex it pops, so the returned distances are exact and there is no re-ranking stage.

It is not a memory saving. Each code is stored once per in-edge and the float vectors stay resident: 2.1 to 2.4x the raw data at the default degree=32, 3.3 to 3.8x at 64. What it buys is query speed at high recall. degree must be a multiple of 32; 32 is one sweep per hop.

Picking one

import ann_search as ann

index = ann.HnswSq8uIndex(n_neighbors=15, metric="cosine").fit(X)
distances, indices = index.kneighbors()

Then measure. The recipe in Quickstart works unchanged: build an ExhaustiveIndex for ground truth, and compare recall rather than distances.

The crate's benchmark tables sweep every codec over the synthetic generators with build time, query time, recall and index size. The PQ-family runs are at higher dimensionality with fewer samples, since that's the regime those methods are for. Don't extrapolate parameters for your own problem from them.

The RaBitQ graphs have their own tables, at 50k samples and 256 to 768 dimensions.