Skip to content

Quantised indices

The thirteen estimators over compressed vectors. See Quantised for what each codec does and when to reach for it.

Two things differ from the uncompressed estimators: the distances are the codec's estimate rather than the distance (bar QgIndex, which keeps the vectors), and none of them support Manhattan.

quantised

Quantised estimators: the same indices, over compressed vectors.

These trade recall for memory. Storage drops from dim floats per vector to somewhere between two bytes per dimension (BF16) and a handful of bytes per vector (PQ), and in several cases the query gets faster too, because integer kernels beat float ones.

Two things differ from the uncompressed estimators, both consequences of the codec rather than choices:

  • Distances are estimates. A quantised index reports the codec's estimate of a distance, not the distance. It is close enough to rank on, which is what an index is for, but don't feed it anywhere the absolute value matters without checking it against ExhaustiveIndex first. QgIndex is the one exception: it keeps the float vectors and returns exact distances.
  • Manhattan is unavailable on all thirteen. Every codec here is built on inner products, which is what makes the integer arithmetic work.

Everything else is identical: same four-method surface, same padding, same persistence, float32 and float64 both supported.

Which one to reach for, roughly. HnswSq8uIndex if you want HnswIndex at a quarter of the vector memory, and can afford the codec's recall cost. IvfSq8Index if you were already on IvfIndex. The PQ family or HnswRaBitQIndex when a quarter is not enough of a saving, which means a high-dimensional embedding space. QgIndex is not a memory saving at all; it spends RaBitQ codes on query speed.

Measure the recall before committing to any of them. On the benchmark runs the codec cost is much larger than these classes used to admit, and it is worst on cosine. See the benchmark tables for quantised and RaBitQ numbers.

ExhaustiveBf16Index

