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
ExhaustiveIndexfirst.QgIndexis 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
¶
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 |
15
|
metric
|
str
|
|
'euclidean'
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
metric
|
str
|
|
'euclidean'
|
nlist
|
int | None
|
Voronoi cells to cut the space into. |
None
|
nprobe
|
int | None
|
Cells visited per query, the recall knob. |
None
|
kmeans_iters
|
int | None
|
Lloyd iterations when training the cells. |
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 |
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 |
15
|
metric
|
str
|
|
'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
|
quant_sample_rows
|
int | None
|
Rows sampled to calibrate the range. |
None
|
seed
|
int
|
Fixes the calibration row sample. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
metric
|
str
|
|
'euclidean'
|
nlist
|
int | None
|
Voronoi cells to cut the space into. |
None
|
nprobe
|
int | None
|
Cells visited per query, the recall knob. |
None
|
kmeans_iters
|
int | None
|
Lloyd iterations when training the cells. |
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
|
quant_sample_rows
|
int | None
|
Rows sampled to calibrate the range. |
None
|
seed
|
int
|
Fixes k-means initialisation and the calibration sample. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
metric
|
str
|
|
'euclidean'
|
m
|
int
|
Edges per node on the upper layers; the base layer gets |
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 |
100
|
quant_drop_ratio
|
float | None
|
Fraction trimmed from each tail of every dimension
before the range is fixed. |
None
|
quant_sample_rows
|
int | None
|
Rows sampled to calibrate the range. |
None
|
seed
|
int
|
Fixes the level assignment and the calibration sample. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
metric
|
str
|
|
'euclidean'
|
m
|
int
|
Edges per node on the upper layers; the base layer gets |
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, |
5
|
nlist
|
int | None
|
Centroids the codes are taken against. |
None
|
ef_search
|
int
|
Beam width at query time, the recall knob. Search-time:
override it per call as |
100
|
seed
|
int
|
Fixes the level assignment, the rotation and the k-means. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
metric
|
str
|
|
'euclidean'
|
degree
|
int
|
Neighbour slots per vertex, a non-zero multiple of 32. |
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 |
100
|
seed
|
int
|
Fixes the graph and the rotation. |
42
|
verbose
|
bool
|
Progress to the process stdout on query, not |
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 |
15
|
m
|
int
|
Subspaces. |
8
|
metric
|
str
|
|
'euclidean'
|
max_iters
|
int | None
|
Lloyd iterations when training the sub-codebooks. |
None
|
n_pq_centroids
|
int | None
|
Centroids per subspace. |
None
|
seed
|
int
|
Fixes the codebook initialisation. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
m
|
int
|
Subspaces. |
8
|
metric
|
str
|
|
'euclidean'
|
max_iters
|
int | None
|
Alternating rotation/codebook iterations. |
None
|
n_pq_centroids
|
int | None
|
Centroids per subspace. |
None
|
seed
|
int
|
Fixes the codebook initialisation. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
m
|
int
|
Subspaces. |
8
|
metric
|
str
|
|
'euclidean'
|
nlist
|
int | None
|
Voronoi cells to cut the space into. |
None
|
nprobe
|
int | None
|
Cells visited per query, the recall knob. |
None
|
kmeans_iters
|
int | None
|
Lloyd iterations when training the cells. |
None
|
kmeans_balanced
|
bool
|
Reseed starved centroids each iteration. |
False
|
n_pq_centroids
|
int | None
|
Centroids per subspace. |
None
|
seed
|
int
|
Fixes both the cell and the codebook initialisation. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
m
|
int
|
Subspaces. |
8
|
metric
|
str
|
|
'euclidean'
|
nlist
|
int | None
|
Voronoi cells to cut the space into. |
None
|
nprobe
|
int | None
|
Cells visited per query, the recall knob. |
None
|
kmeans_iters
|
int | None
|
Lloyd iterations when training the cells. |
None
|
kmeans_balanced
|
bool
|
Reseed starved centroids each iteration. |
False
|
n_pq_centroids
|
int | None
|
Centroids per subspace. |
None
|
opq_iters
|
int | None
|
Alternating rotation/codebook iterations. |
None
|
seed
|
int
|
Fixes both the cell and the codebook initialisation. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
m
|
int
|
Subspaces. |
8
|
metric
|
str
|
|
'euclidean'
|
nlist
|
int | None
|
Voronoi cells to cut the space into. |
None
|
nprobe
|
int | None
|
Cells visited per query, the recall knob. |
None
|
rule
|
str | None
|
Spilling rule: |
None
|
rule_param
|
float | None
|
|
None
|
kmeans_iters
|
int | None
|
Lloyd iterations when training the cells. |
None
|
kmeans_balanced
|
bool
|
Reseed starved centroids each iteration. |
False
|
n_pq_centroids
|
int | None
|
Centroids per subspace. |
None
|
seed
|
int
|
Fixes both the cell and the codebook initialisation. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
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 |
15
|
m
|
int
|
Subspaces. |
8
|
metric
|
str
|
|
'euclidean'
|
nlist
|
int | None
|
Voronoi cells to cut the space into. |
None
|
nprobe
|
int | None
|
Cells visited per query, the recall knob. |
None
|
rule
|
str | None
|
Spilling rule: |
None
|
rule_param
|
float | None
|
|
None
|
kmeans_iters
|
int | None
|
Lloyd iterations when training the cells. |
None
|
kmeans_balanced
|
bool
|
Reseed starved centroids each iteration. |
False
|
n_pq_centroids
|
int | None
|
Centroids per subspace. |
None
|
opq_iters
|
int | None
|
Alternating rotation/codebook iterations. |
None
|
seed
|
int
|
Fixes both the cell and the codebook initialisation. |
42
|
verbose
|
bool
|
Progress to the process stdout, not |
False
|