diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 29c0f9f7..e16b1592 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -104,10 +104,10 @@ cmake -S . -B build -DRABITQ_BUILD_TESTS=ON \
-DRABITQ_BUILD_SAMPLES=OFF -DCMAKE_BUILD_TYPE=Release \
-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF
cmake --build build --parallel 2
-ctest --test-dir build --output-on-failure
+ctest --no-tests=error --test-dir build --output-on-failure
```
-The first configuration needs Git and network access to download GoogleTest.
+The first configuration needs network access to download GoogleTest.
Increase `--parallel 2` if your machine has enough memory for more compiler jobs.
Native CPU tuning is disabled here to keep generic code portable. On Windows
or Apple Silicon, use the matching commands in [the test guide](../tests/README.md#quick-start).
diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py
index a3a3d9d6..7a52564e 100644
--- a/.github/scripts/ci_changes.py
+++ b/.github/scripts/ci_changes.py
@@ -55,7 +55,6 @@ def selects_core(path):
"build_indexes",
"download_datasets",
"install_baselines",
- "plot_homepage",
"plot_results",
"query_index",
"reproduce",
diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py
index be376042..cc1ee247 100644
--- a/.github/scripts/test_ci_changes.py
+++ b/.github/scripts/test_ci_changes.py
@@ -67,7 +67,7 @@ def test_known_support_paths_do_not_select_unrelated_builds(self):
".github/ISSUE_TEMPLATE/bug_report.yml": set(),
".github/CODEOWNERS": set(),
".github/release.yml": {"python_quality"},
- "benchmarks/plot_homepage.py": {"python_quality"},
+ "benchmarks/plot_results.py": {"python_quality"},
"sample/run_cpp.sh": {"shell"},
"sample/run_python.sh": {"shell"},
"example.sh": {"shell"}, # Deleted paths still occur in a push diff.
@@ -87,7 +87,7 @@ def test_known_support_paths_do_not_select_unrelated_builds(self):
self.assertEqual(classify([path]), expected)
def test_support_paths_do_not_mask_source_or_unknown_changes(self):
- support = [".github/ISSUE_TEMPLATE/config.yml", "benchmarks/plot_homepage.py"]
+ support = [".github/ISSUE_TEMPLATE/config.yml", "benchmarks/plot_results.py"]
self.assertEqual(
classify(support + ["src/core/api.cpp"]),
{
@@ -364,7 +364,6 @@ def test_tagged_main_routing_changes_preserve_lightweight_companion_checks(self)
for companions, expected in (
(["scripts/check-python.sh"], {"python_quality", "shell"}),
(["sample/run_cpp.sh"], {"python_quality", "shell"}),
- (["benchmarks/plot_homepage.py"], {"python_quality"}),
(["benchmarks/build_indexes.py"], {"python_quality"}),
([".github/ISSUE_TEMPLATE/config.yml"], {"python_quality"}),
(["src/core/api.cpp"], set(GROUPS)),
diff --git a/.github/scripts/test_ci_workflow_contracts.py b/.github/scripts/test_ci_workflow_contracts.py
index b4b9d2c3..8b56366d 100644
--- a/.github/scripts/test_ci_workflow_contracts.py
+++ b/.github/scripts/test_ci_workflow_contracts.py
@@ -869,6 +869,7 @@ def test_documentation_summary_is_available_for_non_doc_prs(self):
self.assertEqual(needs(build), ["changes"])
self.assertIn("needs.changes.outputs.docs == 'true'", build["if"])
self.assertIn("needs.changes.result != 'success'", build["if"])
+ self.assertIn("github.event_name == 'push'", build["if"])
self.assertEqual(classify(["docs/docs/quick_start.md"]), {"docs"})
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index b9a6fa00..bcb41ed4 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -19,7 +19,9 @@ jobs:
build:
needs: changes
- if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.docs == 'true') }}
+ # Main pushes always deploy: a newer push can cancel a docs-changing run, and
+ # its own diff would otherwise leave the site at the older commit.
+ if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.result != 'success' || needs.changes.outputs.docs == 'true') }}
name: Build documentation
runs-on: ubuntu-latest
timeout-minutes: 10
diff --git a/AGENTS.md b/AGENTS.md
index b8767b24..e5b94d10 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -63,7 +63,7 @@ Use `ctest --no-tests=error`, including filtered and installed-consumer runs.
C++17 quantization/ANN library with Python bindings (`rabitqlib`). Components depend in one
direction: `index` → `fastscan` → `core`; core and FastScan support OpenMP OFF, ANN/Python require ON.
-Stable float32 v1 API: `include/rabitqlib/core/api.hpp`. Implementations: `src/{core,simd,fastscan,index}/`.
+Stable float32 v1 API: `include/rabitqlib/core/api.hpp`. Implementations: `src/{core,simd,utils,fastscan,index}/`.
Bindings: `python_bindings/`; tests: `tests/{unit,python}/`; site sources: `docs/docs/`.
See the [full code map](docs/development.md#project-and-code-map) when locating unfamiliar code.
diff --git a/README.md b/README.md
index 80697cfb..70bea351 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
+
@@ -12,17 +12,17 @@
RaBitQ-Library is a research-backed library for vector quantization and
-approximate nearest-neighbor search. See the [research papers](docs/docs/papers.md).
+approximate nearest-neighbor search. See the [research papers](https://vectordb-ntu.github.io/RaBitQ-Library/papers/).
## News
- **v0.7.2:** Python type hints, expanded API documentation, path-like inputs,
clearer input errors, and concise index representations. Internal quantization
refactoring and streamlined regression checks preserve existing APIs and index formats.
- See the [release notes](docs/docs/releases/0.7.2.md).
+ See the [release notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.7.2/).
- **v0.7.0:** Modular C++ components, stable core API v1, optional
OpenMP for numerical components, and unified index counts. See the
- [upgrade notes](docs/docs/releases/0.7.0.md).
+ [upgrade notes](https://vectordb-ntu.github.io/RaBitQ-Library/releases/0.7.0/).
## Vector indexes
@@ -33,14 +33,14 @@ QPS–recall tradeoffs.
| Index | Search approach | Storage and updates |
| --- | --- | --- |
-| [IVF-RaBitQ](docs/docs/index/ivf.md) | Partitioned search with batch scoring. | 1–9-bit codes or raw-vector reranking; add/remove. |
-| [HNSW-RaBitQ](docs/docs/index/hnsw.md) | Graph traversal over quantized vectors. | 1–9-bit codes plus graph edges; add/remove and resize. |
-| [SymphonyQG](docs/docs/index/qg.md) | Graph search with batched distance estimation. | Raw or 4/8-bit storage; updates require rebuilding. |
+| [IVF-RaBitQ](https://vectordb-ntu.github.io/RaBitQ-Library/index/ivf/) | Partitioned search with batch scoring. | 1–9-bit codes or raw-vector reranking; add/remove. |
+| [HNSW-RaBitQ](https://vectordb-ntu.github.io/RaBitQ-Library/index/hnsw/) | Graph traversal over quantized vectors. | 1–9-bit codes plus graph edges; add/remove and resize. |
+| [SymphonyQG](https://vectordb-ntu.github.io/RaBitQ-Library/index/qg/) | Graph search with batched distance estimation. | Raw or 4/8-bit storage; updates require rebuilding. |
-[Native clustering methods](docs/docs/clustering.md) prepare centroids and
+[Native clustering methods](https://vectordb-ntu.github.io/RaBitQ-Library/clustering/) prepare centroids and
assignments for IVF-RaBitQ and HNSW-RaBitQ.
-See the [benchmarks](docs/docs/benchmarks/bench.md) for measured results,
+See the [benchmarks](https://vectordb-ntu.github.io/RaBitQ-Library/benchmarks/bench/) for measured results,
settings, and limitations.
## Quick start
@@ -53,7 +53,7 @@ python -m pip install --upgrade "rabitqlib>=0.7.2"
Wheels support CPython 3.11–3.14 on Linux x86-64/ARM64, Windows x86-64,
and macOS 14+ ARM64. x86-64 requires AVX2/FMA and optionally uses
-AVX-512; ARM64 uses NEON. See [platform requirements and source installation](docs/docs/installation.md#requirements).
+AVX-512; ARM64 uses NEON. See [platform requirements and source installation](https://vectordb-ntu.github.io/RaBitQ-Library/installation/#requirements).
Build and search a small IVF-RaBitQ index with synthetic data; no dataset download is
required. This example demonstrates the API, not performance.
@@ -78,17 +78,17 @@ print(ids.shape, distances.shape) # (5, 10) (5, 10)
IDs identify input rows; distances here estimate squared L2 distance, with smaller
values first. Quantization remains approximate even when all clusters are probed.
-The [complete quick start](docs/docs/quick_start.md) explains result handling,
+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.
-For other indexes, see the [HNSW-RaBitQ](docs/docs/index/hnsw.md#minimal-workflow) and
-[SymphonyQG](docs/docs/index/qg.md#minimal-workflow) examples.
+For other indexes, see the [HNSW-RaBitQ](https://vectordb-ntu.github.io/RaBitQ-Library/index/hnsw/#minimal-workflow) and
+[SymphonyQG](https://vectordb-ntu.github.io/RaBitQ-Library/index/qg/#minimal-workflow) examples.
### C++
-Use the [C++ integration guide](docs/docs/cpp_integration.md) for source builds,
+Use the [C++ integration guide](https://vectordb-ntu.github.io/RaBitQ-Library/cpp_integration/) for source builds,
component linking, and installation. Start with the
-[C++ index examples](docs/docs/cpp_integration.md#c-examples), or use the
+[C++ index examples](https://vectordb-ntu.github.io/RaBitQ-Library/cpp_integration/#c-examples), or use the
quantization components below in your own engine.
## Quantization components
@@ -98,12 +98,12 @@ engine, independently of the included indexes.
| Component | What it provides | Start here |
| --- | --- | --- |
-| `rabitqlib::core` | Rotation, per-vector encoding, query preparation, and distance estimation. | [Core example](sample/cpp/core/) · [Stable v1 API](docs/docs/rabitq/core_api.md) |
-| `rabitqlib::fastscan` | Batch encoding and lookup-table scoring over 32-vector layouts, built on core. | [FastScan example](sample/cpp/fastscan/) |
+| `rabitqlib::core` | Rotation, per-vector encoding, query preparation, and distance estimation. | [Core example](https://github.com/VectorDB-NTU/RaBitQ-Library/tree/main/sample/cpp/core) · [Stable v1 API](https://vectordb-ntu.github.io/RaBitQ-Library/rabitq/core_api/) |
+| `rabitqlib::fastscan` | Batch encoding and lookup-table scoring over 32-vector layouts, built on core. | [FastScan example](https://github.com/VectorDB-NTU/RaBitQ-Library/tree/main/sample/cpp/fastscan) |
-See [component integration](docs/docs/cpp_integration.md#core-only-c-integration)
-for build options and compatibility, the [quantizer guide](docs/docs/rabitq/quantizer.md)
-for encoding choices, and the [core benchmark](benchmarks/core/README.md) for
+See [component integration](https://vectordb-ntu.github.io/RaBitQ-Library/cpp_integration/#core-only-c-integration)
+for build options and compatibility, the [quantizer guide](https://vectordb-ntu.github.io/RaBitQ-Library/rabitq/quantizer/)
+for encoding choices, and the [core benchmark](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/benchmarks/core/README.md) for
component throughput and estimation error.
## Adoption and resources
@@ -114,8 +114,8 @@ Two examples of direct library integration:
| Project | Integration |
| --- | --- |
-| [txtai](docs/docs/integrations/txtai.md) | Uses the Python IVF-RaBitQ and HNSW-RaBitQ indexes as an embedding-search backend. |
-| [zvec](docs/docs/integrations/zvec.md) | Uses core quantization in its own IVF-RaBitQ and HNSW-RaBitQ indexes, with FastScan batch scoring in IVF-RaBitQ. |
+| [txtai](https://vectordb-ntu.github.io/RaBitQ-Library/integrations/txtai/) | Uses the Python IVF-RaBitQ and HNSW-RaBitQ indexes as an embedding-search backend. |
+| [zvec](https://vectordb-ntu.github.io/RaBitQ-Library/integrations/zvec/) | Uses core quantization in its own IVF-RaBitQ and HNSW-RaBitQ indexes, with FastScan batch scoring in IVF-RaBitQ. |
The linked walkthroughs describe integration boundaries and provide versioned
source references.
@@ -191,15 +191,15 @@ listed above.
### Further reading
-- [Documentation](https://vectordb-ntu.github.io/RaBitQ-Library/) and [algorithm overview](docs/docs/rabitq/rabitq.md).
-- [Papers and citations](docs/docs/papers.md) for the research behind the library.
-- [Release notes](https://github.com/VectorDB-NTU/RaBitQ-Library/releases) and [0.7 migration guide](docs/docs/modular_migration.md).
+- [Documentation](https://vectordb-ntu.github.io/RaBitQ-Library/) and [algorithm overview](https://vectordb-ntu.github.io/RaBitQ-Library/rabitq/rabitq/).
+- [Papers and citations](https://vectordb-ntu.github.io/RaBitQ-Library/papers/) for the research behind the library.
+- [Release notes](https://github.com/VectorDB-NTU/RaBitQ-Library/releases) and [0.7 migration guide](https://vectordb-ntu.github.io/RaBitQ-Library/modular_migration/).
- [GPU implementation](https://github.com/Stardust-SJF/cuvs_rabitq/tree/cuvs_ivf_rabitq) in cuVS.
## Contributing
-Start with the [contribution guide](.github/CONTRIBUTING.md#your-first-contribution)
-or [starter tasks](.github/CONTRIBUTING.md#starter-tasks). Report bugs and request
+Start with the [contribution guide](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/.github/CONTRIBUTING.md#your-first-contribution)
+or [starter tasks](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/.github/CONTRIBUTING.md#starter-tasks). Report bugs and request
features through [GitHub Issues](https://github.com/VectorDB-NTU/RaBitQ-Library/issues/new/choose).
## Authors and acknowledgements
@@ -213,4 +213,4 @@ Jiabao Jin, Xiaoyao Zhong, and Jinjing Zhou for their valuable feedback.
## License
-RaBitQ Library is available under the [Apache License 2.0](LICENSE).
+RaBitQ Library is available under the [Apache License 2.0](https://github.com/VectorDB-NTU/RaBitQ-Library/blob/main/LICENSE).
diff --git a/benchmarks/DBPEDIA_MANIFEST.json b/benchmarks/DBPEDIA_MANIFEST.json
index d596abcf..bef4920a 100644
--- a/benchmarks/DBPEDIA_MANIFEST.json
+++ b/benchmarks/DBPEDIA_MANIFEST.json
@@ -1,6 +1,6 @@
{
"dataset": "openai-1536-angular",
- "source": "SuperKMeans benchmark data_openai.bin and data_openai_test.bin",
+ "source": "openai-1536-angular.hdf5 train/test splits (Google Drive file 1cwIY76n_HEbbZAANJVWiVkjBsIy3b9jB), converted to fvecs",
"metric": "inner_product",
"normalization": "unit-normalized source values preserved exactly",
"dimension": 1536,
diff --git a/benchmarks/README.md b/benchmarks/README.md
index c4ec5a71..ff751b00 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -3,8 +3,8 @@
For embedding the quantizer in another system, use the independent
[core C++ benchmark](core/README.md). It needs only core and reports per-repeat timings,
distance-estimate error and quantized full-scan recall. The workflows below benchmark
-complete ANN/clustering systems. For dispatch, encoding, FastScan, sidecar loading and
-Python ID-conversion regression measurements, use the
+complete ANN/clustering systems. For dispatch, encoding, FastScan, sidecar loading, IVF add
+and Python ID-conversion regression measurements, use the
[component hot-path benchmarks](hotpaths/README.md).
See the [benchmark overview](../docs/docs/benchmarks/bench.md) for datasets,
diff --git a/benchmarks/core/README.md b/benchmarks/core/README.md
index 9f460873..1a3de18e 100644
--- a/benchmarks/core/README.md
+++ b/benchmarks/core/README.md
@@ -12,7 +12,7 @@ cmake -S . -B build-core-bench -DCMAKE_BUILD_TYPE=Release \
-DRABITQ_BUILD_SAMPLES=OFF -DRABITQ_USE_OPENMP=OFF \
-DRABITQ_BUILD_BENCHMARKS=ON -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF
cmake --build build-core-bench --parallel
-ctest --test-dir build-core-bench --output-on-failure
+ctest --no-tests=error --test-dir build-core-bench --output-on-failure
./build-core-bench/benchmarks/core/rabitq_core_benchmark > core-results.csv
```
@@ -22,7 +22,7 @@ For an installed package, configure this directory directly:
cmake -S benchmarks/core -B build-core-bench-installed \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=""
cmake --build build-core-bench-installed --parallel
-ctest --test-dir build-core-bench-installed --output-on-failure
+ctest --no-tests=error --test-dir build-core-bench-installed --output-on-failure
./build-core-bench-installed/rabitq_core_benchmark 128 4 4096 64 7 > core-results.csv
```
@@ -107,8 +107,8 @@ valid code/factor prototypes are encoded, then replicated at distinct memory add
This models access and call costs, not dataset-dependent ANN quality. Inputs are already
in the numerical working domain; no rotation or top-k retrieval is timed or evaluated.
The metric is L2 with a zero centroid. Prototype scores must match all three references
-exactly before timing. Raw timings and double-precision checksums are printed for every
-path and repetition; path order rotates and each timed pass has its own warmup.
+within a computed float32 rounding bound before timing. Raw timings and
+double-precision checksums are printed for every path and repetition; path order rotates and each timed pass has its own warmup.
Compare baseline/candidate binaries with identical harness source, compiler flags and
parameters. Alternate process order, retain all samples, and verify checksums alongside
diff --git a/benchmarks/hotpaths/README.md b/benchmarks/hotpaths/README.md
index 41d5c304..7b7a4bd4 100644
--- a/benchmarks/hotpaths/README.md
+++ b/benchmarks/hotpaths/README.md
@@ -11,6 +11,7 @@ Enable `RABITQ_BUILD_BENCHMARKS=ON` in a Release build. Targets follow enabled c
| `rabitq_core_hotpaths` | core only | One-bit compact encoding or raw L2 calls |
| `rabitq_fastscan_hotpaths` | FastScan | Cached standard/HACC batch scoring |
| `rabitq_load_hotpaths` | index | HNSW centroid-sidecar load and validation |
+| `rabitq_ivf_add_hotpaths` | index | One IVF `add` call on a freshly loaded index |
| `rabitq_ids_benchmark` module + `ids.py` | Python bindings | Production cluster-ID conversion helper |
No additional production dependencies are introduced. Core/FastScan benchmarks support
@@ -27,7 +28,7 @@ cmake -S . -B build-hotpaths -DCMAKE_BUILD_TYPE=Release \
-DRABITQ_BUILD_BENCHMARKS=ON -DRABITQ_BUILD_SAMPLES=OFF \
-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF
cmake --build build-hotpaths --parallel
-ctest --test-dir build-hotpaths -R 'Hotpath' --output-on-failure
+ctest --no-tests=error --test-dir build-hotpaths -R 'Hotpath' --output-on-failure
```
Set both `RABITQ_BUILD_INDEX=OFF` and `RABITQ_BUILD_FASTSCAN=OFF` for pure core.
@@ -50,6 +51,9 @@ Examples (from the repository root):
./build-hotpaths/benchmarks/hotpaths/rabitq_load_hotpaths prepare "$TMPDIR/centroids" 128 16384 1
./build-hotpaths/benchmarks/hotpaths/rabitq_load_hotpaths load "$TMPDIR/centroids" 128 16384 7
+# path dim clusters base_n add_n repetitions single|routed; path receives the base index
+./build-hotpaths/benchmarks/hotpaths/rabitq_ivf_add_hotpaths "$TMPDIR/ivf-add" 64 8 256 16 3 routed
+
python benchmarks/hotpaths/ids.py --module-dir build-hotpaths/python_bindings
```
@@ -61,7 +65,8 @@ the deterministic input. Original production indexes are not benchmark fixtures.
- IVF add verifies the expected point counts and samples new points' self-neighbors
outside the timed call, in addition to comparing output hashes across repetitions.
-- Inputs use an LCG with seed 42 (query/centroid seed 91 where applicable), metric L2.
+- Inputs use an LCG with seed 42 (query/centroid seed 91 where applicable; IVF add uses
+ seed 91 for added points and seed 7 for centroids), metric L2.
Dimensions for encoding/scoring are multiples of 32. Use 64, 128 and 960 dimensions;
encoding rows 32/4096 and threads 1/4 retain the cases from the original investigation.
- Core encoding times the existing serial template at one thread and OpenMP template
diff --git a/benchmarks/plot_results.py b/benchmarks/plot_results.py
index 3945ee26..5e4fdd56 100644
--- a/benchmarks/plot_results.py
+++ b/benchmarks/plot_results.py
@@ -12,6 +12,7 @@
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
+from matplotlib.ticker import FuncFormatter, LogLocator, NullLocator
if __package__:
from .build_indexes import CASES, DATASETS, validate_record
@@ -43,6 +44,156 @@ def observations(record):
)
+# Each panel compares methods with similar per-dimension storage. Colors follow
+# the index type within a figure (validated categorical slots; at most three per
+# figure); line style and marker separate methods that share a color.
+BLUE, ORANGE, AQUA = "#2a78d6", "#eb6834", "#1baf7a"
+INK, SECONDARY, MUTED = "#0b0b0b", "#52514e", "#898781"
+SURFACE, GRID, AXIS = "#fcfcfb", "#e1e0d9", "#c3c2b7"
+PANELS = {
+ "ivf": (
+ (
+ "Codes + float32 reranking",
+ ("rabitq-ivf-raw", "faiss-rabitqfs-raw", "faiss-pqfs", "faiss-opq-pqfs"),
+ ),
+ ("1 + 4 bits", ("rabitq-ivf-1p4", "faiss-rabitqfs-1p4")),
+ ("1 + 8 bits", ("rabitq-ivf-1p8", "faiss-rabitqfs-1p8")),
+ ),
+ "graphs": (
+ ("Float32 vectors", ("rabitq-symqg-raw", "faiss-hnsw-flat")),
+ ("4-5 bits", ("rabitq-symqg-4", "rabitq-hnsw-1p4", "faiss-hnsw-sq4")),
+ ("8-9 bits", ("rabitq-symqg-8", "rabitq-hnsw-1p8", "faiss-hnsw-sq8")),
+ ),
+}
+STYLES = {
+ "rabitq-ivf": (BLUE, "o", "-"),
+ "faiss-ivf-rabitq-fs": (ORANGE, "s", "--"),
+ "faiss-pqfs": (AQUA, "^", "--"),
+ "faiss-opq-pqfs": (AQUA, "D", ":"),
+ "rabitq-symqg": (BLUE, "o", "-"),
+ "rabitq-hnsw": (AQUA, "^", "-"),
+ "faiss-hnsw-flat": (ORANGE, "s", "--"),
+ "faiss-hnsw-sq4": (ORANGE, "s", "--"),
+ "faiss-hnsw-sq8": (ORANGE, "s", "--"),
+}
+for _family, _panels in PANELS.items():
+ if {name for _, names in _panels for name in names} != {
+ case.name for case in CASES if case.family == _family
+ }:
+ raise RuntimeError(f"plot panels do not cover the {_family} cases")
+
+
+def case_style(case):
+ return STYLES.get(case.name) or STYLES[case.method]
+
+
+def qps_label(value, _position):
+ return f"{value / 1000:g}K" if value >= 1000 else f"{value:g}"
+
+
+def plot_family(measurements, dataset, family, cpu):
+ """Draw one dataset's QPS-recall panels for an index family."""
+ cases = {case.name: case for case in CASES}
+ panels = PANELS[family]
+ series = {}
+ for _, names in panels:
+ for name in names:
+ points = measurements.get((dataset, name))
+ if not points:
+ continue
+ for _, recall, qps in points:
+ if not (0 <= recall <= 1 and math.isfinite(qps) and qps > 0):
+ raise ValueError(f"invalid measurement: {dataset}/{name}")
+ series[name] = [(recall, qps) for _, recall, qps in points]
+ # Start at 0.5 recall, or lower so every method's best recall sits at least
+ # 0.1 inside the axis; settings below the start are clipped.
+ lowest_best = min(max(recall for recall, _ in points) for points in series.values())
+ recall_min = min(0.5, math.floor((lowest_best - 0.1) * 10) / 10)
+
+ fig, axes = plt.subplots(1, len(panels), figsize=(9, 4.7), sharey=True)
+ fig.patch.set_facecolor(SURFACE)
+ fig.subplots_adjust(left=0.085, right=0.99, top=0.81, bottom=0.31, wspace=0.16)
+ title = {"gist1m": "GIST1M", "dbpedia": "DBpedia"}[dataset]
+ family_title = "IVF indexes" if family == "ivf" else "Graph indexes"
+ fig.text(
+ 0.012, 0.962, f"{title} · {family_title}", fontsize=14, weight="bold", color=INK
+ )
+ fig.text(
+ 0.012,
+ 0.91,
+ f"Recall@10 vs queries per second · 1 search thread, 1 query per call · CPU: {cpu}",
+ fontsize=9.5,
+ color=SECONDARY,
+ )
+ for ax, (panel_title, names) in zip(axes, panels):
+ ax.set_facecolor(SURFACE)
+ handles = []
+ for name in names:
+ if name not in series:
+ continue
+ case = cases[name]
+ color, marker, style = case_style(case)
+ points = series[name]
+ x, y = zip(*points)
+ ax.scatter(x, y, color=color, marker=marker, s=12, alpha=0.3, linewidths=0)
+ x, y = zip(*frontier(points))
+ ax.plot(
+ x,
+ y,
+ color=color,
+ marker=marker,
+ markersize=4.5,
+ markeredgecolor=SURFACE,
+ markeredgewidth=0.8,
+ linewidth=1.5,
+ linestyle=style,
+ solid_capstyle="round",
+ dash_capstyle="round",
+ )
+ handles.append(
+ Line2D(
+ [],
+ [],
+ color=color,
+ marker=marker,
+ linestyle=style,
+ label=case.label,
+ )
+ )
+ ax.set_title(panel_title, fontsize=10.5, color=INK, loc="left", pad=6)
+ ax.set_xlim(recall_min, 1.005)
+ ax.set_xticks(
+ [
+ round(recall_min + 0.1 * i, 1)
+ for i in range(round((1 - recall_min) * 10) + 1)
+ ]
+ )
+ ax.set_yscale("log")
+ ax.yaxis.set_major_locator(LogLocator(subs=(1, 2, 5)))
+ ax.yaxis.set_major_formatter(FuncFormatter(qps_label))
+ ax.yaxis.set_minor_locator(NullLocator())
+ ax.grid(True, which="major", color=GRID, linewidth=0.75)
+ ax.set_axisbelow(True)
+ ax.spines[["top", "right"]].set_visible(False)
+ ax.spines[["left", "bottom"]].set_color(AXIS)
+ ax.tick_params(colors=MUTED, labelcolor=SECONDARY, labelsize=9, length=0)
+ ax.set_xlabel("Recall@10", fontsize=9.5, color=SECONDARY)
+ # Below the panel, so the legend never covers measured points.
+ ax.legend(
+ handles=handles,
+ loc="upper left",
+ bbox_to_anchor=(0, -0.2),
+ borderaxespad=0,
+ frameon=False,
+ fontsize=8,
+ handlelength=2.2,
+ markerscale=0.8,
+ labelcolor=INK,
+ )
+ axes[0].set_ylabel("Queries / second (log)", fontsize=9.5, color=SECONDARY)
+ return fig
+
+
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, required=True)
@@ -61,6 +212,7 @@ def main():
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
measurements = {}
+ cpus = set()
csv_path = args.output_dir / "qps-recall-points.csv"
if csv_path.exists():
with csv_path.open(newline="") as previous:
@@ -95,97 +247,28 @@ def main():
!= initial["build"]["cpu_placement"]["allowed_cpus"]
):
raise ValueError(f"sweep does not match its index build: {dataset}/{name}")
+ cpus.add(initial["hardware"]["cpu"])
measurements[dataset, name] = list(observations(record))
- colors = list(plt.get_cmap("tab10").colors)
- markers = ("o", "s", "^", "D", "v", "P")
+ if len(cpus) != 1:
+ raise ValueError(f"selected records span CPUs {sorted(cpus)}")
+ (cpu,) = cpus
rows = []
- plt.rcParams.update({"font.size": 10, "svg.fonttype": "none"})
+ plt.rcParams.update(
+ {
+ "font.family": "sans-serif",
+ "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"],
+ "svg.fonttype": "none",
+ }
+ )
for dataset, family in product(args.datasets, ("ivf", "graphs")):
if not any(
case.name in args.methods and case.family == family for case in CASES
):
continue
- fig, ax = plt.subplots(figsize=(12, 9))
- fig.subplots_adjust(top=0.85, bottom=0.32)
- title = {"gist1m": "GIST1M", "dbpedia": "DBpedia"}[dataset]
- family_title = "IVF indexes" if family == "ivf" else "Graph indexes"
- fig.suptitle(
- f"{title} · {family_title} | Recall@10 vs QPS", fontsize=20, y=0.975
- )
- fig.text(
- 0.5,
- 0.92,
- "One Intel Xeon Gold 6418H CPU · one query per call",
- ha="center",
- color="#444444",
- )
- handles = []
- recalls = []
- for ordinal, case in enumerate(case for case in CASES if case.family == family):
- name = case.name
- if (dataset, name) not in measurements:
- continue
- points = []
- for setting, recall, qps in measurements[dataset, name]:
- if not (0 <= recall <= 1 and math.isfinite(qps) and qps > 0):
- raise ValueError(f"invalid measurement: {dataset}/{name}")
- points.append((recall, qps))
- recalls.extend(recall for recall, _ in points)
- color = colors[ordinal]
- marker = markers[ordinal % len(markers)]
- style = "-" if name.startswith("rabitq-") else "--"
- handles.append(
- Line2D(
- [],
- [],
- color=color,
- marker=marker,
- linestyle=style,
- label=case.label,
- )
- )
- if points:
- x, y = zip(*points)
- ax.scatter(x, y, color=color, marker=marker, s=22, alpha=0.45)
- x, y = zip(*frontier(points))
- ax.plot(
- x,
- y,
- color=color,
- marker=marker,
- markersize=4,
- linewidth=1.4,
- linestyle=style,
- )
- ax.set_title(
- "Query set · full sweep · 1 search thread · one timing pass", fontsize=11
- )
- ax.set_xlabel("Recall@10 (measured)")
- ax.set_ylabel("Queries / second (log scale)")
- recall_min = max(0, math.floor((min(recalls) - 0.005) * 20) / 20)
- ax.set_xlim(recall_min, 1.005)
- ax.set_yscale("log")
- ax.grid(True, which="major", alpha=0.2)
- ax.spines[["top", "right"]].set_visible(False)
- fig.legend(
- handles=handles,
- loc="lower center",
- bbox_to_anchor=(0.5, 0.09),
- ncol=2,
- frameon=False,
- fontsize=9,
- )
- fig.text(
- 0.5,
- 0.046,
- "Faint markers: all measured settings. Lines: per-method Pareto frontier; no fitted or extrapolated points.",
- ha="center",
- fontsize=10,
- color="#444444",
- )
+ fig = plot_family(measurements, dataset, family, cpu)
path = args.output_dir / f"{family}-{dataset}-qps-recall.svg"
- fig.savefig(path)
+ fig.savefig(path, facecolor=SURFACE)
path.write_text(
"\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n"
)
diff --git a/docs/development.md b/docs/development.md
index 8cc3b04b..5e049d76 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -140,6 +140,10 @@ ruff check --fix python_bindings sample/python tests/python tests/test_mypy.py t
ruff format python_bindings sample/python tests/python tests/test_mypy.py tests/test_pyright.py benchmarks
```
+`check-python.sh` does not cover the CI automation in `.github/scripts`; CI also
+runs `ruff format --check .github/scripts` and `ruff check .github/scripts`, so
+run both after changing those scripts.
+
### Shell scripts
Run ShellCheck after changing a contributor or automation script:
@@ -167,8 +171,10 @@ It recompiles the library sources from `compile_commands.json` with that build's
(extra arguments such as `-O1` are appended) and fails when copies of a weak function
differ between ISA flag sets. CI checks the Release (`-O3`) configuration; with GCC 14
the check also passes at `-O1` and `-O2 -g`, which is what the sanitizer and
-RelWithDebInfo configurations compile. Debug (`-O0`) still emits standard-library
-helpers (`std::min`, `std::round`, `std::fill`) per source and is not clean. The FastScan
+RelWithDebInfo configurations compile. Debug (`-O0`) is not clean: it emits
+standard-library helpers (`std::min`, `std::round`, `std::fill`) and small
+first-party inline functions (for example `SearchBuffer` and query accessors) per
+source. The FastScan
estimator correction is written as plain loops rather than Eigen expressions so that no
ISA source instantiates a shared Eigen helper; keep it that way, since a static library
only hides such sharing while its generic objects precede the ISA objects. The script
@@ -197,6 +203,7 @@ cmake -S . -B build-tidy -G Ninja \
-DRABITQ_BUILD_SAMPLES=OFF \
-DRABITQ_BUILD_TESTS=OFF \
-DRABITQ_BUILD_PYTHON_BINDINGS=ON \
+ -DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF \
-DCMAKE_BUILD_TYPE=Release \
-Dpybind11_DIR="$pybind11_DIR"
CXX=clang++-23 ./scripts/check-tidy.sh build-tidy
@@ -538,7 +545,9 @@ that corrupt or mismatched files are reported before allocation, without treatin
crafted file as a supported input.
Do not silently reinterpret an old file. Add a magic/version discriminator, use fixed-width
-serialized fields for new formats, validate sizes before allocation, check every read, and retain a
+serialized fields for new formats (the current IVF, HNSW and QG bodies still store several
+`size_t` fields; change those only with a format version bump), validate sizes before
+allocation, check every read, and retain a
compatibility test fixture or an explicit rejection path. Review each affected format;
changes to shared serialization helpers require auditing every format using them.
IVF-RaBitQ, HNSW-RaBitQ, and SymphonyQG have separate formats. SymphonyQG includes a versioned quantized format plus a legacy
@@ -703,7 +712,7 @@ public and protected names, Python keywords, field order and numerical operation
role is clear.
- Clustering's private state uses `dim_`, `num_points_`, `num_clusters_`,
`num_workers_` and `data_`. Keep public `d`, `k`, `num_threads` and graph parameter
- names compatible. Retain the existing private `niter_` graph iteration name.
+ names compatible. The private graph iteration count is `graph_build_iterations_`.
Per-point centroid IDs are `assignments` (arrays) and `cluster_id` (scalars);
`label` stays reserved for HNSW's caller-visible labels.
- FHT backend arguments use `rotated_data`, `padded_dim`, `trunc_dim` and
@@ -878,8 +887,10 @@ nothing in an old file is reinterpreted. A file that has removals is rejected by
through 0.5.1, whose `load` range-checks every cluster ID; that rejection is the compatibility path.
This mark is unrelated to the `SearchBuffer` ID marker. Keep these rules when touching HNSW-RaBitQ code:
-- Read cluster IDs only through `cluster_id_of`, which masks the bit. Estimators,
- routing, and the `add` reconstruction index centroids with it.
+- Read cluster IDs only through the masking accessors. Internal code (estimators,
+ routing, and the `add` reconstruction) uses the unchecked private `stored_cluster_id`;
+ the public `cluster_id_of` adds storage and bounds checks and must stay out of the
+ per-candidate search kernels.
- A removed point stays in the graph: base-layer search still inserts it into `search_pool`, and
`add` may link to it. Only the result buffer skips it, including when it is the entry point.
- `load` masks the bit before range-checking the cluster ID.
@@ -971,8 +982,10 @@ the compiler inlines them. `scripts/check-isa-symbols.sh` checks Release builds;
[ISA symbol check](#isa-symbol-check).
The AVX FHT intrinsics in `src/simd/fht_kernels.hpp` use the private kernel namespace
for the same reason. Rotation uses this implementation on GCC, Clang, and MSVC;
-no assembler is required. Keep the FFHT attribution and MIT license in the
-intrinsics header intact.
+no assembler is required. Keep the FFHT attribution comment in the intrinsics
+header intact. The intrinsics reimplement the transform following FFHT's stage
+schedule without copying its code, so the header does not carry FFHT's MIT notice;
+code copied from FFHT must bring that notice with it.
#### Eigen isolation
@@ -1073,7 +1086,8 @@ source groups/flags with all three dispatchers and `src/utils/cpu_features.cpp`.
### Performance and compatibility
-- Use fixed-width integer types for serialized values and persisted index data.
+- Use fixed-width integer types for new serialized values and persisted index data;
+ existing `size_t` fields change only with a format version bump.
- Preserve existing public headers, aliases, and index formats unless a change
is explicitly documented as breaking.
- Add backend-independent tests when introducing or changing SIMD kernels.
diff --git a/docs/docs/assets/img/SingleData.png b/docs/docs/assets/img/SingleData.png
deleted file mode 100644
index b5bf9565..00000000
Binary files a/docs/docs/assets/img/SingleData.png and /dev/null differ
diff --git a/docs/docs/benchmarks/bench.md b/docs/docs/benchmarks/bench.md
index 9fa02ef6..3c06dbac 100644
--- a/docs/docs/benchmarks/bench.md
+++ b/docs/docs/benchmarks/bench.md
@@ -3,6 +3,52 @@
Compare RaBitQ-Library, Faiss, and SuperKMeans on one Intel Xeon Gold 6418H:
[IVF indexes](ivf.md), [graph indexes](graphs.md), and [k-means](kmeans.md).
+## Key results
+
+{ .chart }
+
+On GIST1M, each bar is the fastest measured setting with Recall@10 >= 90%,
+without interpolation. IVF-RaBitQ/HNSW-RaBitQ use 5 total bits (one sign bit plus
+four extra bits) and SymphonyQG uses 4 bits. Actual recall and total storage
+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,038 | 0.60 | `nprobe=128` |
+| HNSW-RaBitQ · 5-bit | 92.70% | 1,866 | 0.72 | `ef=512` |
+| SymphonyQG · 4-bit | 90.36% | 3,086 | 4.39 | `ef=512` |
+
+These use the 0.7.1 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:
+`reproduce.py` does not regenerate them, so update both by hand after new runs.
+
+Full curves, baselines, construction costs and other precisions:
+[IVF indexes](ivf.md) · [graph indexes](graphs.md) · [k-means](kmeans.md).
+
+## Reading the figures
+
+Recall@10 is the mean fraction of exact top-10 neighbors returned. Each figure
+groups methods with similar storage per dimension into panels that share the QPS
+axis. Faint markers show all measured settings within the axis range; lines connect
+each method's Pareto frontier. QPS uses a logarithmic axis. The recall axis starts
+at 0.5, or lower so every method's best recall sits at least 0.1 inside the axis;
+settings below the start are clipped, and the CSV contains every measured point.
+
+## Reading the indexing tables
+
+| Column | Meaning |
+| --- | --- |
+| Train | Training or centroid clustering time |
+| Construct | Encoding and index construction after training |
+| Total | Construction including setup and training, excluding saving |
+| Save | Serialization time |
+| Index GiB | Serialized files, including sidecars and retained raw vectors |
+
+Times are seconds. Data preparation, ground-truth generation, and correctness
+checks are outside construction timing.
+
## Datasets and ground truth
| Dataset | Base vectors | Dimensions | ANN metric | Queries |
@@ -32,6 +78,32 @@ L2 ground truth for OpenAI.
| RaBitQ-Library | 0.7.1, measured 2026-10-23 (Asia/Singapore) |
| Baselines | Faiss 1.15.0, SuperKMeans 0.2.0; Intel MKL 2020.4, GNU OpenMP |
+## Construction and search settings
+
+IVF uses 4,096 clusters. HNSW uses `M=16` and `efConstruction=200`;
+SymphonyQG uses degree 32. Family pages list all method settings.
+
+| Family | Search grid |
+| --- | --- |
+| IVF | `nprobe=1,2,4,8,16,32,64,128,256,512` |
+| Faiss IVF with RFlat | Above × `k_factor=1,2,5,10,20` |
+| Graph | `ef=16,32,64,128,256,512,1024` |
+
+Each setting searches all 1,000 queries at `k=10`, after warm-up. QPS is queries
+divided by elapsed search time, including Python call overhead and excluding
+index loading. Each setting has one timing pass.
+
+## Scope and limitations
+
+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.7.1 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
+applied. The experiments in the cited papers are separate measurements.
+
## Package installation
The RaBitQ-Library rows and curves were refreshed on 2026-10-23 (Asia/Singapore)
@@ -80,62 +152,23 @@ source benchmarks/env.sh
The installer builds RaBitQ from the selected checkout, Faiss 1.15.0, and SuperKMeans 0.2.0
in the active environment. Both baselines link to MKL with GNU OpenMP. Build logs
and verification records are saved in `build/benchmark-baselines/`.
-Use `--dry-run` to preview installation commands. The installer defaults to the
-historical GCC 13 toolchain; it does not recreate the GCC 14.3.0 wheel used for
-the 0.7.1 measurements. Match the recorded compiler and native-optimization
-setting when comparing new library timings.
-
-## Construction and search settings
-
-IVF uses 4,096 clusters. HNSW uses `M=16` and `efConstruction=200`;
-SymphonyQG uses degree 32. Family pages list all method settings.
-
-| Family | Search grid |
-| --- | --- |
-| IVF | `nprobe=1,2,4,8,16,32,64,128,256,512` |
-| Faiss IVF with RFlat | Above × `k_factor=1,2,5,10,20` |
-| Graph | `ef=16,32,64,128,256,512,1024` |
-
-Each setting searches all 1,000 queries at `k=10`, after warm-up. QPS is queries
-divided by elapsed search time, including Python call overhead and excluding
-index loading. Each setting has one timing pass.
-
-## Reading the figures
-
-Recall@10 is the mean fraction of exact top-10 neighbors returned. Faint markers
-show all measured settings; lines connect each method's Pareto frontier. QPS uses
-a logarithmic axis. The CSV contains every measured point.
-
-## Reading the indexing tables
-
-| Column | Meaning |
-| --- | --- |
-| Train | Training or centroid clustering time |
-| Construct | Encoding and index construction after training |
-| Total | Construction including setup and training, excluding saving |
-| Save | Serialization time |
-| Index GiB | Serialized files, including sidecars and retained raw vectors |
-
-Times are seconds. Data preparation, ground-truth generation, and correctness
-checks are outside construction timing.
-
-## Scope and limitations
-
-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.7.1 raw records,
-logs and indexes are retained locally under
-`results/benchmarks-0.7.1-20261023/`. The source/build manifest, wheel hashes,
-validation logs and execution status are retained in the local release task records.
-Historical baseline raw logs and
-binaries are not included. No run-to-run or shared-host noise correction is
-applied. The experiments in the cited papers are separate measurements.
+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.7.1
+measurements. Match the recorded compiler and native-optimization setting when
+comparing new library timings.
## Data and reproduction
Run from the selected checkout's repository root after installation. Use the
-same checkout for installation and measurement:
+same checkout for installation and measurement. The runners target the recorded
+hardware: the download, build and query stages run under
+`numactl --cpunodebind=0 --membind=0`, ANN
+construction uses 48 threads on socket 0, and the runners stop with an error when
+the process spans more than one socket, the socket has fewer CPUs than threads,
+or k-means does not see 24 physical cores and 48 logical CPUs. Other hosts
+need script changes.
```bash
# Prepare data, run all benchmarks, and export results
@@ -172,7 +205,8 @@ workspace require additional space.
Existing results are skipped. Use a fresh `--output-dir` to repeat measurements;
this changes raw result and log paths, while documentation exports retain the
-paths above. Partial exports preserve unselected results.
+paths above. Partial exports preserve unselected results; figure subtitles name
+the CPU recorded by the selected records, and retained rows are not rechecked.
Use `--stage build`, `--stage query`, or `--stage export` for one stage. Querying
reuses saved indexes; export runs no experiments. `--dry-run` previews commands;
@@ -183,27 +217,3 @@ reuses saved indexes; export runs no experiments. `--dry-run` previews commands;
[Query CSV](figures/qps-recall-points.csv) · [Indexing CSV](figures/indexing.csv) ·
[K-means CSV](figures/kmeans.csv)
-
-## Selected GIST1M configurations
-
-The comparison uses IVF-RaBitQ/HNSW-RaBitQ at 5 total bits (one sign bit plus four extra bits)
-and SymphonyQG at 4 bits. For each GIST1M configuration, the QPS bar selects
-the fastest measured setting with Recall@10 >= 90%, without interpolation.
-Actual recall and total storage differ; this is neither an exact-recall nor an
-equal-memory comparison. The tables below identify the selected CSV rows for the
-[comparison figure](figures/homepage-gist1m.svg).
-
-### GIST1M
-
-| Configuration | Measured Recall@10 | Selected search setting |
-| --- | ---: | --- |
-| IVF-RaBitQ · 5-bit | 92.82% | `nprobe=128` |
-| HNSW-RaBitQ · 5-bit | 92.70% | `ef=512` |
-| SymphonyQG · 4-bit | 90.36% | `ef=512` |
-
-The figures use the 0.7.1 measurements described above, 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.
-See [IVF-RaBitQ results](ivf.md) and [graph results](graphs.md) for full curves,
-construction costs, and additional precision/storage configurations.
diff --git a/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg b/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg
index e205caba..5aa5eb37 100644
--- a/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg
+++ b/docs/docs/benchmarks/figures/graphs-dbpedia-qps-recall.svg
@@ -1,12 +1,12 @@
-