Skip to content

New C++ index classes that match the Python API #234

Open

Description

@gouyt13

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::search has overloads
    whose fifth argument is float* distances or bool use_hacc, so a pointer of any other
    type converts to bool. Both compile cleanly with -Wall -Wextra on GCC 14 and Clang.
  • Two values mean "all threads" and behave differently. The index defaults use
    std::numeric_limits<size_t>::max(), which ignores OMP_NUM_THREADS; 0 is capped by it
    (resolve_num_threads in utils/tools.hpp).
  • The behaviour Python users see is implemented in the bindings, not in C++. Training on
    the first add (leaving the index unchanged if it fails), the default nprobe and ef,
    HNSW capacity growth, padding of HNSW results, building the SymphonyQG graph on add, and
    drawing a seed that the index reports all live in python_bindings/. A C++ caller has to
    rebuild them, and the languages disagree on a zero breadth: C++ IVF throws on
    nprobe == 0, C++ HNSW raises ef_search == 0 to k, C++ SymphonyQG rejects
    ef_search < k, and Python turns each into a default.
  • The public headers are the implementation. hnsw.hpp has about 300 private lines of
    state, record layout and search internals, and hnsw::maxheap, hnsw::minheap and
    hnsw::EstimateRecord sit 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 rabitqlib

Rules behind the sketch:

  1. Required arguments are positional. Other settings are fields of plain structs, like
    quant::v1::Parameters and 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.
  2. One search per class: a batch (a single query is a batch of one), const, writing
    caller-owned buffers padded with kNoId and +inf. distances may be null. No search
    setting is stored in the index. An explicit nprobe or ef_search of 0 throws; an
    ef_search below k is raised to k.
  3. add trains 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.
  4. load is a static factory. The classes are movable and not copyable, and there is no
    default-constructed index that cannot be used.
  5. num_threads == 0 means the available CPUs capped by OMP_NUM_THREADS, everywhere.
  6. IDs are 64-bit and signed at this API, with kNoId for 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, and PID and kPidMax stay as they are
    for the existing classes and for k-means.
  7. A seed is a std::optional<uint32_t> on all three classes; no default argument of these
    classes calls std::random_device.
  8. Bad arguments throw std::invalid_argument, a wrong state (untrained, read-only)
    std::logic_error, and load FormatError. 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.
  9. Thread safety follows today's contract: const members 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

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:

  1. Id, kNoId, IvfIndex with its options, C++ tests for each behaviour that moves
    (rollback of a failed first add, the default nprobe, padding, the drawn seed), and the
    IVF binding on top of the new class.
  2. HnswIndex in the same way (capacity growth, padding, the num_clusters cap).
  3. SymqgIndex.
  4. sample/cpp, benchmarks/hotpaths, docs/docs/index/ and the naming contracts in
    docs/development.md on 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: Id and kNoId, and the headers rabitqlib/index/ivf_index.hpp,
    hnsw_index.hpp and symqg_index.hpp.

Activity

  1. added this to the 0.9 milestone on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions