ring-db
A Rust library for ring queries in high-dimensional vector spaces.
Instead of nearest-neighbour search, ring-db retrieves every vector whose
Euclidean distance to a query falls inside a specified interval.
Three query shapes are supported out of the box:
| Query type | Interval | Typical use |
|---|---|---|
RingQuery |
[d − λ, d + λ] | Hollow shell at distance d |
RangeQuery |
[d_min, d_max] | Arbitrary distance band |
DiskQuery |
[0, d_max] | Full ball / nearest-within-radius |
RingQuery RangeQuery DiskQuery
┌──── λ ───┐ ┌──────────┐ ┌──────────────┐
d ─────────── q d_min d_max q 0 d_max q
└──────────┘
Table of contents
- Why ring queries?
- Use cases
- Quick start
- Payload strategies
- API overview
- Persistence
- Architecture
- Performance
- Running the benchmarks
- Running the tests
- CLI example
- Roadmap
Why ring queries?
Most vector databases answer "what is closest to X?". Ring queries answer a different question: "what is at distance d from X, give or take λ?"
This matters when the magnitude of similarity has semantic meaning and you want to filter by it rather than rank by it. Nearest-neighbour returns a ranked list; a ring query returns a boolean membership set — every result satisfies the same geometric constraint.
The three query types let you express the constraint directly:
RingQuery— symmetric band defined by a centre distancedand half-widthλ: interval[d-λ, d+λ].RangeQuery— arbitrary interval[d_min, d_max], no conversion needed.DiskQuery— full ball of radiusd_max, equivalent toRangeQuery { d_min: 0, d_max }.
Internally, the library avoids computing square roots by working with squared L2 distances:
lower_sq = d_min²
upper_sq = d_max²
dist_sq = ‖x‖² + ‖q‖² − 2·(x · q)
The dot product and norms are computed with four independent accumulators so
the compiler can emit fused multiply-add instructions (fmla/vfmadd).
Use cases
| Domain | Query | Ring semantics |
|---|---|---|
| Anomaly detection | Is this embedding at an unusual distance from the cluster centroid? | Query at d = expected_radius, flag hits at d ± small_λ |
| Content deduplication | Find near-duplicates, exclude exact copies and fully different items | Set d to a "suspicious similarity" threshold, narrow λ |
| Recommendation diversity | Return items similar to but not the same as the seed | Exclude the d ≈ 0 ball with a positive lower bound |
| Spatial shells | Find all POIs within a GPS distance band (e.g. 500 m–1 km) | Direct geometric interpretation |
| Music / audio fingerprinting | Match recordings at a specific perceptual distance | Encodes "sounds like but is not identical" |
| Security / threat intel | Find embeddings of known-bad patterns at a specific mutation distance | Ring encodes "one edit away from known threat" |
Quick start
Add to Cargo.toml:
[]
= "0.4"
= "0.4"
= { = "1", = ["derive"] } # only needed for serde payloads
= { = "1", = ["derive"] } # only needed for pod payloads
Ring query — no payload
use ;
let mut db = new.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap; // dist = 5.0 from origin
let db = db.build.unwrap;
// Find all vectors at distance ≈ 5 from the origin (band: [4.5, 5.5])
let result = db.query.unwrap;
// result.hits is Vec<Hit> — each Hit carries the id and squared distance
for hit in &result.hits
println!; // "cpu"
println!;
// ids() is a convenience method when you need a plain Vec<u32>
let ids = result.ids; // → vec![1, 2]
Range query — explicit [d_min, d_max] band
use ;
let mut db = new.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap;
let db = db.build.unwrap;
let result = db.query_range.unwrap;
Disk query — everything within radius d_max
use ;
let mut db = new.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap;
let db = db.build.unwrap;
let result = db.query_disk.unwrap;
Payload strategies
ring-db ships a #[derive(Payload)] macro (from the ring-db-derive crate,
re-exported as ringdb::Payload) that wires a user type to its storage
strategy at compile time — no runtime dispatch, no trait objects.
Two strategies are available:
| Strategy | Attribute | Storage | Fetch | Best for |
|---|---|---|---|---|
| Serde (default) | (none) | bincode, variable-size | T (owned) |
Strings, enums, dynamic data |
| Pod | #[payload(storage = "pod")] |
raw bytes, fixed-size | &T zero-copy |
repr(C) structs, numeric records |
Serde payload — flexible, variable-size
use ;
use ;
let mut db: = new.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap;
let db = db.build.unwrap;
let result = db.query.unwrap;
// Fetches payloads by deserializing from mmap via bincode
let docs = db.fetch_payloads.unwrap;
for doc in &docs
Pod payload — zero-copy, maximum retrieval performance
Use #[payload(storage = "pod")] when your payload is a fixed-size
plain-old-data struct (#[repr(C)] + bytemuck::Pod). The library stores
raw bytes back-to-back — no offset table — and returns a &T pointing
directly into the mmap. No allocation, no deserialization, O(1).
When to choose Pod: if retrieval latency is critical (real-time serving, large hit counts, tight SLA), the Pod strategy is the right choice for numeric or fixed-size payloads. On 30 M vectors with ~164 K hits, Pod payload fetch is ~288× faster than Serde (92 µs vs 26 ms — see Performance).
use ;
use ;
let mut db: = new.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap;
let db = db.build.unwrap;
let result = db.query.unwrap;
// Zero-copy: &GeoPoint points directly into the mmap — no heap allocation
let points: = db.fetch_pods;
for pt in points
Requirements for Pod storage:
- The type must be
#[repr(C)] - Must derive
bytemuck::Pod+bytemuck::Zeroable - Must be
Copy(fixed-size by definition) - No heap-allocated fields (no
String,Vec, etc.)
API overview
RingDbConfig
let config = RingDbConfig::new(dims); // in-memory, CPU backend (defaults)
let config = RingDbConfig::new(dims).with_persist_dir("/my/db"); // persisted to disk
| Field | Type | Description |
|---|---|---|
dims |
usize |
Number of dimensions per vector. Must be > 0. |
backend_preference |
BackendPreference |
Backend selection. Only Cpu is available today. |
persist_dir |
Option<PathBuf> |
Directory to persist the database. None = in-memory only. |
| Builder method | Description |
|---|---|
with_persist_dir(path) |
Enable persistence to the given directory (created automatically). |
with_backend_preference(p) |
Override the backend (default: BackendPreference::Cpu). |
RingDb<T> — builder
| Method | Description |
|---|---|
RingDb::new(config) |
Create an empty in-memory (or persist-configured) database. |
RingDb::load(dir, backend) |
Rehydrate a previously persisted database from dir. |
add_vector(v, payload) |
Insert one vector and its associated payload. |
build() |
Seal the database; returns SealedRingDb<T>. Writes files to disk if persist_dir is set. |
len() / is_empty() |
Number of staged vectors. |
dims() / backend_name() |
Configuration introspection. |
Vectors are assigned sequential IDs starting from 0 in insertion order.
SealedRingDb<T> — query
| Method | Description |
|---|---|
query(q: &RingQuery) |
Ring query: interval [d-λ, d+λ]. |
query_range(q: &RangeQuery) |
Range query: explicit [d_min, d_max]. |
query_disk(q: &DiskQuery) |
Disk query: full ball [0, d_max]. |
fetch_payload(id) |
Deserialize the payload for a single ID (serde or pod). |
fetch_payloads(ids) |
Deserialize payloads for a slice of IDs, in order. |
fetch_pod(id) |
Pod only — zero-copy &T reference into the mmap for a single ID. |
fetch_pods(ids) |
Pod only — zero-copy Vec<&T> references for a slice of IDs. |
len() / is_empty() / dims() |
Introspection. |
fetch_podandfetch_podsare statically gated: they only exist whenT::Store: RefPayloadStore<T>, which is only true for#[payload(storage = "pod")]types. Calling them on a Serde type is a compile error, not a runtime panic.
#[derive(Payload)]
use Payload;
// Default: serde storage — requires Serialize + DeserializeOwned
// Pod storage — requires repr(C) + bytemuck::Pod
RingQuery<'a>
RingQuery {
query: &[f32], // query vector, length == dims
d: f32, // centre of the ring (target distance)
lambda: f32, // half-width; interval = [max(0, d-λ), d+λ]
}
RangeQuery<'a>
RangeQuery {
query: &[f32], // query vector, length == dims
d_min: f32, // lower bound of the distance interval (inclusive, ≥ 0)
d_max: f32, // upper bound of the distance interval (inclusive, ≥ d_min)
}
DiskQuery<'a>
DiskQuery {
query: &[f32], // query vector, length == dims
d_max: f32, // radius of the ball (inclusive, ≥ 0)
}
// Equivalent to RangeQuery { d_min: 0.0, d_max }
QueryResult
result.hits // Vec<Hit> — all matches with their squared distances
result.backend_used // &'static str — e.g. "cpu"
result.elapsed // Duration — wall-clock query time
result.ids() // Vec<u32> — convenience: just the IDs, for fetch_payloads / fetch_pods
Hit
Each entry in result.hits is a Hit:
hit.id // u32 — insertion-order ID of the matching vector
hit.dist_sq // f32 — squared Euclidean distance to the query
// call hit.dist_sq.sqrt() for the actual distance
Hit is 8 bytes (two f32-sized fields, no padding) and derives Debug, Clone, Copy, and PartialEq.
Error handling
All fallible operations return Result<_, RingDbError>:
pub enum RingDbError {
DimensionMismatch { expected: usize, got: usize },
Payload(String), // serialization / deserialization error
Io(std::io::Error),
Corrupt(String), // missing / inconsistent persistence files
}
Payload storage internals
Payloads are designed to consume zero hot memory after the build phase.
Serde storage
- Build phase — each call to
add_vectorserializes the payload immediately (viabincode) and streams it to a temporary file. - Query phase — after
build(), the temp file is mmap-ed read-only. The OS can page payload bytes out under memory pressure. The only hot-path data is the offset table (8 bytes × number of vectors). - Fetch —
fetch_payloads(ids)slices the mmap by offset and runsbincode::deserializeper hit. O(hits), not O(dataset).
Pod storage
- Build phase — each payload is written as raw bytes
(
bytemuck::bytes_of) back-to-back, with no offset table. - Query phase — the file is mmap-ed read-only. No offset table is kept
in memory at all: position =
id × size_of::<T>(). - Fetch —
fetch_pods(ids)computes the offset arithmetically and callsbytemuck::from_bytes— zero copies, zero allocations. The returned&Tborrows directly from the mmap.
Persistence
ring-db can save the full database to disk and reload it in a later process. No re-insertion is required — the original vectors, norms, and payloads are reconstructed exactly.
Saving
Set persist_dir on the config before calling build():
use ;
let mut db = new.unwrap;
db.add_vector.unwrap;
db.add_vector.unwrap;
let _sealed = db.build.unwrap; // writes files to /tmp/mydb
build() creates the directory if it does not exist and writes:
| File | Content |
|---|---|
meta.bin |
dims + n_vectors as little-endian u64 |
vectors.bin |
Raw f32 vectors (row-major, n_vectors × dims floats) |
norms_sq.bin |
Raw f32 squared L2 norms (n_vectors floats) |
payloads.bin |
Serde: bincode bytes · Pod: raw T bytes |
offsets.bin |
Serde only: byte offsets (u64) into payloads.bin |
Loading
Pass the directory and your chosen backend to RingDb::load(). The correct
storage variant (SerdeStore or PodStore) is selected automatically from T:
use ;
use Path;
let db = load.unwrap;
let result = db.query.unwrap;
load() validates the file sizes against the stored metadata and returns
RingDbError::Corrupt if anything is inconsistent.
Architecture
┌──────────────────────────────────────────────────────┐
│ RingDb<T: Payload> (builder, mutable) │
│ │
│ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ vectors: Vec<f32> │ │ T::Builder │ │
│ │ norms_sq: Vec<f32> │ │ SerdeStoreBuilder │ │
│ └──────────┬──────────┘ │ or PodStoreBuilder │ │
│ │ build() └──────────┬───────────┘ │
└─────────────┼─────────────────────────┼──────────────┘
│ │
│ (if persist_dir set) │
▼ ▼
┌─────────────────────────────────────┐
│ Disk (/persist_dir/) │
│ meta.bin vectors.bin norms_sq.bin│
│ payloads.bin [offsets.bin] │
└──────────────┬──────────────────────┘
│ RingDb::load(dir, backend)
▼
┌──────────────────────────────────────────────────────┐
│ SealedRingDb<T> (immutable, Send + Sync) │
│ │
│ ┌─────────────────────────┐ ┌────────────────────┐ │
│ │ RingComputeBackend │ │ T::Store │ │
│ │ (trait object) │ │ SerdeStore<T> │ │
│ │ │ │ or PodStore<T> │ │
│ │ ┌─────────────────┐ │ │ (mmap, page-able) │ │
│ │ │ CpuBackend │ │ └────────────────────┘ │
│ │ │ Rayon parallel │ │ │
│ │ │ 4-acc dot prod │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Payload trait & derive
The Payload trait ties a user type to its concrete storage strategy at
compile time. It is implemented via #[derive(Payload)]:
RingDb<T> holds T::Builder and SealedRingDb<T> holds T::Store as
concrete fields — no Box<dyn>, no heap indirection on the payload path.
The compiler resolves the entire storage path at monomorphization time.
Backend trait
The RingComputeBackend trait separates the upload step from the query step,
reflecting the intended usage: upload once, then run many queries against
resident data without re-uploading.
pub trait RingComputeBackend: Send + Sync {
fn name(&self) -> &'static str;
fn upload_f32_dataset(&mut self, dims: usize, vectors: Vec<f32>, norms_sq: Vec<f32>) -> Result<()>;
// Ring / range search: returns all vectors with dist in [d_min, d_max]
fn ring_query_f32(&self, dims: usize, query: &[f32], d_min: f32, d_max: f32) -> Result<Vec<Hit>>;
// Disk (ball) search: returns all vectors with dist in [0, d_max]
// Default impl delegates to ring_query_f32; backends can override for a
// tighter loop that skips the lower-bound comparison entirely.
fn disk_query_f32(&self, dims: usize, query: &[f32], d_max: f32) -> Result<Vec<Hit>> { … }
}
query and query_range delegate to ring_query_f32. query_disk delegates
to disk_query_f32, which the CPU backend overrides to remove the
dist_sq >= lower_sq branch — a small but measurable saving when scanning
hundreds of millions of vectors.
New backends (WGPU, CUDA, AVX-512 hand-rolled) can be added without touching
the public API. Backends that do not override disk_query_f32 get the
ring-delegating default for free.
CPU backend implementation
- Parallelism — Rayon
par_chunks_exactsplits the vector matrix across all logical cores. - SIMD-friendly dot product — four independent float accumulators break the
dependency chain so the compiler emits
fmla.4s(NEON) orvfmadd231ps(AVX), yielding ~4× FP throughput on the reduction. - Precomputed norms —
‖x‖²is stored at insertion time; only a dot product is computed per vector at query time (no square root). - Squared bounds — both bounds are squared once before the loop to avoid
sqrtentirely. - Dedicated disk path —
disk_query_f32removes the lower-bound comparison (dist_sq >= 0) that is always true, giving the branch predictor one fewer condition to evaluate per vector.
Performance
Benchmarks run on 30 000 000 randomly generated float32 vectors in [-1, 1]^d,
with a ring width of 0.05 % of the expected inter-vector distance.
Measured with Criterion.
Ring query throughput (no payload)
| Dimensions | Vectors | Query time | Hit rate |
|---|---|---|---|
| 64 | 30 M | ~68 ms | ~0.55 % |
| 128 | 30 M | ~138 ms | ~0.61 % |
At 64 dims this is ~440 M vectors/second scanned on a single machine.
Payload fetch — Serde vs Pod (100-byte string payload, ~164 K hits)
Ring: [6.529, 6.535] · 164 298 hits out of 30 M vectors · 64 dimensions.
| Strategy | Fetch time | Notes |
|---|---|---|
Serde (bincode) |
~26.6 ms | Variable-size, heap-allocates per hit |
| Pod (zero-copy mmap) | ~92 µs | Fixed-size, &T from mmap, no alloc |
Pod is ~288× faster for payload retrieval when the payload is a
fixed-size repr(C) struct. Use #[payload(storage = "pod")] whenever
maximum retrieval throughput is required.
payload_fetch_dynamic/100B_string_payload/64
time: [25.959 ms 26.630 ms 27.608 ms]
payload_fetch_static/100B_string_payload/64
time: [92.162 µs 92.298 µs 92.443 µs]
Both are O(hits), not O(dataset), because payloads are addressed directly by position in the mmap.
Running the benchmarks
# Full benchmark suite (HTML report in target/criterion/)
# Single group
# With native CPU optimisations (recommended)
RUSTFLAGS="-C target-cpu=native"
Results are written to target/criterion/ as an interactive HTML report.
Running the tests
# All tests
# Correctness tests only (hand-crafted, analytically verified)
# Randomised sanity tests
# With output (useful to see ring stats)
CLI example
A small CLI that builds a random dataset and runs a ring query:
# 100 000 vectors, 64 dims, ring centred at d=5.0 with λ=0.2
Sample output:
ringdb demo
dataset : 100000 vectors × 64 dims
ring : d=5, λ=0.2
Building database … done in 45.12ms
backend : cpu
Running ring query … done
Results:
backend : cpu
hits : 312
query time : 2.34ms
hit IDs : [142, 891, … 312 total]
Roadmap
- GPU backend via
wgpu(compute shaders, cross-platform) - CUDA backend for datacenter workloads
- AVX-512 hand-rolled kernel (skipping Rayon for very small datasets)
- Approximate ring search (LSH / quantisation) for datasets > 1 B vectors
- Persistent on-disk format (
build()writes,RingDb::load()rehydrates) -
#[derive(Payload)]macro — serde and pod storage strategies - Zero-copy Pod payload fetch (
fetch_pod/fetch_pods) - Python bindings (PyO3)
License
MIT OR Apache-2.0