Repository navigation
Description
Problem
The Python index classes have had one workflow since 0.8.2. The C++ classes they wrap
(ivf::IVF, hnsw::HierarchicalNSW, symqg::QuantizedGraph<float>) do not:
ivf::IVF |
hnsw::HierarchicalNSW |
symqg::QuantizedGraph<float> |
|
|---|---|---|---|
| Cluster count given to | constructor | train, set_centroids, construct |
n/a |
Last two construct arguments |
fast_quantization, num_threads |
num_threads, fast_quantization |
built by a separate QGBuilder |
add returns |
void |
std::vector<PID> |
n/a |
Default add threads |
every CPU available to the process | 1 | n/a |
search |
one query or a batch into caller buffers, const |
a batch returning vector<vector<pair<float, PID>>>, not const |
caller buffers, not const; breadth stored by set_ef or passed to search_batch_with_ef |
| Bits accessor | nbits(), 32 for raw vectors |
nbits() |
quantization_bits(), 0 for raw vectors |
Beyond the inconsistency:
- Two overload sets select the wrong function without a diagnostic.
IVF(dim, num_clusters, 4, METRIC_IP)matches the point-count constructor, which reads
every argument one position off (the header documents this).IVF::searchhas overloads
whose fifth argument isfloat* distancesorbool use_hacc, so a pointer of any other
type converts tobool. Both compile cleanly with-Wall -Wextraon GCC 14 and Clang. - Two values mean "all threads" and behave differently. The index defaults use
std::numeric_limits<size_t>::max(), which ignoresOMP_NUM_THREADS;0is capped by it
(resolve_num_threadsinutils/tools.hpp). - The behaviour Python users see is implemented in the bindings, not in C++. Training on
the firstadd(leaving the index unchanged if it fails), the defaultnprobeandef,
HNSW capacity growth, padding of HNSW results, building the SymphonyQG graph onadd, and
drawing a seed that the index reports all live inpython_bindings/. A C++ caller has to
rebuild them, and the languages disagree on a zero breadth: C++ IVF throws on
nprobe == 0, C++ HNSW raisesef_search == 0tok, C++ SymphonyQG rejects
ef_search < k, and Python turns each into a default. - The public headers are the implementation.
hnsw.hpphas about 300 private lines of
state, record layout and search internals, andhnsw::maxheap,hnsw::minheapand
hnsw::EstimateRecordsit in the public namespace.
Why now: #211 changes these signatures in any case, and this issue answers its two open
questions; the RaBitQ-SSD index will join the library and should share one interface with the
others; and one implementation of the behaviour then serves both languages.
Proposal
Add rabitqlib::IvfIndex, HnswIndex and SymqgIndex with the names, defaults, behaviour
and error wording of the Python classes. From 0.9 they are the supported and documented C++
index API. Each holds the existing class through a pointer; the algorithms and the file
formats do not change.
namespace rabitqlib {
using Id = int64_t; // row number assigned by the index
inline constexpr Id kNoId = -1; // search slot without a result
struct IvfOptions {
MetricType metric = METRIC_L2;
size_t nbits = 7; // 1-9, or 32 for raw vectors
ivf::Routing routing = ivf::Routing::Auto; // today's InitializerType
std::optional<uint32_t> seed; // no value draws one
};
struct HnswOptions {
MetricType metric = METRIC_L2;
size_t nbits = 7; // 1-9
size_t num_clusters = 16;
size_t M = 16;
size_t ef_construction = 200;
std::optional<uint32_t> seed;
};
struct SymqgOptions {
MetricType metric = METRIC_L2;
size_t nbits = 32; // 32 for raw vectors, or 4 or 8
size_t max_degree = 32;
size_t ef_construction = 200;
symqg::QGInitialization init = symqg::QGInitialization::PiPNN;
std::optional<uint32_t> seed;
};
struct AddOptions { // IvfIndex and HnswIndex
const uint32_t* cluster_ids = nullptr; // nullptr routes each point
std::optional<size_t> num_threads; // no value: cp().num_threads to train,
// every CPU to add
bool fast_quantization = false;
};
struct IvfSearchOptions {
std::optional<size_t> nprobe; // no value: num_clusters / 16, rounded,
// at least 1
size_t num_threads = 1;
std::optional<bool> high_accuracy;
};
struct GraphSearchOptions { // HnswIndex and SymqgIndex
std::optional<size_t> ef_search; // no value: max(k, 64)
size_t num_threads = 1;
};
class IvfIndex {
public:
IvfIndex(size_t dim, size_t num_clusters, const IvfOptions& options = {});
static IvfIndex load(const char* filename);
void save(const char* filename) const;
rabitqkmeans::RaBitQKMeansParameters& cp(); // training settings, as Python's cp
void train(
const float* data, size_t num_points,
std::optional<size_t> num_threads = std::nullopt // no value: cp().num_threads
);
void set_centroids(const float* centroids, size_t num_threads = 0);
Id add(const float* data, size_t num_points, const AddOptions& options = {});
size_t remove(const Id* ids, size_t num_ids);
void reserve_cluster(size_t cluster_id, size_t num_points);
void search(
const float* queries, size_t num_queries, size_t k,
Id* ids, float* distances, const IvfSearchOptions& options = {}
) const;
// dim(), num_clusters(), nbits(), metric(), routing(), seed(),
// num_points(), num_stored_points(), is_trained()
};
class HnswIndex {
public:
explicit HnswIndex(size_t dim, const HnswOptions& options = {});
static HnswIndex load(const char* filename);
void save(const char* filename) const;
rabitqkmeans::RaBitQKMeansParameters& cp();
void train(
const float* data, size_t num_points,
std::optional<size_t> num_threads = std::nullopt
);
void set_centroids(const float* centroids);
Id add(const float* data, size_t num_points, const AddOptions& options = {});
size_t remove(const Id* ids, size_t num_ids);
void reserve(size_t num_points); // capacity only grows
void search(
const float* queries, size_t num_queries, size_t k,
Id* ids, float* distances, const GraphSearchOptions& options = {}
) const;
// dim(), num_clusters(), nbits(), metric(), M(), ef_construction(), seed(),
// capacity(), num_points(), num_stored_points(), is_trained()
};
class SymqgIndex {
public:
explicit SymqgIndex(size_t dim, const SymqgOptions& options = {});
static SymqgIndex load(const char* filename);
void save(const char* filename) const;
void train(const float* data, size_t num_points) const; // checks the input only
Id add(const float* data, size_t num_points, size_t num_threads = 0); // once
void search(
const float* queries, size_t num_queries, size_t k,
Id* ids, float* distances, const GraphSearchOptions& options = {}
) const;
// dim(), nbits(), metric(), max_degree(), ef_construction(), init(), seed(),
// num_points(), num_stored_points(), is_trained()
};
} // namespace rabitqlibRules behind the sketch:
- Required arguments are positional. Other settings are fields of plain structs, like
quant::v1::Parametersand the k-means parameter structs, with the Python keyword
defaults. Set them by name:IvfOptions options; options.nbits = 4;, or designated
initializers from C++20. The metric is declared first, so a lone positional value must be
a metric:{METRIC_IP}is right and{4}does not compile. - One
searchper class: a batch (a single query is a batch of one),const, writing
caller-owned buffers padded withkNoIdand+inf.distancesmay be null. No search
setting is stored in the index. An explicitnprobeoref_searchof 0 throws; an
ef_searchbelowkis raised tok. addtrains an untrained index first and leaves it unchanged if that fails, as in
Python. It returns the first new ID; the batch gets consecutive IDs.loadis a static factory. The classes are movable and not copyable, and there is no
default-constructed index that cannot be used.num_threads == 0means the available CPUs capped byOMP_NUM_THREADS, everywhere.- IDs are 64-bit and signed at this API, with
kNoIdfor a missing result. This is the
answer proposed here to 64-bit IDs, with -1 for missing results #211's open questions: the in-memory indexes keep 32-bit row
numbers inside, with their limit of 2^31 points, andPIDandkPidMaxstay as they are
for the existing classes and for k-means. - A seed is a
std::optional<uint32_t>on all three classes; no default argument of these
classes callsstd::random_device. - Bad arguments throw
std::invalid_argument, a wrong state (untrained, read-only)
std::logic_error, andloadFormatError. Messages start with the class name and keep
the wording Python raises today, such as "IvfIndex must be trained or loaded before
search", which the Python tests match. - Thread safety follows today's contract:
constmembers may run concurrently, and a call
that modifies the index needs exclusive access. Vectors are validated once, in these
classes.
Moving into C++ from the bindings: everything in the third Problem bullet, the mapping of
nbits 32 to the graph's raw storage, HNSW's cap of num_clusters at the row count of its
first add, the "nothing to save" checks, and the conversion between 64-bit and 32-bit IDs.
Staying in the bindings: NumPy conversion and shape checks, releasing the GIL, and the check
for conflicting concurrent calls.
The new layer is not free. A 64-bit output over 32-bit row numbers needs a widening pass per
search (the bindings already pay it for #211 and then drop theirs), remove narrows its
input, and HNSW still collects results in nested vectors before they are copied out. Search
and add are benchmarked before and after.
The existing classes
ivf::IVF, hnsw::HierarchicalNSW, symqg::QuantizedGraph, symqg::QGBuilder and their
headers stay installed at their current paths in 0.9. They leave the documentation and carry
no compatibility promise from 0.9 on. They cannot simply become private:
clustering/qgkmeans.hpp is an installed, header-only file whose QGAssigner constructs a
QuantizedGraph and calls set_ef and QGBuilder from user translation units, and those
members stay.
A later cleanup, which does not block 0.9, removes the point-count constructors and
construct of IVF and HierarchicalNSW, the IVF::search overloads, hnsw::maxheap and
minheap, and the unused hnsw::EstimateRecord, and migrates the roughly 150 C++ test call
sites that use them.
Not carried over to the new classes, and reachable only through the existing ones:
constructing an index with the matrix rotator, HierarchicalNSW::cluster_id_of, the
single-query IVF::search, QuantizedGraph::search_with_scratch with its warm-start hint,
QGBuilder's multi-pass build(n), refine, avg_degree and check_dup, and
padded_dim().
Migration
| Existing | New |
|---|---|
IVF(num_points, dim, num_clusters, bits, ...), then construct(data, centroids, cluster_ids) |
IvfIndex(dim, num_clusters, options), set_centroids(centroids), add(data, num_points, add_options) with add_options.cluster_ids |
HierarchicalNSW(max_elements, dim, bits, M, ef_construction, seed, metric), then construct(...) |
HnswIndex(dim, options), then the same two calls; capacity grows on add |
QuantizedGraph(num_points, dim, max_degree, ...), then QGBuilder(graph, ef_construction, data).build() |
SymqgIndex(dim, options), then add(data, num_points) |
IVF::search, IVF::search_batch, HierarchicalNSW::search (nested vectors), set_ef with QuantizedGraph::search_batch |
search(queries, num_queries, k, ids, distances, search_options) |
Index index; index.load(filename); |
auto index = IvfIndex::load(filename); |
Three differences are not renames: HnswIndex::add uses every CPU by default (the existing
class uses one), so pass num_threads = 1 for a reproducible graph; a seed is drawn unless
given (the existing HNSW class defaults to 100); and default thread counts are capped by
OMP_NUM_THREADS.
Not in scope
- A common virtual base class, for now. Nothing needs runtime polymorphism over index types.
- The core and FastScan APIs, including
quant::v1, and the k-means classes (their renames
are Align k-means parameter names with the index API #233). - The file formats (Index file format v2 with a written specification #212) and the search and build algorithms.
Compatibility
C++ code that uses the existing classes keeps compiling in 0.9, without a compatibility
promise. Core and FastScan consumers are unaffected: zvec builds with RABITQ_BUILD_INDEX=OFF
and includes no index header (checked at 0c2e67a, 2026-11-13), and the new headers live
under index/, so core-only and OpenMP-off builds do not see them. This issue changes no
Python behaviour; the Python-visible changes of 0.9 are in #211, #212, #213, #232 and
#233.
Prerequisites and plan
The 0.9 work collects on the dev/0.9 branch, which already holds #222 (format 2, cosine and
64-bit Python IDs). Order from here: #213 (until it lands the bindings still need the
point-count constructors and construct for the 0.7 build; its txtai gate applies to the
release, not to the merge), then the Python renames (#232), then this issue, one
index per pull request:
Id,kNoId,IvfIndexwith its options, C++ tests for each behaviour that moves
(rollback of a failed firstadd, the defaultnprobe, padding, the drawn seed), and the
IVF binding on top of the new class.HnswIndexin the same way (capacity growth, padding, thenum_clusterscap).SymqgIndex.sample/cpp,benchmarks/hotpaths,docs/docs/index/and the naming contracts in
docs/development.mdon the new classes; the migration table in the release notes; the
0.9 release note that C++ IDs remain 32-bit is replaced.
Each binding pull request is accepted when the Python test suite passes without edits, the
binding no longer contains the staged training, capacity growth and default-breadth code,
and the before and after benchmark is reported.
Open questions
- Is anything under "Not carried over" needed from the new classes? The matrix rotator and
the warm-start search are the likeliest; C++ users of either, please say so here. - Names:
IdandkNoId, and the headersrabitqlib/index/ivf_index.hpp,
hnsw_index.hppandsymqg_index.hpp.
Activity