diff --git a/CMakeLists.txt b/CMakeLists.txt index 45ac186a..d63881f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(RaBitQLib VERSION 0.8.1 LANGUAGES CXX) +project(RaBitQLib VERSION 0.8.2 LANGUAGES CXX) include(CMakePackageConfigHelpers) include(GNUInstallDirs) diff --git a/README.md b/README.md index 8619bb73..a339c09b 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,26 @@ RaBitQ-Library is a research-backed library for vector quantization and approximate nearest-neighbor search. See the [research papers](https://vectordb-ntu.github.io/RaBitQ-Library/papers/). +## Why RaBitQ-Library + +- **Reference implementation.** Developed with the RaBitQ authors: 1–9-bit codes + with a theoretical error bound, plus indexes and clustering from the same research. +- **Fast to build and search.** Optimized SIMD kernels and batch scoring keep both + indexing and queries fast; see the + [benchmarks](https://vectordb-ntu.github.io/RaBitQ-Library/benchmarks/bench/). +- **Use an index or the quantizer.** Build ready-made indexes in Python or C++, + or link only the core and FastScan components into your own engine, as + [zvec](https://vectordb-ntu.github.io/RaBitQ-Library/integrations/zvec/) does. + ## News +- **v0.8.2:** three lines to an index: the first `add` trains, `nbits` defaults to 7 + and search settings are optional; faster IVF-RaBitQ `add` + ([notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.8.2/)). - **v0.8.1:** the default `num_threads=0` respects `OMP_NUM_THREADS`, fixing a macOS crash alongside Faiss and PyTorch ([notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.8.1/)). - **v0.8.0:** Faiss-style `train`, `add` and `search` for all Python indexes; 0.7 calls stay as deprecated forms until 0.9 ([notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.8.0/)). -- **v0.7.2:** Python type hints, path-like inputs and clearer input errors - ([notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.7.2/)). Earlier versions: [all releases](https://github.com/VectorDB-NTU/RaBitQ-Library/releases). @@ -49,7 +61,7 @@ settings, and limitations. ### Python ```bash -python -m pip install --upgrade "rabitqlib>=0.8.1" +python -m pip install --upgrade "rabitqlib>=0.8.2" ``` Wheels support CPython 3.11–3.14 on Linux x86-64/ARM64, Windows x86-64, @@ -67,16 +79,17 @@ rng = np.random.default_rng(42) data = rng.standard_normal((500, 64)).astype(np.float32) queries = rng.standard_normal((5, 64)).astype(np.float32) -index = IvfIndex(64, 5, nbits=4, metric="l2") -index.train(data) -index.add(data) -ids, distances = index.search(queries, k=10, nprobe=5) +index = IvfIndex(64, 5, metric="l2") +index.add(data) # the first add learns 5 centroids from data +ids, distances = index.search(queries, k=10, nprobe=5) # probe all 5 clusters print(ids.shape, distances.shape) # (5, 10) (5, 10) ``` -`train` learns the centroids, and `add` quantizes the vectors into their -clusters. IDs identify added rows; distances here estimate squared L2 distance, -with smaller values first. Quantization remains approximate even when all clusters are probed. +The first `add` learns the centroids from the data, then quantizes the vectors +into their clusters. IDs identify added rows; distances here estimate squared L2 +distance, with smaller values first. Without `nprobe`, search probes +`num_clusters / 16` clusters (at least one); probe more for higher recall. +Quantization remains approximate even when all clusters are probed. The [complete quick start](https://vectordb-ntu.github.io/RaBitQ-Library/quick_start/) explains result handling, measures recall while varying `nprobe`, and shows save/load. Code written for 0.7 still runs in 0.8 with deprecation warnings; see the diff --git a/benchmarks/README.md b/benchmarks/README.md index 99981ccb..663a39e3 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -15,7 +15,7 @@ Results: [IVF](../docs/docs/benchmarks/ivf.md), From the repository root, after installing the documented system prerequisites and activating a Python 3.13 environment, these commands measure the active checkout. -Published library results use 0.8.0; each baseline retains its latest measurement. +Published library results use 0.8.2; each baseline retains its latest measurement. See the [recorded revisions and environments](../docs/docs/benchmarks/bench.md#package-installation). Keep measurements of different revisions in separate output directories. diff --git a/benchmarks/run.py b/benchmarks/run.py index 49a2c949..ee770e59 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -116,10 +116,34 @@ def build_index(cfg, base, path): if hasattr(index, "k_factor"): index.k_factor = cfg["k_factor"] details = {"faiss_factory": spec} - elif method in ("rabitq-ivf", "rabitq-hnsw"): - from rabitqlib import FinalAssignmentMode, IvfIndex, RaBitQKMeans + elif method == "rabitq-ivf": + from rabitqlib import IvfIndex + + # The library's default training: QGKMeans above 2,000 clusters, then add + # routes each vector with the index's centroid initializer. + index = IvfIndex( + dim, cfg["nlist"], nbits=cfg["bits"], metric=cfg["metric"], seed=42 + ) + cp = index.cp + cp.niter = 10 + index.cp = cp + stage_start = time.perf_counter() + index.train(base, num_threads=cfg["threads"]) + training_s = time.perf_counter() - stage_start + stage_start = time.perf_counter() + index.add(base, num_threads=cfg["threads"]) + indexing_s = time.perf_counter() - stage_start + details = { + "clustering": "IvfIndex.train", + "clustering_seed": 42, + "clustering_niter": 10, + "cluster_count": cfg["nlist"], + "assignment": "add routing", + } + elif method == "rabitq-hnsw": + from rabitqlib import FinalAssignmentMode, HnswIndex, RaBitQKMeans - cluster_count = cfg["nlist"] if method == "rabitq-ivf" else cfg["hnsw_clusters"] + cluster_count = cfg["hnsw_clusters"] kmeans = RaBitQKMeans( dim, cluster_count, @@ -134,23 +158,16 @@ def build_index(cfg, base, path): kmeans.train(base) training_s = time.perf_counter() - stage_start stage_start = time.perf_counter() - if method == "rabitq-ivf": - index = IvfIndex( - dim, cluster_count, nbits=cfg["bits"], metric=cfg["metric"] - ) - else: - from rabitqlib import HnswIndex - - index = HnswIndex( - dim, - nbits=cfg["bits"], - num_clusters=cluster_count, - M=cfg["m"], - ef_construction=cfg["ef_construction"], - metric=cfg["metric"], - seed=42, - ) - index.resize(n) + index = HnswIndex( + dim, + nbits=cfg["bits"], + num_clusters=cluster_count, + M=cfg["m"], + ef_construction=cfg["ef_construction"], + metric=cfg["metric"], + seed=42, + ) + index.resize(n) index.set_centroids(kmeans.centroids) index.add(base, cluster_ids=kmeans.assignments, num_threads=cfg["threads"]) indexing_s = time.perf_counter() - stage_start @@ -430,9 +447,7 @@ def main(): if args.method == "rabitq-ivf" and args.metric == "ip": norms = np.linalg.norm(base, axis=1) if not np.allclose(norms, 1, atol=1e-4): - parser.error( - "RaBitQKMeans spherical training requires normalized base vectors" - ) + parser.error("spherical IVF training requires normalized base vectors") if args.groundtruth: if len(base) != total_base: parser.error("supplied ground truth is invalid for a truncated base") diff --git a/docs/docs/benchmarks/bench.md b/docs/docs/benchmarks/bench.md index 2a79eb94..8a702754 100644 --- a/docs/docs/benchmarks/bench.md +++ b/docs/docs/benchmarks/bench.md @@ -14,11 +14,11 @@ differ; this is neither an exact-recall nor an equal-memory comparison. | Configuration | Measured Recall@10 | Queries / second | Index GiB | Selected search setting | | --- | ---: | ---: | ---: | --- | -| IVF-RaBitQ · 5-bit | 92.82% | 1,025 | 0.60 | `nprobe=128` | -| HNSW-RaBitQ · 5-bit | 92.80% | 2,042 | 0.72 | `ef=512` | -| SymphonyQG · 4-bit | 90.71% | 3,062 | 4.39 | `ef=512` | +| IVF-RaBitQ · 5-bit | 93.28% | 976 | 0.60 | `nprobe=128` | +| HNSW-RaBitQ · 5-bit | 92.67% | 2,027 | 0.72 | `ef=512` | +| SymphonyQG · 4-bit | 90.45% | 3,070 | 4.39 | `ef=512` | -These use the 0.8.0 measurements with one search thread and one query per call. +These use the 0.8.2 measurements with one search thread and one query per call. Each setting has one timing pass. QPS includes Python call overhead after warm-up and excludes loading. Index size is serialized storage including sidecars, not resident or peak RAM. The figure and this table are a static export: @@ -72,10 +72,10 @@ L2 ground truth for OpenAI. | CPU | One Intel Xeon Gold 6418H, 24 cores, 48 hardware threads | | Construction / clustering | 48 threads | | Query throughput | 1 process, 1 search thread, 1 query per call | -| Platform | Ubuntu 22.04, x86-64; RaBitQ 0.8.0: Linux 6.8.0-138-generic; retained baselines: Linux 6.8.0-84-generic | +| Platform | Ubuntu 22.04, x86-64; RaBitQ 0.8.2: Linux 6.8.0-138-generic; retained baselines: Linux 6.8.0-84-generic | | Python / NumPy | 3.13.15 / 2.5.2 | -| Compiler / CMake | RaBitQ 0.8.0: GCC 14.3.0 / 3.28.3, Release, native optimization OFF; retained baselines: GCC 13.1.0 / 3.28.3, Release | -| RaBitQ-Library | 0.8.0, measured 2026-11-01 (Asia/Singapore) | +| Compiler / CMake | RaBitQ 0.8.2: GCC 14.3.0 / 3.28.3, Release, native optimization OFF; retained baselines: GCC 13.1.0 / 3.28.3, Release | +| RaBitQ-Library | 0.8.2, measured 2026-11-02 (Asia/Singapore) | | Baselines | Faiss 1.15.0, SuperKMeans 0.2.0; Intel MKL 2020.4, GNU OpenMP | ## Construction and search settings @@ -98,7 +98,7 @@ index loading. Each setting has one timing pass. Each method has one build or fit per dataset; results do not measure run-to-run variation. The committed CSVs support checking the displayed tables and plotted points, -but do not include raw run logs or measurement binaries. The 0.8.0 raw records, +but do not include raw run logs or measurement binaries. The 0.8.2 raw records, logs, indexes, source/build manifest and validation logs are not published. Historical baseline raw logs and binaries are not included. No run-to-run or shared-host noise correction is @@ -106,12 +106,16 @@ applied. The experiments in the cited papers are separate measurements. ## Package installation -The RaBitQ-Library rows and curves were refreshed on 2026-11-01 (Asia/Singapore) -using a wheel built locally from the `v0.8.0` tag -(`4768b74fe9595f87c8298287b034cee5cecd645a`) without local changes. The wheel -SHA-256 was `cb498a7c7aa3a4905dac204e0628daf59c50c6ebddaadf7b54565b87c11b2a03`, +The RaBitQ-Library rows and curves were refreshed on 2026-11-02 (Asia/Singapore) +using a wheel built locally, without local changes, from the 0.8.2 release +candidate `1d49ecd186955cf7ba454fac43bbe11c18a2e25f`, before the version +number was raised, so the wheel and the records report 0.8.1. The wheel +SHA-256 was `4555497f3b6170b295e0f3a7dcde766437eb95216f5f268e9311258824b42792`, and the measured extension SHA-256 was -`a375e636bb0bf89d07039085bfab866fdab9c790dca5189388792d7dd5427b40`. +`12ddcc2b03525728474d261e74df147d372d0d122ff7dd2784d3e6544fd2ceb1`. +IVF-RaBitQ results now follow the library's default construction +(`IvfIndex.train` with 10 iterations, then `add`); 0.8.0 results ran +RaBitQKMeans with exact final assignment and passed the cluster IDs to `add`. The latest available Faiss and SuperKMeans measurements are retained unchanged from snapshot `a332c1ce97588aaed4f6a8a5476f84a81f947313`; they were not rerun. @@ -124,7 +128,7 @@ To use the measured library source, start in a separate checkout and activate your benchmark Python environment: ```bash -git clone --branch v0.8.0 --depth 1 https://github.com/VectorDB-NTU/RaBitQ-Library.git RaBitQ-benchmark-snapshot +git clone --branch v0.8.2 --depth 1 https://github.com/VectorDB-NTU/RaBitQ-Library.git RaBitQ-benchmark-snapshot cd RaBitQ-benchmark-snapshot ``` @@ -151,14 +155,15 @@ and verification records are saved in `build/benchmark-baselines/`. Use `--dry-run` to check prerequisites and print the installation commands without running them. The installer defaults to the historical GCC 13 toolchain and builds RaBitQ with `RABITQ_ENABLE_NATIVE_OPTIMIZATION=ON`; it does not -recreate the GCC 14.3.0, native-optimization-OFF wheel used for the 0.8.0 -measurements. That wheel was built and installed in the same environment with: +recreate the GCC 14.3.0, native-optimization-OFF wheel used for the 0.8.2 +measurements. From a v0.8.2 checkout, the equivalent build, installed in the same +environment, is: ```bash python -m pip wheel . --no-build-isolation --no-deps -w build/benchmark-wheel \ -Ccmake.define.CMAKE_CXX_COMPILER=g++-14 -Ccmake.define.CMAKE_BUILD_TYPE=Release \ -Ccmake.define.RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF -python -m pip install --no-deps --force-reinstall build/benchmark-wheel/rabitqlib-0.8.0-*.whl +python -m pip install --no-deps --force-reinstall build/benchmark-wheel/rabitqlib-0.8.2-*.whl ``` Match the recorded compiler and native-optimization setting when comparing new diff --git a/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg b/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg index e0539f39..f52fa6e3 100644 --- a/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg +++ b/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg @@ -6,7 +6,7 @@ - 2026-11-01T21:42:31.865706 + 2026-11-02T18:25:53.290556 image/svg+xml @@ -42,7 +42,7 @@ z +" clip-path="url(#p40771bc06a)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -53,7 +53,7 @@ L 55.08 64.296 +" clip-path="url(#p40771bc06a)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -64,7 +64,7 @@ L 90.057932 64.296 +" clip-path="url(#p40771bc06a)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -75,7 +75,7 @@ L 125.035863 64.296 +" clip-path="url(#p40771bc06a)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -86,7 +86,7 @@ L 160.013795 64.296 +" clip-path="url(#p40771bc06a)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -97,7 +97,7 @@ L 194.991726 64.296 +" clip-path="url(#p40771bc06a)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -111,79 +111,79 @@ L 229.969658 64.296 - + - 200 + 200 - + - 500 + 500 - + - 1K + 1K - + - 2K + 2K - + - 5K + 5K - + - 10K + 10K - + - 20K + 20K @@ -192,7 +192,7 @@ L 231.718554 85.350523 - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + - + - - - - - - - - - + + + + + + + + - +" clip-path="url(#p40771bc06a)" style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5; stroke-linecap: round"/> - - - - - - - - - + + + + + + + + @@ -312,7 +312,7 @@ L 67.08 273.56475 L 75.88 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - - + @@ -337,7 +337,7 @@ L 67.08 285.072719 L 75.88 285.072719 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - - + @@ -367,7 +367,7 @@ z +" clip-path="url(#p9b415407ab)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -378,7 +378,7 @@ L 259.980723 64.296 +" clip-path="url(#p9b415407ab)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -389,7 +389,7 @@ L 294.958654 64.296 +" clip-path="url(#p9b415407ab)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -400,7 +400,7 @@ L 329.936586 64.296 +" clip-path="url(#p9b415407ab)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -411,7 +411,7 @@ L 364.914517 64.296 +" clip-path="url(#p9b415407ab)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -422,7 +422,7 @@ L 399.892449 64.296 +" clip-path="url(#p9b415407ab)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -436,163 +436,163 @@ L 434.870381 64.296 - + - + - + - + - + - + - + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + @@ -615,7 +615,7 @@ L 271.980723 273.56475 L 280.780723 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -627,14 +627,14 @@ L 271.980723 285.07725 L 280.780723 285.07725 " style="fill: none; stroke: #1baf7a; stroke-width: 1.5; stroke-linecap: square"/> - - + @@ -646,7 +646,7 @@ L 271.980723 296.589594 L 280.780723 296.589594 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -668,7 +668,7 @@ z +" clip-path="url(#p13478f954f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -679,7 +679,7 @@ L 464.881446 64.296 +" clip-path="url(#p13478f954f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -690,7 +690,7 @@ L 499.859377 64.296 +" clip-path="url(#p13478f954f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -701,7 +701,7 @@ L 534.837309 64.296 +" clip-path="url(#p13478f954f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -712,7 +712,7 @@ L 569.81524 64.296 +" clip-path="url(#p13478f954f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -723,7 +723,7 @@ L 604.793172 64.296 +" clip-path="url(#p13478f954f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -737,149 +737,149 @@ L 639.771103 64.296 - + - + - + - + - + - + - + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + @@ -902,7 +902,7 @@ L 476.881446 273.56475 L 485.681446 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -914,7 +914,7 @@ L 476.881446 285.07725 L 485.681446 285.07725 " style="fill: none; stroke: #1baf7a; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -926,7 +926,7 @@ L 476.881446 296.589594 L 485.681446 296.589594 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -942,13 +942,13 @@ L 485.681446 296.589594 - + - + - + diff --git a/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg b/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg index 181880a3..3465ee71 100644 --- a/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg +++ b/docs/docs/benchmarks/figures/graphs-gist1m-qps-recall.svg @@ -6,7 +6,7 @@ - 2026-11-01T21:42:31.663185 + 2026-11-02T18:25:53.090802 image/svg+xml @@ -42,7 +42,7 @@ z +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -53,7 +53,7 @@ L 55.08 64.296 +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -64,7 +64,7 @@ L 84.276455 64.296 +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -75,7 +75,7 @@ L 113.47291 64.296 +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -86,7 +86,7 @@ L 142.669366 64.296 +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -97,7 +97,7 @@ L 171.865821 64.296 +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -108,7 +108,7 @@ L 201.062276 64.296 +" clip-path="url(#p9be7be9c1f)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -122,90 +122,90 @@ L 230.258731 64.296 - + - 200 + 200 - + - 500 + 500 - + - 1K + 1K - + - 2K + 2K - + - 5K + 5K - + - 10K + 10K - + - 20K + 20K - + - 50K + 50K @@ -214,7 +214,7 @@ L 231.718554 68.78631 - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + - + - - - - - - - - - + + + + + + + + - + - - - - - - - - - + + + + + + + + @@ -334,7 +334,7 @@ L 67.08 273.56475 L 75.88 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - - + @@ -359,7 +359,7 @@ L 67.08 285.072719 L 75.88 285.072719 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - - + @@ -389,7 +389,7 @@ z +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -400,7 +400,7 @@ L 259.980723 64.296 +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -411,7 +411,7 @@ L 289.177178 64.296 +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -422,7 +422,7 @@ L 318.373633 64.296 +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -433,7 +433,7 @@ L 347.570089 64.296 +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -444,7 +444,7 @@ L 376.766544 64.296 +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -455,7 +455,7 @@ L 405.962999 64.296 +" clip-path="url(#p9474fb2370)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -469,171 +469,171 @@ L 435.159454 64.296 - + - + - + - + - + - + - + - + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + @@ -656,7 +656,7 @@ L 271.980723 273.56475 L 280.780723 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -668,14 +668,14 @@ L 271.980723 285.07725 L 280.780723 285.07725 " style="fill: none; stroke: #1baf7a; stroke-width: 1.5; stroke-linecap: square"/> - - + @@ -687,7 +687,7 @@ L 271.980723 296.589594 L 280.780723 296.589594 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -709,7 +709,7 @@ z +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -720,7 +720,7 @@ L 464.881446 64.296 +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -731,7 +731,7 @@ L 494.077901 64.296 +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -742,7 +742,7 @@ L 523.274356 64.296 +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -753,7 +753,7 @@ L 552.470812 64.296 +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -764,7 +764,7 @@ L 581.667267 64.296 +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -775,7 +775,7 @@ L 610.863722 64.296 +" clip-path="url(#p252976fbf0)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -789,157 +789,157 @@ L 640.060177 64.296 - + - + - + - + - + - + - + - + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - +" clip-path="url(#p252976fbf0)" style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5; stroke-linecap: round"/> + + + + + + + + @@ -962,7 +962,7 @@ L 476.881446 273.56475 L 485.681446 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -974,7 +974,7 @@ L 476.881446 285.07725 L 485.681446 285.07725 " style="fill: none; stroke: #1baf7a; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -986,7 +986,7 @@ L 476.881446 296.589594 L 485.681446 296.589594 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -1002,13 +1002,13 @@ L 485.681446 296.589594 - + - + - + diff --git a/docs/docs/benchmarks/figures/homepage-gist1m.svg b/docs/docs/benchmarks/figures/homepage-gist1m.svg index 7523a3d0..00bf1696 100644 --- a/docs/docs/benchmarks/figures/homepage-gist1m.svg +++ b/docs/docs/benchmarks/figures/homepage-gist1m.svg @@ -73,35 +73,35 @@ L 325.952542 247.104 - - 1000 + 1000 - - 2000 + 2000 - - 3000 + 3000 @@ -113,16 +113,16 @@ L 325.952542 247.104 @@ -135,13 +135,13 @@ z " clip-path="url(#p5fa146be57)" style="fill: #e29838"/> - 1,025 + 976 - 2,042 + 2,027 - 3,062 + 3,070 Queries / second @@ -256,8 +256,8 @@ L 705.6 247.104 @@ -278,7 +278,7 @@ z " clip-path="url(#p77858e02fd)" style="fill: #e29838"/> - 0.60 + 0.60 0.72 @@ -295,7 +295,7 @@ z GIST1M - Fastest measured QPS at Recall@10 ≥ 90% · RaBitQ-Library 0.8.0 + Fastest measured QPS at Recall@10 ≥ 90% · RaBitQ-Library 0.8.2 1 search thread · 1 query/call · Index size is serialized storage, not RAM diff --git a/docs/docs/benchmarks/figures/indexing.csv b/docs/docs/benchmarks/figures/indexing.csv index 1d02ea90..5cee3588 100644 --- a/docs/docs/benchmarks/figures/indexing.csv +++ b/docs/docs/benchmarks/figures/indexing.csv @@ -1,33 +1,33 @@ dataset,family,method,training_s,indexing_s,build_s,save_s,serialized_index_bytes,construction_threads -gist1m,ivf,rabitq-ivf-raw,14.829409518977627,2.153911415953189,16.983358589001,1.5759151680395007,4000476062,48 -gist1m,ivf,rabitq-ivf-1p4,14.707602448994294,2.536989053012803,17.24462889903225,0.2775544619653374,648476062,48 -gist1m,ivf,rabitq-ivf-1p8,14.566533413017169,7.729440610157326,22.296013419050723,0.4621390509419143,1128476062,48 +gist1m,ivf,rabitq-ivf-raw,4.3164071592036635,6.317062305985019,10.63356236112304,1.5961330391000956,4000526750,48 +gist1m,ivf,rabitq-ivf-1p4,4.293795343954116,6.676137062953785,10.97002165322192,0.25987291685305536,648526750,48 +gist1m,ivf,rabitq-ivf-1p8,4.306566131068394,11.879712961846963,16.186380225000903,0.4391973312012851,1128526750,48 gist1m,ivf,faiss-rabitqfs-raw,41.60687803989276,6.758632214739919,48.367109845392406,1.5819987310096622,4009934430,48 gist1m,ivf,faiss-rabitqfs-1p4,41.239345733076334,13.162328890990466,54.40425459900871,0.28943439619615674,728962696,48 gist1m,ivf,faiss-rabitqfs-1p8,41.37886307295412,39.81617270922288,81.19657412171364,0.51593446591869,1275188872,48 gist1m,ivf,faiss-pqfs,34.52272639609873,6.5879326411522925,41.11183687672019,1.5885270270518959,4119607590,48 gist1m,ivf,faiss-opq-pqfs,232.45455483580008,7.514239463023841,239.97109729098156,1.629965650383383,4123370861,48 -gist1m,graphs,rabitq-symqg-raw,0.0,35.67065822402947,35.67072072997689,3.2334143549669534,8064000518,48 -gist1m,graphs,rabitq-symqg-4,0.0,31.44512256118469,31.445181384915486,1.787698426982388,4712004378,48 -gist1m,graphs,rabitq-symqg-8,0.0,33.29856931301765,33.29862901382148,2.010384045075625,5192004378,48 -gist1m,graphs,rabitq-hnsw-1p4,1.3273746399208903,70.72448819992132,72.0519000289496,0.3321766119915992,768589041,48 -gist1m,graphs,rabitq-hnsw-1p8,1.4569123631808907,74.18529962305911,75.64225046895444,0.5453935279510915,1248589041,48 +gist1m,graphs,rabitq-symqg-raw,0.0,34.606835746904835,34.60689832805656,3.231128714978695,8064000518,48 +gist1m,graphs,rabitq-symqg-4,0.0,31.63803556188941,31.63809794210829,1.752754078945145,4712004378,48 +gist1m,graphs,rabitq-symqg-8,0.0,33.0948328950908,33.09489322709851,2.1268173118587583,5192004378,48 +gist1m,graphs,rabitq-hnsw-1p4,1.247008974896744,70.55495340796188,71.80200038617477,0.33058986789546907,768589041,48 +gist1m,graphs,rabitq-hnsw-1p8,1.2537483999039978,73.42231956496835,74.67610222403891,0.5182427209801972,1248589041,48 gist1m,graphs,faiss-hnsw-flat,3.4594908356666565e-05,78.93601581081748,78.93702552421018,1.6070536905899644,3984258362,48 gist1m,graphs,faiss-hnsw-sq4,1.0616188147105277,55.10552563937381,56.16821300517768,0.24274761974811554,624266078,48 gist1m,graphs,faiss-hnsw-sq8,1.0816914499737322,47.12117446027696,48.20351363904774,0.41724564833566546,1104266078,48 -dbpedia,ivf,rabitq-ivf-raw,23.66688503907062,3.656645755050704,27.32356806192547,2.6389607680030167,6383774494,48 -dbpedia,ivf,rabitq-ivf-1p4,23.87012152886018,4.129904818953946,28.00006323494017,0.40824643708765507,1021142494,48 -dbpedia,ivf,rabitq-ivf-1p8,23.812244654865935,13.49530848604627,37.30758900404908,0.7019223619718105,1788374494,48 +dbpedia,ivf,rabitq-ivf-raw,6.790997711010277,11.704118533991277,18.495210736989975,2.589568723924458,6383761438,48 +dbpedia,ivf,rabitq-ivf-1p4,6.8054207670502365,10.761416014051065,17.566930085187778,0.4006780539639294,1021129438,48 +dbpedia,ivf,rabitq-ivf-1p8,6.768180846935138,20.286328210961074,27.054601087002084,0.6906994499731809,1788361438,48 dbpedia,ivf,faiss-rabitqfs-raw,85.28691623080522,12.971096782013774,98.26071831583977,2.706343464087695,6460068638,48 dbpedia,ivf,faiss-rabitqfs-1p4,86.94140290375799,28.046236151829362,114.99026683531702,0.5991422357037663,1423356232,48 dbpedia,ivf,faiss-rabitqfs-1p8,119.86079298425466,91.1069279271178,210.96929090702906,0.9999383529648185,2511745352,48 dbpedia,ivf,faiss-pqfs,99.81868983665481,15.401424441952258,115.22205780772492,2.5630933842621744,6579360742,48 dbpedia,ivf,faiss-opq-pqfs,1414.2767510288395,13.646546002943069,1427.9251843532547,2.72974043013528,6588736557,48 -dbpedia,graphs,rabitq-symqg-raw,0.0,57.30009534582496,57.30015530297533,5.266420345054939,12659328806,48 -dbpedia,graphs,rabitq-symqg-4,0.0,51.70077793090604,51.70085098105483,2.790108836023137,7296702970,48 -dbpedia,graphs,rabitq-symqg-8,0.0,52.68800215609372,52.68806274677627,3.2438670380506665,8063934970,48 -dbpedia,graphs,rabitq-hnsw-1p4,1.4889749148860574,112.23768582590856,113.72669819020666,0.5221755029633641,1127498521,48 -dbpedia,graphs,rabitq-hnsw-1p8,1.5842520711012185,124.7689996289555,126.35329030104913,0.796100249979645,1894730521,48 +dbpedia,graphs,rabitq-symqg-raw,0.0,55.97930860100314,55.97937200102024,5.408551735104993,12659328806,48 +dbpedia,graphs,rabitq-symqg-4,0.0,52.85237439791672,52.852431915001944,2.8970802891999483,7296702970,48 +dbpedia,graphs,rabitq-symqg-8,0.0,53.22885831003077,53.22891803202219,3.1304104239679873,8063934970,48 +dbpedia,graphs,rabitq-hnsw-1p4,1.872877910034731,111.75948679004796,113.63240649411455,0.5367169028613716,1127498521,48 +dbpedia,graphs,rabitq-hnsw-1p8,1.554618805879727,123.92394626885653,125.47859971085563,0.8134857900440693,1894730521,48 dbpedia,graphs,faiss-hnsw-flat,3.532087430357933e-05,147.53378360485658,147.53478899691254,2.6907204217277467,6281970202,48 dbpedia,graphs,faiss-hnsw-sq4,1.7431773450225592,101.82403805106878,103.56827840721235,0.3557668989524245,911358526,48 dbpedia,graphs,faiss-hnsw-sq8,1.7862217500805855,80.66227205796167,82.44955762801692,0.6384039986878633,1678590526,48 diff --git a/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg b/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg index 5cb16494..aa49e02c 100644 --- a/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg +++ b/docs/docs/benchmarks/figures/ivf-dbpedia-qps-recall.svg @@ -6,7 +6,7 @@ - 2026-11-01T21:42:31.768645 + 2026-11-02T18:25:53.193911 image/svg+xml @@ -42,7 +42,7 @@ z +" clip-path="url(#pbb97ce032d)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -53,7 +53,7 @@ L 55.08 64.296 +" clip-path="url(#pbb97ce032d)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -64,7 +64,7 @@ L 90.057932 64.296 +" clip-path="url(#pbb97ce032d)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -75,7 +75,7 @@ L 125.035863 64.296 +" clip-path="url(#pbb97ce032d)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -86,7 +86,7 @@ L 160.013795 64.296 +" clip-path="url(#pbb97ce032d)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -97,7 +97,7 @@ L 194.991726 64.296 +" clip-path="url(#pbb97ce032d)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -111,46 +111,46 @@ L 229.969658 64.296 - + - 100 + 100 - + - 200 + 200 - + - 500 + 500 - + - 1K + 1K @@ -159,7 +159,7 @@ L 231.718554 93.097393 - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -593,7 +593,7 @@ L 67.08 273.56475 L 75.88 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - - + @@ -618,7 +618,7 @@ L 67.08 285.077094 L 75.88 285.077094 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - - + @@ -638,14 +638,14 @@ L 67.08 296.589437 L 75.88 296.589437 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #1baf7a; stroke-width: 1.5"/> - - + @@ -657,7 +657,7 @@ L 67.08 308.101781 L 75.88 308.101781 " style="fill: none; stroke-dasharray: 1.5,2.475; stroke-dashoffset: 0; stroke: #1baf7a; stroke-width: 1.5"/> - - + @@ -687,7 +687,7 @@ z +" clip-path="url(#paa2e362234)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -698,7 +698,7 @@ L 259.980723 64.296 +" clip-path="url(#paa2e362234)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -709,7 +709,7 @@ L 294.958654 64.296 +" clip-path="url(#paa2e362234)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -720,7 +720,7 @@ L 329.936586 64.296 +" clip-path="url(#paa2e362234)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -731,7 +731,7 @@ L 364.914517 64.296 +" clip-path="url(#paa2e362234)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -742,7 +742,7 @@ L 399.892449 64.296 +" clip-path="url(#paa2e362234)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -756,113 +756,113 @@ L 434.870381 64.296 - + - + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - +" clip-path="url(#paa2e362234)" style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5; stroke-linecap: round"/> + + + + + + + + + + + @@ -885,7 +885,7 @@ L 271.980723 273.56475 L 280.780723 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -897,7 +897,7 @@ L 271.980723 285.077094 L 280.780723 285.077094 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -919,7 +919,7 @@ z +" clip-path="url(#pf0a3dab399)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -930,7 +930,7 @@ L 464.881446 64.296 +" clip-path="url(#pf0a3dab399)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -941,7 +941,7 @@ L 499.859377 64.296 +" clip-path="url(#pf0a3dab399)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -952,7 +952,7 @@ L 534.837309 64.296 +" clip-path="url(#pf0a3dab399)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -963,7 +963,7 @@ L 569.81524 64.296 +" clip-path="url(#pf0a3dab399)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -974,7 +974,7 @@ L 604.793172 64.296 +" clip-path="url(#pf0a3dab399)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -988,113 +988,113 @@ L 639.771103 64.296 - + - + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + @@ -1117,7 +1117,7 @@ L 476.881446 273.56475 L 485.681446 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -1129,7 +1129,7 @@ L 476.881446 285.077094 L 485.681446 285.077094 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -1145,13 +1145,13 @@ L 485.681446 285.077094 - + - + - + diff --git a/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg b/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg index 9affeec9..947c4c95 100644 --- a/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg +++ b/docs/docs/benchmarks/figures/ivf-gist1m-qps-recall.svg @@ -6,7 +6,7 @@ - 2026-11-01T21:42:31.542577 + 2026-11-02T18:25:52.974086 image/svg+xml @@ -42,7 +42,7 @@ z +" clip-path="url(#pc47cdf3b59)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -53,7 +53,7 @@ L 55.08 64.296 +" clip-path="url(#pc47cdf3b59)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -64,7 +64,7 @@ L 90.057932 64.296 +" clip-path="url(#pc47cdf3b59)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -75,7 +75,7 @@ L 125.035863 64.296 +" clip-path="url(#pc47cdf3b59)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -86,7 +86,7 @@ L 160.013795 64.296 +" clip-path="url(#pc47cdf3b59)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -97,7 +97,7 @@ L 194.991726 64.296 +" clip-path="url(#pc47cdf3b59)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -111,57 +111,57 @@ L 229.969658 64.296 - + - 100 + 100 - + - 200 + 200 - + - 500 + 500 - + - 1K + 1K - + - 2K + 2K @@ -170,7 +170,7 @@ L 231.718554 80.470634 - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -582,7 +582,7 @@ L 67.08 273.56475 L 75.88 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - - + @@ -607,7 +607,7 @@ L 67.08 285.077094 L 75.88 285.077094 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - - + @@ -627,14 +627,14 @@ L 67.08 296.589437 L 75.88 296.589437 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #1baf7a; stroke-width: 1.5"/> - - + @@ -646,7 +646,7 @@ L 67.08 308.101781 L 75.88 308.101781 " style="fill: none; stroke-dasharray: 1.5,2.475; stroke-dashoffset: 0; stroke: #1baf7a; stroke-width: 1.5"/> - - + @@ -676,7 +676,7 @@ z +" clip-path="url(#p67d5d7f34c)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -687,7 +687,7 @@ L 259.980723 64.296 +" clip-path="url(#p67d5d7f34c)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -698,7 +698,7 @@ L 294.958654 64.296 +" clip-path="url(#p67d5d7f34c)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -709,7 +709,7 @@ L 329.936586 64.296 +" clip-path="url(#p67d5d7f34c)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -720,7 +720,7 @@ L 364.914517 64.296 +" clip-path="url(#p67d5d7f34c)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -731,7 +731,7 @@ L 399.892449 64.296 +" clip-path="url(#p67d5d7f34c)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -745,119 +745,121 @@ L 434.870381 64.296 - + - + - + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + @@ -880,7 +882,7 @@ L 271.980723 273.56475 L 280.780723 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -892,7 +894,7 @@ L 271.980723 285.077094 L 280.780723 285.077094 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -914,7 +916,7 @@ z +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -925,7 +927,7 @@ L 464.881446 64.296 +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -936,7 +938,7 @@ L 499.859377 64.296 +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -947,7 +949,7 @@ L 534.837309 64.296 +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -958,7 +960,7 @@ L 569.81524 64.296 +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -969,7 +971,7 @@ L 604.793172 64.296 +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke: #e1e0d9; stroke-width: 0.75; stroke-linecap: square"/> @@ -983,119 +985,121 @@ L 639.771103 64.296 - + - + - + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - +" clip-path="url(#pa7c2959a19)" style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5; stroke-linecap: round"/> + + + + + + + + + + + @@ -1118,7 +1122,7 @@ L 476.881446 273.56475 L 485.681446 273.56475 " style="fill: none; stroke: #2a78d6; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -1130,7 +1134,7 @@ L 476.881446 285.077094 L 485.681446 285.077094 " style="fill: none; stroke-dasharray: 5.55,2.4; stroke-dashoffset: 0; stroke: #eb6834; stroke-width: 1.5"/> - + @@ -1146,13 +1150,13 @@ L 485.681446 285.077094 - + - + - + diff --git a/docs/docs/benchmarks/figures/kmeans.csv b/docs/docs/benchmarks/figures/kmeans.csv index 265cc694..5728f559 100644 --- a/docs/docs/benchmarks/figures/kmeans.csv +++ b/docs/docs/benchmarks/figures/kmeans.csv @@ -1,7 +1,7 @@ dataset,method,clusters,threads,iteration_limit,actual_iterations,total_s,training_s,assignment_s,wcss,recall_at_10,nprobe -gist1m,qgkmeans,4096,48,25,25,13.975838155020028,,,1034709.004104586,0.8159,40 +gist1m,qgkmeans,4096,48,25,25,13.976149443071336,,,1034709.004104586,0.8159,40 gist1m,faiss,4096,48,25,25,84.17002899572253,81.0265609798953,3.1434680158272386,1034624.8831152479,0.8157,40 gist1m,superkmeans,4096,48,25,25,28.14010765682906,24.449575346894562,3.690532309934497,1034648.386635695,0.8207,40 -dbpedia,qgkmeans,4096,48,25,25,21.86964446492493,,,620213.4443011406,0.8944,40 +dbpedia,qgkmeans,4096,48,25,25,22.053020362043753,,,620213.4443011406,0.8944,40 dbpedia,faiss,4096,48,25,25,171.03296095784754,164.48468135716394,6.5482796006836,620737.5338938761,0.8947,40 dbpedia,superkmeans,4096,48,25,24,35.231373528018594,28.58055611886084,6.650817409157753,620268.6283306194,0.8968,40 diff --git a/docs/docs/benchmarks/figures/qps-recall-points.csv b/docs/docs/benchmarks/figures/qps-recall-points.csv index 172c8122..5377b035 100644 --- a/docs/docs/benchmarks/figures/qps-recall-points.csv +++ b/docs/docs/benchmarks/figures/qps-recall-points.csv @@ -1,34 +1,34 @@ dataset,method,setting,recall_at_10,qps -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 1}",0.1475,2363.467957170425 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 2}",0.23820000000000002,2296.748658911562 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 4}",0.3503,2224.191011577242 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 8}",0.4853,2119.714393183531 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 16}",0.627,1948.816102875993 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 32}",0.766,1731.013183817204 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 64}",0.8729000000000001,1434.3722679874045 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 128}",0.9492,1093.395382120919 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 256}",0.9820999999999999,745.8723776053278 -gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 512}",0.9952000000000001,446.66946608009715 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 1}",0.1475,2251.9814668599197 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 2}",0.23810000000000003,2270.156506013795 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 4}",0.34979999999999994,2213.6338452894934 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 8}",0.4845,2093.6472284059164 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 16}",0.6255,1922.0770695611034 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 32}",0.7608,1714.7751548236663 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 64}",0.8607,1397.1580678966634 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 128}",0.9282,1025.2847333707268 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 256}",0.9546000000000001,670.8393274904145 -gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 512}",0.9640000000000001,396.2568660357085 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 1}",0.1474,2252.3654589028743 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 2}",0.238,2200.437189878581 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 4}",0.3499,2214.3974863803787 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 8}",0.4848,2115.6526114440862 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 16}",0.6267999999999999,1926.5786914759558 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 32}",0.7652,1705.38105949127 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 64}",0.8715000000000002,1385.0532359842227 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 128}",0.9475000000000001,1014.8520244680006 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 256}",0.9799000000000001,672.6753513485095 -gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 512}",0.993,395.8348867724481 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 1}",0.15860000000000002,2292.2429862395725 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 2}",0.24330000000000002,2231.323444180764 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 4}",0.3633,2158.181425281544 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 8}",0.4953,1964.9085369171164 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 16}",0.6357,1885.6212397171867 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 32}",0.7728,1670.759467634159 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 64}",0.8834000000000001,1378.6308888998242 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 128}",0.9538000000000002,1045.2020166959253 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 256}",0.9849000000000001,703.0290056277265 +gist1m,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 512}",0.996,422.43771092072865 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 1}",0.15860000000000002,2341.4021952794346 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 2}",0.24320000000000003,2274.073058472549 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 4}",0.36290000000000006,2180.1175352493983 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 8}",0.4937,2050.2572967669735 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 16}",0.6327,1893.5086422319062 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 32}",0.7649000000000001,1677.7477119148773 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 64}",0.8701,1315.8095467491191 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 128}",0.9328000000000002,976.4674175693566 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 256}",0.9583000000000002,632.5197877719577 +gist1m,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 512}",0.9667,371.71353216931766 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 1}",0.15860000000000002,2307.9455730316467 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 2}",0.24330000000000002,2239.5970612683604 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 4}",0.3633,2170.702843387501 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 8}",0.4953,1983.3470998680796 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 16}",0.6356,1859.406351840207 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 32}",0.7723,1647.471509406164 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 64}",0.8827,1317.2271645004357 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 128}",0.9521000000000001,950.1874249896673 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 256}",0.983,632.0628591526427 +gist1m,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 512}",0.9939000000000001,376.6421880426241 gist1m,faiss-rabitqfs-raw,"{""k_factor"": 1, ""nprobe"": 1}",0.16110000000000002,2014.0927239452988 gist1m,faiss-rabitqfs-raw,"{""k_factor"": 2, ""nprobe"": 1}",0.1637,1997.9254110236252 gist1m,faiss-rabitqfs-raw,"{""k_factor"": 5, ""nprobe"": 1}",0.16390000000000002,1918.577262395092 @@ -199,41 +199,41 @@ gist1m,faiss-opq-pqfs,"{""k_factor"": 2, ""nprobe"": 512}",0.9036000000000002,26 gist1m,faiss-opq-pqfs,"{""k_factor"": 5, ""nprobe"": 512}",0.9863,254.4628404248078 gist1m,faiss-opq-pqfs,"{""k_factor"": 10, ""nprobe"": 512}",0.9944,253.25533247918946 gist1m,faiss-opq-pqfs,"{""k_factor"": 20, ""nprobe"": 512}",0.996,250.83951174852962 -gist1m,rabitq-symqg-raw,"{""ef_search"": 16}",0.5328999999999999,40839.64181602291 -gist1m,rabitq-symqg-raw,"{""ef_search"": 32}",0.6789000000000001,27395.892684642644 -gist1m,rabitq-symqg-raw,"{""ef_search"": 64}",0.806,17228.442227049716 -gist1m,rabitq-symqg-raw,"{""ef_search"": 128}",0.8967,9787.42848312 -gist1m,rabitq-symqg-raw,"{""ef_search"": 256}",0.9537,5353.942324672757 -gist1m,rabitq-symqg-raw,"{""ef_search"": 512}",0.9821000000000001,2843.446779262826 -gist1m,rabitq-symqg-raw,"{""ef_search"": 1024}",0.9952000000000001,1479.227603264583 -gist1m,rabitq-symqg-4,"{""ef_search"": 16}",0.4578,44722.44100461175 -gist1m,rabitq-symqg-4,"{""ef_search"": 32}",0.6016,29958.13046310635 -gist1m,rabitq-symqg-4,"{""ef_search"": 64}",0.7280999999999999,19465.878495780573 -gist1m,rabitq-symqg-4,"{""ef_search"": 128}",0.818,11015.565764393998 -gist1m,rabitq-symqg-4,"{""ef_search"": 256}",0.8780000000000001,5870.563710390287 -gist1m,rabitq-symqg-4,"{""ef_search"": 512}",0.9071000000000001,3061.571818805918 -gist1m,rabitq-symqg-4,"{""ef_search"": 1024}",0.9174000000000001,1563.3706214067597 -gist1m,rabitq-symqg-8,"{""ef_search"": 16}",0.5334,44202.14607324287 -gist1m,rabitq-symqg-8,"{""ef_search"": 32}",0.681,31326.4560237357 -gist1m,rabitq-symqg-8,"{""ef_search"": 64}",0.8011,19784.74319258463 -gist1m,rabitq-symqg-8,"{""ef_search"": 128}",0.8942,11228.406269838173 -gist1m,rabitq-symqg-8,"{""ef_search"": 256}",0.949,6225.351852101211 -gist1m,rabitq-symqg-8,"{""ef_search"": 512}",0.976,3252.2333337759283 -gist1m,rabitq-symqg-8,"{""ef_search"": 1024}",0.9867999999999999,1682.0374782030074 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 16}",0.4845,15680.92281023948 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 32}",0.6147,13133.889957842093 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 64}",0.7353000000000001,9411.210996872773 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 128}",0.8265,6964.0594381340115 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 256}",0.8904000000000001,3913.5044741765328 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 512}",0.9280000000000002,2042.3317326299784 -gist1m,rabitq-hnsw-1p4,"{""ef_search"": 1024}",0.9434000000000001,1025.5947843087315 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 16}",0.4827,18824.829138501234 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 32}",0.6145,15074.727029801039 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 64}",0.7423,10543.05227423568 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 128}",0.8388000000000001,6713.0929831743615 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 256}",0.9112,3842.558835937474 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 512}",0.951,2022.7785294028427 -gist1m,rabitq-hnsw-1p8,"{""ef_search"": 1024}",0.9708000000000001,1012.7580995440545 +gist1m,rabitq-symqg-raw,"{""ef_search"": 16}",0.5324,40618.235396316275 +gist1m,rabitq-symqg-raw,"{""ef_search"": 32}",0.6827000000000001,26923.441585992474 +gist1m,rabitq-symqg-raw,"{""ef_search"": 64}",0.8067000000000001,17346.141035200297 +gist1m,rabitq-symqg-raw,"{""ef_search"": 128}",0.9009000000000001,9833.280949058817 +gist1m,rabitq-symqg-raw,"{""ef_search"": 256}",0.9575,5273.038736961992 +gist1m,rabitq-symqg-raw,"{""ef_search"": 512}",0.9839000000000001,2845.3627151951205 +gist1m,rabitq-symqg-raw,"{""ef_search"": 1024}",0.9944000000000001,1504.3435928042888 +gist1m,rabitq-symqg-4,"{""ef_search"": 16}",0.45370000000000005,40609.94364592269 +gist1m,rabitq-symqg-4,"{""ef_search"": 32}",0.6038,29284.744007012192 +gist1m,rabitq-symqg-4,"{""ef_search"": 64}",0.737,17569.185108196223 +gist1m,rabitq-symqg-4,"{""ef_search"": 128}",0.8298000000000002,10493.611839935398 +gist1m,rabitq-symqg-4,"{""ef_search"": 256}",0.8813000000000002,5891.696617152718 +gist1m,rabitq-symqg-4,"{""ef_search"": 512}",0.9045000000000002,3069.774979184891 +gist1m,rabitq-symqg-4,"{""ef_search"": 1024}",0.9169,1534.339014942515 +gist1m,rabitq-symqg-8,"{""ef_search"": 16}",0.5284000000000001,44393.06095307739 +gist1m,rabitq-symqg-8,"{""ef_search"": 32}",0.6803,31036.4084195256 +gist1m,rabitq-symqg-8,"{""ef_search"": 64}",0.804,19598.187713719977 +gist1m,rabitq-symqg-8,"{""ef_search"": 128}",0.8965000000000001,11370.334793546055 +gist1m,rabitq-symqg-8,"{""ef_search"": 256}",0.9499000000000001,6152.043126841109 +gist1m,rabitq-symqg-8,"{""ef_search"": 512}",0.9778,3134.1512113640306 +gist1m,rabitq-symqg-8,"{""ef_search"": 1024}",0.9875,1697.2576105741166 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 16}",0.4832,19886.294607729273 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 32}",0.6141,15477.880797542963 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 64}",0.7361,11288.851811759258 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 128}",0.8253,7003.648721674192 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 256}",0.8916000000000001,3867.774823419696 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 512}",0.9267000000000002,2027.1367734618577 +gist1m,rabitq-hnsw-1p4,"{""ef_search"": 1024}",0.9425,1015.2571380621886 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 16}",0.48319999999999996,18912.277031362668 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 32}",0.6167999999999999,15000.6263673616 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 64}",0.7432000000000001,10388.713890067307 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 128}",0.8412000000000001,6699.231182264583 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 256}",0.9106000000000002,3804.73735131126 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 512}",0.9504000000000001,1949.2034838809832 +gist1m,rabitq-hnsw-1p8,"{""ef_search"": 1024}",0.9699000000000001,992.3758670511446 gist1m,faiss-hnsw-flat,"{""ef_search"": 16}",0.49739999999999995,6294.544088577019 gist1m,faiss-hnsw-flat,"{""ef_search"": 32}",0.6306999999999999,3912.743611640825 gist1m,faiss-hnsw-flat,"{""ef_search"": 64}",0.7554,2388.2965969556044 @@ -255,36 +255,36 @@ gist1m,faiss-hnsw-sq8,"{""ef_search"": 128}",0.8434,1275.5174278302363 gist1m,faiss-hnsw-sq8,"{""ef_search"": 256}",0.9039,698.224703435659 gist1m,faiss-hnsw-sq8,"{""ef_search"": 512}",0.9374000000000001,385.09922727219015 gist1m,faiss-hnsw-sq8,"{""ef_search"": 1024}",0.9548000000000001,210.04956181553456 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 1}",0.4968,1438.4280760261988 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 2}",0.6108000000000001,1395.339944681715 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 4}",0.6994000000000001,1387.8428601699286 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 8}",0.777,1334.634041009959 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 16}",0.8332,1273.658060120864 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 32}",0.8846000000000002,1150.3806863129582 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 64}",0.9252999999999999,958.4259576601994 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 128}",0.9566000000000001,722.8973634559267 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 256}",0.9771,487.395520696361 -dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 512}",0.9903,294.7878071621595 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 1}",0.4956,1488.2183238989835 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 2}",0.6092000000000001,1421.937752166503 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 4}",0.6969000000000001,1408.6048198523677 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 8}",0.773,1383.1617884483671 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 16}",0.828,1296.2401865703118 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 32}",0.8778000000000001,1146.7737342721082 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 64}",0.9167000000000001,947.3185998989695 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 128}",0.9454000000000001,695.585739783037 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 256}",0.9642000000000001,452.3847584405979 -dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 512}",0.976,270.4286967498806 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 1}",0.4968,1508.1111565362878 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 2}",0.6107,1466.818072951789 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 4}",0.6992999999999999,1418.1933166187682 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 8}",0.777,1385.2016434793438 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 16}",0.8330000000000001,1304.8506529028696 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 32}",0.8843000000000001,1158.3713318972784 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 64}",0.925,944.2616038005859 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 128}",0.9563000000000001,703.5140776097971 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 256}",0.9766,459.3803327099362 -dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 512}",0.9896000000000001,272.92524047265425 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 1}",0.49160000000000004,1449.7841524665141 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 2}",0.6106,1389.192525776793 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 4}",0.7011000000000001,1379.1664722037838 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 8}",0.7739000000000001,1325.9268436666073 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 16}",0.8336,1268.0697303822276 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 32}",0.8828,1144.4085596013995 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 64}",0.9231,944.1578278570655 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 128}",0.9563,706.748699343516 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 256}",0.9763000000000001,479.73141193221363 +dbpedia,rabitq-ivf-raw,"{""k_factor"": 1, ""nprobe"": 512}",0.9905,291.9782402287662 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 1}",0.4902,1457.9659446733103 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 2}",0.6085,1393.3672506246883 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 4}",0.6984000000000001,1387.578105395565 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 8}",0.7702,1339.0200210117628 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 16}",0.8282999999999999,1190.2406460829059 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 32}",0.8762000000000001,1118.9651021215254 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 64}",0.9151000000000001,913.0076354995437 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 128}",0.9458000000000001,674.381254526796 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 256}",0.964,442.3758851884881 +dbpedia,rabitq-ivf-1p4,"{""k_factor"": 1, ""nprobe"": 512}",0.9767,264.48558962273864 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 1}",0.49160000000000004,1488.026397022922 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 2}",0.6105,1458.9227682940354 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 4}",0.701,1442.5262062187999 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 8}",0.7737999999999999,1382.7434774478588 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 16}",0.8335,1309.6175356782728 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 32}",0.8825,1169.4055419405008 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 64}",0.9228000000000001,953.6070558013462 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 128}",0.9556,695.5234568843545 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 256}",0.9753,460.3457619765794 +dbpedia,rabitq-ivf-1p8,"{""k_factor"": 1, ""nprobe"": 512}",0.9893000000000001,271.2884940017891 dbpedia,faiss-rabitqfs-raw,"{""k_factor"": 1, ""nprobe"": 1}",0.4696,968.0227209640274 dbpedia,faiss-rabitqfs-raw,"{""k_factor"": 2, ""nprobe"": 1}",0.4868,964.4443231982366 dbpedia,faiss-rabitqfs-raw,"{""k_factor"": 5, ""nprobe"": 1}",0.48760000000000003,923.9652572942317 @@ -455,41 +455,41 @@ dbpedia,faiss-opq-pqfs,"{""k_factor"": 2, ""nprobe"": 512}",0.9873000000000001,1 dbpedia,faiss-opq-pqfs,"{""k_factor"": 5, ""nprobe"": 512}",0.9903,190.42143321970212 dbpedia,faiss-opq-pqfs,"{""k_factor"": 10, ""nprobe"": 512}",0.9903,191.85913955260074 dbpedia,faiss-opq-pqfs,"{""k_factor"": 20, ""nprobe"": 512}",0.9903,186.93282549390355 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 16}",0.8248000000000001,29104.75909194195 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 32}",0.9114000000000001,20297.26353753667 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 64}",0.9524000000000001,13560.175365501209 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 128}",0.9741000000000001,8125.614596039694 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 256}",0.9866000000000001,4682.066391350128 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 512}",0.9933,2596.460594279065 -dbpedia,rabitq-symqg-raw,"{""ef_search"": 1024}",0.9971,1389.257975960993 -dbpedia,rabitq-symqg-4,"{""ef_search"": 16}",0.6617999999999999,30534.41821039647 -dbpedia,rabitq-symqg-4,"{""ef_search"": 32}",0.8227000000000002,21517.6414164816 -dbpedia,rabitq-symqg-4,"{""ef_search"": 64}",0.9015,13991.070143700688 -dbpedia,rabitq-symqg-4,"{""ef_search"": 128}",0.9377000000000002,8185.104636973314 -dbpedia,rabitq-symqg-4,"{""ef_search"": 256}",0.9523,4632.76015198188 -dbpedia,rabitq-symqg-4,"{""ef_search"": 512}",0.9602,2478.380929212675 -dbpedia,rabitq-symqg-4,"{""ef_search"": 1024}",0.9651000000000002,1291.7495788737988 -dbpedia,rabitq-symqg-8,"{""ef_search"": 16}",0.8265,31777.972107044385 -dbpedia,rabitq-symqg-8,"{""ef_search"": 32}",0.9114000000000001,22951.66540716551 -dbpedia,rabitq-symqg-8,"{""ef_search"": 64}",0.9537,15442.510081447115 -dbpedia,rabitq-symqg-8,"{""ef_search"": 128}",0.9724,9553.595531842548 -dbpedia,rabitq-symqg-8,"{""ef_search"": 256}",0.982,5446.867577781903 -dbpedia,rabitq-symqg-8,"{""ef_search"": 512}",0.9889,3012.020640225311 -dbpedia,rabitq-symqg-8,"{""ef_search"": 1024}",0.992,1609.2073462297726 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 16}",0.8309000000000001,16064.10009987645 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 32}",0.8971,13366.87812264088 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 64}",0.9376000000000001,9839.721077270764 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 128}",0.9565,6466.948543751877 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 256}",0.9666000000000001,3806.4249418528934 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 512}",0.9718000000000001,2067.0556575149285 -dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 1024}",0.9748000000000001,1024.4188245530174 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 16}",0.8349000000000001,14687.245065997886 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 32}",0.9069,12034.520119590024 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 64}",0.9508000000000001,9209.502036439037 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 128}",0.9737,6182.312717735209 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 256}",0.9843,3664.2163863694113 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 512}",0.9909,1996.5110056050491 -dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 1024}",0.9939000000000001,1005.2213901924908 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 16}",0.8076000000000001,28757.86000146663 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 32}",0.9105,20670.834253020003 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 64}",0.9506,13713.59541047072 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 128}",0.9737,8156.417107648283 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 256}",0.9869,4644.548284117831 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 512}",0.9926,2473.9266627028856 +dbpedia,rabitq-symqg-raw,"{""ef_search"": 1024}",0.9967,1380.4110028061675 +dbpedia,rabitq-symqg-4,"{""ef_search"": 16}",0.6545,30619.35903790233 +dbpedia,rabitq-symqg-4,"{""ef_search"": 32}",0.8246000000000001,21206.44296636798 +dbpedia,rabitq-symqg-4,"{""ef_search"": 64}",0.9044000000000001,13845.910420126613 +dbpedia,rabitq-symqg-4,"{""ef_search"": 128}",0.9406000000000001,8178.721744910037 +dbpedia,rabitq-symqg-4,"{""ef_search"": 256}",0.9553,4581.138175198925 +dbpedia,rabitq-symqg-4,"{""ef_search"": 512}",0.964,2470.964872161067 +dbpedia,rabitq-symqg-4,"{""ef_search"": 1024}",0.9675,1268.1530945867482 +dbpedia,rabitq-symqg-8,"{""ef_search"": 16}",0.8233000000000001,31877.350163475272 +dbpedia,rabitq-symqg-8,"{""ef_search"": 32}",0.9079,23584.28549136094 +dbpedia,rabitq-symqg-8,"{""ef_search"": 64}",0.951,15608.435044230717 +dbpedia,rabitq-symqg-8,"{""ef_search"": 128}",0.9712000000000001,9538.135545106787 +dbpedia,rabitq-symqg-8,"{""ef_search"": 256}",0.9816,5359.94019084852 +dbpedia,rabitq-symqg-8,"{""ef_search"": 512}",0.9906,3020.020911023084 +dbpedia,rabitq-symqg-8,"{""ef_search"": 1024}",0.9945,1611.8173992577324 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 16}",0.8284000000000001,16139.861324504 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 32}",0.8991000000000001,12965.028343201626 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 64}",0.9377000000000001,9041.654683488374 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 128}",0.9568000000000001,5923.270574072943 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 256}",0.967,3529.172666186052 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 512}",0.9718000000000001,2018.2844505111545 +dbpedia,rabitq-hnsw-1p4,"{""ef_search"": 1024}",0.9748000000000001,968.2597595101961 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 16}",0.8346,14617.225255222185 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 32}",0.9037000000000001,12121.988834826634 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 64}",0.9507000000000001,8722.251407519614 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 128}",0.9725,6151.49624433614 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 256}",0.9843,3644.3910506684606 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 512}",0.9909,1991.4170172509994 +dbpedia,rabitq-hnsw-1p8,"{""ef_search"": 1024}",0.9939000000000001,983.8506969675918 dbpedia,faiss-hnsw-flat,"{""ef_search"": 16}",0.848,4172.367906120071 dbpedia,faiss-hnsw-flat,"{""ef_search"": 32}",0.9243000000000001,2742.54484463088 dbpedia,faiss-hnsw-flat,"{""ef_search"": 64}",0.9645,1649.1643632445584 diff --git a/docs/docs/benchmarks/graphs.md b/docs/docs/benchmarks/graphs.md index de6e0b88..965cc99a 100644 --- a/docs/docs/benchmarks/graphs.md +++ b/docs/docs/benchmarks/graphs.md @@ -1,6 +1,6 @@ # Graph benchmark -RaBitQ-Library results were measured with **0.8.0 on 2026-11-01 (Asia/Singapore)**. +RaBitQ-Library results were measured with **0.8.2 on 2026-11-02 (Asia/Singapore)**. Each baseline retains its latest measurement and was not rerun. See the [source revisions, environments and comparison limits](bench.md#package-installation). @@ -25,11 +25,11 @@ See [figure definitions](bench.md#reading-the-figures) and | Method | Train s | Construct s | Total s | Save s | Index GiB | | --- | ---: | ---: | ---: | ---: | ---: | -| SymphonyQG raw | 0.00 | 35.67 | 35.67 | 3.23 | 7.51 | -| SymphonyQG 4-bit | 0.00 | 31.45 | 31.45 | 1.79 | 4.39 | -| SymphonyQG 8-bit | 0.00 | 33.30 | 33.30 | 2.01 | 4.84 | -| HNSW-RaBitQ 1+4 | 1.33 | 70.72 | 72.05 | 0.33 | 0.72 | -| HNSW-RaBitQ 1+8 | 1.46 | 74.19 | 75.64 | 0.55 | 1.16 | +| SymphonyQG raw | 0.00 | 34.61 | 34.61 | 3.23 | 7.51 | +| SymphonyQG 4-bit | 0.00 | 31.64 | 31.64 | 1.75 | 4.39 | +| SymphonyQG 8-bit | 0.00 | 33.09 | 33.09 | 2.13 | 4.84 | +| HNSW-RaBitQ 1+4 | 1.25 | 70.55 | 71.80 | 0.33 | 0.72 | +| HNSW-RaBitQ 1+8 | 1.25 | 73.42 | 74.68 | 0.52 | 1.16 | | Faiss HNSW Flat | 0.00 | 78.94 | 78.94 | 1.61 | 3.71 | | Faiss HNSW SQ4 | 1.06 | 55.11 | 56.17 | 0.24 | 0.58 | | Faiss HNSW SQ8 | 1.08 | 47.12 | 48.20 | 0.42 | 1.03 | @@ -44,11 +44,11 @@ See [figure definitions](bench.md#reading-the-figures) and | Method | Train s | Construct s | Total s | Save s | Index GiB | | --- | ---: | ---: | ---: | ---: | ---: | -| SymphonyQG raw | 0.00 | 57.30 | 57.30 | 5.27 | 11.79 | -| SymphonyQG 4-bit | 0.00 | 51.70 | 51.70 | 2.79 | 6.80 | -| SymphonyQG 8-bit | 0.00 | 52.69 | 52.69 | 3.24 | 7.51 | -| HNSW-RaBitQ 1+4 | 1.49 | 112.24 | 113.73 | 0.52 | 1.05 | -| HNSW-RaBitQ 1+8 | 1.58 | 124.77 | 126.35 | 0.80 | 1.76 | +| SymphonyQG raw | 0.00 | 55.98 | 55.98 | 5.41 | 11.79 | +| SymphonyQG 4-bit | 0.00 | 52.85 | 52.85 | 2.90 | 6.80 | +| SymphonyQG 8-bit | 0.00 | 53.23 | 53.23 | 3.13 | 7.51 | +| HNSW-RaBitQ 1+4 | 1.87 | 111.76 | 113.63 | 0.54 | 1.05 | +| HNSW-RaBitQ 1+8 | 1.55 | 123.92 | 125.48 | 0.81 | 1.76 | | Faiss HNSW Flat | 0.00 | 147.53 | 147.53 | 2.69 | 5.85 | | Faiss HNSW SQ4 | 1.74 | 101.82 | 103.57 | 0.36 | 0.85 | | Faiss HNSW SQ8 | 1.79 | 80.66 | 82.45 | 0.64 | 1.56 | @@ -62,9 +62,9 @@ refinement iteration. Both HNSW implementations use `M=16` and | Method | Stored representation | Additional construction settings | | --- | --- | --- | -| SymphonyQG raw | `quantization_bits=0`, raw vectors | Degree 32, PIPNN initialization | -| SymphonyQG 4-bit | `quantization_bits=4` | Degree 32, PIPNN initialization | -| SymphonyQG 8-bit | `quantization_bits=8` | Degree 32, PIPNN initialization | +| SymphonyQG raw | `nbits=32`, raw vectors | Degree 32, PIPNN initialization | +| SymphonyQG 4-bit | `nbits=4` | Degree 32, PIPNN initialization | +| SymphonyQG 8-bit | `nbits=8` | Degree 32, PIPNN initialization | | HNSW-RaBitQ 1+4 | `nbits=5` | `M=16`, seed 42, 16 residual centroids | | HNSW-RaBitQ 1+8 | `nbits=9` | `M=16`, seed 42, 16 residual centroids | | Faiss HNSW Flat | `HNSW16` | Float32 vectors | diff --git a/docs/docs/benchmarks/ivf.md b/docs/docs/benchmarks/ivf.md index e28a420a..cf071984 100644 --- a/docs/docs/benchmarks/ivf.md +++ b/docs/docs/benchmarks/ivf.md @@ -1,6 +1,6 @@ # IVF-RaBitQ benchmark -RaBitQ-Library results were measured with **0.8.0 on 2026-11-01 (Asia/Singapore)**. +RaBitQ-Library results were measured with **0.8.2 on 2026-11-02 (Asia/Singapore)**. Each baseline retains its latest measurement and was not rerun. See the [source revisions, environments and comparison limits](bench.md#package-installation). @@ -25,9 +25,9 @@ See [figure definitions](bench.md#reading-the-figures) and | Method | Train s | Construct s | Total s | Save s | Index GiB | | --- | ---: | ---: | ---: | ---: | ---: | -| IVF-RaBitQ raw | 14.83 | 2.15 | 16.98 | 1.58 | 3.73 | -| IVF-RaBitQ 1+4 | 14.71 | 2.54 | 17.24 | 0.28 | 0.60 | -| IVF-RaBitQ 1+8 | 14.57 | 7.73 | 22.30 | 0.46 | 1.05 | +| IVF-RaBitQ raw | 4.32 | 6.32 | 10.63 | 1.60 | 3.73 | +| IVF-RaBitQ 1+4 | 4.29 | 6.68 | 10.97 | 0.26 | 0.60 | +| IVF-RaBitQ 1+8 | 4.31 | 11.88 | 16.19 | 0.44 | 1.05 | | Faiss IVF RaBitQfs raw + RFlat | 41.61 | 6.76 | 48.37 | 1.58 | 3.73 | | Faiss IVF RaBitQfs 1+4 | 41.24 | 13.16 | 54.40 | 0.29 | 0.68 | | Faiss IVF RaBitQfs 1+8 | 41.38 | 39.82 | 81.20 | 0.52 | 1.19 | @@ -44,9 +44,9 @@ See [figure definitions](bench.md#reading-the-figures) and | Method | Train s | Construct s | Total s | Save s | Index GiB | | --- | ---: | ---: | ---: | ---: | ---: | -| IVF-RaBitQ raw | 23.67 | 3.66 | 27.32 | 2.64 | 5.95 | -| IVF-RaBitQ 1+4 | 23.87 | 4.13 | 28.00 | 0.41 | 0.95 | -| IVF-RaBitQ 1+8 | 23.81 | 13.50 | 37.31 | 0.70 | 1.67 | +| IVF-RaBitQ raw | 6.79 | 11.70 | 18.50 | 2.59 | 5.95 | +| IVF-RaBitQ 1+4 | 6.81 | 10.76 | 17.57 | 0.40 | 0.95 | +| IVF-RaBitQ 1+8 | 6.77 | 20.29 | 27.05 | 0.69 | 1.67 | | Faiss IVF RaBitQfs raw + RFlat | 85.29 | 12.97 | 98.26 | 2.71 | 6.02 | | Faiss IVF RaBitQfs 1+4 | 86.94 | 28.05 | 114.99 | 0.60 | 1.33 | | Faiss IVF RaBitQfs 1+8 | 119.86 | 91.11 | 210.97 | 1.00 | 2.34 | @@ -58,9 +58,12 @@ See [figure definitions](bench.md#reading-the-figures) and Every IVF index uses **4,096 clusters**. `d` is the dataset dimension and `m=d/2` for PQ: 480 on GIST1M and 768 on DBpedia. PQ subquantizers -use four bits. RaBitQ clustering uses up to 10 iterations, seed 42, exact final -assignment, and spherical training for DBpedia's inner-product metric. Faiss uses its -factory training defaults. Training is reported separately and included in total +use four bits. IVF-RaBitQ builds as the library does by default: `IvfIndex.train` +(QGKMeans above 2,000 clusters, up to 10 iterations on at most 256 sampled points +per centroid, seed 42, spherical training for DBpedia's inner-product metric), then +`add`, which assigns every vector to its nearest centroid, so assignment counts as +construction. Faiss uses its factory training defaults (10 iterations, at most 256 +points per centroid). Training is reported separately and included in total construction time. | Method | Construction specification | Search parameters | diff --git a/docs/docs/benchmarks/kmeans.md b/docs/docs/benchmarks/kmeans.md index 3ade64d9..189e4340 100644 --- a/docs/docs/benchmarks/kmeans.md +++ b/docs/docs/benchmarks/kmeans.md @@ -1,6 +1,6 @@ # K-means benchmark -RaBitQ-Library results were measured with **0.8.0 on 2026-11-01 (Asia/Singapore)**. +RaBitQ-Library results were measured with **0.8.2 on 2026-11-02 (Asia/Singapore)**. Each baseline retains its latest measurement and was not rerun. See the [source revisions, environments and comparison limits](bench.md#package-installation). @@ -24,7 +24,7 @@ Metrics are total time, WCSS, and recall@10 with 1% of clusters probed. | Method | Total time (s) | WCSS | Recall@10 (40 probes) | Iterations | | --- | ---: | ---: | ---: | ---: | -| QGKMeans | 21.87 | 620213.444 | 0.894400 | 25 | +| QGKMeans | 22.05 | 620213.444 | 0.894400 | 25 | | Faiss K-means | 171.03 | 620737.534 | 0.894700 | 25 | | SuperKMeans | 35.23 | 620268.628 | 0.896800 | 24 | diff --git a/docs/docs/clustering.md b/docs/docs/clustering.md index 40e0bc8e..9c7b162e 100644 --- a/docs/docs/clustering.md +++ b/docs/docs/clustering.md @@ -8,7 +8,7 @@ algorithms based on the cluster count. For installation, see [Quick start](quick_start.md#install). You do not need these classes to build an index: `IvfIndex.train` and -`HnswIndex.train` run them for you (see +`HnswIndex.train`, or the first `add`, run them for you (see [IVF-RaBitQ training](index/ivf.md#training-centroids-in-python)). Use them directly to inspect assignments, reuse one clustering across several indexes, or pass the result to `set_centroids` and `add(..., cluster_ids=...)`. The diff --git a/docs/docs/cpp_integration.md b/docs/docs/cpp_integration.md index f68be782..9083df75 100644 --- a/docs/docs/cpp_integration.md +++ b/docs/docs/cpp_integration.md @@ -58,12 +58,12 @@ The core example is built as the `core_example` CMake target. For the GIST bench The [core quantization v1 API](rabitq/core_api.md) has a C++ source-compatibility contract. Other C++ APIs may evolve, and no cross-version C++ binary ABI is promised. -Pin the `v0.8.1` release and include RaBitQ-Library as a Git submodule: +Pin the `v0.8.2` release and include RaBitQ-Library as a Git submodule: ```bash git submodule add https://github.com/VectorDB-NTU/RaBitQ-Library.git third_party/rabitqlib git submodule update --init --recursive -git -C third_party/rabitqlib checkout v0.8.1 +git -C third_party/rabitqlib checkout v0.8.2 git add third_party/rabitqlib ``` diff --git a/docs/docs/index.md b/docs/docs/index.md index 9a2cdae4..b4d22a87 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -26,7 +26,7 @@ estimation into your own C++ search engine.

```bash -python -m pip install "rabitqlib>=0.8.1" +python -m pip install "rabitqlib>=0.8.2" ``` [Installation and supported platforms](installation.md) · @@ -50,7 +50,7 @@ of clusters probed to balance storage, recall, and query time. **Updates:** append and remove vectors. -**GIST1M, 5-bit:** 1,025 QPS at 92.8% Recall@10; 0.60 GiB index. +**GIST1M, 5-bit:** 976 QPS at 93.3% Recall@10; 0.60 GiB index. @@ -63,7 +63,7 @@ search window and graph degree alongside the code precision. **Updates:** append and remove vectors. -**GIST1M, 5-bit:** 2,042 QPS at 92.8% Recall@10; 0.72 GiB index. +**GIST1M, 5-bit:** 2,027 QPS at 92.7% Recall@10; 0.72 GiB index. @@ -76,7 +76,7 @@ Combine batched neighbor scoring with raw or 4/8-bit vector storage. **Updates:** rebuild to change the dataset. -**GIST1M, 4-bit:** 3,062 QPS at 90.7% Recall@10; 4.39 GiB index. +**GIST1M, 4-bit:** 3,070 QPS at 90.5% Recall@10; 4.39 GiB index. @@ -96,7 +96,7 @@ For input shapes, distance conventions, memory estimates, and threading, see ## Compare measured performance The GIST1M numbers above are the fastest measured setting with Recall@10 ≥ 90%, -using one search thread and one query per call with RaBitQ-Library 0.8.0; index +using one search thread and one query per call with RaBitQ-Library 0.8.2; index size is serialized storage. See the [benchmark results](benchmarks/bench.md#key-results) for datasets, baselines, settings, and limitations. Evaluate recall and query time on your own dataset when choosing an index. @@ -112,5 +112,5 @@ on your own dataset when choosing an index. ## Explore further - [Algorithm overview](rabitq/rabitq.md) for the rotation, encoding, and scoring workflow. -- [RaBitQ Library 0.8.1 release notes](releases/0.8.1.md) for the latest changes and upgrade guidance. +- [RaBitQ Library 0.8.2 release notes](releases/0.8.2.md) for the latest changes and upgrade guidance. - [Papers and citations](papers.md) for the original RaBitQ method, its multi-bit extension, and SymphonyQG. diff --git a/docs/docs/index/hnsw.md b/docs/docs/index/hnsw.md index 72cc529e..a846c58d 100644 --- a/docs/docs/index/hnsw.md +++ b/docs/docs/index/hnsw.md @@ -18,19 +18,23 @@ Run the [Quick start](../quick_start.md#build-and-search-an-ivf-index) to prepar ```python from rabitqlib import HnswIndex -index = HnswIndex(data.shape[1], nbits=4, M=16, ef_construction=100) -index.train(data) # 16 RaBitQKMeans centroids by default -index.add(data, num_threads=1) # one thread gives a reproducible graph -ids, distances = index.search(queries, k=10, ef=100) +index = HnswIndex(data.shape[1], M=16, ef_construction=100) # nbits=7 by default +index.add(data, num_threads=1) # trains 16 centroids, then links; 1 thread is reproducible +ids, distances = index.search(queries, k=10) # ef defaults to max(k, 64) index.save("hnsw.index") loaded = HnswIndex.load("hnsw.index") ``` +The first `add` trains an untrained index on the added vectors, as for +[IVF-RaBitQ](ivf.md#training-centroids-in-python); with fewer rows than +`num_clusters`, it trains one centroid per row, and `num_clusters` then reports +the row count. An empty first `add` raises `RuntimeError`. `train` and `set_centroids` work as for [IVF-RaBitQ](ivf.md#training-centroids-in-python), with `num_clusters` centroids (default 16) and always RaBitQKMeans; `index.cp` starts from the same defaults with `seed`'s low 32 bits. The first `add` into the empty index links vectors with their exact coordinates, like the 0.7 `build`; -later adds link through the stored codes (see [Recall after `add`](#recall-after-add)). +later adds link through the stored codes (see [Recall after `add`](#recall-after-add)), +so add most vectors in the first call. ### C++ @@ -63,12 +67,12 @@ see [C++ integration](../cpp_integration.md). | Parameter | Contract | | --- | --- | | `dim` | Original input dimension; Python supports 64–65,536 and pads internally. | -| `nbits` / C++ `total_bits` | Required and, in Python, keyword-only: 1–9 total bits including the sign bit. Raw-vector storage is not supported. | -| `num_clusters` | Python keyword, default 16: centroids `train` learns or `set_centroids` supplies. | -| `M`, `ef_construction` | Graph connectivity and construction search budget; Python defaults are 16 and 200. `M` must be at least 2 and is capped at 10,000; `ef_construction` is raised to at least `M`. The base layer keeps up to `2 * M` edges per node. | +| `nbits` / C++ `total_bits` | 1–9 total bits including the sign bit. Python's keyword-only `nbits` defaults to 7; C++ requires it. Raw-vector storage is not supported. | +| `num_clusters` | Python keyword, default 16: centroids `train` or the first `add` learns, or `set_centroids` supplies; the first `add` caps it at its row count. | +| `M`, `ef_construction` | Graph connectivity and construction search budget; Python defaults are 16 and 200, and the index reports both as properties. `M` must be at least 2 and is capped at 10,000; `ef_construction` is raised to at least `M`. The base layer keeps up to `2 * M` edges per node. | | `metric`, `seed` | Python defaults are `"l2"` and 100; `"ip"` is also supported. `seed` was called `random_seed` before 0.8. | | `max_elements` | Python reports the current capacity, which `add` grows geometrically (at least doubling) and `resize` can preallocate. C++ `add` never grows it. | -| `k`, `ef` | Positive result count and search window. Python requires `k <= num_stored_points`, so a trained index without points rejects every `k`; omitted, `None` or zero `ef` uses `max(k, 10)`, positive `ef` is raised to at least `k`, and `ef` above the stored point count is clamped to it. | +| `k`, `ef` | Positive result count and search window. `k` may exceed the stored point count; Python pads missing results, while C++ returns fewer pairs. Omitted, `None` or zero `ef` uses `max(k, 64)` in Python, positive `ef` is raised to at least `k`, and `ef` above the stored point count is clamped to it. | `seed` controls graph levels and the FHT/Kac rotation (using its low 32 bits). With identical inputs and parameters, single-threaded construction is reproducible @@ -241,7 +245,7 @@ not copied, only their pointer table. ### Recall after `add` -The centroids and the rotation are fixed when the index is constructed, so `add` +The centroids and the rotation are fixed once the index is trained, so `add` never retrains them. If the added vectors come from a different distribution, or the index grows many times beyond its original size, recall can drop. Rebuild from the original data with new centroids when recall matters more than the cost @@ -261,7 +265,7 @@ size_t HierarchicalNSW::remove(const PID* labels, size_t num_points); In Python: ```python -removed = index.remove(ids) # ids in [0, num_stored_points) +removed = index.remove(new_ids[:10]) # IDs in [0, num_stored_points) ``` A removed point keeps its codes, its links, and its place in the graph. Search @@ -276,7 +280,7 @@ exclusive access as other updates. Removed points still take slots in the `ef_search` candidate set, so a search with `ef_search` close to `k` finds fewer live points as removals accumulate. C++ search -uses `max(ef_search, k)`, clamped to `num_stored_points()`. Python uses `max(k, 10)` +uses `max(ef_search, k)`, clamped to `num_stored_points()`. Python uses `max(k, 64)` when `ef` is omitted or zero; an explicit positive `ef` is also raised to at least `k`, and both are clamped to the stored point count. To keep about the same number of live candidates as before the removals, scale diff --git a/docs/docs/index/ivf.md b/docs/docs/index/ivf.md index cf0cf8cf..86e11790 100644 --- a/docs/docs/index/ivf.md +++ b/docs/docs/index/ivf.md @@ -14,9 +14,9 @@ width, number of clusters, and `nprobe`. ### Python The [complete Quick Start](../quick_start.md) is the runnable IVF-RaBitQ example: -create an index, `train` its centroids, `add` the vectors, search, then save and -reload it. [Training](#training-centroids-in-python) describes the clustering -options, and the update examples below reuse the Quick Start's `index`. +create an index, `add` the vectors (the first `add` trains the centroids), search, +then save and reload it. [Training](#training-centroids-in-python) describes the +clustering options, and the update examples below reuse the Quick Start's `index`. ### C++ @@ -47,12 +47,12 @@ See [C++ integration](../cpp_integration.md) for build setup and | Parameter | Contract | | --- | --- | | `dim` | Original input dimension; Python supports 64–65,536 and pads internally. | -| `num_clusters` | Number of centroids `train` learns or `set_centroids` supplies; every cluster ID must be in `[0, num_clusters)`. | -| `nbits` / C++ `total_bits` | Required and, in Python, keyword-only: 1–9 total bits including the sign bit, or 32 for owned raw-vector reranking. | +| `num_clusters` | Number of centroids `train` or the first `add` learns, or `set_centroids` supplies; every cluster ID must be in `[0, num_clusters)`. | +| `nbits` / C++ `total_bits` | 1–9 total bits including the sign bit, or 32 for owned raw-vector reranking. Python's keyword-only `nbits` defaults to 7; C++ requires it. | | `seed` | Rotation seed in `[0, 2**32 - 1]` and the initial `cp.seed`. Python's default `None` draws one at random; `index.seed` reports it, and passing it back reproduces the index. | | `metric` | Python `"l2"` or `"ip"`; defaults to `"l2"`. | | `initializer` | Defaults to `"auto"`; see [centroid routing](#centroid-routing). | -| `k`, `nprobe` | Positive result count and cluster probe count; C++ and Python both require `k` not to exceed the stored point count (`max_elements`), which `add` increases. | +| `k`, `nprobe` | Positive result count and cluster probe count. `k` may exceed the stored point count; missing results are padded with `kPidMax` (`2**32 - 1` in Python) and `+inf`. Python's `nprobe` defaults to `num_clusters / 16`, rounded and at least 1, when omitted, `None` or 0; C++ requires a positive `nprobe`. | | `high_accuracy` | Python defaults to `None`: HACC for 4–9 bits, standard FastScan otherwise. | Results contain IDs and squared L2 or `1 - dot` distances, ordered from smallest @@ -203,15 +203,17 @@ ivf.construct(data.data(), centroids.data(), cluster_ids.data(), true); `train(x)` learns `num_clusters` centroids from the rows of `x`, which must have at least `num_clusters` rows, and stores them in the index; `add` then assigns -and quantizes vectors. Training uses the settings in `index.cp`, a +and quantizes vectors. On an untrained index, the first `add` without +`cluster_ids` calls `train` on the added vectors first, so `train` is needed only +to train on a different sample. Training uses the settings in `index.cp`, a [`RaBitQKMeansParameters`](../clustering.md#rabitqkmeans) whose defaults differ -from the k-means classes: 25 iterations, at most 256 sampled vectors per +from the k-means classes: 10 iterations, at most 256 sampled vectors per centroid (`max_points_per_centroid`), spherical k-means for `metric="ip"`, and `seed` equal to the index seed. Change fields before training: ```python index = IvfIndex(dim, 1024, nbits=4, metric="ip") -index.cp.niter = 10 +index.cp.niter = 20 index.train(sample) # num_threads=None uses index.cp.num_threads (0: all CPUs) index.add(vectors) ``` @@ -225,10 +227,13 @@ index.add(vectors) - Training or setting centroids again replaces them while the index stores no vectors; once it stores any (including removed ones), both raise `RuntimeError`. A failed call leaves the index unchanged. -- Before training, `add`, `search`, `remove`, `reserve` and `save` raise - `RuntimeError`. A trained index without vectors cannot be saved, and its - `search` rejects every `k` with `ValueError`; add vectors first. Loaded - indexes are trained, and their `seed` is `None`. +- Before training, `add` with `cluster_ids`, `search`, `remove`, `reserve` and + `save` raise `RuntimeError`; `add` without `cluster_ids` trains first, except + on an index created with `max_elements`, which `build` fills. If that + first `add` fails, the index stays untrained and empty; an empty first `add` + raises `RuntimeError`. A trained index without + vectors cannot be saved, and its `search` returns only padded results; add + vectors first. Loaded indexes are trained, and their `seed` is `None`. - `train` and `add` default to every available CPU; `search` defaults to one thread. @@ -265,17 +270,16 @@ index.add(data.data(), num_points); threshold is provisional and has not been measured. Only centroids are trained; `add` assigns the points. `cp.num_threads` also limits building the centroid routing. -- `default_training_parameters(metric, seed)` runs 25 iterations on at most +- `default_training_parameters(metric, seed)` runs 10 iterations on at most 256 sampled points per centroid and selects spherical k-means for inner product. The k-means classes keep their own defaults, which train on every point. - `train` and `set_centroids` replace the centroids only while the index stores no points, removed ones included; otherwise they throw `std::logic_error`. A failed call leaves the index unchanged. - `is_trained()` reports whether centroids are set. Before that, `add` and - `search` throw `std::logic_error`. A trained index without points rejects - every `k` in `search` with `std::invalid_argument`, because `k` may not exceed - the stored point count; `save` throws `std::logic_error` until the index stores - at least one point. + `search` throw `std::logic_error`. A trained index without points pads every + `search` result slot with `kPidMax` and `+inf`; `save` throws + `std::logic_error` until the index stores at least one point. ## Querying @@ -370,8 +374,10 @@ void IVF::reserve(PID cluster_id, size_t num_points); receives the PID `num_stored_points() + i`, using the count before the call. Existing IDs stay unchanged, and removed IDs are never reused. - **cluster_ids**: The cluster of each new vector, in `[0, num_clusters)`. When - it is `nullptr`, vectors use the same centroid routing as queries. Routing uses - the initializer selected at construction or restored from the index file. + it is `nullptr`, each vector goes to its nearest centroid under the index's + routing type, the initializer selected at construction or restored from the + index file. With Flat routing, `add` assigns exactly and queries scan in float, + so near-ties may resolve differently. - **fast_quantization**, **num_threads**: Same as in `construct`. - **ids_to_remove**: `num_points` PIDs to remove. Every PID must be below `num_stored_points()`; nothing is removed if one is not. `remove` returns how many were newly removed and can be @@ -395,7 +401,7 @@ Python `vectors` has shape `(n, index.dim)` and is converted to contiguous float one-dimensional integer array or sequence with one valid cluster ID per vector; `remove()` likewise accepts a one-dimensional integer array or sequence of point IDs and returns the number newly removed. Duplicate IDs count once. Python -`num_threads` defaults to 0 (every available CPU) and `fast_quantization` to +`num_threads` defaults to `None` (every available CPU) and `fast_quantization` to `False`; pass both by keyword. After appending, rebuilding with C++ `construct` requires `num_stored_points` @@ -425,7 +431,7 @@ size; serialized file size alone does not determine peak process memory. ### Recall after `add` -The centroids and the rotation are fixed when the index is constructed, so +The centroids and the rotation are fixed once the index is trained, so `add` never retrains them. If the added vectors come from a different distribution, or the index grows many times beyond its original size, recall at a given `nprobe` can drop. Rebuild from the original data with new centroids when diff --git a/docs/docs/index/qg.md b/docs/docs/index/qg.md index c574eee8..cbab008b 100644 --- a/docs/docs/index/qg.md +++ b/docs/docs/index/qg.md @@ -24,7 +24,7 @@ from rabitqlib import SymqgIndex # data and queries are float32 arrays with shape (count, dim). index = SymqgIndex(data.shape[1], max_degree=32, ef_construction=200, init="pipnn") -index.add(data) # builds the whole graph +ids = index.add(data) # builds the whole graph; ids are the row numbers index.save("qg_example.index") loaded = SymqgIndex.load("qg_example.index") @@ -77,8 +77,8 @@ qg.save("qg_example.index"); | `init` | Python keyword: `"pipnn"` (default) or `"random"`. | | `metric` | Python keyword: `"l2"` (default) or `"ip"`. | | `seed` | Python keyword: rotation and graph-construction seed in `[0, 2**32 - 1]`. The default `None` draws one at random; `index.seed` reports it, and passing it back with `num_threads=1` rebuilds the same graph from the same data. | -| `k`, `ef` | Python requires `1 <= k <= num_points`. Omitted or zero `ef` uses `max(k, 10)`, and smaller values are raised to `k`, as for HNSW-RaBitQ. | -| `num_threads` | Python `add` defaults to 0 (every available logical CPU) and `search` to 1. | +| `k`, `ef` | `k` is at least 1 and may exceed the point count; slots the graph cannot fill are padded with ID `2**32 - 1` and distance `+inf`. Omitted or zero `ef` uses `max(k, 64)` in Python, and smaller values are raised to `k`, as for HNSW-RaBitQ. | +| `num_threads` | Python `add` defaults to every available logical CPU, capped by `OMP_NUM_THREADS`, and `search` to 1; `None` selects these defaults. | Python search returns `(ids, distances)` arrays. Distances are squared L2 or `1 - dot`, ordered from smallest to largest; quantized storage returns @@ -180,7 +180,7 @@ do no work and may use null buffers. Inputs and outputs must not overlap. Concurrent searches need separate outputs; do not modify the index or call `set_ef()` while searches are running. -Python `search(queries, k, ef=None, num_threads=1)` accepts a two-dimensional +Python `search(queries, k, ef=None, num_threads=None)` accepts a two-dimensional query array and returns `(ids, distances)` arrays of shape `(num_queries, k)`, including empty batches. It defaults to one worker; `num_threads=0` selects the available logical CPU count, capped by the number of queries. The 0.7 name diff --git a/docs/docs/index/usage.md b/docs/docs/index/usage.md index d7cb4e69..f96c5d2f 100644 --- a/docs/docs/index/usage.md +++ b/docs/docs/index/usage.md @@ -46,7 +46,7 @@ IVF-RaBitQ, HNSW-RaBitQ, and SymphonyQG expose these read-only Python properties added, both point counts are zero; configured capacity may be nonzero. For all three indexes, `len(index)` equals `num_points`. `is_trained` reports whether IVF-RaBitQ or HNSW-RaBitQ centroids are set (by `train`, `set_centroids`, -or `load`), and is always `True` for SymphonyQG. +the first `add`, or `load`), and is always `True` for SymphonyQG. Removal decreases only `num_points`; slots and IDs remain occupied. Append IDs start at the previous `num_stored_points`. Use this stored count for ID bounds, @@ -144,8 +144,9 @@ fewer workers. On macOS, OpenMP runtimes bundled by different packages (for example Faiss or PyTorch wheels) can crash when more than one of them starts worker threads in the same process; set `OMP_NUM_THREADS=1` before the first native call, or pass `num_threads=1`, in such processes. -`train` and `add` on every index default to `0`, as does clustering; `search` -and the deprecated `build` default to one thread. +`train` and `add` on every index default to all CPUs (`None` or `0`); the +clustering classes default to `0`. `search` defaults to one thread (`None` or `1`), +as do the deprecated `build`, which requires an integer, and `search_batch`. Multithreaded HNSW-RaBitQ `add` links points in a nondeterministic order, so pass `num_threads=1` when a build must be reproducible. On Linux, CPU detection accounts for affinity and OpenMP binding. Apply binding diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 2b8e879a..e58411e2 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -1,6 +1,6 @@ # Installation -Platform requirements and source installation for RaBitQ Library **0.8.1**. +Platform requirements and source installation for RaBitQ Library **0.8.2**. For the first Python example, see [Quick Start](quick_start.md). ## Requirements @@ -40,7 +40,7 @@ build can use scalar kernels without the FastScan CPU baseline. ## Python wheels ```bash -python -m pip install "rabitqlib>=0.8.1" +python -m pip install "rabitqlib>=0.8.2" ``` For changes from 0.6.0, see the [0.7.0 upgrade notes](releases/0.7.0.md). @@ -61,11 +61,11 @@ importing those classes. Array shapes and parameter ranges are still checked at ## Get the source -For Python source builds or C++ use, obtain the 0.8.1 checkout once. With Git +For Python source builds or C++ use, obtain the 0.8.2 checkout once. With Git installed, run: ```bash -git clone --branch v0.8.1 --depth 1 https://github.com/VectorDB-NTU/RaBitQ-Library.git +git clone --branch v0.8.2 --depth 1 https://github.com/VectorDB-NTU/RaBitQ-Library.git cd RaBitQ-Library ``` diff --git a/docs/docs/python_migration.md b/docs/docs/python_migration.md index baf942ce..52fad117 100644 --- a/docs/docs/python_migration.md +++ b/docs/docs/python_migration.md @@ -1,10 +1,11 @@ # Migrating Python code from 0.7 to 0.8 In 0.8, `IvfIndex`, `HnswIndex` and `SymqgIndex` follow a `train` → `add` → -`search` workflow, as Faiss indexes do. IVF-RaBitQ and HNSW-RaBitQ learn their -own centroids, so you no longer run k-means and pass the centroids and -assignments to `build`; SymphonyQG has nothing to train and builds its graph in -`add`. Code written for 0.7 +`search` workflow, as Faiss indexes do; since 0.8.2 the first `add` trains, so +construct → `add` → `search` is enough, and `train` remains for training on a +sample. IVF-RaBitQ and HNSW-RaBitQ learn their own centroids, so you no longer +run k-means and pass the centroids and assignments to `build`; SymphonyQG has +nothing to train and builds its graph in `add`. Code written for 0.7 still runs in 0.8. Each old form raises a `DeprecationWarning` at your call site and will be removed in **0.9**. @@ -22,16 +23,16 @@ warning keeps its 0.7 behavior. Calls that are valid in both versions, such as | `IvfIndex(dim=..., max_elements=..., num_clusters=..., nbits=...)` | the same, without `max_elements` | | `HnswIndex(dim, max_elements, M=16, ef_construction=200, nbits=8, metric="l2", random_seed=100)` | `HnswIndex(dim, nbits=..., num_clusters=16, M=16, ef_construction=200, metric="l2", seed=100)`; capacity grows on `add`, and `resize(n)` preallocates it | | `HnswIndex(..., random_seed=s)` | `HnswIndex(..., seed=s)` | -| `kmeans = RaBitQKMeans(dim, k)`
`kmeans.train(x)`
`index.build(x, kmeans.centroids, kmeans.assignments)` | `index.train(x)`
`index.add(x)` | +| `kmeans = RaBitQKMeans(dim, k)`
`kmeans.train(x)`
`index.build(x, kmeans.centroids, kmeans.assignments)` | `index.add(x)`, which trains on `x` first (since 0.8.2; in 0.8.0 and 0.8.1, call `index.train(x)` first) | | `index.build(x, centroids, cluster_ids)` | `index.set_centroids(centroids)`
`index.add(x, cluster_ids=cluster_ids)` | | `index.add(x, cluster_ids, num_threads, fast_quantization)` | `index.add(x, cluster_ids=..., num_threads=..., fast_quantization=...)` | | `index.is_built` | `index.is_trained`, or `len(index) > 0` for "holds vectors" | Except for `dim` and IVF-RaBitQ's `num_clusters`, constructor arguments are -keyword-only in 0.8, and `nbits` has no default. HNSW-RaBitQ used to default to -`nbits=8`; the old form keeps that default. +keyword-only in 0.8. Since 0.8.2, `nbits` defaults to 7 (0.8.0 and 0.8.1 required +it). HNSW-RaBitQ used to default to `nbits=8`; the old form keeps that default. -`train` uses the settings in `index.cp`: 25 iterations, at most 256 sampled +`train` uses the settings in `index.cp`: 10 iterations (25 before 0.8.2), at most 256 sampled vectors per centroid, spherical k-means for `metric="ip"`, and the index seed. See [IVF-RaBitQ training](index/ivf.md#training-centroids-in-python) for the details and for `set_centroids`. The [clustering classes](clustering.md) still @@ -40,7 +41,7 @@ work if you want to run k-means yourself. ### Behavior changes - **Thread defaults.** `train` and `add` now default to every available CPU - (`num_threads=0`), capped by `OMP_NUM_THREADS` since 0.8.1; 0.7 `build` and + (`num_threads=None`, the same as `0`), capped by `OMP_NUM_THREADS` since 0.8.1; 0.7 `build` and `add` defaulted to one thread. This applies to every `add` call that does not pass `num_threads`, including 0.7 code such as `index.add(x)`, without a warning. `search` still defaults to one thread, and the deprecated `build` @@ -50,17 +51,18 @@ work if you want to run k-means yourself. to `add` for the same graph on every run. With one thread and the same seed, `set_centroids` followed by `add` produces exactly the index that 0.7 `build` produced from the same centroids and cluster IDs. -- **HNSW-RaBitQ `search` limits `k` to the stored point count** - (`num_stored_points`) instead of the capacity, since capacity now grows - geometrically. A `k` between the two used to return padding rows and now - raises `ValueError`, as it already did for IVF-RaBitQ. +- **`k` above the point count.** 0.8.0 and 0.8.1 raised `ValueError` when `k` + exceeded `num_stored_points` (HNSW-RaBitQ) or the point count (IVF-RaBitQ, + SymphonyQG). Since 0.8.2, every index accepts any `k >= 1` and fills missing + slots with ID `2**32 - 1` and distance `inf`; test `np.isfinite(distances)`. - **HNSW-RaBitQ capacity grows.** `add` resizes the index, at least doubling it, instead of raising `ValueError` when the batch does not fit. This also applies to `add` on indexes created in the old form, and to the deprecated `build`, which no longer rejects more rows than `max_elements`. -- **State errors.** Calling `add`, `search`, `remove`, `reserve` or `save` - before training raises `RuntimeError("IvfIndex must be trained or loaded - before ...")`; the 0.7 message said "built". A trained index without vectors +- **State errors.** Calling `add` with `cluster_ids`, `search`, `remove`, + `reserve` or `save` before training raises `RuntimeError("IvfIndex must be + trained or loaded before ...")`; the 0.7 message said "built". Since 0.8.2, + `add` without `cluster_ids` trains first. A trained index without vectors cannot be saved. `repr` shows `is_trained` instead of `is_built`. - **Seeds.** `IvfIndex` still draws a random rotation seed by default, and now reports it as `index.seed`; pass it back as `seed=` to reproduce an index. @@ -89,7 +91,7 @@ NSG index does. The deprecated `build` keeps its 0.7 behavior: it replaces any existing graph and, unless you passed `seed`, draws a new random seed each time. `search` now treats `ef` like HNSW-RaBitQ: an omitted or zero `ef` uses -`max(k, 10)`, and a smaller one is raised to `k`. In 0.7, both raised +`max(k, 64)` (`max(k, 10)` before 0.8.2), and a smaller one is raised to `k`. In 0.7, both raised `ValueError`. `add` uses every CPU by default, while the deprecated `build` keeps one thread. The seed now also drives graph construction: without `seed`, each index draws one random seed and reports it as `index.seed`; 0.7 drew @@ -98,11 +100,16 @@ separate random seeds for the rotation and the graph. Before vectors are added, ## Files and C++ -Saved files, the `max_elements` property, and `search` results for the same -index are unchanged. Files written by 0.7 load as trained indexes. +Saved files and the `max_elements` property are unchanged. `HnswIndex.search` +without `ef` uses `max(k, 64)` since 0.8.2 (`max(k, 10)` before), which changes +its results and latency; pass `ef=` to keep the old window. Files written by 0.7 +load as trained indexes, except empty, untrained HNSW-RaBitQ files, which need +`train` before `add`. For C++, the existing constructors and `construct` are unchanged; see [IVF-RaBitQ](index/ivf.md#training-centroids-in-c) and [HNSW-RaBitQ](index/hnsw.md#training-centroids-in-c) for the new training entry -points. The one C++ behavior change is that saving an index without stored -points now throws `std::logic_error`. +points. C++ behavior changes: since 0.8.0, saving an index without stored points +throws `std::logic_error`; since 0.8.2, IVF and SymphonyQG searches pad `k` above +the point count instead of throwing, and `default_training_parameters` runs 10 +iterations. See the [0.8.2 notes](releases/0.8.2.md#c-changes). diff --git a/docs/docs/quick_start.md b/docs/docs/quick_start.md index 817a3dbc..b573bcfd 100644 --- a/docs/docs/quick_start.md +++ b/docs/docs/quick_start.md @@ -1,14 +1,14 @@ # Quick start Build, search, save and reload a small IVF-RaBitQ index with Python. This guide describes -**0.8.1** and uses synthetic data, so no dataset download is required. Coming from 0.7? +**0.8.2** and uses synthetic data, so no dataset download is required. Coming from 0.7? See the [migration guide](python_migration.md). For C++, go directly to [C++ integration](cpp_integration.md). ## Install ```bash -python -m pip install "rabitqlib>=0.8.1" +python -m pip install "rabitqlib>=0.8.2" ``` Wheels support CPython 3.11–3.14 on Linux x86-64/ARM64, Windows x86-64 and @@ -34,23 +34,27 @@ rng = np.random.default_rng(42) data = rng.standard_normal((500, 64)).astype(np.float32) queries = rng.standard_normal((5, 64)).astype(np.float32) -index = IvfIndex(64, 5, nbits=4, metric="l2", seed=42) -index.train(data) # learn 5 centroids -index.add(data) # quantize every vector into its nearest cluster +index = IvfIndex(64, 5, metric="l2", seed=42) # 5 clusters, 7 bits by default +index.add(data) # learn 5 centroids, then quantize every vector into its cluster ids, distances = index.search(queries, k=10, nprobe=5) print(ids.shape, distances.shape) # (5, 10) (5, 10) print(ids[0]) ``` -`train` clusters the data with the settings in `index.cp`: 25 k-means -iterations on at most 256 sampled vectors per centroid, spherical k-means for -`metric="ip"`, and the index `seed`. Change `index.cp` before training to tune -them, or call `index.set_centroids(centroids)` to use centroids you computed +The first `add` on an untrained index trains it on the added vectors, as +`index.train(data)` would, then adds them. Training uses the settings in +`index.cp`: 10 k-means iterations on at most 256 sampled vectors per centroid, +spherical k-means for `metric="ip"`, and the index `seed`. Change `index.cp` +before the first `add` to tune them, call `index.train(sample)` first to train on a +sample, or call `index.set_centroids(centroids)` to use centroids you computed elsewhere. `train` and `add` use every available CPU by default, capped by `OMP_NUM_THREADS`; pass `num_threads=` to limit them. Without `seed`, each index draws a random rotation seed and reports it as `index.seed`. +`nprobe` sets how many clusters a query searches. Omitted, it is +`num_clusters / 16`, rounded and at least 1; this example probes all five. + ## Read the results Both outputs have shape `(number_of_queries, k)`. IDs are zero-based and follow @@ -60,10 +64,10 @@ squared Euclidean distance, while `metric="ip"` estimates `1 - dot(data, query)` Smaller values are better for both metrics. Quantized distances are estimates, not exact distances to the original vectors. -IVF-RaBitQ and HNSW-RaBitQ may return fewer than `k` valid neighbors, for example after removals -or when the probed IVF-RaBitQ clusters contain too few points. Unfilled slots contain -ID `2**32 - 1` and distance `+inf`. Filter them with -`valid = ids != np.iinfo(np.uint32).max` before using IDs to index your data. +A search may return fewer than `k` valid neighbors, for example when `k` exceeds the +number of points, after removals, or when the probed IVF-RaBitQ clusters contain too +few points. Unfilled slots contain ID `2**32 - 1` and distance `+inf`. Filter them with +`valid = np.isfinite(distances)` before using IDs to index your data. The `metric` argument accepts `"l2"` and `"ip"` (also `"innerproduct"`). For cosine search, normalize database and query vectors first and use `metric="ip"`. diff --git a/docs/docs/releases/0.8.2.md b/docs/docs/releases/0.8.2.md new file mode 100644 index 00000000..42a3b01a --- /dev/null +++ b/docs/docs/releases/0.8.2.md @@ -0,0 +1,79 @@ +# RaBitQ Library 0.8.2 + +This release shortens the Python workflow to three lines for every index — +construct, `add`, `search` — and makes adding vectors to IVF-RaBitQ much faster +when the index assigns their clusters. Two default changes alter the results of +existing code; see [Changes to results](#changes-to-results). + +```python +index = IvfIndex(dim, num_clusters, metric="ip") # or HnswIndex(dim) / SymqgIndex(dim) +index.add(vectors) # the first add trains the index +ids, distances = index.search(queries, k=10) +``` + +## Python changes + +- The first `add` on an untrained `IvfIndex` or `HnswIndex` trains it on the + added vectors, as `train(vectors)` does, so `train` is needed only to train on + a sample. If that `add` fails, the index stays untrained and empty. `add` with + `cluster_ids` still requires a trained index. IVF-RaBitQ needs at least + `num_clusters` rows; HNSW-RaBitQ trains one centroid per row when given fewer + rows than `num_clusters`. An empty first `add` raises `RuntimeError`. An + `IvfIndex` created in the 0.7 `max_elements` form is still filled only by + `build`, and untrained HNSW-RaBitQ files saved by 0.7 need `train` before + `add`; a 0.7-form `HnswIndex` trains on its first `add`. +- `nbits` defaults to 7 for `IvfIndex` and `HnswIndex`; the 0.7 HNSW-RaBitQ form + keeps 8 and SymphonyQG keeps 32. +- `IvfIndex.search` no longer requires `nprobe`: omitted, `None` or 0 probes + `num_clusters / 16` clusters, rounded and at least one. +- `k` may exceed the number of points. IVF-RaBitQ, HNSW-RaBitQ and SymphonyQG + fill the missing slots with ID `2**32 - 1` and distance `inf` instead of raising + `ValueError`; a trained index without points returns only such slots. Test + `np.isfinite(distances)` to find real results. +- `train`, `add` and `search` on every index accept `num_threads=None` for their + defaults: all CPUs for `train` and `add`, one thread for `search`. When the + first `add` trains, `None` uses `index.cp.num_threads` for training. +- `SymqgIndex.add` returns the point IDs, `HnswIndex` reports `M` and + `ef_construction`, and `rabitqlib.__version__` reports the package version. + +## Changes to results + +- `HnswIndex.search` and `SymqgIndex.search` without `ef` use `max(k, 64)` + instead of `max(k, 10)`: recall rises and queries take longer. Pass `ef=` to + keep the old window. +- `IvfIndex` and `HnswIndex` training (`index.cp`) and the C++ + `default_training_parameters` run 10 k-means iterations instead of 25, as Faiss + IVF does. The same seed therefore no longer reproduces a 0.8.1 index; set + `index.cp.niter = 25` to restore the old training. The k-means classes keep 25. + +## Performance + +IVF-RaBitQ `add` without `cluster_ids` assigns vectors to Flat-routed centroids +(the `auto` choice below 5,000 clusters) with the blocked exact assignment of +k-means instead of one centroid scan per vector. On GIST1M with 4,096 clusters, +`nbits=5` and 48 threads, a separate A/B run (not part of the benchmark tables) +measured adding all vectors at 24–26 s in 0.8.1 and 6.4–6.7 s now, with +identical index files. Assignments can differ only at floating-point ties, where +the new result matches a float64 reference. See the refreshed +[benchmarks](../benchmarks/bench.md), whose IVF-RaBitQ results now use this +default `train` and `add` path. + +## C++ changes + +- `IVF::search` and `QuantizedGraph` searches accept `k` above the point count + and fill missing slots with `kPidMax` and `+inf` instead of throwing. +- `QuantizedGraph::search_with_scratch` throws `std::invalid_argument` when the + result pool's capacity is not `k`. +- `default_training_parameters` runs 10 iterations, as above. + +## Upgrading + +From 0.8.1, index file formats, the stable core API v1 and the Python +signatures are compatible: every new default fills a parameter that was required +or adds a value that previously raised. Review searches that omit `ef` and +indexes trained with default settings, which change as described above. Rebuild +C++ consumers with matching headers and libraries; no cross-version C++ binary +ABI is promised. + +When upgrading from 0.7, follow the [0.8.0 release notes](0.8.0.md) and the +[migration guide](../python_migration.md). diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e9a239e0..f80cb19a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -15,6 +15,7 @@ nav: # Group by minor version: nested groups stay collapsed except the current page's. - Release notes: - "0.8": + - "0.8.2": releases/0.8.2.md - "0.8.1": releases/0.8.1.md - "0.8.0": releases/0.8.0.md - "0.7": diff --git a/include/rabitqlib/clustering/detail/kmeans.hpp b/include/rabitqlib/clustering/detail/kmeans.hpp index 5447ebc4..809dc689 100644 --- a/include/rabitqlib/clustering/detail/kmeans.hpp +++ b/include/rabitqlib/clustering/detail/kmeans.hpp @@ -79,6 +79,8 @@ namespace detail { // final distances and tie breaking retain the scalar double-precision arithmetic. // Optional previous assignments contain n valid centroid IDs and may alias the // output. Recompute their distances against the current, updated centroids. +// Optional centroid_norms holds l2norm_sqr_f64 of each of the k centroids, so +// callers assigning repeatedly to fixed centroids compute them once. inline void exact_assign( const float* x, const float* centroids, @@ -89,7 +91,8 @@ inline void exact_assign( uint32_t num_workers, PID* assignments, float* distances, - const PID* previous_assignments = nullptr + const PID* previous_assignments = nullptr, + const double* centroid_norms = nullptr ) { constexpr size_t kMaxPointBlock = 128; // Larger row blocks amortize packing on the AVX-512 Eigen backend. Retain @@ -102,7 +105,9 @@ inline void exact_assign( constexpr size_t kCentroidBlock = 256; const size_t blocks = 1 + (n - 1) / point_block; const auto team_size = static_cast(std::min(num_workers, blocks)); - std::vector centroid_norms(k); + std::vector computed_norms(centroid_norms == nullptr ? k : 0); + const double* norms = + centroid_norms != nullptr ? centroid_norms : computed_norms.data(); std::vector> products( team_size, std::vector(point_block * std::min(k, kCentroidBlock)) ); @@ -139,12 +144,15 @@ inline void exact_assign( std::array point_norms{}; std::array best_distances{}; // Reuse this worker team for norms and assignment. The implicit barrier - // publishes all centroid norms before any matrix tile is screened. + // publishes all centroid norms before any matrix tile is screened. Every + // thread takes the same branch. + if (centroid_norms == nullptr) { #pragma omp for schedule(static) - for (std::ptrdiff_t cluster = 0; cluster < static_cast(k); - ++cluster) { - centroid_norms[static_cast(cluster)] = - l2norm_sqr_f64(centroids + static_cast(cluster) * d, d); + for (std::ptrdiff_t cluster = 0; cluster < static_cast(k); + ++cluster) { + computed_norms[static_cast(cluster)] = + l2norm_sqr_f64(centroids + static_cast(cluster) * d, d); + } } #pragma omp for schedule(static) for (std::ptrdiff_t block = 0; block < static_cast(blocks); @@ -184,8 +192,7 @@ inline void exact_assign( 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 norm_sum = point_norms[row] + norms[cluster]; const double dot = dots[row * cols + col]; const double estimate = spherical ? 1.0 - dot : norm_sum - 2.0 * dot; diff --git a/include/rabitqlib/index/detail/buffer.hpp b/include/rabitqlib/index/detail/buffer.hpp index cdc957d2..789866c6 100644 --- a/include/rabitqlib/index/detail/buffer.hpp +++ b/include/rabitqlib/index/detail/buffer.hpp @@ -100,6 +100,7 @@ class SearchBuffer { } [[nodiscard]] size_t size() const { return size_; } + [[nodiscard]] size_t capacity() const { return capacity_; } void copy_results(PID* result_ids) const { for (size_t i = 0; i < size_; ++i) { diff --git a/include/rabitqlib/index/ivf/ivf.hpp b/include/rabitqlib/index/ivf/ivf.hpp index 8a9c4de0..745ed983 100644 --- a/include/rabitqlib/index/ivf/ivf.hpp +++ b/include/rabitqlib/index/ivf/ivf.hpp @@ -78,7 +78,10 @@ class IVF { }; InitializerType initializer_type_ = InitializerType::Auto; - std::unique_ptr initializer_; // initializer for candidate clusters + std::unique_ptr initializer_; // initializer for candidate clusters + // Flat centroid norms for routing in add; computed on first use, cleared with + // the initializer. + std::vector flat_centroid_norms_; std::vector cluster_storage_; // one entry per cluster size_t max_elements_ = 0; // Logical capacity; construct fills it, add grows it. size_t num_removed_ = 0; // Recovered from removal markers when loading. @@ -320,7 +323,8 @@ class IVF { * @param data New points (num_points * dim) * @param num_points Number of new points * @param cluster_ids Cluster of each new point, or nullptr to route each point to its - * nearest centroid the same way a query is routed + * nearest centroid under the index's initializer. Flat routing assigns exactly, so + * near-ties may resolve differently from a query's float scan * @param fast_quantization Use fixed-scale extra-bit quantization for speed at a * possible loss of accuracy; has no effect on one-bit or raw storage * @param num_threads Threads used for routing and quantization @@ -369,6 +373,7 @@ class IVF { // Automatically use HACC for 4-9 quantized bits; use standard FastScan otherwise. // Distances estimate squared L2 or 1 - dot according to the index metric. + // k may exceed the point count; slots without a result get kPidMax and +inf. void search( const float* query, size_t k, diff --git a/include/rabitqlib/index/symqg/qg.hpp b/include/rabitqlib/index/symqg/qg.hpp index 64365818..1e82f6d0 100644 --- a/include/rabitqlib/index/symqg/qg.hpp +++ b/include/rabitqlib/index/symqg/qg.hpp @@ -299,7 +299,8 @@ class QuantizedGraph { /** * Search contiguous row-major queries, using the window set by set_ef(). * queries contains num_queries * dimension() floats; result_ids and distances - * each hold num_queries * k elements. Empty batches do no work. + * each hold num_queries * k elements. Empty batches do no work. When a query + * reaches fewer than k points, the remaining slots get kPidMax and +inf. * Scratch is reused within each worker. num_threads defaults to one; * zero selects the available logical CPU count, capped by the batch. * Input and output buffers must not overlap. Concurrent searches require @@ -332,7 +333,8 @@ class QuantizedGraph { * Reuses scratch and can use a previous result as a warm-start hint. * BatchQuery may allocate LUT storage on its first use or when its shape changes; * this is not the stable core v1 zero-allocation interface. Search and result - * buffers must be sized for ef_search and k respectively. rotated_query and + * buffers must be sized for ef_search and k respectively; a result pool whose + * capacity differs from k raises std::invalid_argument. rotated_query and * lookup_table need D and 4 * D floats, where D is the graph's stored padded * dimension; this advanced caller must already know D. dimension() is the original * input dimension. estimated_distances needs degree_bound() floats. The visited set diff --git a/include/rabitqlib/index/training.hpp b/include/rabitqlib/index/training.hpp index ce35dded..5f3a65a9 100644 --- a/include/rabitqlib/index/training.hpp +++ b/include/rabitqlib/index/training.hpp @@ -9,17 +9,17 @@ namespace rabitqlib { /** - * Centroid training parameters for IVF::train and HierarchicalNSW::train: 25 - * iterations on a sample of at most 256 points per centroid, spherical k-means for - * inner product, and the given seed. The k-means classes keep their own defaults, - * which train on all points. + * Centroid training parameters for IVF::train and HierarchicalNSW::train: 10 + * iterations, as in Faiss IVF, on a sample of at most 256 points per centroid, spherical + * k-means for inner product, and the given seed. The k-means classes keep their own + * defaults, which train on all points. */ inline rabitqkmeans::RaBitQKMeansParameters default_training_parameters( MetricType metric_type, uint32_t seed ) { validate_metric_type(metric_type); rabitqkmeans::RaBitQKMeansParameters cp; - cp.niter = 25; + cp.niter = 10; cp.max_points_per_centroid = 256; cp.spherical = metric_type == METRIC_IP; cp.seed = seed; diff --git a/pyproject.toml b/pyproject.toml index 3ab63ef1..c5bc4c9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "rabitqlib" -version = "0.8.1" +version = "0.8.2" description = "Official RaBitQ library for vector quantization and approximate nearest-neighbor search" readme = "README.md" requires-python = ">=3.11" diff --git a/python_bindings/CMakeLists.txt b/python_bindings/CMakeLists.txt index 63175c21..e77b7ec0 100644 --- a/python_bindings/CMakeLists.txt +++ b/python_bindings/CMakeLists.txt @@ -25,6 +25,8 @@ target_include_directories(_rabitqlib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ) rabitq_configure_target(_rabitqlib) +# Release preparation keeps this in sync with the Python package version. +target_compile_definitions(_rabitqlib PRIVATE RABITQ_VERSION="${PROJECT_VERSION}") # 4. Imported targets provide the matching Python, NumPy, and OpenMP settings. target_link_libraries(_rabitqlib PRIVATE diff --git a/python_bindings/__init__.py b/python_bindings/__init__.py index 9ce77a7f..b84c57a5 100644 --- a/python_bindings/__init__.py +++ b/python_bindings/__init__.py @@ -19,6 +19,9 @@ _QGKMeans, _RaBitQKMeans, ) +from ._rabitqlib import ( + __version__ as __version__, +) # --- 0.8 compatibility layer; remove in 0.9 -------------------------------- # The extension classes expose only the 0.8 API. As Faiss does for its SWIG @@ -49,7 +52,7 @@ def _bind(signature, name, args, kwargs, hint=""): def _ivf_signature( - self, dim, num_clusters, *, nbits, metric="l2", initializer="auto", seed=None + self, dim, num_clusters, *, nbits=7, metric="l2", initializer="auto", seed=None ): ... @@ -62,7 +65,7 @@ def _hnsw_signature( self, dim, *, - nbits, + nbits=7, num_clusters=16, M=16, ef_construction=200, @@ -84,7 +87,7 @@ def _hnsw_legacy_signature( def _add_signature( - self, data, *, cluster_ids=None, num_threads=0, fast_quantization=False + self, data, *, cluster_ids=None, num_threads=None, fast_quantization=False ): ... @@ -111,8 +114,7 @@ def __init__(self, *args, **kwargs): ) _deprecated( "IvfIndex(dim, max_elements, num_clusters, nbits, ...) is deprecated; " - "use IvfIndex(dim, num_clusters, nbits=...) and train() or " - "set_centroids() before add()" + "use IvfIndex(dim, num_clusters, nbits=...), whose first add() trains it" ) native_init( self, diff --git a/python_bindings/__init__.pyi b/python_bindings/__init__.pyi index 629fdf81..62d4f0b5 100644 --- a/python_bindings/__init__.pyi +++ b/python_bindings/__init__.pyi @@ -11,6 +11,8 @@ import numpy as np import numpy.typing as npt from typing_extensions import deprecated +__version__: str + __all__ = [ "FinalAssignmentMode", "HnswIndex", @@ -79,10 +81,10 @@ class IvfIndex: dim : int Original vector dimension, from 64 through 65536. num_clusters : int - Number of centroids, from train or set_centroids. - nbits : int + Number of centroids, from train, set_centroids or the first add. + nbits : int, optional Keyword-only. Total quantization bits, 1 through 9, or 32 for owned - float32 vectors with one-bit filtering and raw reranking. + float32 vectors with one-bit filtering and raw reranking. Default is 7. metric : {'l2', 'ip', 'innerproduct'}, optional Squared L2 or inner-product distance. Default is 'l2'. Vectors are not automatically normalized for cosine similarity. @@ -94,9 +96,10 @@ class IvfIndex: Notes ----- - Train the index or set its centroids, then add vectors before searching or - saving; loaded indexes are already trained. Concurrent reads are supported; - an overlapping mutation raises RuntimeError. + The first add trains an untrained index on the added vectors. Call train + on a sample, or set_centroids, first to choose the training data or the + centroids. Loaded indexes are already trained. Concurrent reads are + supported; an overlapping mutation raises RuntimeError. Data, centroids and queries must be finite float32 values within the index coordinate range; other values raise ValueError. The 0.7 form IvfIndex(dim, max_elements, num_clusters, nbits, ...) and @@ -108,10 +111,9 @@ class IvfIndex: >>> import numpy as np >>> from rabitqlib import IvfIndex >>> x = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) - >>> index = IvfIndex(64, 2, nbits=4, seed=7) - >>> index.train(x) - >>> ids = index.add(x) - >>> ids, distances = index.search(x[:2], k=3, nprobe=2) + >>> index = IvfIndex(64, 2, seed=7) + >>> ids = index.add(x) # trains on x first + >>> ids, distances = index.search(x[:2], k=3) >>> ids.shape, distances.shape ((2, 3), (2, 3)) """ @@ -122,7 +124,7 @@ class IvfIndex: dim: SupportsIndex, num_clusters: SupportsIndex, *, - nbits: SupportsIndex, + nbits: SupportsIndex = 7, metric: _Metric = "l2", initializer: _Initializer = "auto", seed: SupportsIndex | None = None, @@ -134,10 +136,10 @@ class IvfIndex: dim : int Original vector dimension, from 64 through 65536. num_clusters : int - Number of centroids, from train or set_centroids. - nbits : int + Number of centroids, from train, set_centroids or the first add. + nbits : int, optional Keyword-only. Total quantization bits, 1 through 9, or 32 for owned - float32 vectors with one-bit filtering and raw reranking. + float32 vectors with one-bit filtering and raw reranking. Default is 7. metric : {'l2', 'ip', 'innerproduct'}, optional Squared L2 or inner-product distance. Default is 'l2'. Vectors are not automatically normalized for cosine similarity. @@ -149,9 +151,10 @@ class IvfIndex: Notes ----- - Train the index or set its centroids, then add vectors before searching or - saving; loaded indexes are already trained. Concurrent reads are supported; - an overlapping mutation raises RuntimeError. + The first add trains an untrained index on the added vectors. Call train + on a sample, or set_centroids, first to choose the training data or the + centroids. Loaded indexes are already trained. Concurrent reads are + supported; an overlapping mutation raises RuntimeError. Data, centroids and queries must be finite float32 values within the index coordinate range; other values raise ValueError. The 0.7 form IvfIndex(dim, max_elements, num_clusters, nbits, ...) and @@ -161,7 +164,7 @@ class IvfIndex: @overload @deprecated( "IvfIndex(dim, max_elements, num_clusters, nbits, ...) is deprecated since " - "0.8; use IvfIndex(dim, num_clusters, nbits=...)" + "0.8; use IvfIndex(dim, num_clusters, nbits=...), whose first add() trains it" ) def __init__( self, @@ -196,7 +199,7 @@ class IvfIndex: @property def is_trained(self) -> bool: - """Whether centroids are set, by train, set_centroids, build or load.""" + """Whether centroids are set, by train, set_centroids, the first add, build or load.""" @property @deprecated("IvfIndex.is_built is deprecated since 0.8; use is_trained or len()") @@ -209,7 +212,7 @@ class IvfIndex: @property def cp(self) -> RaBitQKMeansParameters: - """Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 25, max_points_per_centroid 256, spherical for inner product, and seed equal to the index seed.""" + """Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 10, max_points_per_centroid 256, spherical for inner product, and seed equal to the index seed.""" @cp.setter def cp(self, value: RaBitQKMeansParameters) -> None: ... @@ -307,10 +310,10 @@ class IvfIndex: data: npt.ArrayLike, *, cluster_ids: _IntegerIds | None = None, - num_threads: SupportsIndex = 0, + num_threads: SupportsIndex | None = None, fast_quantization: bool | np.bool_ = False, ) -> npt.NDArray[np.uint32]: - """Add vectors using the trained centroids. + """Add vectors, training the index first if needed. Parameters ---------- @@ -322,8 +325,8 @@ class IvfIndex: index's configured centroid initializer; routing may be approximate. Centroids do not move. num_threads : int, optional - Keyword-only. Worker count. Default is 0, which selects the available - CPU count. + Keyword-only. Worker count. None (default) and 0 select the available + CPU count, capped by OMP_NUM_THREADS. fast_quantization : bool, optional Keyword-only. Use faster quantizer configuration. Default is False. @@ -334,9 +337,15 @@ class IvfIndex: Notes ----- - Requires a trained or loaded index. Batch additions to reduce repeated - cluster copying; reserve can preallocate room. Passing the options - positionally is deprecated. + On an untrained index, add first trains num_clusters centroids on data, + as train(data) does, so data needs at least num_clusters rows, and an + empty first add raises RuntimeError; with + num_threads None, training uses cp.num_threads. Passing cluster_ids to an + untrained index raises RuntimeError, as does add on an index created with + max_elements, which build fills. If the call fails, the index stays + untrained and empty. Batch additions to reduce repeated cluster copying; + reserve can preallocate room. Passing the options positionally is + deprecated. """ @overload @deprecated( @@ -373,9 +382,9 @@ class IvfIndex: self, queries: npt.ArrayLike, k: SupportsIndex, - nprobe: SupportsIndex, + nprobe: SupportsIndex | None = None, high_accuracy: bool | np.bool_ | None = None, - num_threads: SupportsIndex = 1, + num_threads: SupportsIndex | None = None, ) -> tuple[npt.NDArray[np.uint32], npt.NDArray[np.float32]]: """Search a batch of query vectors. @@ -385,15 +394,17 @@ class IvfIndex: Vectors in the original, unpadded coordinates. Converted to contiguous float32; a copy may be made. k : int - Number of neighbors requested, between 1 and max_elements. - nprobe : int - Positive number of clusters to probe; capped at num_clusters. + Number of neighbors requested, at least 1. It may exceed the number of + points; slots without a neighbor are padded as described below. + nprobe : int, optional + Number of clusters to probe, capped at num_clusters. None (default) and + 0 select num_clusters / 16, rounded to the nearest integer and at least 1. high_accuracy : bool, optional Enable HACC. None (default) selects it for 4-9 bits and standard FastScan otherwise. Raw storage uses standard FastScan. num_threads : int, optional - Worker count. Default is 1; 0 selects the available CPU count, - capped by OMP_NUM_THREADS. + Worker count. None (default) selects 1; 0 selects the available CPU + count, capped by OMP_NUM_THREADS. Returns ------- @@ -406,7 +417,8 @@ class IvfIndex: Notes ----- Requires a trained or loaded index. Returns (ids, distances), in that - order. Missing neighbors have ID 4294967295 and distance inf. Array shapes + order. Missing neighbors have ID 4294967295 and distance inf; test + np.isfinite(distances) to find them. Array shapes and value ranges are checked at runtime, not by static type checkers. """ @@ -467,10 +479,11 @@ class HnswIndex: ---------- dim : int Original vector dimension, from 64 through 65536. - nbits : int - Keyword-only. Total quantization bits, 1 through 9. + nbits : int, optional + Keyword-only. Total quantization bits, 1 through 9. Default is 7. num_clusters : int, optional - Keyword-only. Number of centroids train or set_centroids provides. + Keyword-only. Number of centroids train, set_centroids or the first add + provides; the first add caps it at its row count. Default is 16. M : int, optional Keyword-only. Graph connectivity, at least 2; the base layer keeps up to @@ -486,8 +499,10 @@ class HnswIndex: Notes ----- - Train the index or set its centroids, then add vectors before searching or - saving; loaded indexes are already trained. Capacity grows automatically + The first add trains an untrained index on the added vectors. Call train + on a sample, or set_centroids, first to choose the training data or the + centroids. Loaded indexes are trained, except empty files saved untrained + by 0.7. Capacity grows automatically on add; resize can preallocate it. Concurrent reads are supported; an overlapping mutation raises RuntimeError. Data, centroids and queries must be finite float32 values within the index @@ -502,9 +517,8 @@ class HnswIndex: >>> import numpy as np >>> from rabitqlib import HnswIndex >>> x = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) - >>> index = HnswIndex(64, nbits=4, num_clusters=2) - >>> index.train(x) - >>> ids = index.add(x, num_threads=1) + >>> index = HnswIndex(64, num_clusters=2) + >>> ids = index.add(x, num_threads=1) # trains on x first >>> ids, distances = index.search(x[:2], k=3) >>> ids.shape, distances.shape ((2, 3), (2, 3)) @@ -515,7 +529,7 @@ class HnswIndex: self, dim: SupportsIndex, *, - nbits: SupportsIndex, + nbits: SupportsIndex = 7, num_clusters: SupportsIndex = 16, M: SupportsIndex = 16, ef_construction: SupportsIndex = 200, @@ -528,10 +542,11 @@ class HnswIndex: ---------- dim : int Original vector dimension, from 64 through 65536. - nbits : int - Keyword-only. Total quantization bits, 1 through 9. + nbits : int, optional + Keyword-only. Total quantization bits, 1 through 9. Default is 7. num_clusters : int, optional - Keyword-only. Number of centroids train or set_centroids provides. + Keyword-only. Number of centroids train, set_centroids or the first add + provides; the first add caps it at its row count. Default is 16. M : int, optional Keyword-only. Graph connectivity, at least 2; the base layer keeps up to @@ -547,8 +562,10 @@ class HnswIndex: Notes ----- - Train the index or set its centroids, then add vectors before searching or - saving; loaded indexes are already trained. Capacity grows automatically + The first add trains an untrained index on the added vectors. Call train + on a sample, or set_centroids, first to choose the training data or the + centroids. Loaded indexes are trained, except empty files saved untrained + by 0.7. Capacity grows automatically on add; resize can preallocate it. Concurrent reads are supported; an overlapping mutation raises RuntimeError. Data, centroids and queries must be finite float32 values within the index @@ -579,7 +596,7 @@ class HnswIndex: self, dim: SupportsIndex, *, - nbits: SupportsIndex, + nbits: SupportsIndex = 7, num_clusters: SupportsIndex = 16, M: SupportsIndex = 16, ef_construction: SupportsIndex = 200, @@ -610,7 +627,7 @@ class HnswIndex: @property def is_trained(self) -> bool: - """Whether centroids are set, by train, set_centroids, build or load.""" + """Whether centroids are set, by train, set_centroids, the first add, build or load.""" @property @deprecated("HnswIndex.is_built is deprecated since 0.8; use is_trained or len()") @@ -623,7 +640,7 @@ class HnswIndex: @property def cp(self) -> RaBitQKMeansParameters: - """Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 25, max_points_per_centroid 256, spherical for inner product, and seed equal to the low 32 bits of the index seed.""" + """Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 10, max_points_per_centroid 256, spherical for inner product, and seed equal to the low 32 bits of the index seed.""" @cp.setter def cp(self, value: RaBitQKMeansParameters) -> None: ... @@ -635,6 +652,14 @@ class HnswIndex: def nbits(self) -> int: """Total storage bits per coordinate.""" + @property + def M(self) -> int: + """Graph connectivity; the base layer keeps up to 2 * M edges.""" + + @property + def ef_construction(self) -> int: + """Construction search breadth.""" + @property def num_clusters(self) -> int: """Number of centroids: the configured count until centroids are set.""" @@ -720,10 +745,10 @@ class HnswIndex: data: npt.ArrayLike, *, cluster_ids: _IntegerIds | None = None, - num_threads: SupportsIndex = 0, + num_threads: SupportsIndex | None = None, fast_quantization: bool | np.bool_ = False, ) -> npt.NDArray[np.uint32]: - """Add vectors using the trained centroids. + """Add vectors, training the index first if needed. Parameters ---------- @@ -734,9 +759,9 @@ class HnswIndex: Keyword-only. Existing centroid IDs. None assigns each vector to its nearest centroid. Centroids do not move. num_threads : int, optional - Keyword-only. Worker count. Default is 0, which selects the available - CPU count; more than one thread links nondeterministically, so pass 1 - for a reproducible graph. + Keyword-only. Worker count. None (default) and 0 select the available + CPU count, capped by OMP_NUM_THREADS; more than one thread links + nondeterministically, so pass 1 for a reproducible graph. fast_quantization : bool, optional Keyword-only. Use faster quantizer configuration. Default is False. @@ -747,10 +772,17 @@ class HnswIndex: Notes ----- - Requires a trained or loaded index. The first add into an empty index links - vectors with their exact coordinates; later adds link through the stored - codes. Capacity grows geometrically when needed. Removed IDs are not - reused. Passing the options positionally is deprecated. + On an untrained index, add first trains num_clusters centroids on data, as + train(data) does; with fewer rows it trains one centroid per row, and + num_clusters then reports the row count. An empty first add raises + RuntimeError. With num_threads None, training + uses cp.num_threads. Passing cluster_ids to an untrained index raises + RuntimeError, as does add on an untrained index loaded from a 0.7 file; + call train first. If the call fails, the index stays + untrained and empty. The first add into an empty index links vectors with + their exact coordinates; later adds link through the stored codes, so add + most vectors in the first call. Capacity grows geometrically when needed. + Removed IDs are not reused. Passing the options positionally is deprecated. """ @overload @deprecated( @@ -787,8 +819,8 @@ class HnswIndex: self, queries: npt.ArrayLike, k: SupportsIndex, - ef: SupportsIndex | None = 0, - num_threads: SupportsIndex = 1, + ef: SupportsIndex | None = None, + num_threads: SupportsIndex | None = None, ) -> tuple[npt.NDArray[np.uint32], npt.NDArray[np.float32]]: """Search a batch of query vectors. @@ -798,13 +830,14 @@ class HnswIndex: Vectors in the original, unpadded coordinates. Converted to contiguous float32; a copy may be made. k : int - Number of neighbors requested, between 1 and num_stored_points. + Number of neighbors requested, at least 1. It may exceed the number of + points; slots without a neighbor are padded as described below. ef : int, optional - Search breadth. None or 0 (default) selects max(k, 10); values above the stored + Search breadth. None or 0 (default) selects max(k, 64); values above the stored point count are clamped. num_threads : int, optional - Worker count. Default is 1; 0 selects the available CPU count, - capped by OMP_NUM_THREADS. + Worker count. None (default) selects 1; 0 selects the available CPU + count, capped by OMP_NUM_THREADS. Returns ------- @@ -817,7 +850,8 @@ class HnswIndex: Notes ----- Requires a trained or loaded index. Returns (ids, distances), in that - order. Missing neighbors have ID 4294967295 and distance inf. Array shapes + order. Missing neighbors have ID 4294967295 and distance inf; test + np.isfinite(distances) to find them. Array shapes and value ranges are checked at runtime, not by static type checkers. """ @@ -905,7 +939,7 @@ class SymqgIndex: >>> from rabitqlib import SymqgIndex >>> x = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) >>> index = SymqgIndex(64, ef_construction=40, seed=7) - >>> index.add(x, num_threads=1) + >>> ids = index.add(x, num_threads=1) >>> ids, distances = index.search(x[:2], k=3) >>> ids.shape, distances.shape ((2, 3), (2, 3)) @@ -1072,7 +1106,9 @@ class SymqgIndex: searches. """ - def add(self, data: npt.ArrayLike, *, num_threads: SupportsIndex = 0) -> None: + def add( + self, data: npt.ArrayLike, *, num_threads: SupportsIndex | None = None + ) -> npt.NDArray[np.uint32]: """Build the graph from all vectors. Parameters @@ -1081,8 +1117,13 @@ class SymqgIndex: Vectors in the original, unpadded coordinates, with n > max_degree. Converted to contiguous float32; a copy may be made. num_threads : int, optional - Keyword-only. Worker count. Default is 0, which selects the available - CPU count. + Keyword-only. Worker count. None (default) and 0 select the available + CPU count, capped by OMP_NUM_THREADS. + + Returns + ------- + ids : ndarray of uint32, shape (n,) + Point IDs, the row numbers of data. Notes ----- @@ -1128,7 +1169,7 @@ class SymqgIndex: queries: npt.ArrayLike, k: SupportsIndex, ef: SupportsIndex | None = None, - num_threads: SupportsIndex = 1, + num_threads: SupportsIndex | None = None, ) -> tuple[npt.NDArray[np.uint32], npt.NDArray[np.float32]]: """Search a batch of query vectors. @@ -1138,13 +1179,14 @@ class SymqgIndex: Vectors in the original, unpadded coordinates. Converted to contiguous float32; a copy may be made. k : int - Number of neighbors requested, between 1 and num_points. + Number of neighbors requested, at least 1. It may exceed the number of + points; slots without a neighbor are padded as described below. ef : int, optional - Search breadth. None or 0 (default) selects max(k, 10); smaller values + Search breadth. None or 0 (default) selects max(k, 64); smaller values are raised to k. num_threads : int, optional - Worker count. Default is 1; 0 selects the available CPU count, - capped by OMP_NUM_THREADS. + Worker count. None (default) selects 1; 0 selects the available CPU + count, capped by OMP_NUM_THREADS. Returns ------- @@ -1156,7 +1198,9 @@ class SymqgIndex: Notes ----- - Returns (ids, distances), in that order. Array shapes and value ranges are + Returns (ids, distances), in that order. When the graph reaches fewer than k + points, the remaining neighbors have ID 4294967295 and distance inf; test + np.isfinite(distances) to find them. Array shapes and value ranges are checked at runtime, not by static type checkers. """ diff --git a/python_bindings/bindings_common.hpp b/python_bindings/bindings_common.hpp index 9c025b1c..c80b0a63 100644 --- a/python_bindings/bindings_common.hpp +++ b/python_bindings/bindings_common.hpp @@ -16,6 +16,7 @@ #include #include "rabitqlib/core/metric.hpp" +#include "rabitqlib/index/detail/buffer.hpp" #include "rabitqlib/index/types.hpp" #include "rabitqlib/utils/packed.hpp" @@ -256,6 +257,13 @@ struct SearchResults { }; // (num_queries, k) outputs. Slots without a neighbor keep kPidMax and +inf. +// Searches pad slots without a result, so k may exceed the number of points. +inline void check_k(size_t k) { + if (k == 0 || k > buffer::kSearchBufferMaxPointCount) { + throw std::invalid_argument("k must be between 1 and 2**31"); + } +} + inline SearchResults unfilled_search_results(size_t num_queries, size_t k) { const auto shape = std::vector{ static_cast(num_queries), static_cast(k)}; diff --git a/python_bindings/hnsw_bindings.cpp b/python_bindings/hnsw_bindings.cpp index 8327b4f1..18297530 100644 --- a/python_bindings/hnsw_bindings.cpp +++ b/python_bindings/hnsw_bindings.cpp @@ -155,7 +155,7 @@ class HnswIndex { check_coordinates( centroids_ptr, num_clusters * dim, dim, "HNSW construction centroids" ); - grow_capacity(rows, data_ptr, "HNSW construction data"); + grow_capacity(*index_, rows, data_ptr, "HNSW construction data"); } if (empty && index_->is_trained()) { index_->set_centroids(num_clusters, centroids_ptr); @@ -180,12 +180,18 @@ class HnswIndex { py::array_t add( py::handle data, const py::object& cluster_ids, - size_t num_threads, + std::optional num_threads, bool fast_quantization ) { const auto operation = access_.write(); auto data_array = ensure_2d_array(data, "data"); - check_trained("add"); + // The first add on an untrained index trains it on the same data. Only a + // loaded file lacks the seed that recreates the rotation and graph levels. + const bool trains = !index_->is_trained() && cluster_ids.is_none() && + seed_.has_value() && data_array.shape(0) > 0; + if (!trains) { + check_trained("add"); + } ensure_dimension( data_array, index_->dimension(), "data dimension does not match index dim" ); @@ -199,15 +205,41 @@ class HnswIndex { // Allocated before the index is touched. auto ids = py::array_t(static_cast(rows)); const auto* data_ptr = data_array.data(); + const size_t threads = num_threads.value_or(0); + if (trains) { + // Train and fill a fresh index with the same seed, then swap it in, so + // that a failure leaves this index untrained and empty. Fewer rows than + // num_clusters train one centroid per row. + const size_t num_clusters = std::min(num_clusters_, rows); + const auto cp = training_parameters(cp_, num_threads); + auto staged = std::make_unique( + index_->max_elements(), + index_->dimension(), + index_->nbits(), + index_->M(), + index_->ef_construction(), + *seed_, + index_->metric_type() + ); + const auto labels = [&] { + py::gil_scoped_release release; + staged->train(num_clusters, rows, data_ptr, cp); + grow_capacity(*staged, rows, data_ptr, "HNSW add data"); + return staged->add(data_ptr, rows, nullptr, fast_quantization, threads); + }(); + index_.swap(staged); + std::copy(labels.begin(), labels.end(), ids.mutable_data()); + return ids; + } const auto labels = [&] { py::gil_scoped_release release; - grow_capacity(rows, data_ptr, "HNSW add data"); + grow_capacity(*index_, rows, data_ptr, "HNSW add data"); return index_->add( data_ptr, rows, assigned.empty() ? nullptr : assigned.data(), fast_quantization, - num_threads + threads ); }(); std::copy(labels.begin(), labels.end(), ids.mutable_data()); @@ -239,7 +271,10 @@ class HnswIndex { } py::tuple search( - py::handle queries, size_t k, std::optional ef, size_t num_threads + py::handle queries, + size_t k, + std::optional ef, + std::optional num_threads ) { const auto operation = access_.read(); auto query_array = ensure_2d_array(queries, "queries"); @@ -247,12 +282,9 @@ class HnswIndex { ensure_dimension( query_array, index_->dimension(), "query dimension does not match index dim" ); - // None and 0 select max(k, 10), as SymqgIndex does. - const size_t ef_search = ef.value_or(0) == 0 ? std::max(k, 10) : *ef; - // Capacity grows geometrically, so bound k by what is stored, as IVF does. - if (k == 0 || k > index_->num_stored_points()) { - throw std::invalid_argument("k must be between 1 and num_stored_points"); - } + check_k(k); + // None and 0 select max(k, 64), as SymqgIndex does. + const size_t ef_search = ef.value_or(0) == 0 ? std::max(k, 64) : *ef; auto output = unfilled_search_results(static_cast(query_array.shape(0)), k); auto result_ids_view = output.ids.mutable_unchecked<2>(); @@ -263,7 +295,9 @@ class HnswIndex { std::vector>> results; { py::gil_scoped_release release; - results = index_->search(queries_data, num_queries, k, ef_search, num_threads); + results = index_->search( + queries_data, num_queries, k, ef_search, num_threads.value_or(1) + ); } for (py::ssize_t i = 0; i < static_cast(results.size()); ++i) { @@ -333,6 +367,14 @@ class HnswIndex { const auto operation = access_.read(); return index_->nbits(); } + [[nodiscard]] size_t M() const { + const auto operation = access_.read(); + return index_->M(); + } + [[nodiscard]] size_t ef_construction() const { + const auto operation = access_.read(); + return index_->ef_construction(); + } [[nodiscard]] bool is_trained() const { const auto operation = access_.read(); return index_->is_trained(); @@ -384,19 +426,21 @@ class HnswIndex { // Grow capacity geometrically to fit `rows` more points from `data`, checked // first so that invalid input leaves the capacity unchanged. Leaves IDs beyond // the supported range for add to reject. Called without the GIL. - void grow_capacity(size_t rows, const float* data, const char* what) { - const size_t capacity = index_->max_elements(); - const size_t stored = index_->num_stored_points(); + static void grow_capacity( + hnsw::HierarchicalNSW& index, size_t rows, const float* data, const char* what + ) { + const size_t capacity = index.max_elements(); + const size_t stored = index.num_stored_points(); if (rows <= capacity - stored || rows > buffer::kSearchBufferMaxPointCount - stored) { return; } - const size_t dim = index_->dimension(); + const size_t dim = index.dimension(); check_coordinates(data, rows * dim, dim, what); const size_t needed = stored + rows; const size_t doubled = std::min(capacity * 2, static_cast(buffer::kSearchBufferMaxPointCount)); - index_->resize(std::max(needed, doubled)); + index.resize(std::max(needed, doubled)); } mutable IndexAccess access_; @@ -419,10 +463,11 @@ Parameters ---------- dim : int Original vector dimension, from 64 through 65536. -nbits : int - Keyword-only. Total quantization bits, 1 through 9. +nbits : int, optional + Keyword-only. Total quantization bits, 1 through 9. Default is 7. num_clusters : int, optional - Keyword-only. Number of centroids train or set_centroids provides. + Keyword-only. Number of centroids train, set_centroids or the first add + provides; the first add caps it at its row count. Default is 16. M : int, optional Keyword-only. Graph connectivity, at least 2; the base layer keeps up to @@ -438,8 +483,10 @@ seed : int, optional Notes ----- -Train the index or set its centroids, then add vectors before searching or -saving; loaded indexes are already trained. Capacity grows automatically +The first add trains an untrained index on the added vectors. Call train +on a sample, or set_centroids, first to choose the training data or the +centroids. Loaded indexes are trained, except empty files saved untrained +by 0.7. Capacity grows automatically on add; resize can preallocate it. Concurrent reads are supported; an overlapping mutation raises RuntimeError. Data, centroids and queries must be finite float32 values within the index @@ -454,14 +501,13 @@ Examples >>> import numpy as np >>> from rabitqlib import HnswIndex >>> x = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) ->>> index = HnswIndex(64, nbits=4, num_clusters=2) ->>> index.train(x) ->>> ids = index.add(x, num_threads=1) +>>> index = HnswIndex(64, num_clusters=2) +>>> ids = index.add(x, num_threads=1) # trains on x first >>> ids, distances = index.search(x[:2], k=3) >>> ids.shape, distances.shape ((2, 3), (2, 3)))doc"; - static constexpr char kAddDoc[] = R"doc(Add vectors using the trained centroids. + static constexpr char kAddDoc[] = R"doc(Add vectors, training the index first if needed. Parameters ---------- @@ -472,9 +518,9 @@ cluster_ids : array_like of int, shape (n,), optional Keyword-only. Existing centroid IDs. None assigns each vector to its nearest centroid. Centroids do not move. num_threads : int, optional - Keyword-only. Worker count. Default is 0, which selects the available - CPU count; more than one thread links nondeterministically, so pass 1 - for a reproducible graph. + Keyword-only. Worker count. None (default) and 0 select the available + CPU count, capped by OMP_NUM_THREADS; more than one thread links + nondeterministically, so pass 1 for a reproducible graph. fast_quantization : bool, optional Keyword-only. Use faster quantizer configuration. Default is False. @@ -485,10 +531,17 @@ ids : ndarray of uint32, shape (n,) Notes ----- -Requires a trained or loaded index. The first add into an empty index links -vectors with their exact coordinates; later adds link through the stored -codes. Capacity grows geometrically when needed. Removed IDs are not -reused. Passing the options positionally is deprecated.)doc"; +On an untrained index, add first trains num_clusters centroids on data, as +train(data) does; with fewer rows it trains one centroid per row, and +num_clusters then reports the row count. An empty first add raises +RuntimeError. With num_threads None, training +uses cp.num_threads. Passing cluster_ids to an untrained index raises +RuntimeError, as does add on an untrained index loaded from a 0.7 file; +call train first. If the call fails, the index stays untrained and empty. The first +add into an empty index links vectors with their exact coordinates; later adds +link through the stored codes, so add most vectors in the first call. Capacity +grows geometrically when needed. Removed IDs are not reused. Passing the options +positionally is deprecated.)doc"; py::class_(m, "HnswIndex", kIndexDoc) .def( @@ -502,7 +555,7 @@ reused. Passing the options positionally is deprecated.)doc"; const py::object&>(), py::arg("dim"), py::kw_only(), - py::arg("nbits"), + py::arg("nbits") = 7, py::arg("num_clusters") = 16, py::arg("M") = 16, py::arg("ef_construction") = 200, @@ -578,8 +631,8 @@ failed call leaves the index unchanged.)doc" &HnswIndex::search, py::arg("queries"), py::arg("k"), - py::arg("ef") = 0, - py::arg("num_threads") = 1, + py::arg("ef") = py::none(), + py::arg("num_threads") = py::none(), R"doc(Search a batch of query vectors. Parameters @@ -588,13 +641,14 @@ queries : array_like of float, shape (n_queries, dim) Vectors in the original, unpadded coordinates. Converted to contiguous float32; a copy may be made. k : int - Number of neighbors requested, between 1 and num_stored_points. + Number of neighbors requested, at least 1. It may exceed the number of + points; slots without a neighbor are padded as described below. ef : int, optional - Search breadth. None or 0 (default) selects max(k, 10); values above the stored + Search breadth. None or 0 (default) selects max(k, 64); values above the stored point count are clamped. num_threads : int, optional - Worker count. Default is 1; 0 selects the available CPU count, - capped by OMP_NUM_THREADS. + Worker count. None (default) selects 1; 0 selects the available CPU + count, capped by OMP_NUM_THREADS. Returns ------- @@ -607,7 +661,8 @@ distances : ndarray of float32, shape (n_queries, k) Notes ----- Requires a trained or loaded index. Returns (ids, distances), in that -order. Missing neighbors have ID 4294967295 and distance inf. Array shapes +order. Missing neighbors have ID 4294967295 and distance inf; test +np.isfinite(distances) to find them. Array shapes and value ranges are checked at runtime, not by static type checkers.)doc" ) .def( @@ -616,7 +671,7 @@ and value ranges are checked at runtime, not by static type checkers.)doc" py::arg("data"), py::kw_only(), py::arg("cluster_ids") = py::none(), - py::arg("num_threads") = 0, + py::arg("num_threads") = py::none(), py::arg("fast_quantization") = false, kAddDoc ) @@ -715,6 +770,16 @@ index : HnswIndex .def_property_readonly( "nbits", &HnswIndex::nbits, R"doc(Total storage bits per coordinate.)doc" ) + .def_property_readonly( + "M", + &HnswIndex::M, + R"doc(Graph connectivity; the base layer keeps up to 2 * M edges.)doc" + ) + .def_property_readonly( + "ef_construction", + &HnswIndex::ef_construction, + R"doc(Construction search breadth.)doc" + ) .def_property_readonly( "num_clusters", &HnswIndex::num_clusters, @@ -723,7 +788,7 @@ index : HnswIndex .def_property_readonly( "is_trained", &HnswIndex::is_trained, - R"doc(Whether centroids are set, by train, set_centroids, build or load.)doc" + R"doc(Whether centroids are set, by train, set_centroids, the first add, build or load.)doc" ) .def_property_readonly( "seed", @@ -735,7 +800,7 @@ index : HnswIndex &HnswIndex::cp, &HnswIndex::set_cp, py::return_value_policy::reference_internal, - R"doc(Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 25, max_points_per_centroid 256, spherical for inner product, and seed equal to the low 32 bits of the index seed.)doc" + R"doc(Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 10, max_points_per_centroid 256, spherical for inner product, and seed equal to the low 32 bits of the index seed.)doc" ) .def_property_readonly( "metric", &HnswIndex::metric, R"doc(Canonical metric name: 'l2' or 'ip'.)doc" diff --git a/python_bindings/ivf_bindings.cpp b/python_bindings/ivf_bindings.cpp index 0a73b215..779eae5d 100644 --- a/python_bindings/ivf_bindings.cpp +++ b/python_bindings/ivf_bindings.cpp @@ -206,16 +206,29 @@ class IvfIndex { py::array_t add( py::handle data, const py::object& cluster_ids, - size_t num_threads, + std::optional num_threads, bool fast_quantization ) { const auto operation = access_.write(); auto data_array = ensure_2d_array(data, "data"); - check_trained("add"); + // The first add on an untrained 0.8-form index trains it on the same data. + const bool trains = !index_->is_trained() && !builds_by_construct_ && + cluster_ids.is_none() && data_array.shape(0) > 0; + if (!trains) { + check_trained("add"); + } ensure_dimension( data_array, index_->dimension(), "data dimension does not match index dim" ); const auto rows = static_cast(data_array.shape(0)); + if (trains && rows < index_->num_clusters()) { + throw std::invalid_argument( + "the first add trains the index and needs at least num_clusters rows; " + "expected at least " + + std::to_string(index_->num_clusters()) + ", got shape " + + array_shape(data_array) + ); + } std::vector assigned; if (!cluster_ids.is_none()) { @@ -231,6 +244,28 @@ class IvfIndex { } const auto* data_ptr = data_array.data(); + const size_t threads = num_threads.value_or(0); + if (trains) { + // Train and fill a fresh index with the same rotation, then swap it in, + // so that a failure leaves this index untrained and empty. + const auto cp = training_parameters(cp_, num_threads); + auto staged = std::make_unique( + index_->dimension(), + index_->num_clusters(), + index_->nbits(), + index_->metric_type(), + *seed_, + RotatorType::FhtKacRotator, + index_->initializer_type() + ); + { + py::gil_scoped_release release; + staged->train(rows, data_ptr, cp); + staged->add(data_ptr, rows, nullptr, fast_quantization, threads); + } + index_.swap(staged); + return ids; + } { py::gil_scoped_release release; index_->add( @@ -238,7 +273,7 @@ class IvfIndex { rows, assigned.empty() ? nullptr : assigned.data(), fast_quantization, - num_threads + threads ); } return ids; @@ -269,9 +304,9 @@ class IvfIndex { py::tuple search( py::handle queries, size_t k, - size_t nprobe, + std::optional nprobe, std::optional high_accuracy, - size_t num_threads + std::optional num_threads ) { const auto operation = access_.read(); auto query_array = ensure_2d_array(queries, "queries"); @@ -279,12 +314,11 @@ class IvfIndex { ensure_dimension( query_array, index_->dimension(), "query dimension does not match index dim" ); - if (k == 0 || k > index_->max_elements()) { - throw std::invalid_argument("k must be between 1 and max_elements"); - } - if (nprobe == 0) { - throw std::invalid_argument("nprobe must be positive"); - } + check_k(k); + // None and 0 probe num_clusters / 16 clusters, rounded, and at least one. + const size_t num_probes = + nprobe.value_or(0) == 0 ? std::max(1, (index_->num_clusters() + 8) / 16) + : *nprobe; const size_t num_queries = static_cast(query_array.shape(0)); auto output = unfilled_search_results(num_queries, k); @@ -298,11 +332,11 @@ class IvfIndex { queries_data, num_queries, k, - nprobe, + num_probes, ids_data, distances_data, high_accuracy, - num_threads + num_threads.value_or(1) ); } @@ -446,10 +480,10 @@ Parameters dim : int Original vector dimension, from 64 through 65536. num_clusters : int - Number of centroids, from train or set_centroids. -nbits : int + Number of centroids, from train, set_centroids or the first add. +nbits : int, optional Keyword-only. Total quantization bits, 1 through 9, or 32 for owned - float32 vectors with one-bit filtering and raw reranking. + float32 vectors with one-bit filtering and raw reranking. Default is 7. metric : {'l2', 'ip', 'innerproduct'}, optional Squared L2 or inner-product distance. Default is 'l2'. Vectors are not automatically normalized for cosine similarity. @@ -461,9 +495,10 @@ seed : int, optional Notes ----- -Train the index or set its centroids, then add vectors before searching or -saving; loaded indexes are already trained. Concurrent reads are supported; -an overlapping mutation raises RuntimeError. +The first add trains an untrained index on the added vectors. Call train +on a sample, or set_centroids, first to choose the training data or the +centroids. Loaded indexes are already trained. Concurrent reads are +supported; an overlapping mutation raises RuntimeError. Data, centroids and queries must be finite float32 values within the index coordinate range; other values raise ValueError. The 0.7 form IvfIndex(dim, max_elements, num_clusters, nbits, ...) and @@ -475,14 +510,13 @@ Examples >>> import numpy as np >>> from rabitqlib import IvfIndex >>> x = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) ->>> index = IvfIndex(64, 2, nbits=4, seed=7) ->>> index.train(x) ->>> ids = index.add(x) ->>> ids, distances = index.search(x[:2], k=3, nprobe=2) +>>> index = IvfIndex(64, 2, seed=7) +>>> ids = index.add(x) # trains on x first +>>> ids, distances = index.search(x[:2], k=3) >>> ids.shape, distances.shape ((2, 3), (2, 3)))doc"; - static constexpr char kAddDoc[] = R"doc(Add vectors using the trained centroids. + static constexpr char kAddDoc[] = R"doc(Add vectors, training the index first if needed. Parameters ---------- @@ -494,8 +528,8 @@ cluster_ids : array_like of int, shape (n,), optional index's configured centroid initializer; routing may be approximate. Centroids do not move. num_threads : int, optional - Keyword-only. Worker count. Default is 0, which selects the available - CPU count. + Keyword-only. Worker count. None (default) and 0 select the available + CPU count, capped by OMP_NUM_THREADS. fast_quantization : bool, optional Keyword-only. Use faster quantizer configuration. Default is False. @@ -506,9 +540,15 @@ ids : ndarray of uint32, shape (n,) Notes ----- -Requires a trained or loaded index. Batch additions to reduce repeated -cluster copying; reserve can preallocate room. Passing the options -positionally is deprecated.)doc"; +On an untrained index, add first trains num_clusters centroids on data, +as train(data) does, so data needs at least num_clusters rows, and an +empty first add raises RuntimeError; with +num_threads None, training uses cp.num_threads. Passing cluster_ids to an +untrained index raises RuntimeError, as does add on an index created with +max_elements, which build fills. If the call fails, the index stays +untrained and empty. Batch additions to reduce repeated cluster copying; +reserve can preallocate room. Passing the options positionally is +deprecated.)doc"; py::class_(m, "IvfIndex", kIndexDoc) .def( @@ -522,7 +562,7 @@ positionally is deprecated.)doc"; py::arg("dim"), py::arg("num_clusters"), py::kw_only(), - py::arg("nbits"), + py::arg("nbits") = 7, py::arg("metric") = "l2", py::arg("initializer") = "auto", py::arg("seed") = py::none(), @@ -606,7 +646,7 @@ failed call leaves the index unchanged.)doc" py::arg("data"), py::kw_only(), py::arg("cluster_ids") = py::none(), - py::arg("num_threads") = 0, + py::arg("num_threads") = py::none(), py::arg("fast_quantization") = false, kAddDoc ) @@ -654,9 +694,9 @@ Removal does not reclaim storage and cannot be undone.)doc" &IvfIndex::search, py::arg("queries"), py::arg("k"), - py::arg("nprobe"), + py::arg("nprobe") = py::none(), py::arg("high_accuracy") = py::none(), - py::arg("num_threads") = 1, + py::arg("num_threads") = py::none(), R"doc(Search a batch of query vectors. Parameters @@ -665,15 +705,17 @@ queries : array_like of float, shape (n_queries, dim) Vectors in the original, unpadded coordinates. Converted to contiguous float32; a copy may be made. k : int - Number of neighbors requested, between 1 and max_elements. -nprobe : int - Positive number of clusters to probe; capped at num_clusters. + Number of neighbors requested, at least 1. It may exceed the number of + points; slots without a neighbor are padded as described below. +nprobe : int, optional + Number of clusters to probe, capped at num_clusters. None (default) and + 0 select num_clusters / 16, rounded to the nearest integer and at least 1. high_accuracy : bool, optional Enable HACC. None (default) selects it for 4-9 bits and standard FastScan otherwise. Raw storage uses standard FastScan. num_threads : int, optional - Worker count. Default is 1; 0 selects the available CPU count, - capped by OMP_NUM_THREADS. + Worker count. None (default) selects 1; 0 selects the available CPU + count, capped by OMP_NUM_THREADS. Returns ------- @@ -686,7 +728,8 @@ distances : ndarray of float32, shape (n_queries, k) Notes ----- Requires a trained or loaded index. Returns (ids, distances), in that -order. Missing neighbors have ID 4294967295 and distance inf. Array shapes +order. Missing neighbors have ID 4294967295 and distance inf; test +np.isfinite(distances) to find them. Array shapes and value ranges are checked at runtime, not by static type checkers.)doc" ) .def( @@ -760,7 +803,7 @@ index : IvfIndex .def_property_readonly( "is_trained", &IvfIndex::is_trained, - R"doc(Whether centroids are set, by train, set_centroids, build or load.)doc" + R"doc(Whether centroids are set, by train, set_centroids, the first add, build or load.)doc" ) .def_property_readonly( "seed", @@ -772,7 +815,7 @@ index : IvfIndex &IvfIndex::cp, &IvfIndex::set_cp, py::return_value_policy::reference_internal, - R"doc(Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 25, max_points_per_centroid 256, spherical for inner product, and seed equal to the index seed.)doc" + R"doc(Clustering parameters train uses, a RaBitQKMeansParameters. Defaults: niter 10, max_points_per_centroid 256, spherical for inner product, and seed equal to the index seed.)doc" ) .def_property_readonly( "metric", &IvfIndex::metric, R"doc(Canonical metric name: 'l2' or 'ip'.)doc" diff --git a/python_bindings/rabitq_bindings.cpp b/python_bindings/rabitq_bindings.cpp index 2b47b2ca..1d171ad4 100644 --- a/python_bindings/rabitq_bindings.cpp +++ b/python_bindings/rabitq_bindings.cpp @@ -16,6 +16,7 @@ void register_kmeans(py::module_& m); PYBIND11_MODULE(_rabitqlib, m) { m.doc() = "RabitQ Python bindings combined module"; + m.attr("__version__") = RABITQ_VERSION; m.def("_available_cpu_count", &rabitqlib::total_threads); m.def("_default_thread_count", &rabitqlib::default_threads); diff --git a/python_bindings/symqg_bindings.cpp b/python_bindings/symqg_bindings.cpp index 843131b3..3dc00723 100644 --- a/python_bindings/symqg_bindings.cpp +++ b/python_bindings/symqg_bindings.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -76,7 +77,7 @@ class SymqgIndex { check_coordinates(x_ptr, count, dim_, "x"); } - void add(py::handle data, size_t num_threads) { + py::array_t add(py::handle data, std::optional num_threads) { const auto operation = access_.write(); if (built()) { throw std::runtime_error( @@ -85,7 +86,12 @@ class SymqgIndex { ); } // Only a loaded index lacks these settings, and it is already built. - build_graph(data, *ef_construction_, num_threads, *init_, *seed_); + build_graph(data, *ef_construction_, num_threads.value_or(0), *init_, *seed_); + // Point IDs are the row numbers of data. + const size_t rows = index_->num_points(); + auto ids = py::array_t(static_cast(rows)); + std::iota(ids.mutable_data(), ids.mutable_data() + rows, PID{0}); + return ids; } // Deprecated: the 0.7 build, which replaces any existing graph. Without a @@ -103,17 +109,18 @@ class SymqgIndex { } py::tuple search( - py::handle queries, size_t k, std::optional ef, size_t num_threads + py::handle queries, + size_t k, + std::optional ef, + std::optional num_threads ) { const auto operation = access_.read(); auto query_array = ensure_2d_array(queries, "queries"); check_built("search"); - if (k == 0 || k > index_->num_points()) { - throw std::invalid_argument("k must be between 1 and num_points"); - } - // As in HnswIndex: no or zero ef selects max(k, 10), and ef is at least k. + check_k(k); + // As in HnswIndex: no or zero ef selects max(k, 64), and ef is at least k. const size_t ef_search = - std::max(ef.value_or(0) == 0 ? std::max(k, 10) : *ef, k); + std::max(ef.value_or(0) == 0 ? std::max(k, 64) : *ef, k); ensure_dimension(query_array, dim_, "query dimension does not match index dim"); const size_t num_queries = static_cast(query_array.shape(0)); @@ -133,7 +140,7 @@ class SymqgIndex { ids_data, distances_data, ef_search, - num_threads + num_threads.value_or(1) ); } @@ -363,7 +370,7 @@ Examples >>> from rabitqlib import SymqgIndex >>> x = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) >>> index = SymqgIndex(64, ef_construction=40, seed=7) ->>> index.add(x, num_threads=1) +>>> ids = index.add(x, num_threads=1) >>> ids, distances = index.search(x[:2], k=3) >>> ids.shape, distances.shape ((2, 3), (2, 3)))doc"; @@ -376,13 +383,14 @@ queries : array_like of float, shape (n_queries, dim) Vectors in the original, unpadded coordinates. Converted to contiguous float32; a copy may be made. k : int - Number of neighbors requested, between 1 and num_points. + Number of neighbors requested, at least 1. It may exceed the number of + points; slots without a neighbor are padded as described below. ef : int, optional - Search breadth. None or 0 (default) selects max(k, 10); smaller values + Search breadth. None or 0 (default) selects max(k, 64); smaller values are raised to k. num_threads : int, optional - Worker count. Default is 1; 0 selects the available CPU count, - capped by OMP_NUM_THREADS. + Worker count. None (default) selects 1; 0 selects the available CPU + count, capped by OMP_NUM_THREADS. Returns ------- @@ -394,7 +402,9 @@ distances : ndarray of float32, shape (n_queries, k) Notes ----- -Returns (ids, distances), in that order. Array shapes and value ranges are +Returns (ids, distances), in that order. When the graph reaches fewer than k +points, the remaining neighbors have ID 4294967295 and distance inf; test +np.isfinite(distances) to find them. Array shapes and value ranges are checked at runtime, not by static type checkers.)doc"; py::class_(m, "SymqgIndex", kIndexDoc) @@ -445,7 +455,7 @@ searches.)doc" &SymqgIndex::add, py::arg("data"), py::kw_only(), - py::arg("num_threads") = 0, + py::arg("num_threads") = py::none(), R"doc(Build the graph from all vectors. Parameters @@ -454,8 +464,13 @@ data : array_like of float, shape (n, dim) Vectors in the original, unpadded coordinates, with n > max_degree. Converted to contiguous float32; a copy may be made. num_threads : int, optional - Keyword-only. Worker count. Default is 0, which selects the available - CPU count. + Keyword-only. Worker count. None (default) and 0 select the available + CPU count, capped by OMP_NUM_THREADS. + +Returns +------- +ids : ndarray of uint32, shape (n,) + Point IDs, the row numbers of data. Notes ----- @@ -478,7 +493,7 @@ data. Input arrays need not be retained after this synchronous call.)doc" py::arg("queries"), py::arg("k"), py::arg("ef") = py::none(), - py::arg("num_threads") = 1, + py::arg("num_threads") = py::none(), kSearchDoc ) .def( diff --git a/src/index/ivf.cpp b/src/index/ivf.cpp index fa7e69ed..abc91769 100644 --- a/src/index/ivf.cpp +++ b/src/index/ivf.cpp @@ -22,6 +22,7 @@ #include "file_replacement.hpp" #include "ivf_search.hpp" +#include "rabitqlib/clustering/detail/kmeans.hpp" #include "rabitqlib/clustering/qgkmeans.hpp" #include "rabitqlib/clustering/rabitqkmeans.hpp" #include "rabitqlib/core/data_layout.hpp" @@ -56,6 +57,8 @@ constexpr size_t kHistoricalHnswMinClusters = 20000; // An index from the point-count constructor is built by construct, never trained. constexpr const char* kPointCountConstructOnly = "IVF index created with a point count must be built by construct"; +// Scratch for the rows that flat routing in add rotates at a time. +constexpr size_t kRoutingBlockBytes = size_t{128} << 20; } // namespace IVF::IVF() noexcept = default; @@ -127,6 +130,7 @@ bool IVF::has_capacity(const ClusterStorage& cs, size_t num_points) const { void IVF::free_storage() { initializer_.reset(); + std::vector().swap(flat_centroid_norms_); std::vector().swap(cluster_storage_); } @@ -134,6 +138,7 @@ void IVF::swap(IVF& other) noexcept { using std::swap; swap(initializer_type_, other.initializer_type_); swap(initializer_, other.initializer_); + swap(flat_centroid_norms_, other.flat_centroid_norms_); swap(cluster_storage_, other.cluster_storage_); swap(max_elements_, other.max_elements_); swap(num_removed_, other.num_removed_); @@ -275,6 +280,7 @@ void IVF::set_centroids(const float* centroids, size_t num_threads) { // Commit. Moving the vectors keeps the empty views' storage, and nothing throws. initializer_ = std::move(initializer); + flat_centroid_norms_.clear(); cluster_storage_ = std::move(cluster_storage); clusters_ = std::move(clusters); ip_func_ = ip_func; @@ -495,6 +501,52 @@ void IVF::add( } assigned[i] = cluster_ids[i]; } + } else if (initializer_type_ == InitializerType::Flat) { + // Exact nearest centroids with k-means' blocked assignment, which reuses each + // centroid tile across a block of rotated rows; ties go to the lowest ID. + // FlatInitializer stores its centroids contiguously. + const float* centroids = initializer_->centroid(0); + const auto num_workers = static_cast(resolve_num_threads(num_threads)); + if (flat_centroid_norms_.size() != num_clusters_) { + std::vector norms(num_clusters_); + index_detail::parallel_for( + 0, num_clusters_, num_threads, [&](size_t i, size_t /*thread*/) { + norms[i] = clustering::detail::l2norm_sqr_f64( + centroids + (i * padded_dim_), padded_dim_ + ); + } + ); + flat_centroid_norms_ = std::move(norms); + } + const size_t block_rows = std::min( + num_points, + std::max(1, kRoutingBlockBytes / (sizeof(float) * padded_dim_)) + ); + std::vector rotated(block_rows * padded_dim_); + std::vector distances(block_rows); + for (size_t first = 0; first < num_points; first += block_rows) { + const size_t rows = std::min(block_rows, num_points - first); + index_detail::parallel_for( + 0, rows, num_threads, [&](size_t row, size_t /*thread*/) { + rotator_->rotate( + data + ((first + row) * dim_), &rotated[row * padded_dim_] + ); + } + ); + clustering::detail::exact_assign( + rotated.data(), + centroids, + rows, + padded_dim_, + num_clusters_, + metric_type_ == METRIC_IP, + num_workers, + assigned.data() + first, + distances.data(), + nullptr, + flat_centroid_norms_.data() + ); + } } else { index_detail::parallel_for( 0, num_points, num_threads, [&](size_t row, size_t /*thread*/) { @@ -1125,7 +1177,8 @@ void IVF::search( std::vector> centroid_candidates(nprobe); initializer_->centroids_distances(rotated_query.data(), nprobe, centroid_candidates); - buffer::SearchBuffer result_pool(k); + // The pool never needs more slots than points; the rest of k is padded. + buffer::SearchBuffer result_pool(std::min(k, max_elements_)); SplitBatchQuery query_wrapper( rotated_query.data(), padded_dim_, ex_bits_, metric_type_, use_hacc @@ -1226,8 +1279,9 @@ inline void IVF::validate_search( if (result_ids == nullptr) { throw std::invalid_argument("IVF search results must not be null"); } - if (k == 0 || k > max_elements_ || k > buffer::kSearchBufferMaxPointCount) { - throw std::invalid_argument("IVF search k must be between 1 and the point count"); + // Fewer than k results are padded, so k may exceed the point count. + if (k == 0 || k > buffer::kSearchBufferMaxPointCount) { + throw std::invalid_argument("IVF search k must be between 1 and 2^31"); } if (nprobe == 0) { throw std::invalid_argument("IVF search nprobe must be positive"); diff --git a/src/index/qg.cpp b/src/index/qg.cpp index f1eff8d2..02da4bed 100644 --- a/src/index/qg.cpp +++ b/src/index/qg.cpp @@ -456,7 +456,13 @@ void QuantizedGraph::search_impl( ) { validate_search(query, k, result_ids, distances, ef_search); check_coordinates(query, dim_, dim_, "QuantizedGraph search query"); - detail::SearchScratch scratch(padded_dim_, max_degree_, ef_search, k); + // Pools never need more slots than points; search pads the rest of k. + detail::SearchScratch scratch( + padded_dim_, + max_degree_, + std::min(ef_search, max_elements_), + std::min(k, max_elements_) + ); thread_local VisitedSet visited; thread_local size_t visited_size = 0; if (visited_size != max_elements_) { @@ -512,7 +518,12 @@ void QuantizedGraph::search_batch_with_ef( std::optional scratch; VisitedSet visited; try { - scratch.emplace(padded_dim_, max_degree_, ef_search, k); + scratch.emplace( + padded_dim_, + max_degree_, + std::min(ef_search, max_elements_), + std::min(k, max_elements_) + ); visited.initialize(max_elements_, max_elements_ / 10); } catch (...) { failure.capture(); } #pragma omp for schedule(dynamic) @@ -553,8 +564,9 @@ void QuantizedGraph::validate_search( if (query == nullptr || result_ids == nullptr || distances == nullptr) { throw std::invalid_argument("QuantizedGraph search buffers must not be null"); } - if (k == 0 || k > max_elements_) { - throw std::invalid_argument("QuantizedGraph k must be between 1 and num_points"); + // Fewer than k results are padded, so k may exceed the point count. + if (k == 0 || k > buffer::kSearchBufferMaxPointCount) { + throw std::invalid_argument("QuantizedGraph k must be between 1 and 2^31"); } if (ef_search < k) { throw std::invalid_argument("QuantizedGraph ef must be at least k"); @@ -813,6 +825,9 @@ void detail::search_with_workspace( const float* g_k1xsumq ) { graph.validate_search(query, k, result_ids, distances, graph.default_ef_search_); + if (scratch.result_pool.capacity() != k) { + throw std::invalid_argument("QuantizedGraph result pool capacity must equal k"); + } check_coordinates(query, graph.dim_, graph.dim_, "QuantizedGraph search query"); graph.search_with_scratch_impl( query, k, result_ids, distances, scratch, hint, g_k1xsumq @@ -880,10 +895,11 @@ void QuantizedGraph::search_with_scratch_impl( } update_results(scratch.result_pool, scratch.visited, query, quantized); - if (scratch.result_pool.size() != k) { - throw std::runtime_error("QuantizedGraph search could not produce k results"); - } + // Pad when the graph reaches fewer than k points. The pool holds at most k. + const size_t found = scratch.result_pool.size(); scratch.result_pool.copy_results(result_ids, distances); + std::fill(result_ids + found, result_ids + k, kPidMax); + std::fill(distances + found, distances + k, std::numeric_limits::infinity()); } } // namespace rabitqlib::symqg diff --git a/tests/python/test_concurrent_indexes.py b/tests/python/test_concurrent_indexes.py index ab5f3372..63c21c3f 100644 --- a/tests/python/test_concurrent_indexes.py +++ b/tests/python/test_concurrent_indexes.py @@ -209,8 +209,11 @@ def python_worker(): def test_native_exception_reacquires_gil_and_releases_access(): index, data, build = make_index("qg") - with pytest.raises(ValueError, match="SearchBuffer capacity is too large"): - index.search(data[:2], 5, ef=int(np.iinfo(np.uintp).max)) + queries = data[:2].copy() + queries[1, 0] = np.nan + # The native search checks coordinates after releasing the GIL. + with pytest.raises(ValueError, match="must contain only finite values"): + index.search(queries, 5, ef=64) build() ids, distances = index.search(data[:2], 5, ef=64) assert ids.shape == distances.shape == (2, 5) diff --git a/tests/python/test_hnsw.py b/tests/python/test_hnsw.py index 6d8c1cd4..bf612c6d 100644 --- a/tests/python/test_hnsw.py +++ b/tests/python/test_hnsw.py @@ -112,9 +112,9 @@ def test_search_output_contract(built_hnsw, query_data, nq, k): @pytest.mark.parametrize("k", [1, 10, 17]) def test_ef_default(built_hnsw, query_data, k): - """ef=0 should use the internal default (max(k, 10)).""" - expected_ids, expected_distances = built_hnsw.search(query_data, k=k, ef=max(k, 10)) - for kwargs in ({}, {"ef": 0}): + """None and 0 select the internal default, max(k, 64).""" + expected_ids, expected_distances = built_hnsw.search(query_data, k=k, ef=max(k, 64)) + for kwargs in ({}, {"ef": None}, {"ef": 0}): ids, distances = built_hnsw.search(query_data, k=k, **kwargs) np.testing.assert_array_equal(ids, expected_ids) np.testing.assert_array_equal(distances, expected_distances) @@ -310,10 +310,15 @@ def test_add_rejects_wrong_dimension(partial_hnsw): partial_hnsw.add(np.zeros((4, DIM + 1), dtype=np.float32)) -def test_add_before_build_raises(base_data): +def test_add_before_build_trains_first(base_data): idx = HnswIndex(DIM, N_VECTORS, M=8, ef_construction=50, nbits=4) + np.testing.assert_array_equal(idx.add(base_data[:4]), np.arange(4)) + assert idx.is_trained + assert idx.num_clusters == 4 # fewer rows than clusters: one centroid per row with pytest.raises(RuntimeError, match="trained or loaded"): - idx.add(base_data[:4]) + HnswIndex(DIM, N_VECTORS, M=8, ef_construction=50, nbits=4).add( + base_data[:4], cluster_ids=np.zeros(4, dtype=np.uint32) + ) def test_resize_grows_capacity_and_allows_more(base_data, clusters): diff --git a/tests/python/test_symqg.py b/tests/python/test_symqg.py index 37e12468..220acd7b 100644 --- a/tests/python/test_symqg.py +++ b/tests/python/test_symqg.py @@ -100,9 +100,8 @@ def test_search_batch_validates_inputs(built_symqg, query_data): built_symqg.search_batch(query_data[0], k=1, ef=_EF) with pytest.raises(ValueError, match="query dimension does not match index dim"): built_symqg.search_batch(query_data[:, :-1], k=1, ef=_EF) - for k in [0, N_VECTORS + 1]: - with pytest.raises(ValueError, match="k must be between 1 and num_points"): - built_symqg.search_batch(query_data, k=k, ef=_EF) + with pytest.raises(ValueError, match=r"k must be between 1 and 2\*\*31"): + built_symqg.search_batch(query_data, k=0, ef=_EF) unbuilt = SymqgIndex(DIM, max_degree=_MAX_DEGREE) with pytest.raises(RuntimeError, match="add them or load an index before search"): unbuilt.search_batch(query_data, k=1, ef=_EF) @@ -178,12 +177,12 @@ def test_search_before_build_raises(): def test_search_raises_ef_to_k_and_defaults_it(built_symqg, query_data): - # As in HnswIndex: no or zero ef selects max(k, 10), and ef is at least k. + # As in HnswIndex: no or zero ef selects max(k, 64), and ef is at least k. at_k = built_symqg.search(query_data, k=10, ef=10) for ef in (9, 1): raised = built_symqg.search(query_data, k=10, ef=ef) np.testing.assert_array_equal(raised[0], at_k[0]) - default = built_symqg.search(query_data, k=3, ef=10) + default = built_symqg.search(query_data, k=3, ef=64) for ef in (None, 0): np.testing.assert_array_equal( built_symqg.search(query_data, 3, ef)[0], default[0] diff --git a/tests/python/test_symqg_api.py b/tests/python/test_symqg_api.py index 8c98b0bc..769cbe42 100644 --- a/tests/python/test_symqg_api.py +++ b/tests/python/test_symqg_api.py @@ -123,7 +123,9 @@ def test_train_only_checks_its_input(base_data): @pytest.mark.parametrize("nbits", [4, 8, 32]) def test_add_builds_once_and_search_finds_the_vectors(nbits, base_data, tmp_path): index = SymqgIndex(DIM, nbits=nbits, ef_construction=60, seed=5) - assert index.add(base_data) is None + ids = index.add(base_data) + assert ids.dtype == np.uint32 + np.testing.assert_array_equal(ids, np.arange(N_VECTORS)) assert len(index) == index.num_points == N_VECTORS ids, distances = index.search(base_data[:20], 1) assert np.mean(ids[:, 0] == np.arange(20)) >= 0.9 @@ -153,11 +155,11 @@ def test_add_uses_the_constructor_settings(base_data, tmp_path): ) -def test_search_ef_defaults_to_max_of_k_and_10_and_is_raised_to_k(base_data): +def test_search_ef_defaults_to_max_of_k_and_64_and_is_raised_to_k(base_data): index = SymqgIndex(DIM, ef_construction=60, seed=5) index.add(base_data) queries = base_data[:10] - for k, implied_ef in ((3, 10), (20, 20)): + for k, implied_ef in ((3, 64), (80, 80)): expected = index.search(queries, k, implied_ef) for ef in (None, 0): actual = index.search(queries, k, ef) @@ -186,3 +188,13 @@ def test_type_errors_list_only_the_public_constructor(): with pytest.raises(TypeError) as error: SymqgIndex(DIM, nbits="four") assert "_quantization_bits" not in str(error.value) + + +def test_search_pads_k_beyond_the_points(base_data): + index = SymqgIndex(DIM, ef_construction=60, seed=5) + index.add(base_data[:40]) + ids, distances = index.search(base_data[:2], 45) + assert np.isfinite(distances).sum(axis=1).tolist() == [40, 40] + np.testing.assert_array_equal(np.sort(ids[0, :40]), np.arange(40)) + np.testing.assert_array_equal(ids[:, 40:], np.full((2, 5), 2**32 - 1)) + assert np.isinf(distances[:, 40:]).all() diff --git a/tests/python/test_train_api.py b/tests/python/test_train_api.py index d5d92f50..f80e062b 100644 --- a/tests/python/test_train_api.py +++ b/tests/python/test_train_api.py @@ -55,15 +55,16 @@ def test_constructor_options_are_keyword_only(): "num_clusters", ] assert [p for p in hnsw if hnsw[p].kind is not keyword] == ["self", "dim"] - assert ivf["nbits"].default is inspect.Parameter.empty - assert hnsw["nbits"].default is inspect.Parameter.empty + assert ivf["nbits"].default == 7 + assert hnsw["nbits"].default == 7 + assert IvfIndex(DIM, 4).nbits == HnswIndex(DIM).nbits == 7 assert ivf["seed"].default is None assert hnsw["seed"].default == 100 assert hnsw["num_clusters"].default == 16 for cls in (IvfIndex, HnswIndex): add = inspect.signature(cls.add).parameters assert [p for p in add if add[p].kind is not keyword] == ["self", "data"] - assert add["num_threads"].default == 0 + assert add["num_threads"].default is None # train is native; pybind11 documents its signature on the first line. assert ", x: object, *, num_threads:" in cls.train.__doc__.splitlines()[0] assert cls.train.__doc__.splitlines()[0].endswith("= None) -> None") @@ -72,15 +73,6 @@ def test_constructor_options_are_keyword_only(): @pytest.mark.parametrize( "make,message", [ - # Python 3.11 omits "keyword-only" from inspect's message; 3.12+ includes it. - ( - lambda: IvfIndex(DIM, 4), - r"^IvfIndex\(\) missing a required (keyword-only )?argument: 'nbits'$", - ), - ( - lambda: HnswIndex(DIM), - r"^HnswIndex\(\) missing a required (keyword-only )?argument: 'nbits'$", - ), ( lambda: IvfIndex(DIM, 4, nbits=4, max_degree=8), r"^IvfIndex\(\) got an unexpected keyword argument 'max_degree'$", @@ -129,7 +121,7 @@ def test_cp_defaults_follow_metric_and_seed(metric): ivf = IvfIndex(DIM, 4, nbits=4, metric=metric, seed=11) hnsw = HnswIndex(DIM, nbits=4, metric=metric, seed=2**40 + 5) for index, seed in ((ivf, 11), (hnsw, 5)): - assert index.cp.niter == 25 + assert index.cp.niter == 10 assert index.cp.max_points_per_centroid == 256 assert index.cp.spherical is (metric == "ip") assert index.cp.seed == seed @@ -253,7 +245,8 @@ def test_operations_before_training_raise_and_change_nothing(kind, base_data, tm index = new_index(kind) name = type(index).__name__ operations = { - "add": lambda: index.add(base_data), + # Without cluster_ids, add trains first instead. + "add": lambda: index.add(base_data, cluster_ids=np.zeros(N_VECTORS, np.uint32)), "search": lambda: search(index, base_data[:1], 1), "remove": lambda: index.remove([0]), "save": lambda: index.save(tmp_path / "untrained.index"), @@ -267,9 +260,8 @@ def test_operations_before_training_raise_and_change_nothing(kind, base_data, tm call() assert not (tmp_path / "untrained.index").exists() assert not index.is_trained and len(index) == 0 - index.train(base_data) index.add(base_data) - assert len(index) == N_VECTORS + assert index.is_trained and len(index) == N_VECTORS @pytest.mark.parametrize("kind", KINDS) @@ -289,18 +281,20 @@ def test_trained_index_without_points_cannot_be_saved(kind, base_data, tmp_path) @pytest.mark.parametrize("kind", KINDS) -def test_search_k_is_bounded_by_stored_points(kind, base_data): - # Not by HNSW capacity, which grows geometrically. +def test_search_pads_k_beyond_the_stored_points(kind, base_data): index = new_index(kind) index.train(base_data) - bound = "num_stored_points" if kind == "hnsw" else "max_elements" - with pytest.raises(ValueError, match=f"^k must be between 1 and {bound}$"): - search(index, base_data[:1], 1) + ids, distances = search(index, base_data[:1], 3) + np.testing.assert_array_equal(ids, np.full((1, 3), 2**32 - 1)) + assert np.isinf(distances).all() index.add(base_data[:10]) index.add(base_data[10:11]) - assert search(index, base_data[:1], 11)[0].shape == (1, 11) - with pytest.raises(ValueError, match=f"^k must be between 1 and {bound}$"): - search(index, base_data[:1], 12) + ids, distances = search(index, base_data[:1], 13) + assert np.isfinite(distances).sum() == 11 + np.testing.assert_array_equal(np.sort(ids[np.isfinite(distances)]), np.arange(11)) + np.testing.assert_array_equal(ids[~np.isfinite(distances)], [2**32 - 1] * 2) + with pytest.raises(ValueError, match=r"^k must be between 1 and 2\*\*31$"): + search(index, base_data[:1], 0) @pytest.mark.parametrize("kind", KINDS) @@ -406,3 +400,144 @@ def test_add_without_cluster_ids_routes_to_trained_centroids(kind, base_data): index.add(base_data[250:]) found, _ = search(index, base_data[:20], 1) assert np.mean(found[:, 0] == np.arange(20)) >= 0.9 + + +@pytest.mark.parametrize("kind", KINDS) +@pytest.mark.parametrize("metric", ["l2", "ip"]) +def test_first_add_trains_like_train_then_add(kind, metric, base_data, tmp_path): + explicit = new_index(kind, metric) + explicit.train(base_data) + explicit.add(base_data, num_threads=1) + implicit = new_index(kind, metric) + np.testing.assert_array_equal( + implicit.add(base_data, num_threads=1), np.arange(N_VECTORS) + ) + assert implicit.is_trained + assert saved_bytes(implicit, tmp_path / "implicit.index") == saved_bytes( + explicit, tmp_path / "explicit.index" + ) + + +@pytest.mark.parametrize("kind", KINDS) +def test_failed_first_add_leaves_the_index_untrained(kind, base_data): + index = new_index(kind, num_clusters=8) + bad = base_data.copy() + bad[7, 1] = np.nan + with pytest.raises(ValueError, match="finite"): + index.add(bad) + if kind == "ivf": + with pytest.raises(ValueError, match="at least num_clusters rows"): + index.add(base_data[:7]) + assert not index.is_trained + assert len(index) == index.num_stored_points == 0 + assert index.num_clusters == 8 + index.add(base_data) + assert index.is_trained and len(index) == N_VECTORS + + +def test_ivf_nprobe_defaults_to_a_sixteenth_of_the_clusters(base_data): + index = IvfIndex(DIM, 40, nbits=4, seed=3) + index.add(base_data) + queries = base_data[:10] + expected = index.search(queries, 5, 3) # round(40 / 16) = 2.5 -> 3 + for found in (index.search(queries, 5), index.search(queries, 5, None)): + np.testing.assert_array_equal(found[0], expected[0]) + np.testing.assert_array_equal(found[1], expected[1]) + np.testing.assert_array_equal(index.search(queries, 5, 0)[0], expected[0]) + small = IvfIndex(DIM, 4, nbits=4, seed=3) + small.add(base_data) + np.testing.assert_array_equal( + small.search(queries, 5)[0], small.search(queries, 5, 1)[0] + ) + + +@pytest.mark.parametrize("kind", KINDS) +def test_num_threads_none_selects_the_defaults(kind, base_data): + index = new_index(kind) + index.add(base_data, num_threads=None) + found = ( + index.search(base_data[:5], 3, None, num_threads=None) + if kind == "hnsw" + else index.search(base_data[:5], 3, None, None, None) + ) + assert found[0].shape == (5, 3) + + +def test_hnsw_reports_its_graph_settings(): + index = HnswIndex(DIM, M=12, ef_construction=77) + assert (index.M, index.ef_construction) == (12, 77) + + +def test_package_reports_its_version(): + import importlib.metadata + + import rabitqlib + + assert rabitqlib.__version__ == importlib.metadata.version("rabitqlib") + + +@pytest.mark.parametrize( + "options", + [ + {"nbits": 32}, + {"initializer": "flat_rabitq"}, + {"initializer": "hnsw"}, + {"niter": 3, "max_points_per_centroid": 64}, + ], +) +def test_ivf_first_add_keeps_every_setting(options, base_data, tmp_path): + cp = {k: options[k] for k in ("niter", "max_points_per_centroid") if k in options} + settings = {k: v for k, v in options.items() if k not in cp} + + def make(): + index = IvfIndex(DIM, 8, metric="ip", seed=11, **settings) + for name, value in cp.items(): + setattr(index.cp, name, value) + return index + + implicit = make() + implicit.add(base_data, num_threads=1) + explicit = make() + explicit.train(base_data) + explicit.add(base_data, num_threads=1) + assert saved_bytes(implicit, tmp_path / "implicit.index") == saved_bytes( + explicit, tmp_path / "explicit.index" + ) + + +def test_ivf_first_add_uses_the_drawn_seed(base_data, tmp_path): + implicit = IvfIndex(DIM, 8) + seed = implicit.seed + implicit.add(base_data, num_threads=1) + assert implicit.seed == implicit.cp.seed == seed + explicit = IvfIndex(DIM, 8, seed=seed) + explicit.train(base_data) + explicit.add(base_data, num_threads=1) + assert saved_bytes(implicit, tmp_path / "implicit.index") == saved_bytes( + explicit, tmp_path / "explicit.index" + ) + + +def test_hnsw_first_add_keeps_a_preallocated_capacity(base_data, tmp_path): + implicit = HnswIndex(DIM, num_clusters=4, M=8, ef_construction=50) + implicit.resize(2 * N_VECTORS) + implicit.add(base_data, num_threads=1) + assert implicit.max_elements == 2 * N_VECTORS + explicit = HnswIndex(DIM, num_clusters=4, M=8, ef_construction=50) + explicit.resize(2 * N_VECTORS) + explicit.train(base_data) + explicit.add(base_data, num_threads=1) + assert saved_bytes(implicit, tmp_path / "implicit.index") == saved_bytes( + explicit, tmp_path / "explicit.index" + ) + + +@pytest.mark.parametrize("kind", KINDS) +def test_empty_first_add_requires_training(kind, base_data): + index = new_index(kind) + name = type(index).__name__ + with pytest.raises( + RuntimeError, match=f"^{name} must be trained or loaded before add$" + ): + index.add(base_data[:0]) + assert not index.is_trained diff --git a/tests/unit/rabitqlib/index/ivf_test.cpp b/tests/unit/rabitqlib/index/ivf_test.cpp index c617ea42..cfce20c4 100644 --- a/tests/unit/rabitqlib/index/ivf_test.cpp +++ b/tests/unit/rabitqlib/index/ivf_test.cpp @@ -316,7 +316,10 @@ TEST(IvfSearchTest, RejectsUnbuiltStateAndInvalidArguments) { index.construct(data.data(), centroid.data(), &cluster, false, 1); EXPECT_THROW(index.search(query.data(), 0, 1, &result), std::invalid_argument); - EXPECT_THROW(index.search(query.data(), 2, 1, &result), std::invalid_argument); + // k beyond the point count is padded, not rejected. + std::array two{0, 0}; + index.search(query.data(), two.size(), 1, two.data()); + EXPECT_EQ(two, (std::array{0, kPidMax})); EXPECT_THROW( index.search(query.data(), std::numeric_limits::max(), 1, &result), std::invalid_argument @@ -786,6 +789,108 @@ TEST(IvfAddTest, EveryWayOfAddingTheSamePointsGivesByteIdenticalIndexes) { } } +TEST(IvfAddTest, FlatRoutingSpansSeveralRoutingBlocks) { + // More rows than one flat routing block (128 MiB of rotated rows; kDynDim pads + // to 96), with a partial final block. Row i sits close to centroid i % 4, so + // its nearest centroid is unambiguous. + constexpr size_t kBlockRows = (size_t{128} << 20) / (sizeof(float) * 96); + constexpr size_t kCount = kBlockRows + 33; + auto centroids = random_rows(kDynNumClusters, 51); + for (auto& value : centroids) { + value *= 10.0F; + } + auto points = random_rows(kCount, 52); + for (size_t i = 0; i < kCount; ++i) { + for (size_t d = 0; d < kDynDim; ++d) { + points[i * kDynDim + d] = centroids[(i % kDynNumClusters) * kDynDim + d] + + 0.1F * points[i * kDynDim + d]; + } + } + + for (auto metric : {METRIC_L2, METRIC_IP}) { + SCOPED_TRACE(metric == METRIC_L2 ? "l2" : "ip"); + IVF index(kDynDim, kDynNumClusters, 1, metric, 7U); + index.set_centroids(centroids.data(), 1); + index.add(points.data(), kCount, nullptr, false, 4); + size_t stored = 0; + for (size_t c = 0; c < kDynNumClusters; ++c) { + const Cluster cluster = IvfTestAccess::cluster_view(index, c); + stored += cluster.num(); + for (size_t j = 0; j < cluster.num(); ++j) { + ASSERT_EQ(cluster.ids()[j] % kDynNumClusters, c) << cluster.ids()[j]; + } + } + EXPECT_EQ(stored, kCount); + } +} + +TEST(IvfAddTest, FlatRoutingIsIndependentOfBatchingAndReloads) { + constexpr size_t kOld = 64; + constexpr size_t kNew = 100; + const auto points = random_rows(kOld + kNew, 81); + const auto first_centroids = random_rows(kDynNumClusters, 82); + const auto second_centroids = random_rows(kDynNumClusters, 83); + const float* extra = points.data() + (kOld * kDynDim); + const rabitq_test::TempFile first_file("rabitq_ivf_route_first.index"); + const rabitq_test::TempFile second_file("rabitq_ivf_route_second.index"); + const rabitq_test::TempFile path_file("rabitq_ivf_route_out.index"); + const std::string& path = path_file.path(); + for (const auto* file : {&first_file, &second_file}) { + const auto& centroids = file == &first_file ? first_centroids : second_centroids; + const auto assigned = nearest_centroids(centroids, points.data(), kOld, METRIC_L2); + IVF base(kOld, kDynDim, kDynNumClusters, 4, METRIC_L2); + base.construct(points.data(), centroids.data(), assigned.data(), false, 1); + base.save(file->path().c_str()); + } + + IVF whole; + whole.load(second_file.path().c_str()); + whole.add(extra, kNew, nullptr, false, 1); + const std::string expected = saved_bytes(whole, path); + + // Single rows and other splits route like one batch. + IVF parts; + parts.load(second_file.path().c_str()); + size_t done = 0; + for (size_t chunk : std::array{1, 1, 7, kNew - 9}) { + parts.add(extra + (done * kDynDim), chunk, nullptr, false, 2); + done += chunk; + } + EXPECT_TRUE(saved_bytes(parts, path) == expected) << "batching"; + + // Loading other centroids into the same object discards cached routing state. + IVF reused; + reused.load(first_file.path().c_str()); + reused.add(extra, 1, nullptr, false, 1); + reused.load(second_file.path().c_str()); + reused.add(extra, kNew, nullptr, false, 1); + EXPECT_TRUE(saved_bytes(reused, path) == expected) << "reload"; +} + +TEST(IvfAddTest, FlatRoutingBreaksTiesTowardTheLowestCentroid) { + // Centroids 2 and 3 repeat 0 and 1, so every point ties between two centroids. + auto centroids = random_rows(kDynNumClusters, 61); + std::copy_n(centroids.begin(), 2 * kDynDim, centroids.begin() + (2 * kDynDim)); + constexpr size_t kCount = 50; + const auto points = random_rows(kCount, 62); + + for (auto metric : {METRIC_L2, METRIC_IP}) { + SCOPED_TRACE(metric == METRIC_L2 ? "l2" : "ip"); + IVF index(kDynDim, kDynNumClusters, 4, metric, 7U); + index.set_centroids(centroids.data(), 1); + index.add(points.data(), kCount, nullptr, false, 2); + const auto expected = nearest_centroids(centroids, points.data(), kCount, metric); + std::array expected_sizes{}; + for (PID cluster : expected) { + ASSERT_LT(cluster, 2U); + ++expected_sizes[cluster]; + } + for (size_t c = 0; c < kDynNumClusters; ++c) { + EXPECT_EQ(IvfTestAccess::cluster_view(index, c).num(), expected_sizes[c]) << c; + } + } +} + TEST(IvfAddTest, AddingToOneClusterLeavesOtherClustersUntouched) { // Cluster sizes 0, 1, 32, 97 cover empty, single, full-batch and tail clusters. constexpr size_t kOld = 130; @@ -1803,7 +1908,7 @@ TEST(IvfTrainTest, RejectsInvalidCentroidsAndTrainingDataWithoutChangingState) { EXPECT_THROW(unconfigured.train(kCount, points.data(), cp), std::logic_error); } -TEST(IvfTrainTest, TrainedIndexSearchesAndSavesOnlyAfterAdd) { +TEST(IvfTrainTest, TrainedIndexPadsSearchesAndSavesOnlyAfterAdd) { constexpr size_t kCount = 300; constexpr uint32_t kSeed = 21; const auto points = random_rows(kCount, 71); @@ -1817,15 +1922,13 @@ TEST(IvfTrainTest, TrainedIndexSearchesAndSavesOnlyAfterAdd) { index.train(kCount, points.data(), default_training_parameters(metric, kSeed)); EXPECT_TRUE(index.is_trained()); EXPECT_EQ(index.num_points(), 0U); - PID id = 0; - try { - index.search(points.data(), 1, 1, &id); - FAIL() << "searching a trained index without points succeeded"; - } catch (const std::invalid_argument& error) { - EXPECT_STREQ( - error.what(), "IVF search k must be between 1 and the point count" - ); - } + // Without points, every result slot is padded. + std::array ids{0, 0}; + std::array distances{0.0F, 0.0F}; + index.search(points.data(), ids.size(), 1, ids.data(), distances.data()); + EXPECT_EQ(ids, (std::array{kPidMax, kPidMax})); + EXPECT_TRUE(std::isinf(distances[0]) && std::isinf(distances[1])); + EXPECT_THROW(index.search(points.data(), 0, 1, ids.data()), std::invalid_argument); try { index.save(path.c_str()); FAIL() << "saving a trained index without points succeeded"; @@ -1890,7 +1993,7 @@ TEST(IvfTrainTest, DefaultsUseSphericalKMeansForInnerProduct) { for (auto metric : {METRIC_L2, METRIC_IP}) { SCOPED_TRACE(metric == METRIC_L2 ? "l2" : "ip"); const auto cp = default_training_parameters(metric, 5); - EXPECT_EQ(cp.niter, 25U); + EXPECT_EQ(cp.niter, 10U); EXPECT_EQ(cp.max_points_per_centroid, 256U); EXPECT_EQ(cp.seed, 5U); EXPECT_EQ(cp.spherical, metric == METRIC_IP); diff --git a/tests/unit/rabitqlib/index/qg_test.cpp b/tests/unit/rabitqlib/index/qg_test.cpp index dbf7983b..a35edca4 100644 --- a/tests/unit/rabitqlib/index/qg_test.cpp +++ b/tests/unit/rabitqlib/index/qg_test.cpp @@ -1680,7 +1680,7 @@ TEST(QgQuantTest, SearchesAndRoundTripsFourAndEightBitIndexes) { } } -TEST(QgSearchTest, RejectsInvalidKAndEfInsteadOfReturningPartialResults) { +TEST(QgSearchTest, RejectsInvalidKAndEfAndPadsPartialResults) { constexpr size_t kCount = 33, kDim = 64, kDegree = 32; std::vector data(kCount * kDim); for (size_t i = 0; i < data.size(); ++i) { @@ -1726,16 +1726,25 @@ TEST(QgSearchTest, RejectsInvalidKAndEfInsteadOfReturningPartialResults) { QGConstructionTestAccess::set_encoded_neighbor(graph, source, lane, 0); } } + // Every edge now leads to point 0, so a search reaches fewer than k points and + // pads the remaining slots. graph.set_ef(3); - ids.fill(kPidMax); + ids.fill(0); distances.fill(-123.0F); - EXPECT_THROW( - graph.search(data.data(), 3, ids.data(), distances.data()), std::runtime_error - ); - EXPECT_TRUE(std::all_of(ids.begin(), ids.end(), [](PID id) { return id == kPidMax; })); - EXPECT_TRUE(std::all_of(distances.begin(), distances.end(), [](float distance) { - return distance == -123.0F; - })); + graph.search(data.data(), 3, ids.data(), distances.data()); + const auto found = static_cast(std::count_if( + distances.begin(), distances.begin() + 3, [](float d) { return std::isfinite(d); } + )); + ASSERT_LT(found, 3U); + for (size_t i = 0; i < 3; ++i) { + if (i < found) { + EXPECT_LT(ids[i], kCount); + } else { + EXPECT_EQ(ids[i], kPidMax); + EXPECT_TRUE(std::isinf(distances[i])); + } + } + EXPECT_EQ(distances[3], -123.0F); // Slots beyond k are untouched. } TEST(QgSearchTest, BatchAndParallelQueriesMatchSerialAcrossIndexesAndSettings) { @@ -1876,16 +1885,14 @@ TEST(QgSearchTest, BatchSearchValidatesBuffersSizesAndState) { nullptr, "QuantizedGraph search buffers must not be null" ); - for (uint32_t k : {0U, 98U}) { - reject( - data.data(), - 2, - k, - ids.data(), - distances.data(), - "QuantizedGraph k must be between 1 and num_points" - ); - } + reject( + data.data(), + 2, + 0, + ids.data(), + distances.data(), + "QuantizedGraph k must be between 1 and 2^31" + ); graph.set_ef(1); reject( data.data(), @@ -1956,53 +1963,100 @@ TEST(QgSearchTest, ConcurrentBatchCallsMatchIndividualQueries) { } // namespace rabitqlib::symqg namespace rabitqlib::symqg { -TEST(QgSearchTest, ScratchSearchRejectsIncompleteResults) { +TEST(QgSearchTest, ScratchSearchRequiresAResultPoolOfCapacityK) { + QuantizedGraph graph(66, 64, 32, METRIC_L2, RotatorType::FhtKacRotator, 0, 42); + std::vector data(66 * 64, 0.0F); + for (size_t i = 0; i < data.size(); ++i) { + data[i] = std::sin(static_cast(i) * 0.37F); + } + QGBuilder builder(graph, 66, data.data(), 1, 42); + builder.build(2); + graph.set_ef(64); + std::vector ids(10, 0); + std::vector distances(10, -1.0F), rotated(64), estimates(32), lut(256); + BatchQuery query; + buffer::SearchBuffer search(64); + VisitedSet visited(66, 66); + for (size_t capacity : {4U, 64U}) { + SCOPED_TRACE(capacity); + buffer::SearchBuffer result(capacity); + try { + graph.search_with_scratch( + data.data(), + 10, + ids.data(), + distances.data(), + rotated.data(), + estimates.data(), + lut.data(), + query, + search, + result, + visited + ); + FAIL() << "a result pool of the wrong capacity was accepted"; + } catch (const std::invalid_argument& error) { + EXPECT_STREQ(error.what(), "QuantizedGraph result pool capacity must equal k"); + } + EXPECT_TRUE(std::all_of(ids.begin(), ids.end(), [](PID id) { return id == 0; })); + } +} + +TEST(QgSearchTest, SearchPadsIncompleteResults) { + // Two disconnected halves: a search reaches only the 33 points of one half. QuantizedGraph graph(66, 64, 32, METRIC_L2, RotatorType::FhtKacRotator, 0, 42); std::vector data(66 * 64, 0.0F); QGBuilder builder(graph, 66, data.data(), 1, 42); builder.build(2); QGConstructionTestAccess::disconnect(graph); graph.set_ef(66); - std::vector ids(66, kPidMax); + const auto expect_padded = [](const PID* ids, const float* distances) { + for (size_t i = 0; i < 66; ++i) { + SCOPED_TRACE(i); + if (i < 33) { + EXPECT_LT(ids[i], 66U); + EXPECT_TRUE(std::isfinite(distances[i])); + } else { + EXPECT_EQ(ids[i], kPidMax); + EXPECT_TRUE(std::isinf(distances[i])); + } + } + }; + std::vector ids(66, 0); std::vector distances(66, -1.0F), rotated(64), estimates(32), lut(256); + graph.search(data.data(), 66, ids.data(), distances.data()); + expect_padded(ids.data(), distances.data()); + + std::fill(ids.begin(), ids.end(), 0); + std::fill(distances.begin(), distances.end(), -1.0F); BatchQuery query; buffer::SearchBuffer search(66), result(66); VisitedSet visited(66, 66); - EXPECT_THROW( - graph.search(data.data(), 66, ids.data(), distances.data()), std::runtime_error - ); - EXPECT_THROW( - graph.search_with_scratch( - data.data(), - 66, - ids.data(), - distances.data(), - rotated.data(), - estimates.data(), - lut.data(), - query, - search, - result, - visited - ), - std::runtime_error + graph.search_with_scratch( + data.data(), + 66, + ids.data(), + distances.data(), + rotated.data(), + estimates.data(), + lut.data(), + query, + search, + result, + visited ); - std::vector batch_ids(2 * 66, kPidMax); - std::vector batch_distances(2 * 66, -1.0F); + expect_padded(ids.data(), distances.data()); + for (size_t threads : {1U, 4U}) { - try { - graph.search_batch( - data.data(), 2, 66, batch_ids.data(), batch_distances.data(), threads - ); - FAIL() << "Incomplete batch results accepted"; - } catch (const std::runtime_error& error) { - EXPECT_STREQ(error.what(), "QuantizedGraph search could not produce k results"); - } + SCOPED_TRACE(threads); + std::vector batch_ids(2 * 66, 0); + std::vector batch_distances(2 * 66, -1.0F); + graph.search_batch( + data.data(), 2, 66, batch_ids.data(), batch_distances.data(), threads + ); + expect_padded(batch_ids.data(), batch_distances.data()); + expect_padded(batch_ids.data() + 66, batch_distances.data() + 66); } - EXPECT_TRUE(std::all_of(ids.begin(), ids.end(), [](PID id) { return id == kPidMax; })); - EXPECT_TRUE(std::all_of(distances.begin(), distances.end(), [](float d) { - return d == -1.0F; - })); } } // namespace rabitqlib::symqg