VSAG Documentation
VSAG is a high-performance, production-grade vector indexing library for similarity search. It powers vector retrieval in OceanBase and other projects at Ant Group, and is released under the Apache 2.0 license.
Features
- Multiple index types:
hgraph,ivf,pyramid,sindi,sindi_v2,brute_force, covering in-memory, sparse and multi-tenant scenarios. - Rich quantization: fp32 / fp16 / bf16 / int8 / sq8 / sq4 / pq, with SIMD dispatch on x86_64 and AArch64.
- Advanced capabilities: range search, filtered search, serialization, conjugate graph
enhancement, online
Tune-based optimization, custom allocator / thread pool. - Language bindings: native C++, Python via
pyvsag, Node.js / TypeScript via the npm packagevsag.
How to Read This Documentation
- User Guide — start here if you are new to VSAG: install, create an index, and run search.
- Indexes — compare supported index types and look up their parameters.
- Advanced Features — deep dives into specific search, serialization, memory, and hybrid-index capabilities.
- API Reference — the C++ class, method, and type reference for the public headers in
include/vsag/. - Performance and Tuning — best practices,
Tune, benchmarks, and evaluation tooling. - Developer Guide — building from source, running tests, and contributing.
- Resources — release notes, roadmap, community links, related projects, papers, and contributors.
The Chinese version of the same documentation is available under docs/docs/zh/.
Project Links
- Source: https://github.com/antgroup/vsag
- Issues: https://github.com/antgroup/vsag/issues
- Releases: https://github.com/antgroup/vsag/releases
Installation
VSAG can be installed as a C++ library, a Python package (pyvsag), or a Node.js/TypeScript
package (vsag).
Using Docker (Recommended for Development)
The official development image includes the full toolchain (GCC 9.4+, CMake 3.18+,
clang-format/clang-tidy 15, HDF5, etc.):
docker pull vsaglib/vsag:ubuntu
docker run -it --rm -v $(pwd):/work -w /work vsaglib/vsag:ubuntu bash
Building from Source
Requirements
- Operating System: Ubuntu 20.04+, CentOS 7+, or macOS 14+ on Apple Silicon
- Compiler: GCC 9.4.0+, Clang 13.0.0+, or Apple Clang from Xcode Command Line Tools
- CMake: 3.18.0+
- clang-format / clang-tidy: exactly version 15 (enforced by
make fmt/make lint)
Build
git clone https://github.com/antgroup/vsag.git
cd vsag
./scripts/deps/install_deps.sh
make release
The dependency script detects Linux versus macOS. On macOS it installs or locates the
Homebrew dependencies and uses Apple’s Accelerate framework where appropriate. The supported
macOS path is the arm64 C++ library build; published C++ archives and pyvsag wheels remain
Linux-focused.
Other common Makefile targets:
make debug— plain debug build (no sanitizers; tests/tools/examples disabled by default).make dev— developer configuration: debug + tests + tools + examples.make test— build with tests enabled and run the unit + functional suites.make cov— build with coverage instrumentation; run tests afterwards to generate the report.make asan/make tsan— sanitizer-enabled builds.make pyvsag PY_VERSION=3.10— build the Python wheel.make dist-pre-cxx11-abi/dist-cxx11-abi/dist-libcxx— build redistributable tarballs.
See Building for details.
Python (pyvsag)
pip install pyvsag
Node.js / TypeScript
npm install vsag
The bindings source lives under typescript/ and the npm package name is vsag.
Optional Features
Enable or disable at CMake configure time with these cache options:
ENABLE_INTEL_MKL=ON— Intel MKL acceleration.ENABLE_LIBAIO=ON— Linux AIO for theasync_iodata-cell backend.ENABLE_LIBURING=ON— Linuxio_uringbackend; requires liburing.ENABLE_TOOLS=ON— build tools undertools/(includingeval_performance).ENABLE_EXAMPLES=ON— build sample programs underexamples/cpp/.
If you build through the project Makefile, the corresponding environment variables are
VSAG_ENABLE_INTEL_MKL=ON, VSAG_ENABLE_LIBAIO=ON, VSAG_ENABLE_TOOLS=ON, and
VSAG_ENABLE_EXAMPLES=ON. ENABLE_LIBURING is currently a direct CMake option; configure it
with cmake -S . -B build-release -DENABLE_LIBURING=ON.
Creating an Index
All VSAG indexes are built through vsag::Factory::CreateIndex(name, build_params_json). The
name selects the implementation; build_params_json configures dimension, metric, and
index-specific options.
Supported Index Types
| Name | Description | Page | Example |
|---|---|---|---|
hgraph | Improved graph index with richer quantization options | HGraph | examples/cpp/103_index_hgraph.cpp |
ivf | Inverted file with quantization | IVF | examples/cpp/106_index_ivf.cpp |
sindi | Sparse-vector index (e.g. BM25, SPLADE) | SINDI | examples/cpp/109_index_sindi.cpp |
sindi_v2 | Sparse-vector index (e.g. BM25, SPLADE) supporting both in-memory and disk-based I/O | SINDI_V2 | See below |
pyramid | Multi-tenant / tag-partitioned graph index | Pyramid | examples/cpp/107_index_pyramid.cpp |
brute_force | Exact exhaustive search; useful as baseline | — | examples/cpp/105_index_brute_force.cpp |
Common Top-Level Fields
| Field | Values | Notes |
|---|---|---|
dim | positive integer | Fixed after build |
dtype | float32 / fp16 / bf16 / int8 | Public API currently uses float32 |
metric_type | l2 / ip / cosine | Must match at query time |
Examples
HGraph
HGraph uses index_param as the build-time sub-object (hgraph is reserved for search-time
parameters like ef_search). See examples/cpp/103_index_hgraph.cpp.
std::string params = R"(
{
"dim": 128,
"dtype": "float32",
"metric_type": "l2",
"index_param": {
"base_quantization_type": "fp32",
"max_degree": 32,
"ef_construction": 400
}
}
)";
auto index = vsag::Factory::CreateIndex("hgraph", params).value();
HGraph with SQ8 quantization
Switch base_quantization_type to sq8 to store base vectors as 8-bit scalar-quantized codes
(roughly a 4× reduction versus fp32) with minimal recall impact; other quantization types
(fp16, bf16, pq, …) are selected the same way.
std::string params = R"(
{
"dim": 768,
"dtype": "float32",
"metric_type": "ip",
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 32,
"ef_construction": 400
}
}
)";
auto index = vsag::Factory::CreateIndex("hgraph", params).value();
SINDI_V2 with disk-based I/O
std::string params = R"(
{
"dim": 1024,
"dtype": "sparse",
"metric_type": "ip",
"index_param": {
"term_id_limit": 30000,
"use_reorder": true,
"term_io": {
"type": "async_io",
"file_path": "/path/to/sindi_v2.terms"
},
"rerank_io": {
"type": "async_io",
"file_path": "/path/to/sindi_v2.rerank"
}
}
}
)";
auto index = vsag::Factory::CreateIndex("sindi_v2", params).value();
See Index Parameters for the full reference.
k-Nearest Neighbor Search
This page assumes VSAG is already installed. Examples are available in C++, Python, and TypeScript
under the examples/ directory. This page
uses the C++ BruteForce index for illustration; the full source is at
examples/cpp/105_index_brute_force.cpp.
In most cases, your program should call
vsag::init()once at startup to perform one-time initialization (global logger, allocator, etc.). The snippets below omit boilerplate to focus on the essential steps.
Prepare Vectors
VSAG operates on collections of fixed-dimensional vectors (typically a few hundred to a few
thousand dimensions). Vectors are laid out row-major, equivalent to vector[num_vectors][dim] in
C++. The API only requires a pointer (const float*) to the first element, so you can use a raw
array, std::vector<float>, or a custom buffer.
VSAG currently supports 32-bit float vectors for the public API. Other dtypes are available internally via the
dtypeoption.
A k-NN search needs two datasets:
- base: all vectors in the database; size =
num_vectors * dim. - query: the query vector(s) for which to find nearest neighbors; size =
num_queries * dim. Currently the publicKnnSearchAPI processes one query at a time.
int64_t num_vectors = 10000;
int64_t dim = 128;
int64_t* ids = new int64_t[num_vectors];
float* datas = new float[num_vectors * dim];
std::mt19937 rng(47);
std::uniform_real_distribution<float> distrib;
for (int64_t i = 0; i < num_vectors; ++i) ids[i] = i;
for (int64_t i = 0; i < dim * num_vectors; ++i) datas[i] = distrib(rng);
float* query_vector = new float[dim];
for (int64_t i = 0; i < dim; ++i) query_vector[i] = distrib(rng);
Create an Index and Insert Vectors
The Index interface is the central abstraction. Multiple implementations exist; brute_force is
the simplest (exhaustive comparison, used as a baseline).
All indexes must be created explicitly, specifying dimension and metric:
std::string build_params = R"(
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128
}
)";
auto index = vsag::Factory::CreateIndex("brute_force", build_params).value();
Build performs any required training; Add appends vectors. BruteForce supports both:
auto base = vsag::Dataset::Make();
base->NumElements(num_vectors)
->Dim(dim)
->Ids(ids)
->Float32Vectors(datas)
->Owner(false);
index->Add(base);
Search
KnnSearch takes the query, k, and a JSON search-params string. BruteForce has no tunable
search params, so an empty object is passed.
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(dim)->Float32Vectors(query_vector)->Owner(false);
int64_t topk = 10;
auto result = index->KnnSearch(query, topk, R"({})").value();
for (int64_t i = 0; i < result->GetDim(); ++i) {
std::cout << result->GetIds()[i] << ": " << result->GetDistances()[i] << std::endl;
}
The result contains up to k neighbors sorted by ascending distance to the query.
Threshold Filtering
Threshold filtering is supported by the maintained indexes: BruteForce, HGraph, IVF, Pyramid, SINDI, and SIMQ. HNSW and DiskANN are removed and unsupported; this option does not add or change behavior for either legacy index.
KNN search accepts an optional top-level threshold in the JSON search parameters:
{
"threshold": 4.0,
"hgraph": { "ef_search": 64 }
}
When present, the result contains at most k neighbors and every returned distance is less than
or equal to threshold. The boundary is inclusive, results remain sorted by ascending distance,
and fewer than k results—or no results—may be returned. When omitted, KNN behavior is unchanged.
The value uses the index metric’s returned distance: squared L2 for l2, 1 - inner_product for
ip, and 1 - cosine_similarity for cosine. Therefore negative thresholds can be meaningful
for ip; the threshold must be a finite JSON number.
The same option is available through SearchRequest::threshold_ on BruteForce, HGraph, and IVF;
Pyramid, SINDI, and SIMQ support the option through their KNN JSON parameters. These are the six
maintained indexes supported by this feature. It only limits KNN results; use RangeSearch and
its radius argument for range-search requests. Existing index-specific search parameters can be
supplied alongside threshold.
Search Statistics
VSAG can return query-time statistics together with a search result. Statistics are useful for observability, debugging, and comparing search configurations.
Reading statistics
Dataset provides two views of the same result statistics:
GetSearchMetrics()returns a typed snapshot. New C++ code should prefer this API.GetStatistics()returns the existing JSON representation for backward compatibility.GetStatistics(keys)returns selected JSON values. A missing key is returned as an empty string.
vsag::SearchRequest request;
request.query_ = query;
request.topk_ = 10;
request.params_str_ = R"({})"; // Statistics are enabled by default.
auto result = index->SearchWithRequest(request);
if (!result.has_value()) {
// Handle result.error().
}
auto metrics = result.value()->GetSearchMetrics();
if (metrics.has_value()) {
std::cout << metrics->distance_evaluations << std::endl;
std::cout << metrics->DistanceEvaluations(
vsag::DistanceEvaluationPhase::APPROXIMATE)
<< std::endl;
}
// The compatible JSON view is still available.
std::cout << result.value()->GetStatistics() << std::endl;
The typed snapshot is the authoritative view when it is available. JSON is derived lazily from the snapshot and cached by the result dataset.
Enabling and disabling result statistics
Statistics are enabled by default. To disable them for one SearchWithRequest call, add the reserved
framework key to the top level of params_str_:
request.params_str_ = R"({"want_statistics":false})";
When want_statistics is false:
GetSearchMetrics()returnsstd::nullopt;GetStatistics()returns"{}";- search results and distances are unchanged.
Disabling statistics is not a guarantee of lower latency: parsing/removing the explicit option and normalizing the request have a fixed cost, which can outweigh saved collection work for very small searches. Prefer typed reads over JSON when possible, and benchmark your workload.
BruteForce suppresses statistics collection when this parameter is false. Other indexes may still perform internal accounting, but they do not expose statistics in the result.
The legacy KnnSearch and RangeSearch overloads do not provide the per-request
want_statistics suppression flag and preserve their existing JSON-statistics behavior. Indexes that
support typed metrics may also attach a typed snapshot on these legacy paths; use
SearchWithRequest when per-request suppression is required.
Typed-statistics support
Typed statistics are introduced incrementally. The current support matrix is:
| Index | SearchWithRequest typed snapshot | "want_statistics": false result behavior | Collection suppression |
|---|---|---|---|
| BruteForce | Yes | nullopt and "{}" | Yes |
| Other indexes | Not yet | nullopt and "{}" | Not guaranteed |
Indexes without typed support continue to expose their existing JSON statistics when statistics are enabled.
Baseline fields
The baseline schema is owned by the search-statistics framework. Index-specific extensions must not overwrite these keys.
| Field | C++ type | Meaning |
|---|---|---|
is_timeout | bool | Whether the search timed out |
dist_cmp | uint32_t | Legacy distance-comparison count |
hops | uint32_t | Legacy graph-hop count |
io_cnt | uint32_t | Number of search IO operations |
io_time_ms | uint32_t | Search IO time in milliseconds |
reorder_distance_count | uint32_t | Exact distances evaluated during reorder |
reorder_candidate_count | uint32_t | Candidates considered during reorder |
reorder_lower_bound_probe_count | uint32_t | Lower-bound probes used by reorder |
rabitq_filter_count | uint32_t | RaBitQ filter-distance count |
rabitq_full_count | uint32_t | RaBitQ full-distance count |
rabitq_filter_fallback_full_count | uint32_t | Filter fallbacks to full distance |
rabitq_reorder_hint_full_count | uint32_t | Full distances requested by reorder hints |
rabitq_reorder_fallback_full_count | uint32_t | Reorder fallbacks to full distance |
query_computer_count | uint32_t | Query-computer instances created |
parallel_search_fallback_count | uint32_t | Parallel searches that fell back to serial execution |
distance_evaluations | uint64_t | Total recorded distance evaluations |
distance_evaluations_by_phase | array<uint64_t, 3> | Counts for routing, approximate search, and rerank |
distance_evaluations_by_backend | array<uint64_t, 16> | Counts grouped by distance backend |
complete | bool | Whether the framework considers accounting complete |
A value of zero is different from missing statistics. A collected empty search has a typed snapshot whose counters are zero. A search with statistics disabled has no typed snapshot.
Interpreting counters
APPROXIMATElabels the main candidate-search phase, not distance accuracy. BruteForce’s exact FP32 scan is recorded in this phase; routing and rerank remain zero for this example.distance_evaluationscounts recorded distance evaluations, not distinct candidates or returned neighbors. Evaluating a candidate again can add another evaluation. Phase and backend arrays partition the recorded evaluations along different axes; do not add the two arrays together.dist_cmppreserves the legacy index-specific comparison counter. It can differ from the new total and must not be treated as its alias across indexes or search paths.completedescribes diagnostic distance accounting, not recall, search success, or timeout. The collector marks it false on an unknown backend or saturated distance accounting. A true value is not proof that every possible execution path is instrumented; consult the support matrix and inspectis_timeoutindependently.- The collector’s 64-bit distance counters saturate at
UINT64_MAXrather than wrapping, and mark accounting incomplete when an increment cannot be fully represented. Legacy 32-bit counters keep their existing update semantics; they do not gain a general saturation guarantee. Using an unsigned 64-bit JSON setter preserves their stored values but does not widen collection or recover overflow.
For a runnable example with runtime contract checks, see
330_feature_search_statistics.cpp.
Index-specific extensions
An index may add typed extension values using one of these types:
uint32_tuint64_tbooldoublestd::string
Extension availability depends on the index and search path. Applications read the const map returned
by SearchResultMetrics::Extensions() and must check whether a key exists. Baseline names are
reserved; only the internal collector can add extensions, and it rejects a colliding name.
Compatibility
- Existing
GetStatistics()andGetStatistics(keys)APIs remain available. - The default is to return statistics, preserving existing search behavior.
- A dataset without collected statistics continues to return
"{}"fromGetStatistics(). - Calling the legacy
Statistics(string)setter clears any typed snapshot and makes the supplied JSON string authoritative. GetSearchMetrics()is a nonvirtual bridge and does not add a slot to the existingDatasetvtable.- Per-request control uses the existing
params_str_member, soSearchRequestkeeps its previous object layout for binary compatibility.
Maintenance contract
Changes to a search implementation must preserve these rules:
- A typed snapshot is detached result data; live atomic counters must not escape the query. Snapshot creation happens after search workers complete. Counters are diagnostic values, not a transactional cross-counter view for concurrent writers.
- JSON must be derived from typed metrics when a typed snapshot is attached.
- Index extensions must not overwrite baseline fields.
- Empty-result and early-return paths must honor the reserved
want_statisticsparameter. - A disabled result must be represented by
std::nulloptand"{}", not by a collected all-zero snapshot. - Adding a baseline field, phase, backend, or extension type requires serializer, documentation, unit-test, and functional-test updates in the same change.
- Adding typed support to an index requires updating the support matrix and running the public
behavior contract in
tests/test_search_statistics.cpp.
Run the focused tests with:
./build-release/tests/unittests "[search_metrics]"
./build-release/tests/functests "[search_statistics]"
C API
VSAG exports a C ABI in include/vsag/vsag_c_api.h. It wraps the same index implementations as
the C++ API and is useful for language bindings and applications that need opaque handles and
C-style functions.
The current installed header is not directly includable from a C translation unit: it contains an
unguarded extern "C" block and uses C++ bool. Compile the translation unit that includes this
header as C++ (for example, use a .cpp file). If the rest of the application is written in C,
expose the required operations through a small application-owned C wrapper.
Lifecycle
Include the installed header from a C++ translation unit and link the normal VSAG library:
#include <vsag/vsag_c_api.h>
The basic lifecycle is:
- Create an opaque
vsag_index_twithvsag_index_factory. - Build or train/add vectors.
- Search, update, serialize, or query the index.
- Release the handle with
vsag_index_destroy.
vsag_index_factory returns nullptr when creation fails. Other functions return Error_t;
code == VSAG_SUCCESS indicates success and message contains error details otherwise.
const char* build_params =
"{\"dtype\":\"float32\",\"metric_type\":\"l2\",\"dim\":128,"
"\"index_param\":{\"base_quantization_type\":\"fp32\","
"\"max_degree\":32,\"ef_construction\":200}}";
vsag_index_t index = vsag_index_factory("hgraph", build_params);
if (index == nullptr) {
/* handle creation failure */
}
Error_t error = vsag_index_build(index, base, ids, 128, count);
if (error.code != VSAG_SUCCESS) {
/* inspect error.message */
}
vsag_index_destroy(index);
Search result buffers
The caller owns the ids and dists buffers in SearchResult_t. Allocate at least k entries
for both buffers before KNN or range search. VSAG writes no more than k results and stores the
actual number in count.
int64_t result_ids[10];
float result_dists[10];
SearchResult_t result{};
result.dists = result_dists;
result.ids = result_ids;
Error_t error =
vsag_index_knn_search(index, query, 128, 10, "{\"hgraph\":{\"ef_search\":100}}", &result);
The filter variants accept a FilterFunc_t. Returning true means the ID is valid and may be
returned.
Range search result cap
Both C range-search functions require an explicit positive result cap:
Error_t
vsag_index_range_search(vsag_index_t index,
const float* query,
uint64_t dim,
float radius,
int64_t k,
const char* parameters,
SearchResult_t* result);
k must be at least 1. It is forwarded to the C++ range-search limited_size argument and
also protects the caller-owned buffers: result.count never exceeds k. Applications upgrading
from the older signature must add this argument to both vsag_index_range_search and
vsag_index_range_search_with_filter.
API groups
- Create and destroy:
vsag_index_factory,vsag_index_destroy. - Populate:
vsag_index_build,vsag_index_train,vsag_index_add. - Search:
vsag_index_knn_search,vsag_index_range_search, and their filter variants. - Read and update:
vsag_index_calculate_distance_by_ids,vsag_index_get_vector_by_ids,vsag_index_update_ids,vsag_index_update_vector, andvsag_index_update_vector_force. - Copy and reuse:
vsag_index_clone,vsag_index_export_model. - Persistence:
vsag_serialize_file,vsag_deserialize_file,vsag_serialize_write_func, andvsag_deserialize_read_func.
Support still depends on the selected index. An unsupported operation returns
VSAG_UNSUPPORTED_INDEX_OPERATION.
Persistence errors
vsag_serialize_file and vsag_deserialize_file validate that the target file can be opened.
Check the returned Error_t for VSAG_READ_ERROR, VSAG_MISSING_FILE, or another non-success
code instead of assuming a file operation completed.
The callback-based persistence functions are available when the application manages its own
storage. Their callback offsets and sizes use the OffsetType and SizeType aliases declared in
the header.
Reference
include/vsag/vsag_c_api.h is the complete source-level reference for signatures, error codes,
callbacks, and structures. Search parameters use the same JSON objects documented on the
corresponding index pages.
pyvsag
pyvsag is the official Python binding for VSAG, implemented with pybind11. Sources live under
python_bindings/ and python/.
Installation
pip install pyvsag
To build from source:
make pyvsag PY_VERSION=3.10
# Build wheels for multiple Python versions:
make pyvsag-all
Quick Start
pyvsag.Index(name, parameters) accepts the index name and a JSON-encoded parameter string,
matching the C++ vsag::Factory::CreateIndex signature:
import json
import numpy as np
import pyvsag
dim = 128
num_elements = 10_000
data = np.random.random((num_elements, dim)).astype(np.float32)
ids = np.arange(num_elements, dtype=np.int64)
index_params = json.dumps({
"dtype": "float32",
"metric_type": "l2",
"dim": dim,
"index_param": {
"base_quantization_type": "fp32",
"max_degree": 32,
"ef_construction": 300,
},
})
index = pyvsag.Index("hgraph", index_params)
index.build(vectors=data, ids=ids, num_elements=num_elements, dim=dim)
query = np.random.random(dim).astype(np.float32)
search_params = json.dumps({"hgraph": {"ef_search": 60}})
result_ids, result_dists = index.knn_search(
vector=query, k=10, parameters=search_params,
)
print(result_ids, result_dists)
Saving & Loading
index.save("index.bin")
new_index = pyvsag.Index("hgraph", index_params)
new_index.load("index.bin")
The current save and load wrappers do not propagate errors returned by the underlying
serialization and deserialization operations. Verify that the output file was created and
round-trip important indexes before relying on persisted data.
Index Operations
The Python binding exposes the following index-management methods in addition to build, search, save, and load:
| Method | Result | Notes |
|---|---|---|
get_num_elements() | int | Current number of elements. |
get_memory_usage() | int | Index memory usage in bytes. |
check_id_exist(id) | bool | Whether an ID currently exists. |
get_min_max_id() | (min_id, max_id) | Returns (-1, -1) if the operation fails. |
add(vectors, ids, num_elements, dim) | None | Adds a contiguous dense vector matrix; dtype must match the index. |
remove(ids) | int | Removes a one-dimensional int64 ID array. Returns 0 both when no ID is removed and when the underlying operation fails. |
calc_distances_by_id(query, ids) / cal_distance_by_id(query, ids) (legacy alias) | numpy.ndarray | Returns one float32 distance per ID in a 1D array. Missing IDs produce -1; underlying invalid/unsupported operations raise RuntimeError with the C++ error message. |
# Add two dense vectors.
new_vectors = np.random.random((2, dim)).astype(np.float32)
new_ids = np.array([10_001, 10_002], dtype=np.int64)
index.add(new_vectors, new_ids, len(new_ids), dim)
assert index.check_id_exist(10_001)
print(index.get_num_elements(), index.get_memory_usage())
print(index.get_min_max_id())
# Calculate distances from one query to a candidate list.
candidate_ids = np.array([10_001, 10_002], dtype=np.int64)
distances = index.cal_distance_by_id(query, candidate_ids)
# Remove IDs; the return value is the number actually removed.
removed = index.remove(candidate_ids)
add accepts a flat contiguous array or a row-major two-dimensional matrix. For
dtype: "float16", pass numpy.float16; for dtype: "bfloat16", pass a numpy.uint16
array containing BF16 bit patterns. Operation support remains index-dependent. Input validation
and operations such as add and calc_distances_by_id raise Python exceptions, but remove, save,
and load use the sentinel or unchecked behaviors described above instead of consistently
propagating underlying operation failures.
Distance binding input safety
calc_distances_by_id and its legacy alias accept one 1D dense query whose length must equal the index dimension, plus a 1D ID array. Short, long, and empty queries are rejected by the C++ Dataset validation, not treated as missing IDs. NumPy inputs are converted to contiguous float32 / int64 arrays as needed, so positive- and negative-stride query and ID views retain their logical order. This does not add sparse, multi-vector, or multi-query support to the binding.
The Node.js equivalents calcDistancesById(query, ids) and legacy calDistanceById(query, ids) retain their 1D Float32Array result. They require exactly Float32Array queries and BigInt64Array IDs (other array kinds throw TypeError), validate the actual query length through the C++ Dataset API, and throw Error with the C++ message on operation failure. Missing IDs still produce -1; they are not operation failures.
Relationship with the C++ Library
pyvsag wraps the same vsag::Index API as C++ and shares the underlying index binaries. You can
build an index in Python and load it in C++ (and vice versa) as long as parameters match.
More Examples
See examples/python/ in the repository.
Indexes
VSAG ships a family of index implementations that share a single builder-style API,
one serialization format, and one set of operations (Build, Add, KnnSearch,
RangeSearch, Remove, Serialize / Deserialize, …). They differ in the data
structures and trade-offs they use under the hood.
The pages in this section cover the actively developed indexes:
| Index | Page | Best for |
|---|---|---|
hgraph | HGraph | General-purpose, high-recall graph with rich quantization options |
lazy_hgraph | LazyHGraph | Small-to-growing FP32 collections that start exact and later convert to HGraph |
ivf | IVF | Partition-based search, high-throughput batch queries, large corpora |
sindi | SINDI | Sparse vectors (BM25 / learned sparse) on inner-product |
simq | SIMQ | ColBERT-style multi-vector retrieval (MaxSim) |
sindi_v2 | SINDI_V2 | Sparse vectors (BM25 / learned sparse) on inner-product; supports both in-memory and disk-based I/O |
pyramid | Pyramid | Multi-tenant or tag-partitioned corpora with hierarchical paths |
brute_force is also available as an exact-search baseline (see
Creating an Index and examples/cpp/105_index_brute_force.cpp).
Parameter conventions
All indexes share the same top-level build fields:
| Field | Values | Notes |
|---|---|---|
dim | positive integer | Vector dimensionality; fixed after build |
dtype | float32 / float16 / bfloat16 / int8 / sparse | sparse is for SINDI / SINDI_V2 |
metric_type | l2 / ip / cosine | Must match at query time (SINDI / SINDI_V2 are ip only) |
Index-specific build parameters live under the index_param sub-object; search-time
parameters live under a sub-object named after the index (e.g. hgraph, ivf,
sindi, sindi_v2, pyramid). LazyHGraph also uses the hgraph search object after it
converts to graph phase. Concrete schemas are documented on each page and enumerated in
Index Parameters.
Index Parameters
This page summarises the commonly used parameters for every VSAG index type. For the full enumeration, consult the source:
- Build parameter keys:
src/constants.cpp - Public constants:
include/vsag/constants.h - Per-index examples: the
examples/cpp/*_index_*.cppfiles (e.g.103_index_hgraph.cpp).
Common Fields
Every index accepts these top-level fields at build time. dtype and metric_type are required;
repr is optional. dim is required for non-sparse data and defaults to 4096 for
dtype: "sparse" when omitted:
| Field | Values | Description |
|---|---|---|
dim | positive integer | Vector dimensionality; cannot change after build |
dtype | float32 / fp16 / bf16 / int8 / sparse | Scalar value type. sparse is retained for sparse-index compatibility. |
repr | dense / sparse / multi_vector | Optional data layout. When omitted, VSAG infers sparse from dtype: "sparse" and otherwise uses dense. |
metric_type | l2 / ip / cosine | Distance metric |
dtype and repr describe different properties: dtype is the scalar encoding, while repr
is the record layout. dtype: "sparse" requires repr: "sparse" when repr is explicit.
Use repr: "multi_vector" with a supported multi-vector index and a scalar dtype such as
float32.
HGraph
HGraph places its build parameters under the generic index_param key (see
examples/cpp/103_index_hgraph.cpp); the hgraph key is reserved for search-time parameters.
{
"dim": 128,
"dtype": "float32",
"metric_type": "l2",
"index_param": {
"base_quantization_type": "fp32",
"max_degree": 32,
"ef_construction": 400
}
}
| Field | Typical | Description |
|---|---|---|
max_degree | 16–48 | Maximum out-degree per node |
ef_construction | 200–500 | Candidate set size during build; larger = higher recall, slower build |
alpha | 1.0 | Final robust-pruning factor; PiPNN requires a finite value at least 1.0 |
base_quantization_type | fp32 / fp16 / bf16 / sq8 / sq8_local / sq4_local / sq4 / pq | Quantization of the base storage — see the Quantization chapter for all supported values |
use_reverse_edges | false | Track incoming neighbors for O(1) reverse-edge lookup; roughly doubles edge storage and is unsupported with compressed graph storage |
label_remap_type | pg | Label-map implementation: pg (default) or robin |
reorder_source | precise | Reorder from the precise store or directly from base; RaBitQ x+y split, including tq_chain="mrle, rabitq", selects base automatically |
persist_source_id | false | Include HGraph source-ID metadata in serialization; useful when a restored index must later export a build cache |
use_conjugate_graph | false | Enable HGraph feedback/pretraining and persist the auxiliary conjugate graph |
mrle_dim | 0 | MRLE output dimension in [0, dim]; 0 means input dimension |
fast_encode_rabitq | true | Use fast multi-bit RaBitQ encoding; false restores the exact encoder |
fast_encode_rabitq_rounds | 6 | Fast-encoder refinement rounds in [1, 32] |
For HGraph float32 L2/IP/cosine, sq8_local and sq4_local can select either base_quantization_type or precise_quantization_type (the latter requires use_reorder: true). See local SQ4/SQ8 for memory, persistence and limitations.
PiPNN build parameters
When graph_type is pipnn, HGraph and Pyramid also accept the following build parameters under
index_param. They share these defaults and validation rules:
| Field | Default | Description |
|---|---|---|
pipnn_max_leaf_size | 1024 | Maximum partition leaf size, in [2, 2147483647]. |
pipnn_min_leaf_size | 64 | Target for merging undersized leaves; must be positive and no larger than pipnn_max_leaf_size. |
pipnn_leader_sample_rate | 0.005 | Fraction sampled as leaders, in (0, 1]; each partition uses 2 to 1000, bounded by its point count. |
pipnn_fanout | [10, 2] | Positive nearest-leader fanout at successive partition levels; deeper levels use 1. |
pipnn_leaf_neighbor_count | 5 | Positive nearest-candidate count per point and leaf; undersized leaves use at least 4. |
pipnn_hash_plane_count | 12 | Direction-hash bits in [1, 15]; max_degree <= 2^pipnn_hash_plane_count. |
pipnn_reservoir_size | 64 | Positive per-point candidate capacity before final pruning; effective capacity is max(value, max_degree) and must not exceed 65535. |
The existing alpha field controls PiPNN’s final robust pruning. ef_construction only tunes NSW
and has no effect on PiPNN.
At search time:
{"hgraph": {"ef_search": 100}}
ef_search accepts any positive signed 64-bit integer. It is no longer capped relative to
topk; very large values can substantially increase latency and memory used by the frontier.
use_conjugate_graph_search is a boolean (default true) that uses learned conjugate edges when
the index was built with use_conjugate_graph: true.
The hgraph search-param object also accepts brute_force_threshold (a float
in [0.0, 1.0], default 0.0). When set above zero and the request carries a
filter whose ValidRatio() is at most this threshold, HGraph skips the graph
traversal and runs an exact scan over the surviving ids. See the
HGraph index page
for details.
LazyHGraph
LazyHGraph can take its build parameters in a top-level lazy_hgraph object
(preferred for clarity) or in the generic index_param object. The hgraph
sub-object is forwarded to the internal HGraph used after transition.
{
"dim": 128,
"dtype": "float32",
"metric_type": "l2",
"lazy_hgraph": {
"transition_threshold": 1000,
"hgraph": {
"base_quantization_type": "sq8",
"max_degree": 26,
"ef_construction": 100
}
}
}
| Field | Typical | Description |
|---|---|---|
transition_threshold | 1000 or workload-specific | Positive vector count at which the index converts from exact flat search to HGraph |
hgraph | HGraph build object | Parameters for the graph phase; see HGraph |
LazyHGraph only supports dtype: "float32". Search parameters use the hgraph
object, for example {"hgraph": {"ef_search": 100}}. See the
LazyHGraph index page for details.
The hgraph search-param object also accepts the following filter-related parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
skip_ratio | float | 0.2 | Controls the ratio of filtered-search candidate checks to skip, in range [0.0, 1.0]. Higher values mean more aggressive skipping, faster search, and potentially lower recall. |
skip_strategy | string | "deterministic_accumulative" | Skip strategy. Supports "random" and "deterministic_accumulative". |
IVF
{
"ivf": {
"nlist": 4096,
"base_quantization_type": "sq8",
"nprobe": 32
}
}
Brute Force
{"brute_force": {}}
No extra parameters.
Pyramid
Pyramid build parameters also live under index_param:
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 24,
"ef_construction": 300,
"store_paths": true
}
}
For graph_type: "pipnn", see the shared
PiPNN build parameters above.
store_paths is a top-level Pyramid build parameter and defaults to false. Enable it when
GetDataByIdsWithFlag must return the original default or named-hierarchy paths with
DATA_FLAG_PATH; see the
Pyramid parameter table for its completeness and
persistence semantics.
MRLE with split RaBitQ uses base_quantization_type: "tq",
tq_chain: "mrle, rabitq", mrle_dim, and the
rabitq_bits_per_dim_base/rabitq_bits_per_dim_precise pair. It automatically reorders from the
split base codes and retains original FP32 vectors for decode-only operations. See the
Pyramid page for a complete configuration and
storage/recall tradeoffs.
SINDI (sparse vectors)
{
"dtype": "sparse",
"metric_type": "ip",
"dim": 1024,
"index_param": {
"term_id_limit": 30000,
"doc_prune_ratio": 0.1
}
}
See the SINDI page for use_quantization, immutable builds, and search
parameters such as n_candidate.
SINDI_V2 (sparse vectors)
SINDI_V2 supports all SINDI features with both in-memory and disk-based I/O.
{
"dtype": "sparse",
"metric_type": "ip",
"dim": 1024,
"index_param": {
"term_id_limit": 30000,
"use_reorder": true,
"term_io": {
"type": "async_io",
"file_path": "/path/to/sindi_v2.terms"
},
"rerank_io": {
"type": "async_io",
"file_path": "/path/to/sindi_v2.rerank"
}
}
}
See the SINDI_V2 page for build parameters and search parameters such as
n_candidate and filter_callback_limit.
Runtime Parameters
Beyond build-time parameters, Index::Tune and SearchParam tweak runtime settings such as
ef_search and nprobe. See Optimizer and the
examples/cpp/3xx_feature_*.cpp examples.
HGraph
HGraph is VSAG’s flagship graph-based index. It builds a hierarchical proximity graph
and offers a rich set of quantization options, a unified
build-parameter schema (index_param), and first-class support for reordering,
incremental updates, deletion, and ELP-based runtime tuning.
For most dense-vector workloads (text / image / multimodal embeddings, 64–4096 dims, from a few thousand up to hundreds of millions of points), HGraph is the recommended default.
- Source:
src/algorithm/hgraph.{h,cpp} - Example:
examples/cpp/103_index_hgraph.cpp
How it works
- Graph construction. Vectors are organised in a layered proximity graph; upper
layers act as navigation aids, the bottom layer connects every data point to its
nearest neighbours within a
max_degreebudget. The construction algorithm can be NSW-style insertion (graph_type: "nsw", the default), ODescent (graph_type: "odescent"), or PiPNN (graph_type: "pipnn"). - Quantization. The base storage is compressed with a configurable quantizer
(
base_quantization_type—fp32,fp16,bf16,sq8,sq8_local,sq4_local,sq4,sq8_uniform,sq4_uniform,pq,pqfs,rabitq,tq). Optionally, a second high-precision copy is kept (use_reorder: truewithprecise_quantization_type) and used to re-rank the candidates returned by the coarse search. - Search. Greedy beam search traverses the graph top-down, expanding the current
frontier up to
ef_searchcandidates. When reordering is enabled, the final list is re-scored against the precise representation.
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 32,
"ef_construction": 400
}
})";
auto index = vsag::Factory::CreateIndex("hgraph", params).value();
// Build.
auto base = vsag::Dataset::Make();
base->NumElements(n)->Dim(128)->Ids(ids)->Float32Vectors(data)->Owner(false);
index->Build(base);
// Search.
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(128)->Float32Vectors(q)->Owner(false);
auto result = index->KnnSearch(
query, /*topk=*/10, R"({"hgraph": {"ef_search": 100}})").value();
Build parameters
Build-time parameters live under index_param. The table below highlights the keys
most users need; the exhaustive list is in Index Parameters.
| Parameter | Type | Default | Description |
|---|---|---|---|
base_quantization_type | string | — (required) | fp32, fp16, bf16, sq8, sq8_local, sq4_local, sq4, sq8_uniform, sq4_uniform, pq, pqfs, rabitq, tq — see the Quantization chapter for per-quantizer details |
max_degree | int | 64 | Maximum out-degree per graph node |
ef_construction | int | 400 | Candidate list size during build (higher = better recall, slower build) |
alpha | float | 1.0 | Final robust-pruning factor; PiPNN requires a finite value at least 1.0. |
graph_type | string | "nsw" | Graph algorithm: nsw, odescent, or pipnn |
pipnn_max_leaf_size | int | 1024 | Maximum partition leaf size. Larger leaves increase candidate coverage and leaf distance work. |
pipnn_min_leaf_size | int | 64 | Target used when merging undersized leaves. |
pipnn_leader_sample_rate | float | 0.005 | Fraction sampled as partition leaders; each partition uses at least 2 and at most 1000 leaders, bounded by its point count. |
pipnn_fanout | int[] | [10, 2] | Number of nearest leader partitions joined at each listed partition level; deeper levels use 1. |
pipnn_leaf_neighbor_count | int | 5 | Nearest candidates contributed per point in each leaf. This is the primary PiPNN quality/build-work knob; leaves smaller than pipnn_min_leaf_size use at least 4. |
pipnn_hash_plane_count | int | 12 | Direction-hash bits, in [1, 15]; max_degree <= 2^pipnn_hash_plane_count. |
pipnn_reservoir_size | int | 64 | Candidate slots retained per point before final pruning; effective capacity is at least max_degree. |
use_reverse_edges | bool | false | Track incoming neighbors for O(1) reverse-edge lookup. Roughly doubles edge storage and is unsupported with graph_storage_type: "compressed". |
label_remap_type | string | "pg" | Label-to-inner-ID map implementation: "pg" or "robin". Keep the same value when restoring or combining compatible indexes. |
use_reorder | bool | false | Keep a high-precision copy and re-rank after the coarse search |
reorder_source | string | "precise" | Reorder from "precise" codes or directly from "base" codes. RaBitQ x+y split, including tq_chain: "mrle, rabitq", sets "base" automatically. |
precise_quantization_type | string | "fp32" | Quantizer used for reordering (takes effect only with use_reorder: true) |
base_pq_dim | int | 1 | Number of PQ subspaces. When using pq / pqfs, set this explicitly instead of relying on the default. |
mrle_dim | int | 0 | Output dimension for an MRLE transform in tq_chain; allowed range [0, dim], where 0 means the input dimension. |
fast_encode_rabitq | bool | true | Use the fast multi-bit RaBitQ encoder; set to false for the previous exact encoder. |
fast_encode_rabitq_rounds | int | 6 | Fast RaBitQ coordinate-refinement rounds, in [1, 32]. |
rabitq_fused_datacell | bool | false | Fuse the bottom HGraph node and RaBitQ split codes into one in-memory record. Requires L2/IP, flat in-memory graph storage, RaBitQ x+y split codes with x in [1, 4], and the other constraints described in RaBitQ x+y split. |
train_sample_count | int | 65536 | Maximum number of vectors sampled for quantizer training; must be at least 512 when set explicitly. |
build_thread_count | int | 100 | Threads used to parallelise build |
support_duplicate | bool | false | Enable duplicate-ID detection on insert |
deduplicate_storage | bool | false | Share vector storage between duplicates; requires support_duplicate: true |
duplicate_distance_threshold | float | 0.0 | Duplicate-detection distance threshold. When greater than 0, deduplicate by the nearest candidate distance; when 0, fall back to the current code memcmp check |
support_remove | bool | false | Enable graph delete-tracking metadata used by mark-remove recovery paths |
support_force_remove | bool | false | Enable RemoveMode::FORCE_REMOVE and its extra synchronization on the built index |
store_raw_vector | bool | false | Keep the raw vector in addition to the quantized copy (useful for cosine) |
use_elp_optimizer | bool | false | Auto-tune search parameters after build |
base_io_type / precise_io_type | string | "block_memory_io" | Storage backend (memory_io, block_memory_io, buffer_io, async_io, uring_io, mmap_io) |
base_file_path / precise_file_path | string | — | File path; required when the corresponding *_io_type is disk-backed (buffer_io, async_io, uring_io, mmap_io) |
base_direct_read / precise_direct_read | bool | false | With uring_io, open the corresponding file using direct IO instead of the page cache. |
hgraph_init_capacity | int | 100 | Initial capacity hint (doesn’t cap the final size) |
persist_source_id | bool | false | Persist source-ID metadata during serialization so a restored index can later export a reusable build cache. |
use_conjugate_graph | bool | false | Enable Feedback/Pretrain graph enhancement; see Graph Index Enhancement. |
resize_increase_count_bit | int | 10 | log2 of the slot-growth batch. Valid range is 1 to 31; 1 grows in 2-slot batches and 10 in 1,024-slot batches. Smaller values reduce preallocation but can increase reallocations. |
sq8_local and sq4_local support float32 L2/IP/cosine base and precise stores (including RaBitQ + local SQ4/SQ8 reranking). See layout, numeric limits and Tune compatibility.
use_reverse_edges is intended for workloads that need fast incoming-neighbor inspection, graph
analysis, or future graph-maintenance algorithms. It is disabled by default because maintaining
the reverse adjacency approximately doubles edge storage.
label_remap_type changes the internal label map, not user-visible IDs. "pg" is the default;
"robin" selects the alternate robin-map implementation. Benchmark the target ID distribution
before changing it.
PiPNN build boundary
Set graph_type: "pipnn" to use PiPNN for the initial,
full Build. The PiPNN builder accepts dense float32 input with metric_type set to "l2",
"ip", or "cosine". It builds the bottom graph from the original build vectors, then reuses
HGraph’s route layers, storage, search, filtering, reordering, incremental Add, removal, and
serialization paths. The persistent base storage may use a supported quantizer such as sq8,
including RaBitQ with SQ8 reorder. Cache-assisted build and deduplicated vector storage are not
supported with PiPNN. The pipnn_* parameters in the table above tune this builder;
ef_construction does not. The existing alpha parameter controls PiPNN’s final robust pruning.
build_thread_count parallelizes vector preparation, partitioning, candidate generation, and
final pruning. Keep OPENBLAS_NUM_THREADS=1 when benchmarking so BLAS threads do not obscure
builder scaling. The reproducible tools/eval/pipnn_parallel.yaml workload compares NSW,
ODescent, and PiPNN at a matched Recall@10 target on SIFT1M and query-validates every graph.
Deduplicating vector storage
Set both support_duplicate: true and deduplicate_storage: true to let duplicate
vectors share one physical code slot while retaining their individual labels. This option
currently supports only dense-vector HGraph indexes using graph_type: "nsw"; it is not
available for graph_type: "odescent" or graph_type: "pipnn".
The following operations and configurations are not supported while storage deduplication is enabled:
- force removal (
support_force_remove: true); - cache-assisted build after
ImportCache(); Merge;- legacy v0.14 serialization.
UpdateVector is supported only for IDs whose vector storage is not shared with another
duplicate-group member.
Current serialization and streaming serialization are supported.
Supported input data types
The dtype field in the top-level build config selects how Dataset interprets the raw vector
bytes. HGraph supports four input types; the dtype value, the corresponding Dataset setter,
and the example demonstrating each combination are summarised below.
dtype | Element type | Dataset setter | Example |
|---|---|---|---|
float32 | float | Float32Vectors | 103_index_hgraph.cpp |
int8 | int8_t | Int8Vectors | 316_index_int8_hgraph.cpp |
float16 | uint16_t (IEEE 754 binary16, bit-pattern packed) | Float16Vectors | 321_index_fp16_hgraph.cpp |
bfloat16 | uint16_t (Brain Float, bit-pattern packed) | Float16Vectors (shared with FP16) | adapt 321_index_fp16_hgraph.cpp per the notes below |
The dim value is the logical vector dimensionality (number of elements), not the byte length, so
the same dim is reused across all four data types.
int8 input
Quantized int8 vectors are passed directly via Int8Vectors:
std::vector<int8_t> data(num_vectors * dim); // populate with int8 elements
auto base = vsag::Dataset::Make();
base->NumElements(num_vectors)->Dim(dim)->Ids(ids)
->Int8Vectors(data.data())->Owner(false);
Build config (note dtype: "int8"):
{
"dtype": "int8",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "pq",
"max_degree": 26,
"ef_construction": 100,
"alpha": 1.2
}
}
Queries use the same Int8Vectors setter and the same dtype. A runnable example is
316_index_int8_hgraph.cpp.
float16 / bfloat16 input
FP16 and BF16 vectors are both passed through Float16Vectors, which takes a const uint16_t*
that points at the 16-bit storage of each element. Conversion from float is up to the caller;
inside the VSAG source tree there are convenience helpers (vsag::generic::FloatToFP16 in
src/simd/fp16_simd.h
and vsag::generic::FloatToBF16 in
src/simd/bf16_simd.h),
but these are internal headers that are not installed under include/vsag/. Application code
linking against an installed VSAG library should provide its own conversion (for example, copy
the small helper, use _cvtss_sh / F16C intrinsics, or any FP16 library of choice). The snippet
below uses the in-tree helper for brevity:
// The fp16/bf16 helpers below live in src/simd/ and are not part of the public
// installed headers. Replace with your own float -> uint16_t conversion when
// linking against an installed VSAG.
#include "simd/fp16_simd.h" // FloatToFP16 (for BF16, use simd/bf16_simd.h / FloatToBF16)
std::vector<uint16_t> data(num_vectors * dim);
for (size_t i = 0; i < data.size(); ++i) {
data[i] = vsag::generic::FloatToFP16(some_float_source());
}
auto base = vsag::Dataset::Make();
base->NumElements(num_vectors)->Dim(dim)->Ids(ids)
->Float16Vectors(data.data())->Owner(false);
Build config:
{
"dtype": "float16",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "pq",
"max_degree": 26,
"ef_construction": 100,
"alpha": 1.2
}
}
To switch the example to BF16, change dtype to "bfloat16" and replace FloatToFP16 with
FloatToBF16; the Float16Vectors setter and the rest of the build/search flow stay the same.
A runnable FP16 example is
321_index_fp16_hgraph.cpp.
Note. The header comment at the top of
321_index_fp16_hgraph.cppcurrently mentions aBFloat16Vectors()setter, but no such setter exists —Float16Vectorsis the single entry point for both FP16 and BF16. Use it for bothdtype: "float16"anddtype: "bfloat16".
Choosing an input type
- Pick
float32when accuracy matters most and memory budget allows; this is the default. - Pick
float16/bfloat16to halve the input storage. FP16 has a smaller exponent range; BF16 has fewer mantissa bits but the same exponent range as FP32, which is often preferable for embedding-style vectors. - Pick
int8when your data is already integer-quantised (e.g. produced by an upstream quantiser or by a model with int8 outputs). Withint8input you typically still combine a coarse quantizer such aspq/sq8for the in-index storage.
The chosen dtype only constrains the input representation. The on-disk / in-memory storage is
still controlled by base_quantization_type (and optionally precise_quantization_type when
use_reorder: true), so e.g. dtype: "float16" + base_quantization_type: "sq8" is valid.
Search parameters
Search-time parameters live under the hgraph sub-object:
| Parameter | Type | Default | Description |
|---|---|---|---|
ef_search | int64 | — (required) | Positive search-frontier size. Any value up to INT64_MAX is accepted; there is no topk-relative upper bound. Larger values increase recall, latency, and frontier memory. |
hops_limit | int | unlimited | Hard cap on the number of hops the beam search performs before returning the current frontier. |
skip_ratio | float | 0.2 | Performance tuning parameter for filtered search. Controls the ratio of invalid points to skip, in range [0.0, 1.0]. skip_ratio=0.2 means skip 20% of invalid points and only check 80%. Higher values improve performance but may reduce recall. Only applies to searches with filters. See Filter Skip Strategy below. |
skip_strategy | string | "deterministic_accumulative" | Strategy for filter skipping. Options: "random" (random skipping) or "deterministic_accumulative" (deterministic cumulative skipping). See Filter Skip Strategy below. |
brute_force_threshold | float | 0.0 | Selectivity-aware brute-force fallback. When > 0 and the supplied filter’s ValidRatio() is ≤ brute_force_threshold, the search bypasses the graph traversal entirely and runs an exact scan over the valid ids using the best available flatten codes (see the section below). Must lie in [0.0, 1.0]; the default 0.0 disables the feature and preserves legacy behavior. |
rabitq_one_bit_search | bool | false | Enables the RaBitQ filter/lower-bound path. On an x+y split index it uses all x filter bits; see RaBitQ x+y Split. |
rabitq_error_rate | float | index default | Positive lower-bound error multiplier for this search. It can be tuned without rebuilding the split index. |
auto result = index->KnnSearch(
query, topk, R"({"hgraph": {"ef_search": 200}})").value();
Brute-force fallback under highly selective filters (brute_force_threshold)
Graph traversal is the right strategy when most candidates pass the filter — the
graph quickly reaches the neighborhood of the query. As filter selectivity
increases (only a tiny fraction of vectors survive), the beam has to expand far
more nodes just to fill ef_search with valid candidates, and recall drops.
At some point an exhaustive scan over the surviving ids is both faster and
exact.
brute_force_threshold lets HGraph make that switch automatically on a
per-query basis:
// When the active filter keeps ≤ 1% of ids, run an exact scan instead.
auto params = R"({"hgraph": {"ef_search": 200, "brute_force_threshold": 0.01}})";
auto result = index->KnnSearch(query, topk, params, my_filter).value();
How it works (src/algorithm/hgraph/hgraph_search.cpp):
- The fallback only fires when all of the following hold:
brute_force_threshold > 0.0, and- a filter is supplied, and
filter->ValidRatio() <= brute_force_threshold.
- The accuracy of
Filter::ValidRatio()matters — it is the user-supplied hint the dispatcher checks against the threshold. See Filtered Search for the API contract. - The scan iterates every valid inner id and computes distances in batches of
64 using the most precise flatten storage available (raw vectors if
store_raw_vectorwas set, otherwise the high-precision reorder codes whenuse_reorder=true, otherwise the base quantized codes). - Because the scan already uses precise codes when present, the post-search reorder pass is skipped for queries that took the brute-force branch.
- Applies to
KnnSearch(the non-iterator overload, which is whatSearchWithRequestand the standardKnnSearch(query, k, params, filter)call) and toRangeSearch. It does not apply to the iterator-styleKnnSearch(..., IteratorContext*&, ...), because a single sweep cannot be paged across multiple iterator calls.
Picking a value:
- Leave at
0.0(default) for unfiltered or weakly filtered workloads. - For highly selective filters,
0.01–0.05is a reasonable starting point. Setting it higher than that effectively turns the index into a brute-force scanner whenever a filter is present. - The cost of the brute-force scan is roughly
O(N × dim)whereNis the total number of indexed vectors (regardless of selectivity, because every id is visited to checkCheckValid). The benefit grows when graph search would otherwise need a much largeref_searchto recover recall.
See
322_feature_hgraph_brute_force_threshold.cpp
for a runnable brute-force fallback example.
Filter Skip Strategy (skip_ratio and skip_strategy)
When searching with a filter, HGraph needs to frequently call Filter::CheckValid() during graph traversal to verify whether each candidate point is valid. This check can be expensive (especially for complex filter logic). skip_ratio and skip_strategy provide a probabilistic optimization: they skip some filter checks to speed up the search, but may reduce recall.
How It Works
This is a probabilistic optimization strategy: we don’t know in advance which points are valid, so we decide probabilistically whether to visit each point.
- skip_ratio (default 0.2): Controls the aggressiveness of skipping filter checks. skip_ratio=0.2 means skip 20% of candidate checks and only check 80%. Higher values skip more, making search faster but potentially reducing recall.
- skip_strategy (default “deterministic_accumulative”): Determines how skipping is distributed:
- “random”: Random skipping. Each point is visited independently with probability
visit_ratio = valid_ratio + (1 - valid_ratio) * (1 - skip_ratio), so roughly a1 - skip_ratiofraction of invalid points are skipped. - “deterministic_accumulative”: Deterministic cumulative skipping. Emits visit decisions at fixed intervals so that the long-run visit ratio matches the target
visit_ratio, with lower variance than the random strategy.
- “random”: Random skipping. Each point is visited independently with probability
The specific formula:
- Let valid_ratio be the filter’s global validity rate (from Filter::ValidRatio())
- Probability of visiting each point = valid_ratio + (1 - valid_ratio) * (1 - skip_ratio)
- In expectation, this targets skipping about skip_ratio of invalid candidate checks when Filter::ValidRatio() is accurate
Usage Examples
// Conservative setting: skip 10% of invalid candidate checks, suitable for high-recall
// scenarios where latency is less critical
auto params = R"({"hgraph": {"ef_search": 200, "skip_ratio": 0.1}})";
auto result = index->KnnSearch(query, topk, params, my_filter).value();
// Use random strategy
auto params = R"({"hgraph": {"ef_search": 200, "skip_ratio": 0.2, "skip_strategy": "random"}})";
auto result = index->KnnSearch(query, topk, params, my_filter).value();
// Aggressive skipping: skip 50% of invalid candidate checks for lower latency
auto params = R"({"hgraph": {"ef_search": 200, "skip_ratio": 0.5}})";
auto result = index->KnnSearch(query, topk, params, my_filter).value();
Choosing Values
- Default 0.2: Suitable for most scenarios, balancing performance and recall.
- 0.1 or lower: Conservative setting, suitable for scenarios with high recall requirements where latency is less critical.
- 0.5 or higher: Aggressive skipping, suitable for latency-sensitive scenarios where recall degradation is acceptable (e.g., real-time recommendation systems).
- 0.0: Don’t skip any points, equivalent to disabling this optimization (all points will be checked).
Important notes:
- Only applies to searches with filters. These parameters are ignored when no filter is present.
- Performance optimization works better when Filter::ValidRatio() is accurately estimated.
- Can be used together with brute_force_threshold: when the filter is very strict (ValidRatio is very small), brute_force_threshold will trigger brute-force fallback; otherwise, graph traversal + skip optimization is used.
When to use HGraph
- Dense float vectors with dimensions roughly between 64 and 4096.
- Latency-sensitive queries where high recall matters.
- Mixed workloads with incremental insertion (optionally force removal via
support_force_remove). - Memory-constrained deployments that benefit from
sq8/sq4_uniform/pq— often in combination withuse_reorderto recover recall.
For repeated daily or snapshot builds with stable source identifiers, see
HGraph Build Cache. It documents Dataset::SourceID,
ExportCache/ImportCache, persist_source_id, and cache hit-rate diagnostics.
If your workload is partition-heavy (coarse-grained buckets scanned per query) or strongly I/O-bound on a SSD, compare against IVF before committing to HGraph.
See also
LazyHGraph
LazyHGraph is an adaptive dense-vector index that starts as an exact
BruteForce index and automatically converts to HGraph after the collection
reaches a configurable transition_threshold. It is useful when a dataset
starts small but is expected to grow: early searches stay exact and avoid graph
build overhead, while larger collections get HGraph’s approximate-search
latency and quantization options.
- Source:
src/algorithm/lazy_hgraph.{h,cpp} - Example:
examples/cpp/111_index_lazy_hgraph.cpp
How it works
- Flat phase. Before the threshold is reached, data is stored in an internal BruteForce index using FP32 vectors. Search is exact.
- Transition. When
Buildreceives at leasttransition_thresholdvectors, orAddgrows the flat phase to that size, LazyHGraph builds an internal HGraph from the flat data. - Graph phase. After transition, new data and search requests are handled
by the internal HGraph. Search parameters keep using the
hgraphsearch object.
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"lazy_hgraph": {
"transition_threshold": 1000,
"hgraph": {
"base_quantization_type": "sq8",
"max_degree": 26,
"ef_construction": 100,
"build_thread_count": 4
}
}
})";
auto index = vsag::Factory::CreateIndex("lazy_hgraph", params).value();
auto base = vsag::Dataset::Make();
base->NumElements(n)->Dim(128)->Ids(ids)->Float32Vectors(data)->Owner(false);
index->Add(base);
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(128)->Float32Vectors(q)->Owner(false);
auto result = index->KnnSearch(
query, /*topk=*/10, R"({"hgraph": {"ef_search": 100}})").value();
Build parameters
LazyHGraph accepts its build parameters in a top-level lazy_hgraph object.
For compatibility with the generic factory shape, the same object may also be
provided as index_param.
| Parameter | Type | Default | Description |
|---|---|---|---|
transition_threshold | uint64 | 1000 | Number of vectors at which LazyHGraph converts from the flat phase to HGraph. Must be positive. |
hgraph | object | {} | HGraph build parameters used after transition. See HGraph. |
LazyHGraph only supports top-level dtype: "float32". The flat phase is fixed
to FP32 BruteForce storage and does not accept separate flat quantization
parameters.
Search parameters
Search parameters use the same hgraph object as HGraph:
{"hgraph": {"ef_search": 100}}
In the flat phase, search is exact. In the graph phase, the internal HGraph uses
the supplied HGraph search parameters such as ef_search.
Lifecycle notes
Buildchooses the initial phase from the input size: belowtransition_thresholdstays flat; at or above the threshold builds HGraph directly.Addcan trigger the one-way transition from flat to graph.- Flat-phase
Removealways performs physical removal, even if the caller passesRemoveMode::MARK_REMOVE, so graph transition does not carry tombstones. GetExtraInfoByIds,UpdateExtraInfo, and extra-info filtering are supported in both phases. See Extra Info.
When to use LazyHGraph
- A dense FP32 collection starts small and grows over time.
- Exact results are preferred while the collection is small.
- The same index should automatically switch to HGraph once approximate graph search becomes worthwhile.
Use HGraph directly when the dataset is already large at build time, when you need non-FP32 input types, or when you want graph behavior from the first insertion.
See also
IVF
IVF (Inverted File) is VSAG’s partition-based index. It clusters the corpus into
buckets at build time, and at query time only scans the buckets whose centroids are
closest to the query. This turns an O(N) linear scan into O(N · scan_buckets_count
/ buckets_count) with tunable recall/latency.
IVF trades a little recall (compared to graph indexes) for lower memory overhead, higher throughput on batch workloads, and simpler sharding — which makes it a good default when the corpus is large (hundreds of millions or more), when memory is tight, or when queries are naturally parallelizable.
- Source:
src/algorithm/ivf.{h,cpp},src/algorithm/ivf_parameter.{h,cpp} - Example:
examples/cpp/106_index_ivf.cpp
How it works
- Clustering. A sample of the dataset is clustered with k-means (or sampled
randomly,
ivf_train_type: "random") to producebuckets_countcentroids. - Assignment. Every vector is written to the inverted list of its nearest
centroid, stored in the configured coarse quantization (
base_quantization_type). Optionally, a second high-precision copy is kept (use_reorder: true) for post-filter reordering. - Search. For each query, the
scan_buckets_countnearest centroids are computed first, then the vectors in those buckets are scored. When reordering is enabled,factorcontrols how many extra candidates are fetched from the coarse stage before being re-scored with the precise quantizer.
A second partition strategy, GNO-IMI (partition_strategy_type: "gno_imi"),
splits the space into two orthogonal sets of centroids
(first_order_buckets_count × second_order_buckets_count) for even finer
partitioning on very large corpora.
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"buckets_count": 256,
"base_quantization_type": "sq8",
"partition_strategy_type": "ivf",
"ivf_train_type": "kmeans"
}
})";
auto index = vsag::Factory::CreateIndex("ivf", params).value();
// Build.
auto base = vsag::Dataset::Make();
base->NumElements(n)->Dim(128)->Ids(ids)->Float32Vectors(data)->Owner(false);
index->Build(base);
// Search.
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(128)->Float32Vectors(q)->Owner(false);
auto result = index->KnnSearch(
query, /*topk=*/10,
R"({"ivf": {"scan_buckets_count": 16}})").value();
Input data type
The public Build, Add, and search paths currently accept FP32 vectors supplied with Dataset::Float32Vectors; set dtype to "float32". base_quantization_type selects internal encoding and storage and does not enable FP16/BF16 input. dtype: "int8" is not supported when creating an IVF index.
Build parameters
Build-time parameters live under index_param. See
Index Parameters for the exhaustive list.
| Parameter | Type | Default | Description |
|---|---|---|---|
partition_strategy_type | string | "ivf" | ivf (single-level) or gno_imi (two-level orthogonal) |
buckets_count | int | 10 | Number of inverted lists (effective for ivf) |
first_order_buckets_count | int | 10 | First-level count (effective for gno_imi) |
second_order_buckets_count | int | 10 | Second-level count (effective for gno_imi) |
ivf_train_type | string | "kmeans" | Centroid training: kmeans or random |
kmeans_iter_count | int | 25 (ivf), 30 (gno_imi) | KMeans training iterations for centroid training (effective with ivf_train_type: "kmeans"). Higher → better bucket quality, longer build. |
route_max_degree | int | 64 | Routing HGraph maximum degree (effective for ivf) |
route_ef_construction | int | 300 | Routing HGraph construction search breadth (effective for ivf) |
enable_gpu_build | bool | false | Use the CUDA backend while building (effective for ivf); see GPU-accelerated build |
gpu_device_id | int | 0 | CUDA device ordinal (effective for ivf); an ordinal the machine does not have keeps the build on the CPU |
gpu_memory_budget | int | 0 | Ceiling in bytes on the device working set (effective for ivf); 0 derives it from what the device has free |
gpu_min_work_threshold | int | 0 | Smallest count * buckets_count * dim worth offloading (effective for ivf); 0 uses the calibrated default |
base_quantization_type | string | "fp32" | fp32, fp16, bf16, sq8, sq4, sq8_uniform, sq4_uniform, pq, pqfs, rabitq — see the Quantization chapter for per-quantizer details |
base_pq_dim | int | 1 | PQ subspaces (required with pq / pqfs) |
rabitq_pca_dim | int | 0 | Optional PCA preprocessing dimension for base_quantization_type: "rabitq" |
rabitq_bits_per_dim_query | int | 32 | Query bits for rabitq; allowed values are 4 or 32 |
rabitq_bits_per_dim_base | int | 1 | Stored-code bits for rabitq; allowed range is [1, 8] |
rabitq_bits_per_dim_precise | int | unset | Enables RaBitQ split storage and sets the supplement (y) bits; rabitq_bits_per_dim_base is the filter (x) bits and x + y <= 8 |
rabitq_version | string | "standard" | rabitq layout: "standard" or "split_1bit_7bit" |
rabitq_error_rate | float | 1.9 | Positive error-budget parameter for rabitq encoding |
rabitq_use_fht | bool | false | Enable FHT rotation before rabitq binarization |
fast_encode_rabitq | bool | true | Use CAQ fast construction for multi-bit rabitq; set to false for exact encoding |
fast_encode_rabitq_rounds | int | 6 | CAQ adjustment rounds; allowed range is [1, 32] |
use_residual | bool | false | Encode vectors relative to their IVF bucket centroid (x-c). |
use_reorder | bool | false | Keep a high-precision copy and re-rank after the coarse scan |
precise_quantization_type | string | "fp32" | Quantizer used for reordering (with use_reorder: true) |
precise_codes_layout | string | "flat" | Storage layout for precise codes: "flat" keeps the legacy one-code-per-vector layout; "bucket" stores the precise code in the same bucket and offset as its basic posting |
base_io_type | string | "memory_io" | Storage backend for coarse codes; supports uring_io when built with liburing |
base_supplement_io_type | string | unset | Optional IO backend for RaBitQ split supplement codes |
base_supplement_file_path | string | derived | Optional file path for disk-backed RaBitQ supplement codes |
precise_io_type | string | "block_memory_io" | Storage backend for precise codes (memory_io, block_memory_io, mmap_io, buffer_io, async_io, uring_io, reader_io) |
precise_file_path | string | "" | File path when the precise IO type is disk-backed |
precise_codes_layout: "bucket" requires use_reorder: true. It supports
memory_io, block_memory_io, buffer_io, async_io, and uring_io
(when io_uring is available).
mmap_io and pqfs precise quantization are not supported. The bucket layout currently requires
buckets_per_data: 1; configurations that assign one vector to multiple buckets are rejected.
For both flat and bucket layouts, serialized precise codes can be loaded read-only from an
external Reader. Pass precise_io_type: "reader_io" and precise_reader to Index::Load.
The reader must expose exactly the high_precision_codes block payload for flat, or the
ivf_precise_bucket block payload for bucket. reader_io uses the normal read cache when
precise_enable_read_cache is enabled.
vsag::LoadParameters load_parameters;
load_parameters.Set("precise_io_type", "reader_io")
.Set("precise_enable_read_cache", true)
.Set("precise_cache_total_size", 256ULL * 1024 * 1024)
.SetReader("precise_reader", precise_codes_reader);
auto loaded = vsag::Index::Load(stream, load_parameters).value();
reader_io is a load-and-query placement policy. Build the index with a writable precise IO,
serialize it, and then use the external reader when loading it for search.
A rule of thumb for buckets_count is sqrt(N) to 4 * sqrt(N) where N is the
corpus size.
GPU-accelerated build
Building an IVF index is dominated by two things: clustering the training sample
into centroids, and then assigning every base vector to its bucket. Clustering
grows with buckets_count, because the sample does too (train_sample_count
defaults to max(65536, 64 * buckets_count)), and with
ivf_train_type: "kmeans" the seeding pass alone is
O(buckets_count * train_sample_count * dim) and runs single-threaded.
When VSAG is built with ENABLE_CUDA=ON, both move to a CUDA device. Set
enable_gpu_build to turn it on:
{
"buckets_count": 4096,
"base_quantization_type": "fp32",
"partition_strategy_type": "ivf",
"ivf_train_type": "kmeans",
"enable_gpu_build": true,
"gpu_device_id": 0
}
Four passes run on the device: k-means++ seeding, nearest-centroid assignment
and centroid accumulation inside clustering, and the bucket assignment of the
base vectors afterwards. ivf_train_type: "random" does not cluster, so only
the last one applies to it.
The four settings reach the ivf partition strategy only. With
partition_strategy_type: "gno_imi" they are accepted and ignored, as the other
strategy-specific parameters in the table are, and that build stays on the host.
Scope. Only the build changes. Search is untouched: queries still route through the graph, which is the right shape for one query at a time. The four settings are recorded in the index footer as every other build parameter is, so two otherwise identical builds are not byte-identical, but the compatibility check on load ignores them: an index built with the backend on loads into a reader configured any other way, including one on a machine with no GPU, and then behaves as that reader is configured. The centroids can differ slightly from a host build’s: the random choices come from the caller’s generator either way, but the device sums each cluster with atomics in no fixed order.
Fallback. A pass that declines is taken by the host, silently, and returns
what a build with the backend off would have returned. This happens when VSAG
was built without CUDA, no device is present, gpu_device_id names an ordinal
the machine does not have, the problem is below gpu_min_work_threshold, the
budget cannot hold what a pass needs, or any CUDA call fails. A build never
fails because of the backend.
Two cases stay on the host by design. Inner-product metrics order buckets by the dot product alone, which is not what the device pass minimises, so they keep the graph routing. A build that asked for a reproducible reduction keeps centroid accumulation on the host as well, since the device sums with atomics whose order is not fixed.
Memory. Vectors are streamed in chunks, so the corpus does not have to fit
in device memory; only the centroids and one chunk do. The working set is
bounded by gpu_memory_budget, by default derived from what the device reports
free, so the same build behaves on a small card and makes use of a large one.
Seeding differs only in shape: it sweeps the whole training sample once per
centroid, so the sample has to be resident rather than chunked, and a sample the
budget cannot hold sends seeding back to the host while the other passes stay on
it. When no budget is named the chunked passes take a default ceiling, since a
chunk larger than what saturates the device only costs more; seeding takes what
the device has free, because a ceiling there would only push large samples to
the host.
Measured on SIFT1M (1M base vectors, 128 dimensions), against a 48-thread host build as the baseline:
buckets_count | CPU build | GPU build | Speedup | Recall@10 change |
|---|---|---|---|---|
| 1000 | 59.45 s | 6.30 s | 9.43x | +0.058 pt |
| 4096 | 167.66 s | 8.02 s | 20.90x | +0.075 pt |
| 16384 | 729.04 s | 27.21 s | 26.79x | +0.079 pt |
The speedup grows with buckets_count because clustering takes a larger share
of the build. How large it is depends on the balance between the host’s CPU and
its GPU, which is also what gpu_min_work_threshold exists to retune.
Recall moves because the device compares against every centroid where the host does not, in two places.
Assigning base vectors to buckets, on the default use_route_graph: true
layout, searches the routing graph, which is approximate. On 20000 random
vectors of 64 dimensions the two paths agreed exactly at buckets_count 16; at
256 and 1000 they placed roughly 10% and 12% of the vectors in different
buckets, the device naming the nearer centroid in every one of those cases, and
the mean distance to the assigned centroid improved by under 1% and by about 2%.
The figures shift a little from run to run, because training is not seeded and
each run clusters differently; they held across unit-norm and raw random data.
The scan layout assigns exactly on either path and does not move.
Clustering does the same above 10000 centroids, where the host switches to a graph search over the centroids to keep training affordable.
So a build with the backend on assigns somewhat better, not only faster. The evidence for that is the distance comparison above, not the recall column: the table is one run per configuration, and recall between two separately trained indexes moves by more than the differences it shows. Four builds with identical settings on 50000 random vectors of 128 dimensions spanned a full point of recall, against the hundredths of a point in the table. Read that column as showing no regression, not as measuring the gain. Below those sizes the two agree except where two centroids are equidistant from a point to within floating-point rounding.
RaBitQ split storage
Set both quantizers to rabitq and divide the stored precision into filter
(x) and supplement (y) bits:
{
"base_quantization_type": "rabitq",
"precise_quantization_type": "rabitq",
"rabitq_bits_per_dim_query": 32,
"rabitq_bits_per_dim_base": 3,
"rabitq_bits_per_dim_precise": 5,
"use_reorder": true
}
IVF stores the x-bit filter planes in bucket-local 32-vector packed blocks and
stores the y-bit supplement separately. The default candidate_reorder
strategy scans only the packed x bits, retains factor * topk candidates, and
carries the byte-LUT-quantized x-bit inner product together with each
candidate’s source bucket, offset, and version. Normal reranking combines this
saved inner product with the y-bit supplement contribution. It does not reread
or unpack x-bit planes and does not reconstruct the inner product from a
distance hint.
For residual L2 candidate search, all routed buckets share one LUT built from
the original transformed query. Per-lane factors recover the raw filter-code
contribution and combine it with the supplement under the original query, so
normal reranking does not build a q-c computer. A correctness fallback may
unpack x bits only when the saved value or factors are invalid, source
provenance is stale, or a FastScan lane could not produce a valid result.
The split configuration requires x >= 1, y >= 1, x + y <= 8,
rabitq_bits_per_dim_query: 32, use_reorder: true, and
buckets_per_data: 1. Bucket-local graphs (graph_build_threshold > 0) are
not supported with split storage. Supported homogeneous IO backends are
memory_io, block_memory_io, buffer_io, async_io, mmap_io,
and reader_io; the supported hybrid layout is filter
block_memory_io plus supplement async_io.
Search parameters
Search-time parameters live under the ivf sub-object:
| Parameter | Type | Default | Description |
|---|---|---|---|
scan_buckets_count | int | — (required) | Number of buckets probed per query. Must be ≤ buckets_count (except when disable_bucket_scan is true, where larger values are allowed and unavailable slots are padded with -1). |
disable_bucket_scan | bool | false | Return bucket IDs and distances. Supports batch queries. |
factor | float | 2.0 | With reordering enabled, pulls factor * topk coarse candidates before the precise rescore. |
enable_reorder | bool | true | Set to false to skip the final reorder stage for this request even when the index was built with reorder enabled. |
parallelism | int | 1 | Threads used to scan buckets in parallel for a single query. |
timeout_ms | double | +∞ | Hard cap in milliseconds; partial results are returned once exceeded. |
auto result = index->KnnSearch(
query, topk,
R"({"ivf": {"scan_buckets_count": 32, "factor": 2.0, "parallelism": 4}})").value();
auto fast_result = index->KnnSearch(
query, topk,
R"({"ivf": {"scan_buckets_count": 32, "factor": 2.0, "enable_reorder": false}})").value();
When to use IVF
- Large corpora (hundreds of millions of vectors and above), especially when the working set does not fit comfortably in RAM.
- Batch or high-throughput workloads where per-query latency is less critical than queries-per-second.
- Memory-tight deployments that benefit from aggressive quantization (
sq8,sq4_uniform,pq,pqfs) combined withuse_reorderto recover recall. - Shard-friendly setups: buckets map naturally onto shards or disk blocks.
For latency-sensitive, high-recall workloads on dense embeddings, compare against HGraph first.
See also
SINDI
SINDI (Sparse INverted Dense Index) is VSAG’s index for sparse
vectors — the kind produced by BM25, SPLADE, and other learned-sparse encoders.
Unlike the dense indexes (HGraph, IVF), SINDI operates directly on term/value
pairs and is one of the VSAG indexes that accepts dtype: "sparse".
- Source:
src/algorithm/sindi/ - Example:
examples/cpp/109_index_sindi.cpp
How it works
- Window-based inverted lists. Documents are grouped into fixed-size windows
(
window_size). Within each window, an inverted list per term maps a term id to the(doc_id, value)pairs that mention it. - Optional pruning and quantization. During construction,
doc_prune_ratiodrops low-weight terms per document.use_quantization: truestores SQ8 values, whileuse_quantization: "fp16"stores half-precision values. - Scoring. At query time, SINDI iterates the non-zero terms of the query,
walks the corresponding inverted lists in each window, aggregates contributions
into a max-heap of size
n_candidate, and returns the top-k. Whenuse_reorderis enabled, the candidates are re-scored against a forward store. The default forward store keeps FP32 values.rerank_type: "fp16"stores half-precision values and converts them to FP32 for scoring, whilererank_type: "dmq8"uses a compressed DMQ store to reduce rerank memory further.
Distance is returned as 1 - inner_product so results sort ascending as in the
dense indexes.
For deployments that need both in-memory and disk-based I/O, use SINDI_V2.
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "sparse",
"metric_type": "ip",
"dim": 1024,
"index_param": {
"term_id_limit": 30000,
"window_size": 50000,
"doc_prune_ratio": 0.0,
"use_quantization": false,
"use_reorder": false,
"remap_term_ids": false
}
})";
auto index = vsag::Factory::CreateIndex("sindi", params).value();
// Build a dataset of SparseVector.
auto base = vsag::Dataset::Make();
base->NumElements(n)
->SparseVectors(sparse_vectors) // vsag::SparseVector*
->Ids(ids)
->Owner(false);
index->Build(base);
// Search.
auto query = vsag::Dataset::Make();
query->NumElements(1)->SparseVectors(&query_vec)->Owner(false);
auto result = index->KnnSearch(
query, /*topk=*/10,
R"({"sindi": {"n_candidate": 100}})").value();
Build parameters
Build-time parameters live under index_param. dtype must be "sparse"
and metric_type must be "ip".
| Parameter | Type | Default | Description |
|---|---|---|---|
dim | int | — (required) | Maximum number of non-zero elements per sparse vector. Not the vocabulary size. |
term_id_limit | int | 1000000 | Upper bound on term id values (≥ max term id + 1, up to 50 000 000). |
window_size | int | 50000 | Documents per window (range: 10 000 – 60 000). |
doc_prune_ratio | float | 0.0 | Fraction of lowest-weight terms dropped per doc at build time ([0.0, 1.0)). |
use_quantization | bool or string | false | false stores FP32 values, true stores SQ8 values, and "fp16" stores FP16 values. |
use_reorder | bool | false | Keep a forward store and rescore candidates after coarse SINDI scoring. |
rerank_type | string | "fp32" | Forward-store type used when use_reorder is enabled. fp32 keeps exact values, fp16 stores half-precision values while scoring in FP32, and dmq8 stores compressed 8-bit DMQ codes. |
dmq_shared_codebook_threshold | int | 1024 | With rerank_type: "dmq8", terms occurring at most this many times share one codebook; more frequent terms keep independent codebooks. Set to 0 to disable sharing. |
remap_term_ids | bool | false | Remap term IDs before indexing; useful when term IDs are sparse or have large gaps. |
avg_doc_term_length | int | 100 | Hint for memory estimation only. |
immutable | bool | false | Build or load the compact read-only runtime. Build() compacts completed windows as it proceeds to reduce peak memory; incremental Add() is rejected. |
dimvsterm_id_limit. For the sparse vector{0:0.1, 2:0.5, 177:0.8},dimis3(three non-zero entries) whileterm_id_limitmust be ≥178(largest term id + 1). Sizingterm_id_limitto your vocabulary is the most common first-time mistake.
Sparse value formats
use_quantization retains its legacy boolean behavior and also accepts one string value:
| Value | Stored value format | Trade-off |
|---|---|---|
false | FP32 | Highest value precision and largest posting payload |
true | SQ8 | Smallest posting values; quantization range is learned during the initial build |
"fp16" | FP16 | Half the FP32 value bytes without SQ8 range calibration |
Indexes built with false or true retain the legacy serialization representation. Older VSAG
versions cannot parse a SINDI index that uses the new "fp16" value format; upgrade readers
before deploying FP16 artifacts.
Rerank value formats
rerank_type controls the separate forward store created by use_reorder: true; it is independent
of the posting-list use_quantization setting. fp16 keeps query values and accumulation in FP32
but stores document values in FP16. Because term IDs remain 32-bit, the core bytes per non-zero
forward-store entry decrease from 8 to 6 before record and block overhead. FP16 conversion adds
compute work, so latency may improve for memory-bound workloads but can regress for short or
cache-resident vectors. Measure with representative data.
Both fp16 and dmq8 require use_reorder: true. Older VSAG versions cannot read the FP16 rerank
payload.
Immutable low-memory build
Set immutable: true when the completed SINDI index is read-only:
{
"dtype": "sparse",
"metric_type": "ip",
"dim": 1024,
"index_param": {
"term_id_limit": 30000,
"window_size": 50000,
"use_quantization": "fp16",
"remap_term_ids": true,
"immutable": true
}
}
During Build(), each completed mutable window is compacted into an immutable payload and the
temporary window is released before construction continues. In the Sparse4M FP16 measurement
reported with this feature, peak build memory fell from 27.03 GB to 6.08 GB (77.51% lower), while
build time increased from about 330 seconds to 599 seconds. Treat these figures as workload-specific
evidence, not a capacity guarantee.
The immutable runtime supports KNN and range search plus legacy Serialize/Deserialize.
It rejects incremental Add and GetSparseVectorByInnerId. Single-ID
CalcDistanceById and batch CalcDistancesById (including the legacy CalDistanceById alias)
are supported; see distance-by-ID. Mutable and immutable runtimes both support SerializeStreaming,
DeserializeStreaming, and Index::Load.
The serialized index must be loaded into a SINDI created with the same immutable setting.
New indexes record the sorted posting-list format version and skip normalization when loaded.
Indexes written without this marker remain compatible and are normalized during loading.
Host filtering
Mutable and immutable SINDI and SINDI_V2 indexes can group documents by their source
host and avoid returning documents from other hosts. Attach a complete string host array to a
host-aware Build() or mutable Add() batch. Use an empty string for a document with no host:
base->NumElements(n)
->SparseVectors(sparse_vectors)
->Ids(ids)
->StringMetadata("host", base_hosts)
->Owner(false);
index->Build(base);
std::string query_host = "example.com";
query->NumElements(1)
->SparseVectors(&query_vec)
->StringMetadata("host", &query_host)
->Owner(false);
Each Build() or mutable Add() batch groups internal IDs by host while preserving external
labels. Host queries scan the relevant posting windows and apply exact membership checks over the
host’s one or more internal-ID ranges. Repeated mutable Add() calls may append disjoint ranges for
the same host. Tombstones and an additional user Filter are applied together with host membership.
Host strings are matched byte for byte without case folding or URL normalization. VSAG assigns
compact internal IDs and persists the string-to-ID dictionary with the index; callers never provide
those IDs. The empty string is the missing-host bucket. Once a mutable index contains host metadata,
every later Add() must provide a complete host array; host metadata cannot be introduced after
host-unaware documents. An empty-string query searches only missing-host documents, while omitting
host preserves full-index KNN behavior. An unknown host returns an empty result. Indexes built
without base host metadata ignore query host metadata and retain their previous behavior. The old
numeric host_id input is rejected. Host filtering currently applies only to KNN; range search keeps
its existing full-index behavior.
Publish-time filtering
Mutable and immutable SINDI can filter KNN queries by a document publication timestamp, with or
without reranking. Supply one Unix timestamp in seconds per base document through int64 metadata.
0 means that the document has no publication time:
int64_t base_times[] = {0, 1777593600, 1777680000};
base->Int64Metadata("publish_time_stamp", base_times);
int64_t query_time = 1777593600;
query->Int64Metadata("publish_time_stamp", &query_time)->Owner(false);
int64_t time_begin = 1777593600;
int64_t time_end = 1777766399;
query->Int64Metadata("publish_time_stamp_begin", &time_begin)
->Int64Metadata("publish_time_stamp_end", &time_end)
->Owner(false);
SINDI normalizes every positive timestamp to a UTC epoch day with timestamp / 86400. A single
timestamp matches every document on the same UTC day. A range is inclusive after both endpoints
are normalized to UTC days. The two range endpoints must be supplied together, must satisfy
begin <= end, and cannot be combined with the single-value selector. Query values must be
positive. Base values must be non-negative; negative values are rejected. Timestamps whose epoch
day exceeds INT32_MAX are rejected.
Documents with base value 0 remain searchable when a query omits time selectors, including
host-only queries, but never match a time query. Every base document still needs one array entry.
An index built without publish_time_stamp rejects a query that supplies any time selector.
A time-aware build orders documents by publication timestamp, cuts the ordered sequence into
logical partitions of window_size documents, and groups by host inside each partition. Missing
times form one final partition. Search first selects partitions whose UTC-day range intersects the
query, then applies exact per-document day and optional host checks before candidates enter the
heap. Time, host, tombstone, and user Filter conditions use AND semantics. Failed or skipped
documents can make adjacent logical partitions share a physical window; the exact day check keeps
boundary results correct.
Time-aware indexes are build-once. A mutable index may receive publish_time_stamp through
Build() or the first Add() while empty, but rejects every later Add(). Mutable host-only
indexes retain incremental Add() support. Publish-time filtering supports mutable and immutable
indexes with either use_reorder setting, applies only to KNN search, and is preserved by standard
and streaming serialization. A restored mutable time-aware index remains build-once.
Queries without a time selector scan every time and missing-time partition. Host-only queries select that host from every partition. Exact time filtering disables term-level posting pruning in each selected window, so time queries may scan more postings than host-only queries; wider ranges also select more windows. A host-only query on a time-enabled index can require a full scan for windows that cross partition or host boundaries.
Search parameters
Search-time parameters live under the sindi sub-object:
| Parameter | Type | Default | Description |
|---|---|---|---|
n_candidate | int | 0 | Candidate heap size. When 0, defaults to SPARSE_AMPLIFICATION_FACTOR · topk (500×). If set, must satisfy 1 ≤ n_candidate ≤ SPARSE_AMPLIFICATION_FACTOR · topk. |
filter_callback_limit | uint64 | 0 | Maximum user Filter::CheckValid callback invocations for one filtered search request. A value of 0 disables the limit. Internal checks such as the deleted-ID filter do not count. Reaching a positive limit stops candidate and window scanning after processing the final callback result and returns the already filtered candidates, so the result may be partial. The limit applies to KNN and range search through both the regular APIs and SearchWithRequest. |
query_prune_ratio | float | 0.0 | Fraction of lowest-weight query terms skipped ([0.0, 1.0)). |
term_prune_ratio | float | 0.0 | Fraction of the lowest-value postings skipped from each term list ([0.0, 1.0)). |
term_retain_threshold | uint64 | 0 | Maximum postings for one term across all windows. A value of 0 disables this limit; positive values allow each non-empty window posting list to scan at most max(1, floor(threshold / window_count)) postings. |
After combining the ratio and threshold limits, SINDI scans at least one posting from every non-empty term list.
Within each window, postings for a term are sorted by their stored value in descending
order, then by internal document id in ascending order. If both term-prune limits are
enabled, the scanned prefix is the smaller of
floor(list_size · (1 - ratio)) and floor(threshold / window_count).
SINDI chooses the heap-insertion strategy automatically from the build-time
doc_prune_ratio and search-time query_prune_ratio. With the current 0.1
threshold, SINDI uses the distance-array insertion path when both ratios are
<= 0.1; if either ratio is greater than 0.1, it uses term-list heap
insertion. The legacy
use_term_lists_heap_insert search parameter is ignored; configure pruning
ratios instead.
auto result = index->KnnSearch(
query, topk,
R"({"sindi": {"n_candidate": 200, "filter_callback_limit": 10000, "query_prune_ratio": 0.1}})").value();
When to use SINDI
- Sparse retrieval with BM25, SPLADE, uniCOIL, or similar learned-sparse encoders.
- Hybrid dense+sparse pipelines where SINDI handles the sparse leg in parallel with HGraph / IVF for dense embeddings.
- Memory-constrained deployments of sparse corpora (
use_quantization: trueselects SQ8, while"fp16"halves FP32 posting value bytes;use_reorder: truetrades forward-store memory for recall,rerank_type: "fp16"reduces forward-store value bytes with modest precision loss, andrerank_type: "dmq8"compresses that store further). - Read-only snapshots that need lower peak build memory (
immutable: true), accepting slower construction and no incremental writes.
SINDI does not accept dense vectors and supports only inner-product similarity.
Range search and id-based filtering are supported; see the example for usage.
When rerank_type is dmq8, codebooks are fixed by the initial build, so incremental
Add after the model is established and UpdateVector are not supported.
Practical guidance
- For Chinese corpora, we recommend encoding sparse vectors with BGE-M3. For English corpora, SPLADE is the more common default.
- BGE-M3 can emit both sparse and dense vectors. Today SINDI handles the sparse leg, and VSAG plans to support fused sparse+dense scoring in a future release.
- Sparse vectors are not a complete replacement for BM25 full-text retrieval. In practice, three-way recall with BM25 + sparse + dense usually outperforms any two-way combination.
- At the index level, SINDI can also serve BM25-style scoring: use inverse document frequency as the query-side term weight, and use term-frequency-based weights as the document-side term value.
Common configurations
- Flat brute-force sparse index. Keep all non-zero terms in the inverted index
(
doc_prune_ratio: 0.0), disable the flat reranker (use_reorder: false), and disable quantization (use_quantization: false). This is the simplest high-recall baseline. - Pruned high-accuracy index. Prune most low-weight terms during build
(
doc_prune_ratio: 0.4), keep the flat copy for reranking (use_reorder: true), and enable quantization to shrink inverted-list memory (use_quantization: true). This is a common balance between memory and recall. - Pruned high-accuracy index with compressed reranking. Use the same pruning and
inverted-list quantization as above, but set
rerank_type: "fp16"together withuse_reorder: truefor a precision-oriented reduction in forward-store memory, or selectdmq8for stronger compression. - Very large sparse vocabularies. When term IDs are sparse within the
uint32range, such as hash-based tokenizers, external vocabulary IDs, or vocabularies with large gaps, enableremap_term_ids: true. This avoids managing many empty posting lists and helps stay below theterm_id_limitceiling. - Read-only low-memory build. Add
immutable: true, normally together withuse_quantization: "fp16"andremap_term_ids: true, when peak build memory matters more than build speed and the finished index will not receive incremental writes.
Mark remove
SINDI supports RemoveMode::MARK_REMOVE. Calling Remove(ids) (the default mode)
tombstones the given ids so they no longer appear in search results;
GetNumElements() drops accordingly and GetNumberRemoved() reports the running
total. Removing an id that is absent or already removed is a no-op.
RemoveMode::FORCE_REMOVE is not supported and returns an error.
Mark-removed documents still occupy memory until the index is rebuilt; the space is not physically reclaimed.
See also
SINDI_V2
SINDI_V2 is VSAG’s term-first sparse-vector index. It uses the same windowed
inverted-list scoring model and 1 - inner_product distance as
SINDI, but persists postings by term so a search can read only the
lists needed by the query.
- Factory entry:
sindi_v2 - Source:
src/algorithm/sindi_v2/ - Required
dtype:sparse - Required
metric_type:ip
Storage layout
Each term payload stores only non-empty windows:
non_empty_window_count
ordered {window_id, posting_count}[]
local_doc_ids[]
alignment padding
encoded_values[]
At query time, SINDI_V2 expands the sparse metadata into window_count + 1
window offsets. A query loads postings only for its retained terms through
reader_io, mmap_io, buffer_io, async_io, or an in-memory backend.
This differs from SINDI’s window-first serialization. The two factory entries therefore require different persisted formats and must not be used interchangeably when loading an index.
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "sparse",
"metric_type": "ip",
"dim": 1024,
"index_param": {
"term_id_limit": 30000,
"window_size": 50000,
"doc_prune_ratio": 0.1,
"use_quantization": true,
"use_reorder": true,
"remap_term_ids": true,
"term_io": {
"type": "buffer_io",
"file_path": "/tmp/sindi_v2.terms"
},
"rerank_io": {
"type": "block_memory_io"
}
}
})";
auto index = vsag::Factory::CreateIndex("sindi_v2", params).value();
index->Build(base);
auto result = index->KnnSearch(
query, 10,
R"({"sindi_v2": {
"n_candidate": 100,
"filter_callback_limit": 10000,
"query_prune_ratio": 0.1,
"term_prune_ratio": 0.2,
"term_retain_threshold": 10000
}})").value();
Build parameters
Build parameters live under index_param.
| Parameter | Type | Default | Description |
|---|---|---|---|
dim | int | required | Maximum number of non-zero elements per sparse vector, not the vocabulary size. |
term_id_limit | int | 1000000 | Upper bound on term IDs; maximum 50 000 000. |
window_size | int | 50000 | Documents per window, from 10 000 to 60 000. |
doc_prune_ratio | float | 0.0 | Fraction of lowest-weight document terms removed during build ([0.0, 1.0)). |
use_quantization | bool or string | false | false stores FP32, true stores SQ8, and "fp16" stores FP16 values. |
use_reorder | bool | false | Store high-precision vectors and rerank coarse candidates. |
rerank_type | string | "fp32" | Rerank storage type: fp32, fp16, or dmq8. FP16 stores document values in half precision and scores in FP32. |
dmq_shared_codebook_threshold | int | 1024 | Low-frequency term threshold for the shared DMQ codebook. |
remap_term_ids | bool | false | Compact sparse or widely separated external term IDs. |
avg_doc_term_length | int | 100 | Memory-estimation hint only. |
immutable | bool | false | Select the compact read-only in-memory term DataCell. |
term_io | object | {"type": "reader_io"} | Backend used for term-first postings after load. |
rerank_io | object | {"type": "block_memory_io"} | Backend used for rerank vectors. |
rerank_layout | uint32 | 0 | Number of leading terms in the top-terms-signature layout; 0 disables it. |
File-backed term_io supports mmap_io, buffer_io, and async_io, and
requires file_path. If a file-backed rerank_io omits file_path, VSAG
derives it as <term_io.file_path>.rerank.
rerank_type: "fp16" requires use_reorder: true and supports the same rerank I/O and layout
options as fp32. rerank_layout > 0 requires use_reorder: true. rerank_type: "dmq8"
requires rerank_layout: 0 and the default block_memory_io rerank backend.
Host filtering
SINDI_V2 supports the same string host build and KNN-query metadata contract as
SINDI. Both mutable and immutable indexes are supported. It uses the
shared host grouping and routing component while retaining its own term-first posting storage.
Host membership is enforced during posting-window search, including multiple ranges created by
mutable Add(). The empty-string missing-host value, persisted internal dictionary, mutable Add()
metadata rules, streaming serialization, and the KNN-only scope are the same as SINDI.
Publish-time filtering
SINDI_V2 supports the same SINDI Unix-second input, UTC-day matching, and inclusive time-range
contract. The two indexes share one metadata filtering component
for time-only, host-only, and combined time-and-host KNN routing. SINDI_V2 retains its term-first
posting layout. Publish-time filtering supports mutable and immutable indexes with either
use_reorder setting and is preserved by standard and streaming serialization; range search
remains unfiltered. A mutable time-aware SINDI_V2 is build-once and follows the same initial
Build() or first-Add() and later-Add() rejection rules as SINDI. Base timestamp 0 uses the
same missing-time semantics: unfiltered and host-only queries include those documents, while time
queries exclude them. Exact day filtering disables term-level posting pruning in each selected
window, so time queries may scan more postings than host-only queries. Host-only queries on
time-enabled indexes share the boundary-window behavior documented for SINDI.
Search parameters
Search parameters live under {"sindi_v2": {...}}.
| Parameter | Type | Default | Description |
|---|---|---|---|
n_candidate | int | 0 | Coarse candidate count; 0 uses the index default derived from topk. |
filter_callback_limit | uint64 | 0 | Maximum user Filter::CheckValid callback invocations for one filtered search. 0 disables the limit. Reaching a positive limit stops candidate and window scanning after processing the final callback result and returns the candidates accepted so far, so the result may be partial. The limit applies to the regular KNN and range-search APIs. |
query_prune_ratio | float | 0.0 | Fraction of the lowest-weight query terms skipped ([0.0, 1.0)). |
term_prune_ratio | float | 0.0 | Fraction of the lowest stored values skipped in each term list ([0.0, 1.0)). |
term_retain_threshold | uint64 | 0 | Maximum postings for one term across all windows. 0 disables the limit; positive values allow each non-empty window posting list to scan at most max(1, floor(threshold / window_count)) postings. |
After combining the ratio and threshold limits, SINDI V2 scans at least one posting from every non-empty term list.
SINDI V2 chooses the heap-insertion strategy automatically from the build-time
doc_prune_ratio and search-time query_prune_ratio. With the current 0.1
threshold, it uses distance-array insertion when both ratios are <= 0.1; if
either ratio is greater than 0.1, it uses term-list heap insertion.
Choosing between SINDI and SINDI_V2
Use SINDI when the full sparse index remains in memory and window-first serialization is appropriate. Use SINDI_V2 when persisted postings should be organized by term or when searches should fetch only the query’s term lists from external storage.
SIMQ
SIMQ (Single Index for Multi-vector Query) is VSAG’s index for multi-vector retrieval — the kind of data where each document is a set of token-level vectors rather than a single embedding. This pattern arises in late-interaction models such as ColBERT, where a document is represented by one vector per token and relevance is computed via MaxSim (sum of maximum per-query-token similarities).
- Source:
src/algorithm/simq/
How it works
- Dynamic clustering of token vectors. At build time, all token vectors
across every document are extracted into a flat pool and clustered using an
HGraph-based dynamic clustering algorithm. The initial cluster centers are
sampled at a ratio controlled by
init_cluster_ratio; clusters that grow beyondmax_cluster_sizeare split incrementally. - Representative graph for coarse search. A representative HGraph is built
over the cluster centroids. At query time, each query token searches this
graph to find its nearest clusters (controlled by
coarse_k). The cluster scores are accumulated across all query tokens to produce a candidate set. - Exact MaxSim reranking. The top
rerank_kcandidates are re-scored by reading back the original token vectors from disk (or memory) and computing the exact MaxSim similarity between query tokens and document tokens.
The combination of cluster-level coarse search and exact reranking gives SIMQ a tunable recall/latency tradeoff for multi-vector workloads.
Quick start
#include <vsag/vsag.h>
std::string build_params = R"({
"dtype": "float32",
"metric_type": "ip",
"dim": 256,
"index_param": {
"base_io_type": "async_io",
"base_file_path": "/path/to/simq_base_codes.bin",
"init_cluster_ratio": 0.1,
"max_cluster_size": 160,
"split_start_idx": 80,
"random_seed": 42,
"coarse_k": 50,
"rerank_k": 1000
}
})";
auto index = vsag::Factory::CreateIndex("simq", build_params).value();
// Build a dataset of MultiVector.
// Each document has a variable number of token vectors, each of dimension `dim`.
std::vector<vsag::MultiVector> base_mvs(num_docs);
std::vector<int64_t> ids(num_docs);
for (int64_t i = 0; i < num_docs; ++i) {
base_mvs[i].len_ = doc_token_counts[i]; // number of tokens in doc i
base_mvs[i].vectors_ = doc_token_vectors[i]; // flat array: len_ * dim floats
ids[i] = i;
}
auto base = vsag::Dataset::Make();
base->NumElements(num_docs)
->Dim(dim)
->Ids(ids.data())
->MultiVectors(base_mvs.data())
->MultiVectorDim(dim)
->Owner(false);
index->Build(base);
// Search with a multi-vector query.
vsag::MultiVector query_mv;
query_mv.len_ = query_token_count;
query_mv.vectors_ = query_token_vectors;
auto query = vsag::Dataset::Make();
query->NumElements(1)
->Dim(dim)
->MultiVectors(&query_mv)
->MultiVectorDim(dim)
->Owner(false);
std::string search_params = R"({
"simq": {
"coarse_k": 600,
"rerank_k": 5000
}
})";
auto result = index->KnnSearch(query, /*topk=*/100, search_params).value();
// Read results.
const int64_t* result_ids = result->GetIds();
const float* result_dists = result->GetDistances();
int64_t result_count = result->GetDim();
for (int64_t i = 0; i < result_count; ++i) {
int64_t id = result_ids[i];
float dist = result_dists[i];
}
Build parameters
SIMQ-specific build parameters live under index_param. The common fields
dim, dtype, and metric_type are top-level. dtype must be
"float32" and metric_type must be "ip".
| Parameter | Type | Default | Description |
|---|---|---|---|
dim | int | — (required) | Dimension of each token vector. |
base_io_type | string | "async_io" | Storage backend for reranking multi-vector data. |
base_file_path | string | "./default_file_path" | File path for disk-backed IO types. |
init_cluster_ratio | float | 0.2 | Fraction of tokens sampled as initial cluster centers. |
max_cluster_size | int | 64 | Maximum token vectors per cluster before split. |
split_start_idx | int | 32 | Split position within an overflowing cluster. |
random_seed | int | 42 | Random seed for clustering shuffle. |
coarse_k | int | 8 | Default nearest clusters per query token at build time. |
rerank_k | int | 100 | Default max rerank candidates at build time. |
dim— shared across all documents and queries.base_io_type— supported values:async_io,uring_io,memory_io,block_memory_io,buffer_io,mmap_io,reader_io. Nativeuring_iorequires a build with liburing; otherwise it falls back tobuffer_io.base_file_path— the default is a placeholder; provide a real path when using a disk-backed type (async_io,uring_io,buffer_io,mmap_io).init_cluster_ratio— range(0, 1]. Smaller values yield fewer, larger clusters; larger values produce more, finer-grained clusters.max_cluster_size— must be > 1.split_start_idx— typically half ofmax_cluster_size. Must be in(1, max_cluster_size).coarse_k,rerank_k— must be > 0.
Choosing cluster parameters.
init_cluster_ratioandmax_cluster_sizetogether control the number and size of clusters. A smallerinit_cluster_ratiowith a largermax_cluster_sizeyields fewer clusters and faster coarse search at the cost of recall. Start withinit_cluster_ratio = 0.1–0.2andmax_cluster_size = 2 ×split_start_idx, then tune with the search parameters.
Search parameters
Search-time parameters live under the simq sub-object:
| Parameter | Type | Default | Description |
|---|---|---|---|
coarse_k | int | (index default) | Nearest clusters per query token. |
rerank_k | int | (index default) | Max rerank candidates. |
coarse_k— overrides the build-time value. Larger values increase the candidate pool and improve recall at the cost of latency.rerank_k— overrides the build-time value. Larger values improve recall at the cost of more disk reads and compute.- When omitted, the build-time defaults are used. Both values must be > 0 when explicitly set.
auto result = index->KnnSearch(
query, topk,
R"({"simq": {"coarse_k": 600, "rerank_k": 5000}})").value();
Search statistics
KnnSearch and RangeSearch attach a JSON statistics object to the returned dataset. Read the
complete object or request individual values:
std::cout << result->GetStatistics() << '\n';
auto values = result->GetStatistics(
{"simq_coarse_candidate_count", "simq_rerank_candidate_count"});
| Key | Meaning |
|---|---|
dist_cmp | Exact MaxSim distance comparisons during reranking |
simq_coarse_dist_cmp | Distance comparisons performed by representative-graph searches |
simq_coarse_probe_count | Representative clusters probed across all query tokens |
simq_coarse_candidate_count | Unique document candidates produced by coarse search before applying rerank_k |
simq_rerank_candidate_count | Candidates retained after applying rerank_k |
simq_filtered_candidate_count | Rerank candidates rejected by the supplied filter |
simq_result_count | Results returned after reranking and range/top-k limits |
simq_limited_size_applied | Whether RangeSearch truncated matches to limited_size |
These counters make it possible to distinguish a narrow coarse candidate pool from filtering, reranking, and output-limit effects when tuning recall and latency.
When to use SIMQ
- Late-interaction retrieval with ColBERT or similar models where each document is a bag of token-level vectors and relevance is computed via MaxSim.
- Multi-vector relevance where a single embedding per document loses too much information and fine-grained token-level matching is needed.
- Large-scale multi-vector corpora where brute-force MaxSim is too slow and a two-stage coarse-then-rerank pipeline provides the right recall/latency tradeoff.
SIMQ only accepts float32 multi-vector data with inner-product similarity. It
does not accept single dense vectors or sparse vectors (use HGraph or SINDI
for those).
Practical guidance
- Scaling
coarse_kandrerank_k. Increasingcoarse_kwidens the cluster-level candidate net; increasingrerank_kadmits more documents to exact scoring. In practice,rerank_khas a larger impact on recall but also on latency because each additional candidate requires a disk read and full MaxSim computation. - IO type selection. Use
async_iofor large corpora that do not fit in memory. Usememory_ioorblock_memory_iowhen the multi-vector data fits in RAM for the lowest reranking latency. - Cluster sizing. Set
max_cluster_sizeto roughly twicesplit_start_idx. The split point determines how the token vectors are partitioned when a cluster overflows; centering it keeps the two halves balanced.
MultiVector field reference
| Field | Type | Description |
|---|---|---|
len_ | uint32_t | Number of token vectors in this document or query. |
vectors_ | float* | Contiguous array of len_ * dim floats |
See also
Pyramid
Pyramid is VSAG’s hierarchical, path-partitioned graph index. A vector can be
tagged with one or multiple path strings such as "a/d/f", and Pyramid
builds a graph per node in those path trees. At query time you supply one or more
path prefixes, and Pyramid restricts the search to the corresponding sub-trees.
This is ideal for multi-tenant deployments, tag-partitioned catalogs, or any scenario where one logical index serves many groups that must not cross-contaminate results.
- Source:
src/algorithm/pyramid.{h,cpp},src/algorithm/pyramid_zparameters.{h,cpp} - Example (single hierarchy):
examples/cpp/107_index_pyramid.cpp - Example (multi-hierarchy):
examples/cpp/112_index_pyramid_multi_hierarchy.cpp
How it works
- Path tree. Each vector can carry independent paths in addition to its id.
Paths use
/as separator (e.g."tenant_a/lang_en/topic_news"). Pyramid builds one sub-index for every path prefix seen during build. The vector data and id are stored once even when the vector belongs to multiple paths. - Per-level sub-graphs. By default every level gets its own proximity graph.
Use
no_build_levelsto skip levels that are too small or too coarse to benefit from graph indexing — those levels still exist as passthrough containers, but search degrades to a scan. - Graph construction. Each sub-graph is built with the same machinery as
HGraph:
nswinsertion orodescentwithgraph_iter_turn,neighbor_sample_rate, andalphafor pruning. Base vectors are stored inbase_quantization_type; optional reordering keeps a high-precision copy. - Search. Query vectors also carry a path. The search walks down the tree
to the most specific sub-graph matching the query path and runs a graph search
there with
ef_search(andsubindex_ef_searchfor intermediate levels).
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 32,
"alpha": 1.2,
"graph_type": "odescent",
"graph_iter_turn": 15,
"neighbor_sample_rate": 0.2,
"no_build_levels": [0, 1],
"use_reorder": true,
"build_thread_count": 16
}
})";
auto index = vsag::Factory::CreateIndex("pyramid", params).value();
// Build with per-vector paths.
auto base = vsag::Dataset::Make();
base->NumElements(n)
->Dim(128)
->Ids(ids)
->Paths(paths) // std::string* of length n, e.g. "a/d/f"
->Float32Vectors(data)
->Owner(false);
index->Build(base);
// Search restricted to a path prefix.
std::string query_path = "a/d";
auto query = vsag::Dataset::Make();
query->NumElements(1)
->Dim(128)
->Float32Vectors(q)
->Paths(&query_path)
->Owner(false);
auto result = index->KnnSearch(
query, /*topk=*/10,
R"({"pyramid": {"ef_search": 100}})").value();
Input data type
The public Build, Add, and search paths currently accept FP32 vectors supplied with Dataset::Float32Vectors; set dtype to "float32". base_quantization_type selects internal encoding and storage and does not by itself enable FP16, BF16, or INT8 input.
Build parameters
Build-time parameters live under index_param.
| Parameter | Type | Default | Description |
|---|---|---|---|
base_quantization_type | string | — | Coarse storage quantizer (fp32, fp16, bf16, sq8, sq4, sq8_uniform, sq4_uniform, pq, pqfs, rabitq, tq). See the Quantization chapter for per-quantizer details. |
tq_chain | string | — | Transform chain used when base_quantization_type is tq, for example "mrle, rabitq". |
mrle_dim | int | 0 | Prefix dimension retained by MRLE; 0 keeps the input dimension. |
max_degree | int | 64 | Maximum out-degree per node within a sub-graph. |
graph_type | string | "nsw" | nsw, odescent, or pipnn. PiPNN batch-builds every constructed graph in each hierarchy, including root routing layers for multi_layer roots. |
graph_storage_type | string | "flat" | Bottom-graph storage for a multi_layer root: flat favors construction and search speed, while compressed reduces graph memory. Compressed storage requires max_degree <= 255. Single-layer roots, routing graphs, and child graphs remain sparse. |
ef_construction | int | 400 | Candidate list size for nsw builds. |
alpha | float | 1.2 | Pruning factor during graph construction; PiPNN requires a finite value at least 1.0. |
graph_iter_turn | int | — | ODescent iterations (effective with graph_type: "odescent"). |
neighbor_sample_rate | float | — | ODescent neighbor sampling rate. |
pipnn_max_leaf_size | int | 1024 | Maximum PiPNN partition leaf size. |
pipnn_min_leaf_size | int | 64 | Target used when PiPNN merges undersized leaves. |
pipnn_leader_sample_rate | float | 0.005 | Fraction sampled as leaders; each partition uses 2 to 1000, bounded by its point count. |
pipnn_fanout | int[] | [10, 2] | Nearest leader partitions joined at successive PiPNN partition levels. |
pipnn_leaf_neighbor_count | int | 5 | Nearest candidates contributed per point and leaf; the primary PiPNN quality/build-work knob. Undersized leaves use at least 4. |
pipnn_hash_plane_count | int | 12 | Direction-hash bits, in [1, 15]; max_degree <= 2^pipnn_hash_plane_count. |
pipnn_reservoir_size | int | 64 | Candidate slots retained per point before final pruning; effective capacity is at least max_degree. |
no_build_levels | int[] | [] | Tree levels that skip graph construction (0-indexed from the root). |
use_reorder | bool | false | Keep a high-precision copy for rescoring. |
precise_quantization_type | string | "fp32" | Quantizer for reordering. Use "rabitq" with rabitq_bits_per_dim_precise to enable RaBitQ x+y split reorder from base storage. |
rabitq_bits_per_dim_base | int | 1 | RaBitQ stored-code bits. In x+y split mode, this is x, the filter bits used during graph traversal; allowed range is [1, 8]. |
rabitq_bits_per_dim_precise | int | unset | RaBitQ split y bits. When set with base_quantization_type: "rabitq" and precise_quantization_type: "rabitq", Pyramid uses split storage; rabitq_bits_per_dim_base remains x, and x + y <= 8. |
fast_encode_rabitq | bool | true | Use the fast multi-bit RaBitQ encoder for RaBitQ base or precise storage; set to false for the exact encoder. |
fast_encode_rabitq_rounds | int | 6 | Fast RaBitQ refinement rounds in [1, 32]. |
base_io_type / precise_io_type | string | "block_memory_io" | Base and reorder storage backends; uring_io is available in builds with liburing. |
base_file_path / precise_file_path | string | — | Required for disk-backed storage such as buffer_io, async_io, uring_io, or mmap_io. |
store_raw_vector | bool | false | Preserve an FP32 copy for GetRawVectorByIds and precise distance-by-id calculations. |
store_paths | bool | false | Top-level switch that preserves the original paths supplied to Build and Add so GetDataByIdsWithFlag can return them when DATA_FLAG_PATH is selected. It applies to every configured hierarchy and cannot be overridden per hierarchy. |
index_min_size | int | 0 | Minimum sub-index size; smaller groups fall back to scan. |
root_graph_type | string | "single_layer" | Root graph layout: single_layer preserves the original sparse bottom graph; multi_layer uses a preallocated Flat or Compressed bottom graph with HGraph-style sparse routing layers and joint construction. multi_layer supports graph_type: "nsw" and "pipnn". Do not specify this option when no_build_levels disables level 0. |
support_duplicate | bool | false | Group code-equivalent vectors with distinct external labels, using construction-code CompareVectors semantics. External labels must remain unique. |
build_thread_count | int | 1 | Threads used for parallel build. |
hierarchies | array | [] | Named hierarchy definitions. Each element is either a string (inherits all top-level params) or an object with name and optional overrides (max_degree, ef_construction, alpha, no_build_levels, index_min_size, root_graph_type). When present, multi-hierarchy mode is activated and each hierarchy maintains its own independent path tree. |
PiPNN supports Pyramid builds with dtype: "float32" and metric_type set to "l2", "ip", or
"cosine". It batch-builds every constructed graph in each hierarchy; with
root_graph_type: "multi_layer", this also includes every root routing layer. Search, incremental
Add, removal, duplicate handling, reorder, and serialization retain the standard Pyramid
behavior. Levels in no_build_levels are still skipped.
The pipnn_* values are global to the Pyramid index; hierarchy objects may continue to override
max_degree and alpha, and those overrides are applied when PiPNN builds that hierarchy.
ef_construction does not tune PiPNN.
RaBitQ split configuration
Set all five parameters together to enable RaBitQ x+y split storage and reordering:
{
"use_reorder": true,
"base_quantization_type": "rabitq",
"precise_quantization_type": "rabitq",
"rabitq_bits_per_dim_base": 3,
"rabitq_bits_per_dim_precise": 5
}
Pyramid uses split-code code-to-code distances for incremental flat-to-graph promotion, so it does
not retain an internal FP32 copy by default. Set store_raw_vector to true at build time when
raw-vector access and complete analyzer metrics are required.
MRLE with split RaBitQ
Pyramid can truncate embeddings trained with Matryoshka Representation Learning before encoding them as split RaBitQ codes:
{
"base_quantization_type": "tq",
"tq_chain": "mrle, rabitq",
"mrle_dim": 1280,
"precise_quantization_type": "rabitq",
"rabitq_bits_per_dim_base": 3,
"rabitq_bits_per_dim_precise": 5,
"use_reorder": true
}
The 3-bit filter planes are used for graph traversal and the 5-bit supplement planes for
reordering; both encode the same truncated vector. Pyramid uses split code-to-code distances for
graph promotion and does not retain the original FP32 vectors. Metrics that require decodable
vectors are marked unavailable unless store_raw_vector was enabled at build time. Truncation can
reduce recall unless the embedding model was trained for prefix dimensions.
Build cache
ExportCache captures per-hierarchy, per-node NSW graph seeds and ImportCache makes them available to a later Build. Cache data uses the index cache payload format, not the streaming index serialization format. Set persist_source_id: true before footer-serializing an index whose cache will be reused, and provide a unique Dataset::SourceID and external label for every vector in both builds. Cache warm builds apply only to graph_type: "nsw"; ODescent, missing source IDs, and repeated source IDs or labels fall back to a normal cold build (which retains its normal repeated-label rejection behavior). ef_construction is not an eligibility condition. At least 80% of input source IDs must overlap the imported cache. Cache misses are constructed normally. Single-layer root hits receive forward-row repair; multi-layer nodes rebuild routing overlays, which are not cached.
With support_duplicate: true, Pyramid stores explicit per-graph group membership and the actual adjacency-row owner, not the minimum label or inner ID. Warm construction remaps SourceIDs and verifies current graph membership and construction-code equality. Missing or moved representatives, changed equivalence classes and unusable class rows turn the affected classes into misses; unrelated valid classes remain reusable. Removed aliases alone do not invalidate their class. Newly merged code classes are conservatively rebuilt. Hash-keyed ordered indexes compare exact construction-code bytes to resolve collisions, rather than assuming hash equality. At least 80% of each graph’s memberships must remain reusable; missing/legacy group metadata or insufficient reuse triggers graph-level cold fallback. All-identical graphs without useful cached adjacency still cold-build. Representative adjacency is restored before aliases, and edges to invalid classes are filtered. Aliases count as memberships, not restored edges or routing entries.
Concurrent insertion uses a transient graph-local exact-code representative registry, holding only the corresponding admission stripe through representative publication. It references existing construction codes instead of copying full vectors and is rehydrated when needed after loading an index. After cold construction and warm refinement, duplicate-enabled NSW Build checks both forward reachability from each bottom graph’s entry and return reachability to that entry. When needed, bounded, geometrically selected bridges preserve the protected forward tree and maximum degree. Together these checks ensure strong connectivity among the graph’s physical representatives, including those selected as query-specific entries by routed descent. Return-path checking uses CSR reverse adjacency with O(R + E) temporary memory for R representatives and E edges, released per graph. This is a Build-time invariant, not a guarantee for arbitrary later Add operations. Finite-budget approximate search can still miss whole vector groups. No universal recall or construction speedup is guaranteed. After deserialization, sparse empty adjacency rows retain physical-owner identity. Legacy dense/compressed storage can lose that identity for an isolated empty duplicate group; admission first preserves owners observable through rows, incoming edges or routing, then normalizes an otherwise unobservable persisted duplicate group without changing its labels. This does not recover historical owner identity or restore arbitrary incremental graph connectivity. The one-time legacy admission scan uses O(H + A + E) traversal work and O(H) visitation memory, where H is the stored ID high-water mark, A is the visited duplicate membership count and E is the traversed edge count; exact-code ordered-index operations add their usual comparison cost.
Duplicate-aware cache payloads use a versioned Pyramid-only format. This reader accepts legacy ordinary caches; older readers cannot read the new duplicate payload. Ordinary caches without group metadata retain the legacy format. HGraph cache and index serialization formats are unchanged. GetStats() separates vector and graph-membership hit/miss counts from restored-edge counts; graph fallback contributes misses, not restored groups.
Search parameters
Search-time parameters live under the pyramid sub-object:
| Parameter | Type | Default | Description |
|---|---|---|---|
ef_search | int | 100 | Candidate list size for the leaf-level graph search. |
factor | float | unset | KNN reorder candidate multiplier. Values <= 1 add no limit. When greater than 1, Pyramid first completes the unchanged subgraph searches and merges their results, then sends at most min(max(ef_search, topk), floor(topk * factor)) main-graph candidates to reorder. RaBitQ lower-bound safety candidates may be merged after this limit, matching HGraph; reorder_candidate_count reports the actual merged count. The value must be finite and positive. It has no effect on range search or when reorder is disabled. |
hops_limit | int | unlimited | Per-graph KNN hop cap for the root bottom graph and every non-root graph; ignored when it is not greater than ef_search. Sparse root routing layers are never hop-limited. FLAT scans and range search are unaffected. |
subindex_ef_search | int | 50 | Candidate list size used when traversing intermediate sub-graphs on the path. |
hierarchies | string[] | [] | Select which hierarchy to search. Empty means use the default (unnamed) hierarchy. |
hierarchy_op | string | "single" | How to combine results across hierarchies: single (search one hierarchy), union, or intersection. Note: union and intersection are not yet implemented — setting them will cause KnnSearch/RangeSearch to return an error. |
rabitq_error_rate | float | 1.9 | Positive lower-bound error multiplier for this search. The default 1.9 is relatively large; increasing it improves accuracy but slows down search. |
auto result = index->KnnSearch(
query, topk,
R"({"pyramid": {"ef_search": 200, "subindex_ef_search": 80}})").value();
Multi-Hierarchy Support
A single Pyramid index can maintain multiple independent path trees, each
identified by a name (e.g. "site", "category"). Vectors share IDs and data
across all hierarchies — only the paths differ. Each hierarchy can optionally
override graph construction parameters.
This is useful when the same set of vectors needs to be partitioned along
different dimensions simultaneously. For example, an e-commerce platform might
partition products by site (site-a/lang-en) and by category
(electronics/phones) at the same time, and search can target either hierarchy
independently.
Build configuration
Add a hierarchies array inside index_param. Each element is either:
- A string (inherits all top-level params):
"site" - An object with
nameand optional per-hierarchy overrides:{"name": "category", "max_degree": 64, "no_build_levels": [0]}
Overridable per-hierarchy parameters: max_degree, ef_construction, alpha,
no_build_levels, index_min_size, root_graph_type.
root_graph_type: "multi_layer" changes only the selected hierarchy’s root. Its bottom graph uses
the top-level graph_storage_type: Flat by default, or Compressed to trade construction and search
speed for lower graph memory. Sparse routing graphs choose a better entry point before the bottom
search; child graphs remain sparse. NSW Build and incremental Add jointly construct the route and
bottom layers with the same HGraph-style insertion protocol. PiPNN Build batch-constructs both
layers. Combining multi_layer with odescent is rejected during parameter validation. Bottom
and routing edges use the precise codes when an independent precise store
exists, otherwise they use the base codes. Query traversal continues to use the base codes, and
final reordering uses the configured reorder source.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 32,
"alpha": 1.2,
"graph_type": "odescent",
"graph_iter_turn": 15,
"neighbor_sample_rate": 0.2,
"no_build_levels": [0, 1],
"use_reorder": true,
"build_thread_count": 16,
"hierarchies": [
"site",
{"name": "category", "max_degree": 64, "no_build_levels": [0]}
]
}
}
Dataset API for named hierarchies
Use the overloaded Paths(hierarchy_name, paths) methods to assign paths per
hierarchy. The same Ids() and Float32Vectors() are shared across all
hierarchies. The pointer overload keeps the existing one-path-per-vector form:
auto base = vsag::Dataset::Make();
base->NumElements(n)
->Dim(128)
->Ids(ids)
->Float32Vectors(data)
->Paths("site", site_paths) // std::string* of length n
->Paths("category", category_paths) // independent paths for 2nd hierarchy
->Owner(false);
index->Build(base);
Retrieving paths by ID
Set the top-level build parameter store_paths to true to retain the original paths for ID-based
retrieval. Select DATA_FLAG_PATH in GetDataByIdsWithFlag; the returned paths follow the requested
ID order. Use GetPaths() for the default unnamed hierarchy and GetPaths(hierarchy_name) for a
named hierarchy:
int64_t requested_ids[] = {product_id_b, product_id_a};
auto data = index->GetDataByIdsWithFlag(
requested_ids, 2, DATA_FLAG_ID | DATA_FLAG_PATH).value();
const std::string* site_paths = data->GetPaths("site");
const std::string* category_paths = data->GetPaths("category");
GetDataByIds and GetDataByIdsWithFlag calls without DATA_FLAG_PATH do not attach path arrays.
Selecting DATA_FLAG_PATH while store_paths is false returns an invalid-argument error. When
path storage is enabled, these legacy getters include a hierarchy only if every requested ID has
exactly one recorded path in that hierarchy. If any requested ID has no recorded path or multiple
paths, its getter returns nullptr; other hierarchies whose requested paths are complete are returned.
In single-hierarchy mode, the same rule applies to GetPaths().
Use the structured overload to retrieve every path regardless of the stored representation:
std::vector<std::vector<std::string>> tag_paths;
if (data->GetPaths("tag", tag_paths)) {
// tag_paths[i] contains every path for requested_ids[i].
}
To assign multiple independent paths to one vector in the same hierarchy, pass a nested vector. The outer vector has one entry per dataset element; each inner vector has one or multiple paths:
std::vector<std::vector<std::string>> tag_paths = {
{"technology", "military"}, // vector 0 belongs to both paths
{"sports"}, // vector 1 belongs to one path
{""}, // vector 2 belongs to the hierarchy root
};
auto base = vsag::Dataset::Make();
base->NumElements(3)
->Dim(128)
->Ids(ids)
->Float32Vectors(data)
->Paths("tag", std::move(tag_paths))
->Paths("site", site_paths) // legacy pointer form can coexist
->Owner(false);
index->Build(base);
An empty inner vector is invalid. An inner vector containing "" assigns the vector to the root.
Duplicate paths and shared prefixes are inserted once per tree node. Add() accepts the same form,
and serialization preserves every resulting path assignment.
Searching a specific hierarchy
Specify which hierarchy to search via "hierarchies" in the search parameters.
The query dataset must also set its path on the matching hierarchy name:
auto query = vsag::Dataset::Make();
query->NumElements(1)
->Dim(128)
->Float32Vectors(q)
->Paths("site", &query_path) // target the "site" hierarchy
->Owner(false);
auto result = index->KnnSearch(
query, /*topk=*/10,
R"({"pyramid": {"ef_search": 100, "hierarchies": ["site"]}})").value();
The legacy query form uses | for a union of path alternatives, for example
"technology|military"; a vector reachable through both alternatives appears
once in the result. In this legacy string form, | is reserved and has no
escaping syntax. A structured query avoids that delimiter convention and also
allows a literal | inside a path segment:
auto query = vsag::Dataset::Make();
query->NumElements(1)
->Dim(128)
->Float32Vectors(q)
->Paths("tag", std::vector<std::vector<std::string>>{{"technology", "military"}})
->Owner(false);
Incremental insertion (Add)
Add() works the same as Build() — provide either single or structured named
paths and the index inserts each accepted vector into all matching paths:
auto new_data = vsag::Dataset::Make();
new_data->NumElements(count)
->Dim(128)
->Ids(new_ids)
->Float32Vectors(new_vectors)
->Paths("site", new_site_paths)
->Paths("category", new_cat_paths);
index->Add(new_data);
RangeSearch
RangeSearch also supports hierarchy selection via the same search parameters:
auto result = index->RangeSearch(
query, /*radius=*/20.0f,
R"({"pyramid": {"ef_search": 100, "hierarchies": ["category"]}})").value();
Serialize & Deserialize
Multi-hierarchy indexes serialize and deserialize transparently. The serialized format includes
all hierarchy names and their graph structures. With store_paths: true, both regular and
streaming serialization also persist the retained original paths, making them available through
GetDataByIdsWithFlag after deserialization. With the default false, the graph hierarchy is
persisted but the original per-ID paths are not:
// Serialize
auto binary_set = index->Serialize().value();
// Deserialize into a new index (must use the same build params)
auto new_index = vsag::Factory::CreateIndex("pyramid", build_params).value();
new_index->Deserialize(binary_set);
When to use Pyramid
- Multi-tenant services where each tenant must see results only from its own partition, and you would otherwise maintain one index per tenant.
- Content catalogs with hierarchical tags (language / region / category) where queries always scope to a known prefix.
- Workloads with many small partitions:
no_build_levelsandindex_min_sizelet you skip graph construction for partitions too small to benefit.
If you don’t need path-based scoping, HGraph is simpler and generally faster.
Use Index Analysis to inspect Pyramid tree structure,
per-subindex quality, sampled base recall, and duplicate ratios reported by GetStats().
For each hierarchy, root_graphs reports root_graph_type, bottom_graph_storage_type,
bottom_graph_node_count, bottom_graph_size, route_graph_count, route_node_counts, and
route_graph_size.
AnalyzeIndexBySearch also reports path-scoped query recall, distance, latency, and, when reorder
is enabled, quantization metrics. Its query dataset must carry the same default or named-hierarchy
paths required by KnnSearch. For a batched dataset, the outer path collection has one row per
query: the legacy overload supplies one path string per row (and uses | for a union), while the
structured overload supplies one or many atomic paths per row and permits literal |
characters. The analyze_index tool cannot currently load hierarchy paths from its dense query
file, so use the C++ API for path-scoped dynamic analysis.
Mark remove
Pyramid supports RemoveMode::MARK_REMOVE. Calling Remove(ids) (the default
mode) tombstones the given ids: they are excluded from subsequent search results,
GetNumElements() drops by the number removed, and GetNumberRemoved() reports
the running total. Removing an id that is absent or already removed is a no-op.
RemoveMode::FORCE_REMOVE is not supported and returns an error.
Mark-removed vectors still occupy memory until the index is rebuilt; the space is not physically reclaimed.
Label lookup and persistence
Label 0 is valid. Reserved capacity does not create labels: CheckIdExist(0) is false
when no vector has that label, and single-ID CalcDistanceById returns an error for a
missing or mark-removed label.
Pyramid writes only populated label slots using the existing length-prefixed vector format. When reading older payloads containing unused slots, it uses the stored vector count to discard the unused suffix and rebuild label mappings. Both seekable and streaming formats support this recovery. Older readers can still parse newly written payloads, but do not gain the corrected lookup behavior; upgrade the reader as well.
See also
BruteForce
BruteForce is VSAG’s exact, flat index. At query time it scores the query against every vector in the corpus and returns the true top-k — no graph traversal, no inverted lists, no approximation. Its main role is to be the ground-truth baseline that approximate indexes (HGraph, IVF, …) are evaluated against, but it is also a reasonable production choice for small corpora or for workloads where 100% recall is mandatory.
- Source:
src/algorithm/bruteforce/bruteforce.{h,cpp} - Example:
examples/cpp/105_index_brute_force.cpp
How it works
- Build. Vectors are stored in a single flat data cell encoded by
base_quantization_type(defaultfp32— i.e. raw). No graph, no clustering, no training is performed for the uncompressed quantizers; PQ/SQ-style quantizers that require training will still run their training pass when used. - Add. New vectors are appended to the flat store. There is no rebalancing or rebuild cost.
- Search. For each query the distance is computed against every stored vector under the
configured
metric_type(l2,ip, orcosine), then a top-k heap returns the closest ids. Search uses SIMD kernels and supports intra-query parallelism — a single query can be split across multiple threads via theparallelismsearch parameter (seeBruteForce::SearchWithRequestinsrc/algorithm/bruteforce/bruteforce.cpp).
Because the index keeps every vector verbatim (modulo the chosen quantizer), the result is
exact when base_quantization_type is fp32 and is the standard reference used to compute
ground truth in the eval_performance tool.
Quick start
#include <vsag/vsag.h>
std::string params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128
})";
auto index = vsag::Factory::CreateIndex("brute_force", params).value();
// Build.
auto base = vsag::Dataset::Make();
base->NumElements(n)->Dim(128)->Ids(ids)->Float32Vectors(data)->Owner(false);
index->Build(base);
// Search — no index-specific knobs; pass an empty JSON object (or set `parallelism`).
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(128)->Float32Vectors(q)->Owner(false);
auto result = index->KnnSearch(query, /*topk=*/10, "{}").value();
A full runnable program is at
examples/cpp/105_index_brute_force.cpp.
Input data type
The public Build, Add, KnnSearch, RangeSearch, and UpdateVector paths accept FP32 vectors only. At runtime, supply vectors via Dataset::Float32Vectors. At index creation, set dtype to "float32"; dtype: "int8" is not supported when creating a BruteForce index.
The base_quantization_type parameter controls the index’s internal encoding and storage, not the input type. Selecting an internal fp16, bf16, or other quantizer does not enable FP16/BF16 input.
Build parameters
The minimal config consists of the three top-level fields (dtype, metric_type, dim).
For most uses no index_param is needed — that is the form shown in
example 105.
Advanced users can pass an index_param object to enable quantization or storage tweaks:
| Parameter | Type | Default | Description |
|---|---|---|---|
base_quantization_type | string | "fp32" | fp32, fp16, bf16, sq8, sq4, sq8_uniform, sq4_uniform, pq, pqfs, rabitq — see the Quantization chapter for per-quantizer details |
use_attribute_filter | bool | false | Enable attribute-based filtering (see Attribute Filter) |
resize_increase_count_bit | int | 10 | log2 of the slot-growth batch. Valid range is 1 to 31; 1 grows in 2-slot batches and 10 in 1,024-slot batches. Smaller values reduce preallocation but can increase reallocations. |
Note on
store_raw_vector. Thestore_raw_vectorflag is parsed by the sharedInnerIndexParameterbut BruteForce does not consult it when deciding whetherGetRawVectorByIdsis available. On BruteForce, raw-vector retrieval is enabled strictly whenbase_quantization_typeisfp32and either the metric is notcosineor the quantizer is configured to hold the per-vector norms (hold_molds). Settingstore_raw_vector: trueon BruteForce currently has no observable effect on the capability flags — use HGraph or IVF if you need a quantized index that still answersGetRawVectorByIds.
Example with sq8 quantization for memory savings while keeping linear scan semantics:
{
"dtype": "float32",
"metric_type": "ip",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8"
}
}
When base_quantization_type is set to a quantizer that requires training (sq8,
sq8_uniform, sq4_uniform, pq, pqfs, rabitq), Build will run the training pass on
the supplied dataset before adding vectors; the resulting recall is no longer 100%. Only
fp32, fp16, and bf16 skip training and preserve exact distances (modulo numeric
precision).
Search parameters
BruteForce does not expose any index-specific search knobs (no ef, nprobe, etc.), but the
generic IndexSearchParameter fields are honored:
| Parameter | Type | Default | Description |
|---|---|---|---|
parallelism | int | 1 | Split the linear scan of a single query across this many threads in the index’s internal thread pool. It applies to both KnnSearch and RangeSearch. Larger values cut single-query latency on large corpora at the cost of using more cores. Values <= 0 are clamped to 1. |
// Single-threaded scan (default).
auto r1 = index->KnnSearch(query, topk, "{}").value();
// Use 8 threads to scan a single query in parallel.
auto r2 = index->KnnSearch(query, topk, R"({"parallelism": 8})").value();
// RangeSearch uses the same parallelism parameter.
auto r3 = index->RangeSearch(query, radius, R"({"parallelism": 8})").value();
For range search semantics, see Range Search.
Removing vectors
BruteForce supports both RemoveMode::MARK_REMOVE and RemoveMode::FORCE_REMOVE; neither mode
needs an HGraph-style support_force_remove setting.
When use_attribute_filter: true is enabled, neither removal mode is available. Rebuild the index instead if attribute-filtered data must be deleted.
MARK_REMOVEis the default. It records a tombstone, so the id is excluded from later searches while its vector storage remains allocated.GetNumElements()excludes marked ids andGetNumberRemoved()reports their count.FORCE_REMOVEphysically removes each requested vector. It moves the last internal slot into the vacated slot when necessary and keeps the moved vector’s external id intact. Successful force removals do not add tombstones; pre-existing marked removals remain reflected byGetNumberRemoved().
index->Remove(std::vector<int64_t>{id1, id2}, vsag::RemoveMode::MARK_REMOVE);
index->Remove(std::vector<int64_t>{id3, id4}, vsag::RemoveMode::FORCE_REMOVE);
Physical removal is synchronous and more expensive than marking. It takes the index’s exclusive global lock, so it waits for in-flight searches and prevents new searches while the swap-and-remove operation runs. Batch ids when possible and avoid force removal on latency-critical paths.
By default, BruteForce uses block_memory_io for vector storage. After every successful
force-removal batch, its logical size is reduced to the live entry count and trailing complete blocks
are released; the final partial block remains allocated. The optional extra-information store follows
the same block-granular behavior. To avoid repeated full-table copies, the label table reclaims its
backing allocation when its live entry count falls to at most half of its current capacity.
GetMemoryUsage() then reflects the compacted index. This is index allocation accounting,
not a promise that the process allocator
immediately returns memory to the operating system or that RSS drops.
Capabilities
BruteForce advertises the following capability flags (see BruteForce::InitFeatures in
src/algorithm/bruteforce/bruteforce.cpp):
| Capability | Notes |
|---|---|
SUPPORT_BUILD / SUPPORT_ADD_AFTER_BUILD / SUPPORT_ADD_CONCURRENT | Build once, append later, concurrent inserts. |
SUPPORT_ADD_FROM_EMPTY | Available with non-training quantizers (fp32, fp16, bf16). |
SUPPORT_KNN_SEARCH / SUPPORT_KNN_SEARCH_WITH_ID_FILTER / SUPPORT_SEARCH_CONCURRENT | Standard top-k API and id-list filters, with concurrent search. |
SUPPORT_RANGE_SEARCH / SUPPORT_RANGE_SEARCH_WITH_ID_FILTER | Available with non-training quantizers (fp32, fp16, bf16). |
SUPPORT_DELETE_BY_ID / SUPPORT_DELETE_CONCURRENT | Remove by id is supported. Searches and delete operations are synchronized; FORCE_REMOVE takes an exclusive lock. |
SUPPORT_CAL_DISTANCE_BY_ID | Distance lookup against stored vectors (non-training quantizers only). |
SUPPORT_UPDATE_VECTOR_CONCURRENT | UpdateVector replaces an existing FP32 vector with the same dimension. BruteForce has no graph connectivity check, so force_update does not change its update behavior. |
SUPPORT_GET_RAW_VECTOR_BY_IDS | Available only when base_quantization_type is fp32 and either the metric is not cosine or the underlying quantizer holds molds (hold_molds). Quantized BruteForce indexes do not advertise this flag. |
SUPPORT_CHECK_ID_EXIST / SUPPORT_CLONE / SUPPORT_ESTIMATE_MEMORY / SUPPORT_GET_MEMORY_USAGE | Standard introspection and lifecycle. |
SUPPORT_SERIALIZE_BINARY_SET / SUPPORT_SERIALIZE_FILE / SUPPORT_SERIALIZE_WRITE_FUNC | Full save surface. |
SUPPORT_DESERIALIZE_BINARY_SET / SUPPORT_DESERIALIZE_FILE / SUPPORT_DESERIALIZE_READER_SET | Full load surface. (There is no DESERIALIZE_WRITE_FUNC counterpart — read paths use READER_SET instead.) |
NEED_TRAIN | Set when base_quantization_type is one of sq8, sq4, sq8_uniform, sq4_uniform, pq, pqfs, rabitq. |
Notably not supported by BruteForce: SUPPORT_UPDATE_ID_CONCURRENT and
SUPPORT_EXPORT_MODEL.
When to use BruteForce
- Recall baseline. Compute the ground truth that approximate indexes are scored against
(this is what the
eval_performancetool does). - Tiny corpora. A few hundred to a few hundred thousand vectors, where the cost of a full scan is acceptable and you want to skip tuning altogether.
- Strict-recall requirements. Use cases that cannot tolerate any approximation error.
- Quantization experiments at small scale. Reuse the same scan pipeline but compare
different
base_quantization_typesettings without the confounding effect of a graph or inverted-list structure.
For anything larger, prefer HGraph (latency-sensitive, high recall) or IVF (throughput-oriented, memory-friendly).
See also
- Creating an Index
- k-Nearest Neighbor Search
- Range Search
- Attribute Filter (Hybrid Search)
- Evaluation Tool
Quantization
Vector quantization is the central memory/recall lever in VSAG. Every index
type stores vectors through a base quantizer (configured by
base_quantization_type), and may keep a second precise quantizer for
re-ranking (precise_quantization_type + use_reorder: true). This chapter
documents each supported quantizer: what it does, what JSON parameters it
takes, when it needs training, which metrics it supports, and when to choose
it.
Storage and search pipeline
+---------------------+
raw vector -->| optional transform | (TQ chain: pca / rom / fht / mrle)
+----------+----------+
|
v
+---------------------+
| base quantizer | fp32 / fp16 / bf16 /
| | sq8 / sq4 / sq8_uniform /
| | sq4_uniform / pq / pqfs /
| | rabitq
+----------+----------+
|
v
+-------------------+
| index storage | (HGraph / IVF / Pyramid /
| | BruteForce / SINDI)
+---------+---------+
|
v
graph / list walk
|
+---------------+-----------------+
| |
use_reorder: false use_reorder: true
| |
v v
top-K result +---------------------+
| precise quantizer | re-rank
| (fp32 default; |
| fp16/bf16/sq8 OK) |
+----------+----------+
|
v
top-K result
use_reorder and precise_quantization_type are not specific to any single
quantizer — they apply whenever the index supports reordering (see
HGraph, IVF,
Pyramid).
Supported quantizers at a glance
The factory in src/datacell/flatten_interface.cpp dispatches to
the concrete quantizer based on the JSON type field.
base_quantization_type | Bits / dim (approx.) | Needs training | Lossless | Typical use |
|---|---|---|---|---|
fp32 | 32 | no | yes | Reference / precise reorder store |
fp16 | 16 | no | near-lossless | Half-precision storage; good default for high-dim float vectors |
bf16 | 16 | no | near-lossless | Same memory as fp16, wider dynamic range |
sq8 | 8 | yes | no | General memory-saving baseline |
sq4 | 4 | yes | no | Aggressive memory saving, expect recall drop without reorder |
sq8_uniform | 8 | yes | no | SIMD-friendly SQ8 with global min/max |
sq4_uniform | 4 | yes | no | SIMD-friendly SQ4; supports sq4_uniform_trunc_rate |
pq | ~pq_bits × pq_dim / dim | yes | no | Codebook-based, very compact |
pqfs | 4 × pq_dim / dim | yes | no | PQ FastScan — SIMD-accelerated PQ |
rabitq | 1 or HGraph x+y | yes | no | 1-bit / low-bit split binary quantization, strongest compression |
tq | depends on chain | depends on terminal quantizer | no | Transform Quantizer: prepend rotations / PCA before another quantizer |
int8 and sparse are not exposed as general-purpose
base_quantization_type values:
int8is selected automatically whendtype: "int8"is used; it is not a compression mode.sparsebacks the inverted lists of SINDI and is not selectable on dense indexes.
Training requirement
Quantizers marked yes above implement the NEED_TRAIN flag and require
either Build (which trains internally on the input vectors) or an explicit
Train call before Add. See Build and Train
for the full lifecycle.
For HGraph the training data is the base vectors passed to Build; for IVF
the centroids are trained first and the residuals fed to the configured
base quantizer.
Metric compatibility
All quantizers documented here support the three dense metrics
(l2 / ip / cosine). For cosine, the index normalizes vectors before
quantization, so the underlying quantizer never sees the original magnitude.
A few practical notes:
pq/pqfsperform their distance lookup tables per subspace; very lowpq_dim(≤ 4) onip/cosineis more sensitive to anisotropy thanl2.rabitqworks best when input vectors are decorrelated — either turn onrabitq_use_fht/rabitq_pca_dim, or wrap with atqchain like"pca, rom, rabitq".
Choosing a quantizer
A pragmatic decision tree:
- Need exact distances or a precise reorder store? Use
fp32. - Just want to halve memory with negligible recall loss? Use
fp16(orbf16if the data has a wide dynamic range, e.g. unnormalized embeddings). - Want ~4× memory saving and willing to enable reorder? Use
sq8(orsq8_uniformfor better SIMD throughput onl2/ip). - Memory-tight and willing to lose more recall before reorder? Use
sq4_uniform. - High-dim vectors, want strong compression with codebooks? Use
pq, orpqfswhen the platform supports the SIMD path. - Maximum compression (1-bit) and willing to pay reorder cost? Use
rabitq, ideally withrabitq_use_fht: trueor atqchain.
For every lossy quantizer above, enabling use_reorder: true with
precise_quantization_type: "fp32" is the standard way to recover recall at
the cost of extra memory; see the HGraph build-parameter table
for the exact behavior.
Where quantization is exposed
Not every index exposes every parameter as an external key. As of today:
- HGraph exposes the richest set:
base_quantization_type,precise_quantization_type,use_reorder,base_pq_dim,rabitq_pca_dim,rabitq_bits_per_dim_query,rabitq_bits_per_dim_base,rabitq_bits_per_dim_precise,rabitq_error_rate,rabitq_use_fht,sq4_uniform_trunc_rate,tq_chain(seesrc/algorithm/hgraph/hgraph.cpp). - IVF exposes
base_quantization_type,base_pq_dim, the common reorder keys, and the RabitQ tuning keysrabitq_pca_dim,rabitq_bits_per_dim_query,rabitq_bits_per_dim_base,rabitq_bits_per_dim_precise,rabitq_version,rabitq_error_rate, andrabitq_use_fht. - Pyramid exposes
base_quantization_type,base_pq_dim, the common reorder keys, and the RabitQ PCA, base/query bit, and FHT keys. - BruteForce exposes
base_quantization_typeand the common reorder keys; some tunables (e.g.tq_chain) are wired internally but not exposed as external keys today.
Refer to each index page for its full parameter list.
In this chapter
- FP32 (Baseline)
- Half-Precision (FP16 / BF16)
- Scalar Quantization (SQ4 / SQ8)
- Scalar Uniform (SQ4 / SQ8 Uniform)
- Product Quantization (PQ)
- PQ FastScan
- RaBitQ
- RaBitQ x+y Split
- Transform Quantizer (TQ)
FP32 (Baseline)
fp32 stores every coordinate as a 32-bit IEEE-754 float — the same layout
as the input vectors. It is the only fully lossless option in VSAG and
serves as the reference baseline that all other quantizers are compared
against.
Implementation:
src/quantization/fp32_quantizer.cpp, parameter filefp32_quantizer_parameter.cpp.
When to use it
- Reorder / precise store.
precise_quantization_type: "fp32"is the default precise store whenuse_reorder: true; the graph walk uses a cheap base quantizer and the top-K candidates are re-scored exactly against the fp32 copy. - Reference / ground truth. Building an index with
base_quantization_type: "fp32"gives the highest possible recall for that index type and is the standard baseline for benchmarking other quantizers (docs/docs/en/src/resources/eval.md). - Small datasets where memory is not the bottleneck.
- BruteForce with raw-vector retrieval.
SUPPORT_GET_RAW_VECTOR_BY_IDSis only advertised whenbase_quantization_typeisfp32and the metric allows it (src/algorithm/bruteforce/bruteforce.cpp).
Memory cost
4 × dim bytes per vector for the codes alone. When fp32 is used as a
precise store on top of a base quantizer, the per-vector cost is
base codes + 4 × dim.
Parameters
fp32 has no quantizer-specific JSON parameters.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "fp32",
"max_degree": 32,
"ef_construction": 300
}
}
Training
Not required. fp32 does not set NEED_TRAIN.
Metric compatibility
l2, ip, cosine — all supported with no special handling.
Related pages
- Quantization overview
- HGraph index — see
precise_quantization_type - Memory Management
Half-Precision (FP16 / BF16)
fp16 and bf16 store each coordinate in 16 bits instead of 32, cutting
code memory in half with near-lossless accuracy. They have no
quantizer-specific JSON parameters; the only difference is the bit layout
of the float format itself.
Implementation:
src/quantization/scalar_quantization/half_precision_quantizer.cppwith the type traits athalf_precision_traits.h. Runnable example:examples/cpp/321_index_fp16_hgraph.cpp.
FP16 vs BF16 at a glance
| Format | Sign | Exponent | Mantissa | Effective range | Precision |
|---|---|---|---|---|---|
fp16 | 1 | 5 | 10 | ~±6.55e4 | ~3 decimal digits |
bf16 | 1 | 8 | 7 | same as fp32 (~±3.4e38) | ~2 decimal digits |
Practical implications:
fp16keeps more mantissa bits — better precision for normalized embeddings whose values lie roughly in[-1, 1]. Standard choice for cosine-normalized vectors.bf16keeps the fullfp32exponent range — safer for raw, un-normalized features (e.g. weighted sums, accumulator-like embeddings). Loses some precision compared tofp16on values close to zero.
If you do not know which one to pick, start with fp16 for normalized
embeddings and bf16 for unnormalized or wide-range data.
When to use it
- Default “drop-in” memory saving on top of an
fp32baseline. Recall loss is typically below 1% on standard benchmarks (SIFT, GIST, Glove, sentence embeddings). - As a precise reorder store that is half the size of fp32:
precise_quantization_type: "fp16"or"bf16"withuse_reorder: true. - High-dim float vectors where 32-bit storage is the bottleneck.
Memory cost
2 × dim bytes per vector for the codes alone.
Parameters
Neither fp16 nor bf16 has quantizer-specific JSON parameters.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 768,
"index_param": {
"base_quantization_type": "fp16",
"max_degree": 32,
"ef_construction": 300
}
}
Swap "fp16" for "bf16" to switch formats. The input dtype stays
"float32": the quantizer converts on the fly.
Training
Not required. Neither fp16 nor bf16 sets NEED_TRAIN.
Metric compatibility
l2, ip, cosine — all supported. cosine is implemented by
normalizing inputs before storing them at 16-bit precision.
When not to use it
- When you also need a memory-aggressive base quantizer such as
sq8orpq— those already pull the storage well below 2 bytes/dim. - When you need exact distances (use
fp32).
Related pages
- Quantization overview
- HGraph index —
precise_quantization_typetable - Memory Management
Scalar Quantization (SQ4 / SQ8)
sq8 and sq4 are per-dimension scalar quantizers: each coordinate is
mapped from float32 to an 8-bit (sq8) or 4-bit (sq4) integer using a
per-dimension [min, max] range learned during training. They share the
same implementation, parameterized by bit width, in
src/quantization/scalar_quantization/scalar_quantizer.cpp and
scalar_quantizer_parameter.h.
For SIMD-friendlier variants with a global [min, max], see
Scalar Uniform.
SQ4 vs SQ8 at a glance
| Type | Bits / dim | Memory vs fp32 | Typical accuracy | Notes |
|---|---|---|---|---|
sq8 | 8 | ~1/4 | minor recall loss | General memory-saving baseline |
sq4 | 4 | ~1/8 | noticeable loss without reorder | Aggressive compression; pair with use_reorder: true |
The training is per-dimension min/max, so heavy-tailed coordinates can
waste code bits. If your data is anisotropic, consider either
Scalar Uniform or a Transform Quantizer
chain like "rom, sq8_uniform" to rotate first.
Memory cost (codes only)
sq8:dimbytes per vector.sq4:ceil(dim / 2)bytes per vector.
There is also a small per-dimension range table (8 × dim bytes,
amortized across all vectors).
Parameters
Neither sq8 nor sq4 has quantizer-specific JSON parameters today
(scalar_quantizer_parameter.h:36-58). The bit width is selected by the
type string alone.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 32,
"ef_construction": 300,
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
Replace "sq8" with "sq4" for 4-bit codes.
Training
NEED_TRAIN is set. Training collects per-dimension min / max from a
sample of the input vectors. Calling Build(base) trains internally; on
indexes that require an explicit Train (some IVF flows), call it before
Add. See Build and Train.
Metric compatibility
For sq8 and sq4, l2, ip, cosine are all supported. Distances are computed by decoding
the integer codes back to per-dimension scaled floats.
When to choose sq8 vs sq4
sq8: default memory-saving choice for graph indexes (HGraph, Pyramid) when ~4× memory reduction is the target. Recall loss is small enough thatuse_reorderis often optional, but enabling it withprecise_quantization_type: "fp32"is the safest setup.sq4: choose when memory is tight and you can afford a precise reorder store. Almost always pair withuse_reorder: true.- Pick
sq*_uniforminstead when the data is roughly homogeneous across dimensions; the uniform variants have higher SIMD throughput. - For heavy-tailed / anisotropic data, prefer a Transform Quantizer chain that rotates before quantization.
Related pages
Local SQ4 / SQ8 (float32 L2, IP and cosine)
HGraph accepts sq4_local and sq8_local as base_quantization_type, or as precise_quantization_type with use_reorder: true. Both support dense float32 L2, IP and cosine. For example, use RaBitQ traversal with local SQ4 reranking:
{
"dtype": "float32",
"metric_type": "cosine",
"dim": 128,
"index_param": {
"base_quantization_type": "rabitq",
"use_reorder": true,
"precise_quantization_type": "sq4_local",
"max_degree": 32,
"ef_construction": 300
}
}
Replace sq4_local with sq8_local for eight-bit precision. Direct search uses, for example, base_quantization_type: "sq8_local" and use_reorder: false. Existing optional FP32 reranking remains available; local reranking does not add a hidden FP32 store or third reranking stage. The name precise_quantization_type does not guarantee exact distances: both local stores are lossy, and SQ4’s coarser levels can reduce recall substantially. Choose precision and search settings using your own data.
Design and layout
Each vector stores its own FP32 minimum m and FP32 scale s = (max - min) / (2^bits - 1). Offsets 0–3 contain m, offsets 4–7 contain s, and codes start at offset 8. There is no padding or record alignment requirement.
| Type | Levels | Code payload | Bytes per vector |
|---|---|---|---|
sq8_local | 256 | One unsigned byte per component | dim + 8 |
sq4_local | 16 | Even component in low nibble, odd component in high nibble | ceil(dim / 2) + 8 |
For odd SQ4 dimensions the unused high nibble is zero. Code storage is N * bytes_per_vector, excluding graph, IDs, allocator capacity and optional other stores. Each query computer uses 4 * dim scratch bytes. Cosine encoding additionally uses a temporary 4 * dim normalization buffer.
Encoding rounds (x - m) / s to the nearest integer (half steps round upward) and clamps to [0, 255] or [0, 15]. Constant vectors use zero scale and zero codes. Decode reconstructs m + s * code with fused rounding (and finite endpoint clamping). Unlike sq8 / sq4, ranges are per vector rather than trained per coordinate. Unlike uniform SQ, queries remain FP32.
Metric definitions and normalization
- L2: squared Euclidean distance between the FP32 query and reconstructed database vector. Code-to-code distance reconstructs both operands independently.
- IP:
1 - dot(query, decoded_vector), retaining original magnitudes. Code-to-code uses1 - dot(decoded_a, decoded_b). Both minimum offsets contribute to the dot product. - Cosine: the quantizer applies the same
Normalizeroutine as existingScalarQuantizerto original database vectors before encoding, and to the FP32 query before ADC. Distance is then1 - dot, as above. Decoded vectors are not renormalized; quantization can change their norm, so results need not lie exactly in [0, 2]. Zero vectors remain zero; their cosine distance to any vector is 1.
HGraph passes the original vectors to these stores; the quantizer owns cosine normalization. The shared FP32 normalization semantics also apply to extreme values: norm overflow may produce zero normalized components, and underflow/denormal handling may preserve tiny components. This feature does not redefine the established normalization policy.
Numeric and training contract
Other input dtypes and transform wrappers around local SQ are rejected. Negative and constant vectors are supported; encoding and query processing reject NaN and infinity. Range calculation uses double intermediates. Extreme finite records use a double-intermediate distance fallback, with reconstructed values clamped to finite FP32 endpoints. Distances outside the FP32 range can be infinite. Tiny ranges may round the FP32 scale to zero and are subject to platform denormal handling. SIMD results agree within floating-point tolerance, not necessarily bit for bit.
Dimensions from 1 to INT_MAX, odd dimensions and unaligned records are supported. Shared kernels cover scalar, SSE, AVX, AVX2, AVX512 and NEON paths; SVE uses the scalar fallback. SSE/AVX reconstruct lanes scalarly with fused rounding before SIMD accumulation so that ADC agrees with the public decoder. Native ARM execution was unavailable for this change’s local validation.
There are no learned shared bounds or extra parameters. Train / ReTrain complete without consuming samples; encoding computes each vector’s metadata. HGraph reports NEED_TRAIN as false when either local type is the base store. HGraph’s normal Build/Train lifecycle still applies.
Persistence and Tune
Existing sq8, sq4, sq8_uniform, defaults and their formats are unchanged.
The local types require readers that support them. Records and quantizer metadata use existing native binary conventions, not a new cross-endian format. Loading validates dimension, metric and record length. HGraph serialization preserves base and precise stores, including RaBitQ plus either local quantizer. Tune can create them from retained raw data or FP32 codes; lossy local codes alone cannot supply a later precision-changing Tune. Existing ignore_reorder, reorder_source and Tune restrictions remain applicable.
No broad recall/throughput benchmark campaign accompanies this change.
Scalar Quantization Uniform (SQ4 / SQ8 Uniform)
sq8_uniform and sq4_uniform are scalar quantizers like
sq8 / sq4, except they learn a single global [min, max]
range that applies to every dimension. This trade-off — slightly less
adaptive per dimension, but a much simpler decode path — unlocks SIMD code
that runs significantly faster on l2 and ip distance kernels and
keeps the code layout tighter.
Implementation:
src/quantization/scalar_quantization/sq8_uniform_quantizer.cpp,src/quantization/scalar_quantization/sq4_uniform_quantizer.cpp.
Why it is fast: distances stay in the integer domain
This is the core reason to prefer sq*_uniform over sq* whenever it
applies. Because every dimension shares one (min, max) pair, the affine
decode x = min + code · (max - min) / (2^b - 1) has the same scale and
offset for every coordinate. That has three consequences in the hot path:
- The query is encoded once with the same global
(min, max)into a uint8 (or packed nibble) buffer, inProcessQueryImpl(src/quantization/scalar_quantization/sq8_uniform_quantizer.cpp:179). - Each base vector code is never decoded back to fp32. The kernel
SQ8UniformComputeCodesIP(uint8_t* q, uint8_t* x, dim)/SQ4UniformComputeCodesIP(...)reads both operands as raw integer codes and does the dot product on uint8 / packed nibble lanes using AVX-512 / AMX (or NEON on ARM), one cache-line at a time. There is no per-element fp dequantization in the inner loop. - The single shared scale factor and offset are applied once per
pair, after the integer reduction, to recover the fp distance. Some
metric-specific corrections (a per-vector norm or sum) are also added
outside the loop; see the trailing metadata noted in
sq8_uniform_quantizer.cpp:200and theSQ8UniformComputeCodesIPBatchbatch kernel.
In the per-dimension sq* quantizers, each coordinate has its own
(min_i, max_i) so the kernel either has to multiply by a per-dim scale
table inside the loop or decode at least one operand back to fp first.
Skipping that work is what makes uniform variants significantly faster at
the same recall.
When to use it
- HGraph / IVF / Pyramid hot paths. When the bottleneck is the
base-quantizer distance computation,
sq8_uniform/sq4_uniformare almost always faster than their non-uniform counterparts at comparable recall. - Data with similar coordinate ranges across dimensions. Normalized
embeddings (cosine), or vectors that have already been rotated (e.g.
through a Transform Quantizer
chain like
"rom, sq8_uniform"or"fht, sq8_uniform") are the ideal inputs. - As the terminal quantizer of a
tqchain. The most common chain is"pca, rom, sq8_uniform", see example 501.
SQ4 uniform vs SQ8 uniform
| Type | Bits / dim | Memory vs fp32 | Typical accuracy |
|---|---|---|---|
sq8_uniform | 8 | ~1/4 | minor recall loss |
sq4_uniform | 4 | ~1/8 | needs reorder for high recall |
Parameters
| Key | Type | Default | Applies to | Meaning |
|---|---|---|---|---|
sq4_uniform_trunc_rate | float | 0.05 | sq4_uniform only | Finite symmetric truncation rate for outliers in the inclusive range [0.0, 0.5]. Higher values clip more extreme coordinates, reducing range loss for the bulk of the data at the cost of clipping the tails. |
sq8_uniform has no quantizer-specific JSON parameters.
When using HGraph, sq4_uniform_trunc_rate is exposed as a top-level key
and mapped into the nested quantization params
(src/algorithm/hgraph.cpp:409-416).
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq4_uniform",
"sq4_uniform_trunc_rate": 0.05,
"max_degree": 32,
"ef_construction": 300,
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
Set "base_quantization_type": "sq8_uniform" and drop the trunc_rate
key for the 8-bit variant.
Training
NEED_TRAIN is set. Training estimates one global [min, max] across all
dimensions (with optional truncation for sq4_uniform). Build will
perform training internally.
Metric compatibility
l2, ip, cosine — all supported. cosine normalizes before quantizing,
which is also what makes uniform scaling close to optimal for that metric.
Choosing between uniform and non-uniform
- Data is normalized (
cosineor pre-normalizedl2) → uniform. - Data has very heterogeneous per-dimension ranges (e.g. mixed feature
blocks) → start with non-uniform
sq*, or use uniform behind a rotation transformer ("rom, sq*_uniform"). - Throughput matters more than the last bit of recall → uniform.
Related pages
Product Quantization (PQ)
Product Quantization splits a vector into pq_dim equal-sized subvectors
and quantizes each one independently against a small learned codebook of
2^pq_bits centroids. The stored code is then pq_dim × pq_bits bits per
vector — orders of magnitude smaller than fp32. Distance computations
use precomputed lookup tables (LUT) per query.
Implementation:
src/quantization/product_quantization/product_quantizer.cpp, parameter fileproduct_quantizer_parameter.cpp.
When to use it
- High-dim float vectors (≥ 256 dim) where
sq8is still too large. - Memory-tight, accuracy-acceptable workloads where ~16× compression vs fp32 is required.
- Combined with
use_reorder: trueand a smallfp16/fp32precise store, PQ is the standard “compressed graph index” recipe at large scale.
For wider SIMD throughput at pq_bits = 4, see PQ FastScan.
Memory cost (codes only)
ceil(pq_dim × pq_bits / 8) bytes per vector for the codes, plus a small
codebook stored once (pq_dim × 2^pq_bits × subspace_dim × 4 bytes).
For typical settings (pq_dim = 32, pq_bits = 8, dim = 128):
- code size =
32 × 8 / 8 = 32bytes per vector (vs128 × 4 = 512for fp32 → 16× smaller).
Parameters
| Key | Type | Default | Meaning |
|---|---|---|---|
pq_dim | int | 1 | Number of subvectors. Must divide dim. Larger values give finer quantization at the cost of more codebooks and larger codes (product_quantizer_parameter.h:38). |
pq_bits | int | 8 | Bits per subvector (1–8). With 8, each subvector is one byte. Most reliable with 8; see PQ FastScan for the 4-bit SIMD variant. |
On HGraph these are exposed as the top-level keys base_pq_dim and
pq_bits (src/algorithm/hgraph.cpp:465-472).
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "pq",
"base_pq_dim": 32,
"max_degree": 32,
"ef_construction": 300,
"use_reorder": true,
"precise_quantization_type": "fp16"
}
}
Training
NEED_TRAIN is set. Training runs k-means per subspace to learn the
2^pq_bits centroids; this is typically the most expensive training step
of any built-in quantizer. Use a training sample of at least 256 × 2^pq_bits vectors per subspace for stable codebooks; Build(base)
samples from the input automatically.
Metric compatibility
l2, ip, cosine — all supported. Query-time distance is computed via
a per-subspace LUT: for l2 it is squared L2 between the query subvector
and each centroid; for ip it is the dot product. Cosine reduces to ip
on pre-normalized vectors.
Tips
pq_dimshould dividedimevenly. Common ratios aredim/4ordim/8.- Very small
pq_dim(e.g.dim/16) produces very compact codes but loses recall fast; combine with reorder. - For anisotropic data, a rotation transformer in front improves PQ
recall noticeably: use Transform Quantizer
with a chain like
"rom, pq".
Related pages
PQ FastScan
pqfs is a SIMD-accelerated variant of Product Quantization that
fixes pq_bits = 4 and uses a memory layout designed for the AVX-2 /
AVX-512 “FastScan” lookup-table kernel. At the cost of being 4-bit only,
it delivers significantly higher distance-computation throughput.
Implementation:
src/quantization/product_quantization/pq_fastscan_quantizer.cpp, parameter filepq_fastscan_quantizer_parameter.cpp.
When to use it
- The platform has AVX-2 (and ideally AVX-512); the FastScan kernel is
the main reason to choose
pqfsoverpq. - Search throughput, not just memory, matters.
- 4-bit subspace codebooks (16 centroids per subvector) are sufficient for your recall target — typically yes when combined with reorder.
If your platform does not advertise the required SIMD width, fall back to
plain pq.
Memory cost (codes only)
ceil(pq_dim / 2) = (pq_dim + 1) / 2 bytes per vector — both even and odd
pq_dim are supported (src/quantization/product_quantization/pq_fastscan_quantizer.cpp:41).
Codebooks: pq_dim × 16 × subspace_dim × 4 bytes — significantly smaller
than 8-bit pq because the codebook has only 16 centroids per subspace.
Parameters
| Key | Type | Default | Meaning |
|---|---|---|---|
pq_dim | int | 1 | Number of subvectors. Must divide dim. pq_bits is fixed to 4 internally and not configurable (pq_fastscan_quantizer_parameter.cpp:28-33). |
Exposed on HGraph as base_pq_dim (src/algorithm/hgraph.cpp:465-472).
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "pqfs",
"base_pq_dim": 32,
"max_degree": 32,
"ef_construction": 300,
"use_reorder": true,
"precise_quantization_type": "fp16"
}
}
Training
NEED_TRAIN is set. Trains 16-centroid codebooks per subspace; cheaper
than the 256-centroid training in pq.
Metric compatibility
l2, ip, cosine — same coverage as pq. The LUT layout is metric-
specific but transparently handled by the quantizer.
Tips
pq_dimshould be a multiple of the SIMD-batch width the kernel expects (the implementation uses 32 internally on AVX-512). When in doubt, choosepq_dim ∈ {32, 64, 96, 128}.- The benefit over
pqis throughput at the same recall, not memory (4-bit codes are inherently smaller, butpqwithpq_bits = 4would match). - For maximum recall recovery, pair with
use_reorder: trueand anfp16orfp32precise store.
Related pages
RaBitQ
rabitq is VSAG’s binary / low-bit quantizer. In its default mode each
coordinate is encoded with 1 bit, giving the highest compression ratio
of any built-in quantizer. On HGraph, IVF, and Pyramid, an x+y split mode stores
low-bit base codes as x filter bits plus y supplement bits, so graph traversal
or IVF bucket scanning can use
only the filter code and re-ranking can fetch only the supplement bits it needs.
Implementation:
src/quantization/rabitq_quantization/rabitq_quantizer.cpp, parameter filerabitq_quantizer_parameter.cpp. For the complete split layout, lower-bound formula, and IO modes, see RaBitQ x+y Split.
When to use it
- Maximum compression. 1-bit codes are the smallest possible storage for dense vectors.
- High-dim embeddings where rotation + binarization preserves enough geometry for nearest-neighbor search.
- Combined with a precise reorder store (
fp16/fp32) — the standard recipe is “RaBitQ + reorder”, because the binary distance is noisy on its own.
For best accuracy, also enable rabitq_use_fht: true or wrap with a
Transform Quantizer chain such
as "pca, rom, rabitq".
Memory cost (codes only)
rabitq_bits_per_dim_base = 1:ceil(dim / 8)bytes per vector. Withdim = 768that is 96 bytes (vs 3072 for fp32 → 32× smaller).rabitq_bits_per_dim_base = xplusrabitq_bits_per_dim_precise = yon HGraph, IVF, or Pyramid: split mode stores roughly(x + y) * dim / 8bytes per vector for the RaBitQ code bytes. For example,3+5is aboutdimbytes per vector.
Parameters
| Key | Type | Default | Meaning |
|---|---|---|---|
pca_dim | int | 0 (= input dim) | Optional PCA preprocessing dimension applied inside RaBitQ. 0 means no PCA reduction (rabitq_quantizer_parameter.cpp:30-32). |
rabitq_bits_per_dim_query | int | 32 | Bits per dimension used to encode the query during search. Allowed values: 4 or 32 (rabitq_quantizer_parameter.cpp:38-43). |
rabitq_bits_per_dim_base | int | 1 | In standard RaBitQ, bits per dimension for the stored base code. In HGraph/IVF/Pyramid x+y split mode, this external key means x, the filter bits used during filtering. Allowed range [1, 8]. |
rabitq_bits_per_dim_precise | int | unset | HGraph/IVF/Pyramid split-mode key. When present with base_quantization_type: "rabitq" and precise_quantization_type: "rabitq", this means y, the supplement bits used for reorder/full-distance refinement. The sum x + y must be <= 8. |
rabitq_error_rate | float | 1.9 | Default lower-bound error multiplier for HGraph/IVF/Pyramid split search; must be finite and positive. It can be overridden per search under the hgraph or pyramid object. |
use_fht | bool | false | If true, applies a Fast Hadamard Transform rotation before binarization. Improves accuracy on anisotropic data with cheap O(dim log dim) cost (rabitq_quantizer_parameter.cpp:76-78). |
fast_encode_rabitq | bool | true | For stored codes wider than one bit, use CAQ-based fast encoding. Set to false to retain the exact RaBitQ encoder. The setting is ignored for one-bit codes. |
fast_encode_rabitq_rounds | int | 6 | Number of CAQ coordinate-adjustment rounds. Allowed range: [1, 32]. Each coordinate moves by at most one level per round. |
Multi-bit RaBitQ uses an LVQ initialization followed by fixed-round coordinate
adjustment when fast_encode_rabitq is enabled. This reduces code selection
from approximately O(2^B * dim * log(dim)) to O(rounds * dim) while keeping
the existing code layout and query estimator. The implementation follows the
CAQ component of SAQ; use the exact fallback
when measuring the quality/speed trade-off on a new dataset. These build-only
settings do not affect index loading compatibility. VSAG uses a clean-room
implementation and does not depend on the Apache-2.0 licensed
SAQ reference repository.
Index pages expose RaBitQ settings as top-level index_param keys:
HGraph exposes rabitq_pca_dim, rabitq_bits_per_dim_query,
rabitq_bits_per_dim_base, rabitq_bits_per_dim_precise,
rabitq_error_rate, and rabitq_use_fht; IVF exposes
rabitq_pca_dim, rabitq_bits_per_dim_query, rabitq_bits_per_dim_base,
rabitq_bits_per_dim_precise,
rabitq_version, rabitq_error_rate, and rabitq_use_fht; Pyramid exposes
the PCA, base/query bit, and FHT keys for its base quantizer. The
rabitq_use_fht key is an index-level alias for the quantizer’s internal
use_fht key and is rewritten by the index layer.
fast_encode_rabitq and fast_encode_rabitq_rounds are available on HGraph,
IVF, and Pyramid and are propagated to both base and precise RaBitQ quantizers.
For a normal first HGraph or Pyramid build with split RaBitQ and fast_encode_rabitq=true,
the index first encodes all vectors into one unsigned-byte scalar code per dimension, together
with the standard RaBitQ metadata and a separate 8-byte code sum per vector. It waits for
the encoding phase before starting graph tasks. Those tasks use scalar SIMD kernels
for code-code distances; the raw inner-product kernels are independent of the configured
x+y bit count, while the quantizer still applies the matching quantization range and
center. After construction, the scalar codes are packed
once into bit planes and written to the persistent filter and supplement stores without
rerunning PCA, ROM/FHT, or RaBitQ quantization. The temporary scalar records and code sums
are released before Build returns. For an 8-bit total code the scalar and packed
payloads have the same size; lower total bit counts trade extra build memory for faster
graph-distance evaluation.
Pyramid also uses the scalar SIMD build path described above for ordinary initial Build calls (NSW and ODescent), including 3+5 split configurations. Construction uses symmetric distances from the full x+y-bit codes, then packs filter/supplement records once and releases temporary storage. Setting fast_encode_rabitq=false selects the existing build path. Builds that use an imported Build Cache and subsequent Add calls do not enable this temporary build mode. Pyramid encodes in parallel when storage is preallocated and the IO supports it, and sequentially otherwise; both cases use scalar SIMD graph distances. Pyramid does not require the HGraph-only rabitq_fused_datacell option.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 768,
"index_param": {
"base_quantization_type": "rabitq",
"rabitq_use_fht": true,
"rabitq_pca_dim": 0,
"rabitq_bits_per_dim_base": 1,
"rabitq_bits_per_dim_query": 32,
"max_degree": 32,
"ef_construction": 300,
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
Swap to the higher-accuracy x+y split mode by setting both base and precise
quantization to RaBitQ and providing rabitq_bits_per_dim_precise. HGraph, IVF, and
Pyramid then automatically select the split datacell. In the example below, filtering uses
x = 3 filter bits and reorder reads only y = 5 supplement bits:
{
"base_quantization_type": "rabitq",
"precise_quantization_type": "rabitq",
"rabitq_bits_per_dim_base": 3,
"rabitq_bits_per_dim_precise": 5,
"rabitq_use_fht": true
}
Training
NEED_TRAIN is set. Training learns the rotation and per-dimension
statistics that make the 1-bit encoding well-balanced. The optional FHT
rotation is fixed (not learned), so it adds no extra training cost; PCA
preprocessing (when pca_dim > 0) trains a projection matrix.
Metric compatibility
l2, ip, cosine — all supported. The binary distance kernel is a
popcount over XORed code words; for ip / cosine the implementation
also tracks a residual norm so the inner-product estimate is unbiased.
Tips
- Always enable reorder unless you have validated that 1-bit recall
is acceptable on your data.
use_reorder: true+precise_quantization_type: "fp32"is the safe default. - Rotate first. For un-normalized data, set
rabitq_use_fht: trueor use atqchain that includesrom/fht. - Split mode for accuracy. HGraph/IVF/Pyramid
x+ysplit keeps anx-bit fast path for filtering and addsysupplement bits for re-ranking; expect significantly higher recall than pure 1-bit when using more total bits.
Related pages
RaBitQ x+y Split
RaBitQ x+y split is an HGraph, IVF, and Pyramid storage and search mode for low-bit base codes. Each vector is divided into two records:
xfilter bits are read during graph traversal or IVF bucket scanning.ysupplement bits are fetched only for candidates that reach reorder.- The final reorder distance uses all
x+ybits.
This layout keeps the traversal record small while retaining a higher-precision RaBitQ distance for final ranking. It also allows the filter record to stay in memory while the colder supplement record is stored on disk.
Enable split mode
HGraph, IVF, and Pyramid select split mode when both quantization types are
rabitq and
rabitq_bits_per_dim_precise is present:
{
"dtype": "float32",
"metric_type": "l2",
"dim": 960,
"index_param": {
"base_quantization_type": "rabitq",
"precise_quantization_type": "rabitq",
"use_reorder": true,
"rabitq_bits_per_dim_query": 32,
"rabitq_bits_per_dim_base": 3,
"rabitq_bits_per_dim_precise": 5,
"rabitq_error_rate": 1.9,
"max_degree": 64,
"ef_construction": 400
}
}
The relevant parameters are:
| Parameter | Meaning |
|---|---|
base_quantization_type | Must be "rabitq". |
precise_quantization_type | Must also be "rabitq" to select split mode. |
rabitq_bits_per_dim_base | x, the number of filter bits read during traversal. |
rabitq_bits_per_dim_precise | y, the number of supplement bits fetched during reorder. |
rabitq_bits_per_dim_query | Must be 32 for split storage. |
rabitq_error_rate | Default positive multiplier applied to the lower-bound error term. |
use_reorder | Should be true so candidates are ranked with the x+y distance. |
rabitq_fused_datacell | HGraph only; enables fused graph/code layout. Default: false. |
train_sample_count | Maximum HGraph training sample size. The default is 65536; the minimum explicit value is 512. |
The constraints are:
1 <= x <= 8
1 <= y <= 8
x + y <= 8
If rabitq_bits_per_dim_precise is omitted, HGraph, IVF, and Pyramid use the standard RaBitQ
path instead of split storage.
HGraph fused in-memory layout
For HGraph only, set rabitq_fused_datacell to true to store each
bottom-layer node’s neighbors, cluster id, label, x-bit code, and y-bit
supplement in one cache-line-aligned record. Pyramid uses ordinary split
storage; rabitq_fused_datacell is not a Pyramid parameter. The specialized
HGraph search loop reads the record directly and prefetches graph links and
quantized codes together. The codec uses 16 reproducibly trained residual
clusters.
By default, fused HGraph trains the base RaBitQ quantizer and fused KMeans codec on at most 65,536
vectors. When train_sample_count is smaller than the dataset, it selects that many vectors using
fixed-seed uniform reservoir sampling. Increasing the value can improve cluster-centroid quality at
the cost of build time and temporary training memory; set it to at least the dataset size to train
on all vectors.
This option creates an incrementally mutable in-memory index. After Build or
Deserialize, it supports Add, mark remove, vector/id/attribute/extra-info
updates, search (including filters, iterators, range search, and concurrent
queries), distance-by-id, memory statistics, and serialization/deserialization.
Force remove, merge, tune, clone, model export, and build-cache import/export
remain unsupported. Build and Deserialize leave the index mutable and retain
the search and per-node locks. Call SetImmutable explicitly when no more
mutations are needed; this terminal transition removes those locks from the
search path, and later mutations are rejected.
Each record starts with a 4-byte neighbor count followed by plain
InnerIdType neighbor IDs. There is no node version and no version encoding in
neighbor IDs. Cluster id, aligned external label, filter code, and supplement
follow; the record stride remains rounded up to a 64-byte boundary. Storage can
grow during Build and later Add operations.
The optimized build uses temporary SQ8 scalar codes for symmetric base-to-base
graph construction, then discards them. The completed index keeps only the
fused RaBitQ records. Post-build Add decodes fused records on demand for
pairwise graph updates, so it does not retain an SQ8 or raw-vector copy; batch
adds are preferable when insertion throughput matters.
The fused layout is opt-in and has stricter constraints than ordinary split storage:
1 <= x <= 4,y >= 1, andx + y <= 8.- The metric must be L2 or inner product.
- The graph, filter codes, and supplement codes must all use memory IO.
- MCI,
deduplicate_storage, removal metadata, reverse edges, and force remove must be disabled. - PCA is not supported; omit
rabitq_pca_dimor set it to0. - The legacy v0.14 serialization format is not supported.
- The fused slab wire format is versioned independently. The current format intentionally rejects earlier development formats that contained node versions and remove flags.
Indexes created without this option keep their existing layout, behavior, and serialization format.
Enable the filter/lower-bound search path with:
{
"hgraph": {
"ef_search": 200,
"parallelism": 4,
"rabitq_one_bit_search": true,
"rabitq_error_rate": 1.9
}
}
For Pyramid, put the equivalent search controls under pyramid:
{
"pyramid": {
"ef_search": 200,
"rabitq_one_bit_search": true,
"rabitq_error_rate": 1.9
}
}
The external search key is named rabitq_one_bit_search, but on a split index
it uses all x filter bits configured by rabitq_bits_per_dim_base.
hgraph.rabitq_error_rate and pyramid.rabitq_error_rate override the index
default for their respective searches without requiring a rebuild. Native
fused HGraph records store the geometric error scale before this multiplier is
applied. HNSW-compatible fused 1+7 records retain metadata scaled by the
canonical default and apply an override as a ratio at query time.
IVF bucket scans use the configured x filter bits automatically; no
rabitq_one_bit_search switch is needed. The ivf.factor parameter controls
how many filter-stage candidates (factor * topk) proceed to supplement
reranking.
Search pipeline
The split search path has four stages:
- The query is transformed and normalized once. Residual IVF builds one shared HighAcc lookup table from the original transformed query.
- IVF bucket scanning reads only the packed x-bit filter record and emits a distance estimate plus the reusable quantized filter inner product.
- IVF retains
factor * topkcandidates by filter distance. The candidate heap carries the source bucket, offset, and version needed to validate the saved inner product under concurrent updates. - Reranking reads only the y-bit supplement on the valid fast path and combines it with the saved filter contribution. Packed x bits are reread only by the correctness fallback for an invalid or stale candidate.
Encoding and bit planes
Let:
d = transformed dimension
x = filter bits per dimension
y = supplement bits per dimension
B = x + y
P = ceil(d / 8), bytes in one bit plane
q_i = transformed and normalized query coordinate
u_i = unsigned B-bit base code, 0 <= u_i < 2^B
The centered full code is:
c_B = (2^B - 1) / 2
z_i = u_i - c_B
N_B = sqrt(sum_i z_i^2)
PackIntoPlanes stores each logical bit of u_i in a separate bit plane.
The split is defined by:
f_i = floor(u_i / 2^y) # top x bits
s_i = u_i mod 2^y # low y bits
u_i = 2^y * f_i + s_i
The physical order keeps the most significant filter planes contiguous:
filter record: logical B-1, B-2, ..., B-x
supplement record: logical 0, 1, ..., y-1
This order lets traversal scan exactly x * P plane bytes and lets reorder
fetch exactly y * P additional plane bytes, excluding metadata and alignment.
Datacell layout
RaBitQSplitDataCell owns two RaBitQSplitCodeStorage instances.
Filter record
The filter record in x_bit_cell_ contains:
x high bit planes
base norm
filter-code norm when x > 1
optional MRQ residual norm
optional raw norm for IP/cosine
lower-bound error
filter approximation error
For one vector, its plane payload is:
FilterPlanesSize = x * ceil(d / 8)
The filter record is the hot traversal record. Graph search and prefetch do not need the supplement record while the x-bit estimate is valid.
Supplement record
The supplement record in supplement_cell_ contains:
y low bit planes
full-code norm
full-code approximation error
remaining metadata required by the selected metric and transforms
Its plane payload is:
SupplementPlanesSize = y * ceil(d / 8)
The complete code payload is approximately (x+y) * d / 8 bytes per vector,
plus aligned norms, errors, and optional transform metadata.
X-bit filter estimate and lower bound
The filter code for coordinate i is f_i in [0, 2^x - 1]. Define:
c_x = (2^x - 1) / 2
N_x = sqrt(sum_i (f_i - c_x)^2)
S_x = sum_i q_i * f_i
Q_sum = sum_i q_i
rho_x = (S_x - c_x * Q_sum) / N_x
During index construction, RaBitQ stores the absolute filter approximation
error E_x and the geometric error scale:
E_safe = clamp(abs(E_x), 1e-5, 1)
epsilon_x = sqrt(max(0, 1 - E_safe^2) / max(1, d - 1))
The corrected filter inner-product estimate is:
rho_hat_x = rho_x / abs(E_x)
For L2, with base norm N_o and query norm N_q, the x-bit distance and
lower bound are:
D_x = N_o^2 + N_q^2 - 2 * N_o * N_q * rho_hat_x
LB = D_x
- 2 * N_o * N_q * rabitq_error_rate * epsilon_x / abs(E_x)
The implementation subtracts a small floating-point guard from LB. IP and
cosine apply the corresponding metric conversion to the error term.
The lower bound is used only to reject candidates safely. D_x remains the
traversal estimate, while the final ranking uses the x+y distance.
IVF 1-bit, 2-bit, and 3-bit 32-vector FastScan layout
When IVF split storage uses x from 1 through 3, RaBitQSplitBucketDataCell
packages each bucket in groups of 32 candidates. These bucket-local packed
blocks are the only persistent filter-code layout and contain the SIMD-oriented
filter planes plus compact metadata. Single-lane x bits are unpacked from the
block only for correctness fallback or full-code access by ID. Supplement
reranking normally reads only the separate y-bit record.
Let G = ceil(d / 8) * 2 be the number of four-dimensional groups in one
bitplane. Each group stores one four-bit mask per candidate. For every group,
the 32 masks are transposed into 16 bytes. The low nibbles represent candidates
0..15, the high nibbles represent candidates 16..31, and both halves use
the lane order 0, 8, 1, 9, ..., 7, 15. Multiple bitplanes remain separate
and contiguous in plane-major order.
For x = 1, each group has the 16-entry subset-sum table:
LUT_g[m] = sum(q_(4g+j) for j in [0, 3] when bit_j(m) is set)
For x = 2 or x = 3, let bitplane zero be the most significant plane. The
centered lookup table is:
weight(x, p) = 2^(x - p - 2)
LUT_(p,g)[m] = weight(x, p) * sum((2 * bit_j(m) - 1) * q_(4g+j))
The plane weights are {1, 0.5} for two bits and {2, 1, 0.5} for three
bits. Coordinates beyond d are zero.
One-bit and two-bit scans use one query-wide unsigned-byte quantizer. Three-bit scans quantize each plane independently, preventing the low-weight plane from losing effective precision to the four-times-wider range of the high plane:
delta[p] = (v_max[p] - v_min[p]) / 255
plane_sum[p] ~= G * v_min[p] + delta[p] * sum LUT_u8[p][code]
centered_ip ~= plane_sum[0] + plane_sum[1] + plane_sum[2]
The three-bit scanner invokes PQFastScanLookUp32 once for each contiguous
plane. This keeps the same 3 * G total shuffle groups and packed-code size as
a single combined scan, while allowing separate dequantization scales.
For one bit, the lookup sum is converted to the normalized binary inner
product with sum(q) and sqrt(d). For two and three bits, it is already the
centered inner product and is divided by the stored filter-code norm. All paths
then use the stored RaBitQ norm and error metadata to recover the filter-stage
distance.
The packed filter planes are followed by the metadata for all 32 candidates.
A final bucket block with fewer than 32 vectors is zero-padded, while the
scanner returns only valid candidates. Runtime dispatch selects generic, SSE,
AVX2, or AVX-512 code. The dequantized byte-LUT result is saved as S_x and reused in the final
x+y estimate, so filtering and reranking share the same LUT quantization error. Wider filter widths continue to use the
bit-plane batch paths.
Example configurations are:
{
"rabitq_bits_per_dim_base": 1,
"rabitq_bits_per_dim_precise": 7,
"rabitq_bits_per_dim_query": 32
}
{
"rabitq_bits_per_dim_base": 2,
"rabitq_bits_per_dim_precise": 6,
"rabitq_bits_per_dim_query": 32
}
{
"rabitq_bits_per_dim_base": 3,
"rabitq_bits_per_dim_precise": 5,
"rabitq_bits_per_dim_query": 32
}
GIST1M comparison
The following results compare traditional 8-bit IVF RaBitQ with the split
layouts on gist-960-euclidean.hdf5 (1,000,000 base vectors, 960 dimensions,
L2). The index used 1,024 buckets, 100,000 training samples, 16 build threads,
and rabitq_bits_per_dim_query = 32. Search used one thread, 32 scanned
buckets, factor = 10, top-10, and 5,000 measured queries. The split search
results include the 32-vector FastScan layout described above.
| Layout | Build time (s) | Build TPS | Index memory (bytes) | Search QPS | Avg latency (ms) | Recall@10 |
|---|---|---|---|---|---|---|
| Traditional RaBitQ 8-bit | 150.742 | 6,633.840 | 1,284,595,208 | 17.767 | 56.280 | 0.9197 |
| Split RaBitQ 1+7 | 551.701 | 1,812.576 | 1,450,355,304 | 118.333 | 8.447 | 0.9019 |
| Split RaBitQ 2+6 | 509.431 | 1,962.974 | 1,582,237,512 | 74.786 | 13.368 | 0.9128 |
| Split RaBitQ 3+5 | 496.873 | 2,012.589 | 1,702,580,248 | 67.892 | 14.726 | 0.9094 |
These numbers are a single-machine comparison rather than a universal performance guarantee. Split storage spends more time building two code streams and keeps a bucket-local FastScan copy in memory. In return, its filter scan avoids reading the full 8-bit code for every candidate. On this workload, 2+6 provides the closest recall to traditional 8-bit RaBitQ while improving search throughput by about 4.2 times; 1+7 maximizes throughput with a larger recall trade-off.
Query lookup table and SIMD
For x = 2 and x = 3, the query computer builds a FastScan-style byte
lookup table. Each table row corresponds to eight query coordinates and has
256 entries:
LUT[block][byte_value]
= sum of q_i for the set bits in byte_value within that 8-D block
Each filter plane then contributes one lookup per byte instead of decoding
eight coordinates separately. Binary weights combine the x planes into
S_x.
The AVX2 and AVX512 kernels gather multiple lookup entries at once and also provide a batch-of-four path. The scalar implementation is kept as the portable fallback. The relevant entry points are:
RaBitQFloatMultiBitIPByLookupRaBitQFloatMultiBitIPBatch4ByLookupRaBitQFloatBuildByteIPLookupTable
An x-bit width outside the specialized set remains supported through the generic bit-plane computation path.
Reorder scans only y supplement bits
The full unsigned code satisfies:
sum_i q_i * u_i
= 2^y * sum_i q_i * f_i
+ sum_i q_i * s_i
For x >= 2, the dedicated fused HGraph search/reorder path carries the exact
x-bit filter inner product from traversal to reorder. It consumes the hint
while reading codes directly from the node record, so full rerank computes only
the second term from the y supplement planes. For L2 with an x-bit lookup
filter, HGraph, IVF, and Pyramid pass the previously computed filter distance to
reorder as a hint. ComputeDistWithSplitCodeAndFilterDist recovers the first
term from that hint and computes only the second term from the y supplement
planes:
full contribution = shifted filter contribution + supplement contribution
Thus fused 2+y, 3+y, and 4+y indexes reuse the exact x-bit filter inner
product and scan only the y supplement planes for each reranked candidate. The
richer filter-IP/full-distance candidate record is private to HGraph’s fused
search path; generic HGraph searchers, Pyramid, and ReorderInterface retain
the original distance/id candidate protocol. The fused 1+y traversal uses a four-bit query bit-plane and
popcount approximation; precise reranking recomputes its exact one-bit
contribution because the approximate value is not an exact full-distance
hint. If a usable hint is unavailable, the code computes the same final
distance directly from both split records.
Memory, disk, and hybrid IO
Both records use the base IO type unless a separate supplement IO type is configured.
Both records in memory
{
"base_io_type": "block_memory_io"
}
Both records on disk
{
"base_io_type": "async_io",
"base_file_path": "/data/hgraph_rabitq_split"
}
VSAG creates separate backing paths for the filter and supplement records.
Filter in memory, supplement on disk
{
"base_io_type": "block_memory_io",
"base_supplement_io_type": "async_io",
"base_file_path": "/data/hgraph_rabitq_split"
}
The supported mixed-IO combination keeps x_bit_cell_ in block memory and
places supplement_cell_ in async IO. During batched reorder, the filter
record is read by direct pointer while MultiRead fetches only supplement
records. base_supplement_file_path may be set explicitly; otherwise VSAG
derives a supplement path from base_file_path.
Serialization and loading
Use the normal index-level serialization API. Applications do not need to persist the two records independently.
std::ofstream out("/path/to/index.bin", std::ios::binary);
auto serialized = index->Serialize(out);
auto loaded = vsag::Factory::CreateIndex("hgraph", index_params).value();
std::ifstream in("/path/to/index.bin", std::ios::binary);
auto deserialized = loaded->Deserialize(in);
The split datacell serializes, in order:
- Base datacell state and supplement IO type.
- Filter storage.
- Supplement storage.
- RaBitQ quantizer state.
Create the destination index with parameters compatible with the serialized
index, especially dim, metric_type, x/y bit widths, and query bits.
Changing an encoded parameter requires rebuilding the index. Tuning only the
search-time hgraph.rabitq_error_rate or pyramid.rabitq_error_rate does not.
For a fused index, the codec model is serialized with the split datacell and the per-node codes are serialized once as part of the bottom-graph slab. Ordinary and streaming round trips preserve this layout without creating a second count-scaled copy of the split codes.
Implementation map
| Area | File / entry point |
|---|---|
| External x/y parameter mapping | hgraph_param_mapping.cpp, pyramid.cpp |
| Split record ownership and IO | src/datacell/rabitq_split_datacell.h |
| Plane layout and code splitting | RaBitQuantizer::StoredPlaneIndex, SplitCode |
| Filter estimate and lower bound | ComputeDistWithOneBitLowerBound |
| Direct split distance | ComputeDistWithSplitCode |
| Reorder using the filter-distance hint | ComputeDistWithSplitCodeAndFilterDist, ComputeDistWithSplitCodeAndFilterIP |
| SIMD dispatch | src/simd/rabitq_simd.cpp |
| AVX2 / AVX512 lookup kernels | src/simd/avx2.cpp, src/simd/avx512.cpp |
| Runnable memory/disk/hybrid example | examples/cpp/323_index_hgraph_rabitq_split.cpp |
Operational notes
- Split storage is currently available on HGraph, IVF, and Pyramid and requires
fp32 query codes. Pyramid enables the one-bit split search path by default for
split indexes; pass
rabitq_one_bit_search: falseunderpyramidto force the standard search path. l2,ip, andcosineare supported. Forx >= 2, the canonical ordinary split and fused HGraph paths directly reuse the exact filter inner product for L2 and inner product. Other cases safely compute the full split distance.- The fused datacell supports only L2 and inner product and only the in-memory configuration described above.
- Fused HGraph remains mutable after
Buildand deserialize. It supports post-buildAdd, mark remove, and vector/id/attribute/extra-info updates, but not force remove. CallSetImmutableexplicitly for the lock-free read-only search path. - With
support_duplicate: true, duplicate build probes and alias-expanding queries use the canonical HGraph searcher; the fused slab remains the code and graph storage. - Keep
use_reorder: trueunless x-bit traversal accuracy alone has been validated for the dataset. - Changing x, y, metric, or transform parameters requires rebuilding the
index. A search-time
hgraph.rabitq_error_rateorpyramid.rabitq_error_rateoverride does not. - Use RaBitQ for the general quantizer description and HGraph and Pyramid for the complete index parameter tables.
Quantization Transform
The Transform Quantizer (base_quantization_type: "tq") chains one or more vector
transformations in front of a final quantizer. Transformations reshape vectors so a downstream
quantizer can encode them more accurately or compactly — for example, rotate vectors so their
energy is spread across dimensions (RaBitQ / SQ benefit greatly), or reduce dimensionality with
PCA before storing them.
Runnable example:
examples/cpp/501_quantization_transform.cpp.
Why a transform layer
A pure quantizer compresses vectors directly. With low-bit quantizers (e.g. sq4,
sq*_uniform, rabitq) accuracy depends heavily on the distribution of vector
coordinates: heavy-tailed or anisotropic dimensions waste code bits. A transform layer
mitigates this:
- Random rotations (
rom,fht) decorrelate coordinates so a uniform/scalar quantizer works better on each axis. - PCA (
pca) reduces dimensions while keeping most of the variance — code size shrinks proportionally. - MRLE (
mrle) is a metric-recoverable low-rank encoding tailored to L2/IP search.
The transform output then feeds a standard quantizer (fp32, sq8, sq8_uniform, rabitq,
…), which actually stores the codes. The whole chain is referred to as tq (Transform
Quantizer).
Quick start
tq is exposed as a public, externally configurable quantization type by HGraph and
Pyramid. Both map tq_chain and dimension parameters into
base_codes.quantization_params. The MRLE + RaBitQ x+y split combination is available in both
indexes and automatically reorders from the base split datacell. IVF, BruteForce and WARP do
not currently expose tq_chain through their external parameter mapping.
std::string params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "tq",
"tq_chain": "pca, rom, sq8_uniform",
"rabitq_pca_dim": 64,
"max_degree": 32,
"ef_construction": 300,
"use_reorder": true,
"precise_quantization_type": "fp32"
}
})";
vsag::Resource resource(vsag::Engine::CreateDefaultAllocator(), nullptr);
vsag::Engine engine(&resource);
auto index = engine.CreateIndex("hgraph", params).value();
index->Build(base);
auto result = index->KnnSearch(query, topk, search_params).value();
In the example above, base vectors are first projected from 128 to 64 dimensions (pca),
randomly rotated (rom), then quantized with sq8_uniform. Reordering is enabled, so HGraph
keeps an fp32 precise copy and re-ranks the top candidates returned by the graph search
(include/vsag/index.h; see Memory Management for the storage implications).
tq_chain syntax
tq_chain is a comma-separated string: one or more transformer names followed by exactly
one final quantizer name. Whitespace around tokens is trimmed
(src/quantization/transform_quantization/transform_quantizer_parameter.cpp:53-74).
"<transform1>, <transform2>, ..., <quantizer>"
Examples:
| Chain | Effect |
|---|---|
"rom, fp32" | Random rotation, then store as fp32 (used for tests / sanity baselines). |
"fht, sq8_uniform" | Fast Hadamard rotation, then 8-bit uniform scalar quantization. |
"pca, rom, sq8_uniform" | PCA reduction, random rotation, then 8-bit uniform — the example chain. |
"pca, rom, rabitq" | PCA + rotation feeding the RaBitQ binary quantizer. |
"mrle, fp32" | MRLE projection then store as fp32 (MRLE must be first). |
"mrle, rabitq" | MRLE reduction followed by RaBitQ; with x+y split storage, filter and supplement codes are produced by the terminal RaBitQ. |
Constraints (transform_quantizer_parameter.cpp:33-45):
- The chain must contain at least one transformer + one quantizer (length ≥ 2). An empty
or single-token chain raises
INVALID_ARGUMENT. - The last token must be a quantizer that the TQ flatten path can dispatch: one of
fp32,sq8,sq8_uniform,sq4,sq4_uniform,bf16,fp16,pq,pqfs,rabitq(src/datacell/flatten_interface.cpp:126-164).TransformQuantizerParameterparses a slightly wider set of names (it also acceptssparse,int8,tq), but the flatten factory does not have a dispatch branch forint8/tqand explicitly rejectssparsewhenis_transform_quantizeris true (src/datacell/flatten_interface.cpp:166), so using any of those three as the terminal quantizer fails at index construction with an “unsupported quantization type” error. - RaBitQ x+y split storage supports TQ only for the exact chain
"mrle, rabitq". The split datacell reuses the terminal RaBitQ encoder and keeps RaBitQ internal FHT/ROM rotation behavior unchanged. Other transformed split chains are rejected. - Any unrecognized transformer name raises
INVALID_ARGUMENT: invalid transformer name(transform_quantizer.h:225-227).
Supported transformers
The factory at src/quantization/transform_quantization/transform_quantizer.h:192-227
recognizes four transformer names today:
| Name | Output dim | Description | Implementation |
|---|---|---|---|
pca | pca_dim if set, else input dim | Principal-Component-Analysis projection; reduces dim while keeping variance. | src/impl/transform/pca_transformer.h |
rom | input dim | Random Orthogonal Matrix; rotates vectors to decorrelate dimensions. | src/impl/transform/random_orthogonal_transformer.h |
fht | input dim | Fast Hadamard / KAC random rotation; cheaper variant of rom. | src/impl/transform/fht_kac_rotate_transformer.h |
mrle | mrle_dim (≤ input dim) | Metric-Recoverable Low-rank Encoding; must be the first transformer in the chain. | src/impl/transform/mrle_transformer.h |
Notes:
mrleplacement is enforced attransform_quantizer.h:155-159andmrle_dim ≤ input_dimattransform_quantizer.h:217-220.- Other strings declared in headers (
residual,normalize) are not wired into the factory and will be rejected.
Transformer parameters
The transformer JSON is read by VectorTransformerParameter::FromJson
(src/impl/transform/vector_transformer_parameter.cpp:22-35):
| Key | Type | Default | Meaning |
|---|---|---|---|
pca_dim | int | 0 (= input dim) | Output dim of the pca transformer. |
mrle_dim | int | 0 (= input dim) | Output dim of the mrle transformer. |
input_dim | int | auto | Auto-populated by the chain — do not set manually. |
HGraph external mapping
When using HGraph, top-level shortcuts are mapped into the nested quantizer params:
tq_chain→base_codes.quantization_params.tq_chainrabitq_pca_dim→base_codes.quantization_params.pca_dimmrle_dim→base_codes.quantization_params.mrle_dim
The name rabitq_pca_dim predates Transform Quantizer; when the chain includes pca, it
drives the pca transformer’s output dim (it is not RaBitQ-specific). When the chain
ends in rabitq without pca, the same key configures RaBitQ’s own PCA preprocessing
(src/quantization/rabitq_quantization/rabitq_quantizer_parameter.cpp:30).
mrle_dim accepts an integer in [0, dim]; 0 means the input dimension. It has an effect when
tq_chain includes mrle, which must remain the first transform in the chain.
Reordering and the precise codes store
Transform chains lose some information by design (rotation is lossless, but pca /
sq*_uniform / rabitq are not). Combining tq with reorder — keep a precise (typically
fp32) copy of every vector and re-rank the top candidates — restores accuracy with a
modest memory cost:
use_reorder: truemakes HGraph keep a second flatten store, the precise codes store (src/algorithm/hgraph.cpp:76-79).precise_quantization_typeselects its quantizer (fp32default; can befp16/bf16/sq8if you want to trade memory for accuracy).- At search time the graph walk uses the cheap
tqbase codes, then the top-K are re-scored against the precise codes (hgraph.cpp:978-981and surrounding sites).
use_reorder and precise_quantization_type are not specific to tq — they also apply when
base_quantization_type is sq8, pq, rabitq, etc. See the table in
HGraph index for the full per-index parameter list.
Choosing a chain
A pragmatic rule of thumb:
| Goal | Suggested chain | Notes |
|---|---|---|
| Memory-aggressive, accuracy-restored | "pca, rom, sq8_uniform" + use_reorder: true, precise_quantization_type: "fp32" | Example 501 baseline. |
| Maximum compression | "pca, rom, rabitq" + reorder | 1-bit quantization with rotation cleanup; expect noticeable accuracy loss without reorder. |
| Anisotropic data, no dim reduction | "rom, sq8_uniform" or "fht, sq8_uniform" | Use fht for lower build cost on high dim. |
| Distance-preserving low-rank | "mrle, fp32" | Metric-aware reduction, no further quantization. |
Always benchmark on your own data — the right tradeoff between tq aggressiveness and
use_reorder depends on dataset distribution, target recall, and memory budget.
Compatibility and merge
Two tq configurations are considered compatible only when the chain length, every
transformer name, and the final quantizer all match
(src/quantization/transform_quantization/transform_quantizer_parameter.cpp:99-117). This
matters for serialization round-trips and for any future merge / clone operations across
indexes — keep the chain string stable across builds you intend to combine.
Chain string equality is necessary but not sufficient. The
tq_chaintoken list does not encode transformer parameters such aspca_dim/mrle_dim(read as separate sibling JSON keys atsrc/quantization/transform_quantization/transform_quantizer.h:200-216) or the internal parameters of the terminal quantizer (e.g.pqsubspace count,rabitqrotation seed). These parameters change the effective code dimension and layout, so for two builds to be practically merge-/clone-compatible you must keep the entire transform + quantizer parameter set consistent, not just the chain string.
Related pages
- HGraph index — parameter reference for
base_quantization_type,use_reorder,precise_quantization_type. - Memory Management — memory cost of base + precise stores.
Code Structure
This page gives a quick tour of the VSAG repository layout.
Top-Level Directories
| Path | Contents |
|---|---|
include/vsag/ | Public C++ headers (index.h, engine.h, resource.h, constants.h, …) |
src/ | Core implementation and unit tests |
tests/ | Functional tests (Catch2) |
examples/cpp/ | C++ end-to-end examples |
examples/python/ | Python examples |
python/ | pyvsag packaging |
python_bindings/ | pybind11 bindings |
typescript/ | Node.js / TypeScript bindings (npm package vsag) |
tools/ | Utilities such as eval_performance, analyze_index, check_compatibility |
extern/ | Third-party dependencies (do not modify unless necessary) |
docs/ | Documentation (this site) and blog posts |
cmake/ | CMake modules |
Core Subsystems (inside src/)
- index: concrete index implementations (HGraph, IVF, Pyramid, SINDI, and others).
- quantization: FP32 / FP16 / BF16 / SQ4 / SQ8 / PQ quantizers with SIMD dispatch.
- graph: shared graph data structures used by HGraph and other graph-based indexes.
- storage: binary/reader sets, streaming serialization.
- allocator / thread pool: user-pluggable resource management.
- simd: cascaded SIMD dispatch for x86_64 and AArch64.
Naming Conventions
- Public API:
vsagnamespace, ininclude/vsag/. - Implementation:
src/, same namespace unless the file explicitly needs otherwise. - File extension:
.cpp(not.cc).
Build Artifacts
make debug / make release / make dev produce build trees:
build-debug/build-release/build-dev/
Each contains the test binaries, example executables, and libraries.
C++ Coding Guide
VSAG follows the Google C++ Style Guide with project-specific formatting, naming, and static-analysis rules. The configuration files at the repository root are authoritative:
.clang-formatdefines mechanical formatting..clang-tidydefines naming and static-analysis checks.
VSAG requires clang-format 15 and clang-tidy 15 exactly. Other versions may produce different formatting or diagnostics and are not accepted by CI.
Code layout
- Put public APIs in
include/vsag/and implementation code insrc/. - Use
.cppfor C++ source files, not.cc. - Keep code in the
vsagnamespace unless the file clearly requires otherwise. - Preserve API compatibility unless a breaking change is explicitly planned.
- Add or update Doxygen comments when changing a public API.
- Prefer
uint64_toversize_tin new code to avoid macOS compatibility problems. - Do not modify
extern/unless the change explicitly concerns a third-party dependency. - End every committed text file with a trailing newline.
Formatting rules
The formatter starts from the Google style and applies the following VSAG rules:
| Rule | VSAG style |
|---|---|
| Indentation | 4 spaces |
| C++ line length | At most 100 columns |
| Access specifiers | Outdented by one indentation level |
| Return types | Placed on a separate line from the function name |
Short blocks, functions, and if statements | Never collapsed onto one line |
| Pointer and reference alignment | * and & bind to the type |
| Includes | Sorted automatically |
| Wrapped parameters and arguments | One per line instead of bin-packed |
| Trailing comments | Aligned when they form a consecutive group |
| Existing comment text | Not automatically reflowed |
For example, clang-format 15 formats a class declaration like this:
class Example {
public:
Result<DatasetPtr>
Build(const DatasetPtr& base, const std::string& parameters);
private:
void
reset_state();
DatasetPtr dataset_;
};
The 100-column limit applies to C++ source and header files. It does not impose a line-width limit on Markdown or other non-C++ text.
Naming rules
The readability-identifier-naming check enforces these names:
| Entity | Style | Example |
|---|---|---|
| Namespace | lower_case | vsag::storage |
| Class | CamelCase | HGraphIndex |
| Struct | CamelCase | SearchResult |
| Type template parameter | CamelCase | DataType |
| Value template parameter | lower_case | max_degree |
| Free or C-style function | lower_case | normalize_vector |
| Public method | CamelCase | Build |
| Private method | lower_case | reset_state |
| Variable | lower_case | search_result |
| Private or protected data member | lower_case_ | search_result_ |
| Macro definition | UPPER_CASE | CHECK_ARGUMENT |
| Enum constant | UPPER_CASE | ROUTING |
| Global constant | UPPER_CASE | DEFAULT_RESIZE_INCREASE_COUNT_BIT |
make lint also runs scripts/linters/check-struct-names.py. It checks named struct definitions
across tracked project C/C++ files, including files outside the normal clang-tidy source scope.
Therefore, all new named structs must use CamelCase.
For entities without an explicit project rule, follow the Google C++ style and the convention in the surrounding code.
Choosing between class and struct
Use a struct for a passive, value-like data carrier:
- Its primary purpose is to group related values.
- Its data members are intentionally public and callers may modify them independently.
- It does not need to protect an invariant or hide a representation.
- It does not own a resource that requires controlled access or lifetime management.
Examples include Binary, SparseVector, and MultiVector. A struct may still have default
member initializers, constructors, or small helper functions. Having a method does not by itself
require using a class; the deciding factor is whether the type remains a transparent data value.
Use a class when the type represents behavior or must control its state:
- It maintains invariants through its public methods.
- It hides implementation details behind private or protected members.
- It owns or manages resources whose lifetime must be controlled.
- It provides a behavioral or polymorphic interface, especially one with virtual methods.
Examples include BinarySet, Dataset, Filter, and Index. Prefer class for new interfaces
and polymorphic base types.
When unsure, ask whether callers should be allowed to change every data member directly and in any
order. If yes, use struct; if the type must validate, coordinate, or restrict those changes, use
class. Do not use struct merely to avoid writing public:, and do not expose mutable
implementation details just to make a type aggregate-initializable.
Both classes and structs use CamelCase. Preserve the existing choice for a public type unless an
intentional API change requires otherwise; do not switch between class and struct as a cosmetic
cleanup.
Static analysis
All enabled clang-tidy diagnostics are treated as errors. The configuration enables the following check families:
bugprone-*for likely logic defects and suspicious constructs.clang-analyzer-*for path-sensitive correctness and lifetime analysis.clang-diagnostic-*for compiler diagnostics exposed through clang-tidy.modernize-*for supported modern C++ practices.openmp-*for OpenMP correctness.performance-*for avoidable copies and inefficient operations.readability-*for maintainability and naming consistency.
Some checks are deliberately disabled because they conflict with VSAG’s low-level and performance-sensitive code or are too noisy for the current codebase:
| Disabled check | Reason |
|---|---|
clang-analyzer-deadcode.DeadStores | Stored-but-unread values produce too much noise |
clang-diagnostic-deprecated-builtins | Some deprecated builtins are required for toolchain or platform compatibility |
bugprone-easily-swappable-parameters | Produces too many false positives |
modernize-use-trailing-return-type | Conflicts with the project return-type style |
modernize-avoid-c-arrays | C arrays are intentional in SIMD and other low-level code |
readability-identifier-length | Short names are acceptable when clear in context |
readability-magic-numbers | Numeric-heavy vector-index code makes the check excessively noisy |
readability-function-cognitive-complexity | Deferred until existing violations can be addressed |
readability-redundant-access-specifiers | Repeated access specifiers may separate method groups visually |
A disabled check is not a recommendation to ignore the underlying concern. Use meaningful parameter names and named constants whenever they make the code clearer.
Suppressing a diagnostic
Fix the underlying issue whenever practical. If a diagnostic is a false positive or the construct is required for compatibility or performance, use the narrowest suppression and explain why it is safe:
// NOLINTNEXTLINE(performance-no-int-to-ptr): the platform API stores this handle as an integer.
auto* handle = reinterpret_cast<Handle*>(raw_handle);
Name the exact check in NOLINT, NOLINTNEXTLINE, or a tightly scoped
NOLINTBEGIN/NOLINTEND pair. Avoid an unqualified NOLINT or a file-wide suppression unless
there is no narrower practical option.
Running the checks
Install the required tools:
# Ubuntu or Debian
sudo apt-get install clang-format-15 clang-tidy-15 clang-tools-15
# macOS
brew install llvm@15
Format all project C++ files and inspect the resulting diff:
make fmt
git diff --check
clang-tidy requires a compilation database in build-release/, so create a release build first:
make release
make lint
The default lint target uses production .cpp files under src/ as clang-tidy entry points and
excludes *_test.cpp. It also runs the repository-wide struct-name checker. Tests and other
directories are not separate clang-tidy entry points in this command.
make fix-lint applies suggested replacements in place. It may make broad edits, so use it on a
clean or safely backed-up working tree, review the complete diff, and rerun the checks and relevant
tests:
make fix-lint
make fmt
make lint
make test
Before submitting a change
- Format all changed C++ files with clang-format 15.
- Run clang-tidy 15 for changed production sources.
- Ensure names follow the rules above, including
CamelCasestruct names. - Keep every
NOLINTcheck-specific and document its rationale. - Add tests for new features and regression tests for bug fixes.
- Keep unit-test coverage for the C++ library at or above 90%.
- Run
git diff --checkand review the complete diff.
New Index Integration Checklist
Use this checklist when adding a new index implementation to VSAG. Keep the first pass small: make the index creatable through the public factory, support the lifecycle methods it advertises, and add feature flags only after the behavior is implemented and tested.
Required
-
Choose the public index name and type.
- Add the user-facing index name constant in
include/vsag/constants.horsrc/inner_string_params.hif the new index needs one. - Add an
IndexTypevalue ininclude/vsag/index.hwhen callers must distinguish this index throughIndex::GetIndexType(). - Keep the public name stable.
src/factory/index_registry.cppnormalizes factory names to lower case before lookup.
- Add the user-facing index name constant in
-
Implement the index behind the public
IndexAPI.- Prefer the current
IndexImpl<T>pattern insrc/index/index_impl.hfor new in-memory indexes: implementTas anInnerIndexInterfacesubclass undersrc/algorithm/<name>/. - Implement
static CheckAndMappingExternalParam(const JsonType&, const IndexCommonParam&)soIndexImpl<T>can validate external JSON and construct the internal parameter object. - Implement
GetName(),GetIndexType(),GetNumElements(),Add(),KnnSearch(),Serialize(StreamWriter&), andDeserialize(StreamReader&)as required by the inner-index contract; implementBuild()when the index supports it.InnerIndexInterface::Add()is pure virtual, so every subclass must override it even when the index only supportsBuild()and should throwUNSUPPORTED_INDEX_OPERATIONwithout enabling the corresponding feature flag. - Leave unsupported operations on the base class defaults instead of advertising them.
- Prefer the current
-
Wire creation through the factory and engine path.
- Add a creator in
src/factory/index_creators.cpp. - Register it in
register_all_index_creators(). - Use
IndexCommonParam::CheckAndCreate()fromsrc/index_common_param.cppfor shared fields:dtype,metric_type,dim, optionalrepr, optionalextra_info_size, allocator, thread pool, and old serialization format compatibility. - Add factory tests for the accepted name, invalid parameters, and unsupported parameter shapes in
src/factory/factory_test.cppor a focused test near the implementation.
- Add a creator in
-
Add build-system wiring.
- Add
src/algorithm/<name>/CMakeLists.txtand include it fromsrc/algorithm/CMakeLists.txt. - Add new sources to the closest existing target rather than creating a parallel build path.
- Keep file suffixes as
.cpp; do not editextern/unless the dependency itself is part of the change.
- Add
-
Define and validate index parameters.
- Put implementation parameters in a
<name>_parameter.{h,cpp}pair when the index has its own schema. - Implement JSON parsing,
ToJson(), andCheckCompatibility()for serialized/recreated parameter checks. - Reject invalid dimensions, metric/data-type combinations, missing required blocks, and unknown
modes with
ErrorType::INVALID_ARGUMENTthrough the existingCHECK_ARGUMENT/VsagExceptionflow. - Update
docs/docs/{en,zh}/src/resources/index_parameters.mdand the per-index docs if the parameter becomes user-facing.
- Put implementation parameters in a
-
Implement lifecycle behavior deliberately.
- Decide whether the index supports
Train(),Build(),ContinueBuild(),Add()after build, andAdd()from empty. - Decide whether
Remove(),UpdateId(),UpdateVector(),UpdateAttribute(), andUpdateExtraInfo()are supported. - For every supported mutation, test empty datasets, duplicate IDs if applicable, missing IDs, immutable index behavior, and search correctness after mutation.
- Keep
InitFeatures()in sync with the implemented operations.
- Decide whether the index supports
-
Implement search behavior and result packing.
- Support the public
KnnSearch()overloads required by the index, includingBitsetPtr,std::function<bool(int64_t)>, andFilterPtrfiltering when advertised. - Implement
SearchWithRequest()if the index supports the newer request path. - Return
Datasetfields consistently: IDs, distances,num_elements, result dimension, and optional result statistics. - Parse search parameters with the same nested index-name convention used by existing indexes such as HGraph.
- Support the public
-
Preserve serialization compatibility.
- Implement both
Serialize(StreamWriter&)andDeserialize(StreamReader&); the baseInnerIndexInterfaceadapts these toBinarySet,ReaderSet, and streams. - Store enough metadata to reject incompatible binaries, including parameter compatibility and
extra_info_sizewhen extra info is present. - Add round-trip tests through
BinarySetandReaderSetwhen the index supports both. - If the binary format changes for an existing index, update compatibility tests and document the migration path.
- Implement both
-
Add tests before advertising features.
- Unit tests should cover parameter parsing, build/add/search, serialization, feature flags, memory estimation if implemented, and error paths.
- Functional tests under
tests/should cover public API behavior that users can reach throughFactory::CreateIndex(). - Keep C++ unit-test coverage for
src/andinclude/at or above the project threshold.
Optional Adaptation Points
Add these only when the new index actually implements the behavior. When implemented, enable the
matching IndexFeature values in InitFeatures() and add focused tests.
-
Extra info (
extra_info/extrainfo).- Parse
extra_info_sizethroughIndexCommonParam. - Store fixed-size per-vector payloads from
Dataset::GetExtraInfos()and validateDataset::GetExtraInfoSize()duringBuild(),Add(), andUpdateExtraInfo(). - Implement
GetExtraInfoByIds()and populate search-result extra info when the feature is supported. - If the index supports extra-info filtering, document and test the search parameter that switches
Filter::CheckValid(const char*)on. - See
docs/docs/en/src/advanced/extra_info.mdandexamples/cpp/320_feature_extra_info.cpp.
- Parse
-
Statistics and analysis.
- Implement
GetStats()for static structure data that helps operators understand an index. - Implement
AnalyzeIndexBySearch(const SearchRequest&)only for query-driven analysis. - Include result statistics with
Dataset::Statistics()when search-time metrics are useful. - Keep tool output compatible with
tools/analyze_indexanddocs/docs/en/src/resources/analyze_index.md.
- Implement
-
Range search.
- Override the pure-virtual primary
RangeSearch(..., const FilterPtr&, ...)required byInnerIndexInterface, even when the algorithm does not support range search; in that case, throwUNSUPPORTED_INDEX_OPERATIONwithout enabling the corresponding feature flag. - Implement the other
RangeSearch()overloads only when the algorithm can honor radius semantics andlimited_size. - Test no-limit, limited, filtered, and empty-result cases.
- See
docs/docs/en/src/advanced/range_search.md.
- Override the pure-virtual primary
-
Filters and attributes.
- Support
BitsetPtr,std::function<bool(int64_t)>, orFilterPtronly when each path is wired through search. - If attribute filtering is supported, implement attribute storage/update paths and document accepted attribute schemas.
- Test the difference between bitset invalidation and
Filter::CheckValid()keep semantics.
- Support
-
Allocator, resource, and threading integration.
- Allocate long-lived structures with
IndexCommonParam::allocator_or a derived allocator-aware component. - Use the
Resourcethread pool when build/search work is parallelized. - Verify custom allocator and custom thread-pool examples still describe the behavior accurately.
- Mark concurrency features only after add/search/delete/update interactions are tested.
- Allocate long-lived structures with
-
Memory and introspection APIs.
- Implement
EstimateMemory(),EstimateBuildMemory(),GetMemoryUsage(), andGetMemoryUsageDetail()when the index can report meaningful numbers. - Implement
GetMinAndMaxId(),CheckIdExist(),ExportIDs(),GetVectorByIds(),GetDataByIds(),GetIndexDetailInfos(), orGetDetailDataByName()only when the backing storage supports them.
- Implement
-
Model export, clone, merge, tune, and cache import/export.
- Implement
Clone()andExportModel()when the index can be copied without sharing mutable storage incorrectly. - Implement
Merge()only when parameter compatibility, ID remapping, and deletion semantics are clear. - Implement
Tune(),ExportCache(), andImportCache()only with explicit parameter parsing and tests.
- Implement
-
Bindings, examples, benchmarks, and docs.
- Python bindings usually need updates only when the public API surface changes; current
pyvsagusers create indexes through names and JSON parameters. - Add C++ examples under
examples/cpp/when the index introduces a new user workflow. - Add Python examples/tests under
tests/python/if the behavior is reachable frompyvsag. - Add benchmark YAML under
benchs/when reviewers need repeatable performance data. - Add English and Chinese website docs under
docs/docs/{en,zh}/src/for user-facing indexes or parameters.
- Python bindings usually need updates only when the public API surface changes; current
Review Checklist
-
Factory::CreateIndex()andEngine::CreateIndex()create the index by the documented name. -
CheckFeature()returns true only for implemented and tested behavior. - Unsupported operations return
UNSUPPORTED_INDEX_OPERATIONthrough existing wrappers. - Serialization round trips preserve IDs, vectors or compressed codes, parameters, deletions, attributes, and extra info that the index claims to support.
- Search results remain valid after every supported lifecycle transition.
- Documentation lists user-facing parameters, supported metrics/data types, and unsupported operations.
- Practical validation has run: unit/functional tests for changed code, plus formatting or
git diff --checkfor documentation-only changes.
Building
This page documents how to build VSAG from source.
Build phase and cache reporting
The collector preserves Ninja history for clean, warm-cache, and unchanged no-op phases. A no-op is verified only when available Ninja telemetry records zero build edges and the command succeeds; missing telemetry is explicitly unverified. Every observed category, including nested production modules such as datacell, tools, examples, and unclassified work, appears in JSON and Markdown. Multi-output edges count once. Cumulative edge time sums overlapping parallel durations and is not wall time; nested ExternalProject work is included in its parent edge rather than individually timed.
Cacheable-request hit rate is hits / (hits + misses). Available uncacheable reasons,
including could_not_use_precompiled_header, are reported separately; missing counters
and zero denominators are null in JSON and n/a in Markdown. Overall cache coverage is
unavailable because compiler invocations bypassing ccache are not measured. Raw counters
remain in JSON. This reporting does not change PCH or cache correctness settings.
Dependency preparation timers cover source preparation and archive restoration before
configure, not all dependency work: further downloads, configuration, compilation and
installation occur in configure/build phases. Peak RSS from /usr/bin/time -v is the
maximum resident set size reported for the timed command and waited-for children, not
the sum of simultaneously resident build processes.
Prerequisites
- OS: Ubuntu 20.04+, CentOS 7+, or macOS 14+ on Apple Silicon
- Compiler: GCC 9.4.0+, Clang 13.0.0+, or Apple Clang from Xcode Command Line Tools
- CMake: 3.18.0+
- Ninja: preferred when available; Unix Makefiles are used as the fallback
- clang-format / clang-tidy: exactly version 15 (enforced)
- Optional: HDF5 (for
tools/eval/eval_performance), libaio (for theasync_iodata-cell backend), liburing (foruring_ioon Linux), Intel MKL.
We recommend using the official Docker dev image, which already contains the matching toolchain:
docker pull vsaglib/vsag:ubuntu
Makefile Targets
Running make help prints a concise list; the most common targets are:
debug Build debug binaries (no sanitizers; tests/tools/examples OFF by default)
release Build release binaries (tests/tools/examples OFF by default)
dev Developer build: debug + tests + tools + examples
test Build with tests enabled and run unit + functional tests
cov Build with coverage instrumentation enabled
asan Build with AddressSanitizer
tsan Build with ThreadSanitizer
fmt Run clang-format
lint Run clang-tidy
fix-lint Apply clang-tidy fix-its in-place (destructive)
pyvsag Build pyvsag for a specific Python version (PY_VERSION=...)
pyvsag-all Build pyvsag wheels for all supported Python versions
dist-pre-cxx11-abi Build redistributable tarball (pre-C++11 ABI)
dist-cxx11-abi Build redistributable tarball (C++11 ABI)
dist-libcxx Build redistributable tarball (libc++)
clean Remove build trees
These targets prefer Ninja when a usable ninja executable is available. Otherwise they fall back
to Unix Makefiles. An explicit generator always wins; for example, use
make debug CMAKE_GENERATOR='Unix Makefiles' to request the fallback directly. Because CMake build
trees are generator-specific, run the matching clean target before changing the generator for an
existing build directory.
For configure/build targets, pass DEBUG_BUILD_DIR as a plain path, for example
make asan DEBUG_BUILD_DIR="custom build". The shell removes these command-line quotes;
the recipes quote the path when invoking CMake. Do not embed literal quote characters in the variable.
Dependency size metrics
The build metrics JSON uses schema version 3. Each dependency’s local_bytes (the Markdown
report’s Local size) is an aggregate of file sizes, not allocated disk space. It replaces
source_bytes and build_bytes: HDF5 and OpenBLAS build in their source trees, and ANTLR4’s
binary directory is nested under its source tree, so those categories cannot be separated reliably.
For ExternalProject dependencies, the aggregate counts the entire dependency prefix once,
including source, build, install, and any metadata inside that prefix. Shared BUILD_INFO_DIR
files (by default .vsag-build-info: temporary files, stamps, logs, and metadata) are excluded
from both local_bytes and the preparation table’s external_build_bytes; neither measures all
ExternalProject storage. For FetchContent dependencies, it
sums the sibling <name>-src and <name>-build trees. Symlink entries are excluded. Separately
cached ExternalProject archives are reported in the preparation table, not added to local_bytes.
System dependencies show zero because host installations are not measured; unclassified or missing
trees also contribute zero. This is a snapshot of the measured local trees, not a source-only size
or a measurement of generated build artifacts alone.
PR ASan builds and standalone build benchmarks
Legacy no-op measurements are not trustworthy until #2899. The standalone workflow deliberately fails its preflight on collectors without the supported schema 3
true_noopreporting. Separating PR builds does not itself fix the collector. While the prerequisite is unmerged, scheduled runs fail clearly instead of publishing misleading legacy baselines.
PR CI’s ASan Build X86 runs one make asan COMPILE_JOBS=3, with Ninja, compile commands, system OpenBLAS, examples and tools enabled. The Makefile still enables tests, Sanitize and ASan/UBSan; changed-source lint, downstream tests, compatibility checks and build artifacts retain their existing contracts. ccache --zero-stats resets counters, not cached objects. The build step logs elapsed time, and an always-run step prints verbose/fallback cache diagnostics without hiding a build failure. ccache stays enabled in its default directory on the disposable GitHub-hosted VM; no compiler cache is restored or saved between jobs. PR X86 dependency archives intentionally change from restore-and-save to restore-only (actions/cache/restore@v4), preventing PR contexts from writing archives back to the shared cache; this policy is not limited to the standalone benchmark.
The separate Build Performance Benchmark workflow (.github/workflows/build_performance.yml) runs weekly on Mondays at 03:23 UTC on upstream main, or manually from Actions. It is not a PR required check and uses separate concurrency, an Ubuntu 22.04 hosted runner, a 180-minute timeout and 14-day report retention. Once the workflow is on the default branch, for example:
gh workflow run build_performance.yml --ref main -f ref=main
# Or use a full 40-character commit SHA reachable from this repository's main:
gh workflow run build_performance.yml --ref main -f ref=<full-main-history-sha>
The workflow must itself be dispatched from main (other workflow branches are skipped). It explicitly checks out antgroup/vsag upstream main, accepts only main or a full SHA in that fetched upstream history, and records the actual checked-out SHA (not the dispatch event SHA) and the main baseline in target.txt. PR refs, arbitrary branches and unmerged feature commits are intentionally rejected. Therefore a draft migration branch cannot benchmark itself through this entry point; hosted dispatch and end-to-end validation remain pending until the workflow and supported collector are available on main. Scheduled execution is disabled on forks.
Security is deliberately conservative: read-only contents permission, no business secrets, no persisted checkout credentials, quoted environment input with SHA/ancestry validation, and a fixed workflow-owned Ubuntu package list instead of privileged installation scripts from the selected ref. Packages match the PR’s OpenBLAS-only host setup; keep these lists synchronized. Dependencies are checked out at pinned revisions by the existing composite action; download archives are restored without saving. There is no compiler-cache restore/save. The fresh benchmark job owns build/ and .benchmark-ccache; --clear-ccache never touches the normal PR cache. Default build/ retains compatibility with the existing collector/Makefile API.
“Cold” means a clean build with an empty compiler cache, not a cold network or dependency-source download. The collector’s existing CLI measures configure → cold build → clean → warm ccache rebuild → no-op without another clean between warm and no-op. Reports include JSON, Markdown, phase logs, peak RSS, Ninja statistics and ccache details; toolchain.txt records tool versions and actual workflow environment values, while collector-command.txt records the shell-escaped collector invocation executed by the workflow. The collector’s phase logs remain authoritative for the underlying build commands/options; dependency preparation time/cache metadata remain in its report. The compiler-cache key is descriptive metadata, not evidence of restored cache. Parallel cumulative edge time is not wall time, and cache hit rates do not cover bypassed/PCH/link work; missing telemetry must not be read as zero.
The preflight checks the known schema 3 capability without executing the collector; it is not a proof of measurement correctness. After collection, the workflow additionally requires success, available Ninja telemetry, true_noop: true and zero build edges. #2899 can report a false no-op without failing collection, so this workflow fails such runs and preserves their raw reports for diagnosis. Residual generated/check edges require inspecting the logs and the collector’s build-edge classification. Unknown future schemas fail closed until reviewed. This trades temporary red/absent baselines for avoiding confidently mislabeled measurements; do not bypass the gate to restore legacy reports. Recheck #2899’s final API and tests after merge, including missing logs, rewritten history, multi-output edges and source/header rebuilds. This migration does not modify the collector, PCH, compiler flags or persistent caching.
Step-by-Step
git clone https://github.com/antgroup/vsag.git
cd vsag
./scripts/deps/install_deps.sh
make release
The dependency script selects the matching Linux distribution or macOS installer. macOS support currently targets the arm64 C++ build; published archives and Python wheels remain Linux-focused.
Resulting binaries from a plain make release:
- Library:
build-release/src/libvsag.{a,so}
Examples and tools are not built by default. To include them, either use make dev, or enable
the corresponding Makefile variables (VSAG_ENABLE_EXAMPLES=ON, VSAG_ENABLE_TOOLS=ON) or the
underlying CMake cache options (-DENABLE_EXAMPLES=ON, -DENABLE_TOOLS=ON).
Environment Variables / CMake Options
The Makefile exposes a few VSAG_ENABLE_* environment variables that are translated into CMake
cache options (ENABLE_*). Defaults below reflect a plain make release.
| Makefile env var | CMake option | Default | Effect |
|---|---|---|---|
VSAG_ENABLE_INTEL_MKL | ENABLE_INTEL_MKL | OFF | Use Intel MKL for BLAS kernels |
VSAG_ENABLE_LIBAIO | ENABLE_LIBAIO | ON on Linux | Enable the async_io data-cell backend via libaio |
VSAG_ENABLE_TOOLS | ENABLE_TOOLS | OFF | Build utilities under tools/ |
VSAG_ENABLE_EXAMPLES | ENABLE_EXAMPLES | OFF | Build sample programs under examples/cpp/ |
| n/a | ENABLE_LIBURING | OFF | Enable the Linux uring_io backend when liburing is installed |
| n/a | ENABLE_CUDA | OFF | Build the optional CUDA backend; requires an NVIDIA toolkit |
| n/a | CMAKE_BUILD_TYPE | driven by Makefile target | Debug / Release |
When invoking CMake directly instead of using make, use the underlying CMake cache option names:
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DENABLE_INTEL_MKL=ON
cmake --build build-release -j
To enable io_uring, install liburing and add -DENABLE_LIBURING=ON. On non-Linux systems or
when liburing is unavailable, VSAG compiles without native io_uring support; a configuration
that requests uring_io then logs a one-time warning and falls back to buffer_io.
Offline / Air-gapped Builds
VSAG downloads its third-party libraries at configure/build time. In offline or
restricted-network environments, set the per-dependency VSAG_THIRDPARTY_*
environment variables to fetch each archive from a local path or an internal
mirror (internal HTTP server, object storage, etc.). See
Offline / Air-gapped Builds for the full list of variables
and worked examples.
Python Wheel (pyvsag)
make pyvsag PY_VERSION=3.10
# Or build all supported versions in parallel:
make pyvsag-all
Wheels are emitted under python/dist/.
Distribution Tarballs
For ABI-compatible redistribution use one of:
make dist-pre-cxx11-abi # _GLIBCXX_USE_CXX11_ABI=0
make dist-cxx11-abi # _GLIBCXX_USE_CXX11_ABI=1
make dist-libcxx # libc++ (Clang)
The produced tarballs contain headers, static/shared libraries, and version metadata.
Release Publishing
To publish a new GitHub Release, use the Build and Publish Release workflow in the GitHub
Actions tab and run it manually with:
branch: the branch, tag, or commit SHA to release fromtag_name: the new release tag, such asv1.0.0prerelease: whether to mark the release as a prerelease
For a local dry run of the same packaging script, run:
COMPILE_JOBS=6 bash ./scripts/release/dist.sh
For accelerated dependency downloads in both ABI containers, optionally set
VSAG_THIRDPARTY_ENV_FILE to a readable regular file on the host (quote paths containing spaces):
VSAG_THIRDPARTY_ENV_FILE="/path/to/dependency urls.env" bash ./scripts/release/dist.sh
Use ASCII VSAG_THIRDPARTY_<NAME>=URL lines, with uppercase letters, digits and underscores
in names, including pin-qualified names. URLs must start with a scheme
such as https:// and contain no whitespace; do not use export, quotes or inline comments.
Blank lines and full-line # comments are allowed; LF, CRLF and a missing final newline work.
Values are literal: =, &, # and $ are preserved without shell expansion. Duplicate keys,
invalid entries and lines longer than 65,534 bytes (excluding line endings) fail before image builds.
File values override image defaults; host dependency variables are not forwarded or modified.
CMake prefers pinned names over legacy names and retains default URLs as download fallbacks.
An unset path preserves existing behavior; an explicitly empty or unreadable path is an error.
You can increase COMPILE_JOBS if your machine has enough memory, but the default is conservative
to avoid out-of-memory failures in CI runners.
Offline / Air-gapped Builds
VSAG downloads a set of third-party libraries at CMake configure / build time
(via ExternalProject_Add and FetchContent). On a machine without internet
access, or behind a slow / restricted network, those downloads can fail or time
out. This page explains how to point each dependency at a local path or an
internal mirror (internal HTTP server, object storage, Artifactory, etc.) so the
build can complete fully offline.
How third-party downloads are resolved
For every downloaded dependency, VSAG builds a list of candidate URLs and lets
CMake try them in order, stopping at the first one that succeeds. Using
antlr4 as the representative example
(extern/antlr4/antlr4.cmake):
set (antlr4_urls
https://github.com/antlr/antlr4/archive/refs/tags/4.13.2.tar.gz
)
vsag_resolve_thirdparty_override (ANTLR4 4.13.2 antlr4_urls)
ExternalProject_Add (antlr4
URL ${antlr4_urls}
URL_HASH MD5=3b75610fc8a827119258cba09a068be5
...)
The resolution order is therefore:
- The pinned variable,
VSAG_THIRDPARTY_<LIB>_<PIN_SUFFIX>, for the exact dependency identifier used by the current branch. - The unversioned
VSAG_THIRDPARTY_<LIB>variable, as a deprecated compatibility fallback. If both variables are set, the pinned variable wins. - The authoritative upstream URL or URLs (GitHub / project release page).
VSAG does not provide a project-controlled archive mirror. For a local or internal mirror, set the pinned variable (preferred) or the deprecated legacy variable.
Only the pinned variable for the branch’s exact pin is read. Pinned variables for other versions can remain exported and are ignored. This lets one CI image or shell profile safely support several VSAG branches at once.
Pinned-variable names
The suffix is derived from the exact pinned identifier:
- Numeric releases strip one leading
v/Vand a dependency-name decoration immediately before the number. ASCII letters are uppercased and runs of other characters become one underscore. Thus10.2.1andv10.2.1become10_2_1,hdf5_1.14.4becomes1_14_4, andyaml-cpp-0.9.0becomes0_9_0. - A full commit hash becomes
COMMIT_<12_HEX>, for exampleVSAG_THIRDPARTY_CPUINFO_COMMIT_CA678952A9A8. - Other tags and refs become
TAG_<NORMALIZED>_H<12_HEX>. The hexadecimal part is the uppercase prefix of SHA-256 over the exact, case-sensitive identifier, so spellings that normalize alike remain distinguishable. - If two pins for one dependency collide at 12 hexadecimal characters, both names extend deterministically one character at a time. Configuration fails instead of accepting an ambiguous name if the full hash or digest cannot distinguish them.
Key facts before you start
- The value may be a local path or a URL. Accepted forms include an
absolute filesystem path (
/data/deps/fmt-10.2.1.tar.gz), afile://URL, or anyhttp(s)://URL — including an internal HTTP server or object storage. - The archive hash is still verified. Each dependency declares a
URL_HASH(MD5 or SHA256). Your mirrored / local archive must be byte identical to the upstream archive, otherwise CMake aborts with a hash mismatch. The simplest safe approach is to download the exact upstream file once and re-host it unchanged. - Overrides are read at configure time. If you change a variable after a
previous configure, re-run CMake configure or run
make cleanfirst so the new value takes effect. - Use a non-empty value, or leave it unset. CMake treats a variable that is
exported but empty as defined, so
export VSAG_THIRDPARTY_FMT_10_2_1=would prepend an empty entry to the URL list and break the download. To disable an override,unsetit instead of setting it to an empty string. - Each dependency is independent. There is no single global mirror variable; set one pinned variable per dependency you need. You only need to set variables for the dependencies your build actually pulls in (see Which dependencies do I need?).
- Confirmation in the log. CMake reports the dependency, pin, selected source category and variable, plus fallback/deprecation behavior. It intentionally does not print the value because URLs can contain credentials.
Environment variables
Pinned variable on main | Library | Upstream archive to mirror | Pulled in when |
|---|---|---|---|
VSAG_THIRDPARTY_JSON_3_11_3 | nlohmann/json 3.11.3 | github.com/nlohmann/json/.../v3.11.3.tar.gz | always |
VSAG_THIRDPARTY_ANTLR4_4_13_2 | ANTLR4 runtime 4.13.2 | github.com/antlr/antlr4/.../4.13.2.tar.gz | always |
VSAG_THIRDPARTY_OPENBLAS_0_3_34 | OpenBLAS 0.3.34 | github.com/OpenMathLib/OpenBLAS/.../OpenBLAS-0.3.34.tar.gz | default BLAS backend (when not using system / MKL) |
VSAG_THIRDPARTY_CPUINFO_COMMIT_CA678952A9A8 | pytorch/cpuinfo | github.com/pytorch/cpuinfo/archive/ca678952...tar.gz | always |
VSAG_THIRDPARTY_FMT_10_2_1 | fmt 10.2.1 | github.com/fmtlib/fmt/.../10.2.1.tar.gz | always (unless system fmt) |
VSAG_THIRDPARTY_THREAD_POOL_COMMIT_3507796E172D | log4cplus/ThreadPool | github.com/log4cplus/ThreadPool/archive/3507796e...tar.gz | always |
VSAG_THIRDPARTY_TSL_1_4_0 | Tessil/robin-map 1.4.0 | github.com/Tessil/robin-map/.../v1.4.0.tar.gz | always |
VSAG_THIRDPARTY_ROARINGBITMAP_3_0_1 | CRoaring 3.0.1 | github.com/RoaringBitmap/CRoaring/.../v3.0.1.tar.gz | always |
VSAG_THIRDPARTY_CATCH2_3_7_1 | Catch2 3.7.1 | github.com/catchorg/Catch2/.../v3.7.1.tar.gz | ENABLE_TESTS=ON |
VSAG_THIRDPARTY_HDF5_1_14_4 | HDF5 1.14.4 | github.com/HDFGroup/hdf5/.../hdf5_1.14.4.tar.gz | ENABLE_TOOLS=ON (+ C++11 ABI) |
VSAG_THIRDPARTY_ARGPARSE_3_1 | p-ranav/argparse 3.1 | github.com/p-ranav/argparse/.../v3.1.tar.gz | ENABLE_TOOLS=ON (+ C++11 ABI) |
VSAG_THIRDPARTY_YAML_CPP_0_9_0 | yaml-cpp 0.9.0 | github.com/jbeder/yaml-cpp/.../yaml-cpp-0.9.0.tar.gz | ENABLE_TOOLS=ON (+ C++11 ABI) |
VSAG_THIRDPARTY_TABULATE_COMMIT_3A58301067BB | p-ranav/tabulate | github.com/p-ranav/tabulate/archive/3a583010...tar.gz | ENABLE_TOOLS=ON (+ C++11 ABI) |
VSAG_THIRDPARTY_HTTPLIB_0_35_0 | cpp-httplib 0.35.0 | github.com/yhirose/cpp-httplib/.../v0.35.0.tar.gz | ENABLE_TOOLS=ON (+ C++11 ABI) |
VSAG_THIRDPARTY_PYBIND11_2_11_1 | pybind11 2.11.1 | github.com/pybind/pybind11/.../v2.11.1.tar.gz | Python bindings (pyvsag / ENABLE_PYBINDS=ON) |
The exact upstream URL and the expected
URL_HASHfor each dependency are the single source of truth in the correspondingextern/<lib>/<lib>.cmakefile. Check that file when mirroring, especially after a version bump.
Not listed here (no download, so no override needed): Intel MKL, which is located on the host
with find_path.
Which dependencies do I need?
You only have to mirror what your specific build actually downloads:
- Core library (
make debug/make release):JSON,ANTLR4,OPENBLAS,CPUINFO,FMT,THREAD_POOL,TSL,ROARINGBITMAP. Two of these are conditional:OPENBLASis not downloaded when BLAS comes from Intel MKL (x86_64 withENABLE_INTEL_MKL=ON) or from a system OpenBLAS, andFMTis skipped when a systemfmtis found. - + Tests (
make test,ENABLE_TESTS=ON): alsoCATCH2. - + Tools (
ENABLE_TOOLS=ONandENABLE_CXX11_ABI=ON): alsoHDF5,ARGPARSE,YAML_CPP,TABULATE,HTTPLIB— downloaded only when both options are enabled (seecmake/VSAGThirdParty.cmake). - + Python wheel (
make pyvsag): alsoPYBIND11.
Examples
A. Internal HTTP server or object storage (recommended)
Re-host the upstream archives unchanged on an internal endpoint, then point each variable at it. A base-URL shell variable keeps this compact:
# Internal mirror that serves the upstream archives byte-for-byte
export VSAG_MIRROR=https://mirror.corp.example.com/vsag-thirdparty
export VSAG_THIRDPARTY_JSON_3_11_3=$VSAG_MIRROR/v3.11.3.tar.gz
export VSAG_THIRDPARTY_ANTLR4_4_13_2=$VSAG_MIRROR/antlr4-4.13.2.tar.gz
export VSAG_THIRDPARTY_OPENBLAS_0_3_34=$VSAG_MIRROR/OpenBLAS-0.3.34.tar.gz
export VSAG_THIRDPARTY_CPUINFO_COMMIT_CA678952A9A8=$VSAG_MIRROR/cpuinfo-ca678952.tar.gz
export VSAG_THIRDPARTY_FMT_10_2_1=$VSAG_MIRROR/fmt-10.2.1.tar.gz
export VSAG_THIRDPARTY_THREAD_POOL_COMMIT_3507796E172D=$VSAG_MIRROR/thread_pool-3507796e.tar.gz
export VSAG_THIRDPARTY_TSL_1_4_0=$VSAG_MIRROR/robin-map-1.4.0.tar.gz
export VSAG_THIRDPARTY_ROARINGBITMAP_3_0_1=$VSAG_MIRROR/CRoaring-3.0.1.tar.gz
make release
An object-storage endpoint works identically: use its network-reachable object URL.
B. Pre-downloaded local files (fully air-gapped)
On a machine that has no network at all, copy the archives onto the box first
(e.g. to /data/vsag-deps) and point the variables at the local files:
export VSAG_THIRDPARTY_JSON_3_11_3=/data/vsag-deps/v3.11.3.tar.gz
export VSAG_THIRDPARTY_ANTLR4_4_13_2=/data/vsag-deps/antlr4-4.13.2.tar.gz
export VSAG_THIRDPARTY_OPENBLAS_0_3_34=/data/vsag-deps/OpenBLAS-0.3.34.tar.gz
export VSAG_THIRDPARTY_CPUINFO_COMMIT_CA678952A9A8=/data/vsag-deps/cpuinfo-ca678952.tar.gz
export VSAG_THIRDPARTY_FMT_10_2_1=/data/vsag-deps/fmt-10.2.1.tar.gz
export VSAG_THIRDPARTY_THREAD_POOL_COMMIT_3507796E172D=/data/vsag-deps/thread_pool-3507796e.tar.gz
export VSAG_THIRDPARTY_TSL_1_4_0=/data/vsag-deps/robin-map-1.4.0.tar.gz
export VSAG_THIRDPARTY_ROARINGBITMAP_3_0_1=/data/vsag-deps/CRoaring-3.0.1.tar.gz
make release
A file:// URL (export VSAG_THIRDPARTY_FMT_10_2_1=file:///data/vsag-deps/fmt-10.2.1.tar.gz)
is equally valid.
C. Override a single dependency
If only one download is unreliable, override just that one and let the rest use the defaults:
export VSAG_THIRDPARTY_OPENBLAS_0_3_34=https://mirror.example.com/OpenBLAS-0.3.34.tar.gz
make release
D. Keep several branch pins exported
These variables can coexist. After later backports land, switching branches will select only that branch’s pin without editing the environment:
export VSAG_THIRDPARTY_OPENBLAS_0_3_23=/data/vsag-deps/OpenBLAS-0.3.23.tar.gz
export VSAG_THIRDPARTY_OPENBLAS_0_3_34=/data/vsag-deps/OpenBLAS-0.3.34.tar.gz
export VSAG_THIRDPARTY_YAML_CPP_0_8_0=/data/vsag-deps/yaml-cpp-0.8.0.tar.gz
export VSAG_THIRDPARTY_YAML_CPP_0_9_0=/data/vsag-deps/yaml-cpp-0.9.0.tar.gz
git switch main # selects OpenBLAS 0.3.34 and yaml-cpp 0.9.0
make release
Alternative: reuse system libraries
For dependencies that are already installed on the host, you can skip the
download entirely instead of mirroring it. Set VSAG_USE_SYSTEM_DEPS=ON (or the
per-dependency VSAG_USE_SYSTEM_<DEP>=ON). See
DEVELOPMENT.md
for the list of dependencies that currently support system reuse.
Troubleshooting
- Hash mismatch / “HASH mismatch” error — your mirrored or local archive is
not byte-identical to the upstream file. Re-download the exact upstream
archive and re-host it unchanged, or confirm the expected
URL_HASHinextern/<lib>/<lib>.cmake. - Override seems ignored — make sure the variable was
exported in the same shell that runsmake/cmake, then re-run configure (ormake clean), because the value is read at CMake configure time. Confirm that theThird-party overridediagnostic names the expected pin and variable. - Still hitting the network — you probably missed a dependency that your
build pulls in. Cross-check the list in
Which dependencies do I need? against your
enabled options (
ENABLE_TESTS,ENABLE_TOOLS, Python bindings).
Version availability
Pinned variables are currently implemented on main; backports to 1.0, 0.18,
0.17, and 0.16 are independent changes because each branch owns its pins. The
deprecated unversioned overrides remain compatible. They were introduced on main
by #1606 and backported to release
lines through #2308. Built-in URL
fallbacks and system-library reuse are unchanged.
Running Tests
VSAG uses Catch2 for testing, organized in two layers:
- Unit tests live next to source files under
src/. - Functional tests live under
tests/and cover cross-module, end-to-end behavior. Typical files includetest_hgraph.cpp,test_ivf.cpp,test_pyramid.cpp,test_sindi.cpp,test_brute_force.cpp, andtest_memleak.cpp.
Run the Full Suite
make test configures a Debug build with tests enabled and runs the full unit + functional
suite:
make test
Build and Run One Unit-Test Module
For a shorter edit-build-test cycle, build and run one maintained unit-test subsystem:
make test-module MODULE=datacell
make test-module MODULE=algorithm CASE='[ut][sample_train_data]'
The available module names are simd, common, algorithm, factory, attr, datacell,
layout, quantization, storage, io, utils, and impl. Each command builds the shared
production and fixture dependencies plus only the selected module’s test sources. The corresponding
CMake target and executable are named unittests_<module>:
cmake -S . -B build -DENABLE_TESTS=ON
cmake --build build --target unittests_datacell
./build/tests/unittests_datacell '[ut][AttributeInvertedInterfaceParameter]'
These module targets are a local acceleration path. Continue to run make test, whose aggregate
unittests executable contains every module, for full validation.
Note: make test does not enable coverage instrumentation. To produce a coverage report, use
make cov — it configures the build with ENABLE_COVERAGE=ON; then run the same non-daily unit
and functional suites as coverage CI with its fixed seed before collecting the trace:
make cov
VSAG_TEST_SEED=424242 bash scripts/testing/test_parallel_bg.sh
bash scripts/coverage/collect_cpp_coverage.sh
bash scripts/coverage/check_cov.sh
The collector writes coverage/coverage.info with repository-relative paths and branch data.
It measures maintained production sources under src/ and public headers under include/,
excluding the generated version.h header in src/ (created at build time from the tracked
template src/version.h.in by cmake/GenerateVersionHeader.cmake) and vendored
include/vsag/expected.hpp. Code compiled only
on another platform is explicitly outside the Linux x86 report and must be measured by a
platform-specific coverage job rather than treated as covered.
Pre-merge SIMD stage
PR SIMD Coverage runs for pull requests targeting main when SIMD sources/tests or their build and coverage infrastructure change.
This rollout is intentionally limited to main. Release branches retain their existing PR CI, but do not receive this new SIMD coverage check; extending it to release branches requires a separate rollout.
It builds the PR base and GitHub merge candidate sequentially on one Linux x86 runner, using
four build jobs, OpenBLAS, [simd]~[!benchmark], Catch2 seed 424242, and lexical order.
Each test run has a 15-minute limit; the job has a 90-minute limit. It still builds the aggregate
test executable. Full post-merge/manual unit and functional coverage is unchanged.
The Actions summary and artifact report only maintained src/simd/ line coverage.
Measured added executable lines must reach 80%; scoped line coverage cannot decrease against
the fresh base measurement, using unrounded ratios. New files enter head and patch totals
without an invented per-file baseline. Missing traces, SHA/profile mismatches, unmeasured
changed files, or disappearing non-deleted source records fail. No measured added executable
lines means N/A, not 100%.
This is a bounded first stage, not full-project regression protection. Other changed C++ paths
are listed as unsupported when the job runs; PRs outside its trigger paths do not receive this
measurement. Conditional paths remain limited to this runner. Some tests use random_device,
so the Catch2 seed does not control every input. Partial traces are never uploaded to Codecov
or compared with full-suite coverage. The job uses read-only PR permissions, no secrets,
no persistent checkout credentials, and no restored/saved build caches.
Changes to dependency definitions report an unavailable comparison because the two builds
cannot safely share one prepared dependency checkout when its pins differ.
To compare collected traces, put base.info, head.info, and JSON metadata files
base.json/head.json in a report directory. Each metadata file contains sha and profile
(the exact PROFILE constant in scripts/coverage/compare_simd_coverage.py). Then run:
python3 scripts/coverage/compare_simd_coverage.py --root . \
--base "$BASE_SHA" --head "$MERGE_SHA" --reports simd-coverage
Run a Single Binary
./build/tests/functests "[hgraph]"
./build/tests/functests "[hgraph][concurrent]"
Catch2 supports filtering by name, tag, and wildcards — see --help.
Coverage Expectations
Contributions are expected to keep the C++ line coverage over src/ and include/ at 90% or
higher, as measured by the make cov flow and the CI coverage job.
Memory & Concurrency
test_memleak.cpp: run under AddressSanitizer / LeakSanitizer to verify construction and destruction paths.test_index/test_index_concurrent.cpp: shared concurrent add and search correctness for maintained indexes that advertise the corresponding feature flags.
Python Tests
make pyvsag PY_VERSION=3.10
cd tests/python && pytest -q
References
tests/directory- Makefile entries:
test,cov,asan
Contributing to VSAG
First of all, thank you for taking the time to contribute to VSAG! Contributors like you are what keep the project alive and growing. 🎉
If this is your first open-source contribution, we recommend walking through the First Contributions tutorial to get familiar with the basic workflow.
The sections below cover what you may want to know before contributing.
Ways to Contribute
- Report bugs. File a bug issue with enough detail to reproduce the problem. If you consider the issue urgent, mention the VSAG team in a comment.
- Propose features. File a feature request issue describing the expected behavior. Discuss the design with the VSAG team and the community before implementation. Once the plan is agreed, follow the contribution flow.
- Implement features or fix bugs. Pick up an open issue and follow the contribution flow. Feel free to ask for clarifications by commenting on the issue and @-mentioning the VSAG team.
Contribution Flow
We use GitHub Flow to collaborate on VSAG.
- Fork the VSAG repository on GitHub.
- Clone your fork locally:
git clone git@github.com:<yourname>/vsag.git. - Create a working branch:
git checkout -b my-topic-branch. - Make changes, run local checks, commit, and push with
git push --set-upstream origin my-topic-branch. - Open a pull request on GitHub.
If you already have a local clone, update it before starting so that merge conflicts are less likely:
git remote add upstream git@github.com:antgroup/vsag.git
git checkout main
git pull upstream main
git checkout -b my-topic-branch
Guidelines
Before opening a pull request, make sure your changes pass local checks and follow the VSAG coding style.
- New features must ship with tests that demonstrate correct behavior and guard against regressions.
- Bug fixes should add a regression test covering the triggering case; a missing test is usually what allowed the bug in the first place.
- Preserve API compatibility when editing code under
include/. - Do not include internal headers (from
src/) in public headers (underinclude/). - When contributing a new feature, remember that the maintenance cost shifts to the VSAG team by default — we evaluate contributions by weighing benefit against long-term maintenance.
Signing Off (DCO)
All contributions to this project must include a
Developer Certificate of Origin (DCO) sign-off. The sign-off
must be included in every commit message in the form
Signed-off-by: {{Full Name}} <{{email address}}> (without the {}). Contributions without a DCO
sign-off cannot be accepted.
This is my commit message
Signed-off-by: Random J Developer <random@developer.example.org>
Git provides a -s flag that appends the trailer automatically:
git commit -s -m "This is my commit message"
For contributions made with the help of an AI coding agent (OpenCode, Claude Code, Codex,
etc.), only human contributors sign off on the DCO; the AI agent must not add its
own Signed-off-by trailer, because only a human can legally certify the DCO. Each human
contributor still adds their own Signed-off-by: trailer as usual. Instead of signing off,
attribute the AI agent with an Assisted-by: trailer that follows the
Linux kernel AI Coding Assistants policy,
in the form Assisted-by: AgentName:ModelVersion. Place the human Signed-off-by: line(s) first,
followed by the Assisted-by: line, for example:
Signed-off-by: Random J Developer <random@developer.example.org>
Assisted-by: OpenCode:claude-opus-4.7
The human submitter is responsible for reviewing AI-generated changes, ensuring license compliance, and taking full responsibility for the contribution.
Commit Messages and PR Labels
- Follow Conventional Commits; common prefixes include
feat:,fix:,docs:,chore:,refactor:,test:,ci:. - If a commit must skip CI, put
[skip ci]at the beginning of the subject line, e.g.[skip ci] docs: fix typo in README. - Every PR must carry two labels (enforced by Mergify, required to merge):
kind/*:kind/bug,kind/feature,kind/improvement, orkind/documentation.version/*: the target release, e.g.version/1.0,version/0.18.
Coding Style
Read the C++ Coding Guide before changing C++ code. It documents the exact clang-format 15 and clang-tidy 15 rules, naming conventions, diagnostic suppressions, and local commands required by VSAG.
Local Testing
Run the full test suite and make sure it passes:
make test
Search Reasoning (Recall Diagnostics)
Search Reasoning is a per-query diagnostic mechanism that explains why each
expected label was or was not recalled. Enable it by populating
SearchRequest::expected_labels_ and calling SearchWithRequest; the result
dataset carries a JSON report accessible through Dataset::GetReasoning().
Quick Start
For a built SINDI_V2 index and a single-query sparse query_dataset:
// Populate expected_labels_ with the labels of interest
vsag::SearchRequest request;
request.expected_labels_ = {100, 200, 300};
request.query_ = query_dataset;
request.topk_ = 10;
request.params_str_ = R"({"sindi_v2": {"n_candidate": 100}})";
auto results = index->SearchWithRequest(request);
if (!results.has_value()) {
std::cerr << "SearchWithRequest failed: " << results.error().message << std::endl;
} else {
std::cout << results.value()->GetReasoning() << std::endl;
}
Report Schema (v1)
A full report is a JSON object with expected_analysis and meta sections.
Status-only reports produced by MakeStatusReport contain only meta, with exactly
schema_version, status, and index_type; they do not include expected_analysis
or the remaining metadata fields. Consumers must check meta.status and treat
those additional fields as optional rather than requiring a full report.
For example, a status-only report for an empty IVF index is:
{"meta":{"schema_version":1,"status":"empty_index","index_type":"IVF"}}
expected_analysis
| Field | Type | Description |
|---|---|---|
summary | string | “M/N expected labels found, P missed” |
missed_targets | array | One entry per missed label |
Each missed_targets entry:
| Field | Type | Description |
|---|---|---|
label | int | Original label |
inner_id | int | Internal ID |
diagnosis | string | One of the diagnoses below |
true_distance | float | Ground-truth distance |
quantized_distance | float | Approximate distance seen during search |
was_visited | bool | Whether the id was visited |
visited_at_hop | int | Hop when first visited (-1 if never) |
was_evicted | bool | Whether it was evicted from the candidate set |
filter_rejected | bool | Whether the filter rejected it |
reorder_evicted | bool | Whether reorder evicted it |
meta
| Field | Type | Description |
|---|---|---|
schema_version | int | Always 1 |
status | string | Currently emitted: ok, empty_index. Reserved, not currently emitted: skipped_range_search, unsupported_by_index. |
index_type | string | HGraph, IVF, SINDI, etc. |
The following metadata fields belong to full reports and are absent from
MakeStatusReport output; consumers must tolerate their absence:
| Field | Type | Description |
|---|---|---|
search_mode | string | knn or range |
topk | int | Requested top k |
use_reorder | bool | Whether reorder was active |
filter_active | bool | Whether a filter was active |
termination_reason | string | none, lower_bound_reached, hops_limit_reached, timeout |
total_hops | int | Total search hops |
total_distance_computations | int | Total distance evaluations |
available_diagnoses | [string] | All diagnoses the engine can emit |
available_events | [string] | Events this index supported for this search |
supports_range | bool | Whether this index supports range-reasoning |
Diagnoses
| Value | Meaning |
|---|---|
success | Label was in the result set |
not_reachable | Expected target was never visited |
filter_rejected | Filter blocked the entry |
quantization_error | Approximate distance was too far from true distance |
ef_too_small | Visited but evicted (try larger ef) |
reorder_evicted | Reorder evicted the entry |
unknown | No clear reason determined |
For missed targets, rules use first-match priority: not visited, filter rejected,
quantization error, candidate eviction, reorder eviction, then unknown. In particular,
quantization_error takes priority over ef_too_small when both conditions hold.
The quantization check is the legacy heuristic approximate > 1.5 * true, applied
only when the true distance is positive. It is not a metric-independent error bound
or proof that quantization caused the miss; signed IP distances need particular care.
When present in a full report, supports_range is a boolean describing registered
range-reasoning support, not availability of the index’s RangeSearch API. Unregistered
index types report false; absence from a status-only report does not mean false.
For range requests, HGraph ignores expected_labels_ for reasoning and leaves
GetReasoning() as {}. Pyramid instead rejects range requests with non-empty
expected_labels_ with an invalid-argument error. Neither behavior produces a
skipped_range_search or unsupported_by_index status report; those statuses
are reserved and not currently emitted.
Per-Index Event Support
| Index | KNN | Range | Visit | Eviction | Filter Reject | Reorder | Reorder Eviction | Bucket Selection |
|---|---|---|---|---|---|---|---|---|
| HGraph | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| IVF | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| SINDI | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| SINDI_V2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| BruteForce | ✓ | ✓ | ✓ | — | ✓ | — | — | — |
| WARP | ✓ | ✓ | ✓ | — | ✓ | — | — | — |
| Pyramid | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | — |
Advanced
To extend the framework:
- Add a new
ReasoningDiagnosisvalue inreasoning_types.h, map it inToString, add a diagnosis rule inreasoning_context.cpp, add a test, and document it. - Add a new
ReasoningEventvalue inreasoning_types.h, implement aRecordXxxmethod onReasoningContext, tag the instrumentation site with// [reasoning], update the capability matrix, and document it. - For a new index: add a capability entry in
reasoning_capability.cpp, implementSearchWithRequest, and insert recording hooks.
Build and Train
VSAG separates index construction into three stages:
- Train — fit any internal quantizers / partitioners on a sample of the data.
- Add — insert vectors into the index using those trained encoders.
- Build — convenience wrapper that does
TrainthenAddon the same dataset.
Most users only call Build. Two situations are worth knowing about explicitly:
Train+ streamingAdd. When the corpus is large or arrives incrementally, train on a representative sample first and then stream the rest viaAdd(no rebuild). Seeexamples/cpp/311_feature_train.cpp.- ODescent. An alternative graph-construction algorithm for HGraph / Pyramid that builds the
whole neighbor graph in batch instead of insertion-by-insertion. See
examples/cpp/312_feature_odescent.cpp.
The Train API
tl::expected<void, Error> Index::Train(const DatasetPtr& data);
Declared in include/vsag/index.h. Trains the index on a (typically sampled) dataset
without inserting it. Returns tl::expected<void, Error>; check .has_value().
Indexes that perform meaningful training: HGraph, IVF, BruteForce, WARP,
Pyramid. For all of them, Build(data) first trains and then inserts the vectors —
for the default NSW graph it calls the equivalent of Train(data) followed by Add(data),
while for HGraph/Pyramid configured with graph_type: "odescent" the insertion step is a
batch ODescent graph build instead of Add
(see HGraph::build_by_odescent / Pyramid::Build in src/algorithm/).
When you need to call Train explicitly
- The base quantizer requires training. The capability flag
IndexFeature::NEED_TRAINreflects this on HGraph and IVF: HGraph sets it wheneverbase_quantization_typeis not one offp32,fp16,bf16(src/algorithm/hgraph.cpp:1803); IVF always sets it (src/algorithm/ivf.cpp:316) because its centroids must be trained. Pyramid does not currently setNEED_TRAINinInitFeatures()even when its underlying HGraph quantizer would need training, so do not rely onHasFeature(NEED_TRAIN)for Pyramid — callTrainexplicitly when you choose a trainedbase_quantization_type. fp32 / fp16 / bf16 do not require training (you can still callTrain— it is a harmless no-op). - You want to insert vectors in many small batches rather than in one
Buildcall. - You plan to export the trained model and reuse it on another index instance
(via
ExportModel).
Pattern: train once, add in a stream
auto params = R"({
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"max_degree": 32,
"ef_construction": 100,
"base_quantization_type": "sq8"
}
})";
auto index_result = vsag::Factory::CreateIndex("hgraph", params);
if (!index_result.has_value()) {
std::cerr << "Create index failed: " << index_result.error().message << std::endl;
return -1;
}
auto index = index_result.value();
// Step 1 — train on the whole base (or a representative sample).
auto train_result = index->Train(base);
if (!train_result.has_value()) {
std::cerr << "Train failed: " << train_result.error().message << std::endl;
return -1;
}
// Step 2 — stream vectors in one at a time (or in small batches).
for (int64_t i = 0; i < num_vectors; ++i) {
auto one = vsag::Dataset::Make();
one->NumElements(1)
->Dim(dim)
->Ids(ids + i)
->Float32Vectors(vectors + i * dim)
->Owner(false);
auto add_result = index->Add(one);
if (!add_result.has_value()) { /* handle */ }
}
The complete program is
examples/cpp/311_feature_train.cpp.
Train vs Build vs Add
| Call | Trains quantizer? | Inserts vectors? | Use it when |
|---|---|---|---|
Build(data) | yes | yes (all of data) | Bulk-load: you have the whole dataset already. |
Train(data) | yes | no | You want to insert vectors later, possibly in batches. |
Add(data) | no (requires prior Train or Build) | yes | Incremental inserts after the index is trained. |
ODescent: an alternative graph builder
By default, HGraph and Pyramid build their graphs NSW-style — every vector is inserted one
at a time and connects to the neighbors found by a search-on-insert (graph_type: "nsw").
ODescent (“Optimized NN-Descent”) is an alternative: it seeds a random k-NN graph over the
entire dataset and then iteratively refines edges using sampled candidate exchanges.
ODescent typically produces graphs with comparable recall to NSW at lower build cost for large batches, because the refinement loop parallelizes cleanly over the data and avoids per-insert search.
ODescent is implemented in src/impl/odescent/odescent_graph_builder.{h,cpp} and is currently
used by HGraph and Pyramid (build path).
Enabling ODescent on HGraph
Add graph_type: "odescent" to the HGraph index_param:
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 26,
"ef_construction": 100,
"graph_type": "odescent",
"graph_iter_turn": 10,
"neighbor_sample_rate": 0.3,
"alpha": 1.2
}
}
Then just call Build(data) — no other API change. The complete program is
examples/cpp/312_feature_odescent.cpp.
ODescent build parameters
These keys go under index_param alongside the usual HGraph keys:
| Parameter | Default (HGraph) | Description |
|---|---|---|
graph_type | "nsw" | Set to "odescent" to switch on this builder. |
graph_iter_turn | 30 | Number of refinement iterations. Higher → better graph quality, longer build. |
neighbor_sample_rate | 0.2 | Fraction of each node’s neighbors sampled per iteration for candidate exchange. |
alpha | 1.2 | α factor used by the diversity-aware edge pruning step. Larger alpha → sparser, more diverse edges. |
min_in_degree | 1 | Minimum in-degree enforced when repairing the graph after pruning. |
build_block_size | 10000 | Parallelization granularity (vectors per worker block). |
max_degree is inherited from the HGraph top-level setting; you do not need to repeat it under
ODescent. Upper graph layers automatically use half of max_degree.
When to use ODescent vs NSW
- Use ODescent when you have the full dataset up front and care about build throughput on a many-core machine. The batch refinement parallelizes better than insertion-by-insertion.
- Use NSW (the default) when you build incrementally or care about strictly minimal memory during the build, or when you have not measured a build-time problem.
Both choices produce a graph that is searched the same way at query time, so search-side
parameters (ef_search, pq_rerank, …) carry over unchanged.
See also
HGraph Build Cache
HGraph can export graph-neighbor information from one build and import it before a later
Build(). Vectors are matched by stable string source IDs, so unchanged data can warm-start from
the previous graph while new or changed data is refined normally.
This workflow is intended for recurring snapshot builds, such as rebuilding an index every day from a mostly overlapping corpus. It is a build accelerator, not an index serialization format: use the normal Serialization APIs to persist a searchable index.
Requirements
- The index type must be HGraph.
- Every base dataset used in the workflow must set
Dataset::SourceID. - Source IDs must be stable and unique for the logical records you want to match across builds.
- Import the cache into a fresh, empty, compatible HGraph before calling
Build(). - Keep dimensions, metrics, and storage/quantization parameters compatible between builds.
The numeric label in Dataset::Ids may change between snapshots. Cache matching uses the
corresponding SourceID string instead.
First build and cache export
Provide one source ID per vector when building the source index:
std::vector<std::string> source_ids = load_stable_source_ids();
auto base = vsag::Dataset::Make();
base->NumElements(count)
->Dim(dim)
->Ids(ids.data())
->Float32Vectors(vectors.data())
->SourceID(source_ids.data())
->Owner(false);
auto index = vsag::Factory::CreateIndex("hgraph", build_params).value();
index->Build(base).value();
std::ofstream cache_out("hgraph.cache", std::ios::binary);
index->ExportCache(cache_out).value();
Keep the std::string array alive for the duration of Build(). The cache contains source-ID to
neighbor information used by a later build; it is not directly searchable.
Warm-starting a later build
Create an empty compatible HGraph, import the previous cache, and then build with the new snapshot:
auto next_index = vsag::Factory::CreateIndex("hgraph", build_params).value();
std::ifstream cache_in("hgraph.cache", std::ios::binary);
next_index->ImportCache(cache_in).value();
auto next_base = vsag::Dataset::Make();
next_base->NumElements(next_count)
->Dim(dim)
->Ids(next_ids.data())
->Float32Vectors(next_vectors.data())
->SourceID(next_source_ids.data())
->Owner(false);
next_index->Build(next_base).value();
Build() automatically takes the cache-assisted path after ImportCache(). Source IDs found in
both snapshots are warm-started from cached neighbors; unmatched records are treated as cache
misses and refined by the normal build path. Calling cache-assisted Build() without
Dataset::SourceID returns an invalid-argument error.
Persisting source IDs with the index
Source-ID metadata is not included in HGraph serialization by default. Set
persist_source_id: true in index_param when a deserialized index must later call
ExportCache():
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 32,
"ef_construction": 400,
"persist_source_id": true
}
}
This option adds source-ID metadata to the serialized index. It is unnecessary if cache export always happens from the original in-memory build and the restored index never needs the source mapping.
Measuring cache reuse
After the warm-started build, call GetStats() and inspect:
| Field | Meaning |
|---|---|
build_cache_hit_rate | Fraction of nodes warm-started from the imported cache |
build_cache_hit_nodes | Matched node count |
build_cache_missed_nodes | Nodes built without a matching cache entry |
When no imported cache participated in the last build, the statistics include a
skipped_reason instead. See Index Analysis for the rest of the
HGraph statistics.
Limitations and operational guidance
- Import before
Build(); cache-assisted build requires an empty index. - A build cache is tied to compatible HGraph configuration and should be regenerated after incompatible parameter changes.
deduplicate_storage: truecannot be combined with cache-assisted build.- Validate recall and build time against a full rebuild before adopting the cache in production.
- Check stream-open and API return values. A cache file is separate from the searchable serialized index and should be versioned or replaced atomically with the snapshot that produced it.
API signatures are listed under Index Cache, and the source-ID dataset field is documented on the Dataset page.
Range Search
Besides k-nearest-neighbor search (KnnSearch), VSAG also supports range search
(RangeSearch): return every result whose distance to the query vector is less than or equal to
a given radius. It is useful for threshold filtering, de-duplication, and approximate recall
scenarios.
Basic Usage
#include <vsag/vsag.h>
// 1. Create an index (HGraph in this example)
auto index = vsag::Factory::CreateIndex("hgraph", hgraph_build_params).value();
index->Build(dataset);
// 2. Prepare the query
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(dim)->Float32Vectors(query_vec)->Owner(false);
// 3. Range search
float radius = 0.5f;
auto result = index->RangeSearch(query, radius, search_params);
if (result.has_value()) {
auto ids = result.value()->GetIds();
auto dists = result.value()->GetDistances();
int64_t n = result.value()->GetDim();
// ...
}
See
examples/cpp/302_feature_range_search.cppfor a complete example.
limited_size Parameter
RangeSearch accepts a limited_size argument that caps the number of returned results:
// Return at most 100 results within the radius
auto result = index->RangeSearch(query, radius, search_params, /*limited_size=*/100);
limited_size = -1(default): return every result inside the radius (unlimited).limited_size > 0: return at most this many results.limited_size = 0: invalid; the implementation explicitly rejects this value (CHECK_ARGUMENT(limited_size != 0, ...)).
Combining with Filter
RangeSearch has the same signature shape as KnnSearch and also accepts a filter (see
examples/cpp/301_feature_filter.cpp). The filter is applied during the search, not afterwards,
which is more efficient than post-filtering.
Support Matrix
| Index type | Supports RangeSearch |
|---|---|
| hgraph | yes |
| ivf | yes |
| brute_force | yes |
| sindi | yes (sparse vectors) |
Notes
- The distance metric (IP / L2 / cosine) defines the semantics of
radius. Make sure it matches themetric_typespecified at index creation. - If
radiusis very large, the result set can be huge; combine withlimited_sizeto avoid unbounded memory usage. - For graph-based indexes (HGraph), runtime parameters like
efshare the same meaning betweenRangeSearchandKnnSearch.
Calculate Distance by ID
Besides KnnSearch and RangeSearch, VSAG exposes APIs that compute the distance between a
query vector and already-indexed vectors referenced by their IDs. This is useful for
re-ranking external candidate sets, validating recall, or implementing custom retrieval
pipelines on top of VSAG.
Two flavors are provided:
CalcDistanceById— single ID, returns one distance.CalcDistancesById— batch of IDs, returns aDatasetPtrcontaining distances.
Each flavor has two overloads: one taking a raw const float* (dense vectors) and one taking
a DatasetPtr (works for both dense and sparse vectors).
Migration note.
CalDistanceByIdis the historical spelling of the batch method. It remains available as a deprecated alias through the 1.1 migration window; new code should useCalcDistancesById. The two names have identical semantics. See issue #2068.
HGraph also supports configured sparse and non-FP32 data types. In those configurations, use a Dataset containing the native query field (SparseVectors, Int8Vectors, or Float16Vectors for FP16/BF16); a raw float* distance query is only valid for a float32 index. The Dataset representation must match the index configuration, not merely its vector dimension.
C++ implementation migration
CalcDistancesById is the canonical virtual batch interface, including its topk parameter. Public wrappers and internal index implementations dispatch through this name directly. The deprecated public CalDistanceById aliases forward to the canonical interface, never the reverse.
Custom index implementations must override the appropriate CalcDistancesById overloads instead of relying on an override of the historical spelling. Existing ordinary calls to the deprecated alias remain source-compatible, but overriding only that alias is not sufficient to implement the canonical interface. The virtual interface layout changes: binary compatibility with previously compiled Index subclasses is not preserved. Recompile custom subclasses and dependent C++ components against the matching headers and library.
API Overview
// Single, dense float pointer.
tl::expected<float, Error>
CalcDistanceById(const float* vector,
int64_t id,
bool calculate_precise_distance = true) const;
// Single, DatasetPtr (dense or sparse).
tl::expected<float, Error>
CalcDistanceById(const DatasetPtr& vector,
int64_t id,
bool calculate_precise_distance = true) const;
// Batch, dense float pointer.
tl::expected<DatasetPtr, Error>
CalcDistancesById(const float* query,
const int64_t* ids,
int64_t count,
bool calculate_precise_distance = true,
int64_t topk = -1) const;
// Batch, DatasetPtr (dense or sparse).
tl::expected<DatasetPtr, Error>
CalcDistancesById(const DatasetPtr& query,
const int64_t* ids,
int64_t count,
bool calculate_precise_distance = true,
int64_t topk = -1) const;
For DatasetPtr queries with NumElements() > 1, check
SUPPORT_BATCH_CALC_DISTANCE_BY_ID first. count is the number of IDs per query, ids must
contain NumElements() * count row-major IDs, and returned distances use the same layout. When
topk > 0, each query returns min(topk, count) entries sorted by distance in ascending order,
and the result also contains the corresponding IDs.
Declarations live in
include/vsag/index.h.
calculate_precise_distance
true(default) prefers an available higher-precision/reorder representation. It does not reconstruct discarded original vectors. When no separate precise representation exists, it uses the stored representation, which can still be quantized or pruned.falseuses the base representation. Both modes compute the configured distance; they do not run approximate candidate retrieval. A disk-backed representation can incur I/O in either mode.- BruteForce/WARP and SIMQ use their stored distance backend for both modes. HGraph/Pyramid prefer raw vectors when retained, otherwise reorder codes; IVF uses its configured reorder codes/buckets. SINDI/SINDI_V2 use rerank vectors when configured.
Native query representations
| Index | Single-ID query | Multi-query batch query |
|---|---|---|
| BruteForce, HGraph, IVF, Pyramid, LazyHGraph | float* or one-row DatasetPtr with Float32Vectors | DatasetPtr with Float32Vectors |
| SINDI, SINDI_V2 | one-row DatasetPtr with SparseVectors | DatasetPtr with SparseVectors |
| WARP, SIMQ | one-row DatasetPtr with MultiVectors and MultiVectorDim | DatasetPtr with MultiVectors and MultiVectorDim |
SINDI immutable storage supports single-ID and batch distances, including after deserialization. WARP/SIMQ calculate the same multi-vector aggregation as their search distance backend, without coarse candidate selection. Raw float pointers cannot represent these native sparse/multi-vector queries.
For N query rows and count candidates per row, provide N × count candidate IDs in row-major order (ids[q * count + j]); a single candidate list is not implicitly broadcast. CalcDistancesById is the preferred batch name; CalDistanceById remains the compatibility alias.
Missing labels produce -1, but a valid inner-product distance can also be negative (including -1). Top-k uses label validity, not the sign or value of the distance, to place missing labels last.
IVF PQFS supports point and batch distances using the same packed-code lookup as bucket scanning. A point lookup reads its 32-vector package and selects one lane; this may perform more work than a scalar quantizer lookup, particularly for disk-backed storage.
PQFS is supported as IVF base bucket encoding, not as flat reorder encoding: flat reorder storage does not maintain PQFS packages. Invalid PQFS reorder configurations are rejected at index creation; choose a supported precise quantizer such as FP32 instead.
Return Semantics
- The single-ID overload returns the distance as a
float. - With
topk == -1, the raw-pointer batch overload returns one row withcountdistances. TheDatasetPtroverload returnsNumElements()rows withcountdistances each. Both preserve input order and do not return IDs. - With
topk > 0, the batch overload returns the smallestmin(topk, count)distances per query, sorted ascending, andGetIds()contains the corresponding IDs. Missing IDs (represented by-1distances) are ordered after valid distances regardless of their sign and only appear if there are not enough valid IDs. - The distance metric (IP / L2 / cosine) follows the
metric_typechosen at index construction; see Metric Semantics.
Basic Usage
#include <vsag/vsag.h>
// 1. Build an HGraph index over float32 vectors.
auto index = engine.CreateIndex("hgraph", hgraph_build_parameters).value();
index->Build(base);
// 2. Single ID.
auto d = index->CalcDistanceById(query_vector.data(), /*id=*/42);
if (d.has_value()) {
std::cout << "distance to id 42 = " << d.value() << std::endl;
}
// 3. Batch IDs.
std::vector<int64_t> ids = { 1, 2, 3, 4, 5 };
auto result = index->CalcDistancesById(query_vector.data(), ids.data(), ids.size());
if (result.has_value()) {
const float* dists = result.value()->GetDistances();
for (size_t i = 0; i < ids.size(); ++i) {
if (dists[i] == -1.0f) {
std::cout << ids[i] << " -> invalid ID" << std::endl;
} else {
std::cout << ids[i] << " -> " << dists[i] << std::endl;
}
}
}
Multiple Queries
The DatasetPtr batch overload accepts more than one query when the index advertises
SUPPORT_BATCH_CALC_DISTANCE_BY_ID. Candidate IDs and outputs are row-major. For LazyHGraph, the
call is delegated to the active internal index, so actual support can change after a phase
transition even though the wrapper advertises the feature; handle a returned error:
// Two dense queries and three candidate IDs per query.
auto queries = vsag::Dataset::Make()
->NumElements(2)
->Dim(dim)
->Float32Vectors(query_vectors.data())
->Owner(false);
std::vector<int64_t> candidate_ids = {
10, 11, 12, // candidates for query 0
20, 21, 22 // candidates for query 1
};
if (index->CheckFeature(vsag::SUPPORT_BATCH_CALC_DISTANCE_BY_ID)) {
auto result = index->CalcDistancesById(queries, candidate_ids.data(), 3, true, /*topk=*/2);
if (result.has_value()) {
auto batch = result.value();
// batch has NumElements() == 2 and Dim() == 2.
for (int64_t q = 0; q < batch->GetNumElements(); ++q) {
for (int64_t j = 0; j < batch->GetDim(); ++j) {
const int64_t offset = q * batch->GetDim() + j;
std::cout << batch->GetIds()[offset] << ": "
<< batch->GetDistances()[offset] << '\n';
}
}
}
}
With topk == -1, GetDim() is count and position q * count + j corresponds to input ID
ids[q * count + j]. With positive topk, the row stride is GetDim(), and each row contains
the closest valid candidates followed by invalid IDs only when fewer than topk valid IDs exist.
A runnable example is provided in
examples/cpp/306_feature_calculate_distance_by_id.cpp.
Sparse Vectors
For sparse-vector indexes such as SINDI, the const float* overloads are not applicable. Pass
the query as a DatasetPtr carrying sparse vectors via SparseVectors(...), and use the
DatasetPtr overloads:
auto query = vsag::Dataset::Make();
query->NumElements(1)->SparseVectors(&sparse_query)->Owner(false);
auto d = index->CalcDistanceById(query, /*id=*/42);
Support Matrix
| Index type | Single-ID dense (const float*) | Single-ID DatasetPtr | Multi-query DatasetPtr batch | Notes |
|---|---|---|---|---|
| hgraph | yes | yes | when the feature is advertised | Honors calculate_precise_distance. |
| ivf | yes | yes | when the feature is advertised | Availability depends on retained precise storage. |
| brute_force | yes | yes | when the feature is advertised | Dense single-vector indexes only. |
| pyramid | yes | yes | yes | |
| lazy_hgraph | yes | no | depends on the active internal index | DatasetPtr batch calls delegate to the active BruteForce/HGraph; availability can change after a phase transition. |
| sindi | no | yes | yes | Sparse vectors only. |
Indexes that do not implement the API surface for a given overload return an
UNSUPPORTED_INDEX_OPERATION error.
Notes
- The query dimension (for dense overloads) must match the index dimension.
- The batch overload has a default implementation that loops over single-ID calls; some indexes override it for batch-level optimization.
- Like all VSAG read-only APIs, these methods are safe to call concurrently with other
read-only operations (e.g.
KnnSearch).
Filtered Search
Filtered search restricts the result set of a KnnSearch or RangeSearch to vectors that
satisfy an application-defined predicate. VSAG applies the predicate during index
traversal whenever the underlying algorithm supports it, so you avoid the recall loss and
extra latency of post-filtering top-k results.
This page covers the three id-based filter APIs:
- Bitset filter — a compact bit array indexed by vector id.
- Function-callback filter — a
std::function<bool(int64_t)>. Filterobject — avsag::Filtersubclass that can also expose hints (valid ratio, distribution) to the search algorithm.
For attribute / “hybrid” search where the predicate is an SQL-like expression over typed fields, see Attribute Filter (Hybrid Search). For filtering against an opaque per-vector byte payload during graph traversal, see Extra Info.
Truth-value Conventions
The three APIs disagree on how to spell “exclude this id”. Read this table carefully before mixing them.
| API | Method | Returning true means … |
|---|---|---|
Bitset | Test(id) | id is filtered out |
std::function | f(id) | id is filtered out |
Filter::CheckValid | CheckValid(id) | id is kept |
The bitset and std::function overloads are wrapped internally as a BlackListFilter
(src/impl/filter/black_list_filter.cpp): the bit being set, or the callback returning
true, marks the id as excluded. The Filter::CheckValid API inverts that polarity — true
keeps the id. If you maintain your own deletion bitmap, the bitset/function APIs are a
natural fit. If you want predicate logic with hints, the Filter form is clearer.
Bitset Filter
vsag::Bitset (include/vsag/bitset.h) is a growable, ordinal-indexed bit array.
auto invalid = vsag::Bitset::Make();
for (int64_t i = 0; i < num_vectors; ++i) {
if (ids[i] % 2 == 0) {
invalid->Set(ids[i]); // even ids are excluded
}
}
auto search_params = R"({ "hgraph": { "ef_search": 100 } })";
auto result = index->KnnSearch(query, /*topk=*/10, search_params, invalid).value();
The bitset is indexed by vector id, but ids are masked to their low 32 bits before lookup
(bit_index = id & ROW_ID_MASK in src/impl/filter/black_list_filter.cpp, where
ROW_ID_MASK = 0xFFFFFFFFLL). Two ids that share the same low 32 bits will collide in the
bitset, so keep ids within [0, 2^32) if you rely on this filter; otherwise switch to the
Filter form. The bitset is indexed by id, not by insertion order, so reused/recycled ids
must be handled by your application.
Function-callback Filter
A plain lambda or std::function<bool(int64_t)> works directly. The callback must return
true for ids that should be excluded (it is wrapped as a BlackListFilter):
// Drop even ids: return true to exclude.
std::function<bool(int64_t)> drop_even = [](int64_t id) { return id % 2 == 0; };
auto result = index->KnnSearch(query, 10, search_params, drop_even).value();
This is the easiest way to drop in a small amount of custom logic without subclassing. If
you prefer the “return true to keep” polarity, use the Filter object instead.
Filter Object
The richest API is vsag::Filter (include/vsag/filter.h). Subclass it when the search
algorithm can benefit from hints about the predicate:
class MyFilter : public vsag::Filter {
public:
bool CheckValid(int64_t id) const override {
return id % 2 == 1;
}
// Approximate fraction of ids that pass the predicate. The search uses this to
// size internal candidate buffers; an accurate estimate improves latency and recall.
float ValidRatio() const override { return 0.5F; }
// Hint whether passing ids cluster spatially. NONE means "no correlation"; use
// RELATED_TO_VECTOR if the predicate correlates with vector position (e.g. region tags).
Distribution FilterDistribution() const override { return Distribution::NONE; }
};
auto filter = std::make_shared<MyFilter>();
auto result = index->KnnSearch(query, 10, search_params, filter).value();
Important methods:
| Method | Default | Purpose |
|---|---|---|
CheckValid(int64_t id) | pure virtual | Required. true keeps the id. |
CheckValid(const char* data) | returns true | Used for in-graph filtering against the per-vector byte payload; see Extra Info. |
ValidRatio() | 1.0F | Hint, in [0, 1], of the fraction of ids that pass. |
FilterDistribution() | NONE | NONE or RELATED_TO_VECTOR. |
GetValidIds(...) | empty | Optional whitelist for very selective filters. |
Passing the wrong ValidRatio is not a correctness bug, but a poor estimate may either
inflate latency (overestimate) or hurt recall (underestimate).
Available Overloads
KnnSearch and RangeSearch both expose four filter shapes (include/vsag/index.h):
// KnnSearch
index->KnnSearch(query, topk, params); // no filter
index->KnnSearch(query, topk, params, BitsetPtr invalid);
index->KnnSearch(query, topk, params, std::function<bool(int64_t)> f);
index->KnnSearch(query, topk, params, FilterPtr filter);
// RangeSearch
index->RangeSearch(query, radius, params, limited_size); // no filter
index->RangeSearch(query, radius, params, BitsetPtr invalid, limited_size);
index->RangeSearch(query, radius, params, std::function<bool(int64_t)> f, limited_size);
index->RangeSearch(query, radius, params, FilterPtr filter, limited_size);
limited_size is the maximum number of results returned by RangeSearch:
limited_size < 0: no limit (the default-1).limited_size == 0: rejected explicitly by the API (CHECK_ARGUMENT(limited_size != 0, ...)); pass-1for “no limit”.limited_size > 0: cap the result list at this many entries.
A filtered iterator-style search is also exposed:
vsag::IteratorContext* ctx = nullptr;
index->KnnSearch(query, topk, params, filter, ctx, /*is_last_search=*/false);
// repeat with the same ctx; pass true on the final call to release resources
Index Support Matrix
All index types accept the bitset, function, and FilterPtr overloads — the inner
implementation wraps bitsets and lambdas into a FilterPtr automatically. The columns below
reflect the capability flags each index registers (see include/vsag/index_features.h),
which is what runtime feature checks return.
| Index | _KNN_SEARCH_WITH_ID_FILTER | _RANGE_SEARCH_WITH_ID_FILTER | _KNN_ITERATOR_FILTER_SEARCH |
|---|---|---|---|
| HGraph | Yes | Yes | Yes |
| IVF | Yes | Yes | — |
| BruteForce | Yes | Yes | — |
| Pyramid | Yes | Yes | — |
| SINDI / WARP | Yes | Yes | — |
For id-based filtering, query support at runtime via
index->CheckFeature(vsag::SUPPORT_KNN_SEARCH_WITH_ID_FILTER),
SUPPORT_RANGE_SEARCH_WITH_ID_FILTER, and SUPPORT_KNN_ITERATOR_FILTER_SEARCH. The flag
SUPPORT_KNN_SEARCH_WITH_EX_FILTER is unrelated — it covers extra-info (byte-payload)
filtering, see Extra Info.
Performance Notes
- The more selective the filter (smaller
ValidRatio), the more candidates the search has to expand. For graph indexes, increaseef_searchproportionally when the filter is very selective; otherwise recall will drop sharply below ~1% selectivity. - HGraph also offers a selectivity-aware brute-force fallback: set
brute_force_threshold(e.g.0.01–0.05) in the search params so that, whenFilter::ValidRatio()is small enough, HGraph automatically skips graph traversal and runs an exact scan over the surviving ids. This is often a better choice than chasing recall by raisingef_searchto very large values. See the HGraph index page and example322_feature_hgraph_brute_force_threshold.cpp. - Bitset filters are fastest because
Test()is a single bit lookup. AFilterobject that performs heavy work inCheckValidwill be called many times per query. - For
RangeSearch, set a finitelimited_sizewhen filters can let through millions of ids — otherwise the result set may grow unbounded. - Filters compose cheaply with Attribute Filter when using
SearchRequest: all enabled filters are combined with logical AND.
Combining Filters via SearchRequest
SearchRequest (include/vsag/search_request.h) is the unified entry point used by
SearchWithRequest. It can carry a bitset filter, a Filter object, and an attribute
expression simultaneously; all are ANDed together.
vsag::SearchRequest req;
req.query_ = query;
req.mode_ = vsag::SearchMode::KNN_SEARCH;
req.topk_ = 10;
req.params_str_ = R"({ "hgraph": { "ef_search": 200 } })";
req.enable_filter_ = true;
req.filter_ = std::make_shared<MyFilter>();
req.enable_bitset_filter_ = true;
req.bitset_filter_ = invalid;
auto result = index->SearchWithRequest(req).value();
See Attribute Filter for the attribute_filter_str_ field.
Examples
- C++:
examples/cpp/301_feature_filter.cpp— bitset, function, andFilter-object styles. - C++:
examples/cpp/320_feature_extra_info.cpp— in-graph filtering using theCheckValid(const char*)byte-buffer overload.
Python Status
Python bindings for the filter APIs are not yet exposed; the placeholder at
examples/python/todo_examples/301_feature_filter.py is intentionally empty. Use the C++
API for filtered search today.
Iterator Search
VSAG supports iterator-based search (also called iterative search): instead of asking for
the top-k results in one shot, the caller can request results in successive chunks while VSAG
preserves the internal search state between calls. Each subsequent call resumes from where the
previous one left off and returns new, non-overlapping results.
This is useful when:
- The application implements an external re-ranker or post-filter and wants to keep pulling more candidates until enough survivors are collected.
- Result consumption is lazy / streaming (e.g. UI pagination, server-side cursor).
- The eventual
kis unknown up front and may grow on demand.
How It Works
Iterator search relies on a long-lived IteratorContext object that holds:
- the current candidate heap / visited bitmap, and
- the cursor into the underlying graph or inverted lists.
The first call creates the context (when the pointer is nullptr); follow-up calls reuse it so
the search continues instead of restarting. When the caller is done, the IteratorContext object
itself must be deleted by the caller — that is what releases the iterator’s internal state.
The is_last_search flag is optional: when set to true, the index drains the candidates that
are still buffered inside the context (the “discard heap”) and returns them as the result of that
call. This is useful when the caller wants the long tail of explored-but-not-yet-emitted
candidates; if you don’t need them, you can simply skip the final call and delete the context
directly. Note that the returned set is still capped to k, so if you want all tail candidates,
pass a sufficiently large k on the finalize call.
Basic Usage (SearchParam API)
#include <vsag/vsag.h>
// 1. Build an index (HGraph in this example)
auto index = vsag::Factory::CreateIndex("hgraph", hgraph_build_params).value();
index->Build(dataset);
// 2. Prepare query
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(dim)->Float32Vectors(query_vec)->Owner(false);
// 3. Configure SearchParam in iterator mode
nlohmann::json search_parameters = {
{"hgraph", {{"ef_search", 100}}},
};
std::string param_str = search_parameters.dump();
vsag::SearchParam search_param(
/*iter_filter_flag=*/true, // enable iterator mode
param_str,
/*filter=*/nullptr,
/*allocator=*/&allocator,
/*iter_ctx=*/nullptr, // first call: context is created internally
/*last_search_flag=*/false);
// 4. First page
auto page1 = index->KnnSearch(query, /*k=*/10, search_param).value();
// 5. Next page — context carries over, results do not overlap with page1
auto page2 = index->KnnSearch(query, /*k=*/10, search_param).value();
// 6. (Optional) drain the candidates still buffered in the context.
// Skip this call if you don't need the tail candidates; cleanup
// happens through `delete` below either way.
search_param.is_last_search = true;
auto page3 = index->KnnSearch(query, /*k=*/10, search_param).value();
// 7. The caller owns the context object — this is what releases resources.
delete search_param.iter_ctx;
Reference:
examples/cpp/313_feature_search_allocator.cppandexamples/cpp/314_feature_hgraph_search_allocator.cpp.
Alternative: Explicit IteratorContext Argument
The lower-level KnnSearch overload accepts the context pointer directly. This is the form used
by VSAG’s own tests (tests/test_index/test_index_search.cpp) when calling KnnSearch several
times in a row:
vsag::IteratorContext* iter_ctx = nullptr;
auto r1 = index->KnnSearch(query, k1, param_str, filter, iter_ctx, /*is_last_search=*/false);
auto r2 = index->KnnSearch(query, k2, param_str, filter, iter_ctx, /*is_last_search=*/false);
auto r3 = index->KnnSearch(query, k3, param_str, filter, iter_ctx, /*is_last_search=*/false);
delete iter_ctx;
Each call advances iter_ctx; the union of the returned ids is a non-overlapping continuation of
the search ordered by distance. Pass is_last_search=true on a trailing call instead if you want
the index to also emit the candidates still buffered in the context.
SearchRequestAPI.SearchRequestdeclaresenable_iterator_search_,p_iter_ctx_, andis_last_search_fields, but no in-treeSearchWithRequestimplementation currently consults them. Until that wiring lands, use one of the twoKnnSearchforms above to drive iterator search.
Combining With Filters
Iterator search composes with regular filters (label filter, attribute filter, bitset filter). A common use case is “keep iterating until enough results pass my external check”:
size_t needed = 50;
std::vector<int64_t> kept;
vsag::IteratorContext* ctx = nullptr;
while (kept.size() < needed) {
auto page = index->KnnSearch(query, 32, param_str, filter, ctx, /*is_last_search=*/false);
if (!page.has_value() || page.value()->GetDim() == 0) break;
for (int64_t i = 0; i < page.value()->GetDim(); ++i) {
if (external_check(page.value()->GetIds()[i])) {
kept.push_back(page.value()->GetIds()[i]);
}
}
}
// Release the iterator state. No `is_last_search=true` call is required —
// add one only if you also want the candidates still buffered in `ctx`.
delete ctx;
The HGraph index supports an additional runtime parameter — skip_ratio — that controls how
aggressively the iterator skips already-explored regions during continuation. See
examples/cpp/314_feature_hgraph_search_allocator.cpp.
Support Matrix
Indexes that advertise the SUPPORT_KNN_ITERATOR_FILTER_SEARCH feature (queryable via
Index::CheckFeature):
| Index type | Supports iterator search |
|---|---|
| hgraph | yes |
| ivf | no |
| brute_force | no |
| sindi | no |
Always check index->CheckFeature(vsag::SUPPORT_KNN_ITERATOR_FILTER_SEARCH) at runtime before
relying on this capability — coverage may expand in future releases.
Notes and Pitfalls
- Ownership. The
IteratorContextis owned by the caller. Forgetting todeleteit leaks the internal search state (heap, visited bitmap, allocator scratch). Resource release is driven entirely bydelete, not byis_last_search. - Optional last call.
is_last_search = trueis not required for cleanup. Its only effect is to make the index drain the candidates that are still buffered in the context and return them as that call’s result, still capped tok. Use it only when you want those tail candidates, and pick aklarge enough not to truncate them. - Parameter stability. Do not change the query vector, distance metric, or filter between calls that share a context — results are only meaningful when the search state is reused for the same logical query.
kper call. Thekargument applies to each call individually; the returned chunks are disjoint, so the cumulative result size grows byk(or less if the index is exhausted) each iteration.- Thread safety. A single
IteratorContextmust not be used concurrently from multiple threads. Different queries should each have their own context.
Native HGraph SearchSession (experimental)
OpenSearchSession(query, allocator = nullptr) fixes only query/index/resources and returns an owning,
single-consumer session (SUPPORT_CONTINUE_SEARCH_SESSION). This API is separate from
IteratorContext; the legacy iterator implementation is unchanged.
Open deep-copies the query without creating computers, routing or scoring. The first
Next(SearchSessionNextOptions) creates reusable computers and routes through the upper graphs. Later calls resume
the retained bottom-graph frontier, reusing scores, visited/expanded state and computers.
Each call expands at least max(ef_search, max_candidates) remaining vertices,
or exhausts the frontier; restrictive filters may require more expansion to fill a page.
It is not a page sliced from a larger precomputed KNN result. Results are ordered within
each page, not necessarily across pages. Exhaustion covers the reachable graph, not a
promise to enumerate disconnected vectors. HasMore() is conservative before filtering.
#include <vsag/search_session.h>
auto opened = index->OpenSearchSession(query);
vsag::SearchSessionNextOptions options{10, R"({"hgraph":{"ef_search":64}})", nullptr};
if (!opened.has_value()) { /* handle error */ }
else {
auto session = std::move(opened.value());
while (session->HasMore()) {
auto page = session->Next(options);
if (!page.has_value()) { /* handle error */ break; }
if (page.value()->GetDim() == 0) break; // Current conditions exhausted, not necessarily session.
// Consume page.value(); every result is returned at most once.
}
session->Close(); // optional; destruction also closes
}
SearchSessionNextOptions defaults to HGraph JSON because HGraph is currently the only supported
session backend; future backends must document their accepted parameters.
Next options replace max_candidates, filter (nullptr means accept all) and search_parameters
for that call only. Filters may be replaced or mutated between calls, including the same object;
conditions must remain stable during a call. All discovered undelivered IDs are re-evaluated,
including previous filter/threshold rejects and ordinary duplicate aliases. Thresholds may tighten
or relax. Reorder may toggle: precise scores are computed lazily once per ID and cached separately
from coarse traversal scores. Neither changing ef/demand nor eligibility reroutes or recomputes
existing distances. Delivered IDs are never returned again.
HasMore() ignores current eligibility: it is true while routing/frontier work or discovered
undelivered IDs remain. An empty filtered batch does not close the session; a later wider filter
may return results after traversal is exhausted. Stop on empty for the current request; a loop using
only HasMore() can run forever under unsatisfiable conditions. session_traversal_exhausted and
session_undelivered_nodes distinguish traversal exhaustion from retained rejects.
Permanently unreturnable NaN IDs are excluded from HasMore once no alternate output cell can
rescue them (no distinct reorder cell, or both cached scores are NaN); they still serve as traversal
bridges. An unevaluated alternate reorder cell conservatively keeps such an ID available.
Non-finite upper-route recovery has a safety cap of 65536 visited vertices per level; exceeding it returns an execution error and closes the session rather than silently truncating routing.
Parameters are a full replacement, not a JSON patch. Eligibility rebuilding scans the O(N) dense ID
domain and sorts eligible candidates; this POC retains O(N) score/state arrays.
On nonterminal calls, invalid parameters or demand outside [1, INT64_MAX] are retryable without losing valid state.
Demand is an upper bound, not an allocation size: only actual results are allocated. Actual result-size overflow or allocation failure closes the session, as do other traversal exceptions.
IDs marked removed before Open are permanently excluded from delivery but remain traversal bridges; user-filter rejects remain eligible for later calls.
The legacy OpenSearchSession(query, k_per_call, parameters, filter, allocator) plus Next(max)
remains a convenience: Open supplies defaults, with minimum effort max(k_per_call, ef_search).
Explicit Next(options) replaces those defaults for that call; it does not change later legacy calls.
Do not mutate the index/query metric or call session methods concurrently while a session is open. The public index and query may be destroyed after Open; the session retains backend resources. A caller-supplied allocator and any external allocator/thread pool borrowed by its index Resource must outlive the session. Results have independent buffer ownership and survive Close, session destruction and index destruction. Close is idempotent; Terminal/closed Next returns an empty dataset with cumulative work statistics preserved and zero per-round work, without demand validation. Close releases traversal storage.
Supported: ordinary label/extra-info filtering, returned extra info, output-distance thresholds,
and ordinary precise reorder. Filter-rejected vertices remain traversable bridges. Unsupported
configurations are rejected: shared duplicate storage/force removal, active MCI/conjugate search,
specialized RaBitQ, parallel/timed/factored/brute-force/hop-limited search and explicit skip strategies.
The session uses standard-library storage for its persistent frontier and score cache; the optional
allocator is used for query copying and graph neighbor scratch, not all session allocations.
Cumulative diagnostic statistics session_routing_runs, session_computers, session_scored,
session_reordered and session_expanded expose actual traversal work.
Attribute Filter (Hybrid Search)
Attribute filtering — sometimes called hybrid search or filtered ANN with structured
predicates — restricts a KnnSearch / RangeSearch to vectors whose structured tags
satisfy an SQL-like expression. Compared to the id-based filters in
Filtered Search, it lets you express predicates like:
category = "electronics" AND price <= 1000 AND multi_in(tag, "promo|new", "|")
without writing a callback. VSAG builds an attribute inverted index alongside the vector index; the predicate is parsed once and evaluated during graph traversal, so candidates that cannot satisfy the predicate are pruned early.
“Hybrid search” on this page means vector + structured attributes (not a storage-layout hybrid).
When to Use Each Filter API
| You want to … | Use |
|---|---|
| Exclude a known set of ids (e.g. tombstones) | Bitset / function filter |
| Run user-defined logic over an id | Filter object |
| Filter on opaque per-vector bytes inside the graph | Extra Info |
| Filter on named, typed fields with AND/OR/IN | This page |
All three can be combined inside a single SearchRequest; they are ANDed together.
Index Support
| Index | Build with use_attribute_filter | SearchWithRequest + attribute string | UpdateAttribute |
|---|---|---|---|
| HGraph | Yes | Yes | Yes |
| IVF | Yes | Yes | Yes |
| BruteForce | Yes | Yes | Yes |
| WARP (sparse) | Yes | Yes | Yes |
| SINDI / Pyramid | — | id-based filters only (see Filtered Search) | — |
When use_attribute_filter is enabled, BruteForce currently rejects Remove calls
(re-add the index to delete entries).
Reusing parsed expressions
For repeated requests with the same predicate, parse it with the index schema and pass the
result in SearchRequest::expression_:
auto expression = vsag::AstParse("age >= 18", index->GetAttrTypeSchema());
vsag::SearchRequest request;
request.enable_attribute_filter_ = true;
request.expression_ = expression;
// Set query_, topk_, and index-specific search parameters as usual.
A non-null expression_ takes precedence over attribute_filter_str_ in IVF, HGraph,
and BruteForce. Attribute filtering must still be enabled. Treat a shared expression as
immutable while requests use it. The schema pointer is borrowed from the index; do not
retain it past the index lifetime or mutate the schema through it. Precompiled C++ clients
must rebuild because the request and index interfaces have changed.
Region filter
The ads branch’s specialized predicate is available for FastBitset-backed attribute
indexes:
region_filter(region_type, region_list, residence_tag_list, "10|20", "30", "40")
The first three arguments select the type, region, and residence fields. The final three
arguments are integer lists for region, residence, and trigger matching. Trigger values
are looked up in the residence field, not a fourth field. Unlike the old ads
implementation, execution honors the supplied field names instead of hard-coding them.
Let R, H, and T be region, residence, and trigger matches respectively. The predicate accepts a vector according to its type value:
| Type | Condition |
|---|---|
| -1 | Always accepted |
| 0 | T OR (R AND H) |
| 1 | H |
| 2 | R |
| 3 | R OR H |
| 4 | R AND H |
| 5 | R AND NOT H |
Missing values yield empty matches; other type values have no accepting branch. Missing fields or incompatible types are errors. Non-FastBitset attribute storage is not supported by this specialized executor. It can be combined with ordinary AND/OR/NOT predicates.
The type field must be signed and represent -1. Region and residence fields may use signed or unsigned integer types; query values must fit their field type and signed 64-bit input range. Out-of-range values are rejected rather than truncated.
FUNCTION(name,"arg1|arg2","type1|type2") is retained for parser/AST compatibility with
ads only. It does not register callbacks or provide search-time function execution;
passing such an expression to the executor is unsupported.
Introducing fields during updates
For IVF’s bucket attribute index, the update overload that receives both new and original attributes can introduce previously unseen fields. It creates their value maps and records their types; these are retained by serialization. Existing field types must match: mismatched incoming or original types are rejected before any postings are erased, and existing schema entries are not overwritten. This does not change other attribute-update overloads.
Attribute Data Model
Attributes are defined per vector and grouped into an AttributeSet
(include/vsag/attribute.h). Each attribute has:
- a name (string),
- a value type (
AttrValueTypeenum), - a list of values — every field is multi-valued by design, so
IN-style membership works naturally for tag-like fields.
Supported value types:
enum AttrValueType {
INT8 = 5, INT16 = 7, INT32 = 1, INT64 = 3,
UINT8 = 6, UINT16 = 8, UINT32 = 2, UINT64 = 4,
STRING = 9,
};
The schema is auto-discovered from the first build/add: the (name, type) pair seen for each field is locked. Subsequent inserts must match.
Building an AttributeSet
auto* category = new vsag::AttributeValue<std::string>();
category->name_ = "category";
category->GetValue() = { "electronics" };
auto* tags = new vsag::AttributeValue<std::string>();
tags->name_ = "tag";
tags->GetValue() = { "promo", "new" }; // multi-valued
auto* price = new vsag::AttributeValue<int32_t>();
price->name_ = "price";
price->GetValue() = { 899 };
vsag::AttributeSet set;
set.attrs_ = { category, tags, price };
Lifetime of the Attribute* entries depends on the Dataset::Owner(...) flag passed to the
dataset that carries the AttributeSet:
Owner(true)(the default):DatasetImpl’s destructor willdeleteeachAttribute*anddelete[]theAttributeSetarray; do not free them yourself.Owner(false)(used in the example below): the caller retains ownership and must free theAttribute*entries (and theAttributeSetarray, if heap-allocated) afterBuild/Addreturns.
Pick one and stick with it for a given dataset to avoid double-free or leaks.
Building an Index with Attribute Support
Set index_param.use_attribute_filter to true and (optionally) tune the
attribute-inverted-index parameters under attr_params.
std::string build_params = R"(
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"use_attribute_filter": true,
"attr_params": {
"has_buckets": false
}
}
}
)";
auto index = vsag::Factory::CreateIndex("hgraph", build_params).value();
has_buckets controls how the inverted index lays out posting lists. Defaults differ by
index:
| Index | Default has_buckets |
|---|---|
| HGraph | false |
| IVF | true |
| BruteForce | true |
Leave the defaults unless profiling indicates otherwise.
Attaching Attributes During Build / Add
Dataset::AttributeSets accepts a contiguous array of AttributeSet, one per vector
(include/vsag/dataset.h):
std::vector<vsag::AttributeSet> sets(num_vectors);
for (int64_t i = 0; i < num_vectors; ++i) {
sets[i] = build_attrs_for_row(i);
}
auto base = vsag::Dataset::Make();
base->NumElements(num_vectors)
->Dim(dim)
->Ids(ids)
->Float32Vectors(vectors)
->AttributeSets(sets.data())
->Owner(false);
index->Build(base); // or index->Add(base)
Querying with SearchRequest
Attribute filtering is only exposed via SearchWithRequest
(include/vsag/search_request.h):
vsag::SearchRequest req;
req.query_ = query;
req.mode_ = vsag::SearchMode::KNN_SEARCH;
req.topk_ = 10;
req.params_str_ = R"({ "hgraph": { "ef_search": 200 } })";
req.enable_attribute_filter_ = true;
req.attribute_filter_str_ =
"category = \"electronics\" AND price <= 1000 "
"AND multi_in(tag, \"promo|new\", \"|\")";
auto result = index->SearchWithRequest(req).value();
for (int64_t i = 0; i < result->GetDim(); ++i) {
std::cout << result->GetIds()[i] << " " << result->GetDistances()[i] << "\n";
}
You can simultaneously enable enable_filter_ (with a FilterPtr) and
enable_bitset_filter_ (with a BitsetPtr); all enabled filters are combined with
AND.
Filter Expression Language
The expression grammar is defined in src/attr/grammar/FC.g4. It is small but covers the
common needs of structured filtering.
Logical operators
| Form | Aliases |
|---|---|
| AND | AND, and, && |
| OR | OR, or, || |
| NOT | !(expr) |
| Grouping | (...) |
NOT is only available in the prefixed form !(...).
Comparison operators
For numeric fields: =, !=, >, <, >=, <=.
For string fields: only = and !=.
Numeric comparands may include arithmetic (+, -, *, /):
(price - discount) <= 100
List membership
Two forms are supported. They use the same set of keywords (IN and NOT_IN, with the
aliases listed below) but different argument shapes.
Infix bracket form — use this with a literal list:
id IN [1, 2, 3, 4]
category NOT_IN ["electronics", "clothing"]
The list members must be INTEGER literals or double-quoted strings. Single quotes are
not accepted by the grammar.
Function pipe form — use this when the candidate values are produced by string
concatenation upstream. The second argument must be a single pipe-delimited string literal,
and the third (optional) argument is the separator and must be "|":
multi_in(category, "electronics|clothing", "|")
multi_notin(uid, "1961|8669|9090", "|")
Bracket lists are not accepted in the function form (multi_in(field, [...]) is a
syntax error). Pipe strings are not accepted in the infix form.
Aliases for both forms: IN / in / MULTI_IN / multi_in,
NOT_IN / not_in / NOTIN / notin / MULTI_NOTIN / multi_notin.
A field with multiple values matches the membership predicate if any of its values is contained in the literal list.
Literals
| Kind | Examples |
|---|---|
| Integer | 42, -7 |
| Float | 3.14, 1.5e-3 |
| String | "electronics", "new" (always double-quoted) |
| Quoted integer (string) | "123" (treated as a string in multi_in) |
Identifiers match [a-zA-Z_][a-zA-Z0-9_]* and may contain dots
(namespace.field is one identifier).
Comments start with # and run to end of line.
Examples
# simple equality
category = "electronics"
# numeric range, multi-valued field
price >= 100 AND price <= 1000 AND tag IN ["promo", "new"]
# negation
!(status = "archived") AND multi_notin(region, "us-east|us-west", "|")
# arithmetic on the left side of the comparison
(end_ts - start_ts) > 3600 AND charge_type = 5
Updating Attributes
Use index->UpdateAttribute(id, new_attrs) (or the overload that also takes the previous
attribute set for cheaper inverted-index updates):
vsag::AttributeSet new_attrs = build_new_attrs();
auto status = index->UpdateAttribute(/*id=*/123, new_attrs);
The vector itself is unchanged; only the inverted index is updated. Subsequent searches see the new attribute values immediately.
Performance Notes
- The attribute inverted index adds memory roughly proportional to the average number of values per field times the number of vectors. For string fields, the dictionary cost is proportional to the number of distinct values.
- Highly selective predicates accelerate search (more candidates pruned early); very unselective predicates approach the cost of unfiltered search plus a constant overhead.
- For graph indexes, increase
ef_searchwhen predicates are very selective so the search has enough surviving candidates to converge. - Use
multi_in/INinstead of longORchains; the inverted index can resolve list membership in a single pass.
Tests as Reference
The most complete usage sample lives in the test suite:
tests/test_index/test_index_misc.cpp—TestIndex::TestWithAttr(build attributes, search viaSearchRequest, thenUpdateAttributeand re-search).tests/fixtures/data/vector_generator.cpp—generate_attributesshows how to constructAttributeSet*arrays of mixed types programmatically.src/attr/expression_visitor_test.cpp— exhaustive grammar coverage; useful as a working reference for the DSL.
Python Status
The attribute / hybrid-search API is currently C++-only. There is no pyvsag binding yet,
and the placeholder example at examples/python/todo_examples/301_feature_filter.py is
intentionally empty.
Serialization
VSAG indexes can be serialized and deserialized through the existing serialization interfaces, supporting persistence, cross-process sharing, and distributed deployment.
This page describes the existing serialization format used by Serialize and Deserialize. For
the header-first streaming format introduced later, see New Serialization.
The two formats are not compatible with each other.
Three Interfaces
1. BinarySet / ReaderSet
The most flexible option. The index is split into named binary segments, and the caller owns the storage medium (object store, KV, sharded uploads, etc.).
// Save
vsag::BinarySet bs = index->Serialize().value();
for (const auto& key : bs.GetKeys()) {
auto binary = bs.Get(key);
// Write to storage
}
// Load
vsag::BinarySet bs_loaded;
// Populate bs_loaded by reading each key from storage.
auto empty = vsag::Factory::CreateIndex("hgraph", build_params).value();
empty->Deserialize(bs_loaded);
ReaderSet is similar to BinarySet but uses a user-supplied Reader to read on demand, which
avoids loading everything at once. This is useful for memory-constrained or partial-deserialization
scenarios.
2. File Streams (std::ostream / std::istream)
The simplest option: serialize the whole index to a file or memory stream:
std::ofstream out("index.bin", std::ios::binary);
index->Serialize(out);
std::ifstream in("index.bin", std::ios::binary);
empty->Deserialize(in);
3. Custom Write Function (WriteFuncType)
For streaming or chunked backends, supply a write callback:
index->Serialize([&](const void* buf, uint64_t offset, uint64_t size) {
// Write [buf, buf+size) at offset
});
Notes
Deserializerequires an empty target index whose configuration (dim,metric_type, etc.) matches the one used at serialization time.Serialize/Deserializekeep the existing footer-based format. The newSerializeStreamingformat is header-first and must be read withDeserializeStreamingorLoad.- When upgrading across major versions, check the compatibility notes in the release notes.
- References:
examples/cpp/318_feature_tune.cpp,examples/cpp/401_persistent_kv.cpp, andexamples/cpp/402_persistent_streaming.cpp.
New Serialization
The new serialization format is designed for large index artifacts and forward-only readers. Its main goals are:
- Make the file self-describing from the beginning, so readers can inspect the magic, version, metadata, and block manifest without seeking to a footer.
- Split index content into typed TLV blocks, so tools can inspect block sizes and future readers can skip unknown non-critical blocks.
- Provide one streaming path for full restoration (
DeserializeStreaming) and policy-based loading (Load). - Support debugging and operations tooling through a stable layout that can be visualized.
The new serialization format is not compatible with the previous Serialize/Deserialize
format. Files written by SerializeStreaming must be read with DeserializeStreaming or Load;
files written by Serialize must be read with Deserialize.
Usage Model
Serialization and deserialization are the persistence and transport path for index artifacts.
SerializeStreaming writes a built index into a self-describing file, and DeserializeStreaming
restores the complete in-memory index when a caller already knows which index object to create.
Index::Load is the serving path: it creates the index from the file metadata and returns an
IndexPtr that can be used for search.
Streaming Serialization
SerializeStreaming, DeserializeStreaming, and Load write and read a forward-only index file.
The format is designed for large index artifacts where the reader should not seek to a footer
before it can understand the file layout. It is currently implemented for BruteForce, HGraph, IVF,
SINDI, and Pyramid.
auto index = vsag::Factory::CreateIndex("hgraph", build_params).value();
index->Build(base).value();
{
std::ofstream out("hgraph.streaming", std::ios::binary);
index->SerializeStreaming(out).value();
}
auto restored = vsag::Factory::CreateIndex("hgraph", build_params).value();
{
std::ifstream in("hgraph.streaming", std::ios::binary);
restored->DeserializeStreaming(in).value();
}
vsag::IndexPtr loaded;
{
std::ifstream in("hgraph.streaming", std::ios::binary);
loaded = vsag::Index::Load(in, "{}").value();
}
Static Load
Index::Load is the entry point for policy-based loading from the new streaming format. Unlike
DeserializeStreaming, callers do not create an empty index first. Load reads the streaming
metadata, checks the serialized index type and basic_info["index_param"], creates the matching
index internally, and then loads the TLV body blocks according to the load parameters.
std::ifstream in("hgraph.streaming", std::ios::binary);
vsag::LoadParameters load_parameters(R"({"base_io_type":"block_memory_io"})");
auto loaded = vsag::Index::Load(in, load_parameters).value();
The returned value is a ready-to-use IndexPtr, so this is the preferred path for loading
an index that will serve search traffic. Load parameters control placement policy for supported
blocks. The parameters object can be built from a JSON string and can also carry reader objects
with SetReader. Unsupported policies return an error instead of silently falling back. The API
currently supports streaming BruteForce, HGraph, IVF, SINDI, and Pyramid indexes. BruteForce
supports limited block placement policies. HGraph can bind high_precision_codes to an external
reader through precise_reader. IVF can do the same for either precise-code layout. SINDI and
Pyramid currently load all emitted streaming blocks into memory.
File Layout
A streaming file starts with a fixed header and then a sequence of TLV blocks:
magic("vsagstm0")
format_version
metadata_length
metadata_json
metadata_checksum
block_header + block_payload
block_header + block_payload
...
section_end
The metadata JSON stores the index name, basic index information, and a block manifest; build
parameters are stored in basic_info["index_param"]. The manifest lists the expected block tags,
block versions, and whether a block is critical. Unknown critical blocks fail deserialization;
unknown non-critical blocks can be skipped by compatible readers.
TLV Block Version Compatibility
format_version describes the whole streaming file structure, such as the fixed header, metadata
layout, and TLV framing. When only one block payload changes in a binary-incompatible
way, the format should not bump the global format version. Instead, bump the
block_version of that TLV block. For
example, if HGraph base_codes cannot be parsed by older readers after a basic_flatten_codes
implementation change, the base_codes block version must be increased.
Each independently evolving block must distinguish two kinds of version information:
- Current write version: the
block_versionwritten by the current code when serializing that block. - Supported read versions: the set or range of block versions that the current code can read for that block.
After a reader reads the TLV header, it checks whether tag + block_version is supported by the
current code:
- Supported versions are parsed by the matching block reader.
- Unsupported critical blocks fail fast, preventing older code from misreading newer bytes.
- Unsupported non-critical blocks are skipped with
value_len, then reading continues from the next block.
Therefore, when a block is upgraded from v1 to v2, the implementation must not only change the current write version to v2; it must also update that block’s supported read versions. If the new code keeps the v1 reader, supported versions should include both v1 and v2 so v2 code can still load v1 indexes. If v1 is intentionally no longer supported, remove it from the supported versions and make old critical blocks fail explicitly.
The block manifest in metadata lets tools and readers inspect expected block versions before reading
the body. During body parsing, the block_version stored in each TLV header remains the
authoritative version for that payload.
BruteForce Blocks
BruteForce writes these streaming blocks in order:
| Block | Contents | Required |
|---|---|---|
attribute_filter | optional attribute filter index | conditional |
base_codes | flatten codes used for exhaustive search | yes |
label_table | external labels and label remap | yes |
DeserializeStreaming restores the full in-memory BruteForce index. Load currently requires
base_codes to be loaded into memory; reader-based loading for required BruteForce codes is
rejected.
HGraph Blocks
HGraph writes these streaming blocks in order:
| Block | Contents | Required |
|---|---|---|
label_table | external labels, label remap, optional source id table | yes |
base_codes | base flatten codes used by graph search | yes |
bottom_graph | bottom-layer graph over all vectors | yes |
high_precision_codes | precise reorder codes when reorder uses separate codes | conditional |
route_graphs | all upper route graph layers | yes |
extra_info | optional extra info payloads | conditional |
attribute_filter | optional attribute filter index | conditional |
raw_vector | optional stored raw vectors | conditional |
DeserializeStreaming restores the full in-memory index. Load loads HGraph blocks into memory by
default. Load parameters can set precise_io_type to override the IO type for precise_codes. If
they also provide precise_reader, and that reader size matches the high_precision_codes payload
size, Load validates the external reader payload checksum and then binds reorder codes to that
reader.
IVF Blocks
IVF writes these streaming blocks in order:
| Block | Contents | Required |
|---|---|---|
ivf_bucket | bucket datacell payloads for inverted lists | yes |
ivf_partition_strategy | partition strategy state, such as trained centroids | yes |
label_table | external labels and label remap | yes |
high_precision_codes | flat reorder codes when IVF reorder uses the flat layout | conditional |
ivf_precise_bucket | bucket-aligned reorder codes when IVF uses the bucket layout | conditional |
attribute_filter | optional attribute filter index | conditional |
Load can bind either precise-code block to an external reader. Set precise_io_type to
reader_io, provide precise_reader, and make the reader expose exactly the
high_precision_codes payload for flat or the ivf_precise_bucket payload for bucket. Load
validates its size and checksum before the returned index can query it.
precise_enable_read_cache and precise_cache_total_size configure the normal read cache for
the remote precise codes.
DeserializeStreaming restores the full in-memory IVF index. Index::Load can create the IVF
index directly from streaming metadata. It loads IVF blocks into memory by default; reader-backed
precise codes remain external. The two precise-code blocks are mutually exclusive and are selected
by precise_codes_layout.
SINDI Blocks
SINDI writes these streaming blocks in order:
| Block | Contents | Required |
|---|---|---|
sindi_windows | sparse term windows and quantization runtime state | yes |
label_table | external labels and label remap | yes |
sindi_rerank_index | optional rerank flat index when rerank is enabled | conditional |
sindi_term_id_mapper | optional term-id remapping table | conditional |
sindi_host_metadata | optional host dictionary and one-or-more inner-ID ranges per host | conditional |
sindi_time_metadata | optional per-document UTC days and time-partition/host directories | conditional |
DeserializeStreaming restores the full in-memory SINDI index. Index::Load can create the SINDI
index directly from streaming metadata and currently loads all emitted SINDI blocks into memory.
Mutable and immutable SINDI runtimes both support this streaming path. Legacy serialization stores
standalone host metadata, including its persisted string-to-ID dictionary, or publish-time routing
metadata for mutable and immutable indexes. These top-level payloads are mutually exclusive;
combined time-and-host routing stores its host directory inside the time payload. A restored
mutable time-aware index rejects incremental Add().
SINDI_V2 Blocks
SINDI_V2 writes these streaming blocks in order:
| Block | Contents | Required |
|---|---|---|
sindi_v2_term_layout | term-first posting layout, element count, and quantization state | yes |
label_table | external labels and label remap | yes |
sindi_rerank_index | optional rerank index when rerank is enabled | conditional |
extra_info | optional per-document extra information | conditional |
sindi_term_id_mapper | optional term-id remapping table | conditional |
sindi_host_metadata | optional host dictionary and one-or-more inner-ID ranges per host | conditional |
sindi_time_metadata | optional per-document UTC days and time-partition/host directories | conditional |
DeserializeStreaming and Index::Load restore mutable or immutable SINDI_V2 runtimes from these
blocks. Legacy serialization stores standalone host metadata, including its persisted string-to-ID
dictionary, or publish-time routing metadata for mutable and immutable indexes. These top-level
payloads are mutually exclusive; combined time-and-host routing stores its host dictionary and
directory inside the time payload. A restored mutable time-aware index rejects incremental Add().
Pyramid Blocks
Pyramid writes these streaming blocks in order:
| Block | Contents | Required |
|---|---|---|
label_table | external labels and label remap | yes |
base_codes | base flatten codes used by graph search | yes |
high_precision_codes | precise reorder codes when reorder is enabled | conditional |
pyramid_hierarchies | hierarchy names and graph roots | yes |
DeserializeStreaming restores the full in-memory Pyramid index. Index::Load can create the
Pyramid index directly from streaming metadata and currently loads all emitted Pyramid blocks into
memory.
Visualizing a Streaming Index
Build the tool and point it at a streaming index file:
cmake --build build --target visualize_index
build/tools/visualize_index/visualize_index \
--index_path /tmp/vsag-hgraph-streaming.index \
--html /tmp/vsag-hgraph-streaming.html
The CLI output includes a raw horizontal layout by real byte proportion and a compact logical-block layout. The HTML output groups related small segments, such as a TLV header and its payload, and shows exact segment details in tables.
See examples/cpp/403_persistent_streaming_load.cpp for runnable examples of streaming
serialization and Index::Load.
Chunked Serialization
Chunked serialization writes an index body as a sequence of independently readable frames and records their physical placement in the footer. Because every frame can be located and restored on its own, the resulting file can be loaded concurrently — see Parallel Deserialization.
This page describes the chunked form produced by Serialize(SerializeWriter&). It is a variant of
the footer-based format described in Serialization; the header-first format in
New Serialization is a separate mechanism and is not interchangeable with
it.
The chunked form is currently implemented for HGraph; other index types return an unsupported-operation error.
SerializeWriter
SerializeWriter is a byte sink that optionally understands frame boundaries. Implementations
decide the codec: a plain writer stores the body verbatim, while a compressing writer (for example
zstd) emits one independent compressed frame per chunk.
class MyWriter : public vsag::SerializeWriter {
public:
void Write(const char* data, uint64_t size) override { /* append bytes */ }
// Compression is optional. A writer that does not compress reports an empty
// name and leaves the frame hooks as no-ops.
std::string GetCompressorName() const override { return "zstd"; }
void BeginCompressedFrame() override { /* reset the codec session */ }
uint64_t EndCompressedFrame() override { /* flush; return bytes written */ }
};
Each frame is self-contained: BeginCompressedFrame resets the codec session, so a reader can
decompress any single frame without touching its neighbours.
Writing a Chunked File
MyWriter writer(out);
index->Serialize(writer).value(); // chunk_size defaults to 128 MiB
index->Serialize(writer, 64 * 1024 * 1024).value(); // or pick a frame size
The body is split into frames of at most chunk_size logical bytes. chunk_size is a property of
the written file, not a runtime knob: it is recorded in the footer, and readers always follow the
value stored in the file. Changing it therefore affects new files only and never invalidates
existing ones.
Each component is written with one of two granularities:
- whole — the component occupies a single frame. Small components, and components that expose no parallel fill hooks, are written this way.
- chunked — the component is a head / io-data / tail triple. Only the io data is split into frames; the head and tail stay plaintext.
Footer Layout
The footer carries a chunked_layout key that describes the physical placement of every
component:
{
"chunked_layout": {
"version": 1,
"codec": "zstd",
"chunk_size": 33554432,
"components": [
{ "name": "label_table", "type": "whole",
"offset": 0, "csize": 489597, "lsize": 2002832 },
{ "name": "base_codes", "type": "chunked", "io_size": 102760448,
"head": { "offset": 489597, "size": 20 },
"chunks": [ { "offset": 489617, "csize": 33555209 } ],
"tail": { "offset": 102892065, "size": 8229 } }
]
}
}
The layout is the single source of truth for readers: they parse the footer from the end of the data source and drive every read from it, without prior knowledge of the component order. Two validations run at parse time — the recorded chunks must cover the component extent exactly, and unknown component types are rejected — so a reader older than a format extension fails fast instead of misparsing the body.
Compatibility
| File form | Deserialize(istream) | ParallelDeserialize |
|---|---|---|
| existing all-in-one, uncompressed | supported | supported through the probe path |
| chunked, uncompressed | supported (same body order as before) | supported through the layout |
| chunked, compressed | rejected with a clear error | supported through the layout |
An uncompressed chunked file stays readable by the sequential path because its body bytes keep the
same order as the existing format and the extra chunked_layout key is ignored by older readers. A
compressed chunked body is a sequence of independent frames rather than one stream, so the
sequential reader rejects it instead of misparsing it.
In the other direction, an uncompressed file that carries no layout at all — including the
all-in-one output of Serialize(ostream) — can still be loaded concurrently; see
Loading Files Without a Layout.
Notes
- Frame size trades footer size against load concurrency: smaller frames yield more parallel tasks but add roughly one record per frame to the footer.
- The codec name is recorded in the layout; a reader that cannot provide the matching decompressor fails with an explicit error.
Parallel Deserialization
Loading a large index is often I/O bound: the sequential Deserialize path reads and decompresses
the file with a single thread, so multi-core machines and high-bandwidth storage stay
underutilized. ParallelDeserialize restores an index from a
chunked file with one task per frame, driven by a caller-supplied
positioned reader and the thread pool bound to the index.
The parallel entry points are currently implemented for HGraph; other index types return an unsupported-operation error.
DeserializeReader
The caller supplies the data source. VSAG never opens the file itself, which keeps object storage, distributed file systems, and local files behind one interface:
class MyReader : public vsag::DeserializeReader {
public:
uint64_t Size() const override { /* total size of the index data */ }
void Read(uint64_t offset, uint64_t len, void* dest) override {
/* positioned read; must throw on short reads and out-of-range offsets */
}
void ReadDecompressed(uint64_t offset, uint64_t compressed_size,
const std::function<void(std::istream&)>& consume) override {
/* decompress the frame at [offset, offset + compressed_size) into a
sequential stream and hand it to consume; required only for files
written through a compressing writer */
}
};
The interface has two levels. A plain source implements Size and Read only and can load
uncompressed chunked files; a source that additionally implements ReadDecompressed can load
compressed ones.
Read and ReadDecompressed are invoked concurrently from worker threads on non-overlapping
ranges, so an implementation must be thread-safe for positioned reads. A handle whose reads
share a cursor, or serialize on an internal lock, silently degrades the load to a single I/O
stream even when the pool is large; prefer a per-call positioned read (pread-style) or a pool of
handles. Each decompressed stream is consumed by exactly one thread.
Loading an Index
MyReader reader; // wraps a file / object-storage handle
auto index = vsag::Factory::CreateIndex("hgraph", build_params).value();
index->ParallelDeserialize(reader).value(); // internal default pool
To run the load on your own executor, bind the pool to the Resource the index is created from:
auto allocator = vsag::Engine::CreateDefaultAllocator();
auto pool = vsag::Engine::CreateThreadPool(16).value();
vsag::Resource resource(allocator, pool);
vsag::Engine engine(&resource);
auto index = engine.CreateIndex("hgraph", build_params).value();
index->ParallelDeserialize(reader).value(); // runs on the bound pool
Binding a pool lets the caller reuse an existing executor and bound the concurrency of the whole process. The pool must outlive the call.
Load Phases
- Prepare (main thread). Parse the footer and, for every chunked component, read its head and pre-allocate the io extent.
- Fill (thread pool). One task per frame: decompress the frame when compressed and write it into the pre-allocated extent at its recorded offset. Tasks touch disjoint byte ranges, so no locking is required.
- Finalize (main thread). Read the tails and run the same post-load setup as the sequential path.
Pre-allocating in phase 1 is what makes phase 2 lock-free: the extent never grows while workers are writing. Before any task is dispatched, the recorded layout is validated against the body extent: component names must be unique, every frame must lie inside the body without overflowing, no two frames may overlap, and an uncompressed codec must record a physical size equal to the logical size. Per-frame byte counts are validated against the layout and each whole component must consume exactly its recorded logical size, so a corrupted or tampered frame fails with an error instead of corrupting memory.
Loading Files Without a Layout
An index written before the chunked form existed carries no chunked_layout in its footer, but it
can still be loaded concurrently as long as its body is uncompressed — including the all-in-one
output of Serialize(ostream). ParallelDeserialize detects the missing layout and
switches to a probe path:
- The main thread walks the body in serialization order. For every component that exposes the parallel hooks it reserves the io extent, records the extent’s file offset, skips over the io bytes, and reads the tail; components without the hooks are deserialized in place.
- The recorded extents are then filled by the pool, split into tasks of
DEFAULT_SERIALIZE_CHUNK_SIZEbytes each.
The body must be uncompressed for this to work: without a recorded layout there is no way to locate the individual frames of a compressed body, so only files whose body bytes are stored verbatim can be probed.
Because the head and tail of every component are still read sequentially by the main thread, the probe path scales less than the layout path, but it needs no re-serialization of existing files.
Peak memory is decided by the io type chosen for each component, not by whether a layout is
recorded: a SkipDeserialize io such as reader_io only records the extent and seeks past the
bytes on both paths, a file-backed io writes into its backing file, and only an in-memory io holds
the component on the heap. What the recorded layout buys is concurrency and support for compressed
frames.
Concurrency and IO Notes
- Allocator. Workers may allocate through the index allocator; a custom allocator must be thread-safe.
- Memory. Frames are streamed, not accumulated: a task holds a bounded read window and a codec
context rather than a whole chunk, so peak memory scales with the thread count instead of
chunk_size. - Whole components are handled by a single task each. For compressed files they are read forward-only, so a component’s deserialization must not seek backwards inside its frame.
- File-backed io. In-memory, mmap, buffered, and asynchronous io types fill their large
components concurrently.
reader_iois excluded — its write path is a no-op — and its components fall back to whole frames loaded by one task each. - Thread count. The benefit comes mainly from I/O concurrency. Because worker threads spend most of their time blocked in reads, sizing the pool after the storage bandwidth and the number of independent frames is usually more effective than matching the CPU core count.
Performance
1M x 1024 vectors, sq8 base with fp32 reorder, 32 MiB frames, zstd, 4.6 GiB index file, loaded
from page cache on a 48-core machine. Speedup over the sequential Deserialize, whose wall-clock
baseline is given in the second column:
| io type | sequential | 4 threads | 8 threads | 16 threads |
|---|---|---|---|---|
| block_memory_io | 12.53 s | 1.70x | 2.98x | 4.64x |
| memory_io | 13.19 s | 1.83x | 3.25x | 5.19x |
| mmap_io | 14.51 s | 1.75x | 3.05x | 3.50x |
| buffer_io | 11.83 s | 1.67x | 2.74x | 2.68x |
Recall is unaffected: every configuration above returns results identical to the in-memory index, query by query.
The probe path gives a smaller but still useful speedup on files that predate the chunked form. The
same index written by Serialize(ostream) (5.0 GiB, uncompressed, block_memory_io) loads as
follows, with the single-argument Deserialize(istream) as the baseline:
| mode | wall time | speedup |
|---|---|---|
Deserialize(istream) | 7.42 s | 1.00x |
ParallelDeserialize, 4 threads | 3.79 s | 1.96x |
ParallelDeserialize, 8 threads | 3.06 s | 2.43x |
ParallelDeserialize, 16 threads | 2.77 s | 2.67x |
Note that a single-threaded parallel load is slower than the sequential path. Each frame builds its own codec session and issues its own positioned read, and with one worker that overhead has nothing to overlap with. The crossover is around four threads.
See Also
- Chunked Serialization — the file format this path consumes.
- Serialization — the sequential interfaces and the footer-based format.
Memory Management
VSAG uses custom Allocator and Resource objects on its hot paths, allowing users to:
- plug in existing in-house memory pools;
- measure and cap index memory usage;
- route allocations precisely in multi-process or NUMA environments.
Custom Allocator
class MyAllocator : public vsag::Allocator {
public:
std::string Name() override { return "my_allocator"; }
void* Allocate(size_t size) override;
void Deallocate(void* p) override;
void* Reallocate(void* p, size_t size) override;
// ...
};
auto allocator = std::make_shared<MyAllocator>();
auto resource = std::make_shared<vsag::Resource>(allocator, /*thread_pool=*/nullptr);
auto engine = vsag::Engine(resource);
auto index = engine.CreateIndex("hgraph", build_params).value();
See examples/cpp/201_custom_allocator.cpp for a full example.
Per-Search Temporary Allocator
KnnSearch / RangeSearch can take a per-call Allocator that lives in a thread-local arena,
avoiding contention with the global heap:
vsag::SearchParam search_param;
search_param.allocator = thread_local_allocator.get();
auto result = index->KnnSearch(query, k, search_param);
See examples/cpp/313_feature_search_allocator.cpp and
examples/cpp/314_feature_hgraph_search_allocator.cpp.
Estimating and Querying Memory
EstimateMemory(data_num)
Index::EstimateMemory(data_num) returns a byte-level estimate of the memory the index will
occupy once data_num vectors have been inserted. It is computed from the build parameters
(dimension, quantization, max_degree, etc.) without allocating any vector storage, so it is
safe to call on an empty index and is the recommended way to size a node before ingest:
if (index->CheckFeature(vsag::SUPPORT_ESTIMATE_MEMORY)) {
uint64_t estimated = index->EstimateMemory(1'000'000); // bytes
}
See examples/cpp/308_feature_estimate_memory.cpp for a full run.
EstimateBuildMemory(num_elements)
Index::EstimateBuildMemory(num_elements) returns the estimated memory (in bytes) required
during the build process for num_elements vectors. Unlike EstimateMemory, which
estimates the steady-state size of the final index, this accounts for temporary buffers and
intermediate data structures that exist only while Build is running. The peak memory during
build is typically higher than the post-build footprint:
uint64_t peak = index->EstimateBuildMemory(1000000); // bytes
No maintained index currently provides a non-trivial implementation; callers must handle the default unsupported-operation exception.
GetMemoryUsage()
Index::GetMemoryUsage() returns the current memory footprint of an index in bytes:
uint64_t bytes = index->GetMemoryUsage();
Properties:
- Implemented by every index type, but only indexes that advertise
vsag::SUPPORT_GET_MEMORY_USAGEviaCheckFeatureare formally guaranteed to return a meaningful value. HGraph, IVF, BruteForce, Pyramid and WARP set the flag (seesrc/algorithm/{hgraph,ivf,brute_force,pyramid,warp}.cpp); SINDI implements the call (since the method is pure-virtual onIndex) but does not currently set the feature flag, so treat its value as informational only. - Thread-safe; can be polled concurrently with searches.
- Latency is on the order of microseconds — suitable for production-grade real-time monitoring loops.
- Reports memory attributable to the index itself (vectors, graph, quantizer state). The number
is typically smaller than the resident set size observed at the OS level, which also includes
allocator overhead, scratch buffers, and any data held outside the index (e.g. user-owned input
vectors). For SINDI in particular, call
GetMemoryUsage()after the build completes to get a representative value.
See examples/cpp/319_feature_get_memory_usage.cpp for a runnable example, including a helper
that compares the interface value with the process resident size.
GetMemoryUsageDetail()
Index::GetMemoryUsageDetail() returns a breakdown of the current memory usage by
component:
std::unordered_map<std::string, uint64_t> detail = index->GetMemoryUsageDetail();
for (const auto& [component, bytes] : detail) {
std::cout << component << ": " << bytes << " bytes\n";
}
The returned map keys are component names and values are memory in bytes. This is useful for understanding where the memory is going inside an index.
Currently only HGraph provides a meaningful implementation, returning components such as
basic_flatten_codes, bottom_graph, route_graph, neighbors_mutex, pool,
label_table, high_precise_codes, extra_infos, and raw_vector. SINDI returns an empty
map. Other index types throw an exception by default.
Capability Flags
| Flag | Meaning |
|---|---|
vsag::SUPPORT_ESTIMATE_MEMORY | EstimateMemory(data_num) is available. |
vsag::SUPPORT_GET_MEMORY_USAGE | GetMemoryUsage() is available. |
Both flags can be checked via index->CheckFeature(...) — see
Index Introspection.
Thread Pool
Resource also accepts a user-supplied ThreadPool, which combined with a custom allocator gives
full control over parallelism and resource ownership. See
examples/cpp/203_custom_thread_pool.cpp.
Notes
- A custom allocator must be thread-safe.
- The allocator’s lifetime must outlive any index and result object referencing it.
- If nothing is configured, VSAG falls back to a default
malloc-based allocator.
Per-Search Allocator
VSAG exposes a per-call Allocator hook that is separate from the index’s own allocator,
intended for use cases such as:
- isolating per-query memory from the index’s long-lived heap;
- backing high-concurrency online traffic with a thread-local arena that has no atomic contention with neighbours;
- accounting or capping each query’s footprint independently of the index.
The hook is exposed through two surfaces — SearchRequest::search_allocator_ (recommended) and
the legacy SearchParam::allocator — but how much of a search actually consumes that
allocator depends on the index and the entry point. HGraph, IVF, Pyramid, and SINDI use a
non-null SearchRequest::search_allocator_ for non-empty result buffers as well as search
scratch state. BruteForce (including WARP mode) uses it for selected temporary work, while its
result Dataset still uses the index allocator. See
Relationship to the Index’s Allocator below for the
per-surface breakdown.
Scope. The allocator hook is exposed through
KnnSearch(SearchParamoverload) andSearchWithRequest. The dedicatedRangeSearchoverloads do not accept an allocator, but a range-modeSearchRequestdoes, and supporting indexes consultsearch_allocator_on that path too.
Recommended API — SearchRequest::search_allocator_
#include "vsag/search_request.h"
vsag::SearchRequest req;
req.query_ = query;
req.mode_ = vsag::SearchMode::KNN_SEARCH;
req.topk_ = 10;
req.params_str_ = R"({"hgraph":{"ef_search":100}})";
req.search_allocator_ = thread_local_allocator.get(); // optional, may stay nullptr
auto result = index->SearchWithRequest(req).value();
SearchRequest (include/vsag/search_request.h) is the recommended, non-deprecated way to drive
a single search call. The search_allocator_ field is optional — when left at nullptr, the
index falls back to the allocator that was attached to its owning Resource.
Availability.
Index::SearchWithRequesthas a default implementation that returns an unsupported error. HGraph, IVF, BruteForce, WARP, SINDI, SINDI_V2 and Pyramid implement it today. Pyramid supports KNN request searches, includingexpected_labels_reasoning reports; range requests with expected labels are not supported.
Legacy API — SearchParam::allocator (deprecated)
#include "vsag/search_param.h"
nlohmann::json search_params = {{"hgraph", {{"ef_search", 100}}}};
std::string param_str = search_params.dump();
vsag::SearchParam search_param(/*iter_filter=*/false,
param_str,
/*filter=*/nullptr,
/*allocator=*/thread_local_allocator.get());
auto result = index->KnnSearch(query, /*k=*/10, search_param).value();
SearchParam is documented as deprecated in include/vsag/search_param.h (“Use SearchRequest
instead”) and remains only for source compatibility. The wording is currently a doc comment —
the struct itself does not carry the C++ [[deprecated]] attribute, so the compiler will not
emit deprecation warnings, but new code should still target SearchRequest /
SearchWithRequest on indexes that support it. The example
examples/cpp/314_feature_hgraph_search_allocator.cpp (HGraph) demonstrates the legacy form.
Result Ownership
The result-Dataset ownership contract depends on which index implements SearchWithRequest:
- HGraph / IVF / Pyramid / SINDI pass the selected query allocator into their non-empty
result builders. When
request.search_allocator_is non-null, the returnedDatasetowns itsids/distancesbuffers through that allocator and releases them from its destructor. - BruteForce / WARP currently build the result through
pack_knn_result_with_extra_info(heap)without an allocator argument, so the result uses the index allocator.request.search_allocator_is still used for selected temporary work. - Empty results use the shared empty-dataset path and do not allocate result buffers through the per-search allocator.
What this means in practice:
- Do not manually
Deallocatethe result buffers. Letting theDatasetgo out of scope is enough; double-freeing through both manualDeallocate(...)and the destructor is undefined behaviour. - Whichever allocator owns the result must outlive that result
Dataset. For HGraph, IVF, Pyramid, and SINDI this can be the per-search allocator; for BruteForce / WARP it is the index allocator.
The simplest safe pattern is “one allocator per thread, reset between batches”:
ArenaAllocator arena; // thread-local, big enough for one batch
for (const auto& q : batch) {
vsag::SearchRequest req;
req.query_ = q;
req.topk_ = topk;
req.params_str_ = params;
req.search_allocator_ = &arena;
auto result = index->SearchWithRequest(req).value();
consume(result);
// result Dataset destroyed here; arena frees ids/distances via its Deallocate.
}
arena.reset(); // drops every per-query buffer at once
Relationship to the Index’s Allocator
| Surface | Allocator used |
|---|---|
| Index build, insert, persistent state | Resource’s allocator (or the default if none was passed). |
HGraph / IVF / Pyramid / SINDI SearchWithRequest non-empty result | search_allocator_ if set, otherwise the index allocator. |
BruteForce / WARP SearchWithRequest result | The index allocator; search_allocator_ is used only for selected temporary work. |
SearchWithRequest scratch state | Index-specific; the implementations above consult search_allocator_ for at least part of the query path. |
KnnSearch(query, k, SearchParam) (legacy) | SearchParam::allocator on indexes whose legacy overload honors it; otherwise the index allocator. |
KnnSearch(query, k, parameters_str) | No per-search allocator hook; uses the index allocator. |
Dedicated RangeSearch(...) overloads | No allocator parameter; use the index allocator. |
Range-mode SearchWithRequest | Follows the same index-specific rules as KNN-mode SearchWithRequest. |
SINDI and IVF reasoning containers also use the selected search allocator:
search_allocator_ when non-null, otherwise the index allocator. This does not mean
that all standard-library allocations (such as JSON or string storage) use that allocator.
Setting a per-search allocator never affects the index’s permanent data structures. It only narrows the lifetime of memory touched by one specific search call, and only to the extent that the index/entry point actually consumes it (see the per-row notes above).
Requirements
- The allocator must be thread-safe only if it is shared across threads. A thread-local arena does not need internal synchronization.
- The allocator’s lifetime must outlive every result
Datasetit produced. Reallocate(nullptr, size)must behave likeAllocate(size). VSAG relies on this contract for its internal containers.
Runnable Examples
examples/cpp/314_feature_hgraph_search_allocator.cpp— HGraph (sq8) + custom allocator.
See also Memory Management for the index-level Allocator / Resource setup, and
Filtered Search for combining a per-search allocator with custom filtering
in a SearchRequest.
Index Introspection
VSAG indexes expose three families of introspection APIs that let callers discover what an index can do, compute distances against existing vectors, and read back structured information about the built index without re-running a search:
CheckFeature(IndexFeature)— runtime capability discovery.CalcDistancesById(...)— distances from a query to specific stored ids.GetIndexDetailInfos()/GetDetailDataByName(...)— structured per-index detail data.
These APIs are read-only and safe to call concurrently with search.
Capability Discovery — CheckFeature
index->CheckFeature(vsag::SUPPORT_*) returns true when the underlying index implementation
advertises the given feature. Use it whenever a code path takes an IndexPtr of unknown concrete
type (e.g. user-supplied configuration, polymorphic store):
if (index->CheckFeature(vsag::SUPPORT_ESTIMATE_MEMORY)) {
uint64_t est = index->EstimateMemory(100'000);
}
if (not index->CheckFeature(vsag::SUPPORT_DELETE_BY_ID)) {
// Skip / fall back to remove + re-add via a different index.
}
Feature flags cover almost every optional surface in the library: build / add /
serialize variants, concurrent combinations, metric types, attribute and extra-info filters,
Clone, ExportModel, Tune, and more. See include/vsag/index_features.h for the full
enumeration.
A runnable example is available at examples/cpp/307_feature_check_features.cpp.
Distances to Existing Ids — CalcDistancesById
CalcDistancesById computes the distance between a query and one or more vectors that are
already stored in the index, without running a search. This is useful for re-ranking, A/B
evaluation, ground-truth checks, or computing pairwise distances to a known shortlist.
Two overloads are provided:
// Dense vector indexes (HGraph, BruteForce, IVF)
auto r = index->CalcDistancesById(query_ptr, ids, count, /*calculate_precise_distance=*/true);
// Sparse vector indexes (SINDI, SINDI_V2) — wrap the query in a Dataset
auto query_ds = vsag::Dataset::Make();
query_ds->NumElements(1)->SparseVectors(/* ... */);
auto r = index->CalcDistancesById(query_ds, ids, count, /*calculate_precise_distance=*/true);
The raw-pointer overload returns one row of count distances. The DatasetPtr overload can
process several queries on indexes that advertise SUPPORT_BATCH_CALC_DISTANCE_BY_ID: candidate
IDs and distances then form NumElements() * count row-major matrices. A value of -1.0F means
the corresponding ID was invalid. A positive topk returns the nearest candidates per row,
sorted by distance, together with their IDs. See
Calculate Distance by ID for the layout and support matrix.
calculate_precise_distance
The trailing bool argument trades precision for latency:
| Value | Behavior |
|---|---|
true (default) | Use the full-precision vector representation. May incur disk I/O on hybrid memory-disk indexes. |
false | Use the quantized / approximate representation cached for search. Faster, no I/O. |
A runnable example is available at examples/cpp/306_feature_calculate_distance_by_id.cpp.
Detail Data — GetIndexDetailInfos / GetDetailDataByName
GetIndexDetailInfos() returns a list of IndexDetailInfo records that describe every named
piece of structured data the index can expose. Each record carries a name, a description, and
a type enum that selects the right typed accessor on DetailData.
Support is index-dependent — there is no dedicated SUPPORT_* flag for these two APIs. The
Index base class throws std::runtime_error("Index doesn't support ...") by default
(GetIndexDetailInfos and GetDetailDataByName in include/vsag/index.h:658,674);
HGraph / IVF / BruteForce / Pyramid / SINDI / WARP implement them through
InnerIndexInterface. Always handle the tl::expected error path when calling these APIs.
auto infos = index->GetIndexDetailInfos().value();
for (const auto& info : infos) {
std::cout << info.name << " : " << info.description << '\n';
}
Once you know which entries are available, call GetDetailDataByName(name, info) to retrieve the
typed payload:
vsag::IndexDetailInfo info;
auto detail = index->GetDetailDataByName(vsag::INDEX_DETAIL_NAME_NUM_ELEMENTS, info).value();
int64_t n = detail->GetDataScalarInt64();
detail = index->GetDetailDataByName(vsag::INDEX_DETAIL_NAME_LABEL_TABLE, info).value();
auto table = detail->GetData2DArrayInt64(); // [row][col] int64 matrix
detail = index->GetDetailDataByName(vsag::INDEX_DETAIL_DATA_TYPE, info).value();
std::string dt = detail->GetDataScalarString();
Data Types
info.type selects which accessor on DetailData is valid:
IndexDetailDataType | Accessor |
|---|---|
TYPE_SCALAR_INT64 | GetDataScalarInt64() |
TYPE_SCALAR_DOUBLE | GetDataScalarDouble() |
TYPE_SCALAR_BOOL | GetDataScalarBool() |
TYPE_SCALAR_STRING | GetDataScalarString() |
TYPE_1DArray_INT64 | GetData1DArrayInt64() |
TYPE_2DArray_INT64 | GetData2DArrayInt64() |
Standard detail names exposed as constants in include/vsag/index_detail_info.h:
| Constant | Typical type | Meaning |
|---|---|---|
INDEX_DETAIL_NAME_NUM_ELEMENTS | TYPE_SCALAR_INT64 | Number of vectors currently in the index. |
INDEX_DETAIL_NAME_LABEL_TABLE | TYPE_2DArray_INT64 | Per-vector label table (e.g. internal-to-user id mapping). |
INDEX_DETAIL_DATA_TYPE | TYPE_SCALAR_STRING | Underlying vector data type (e.g. "float32"). |
Individual indexes may expose additional names; iterate GetIndexDetailInfos() to discover them
at runtime. A runnable example is available at examples/cpp/317_feature_get_detail_data.cpp.
Notes and Limitations
CheckFeatureis constant-time. Prefer it overtry/catcharound an unsupported call.CalcDistancesByIdrequires the underlying index to retain enough information to recompute the distance. For purely quantized indexes (no raw vectors retained),calculate_precise_distance = truemay return the quantized distance instead.GetIndexDetailInfosandGetDetailDataByNameare read-only snapshots. The values returned reflect the index state at the moment of the call; concurrent mutations may invalidate them.
Extensibility
VSAG exposes a small set of stable C++ extension points so applications can plug in their own infrastructure without forking the library. This page summarizes what is extensible and what is not, and links to runnable examples.
Public extension points
| Extension point | Header | Purpose |
|---|---|---|
vsag::Allocator | vsag/allocator.h | Custom memory allocation strategy. |
vsag::Logger | vsag/logger.h | Redirect VSAG logs to your logging stack. |
vsag::ThreadPool | vsag/thread_pool.h | Reuse an external worker pool for builds and IO. |
vsag::Filter | vsag/filter.h | Custom pre-filter for KnnSearch / RangeSearch. |
vsag::Reader (+ ReaderSet) | vsag/readerset.h | Custom IO backend for deserialization. |
All five are abstract base classes. Each declares at least one pure-virtual method that you
must implement; some also declare non-pure-virtual methods with sensible defaults (for example,
Filter::CheckValid(const char*), Filter::ValidRatio(), Filter::FilterDistribution(),
Filter::GetValidIds(), and Reader::MultiRead()) that you can override only when you need
custom behaviour. Implement the required methods, wrap your instance in a std::shared_ptr
(or pass a raw pointer where the API requires it), and hand it to VSAG.
Wiring extensions into an index
There are two main entry points.
1. Per-index resources via Engine
vsag::Engine (vsag/engine.h) is the recommended way to bind a custom
Allocator and ThreadPool to every index it creates:
auto allocator = std::make_shared<MyAllocator>();
auto thread_pool = std::make_shared<MyThreadPool>();
vsag::Resource resource(allocator, thread_pool);
vsag::Engine engine(&resource);
auto index = engine.CreateIndex("hgraph", parameters).value();
// ... use index ...
engine.Shutdown();
Engine(Resource*) takes a non-owning pointer — the caller is responsible for
keeping the Resource alive for at least as long as the engine and every index
it produced (until Shutdown() returns / those indexes are destroyed). The
Resource itself owns the Allocator / ThreadPool shared pointers. See
Memory Management for the full ownership model, and
Per-Search Allocator for scoping an allocator to a
single search call.
For quick prototypes, Engine::CreateDefaultAllocator() and
Engine::CreateThreadPool(num_threads) return ready-to-use implementations.
2. Factory::CreateIndex with a raw allocator
vsag::Factory::CreateIndex(name, params, allocator)
(vsag/factory.h) accepts an optional Allocator*. This path does not take a
thread pool; new code should prefer Engine.
Filter
Implement vsag::Filter and pass a FilterPtr through SearchRequest::filter_
and set SearchRequest::enable_filter_ = true (the filter is ignored when
the flag is off). The legacy SearchParam::filter path remains supported.
Only CheckValid(int64_t id) is required; the other hooks are optional
optimizations:
CheckValid(const char* data)— filter on per-vector extra info.ValidRatio()— hint the planner about selectivity.FilterDistribution()— hint about the spatial distribution of the valid ids:NONE(default) means no hint,RELATED_TO_VECTORmeans the valid ids are correlated with vector position. Seevsag/filter.h.GetValidIds(...)— expose a precomputed valid-id list for very selective filters.
Runnable example: examples/cpp/301_feature_filter.cpp. The
Filtered Search page describes filter integration in
detail.
Reader / ReaderSet
Index::Deserialize(const ReaderSet&) lets you stream an index from any storage
backend (local file, object storage, remote FS, …) by providing a Reader per
named binary stream. Implement Read, AsyncRead, and Size at minimum;
MultiRead is optional and improves throughput when the backend supports
batched IO. vsag::Factory::CreateLocalFileReader is a reference
implementation for local files.
See Serialization Types for a ReaderSet construction example
and Serialization for the full serialize / deserialize matrix.
Logger
VSAG uses a single global logger configured through the Options singleton:
class MyLogger : public vsag::Logger { /* implement Trace/Debug/Info/... */ };
static MyLogger my_logger;
vsag::Options::Instance().set_logger(&my_logger);
The logger pointer is not owned by VSAG — keep it alive for the duration of
any VSAG call. Pass nullptr to fall back to the built-in logger.
Runnable example: examples/cpp/202_custom_logger.cpp.
Global tuning via Options
vsag::Options::Instance() (vsag/options.h) is a process-wide singleton for
settings that do not belong to a specific index:
| Setter | Default | Notes |
|---|---|---|
set_num_threads_building(n) | 4 | Threads used while building disk indexes. |
set_block_size_limit(bytes) | 128 MiB | Maximum size of a single allocation block. Must be ≥ 256 KiB (src/options.cpp:53-57). |
set_direct_IO_object_align_bit(bits) | 9 | Direct-IO alignment, in bits. Must be ≤ 21 (alignment size up to 2 MiB; src/options.cpp:40-46). |
set_logger(Logger*) | built-in | See Logger. |
These options affect every index in the process; set them once at startup. They
do not override per-index parameters such as HGraph’s build_thread_count.
What is not publicly extensible
VSAG does not currently provide stable public interfaces for the following:
- Quantizers. Concrete quantizer types (SQ8, PQ, RaBitQ, …) are selected via index parameters; subclassing them from user code is not supported.
- Distance computers / metric types. Distance metrics are fixed to
l2,ip, andcosineper index. - DataCell / IO / storage backends inside an index. These are
implementation details. Use the
Readerinterface for custom IO at the deserialization boundary.
If you need one of these, please open an issue describing the use case.
A note on vsag::ext
The vsag/vsag_ext.h header defines a thin handle-based API (IndexHandler,
DatasetHandler, BitsetHandler, …) intended for language bindings and FFI. It is not a
user-facing extension surface; prefer the standard vsag::Index API for C++
applications.
Related examples
examples/cpp/201_custom_allocator.cppexamples/cpp/202_custom_logger.cppexamples/cpp/203_custom_thread_pool.cppexamples/cpp/301_feature_filter.cppexamples/cpp/401_persistent_kv.cpp
Graph Index Enhancement
Graph-based indexes may see recall drops on “hard queries” — queries that are poorly connected to their true nearest neighbors. VSAG patches these queries online or offline using a conjugate graph, noticeably improving tail recall at almost zero index-size cost.
Enabling the Conjugate Graph
At build time:
{
"index_param": {
"base_quantization_type": "fp32",
"max_degree": 32,
"ef_construction": 400,
"use_conjugate_graph": true
}
}
At search time, toggle it via the use_conjugate_graph_search key in the search-parameter JSON
(there is no boolean overload on KnnSearch):
std::string search_param_json = R"({
"hgraph": {
"ef_search": 100,
"use_conjugate_graph_search": true
}
})";
auto result = index->KnnSearch(query, k, search_param_json);
How It Works
Call Feedback(query, k, search_parameters, global_optimum_id) after a hard query when the exact
nearest label is known. Omitting global_optimum_id makes HGraph compute it by an exact scan.
For offline enhancement, Pretrain(base_ids, k, search_parameters) generates queries between the
chosen float32 base vectors and their neighbors, then feeds the resulting failures back. Both
methods return the number of newly inserted conjugate edges; redundant feedback returns zero.
The conjugate graph maps local-result labels to known global optima and contributes extra
candidates after HGraph’s normal traversal. use_conjugate_graph is disabled by default; calling
Feedback or Pretrain without it returns UNSUPPORTED_INDEX_OPERATION. Search enhancement is
enabled by default for an enabled graph and can be disabled per search.
Example
examples/cpp/304_feature_enhance_graph.cpp walks through building, training, and comparing
recall end-to-end.
When to Use It
- Data distributions with sparse clusters or outliers.
- Online services sensitive to P99 recall.
- You want a recall boost without rebuilding the index.
Notes
- Build time increases slightly when enabled.
- Conjugate-graph data is serialized together with the index.
UpdateIdupdates conjugate edges as well as the HGraph label table.Pretraincurrently supports float32 HGraph indexes;Feedbacksupports all HGraph dtypes.- It can be combined with
Tune— they target route quality and runtime parameters respectively.
Extra Info
extra_info is a fixed-size, opaque per-vector byte payload stored alongside vectors inside
the index. It lets you keep small pieces of non-vector metadata, such as timestamps, category ids,
permission tags, or application-specific fields, right next to the vectors, so you can:
- Retrieve metadata by vector id without a separate KV store.
- Update a vector’s metadata in place without re-inserting the vector.
- Filter candidates during search using your own metadata instead of post-filtering results.
The library treats the payload as raw bytes. You fully own its layout, serialization, and interpretation.
Index Support
Supported operations by index:
- HGraph: store on Build/Add,
GetExtraInfoByIds,UpdateExtraInfo,use_extra_info_filter, and search-result extra info. - LazyHGraph: the same support as HGraph in both phases. The flat phase is served by BruteForce, and after transition the graph phase is served by HGraph.
- BruteForce: store on Build/Add,
GetExtraInfoByIds,UpdateExtraInfo,use_extra_info_filter, and search-result extra info. - IVF and SINDI: store extra info on Build/Add, but do not expose retrieval, update, extra-info filtering, or search-result extra info.
HGraph, LazyHGraph, and BruteForce advertise the related capability flags when
extra_info_size > 0. You can always check at runtime with index->CheckFeature(...).
Enabling Extra Info
Add the top-level integer field extra_info_size to the build parameters. The value is the size
in bytes of the payload reserved per vector. Once an index is built, the size is fixed and is
serialized together with the index.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"extra_info_size": 12,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 26,
"ef_construction": 100
}
}
For LazyHGraph, extra_info_size is still a top-level field; the LazyHGraph-specific parameters
stay in the lazy_hgraph object:
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"extra_info_size": 12,
"lazy_hgraph": {
"transition_threshold": 1000,
"hgraph": {
"base_quantization_type": "sq8",
"max_degree": 26,
"ef_construction": 100
}
}
}
If extra_info_size is omitted or set to 0, the feature is disabled.
Providing Extra Info on Build / Add
Use the Dataset builder API to attach the payload. The buffer must be contiguous, with vector
i’s payload at byte offset i * extra_info_size.
auto base = vsag::Dataset::Make();
base->NumElements(num_vectors)
->Dim(dim)
->Ids(ids.data())
->Float32Vectors(vectors.data())
->ExtraInfos(extra_infos.data()) // num_vectors * extra_info_size bytes
->ExtraInfoSize(extra_info_size) // must match the index's extra_info_size
->Owner(false);
index->Build(base); // or index->Add(base)
ExtraInfoSize must equal the index’s extra_info_size; otherwise the call is rejected.
Retrieving Extra Info
From Search Results
When extra_info_size > 0, supported indexes populate the result Dataset with the matching
extra_info bytes for every returned id:
auto result = index->KnnSearch(query, k, search_params).value();
const char* infos = result->GetExtraInfos();
auto info_size = result->GetExtraInfoSize();
Use info_size to compute offsets in the returned buffer.
By Ids (GetExtraInfoByIds)
Allocate a count * extra_info_size byte buffer and call:
if (index->CheckFeature(vsag::SUPPORT_GET_EXTRA_INFO_BY_ID)) {
std::vector<char> out(count * extra_info_size);
index->GetExtraInfoByIds(ids, count, out.data());
}
If the feature is not enabled, the call returns UNSUPPORTED_INDEX_OPERATION.
Updating Extra Info In Place
Update a single vector’s payload without touching the vector itself:
if (index->CheckFeature(vsag::SUPPORT_UPDATE_EXTRA_INFO_CONCURRENT)) {
auto upd = vsag::Dataset::Make();
upd->NumElements(1)
->Ids(&id)
->ExtraInfos(buffer.data())
->ExtraInfoSize(extra_info_size)
->Owner(false);
index->UpdateExtraInfo(upd);
}
The dataset must contain exactly one element and the size must match.
Filtering with Extra Info
Post-filtering can be wasteful when the filter prunes many candidates. HGraph and LazyHGraph can call your filter on each candidate’s extra_info bytes during graph traversal, so disqualified candidates never enter the result set. LazyHGraph also supports the same byte-payload filter before transition, where the flat phase runs an exact scan.
-
Override the byte-buffer overload of
vsag::Filter:class CategoryFilter : public vsag::Filter { public: CategoryFilter(uint32_t lo, uint32_t hi) : lo_(lo), hi_(hi) {} bool CheckValid(int64_t /*id*/) const override { return true; } bool CheckValid(const char* data) const override { uint32_t category_id; std::memcpy(&category_id, data, sizeof(category_id)); return category_id >= lo_ && category_id <= hi_; } float ValidRatio() const override { return 0.5F; } private: uint32_t lo_, hi_; }; -
Enable
use_extra_info_filterinside thehgraphblock of the search parameters and pass the filter toKnnSearch:std::string search_params = R"({ "hgraph": { "ef_search": 100, "use_extra_info_filter": true } })"; auto filter = std::make_shared<CategoryFilter>(3, 7); auto result = index->KnnSearch(query, k, search_params, filter).value();
When use_extra_info_filter is true, the search path calls CheckValid(const char*) instead of
CheckValid(int64_t). You can guard with
index->CheckFeature(vsag::SUPPORT_KNN_SEARCH_WITH_EX_FILTER).
LazyHGraph Notes
extra_info_sizemust be configured when the LazyHGraph index is created; it is not nested underlazy_hgraphorhgraph.- Extra info supplied while the index is still in the flat phase is migrated into the internal HGraph during transition.
GetExtraInfoByIds,UpdateExtraInfo, search-result extra info, anduse_extra_info_filterwork before and after transition.- Serialized LazyHGraph indexes preserve both the current phase and the stored extra info.
Capability Flags
vsag::SUPPORT_GET_EXTRA_INFO_BY_ID:GetExtraInfoByIdsis available.vsag::SUPPORT_UPDATE_EXTRA_INFO_CONCURRENT:UpdateExtraInfois available and thread-safe.vsag::SUPPORT_KNN_SEARCH_WITH_EX_FILTER:use_extra_info_filteris available in search.
Notes and Limitations
- The payload is opaque bytes; you are responsible for serialization/deserialization. The library only copies by offset.
extra_info_sizeis fixed at build time and persisted in the serialized index.- Storage cost is
extra_info_size * num_elementsbytes, accounted intoEstimateMemoryby indexes that support memory estimation for this storage. - Keep the payload compact because it is read during extra-info filtering.
- The feature is currently C++ only; there is no Python binding for
extra_info.
Example
A complete, runnable example is available at
examples/cpp/320_feature_extra_info.cpp. It demonstrates building an HGraph index with
extra_info, retrieval by id, extra-info filtering, and in-place updates.
Index Lifecycle Management
After an index is built, VSAG provides several operations that mutate the index in place or produce a new index derived from it. This page documents the full lifecycle surface:
Remove— delete vectors by id.UpdateVector/UpdateId— modify an existing vector or rename its id.Clone— produce a deep copy of an existing index.ExportModel— extract the trained model as an empty index for reuse.
Each operation is optional. For operations with a dedicated capability flag, check it through
index->CheckFeature(...); Remove has no dedicated flag and must be checked by index type and
RemoveMode.
Capability Flags
| Operation | Capability Flag | HGraph | Pyramid | IVF | BruteForce | SINDI |
|---|---|---|---|---|---|---|
Remove | (no dedicated flag — see below) | Yes | RemoveMode::MARK_REMOVE only | RemoveMode::MARK_REMOVE only | Yes | RemoveMode::MARK_REMOVE only |
UpdateVector | SUPPORT_UPDATE_VECTOR_CONCURRENT | Yes | — | — | Yes | Yes |
UpdateId | SUPPORT_UPDATE_ID_CONCURRENT | Yes | — | — | — | Yes |
Clone | SUPPORT_CLONE | Yes | Yes | Yes | Yes | — |
ExportModel | SUPPORT_EXPORT_MODEL | Yes | Yes | Yes | — | — |
For the flag-gated operations, check at runtime with index->CheckFeature(vsag::SUPPORT_*) before
calling; unsupported indexes return UNSUPPORTED_INDEX_OPERATION. Remove does not currently
have a dedicated capability flag — see the next section for how to determine whether your index
supports it and which mode it supports.
Removing Vectors
Remove deletes vectors by id. HGraph supports two deletion modes with different requirements:
RemoveMode::MARK_REMOVE(the default) only writes a tombstone via the label table and works regardless ofsupport_force_remove. The id is filtered out of subsequent searches, but the underlying graph node and vector storage are kept.RemoveMode::FORCE_REMOVEphysically rewrites the graph and reclaims the slot. This mode is only available when the index was built withsupport_force_remove: trueinindex_param. That flag enables the force-remove path and its extra synchronization; callingFORCE_REMOVEon an index built withoutsupport_force_remove: truewill fail.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 16,
"ef_construction": 100,
"support_force_remove": true
}
}
The JSON snippet above is only required if you intend to use FORCE_REMOVE. For MARK_REMOVE
alone you can omit the support_force_remove flag.
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "sq8",
"max_degree": 16,
"ef_construction": 100
}
}
// Single-id and batch overloads are available.
index->Remove(id);
index->Remove(std::vector<int64_t>{id1, id2, id3});
Remove Modes
The optional RemoveMode argument selects the deletion strategy:
| Mode | Behavior |
|---|---|
RemoveMode::MARK_REMOVE (default) | Tombstones the id; fast, no shrink or graph repair. Subsequent searches skip the id. Does not require support_force_remove: true. |
RemoveMode::FORCE_REMOVE | Physically removes the vector and repairs the graph. Heavier. Requires the index to be built with support_force_remove: true. |
Remove returns the number of ids that were successfully removed. Ids that did not exist are
silently skipped and not counted.
A runnable example is available at examples/cpp/303_feature_remove.cpp.
Pyramid and IVF Deletion
Pyramid and IVF support RemoveMode::MARK_REMOVE only. The operation writes tombstones, excludes
the ids from later searches, and does not physically reclaim vector storage. FORCE_REMOVE is not
supported for either index.
BruteForce Deletion
BruteForce also supports both modes, but it does not use HGraph’s
support_force_remove setting. MARK_REMOVE writes a tombstone and keeps vector storage.
FORCE_REMOVE needs no extra configuration: it physically removes entries, moving the final
internal slot when necessary while keeping external ids intact.
When BruteForce is not in multi-vector mode and use_attribute_filter: true is enabled, neither removal mode is available; rebuild the index instead if attribute-filtered data must be deleted.
In multi-vector mode, BruteForce supports RemoveMode::MARK_REMOVE only; FORCE_REMOVE is not available, including when use_attribute_filter: true is enabled.
BruteForce uses block_memory_io by default. Every successful force-removal batch reduces its
logical vector and optional extra-information storage to the live entry count and releases trailing
complete blocks; its final partial block remains allocated. The label table reclaims its backing
allocation when its live entry count falls to at most half of its current capacity, avoiding repeated
full-table copies. GetMemoryUsage() reflects the compacted index allocation. This does not guarantee that the
process allocator immediately returns memory to the operating system or that RSS decreases. Force removal is synchronous and takes the
BruteForce global exclusive lock, so it waits for in-flight searches and blocks new searches while
it runs; batch ids and avoid it on latency-sensitive paths.
index->Remove(std::vector<int64_t>{id1, id2}, vsag::RemoveMode::FORCE_REMOVE);
Updating Vectors and Ids
UpdateVector
UpdateVector(id, new_base, force_update = false) replaces the vector data of an existing id in
place. The default force_update = false mode performs a connectivity check: if the new vector
is far from the original (which would degrade graph quality), the update is rejected and the
caller is expected to fall back to Remove + Add.
std::vector<float> new_vec(dim); // populate with the replacement vector
auto upd = vsag::Dataset::Make();
upd->NumElements(1)->Dim(dim)->Ids(&id)->Float32Vectors(new_vec.data())->Owner(false);
auto status = index->UpdateVector(id, upd, /*force_update=*/false);
if (status.has_value() && *status) {
// updated in place
} else if (status.has_value() && not *status) {
// rejected: new vector is too far from the old one — fall back to remove + add
index->Remove(id);
index->Add(upd);
}
Setting force_update = true skips the check and always applies the update; use with caution as
it may degrade recall.
This connectivity check applies to HGraph. BruteForce also supports UpdateVector for a
same-dimension FP32 replacement, but it has no graph connectivity check and therefore does not
give force_update a separate effect. Pyramid and IVF do not support UpdateVector.
UpdateId
UpdateId(old_id, new_id) renames an existing id without touching the underlying vector.
Returns true on success, false if old_id was not found or new_id already exists.
index->UpdateId(123, 456);
A runnable example combining UpdateVector, Remove, and Add is available at
examples/cpp/305_feature_update.cpp.
UpdateId is implemented through a shared label-table path on several indexes, but only indexes
that advertise SUPPORT_UPDATE_ID_CONCURRENT promise the corresponding concurrent-update
semantics.
Cloning an Index
Clone() produces a deep copy of the entire index — vectors, graph, quantizer state, and
metadata — as an independent IndexPtr. The clone can be searched, mutated, or serialized
independently of the source.
auto cloned = index->Clone().value();
// Both indexes return identical search results immediately after cloning.
auto r1 = index->KnnSearch(query, k, params).value();
auto r2 = cloned->KnnSearch(query, k, params).value();
Clone optionally accepts a custom Allocator so that the cloned index uses a different memory
region than the source — useful for handing an index off to a thread or component that owns its
own allocator. See Memory Management for allocator details.
A runnable example is available at examples/cpp/309_feature_clone.cpp.
Exporting the Trained Model
ExportModel() returns an empty index that retains all trained state (quantization codebooks,
centroids, hyperparameters) of the source but contains no vectors. It is the canonical way to
share a pre-trained model across shards, processes, or hosts without re-running training.
auto exported = index->ExportModel();
if (not exported.has_value()) {
// index does not support ExportModel — handle the error
return;
}
auto model = *exported;
// Populate the empty model with a new (potentially different) vector set.
for (int64_t i = 0; i < num_vectors; ++i) {
auto one = vsag::Dataset::Make();
one->NumElements(1)->Dim(dim)->Ids(ids + i)
->Float32Vectors(vectors + i * dim)->Owner(false);
model->Add(one);
}
The returned index behaves identically to one freshly created via Factory::CreateIndex(...) and
trained on the source data — only the per-vector storage is empty. This pattern is particularly
useful for IVF-style indexes where training (k-means on centroids) is the dominant cost.
A runnable example is available at examples/cpp/310_feature_export_model.cpp.
Notes and Limitations
Remove,UpdateVector, andUpdateIdare concurrent-safe on HGraph when the matching*_CONCURRENTcapability flag is set. The flag set also gates safe combinations with concurrent search and add (e.g.SUPPORT_ADD_SEARCH_DELETE_CONCURRENT).MARK_REMOVEdoes not free memory; useFORCE_REMOVEor rebuild periodically if you need to reclaim space.Clonecost scales linearly with index size. For large indexes prefer serialization + deserialization with a dedicated reader if you only need a snapshot on disk.ExportModelpreserves training but not any inserted vectors. The exported model can be freely serialized and shipped before any vectors are added.
HGraph MCI Companion
HGraph can optionally build an MCI (Maximal Clique Index) companion for filtered KNN
search. The companion stores clique metadata beside the HGraph index and shares HGraph’s
vector storage. It is not a standalone index type: create the index as hgraph, not mci.
Use this feature when filtered search is the main workload and the filter keeps only a
small fraction of vectors. HGraph chooses between normal graph traversal and the MCI
companion by comparing Filter::ValidRatio() with a threshold.
Build Configuration
MCI build parameters are flat fields in index_param. The companion is enabled when
use_mci is true or when any MCI build parameter is present. The mci_knng_source
parameter selects whether clique construction derives its KNN graph from the completed
HGraph bottom graph or builds a dedicated ODescent graph:
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "fp32",
"max_degree": 32,
"ef_construction": 400,
"mci_mcs": 200,
"mci_clique_max": 50,
"mci_alpha": 1.2,
"mci_knng_source": "odescent"
}
}
The default source is hgraph, which preserves the existing behavior. Set the source to
odescent to build the MCI KNN graph directly from the stored vectors. An internally configured
external KNN graph path takes precedence over this selector.
Both the float fast path and the generic full-build path share a final coverage pass. Any node still uncovered after clique enumeration is placed in a fallback clique with graph neighbors (or a singleton when none are available). This pass relaxes clique distance constraints, preserves the seed under the clique-size cap, and counts only stored memberships.
| Parameter | Purpose |
|---|---|
use_mci | Enables MCI with default build parameters when set to true. |
mci_mcs | Candidate neighbor count used when constructing cliques. |
mci_knng_source | KNN graph source: hgraph (default) or odescent. |
mci_clique_max | Minimum size of a maximal clique the full build must keep. Cliques are stored in full, so this is a lower bound, not a cap. |
mci_alpha | Clique construction expansion factor. |
Incremental Maintenance Parameters
These parameters also belong in index_param. They control ADD and survivor repair after deletion.
Repair shares ADD’s existing-clique join and new-clique construction routines; it does not
reinsert the existing vector into HGraph.
Degree is the size of the deduplicated union of other live members of all effective cliques covering the current point. It is neither HGraph out-degree nor the point’s clique-membership count. The degree stopping target is:
target = N <= 1 ? 0 :
min(N - 1,
max(mci_incremental_degree_min,
min(N / mci_incremental_degree_n_divisor,
mcs / mci_incremental_degree_mcs_divisor)));
N is the current live vector count, including the completed HGraph insertion batch but excluding
marked removals; mcs means mci_mcs. Integer division rounds down. This is a stopping target,
not a guaranteed minimum degree or a hard cap: joining a whole clique can overshoot it,
and exhausted candidates or stalled construction can stop below it.
Migration warning: mci_incremental_added_mct is no longer supported. Remove this old
clique-count setting from index_param and configure mci_incremental_degree_min,
mci_incremental_degree_n_divisor, and mci_incremental_degree_mcs_divisor instead.
The old key is ignored, not translated; leaving it in the JSON applies the new defaults
unless the degree parameters are explicitly set. There is no equivalent one-to-one mapping
because overlapping cliques contribute different numbers of unique neighbors. Recheck recall
and mutation cost, and rebuild old snapshots with the new configuration rather than assuming
that a removed parameter provides snapshot compatibility.
The default MCS divisor 2 limits the adaptive term to half the KNN candidate budget before
the floor is applied; it does not cap the final target at half a clique size. MCS defaults to
200 and is distinct from mci_clique_max. If mcs=32 and the floor is 50, the target is
min(N-1, 50) for every N, not max(50, N/10000). This is a tuning heuristic, not a guarantee
that half the candidates become neighbors; the floor can dominate and a whole-clique join
can exceed the target.
| Parameter | Default and range | Meaning |
|---|---|---|
mci_incremental_join_ratio_threshold | Default 0.6; range [0, 1]. | Overlap threshold for joining an existing clique: the number of its live members present in the point’s KNN candidates, divided by its live member count. For a 10-member clique with 6 members in the candidates, the ratio is 0.6: threshold 0.6 qualifies, whereas 0.7 does not. The clique must also have room and add new neighbors. This ratio does not revalidate distance constraints against every member. Lowering the threshold relaxes joining; raising it is stricter and may leave more degree deficits for new-clique construction. Compare 0.5 / 0.6 / 0.7 while measuring recall, ADD time, and total memberships. |
mci_incremental_degree_min | Default 50; positive integer. | Floor in the degree-target formula, still capped at N-1. At N=10000, mcs=200, the default target is 50; setting the floor to 70 makes the target 70. At N≈3m, mcs=200, both floors produce target 100. If many points have low degree, try a higher floor and verify recall; a lower floor can reduce maintenance needed to reach the target but may reduce search connectivity. First check whether the floor actually determines the target. |
mci_incremental_degree_n_divisor | Default 10000; positive integer. | Controls the size-dependent term N / divisor. At N=800000, mcs=200, degree_min=50, the default target is 80. Changing the divisor to 20000 makes this term 40, so the floor sets the final target to 50. A smaller divisor raises the size-dependent term; a larger divisor lowers it. Use it to adjust how the target grows with the dataset, noting that the floor or MCS term can make a change ineffective. |
mci_incremental_degree_mcs_divisor | Default 2; positive integer. | Controls mcs / divisor, which is combined with the size-dependent term before applying the floor. At N≈3m, mcs=200, degree_min=50, divisor 2 gives target 100; divisor 4 gives target 50. A smaller divisor can raise the target and a larger one can lower it, subject to the other terms. This parameter does not change the number of KNN candidates; change mci_mcs to change that candidate budget. |
mci_incremental_clique_max | Default 50; integer at least 2. | Caps both newly constructed incremental cliques and the size after appending to an existing clique. With cap 50, a 49-member clique can grow to 50, but a 50-member clique cannot accept another point. Full-build cliques already at size 50 therefore cannot be appended to under the defaults. New cliques can be smaller than the cap. Raising it allows some formerly full cliques to grow and permits larger new cliques; lowering it may require more cliques to fill the degree deficit. Evaluate it together with mci_clique_max, total memberships, and query cost. |
mci_delete_clique_size_threshold | Default 30; positive integer. | Retire a clique affected by the deletion batch only if its remaining live member count after the entire batch is strictly below the threshold: 29 is retired, 30 is retained by default. Retirement removes the clique and its memberships, not surviving vectors; unrelated small cliques are not scanned for retirement. This is not a construction size cap. A higher threshold expands retirement and may increase repair cost and structural change; a lower threshold retains more small cliques. Start at 30 and adjust in steps of 10, comparing retired cliques, repair counts, deletion time, and recall rather than assuming higher is better. If a construction size cap is below this threshold, every affected clique of that size is retired; evaluate the settings together. |
mci_delete_node_mct_threshold | Default 3; positive integer. | Selects surviving members of retired cliques whose projected effective clique count is strictly below the threshold. Counts exclude all cliques retiring in the batch, and repair candidates are deduplicated. By default, projected counts 0, 1, and 2 qualify; 3 does not. This counts cliques, not unique neighbors. Raising the threshold expands the candidate set; lowering it narrows the set. Value 1 selects only candidates projected to lose all clique coverage. Compare 3 → 4 → 5 → 6 for repair cost and query quality. Coverage is checked again before repair, so selection does not guarantee creation of a new clique. |
These tuning directions follow the implementation; they are not validated optimal settings.
Keep dataset stages, mutation IDs, ground truth, and other settings fixed while changing one
parameter at a time. Compare quality, throughput, mutation time, and memory together.
For example, at N≈3m, mcs=100, degree_min=50, the default target is 50; changing only the
live-count divisor leaves it unchanged whenever the size-dependent term remains at least 50.
Explicit settings override defaults, and loading an existing index requires matching its stored
parameters. Historical experiments using retirement threshold 3 do not measure the current default 30.
Search Configuration
Search parameters live under the hgraph search object:
{
"hgraph": {
"ef_search": 120,
"use_mci": true,
"mci_seed_ratio": 0.1,
"hgraph_valid_ratio_threshold": 0.2
}
}
use_mci defaults to true for search and can be set to false to disable MCI for a single
query. hgraph_valid_ratio_threshold is the search routing threshold: use MCI when
ValidRatio() is below this value; otherwise use HGraph. The default is 0.05.
The seed count is
ceil(sqrt(current_vector_count) * mci_seed_ratio), with a minimum of one seed.
mci_seed_ratio defaults to 0.1 and must be finite and non-negative. The resulting
seed count is capped at the number of points that satisfy the filter.
mci_seed_coverage raises that budget to ceil(mci_seed_coverage * valid_count) whenever the
target fits into mci_seed_max_count and does not exceed the vector count; otherwise the
coverage term is dropped entirely rather than truncated. mci_seed_coverage defaults to 1.0
and mci_seed_max_count to 32768 (0 means unlimited). The budget never drops below one, so
seeding cannot be disabled completely – set both terms to 0 to leave a single seed.
Because that term is dropped rather than clamped, mci_seed_coverage has no effect once the
valid set exceeds mci_seed_max_count: for those (wide-filter) queries the budget silently
falls back to the ceil(sqrt(N) * mci_seed_ratio) floor. Raising the cap is what extends the
exact-seeding regime, at the cost of a larger seed phase. The default of 32768 keeps that
enumeration cheap in absolute terms – 32768 inner ids are 128 KiB and therefore stay in cache –
so the saturated skip stays a win instead of turning a wide predicate into a scan-sized seed
phase.
The companion needs a Filter object with a meaningful ValidRatio() hint. Bitset and
function filters are accepted, but a custom Filter gives the search planner better
selectivity information.
Dynamic Neighbor Traversal
use_hybrid_traversal (default false) replaces the either/or routing above with a single
traversal that handles both neighbor sources of every expanded vector in one pass:
- the sparse HGraph neighbors are scored first and pushed into the candidate heap, so the search subgraph stays connected regardless of selectivity;
- the clique members of the same vector are scored predicate-first, and that part is cut short by a virtual-overhead budget.
The budget stops the clique part once
considered * (hybrid_filter_cost_ratio + local_selectivity) reaches hybrid_vob, where
hybrid_filter_cost_ratio is O_filter / O_dist (the cost of one predicate filtering relative
to one distance computation) and local_selectivity is the running predicate hit rate of the
current neighbor traversal. A non-positive hybrid_vob disables the early stop. The two
parameters default to 0.0 and 1.0, and hybrid_vob is expressed in units of O_dist.
{
"hgraph": {
"ef_search": 600,
"use_hybrid_traversal": true,
"hybrid_vob": 32.0,
"mci_seed_ratio": 1.0,
"mci_seed_coverage": 1.0
}
}
The traversal is seeded from the predicate’s valid set using the same budget and samplers as
the MCI route, so the two can be compared without the seed strategy confounding the result.
When that budget ends up covering every valid point, all valid distances are already known and
the traversal answers from the seeds directly; the expansion is skipped because it is provably
redundant, not as an approximation. GetStatistics() reports the behavior through
mci_hybrid_route ("hybrid" when the traversal ran), hybrid_seed_budget,
hybrid_seeded_entries, hybrid_expansion_skipped, hybrid_expanded_nodes,
hybrid_mci_members_considered, hybrid_dist_computations and hybrid_mci_stopped_early.
Code Map and Maintenance
Paths below are relative to the repository root.
| File | Entry points / responsibility |
|---|---|
include/vsag/index.h, src/index/index_impl.h | Public Build/Add/Remove/Search and error wrapping |
src/algorithm/hgraph/hgraph_build.cpp | Add → add_impl: insert into HGraph, then maintain MCI for successful insertions |
src/algorithm/hgraph/hgraph_mci.cpp | Full construction, search_mci_knn, incremental_update_mci_clique, repair_mci_clique, force_remove_with_mci, maybe_compact_mci |
src/algorithm/mci/mci_local_builder.h | Shared local graph construction, maximal-clique enumeration/selection and alpha policy for Build and incremental construction |
src/algorithm/hgraph/hgraph_modify.cpp | Removal dispatch, graph repair, tail-slot moves, shrinking |
src/datacell/clique_datacell.{h,cpp} | Bidirectional CSR, delta, deletion snapshots, retirement, remapping, Flush |
src/impl/searcher/mci_searcher.cpp | search_clique_view: base CSR + delta + deletion markers |
src/impl/label_table/label_table.{h,cpp} | External-label mapping and a deletion-set read view pinned once per query |
src/algorithm/hgraph/hgraph_parameter.{h,cpp}, hgraph_param_mapping.cpp | Defaults, validation, external JSON mapping |
src/algorithm/hgraph/hgraph_serialize.cpp | Format version and restoration |
src/analyzer/hgraph_analyzer.cpp | Coverage, clique sizes, memberships, memory |
The base has clique → nodes and node → cliques CSR. ADD and deletion repair share:
| Field | Contents |
|---|---|
delta_cliques_ | Complete membership of new cliques |
delta_clique_extra_ | Members appended to base cliques |
delta_node_to_cids_ | Incremental node-to-clique relationships |
Node-deletion and clique-retirement markers are separate. Flush merges delta into CSR, preserves vector inner IDs, and may renumber cliques. FORCE_REMOVE also moves vector inner IDs.
Shared ADD/repair pipeline:
- Insert an ADD batch into HGraph, then maintain the successful points: each chunk is planned
in parallel and merged in inner-ID order by a single thread, and every row still follows the
rules in steps 2-3.
search_mci_knncalls HGraphKnnSearchwithuse_mci=false, not MCI search. - Target
min(mci_mcs, visible_total - 1)neighbors. Internal ef ismax(query_k, 100), not benchmark ef=320. Remove self, deleted, and out-of-range points; enlarge the request if needed. - Try existing cliques with
|KNN ∩ C| / |C| >= join_ratioand room below the incremental cap, stopping at the unique-neighbor degree target rather than a clique-count limit. If degree remains below target, invoke the same local clique builder as full Build on not-yet-adjacent KNN candidates, repeating until the target is reached, candidates run out, or progress stops. Empty candidates yield a singleton. Incremental construction no longer stops alpha expansion at two members. - Deletion retires only affected cliques whose surviving size is less than
delete_size. Select surviving members whose projected effective coverage is less thandelete_mct; recheck coverage before repairing. - Pure-FP32 repair reads/decodes the existing point and uses the shared candidate/join/build path. It does not insert the vector again through public ADD. ADD sees its insertion prefix; repair can see the whole current HGraph.
The ADD clique-count limit has been removed; delete_mct still triggers repair.
The degree target is max(mci_incremental_degree_min, min(N/10000, mcs/2)), with zero for N≤1 and otherwise
capped at N-1; integer division rounds down. N counts live vectors including the completed graph
insertion batch. The floor defaults to 50, so at N=10000 and mcs=200 the target is 50.
Whole-clique joins may overshoot;
candidate exhaustion or lack of progress can stop below target. Non-FP32 repair retains pair-distance
candidate generation; FP32 findings do not establish RaBitQ behavior.
| Operation | Vector storage | MCI work | Persistence |
|---|---|---|---|
| MARK_REMOVE, default | Retain physical slots | Retire small cliques, repair under-covered points into delta | None |
| FORCE_REMOVE | Move tail points into holes; reduce slots and attempt shrinking | Snapshot, remap, repair, automatic Flush | None |
| Internal automatic compaction | No vector deletion/movement | Merge delta, remove retired memberships, rebuild CSR | None |
Add and MARK_REMOVE share a 100-successful-vector counter: Add checks per point; deletion checks after the complete batch is projected and repaired. Failed inserts and duplicate/missing removals do not count. A successful compaction, full rebuild, load, or FORCE_REMOVE end-of-batch compaction resets the counter. Allocation failure during automatic maintenance retains delta and retries on the next successful mutation. Delta remains searchable and serializable below the threshold.
MCI mutations share a serialization mutex. Physical moves and final shrinking hold exclusive force-remove protection. Repair releases that lock so internal HGraph queries can acquire read locks; MCI is unpublished and external searches may fall back to HGraph. Queries are not necessarily blocked for the entire operation. Mutations are not transactionally rolled back.
Benchmark Profile and C++ Integration
Pass this JSON string to Factory::CreateIndex("hgraph", config_json). MCI build keys are flat
under index_param, not in a nested mci object. This is the Codefilter benchmark profile,
not the defaults for every library option.
{
"dtype": "float32",
"metric_type": "cosine",
"dim": 384,
"index_param": {
"base_quantization_type": "fp32",
"base_io_type": "memory_io",
"graph_type": "nsw",
"max_degree": 32,
"ef_construction": 200,
"build_thread_count": 16,
"support_force_remove": true,
"use_mci": true,
"mci_knng_source": "hgraph",
"mci_mcs": 200,
"mci_clique_max": 50,
"mci_alpha": 1.2,
"mci_incremental_join_ratio_threshold": 0.6,
"mci_incremental_degree_min": 50,
"mci_incremental_degree_n_divisor": 10000,
"mci_incremental_degree_mcs_divisor": 2,
"mci_incremental_clique_max": 50,
"mci_delete_clique_size_threshold": 30,
"mci_delete_node_mct_threshold": 3
}
}
Enable support_force_remove at creation. MCI physical removal requires flat graph storage,
enables reverse edges, and rejects incompatible deduplication, duplicate-group, or attribute storage.
| Parameter | Library default | Benchmark CLI / meaning |
|---|---|---|
mci_mcs | 200 | --mci-mcs; benchmark default 50, candidate count |
mci_clique_max | 50 | --mci-clique-max; full-build clique cap |
mci_alpha | 1.2 | --mci-alpha; expansion coefficient |
mci_incremental_join_ratio_threshold | 0.6 | --mci-incremental-join-ratio-threshold; [0,1] |
mci_incremental_degree_min | 50 | Positive degree-target floor; serialized with the index |
mci_incremental_degree_n_divisor | 10000 | Positive live-count divisor for the degree target |
mci_incremental_degree_mcs_divisor | 2 | Positive MCS divisor for the degree target |
mci_incremental_clique_max | 50 | --mci-incremental-clique-max; at least 2; benchmark inherits the full-build cap when omitted |
mci_delete_clique_size_threshold | 30 | Positive; retire affected cliques with fewer than 30 live members by default, retain those with exactly 30 |
mci_delete_node_mct_threshold | 3 | --mci-delete-node-mct-threshold; positive, strict less-than repair |
For example, delete_size=4 considers affected cliques with 0–3 survivors, not all cliques
containing a deleted point. Increasing delete_mct alone may do nothing if no small clique retires.
Search JSON uses hgraph, not index_param:
{
"hgraph": {
"ef_search": 320,
"use_mci": true,
"mci_seed_ratio": 0.1,
"hgraph_valid_ratio_threshold": 1.0
}
}
The library’s route threshold defaults to 0.05, while this profile and benchmark use 1.0.
Filtered queries with selectivity below the threshold may use MCI; routing is not guaranteed.
Supply an actual filter with accurate ValidRatio(), not an artificially low estimate.
Set query use_mci=false for an ordinary-HGraph comparison on the same index.
The following integration fragment assumes JSON strings above, caller-prepared base, added,
and query Datasets, a filter, and an external-label vector removed_labels.
Include VSAG and standard exception headers. Keep non-owned Dataset buffers alive during calls.
auto created = vsag::Factory::CreateIndex("hgraph", config_json);
if (!created.has_value()) {
throw std::runtime_error(created.error().message);
}
auto index = created.value();
auto check = [](const auto& result) {
if (!result.has_value()) {
throw std::runtime_error(result.error().message);
}
};
auto built = index->Build(base);
check(built);
if (!built.value().empty()) {
throw std::runtime_error("some initial vectors were not inserted");
}
auto removed = index->Remove(removed_labels, vsag::RemoveMode::FORCE_REMOVE);
check(removed);
auto appended = index->Add(added);
check(appended);
if (!appended.value().empty()) {
throw std::runtime_error("some added vectors were not inserted");
}
// MCI compaction is automatic; no public Flush call is required or available.
auto result = index->KnnSearch(query, 10, search_json, filter);
check(result);
Build/Add also return failed-insertion labels: checking only expected is insufficient.
removed.value() is the actual removal count. MARK_REMOVE retains physical slots.
Compaction is automatic, including at the end of FORCE_REMOVE; it does not serialize.
Remove accepts external labels, not inner slots; do not retain internal node/clique IDs across compaction.
See examples/cpp/324_feature_hgraph_mci_companion.cpp for Dataset and Filter construction.
Add, Delete, Serialize, and Stats
When MCI is enabled by the flat build parameters, HGraph::Add() first inserts the HGraph
batch, then updates the MCI companion for the successfully inserted rows. It first tries to join
suitable existing cliques, then creates incremental cliques if the unique neighbor degree is still
too low.
That update works in chunks. Inside a chunk the candidate KNN, the clique JOIN proposals and the local clique construction of every row are planned in parallel against one frozen read view, and a single thread then merges the plans in inner-ID order and applies them. Per-row coverage and the degree target do not depend on the planning order, but the candidate KNN comes from concurrent graph search, so as in the full parallel build the resulting clique structure may depend on the thread count and on scheduling.
Prefer Build() for the initial index, followed by incremental additions. Add on an empty
index can trigger construction, but many tiny Adds are not recommended as a replacement
for bulk Build.
When ADD needs a new clique, it shares the local graph, maximal-clique enumeration and selection
core with full BuildMCICliques. The incremental size cap determines the local size threshold;
it no longer accepts two members as the alpha-expansion stopping criterion. JOIN and construction
use the degree target defined by the incremental maintenance parameters above, stopping when
the target is reached, candidates are exhausted, or construction stalls.
High-alpha fallback may still emit smaller cliques.
MARK_REMOVE updates the MCI companion as part of the same operation. After removing a node, MCI
keeps every affected clique whose remaining live size is at least
mci_delete_clique_size_threshold. It retires only smaller cliques and collects only their live
members whose projected clique count is below mci_delete_node_mct_threshold. Those
under-covered nodes are repaired with the same incremental join/build routine used by Add(), so
the new memberships and cliques share Add’s delta storage. This avoids rebuilding the full
one-hop neighborhood when most members still have sufficient clique coverage.
MARK_REMOVE remains the default: logical deletion does not reclaim vector slots.
Set index_param.support_force_remove: true to enable
index->Remove(ids, vsag::RemoveMode::FORCE_REMOVE). MCI automatically enables reverse graph
edges and requires flat graph storage. Physical deletion remains incompatible with
deduplicate_storage, duplicate groups, and attribute-inverted storage. Indexes built without
force-remove support must be rebuilt to enable it.
Physical deletion repairs HGraph edges and fills removed slots with tail vectors, updating labels and graph references. MCI rebuilds both CSR directions using the same node-ID mapping, preserves unrelated soft-deletion markers, and repairs under-covered members of small affected cliques with Add’s incremental clique routine. It then flushes metadata and shrinks vector/graph storage. Repeated IDs in one request count once; missing IDs count zero. Explicitly requested soft-deleted IDs can also be physically removed. Subsequent Add starts at the compacted tail.
FORCE_REMOVE is serialized with Add, MARK_REMOVE, and Flush. Queries wait during ID moves and final shrinking; repair releases the force-remove lock while MCI remains unpublished, so queries may fall back to HGraph. This is not a transactional operation: deletions completed before an error may remain, and an unfinished companion is not published to fast search. CSR replacement itself commits only after allocation succeeds; temporary old/new buffers coexist during compaction. Allocator retention and IO block granularity mean RSS need not fall proportionally to index memory accounting.
MCI automatically merges incremental metadata into its two CSR arrays after 100 successful vector mutations, using one counter shared by Add and MARK_REMOVE. Add checks after each inserted point, including within a large batch. MARK_REMOVE projects and repairs the complete deletion batch before checking the threshold, so a large deletion batch compacts once at its end. Duplicate/missing removal IDs and failed insertions do not count; repair seeds do not count as new vectors. FORCE_REMOVE retains its mandatory end-of-batch compaction and resets the counter. Successful compaction resets the counter to zero; a new full build or load also resets this runtime-only counter. Below the threshold, queries and serialization still include delta. There is no public Flush interface and no change to the Index virtual interface for compaction.
Flush includes new delta cliques and members appended to base cliques, drops retired cliques and
deleted members, and rebuilds the node-to-clique mapping with compact clique IDs. It clears the
delta storage but preserves vector inner IDs and deletion markers. The operation is idempotent
and does not rebuild HGraph or persist data to disk; use Serialize() for persistence.
Flush is serialized with Add/Remove. It holds an exclusive clique-storage lock while constructing and publishing the replacement, so searches may wait. Replacement buffers are allocated before publication; an allocation failure leaves the old clique representation intact. Automatic maintenance defers allocation failures without failing a completed Add/MARK_REMOVE and retries on the next successful mutation. This does not change FORCE_REMOVE’s failure semantics. Temporary memory includes both the old and new CSR. Do not retain clique IDs across a flush. Loading a validated compact CSR with no delta entries, empty clique rows, or deletion/retirement markers preserves the clean state, so its first Flush skips rebuilding the CSR. Other snapshots are conservatively compacted when flushed.
MCI search pins CSR, delta, and deletion masks with one shared lock per query and traverses them
without copying membership lists. Contiguous FP32 vectors continue to use direct distance
calculation after Add/Remove, before or after Flush; mci_raw_float_csr reports this path, including
queries that traverse delta. Other vector layouts use the same traversal and candidate queue with
their normal distance computer. Deletion filtering pins the deletion set once per MCI query,
checks the user filter first, and avoids acquiring the deletion-set lock for every visited node.
Add/MARK_REMOVE may temporarily unpublish the companion, so concurrent queries still fall back to
HGraph under the existing mutation semantics. This does not guarantee complete recall during a
mutation; fallback results can be empty when the graph entry point has been deleted. Automatic
compaction runs within that mutation; queries may wait for the clique storage lock.
Fallback queries recheck candidate IDs against one deletion-set view before packing results.
This removes old candidates deleted during traversal, including an old slot whose label has
been re-added at a new slot. It does not make an entire concurrent search a transactional snapshot.
If candidates are deleted during traversal, KNN fallback may return fewer than k results even
when enough live vectors remain. Results are best-effort under concurrent mutations; there is no
secondary fill pass, since another pass could encounter further mutations too.
The clique data is serialized inside the HGraph index. Loading the HGraph index restores the companion automatically.
GetStats() includes MCI quality fields such as:
mci_has_indexmci_total_nodesmci_covered_nodesmci_total_clique_countmci_retired_clique_countmci_inactive_node_countmci_total_membership_countmci_avg_membership_per_nodemci_avg_clique_sizemci_max_clique_sizemci_memory_usage
Example
See
examples/cpp/324_feature_hgraph_mci_companion.cpp
for a minimal build and filtered-search flow.
Evaluation Notes
Development benchmark sources, scripts and raw results are not part of this PR; the field descriptions below document the CSV produced by that harness.
Reading results and validating
Five-stage runs protect evaluation top-k neighbors and retain the same truth. Random churn does not protect neighbors; each checkpoint selects live top-k from exact full-pool distance rankings. Do not mix their recall conclusions.
| CSV fields | Meaning |
|---|---|
stage, active_vectors, index_elements | Stage, live count, public element count; the last is not physical storage accounting |
ef_search, recall_at_k, qps | Search width, recall, timed throughput; fixed ef is not fixed quality |
build_seconds, mutation_seconds, flush_seconds | Historical build/mutation/explicit-flush timings; current automatic compaction is included in mutation time, with no public post-stage Flush |
index_memory_bytes, vector_memory_bytes, graph_memory_bytes, mci_memory_bytes | Index/component accounting, not RSS |
mci_route_ratio, mci_raw_float_ratio | Actual MCI/direct FP32 route rates |
mci_total_cliques, mci_delta_cliques, mci_total_memberships | Clique, delta-clique, and membership counts |
avg_dist_cmp, avg_hops, avg_seed_count | Search work useful for QPS diagnosis |
MiB = bytes / 1,048,576. Stage memory/timing repeats across ef rows; do not sum it. QPS is
multithreaded throughput: 1000 / QPS is not per-request latency. CSV has neither latency percentiles
nor per-stage RSS. Raw result files are not included in this PR. These measurements predate adaptation to newer
main and are not a performance rerun of current PR HEAD.
make debug VSAG_ENABLE_TESTS=ON COMPILE_JOBS=12
build/tests/unittests '[mci],[LabelTable],RaBitQSplitDataCell serialize and methods'
Before this documentation update: targeted C++ tests passed 38 cases / 3,294 assertions; Python suites passed 10+1+3 tests, with the executable integration suites using a Debug benchmark for correctness. Review follow-up adds reverse-edge restoration and reload-mutation regressions. Full lint, full tests, and >=90% coverage verification are outstanding. Large-dataset snapshot reuse, ABI, serialization compatibility, and broader concurrent mutation require separate validation; targeted passes are not proof of production readiness.
API Reference
This chapter is a curated reference for VSAG’s public C++ API — the headers installed under
include/vsag/. It documents the classes, structs, enums, and free functions an application links
against, grouped by responsibility. The installed headers remain the authoritative source of
truth; the pages here explain intent, ownership, and how the pieces fit together.
Looking for how to configure an index (the JSON
index_param/ search keys)? That is covered in Index Parameters and each index page. This chapter covers the code surface (types and methods), not the JSON schema.
Include and namespace
A single umbrella header pulls in the whole public API, and every symbol lives in the vsag
namespace:
#include <vsag/vsag.h> // includes factory.h, index.h, dataset.h, engine.h, ...
int main() {
vsag::init(); // one-time process initialization
std::string ver = vsag::version(); // git-derived version string
}
| Free function | Header | Description |
|---|---|---|
bool vsag::init() | vsag/vsag.h | Initializes the library. Call once before other APIs. Always returns true. |
std::string vsag::version() | vsag/vsag.h | Returns the build version derived from the git revision. |
Error-handling model
Almost every fallible call returns tl::expected<T, Error> (a std::expected-style type shipped in
vsag/expected.hpp) instead of throwing. A handful of legacy statistics accessors still throw
std::runtime_error when unsupported; those are called out on the Index page.
auto result = vsag::Factory::CreateIndex("hgraph", params);
if (not result.has_value()) {
const vsag::Error& err = result.error();
std::cerr << "create failed: " << static_cast<int>(err.type) << " " << err.message << "\n";
return;
}
std::shared_ptr<vsag::Index> index = result.value();
Error carries a machine-readable type and a human-readable message:
struct Error {
ErrorType type;
std::string message;
};
ErrorType
Defined in vsag/errors.h. Values start at 1 (0 is reserved).
| Category | Value | Meaning |
|---|---|---|
| Common | UNKNOWN_ERROR | Unknown error. |
| Common | INTERNAL_ERROR | Internal algorithm error. |
| Common | INVALID_ARGUMENT | An argument was invalid. |
| Behavior | WRONG_STATUS | Index is in the wrong state for the call. |
| Behavior | BUILD_TWICE | The index was already built and cannot be built again. |
| Behavior | INDEX_NOT_EMPTY | Deserializing onto a non-empty index. |
| Behavior | UNSUPPORTED_INDEX | Requested an index type that does not exist. |
| Behavior | UNSUPPORTED_INDEX_OPERATION | This index does not implement the called method. |
| Behavior | DIMENSION_NOT_EQUAL | Request dimension differs from the index dimension. |
| Behavior | INDEX_EMPTY | Index is empty; cannot search or serialize. |
| Runtime | NO_ENOUGH_MEMORY | Memory allocation failed. |
| Runtime | READ_ERROR | Failed to read from a binary. |
| Runtime | MISSING_FILE | A required index file is missing during deserialization. |
| Runtime | INVALID_BINARY | Serialized binary content is invalid. |
Because most index methods are virtual with a default body that returns
UNSUPPORTED_INDEX_OPERATION, an “unsupported” result is normal and expected: it means the concrete
index does not implement that optional capability. Use
Index::CheckFeature to probe support ahead of time.
Header map
| Header | Primary symbols | Reference page |
|---|---|---|
factory.h, engine.h, vsag.h | Factory, Engine, init, version | Factory & Engine |
index.h | Index, IndexType, RemoveMode, MergeUnit | Index |
dataset.h | Dataset, SparseVector, MultiVector | Dataset |
search_request.h, filter.h, bitset.h, search_param.h, iterator_context.h | SearchRequest, Filter, Bitset | Search Request & Filters |
binaryset.h, readerset.h | BinarySet, Binary, Reader, ReaderSet | Serialization Types |
resource.h, allocator.h, thread_pool.h, options.h, logger.h | Resource, Allocator, ThreadPool, Options, Logger | Resource Management |
attribute.h, index_features.h, index_detail_info.h, utils.h, constants.h | Attribute, IndexFeature, IndexDetailInfo | Auxiliary Types |
In this chapter
- Factory & Engine — create indexes and readers; own resources with
Engine. - Index — the core index interface: build, search, update, serialize, inspect.
- Dataset — the builder-pattern container for vectors, ids, and metadata.
- Search Request & Filters —
SearchRequest,Filter,Bitset, iterator context. - Serialization Types —
BinarySet/BinaryandReader/ReaderSet. - Resource Management — allocator, thread pool, engine resources, options, logger.
- Auxiliary Types — attributes, feature flags, index detail info, and utility helpers.
Factory & Engine
Every VSAG workflow begins by obtaining an Index. There are two entry points:
Factory— the simplest way to create an index or a file reader. It uses a default (or caller-supplied) allocator and manages resources internally.Engine— an explicit owner of shared resources (allocator + thread pool). Use it when you want several indexes to share one memory allocator / thread pool, or when you need deterministic control over resource lifetime.
This page also documents the process-level initialization helpers.
Library initialization
#include <vsag/vsag.h>
vsag::init(); // call once, before any other API
std::string ver = vsag::version(); // e.g. the git-derived build string
| Function | Signature | Notes |
|---|---|---|
vsag::init | bool init() | One-time process initialization. Returns true. |
vsag::version | std::string version() | Build version derived from the git revision. |
Factory
Declared in vsag/factory.h. Factory is a stateless utility class with only static methods; it
cannot be instantiated.
CreateIndex
static tl::expected<std::shared_ptr<Index>, Error>
CreateIndex(const std::string& name,
const std::string& parameters,
Allocator* allocator = nullptr);
Creates an index of the given type.
| Parameter | Description |
|---|---|
name | Index type name, e.g. "hgraph", "ivf", "brute_force", "sindi", "pyramid". Removed names such as "hnsw", "fresh_hnsw", and "diskann" return UNSUPPORTED_INDEX. |
parameters | A JSON string describing the index configuration (dtype, dim, metric, index-specific keys). See Index Parameters. |
allocator | Optional custom Allocator. When nullptr, VSAG uses a built-in default allocator. The caller must keep the allocator alive for the whole lifetime of the returned index. |
Returns a std::shared_ptr<Index> on success, or an Error (typically UNSUPPORTED_INDEX for an
unknown name, or INVALID_ARGUMENT for malformed parameters).
auto index = vsag::Factory::CreateIndex("hgraph", R"(
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": { "base_quantization_type": "sq8" }
})");
if (not index.has_value()) {
std::cerr << index.error().message << std::endl;
return;
}
std::shared_ptr<vsag::Index> hgraph = index.value();
CreateLocalFileReader
static std::shared_ptr<Reader>
CreateLocalFileReader(const std::string& filename, int64_t base_offset, int64_t size);
Creates a Reader that reads a window of a local file starting at
base_offset for size bytes. This is most often used to build a ReaderSet
for streaming deserialization of on-disk indexes. Unlike the methods above, it returns a plain
std::shared_ptr (there is no fallible Error channel).
Engine
Declared in vsag/engine.h. An Engine binds a Resource (allocator +
thread pool) and lets you create indexes that share it. The engine never takes ownership of a
Resource* passed to it; you control its lifetime.
vsag::Resource resource(vsag::Engine::CreateDefaultAllocator().get(), nullptr);
vsag::Engine engine(&resource);
auto index = engine.CreateIndex("hgraph", params);
// ... use index ...
engine.Shutdown(); // release engine-held state; warns on dangling references
Constructor & lifecycle
| Member | Signature | Description |
|---|---|---|
| Constructor | explicit Engine(Resource* resource) | Binds an externally-owned Resource. The Resource is not managed by the engine. |
Shutdown | void Shutdown() | Gracefully tears down engine-held state. Warns if external references to engine resources still exist, guarding against dangling references. |
CreateIndex
[[nodiscard]] tl::expected<std::shared_ptr<Index>, Error>
CreateIndex(const std::string& name, const std::string& parameters);
Same semantics as Factory::CreateIndex, except the index is created against the
engine’s shared Resource (allocator and thread pool) instead of a per-call allocator.
Static resource helpers
| Member | Signature | Description |
|---|---|---|
CreateDefaultAllocator | static std::shared_ptr<Allocator> CreateDefaultAllocator() | Creates VSAG’s built-in allocator. Returns an empty pointer on failure — check for null. |
CreateThreadPool | static tl::expected<std::shared_ptr<ThreadPool>, Error> CreateThreadPool(uint32_t num_threads) | Creates a thread pool with num_threads workers. Returns an Error for an invalid count. |
See Resource Management for how Resource, Allocator, and ThreadPool fit
together, and examples/cpp/201_custom_allocator.cpp / 203_custom_thread_pool.cpp for runnable
samples.
See also
- Index — what you can do with the index once it is created.
- Resource Management — allocator, thread pool, and
Resourcedetails. - Index Parameters — the JSON schema accepted by
CreateIndex.
Index
vsag::Index (declared in vsag/index.h) is the central abstraction of the library. Every concrete
index type — HGraph, IVF, BruteForce, SINDI, Pyramid, and so on — implements this
interface. You never instantiate Index directly; obtain one from
Factory::CreateIndex or Engine::CreateIndex
and hold it through IndexPtr (std::shared_ptr<Index>).
using IndexPtr = std::shared_ptr<Index>;
How to read this reference
Index exposes many optional capabilities. The base class provides a default implementation for
almost every method:
- Most methods return
tl::unexpected(Error(ErrorType::UNSUPPORTED_INDEX_OPERATION, ...))when the concrete index does not implement them. - A handful of statistics accessors instead throw
std::runtime_error(called out explicitly below). Wrap those intry/catchif you call them on an index that may not support them.
Because “unsupported” is a normal, expected outcome, probe capabilities up front with
CheckFeature rather than assuming a method works. Methods marked (pure virtual)
must be implemented by every index and are always safe to call.
Pointer/handle types used throughout this page: DatasetPtr (Dataset), FilterPtr
(Filter), BitsetPtr (Bitset), BinarySet /
ReaderSet (Serialization Types).
Enumerations and helper types
IndexType
enum class IndexType {
HGRAPH = 2, IVF = 3, PYRAMID = 4, BRUTEFORCE = 5, SPARSE = 6, SINDI = 7,
WARP = 8, LAZY_HGRAPH = 9, SIMQ = 10
};
Returned by GetIndexType.
RemoveMode
enum class RemoveMode {
MARK_REMOVE = 0, // mark as deleted; no shrink/repair — fast
FORCE_REMOVE = 1, // physically remove and repair the graph — heavy
};
Passed to Remove.
MergeUnit and IdMapFunction
using IdMapFunction = std::function<std::tuple<bool, int64_t>(int64_t)>;
struct MergeUnit {
IndexPtr index = nullptr; // source sub-index to merge from
IdMapFunction id_map_func = nullptr; // per-id filter + remap
};
For each source id, id_map_func returns {keep, new_id}: keep == true includes the vector under
target id new_id. Used by Merge.
Checkpoint
struct Index::Checkpoint {
BinarySet data; // intermediate state
bool finish = false; // true once the build is complete
};
Returned by ContinueBuild to drive incremental builds.
Data-selection flags
Bit flags for GetDataByIdsWithFlag, combined with bitwise OR:
| Macro | Value | Selects |
|---|---|---|
DATA_FLAG_FLOAT32_VECTOR | 0x01 | float32 vectors |
DATA_FLAG_INT8_VECTOR | 0x02 | int8 vectors |
DATA_FLAG_SPARSE_VECTOR | 0x04 | sparse vectors |
DATA_FLAG_EXTRA_INFO | 0x10 | extra info blobs |
DATA_FLAG_ATTRIBUTE | 0x20 | attributes |
DATA_FLAG_ID | 0x40 | ids |
DATA_FLAG_PATH | 0x80 | Pyramid paths retained with store_paths |
WriteFuncType
using OffsetType = uint64_t;
using SizeType = uint64_t;
using WriteFuncType = std::function<void(OffsetType, SizeType, const void*)>;
A sink callback for streaming Serialize. Each call asks you to persist SizeType
bytes (at the given source pointer) at logical OffsetType in the output.
Build & train
| Method | Signature | Notes |
|---|---|---|
Build | tl::expected<std::vector<int64_t>, Error> Build(const DatasetPtr& base) | (pure virtual) Builds the index from all vectors. Returns the ids that failed to insert. |
Train | tl::expected<void, Error> Train(const DatasetPtr& data) | Trains an index (e.g. IVF centroids, quantizer) without inserting. |
Tune | tl::expected<bool, Error> Tune(const std::string& parameters, bool disable_future_tuning = false) | Applies runtime tuning. See Optimizer (Tune). |
ContinueBuild | tl::expected<Checkpoint, Error> ContinueBuild(const DatasetPtr& base, const BinarySet& binary_set) | Adds dynamism to indexes that cannot insert incrementally; drive it with the returned Checkpoint. |
Add | tl::expected<std::vector<int64_t>, Error> Add(const DatasetPtr& base) | Inserts new vectors into an already-built index. Returns ids that failed to insert. |
See Build and Train and examples/cpp/311_feature_train.cpp.
Update & remove
See examples/cpp/303_feature_remove.cpp.
Search
The recommended entry point is SearchWithRequest, which takes a single
SearchRequest carrying the query, mode, top-k / radius, and any filters.
The older per-argument KnnSearch / RangeSearch overloads remain for compatibility.
Every search returns a DatasetPtr: single-query KNN has num_elements == 1; HGraph and IVF also
support batched KNN with a row-major num_elements x dim result. HGraph pads missing neighbors with
id == -1 and infinite distance. For range search, the result length is the number of matches. See
Dataset for how to read results.
SearchWithRequest
[[nodiscard]] tl::expected<DatasetPtr, Error>
SearchWithRequest(const SearchRequest& request) const;
Unified KNN or range search driven by SearchRequest. This is the
preferred API for new code; it supports attribute filters, callback filters, bitset filters, a
per-search allocator, and iterator search through one struct.
KnnSearch overloads
// (1) bitset pre-filter — pure virtual
tl::expected<DatasetPtr, Error>
KnnSearch(const DatasetPtr& query, int64_t k, const std::string& parameters,
BitsetPtr invalid = nullptr) const;
// (2) callback pre-filter — pure virtual
tl::expected<DatasetPtr, Error>
KnnSearch(const DatasetPtr& query, int64_t k, const std::string& parameters,
const std::function<bool(int64_t)>& filter) const;
// (3) Filter object
tl::expected<DatasetPtr, Error>
KnnSearch(const DatasetPtr& query, int64_t k, const std::string& parameters,
const FilterPtr& filter) const;
// (4) Filter + iterator context
tl::expected<DatasetPtr, Error>
KnnSearch(const DatasetPtr& query, int64_t k, const std::string& parameters,
const FilterPtr& filter, IteratorContext*& iter_ctx, bool is_last_search) const;
// (5) SearchParam — [[deprecated]], use SearchWithRequest
tl::expected<DatasetPtr, Error>
KnnSearch(const DatasetPtr& query, int64_t k, SearchParam& search_param) const;
Notes on the filter argument:
- In overloads (1)/(2) the predicate/bitset marks vectors filtered out. For a
bitset,Test(id) == truemeans id is excluded. For thestd::functionpredicate, returningtruemeans the id is excluded. - Overload (3)/(4) take a
Filterobject, whoseCheckValid(id)uses the opposite convention (truemeans keep). See Filtered Search for the full semantics, andexamples/cpp/301_feature_filter.cpp. - Overload (4) powers Iterator Search; pass the same
iter_ctxacross calls and setis_last_searchon the final call.
RangeSearch overloads
// (1) plain — pure virtual
tl::expected<DatasetPtr, Error>
RangeSearch(const DatasetPtr& query, float radius, const std::string& parameters,
int64_t limited_size = -1) const;
// (2) bitset pre-filter — pure virtual
tl::expected<DatasetPtr, Error>
RangeSearch(const DatasetPtr& query, float radius, const std::string& parameters,
BitsetPtr invalid, int64_t limited_size = -1) const;
// (3) callback pre-filter — pure virtual
tl::expected<DatasetPtr, Error>
RangeSearch(const DatasetPtr& query, float radius, const std::string& parameters,
const std::function<bool(int64_t)>& filter, int64_t limited_size = -1) const;
// (4) Filter object
tl::expected<DatasetPtr, Error>
RangeSearch(const DatasetPtr& query, float radius, const std::string& parameters,
const FilterPtr& filter, int64_t limited_size = -1) const;
radius bounds the distance; limited_size caps the result count (< 0 means no limit, 0 is an
error). See Range Search and examples/cpp/302_feature_range_search.cpp.
Distance by id
| Method | Signature | Notes |
|---|---|---|
CalcDistanceById | tl::expected<float, Error> CalcDistanceById(const float* vector, int64_t id, bool calculate_precise_distance = true) const | Distance from a dense query to the stored vector id. |
CalcDistanceById | tl::expected<float, Error> CalcDistanceById(const DatasetPtr& vector, int64_t id, bool calculate_precise_distance = true) const | Same, accepting one native query: dense Float32Vectors, sparse SparseVectors, or WARP/SIMQ MultiVectors with MultiVectorDim. |
CalcDistancesById | tl::expected<DatasetPtr, Error> CalcDistancesById(const float* query, const int64_t* ids, int64_t count, bool calculate_precise_distance = true, int64_t topk = -1) const | Canonical batch variant; topk > 0 returns sorted smallest distances with IDs, and missing IDs are ordered last (valid distances may also be negative). |
CalcDistancesById | tl::expected<DatasetPtr, Error> CalcDistancesById(const DatasetPtr& query, const int64_t* ids, int64_t count, bool calculate_precise_distance = true, int64_t topk = -1) const | Canonical DatasetPtr batch variant. For query->GetNumElements() > 1, indexes must advertise SUPPORT_BATCH_CALC_DISTANCE_BY_ID; ids contains NumElements * count row-major entries. With topk > 0, each query returns min(topk, count) sorted distances with matching IDs. |
CalDistanceById | Same signatures as CalcDistancesById | Deprecated public forwarding alias to the canonical virtual CalcDistancesById; new implementations override the canonical name. See C++ implementation migration. |
calculate_precise_distance = true may load full-precision vectors (possibly from disk) instead of
quantized codes. See Calculate Distance by ID and
examples/cpp/306_feature_calculate_distance_by_id.cpp.
Conjugate-graph enhancement
| Method | Signature | Notes |
|---|---|---|
Pretrain | tl::expected<uint32_t, Error> Pretrain(const std::vector<int64_t>& base_tag_ids, uint32_t k, const std::string& parameters) | Enhances chosen base vectors by searching generated queries. Returns successful insertions. |
Feedback | tl::expected<uint32_t, Error> Feedback(const DatasetPtr& query, int64_t k, const std::string& parameters, int64_t global_optimum_tag_id = INT64_MAX) | Feeds a known optimum back into the conjugate graph. |
See Graph Enhancement.
Data retrieval
| Method | Signature | Notes |
|---|---|---|
GetMinAndMaxId | tl::expected<std::pair<int64_t, int64_t>, Error> GetMinAndMaxId() const | Smallest and largest ids in the index. |
GetExtraInfoByIds | tl::expected<void, Error> GetExtraInfoByIds(const int64_t* ids, int64_t count, char* extra_infos) const | Copies extra-info blobs for ids into a caller-provided buffer. |
GetRawVectorByIds | tl::expected<DatasetPtr, Error> GetRawVectorByIds(const int64_t* ids, int64_t count, Allocator* specified_allocator = nullptr) const | Returns stored vectors. Values are close to the originals but not guaranteed bit-identical (quantization/precision). |
GetDataByIds | tl::expected<DatasetPtr, Error> GetDataByIds(const int64_t* ids, int64_t count) const | Returns the implementation’s default stored fields for ids; optional fields may require explicit selection. |
GetDataByIdsWithFlag | tl::expected<DatasetPtr, Error> GetDataByIdsWithFlag(const int64_t* ids, int64_t count, uint64_t selected_data_flag) const | Selects supported fields via DATA_FLAG_*. Pyramid paths require store_paths: true and DATA_FLAG_PATH. |
GetIndexDetailInfos | tl::expected<std::vector<IndexDetailInfo>, Error> GetIndexDetailInfos() const | Lists the introspectable detail fields. See IndexDetailInfo. |
GetDetailDataByName | tl::expected<DetailDataPtr, Error> GetDetailDataByName(const std::string& name, IndexDetailInfo& info) const | Fetches one detail-data payload by name. |
See Index Introspection and
examples/cpp/317_feature_get_detail_data.cpp.
Capabilities, merge, clone, export
| Method | Signature | Notes |
|---|---|---|
CheckFeature | bool CheckFeature(IndexFeature feature) const | Probes whether an optional capability is supported. See IndexFeature. |
Merge | tl::expected<void, Error> Merge(const std::vector<MergeUnit>& merge_units) | Merges same-type sub-indexes with id remapping. See MergeUnit. |
Clone | tl::expected<IndexPtr, Error> Clone(const std::shared_ptr<Allocator>& allocator = nullptr) const | Deep-copies the index. |
ExportModel | tl::expected<IndexPtr, Error> ExportModel() const | Returns an empty index carrying only the trained model. |
ExportIDs | tl::expected<DatasetPtr, Error> ExportIDs() const | Returns all ids as a dataset. |
SetImmutable | tl::expected<void, Error> SetImmutable() | Freezes the index; further add/delete is rejected. |
See examples/cpp/309_feature_clone.cpp, 310_feature_export_model.cpp, and
315_feature_hgraph_merge.cpp, plus Index Lifecycle Management.
Serialization
| Method | Signature | Notes |
|---|---|---|
Serialize | tl::expected<BinarySet, Error> Serialize() const | (pure virtual) Serializes to an in-memory BinarySet. |
Serialize | tl::expected<void, Error> Serialize(WriteFuncType write_func) const | Streams the serialized index through a WriteFuncType sink. |
Serialize | tl::expected<void, Error> Serialize(std::ostream& out_stream) | Serializes to an open output stream. |
Deserialize | tl::expected<void, Error> Deserialize(const BinarySet& binary_set) | (pure virtual) Restores from a BinarySet. Fails if the index is not empty. |
Deserialize | tl::expected<void, Error> Deserialize(const ReaderSet& reader_set) | (pure virtual) Restores from a ReaderSet (e.g. on-disk readers). |
Deserialize | tl::expected<void, Error> Deserialize(std::istream& in_stream) | Restores from an open input stream. |
Deserializing onto a non-empty index yields INDEX_NOT_EMPTY. See Serialization
and examples/cpp/401_persistent_kv.cpp / 402_persistent_streaming.cpp.
Cache (build acceleration)
| Method | Signature | Notes |
|---|---|---|
ExportCache | tl::expected<void, Error> ExportCache(std::ostream& out_stream) const | Writes a build-time cache (e.g. graph neighbors) that can accelerate a later Build. |
ImportCache | tl::expected<void, Error> ImportCache(std::istream& in_stream) | Loads a previously exported cache; the next Build reuses it. |
HGraph matches cache entries by Dataset::SourceID. Import into an empty compatible index before
Build(); see HGraph Build Cache for the complete workflow,
persist_source_id, and hit-rate diagnostics.
Statistics & introspection
Unless noted, these return values directly. The methods marked “throws” raise
std::runtime_error (not tl::expected) when the index does not support them.
See examples/cpp/308_feature_estimate_memory.cpp, 319_feature_get_memory_usage.cpp, and the
Index Analysis Tool.
See also
- Dataset — construct query/base inputs and read search results.
- Search Request & Filters — the
SearchRequestfields and filter types. - Serialization Types —
BinarySet,Binary,Reader,ReaderSet. - Auxiliary Types —
IndexFeature,IndexDetailInfo,AttributeSet.
Dataset
vsag::Dataset (declared in vsag/dataset.h) is the universal container VSAG uses for inputs
(base vectors to build/add, query vectors to search) and outputs (search results, retrieved
vectors). You always hold it through DatasetPtr:
using DatasetPtr = std::shared_ptr<Dataset>;
Builder pattern
Dataset uses a fluent builder: Make() creates an instance, and every setter returns the same
DatasetPtr so calls chain. Pointer setters store the supplied buffers without copying them. Value
setters, such as the structured Pyramid paths overload, keep their own value.
auto base = vsag::Dataset::Make()
->Dim(128)
->NumElements(10000)
->Ids(ids) // const int64_t*
->Float32Vectors(vectors) // const float*
->Owner(false); // caller keeps ownership of ids/vectors
Ownership
Ownership controls who frees the underlying buffers:
| Call | Meaning |
|---|---|
Owner(true) | The dataset owns its buffers and frees them on destruction (using the default allocator). |
Owner(true, allocator) | The dataset owns its buffers and frees them via the supplied Allocator. |
Owner(false) | The caller keeps ownership; the dataset only borrows the pointers. They must outlive the dataset. |
Use Owner(false) for build/query inputs you already hold. Search results returned by the index use
Owner(true), so you can read them and let the DatasetPtr free everything.
DatasetPtr Make(); // static factory
DatasetPtr Owner(bool is_owner, Allocator* allocator);
DatasetPtr Owner(bool is_owner); // uses the default allocator
DatasetPtr Append(const DatasetPtr& other); // concatenate another dataset
DatasetPtr DeepCopy(Allocator* allocator = nullptr) const; // independent copy
Metadata
| Setter | Getter | Type | Meaning |
|---|---|---|---|
NumElements(int64_t) | GetNumElements() | int64_t | Number of elements (vectors/rows). |
Dim(int64_t) | GetDim() | int64_t | Dense vector dimensionality. |
Ids(const int64_t*) | GetIds() | const int64_t* | Per-element ids (length NumElements). |
Distances(const float*) | GetDistances() | const float* | Distances (search output; length depends on k/matches). |
Vector payloads
A dataset carries exactly one vector representation. Select the payload setter from both the
index’s scalar dtype and record-layout repr:
| Setter | Getter | Element type | Use with |
|---|---|---|---|
Float32Vectors(const float*) | GetFloat32Vectors() | float | dtype: float32 |
Float16Vectors(const uint16_t*) | GetFloat16Vectors() | uint16_t | dtype: float16 and bfloat16 (raw 16-bit payload) |
Int8Vectors(const int8_t*) | GetInt8Vectors() | int8_t | dtype: int8 |
SparseVectors(const SparseVector*) | GetSparseVectors() | SparseVector | dtype: sparse (SINDI) |
Dense vectors are laid out row-major: element i, dimension j lives at vectors[i * dim + j].
Multi-vector datasets use repr: multi_vector with a scalar dtype, normally float32, and the
payload described below.
Multi-vector payloads
For documents that hold several dense sub-vectors each:
| Setter | Getter | Type | Meaning |
|---|---|---|---|
MultiVectors(const MultiVector*) | GetMultiVectors() | MultiVector | One entry per document. |
MultiVectorDim(int64_t) | GetMultiVectorDim() | int64_t | Floats per sub-vector (independent of Dim). |
VectorCounts(const uint32_t*) | GetVectorCounts() | const uint32_t* | Sub-vector count per document. |
Metadata payloads
| Setter | Getter | Type | Meaning |
|---|---|---|---|
AttributeSets(const AttributeSet*) | GetAttributeSets() | AttributeSet | Per-element attributes for hybrid search. |
ExtraInfos(const char*) | GetExtraInfos() | const char* | Packed extra-info blobs. |
ExtraInfoSize(int64_t) | GetExtraInfoSize() | int64_t | Bytes per extra-info blob. |
Paths(const std::string*) | GetPaths() | const std::string* | Hierarchy paths (Pyramid). Default hierarchy. |
Paths(const std::string& hierarchy, const std::string*) | GetPaths(const std::string& hierarchy) | const std::string* | Paths for a named hierarchy. |
Paths(const std::string& hierarchy, std::vector<std::vector<std::string>>) | GetPaths(const std::string& hierarchy, std::vector<std::vector<std::string>>& paths) | structured paths | One or multiple Pyramid paths per element. |
SourceID(const std::string*) | GetSourceID() | const std::string* | Optional stable source identifier per element; HGraph uses it to match build-cache entries across snapshots. |
For the structured Paths overload, the outer vector must have exactly NumElements() entries and
each inner vector must list at least one independent path for that element. An empty inner vector is
invalid; an inner vector containing one empty string ({""}) assigns the element to the hierarchy
root. Repeated or shared-prefix paths do not duplicate the vector in a node or in search results.
The structured container is copied or moved into Dataset, so its lifetime is independent of
Owner(). The structured GetPaths overload copies either representation into the output container
and returns false after clearing the output when the hierarchy is absent.
See Attribute Filter (Hybrid Search), Extra Info, and HGraph Build Cache.
Diagnostics payloads
| Setter | Getter | Type | Meaning |
|---|---|---|---|
Statistics(const std::string&) | GetStatistics() / GetStatistics(keys) | std::string / std::vector<std::string> | Serialized statistics; the keyed getter returns values for the requested keys. |
Reasoning(const std::string&) | GetReasoning() | std::string | Reasoning report (JSON) explaining recall of expected_labels_. |
Reading search results
Search methods return a DatasetPtr you read back with the getters:
auto result = index->KnnSearch(query, 10, search_params);
if (result.has_value()) {
auto r = result.value();
for (int64_t i = 0; i < r->GetDim(); ++i) {
int64_t id = r->GetIds()[i];
float dist = r->GetDistances()[i];
}
}
For single-query KNN, GetNumElements() is 1 and GetDim() is the actual result count. HGraph
and IVF batched KNN results are row-major GetNumElements() x GetDim() matrices. HGraph pads short
rows with id == -1 and infinite distance. For range search, the number of matches is reported
through the result’s dimension. See
k-Nearest Neighbor Search.
For HGraph batched KNN, an empty index (including one with all elements removed) returns
NumElements = query_count and Dim = requested k, with every slot padded by -1 and
+infinity. For a non-empty index, Dim = min(requested k, live element count); filtering
can leave padded slots within that width. Single-query empty results retain Dim = 0.
SparseVector
struct SparseVector {
uint32_t len_ = 0; // number of non-zero entries
uint32_t* ids_ = nullptr; // term ids, length len_ (sorted ascending inside the index)
float* vals_ = nullptr; // term weights, length len_
// optional original tokenization (order/duplicates preserved, unlike ids_)
uint32_t token_seq_len_ = 0;
uint32_t* token_sequence_ = nullptr;
};
Sorting ids_ ascending before insertion is recommended. token_sequence_ is optional and only
used by indexes that consume raw token order.
MultiVector
struct MultiVector {
uint32_t len_ = 0; // number of sub-vectors in this document
float* vectors_ = nullptr; // flat array of len_ * MultiVectorDim floats
};
When Owner(true) is set, each element’s vectors_ must be independently allocated, because the
destructor frees each vectors_ separately.
Named metadata
UInt32Metadata(name, values) attaches one unsigned integer per dataset element, and
GetUInt32Metadata returns the named array or nullptr. Int64Metadata(name, values) and
GetInt64Metadata(name) provide named signed 64-bit integer arrays; StringMetadata(name, values)
and GetStringMetadata(name) provide named string arrays. SINDI and SINDI_V2 use string metadata
named host for host filtering and int64 metadata named publish_time_stamp for UTC-day time
filtering. A time range on a one-element query dataset uses publish_time_stamp_begin and
publish_time_stamp_end. For SINDI and SINDI_V2 base datasets, publish_time_stamp == 0 is
reserved for a missing timestamp and never matches a time selector. These arrays have one value
per dataset element and follow the normal Dataset ownership, deep-copy, and append rules.
See also
- Index — the methods that consume and return datasets.
- Search Request & Filters — wrapping a query dataset in a
SearchRequest. - Auxiliary Types —
AttributeSetand attribute value types.
Per-search distance statistics
Results from maintained HGraph, BruteForce, IVF, Pyramid, SINDI, and SIMQ searches include an
additive GetStatistics() contract:
{"distance_evaluations":1164,
"distance_evaluations_by_phase":{"routing":64,"approximate":1000,"rerank":100},
"distance_evaluations_by_backend":{"sq8":1000,"fp32":100,"unknown":0},
"complete":true}
One logical query-to-candidate distance or bound evaluation counts once. A batch of N counts
N; duplicate evaluations count each time; pre-distance rejects, filters, graph edges, prefetches,
heap operations, and skipped lower bounds count zero. A lower bound and later exact rerank count
separately. Phases are routing, approximate, and rerank; backends are stable representation
families such as fp32, fp16, bf16, int8, sq8, sq4, pq, pq_fastscan, rabitq,
binary, and sparse families, never ISA or batch variants.
The total equals the phase sum. Known backend values equal the total; unknown work is in unknown
and sets complete to false. Values are unsigned 64-bit JSON integers with saturating addition.
SINDI and SINDI_V2 omit approximate-phase evaluations to avoid per-posting-ID tracking in their
search hot paths. Their measured phases remain available, but complete is false when
approximate evaluations occur.
Legacy dist_cmp and reorder_distance_count remain available unchanged for compatibility.
Python preserves (ids, distances) tuple unpacking. Opt in with
knn_search_with_statistics: the dense overload returns one statistics JSON string, while the
sparse CSR overload returns one string per query. range_search_with_statistics returns the range
arrays plus one statistics JSON string. C preserves SearchResult_t; call
vsag_search_result_enable_statistics() before a search and release returned statistics with
vsag_search_result_destroy_statistics(). Legacy callers retain other_result ownership and
incur no statistics allocation. Legacy HNSW and DiskANN are explicit non-goals.
Search Request & Filters
This page covers the types that describe how to search: the unified
SearchRequest, the filtering primitives Filter and
Bitset, and the IteratorContext used for incremental search. The
deprecated SearchParam is documented at the end for migration.
SearchRequest
Declared in vsag/search_request.h. SearchRequest is a plain struct that bundles every option for
Index::SearchWithRequest. Fill in the fields you need and leave
the rest at their defaults.
vsag::SearchRequest request;
request.query_ = query; // DatasetPtr with one query vector
request.mode_ = vsag::SearchMode::KNN_SEARCH;
request.topk_ = 10;
request.params_str_ = R"({"hgraph": {"ef_search": 100}})";
auto result = index->SearchWithRequest(request);
SearchMode
enum class SearchMode {
KNN_SEARCH = 1, // return the top-k nearest vectors
RANGE_SEARCH = 2, // return all vectors within radius_
};
Basic fields
| Field | Type | Default | Meaning |
|---|---|---|---|
query_ | DatasetPtr | nullptr | The query. HGraph and IVF KNN requests support multiple query vectors; other requests allow one. |
mode_ | SearchMode | KNN_SEARCH | KNN vs. range search. |
topk_ | int64_t | 10 | Neighbors to return (KNN mode). Must be positive. |
radius_ | float | 0.5 | Distance threshold (range mode). Non-negative. |
limited_size_ | int64_t | -1 | Cap on range results; -1 means no limit. |
params_str_ | std::string | "" | Algorithm-specific search params as JSON (e.g. ef_search). |
Custom query distance callback
distance_batch_func_ optionally supplies query-to-vector scores from application code. It receives
stable external vector IDs, so a closure can keep the query and query-specific state outside VSAG:
request.distance_batch_size_ = 32;
request.distance_batch_func_ = [query](const int64_t* ids, uint64_t count, float* scores) {
for (uint64_t i = 0; i < count; ++i) {
scores[i] = Score(query, ids[i]);
}
};
| Field | Type | Default | Meaning |
|---|---|---|---|
distance_batch_func_ | SearchDistanceBatchFunc | nullptr | Fills one score for each input external ID, in input order. Smaller finite scores are better. |
distance_batch_size_ | uint64_t | 1 | Maximum IDs passed to one callback invocation. Set this to the scorer’s efficient batch width for batched inference or external data access. Must be positive when a callback is set. |
The callback is request-scoped and is not serialized. It may capture the query. hgraph permits a
null query_ in callback mode, while ivf still requires one query vector for bucket routing. The
callback must be stable for one request, thread-safe if the caller enables parallel execution,
non-throwing, and must write only finite scores.
brute_force uses the callback for exact KNN and range search. hgraph supports KNN only; the
callback drives graph traversal and final ordering, while the graph remains built with its configured
built-in metric. Therefore recall under the callback score is not guaranteed. HGraph may score
filtered traversal nodes to preserve graph connectivity, but filtered nodes are never returned.
Callback HGraph does not support parallel search or brute_force_threshold; those configurations
are rejected rather than silently changing search semantics.
ivf supports callback KNN with a non-null single query vector. IVF uses its configured built-in
metric to select scan_buckets_count buckets, then applies the callback to candidates in those
buckets. The callback controls candidate ranking but cannot recover vectors outside the selected
buckets; increase scan_buckets_count to improve recall. Callback IVF does not support range search,
disable_bucket_scan, bucket-graph search, or parallel search. Those configurations are rejected.
Reordering is automatically disabled in callback mode. Other indexes do not support the callback.
IVF bucket routing
IVF accepts {"ivf":{"scan_buckets_count":N,"disable_bucket_scan":true}} through
params_str_. This routing-only mode returns the N selected bucket IDs per query in the
result Dataset instead of vector labels. NumElements() equals the number of queries,
Dim() equals scan_buckets_count, GetIds() contains bucket IDs (with -1 for empty
slots), and GetDistances() has distances to bucket centroids. No vector scan is performed,
so filters, topk, range limits, reordering, and reasoning options are ignored.
IVF bucket IDs bypass
The bucket_ids_ field allows callers to provide pre-selected bucket IDs, bypassing IVF’s
internal bucket routing (ClassifyDatasForSearch). When non-empty, the search skips centroid-based
selection and scans only the specified buckets.
| Field | Type | Default | Meaning |
|---|---|---|---|
bucket_ids_ | std::vector<std::vector<int64_t>> | {} | Pre-selected bucket IDs for bypassing IVF routing. |
Semantics:
- Empty (default): Use normal bucket routing via
ClassifyDatasForSearch. - Non-empty: Skip routing and scan only the provided bucket IDs.
- Batch IVF KNN: One outer
bucket_ids_entry per query vector. Result is rectangular:NumElements()is query count,Dim()istopk_. Missing neighbors are-1with infinite distance.
Constraints:
- Batch IVF search supports KNN only; custom query distance and reasoning labels are unsupported.
- A non-empty outer vector must contain exactly one non-empty entry per query vector.
- Each bucket ID must be in range
[0, bucket_count). - Duplicate bucket IDs are rejected.
- Incompatible with
disable_bucket_scanmode. - Caller is responsible for ordering bucket IDs (no automatic sorting).
Filtering fields
Three filtering mechanisms are available and are combined with logical AND when more than one is enabled.
| Field | Type | Default | Meaning |
|---|---|---|---|
enable_attribute_filter_ | bool | false | Enable SQL-style attribute filtering. |
attribute_filter_str_ | std::string | "" | The filter expression (see below). Requires enable_attribute_filter_. |
enable_filter_ | bool | false | Enable a custom Filter callback. |
filter_ | FilterPtr | nullptr | The filter object. Requires enable_filter_. |
enable_bitset_filter_ | bool | false | Enable a Bitset filter. |
bitset_filter_ | BitsetPtr | nullptr | The bitset. Test(id) == true excludes id. Requires enable_bitset_filter_. |
The attribute_filter_str_ grammar is SQL-like. Examples:
category = 'electronics' AND price != 1000
multi_in(category, ['electronics', 'clothing']) AND multi_notin(color, ['red', 'blue'])
See Attribute Filter (Hybrid Search) and Filtered Search.
Resource & iterator fields
| Field | Type | Default | Meaning |
|---|---|---|---|
search_allocator_ | Allocator* | nullptr | Per-search allocator; falls back to the index allocator when null. |
enable_iterator_search_ | bool | false | Enable incremental (iterator) search. |
p_iter_ctx_ | IteratorContext** | nullptr | Handle to the iterator state, reused across calls. |
is_last_search_ | bool | false | Marks the final call of an iterator sequence. |
expected_labels_ | std::vector<int64_t> | {} | Ids expected in the result; enables reasoning analysis of missed recalls. |
See Per-Search Allocator and
Iterator Search, plus examples/cpp/313/314 for the allocator.
Filter
Declared in vsag/filter.h. Implement this abstract class to express arbitrary “keep this id?”
logic. Hold it through FilterPtr (std::shared_ptr<Filter>).
class Filter {
public:
enum class Distribution { NONE = 0, RELATED_TO_VECTOR };
virtual bool CheckValid(int64_t id) const = 0; // true => KEEP the id
virtual bool CheckValid(const char* data) const; // extra-info variant (default true)
virtual float ValidRatio() const; // fraction kept (default 1.0)
virtual Distribution FilterDistribution() const; // hint (default NONE)
virtual void GetValidIds(const int64_t** valid_ids, int64_t& count) const;
};
Convention:
Filter::CheckValid(id)returnstrueto keep a vector. This is the opposite of thebitset/std::function<bool(int64_t)>pre-filter overloads onIndex, wheretruemeans filtered out. Keep this distinction in mind when choosing an overload.
| Member | Purpose |
|---|---|
CheckValid(int64_t id) | Core predicate. true keeps the id in results. |
CheckValid(const char* data) | Predicate over an element’s extra-info bytes. Defaults to true. |
ValidRatio() | Estimated fraction of vectors that pass; lets the engine pick a strategy. |
FilterDistribution() | RELATED_TO_VECTOR hints validity correlates with vector position. |
GetValidIds(...) | Optionally expose the explicit valid-id set. |
See examples/cpp/301_feature_filter.cpp.
Bitset
Declared in vsag/bitset.h. A compact set of bit flags keyed by position, held through BitsetPtr.
It is used both as a filtering input and as a utility (e.g. the result of
l2_and_filtering).
static BitsetPtr Random(int64_t length); // random bitset of the given length
static BitsetPtr Make(); // empty bitset
void Set(int64_t pos, bool value);
void Set(int64_t pos); // = Set(pos, true)
bool Test(int64_t pos) const;
uint64_t Count(); // number of set bits
std::string Dump(); // debug dump
When a
Bitsetis used as a search pre-filter (bitset_filter_, or theinvalidargument ofKnnSearch/RangeSearch),Test(id) == truemeans the id is filtered out.
IteratorContext
Declared in vsag/iterator_context.h. An opaque handle that stores the position of an in-progress
iterator search so that subsequent calls resume where the previous one stopped.
class IteratorContext {
public:
virtual ~IteratorContext() = default;
};
You do not construct or inspect it directly. VSAG allocates it on the first iterator search; pass the
same handle back (via SearchRequest::p_iter_ctx_, or the KnnSearch iterator overload) on each
subsequent call, and set the last-search flag on the final call so the engine can release it. See
Iterator Search.
SearchParam (deprecated)
Declared in vsag/search_param.h. SearchParam predates SearchRequest and is retained only for
the deprecated KnnSearch(query, k, SearchParam&) overload.
struct SearchParam { // [[deprecated]] use SearchRequest
bool is_iter_filter{false};
bool is_last_search{false};
const std::string& parameters;
FilterPtr filter{nullptr};
Allocator* allocator{nullptr};
IteratorContext* iter_ctx{nullptr};
};
Prefer SearchRequest + SearchWithRequest
for all new code. SearchParam holds parameters by reference, so the referenced string must
outlive the call.
See also
- Index — the search methods that consume these types.
- Dataset — building the
query_and reading results. - Auxiliary Types — attribute value types used by attribute filters.
Serialization Types
VSAG can persist an index in two shapes: an in-memory BinarySet (a named collection
of byte blobs) or, for on-disk / streaming scenarios, a ReaderSet of lazy
Reader objects. These types are the payloads passed to
Index::Serialize / Index::Deserialize.
For end-to-end workflows and stream-based serialization, see
Serialization and examples/cpp/401_persistent_kv.cpp /
402_persistent_streaming.cpp.
Binary
Declared in vsag/binaryset.h. A single named byte buffer with its length.
struct Binary {
std::shared_ptr<int8_t[]> data; // the bytes
uint64_t size; // number of bytes
};
The shared_ptr owns the buffer, so a Binary can be copied and stored freely without worrying
about lifetime.
BinarySet
Declared in vsag/binaryset.h. A string-keyed map of Binary blobs — the standard in-memory
serialization container. An index serializes itself into several named parts (graph, vectors,
quantizer, etc.), all gathered in one BinarySet.
class BinarySet {
public:
void Set(const std::string& name, Binary binary); // store a blob
Binary Get(const std::string& name) const; // {nullptr, 0} if absent
std::vector<std::string> GetKeys() const; // all stored names
bool Contains(const std::string& key) const;
};
| Method | Description |
|---|---|
Set(name, binary) | Stores binary under name, overwriting any existing entry. |
Get(name) | Returns the blob, or an empty Binary{nullptr, 0} if the name is absent. |
GetKeys() | Returns every stored name. |
Contains(key) | Whether a blob is stored under key. |
// Serialize to a BinarySet, then persist each part however you like.
auto serialized = index->Serialize();
if (serialized.has_value()) {
vsag::BinarySet bs = serialized.value();
for (const auto& key : bs.GetKeys()) {
vsag::Binary part = bs.Get(key);
// write part.data[0 .. part.size) to your store, keyed by `key`
}
}
To restore, rebuild the BinarySet from your store and call
Deserialize(const BinarySet&) on a fresh (empty) index.
Reader
Declared in vsag/readerset.h. An abstract source of bytes that the index reads on demand — the
basis for deserializing large, disk-resident indexes without loading everything into memory. Obtain a
local-file reader from Factory::CreateLocalFileReader,
or implement Reader for a custom backend (object storage, mmap, etc.). Hold it through ReaderPtr
(std::shared_ptr<Reader>).
class Reader {
public:
virtual void Read(uint64_t offset, uint64_t len, void* dest) = 0; // sync
virtual void AsyncRead(uint64_t offset, uint64_t len, void* dest, CallBack cb) = 0; // async
virtual bool MultiRead(uint8_t* dests, const uint64_t* lens,
const uint64_t* offsets, uint64_t count); // batched
virtual uint64_t Size() const = 0;
};
| Method | Description |
|---|---|
Read(offset, len, dest) | Synchronously copy len bytes from offset into dest. Thread-safe. |
AsyncRead(offset, len, dest, callback) | Asynchronous read; callback is invoked with an IOErrorCode and message on completion. |
MultiRead(dests, lens, offsets, count) | Perform count synchronous reads in one call; returns false on any failure. |
Size() | Total size of the underlying source in bytes. |
IOErrorCode
enum class IOErrorCode {
IO_SUCCESS = 0, // operation succeeded
IO_ERROR = 1, // general I/O error
IO_TIMEOUT = 2, // operation timed out
};
CallBack
using CallBack = std::function<void(IOErrorCode code, const std::string& message)>;
The completion handler for AsyncRead.
ReaderSet
Declared in vsag/readerset.h. A string-keyed map of Reader objects — the streaming analogue of
BinarySet. Each named part of a serialized index maps to a Reader that fetches that part on
demand. Pass a fully populated ReaderSet to
Deserialize(const ReaderSet&).
class ReaderSet {
public:
void Set(const std::string& name, ReaderPtr reader);
ReaderPtr Get(const std::string& name) const; // nullptr if absent
std::vector<std::string> GetKeys() const;
bool Contains(const std::string& key) const;
};
The method semantics mirror BinarySet, except values are ReaderPtr instead of
Binary.
vsag::ReaderSet readers;
readers.Set("graph", vsag::Factory::CreateLocalFileReader("index.graph", 0, graph_size));
readers.Set("vectors", vsag::Factory::CreateLocalFileReader("index.vectors", 0, vec_size));
auto fresh = vsag::Factory::CreateIndex("hgraph", params).value();
fresh->Deserialize(readers);
Optional prefetch hints for custom readers
A custom reader can additionally inherit from ReaderPrefetcher. This keeps the existing Reader
ABI unchanged while allowing reader_io to forward best-effort search-time hints:
class RemoteReader : public vsag::Reader, public vsag::ReaderPrefetcher {
public:
void Read(uint64_t offset, uint64_t len, void* dest) override;
void AsyncRead(uint64_t offset, uint64_t len, void* dest, vsag::CallBack callback) override;
uint64_t Size() const override;
void Prefetch(uint64_t offset, uint64_t len) override {
// Optionally enqueue a non-blocking range fetch. Do not let failures escape.
remote_cache.TryPrefetch(offset, len);
}
};
vsag::LoadParameters load_parameters;
load_parameters.Set("precise_io_type", "reader_io")
.Set("precise_enable_prefetch_hint", true)
.SetReader("precise_reader", std::make_shared<RemoteReader>());
Readers that only implement Reader remain compatible; the enabled hint is then a safe no-op.
See the runnable examples/cpp/408_feature_reader_prefetch.cpp example.
See also
- Index — the
Serialize/Deserializemethod family. - Factory & Engine — creating file-backed readers.
- Serialization — full persistence guide.
Resource Management
VSAG lets you take control of the memory and threads it uses. This page covers
Allocator (custom memory management), ThreadPool (custom
concurrency), Resource (a bundle of the two shared by an Engine),
the process-wide Options singleton, and the pluggable Logger.
Runnable samples: examples/cpp/201_custom_allocator.cpp, 202_custom_logger.cpp, and
203_custom_thread_pool.cpp. See also Memory Management and
Extensibility.
Allocator
Declared in vsag/allocator.h. An abstract interface for custom memory management. Implement it to
route all of an index’s allocations through your own pool, arena, or accounting layer, then pass it to
Factory::CreateIndex or a Resource.
class Allocator {
public:
virtual std::string Name() = 0;
virtual void* Allocate(uint64_t size) = 0;
virtual void Deallocate(void* p) = 0;
virtual void* Reallocate(void* p, uint64_t size) = 0;
template <typename T, typename... Args> T* New(Args&&... args); // Allocate + construct
template <typename T> void Delete(T* p); // destruct + Deallocate
};
| Member | Description |
|---|---|
Name() | Identifier for the allocator implementation (used in diagnostics). |
Allocate(size) | Return a block of at least size bytes. |
Deallocate(p) | Free a block previously returned by this allocator. |
Reallocate(p, size) | Resize a block, preserving contents. |
New<T>(args...) | Helper: allocate and construct a T; frees and rethrows if the constructor throws. |
Delete<T>(p) | Helper: destruct *p and free its storage (null-safe). |
An allocator passed to an index must outlive that index. VSAG’s built-in allocator is available via
Engine::CreateDefaultAllocator.
ThreadPool
Declared in vsag/thread_pool.h. An abstract task executor. Supply your own to make VSAG share your
application’s threads instead of spawning its own.
class ThreadPool {
public:
virtual void WaitUntilEmpty() = 0;
virtual void SetQueueSizeLimit(std::uint64_t limit) = 0;
virtual void SetPoolSize(std::uint64_t limit) = 0;
virtual std::future<void> Enqueue(std::function<void(void)> task) = 0;
};
| Member | Description |
|---|---|
WaitUntilEmpty() | Block until all enqueued tasks finish. |
SetQueueSizeLimit(limit) | Cap the pending-task queue; behavior past the cap is implementation-defined. |
SetPoolSize(limit) | Cap the number of worker threads. |
Enqueue(task) | Submit a task; returns a std::future<void> for its completion. |
A ready-made pool can be created with
Engine::CreateThreadPool.
Resource
Declared in vsag/resource.h. A Resource bundles an Allocator and a
ThreadPool so that an Engine — and every index it
creates — can share them.
class Resource {
public:
explicit Resource(Allocator* allocator, ThreadPool* thread_pool);
explicit Resource(const std::shared_ptr<Allocator>& allocator,
const std::shared_ptr<ThreadPool>& thread_pool);
explicit Resource(); // default allocator, no thread pool
std::shared_ptr<Allocator> GetAllocator() const;
std::shared_ptr<ThreadPool> GetThreadPool() const;
};
| Constructor / method | Description |
|---|---|
Resource(Allocator*, ThreadPool*) | Use raw pointers you own. A null allocator means “create and own a default”; a null thread pool means “no pool”. |
Resource(shared_ptr, shared_ptr) | Same, with shared ownership. |
Resource() | Default allocator, no thread pool. |
GetAllocator() | The resource’s allocator (a default one if none was supplied). |
GetThreadPool() | The resource’s thread pool, or null if none was supplied. |
auto alloc = vsag::Engine::CreateDefaultAllocator();
auto pool = vsag::Engine::CreateThreadPool(4).value();
vsag::Resource resource(alloc, pool);
vsag::Engine engine(&resource);
auto index = engine.CreateIndex("hgraph", params);
Options
Declared in vsag/options.h. A process-wide singleton for global configuration, accessed via
Options::Instance(). Thread-safe. Option is a type alias for Options.
vsag::Options::Instance().set_num_threads_building(8);
vsag::Options::Instance().set_logger(&my_logger);
| Setting | Accessors | Default | Meaning |
|---|---|---|---|
| Build threads | num_threads_building() / set_num_threads_building(n) | 4 | Threads for constructing an index. |
| Block size limit | block_size_limit() / set_block_size_limit(bytes) | 128 MB | Max bytes per allocation block (must be > 2 MB). |
| Direct-IO align | direct_IO_object_align_bit() / set_direct_IO_object_align_bit(bits) | 9 | Direct-IO object alignment, in bits (< 21). |
| Logger | logger() / set_logger(Logger*) | nullptr | Active Logger; returns true on set. |
Logger
Declared in vsag/logger.h. An abstract logging sink. Implement it and register it via
Options::set_logger to route VSAG’s log output through your application’s logging system.
The built-in logger defaults to info. Set VSAG_LOG_LEVEL before the built-in logger is
created to choose trace, debug, info, warn/warning, error, critical, or off. Invalid
values are ignored and keep the default level. An explicit SetLevel call still overrides the
environment-derived level.
Startup init banner
VSAG logs a startup initialization banner when vsag::init() runs. To suppress that banner, set
VSAG_SUPPRESS_INIT_BANNER before starting the process. This is useful for tests, CI jobs, or
applications that need quieter startup logs.
Truthy values are 1, on, and true; matching for on and true is ASCII case-insensitive, so
ON, On, and TRUE also work. Other values leave the banner enabled.
Set the variable before process start. VSAG also runs vsag::init() during static initialization,
so setting the variable later from inside the process cannot suppress the first banner.
The banner includes an instance spec value such as 48C503G, combining the cpuinfo core count
with total physical memory. Memory is reported in whole GiB using floor division by 1024^3; if the
platform query fails, the memory portion is shown as ?G. SIMD lines, including neon and sve,
retain their existing distribution/platform/using capability semantics.
VSAG_SUPPRESS_INIT_BANNER=1 ./your_vsag_app
VSAG_SUPPRESS_INIT_BANNER=true ./your_vsag_test
class Logger {
public:
enum Level : int {
kTRACE = 0, kDEBUG = 1, kINFO = 2, kWARN = 3, kERR = 4, kCRITICAL = 5, kOFF = 6, kN_LEVELS
};
virtual void SetLevel(Level log_level) = 0;
virtual void Trace(const std::string& msg) = 0;
virtual void Debug(const std::string& msg) = 0;
virtual void Info(const std::string& msg) = 0;
virtual void Warn(const std::string& msg) = 0;
virtual void Error(const std::string& msg) = 0;
virtual void Critical(const std::string& msg) = 0;
};
| Member | Description |
|---|---|
SetLevel(level) | Only messages at or above level are emitted. kOFF disables logging. |
Trace / Debug / Info / Warn / Error / Critical | Emit a message at the corresponding severity. |
See examples/cpp/202_custom_logger.cpp.
See also
- Factory & Engine — how
Engineconsumes aResource. - Index — per-call allocators on retrieval and search methods.
- Search Request & Filters — the per-search allocator field.
Auxiliary Types
This page gathers the remaining public types: the attribute system used for hybrid
(attribute-filtered) search, the IndexFeature capability flags, the
index detail info introspection types, the utility functions
in utils.h, and the string constants in constants.h.
Attributes
Declared in vsag/attribute.h. Attributes are typed, named metadata attached to each vector, enabling
SQL-style filtering during search (see
Attribute Filter (Hybrid Search)).
AttrValueType
enum AttrValueType {
INT32 = 1, UINT32 = 2, INT64 = 3, UINT64 = 4,
INT8 = 5, UINT8 = 6, INT16 = 7, UINT16 = 8,
STRING = 9,
};
The element type carried by an attribute.
Attribute
class Attribute {
public:
std::string name_{};
virtual AttrValueType GetValueType() const = 0;
virtual uint64_t GetValueCount() const = 0;
virtual Attribute* DeepCopy() const = 0;
virtual bool Equal(const Attribute* other) const = 0;
};
using AttributePtr = std::shared_ptr<Attribute>;
An abstract, named attribute. Each attribute may hold multiple values (GetValueCount()), so a single
field can represent a multi-valued tag set.
| Member | Description |
|---|---|
name_ | The attribute (field) name. |
GetValueType() | The AttrValueType of the stored values. |
GetValueCount() | Number of values held. |
DeepCopy() | Allocate an independent copy. |
Equal(other) | Value equality against another attribute. |
AttributeValue<T>
template <class T>
class AttributeValue : public Attribute {
public:
AttrValueType GetValueType() const override;
uint64_t GetValueCount() const override;
std::vector<T>& GetValue();
const std::vector<T>& GetValue() const;
Attribute* DeepCopy() const override;
bool Equal(const Attribute* other) const override;
};
The concrete, typed implementation of Attribute. Instantiate it with the C++ type matching the
desired AttrValueType (e.g. AttributeValue<int32_t>, AttributeValue<std::string>),
set name_, and push values into GetValue().
auto tag = std::make_shared<vsag::AttributeValue<int32_t>>();
tag->name_ = "category";
tag->GetValue().push_back(7);
AttributeSet
struct AttributeSet {
std::vector<Attribute*> attrs_;
};
A bag of attributes describing one element. Attach a per-element array of AttributeSet to a
Dataset via AttributeSets(...), or pass one to
Index::UpdateAttribute.
IndexFeature
Declared in vsag/index_features.h. An enum of optional capabilities you can probe with
Index::CheckFeature before calling an optional method.
enum IndexFeature {
NEED_TRAIN = 1,
SUPPORT_BUILD,
SUPPORT_ADD_AFTER_BUILD,
SUPPORT_KNN_SEARCH,
SUPPORT_RANGE_SEARCH,
SUPPORT_DELETE_BY_ID,
SUPPORT_SERIALIZE_BINARY_SET,
SUPPORT_CAL_DISTANCE_BY_ID,
SUPPORT_MERGE_INDEX,
SUPPORT_CLONE,
/* ... many more ... */
INDEX_FEATURE_COUNT // sentinel; always the last value
};
The enum groups capabilities into families:
| Family | Examples |
|---|---|
| Lifecycle | NEED_TRAIN, SUPPORT_BUILD, SUPPORT_ADD_AFTER_BUILD, SUPPORT_ADD_FROM_EMPTY, SUPPORT_RESET |
| Search | SUPPORT_KNN_SEARCH, SUPPORT_RANGE_SEARCH, SUPPORT_*_WITH_ID_FILTER, SUPPORT_KNN_ITERATOR_FILTER_SEARCH, SUPPORT_BATCH_SEARCH |
| Metric | SUPPORT_METRIC_TYPE_L2, SUPPORT_METRIC_TYPE_INNER_PRODUCT, SUPPORT_METRIC_TYPE_COSINE |
| Serialization | SUPPORT_SERIALIZE_FILE / _BINARY_SET / _WRITE_FUNC, SUPPORT_DESERIALIZE_FILE / _BINARY_SET / _READER_SET |
| Concurrency | SUPPORT_ADD_CONCURRENT, SUPPORT_SEARCH_CONCURRENT, SUPPORT_ADD_SEARCH_DELETE_CONCURRENT, and the SUPPORT_*_WITH_MULTI_THREAD build/train variants |
| Introspection & ops | SUPPORT_ESTIMATE_MEMORY, SUPPORT_GET_MEMORY_USAGE, SUPPORT_CHECK_ID_EXIST, SUPPORT_MERGE_INDEX, SUPPORT_CLONE, SUPPORT_EXPORT_MODEL, SUPPORT_EXPORT_IDS, SUPPORT_TUNE, SUPPORT_CAL_DISTANCE_BY_ID, SUPPORT_GET_*_BY_ID(S) |
INDEX_FEATURE_COUNT marks the end of the enum and is not a real feature. See
examples/cpp/307_feature_check_features.cpp.
Index detail info
Declared in vsag/index_detail_info.h. These types describe and carry the structured data returned by
Index::GetIndexDetailInfos and
Index::GetDetailDataByName. See
Index Introspection and
examples/cpp/317_feature_get_detail_data.cpp.
IndexDetailDataType
enum class IndexDetailDataType {
TYPE_2DArray_INT64,
TYPE_1DArray_INT64,
TYPE_SCALAR_INT64,
TYPE_SCALAR_DOUBLE,
TYPE_SCALAR_STRING,
TYPE_SCALAR_BOOL,
};
Tells you which DetailData getter is valid for a given field.
IndexDetailInfo
class IndexDetailInfo {
public:
std::string name;
std::string description;
IndexDetailDataType type;
};
A descriptor for one introspectable field: its name, a human-readable description, and the payload
type.
DetailData
class DetailData {
public:
virtual std::vector<int64_t> GetData1DArrayInt64();
virtual std::vector<std::vector<int64_t>> GetData2DArrayInt64();
virtual std::string GetDataScalarString();
virtual bool GetDataScalarBool();
virtual int64_t GetDataScalarInt64();
virtual double GetDataScalarDouble();
// ... const overloads ...
};
using DetailDataPtr = std::shared_ptr<DetailData>;
The payload itself. Read it through the getter matching the descriptor’s
IndexDetailDataType; calling a mismatched getter is not meaningful.
Utility functions
Declared in vsag/utils.h. Free helper functions for clustering and recall evaluation.
kmeans_clustering
float kmeans_clustering(uint64_t d, uint64_t n, uint64_t k, const float* x,
float* centroids, const std::string& dis_type);
Runs k-means over n points of dimension d, writing k centroids into the pre-allocated
centroids (size k * d). dis_type is one of "l2", "cosine", "ip". Returns the final
quantization error.
l2_and_filtering
BitsetPtr l2_and_filtering(int64_t dim, int64_t nb, const float* base,
const float* query, float threshold);
Returns a Bitset in which bit i is set (true) when base vector i falls
within threshold L2 distance of query — the ground truth consumed by range_search_recall.
Note the polarity is the opposite of a search pre-filter, where a set bit excludes an id (see
Bitset); invert it before reusing it as an invalid / bitset_filter_ mask.
knn_search_recall / range_search_recall
float knn_search_recall(const float* base, const int64_t* id_map, int64_t base_num,
const float* query, int64_t data_dim,
const int64_t* result_ids, int64_t result_size);
float range_search_recall(const float* base, const int64_t* base_ids, int64_t num_base,
const float* query, int64_t dim,
const int64_t* result_ids, int64_t result_size, float threshold);
Compute the recall of a KNN or range search result against the ground truth derived from the base vectors. Handy for tests and benchmarks; see Benchmarks.
Constants
Declared in vsag/constants.h. A large set of extern const char* const string constants for the
keys and enumerated string values used throughout the JSON-based configuration. Using the constants
instead of raw string literals avoids typos. They fall into several groups:
| Group | Examples |
|---|---|
| Index type names | INDEX_HGRAPH, INDEX_IVF, INDEX_BRUTE_FORCE, INDEX_SINDI, INDEX_PYRAMID |
| Dataset field names | DIM, NUM_ELEMENTS, IDS, DISTS, FLOAT32_VECTORS, SPARSE_VECTORS |
| Metric names | METRIC_L2, METRIC_COSINE, METRIC_IP |
| Data type names | DATATYPE_FLOAT32, DATATYPE_FLOAT16, DATATYPE_BFLOAT16, DATATYPE_INT8, DATATYPE_SPARSE |
| Top-level params | PARAMETER_DTYPE, PARAMETER_DIM, PARAMETER_METRIC_TYPE, INDEX_PARAM |
| Per-index params | HGRAPH_*, IVF_*, PYRAMID_*, BRUTE_FORCE_* |
| Statistics keys | STATSTIC_MEMORY, STATSTIC_KNN_TIME, STATSTIC_RANGE_TIME |
For the meaning of each parameter key, see Index Parameters and the individual index pages.
See also
- Index —
CheckFeature,GetIndexDetailInfos,UpdateAttribute. - Dataset — attaching
AttributeSetdata to elements. - Search Request & Filters — attribute-filter expressions and
Bitset.
Best Practices
This page gathers practical advice for running VSAG in production, as a companion to the parameter reference and performance tuning guide.
Index Selection
| Scenario | Recommended index | Rationale |
|---|---|---|
| Medium scale (≤ 10M), in-memory, recall/latency critical | hgraph | Unified high-quality graph index with multiple quantizations and Tune support |
| Coarse recall / candidate layer | ivf | Trains once, parallelizes widely |
| Small scale, 100% precision required | brute_force | Exhaustive search; useful as a recall baseline |
| Multi-tenant or partitioned data | pyramid | Multiple subgraphs inside one index, supports tag-based retrieval |
| Sparse vectors (BM25 / SPLADE-style) | sindi | Dedicated sparse-vector index |
Detailed parameters: Index Parameters.
Build Time
- Pick the metric first:
l2/ip/cosinecannot be changed after the index is built. ef_construction: typically 200–500. Too small hurts recall; too large slows builds.max_degree/M: typically 16–48. Larger values mean higher recall and memory.- Quantization: latency-sensitive scenarios favor
sq8orpq; accuracy-sensitive ones favorfp32orfp16. - Parallel builds: use a custom
ThreadPool(seeexamples/cpp/203_custom_thread_pool.cpp) to control concurrency.
Search Time
ef_search: commonlytopktotopk * 10; do a QPS/recall grid search to settle on the right value.- Batch search: merging multiple queries improves cache utilization; batch at the caller or use batch-capable examples.
- Filter: use the built-in
Filter(examples/cpp/301_feature_filter.cpp) rather than post-filtering. - Per-search allocator: for high-concurrency online services, use a per-thread arena allocator; see Memory Management.
Tuning
- Use
Tuneagainst realistic query distributions. - Treat
eval_performanceas a continuous regression test.
Deployment
- The official Docker image is the recommended starting point; see Installation.
- For production binaries, pick the distribution matching your ABI:
dist-pre-cxx11-abi,dist-cxx11-abi, ordist-libcxx(see Building). - Enable
VSAG_ENABLE_INTEL_MKL=ONon Intel CPUs for additional acceleration.
Observability
Index::GetMemoryUsage()exposes runtime memory usage.- The search path supports a custom
Logger(examples/cpp/202_custom_logger.cpp) to integrate with your logging stack. eval_performancecan write its metrics directly to InfluxDB for long-term monitoring.
Disk-Based Index Best Practices
When a corpus grows past the point where every vector fits in RAM, moving the coldest, largest part of the index onto an SSD is the most direct way to control cost. VSAG does this by letting each part of an index choose its own storage backend, so hot data stays in memory while cold data is served from disk. This page covers HGraph with disk-backed IO and shows copy-pasteable configurations, a capacity model, and a tuning checklist.
“Disk index” here means tiering the index data across memory and disk. It is unrelated to filtering by scalar attributes alongside the vector — for that, see Attribute Filter (Hybrid Search).
When to move to disk
A large corpus does not automatically require disk. Weigh these signals first:
| Signal | In-memory is fine | Consider disk |
|---|---|---|
| Corpus size | ≤ tens of millions | Hundreds of millions / billions |
Full fp32 fits in RAM | Yes | No |
| Latency budget | Sub-millisecond, strict | A few to low-tens of milliseconds is acceptable |
| Cost structure | RAM cost is acceptable | Want to replace most RAM with SSD |
A quick memory estimate: a full fp32 copy occupies about N × dim × 4 bytes. For
example 1e9 × 128 × 4 ≈ 512 GB and 1e8 × 768 × 4 ≈ 307 GB — sizes that rarely fit in a
single machine’s RAM, which is exactly the disk-index target.
The cost of going to disk is a few random-read I/Os per query, so latency is higher than a pure in-memory index. If your service needs sub-millisecond latency and the data can be compressed to fit in RAM, prefer in-memory HGraph with quantization instead.
The core idea
VSAG’s disk-backed indexing follows one principle: keep a small, approximate representation in memory to navigate, and keep the large, precise representation on disk to rank. Graph traversal touches only the in-memory approximate codes; a small number of finalists are then re-scored (“reordered”) against the precise copy read from disk. Because the disk reads happen only at the end, for only a handful of candidates, the I/O cost stays bounded while recall is recovered by the precise rescore.
How HGraph tiers storage
HGraph stores an index as several independent cells, and each cell can be pointed at its own IO backend. That is what enables “hot in memory, cold on disk”:
| Cell | Holds | Access pattern | Recommended placement |
|---|---|---|---|
graph | Adjacency lists of the proximity graph | Read on every hop | Memory (or mmap_io under pressure) |
base | Quantized codes used to traverse and prune | Read on every hop | Memory |
precise | High-precision copy used to reorder (use_reorder) | Read for a few finalists | Disk |
raw_vector | Optional raw vectors (store_raw_vector) | Rarely, e.g. cosine/exact | Memory or disk |
The IO backends you can assign to a cell:
Backend (*_io_type) | Location | Needs *_file_path | Notes |
|---|---|---|---|
memory_io | Memory (contiguous) | No | Basic in-memory storage |
block_memory_io | Memory (block-allocated) | No | Default backend for large cells |
buffer_io | Disk (buffered pread) | Yes | Portable disk reads; works everywhere |
mmap_io | Disk (mmap + page cache) | Yes | Near-memory speed when the working set fits the page cache |
async_io | Disk (Linux libaio) | Yes | High-concurrency disk reads; Linux + libaio only, otherwise falls back to buffer_io |
uring_io | Disk (Linux io_uring) | Yes | Optional io_uring backend; requires a build with ENABLE_LIBURING=ON, otherwise falls back to buffer_io |
reader_io | Custom Reader | No | Read through a user ReaderSet at load time (e.g. remote / object storage) |
Each cell is wired with a flat pair of build parameters: graph_io_type / graph_file_path,
base_io_type / base_file_path, precise_io_type / precise_file_path, and
raw_vector_io_type / raw_vector_file_path. A *_file_path is required whenever the
matching *_io_type is disk-backed (buffer_io, mmap_io, async_io, or uring_io); the in-memory
backends ignore it. All cells default to block_memory_io (fully in memory).
Enabling uring_io
uring_io is an optional Linux backend. Install liburing and enable it when configuring CMake:
cmake -S . -B build-release \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_LIBURING=ON \
-DENABLE_EXAMPLES=ON
cmake --build build-release --target 325_feature_uring_io -j
Select it with any supported disk cell parameter, for example:
{
"index_param": {
"base_io_type": "uring_io",
"base_file_path": "/data/vsag/hgraph_base.data",
"base_direct_read": true
}
}
For HGraph, base_direct_read and precise_direct_read default to false. Set the matching flag
to true to open a uring_io file with direct IO and bypass the page cache. Confirm that the
target filesystem supports direct IO and benchmark both modes for the workload.
When VSAG is built without liburing, the same configuration remains loadable but logs a one-time
warning and uses buffer_io. This fallback is functional, but it does not provide io_uring
submission/completion batching. See examples/cpp/325_feature_uring_io.cpp for an end-to-end load
example.
Optional page-cache prefetch hints
File-backed IO parameters accept enable_prefetch_hint (default: false). When enabled, existing
search-time Prefetch calls issue best-effort operating-system hints: buffer_io and buffered
uring_io use file read-ahead advice, mmap_io uses mapped-page advice, and reader_io
forwards to the optional ReaderPrefetcher::Prefetch hook. A reader_io Reader that does not
implement ReaderPrefetcher remains fully compatible: enabled hints are simply ignored. A remote
Reader can opt in by inheriting from both Reader and ReaderPrefetcher; its Prefetch method
should return promptly, treat failures as non-fatal, and must not change subsequent read results.
The option is hint-only and does not affect correctness. Direct-IO paths ignore it because they
bypass the page cache. In public HGraph/IVF build or Index::Load parameters, use the component
form precise_enable_prefetch_hint: true for precise-code reader_io; the runnable
examples/cpp/408_feature_reader_prefetch.cpp demonstrates this setup. Enable it only
after benchmarking the target workload, since frequent hint calls can add overhead.
Recommended configuration: base in memory, precise on disk
The workhorse layout keeps a very compact 3-bit RaBitQ base in memory for traversal and pushes
a higher-precision sq8 copy to disk for reorder, with use_reorder turned on so the
finalists are re-ranked against it:
{
"dtype": "float32",
"metric_type": "l2",
"dim": 128,
"index_param": {
"base_quantization_type": "rabitq",
"rabitq_bits_per_dim_base": 3,
"max_degree": 32,
"ef_construction": 400,
"use_reorder": true,
"precise_quantization_type": "sq8",
"precise_io_type": "async_io",
"precise_file_path": "/data/vsag/hgraph_precise.data"
}
}
Here rabitq_bits_per_dim_base selects the standard multi-bit RaBitQ base code (range [1, 8]);
leave rabitq_bits_per_dim_precise unset so the base stays a plain RaBitQ code rather than
switching to the x+y split variant.
Search is unchanged — set ef_search as usual and reorder transparently reads the precise
copy from disk:
{"hgraph": {"ef_search": 200}}
The per-query data flow: graph traversal reads only the in-memory base codes and graph
adjacency; disk I/O happens solely at the end, when reorder fetches the precise copy for
the small candidate set before returning the top-k. This is why a disk-backed HGraph adds
only a bounded number of reads per query rather than one read per hop.
Hardware and deployment
- Use NVMe SSDs. Disk-backed vector search is dominated by random-read latency; NVMe is
an order of magnitude better than SATA SSDs and essential for
async_io/mmap_io. async_iorequires Linux with libaio, which is enabled by default. The CMake optionENABLE_LIBAIOdefaults toON, and the Makefile passesVSAG_ENABLE_LIBAIO=ON; you only set these flags to turn libaio back on if a previous build disabled it. When libaio is absent (including on macOS),async_iologs a one-time warning and falls back tobuffer_io, so configs remain portable but lose asynchronous batching. For production throughput, build on Linux with libaio.uring_iorequires Linux with liburing and is opt-in. Configure with-DENABLE_LIBURING=ON; otherwise it falls back tobuffer_io. Benchmarkuring_ioandasync_iowith the target kernel, queue depth, and SSD rather than assuming one always wins.- Warm the page cache for
mmap_iocells after load (e.g. a sequential read of the file, or a warm-up query pass) so early queries do not pay cold-miss latency. - Plan file paths and lifecycle. Disk-backed cells write to the
*_file_pathyou supply; place them on a fast, dedicated volume with enough space for the precise copy, and clean up stale files when rebuilding. Serialize and load the index through the normal Serialization API — the backing files are managed with it.
Capacity planning
Approximate per-vector storage for the main quantizers (plus small per-vector metadata such as norms and errors):
| Representation | Bytes per vector | Typical placement |
|---|---|---|
fp32 (precise) | dim × 4 | Disk |
fp16 / bf16 | dim × 2 | Memory or disk |
sq8 | dim × 1 | Memory or disk |
sq4 | dim × 0.5 | Memory |
rabitq (b-bit) | dim × b / 8 | Memory |
Worked example for N = 1e9, dim = 128:
- 3-bit
rabitqbase in memory:1e9 × 128 × 3 / 8 ≈ 48 GBRAM. sq8precise on disk:1e9 × 128 × 1 ≈ 128 GBSSD.- A full
fp32precise instead (maximum reorder accuracy):1e9 × 128 × 4 ≈ 512 GBSSD. - Add the graph: roughly
N × max_degree × 4bytes for neighbor ids (memory ormmap_io).
This is how a billion-scale index that would need ~0.5 TB of RAM as pure fp32 collapses to
tens of GB of RAM plus an SSD.
Tuning and troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| Recall too low | Base quantization too coarse, or reorder off | Keep use_reorder: true; raise precise_quantization_type toward fp32; increase ef_search |
| Latency too high | Too many disk reads per query | Lower ef_search; keep graph/base in memory; ensure precise-only is on disk; use NVMe + async_io |
| Memory still too high | Base or graph too large | Move base to sq4 / pq / rabitq; push graph to mmap_io |
async_io seems synchronous | libaio not compiled in | Rebuild with VSAG_ENABLE_LIBAIO=ON on Linux; check for the fallback warning |
| Cold-start latency spikes (mmap) | Page cache not warm | Warm the file after load before serving traffic |
Treat these as starting points and validate with eval_performance against a
realistic query distribution; the Optimizer (Tune) can then
settle search-time parameters automatically.
See also
- HGraph — the flagship index and its full parameter table
- Quantization Overview — choosing a base/precise quantizer
- Best Practices — general production guidance
- Serialization — persisting and loading indexes
- Evaluation Tool and Optimizer (Tune) — measuring and tuning
Metric Semantics in VSAG
This page explains how VSAG treats l2, ip, and cosine in practice.
Warning: VSAG’s internal metric implementations are optimized for performance and consistency. Their behavior may differ from the textbook mathematical definitions, so use the semantics described here when comparing results or preparing ground truth.
VSAG keeps all search APIs in a “smaller is better” distance model. For that reason, several internal implementations reuse squared distances, normalized vectors, or cached norms to keep behavior fast and consistent across index types.
l2
- The distance is
L2Sqr(squared L2 distance). - Internally, many kernels work with
L2Sqrfor speed. - The squared form is used for performance; ranking remains consistent with L2 distance. Returned distance values and range-search thresholds are squared.
ip
- The distance is
1 - inner_product. - Larger inner product means smaller distance.
cosine
- The distance is
1 - cosine_similarity. - For performance, implementations may normalize vectors or store extra norm information so cosine can reuse IP-oriented kernels.
Cosine search generally assumes normalized vectors on the internal compute path. Because the implementation may normalize or cache norms, the returned value is intended to behave like a distance, but floating-point error can still push it slightly outside the ideal mathematical range.
Return Value Range
l2:0to+infinityip: unbounded; values may be negative wheninner_product > 1cosine: ideally0to2when cosine similarity is in[-1, 1], but small floating-point deviations are possible
Why this matters
- Dataset ground truth, query semantics, and index internals need to agree on the same metric family.
l2,ip, andcosineare not interchangeable after an index is built.- When comparing results across tools, check whether the tool uses a distance or a similarity convention.
Related Pages
Optimizer (Tune)
For graph-based indexes (HGraph), VSAG exposes the Tune interface, which automatically
adjusts runtime parameters based on a representative query set to get a better trade-off between
recall and latency. Internally this is the historical “ELP Optimizer”.
Basic Usage
#include <vsag/vsag.h>
auto index = vsag::Factory::CreateIndex("hgraph", build_params).value();
index->Build(base_dataset);
std::string tune_params = R"(
{
"queries_dataset": "path/or/inline/queries",
"target_recall": 0.95,
"top_k": 10
}
)";
auto ret = index->Tune(tune_params);
The second argument disable_future_tuning defaults to false, allowing repeated calls to keep
refining. Set it to true to freeze the parameters.
Relationship with the ELP Optimizer
Older literature (see Research Papers) refers to the “ELP
Optimizer”. Its implementation key is use_elp_optimizer, which now lives behind the unified
Tune API — users no longer need to flip it directly.
Supported Indexes
| Index type | Supports Tune |
|---|---|
| hgraph | yes |
| ivf / sindi / brute_force | no |
Example
examples/cpp/318_feature_tune.cpp walks through an end-to-end tuning flow:
- Create the index and
Build. - Call
Tunewith a representative query set. - Serialize the tuned index for production use.
Notes
- Tuning is sensitive to the query distribution — use samples that reflect real traffic.
- Tuned parameters are persisted together with the index metadata via
Serialize/Deserializeand remain in effect after deployment.
Reference Performance
This page is the entry point and explanation for official performance numbers. For concrete figures, use the latest GitHub releases and reproduce with the performance evaluation tool in your target environment.
Reference Hardware
Official benchmarks typically run on hardware in the following class (concrete SKUs vary per release):
- CPU: mainstream x86_64 server CPUs (with AVX2 / AVX-512)
- Memory: enough DDR4/DDR5 to cover the index plus OS page cache
- OS: Ubuntu 20.04 / 22.04 or CentOS 7 / 8
- Build:
make releaseby default; MKL is off by default (VSAG_ENABLE_INTEL_MKL=OFF). To enable it explicitly, useVSAG_ENABLE_INTEL_MKL=ON make release(or-DENABLE_INTEL_MKL=ONwhen invoking CMake directly)
Reference Datasets
Official comparisons use HDF5 datasets compatible with ann-benchmarks:
| Dataset | Dim | Metric | Size |
|---|---|---|---|
| SIFT-1M | 128 | L2 | 1,000,000 |
| GIST-1M | 960 | L2 | 1,000,000 |
| Deep-10M | 96 | L2 | 10,000,000 |
| Text-to-Image-1M | 200 | IP | 1,000,000 |
Key Metrics
- QPS (single- and multi-threaded)
- Average recall (Recall@k)
- P50 / P95 / P99 latency
- Peak memory and index size
- Build time
Reproduction
make release
./build-release/tools/eval/eval_performance --config tools/eval/eval_template.yaml
Compare the resulting JSON / Markdown output against the official figures to catch performance regressions or quantization degradations.
Contributing Numbers
Pull requests that extend this page with “results on additional hardware” sections are welcome. Please include:
- Detailed CPU / memory / disk information.
- The VSAG version (
git rev-parse HEAD). - The
eval_performanceoutput (JSON and Markdown are both helpful). - The exact build command and environment variables (e.g.
VSAG_ENABLE_INTEL_MKL).
Performance Evaluation Tool (eval_performance)
eval_performance is the command-line performance evaluation tool shipped with VSAG, under
tools/eval/. After building, the binary lives at build-release/tools/eval/eval_performance. It
is used to compare throughput, latency, and recall across different indexes or parameter
combinations.
Building
Tools are not built by default — enable them explicitly:
# via the project Makefile
VSAG_ENABLE_TOOLS=ON make release
# or: make dev
# or directly through CMake
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DENABLE_TOOLS=ON
cmake --build build-release -j
# Output: ./build-release/tools/eval/eval_performance
HDF5 must be installed on the system (Ubuntu: apt install libhdf5-dev; CentOS:
yum install hdf5-devel).
Two Modes
1. Command-line mode (quick, one-off experiments)
./build-release/tools/eval/eval_performance \
--datapath /tmp/sift-128-euclidean.hdf5 \
--index_name hgraph \
--type search \
--create_params '{"dim":128,"dtype":"float32","metric_type":"l2","index_param":{"base_quantization_type":"fp32","max_degree":32,"ef_construction":300}}' \
--search_params '{"hgraph":{"ef_search":60}}' \
--topk 10
Useful flags include --search_mode (knn / range / knn_filter / range_filter),
--search-query-count, --delete-index-after-search, and the various --disable_* switches that
turn off individual metrics. The reference template at tools/eval/eval_template.yaml shows the
complete YAML shape.
2. Config-file mode (batch comparisons)
The YAML file is passed directly as a positional argument (no --config flag):
./build-release/tools/eval/eval_performance my_eval.yaml
A reference template is available at tools/eval/eval_template.yaml. A single configuration can
define multiple named cases, plus an optional global section that holds shared settings such as
thread counts, exporters, and an embedded HTTP monitor.
A minimal example:
global:
num_threads_building: 8
num_threads_searching: 16
exporters:
print-directly:
to: stdout
format: table
save-to-file:
to: "file:///tmp/eval_results.json"
format: json
eval_case1:
datapath: /tmp/sift-128-euclidean.hdf5
type: search
index_name: hgraph
create_params: '{"dim":128,"dtype":"float32","metric_type":"l2","index_param":{"base_quantization_type":"fp32","max_degree":32,"ef_construction":300}}'
search_params: '{"hgraph":{"ef_search":60}}'
index_path: /tmp/vsag_eval/hgraph_fp32
topk: 10
Note: under global.exporters, each entry is a named exporter (a YAML map), not a list item.
Supported Dimensions
- Efficiency: QPS, TPS
- Quality: average recall and quantile recall (P0/P10/P50/P90…)
- Latency: average, P50/P95/P99
- Resource: peak memory usage
Search Measurement Semantics
- Latency is the elapsed wall duration of each measured
Index::KnnSearchcall, measured with the monotonicstd::chrono::steady_clock. - QPS is the number of successful queries divided by the wall duration, in seconds, of the measured search pass.
- Statistics extraction, recall calculation, and memory sampling run outside the performance pass and do not contribute to latency or QPS.
- Every measured query is included, including the first query on each worker. The tool does not apply an implicit warm-up; run any desired warm-up explicitly before the measured pass.
JSON results include measurement_method, measurement_sample_count,
measurement_successful_query_count, and measurement_duration(s) so the measurement population
and method can be audited.
Results produced by older versions that measured intervals between monitor callbacks are not directly comparable with results that use these semantics.
Search Modes
search_mode accepts knn, range, knn_filter, and range_filter.
Output Formats and Destinations
Each exporter combines a format with a to destination.
- Formats:
table(or its aliastext),json,line_protocol(for InfluxDB). - Destinations:
stdout— print to standard output.file://<path>— write (overwrite) to a file.influxdb://<host>:<port>/<path>?<query>— POST to an InfluxDB v2 endpoint. Useformat: line_protocoland pass an authentication token viavars.token(the value must include theTokenprefix, e.g.Token <your-influxdb-token>).
If no exporter is configured, results are printed to stdout in table format by default.
HTTP Monitor (optional)
When configured, the tool starts an embedded HTTP server for the duration of a batch run and exposes live progress (current case, total cases, completion %) plus the latest metrics. This is helpful for long-running evaluations.
global:
http_server:
enabled: true
port: 8080
Datasets
Any HDF5 dataset from ann-benchmarks
(e.g. sift-128-euclidean.hdf5, gist-960-euclidean.hdf5) works out of the box.
References
- Source:
tools/eval/ - Local tool entry point:
tools/eval/README.md - Reference numbers on standard hardware: Reference Performance.
AutoTune Tool (autotune)
autotune automates the VSAG build-and-search parameter sweep. AutoTune V1 evaluates real VSAG
indexes through the shared eval core, filters trials by constraints, and selects the best feasible
trial for one KNN workload.
The CLI JSON request and shared report schema are defined in the AutoTune V1 CLI JSON contract.
V1 builds and tunes HGraph and IVF on dense float32 data. It also tunes search parameters for an existing HGraph, IVF, or Pyramid index. The CLI reads HDF5; the C++ API accepts in-memory datasets and indexes. The C++ API is experimental and build-tree-only; it is not installed with the VSAG library.
Building
cmake -S . -B build-release \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_TOOLS=ON
cmake --build build-release --target autotune -j
The executable is build-release/tools/autotune/autotune.
A runnable request template is available at the SIFT request template.
It expects the dataset at /tmp/sift-128-euclidean.hdf5; edit data_path when necessary.
C++ API
The CLI and C++ entry points share candidate generation, trial planning, evaluation, constraint filtering, and result selection. Only their input adapters differ:
- the CLI loads JSON and HDF5;
TuneIndexaccepts in-memory datasets and jointly tunes build and search parameters;TuneSearchaccepts an already built or loadedIndexPtrand tunes search parameters only.
Consumers that link the build-tree vsag::autotune target can run the complete tune, select, and
load loop directly:
vsag::autotune::IndexRequest request;
request.base = base;
request.metric_type = vsag::METRIC_L2;
request.workload = {queries, ground_truth, 10, 48};
request.index_spaces = {
{"hgraph", create_candidate_space, search_candidate_space},
};
request.constraints = {
{vsag::autotune::Metric::RECALL_AT_K, 0.95},
{vsag::autotune::Metric::INDEX_MEMORY_MB, 8192.0},
};
request.objective = vsag::autotune::Metric::LATENCY_AVG_MS;
auto result = vsag::autotune::TuneIndex(request);
if (result.has_value() && result->status == vsag::autotune::TuneStatus::SUCCESS) {
auto neighbors =
result->index->KnnSearch(query, 10, result->search_parameters).value();
}
base and queries are dense float32 datasets. base IDs must be present and unique. The
ground-truth dataset stores query_count rows, uses Dim() as ground-truth K, and contains
flattened IDs from the base ID space. It is required when RECALL_AT_K is a constraint or the
objective. Dataset buffers must remain valid until the synchronous call returns.
IndexSpace::create_parameter_space and search_parameter_space remain JSON strings because each
index owns its native parameter schema. An empty index_spaces vector selects the built-in HGraph
and IVF spaces; otherwise arrays and $range expressions use the same semantics as the CLI
contract.
The result contains the loaded index, index_name, complete concrete create_parameters,
validated search_parameters, metrics, artifact path, and the complete report. metrics and
report are JsonType objects. The typed API returns the report in memory and never writes a
report file. The recommended artifact is retained, so another process can recreate an empty index
from index_name + create_parameters and deserialize that file. Unselected intermediate
artifacts are removed by default. The caller removes the recommended artifact when it is no longer
needed.
If no candidate satisfies every constraint, the completed call has
status=TuneStatus::NO_FEASIBLE_CANDIDATE and a structured best_effort; recommendation fields
such as index are not valid. Invalid requests and execution failures still return an error.
See
examples/cpp/326_feature_create_index_with_constraints.cpp for a complete
example. Enable both tools and examples to build it:
cmake -S . -B build-release \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_TOOLS=ON \
-DENABLE_EXAMPLES=ON
cmake --build build-release --target 326_feature_create_index_with_constraints -j
Request
Save a request such as the following as request.json. dim, dtype, and metric_type are read
from the dataset and need not be repeated. If provided, they must match the dataset.
{
"version": 1,
"data_path": "/tmp/sift-128-euclidean.hdf5",
"indexes": [
{
"name": "hgraph",
"create_params": {
"index_param": {
"base_quantization_type": ["fp32", "sq8_uniform"],
"max_degree": [16, 32],
"ef_construction": 200
}
},
"search_params": {
"hgraph": {
"ef_search": {
"$range": {"start": 40, "stop": 1000}
}
}
}
}
],
"workload": {
"top_k": 10,
"concurrency": 1
},
"constraints": {
"recall_at_k": 0.95,
"index_memory_mb": 8192
},
"objective": {
"metric": "latency_avg_ms"
},
"tuning_config": {
"workspace_path": "/tmp/vsag_autotune",
"keep_intermediate": false,
"max_trials": 1000
}
}
Run it with:
./build-release/tools/autotune/autotune request.json
Every parameter leaf may be a scalar, an array, or a $range with start, stop, and step.
Explicit values are preserved. HGraph ef_search additionally accepts an integer $range without
step; AutoTune then doubles from start until it brackets a passing value and binary-searches
that interval for the smallest passing value. This assumes that recall does not decrease as
ef_search increases for the fixed configuration. Every evaluated point uses all queries. For
omitted tuning fields, V1 supplies a small built-in candidate set for HGraph or IVF. If indexes
is omitted, both indexes are evaluated.
Candidates with identical concrete create parameters share one build. Each concrete search
parameter set remains a separate trial. An adaptive ef_search range records only the concrete
points that were actually evaluated.
Existing Index
Set index_path and provide exactly one index specification to tune search parameters without
rebuilding. Its create_params must be the concrete parameters used to create the serialized
index. AutoTune does not expand create_params in this mode, so native index fields that happen to
be arrays remain unchanged.
Programmatic callers use a separate SearchRequest and call TuneSearch:
vsag::autotune::SearchRequest request;
request.index = existing_index;
request.workload = {queries, ground_truth, 10, 48};
request.parameter_space = R"({"hgraph":{"ef_search":[40,80,120]}})";
request.constraints = {{vsag::autotune::Metric::RECALL_AT_K, 0.95}};
request.objective = vsag::autotune::Metric::LATENCY_AVG_MS;
auto result = vsag::autotune::TuneSearch(request);
if (result.has_value() && result->status == vsag::autotune::TuneStatus::SUCCESS) {
auto neighbors = existing_index->KnnSearch(query, 10, result->parameters).value();
}
This avoids serialization and file I/O and reuses the caller’s index for all search trials.
AutoTune derives the index type and element count from IndexPtr. Search tuning therefore needs
queries but not the base dataset or metric type. When recall is requested, it also needs ground
truth and defines per-query recall_at_k as the intersection of the returned and ground-truth IDs
in their first top_k entries, divided by top_k; the reported value is the average over all
queries. Ground truth is optional when recall is neither a constraint nor the objective.
The CLI’s index_path field remains an offline adapter: it uses the concrete create parameters to
create and deserialize an index, then enters the same search-only flow.
Pyramid is supported in this search-only mode. The HDF5 CLI adapter does not provide query paths,
so it can evaluate only Pyramid’s native default/root search. Typed requests take paths directly
from workload.queries->GetPaths(): when paths are present, each query is evaluated against its
path; when absent, Pyramid applies its native root-search behavior. V1 supports only the default
unnamed hierarchy. To select different ef_search values for different paths, run one typed
AutoTune request per representative path workload, with the matching ground truth. V1 does not
aggregate path-specific recommendations into one result.
A complete example is available at
examples/cpp/327_feature_autotune_existing_index.cpp.
For Pyramid path tuning, see
examples/cpp/328_feature_autotune_existing_pyramid.cpp.
It tunes the same Pyramid index separately for 512-vector and 4096-vector leaf subgraphs under the
same recall target, illustrating why different paths may need different ef_search values.
Metrics
The following metrics can be used as constraints or as the objective:
| Metric | Constraint comparison |
|---|---|
recall_at_k, qps | At least the requested value |
latency_avg_ms, latency_p99_ms | At most the requested value |
index_memory_mb, index_size_mb | At most the requested value |
build_seconds, search_seconds, build_and_search_seconds | At most the requested value |
build_seconds, index_size_mb, and build_and_search_seconds are unavailable in
existing-index mode. index_memory_mb may be a constraint, but not the objective, because it is
constant across search candidates for the same existing index and cannot rank them. The objective
direction is inferred from the metric, so no direction field is needed.
AutoTune uses a dedicated latency/QPS pass and a separate recall/statistics pass, so a logical
query runs more than once per trial. search_seconds covers both in-memory passes and metric
collection, but excludes index deserialization. Candidates run in a fixed order without warm-up
or randomization. Measure latency and QPS on a representative, controlled machine and repeat runs
when these metrics determine the recommendation.
Reading the Output
Standard output is a short summary ordered for copying:
recommendation: the selected concrete parameters, metrics, and trial evidence. Build-and-search results also include create parameters and artifact/build evidence; search-only results do not.best_effort: present only when no trial satisfies every constraint.failure: present when the request or all evaluations fail.report_path: the full reproducible report.
The full report also contains every build and trial, constraint violations, the normalized request,
and structured per-stage failures. In search-only mode builds is empty and trials contain no
artifact or build fields. For both the CLI and typed TuneIndex, keep_intermediate=false retains
only the recommended generated index and removes unselected artifacts. Setting it to true
retains every generated index.
After initial request validation succeeds, the CLI and RunAutoTune persist the full report:
output.result_path overrides the path, otherwise a generated path under the workspace is used.
Early validation and request-file failures do not write a report. Typed TuneIndex and
TuneSearch never write a report file; callers receive the complete JsonType report in the
result.
V1 Boundaries
V1 evaluates one KNN workload and performs a full sweep except for the supported HGraph
ef_search adaptive search. It does not provide filtered or range-search workloads, adaptive query
sampling, cross-request build cache, or model-based candidate generation.
AutoTune V1 CLI JSON Input and Output Contract
This page defines the offline JSON/HDF5 adapter used by the autotune command-line tool. The
experimental, build-tree-only C++ API instead accepts typed IndexRequest and SearchRequest
objects. It is not installed with the VSAG library. All entry points use the same normalized
request and tuning engine after input conversion. AutoTune V1 supports one full-dataset KNN
workload on dense float32 data, with HGraph and IVF as the supported build-and-search index types.
Unknown fields are rejected in the request, index specification, workload, objective,
tuning_config, and output objects. Native fields inside create_params and search_params are
passed to the concrete VSAG index and are validated when that index is created or searched.
Request Object
| Field | Type | Required | Meaning |
|---|---|---|---|
version | integer | Yes | Must be 1. |
data_path | string | Yes | Readable HDF5 evaluation dataset. |
index_path | string | No | Existing serialized index for search-only tuning. |
indexes | array | No | Index specifications. Defaults to HGraph and IVF in normal mode. |
workload | object | Yes | The KNN workload being optimized. |
constraints | object | Yes | Non-empty metric-to-threshold map. |
objective | object | Yes | Metric used to rank feasible candidates. |
tuning_config | object | No | Workspace, artifact retention, and trial limit. |
output | object | No | Report path and raw-eval control. |
Dataset Rules
data_path must identify a readable regular file in the HDF5 format accepted by the VSAG
evaluation tool. V1 requires:
- dense vectors;
- float32 base and query vectors;
- at least one base vector, query vector, and ground-truth neighbor;
workload.top_kno greater than the ground-truth width when recall is requested;- a distance attribute that maps to
l2,ip, orcosine.
AutoTune reads dim, dtype, and metric_type from the dataset and injects them into every
concrete create_params. A request may repeat one of these fields only when it exactly matches the
dataset.
Index Specifications
Each indexes[] item has the following shape:
{
"name": "hgraph",
"create_params": {
"index_param": {
"max_degree": [16, 32]
}
},
"search_params": {
"hgraph": {
"ef_search": {"$range": {"start": 40, "stop": 1000}}
}
}
}
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | Yes | hgraph, ivf, or search-only pyramid, case-insensitive. |
create_params | object | No | VSAG create parameters or candidate expressions. |
search_params | object | No | Search parameters, keyed by the same index name. |
When indexes is absent in normal build-and-search mode, AutoTune evaluates both HGraph and IVF.
When index_path is present, indexes is required and must contain exactly one item.
Pyramid is accepted only with an existing in-memory or serialized index.
Candidate Expressions
Every leaf in create_params and search_params can be written as:
- a scalar, which fixes that value;
- a non-empty array, which provides discrete candidate values;
- an inclusive
$rangecontaining exactlystart,stop, and non-zerostep.
Examples:
{
"fixed": 32,
"choices": [16, 32, 48],
"integer_range": {"$range": {"start": 40, "stop": 120, "step": 40}},
"float_range": {"$range": {"start": 0.1, "stop": 0.3, "step": 0.1}}
}
HGraph ef_search has one additional form:
{"$range": {"start": 40, "stop": 1000}}
The bounds must be positive integers with start <= stop. This form requires a recall_at_k
constraint and an objective of latency_avg_ms, latency_p99_ms, qps, search_seconds, or
build_and_search_seconds. It cannot be combined with timeout_ms or hops_limit. For every
fixed combination of the other parameters, AutoTune evaluates concrete ef_search values and
doubles from start, capped at stop, until recall satisfies the constraint. It then
binary-searches the last failing/passing interval for the smallest passing value. Branching uses
recall only; latency and QPS remain measured metrics used by final constraint evaluation and
selection. This strategy assumes that recall does not decrease as ef_search increases while all
other parameters and the workload remain fixed.
Every probe evaluates the full query workload. If a probe fails to execute or does not produce recall, AutoTune stops that adaptive range because it cannot choose the next interval safely.
V1 treats every JSON array and stepped range in these parameter objects as candidate choices. It computes their full Cartesian product and removes identical complete candidates. A no-step range is not accepted for other parameters.
The request is rejected when the planned worst-case evaluation count exceeds
tuning_config.max_trials. A normal concrete candidate costs one trial. An adaptive ef_search
range costs one when start == stop. Otherwise, the planner reserves the largest probe count
through any possible passing bound plus the binary-search cost of that bound’s preceding interval.
For example, 40..1000 reserves 15 trials in the worst case.
Built-in Missing-Field Candidates
Explicit user fields always win. The following values are supplied only when the corresponding field is absent:
| Index | Missing field | V1 candidates |
|---|---|---|
| HGraph | base_quantization_type | fp32, sq8_uniform |
| HGraph | max_degree | 16, 32 |
| HGraph | ef_construction | 100, 200 |
| HGraph | ef_search | Three values derived from top_k, with floors 40, 80, 120 |
| Pyramid, search-only | ef_search | Three values derived from top_k, with floors 40, 80, 120 |
| IVF | base_quantization_type | fp32, sq8_uniform |
| IVF | buckets_count | Up to 1024 and 2048, capped by base-vector count |
| IVF, build-and-search | scan_buckets_count | Up to four values derived from concrete buckets_count |
| IVF, search-only | scan_buckets_count | 1, 4, 16, 64 |
The exact HGraph and Pyramid search values are max(40, top_k), max(80, 2 * top_k), and
max(120, 4 * top_k), with duplicates removed. Build-and-search IVF candidates never exceed the
concrete bucket count. AutoTune cannot infer an existing IVF index’s bucket count, so search-only
mode uses the conservative generic values above. Values rejected by the loaded index become
failed trials; callers can avoid them by providing an explicit scan_buckets_count space.
Candidates with the same normalized index name and identical concrete create_params belong to
one build group. AutoTune builds that group once, serializes it once as evidence, and evaluates
every associated search candidate against the same in-memory index instance.
Existing-Index Mode
With index_path:
- the path must identify a readable regular file;
- exactly one index specification is required;
create_params.index_paramis required;create_paramsis treated as one concrete native index configuration and is not expanded;- AutoTune does not generate missing create candidates or invoke build;
- missing search fields may still receive built-in search candidates;
build_seconds,index_size_mb, andbuild_and_search_secondscannot be constraints or objectives;index_memory_mbcan be a constraint but not the objective;- the caller-owned index is never deleted.
The concrete create parameters are needed to instantiate the correct VSAG index before deserialization. AutoTune does not infer them from the serialized file in V1.
For a typed SearchRequest, the index is already instantiated and loaded. AutoTune derives its
index type and element count from IndexPtr; the request supplies only the index, workload, search
parameter_space, constraints, objective, and config. It needs neither create parameters nor the
base dataset or metric type.
The HDF5 adapter does not provide Pyramid query paths, so the CLI can evaluate only Pyramid’s
native default/root search. Path-specific Pyramid tuning requires a typed SearchRequest whose
query dataset supplies Dataset::Paths(). V1 forwards only the default unnamed hierarchy; named
Pyramid hierarchies are not supported by AutoTune.
Workload
{
"workload": {
"top_k": 10,
"concurrency": 48
}
}
| Field | Type | Required | Default | Validation |
|---|---|---|---|---|
top_k | positive integer | Yes | — | No greater than base count, and no greater than ground-truth width when recall is requested; representable by native int. |
concurrency | positive integer | No | 1 | Maximum 200. |
V1 always evaluates the complete query set in the HDF5 file and supports only KNN search. The
benchmark machine, system load, and runtime environment are part of the measured workload.
Latency and QPS are not portable across different machine specifications or load conditions.
For a typed SearchRequest, top_k is checked against IndexPtr::GetNumElements() instead of a
base dataset. Ground truth may be null unless recall is a constraint or the objective.
Constraints and Objective
constraints maps metric names directly to thresholds. objective.metric names one metric. The
comparison and objective direction are inferred from the metric and cannot be overridden.
{
"constraints": {
"recall_at_k": 0.95,
"latency_p99_ms": 2.0,
"index_memory_mb": 8192
},
"objective": {
"metric": "latency_avg_ms"
}
}
| Metric | Constraint | Objective direction | Existing-index use |
|---|---|---|---|
recall_at_k | actual ≥ threshold | Maximize | Yes |
qps | actual ≥ threshold | Maximize | Yes |
latency_avg_ms | actual ≤ threshold | Minimize | Yes |
latency_p99_ms | actual ≤ threshold | Minimize | Yes |
index_memory_mb | actual ≤ threshold | Minimize | Constraint only |
index_size_mb | actual ≤ threshold | Minimize | No |
build_seconds | actual ≤ threshold | Minimize | No |
search_seconds | actual ≤ threshold | Minimize | Yes |
build_and_search_seconds | actual ≤ threshold | Minimize | No |
Thresholds must be finite and non-negative. recall_at_k must also be at most 1.0.
Metric meanings in V1:
- For each query,
recall_at_kis the size of the intersection between the firsttop_kreturned IDs and ground-truth IDs, divided bytop_k; the reported metric is the average over all queries. It needs ground truth but does not need base vectors or a metric type. build_secondsmeasures the indexBuildoperation reported by the evaluation tool.- It excludes index serialization, dataset loading, and candidate orchestration.
search_secondsmeasures the wall time of a complete in-memory search evaluation trial. It excludes index deserialization, but includes all search passes and metric collection. Use latency or QPS for serving-performance goals.build_and_search_secondsis the sum of those two metrics for a build-and-search trial.index_memory_mbcomes from the loaded index’s positiveGetMemoryUsage()result. When an index reports zero, AutoTune treats the metric as unavailable instead of as zero usage; a corresponding constraint is recorded as a missing-metric violation.index_size_mbis the generated serialized artifact’s file size and is unavailable in search-only mode.- AutoTune uses a dedicated latency/QPS pass and a separate recall/statistics pass. Latency wraps
each
KnnSearchcall withsteady_clock; QPS is successful queries divided by the wall time of the concurrent performance pass. The same logical query is therefore executed more than once per trial.
search_seconds remains evaluation cost because it includes both passes and monitor work; it is
not the time for one logical production query batch. Candidates run in a fixed order against a
reused index, without a warm-up or randomized ordering. Cache state and unrelated system load can
therefore affect performance metrics; benchmark in a representative, controlled environment and
repeat runs when latency or QPS drives selection.
Tuning and Output Configuration
{
"tuning_config": {
"workspace_path": "/tmp/vsag_autotune",
"keep_intermediate": false,
"max_trials": 1000
},
"output": {
"result_path": "/tmp/vsag_autotune/report.json",
"include_raw_eval": false
}
}
| Field | Default | Meaning |
|---|---|---|
tuning_config.workspace_path | /tmp/vsag_autotune | Run artifacts and CLI default reports. |
tuning_config.keep_intermediate | false | Keep all generated indexes. |
tuning_config.max_trials | 1000 | Maximum planned worst-case trials; hard maximum 100000. |
output.result_path | <workspace>/run-<id>.json | CLI full report path. |
output.include_raw_eval | false | Include native eval JSON in build and trial records. |
output.result_path cannot alias data_path or index_path. After initial validation succeeds,
the offline JSON/CLI entry writes the full report. An explicit output.result_path overrides its
location; otherwise AutoTune generates a report path under workspace_path. Early validation and
request-file failures do not write one.
Typed TuneIndex and TuneSearch do not accept a report path and never write report files. Their
result objects return the complete report as JsonType. For both the CLI and typed TuneIndex,
keep_intermediate=false retains only the recommended generated index and removes unselected
artifacts; true retains every generated index. When there is no recommendation, the default is
to remove all generated indexes. Because TuneSearch creates no artifacts,
workspace_path and keep_intermediate have no effect on it.
Full Report
A completed evaluation returns this top-level shape. The CLI also writes it to disk:
{
"version": 1,
"status": "success",
"recommendation": {},
"best_effort": null,
"builds": [],
"trials": [],
"request": {},
"elapsed_seconds": 84.2,
"report_path": "/tmp/vsag_autotune/run-....json"
}
| Field | Meaning |
|---|---|
version | Report contract version, always 1. |
status | success, no_feasible_candidate, or failed. |
recommendation | Best feasible trial, otherwise null. |
best_effort | Closest successful trial when constraints are infeasible, otherwise null. |
builds | One record per concrete generated build group; empty in search-only mode. |
trials | One record per executed concrete search candidate. |
request | Effective normalized request used by the tuning engine. |
elapsed_seconds | AutoTune wall time through artifact cleanup; excludes final report writing. |
report_path | Persisted full report path; CLI/JSON adapter only. |
failure | Present when the overall run failed. |
Early validation failures do not have builds, trials, or report_path, and no report file is
written. A CLI run in which all candidate evaluations fail does contain the attempted build and
trial records and is written when the report path itself is usable. Typed API reports never
contain report_path.
The normalized request contains derived dataset metadata, workload defaults, constraints,
objective, config, and output fields. Build-and-search mode adds index_spaces; search-only mode
adds index_name and parameter_space. CLI reports also retain the offline data_path and, when
present, index_path as top-level fields inside this normalized object.
Status Semantics
success: at least one successful trial satisfies every constraint;recommendationis set.no_feasible_candidate: successful trials exist, but none satisfies every constraint;best_effortis set for explanation and is not a valid recommendation. Typed calls return a value withTuneStatus::NO_FEASIBLE_CANDIDATE, not an error.failed: the request is invalid, execution/reporting fails, or no trial can be evaluated successfully with the objective metric.
Recommendation and Best Effort
In build-and-search mode, recommendation contains:
{
"index_name": "hgraph",
"create_params": {},
"search_params": {},
"workload": {"top_k": 10, "concurrency": 1},
"metrics": {},
"artifacts": {},
"evidence": {"build_id": "build-0", "trial_id": "trial-0"}
}
In search-only mode, create_params, artifacts, and evidence.build_id are omitted; the
recommendation contains the concrete search parameters, workload, metrics, and
evidence.trial_id. best_effort has the same mode-specific fields plus
constraint_evaluation. It is selected first by the number of violated or missing constraints,
then by normalized violation magnitude. It is only explanatory.
Build Records
builds is empty in search-only mode. In build-and-search mode, each builds[] item contains:
| Field | Meaning |
|---|---|
build_id | Stable ID referenced by trials. |
index_name | Concrete index type. |
create_params | Concrete create parameters. |
status | success or failed. |
metrics | Available build-shared metrics. |
artifacts | source, index_path, use_existing_index, and retained. |
failure | Structured failure or null. |
elapsed_seconds | Build-group preparation time. |
raw_eval_result | Present only when requested and a native build eval ran. |
Trial Records
Each trials[] item contains:
| Field | Meaning |
|---|---|
trial_id | Stable trial ID. |
build_id | Associated build group; build-and-search mode only. |
index_name | Concrete index type. |
create_params | Concrete create parameters; build-and-search mode only. |
search_params | Concrete search parameters. |
status | success or failed. |
metrics | Available metrics used by constraint evaluation and selection. |
constraint_evaluation | satisfied plus a violations array. |
artifacts | Artifact evidence copied from the build group; build-and-search mode only. |
failure | Structured failure or null. |
elapsed_seconds | Search trial wall time. |
raw_eval_result | Present only when requested and native search eval succeeded. |
For an adaptive HGraph ef_search range, the report contains one trial for each point actually
evaluated. Every recorded search_params value is concrete; the $range expression remains only
in the normalized request. Every recorded trial evaluates the full query workload.
A constraint violation contains metric, comparison, expected, and actual. actual is
null when the required metric is missing or non-finite.
Search trials in one build group reuse the same loaded index instance. A generated index is built once and serialized once. In search-only mode the caller’s index, or the index deserialized by the CLI adapter, is reused directly for every trial.
Artifact Semantics
Artifact fields appear only in build-and-search records in V1. artifacts.source is generated,
and artifacts.index_path is evidence of where the evaluated index was stored, not a promise that
the path still exists. Check artifacts.retained:
true: this is the selected artifact, or retention of all generated indexes was requested;false: AutoTune planned to remove or already removed the generated artifact.
Structured Failures
Failures use one common object:
{
"stage": "validation",
"code": "invalid_request",
"message": "request.workload.top_k is required"
}
Typical stages are cli, validation, candidate_generation, build, search, evaluation,
selection, and report. Per-build and per-trial failures remain in their corresponding records
so other candidates can continue.
| Stage | Code | Meaning |
|---|---|---|
cli | request_file_error | The CLI could not read or parse its request file. |
validation | invalid_request | Request validation failed. |
candidate_generation | invalid_request | A candidate expression or count was invalid. |
build | build_evaluation_failed | One build group failed. |
search | build_failed | Search was skipped because its build failed. |
search | search_evaluation_failed | One search trial failed. |
evaluation | all_trials_failed | Every candidate trial failed. |
selection | objective_metric_unavailable | Trials succeeded, but none produced the objective metric. |
evaluation, report | execution_failed | Top-level execution or report writing failed. |
CLI Summary
The CLI prints a compact subset of the full report in this order:
recommendation, when present;best_effort, when present;failure, when present;status;elapsed_seconds;report_path, when present;version.
Null result branches and the detailed build/trial arrays are omitted from standard output. Read
report_path for complete evidence.
The CLI exits with code 1 for status=failed and command-line/request-file errors. It exits with
code 0 for both success and no_feasible_candidate; callers must inspect status rather than
using only the exit code to decide whether a recommendation exists.
Build-Tree C++ Entry Point
The experimental optional vsag::autotune CMake target exposes the declarations in
tools/autotune/autotune.h. The target and header are build-tree-only and are not installed:
tl::expected<IndexResult, Error> TuneIndex(const IndexRequest& request);
tl::expected<SearchResult, Error> TuneSearch(const SearchRequest& request);
JsonType RunAutoTune(const JsonType& request); // CLI adapter
The JSON entry point is an offline adapter: it loads data_path, creates or deserializes an index
when index_path is present, and constructs the same internal request used by the typed entry
points. The target and header are intentionally not installed in V1. Calls inside one process are
serialized because the underlying evaluation path configures process-global OpenMP state.
TuneIndex jointly tunes build and search parameters:
IndexRequest request;
request.base = base;
request.metric_type = METRIC_L2;
request.workload = {queries, ground_truth, 10, 48};
request.index_spaces = {{"hgraph", create_candidate_space, search_candidate_space}};
request.constraints = {{Metric::RECALL_AT_K, 0.95}};
request.objective = Metric::LATENCY_AVG_MS;
auto result = TuneIndex(request);
It returns a loaded, query-ready selected index together with concrete create and search
parameters. metrics and the complete report are returned as JsonType; typed calls never
persist the report. TuneSearch tunes an already built or loaded index:
SearchRequest request;
request.index = existing_index;
request.workload = {queries, ground_truth, 10, 48};
request.parameter_space = search_candidate_space;
request.constraints = {{Metric::RECALL_AT_K, 0.95}};
request.objective = Metric::LATENCY_AVG_MS;
auto result = TuneSearch(request);
AutoTune derives the type and element count of existing_index. TuneSearch therefore does not
need base vectors or a metric type. Ground truth is required only when recall is a constraint or
the objective. Build-time metrics and index_size_mb are unavailable to TuneSearch.
index_memory_mb may be a constraint but cannot be the objective because it does not vary across
search candidates.
Both typed calls return tl::unexpected<Error> only for invalid requests and execution failures.
If evaluation completes but no candidate satisfies every constraint, they return a result with
status=TuneStatus::NO_FEASIBLE_CANDIDATE and structured best_effort; recommendation fields are
invalid in that state.
HDF5 Dataset Format
VSAG’s evaluation and benchmark tooling (most notably
eval_performance) consumes datasets in the HDF5 format used by
ann-benchmarks. This page
documents the exact layout VSAG expects so you can prepare custom datasets or
debug failing evaluations.
The dataset layout described below is the dense layout (selected by the global
attribute type="dense", or by omitting the attribute). For sparse datasets
(type="sparse"), /train and /test are flat INT8 byte streams of shape (X,)
produced by VSAG’s sparse-vector serialization (decoded by parse_sparse_vectors in
tools/eval/eval_dataset.cpp); all other datasets and attributes below still apply.
Mandatory Datasets
/train (base vectors)
- Type:
INT8orFLOAT32 - Shape:
(N, D)N— number of base vectors (number_of_base)D— feature dimensionality (dim)
- Notes: the element type is inferred from HDF5:
H5T_INTEGER(1-byte) →INT8H5T_FLOAT(4-byte) →FLOAT32
/test (query vectors)
- Type: must match
/train - Shape:
(Q, D)Q— number of query vectors (number_of_query)D— must equal/train’sD
/neighbors (ground-truth indices)
- Type:
INT64 - Shape:
(Q, K)K— number of ground-truth neighbors per query
- Content: precomputed top-
Kindices into/train.
/distances (ground-truth distances)
- Type:
FLOAT32 - Shape:
(Q, K)(identical to/neighbors) - Note: each entry must align with the same position in
/neighbors.
Global Attributes
type (vector type)
- Type: ASCII string
- Required: no (defaults to
"dense"if the attribute is missing) - Allowed values:
"dense"— dense vectors stored as standard matrices in/trainand/test"sparse"— sparse vectors stored in the serialized format produced by VSAG’s sparse-vector helpers
distance (metric definition)
The evaluation tool treats distance values as distances (smaller is better) when
comparing against the ground truth in /distances. Prepare ground-truth distances using the
formulas below.
- Type: ASCII string
- Required: yes
- Allowed values for dense vectors:
"euclidean"— L2 distance, computed assqrt(L2Sqr)"ip"— inner-product distance (1 - inner_product); data type auto-detected"angular"— cosine distance (1 - cosine_similarity)
- Allowed values for sparse vectors:
"ip"— sparse inner-product distance (1 - sparse_inner_product); other metrics are not supported for sparse vectors
- Allowed values for multi-vector:
- Same as dense vectors (
"euclidean","ip","angular"); multi-vector uses the same per-sub-vector distance function as dense vectors
- Same as dense vectors (
Optional Datasets
/train_labels and /test_labels
- Type:
INT64 - Shapes:
/train_labels:(N,)/test_labels:(Q,)
- Requirement: if labels are present, both datasets must exist.
/valid_ratios
- Type:
FLOAT32 - Shape:
(L,) - Usage: stores per-class validation ratios. The evaluation tool indexes this array
with the raw label value (
valid_ratio_[label], seetools/eval/eval_dataset.h:71), so labels must be non-negative integers andLmust be strictly greater than the maximum label value (typicallyL > max(label)with valid indices0..L-1). It is the dataset author’s responsibility to keep the array large enough to cover every label that appears in/train_labelsand/test_labels.
Multi-Vector Datasets
When type="multi_vector", the file uses a flat-expanded layout where each document’s
sub-vectors are concatenated into a single 2D matrix, and a companion vector_counts
array records how many sub-vectors belong to each document.
Additional Global Attribute
| Attribute | Type | Required | Description |
|---|---|---|---|
multi_vector_dim | INT64 | yes | Sub-vector dimensionality (number of floats per sub-vector) |
Additional Datasets
| Dataset | Shape | Type | Description |
|---|---|---|---|
/train_multi_vectors | (sum_counts_train, D) | FLOAT32 | All training sub-vectors, flat-concatenated row by row |
/test_multi_vectors | (sum_counts_test, D) | FLOAT32 | All query sub-vectors, flat-concatenated row by row |
/train_vector_counts | (N,) | UINT32 | Number of sub-vectors per training document |
/test_vector_counts | (Q,) | UINT32 | Number of sub-vectors per query document |
Dequalsmulti_vector_dim.sum_counts_trainis the sum of all values in/train_vector_counts, andsum_counts_testis the sum of all values in/test_vector_counts.
When type="multi_vector", the standard /train and /test datasets are not
required — the document count (N, Q) is derived from /train_vector_counts
and /test_vector_counts instead. All other datasets (/neighbors, /distances,
optional labels) remain mandatory.
The evaluation tool reconstructs one vsag::MultiVector per document from the
flat array plus the counts, then passes the full array to
vsag::Dataset::MultiVectors(), VectorCounts(), and MultiVectorDim().
Structural Requirements
-
Dimensional compatibility
train_shape[1] == test_shape[1](sameD)neighbors.shape == distances.shape
-
Type mapping
HDF5 Specification Internal Type Size Used In H5T_INTEGER(size=1)INT81 byte /train,/testH5T_FLOAT(size=4)FLOAT324 bytes /train,/test,/distances,/valid_ratiosH5T_INTEGER(size=8)INT648 bytes /neighbors,/train_labels,/test_labels -
Memory organization
- Row-major storage for all matrices.
- Feature vectors stored contiguously:
/traintotal size =N × D × element_size(1 or 4 bytes per element).
Sparse layout
When the global attribute type equals "sparse", /train and /test do not follow
the (N, D) dense matrix layout. They are instead stored as flat INT8
(H5T_INTEGER, size 1) datasets whose payload is a raw byte stream of packed sparse
vectors. Calling f["/train"].shape from h5py returns (X,) where X is the total
number of bytes; the int8 storage class is a transport detail only — the bytes are
not int8 vector elements.
/train, /test (sparse byte stream)
-
HDF5 type:
H5T_INTEGER, size 1 (INT8) -
HDF5 shape:
(X,), whereXis the total byte-stream length (sum of all per-vector record sizes) -
Endianness: little-endian
-
Content: a contiguous sequence of records, one per sparse vector, in order. Each record has the following fields, concatenated with no padding or separators:
Field Type Size Description lenuint324 bytes Number of non-zero entries in the vector ids[len]uint32[]4 * lenbytesFeature indices (column ids) vals[len]float32[]4 * lenbytesValues associated with idsA
len == 0record is allowed and occupies only the 4-byte length field. -
Key ordering: on load, the eval tool sorts each vector’s
idsin ascending order (and reordersvalsaccordingly). Writers may emit unordered keys, but readers should not rely on that.
/train_offsets, /test_offsets (random-access index, optional)
These two datasets store the per-record byte offsets into the matching
/train and /test sparse byte streams so that the i-th sparse vector
can be located in O(1) without scanning the stream.
- HDF5 type:
H5T_INTEGER, size 8 (UINT64) - HDF5 shape:
(N + 1,)for/train_offsetsand(Q + 1,)for/test_offsets - Content:
offsets[i]is the byte offset of recordi;offsets[N]is the sentinel and equals the total byte stream length. The size of recordiisoffsets[i + 1] - offsets[i]. The array is non-decreasing.
Both datasets are optional. VSAG writers always emit them when
writing sparse files, but legacy sparse files that only contain /train
and /test keep loading: the offsets are recomputed on load by walking
the byte stream once. When the on-disk offsets are present, they are
cross-checked against the recomputed offsets and the file is rejected as
corrupted on any mismatch.
/train_token_sequences, /test_token_sequences (optional)
These two datasets carry the original tokenized document that
produced each sparse vector. They are entirely optional: sparse HDF5
files that omit both datasets still load correctly. When present, they
must appear in lockstep with /train and /test: the i-th record in
/train_token_sequences corresponds to the i-th sparse vector in
/train (same for /test).
-
HDF5 type:
H5T_INTEGER, size 1 (INT8) -
HDF5 shape:
(X,), whereXis the total byte-stream length (sum of all per-record sizes) -
Endianness: little-endian
-
Content: a contiguous sequence of records, one per sparse vector, in the same order as
/train//test. Each record has the layout:Field Type Size Description seq_lenuint324 bytes Number of tokens in the original document term_ids[seq_len]uint32[]4 * seq_lenbytesTerm ids in tokenization order (duplicates and order are preserved) Records are concatenated with no padding or separators. A
seq_len == 0record is allowed and occupies only the 4-byte length field; readers should treat it as “no original document available for this vector”. -
Number of records: must equal the number of sparse vectors in the matching split. Readers raise an error if counts disagree or if the stream is truncated.
-
Ordering vs.
ids:term_idsare stored in the original token order (duplicates kept). This is intentionally different fromids, which the loader sorts ascending.
/train_token_sequences_offsets, /test_token_sequences_offsets (required when sequences are present)
Whenever /train_token_sequences (resp. /test_token_sequences) is
present, the paired UINT64 offset index must also be present.
- HDF5 type:
H5T_INTEGER, size 8 (UINT64) - HDF5 shape:
(N + 1,)(resp.(Q + 1,)) - Content: same contract as
/train_offsets, enabling O(1) random access to the i-th token-sequence record.
Contract: the byte-stream dataset and its offsets dataset live or die
together. Readers reject the file if exactly one of the pair exists
(either a *_token_sequences dataset without its *_offsets, or vice
versa). When both are present, the on-disk offsets are cross-checked
against the offsets rebuilt from the byte stream; a mismatch is treated
as corruption and aborts the load.
Ground truth and metric
/neighbors and /distances follow the same shape and type rules as in the dense
layout above. Only "ip" (sparse inner-product distance, 1 - sparse_inner_product)
is supported via the distance attribute.
Python helper
The Python package pyvsag ships a decoder in pyvsag.sparse:
from pyvsag.sparse import load_sparse_hdf5
data = load_sparse_hdf5("sparse.hdf5")
# data["type"] -> "sparse"
# data["distance"] -> "ip"
# data["train"] -> list[dict[int, float]] one dict per sparse vector, keys ascending
# data["test"] -> list[dict[int, float]]
# data["neighbors"] -> numpy.ndarray shape (Q, K) int64
# data["distances"] -> numpy.ndarray shape (Q, K) float32
pyvsag.sparse.decode_sparse_bytes(buffer) is also exposed for callers that already
hold the raw byte stream.
Reference implementation
The byte-stream encoder/decoder lives at
tools/eval/eval_dataset.cpp
(see parse_sparse_vectors and serialize_sparse_vectors).
References
- Public benchmark datasets compatible with this layout are available from
ann-benchmarks
(e.g.
sift-128-euclidean.hdf5,gist-960-euclidean.hdf5). - See Evaluation Tool for how datasets in this format are consumed.
Index Analysis (AnalyzeIndexBySearch & analyze_index)
VSAG ships an introspection capability for inspecting an index that has already been built or loaded, so you can diagnose recall regressions, quantization quality, graph health and search performance without rebuilding the index. This capability is exposed in two ways:
- the C++ API
Index::AnalyzeIndexBySearch(declared ininclude/vsag/index.h); - the command-line diagnostic tool
analyze_index, located undertools/analyze_index/.
The AnalyzeIndexBySearch API
// include/vsag/index.h
virtual std::string
AnalyzeIndexBySearch(const SearchRequest& request);
- Input: a
SearchRequest(query dataset +topk+ search parameter JSON). - Output: a JSON-formatted string containing dynamic, query-driven metrics.
- Supported indexes: currently
HGraph,IVF,Pyramid, andSINDI. Indexes that do not implement this API will throw an exception when called.
It is complementary to Index::GetStats(), which reports static structural properties of the
index without needing query data. For graph-based indexes, additional graph-health details such
as degree distribution, entry-point quality, sub-index recall and low-recall hot-spots are
exposed through GetStats() rather than through AnalyzeIndexBySearch.
Pyramid query analysis follows the same path-scoping rules as KnnSearch. For the default
hierarchy, attach paths with Dataset::Paths. For a named hierarchy, use
Dataset::Paths(hierarchy_name, paths) and select that hierarchy in the Pyramid search parameters.
A path is required when the selected hierarchy’s root is NO_INDEX. In a batched query dataset,
the outer path collection has one row per query. The legacy overload stores one path string per
row and interprets | as a union; the structured overload stores one or many atomic paths
per row and treats | as an ordinary path character. Pyramid calculates ground truth only from
vectors eligible for each query’s hierarchy and path union, excluding mark-removed vectors.
Pyramid query analysis currently supports KNN requests only and uses query_, topk_, and
params_str_ from SearchRequest; filtered and iterator requests are not supported. Run analysis
against a stable index without concurrent add or remove operations.
Static metrics from GetStats()
HGraph metrics
| Metric | Meaning |
|---|---|
total_count | Total number of vectors in the index |
deleted_count | Vectors marked for deletion |
connect_components | Connected components in the proximity graph |
maximal_component_size | Size of the largest connected component |
in_degree_distribution | Histogram of graph in-degrees |
out_degree_distribution | Histogram of graph out-degrees |
average_degree | Average graph degree over valid nodes |
duplicate_ratio | Proportion of duplicate vectors in the dataset |
avg_distance_base | Average distance on sampled base vectors |
recall_base | Self-recall on sampled base vectors |
time_cost_query | Average latency when sampled base vectors are searched as queries |
proximity_recall_neighbor | Recall of graph neighbor lists against true nearest neighbors |
quantization_bias_ratio | Quantized-distance bias against exact distance |
quantization_inversion_count_rate | Rate of distance-order inversions caused by quantization |
build_cache_hit_rate | Fraction of nodes warm-started from an imported cache during the last Build(); emits a skipped_reason when the index was not built from a cache imported via ImportCache() |
build_cache_hit_nodes / build_cache_missed_nodes | Node counts behind build_cache_hit_rate (only present when the index was built from an imported cache) |
Pyramid metrics
| Metric | Meaning |
|---|---|
total_count | Total number of vectors in the index |
index_node_structure | Path-tree node count, depth, nodes by level, and node-status distribution |
leaf_node_size_distribution | Distribution of vector counts across leaf nodes |
subindex_quality | Per-leaf GRAPH/FLAT status and size, plus graph/flat counts and vector coverage |
recall_base | Size-weighted recall across graph-backed nodes; present when sampling and analysis search parameters are available |
graph_node_count / total_graph_size | Number and combined size of graph-backed nodes included in recall analysis |
skipped_node_count / low_recall_nodes | Nodes excluded from recall analysis and details of nodes below the recall threshold |
duplicate_ratio | Duplicate-vector ratio within the hierarchy |
hierarchy_count / hierarchies | Multi-hierarchy count and a per-name object containing the metrics above; single unnamed hierarchies expose them at the top level |
SINDI metrics
| Metric | Meaning |
|---|---|
total_count | Total number of sparse vectors in the index |
window_count | Number of SINDI windows |
active_term_count.mean / min / max | Per-window ratio of non-empty terms to term capacity |
active_term_count.avg_count | Average count of non-empty terms per window |
posting_length_distribution.mean / max / p95 / p99 | Distribution of non-empty posting-list lengths |
posting_length_distribution.long_tail_threshold | P99 posting-list length used as the long-tail threshold |
posting_length_distribution.long_tail_mean | Ratio of posting lists longer than the P99 threshold |
mean_doc_retained.mean | Average ratio of retained terms after document pruning |
recall_base | Self-recall using sampled base vectors as queries and exact sparse ground truth |
doc_prune_recall | Candidate recall from the doc-pruned index with query pruning disabled |
doc_prune_bias_mean | Average relative distance bias between doc-pruned distance and exact sparse distance |
doc_prune_inversion_count_rate | Candidate-pair order inversion rate introduced by document pruning |
quantization_range.min_val / max_val / diff | SQ8 quantization range, emitted only when quantization is enabled |
quantization_recall | Candidate recall from quantized coarse scoring, emitted only when quantization is enabled |
quantization_bias_ratio | Average relative distance bias between quantized distance and decoded doc-pruned distance |
quantization_inversion_count_rate | Candidate-pair order inversion rate introduced by quantization |
Metrics that require original base vectors output a skipped_reason object when the data is not
available. Original vectors are available inside the index when use_reorder=true; otherwise pass
SINDI base_path through the analyze parameters or the command-line option described below.
Dynamic metrics from AnalyzeIndexBySearch
HGraph metrics
| Metric | Meaning |
|---|---|
recall_query | Recall on the supplied query set against true nearest neighbors |
avg_distance_query | Average distance between query vectors and retrieved neighbors |
time_cost_query | Average per-query latency in milliseconds |
quantization_bias_ratio_query | Quantization bias observed during query search |
quantization_inversion_count_rate_query | Query-time ordering errors introduced by quantization |
Pyramid metrics
| Metric | Meaning |
|---|---|
recall_query | Recall against exact ground truth within each query’s selected hierarchy and path |
avg_distance_query | Average distance of the path-scoped exact ground-truth neighbors |
time_cost_query | Average per-query latency in milliseconds |
quantization_error_query | Query-time distance error introduced by base quantization; emitted when reorder is enabled |
quantization_inversion_ratio_query | Query-time ordering inversion ratio introduced by base quantization; emitted when reorder is enabled |
SINDI metrics
| Metric | Meaning |
|---|---|
recall_query | Search-result recall against supplied or generated sparse ground truth |
mean_latency_ms | Average per-query latency measured while running KnnSearch |
time_cost_query | Alias of mean_latency_ms, kept consistent with other analyzers |
postings_scanned.query_term_count_after_prune_mean | Average number of query terms left after query pruning |
postings_scanned.query_term_with_posting_mean | Average number of retained query terms that hit at least one non-empty posting list |
postings_scanned.posting_hit_mean | Average hit ratio of retained query terms against non-empty posting lists |
doc_prune_recall | Recall of doc-pruned pre-rerank candidates against sparse ground truth with query pruning disabled |
doc_prune_bias_mean | Average relative distance bias between doc-pruned distance and exact sparse distance on sampled queries |
doc_prune_inversion_count_rate | Candidate-pair order inversion rate introduced by document pruning on sampled queries |
quantization_recall | Recall of quantized pre-rerank candidates, emitted only when quantization is enabled |
quantization_bias_ratio | Average relative distance bias between quantized distance and decoded doc-pruned distance |
quantization_inversion_count_rate | Candidate-pair order inversion rate introduced by quantization |
reorder_recall.before_reorder_recall_k_at_k | Recall of coarse top-k candidates before precise reorder |
reorder_recall.after_reorder_recall_k_at_k | Recall of final top-k candidates after precise reorder |
last_topk_rank_in_heap.mean / p95 / p99 / max | Rank distribution of final top-k results inside the pre-rerank candidate heap |
SINDI dynamic recall and distance-quality metrics need ground truth. Pass groundtruth_path to
reuse an existing .dev.gt file, or pass base_path so the analyzer can generate exact sparse
ground truth. save_groundtruth_path can persist generated ground truth for later runs. Without
ground truth, those fields return skipped_reason; postings_scanned still runs because it only
needs the query and index postings.
Quantization-related fields differ by index type — they are not unified across implementations:
| Index | Field | Meaning |
|---|---|---|
HGraph | quantization_bias_ratio_query | Quantization bias observed during search |
HGraph | quantization_inversion_count_rate_query | Quantization-induced ordering errors during search |
IVF | quantization_bias_ratio | Quantization bias observed during search (only when use_reorder_ is enabled) |
IVF | quantization_inversion_count_rate | Quantization-induced ordering errors during search (only when use_reorder_ is enabled) |
Pyramid | quantization_error_query | Quantization distance error during search (only when reorder is enabled) |
Pyramid | quantization_inversion_ratio_query | Quantization-induced ordering inversions during search (only when reorder is enabled) |
For static structure and health information, including degree distributions, entry-point
analysis, and Pyramid subindex quality, use the GetStats() JSON.
The analyze_index Tool
analyze_index is the user-facing wrapper around the analyzer APIs. It loads a serialized VSAG
index from disk, prints its metadata and GetStats() output, and (optionally) runs
AnalyzeIndexBySearch against a query file.
Building
Tools are not built by default — enable them explicitly:
# via the project Makefile
VSAG_ENABLE_TOOLS=ON make release
# or directly through CMake
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DENABLE_TOOLS=ON
cmake --build build-release -j
# Output: ./build-release/tools/analyze_index/analyze_index
Command-line arguments
| Argument | Alias | Required | Description |
|---|---|---|---|
--index_path | -i | Yes | Path to the serialized VSAG index file. |
--build_parameter | -bp | No | Build parameters (JSON) used when reloading the index. Defaults to the parameters embedded in the index file. |
--query_path | -qp | No | Binary query vector dataset path. This is not Pyramid hierarchy-path metadata. If omitted, only static analysis is performed. |
--query_data_type | No | Query dataset type: auto, dense, or sparse. auto uses sparse loading for SINDI. | |
--base_path | No | Optional sparse CSR base dataset for SINDI analysis and ground-truth generation. | |
--groundtruth_path | No | Optional SINDI .dev.gt ground-truth file. If present, it is reused. | |
--save_groundtruth_path | No | Optional path for saving generated SINDI ground truth. | |
--search_parameter | -sp | No | Search parameters (JSON) used during dynamic analysis. |
--topk | -k | No | Top-K for dynamic analysis (default 100). |
The query file format is the simple binary (uint32 rows, uint32 cols, float32 data...) layout
consumed by load_query() in tools/analyze_index/analyze_index.cpp.
The dense query format contains vectors only. The tool does not currently provide a way to attach
default or named Pyramid hierarchy paths to the loaded Dataset. Therefore it cannot express
path-scoped Pyramid queries or run dynamic analysis when the selected hierarchy’s root is
NO_INDEX. Static Pyramid analysis through GetStats() remains available. For path-scoped
dynamic analysis, call the C++ API and attach the paths to the query dataset.
For SINDI, query and base datasets use CSR sparse binary layout:
int64 nrow, int64 ncol, int64 nnz, followed by int64 indptr[nrow + 1],
int32 indices[nnz], and float32 data[nnz]. SINDI ground truth uses .dev.gt layout:
uint32 query_count, uint32 topk, followed by flattened int32 ids and float32 distances.
If --groundtruth_path is not provided but --base_path is available, SINDI analysis generates
ground truth from the original sparse base vectors and can save it through --save_groundtruth_path.
Two analysis modes
1. Static analysis (no query file)
./build-release/tools/analyze_index/analyze_index \
--index_path /path/to/my_index.hgraph
Reports the index name, dimension, data type, metric, build parameters, and GetStats() JSON.
2. Static + dynamic analysis
./build-release/tools/analyze_index/analyze_index \
--index_path /path/to/my_index.ivf \
--query_path /path/to/queries.bin \
--search_parameter '{"ivf":{"scan_buckets_count":16}}' \
--topk 50
In addition to the static section, prints a Search Analyze: { ... } JSON block produced by
AnalyzeIndexBySearch.
When a serialized index only embeds index_param, analyze_index can still reload it without
--build_parameter; missing metadata fields are filled with analyzer defaults where possible.
Typical Use Cases
- Recall regression triage: confirm whether a drop is caused by quantization
(
quantization_*metrics), graph structure (connect_components,proximity_recall_neighbor), or query-side parameters (recall_queryvs.recall_base). - Capacity / health checks: detect duplicated data (
duplicate_ratio), disconnected components, or excessive deletions. - Parameter tuning: re-run
AnalyzeIndexBySearchwith differentsearch_parametervalues to pick an operating point that balancesrecall_queryandtime_cost_query— without rebuilding the index. - What-if experiments: override
--build_parameteron load to evaluate alternative settings for indexes whose parameters are not embedded in the file.
References
- API:
Index::AnalyzeIndexBySearchininclude/vsag/index.h - Implementations:
src/analyzer/{analyzer,hgraph_analyzer,pyramid_analyzer}.h - Tool source:
tools/analyze_index/ - Local tool entry point:
tools/analyze_index/README.md
Compatibility Check Tool (check_compatibility)
check_compatibility verifies whether the current VSAG build can load and search index files
created by older VSAG versions. It is mainly used in CI to catch serialization and backward
compatibility regressions.
Build
Using the project Makefile, enable tools with VSAG_ENABLE_TOOLS=ON; the underlying
CMake options are ENABLE_TOOLS=ON and ENABLE_CXX11_ABI=ON:
VSAG_ENABLE_TOOLS=ON make release
# Output: ./build-release/tools/check_compatibility/check_compatibility
When invoking CMake directly, pass both options explicitly:
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release \
-DENABLE_TOOLS=ON -DENABLE_CXX11_ABI=ON
cmake --build build-release -j
Inputs
The command accepts one positional identifier in the form <tag>_<algo_name>, for example
v1.1.0_hgraph. For that identifier, the tool expects these files under /tmp/:
| File | Purpose |
|---|---|
/tmp/<tag>_<algo_name>.index | Serialized index produced by the older VSAG version |
/tmp/<tag>_<algo_name>_build.json | Build parameters used for that index |
/tmp/<tag>_<algo_name>_search.json | Search parameters used for the sanity check |
/tmp/random_512d_10K.bin | Test vectors used by the search verification |
These files are usually generated by compatibility fixtures from previous releases.
Usage
./build-release/tools/check_compatibility/check_compatibility v1.1.0_hgraph
The tool creates a current-version index instance, deserializes the old index file, then runs a
small KNN search. It prints <identifier> success when both load and search succeed; otherwise it
prints <identifier> failed and exits with a non-zero status.
Local Entry Point
A short local pointer is kept at tools/check_compatibility/README.md for developers browsing the
tool directory.
FAQ
This page collects common questions that VSAG users hit while choosing indexes, tuning performance, and integrating APIs. Follow the linked pages for details.
Which index should I choose?
Common VSAG indexes target different workloads. Choose by data type, scale, and recall / latency target.
hgraph is the default choice for dense vectors. It fits text, image, and
multimodal embeddings in online search systems that need high recall and low
latency. It supports multiple quantizers, incremental insertion, deletion,
reranking, and automatic tuning.
ivf fits large-scale or high-throughput workloads where memory is tight and
queries can tolerate bucket-based recall tradeoffs. It reduces scanning by
partitioning vectors into buckets.
sindi is for sparse vector retrieval, such as BM25, SPLADE, or BGE-M3 sparse
outputs. It only accepts dtype: "sparse" and primarily uses
metric_type: "ip".
pyramid fits multi-tenant, partitioned, or tag-path workloads. It keeps
multiple subgraphs inside one index and supports tag / path based retrieval.
brute_force is for small datasets, functional validation, and exact-recall
baselines. It is exact, but latency and throughput usually do not scale to large
datasets.
Practical guidance:
- If you are unsure and your vectors are dense, start with
hgraph. - Use
sindifor sparse vectors. - Use
brute_forcefor small datasets or recall baselines. - Compare
ivfwhen throughput and memory matter more than single-query latency. - Consider
pyramidwhen your data has clear partition, tenant, or path structure.
Related pages: Index Overview, Best Practices.
Why does the same parameter set perform very differently on different datasets?
This is common in vector search. Even if the vector count, dimensionality, and index parameters are identical, different datasets can have very different search difficulty.
The root cause is data distribution:
- Some datasets have clear neighbor structure, so graph search reaches the right region quickly.
- Some datasets have ambiguous neighbor boundaries, so search must expand more candidates for the same recall.
- Embedding normalization, clustering, per-dimension distribution, and noise all affect search difficulty.
For HGraph-like graph indexes, ef_search is a key search-time parameter for
recall and latency. It controls how many candidates the search keeps and
expands:
- Larger
ef_searchusually improves recall. - Larger
ef_searchusually increases per-query latency. - When other factors are similar, query latency is often approximately linear in
ef_search.
Therefore, do not compare datasets only by QPS at the same ef_search. A more
meaningful process is:
- Tune
ef_searchseparately on each dataset. - Make each dataset reach the same target recall, such as 95% or 98% recall.
- Compare P50 / P95 / P99 latency and QPS at that target recall.
If dataset A reaches 95% recall with ef_search = 80, while dataset B needs
ef_search = 300, B being much slower is expected. It means B is harder to
search; it does not necessarily mean the index degraded.
When reporting performance, record:
- Dataset name and scale.
- Dimensionality.
- Index parameters.
- Target recall and actual recall.
ef_search.- QPS.
- P50 / P95 / P99 latency.
Related pages: HGraph, Evaluation Tool.
Why is sq8_uniform usually faster than sq8? When should I enable use_reorder?
Both sq8 and sq8_uniform are 8-bit scalar quantizers, but they use different
scaling strategies.
sq8 is per-dimension quantization:
- Each dimension has its own
min_i/max_i/scale_i. - This adapts better to each dimension’s value range.
- Distance computation has to handle per-dimension scales, so the hot path is more complex.
sq8_uniform is global uniform quantization:
- All dimensions share one
min/max/scale. - Query and base codes can more easily be computed directly in the integer domain.
- SIMD, AVX-512, AMX, and NEON paths are more efficient.
- Distance computation can avoid per-element dequantization and per-dimension scale handling.
When the data distribution fits this assumption, sq8_uniform is often faster
than sq8.
Good use cases for sq8_uniform:
- Normalized vectors, especially
cosineworkloads. - Dimensions have similar value ranges.
- Distance computation is the query bottleneck.
- Throughput and latency matter more than the last bit of recall.
- You can use
use_reorderto fix coarse-ranking errors.
Less suitable cases:
- Different dimensions have very different value ranges.
- Vectors concatenate heterogeneous feature blocks.
- Some dimensions have heavy tails or strong outliers.
- You do not plan to enable reorder and are very sensitive to recall.
use_reorder first uses the compressed base quantizer for coarse ranking, then
reranks candidates with a higher-precision precise quantizer.
Common configuration:
{
"index_param": {
"base_quantization_type": "sq8_uniform",
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
Enable use_reorder when:
- You use lossy quantizers such as
sq4,sq4_uniform,pq,pqfs, orrabitq. - Recall is not stable enough with
sq8orsq8_uniform. topkis small but final ranking quality is important.- You can afford an additional higher-precision representation.
- Production recall stability matters more than maximum compression.
You can skip use_reorder when:
fp32orfp16already meets recall.sq8_uniformreaches recall targets without reorder.- Memory budget is very tight.
- Latency is extremely sensitive and reranking overhead is unacceptable.
Simple guidance:
- Throughput first: try
sq8_uniformwithout reorder and measure recall. - Safer default:
sq8_uniform+use_reorder: true+precise_quantization_type: "fp32". - Strong compression:
sq4_uniform/pq/rabitqusually need reorder.
Related pages: Scalar Uniform Quantization, Scalar Quantization.
What are the distance semantics of l2, ip, and cosine?
VSAG search results are always sorted by smaller distance first. Even when the underlying metric is inner product or cosine similarity, VSAG converts the score into distance semantics.
Specific semantics:
l2returnsL2Sqr, the squared L2 distance.ipreturns1 - inner_product.cosinereturns1 - cosine_similarity.
Why does l2 return squared distance? Squared L2 distance has the same ordering
as L2 distance, and avoiding the square root improves performance. VSAG
therefore commonly uses L2Sqr internally and in returned distances.
This affects RangeSearch radius settings:
- If you want L2 distance smaller than
2.0, pass radius4.0. - For
ip, radius means1 - inner_product. - For
cosine, radius means1 - cosine_similarity.
For example, if you want cosine similarity at least 0.8:
distance = 1 - cosine_similarity
radius = 1 - 0.8 = 0.2
Notes:
- Different systems may return similarity or distance.
- Before comparing with another library or ground truth, confirm the scoring semantics.
- After an index is created,
metric_typecannot be changed at search time.
Related pages: Metric Semantics, Range Search.
What is the difference between base_quantization_type and precise_quantization_type?
These two parameters control coarse storage and rerank storage.
base_quantization_type is the main storage quantizer:
- It stores the main vectors inside the index.
- It is used for coarse distance computation during graph search or inverted-list scanning.
- It directly affects memory usage, search speed, and coarse-ranking recall.
- Common values include
fp32,fp16,bf16,sq8,sq8_uniform, andpq.
precise_quantization_type is the higher-precision quantizer for reranking:
- It only takes effect when
use_reorder: true. - It reranks coarse candidates.
- It corrects distance errors introduced by lossy quantization.
- The common choice is
fp32; depending on memory budget,fp16,bf16, orsq8may also be used.
A useful mental model:
base_quantization_type = format used to quickly find candidates
precise_quantization_type = format used to recompute candidate distances
High-recall baseline:
{
"index_param": {
"base_quantization_type": "fp32"
}
}
Memory / recall tradeoff:
{
"index_param": {
"base_quantization_type": "sq8_uniform",
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
Lower memory:
{
"index_param": {
"base_quantization_type": "sq4_uniform",
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
More aggressive compression:
{
"index_param": {
"base_quantization_type": "pq",
"use_reorder": true,
"precise_quantization_type": "fp32"
}
}
Setting guidance:
- Recall first, enough memory:
base_quantization_type: "fp32". - General production choice:
base_quantization_type: "sq8_uniform". - Data distribution does not fit uniform scaling: try
sq8. - Tight memory: try
sq4_uniform,pq, orrabitq, and enable reorder. - If
use_reorderis enabled, start withprecise_quantization_type: "fp32".
Note that dtype is the input data type, while base_quantization_type is the
internal storage / computation format. They are not the same. For example, input
can be dtype: "float32" while the index stores vectors with
base_quantization_type: "sq8_uniform".
Related pages: Quantization Overview, HGraph, IVF.
Should I use Bitset, lambda, Filter, attribute filtering, or extra_info?
VSAG provides multiple filtering APIs for different workloads.
Bitset filtering fits a known set of ids to exclude, such as tombstones,
blacklists, or permission-denied ids. Bitset::Test(id) == true means the id is
filtered out.
Lambda or std::function<bool(int64_t)> fits simple filtering logic. Returning
true means the id is filtered out.
A Filter object fits more complex filtering logic, or cases where you can
provide hints such as ValidRatio(). Filter::CheckValid(id) == true means the
id is kept.
Attribute filtering fits structured predicates, such as
category = "book" AND price <= 100. It is used through SearchRequest and fits
vector + structured field hybrid search.
extra_info filtering fits fixed-size byte payloads stored beside each vector.
HGraph can filter on those bytes during graph traversal.
Filter::CheckValid(const char*) == true means the vector is kept.
How to choose:
- Use Bitset if you only need to exclude a known id set.
- Use lambda for simple ad hoc logic.
- Use a
Filterobject for complex logic and when you can estimate pass ratio. - Use attribute filtering for named, typed structured fields.
- Use
extra_infowhen metadata is a fixed-size byte payload stored with vectors.
The most confusing part is true / false semantics:
Bitset::Test(id)returnstrueto filter out this id.- A lambda returns
trueto filter out this id. Filter::CheckValid(id)returnstrueto keep this id.Filter::CheckValid(const char*)returnstrueto keep the vector.
When using bitset filtering, keep ids in [0, 2^32) when possible to avoid
low-32-bit collisions. If the predicate is very selective, graph search may need
to expand more candidates to collect enough valid results. For HGraph, consider
brute_force_threshold so highly selective filters can automatically fall back
to brute-force scanning.
Related pages: Filtered Search, Attribute Filter, Extra Info.
Release Notes
VSAG website release notes are maintained by MAJOR.MINOR series. Each series page covers the
first release and every later patch release in that line. GitHub Releases remains the source for
the complete per-patch pull request list, assets, and contributor credits.
Release Series
Future release notes follow the same layout: v1.1, v1.2, v2.0, and so on. Patch releases
update their existing series page instead of creating a separate website page.
Version and Note Grouping
Release tags use the vMAJOR.MINOR.PATCH form. The website groups them by MAJOR.MINOR so each
page can explain the full series, while GitHub Releases records the exact contents of each tag.
Getting a Specific Version
C++ / source
git checkout vX.Y.Z
make release
Python
Check PyPI for an available binding version, then install that exact version:
pip install pyvsag==X.Y.Z
Binding releases may not match every core C++ tag. The repository also contains C and Node.js/TypeScript bindings. See the corresponding release series page and repository examples for their support and packaging state.
Upgrade Guidance
- Read the compatibility section of the target release series before upgrading.
- When a serialization format changes, validate old artifacts with the compatibility check tool in a staging environment.
- Roll out gradually in production and use the performance evaluation tool to compare recall, latency, and resource use.
For complete patch-level history, see all VSAG releases on GitHub.
VSAG 1.0 Release Notes
v1.0.0 was released on July 12, 2026, the project’s third anniversary.
- Official GitHub Release
- Full v0.18.0…v1.0.0 changelog
- Tag commit:
efdaf17a10e96cdb5222baf558d50dfacbdc672e
Overview
VSAG 1.0 is the project’s first long-term support (LTS) major release. At the time of its release,
the public v0.x history comprised 81 tags from v0.11 through v0.18. v1.0.0 brings that
work together across dense, sparse, hierarchical, and multi-vector retrieval with structured
filtering.
The official v1.0.0 release contains 375 changes since v0.18.0: 48 features,
134 improvements, 105 bug fixes, and 88 other changes. The broader
v0.11.0...v1.0.0 history contains 1,252 mainline commits.
The overview and highlights below describe the APIs and features available in the v1.0.0 tag.
Subsequent updates are listed under v1.0 Patch Releases.
Highlights
Index families
The following matrix groups VSAG 1.0 indexes by role: Pyramid and LazyHGraph provide composite or adaptive behavior, while the lower row presents the five core index families.
Composite and adaptive indexes:
- The partitioned index Pyramid supports assigning one vector to multiple Pyramid paths and scoping searches to a selected path for hierarchical and multi-tenant retrieval (PR #2226).
- The self-scaling graph index LazyHGraph starts with exact BruteForce search and converts to HGraph after a configurable threshold, avoiding graph-build overhead for small, growing collections (PR #2151).
Core index families:
- The brute-force index BruteForce supports exact search for both single-vector and multi-vector datasets. It is the exact-search baseline and an option for smaller collections.
- The graph index HGraph targets high-recall, low-latency dense-vector search. Since its initial implementation, it has added quantization, filtering, range and iterator searches, updates, mark and force-removal modes, cache import and export, diagnostics, and memory-plus-disk configurations.
- The space-partitioning index IVF targets large datasets, batch queries, and large
top-kworkloads. It supports quantization, reordering, attribute filters, parallel build and search, and on-disk bucket storage. See the original IVF PR. - The sparse-vector index SINDI supports BM25-style and learned sparse retrieval, including term-ID remapping, index analysis, immutable reads, FP16 sparse values, term-list compaction, and a low-memory immutable build.
- The multi-vector index SIMQ targets ColBERT-style late-interaction retrieval. It generates candidates at the cluster level, then performs exact MaxSim reranking to balance recall and latency (PR #2357).
For configuration and usage details, see Indexes.
Quantization, data types, and hardware acceleration
VSAG 1.0 supports the following input formats, quantizers, transforms, and hardware-acceleration paths:
- FP32, INT8, FP16, and BF16 dense inputs, plus sparse and multi-vector datasets, with direct FP16/BF16 input support (PR #1731).
- SQ4/SQ8 and their uniform variants.
- Product Quantization and PQ FastScan (PR #626, PR #691).
- RaBitQ, extended-bit and x+y split base/reorder layouts, FHT/PCA-assisted transforms, and dedicated SIMD kernels.
- Transform Quantizer chains and MRL-E dimension reduction.
- x86_64 dispatch across SSE, AVX, AVX2, AVX-512, and selected AMX kernels, plus ARM NEON and SVE.
- AMX acceleration for SQ8U inner product and KMeans BF16 GEMM (PR #2032).
See Quantization for supported combinations and tuning guidance.
Search, filtering, and index management
- Basic search:
KnnSearchprovides KNN queries, whileRangeSearchprovides range queries with an optional result limit. - Unified request API:
SearchRequestandIndex::SearchWithRequestuse one request object to select KNN or range search and carry index-specific JSON parameters, supported filters, and diagnostic inputs. In v1.0, HGraph, IVF, and BruteForce implement this interface; supported fields vary by index. - Filtering: ID callbacks/
FilterPtr, bitsets, and SQL-like attribute expressions are available. HGraph, IVF, and BruteForce support structured filtering with inverted attribute indexes. HGraph also supports iterator filtering and can switch to brute-force search when the valid ratio is at or belowhgraph.brute_force_threshold. - Training and model reuse:
Train,Clone,ExportModel, andTunecover standalone training, deep copies, trained-model export, and index tuning. - Data maintenance and access: The APIs cover batch removal, mark/force removal,
ID/vector/attribute updates, source IDs,
extra_info, index-detail reads, andCalcDistanceById. - Missed-recall diagnosis:
SearchRequest::expected_labels_helps HGraph, IVF, and BruteForce explain why expected vectors were not recalled. The resultDatasetcarries the reasoning report (PR #1838). - Statistics and capacity planning: Search, I/O, memory, and index-specific statistics expose runtime state, while memory estimates, index introspection, and analysis tools support capacity planning and troubleshooting.
Support varies by index; Index::CheckFeature(IndexFeature) reports capabilities represented by
IndexFeature.
Serialization and compatibility
VSAG 1.0 maintains two serialization families in parallel. The legacy Serialize/Deserialize
APIs remain maintained for compatibility with existing integrations. The newer
streaming serialization APIs use a header-first,
forward-only format and extend the interface with the following capabilities:
SerializeStreamingwrites metadata followed by typed TLV blocks.DeserializeStreamingrestores into an existing compatible, empty index object.Index::Loadreads metadata, creates the matching index, and applies supported placement policy.- The v1.0 streaming path supports BruteForce, HGraph, IVF, mutable SINDI, and Pyramid. Streaming serialization of immutable SINDI indexes is not supported in v1.0.
Both API families remain available, but their formats are not compatible; a file must be read by the matching family. Existing integrations can continue to use the legacy APIs, while new integrations should prefer the streaming APIs. See New Serialization for format and block-version details.
Cross-version index fixtures and the compatibility check tool provide a repeatable way to validate old artifacts before an upgrade.
Platforms, bindings, and tooling
- The VSAG core C++ library supports Linux and macOS, with most development and the full validation pipeline centered on Linux. Linux x86_64 and AArch64 are both covered by CI, while macOS validation currently targets arm64 builds (source build, PR CI). Prebuilt C++ release archives are currently limited to Linux x86_64.
- The Python bindings are packaged as
pyvsag; v1.0.0 declares support for CPython 3.6-3.14 and configures wheel builds for that range. Its build uses native CMake integration (PR #1599); the bindings also provide broader index operations, FP16/BF16 inputs, sparse-vector support, and sparse HDF5 helpers. - VSAG now includes a C API and Node.js/TypeScript bindings with quickstart examples. Language bindings are released independently; check the corresponding package version before use.
- Builds support system OpenBLAS/fmt dependencies, custom dependency mirrors, and installable CMake package metadata.
eval_performancesupports dense, sparse, and multi-vector datasets.analyze_index,check_compatibility,visualize_index, and the HTTP monitor support index analysis, compatibility testing, serialization inspection, and monitoring.
Reliability and validation
Functional and regression tests cover allocation, leaks, out-of-memory paths, and concurrent build, insertion, search, update, removal, and destruction. CI reinforces these checks with ASan for memory safety and TSan for data races. Compatibility fixtures validate legacy-index upgrade paths.
Compatibility and Upgrade Notes from v0.18
VSAG 1.0 is a major release and includes source-level API changes. Review these points before upgrading:
Removereturns a count and supports batches. The v0.18 methodtl::expected<bool, Error> Remove(int64_t)now returnstl::expected<uint32_t, Error>. v1.0 also adds a vector overload and explicit remove modes (PR #1551). The final API exposesRemoveMode::MARK_REMOVEandRemoveMode::FORCE_REMOVE; HGraph force removal landed in PR #1810.- Unsupported operations usually return an error. Many default methods that return
tl::expectednow returntl::unexpectedwithErrorType::UNSUPPORTED_INDEX_OPERATIONinstead of throwingstd::runtime_error(PR #2141). Check thetl::expectedresult before calling.value(). - Memory-statistics signatures changed.
GetMemoryUsageusesuint64_t,GetMemoryUsageDetailreturns astd::unordered_map<std::string, uint64_t>, andGetEstimateBuildMemorybecameEstimateBuildMemory(PR #2388). - Search migration can be incremental. Prefer
SearchRequest/SearchWithRequestfor new integration work, but the existing search overloads remain in v1.0. - Do not mix serialization families. Legacy output must use legacy deserialization; streaming
output must use
DeserializeStreamingorIndex::Load. - SINDI heap insertion is automatic. The legacy
use_term_lists_heap_insertsearch parameter is ignored. SINDI derives the strategy fromdoc_prune_ratioandquery_prune_ratio; update configurations that forced the old path. - Intel MKL is opt-in. The default is
OFF. Enable it withVSAG_ENABLE_INTEL_MKL=ONthrough the Makefile or-DENABLE_INTEL_MKL=ONthrough CMake when required.
For persisted indexes, validate the exact source and target versions in staging. Serialization compatibility can differ by index, feature flags, and format family.
The v0.x Journey to 1.0
v0.11.0 was VSAG’s first formal release after the project was open-sourced. Earlier version
numbers were used only for internal iterations and were not published as GitHub Releases, so this
history begins with v0.11.
Foundations: v0.11-v0.14
- v0.11, September 2024: established the initial HNSW/DiskANN, C++/Python, pre-filter, cosine-distance, locking, and serialization baseline.
- v0.12, December 2024: introduced DataCell, I/O, and graph abstractions; HGraph; SQ4/SQ8/INT8 paths; the Engine/factory model; and pyvsag packaging.
- v0.13, February 2025: added
BruteForce, expanded Pyramid, memory estimation, index feature discovery, filter hints, and the
eval_performancetool. - v0.14, April 2025: introduced IVF,
FP16/BF16 and RaBitQ support, async/buffer I/O, sparse datasets, HGraph
extra_info, iterator filtering, and systematic compatibility checks.
Expansion: v0.15-v0.18
- v0.15, June 2025: added Train/Clone/ExportModel, PQ/PQ FastScan, attribute expressions, compressed graphs, HGraph merge and mark-delete, and self-describing legacy serialization with compatibility CI.
- v0.16, August 2025: added mmap HGraph, SINDI, parallel IVF, attribute updates, raw-vector access, parameter compatibility checks, and numerous ABI, concurrency, and legacy-index fixes in subsequent patches.
- v0.17, October 2025: expanded
SearchRequestto cover the main search cases and added search timeouts, broaderextra_info, Transform Quantizer support, single-query parallel HGraph search, export APIs, and richer SINDI lifecycle and statistics support. - v0.18, January 2026: added the C API, automated Python wheels, sparse-vector Python bindings, on-disk IVF, index detail/search/I/O statistics, MRL-E and HGraph tuning, extended RaBitQ, and additional SINDI and Pyramid capabilities.
See the v0.11.0…v1.0.0 comparison for the complete commit history.
v1.0 Patch Releases
v1.0.1 — September 22, 2026
v1.0.1 is the first patch release in the 1.0 LTS series. It includes 17 changes covering index correctness, DiskANN memory use, and build reliability.
- HGraph: remove the
topk-dependent upper bound onef_search(#2525), traverse bridges with non-finite distances (#2806), and prevent stale entry points after force-removing tail nodes (#2906). - Other index fixes: reclaim BruteForce storage after force removal (#2538), skip zero-distance SINDI range candidates (#2715), and align Pyramid raw-vector retrieval (#2760).
- DiskANN: avoid copying streams for size checks (#2630) and bound closest-center block memory (#2646).
- Dependency configuration: add pin-qualified third-party overrides (#2686).
- OpenBLAS and packaging: update bundled OpenBLAS and stabilize x86 detection (#2704, #2876), then repair manylinux dependency builds (#2971).
- Compiler compatibility: fix GCC 15 header compilation (#2976) and avoid a GCC 14 sparse duplicate-copy warning (#2992).
- CI and tests: restore PR lint on the 1.0 branch (#2488), scope the OpenBLAS workaround to regular TSan (#2670), and tolerate INT8 cosine rounding in tests (#2968).
See the full v1.0.0…v1.0.1 changelog and the official release for the complete PR list and contributor credits.
- v1.0.0 — July 12, 2026: first long-term support major release.
Acknowledgments
VSAG 1.0 is the result of work by the Ant Group VSAG team and the wider open-source community. Thank you to every contributor who designed algorithms, implemented features, reported issues, reviewed changes, improved tests, and wrote documentation.
See the contributors page and the official release for contributor details.
Roadmap 2025
This historical roadmap led to VSAG 1.0.
v1.0.0was released on July 12, 2026. See the VSAG 1.0 Release Notes for the delivered result.
As AI capabilities keep advancing and strong open-source LLMs become widespread, demand for unstructured-data retrieval has exploded. Vector algorithms are the cornerstone of unstructured retrieval, and the VSAG community will keep investing in algorithmic research to help partners improve retrieval performance, reduce latency, and cut costs.
The roadmap defined the first major release as follows:
- VSAG 1.0 would provide comprehensive support for both graph-based and inverted-index structures, as well as in-memory and memory-plus-disk hybrid retrieval modes, delivering low memory cost and outstanding search performance.
Planned algorithms and features:
- Support for common data types to cover diverse unstructured retrieval scenarios
- FP32 vectors: mainstream retrieval scenarios
- INT8, BF16, FP16 vectors: adapt to quantized embedding models without extra storage overhead
- Sparse vectors: extending text-retrieval workloads
- Fully optimized core index types covering the majority of retrieval scenarios
- Graph index HGraph: high precision and low latency
- Inverted index IVF: large K and batch query workloads
- Rich quantization options for the memory/recall trade-off
- RabitQ (BQ): ultra-high compression with minimal memory
- PQ: flexible compression ratios for accuracy-tolerant scenarios
- SQ4, SQ8: standard quantization with minor recall loss and large memory/perf gains
- Multi-platform instruction support to simplify distribution
- x86_64: SSE, AVX, AVX2, AVX-512
- ARM: NEON, SVE
- Optional matrix-multiplication libraries: Intel MKL, OpenBLAS
- Resource isolation and fine-grained runtime configurability
- Memory: per-index allocators, enabling tenant-level memory management
- CPU: injectable thread pools to boost write and search throughput
Beyond these, there is much more we want to discuss, design, and build in the open-source community — follow the VSAG project to stay up to date!
Community
VSAG is open-sourced by Ant Group and is actively maintained on GitHub. Developers, researchers, and users are all welcome to join the community.
Channels
- GitHub Issues — bug reports, feature requests, design discussions. https://github.com/antgroup/vsag/issues
- GitHub Discussions (when enabled) — long-running topics, Q&A, best practices.
- Pull Requests — every code, doc, or example change goes through a PR. See Contributing to VSAG.
- DingTalk / WeChat groups — if announced by the community, the latest invite links are pinned at the top of the repository README.
Governance
- A maintainer team owns code review, releases, and the roadmap.
- Every PR requires at least one approving review plus the required CI checks.
- Every PR must carry both a
kind/*label and aversion/*label (enforced by Mergify). See the contributors’ guide.
Ways to Contribute
More than just code:
- Docs — fix typos, add examples, translate pages.
- Examples — contribute end-to-end demos to
examples/cpp/orexamples/python/. - Benchmarks — share results on new hardware or datasets, extending the reference performance page.
- Ecosystem integrations — write bindings or adapters for other languages / databases.
- Articles — guest posts are welcome under
docs/blog/(see the repository README).
Code of Conduct
The community follows the Contributor Covenant Code of Conduct. Please participate constructively and respectfully.
Related Projects
See Related Projects.
Filing Issues with an AI Agent
You can use an AI coding agent (Claude Code, OpenCode, or Codex) together with
the VSAG repository’s built-in /create-issue slash command to draft and
submit a high-quality GitHub issue for VSAG. The agent maps your request onto
the project’s issue templates, fills in the required fields, and submits the
issue through GitHub CLI.
This page walks through the end-to-end setup. The canonical workflow that the
agent itself follows lives in
.github/agent-prompts/create-issue.md;
this page focuses on the user-facing steps.
Prerequisites
- A GitHub account.
- One of the supported AI coding agents installed and configured locally: Claude Code, OpenCode, or Codex.
gitavailable on your machine.
1. Install and sign in to GitHub CLI (gh)
First, install gh by following the official quickstart for your platform:
https://docs.github.com/en/github-cli/github-cli/quickstart
Then sign in from your terminal:
gh auth login
Choose GitHub.com, pick an authentication protocol (HTTPS is fine), and follow the browser prompts to complete sign-in.
2. Verify your gh login
gh auth status
Confirm that GitHub.com authentication is active before continuing.
3. Clone the VSAG repository
git clone https://github.com/antgroup/vsag.git
cd vsag
The /create-issue command and its prompt files live inside the repository,
so the agent must be launched from within the vsag/ working directory to
pick them up.
4. Launch your agent inside the repo
From the vsag/ directory, start one of the supported agents:
# Claude Code
claude
# OpenCode
opencode
# Codex CLI
codex
5. Run /create-issue
In the agent prompt, invoke the slash command and describe your need in natural language. For example:
/create-issue HGraph build crashes when dim=0; want a clear error instead.
The agent will:
- Pick the most appropriate template under
.github/ISSUE_TEMPLATE/. - Ask follow-up questions if required fields are missing.
- Draft the issue body with code/doc references in
path:lineform. - Show you the final draft for confirmation.
- Submit the issue via
gh issue createonce you approve.
You can iterate with the agent freely — ask it to revise wording, add reproduction steps, switch templates, or attach logs before it submits.
Tips
- Be specific: include the index type, parameters, dataset shape, error message, and platform when filing a bug.
- For feature requests, describe the use case and the expected API or behavior. The agent will mirror this into the template’s required fields.
- Issues do not carry
Signed-off-by:— DCO applies only to commits. - If you prefer to drive the workflow without an interactive agent, see the
shell wrapper at
tools/issue-helper/new-issue.sh.
See also
Related Projects
This page lists upstream and downstream projects related to or integrating with VSAG, making it easier to assemble complete stacks.
Projects Using VSAG
- OceanBase — Ant Group’s open-source distributed relational database; its vector search is powered by VSAG.
- Other vector databases / integrations — if you maintain an integration, feel free to open a PR to list it here.
Dependencies and Inspirations
- Faiss — Meta’s vector search library; VSAG borrows ideas in IVF and quantization.
- SPANN / SPTAG — Microsoft’s large-scale retrieval system; an inspiration for VSAG’s large-scale search design.
Ecosystem Tooling
- ann-benchmarks — the de-facto ANN benchmark harness; VSAG’s performance evaluation tool is compatible with its dataset format.
- pybind11 — powers the
pyvsagPython binding. - napi-rs — powers the Node.js binding under
typescript/.
Bindings / Language Support
- C++ (native)
- Python —
pyvsag, source underpython_bindings/andpython/. - Node.js / TypeScript — source under
typescript/, npm package namevsag.
Pull requests to extend this list are welcome.
Research Papers
1. Effective and General Distance Computation for Approximate Nearest Neighbor Search [ICDE’25]
Approximate K-nearest-neighbor (AKNN) search in high-dimensional spaces is a key and challenging problem. Distance computation dominates AKNN runtime, and existing approaches rely on approximate distances to gain efficiency, usually at the cost of accuracy. The state-of-the-art ADSampling uses random projection to estimate distances and a correction step to mitigate accuracy loss, but is limited in both effectiveness and generality because both steps depend on random projection. This work improves distance computation by using data-aware orthogonal projections and a data-driven correction procedure decoupled from the approximation step. Extensive experiments show 1.6×–2.1× speedups over ADSampling on real-world datasets with higher accuracy.
Integrated into VSAG under the name BSA; used to reduce the amount of high-precision re-ranking data inside disk-based indexes.
2. VSAG: An Optimized Search Framework for Graph-based Approximate Nearest Neighbor Search [VLDB’25]
Approximate nearest-neighbor search (ANNS) is foundational to vector databases and AI infrastructure. Recent graph-based ANNS algorithms deliver both high accuracy and practical efficiency, but production performance is still limited by random memory access patterns and expensive distance computations. Moreover, graph-based ANNS is highly parameter-sensitive, and finding optimal parameters traditionally requires repeatedly rebuilding the index. This paper introduces VSAG, an open-source framework that targets these issues in production. VSAG is widely deployed across Ant Group services and combines three key optimizations: (i) efficient memory access via prefetching and cache-friendly vector layout to reduce L3 misses; (ii) automatic parameter tuning without rebuilding the index; and (iii) efficient distance computation leveraging modern hardware, scalar quantization, and low-precision fallbacks. On real-world datasets VSAG matches or exceeds state-of-the-art accuracy while achieving up to 4× higher throughput than HNSWlib.
Integrated into VSAG; enabled through the
TuneAPI (historically called the “ELP Optimizer” and implemented behind theuse_elp_optimizerkey).
3. EnhanceGraph: A Continuously Enhanced Graph-based Index for High-dimensional Approximate Nearest Neighbor Search [TKDE]
Driven by rapid progress in deep learning, high-dimensional ANNS has received growing attention. We observe that graph-based indexes generate large amounts of search and construction logs over their lifetime, but static indexes fail to exploit these valuable signals. This paper proposes EnhanceGraph, a framework that folds both log types into a novel structure called a conjugate graph to improve search quality. Guided by theoretical analysis and observations of the limitations of graph-based indexes, we propose several optimisations: for search logs, the conjugate graph stores edges from local optima to the global optimum to strengthen routing; for construction logs it stores edges pruned from the proximity graph to improve k-NN recall. Experiments on public and real industrial datasets show EnhanceGraph significantly improves accuracy without sacrificing search efficiency, with recall gains reaching from 41.74% to 93.42%. EnhanceGraph has been integrated into VSAG.
Available in VSAG via the
use_conjugate_graphparameter.
4. SINDI: an Efficient Index for Approximate Maximum Inner Product Search on Sparse Vectors [arxiv]
Maximum inner product search (MIPS) on sparse vectors is critical for multi-way retrieval used in retrieval-augmented generation (RAG). Recent inverted-index and graph-based algorithms combine high accuracy with practical efficiency, but production performance is often limited by redundant distance computations and frequent random memory accesses. Furthermore, the compressed storage format of sparse vectors makes it hard to take advantage of SIMD acceleration. This paper presents the Sparse Inverted Non-redundant Distance Index (SINDI), which combines three key optimisations: (i) efficient inner-product computation that uses SIMD acceleration and eliminates redundant identifier lookups for batched computations; (ii) memory-friendly design that replaces random access on raw vectors with sequential access on inverted lists, greatly reducing memory-access latency; and (iii) vector pruning that keeps only the non-zero entries with larger magnitude, so query throughput improves while accuracy is preserved. On real-world datasets SINDI is state-of-the-art across scales, languages, and models. On MsMarco, for Recall@50 above 99%, SINDI delivers 4.2×–26.4× higher single-thread QPS than SEISMIC and PyANNs. SINDI has been integrated into VSAG.
SINDI is an index type inside VSAG.
Contributors
The following is the list of VSAG contributors (updated 2026-08-21), ordered by the date of their first contribution:
- 2024-07-26 Xiangyu Wang (wxyucs) from AntGroup
- 2024-08-21 Jiabao Jin (inabao) from AntGroup
- 2024-08-30 👑 Haotian Li (LHT129) from AntGroup
- 2024-09-04 Xiaoyao Zhong (ShawnShawnYou) from AntGroup
- 2024-10-23 Jiacai Liu (jiacai2050)
- 2024-10-28 Coien-rr
- 2024-12-16 Mingyu Yang (mingyu-hkustgz) from HKUST(GZ)
- 2025-01-24 Carrot-77 from OceanBase
- 2025-03-05 Deming Chu (nedchu) from AntGroup
- 2025-03-25 Liyao Xiong (lyxiong0) from OceanBase
- 2025-04-15 skylhd from OceanBase
- 2025-04-23 azl (shadowao) from OceanBase
- 2025-06-10 dasurax from AntGroup
- 2025-06-12 L J. Yun (yulijunzj) from AntGroup
- 2025-06-13 Danbaiwq from OceanBase
- 2025-06-17 jingyueob from OceanBase
- 2025-07-28 jac (jac0626)
- 2025-07-29 mly (mly5269)
- 2025-08-25 HuMing He (HeHuMing)
- 2025-10-22 cubicc from ByteDance
- 2025-10-29 Roxanne0321 from AntGroup
- 2025-11-12 baoyuan (misaka0714)
- 2025-11-20 Zihao Wang (hhy3)
- 2025-12-19 Xinger (Ningsir) from OceanBase
- 2026-02-05 stuBirdFly from OceanBase
- 2026-04-08 Sun Jiayu (pkusunjy) from AntGroup
- 2026-04-20 wei (jiaweizone) from AntGroup
- 2026-04-20 XFMENG17 from AntGroup
- 2026-05-07 liric24 from AntGroup
- 2026-05-14 LightWant from AntGroup
- 2026-06-09 mukejane
- 2026-06-09 Jiangtian Feng (jfeng18) from Alibaba
- 2026-06-29 Wen Huanzhi (Sia-Sheerland)
- 2026-08-04 Siyang Tang (Hastyshell) from Xiaohongshu
- 2026-08-16 Charles xu (CharlesXu-HQ) from AntGroup