IVF RaBitQ¶
IVF is a classical
clustering-based ANN method. This implementation stores RaBitQ codes and
vector IDs, optionally retaining raw vectors for reranking, and uses
FastScan to estimate a
batch of distances. Its actual memory, latency, and recall depend on the bit
width, number of clusters, and nprobe.
The algorithm includes two phases: indexing and querying.
Index Construction¶
Use QGKMeans to partition the input vectors (.fvecs).
Build the C++ examples, then run bin/qgkmeans to save centroids and cluster IDs.
For small cluster counts, use bin/rabitqkmeans for
RaBitQKMeans flat assignment. Both use exact final
assignment. The optional metric defaults to l2; append ip for normalized input.
For example, split SIFT into 4,096 clusters using squared L2 distance:
./bin/qgkmeans /data/sift/sift_base.fvecs 4096 \
/data/sift/sift_centroids_4096_l2.fvecs \
/data/sift/sift_clusterids_4096_l2.ivecs
After files are prepared, you need to load them into memory:
using data_type = rabitqlib::RowMajorArray<float>;
using gt_type = rabitqlib::RowMajorArray<uint32_t>;
data_type data;
data_type centroids;
gt_type cids;
rabitqlib::load_vecs<float, data_type>(data_file, data);
rabitqlib::load_vecs<float, data_type>(centroids_file, centroids);
rabitqlib::load_vecs<PID, gt_type>(cids_file, cids);
Then initialize an IVF object with the number of data points, vector
dimension, number of clusters, and total bits per dimension. The C++ index
accepts total quantized bit widths from 1 through 9, or total_bits=32
(Python nbits=32) to store raw float32 vectors for reranking. Raw storage
and automatic FastScan precision selection are available starting with version 0.3.3.
using index_type = rabitqlib::ivf::IVF;
size_t num_points = data.rows();
size_t dim = data.cols();
size_t k = centroids.rows();
index_type ivf(num_points, dim, k, total_bits);
Centroid routing¶
Configurable centroid routing requires 0.6.0 or newer.
The optional final C++ constructor argument selects InitializerType::Auto,
Flat, FlatRaBitQ, or HNSW from the rabitqlib::ivf namespace. Python accepts
initializer="auto", "flat", "flat_rabitq", or "hnsw":
index = IvfIndex(dim, num_points, num_clusters, nbits=4,
initializer="flat_rabitq")
print(index.initializer) # resolved routing type
index_type ivf(num_points, dim, k, total_bits, rabitqlib::METRIC_L2,
rabitqlib::RotatorType::FhtKacRotator,
rabitqlib::ivf::InitializerType::FlatRaBitQ);
auto routing = ivf.initializer_type();
autoselectsflatbelow 5,000 clusters,flat_rabitqfrom 5,000 through 59,999 clusters, andhnswfrom 60,000 clusters onward. These defaults are practical thresholds; explicitly select a different initializer when it better suits your workload.flatcomputes exact distances to every centroid.flat_rabitqscans one-bit centroid codes with FastScan, prunes candidates using twice the standard RaBitQ error margin, and recomputes exact distances for the retained candidates. Returned distances are exact for those centroids, but routing is approximate: pruning can miss a true nearest centroid.hnswsearches a graph of float32 centroids approximately.
All choices support L2 and inner product. Flat RaBitQ uses the IVF rotation and
padded domain, retains float32 centroids for refinement, and stores its mean and
codes in the main index file. Its code precision is independent of the index's
nbits. It trades additional code storage and an O(cluster count) query work
buffer for fewer full-vector distance evaluations. Performance depends on
cluster count, dimension, nprobe, and data distribution. Measure recall and
latency on your workload, and use an explicit initializer to override auto.
Existing constructor calls remain source-compatible. C++ consumers must rebuild
against matching headers and the core library because the IVF object layout has
changed. Newly built indexes with 5,000–59,999 clusters now use Flat RaBitQ routing. In particular, the 5,000–19,999 range changes
from exact to approximate centroid selection. To reproduce the previous selection
policy when rebuilding, explicitly choose flat below 20,000 clusters and hnsw
otherwise. Loading an old index preserves its historical routing instead.
Routing is fixed for an index and is also used by add when cluster IDs are
omitted. initializer_type() (Python initializer) reports the resolved type,
including after loading; loading never re-runs the current auto policy for a
new-format file.
Finally, call the construct API:
void IVF::construct(
const float* data,
const float* centroids,
const PID* cluster_ids,
bool faster = false,
size_t num_threads = std::numeric_limits<size_t>::max()
);
- data: Pointer to the raw data vectors.
- centroids: Clustering centroids; tune
cluster_numfor the dataset and search budget. - cluster_ids: Array of length
data_num; every entry must be in the range[0, cluster_num). - faster: If true, enable fast implementations for RaBitQ (By default, it is set as
falseto pursue better accuracy.). - num_threads: Maximum number of OpenMP threads used to quantize clusters.
For example:
During construction, we process clusters in parallel, rotate their centroids and
vectors, and compute one-bit codes and factors. Quantized mode also computes
total_bits - 1 extra-bit codes and factors; raw mode stores the original
float32 vectors instead. In raw mode, the index owns a copy of the input, so
the original data can be released after construction.
Construction reuses a rotation buffer of at most 32 vectors per worker. This
temporary storage uses at most workers * 32 * padded_dim * sizeof(float) bytes,
independently of cluster sizes.
New indexes pad the rotated dimension to a multiple of 32 for every supported bit width, including raw mode (32). This applies to both rotator types. Extra-bit layouts retain full 64-coordinate blocks and store any final 32-coordinate tail compactly.
After construction, you can directly save the index file to disk:
New files use theRABQIDX1 magic, version 2, storage flags, an explicit
padded dimension, and a uint32 initializer type (1 = Flat, 2 = Flat RaBitQ,
3 = HNSW) before the index metadata. Auto is resolved before saving. Loading
restores the routing type, storage mode, rotation state, and padded dimension.
Raw files also contain the original vectors, so querying does not require an external dataset.
Version-1 RABQIDX1 files remain readable with their stored padding. Legacy
unversioned quantized files and raw v1 files retain their original 64-dimension
padding. All three historical formats infer routing with the original 20,000
cluster threshold. HNSW routing still requires the accompanying .hnsw file.
Flat RaBitQ loads its saved codes without random re-quantization. Saving a loaded
legacy index writes the new header without changing its padded dimension or encoded data. Older library
versions cannot read the new format.
Data Layout¶
The main data layout for our IVF is organized as follows:
[batch data] // 1-bit code and factors
[ex_data] // code for remaining bits, or original float32 vectors
[ids] // PID of vectors (organized by clusters)
[cluster_lst] // List of clusters' metadata in IVF
ex_data holds 4 * num_points * dim bytes in cluster order,
without padding or extra-bit factors. The one-bit filtering codes and factors
are additional storage. nbits() (Python nbits) reports 32 in raw mode;
the one-bit filtering code is not included in that value.
Querying¶
Currently, querying requires the index to be loaded in memory. If you want to use a previously saved index on the disk, firstly load it into memory:
Once the index is loaded, you can call the search function for queries:void IVF::search(
const float* __restrict__ query,
size_t k,
size_t nprobe,
PID* __restrict__ results,
float* dists = nullptr
) const;
- query: Query vector.
- k: Top-k.
- nprobe: The number of closest clusters to search.
- results: Result buffer, size of k.
- dists: Optional distance buffer, size of
k.
FastScan precision is selected automatically: HACC for 4–9 quantized bits, and
standard FastScan for 1–3 bits or raw vectors (32). HACC reduces lookup-table
error carried into extra-bit distance estimation; raw reranking computes its
final distances directly from floats. Existing C++ overloads with use_hacc
and Python's high_accuracy=True/False still allow explicit overrides. Python
uses automatic selection when high_accuracy is omitted or None.
ivf.search(query, k, nprobe, ids, distances); // Automatic
ivf.search(query, k, nprobe, ids, distances, true); // Force HACC
ivf.search(query, k, nprobe, ids, distances, false); // Force standard FastScan
The IDs-only overloads accept the same override without the distances
argument. In Python, call index.search(queries, k, nprobe) for automatic
selection, or pass high_accuracy=True / False to force either mode.
The querying examples also default to automatic selection; Python's
--use-hacc flag forces HACC, and the C++ example accepts an optional final
true or false argument.
During search, we rotate the query and select the nprobe closest centroids.
FastScan filters candidates using one-bit codes, then reranking uses either the
remaining quantized bits or the stored raw vectors. Raw reranking computes
squared L2 or 1 - dot(query, vector) in the original coordinates; the search
API is unchanged. Cluster selection and filtering remain approximate in both
modes. Search returns the top k results after scanning the selected clusters.
Starting with 0.5.2, C++ search_batch distributes multiple queries across workers.
Input queries are contiguous row-major float32
vectors with shape (num_queries, dimension()); output buffers have shape
(num_queries, k).
ivf.search_batch(queries, num_queries, k, nprobe, ids, distances,
std::nullopt, 4); // Automatic precision, up to four workers
The last two arguments are std::optional<bool> use_hacc (default
std::nullopt) and size_t num_threads (default 1, using OpenMP for parallel
batches). Pass true or false
for an explicit precision override. Distances may be nullptr. An empty batch
does no work. Input and output buffers must not overlap. Simultaneous searches
need separate output buffers, and must not overlap index updates.
Python's existing index.search(queries, k, nprobe, num_threads=4) uses this
batch path with the same output and precision rules.
Python search releases the GIL. Concurrent searches may use independent
nprobe, high_accuracy, and num_threads values. Conflicting updates raise
RuntimeError immediately; see the
Python concurrency contract.
Updating an Index¶
Starting with 0.5.1, C++ IVF and Python IvfIndex support add() and remove()
on a constructed or loaded index without the original dataset. HNSW provides
its own update APIs starting with 0.5.2.
IVF updates preserve the index file format.
void IVF::add(
const float* data,
size_t n,
const PID* cluster_ids = nullptr,
bool faster = false,
size_t num_threads = std::numeric_limits<size_t>::max()
);
size_t IVF::remove(const PID* ids_to_remove, size_t n);
- data, n:
nnew vectors, quantized likeconstructdoes. Vectorireceives the PIDmax_elements() + i, using the count before the call. Existing IDs stay unchanged, and removed IDs are never reused. - cluster_ids: The cluster of each new vector, in
[0, cluster_num). When it isnullptr, vectors use the same centroid routing as queries. Routing uses the initializer selected at construction or restored from the index file. - faster, num_threads: Same as in
construct. - ids_to_remove: PIDs to remove. Every PID must be below
max_elements(); nothing is removed if one is not.removereturns how many were newly removed and can be repeated safely.
In Python:
new_ids = index.add(vectors, num_threads=1, fast_quantization=False)
# To choose clusters explicitly, use this instead of the call above:
# new_ids = index.add(vectors, cluster_ids=labels)
removed = index.remove(new_ids[:10])
Python vectors has shape (n, index.dim) and is converted to contiguous float32.
add() returns a uint32 array of the new IDs. Optional cluster_ids must be a
one-dimensional integer array or sequence with one valid cluster ID per vector;
remove() likewise accepts a one-dimensional integer array or sequence of point
IDs and returns the number newly removed. Duplicate IDs count once. Python
num_threads defaults to 1; fast_quantization defaults to False.
The stored point count (max_elements() in C++, index.max_elements in Python)
increases after add() and includes removed points. Rebuilding with C++
construct or Python build expects that many rows. In C++, callers must prevent
add() or remove() from overlapping with searches or other updates on the same
index. Python detects these conflicts and raises RuntimeError immediately.
Cost of add¶
add builds the grown index next to the old one and swaps it in, so the index is
unchanged if it throws. Every call copies the whole storage, so:
- It needs memory for both copies while it runs.
- Each call has a fixed cost that grows with the size of the whole index, on top of a cost per new vector. Add many vectors per call rather than one at a time.
On an index of 1,000,000 vectors with 128 dimensions, 1,024 clusters and 1 bit
(8 threads, portable build, Intel i7-12700KF), adding 1,000 vectors in one call took
about 5 ms, and adding the same vectors one call each took about 3 s (2.8 s and 3.7 s
in two runs). For that 33 MB file, a process that had just loaded it held about
65 MB, and peaked at about 100 MB during one add of 1,000 vectors.
There is no spare capacity: every add moves the data.
Recall after add¶
The centroids and the rotation are fixed when the index is constructed, so
add never retrains them. If the added vectors come from a different distribution,
or the index grows many times beyond its original size, recall at a given
nprobe can drop. Rebuild from the original data with new centroids when
recall matters more than the cost of a rebuild.
How removal is stored¶
remove hides points from search but keeps their storage. Removed points still
count in max_elements() and cannot be restored. Each nonempty call scans all
stored point IDs, so batch removals when possible.
A removed point has its f_add value set to +inf. That value is the constant
term of the distance estimate, so the estimated distance and the lower bound of the
point are +inf for every bit width, metric, and SIMD backend. The point never
enters the result buffer and never triggers reranking. Any code that estimates
IVF distances must keep this behavior and must never turn +inf into NaN.
Search may return fewer than k results once points are removed: the missing slots
hold kPidMax (2**32 - 1 in Python) with an infinite distance, as when the probed
clusters hold fewer than k points.
The value lives in the ordinary batch data, so the file format does not change and
removal survives save and load. No file written before remove existed has an
infinite f_add, so nothing in an old file is reinterpreted. A file that has
removals also loads in release 0.5.0, where the removed points never appear in results.