diff --git a/.gitignore b/.gitignore
index 80062aa4..9fcd60e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,6 @@ __pycache__/
# macOS garbages
.DS_Store
+
+# Generated documentation
+/docs/site/
diff --git a/AGENTS.md b/AGENTS.md
index 22aef9fe..33a50d54 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,7 @@ factors estimate L2 distance or inner product.
| --- | --- |
| `include/rabitqlib/quantization/` | Encoding, packing, reconstruction, and byte layouts |
| `include/rabitqlib/fastscan/` | FastScan interfaces and high-accuracy scanning |
+| `include/rabitqlib/clustering/` | RaBitQKMeans flat assignment, QGKMeans graph assignment, and shared Lloyd training |
| `include/rabitqlib/index/{ivf,hnsw,symqg}/` | Index construction, persistence, and search |
| `include/rabitqlib/index/{query,estimator}.hpp` | Query state and distance estimation |
| `include/rabitqlib/simd/`, `src/simd/` | Kernel declarations, implementations, and dispatch |
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9561e667..d22554b6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -175,7 +175,6 @@ another. Each task can be a separate PR.
| --- | --- | --- |
| Explain Python search results | [Quick start](docs/docs/quick_start.md), [IVF binding](python_bindings/ivf_bindings.cpp), and [IVF tests](tests/python/test_ivf.py) | A short explanation covers result shapes, how IDs map to input rows, and what L2 distances represent, checked against the implementation. The example runs and the strict docs build passes. |
| Add a self-contained IVF save/load example | [Python examples](sample/python/), [README quick start](README.md#python-quick-start), and [IVF tests](tests/python/test_ivf.py) | A deterministic script builds from synthetic data, saves to a temporary directory, reloads, and checks that search IDs and distances match. It needs no dataset download, cleans up its temporary files, passes Python checks, and is linked from the README. |
-| Explain the quick start's cluster assignment | [Quick start](docs/docs/quick_start.md) and [IVF guide](docs/docs/index/ivf.md) | The tutorial explains that its round-robin assignment is for a small runnable example, explains why real IVF workloads use clustering, and links to the existing clustering workflow. The strict docs build passes. |
## Feedback and further reading
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index efe9f676..a75b9d69 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -310,10 +310,11 @@ covers its arithmetic kernels, not every scalar loop in construction and search.
| IVF and float SymphonyQG batch correction | Complete estimator runs in the selected backend; non-float template paths remain generic |
| FHT/Kac rotation | Complete rotation and scaling run in selected ISA translation units; shared AVX intrinsics preserve the FFHT butterfly order |
| Float matrix rotation and PiPNN construction | Matrix products, row norms, and lower-triangle pairwise distances use isolated matrix backends |
+| RaBitQKMeans and QGKMeans centroid updates | Indexed float32 rows accumulate into float64 sums through the matrix backends, preserving per-cluster point order without additional workspace |
| HNSW search and IVF centroid routing | Cached HNSW search selection; centroid routing uses the common raw-distance dispatcher |
| Quantization orchestration, reconstruction, non-float utilities | Template/control code remains generic; no blanket native tuning or reduction-order rewrite |
| Graph scheduling, candidate queues, I/O, allocation, random initialization | Generic control code; IVF one-bit candidate insertion stays in a small compiled function to avoid inlining-induced register spills; thread scheduling and seeds remain caller-owned |
-| Example KMeans training | Uses external FAISS, whose build and dispatch are independent of this package |
+| Example KMeans training | Explicit RaBitQKMeans flat or QGKMeans graph assignment; FAISS is an optional comparison dependency only |
Portable wheels disable `RABITQ_ENABLE_NATIVE_OPTIMIZATION`. ARM64 builds exclude all
x86 source groups and use standard AArch64 NEON intrinsics, without Apple-only APIs.
diff --git a/README.md b/README.md
index d31d8c6a..d289c1c7 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
A research-backed C++17 library with Python bindings for 1-bit and multi-bit
- vector quantization, IVF, HNSW, and SymphonyQG.
+ vector quantization, IVF, HNSW, SymphonyQG, and clustering.
@@ -63,17 +63,16 @@ Build and search a small IVF index using synthetic data:
```python
import numpy as np
-from rabitqlib import IvfIndex
+from rabitqlib import FinalAssignmentMode, IvfIndex, RaBitQKMeans
rng = np.random.default_rng(42)
data = rng.standard_normal((500, 64)).astype(np.float32)
queries = rng.standard_normal((5, 64)).astype(np.float32)
-# Assign vectors to five clusters and calculate their centroids.
-cluster_ids = (np.arange(len(data)) % 5).astype(np.uint32)
-centroids = np.stack(
- [data[cluster_ids == cluster].mean(axis=0) for cluster in range(5)]
-).astype(np.float32)
+clustering = RaBitQKMeans(
+ 64, 5, num_threads=2, final_assignment=FinalAssignmentMode.Exact
+)
+clustering.train(data)
index = IvfIndex(
dim=64,
@@ -82,7 +81,7 @@ index = IvfIndex(
nbits=4,
metric="l2",
)
-index.build(data, centroids, cluster_ids)
+index.build(data, clustering.centroids, clustering.assignments)
ids, distances = index.search(queries, k=10, nprobe=5)
print(ids.shape, distances.shape) # (5, 10) (5, 10)
@@ -93,16 +92,20 @@ For all three indexes, `build` and `search` interpret `num_threads=0` as the
detected hardware thread count. Larger requests are capped at that count;
smaller positive requests are respected. Operations may use fewer workers when
there are fewer work items. If hardware detection is unavailable, one thread is
-used. Omitting `num_threads` in Python still defaults to one thread.
+used. Python index methods default to one thread when `num_threads` is omitted.
-Python bindings are also available for `HnswIndex` and `SymqgIndex`. See the
-[Python examples](sample/python/README.md) for index construction, querying, and
-index persistence. IVF and HNSW examples run Faiss clustering in a separate process,
-then pass saved clusters to RaBitQ indexing so their OpenMP runtimes stay separate.
+IVF and HNSW examples save clusters for reuse across index configurations.
+Choose RaBitQKMeans for flat assignment or QGKMeans for graph assignment;
+FAISS is needed only for the optional clustering comparison.
Index save/load paths are UTF-8 strings on Windows and native path bytes on POSIX
in C++; Python paths are Unicode strings on all platforms.
+See the [Python examples](sample/python/README.md) for IVF, HNSW, and SymphonyQG.
+For clustering, use [RaBitQKMeans](docs/docs/clustering.md#rabitqkmeans) for small cluster
+counts or [QGKMeans](docs/docs/clustering.md#qgkmeans) for graph assignment. The
+[FAISS comparison](sample/python/compare_with_faiss.py) benchmarks both methods.
+
Build the Python bindings from source
@@ -145,6 +148,7 @@ depend on the dataset, index configuration, and search parameters.
| **Accurate estimates** | An asymptotically optimal theoretical error bound supports reliable ordering and reranking. |
| **Native CPU backends** | Runtime AVX2/AVX-512 selection on x86-64; NEON distance, packed-code, FastScan, rotation, query preparation, and HNSW search kernels on ARM64, with portable scalar fallbacks. |
| **Ready for ANN search** | Use the quantizer directly or build complete IVF, HNSW, and [SymphonyQG](https://dl.acm.org/doi/abs/10.1145/3709730) indexes. |
+| **Native clustering** | Choose [RaBitQKMeans](docs/docs/clustering.md#rabitqkmeans) with flat RaBitQ assignment or [QGKMeans](docs/docs/clustering.md#qgkmeans) with SymphonyQG assignment; neither needs an external k-means package. |
The library supports Euclidean distance and inner product. Cosine search is
available by normalizing vectors before using inner product.
diff --git a/docs/docs/clustering.md b/docs/docs/clustering.md
new file mode 100644
index 00000000..57d16c04
--- /dev/null
+++ b/docs/docs/clustering.md
@@ -0,0 +1,189 @@
+# Clustering
+
+RaBitQ Library provides two implementations of Lloyd's k-means. Both compute
+centroid means from the original float32 data, accumulate in float64, and require
+no external clustering package. Choose the class explicitly; neither switches
+algorithms based on the cluster count.
+
+## Choose a method
+
+| Class | Assignment | Recommended use | Cluster count |
+| --- | --- | --- | --- |
+| [RaBitQKMeans](#rabitqkmeans) | Flat RaBitQ scan | Small cluster counts | `n >= k >= 1` |
+| [QGKMeans](#qgkmeans) | SymphonyQG centroid graph | Larger cluster counts | `n >= k > graph_degree` (default 32) |
+
+## Python
+
+Both classes have the same training and result interface:
+
+```python
+from rabitqlib import QGKMeans, RaBitQKMeans
+
+kmeans = RaBitQKMeans(d, 16, niter=25, num_threads=8)
+# For larger cluster counts, use instead:
+# kmeans = QGKMeans(d, 4096, niter=25, num_threads=8)
+last_iteration_obj = kmeans.train(x)
+
+centroids = kmeans.centroids # shape (k, d)
+assignments = kmeans.assignments # shape (n,)
+distances = kmeans.distances # shape (n,)
+final_obj = kmeans.final_obj
+```
+
+`x` has shape `(n, d)` and is converted to a C-contiguous float32 array.
+
+## C++
+
+```cpp
+#include
+#include
+
+rabitqlib::rabitqkmeans::RaBitQKMeansParameters parameters;
+parameters.niter = 25;
+parameters.num_threads = 8;
+rabitqlib::rabitqkmeans::RaBitQKMeans kmeans(dimension, 16, parameters);
+
+// For graph assignment, use qgkmeans::QGKMeansParameters and qgkmeans::QGKMeans.
+kmeans.train(num_points, data); // row-major float32 input
+
+const auto& centroids = kmeans.centroids;
+const auto& assignments = kmeans.assignments;
+const auto& distances = kmeans.distances;
+const double final_obj = kmeans.final_obj;
+```
+
+Complete examples: [RaBitQKMeans](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/sample/cpp/rabitqkmeans.cpp)
+(`bin/rabitqkmeans`) and [QGKMeans](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/sample/cpp/qgkmeans.cpp)
+(`bin/qgkmeans`). Run either without arguments for a synthetic demo, or save
+clusters for the C++ indexing examples:
+
+```bash
+./bin/qgkmeans data.fvecs 4096 centroids.fvecs labels.ivecs
+./bin/rabitqkmeans data.fvecs 16 centroids.fvecs labels.ivecs
+```
+
+File mode uses exact final assignment and the default training parameters.
+Append `ip` for spherical clustering of normalized input; the default is `l2`.
+`example.sh` runs the C++ clustering and indexing workflow without Python.
+
+## Parameters and input
+
+Set Python keyword arguments or fields of the corresponding C++ parameter type:
+
+| Parameter | Default | Meaning |
+| --- | --- | --- |
+| `niter` | `25` | Maximum clustering iterations. |
+| `early_stop_threshold` | `0.0` | Stop from iteration 2 when the absolute relative objective change is at most this value; range `[0, 1]`. Zero stops only on an unchanged objective. |
+| `num_threads` | `0` | Use detected hardware threads; positive requests are capped at that count. |
+| `spherical` | `false` | Normalize centroids and assign by inner product; input must already be normalized. |
+| `seed` | `42` | Seed for centroid selection and rotation. |
+| `min_points_per_centroid` | `39` | Warn below this training-points-per-centroid ratio when verbose. |
+| `verbose` | `false` | Print training progress and warnings. |
+| `final_assignment` | `Approximate` | Use the selected method for final labels, or `Exact` for nearest-centroid labels. |
+
+Training requires `64 <= d <= 65536`, finite coordinates, and
+`abs(x) <= sqrt(sqrt(FLT_MAX) / (64 * d))`; scale larger inputs before training.
+Cluster-count requirements are listed above. Training does not normalize or
+mutate the input; provide normalized vectors for spherical mode.
+
+Empty clusters are reseeded from distant points; duplicate data can still leave
+empty clusters in the final assignment. Thread scheduling can affect graph
+construction and floating-point reductions, so a fixed seed does not guarantee
+identical multithreaded results.
+
+## Method details
+
+### RaBitQKMeans
+
+The flat assignment design is inspired by
+[SuperKMeans](https://github.com/cwida/SuperKMeans). Training points are rotated
+and encoded once around a fixed dataset mean. Each centroid becomes a query
+that scans all packed point batches, and each point receives the centroid with
+the best estimated distance.
+
+Each iteration compares all `n * k` point-centroid pairs. The batch size of 32
+is not a cluster-count limit. This method uses one-bit point codes throughout;
+it has no graph parameters or `quantization_bits` setting.
+
+### QGKMeans
+
+QGKMeans builds a SymphonyQG graph over the centroids and uses previous
+assignments as search hints. Its additional parameters are:
+
+| Parameter | Default | Meaning |
+| --- | --- | --- |
+| `quantization_bits` | `0` | Graph centroid storage: raw, or `4`/`8` bits. |
+| `graph_degree` | `32` | A positive multiple of 32, strictly smaller than `k`. |
+| `ef_build`, `ef_search` | `240`, `16` | Positive graph construction and search window sizes. |
+| `graph_build_iterations` | `1` | PiPNN initialization followed by one refinement; values ≥2 request that many graph passes, ending in refinement. Independent of `niter`. |
+
+The seed also controls fallback neighbors; PiPNN uses fixed internal seeds.
+`QGAssigner` in `qgkmeans.hpp` supports repeated vector-to-centroid assignment
+without the clustering loop.
+
+## Results and final assignment
+
+Python result properties are `None` until a successful fit; a failed fit
+preserves the previous successful results.
+
+`distances` contains distances to the returned **assigned** centroids, computed
+from the original float32 vectors and centroids. For squared L2, `final_obj` is
+their sum (WCSS); spherical mode sums `1 - inner_product`. `obj` and
+`iteration_stats` record objectives before each centroid update; Python
+`train()` returns the last such objective.
+
+`FinalAssignmentMode.Approximate` (default) uses the selected class's assignment
+algorithm. The legacy name `FinalAssignmentMode.SymphonyQG` remains an alias for
+`Approximate`. Labels need not identify the exact nearest centroids, but reported
+distances and WCSS remain exact for those labels. Quantized QGKMeans final
+assignment retains a previous label when it is closer. Approximate training
+objectives need not decrease monotonically.
+
+To require exact nearest-centroid **final** assignments with either class:
+
+```python
+from rabitqlib import FinalAssignmentMode, RaBitQKMeans
+
+kmeans = RaBitQKMeans(d, 16, final_assignment=FinalAssignmentMode.Exact)
+kmeans.train(x)
+```
+
+In C++, set `parameters.final_assignment` to
+`rabitqlib::rabitqkmeans::FinalAssignmentMode::Exact` or
+`rabitqlib::qgkmeans::FinalAssignmentMode::Exact`; both names refer to the same
+enum. Training still uses the selected approximate assignment algorithm.
+
+Exact final assignment screens all centroids with blocked GEMM and refines
+possible winners in float64; ties select the smallest centroid index. Returned
+distances remain float32. Work is proportional to `n * k * d`; large coordinate
+offsets or near ties can require more refinement.
+
+## Compare with FAISS
+
+The [comparison script](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/sample/python/compare_with_faiss.py)
+compares both methods with FAISS and requires `faiss-cpu`. Defaults are one
+million 256-dimensional vectors, 4,000 centroids, and up to 25 iterations.
+Load data with `--input data.npy`, or `--input data.bin --d D` for raw row-major
+float32 data. Choose a thread count within the available hardware limit.
+
+```bash
+python sample/python/compare_with_faiss.py --threads 48 --num-seeds 2 --repeats 2
+```
+
+All three methods receive the same full dataset, iteration limit, cluster count,
+thread budget, and seed values; FAISS subsampling is disabled. This command runs
+four fits per method with rotating order. Different initializers mean identical
+seeds need not produce identical centroids.
+
+Only `train()` is timed, including the library methods' default approximate
+final assignment. The script reports actual iterations, timing medians/ranges,
+exhaustive nearest-centroid objectives, and both library methods' returned-label
+objectives and label mismatches. Equal-distance ties can produce mismatches
+without changing the objective. `--spherical` normalizes the input copy and
+compares sums of `1 - inner_product`.
+
+Separate worker processes isolate OpenMP runtimes and share a temporary float32
+input file; allow about `4 * n * d` bytes of temporary disk space. Input staging,
+process startup, and exhaustive evaluation are outside training time. For Linux
+CPU placement, set `OMP_PLACES=threads OMP_PROC_BIND=close`; on NUMA systems,
+use `numactl --cpunodebind=0 --membind=0` with a thread count that fits the node.
diff --git a/docs/docs/index.md b/docs/docs/index.md
index 8a1edcca..b9bd8bac 100644
--- a/docs/docs/index.md
+++ b/docs/docs/index.md
@@ -46,16 +46,16 @@ Build an IVF index and search a batch of queries:
```python
import numpy as np
-from rabitqlib import IvfIndex
+from rabitqlib import FinalAssignmentMode, IvfIndex, RaBitQKMeans
rng = np.random.default_rng(42)
data = rng.standard_normal((500, 64)).astype(np.float32)
queries = rng.standard_normal((5, 64)).astype(np.float32)
-cluster_ids = (np.arange(len(data)) % 5).astype(np.uint32)
-centroids = np.stack(
- [data[cluster_ids == cluster].mean(axis=0) for cluster in range(5)]
-).astype(np.float32)
+clustering = RaBitQKMeans(
+ 64, 5, num_threads=2, final_assignment=FinalAssignmentMode.Exact
+)
+clustering.train(data)
index = IvfIndex(
dim=64,
@@ -64,7 +64,7 @@ index = IvfIndex(
nbits=4,
metric="l2",
)
-index.build(data, centroids, cluster_ids)
+index.build(data, clustering.centroids, clustering.assignments)
ids, distances = index.search(queries, k=10, nprobe=5)
print(ids.shape, distances.shape) # (5, 10) (5, 10)
diff --git a/docs/docs/index/hnsw.md b/docs/docs/index/hnsw.md
index 1ecb6afd..f3e363ab 100644
--- a/docs/docs/index/hnsw.md
+++ b/docs/docs/index/hnsw.md
@@ -28,7 +28,7 @@ HierarchicalNSW::construct(size_t cluster_num,
- **data**: Pointer to the raw data vectors.
- **data_num**: The number of data vectors.
-- **centroids**: Centroids computed by K-means clustering on the raw data vectors (we recommend `cluster_num = 16`).
+- **centroids**: Centroids computed by [RaBitQKMeans](../clustering.md#rabitqkmeans); the examples use `cluster_num = 16`.
- **cluster_ids**: Array of length `data_num`; every entry must be in the range `[0, cluster_num)`.
- **num_threads**: Number of threads to use (default: 0, which auto-selects).
- **faster**: If `true`, enables the faster quantizer.
diff --git a/docs/docs/index/ivf.md b/docs/docs/index/ivf.md
index 547df2b3..5ef43825 100644
--- a/docs/docs/index/ivf.md
+++ b/docs/docs/index/ivf.md
@@ -10,24 +10,19 @@ width, number of clusters, and `nprobe`.
The algorithm includes two phases: indexing and querying.
## Index Construction
-The first step is to run a clustering algorithm to partition raw data vectors (`*.fvecs` format) into different buckets.
-The algorithm performs KMeans clustering on raw vectors based on [Faiss](https://github.com/facebookresearch/faiss) (see `python/ivf.py`).
-To run the algorithm, you need to execute the command in `shell`:
+
+Use [QGKMeans](../clustering.md#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](../clustering.md#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:
```shell
-python python/ivf.py /path/to/raw/data \
- number_of_clusters \
- /path/to/output/centroids \
- /path/to/output/cluster_ids \
- distance_metric
-```
-For example, the following command splits the sift vector data into 4096 clusters using Euclidean (l2) distance:
-```shell
-python python/ivf.py /data/sift/sift_base.fvecs \
- 4096 \
- /data/sift/sift_centroids_4096_l2.fvecs \
- /data/sift/sift_clusterids_4096_l2.ivecs \
- l2
+./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:
@@ -72,7 +67,7 @@ void IVF::construct(
```
- **data**: Pointer to the raw data vectors.
-- **centroids**: Centroids computed by K-means clustering on the raw data vectors (we recommend to tune cluster_num around 4 * the square root of the dataset following Faiss).
+- **centroids**: Clustering centroids; tune `cluster_num` for 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 `false` to pursue better accuracy.).
- **num_threads**: Maximum number of OpenMP threads used to quantize clusters.
diff --git a/docs/docs/index/qg.md b/docs/docs/index/qg.md
index 85316697..21d9b47b 100644
--- a/docs/docs/index/qg.md
+++ b/docs/docs/index/qg.md
@@ -10,7 +10,7 @@ Set `quantization_bits=0` for raw vectors (default), or `4`/`8` for QG-quant.
Quantized vectors share a rotated global centroid. Refinement temporarily
reconstructs source vectors and estimates distances to stored target codes;
reverse edges are rescored because these estimates are directional. Raw refinement
-uses owned raw vectors. No full reconstructed-vector cache is retained.
+uses owned raw vectors. No full reconstructed-vector cache is retained by default.
## Index Construction
@@ -39,29 +39,38 @@ window; `ef` controls the query search window. Python defaults to one thread.
### C++
-The C++ API uses the float-only `rabitqlib::symqg::QuantizedGraph` and `QGBuilder`.
-`QuantizedGraph` retains the C++ template API, with its implementation
-compiled in `src/index/qg.cpp`. C++17 variable declarations can also omit ``
-through class template argument deduction:
+The C++ API provides `rabitqlib::symqg::QuantizedGraph` and `QGBuilder`.
+Only float vectors are supported; C++17 declarations may omit `` through
+class template argument deduction:
```cpp
QuantizedGraph(
size_t num, size_t dim, size_t max_deg,
MetricType metric_type = METRIC_L2,
RotatorType rotator_type = RotatorType::FhtKacRotator,
- size_t quantization_bits = 0
+ size_t quantization_bits = 0, uint32_t seed = std::random_device{}()
);
QGBuilder(
QuantizedGraph& index, uint32_t ef_build, const float* data,
size_t num_threads = std::numeric_limits::max(),
- QGInitialization init = QGInitialization::PiPNN
+ QGInitialization init = QGInitialization::PiPNN,
+ uint32_t seed = std::random_device{}(), bool cache_vectors = false
);
```
-`data` contains `num * dim` floats; `max_deg` has the same constraints as Python's
-`max_degree`. C++ defaults to all available threads. Pass
-`QGInitialization::Random` as the final builder argument to use random initialization.
-The builder handles initialization internally.
+`data` contains `num * dim` floats; `max_deg` has Python's `max_degree`
+constraints. C++ defaults to all available threads. `build()` runs one refinement
+for PiPNN or three passes for `QGInitialization::Random`; `build(n)` requests
+`n >= 2` passes. The older overload with a numeric fifth argument uses that value
+as the random-initialization seed.
+
+`builder.reset(data)` reinitializes vectors of the same shape and reuses workspace.
+Input may be released after reset returns; rebuild before querying or saving.
+`cache_vectors=true` retains transformed vectors and query factors
+(`4 * num * padded_dim` bytes plus factors). [QGKMeans](../clustering.md#qgkmeans) enables
+this for its centroid graph; the cache is not persisted. The graph seed controls
+rotation, and the builder seed controls random initialization and fallback edges.
+PiPNN uses fixed internal seeds.
```cpp
using namespace rabitqlib::symqg;
diff --git a/docs/docs/quick_start.md b/docs/docs/quick_start.md
index 358253a3..9d5297e5 100644
--- a/docs/docs/quick_start.md
+++ b/docs/docs/quick_start.md
@@ -55,17 +55,16 @@ require a dataset download:
```python
import numpy as np
-from rabitqlib import IvfIndex
+from rabitqlib import FinalAssignmentMode, IvfIndex, RaBitQKMeans
rng = np.random.default_rng(42)
data = rng.standard_normal((500, 64)).astype(np.float32)
queries = rng.standard_normal((5, 64)).astype(np.float32)
-# Assign vectors to five clusters and calculate their centroids.
-cluster_ids = (np.arange(len(data)) % 5).astype(np.uint32)
-centroids = np.stack(
- [data[cluster_ids == cluster].mean(axis=0) for cluster in range(5)]
-).astype(np.float32)
+clustering = RaBitQKMeans(
+ 64, 5, num_threads=2, final_assignment=FinalAssignmentMode.Exact
+)
+clustering.train(data)
index = IvfIndex(
dim=64,
@@ -74,7 +73,7 @@ index = IvfIndex(
nbits=4,
metric="l2",
)
-index.build(data, centroids, cluster_ids)
+index.build(data, clustering.centroids, clustering.assignments)
ids, distances = index.search(queries, k=10, nprobe=5)
print(ids.shape, distances.shape) # (5, 10) (5, 10)
@@ -101,9 +100,10 @@ The `metric` argument accepts `"l2"` and `"ip"` (also spelled
`"innerproduct"`). To search by cosine similarity, normalize database and
query vectors first and use `metric="ip"`.
-Python bindings are also available for `HnswIndex` and `SymqgIndex`. The
-[Python examples](https://github.com/VectorDB-NTU/RaBitQ-Library/tree/main/sample/python)
-cover construction, querying, and index persistence.
+See the [Python examples](https://github.com/VectorDB-NTU/RaBitQ-Library/tree/main/sample/python)
+for IVF, HNSW, and SymphonyQG. For clustering, choose
+[RaBitQKMeans](clustering.md#rabitqkmeans) for flat assignment, recommended for small cluster
+counts, or [QGKMeans](clustering.md#qgkmeans) for graph assignment.
Build the Python bindings from source
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 9f931cb2..894d3ac6 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -24,6 +24,7 @@ nav:
- SymphonyQG: index/qg.md
- Integrations:
- Zvec: integrations/zvec.md
+ - Clustering: clustering.md
theme:
name: material
diff --git a/example.sh b/example.sh
index 499c02ff..1582232c 100755
--- a/example.sh
+++ b/example.sh
@@ -14,15 +14,15 @@ tar -xzvf ./data/gist/gist.tar.gz -C ./data/gist
./bin/symqg_querying ./data/gist/symqg_32.index ./data/gist/gist_query.fvecs ./data/gist/gist_groundtruth.ivecs
-# indexing and querying for RabitQ+ with ivf, please refer to python/ivf.py for more information about clustering
-python ./python/ivf.py ./data/gist/gist_base.fvecs 4096 ./data/gist/gist_centroids_4096.fvecs ./data/gist/gist_clusterids_4096.ivecs
+# Cluster with QGKMeans, then build and query an IVF index.
+./bin/qgkmeans ./data/gist/gist_base.fvecs 4096 ./data/gist/gist_centroids_4096.fvecs ./data/gist/gist_clusterids_4096.ivecs
./bin/ivf_rabitq_indexing ./data/gist/gist_base.fvecs ./data/gist/gist_centroids_4096.fvecs ./data/gist/gist_clusterids_4096.ivecs 3 ./data/gist/ivf_4096_3.index
./bin/ivf_rabitq_querying ./data/gist/ivf_4096_3.index ./data/gist/gist_query.fvecs ./data/gist/gist_groundtruth.ivecs
-# indexing and querying for RabitQ+ with hnsw, do clustering first
-python ./python/ivf.py ./data/gist/gist_base.fvecs 16 ./data/gist/gist_centroids_16.fvecs ./data/gist/gist_clusterids_16.ivecs
+# Cluster with RaBitQKMeans, then build and query an HNSW index.
+./bin/rabitqkmeans ./data/gist/gist_base.fvecs 16 ./data/gist/gist_centroids_16.fvecs ./data/gist/gist_clusterids_16.ivecs
./bin/hnsw_rabitq_indexing ./data/gist/gist_base.fvecs ./data/gist/gist_centroids_16.fvecs ./data/gist/gist_clusterids_16.ivecs 16 200 5 ./data/gist/hnsw_5.index
diff --git a/example_py.sh b/example_py.sh
index 661e7a2b..d47b17c3 100755
--- a/example_py.sh
+++ b/example_py.sh
@@ -1,5 +1,5 @@
# compiling
-pip install .
+python -m pip install .
# Download the dataset
mkdir -p ./data/gist
@@ -14,14 +14,14 @@ python ./sample/python/symqg_indexing.py --max-degree 32 --ef-construction 400
python ./sample/python/symqg_querying.py ./data/gist/symqg_32.index ./data/gist/gist_query.fvecs ./data/gist/gist_groundtruth.ivecs
# indexing and querying for RabitQ+ with ivf
-python ./sample/python/faiss_clustering.py --num-clusters 4096 ./data/gist/gist_base.fvecs ./data/gist/clusters_4096_l2.npz
+python ./sample/python/kmeans_clustering.py --method qg --num-clusters 4096 ./data/gist/gist_base.fvecs ./data/gist/clusters_4096_l2.npz
python ./sample/python/ivf_rabitq_indexing.py --total-bits 5 --clusters ./data/gist/clusters_4096_l2.npz ./data/gist/gist_base.fvecs ./data/gist/ivf_4096_5.index
python ./sample/python/ivf_rabitq_querying.py ./data/gist/ivf_4096_5.index ./data/gist/gist_query.fvecs ./data/gist/gist_groundtruth.ivecs
# indexing and querying for RabitQ+ with hnsw
-python ./sample/python/faiss_clustering.py --num-clusters 16 ./data/gist/gist_base.fvecs ./data/gist/clusters_16_l2.npz
+python ./sample/python/kmeans_clustering.py --method rabitq --num-clusters 16 ./data/gist/gist_base.fvecs ./data/gist/clusters_16_l2.npz
python ./sample/python/hnsw_rabitq_indexing.py --total-bits 5 --clusters ./data/gist/clusters_16_l2.npz --degree 16 --ef-construction 200 ./data/gist/gist_base.fvecs ./data/gist/hnsw_5.index
diff --git a/include/rabitqlib/clustering/detail/kmeans.hpp b/include/rabitqlib/clustering/detail/kmeans.hpp
new file mode 100644
index 00000000..ef783719
--- /dev/null
+++ b/include/rabitqlib/clustering/detail/kmeans.hpp
@@ -0,0 +1,594 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "rabitqlib/defines.hpp"
+#include "rabitqlib/simd/matrix_dispatch.hpp"
+#include "rabitqlib/utils/rotator.hpp"
+#include "rabitqlib/utils/space.hpp"
+#include "rabitqlib/utils/tools.hpp"
+
+namespace rabitqlib::clustering {
+
+enum class FinalAssignmentMode : uint8_t {
+ Approximate = 0,
+ SymphonyQG = Approximate, // Compatibility name for the graph assignment path.
+ Exact = 1,
+};
+
+struct KMeansIterationStats {
+ size_t iteration = 0;
+ double obj = 0;
+ double shift = 0;
+ size_t nsplit = 0;
+};
+
+namespace detail {
+
+[[nodiscard]] inline bool should_terminate_by_objective(
+ double previous_objective, double objective, double early_stop_threshold
+) {
+ if (previous_objective == 0.0) {
+ return objective == 0.0;
+ }
+ const double change =
+ std::abs(previous_objective - objective) / std::abs(previous_objective);
+ return change <= early_stop_threshold;
+}
+
+// Inputs have already passed clustering validation. GEMM only screens candidates;
+// final distances and tie breaking retain the scalar double-precision arithmetic.
+inline void exact_assign(
+ const float* x,
+ const float* centroids,
+ size_t n,
+ size_t d,
+ size_t k,
+ bool spherical,
+ uint32_t num_threads,
+ PID* assignments,
+ float* distances
+) {
+ constexpr size_t kPointBlock = 64;
+ constexpr size_t kCentroidBlock = 256;
+ const size_t blocks = 1 + (n - 1) / kPointBlock;
+ const auto threads = static_cast(std::min(num_threads, blocks));
+ std::vector centroid_norms(k);
+ std::vector> products(
+ threads, std::vector(kPointBlock * std::min(k, kCentroidBlock))
+ );
+ std::vector errors(threads);
+
+#pragma omp parallel for num_threads(threads) schedule(static)
+ for (std::ptrdiff_t cluster = 0; cluster < static_cast(k); ++cluster) {
+ double norm = 0.0;
+ for (size_t dim = 0; dim < d; ++dim) {
+ const double value = centroids[static_cast(cluster) * d + dim];
+ norm += value * value;
+ }
+ centroid_norms[static_cast(cluster)] = norm;
+ }
+
+ // For d <= 65536, this exceeds the float dot-product error bound
+ // gamma_d * sum(abs(x_i*c_i)), using 2*abs(x_i*c_i) <= x_i^2+c_i^2.
+ // The slack also covers double norm/distance rounding and underflow.
+ const double error_scale = 8.0 * static_cast(d) *
+ static_cast(std::numeric_limits::epsilon());
+ const double error_floor =
+ 8.0 * static_cast(d) * std::numeric_limits::min() +
+ 8.0 * std::numeric_limits::epsilon();
+#pragma omp parallel num_threads(threads)
+ {
+ // Set only this OpenMP task's ICV, not Eigen's process-global thread limit.
+ // This also prevents nested GEMM teams when the outer team has one worker.
+ omp_set_num_threads(1);
+ const auto thread = static_cast(omp_get_thread_num());
+ float* dots = products[thread].data();
+ std::array point_norms{};
+ std::array best_distances{};
+#pragma omp for schedule(static)
+ for (std::ptrdiff_t block = 0; block < static_cast(blocks);
+ ++block) {
+ if (errors[thread]) {
+ continue;
+ }
+ try {
+ const size_t first_point = static_cast(block) * kPointBlock;
+ const size_t rows = std::min(kPointBlock, n - first_point);
+ for (size_t row = 0; row < rows; ++row) {
+ const float* vector = x + (first_point + row) * d;
+ double norm = 0.0;
+ for (size_t dim = 0; dim < d; ++dim) {
+ const double value = vector[dim];
+ norm += value * value;
+ }
+ point_norms[row] = norm;
+ best_distances[row] = std::numeric_limits::infinity();
+ }
+ for (size_t first_cluster = 0; first_cluster < k;
+ first_cluster += kCentroidBlock) {
+ const size_t cols = std::min(kCentroidBlock, k - first_cluster);
+ simd::matrix_product_transposed(
+ x + first_point * d,
+ centroids + first_cluster * d,
+ dots,
+ rows,
+ d,
+ cols
+ );
+ for (size_t row = 0; row < rows; ++row) {
+ const size_t point = first_point + row;
+ const float* vector = x + point * d;
+ for (size_t col = 0; col < cols; ++col) {
+ const size_t cluster = first_cluster + col;
+ const double norm_sum =
+ point_norms[row] + centroid_norms[cluster];
+ const double dot = dots[row * cols + col];
+ const double estimate =
+ spherical ? 1.0 - dot : norm_sum - 2.0 * dot;
+ const double error = error_scale * norm_sum + error_floor;
+ if (estimate > best_distances[row] + error) {
+ continue;
+ }
+ const float* centroid = centroids + cluster * d;
+ double distance = 0.0;
+ if (spherical) {
+ for (size_t dim = 0; dim < d; ++dim) {
+ distance -=
+ static_cast(vector[dim]) * centroid[dim];
+ }
+ distance += 1.0;
+ } else {
+ for (size_t dim = 0; dim < d; ++dim) {
+ const double difference =
+ static_cast(vector[dim]) - centroid[dim];
+ distance += difference * difference;
+ }
+ }
+ // Visit in ascending ID order, retaining the first exact tie.
+ if (distance < best_distances[row]) {
+ assignments[point] = static_cast(cluster);
+ best_distances[row] = distance;
+ }
+ }
+ }
+ }
+ for (size_t row = 0; row < rows; ++row) {
+ distances[first_point + row] = static_cast(best_distances[row]);
+ }
+ } catch (...) { errors[thread] = std::current_exception(); }
+ }
+ }
+ for (const auto& error : errors) {
+ if (error) {
+ std::rethrow_exception(error);
+ }
+ }
+}
+
+template
+class LloydKMeans : public Parameters {
+ public:
+ using Parameters::early_stop_threshold;
+ using Parameters::final_assignment;
+ using Parameters::min_points_per_centroid;
+ using Parameters::niter;
+ using Parameters::num_threads;
+ using Parameters::seed;
+ using Parameters::spherical;
+ using Parameters::verbose;
+
+ LloydKMeans(size_t d, size_t k, const Parameters& cp) : Parameters(cp), d(d), k(k) {
+ validate_parameters();
+ }
+
+ void train(size_t n, const float* x) {
+ validate_parameters();
+ if (x == nullptr) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " x must not be null"
+ );
+ }
+ if (n < k) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " requires n >= k"
+ );
+ }
+ if (n > static_cast(std::numeric_limits::max()) / d) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " input dimensions overflow size_t"
+ );
+ }
+ const uint32_t threads = effective_num_threads();
+ static_cast(threads);
+ // Leave headroom for products of squared distances in graph pruning,
+ // as well as quantized reconstruction and rotated residuals.
+ const double max_coordinate = std::sqrt(
+ std::sqrt(static_cast(std::numeric_limits::max())) /
+ (64.0 * static_cast(d))
+ );
+ bool in_range = true;
+ bool finite = true;
+#pragma omp parallel for num_threads(threads) reduction(&& : finite, in_range) schedule(static)
+ for (std::ptrdiff_t loop_index = 0; loop_index < static_cast(n * d);
+ ++loop_index) {
+ const size_t i = static_cast(loop_index);
+ finite = finite && std::isfinite(x[i]);
+ in_range = in_range && std::abs(static_cast(x[i])) <= max_coordinate;
+ }
+ if (!finite) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " x must contain only finite values"
+ );
+ }
+ if (!in_range) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) +
+ " x exceeds the safe float32 coordinate range"
+ );
+ }
+ if (verbose && min_points_per_centroid > 0 && n / k < min_points_per_centroid) {
+ std::cerr << "WARNING " << Assignment::kName << " clustering " << n
+ << " points to " << k << " centroids: please provide at least "
+ << min_points_per_centroid << " training points per centroid\n";
+ }
+
+ // Retraining may borrow the previous centroids, including a subrange.
+ // Preserve that input through initialization, updates, and final assignment.
+ std::vector input_copy;
+ const std::less before;
+ if (!centroids.empty() && !before(x, centroids.data()) &&
+ before(x, centroids.data() + centroids.size())) {
+ input_copy.assign(x, x + n * d);
+ x = input_copy.data();
+ }
+
+ Assignment assigner(d, k, x, n, threads, *this);
+
+ centroids.resize(k * d);
+ assignments.resize(n);
+ distances.resize(n);
+ iteration_stats.clear();
+ iteration_stats.reserve(niter);
+ initialize_centroids(x, n);
+ if (spherical) {
+ normalize_centroids();
+ }
+
+ std::vector previous_centroids(centroids.size());
+ std::vector cluster_sizes(k);
+ std::vector sums(centroids.size());
+ const size_t update_workers = std::min(k, static_cast(threads));
+ std::vector> update_point_bins(update_workers);
+ std::vector update_worker_for_cluster(k);
+ for (size_t worker = 0; worker < update_workers; ++worker) {
+ const size_t first_cluster = k * worker / update_workers;
+ const size_t last_cluster = k * (worker + 1) / update_workers;
+ for (size_t cluster = first_cluster; cluster < last_cluster; ++cluster) {
+ update_worker_for_cluster[cluster] = worker;
+ }
+ update_point_bins[worker].reserve(n / update_workers);
+ }
+ double previous_obj = std::numeric_limits::infinity();
+
+ for (size_t iteration = 0; iteration < niter; ++iteration) {
+ previous_centroids = centroids;
+ assigner.assign(
+ previous_centroids.data(), assignments.data(), distances.data()
+ );
+
+ const double obj = std::accumulate(distances.begin(), distances.end(), 0.0);
+ std::fill(sums.begin(), sums.end(), 0.0);
+ std::fill(cluster_sizes.begin(), cluster_sizes.end(), size_t{0});
+ update_centroids(
+ x, n, sums, cluster_sizes, update_point_bins, update_worker_for_cluster
+ );
+ const size_t nsplit = consolidate_centroids(x, n, sums, cluster_sizes);
+ const double shift = centroid_shift(previous_centroids);
+ iteration_stats.push_back(KMeansIterationStats{
+ iteration + 1, obj, shift, nsplit});
+
+ if (verbose) {
+ std::cout << Assignment::kName << " iteration " << iteration + 1 << "/"
+ << niter << " | objective=" << obj << " | shift=" << shift
+ << " | splits=" << nsplit << '\n';
+ }
+
+ const bool converged = detail::should_terminate_by_objective(
+ previous_obj, obj, early_stop_threshold
+ );
+ previous_obj = obj;
+ if (iteration > 0 && converged) {
+ break;
+ }
+ }
+
+ // Return assignments for the final centroids, not the centroids from
+ // the beginning of the last update step.
+ if (final_assignment == FinalAssignmentMode::Exact) {
+ detail::exact_assign(
+ x,
+ centroids.data(),
+ n,
+ d,
+ k,
+ spherical,
+ threads,
+ assignments.data(),
+ distances.data()
+ );
+ } else {
+ assigner.assign(centroids.data(), assignments.data(), distances.data(), true);
+ }
+ final_obj = std::accumulate(distances.begin(), distances.end(), 0.0);
+ }
+
+ size_t d;
+ size_t k;
+ std::vector centroids;
+ std::vector assignments;
+ std::vector distances;
+ std::vector iteration_stats;
+ /// Objective of the final returned assignments against the final centroids.
+ double final_obj = 0.0;
+
+ private:
+ void validate_parameters() const {
+ if (d == 0 || k == 0 || niter == 0) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " d, k, and niter must be positive"
+ );
+ }
+ if (d < 64 || d > rotator_impl::FhtKacRotator::kMaxDim) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) +
+ " currently requires dimensions in the range [64, 65536]"
+ );
+ }
+ if (num_threads > static_cast(std::numeric_limits::max())) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " num_threads must fit in an OpenMP int"
+ );
+ }
+ if (final_assignment != FinalAssignmentMode::Approximate &&
+ final_assignment != FinalAssignmentMode::Exact) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) + " final_assignment mode is invalid"
+ );
+ }
+ Assignment::validate_parameters(k, *this);
+ if (!std::isfinite(early_stop_threshold) || early_stop_threshold < 0.0 ||
+ early_stop_threshold > 1.0) {
+ throw std::invalid_argument(
+ std::string(Assignment::kName) +
+ " early_stop_threshold must be in the range [0, 1]"
+ );
+ }
+ }
+
+ void initialize_centroids(const float* x, size_t n) {
+ std::vector indices(n);
+ std::iota(indices.begin(), indices.end(), size_t{0});
+ std::mt19937 rng(seed);
+ std::shuffle(indices.begin(), indices.end(), rng);
+ for (size_t cluster = 0; cluster < k; ++cluster) {
+ std::memcpy(
+ centroids.data() + cluster * d, x + indices[cluster] * d, d * sizeof(float)
+ );
+ }
+ }
+
+ void update_centroids(
+ const float* x,
+ size_t n,
+ std::vector& sums,
+ std::vector& cluster_sizes,
+ std::vector>& point_bins,
+ const std::vector& worker_for_cluster
+ ) {
+ for (auto& bin : point_bins) {
+ bin.clear();
+ }
+ // Keep point order within each cluster, preserving the original sums.
+ for (size_t point = 0; point < n; ++point) {
+ point_bins[worker_for_cluster[assignments[point]]].push_back(point);
+ }
+ const uint32_t threads = effective_num_threads();
+ static_cast(threads);
+#pragma omp parallel for num_threads(threads) schedule(static)
+ for (std::ptrdiff_t loop_worker = 0;
+ loop_worker < static_cast(point_bins.size());
+ ++loop_worker) {
+ const size_t worker = static_cast(loop_worker);
+ simd::accumulate_cluster_sums(
+ x,
+ point_bins[worker].data(),
+ point_bins[worker].size(),
+ d,
+ assignments.data(),
+ sums.data(),
+ cluster_sizes.data()
+ );
+ }
+ }
+
+ size_t consolidate_centroids(
+ const float* x,
+ size_t n,
+ const std::vector& sums,
+ std::vector& cluster_sizes
+ ) {
+ const uint32_t threads = effective_num_threads();
+ static_cast(threads);
+#pragma omp parallel for num_threads(threads)
+ for (std::ptrdiff_t loop_index = 0; loop_index < static_cast(k);
+ ++loop_index) {
+ const size_t cluster = static_cast(loop_index);
+ const double scale = cluster_sizes[cluster] == 0
+ ? 0.0
+ : 1.0 / static_cast(cluster_sizes[cluster]);
+ for (size_t dim = 0; dim < d; ++dim) {
+ centroids[cluster * d + dim] =
+ static_cast(sums[cluster * d + dim] * scale);
+ }
+ }
+ if (spherical) {
+ normalize_centroids();
+ }
+
+ const size_t nsplit = static_cast(
+ std::count(cluster_sizes.begin(), cluster_sizes.end(), size_t{0})
+ );
+ if (nsplit == 0) {
+ return 0;
+ }
+
+ // Reuse the distance buffer after recording the pre-update objective.
+ // Keep populated means intact and give unused centroids poorly fitted
+ // training points. Unlike coordinate perturbations, this also works at zero.
+ const auto distance_func =
+ spherical ? dot_product_dis : euclidean_sqr;
+ const auto less = [&](size_t left, size_t right) {
+ return distances[left] == distances[right] ? left > right
+ : distances[left] < distances[right];
+ };
+ if (nsplit == 1) {
+ // A single empty centroid needs only one eligible maximum per worker.
+ std::vector best_points(threads, n);
+#pragma omp parallel num_threads(threads)
+ {
+ size_t best = n;
+#pragma omp for schedule(static)
+ for (std::ptrdiff_t loop_index = 0;
+ loop_index < static_cast(n);
+ ++loop_index) {
+ const size_t point = static_cast(loop_index);
+ if (cluster_sizes[assignments[point]] <= 1) {
+ continue;
+ }
+ distances[point] = distance_func(
+ x + point * d, centroids.data() + assignments[point] * d, d
+ );
+ if (best == n || less(best, point)) {
+ best = point;
+ }
+ }
+ best_points[static_cast(omp_get_thread_num())] = best;
+ }
+ size_t point = n;
+ for (const size_t candidate : best_points) {
+ if (candidate != n && (point == n || less(point, candidate))) {
+ point = candidate;
+ }
+ }
+ assert(point != n); // n >= k guarantees a donor with at least two points.
+ const size_t cluster = static_cast(
+ std::find(cluster_sizes.begin(), cluster_sizes.end(), size_t{0}) -
+ cluster_sizes.begin()
+ );
+ std::copy_n(x + point * d, d, centroids.data() + cluster * d);
+ if (spherical) {
+ normalize_centroid(centroids.data() + cluster * d);
+ }
+ --cluster_sizes[assignments[point]];
+ cluster_sizes[cluster] = 1;
+ return 1;
+ }
+
+#pragma omp parallel for num_threads(threads) schedule(static)
+ for (std::ptrdiff_t loop_index = 0; loop_index < static_cast(n);
+ ++loop_index) {
+ const size_t point = static_cast(loop_index);
+ distances[point] =
+ distance_func(x + point * d, centroids.data() + assignments[point] * d, d);
+ }
+ std::vector candidates(n);
+ std::iota(candidates.begin(), candidates.end(), size_t{0});
+ std::make_heap(candidates.begin(), candidates.end(), less);
+ for (size_t cluster = 0; cluster < k; ++cluster) {
+ if (cluster_sizes[cluster] != 0) {
+ continue;
+ }
+ size_t point = 0;
+ do {
+ point = candidates.front();
+ std::pop_heap(candidates.begin(), candidates.end(), less);
+ candidates.pop_back();
+ } while (cluster_sizes[assignments[point]] <= 1);
+ std::copy_n(x + point * d, d, centroids.data() + cluster * d);
+ if (spherical) {
+ normalize_centroid(centroids.data() + cluster * d);
+ }
+ --cluster_sizes[assignments[point]];
+ cluster_sizes[cluster] = 1;
+ }
+ return nsplit;
+ }
+
+ void normalize_centroid(float* centroid) const {
+ double squared_norm = 0;
+ for (size_t dim = 0; dim < d; ++dim) {
+ squared_norm += static_cast(centroid[dim]) * centroid[dim];
+ }
+ if (squared_norm == 0) {
+ return;
+ }
+ const double scale = 1.0 / std::sqrt(squared_norm);
+ for (size_t dim = 0; dim < d; ++dim) {
+ centroid[dim] = static_cast(centroid[dim] * scale);
+ }
+ }
+
+ void normalize_centroids() {
+ const uint32_t threads = effective_num_threads();
+ static_cast(threads);
+#pragma omp parallel for num_threads(threads)
+ for (std::ptrdiff_t loop_index = 0; loop_index < static_cast(k);
+ ++loop_index) {
+ const size_t cluster = static_cast(loop_index);
+ normalize_centroid(centroids.data() + cluster * d);
+ }
+ }
+
+ [[nodiscard]] double centroid_shift(const std::vector& previous_centroids
+ ) const {
+ const uint32_t threads = effective_num_threads();
+ static_cast(threads);
+ double shift = 0;
+#pragma omp parallel for num_threads(threads) reduction(+ : shift)
+ for (std::ptrdiff_t loop_index = 0;
+ loop_index < static_cast(centroids.size());
+ ++loop_index) {
+ const size_t index = static_cast(loop_index);
+ const double difference =
+ static_cast(centroids[index]) - previous_centroids[index];
+ shift += difference * difference;
+ }
+ return shift;
+ }
+
+ [[nodiscard]] uint32_t effective_num_threads() const {
+ return static_cast(resolve_num_threads(num_threads));
+ }
+};
+
+} // namespace detail
+
+} // namespace rabitqlib::clustering
diff --git a/include/rabitqlib/clustering/qgkmeans.hpp b/include/rabitqlib/clustering/qgkmeans.hpp
new file mode 100644
index 00000000..3de60e81
--- /dev/null
+++ b/include/rabitqlib/clustering/qgkmeans.hpp
@@ -0,0 +1,408 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "rabitqlib/clustering/detail/kmeans.hpp"
+#include "rabitqlib/defines.hpp"
+#include "rabitqlib/fastscan/fastscan.hpp"
+#include "rabitqlib/index/query.hpp"
+#include "rabitqlib/index/symqg/qg.hpp"
+#include "rabitqlib/index/symqg/qg_builder.hpp"
+#include "rabitqlib/utils/buffer.hpp"
+#include "rabitqlib/utils/rotator.hpp"
+#include "rabitqlib/utils/space.hpp"
+#include "rabitqlib/utils/tools.hpp"
+#include "rabitqlib/utils/visited_set.hpp"
+
+namespace rabitqlib::qgkmeans {
+
+using clustering::FinalAssignmentMode;
+using QGKMeansIterationStats = clustering::KMeansIterationStats;
+
+namespace detail {
+
+using clustering::detail::exact_assign;
+using clustering::detail::should_terminate_by_objective;
+class QGAssignment;
+
+} // namespace detail
+
+struct QGKMeansParameters {
+ /// Number of clustering iterations.
+ size_t niter = 25;
+ bool verbose = false;
+ /// Normalize centroids after each update.
+ bool spherical = false;
+ uint32_t seed = 42;
+ /// Warn below this number of training vectors per centroid.
+ size_t min_points_per_centroid = 39;
+ /// Stop when the absolute relative objective (WCSS) change is at most this value.
+ double early_stop_threshold = 0.0;
+ FinalAssignmentMode final_assignment = FinalAssignmentMode::Approximate;
+
+ // SymphonyQG assignment parameters.
+ uint32_t graph_degree = 32;
+ uint32_t ef_build = 240;
+ uint32_t ef_search = 16;
+ /// Graph-building passes after PiPNN initialization; the last refines the graph.
+ uint32_t graph_build_iterations = 1;
+ uint32_t num_threads = 0;
+ /// Centroid storage in QG: 0 for raw float32, or 4/8-bit RaBitQ.
+ uint32_t quantization_bits = 0;
+};
+
+/**
+ * Builds a SymphonyQG over a fixed number of centroids and assigns vectors to
+ * them. The graph allocation and per-thread search scratch are reused between
+ * calls, and the previous assignment is used as the next call's search hint.
+ */
+class QGAssigner {
+ friend class detail::QGAssignment;
+
+ private:
+ struct ThreadScratch {
+ std::vector rotated_query;
+ std::vector estimated_distances;
+ std::vector lookup_table;
+ BatchQuery batch_query;
+ buffer::SearchBuffer search_pool;
+ buffer::SearchBuffer result_pool;
+
+ ThreadScratch(size_t padded_dim, size_t M, size_t ef_search)
+ : rotated_query(padded_dim)
+ , estimated_distances(M)
+ , lookup_table(padded_dim << 2)
+ , search_pool(ef_search)
+ , result_pool(1) {}
+ };
+
+ public:
+ QGAssigner(
+ size_t d,
+ size_t k,
+ uint32_t M,
+ uint32_t ef_construction,
+ uint32_t ef_search,
+ uint32_t niter,
+ uint32_t num_threads = 0,
+ uint32_t seed = 42,
+ MetricType metric_type = METRIC_L2,
+ uint32_t quantization_bits = 0
+ )
+ : d_(d)
+ , k_(k)
+ , M_(M)
+ , ef_construction_(ef_construction)
+ , ef_search_(ef_search)
+ , niter_(niter)
+ , num_threads_(num_threads)
+ , seed_(seed) {
+ validate_configuration();
+ num_threads_ = static_cast(resolve_num_threads(num_threads_));
+
+ graph_ = std::make_unique>(
+ k_, d_, M_, metric_type, RotatorType::FhtKacRotator, quantization_bits, seed_
+ );
+ graph_->set_ef(ef_search_);
+
+ scratch_.reserve(num_threads_);
+ for (uint32_t thread = 0; thread < num_threads_; ++thread) {
+ scratch_.emplace_back(
+ std::make_unique(graph_->padded_dim(), M_, ef_search_)
+ );
+ }
+ }
+
+ // Optional n-element cache: fill it on the first call, then reuse it only
+ // for the same input vectors and this assigner. A null pointer disables it.
+ void assign(
+ const float* centroids,
+ const float* x,
+ size_t n,
+ PID* labels,
+ float* distances,
+ float* query_sums = nullptr,
+ bool sums_ready = false
+ ) {
+ if (centroids == nullptr || x == nullptr || labels == nullptr ||
+ distances == nullptr) {
+ throw std::invalid_argument(
+ "QGAssigner input and output pointers must not be null"
+ );
+ }
+
+ if (n > static_cast(std::numeric_limits::max()) / d_) {
+ throw std::invalid_argument("QGAssigner input dimensions overflow ptrdiff_t");
+ }
+
+ sums_ready = query_sums != nullptr && sums_ready;
+ if (builder_) {
+ builder_->reset(centroids);
+ } else {
+ builder_ = std::make_unique(
+ *graph_,
+ ef_construction_,
+ centroids,
+ num_threads_,
+ symqg::QGInitialization::PiPNN,
+ seed_,
+ true
+ );
+ }
+ if (niter_ == 1) {
+ builder_->build();
+ } else {
+ builder_->build(niter_);
+ }
+ const auto distance_func = graph_->metric_type() == METRIC_IP
+ ? dot_product_dis
+ : euclidean_sqr;
+
+ // Stable grouping changes only the order of independent full searches.
+ std::vector query_order;
+ if (previous_labels_.size() == n && n <= std::numeric_limits::max()) {
+ query_order.resize(n);
+ std::vector offsets(k_ + 1, 0);
+ for (PID label : previous_labels_) {
+ ++offsets[label + 1];
+ }
+ std::partial_sum(offsets.begin(), offsets.end(), offsets.begin());
+ for (size_t point = 0; point < n; ++point) {
+ query_order[offsets[previous_labels_[point]]++] =
+ static_cast(point);
+ }
+ }
+ std::vector errors(num_threads_);
+#pragma omp parallel for num_threads(num_threads_) schedule(dynamic, 64)
+ for (std::ptrdiff_t loop_index = 0; loop_index < static_cast(n);
+ ++loop_index) {
+ const size_t i = query_order.empty()
+ ? static_cast(loop_index)
+ : query_order[static_cast(loop_index)];
+ const int thread_id = omp_get_thread_num();
+ if (errors[static_cast(thread_id)]) {
+ continue;
+ }
+ try {
+ ThreadScratch& scratch = *scratch_[static_cast(thread_id)];
+ // Keep visited storage with the worker, as in QuantizedGraph::search.
+ thread_local VisitedSet visited;
+ thread_local size_t visited_size = 0;
+ thread_local size_t visited_capacity = 0;
+ const size_t capacity =
+ std::min(static_cast(ef_search_) * ef_search_, k_ / 10);
+ if (visited_size != k_ || visited_capacity != capacity) {
+ visited.initialize(k_, capacity);
+ visited_size = k_;
+ visited_capacity = capacity;
+ }
+ const PID hint =
+ previous_labels_.size() == n ? previous_labels_[i] : kPidMax;
+ graph_->search_with_scratch(
+ x + i * d_,
+ 1,
+ labels + i,
+ distances + i,
+ scratch.rotated_query.data(),
+ scratch.estimated_distances.data(),
+ scratch.lookup_table.data(),
+ scratch.batch_query,
+ scratch.search_pool,
+ scratch.result_pool,
+ visited,
+ hint,
+ sums_ready ? query_sums + i : nullptr
+ );
+ if (query_sums != nullptr && !sums_ready) {
+ query_sums[i] = scratch.batch_query.k1xsumq();
+ }
+ if (graph_->is_quantized()) {
+ // Keep objectives and returned distances relative to the original
+ // centroids; graph search scores are quantized estimates.
+ distances[i] =
+ distance_func(x + i * d_, centroids + labels[i] * d_, d_);
+ }
+ } catch (...) {
+ errors[static_cast(thread_id)] = std::current_exception();
+ }
+ }
+
+ for (const auto& error : errors) {
+ if (error) {
+ std::rethrow_exception(error);
+ }
+ }
+
+ previous_labels_.assign(labels, labels + n);
+ }
+
+ void clear_hints() { previous_labels_.clear(); }
+
+ private:
+ void validate_configuration() const {
+ if (num_threads_ > static_cast(std::numeric_limits::max())) {
+ throw std::invalid_argument("QGAssigner num_threads must fit in an OpenMP int");
+ }
+ if (d_ < 64 || d_ > rotator_impl::FhtKacRotator::kMaxDim) {
+ throw std::invalid_argument(
+ "QGKMeans currently requires dimensions in the range [64, 65536]"
+ );
+ }
+ if (M_ == 0 || M_ % fastscan::kBatchSize != 0) {
+ throw std::invalid_argument(
+ "QGKMeans graph degree must be a positive multiple of 32"
+ );
+ }
+ if (k_ <= M_) {
+ throw std::invalid_argument(
+ "QGKMeans requires more centroids than the graph degree"
+ );
+ }
+ if (ef_construction_ == 0 || ef_search_ == 0) {
+ throw std::invalid_argument("QGKMeans ef_build and ef_search must be positive");
+ }
+ if (niter_ == 0) {
+ throw std::invalid_argument("QGKMeans graph_build_iterations must be positive");
+ }
+ }
+
+ size_t d_;
+ size_t k_;
+ uint32_t M_;
+ uint32_t ef_construction_;
+ uint32_t ef_search_;
+ uint32_t niter_;
+ uint32_t num_threads_;
+ uint32_t seed_;
+ std::unique_ptr> graph_;
+ std::unique_ptr builder_;
+ std::vector> scratch_;
+ std::vector previous_labels_;
+};
+
+namespace detail {
+
+// Retain graph-specific state for one synchronous training call.
+class QGAssignment {
+ size_t d_;
+ const float* x_;
+ size_t n_;
+ uint32_t num_threads_;
+ MetricType metric_type_;
+ bool quantized_;
+ QGAssigner assigner_;
+ bool cache_query_sums_;
+ std::vector query_sums_;
+ bool query_sums_ready_ = false;
+
+ public:
+ static constexpr const char* kName = "QGKMeans";
+
+ static void validate_parameters(size_t, const QGKMeansParameters& cp) {
+ if (cp.quantization_bits != 0 && cp.quantization_bits != 4 &&
+ cp.quantization_bits != 8) {
+ throw std::invalid_argument("QGKMeans quantization_bits must be 0, 4, or 8");
+ }
+ }
+
+ QGAssignment(
+ size_t d,
+ size_t k,
+ const float* x,
+ size_t n,
+ uint32_t num_threads,
+ const QGKMeansParameters& cp
+ )
+ : d_(d)
+ , x_(x)
+ , n_(n)
+ , num_threads_(num_threads)
+ , metric_type_(cp.spherical ? METRIC_IP : METRIC_L2)
+ , quantized_(cp.quantization_bits != 0)
+ , assigner_(
+ d,
+ k,
+ cp.graph_degree,
+ cp.ef_build,
+ cp.ef_search,
+ cp.graph_build_iterations,
+ cp.num_threads,
+ cp.seed,
+ metric_type_,
+ cp.quantization_bits
+ )
+ // Inputs and the assigner's rotator are immutable for this training call.
+ , cache_query_sums_(
+ assigner_.graph_->padded_dim() >= 1024 &&
+ (cp.niter > 1 || cp.final_assignment != FinalAssignmentMode::Exact)
+ ) {}
+
+ void assign(const float* centroids, PID* labels, float* distances, bool final = false) {
+ if (cache_query_sums_ && query_sums_.empty()) {
+ // Allocate after centroid initialization releases its point permutation.
+ query_sums_.resize(n_);
+ }
+ std::vector previous_labels;
+ if (final && quantized_) {
+ previous_labels.assign(labels, labels + n_);
+ }
+ assigner_.assign(
+ centroids,
+ x_,
+ n_,
+ labels,
+ distances,
+ query_sums_.empty() ? nullptr : query_sums_.data(),
+ query_sums_ready_
+ );
+ query_sums_ready_ = true;
+ if (previous_labels.empty()) {
+ return;
+ }
+
+ // Protect the returned assignments without changing exploration during
+ // Lloyd iterations: uphill approximate moves can improve final centroids.
+ const auto distance_func =
+ metric_type_ == METRIC_IP ? dot_product_dis : euclidean_sqr;
+#pragma omp parallel for num_threads(num_threads_) schedule(static)
+ for (std::ptrdiff_t loop_index = 0; loop_index < static_cast(n_);
+ ++loop_index) {
+ const size_t point = static_cast(loop_index);
+ const PID previous = previous_labels[point];
+ if (previous == labels[point]) {
+ continue;
+ }
+ const float distance =
+ distance_func(x_ + point * d_, centroids + previous * d_, d_);
+ if (distance < distances[point]) {
+ labels[point] = previous;
+ distances[point] = distance;
+ }
+ }
+ }
+};
+
+} // namespace detail
+
+/** K-means with SymphonyQG graph assignment. */
+class QGKMeans
+ : public clustering::detail::LloydKMeans {
+ using Base = clustering::detail::LloydKMeans;
+
+ public:
+ QGKMeans(size_t d, size_t k) : QGKMeans(d, k, QGKMeansParameters{}) {}
+
+ QGKMeans(size_t d, size_t k, const QGKMeansParameters& cp) : Base(d, k, cp) {}
+};
+
+} // namespace rabitqlib::qgkmeans
diff --git a/include/rabitqlib/clustering/rabitqkmeans.hpp b/include/rabitqlib/clustering/rabitqkmeans.hpp
new file mode 100644
index 00000000..d7e54f6b
--- /dev/null
+++ b/include/rabitqlib/clustering/rabitqkmeans.hpp
@@ -0,0 +1,243 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "rabitqlib/clustering/detail/kmeans.hpp"
+#include "rabitqlib/defines.hpp"
+#include "rabitqlib/fastscan/fastscan.hpp"
+#include "rabitqlib/index/estimator.hpp"
+#include "rabitqlib/index/query.hpp"
+#include "rabitqlib/quantization/data_layout.hpp"
+#include "rabitqlib/quantization/rabitq.hpp"
+#include "rabitqlib/utils/rotator.hpp"
+#include "rabitqlib/utils/space.hpp"
+
+namespace rabitqlib::rabitqkmeans {
+
+using clustering::FinalAssignmentMode;
+using RaBitQKMeansIterationStats = clustering::KMeansIterationStats;
+
+struct RaBitQKMeansParameters {
+ /// Number of clustering iterations.
+ size_t niter = 25;
+ bool verbose = false;
+ /// Normalize centroids after each update.
+ bool spherical = false;
+ uint32_t seed = 42;
+ /// Warn below this number of training vectors per centroid.
+ size_t min_points_per_centroid = 39;
+ /// Stop when the absolute relative objective (WCSS) change is at most this value.
+ double early_stop_threshold = 0.0;
+ FinalAssignmentMode final_assignment = FinalAssignmentMode::Approximate;
+ uint32_t num_threads = 0;
+};
+
+namespace detail {
+
+// Encode immutable training points once; each moving centroid supplies one LUT.
+// RaBitQKMeans validates inputs and keeps the borrowed x buffer alive through training.
+class RaBitQAssigner {
+ size_t d_;
+ size_t k_;
+ const float* x_;
+ size_t n_;
+ size_t num_batches_;
+ uint32_t num_threads_;
+ MetricType metric_type_;
+ size_t padded_dim_;
+ rotator_impl::FhtKacRotator rotator_;
+ std::vector center_;
+ std::vector batch_data_;
+ std::vector> centroid_queries_;
+ std::vector rotated_query_;
+ std::vector lookup_table_;
+
+ public:
+ static constexpr const char* kName = "RaBitQKMeans";
+
+ static void validate_parameters(size_t k, const RaBitQKMeansParameters&) {
+ if (k - 1 > std::numeric_limits::max()) {
+ throw std::invalid_argument(
+ "RaBitQKMeans k exceeds the representable assignment ID range"
+ );
+ }
+ }
+
+ RaBitQAssigner(
+ size_t d,
+ size_t k,
+ const float* x,
+ size_t n,
+ uint32_t num_threads,
+ const RaBitQKMeansParameters& cp
+ )
+ : d_(d)
+ , k_(k)
+ , x_(x)
+ , n_(n)
+ , num_batches_(1 + (n - 1) / fastscan::kBatchSize)
+ , num_threads_(static_cast(std::min(num_threads, num_batches_)))
+ , metric_type_(cp.spherical ? METRIC_IP : METRIC_L2)
+ , padded_dim_(rotator_impl::padding_requirement(d, RotatorType::FhtKacRotator))
+ , rotator_(d, padded_dim_, cp.seed)
+ , center_(d)
+ , batch_data_(num_batches_ * QGBatchDataMap::data_bytes(padded_dim_), 0)
+ , centroid_queries_(k)
+ , rotated_query_(padded_dim_)
+ , lookup_table_(padded_dim_ * 4) {
+ {
+ std::vector> partial_sums(
+ num_threads_, std::vector(d_, 0.0)
+ );
+#pragma omp parallel num_threads(num_threads_)
+ {
+ auto& sums = partial_sums[static_cast(omp_get_thread_num())];
+#pragma omp for schedule(static)
+ for (std::ptrdiff_t point = 0; point < static_cast(n_);
+ ++point) {
+ const float* vector = x_ + static_cast(point) * d_;
+ for (size_t dim = 0; dim < d_; ++dim) {
+ sums[dim] += static_cast(vector[dim]);
+ }
+ }
+ }
+ for (size_t dim = 0; dim < d_; ++dim) {
+ double sum = 0.0;
+ for (const auto& partial : partial_sums) {
+ sum += partial[dim];
+ }
+ center_[dim] = static_cast(sum / static_cast(n_));
+ }
+ }
+
+ std::vector rotated_center(padded_dim_);
+ rotator_.rotate(center_.data(), rotated_center.data());
+ std::vector> rotated_blocks(
+ num_threads_, std::vector(fastscan::kBatchSize * padded_dim_)
+ );
+ std::vector errors(num_threads_);
+#pragma omp parallel for num_threads(num_threads_) schedule(static)
+ for (std::ptrdiff_t batch = 0; batch < static_cast(num_batches_);
+ ++batch) {
+ const auto thread = static_cast(omp_get_thread_num());
+ if (errors[thread]) {
+ continue;
+ }
+ try {
+ const auto block = static_cast(batch);
+ const size_t first = block * fastscan::kBatchSize;
+ const size_t count = std::min(fastscan::kBatchSize, n_ - first);
+ auto& rotated = rotated_blocks[thread];
+ for (size_t point = 0; point < count; ++point) {
+ rotator_.rotate(
+ x_ + (first + point) * d_, rotated.data() + point * padded_dim_
+ );
+ }
+ quant::quantize_qg_batch(
+ rotated.data(),
+ rotated_center.data(),
+ count,
+ padded_dim_,
+ batch_data_.data() +
+ block * QGBatchDataMap::data_bytes(padded_dim_),
+ metric_type_
+ );
+ } catch (...) { errors[thread] = std::current_exception(); }
+ }
+ for (const auto& error : errors) {
+ if (error) {
+ std::rethrow_exception(error);
+ }
+ }
+ }
+
+ void assign(const float* centroids, PID* labels, float* distances, bool = false) {
+ const auto distance_func =
+ metric_type_ == METRIC_IP ? dot_product_dis : euclidean_sqr;
+ for (size_t cluster = 0; cluster < k_; ++cluster) {
+ const float* centroid = centroids + cluster * d_;
+ rotator_.rotate(centroid, rotated_query_.data());
+ auto& query = centroid_queries_[cluster];
+ query.reset(
+ rotated_query_.data(), padded_dim_, lookup_table_.data(), metric_type_
+ );
+ query.set_g_add(distance_func(centroid, center_.data(), d_));
+ }
+
+ std::vector errors(num_threads_);
+#pragma omp parallel for num_threads(num_threads_) schedule(static)
+ for (std::ptrdiff_t batch = 0; batch < static_cast(num_batches_);
+ ++batch) {
+ const auto thread = static_cast(omp_get_thread_num());
+ if (errors[thread]) {
+ continue;
+ }
+ try {
+ const auto block = static_cast(batch);
+ const size_t first = block * fastscan::kBatchSize;
+ const size_t count = std::min(fastscan::kBatchSize, n_ - first);
+ const char* codes = batch_data_.data() +
+ block * QGBatchDataMap::data_bytes(padded_dim_);
+ std::array estimated_distances{};
+ std::array best_distances;
+ best_distances.fill(std::numeric_limits::infinity());
+ std::array best_labels{};
+ for (size_t cluster = 0; cluster < k_; ++cluster) {
+ qg_batch_estdist(
+ codes,
+ centroid_queries_[cluster],
+ padded_dim_,
+ estimated_distances.data()
+ );
+ for (size_t point = 0; point < count; ++point) {
+ // Ascending centroid order retains the lowest ID for exact score
+ // ties.
+ if (estimated_distances[point] < best_distances[point]) {
+ best_distances[point] = estimated_distances[point];
+ best_labels[point] = static_cast(cluster);
+ }
+ }
+ }
+ for (size_t point = 0; point < count; ++point) {
+ const size_t id = first + point;
+ labels[id] = best_labels[point];
+ // Scores choose the centroid; objectives retain original-space
+ // distances.
+ distances[id] = distance_func(
+ x_ + id * d_, centroids + best_labels[point] * d_, d_
+ );
+ }
+ } catch (...) { errors[thread] = std::current_exception(); }
+ }
+ for (const auto& error : errors) {
+ if (error) {
+ std::rethrow_exception(error);
+ }
+ }
+ }
+};
+
+} // namespace detail
+
+/** K-means with a graph-free RaBitQ scan, recommended for small cluster counts. */
+class RaBitQKMeans : public clustering::detail::
+ LloydKMeans {
+ using Base =
+ clustering::detail::LloydKMeans;
+
+ public:
+ RaBitQKMeans(size_t d, size_t k) : RaBitQKMeans(d, k, RaBitQKMeansParameters{}) {}
+
+ RaBitQKMeans(size_t d, size_t k, const RaBitQKMeansParameters& cp) : Base(d, k, cp) {}
+};
+
+} // namespace rabitqlib::rabitqkmeans
diff --git a/include/rabitqlib/index/lut.hpp b/include/rabitqlib/index/lut.hpp
index 242d9410..a37559d5 100644
--- a/include/rabitqlib/index/lut.hpp
+++ b/include/rabitqlib/index/lut.hpp
@@ -51,30 +51,55 @@ class Lut {
explicit Lut(const T* rotated_query, size_t padded_dim, bool use_hacc = false)
: table_length_(checked_table_length(padded_dim))
, lut_(checked_storage_length(padded_dim, use_hacc)) {
- // quantize float lut
std::vector lut_float(table_length_);
- fastscan::pack_lut(padded_dim, rotated_query, lut_float.data());
+ std::vector lut_u16(use_hacc ? table_length_ : 0);
+ reset(rotated_query, padded_dim, lut_float.data(), lut_u16.data(), use_hacc);
+ }
+
+ /**
+ * Reinitialize the lookup table while reusing its allocation.
+ * lut_float_scratch must contain space for padded_dim * 4 floats.
+ * lut_u16_scratch is additionally required when use_hacc is true.
+ */
+ void reset(
+ const T* rotated_query,
+ size_t padded_dim,
+ float* lut_float_scratch,
+ uint16_t* lut_u16_scratch = nullptr,
+ bool use_hacc = false
+ ) {
+ const size_t required_size = checked_storage_length(padded_dim, use_hacc);
+ if (rotated_query == nullptr || lut_float_scratch == nullptr) {
+ throw std::invalid_argument("Lut::reset requires query and float scratch");
+ }
+ if (use_hacc && lut_u16_scratch == nullptr) {
+ throw std::invalid_argument(
+ "Lut::reset requires uint16 scratch for high-accuracy lookup tables"
+ );
+ }
+ table_length_ = checked_table_length(padded_dim);
+ lut_.resize(required_size);
+
+ fastscan::pack_lut(padded_dim, rotated_query, lut_float_scratch);
T vl_lut;
T vr_lut;
- data_range(lut_float.data(), table_length_, vl_lut, vr_lut);
+ data_range(lut_float_scratch, table_length_, vl_lut, vr_lut);
if (use_hacc) {
delta_ = (vr_lut - vl_lut) / ((1 << kNumBitsHacc) - 1);
-
- // quantize float lut into uint16 then change to split table
- std::vector lut_u16(table_length_);
scalar_quantize(
- lut_u16.data(), lut_float.data(), table_length_, vl_lut, delta_
+ lut_u16_scratch, lut_float_scratch, table_length_, vl_lut, delta_
);
- fastscan::transfer_lut_hacc(lut_u16.data(), padded_dim, lut_.data());
+ fastscan::transfer_lut_hacc(lut_u16_scratch, padded_dim, lut_.data());
} else {
delta_ = (vr_lut - vl_lut) / ((1 << kNumBits) - 1);
- scalar_quantize(lut_.data(), lut_float.data(), table_length_, vl_lut, delta_);
+ scalar_quantize(lut_.data(), lut_float_scratch, table_length_, vl_lut, delta_);
}
- size_t num_table = table_length_ / 16;
- sum_vl_lut_ = vl_lut * static_cast(num_table);
+ const size_t num_tables = table_length_ / 16;
+ sum_vl_lut_ = vl_lut * static_cast(num_tables);
}
+
[[nodiscard]] const uint8_t* lut() const { return lut_.data(); };
[[nodiscard]] T delta() const { return delta_; };
[[nodiscard]] T sum_vl() const { return sum_vl_lut_; };
diff --git a/include/rabitqlib/index/query.hpp b/include/rabitqlib/index/query.hpp
index 3440d765..ec4f1460 100644
--- a/include/rabitqlib/index/query.hpp
+++ b/include/rabitqlib/index/query.hpp
@@ -21,9 +21,11 @@ class BatchQuery {
Lut lookup_table_;
T G_add_ = 0;
T G_k1xSumq_ = 0; // G_k1xSumq
- MetricType metric_type_;
+ MetricType metric_type_ = METRIC_L2;
public:
+ explicit BatchQuery() = default;
+
explicit BatchQuery(
const T* rotated_query, size_t padded_dim, MetricType metric_type = METRIC_L2
)
@@ -49,6 +51,32 @@ class BatchQuery {
G_add_ = metric_type_ == METRIC_IP ? dist - T{1} : dist;
}
+ /**
+ * Reinitialize the query while reusing the lookup-table allocation.
+ * An optional correction must come from this exact rotated query.
+ */
+ void reset(
+ const T* rotated_query,
+ size_t padded_dim,
+ float* lut_float_scratch,
+ MetricType metric_type = METRIC_L2,
+ const T* k1xsumq = nullptr
+ ) {
+ metric_type_ = metric_type;
+ lookup_table_.reset(rotated_query, padded_dim, lut_float_scratch);
+
+ if (k1xsumq != nullptr) {
+ G_k1xSumq_ = *k1xsumq;
+ } else {
+ constexpr float c_1 = -((1 << 1) - 1) / 2.F;
+ const T sumq = std::accumulate(
+ rotated_query, rotated_query + padded_dim, static_cast(0)
+ );
+ G_k1xSumq_ = sumq * c_1;
+ }
+ G_add_ = 0;
+ }
+
[[nodiscard]] const uint8_t* lut() const { return lookup_table_.lut(); }
};
diff --git a/include/rabitqlib/index/symqg/qg.hpp b/include/rabitqlib/index/symqg/qg.hpp
index 2758bf62..67c07be5 100644
--- a/include/rabitqlib/index/symqg/qg.hpp
+++ b/include/rabitqlib/index/symqg/qg.hpp
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#include "rabitqlib/defines.hpp"
@@ -30,6 +31,7 @@ class QuantizedQuery {
size_t padded_dim,
MetricType metric_type
);
+ QuantizedQuery(const float* rotated_query, const QuantizedQuery& prepared);
[[nodiscard]] const float* rotated_query() const;
[[nodiscard]] float k1xsumq() const;
[[nodiscard]] float g_add() const;
@@ -73,6 +75,13 @@ class QuantizedGraph {
size_t row_offset_ = 0; // length of entire row
size_t ef_ = 0;
bool ready_ = false;
+ uint32_t seed_ = 42;
+
+ // Owned by QGBuilder and never persisted.
+ struct BuildCache {
+ std::vector rotated_vectors;
+ std::vector queries;
+ };
[[nodiscard]] static size_t checked_add(size_t lhs, size_t rhs);
@@ -90,23 +99,36 @@ class QuantizedGraph {
void set_quantization_centroid(const float* centroid);
- [[nodiscard]] char* get_row_data(PID data_id);
+ [[nodiscard]] char* get_row_data(PID data_id) {
+ return reinterpret_cast(get_vector(data_id));
+ }
- [[nodiscard]] const char* get_row_data(PID data_id) const;
+ [[nodiscard]] const char* get_row_data(PID data_id) const {
+ return reinterpret_cast(get_vector(data_id));
+ }
- [[nodiscard]] float* get_vector(PID data_id);
+ [[nodiscard]] float* get_vector(PID data_id) {
+ return data_.data() + ((row_offset_ / sizeof(float)) * data_id);
+ }
- [[nodiscard]] const float* get_vector(PID data_id) const;
+ [[nodiscard]] const float* get_vector(PID data_id) const {
+ return data_.data() + ((row_offset_ / sizeof(float)) * data_id);
+ }
- [[nodiscard]] char* get_quantized_vector(PID data_id);
+ [[nodiscard]] char* get_quantized_vector(PID data_id) { return get_row_data(data_id); }
- [[nodiscard]] const char* get_quantized_vector(PID data_id) const;
+ [[nodiscard]] const char* get_quantized_vector(PID data_id) const {
+ return get_row_data(data_id);
+ }
- void prepare_query(const float*, std::vector&, std::optional&)
- const;
+ void validate_search(const float*, uint32_t, const uint32_t*, const float*) const;
- const float*
- prepare_build_query(PID, std::vector&, std::optional&) const;
+ const float* prepare_build_query(
+ PID,
+ std::vector&,
+ std::optional&,
+ const BuildCache* = nullptr
+ ) const;
float point_distance(const float*, const QuantizedQuery*, PID) const;
@@ -114,25 +136,47 @@ class QuantizedGraph {
void reconstruct_quantized_vector(PID, float*) const;
- [[nodiscard]] char* get_batch_data(PID data_id);
+ [[nodiscard]] char* get_batch_data(PID data_id) {
+ return get_row_data(data_id) + batch_data_offset_;
+ }
- [[nodiscard]] const char* get_batch_data(PID data_id) const;
+ [[nodiscard]] const char* get_batch_data(PID data_id) const {
+ return get_row_data(data_id) + batch_data_offset_;
+ }
- [[nodiscard]] rabitqlib::detail::PackedArrayView get_neighbors(PID data_id);
+ [[nodiscard]] rabitqlib::detail::PackedArrayView get_neighbors(PID data_id) {
+ return rabitqlib::detail::PackedArrayView(
+ get_row_data(data_id) + neighbor_offset_
+ );
+ }
[[nodiscard]] rabitqlib::detail::ConstPackedArrayView get_neighbors(PID data_id
- ) const;
+ ) const {
+ return rabitqlib::detail::ConstPackedArrayView(
+ get_row_data(data_id) + neighbor_offset_
+ );
+ }
- void
- find_candidates(PID, size_t, std::vector>&, VisitedSet&, const std::vector&)
- const;
+ void find_candidates(
+ PID,
+ size_t,
+ std::vector>&,
+ VisitedSet&,
+ const std::vector&,
+ const BuildCache* = nullptr
+ ) const;
- void update_qg(PID, const std::vector>&);
+ void update_qg(
+ PID,
+ const std::vector>&,
+ const BuildCache* = nullptr,
+ std::vector* = nullptr
+ );
void
update_results(buffer::SearchBuffer&, VisitedSet&, const float*, const QuantizedQuery*);
- void scan_neighbors(
+ inline void scan_neighbors(
const BatchQuery&,
PID,
float*,
@@ -148,7 +192,8 @@ class QuantizedGraph