Rust implementation of a vector database benchmarking tool. 15 engines: Redis (RediSearch), VectorSets, Valkey, Dragonfly, KiviDB, Elasticsearch, OpenSearch, Qdrant, pgvector, Weaviate, Milvus, MongoDB, Chroma, Turbopuffer, Vertex AI.
The thing this repo optimises for is not being wrong. Its dominant historical bug class is a run that completes, reports a plausible number, and measured something other than what its config name claims. Most of the unusual-looking rigour below exists because of a specific incident.
make agent-checkThat is the entire no-Docker CI gate — fmt, clippy -D warnings, and the unit /
binary / invariant suites in release — in CI's order and profile. If it
passes, the non-Docker CI jobs will pass.
Run it on its own line and check the exit status. Do not pipe it —
make agent-check | tail -2 && git commit takes the pipeline's status, which
is tail's, so it reports success over a failing gate. That has already
happened once, in the commit that wired the gate up.
make checkruns NO TESTS. It isfmt-check+lintonly. It is not a sufficient gate;make agent-checkis.- CI runs the test suites with
--release. Adebug_assert!therefore does not exist in CI, or in the binaries this repo ships. If you write a guard, make it a returnedError a realassert!, and run the suite under both profiles. - Match CI's Rust toolchain. CI uses default
stable. A newer stable adds clippy lints your local version does not have, somake checkcan be clean locally and fail CI on-D warnings.rustup update stablebefore concluding a lint failure is spurious. - Rebuild
--releasebefore copying a binary anywhere.cargo testbuilds debug;scp target/release/<bin>after it ships the previous build.
make vector-db-benchmark # Build the main CLI binary (release)
make build # Build all binaries (release)
make test # Unit tests only, debug profile (no Docker)
make check # fmt + clippy ONLY — see the warning above
make fmt # Auto-format
make prepare-msmarco # Build an MS MARCO corpus (see the MS MARCO section)
make v0-check # Compare Rust vs Python v0 (precision, QPS, latency)
make cleanPer-engine integration tests, each starts and stops its own containers:
make integration-test # Redis
make integration-test-{valkey,dragonfly,kividb,chroma}
make integration-test-{pgvector,qdrant,elasticsearch,opensearch}
make integration-test-{weaviate,milvus,mongodb}
make integration-test-vertex # cloud-only, needs real GCP credentials
make integration-test-no-docker- Run
make agent-checkafter any code change. Notmake checkalone. - Run the matching
make integration-test-<engine>after touching an engine. Changing shared code underengine/(e.g.redis_utils.rs,filter_guard.rs,index_naming.rs) affects several engines — run each. - Put logic in
src/(the library), not insrc/bin/. The library is unit-tested; the binaries are meant to be I/O only. The one regression in the regression this rule was written for was selection logic that ended up in a binary and so had no test to mutate. - Mutate your guard, not just your code. Break the thing the guard is named after and confirm the suite goes red. A guard that passes either way is decoration — this is the repo's most common review finding.
- Never bypass
maketargets for build/test/check.
src/
lib.rs # Library root — put LOGIC here, it is what gets unit-tested
readers/ # hdf5, jsonl, npy, compound(tar), sparse, multivector, metadata
synthetic.rs # Generators for the synthetic-* fixtures
msmarco.rs # MS MARCO corpus prep: sampling, oracle, NPY writers (unit-tested)
query_filter.rs # The ONE door from a dataset's `conditions` JSON to a filter (#219)
metrics.rs # recall / precision / MRR / NDCG — see the metric-naming note below
start_gate.rs # Worker barrier for parallel search
config.rs, redis_client.rs, parsers.rs
bin/
vector_db_benchmark/
main.rs cli.rs config.rs dataset.rs download.rs experiment.rs ground_truth.rs
engine/ # 15 engines + shared: mod.rs redis_utils.rs filter_guard.rs
# index_naming.rs geo.rs
generate_dataset.rs # -> `generate-dataset`, writes the synthetic-* fixtures
prepare_msmarco.rs # -> `prepare-msmarco`, builds/verifies MS MARCO corpora
tests/ # integration_<engine>.rs, plus harness_invariants +
# overhead_invariants (both run in CI, no Docker)
datasets/datasets.json # Dataset registry
experiments/configurations/ # Engine configs (HNSW params, search params)
- HDF5 (
.hdf5): Single file withtrain,test,neighborsdatasets - JSONL (
type: "jsonl"): Directory withvectors.jsonl,queries.jsonl,neighbours.jsonl - Compound/TAR (
type: "tar"): Directory withvectors.npy,payloads.jsonl,tests.jsonl
Datasets are auto-downloaded from the link URL in datasets.json when not found locally.
Each of these produced a real incident, and none of them look wrong while you are doing them.
- A config key that is parsed and then never applied. The run completes and
the result JSON names a tuning knob it did not use. Recall-based tests cannot
catch this — the numbers stay plausible. After adding a knob, assert the SERVER
reports it (
FT.INFO,indexes/describe, …), not that the code read it. - A guard written one dimension narrower than the bug it names, so it passes either way and its name convinces everyone the case is covered. Always mutate the guard.
- Ground truth that is subtly for a different corpus. Row counts match, ids are in range, recall comes out "reasonable" and is meaningless. Ground truth must be derived from, or cross-checked against, the exact bytes uploaded.
topderived from the ground-truth width. A search config withouttoptakes it from the dataset's neighbour count. On a wide-ground-truth dataset that runs every point at k=width, and HNSW raises effective breadth to at least k — so anefsweep publishes one measurement under several config names.- Two agents on one branch or worktree silently clobber each other's commits. One nearly reverted an already-merged PR. Verify the SHA you push is the SHA you gated, and check the tree contents, not just a green build.
--skip-uploadagainst a partially-loaded server scores recall over a fraction of the corpus without erroring. The completeness gate exists for this.
Most entries in datasets/datasets.json auto-download from their link.
Entries with no link do not exist until a tool writes them:
synthetic-*—cargo run --release --bin generate-dataset. Small, fixed-seed, exist to exercise sparse / hybrid / multivector / filter code paths.
The msmarco-cohere-1024-* entries all DO have links and auto-download; they are
rebuilt rather than generated, and have their own section below.
A dataset whose path names its own size (…/1M) must declare a matching
vector_count; config.rs enforces this, because a mismatch once made a sweep
score recall over 0.01% of a corpus. A deliberate subset gets a path that does
not claim a size it does not have.
msmarco-cohere-1024-{100K,1M,10M}-cosine and their -crc32- twins: MS MARCO
v2.1 passages + Cohere embed-v3 vectors and real document metadata. All six
auto-download from S3; prepare-msmarco rebuilds them from Hugging Face.
- Logic lives in
src/msmarco.rs(unit-tested); the binary is I/O only. That convention is load-bearing — the one regression in this area was offset-selection logic that ended up in the binary and so had no test. - Size is fixed by the dataset name, never a flag. The
-crc32-variants declare their REALIZED count (99,964 / 1,000,044 / 9,999,959), since a hash threshold cannot land on a round number. - Ground truth is brute-forced per corpus and cross-checked against the upstream
global top-1k before anything is written;
--verifyre-runs that check against an already-prepared dataset. --discover-crc32re-derives the sampling thresholds (26.9 GB metadata scan).
- Parallel upload/search:
thread::scope+AtomicUsizework-stealing across batches - Vector encoding: f32 little-endian bytes for both Redis and VectorSets
- Score conversion: VectorSets uses
1.0 - score(1=identical, 0=opposite) - Config resolution:
./datasets/→ site-packages →v0/datasets/
HDF5_DIR: Path to HDF5 library (default:/usr/lib/x86_64-linux-gnu/hdf5/serial)REDIS_HOST,REDIS_PORT,REDIS_PASSWORD: Redis connection (default:localhost:6379)- Docker:
tests/docker-compose.test.ymlruns redis:8.8.0 on port 6399 for integration tests
The v0/ directory contains the original Python implementation. The Rust port
now covers more engines than v0/ did; the table below is the mapping.
| Engine | Python (v0/) | Rust | Client Library |
|---|---|---|---|
| Redis / RediSearch | redis |
redis |
redis 0.27 |
| VectorSets | vectorsets |
vectorsets |
redis 0.27 |
| Elasticsearch | elasticsearch |
elasticsearch |
elasticsearch 8.15 |
| Milvus | milvus |
milvus |
reqwest (REST API v2) |
| OpenSearch | opensearch |
opensearch |
opensearch 2.3 |
| pgvector | pgvector |
pgvector |
postgres 0.19 + pgvector 0.4 |
| Qdrant | qdrant |
qdrant |
qdrant-client 1.17 (gRPC) |
| Weaviate | weaviate |
weaviate |
tonic/prost (gRPC search) + reqwest (REST schema) |
| MongoDB | — | mongodb |
mongodb 3 (sync) |
| Valkey | — | valkey |
redis 0.27 * |
| Turbopuffer | — | turbopuffer |
turbopuffer-client 0.0.4 |
| Dragonfly | — | dragonfly |
redis 0.27 (RESP) |
| KiviDB | — | kividb |
redis 0.27 (RESP) |
| Chroma | — | chroma |
reqwest (REST v2) |
| Vertex AI | — | vertex |
tonic/prost (gRPC) + reqwest — cloud-only |
* Valkey GLIDE has no Rust crate (valkey-io/valkey-glide#828, closed NOT_PLANNED). GLIDE maintainers recommend redis-rs for Rust.
Python engine configs use engine names like redis, vectorsets. Rust engine configs use the same.
When migrating or modifying search logic, always compare precision output between the Rust and Python versions to verify correctness. Both versions save results as JSON files in results/.
Python v0 search result JSON fields:
{
"total_time": 1.23,
"mean_time": 0.0012,
"mean_precisions": 0.95,
"std_time": 0.0003,
"min_time": 0.0008,
"max_time": 0.0021,
"rps": 820.5,
"p50_time": 0.0011,
"p95_time": 0.0018,
"p99_time": 0.0020,
"precisions": [0.9, 1.0, ...],
"latencies": [0.001, 0.0012, ...]
}Rust search result JSON uses the same field names as Python v0 for timing/throughput (rps, p50_time, …). Both versions write to results/ with filename format: {engine}-{dataset}-search-{id}-{pid}-{timestamp}.json.
Python v0 — and upstream qdrant/vector-db-benchmark, engine/base_client/search.py — computes len(ids & expected[:top]) / top, i.e. recall@top, and publishes it under the key mean_precisions. Our Rust build emits, since schema version 2:
| key | formula | notes |
|---|---|---|
mean_precision_at_returned |
`hits / | results returned |
mean_recall |
`hits / | valid ground-truth ids in expected[:top] |
precisions_at_returned |
per-query array of the above precision | only under --dump-raw-latencies; was precisions |
precision_at_returned_dist |
digest of that array | was precision_dist |
Every result file carries a top-level metrics_schema block with these formulas plus a ground_truth width profile and a comparable_to_upstream_mean_precisions field naming the key (if any) that can be overlaid on upstream numbers. We never emit a key named mean_precisions — the same name for two formulas is the state that must not ship.
Calibration (calibration_precision) targets mean_precision_at_returned; when the dataset's ground truth is narrower than top the target can be unreachable by construction, which the run now warns about and records in params.calibration.reached_target.
- Run the same dataset + engine config on both versions:
# Python v0 cd v0 && poetry run vector-db-benchmark --engines "redis-m-16-ef-128" --datasets "h-and-m-2048-angular-filters" # Rust ./target/release/vector-db-benchmark --engines "redis-m-16-ef-128" --datasets "h-and-m-2048-angular-filters"
- Compare
mean_precisions(Python) vsmean_recall(Rust) — the matching pair; values should agree within floating-point tolerance on full-width ground truth.scripts/v0_check.shdoes this mapping for you. Comparing Pythonmean_precisionsagainst Rustmean_precision_at_returnedcompares recall with precision. - If it differs, check: score conversion, neighbor ordering, distance metric, top-k cutoff logic — and whether the dataset's ground-truth rows are shorter than
top(metrics_schema.ground_truth.queries_with_fewer_than_top_neighboursin the Rust output), in which case the two denominators legitimately disagree.