ExhaustiveBf16Index(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

Brute force over bf16 storage.

bf16 keeps float32's exponent range and throws away mantissa bits from roughly the third significant digit on, so it halves the memory without the overflow traps of float16. The scan is still exhaustive, so the only thing between this and ExhaustiveIndex is the codec's rounding. The cheapest quantisation to reason about, and the one with the least to go wrong.

Halving the memory is not free. On the benchmark runs the query comes out around twice as slow at 32 dimensions and four times as slow at 128, since the values are widened back to float32 to compute on. Recall lands near 0.98 on Euclidean and near 0.89 on cosine.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
verbose bool

Progress to the process stdout, not sys.stdout. In Jupyter that lands in the terminal running the kernel.

False

IvfBf16Index

IvfBf16Index(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    nlist: int | None = None,
    nprobe: int | None = None,
    kmeans_iters: int | None = None,
    kmeans_balanced: bool = False,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

IvfIndex with the posting lists held at bf16.

Half the vector memory. The recall cost is the codec's, and it does not hide inside IVF's own approximation the way you might expect: on the benchmark runs plain IVF already sits at recall 1.0 on the correlated and low-rank generators at every nprobe, so the codec error is the whole error. At a fixed nprobe the drop is around 0.05 on Euclidean and up to 0.11 on cosine, and the query gets slightly slower rather than faster.

Still the smallest change to reason about if you are already on IvfIndex and memory is binding. Measure the recall first.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
nlist int | None

Voronoi cells to cut the space into. None defaults to sqrt(n).

None
nprobe int | None

Cells visited per query, the recall knob. None defaults to sqrt(nlist). Search-time: override it per call as index.kneighbors(nprobe=32).

None
kmeans_iters int | None

Lloyd iterations when training the cells. None defaults to 30.

None
kmeans_balanced bool

Reseed starved centroids each iteration, evening out the posting lists.

False
seed int

Fixes k-means initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

ExhaustiveSq8Index

ExhaustiveSq8Index(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    quant_drop_ratio: float | None = None,
    quant_sample_rows: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

Brute force over 8-bit codes.

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

The memory saving is real, around 3.5x at 32 dimensions and 3.9x at 128. The speed is not: on the benchmark runs the exhaustive integer scan is slower than the float one at 32 dimensions, by up to 1.5x, and only edges ahead at 128. IvfSq8Index is where the integer kernels do pay.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
quant_drop_ratio float | None

Fraction trimmed from each tail of every dimension before the range is fixed; values outside it clamp to the end codes. None uses the crate default. Raise it when a few outliers are stretching the range and wasting code levels.

None
quant_sample_rows int | None

Rows sampled to calibrate the range. None auto-picks, capped at the dataset size.

None
seed int

Fixes the calibration row sample.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

IvfSq8Index

IvfSq8Index(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    nlist: int | None = None,
    nprobe: int | None = None,
    kmeans_iters: int | None = None,
    kmeans_balanced: bool = False,
    quant_drop_ratio: float | None = None,
    quant_sample_rows: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

IvfIndex with the posting lists held as 8-bit codes.

Around 2.9x smaller than IvfIndex at 32 dimensions, and the one place the integer kernels reliably pay: on the benchmark runs every matched nlist/nprobe pairing queries 1.15 to 1.5x faster than plain IVF.

The recall cost is steep and worth measuring before you commit. At a fixed nprobe the drop against plain IVF runs to 0.21 on the low-rank generator and 0.26 on cosine.

Still the natural step if you were already on IvfIndex and want the saving without thinking about subspaces.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
nlist int | None

Voronoi cells to cut the space into. None defaults to sqrt(n).

None
nprobe int | None

Cells visited per query, the recall knob. None defaults to sqrt(nlist). Search-time.

None
kmeans_iters int | None

Lloyd iterations when training the cells. None defaults to 30.

None
kmeans_balanced bool

Reseed starved centroids each iteration.

False
quant_drop_ratio float | None

Fraction trimmed from each tail of every dimension before the range is fixed. None uses the crate default.

None
quant_sample_rows int | None

Rows sampled to calibrate the range. None auto-picks.

None
seed int

Fixes k-means initialisation and the calibration sample.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

HnswSq8uIndex

HnswSq8uIndex(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    m: int = 16,
    ef_construction: int = 200,
    ef_search: int = 100,
    quant_drop_ratio: float | None = None,
    quant_sample_rows: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

HnswIndex built and searched entirely on 8-bit codes.

Inspired by pyglass. The graph is constructed in the space it is searched in, so there is no float copy hanging around for re-ranking and no mismatch between the edges and the distances that traverse them. Roughly a quarter of HNSW's vector memory, and the same recall knob. The graph edges are not compressed, so the whole index lands at 0.44 to 0.80 of a plain HnswIndex depending on m and dimensionality, not at a quarter.

Build and query are both genuinely faster than HnswIndex. Recall is not: on the benchmark runs at matched m, ef_construction and ef_search it gives up 0.07 on Euclidean and 0.26 to 0.33 on cosine. The loss is the codec rather than the graph, so ExhaustiveSq8Index is the ceiling it is working against. Measure it against HnswIndex on your own data before taking the memory.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
m int

Edges per node on the upper layers; the base layer gets 2 * m. Raising it gives a better graph, a larger index and a slower build.

16
ef_construction int

Candidate list size during insertion. Larger means a better graph and a slower build, and it costs nothing at query time.

200
ef_search int

Beam width at query time, the recall knob. Raised to k internally when it is smaller. Search-time: override it per call as index.kneighbors(ef_search=200).

100
quant_drop_ratio float | None

Fraction trimmed from each tail of every dimension before the range is fixed. None uses the crate default.

None
quant_sample_rows int | None

Rows sampled to calibrate the range. None auto-picks.

None
seed int

Fixes the level assignment and the calibration sample.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

HnswRaBitQIndex

HnswRaBitQIndex(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    m: int = 16,
    ef_construction: int = 200,
    ex_bits: int = 5,
    nlist: int | None = None,
    ef_search: int = 100,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

HNSW over RaBitQ+ codes, with the float vectors thrown away.

The graph is linked on exact distances, then every vector is encoded and dropped. What stays resident is the topology plus one code of ex_bits + 1 bits per coordinate, and every query answers from those codes alone. That makes this the low-memory graph index: on the benchmark runs at 50k by 256 it holds 11 to 28 MB against 49 MB of raw data.

ex_bits is the dial. It moves accuracy and size together without touching the graph, since quantisation happens after linking. On the correlated benchmark at m=16 recall tops out near 0.78 at ex_bits=1, 0.93 at 3, 0.98 at 5 and 0.996 at 8, where raising ef_search stops helping. Below 5 the ceiling is the codec, not the beam. Returned distances are estimates; re-rank against your own vectors if you need exact ones.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
m int

Edges per node on the upper layers; the base layer gets 2 * m.

16
ef_construction int

Candidate list size during insertion. Costs nothing at query time.

200
ex_bits int

Magnitude bits per coordinate on top of the sign bit, 0 to 8. 0 is plain one-bit RaBitQ.

5
nlist int | None

Centroids the codes are taken against. None defaults to sqrt(n).

None
ef_search int

Beam width at query time, the recall knob. Search-time: override it per call as index.kneighbors(ef_search=200).

100
seed int

Fixes the level assignment, the rotation and the k-means.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

QgIndex

QgIndex(
    n_neighbors: int = 15,
    metric: str = "euclidean",
    degree: int = 32,
    l_build: int = 64,
    alpha_pass1: float = 1.0,
    alpha_pass2: float = 1.2,
    ef_search: int = 100,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

Quantised graph (SymphonyQG-style): a Vamana graph with RaBitQ neighbour blocks.

Every vertex stores its own neighbours' one-bit RaBitQ codes in the fast-scan layout, so one hop estimates all degree neighbours in a single SIMD sweep instead of one random read per neighbour. The exact distance is computed only for the vertices the walk pops, so the returned distances are exact and there is no separate re-ranking stage.

This one is not about memory. Each code is stored once per in-edge and the float vectors stay resident, so the index comes out at two to three times the raw data. The point is query speed at a given recall. For a small graph index use HnswRaBitQIndex.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
degree int

Neighbour slots per vertex, a non-zero multiple of 32. 32 is exactly one sweep per hop; doubling it doubles both the code memory and the per-hop work.

32
l_build int

Beam width for the second Vamana pass, where the build time goes. The first pass runs at the crate's narrower default.

64
alpha_pass1 float

Relaxed-neighbour factor on the first pruning pass.

1.0
alpha_pass2 float

Same on the second pass. Above 1 keeps longer edges.

1.2
ef_search int

Beam width at query time, the recall knob. Search-time: override it per call as index.kneighbors(ef_search=200).

100
seed int

Fixes the graph and the rotation.

42
verbose bool

Progress to the process stdout on query, not sys.stdout. The build is silent.

False

ExhaustivePqIndex

ExhaustivePqIndex(
    n_neighbors: int = 15,
    m: int = 8,
    metric: str = "euclidean",
    max_iters: int | None = None,
    n_pq_centroids: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

Brute force over product-quantised codes.

Each vector is cut into m subvectors and each subvector is replaced by the id of its nearest sub-codebook centroid, so a vector costs m bytes rather than dim floats. A query builds one lookup table per subspace and then scores each point by summing m table reads.

The compression is the whole point and it is aggressive: at dim=512 and m=64 that is 64 bytes against 2 KB. Expect the recall to reflect that. This is a method for high-dimensional embedding spaces, not for a 30-dimensional PCA where ExhaustiveSq8Index will do better on both counts.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
m int

Subspaces. dim must divide by it, and it sets the code length in bytes.

8
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
max_iters int | None

Lloyd iterations when training the sub-codebooks. None uses the crate default.

None
n_pq_centroids int | None

Centroids per subspace. None uses 256, which is what makes a code fit in a byte. Cannot exceed the sample count, so a small dataset needs this lowered.

None
seed int

Fixes the codebook initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

ExhaustiveOpqIndex

ExhaustiveOpqIndex(
    n_neighbors: int = 15,
    m: int = 8,
    metric: str = "euclidean",
    max_iters: int | None = None,
    n_pq_centroids: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

ExhaustivePqIndex with a learned rotation in front of it.

Plain PQ splits on the original axis order, so a space where the variance is concentrated in a few coordinates gets subspaces of wildly unequal difficulty. OPQ learns an orthogonal rotation that spreads it before splitting. It costs 3 to 5x the build time, and the query is not free either: the rotation has to be applied to every query, which runs from 1.1x at 256 dimensions to 3.1x at 768. It also stores the rotation matrix, so the index is larger than the equivalent ExhaustivePqIndex.

On the benchmark runs it beats plain PQ on recall everywhere it was measured, by +0.01 to +0.19 depending on the generator.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
m int

Subspaces. dim must divide by it.

8
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
max_iters int | None

Alternating rotation/codebook iterations. None uses the crate default. This is the build-cost knob.

None
n_pq_centroids int | None

Centroids per subspace. None uses 256.

None
seed int

Fixes the codebook initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

IvfPqIndex

IvfPqIndex(
    n_neighbors: int = 15,
    m: int = 8,
    metric: str = "euclidean",
    nlist: int | None = None,
    nprobe: int | None = None,
    kmeans_iters: int | None = None,
    kmeans_balanced: bool = False,
    n_pq_centroids: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

Inverted file plus product quantisation.

The two compress different things and stack cleanly: the inverted file cuts how many vectors get scored, PQ cuts what each one costs. Codes are learned on the residual from the cell centroid rather than the vector itself, so the sub-codebooks only have to cover within-cell spread, which is why this reaches better recall than ExhaustivePqIndex at the same m.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
m int

Subspaces. dim must divide by it.

8
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
nlist int | None

Voronoi cells to cut the space into. None defaults to sqrt(n).

None
nprobe int | None

Cells visited per query, the recall knob. None defaults to sqrt(nlist). Search-time.

None
kmeans_iters int | None

Lloyd iterations when training the cells. None defaults to 30.

None
kmeans_balanced bool

Reseed starved centroids each iteration.

False
n_pq_centroids int | None

Centroids per subspace. None uses 256.

None
seed int

Fixes both the cell and the codebook initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

IvfOpqIndex

IvfOpqIndex(
    n_neighbors: int = 15,
    m: int = 8,
    metric: str = "euclidean",
    nlist: int | None = None,
    nprobe: int | None = None,
    kmeans_iters: int | None = None,
    kmeans_balanced: bool = False,
    n_pq_centroids: int | None = None,
    opq_iters: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

IvfPqIndex with the learned rotation in front of the sub-codebooks.

Same trade as ExhaustiveOpqIndex against ExhaustivePqIndex: better codes for a slower build, free at query time.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
m int

Subspaces. dim must divide by it.

8
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
nlist int | None

Voronoi cells to cut the space into. None defaults to sqrt(n).

None
nprobe int | None

Cells visited per query, the recall knob. None defaults to sqrt(nlist). Search-time.

None
kmeans_iters int | None

Lloyd iterations when training the cells. None defaults to 30.

None
kmeans_balanced bool

Reseed starved centroids each iteration.

False
n_pq_centroids int | None

Centroids per subspace. None uses 256.

None
opq_iters int | None

Alternating rotation/codebook iterations. None uses the crate default. This is the build-cost knob.

None
seed int

Fixes both the cell and the codebook initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

SoarPqIndex

SoarPqIndex(
    n_neighbors: int = 15,
    m: int = 8,
    metric: str = "euclidean",
    nlist: int | None = None,
    nprobe: int | None = None,
    rule: str | None = None,
    rule_param: float | None = None,
    kmeans_iters: int | None = None,
    kmeans_balanced: bool = False,
    n_pq_centroids: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

IvfPqIndex with SOAR spilling.

Every vector also lands in a second cell, chosen so its residual points somewhere the first one doesn't. That fixes IVF's main failure, a true neighbour sitting just over a cell boundary, at the cost of roughly twice the posting-list size, which is exactly what quantisation makes affordable.

Read the 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.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
m int

Subspaces. dim must divide by it.

8
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
nlist int | None

Voronoi cells to cut the space into. None defaults to sqrt(n).

None
nprobe int | None

Cells visited per query, the recall knob. None defaults to sqrt(nlist). Search-time.

None
rule str | None

Spilling rule: "nearest", "shifted" or "orthogonal". None lets the crate pick per metric, orthogonal for cosine and shifted otherwise.

None
rule_param float | None

mu for "shifted", lambda for "orthogonal". None uses the crate's value. Ignored by "nearest".

None
kmeans_iters int | None

Lloyd iterations when training the cells. None defaults to 30.

None
kmeans_balanced bool

Reseed starved centroids each iteration.

False
n_pq_centroids int | None

Centroids per subspace. None uses 256.

None
seed int

Fixes both the cell and the codebook initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False

SoarOpqIndex

SoarOpqIndex(
    n_neighbors: int = 15,
    m: int = 8,
    metric: str = "euclidean",
    nlist: int | None = None,
    nprobe: int | None = None,
    rule: str | None = None,
    rule_param: float | None = None,
    kmeans_iters: int | None = None,
    kmeans_balanced: bool = False,
    n_pq_centroids: int | None = None,
    opq_iters: int | None = None,
    seed: int = 42,
    verbose: bool = False,
)

Bases: _BaseQuantisedIndex

SoarPqIndex with the learned rotation in front of the sub-codebooks.

The rotation on top of the spilling, so it carries both costs: the extra posting-list entries and the dim x dim rotation matrix. On the benchmark runs it is the largest of the quantised indices, not the smallest, and an IvfOpqIndex at twice the m is both smaller and quicker to build at matching memory. Reach for it when you want SOAR's recall per cell and the rotation, not to save space.

Parameters:

Name Type Description Default
n_neighbors int

Neighbours per query, and the default k for kneighbors.

15
m int

Subspaces. dim must divide by it.

8
metric str

"euclidean"/"l2", "sqeuclidean" or "cosine".

'euclidean'
nlist int | None

Voronoi cells to cut the space into. None defaults to sqrt(n).

None
nprobe int | None

Cells visited per query, the recall knob. None defaults to sqrt(nlist). Search-time.

None
rule str | None

Spilling rule: "nearest", "shifted" or "orthogonal". None lets the crate pick per metric.

None
rule_param float | None

mu for "shifted", lambda for "orthogonal". None uses the crate's value.

None
kmeans_iters int | None

Lloyd iterations when training the cells. None defaults to 30.

None
kmeans_balanced bool

Reseed starved centroids each iteration.

False
n_pq_centroids int | None

Centroids per subspace. None uses 256.

None
opq_iters int | None

Alternating rotation/codebook iterations. None uses the crate default. This is the build-cost knob.

None
seed int

Fixes both the cell and the codebook initialisation.

42
verbose bool

Progress to the process stdout, not sys.stdout.

False