Vector search APIs with explicit storage and memory behavior.
Rust is the core. Python 3.12+ uses PyO3. Node 22, 24, and 26 use N-API. The CLI is for administration, not package runtime calls.
storageParquet segments
Vectors, sketches, routing rows, manifests, summaries, and graph blocks stay binary and columnar.
memorybounded routing
Approximate search walks from the top routing layer through selected parent pages before fetching selected leaf page objects.
apiids or vectors
Normal searches return ids or stored vectors. Report APIs add counters for tuning.
Quickstart
Eight rungs from a first search to a full-featured production deployment — the basics, then the retrieval features (filtering, updates, hybrid search), then object storage, tuning, and serving. Each is a complete program you can copy and run — every snippet here is extracted from an example that runs in CI, so the code always works. Switch languages with the tabs.
1
Your first search
Create an index, add three vectors, and get the two nearest. That is the whole program — the index is just files under uri, local here or s3://… in production.
// Create an index. It lives entirely as files under `uri` — a local path
// here, or an `s3://…` URI for object storage. Nothing else to run.
let mut index = BorsukIndex::create(IndexConfig {
uri,
metric: VectorMetric::Euclidean,
dimensions: 3,
segment_max_vectors: 4096,
ram_budget_bytes: None,
text: false,
named_vectors: Default::default(),
})?;
// Add a few vectors with your own ids.
index.add(vec![
VectorRecord::new("alpha", vec![0.0, 0.0, 0.0]),
VectorRecord::new("beta", vec![1.0, 0.0, 0.0]),
VectorRecord::new("gamma", vec![0.0, 5.0, 0.0]),
])?;
// Ask for the 2 nearest neighbours. `exact` returns the true top-k.
let ids = index.search_ids(&[0.1, 0.0, 0.0], SearchOptions::exact(2))?;
assert_eq!(ids, ["alpha", "beta"]);
println!("nearest: {ids:?}");
# Create an index. It lives entirely as files under `uri` — a local path
# here, or an `s3://…` URI for object storage. Nothing else to run.
index = borsuk.create(
uri=Path(root).as_uri(),
metric=borsuk.VectorMetricName.EUCLIDEAN,
dimensions=3,
segment_size=4096,
)
# Add a few vectors with your own ids.
index.add(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
ids=["alpha", "beta", "gamma"],
)
# Ask for the 2 nearest neighbours. `k` with exact mode returns the true top-k.
ids = index.search_ids([0.1, 0.0, 0.0], k=2)
assert ids == ["alpha", "beta"]
print("nearest:", ids)
// Create an index. It lives entirely as files under `uri` — a local path here,
// or an `s3://…` URI for object storage. Nothing else to run.
const index = await create({
uri: pathToFileURL(root).href,
metric: VectorMetricName.Euclidean,
dimensions: 3,
segmentMaxVectors: 4096,
});
// Add a few vectors with your own ids.
await index.add(
[
[0, 0, 0],
[1, 0, 0],
[0, 5, 0],
],
["alpha", "beta", "gamma"],
);
// Ask for the 2 nearest neighbours. `k` with exact mode returns the true top-k.
const ids = await index.searchIds([0.1, 0, 0], { k: 2 });
console.log("nearest:", ids);
2
Read the report
search_with_report returns the hits plus exactly what the query did: bytes read, segments searched, and the object-store requests it issued. This report is how you tune everything that follows.
// `search_with_report` returns the hits plus everything the query touched:
// bytes read, segments searched, and the object-store requests it issued.
let report = index.search_with_report(&[0.1, 0.0, 0.0], SearchOptions::exact(2))?;
println!(
"hits={:?} bytes_read={} segments_searched={} requests={} (gets={}, heads={})",
report
.hits
.iter()
.map(|h| h.id.to_string())
.collect::<Vec<_>>(),
report.bytes_read,
report.segments_searched,
report.requests.total(),
report.requests.gets,
report.requests.heads,
);
# `search_with_report` returns the hits plus everything the query touched:
# bytes read, segments searched, and the object-store requests it issued.
report = index.search_with_report(
[0.1, 0.0, 0.0], k=2, mode=borsuk.SearchMode.EXACT
)
print(
f"hits={[hit.id for hit in report.hits]} "
f"bytes_read={report.bytes_read} "
f"segments_searched={report.segments_searched} "
f"requests={report.requests.total} "
f"(gets={report.requests.gets}, heads={report.requests.heads})"
)
// `searchWithReport` returns the hits plus everything the query touched: bytes
// read, segments searched, and the object-store requests it issued.
const report = await index.searchWithReport([0.1, 0, 0], { k: 2, mode: SearchMode.Exact });
console.log(
`hits=${report.hits.map((hit) => hit.id).join(",")} ` +
`bytesRead=${report.bytesRead} segmentsSearched=${report.segmentsSearched} ` +
`requests=${report.requests.total} (gets=${report.requests.gets}, heads=${report.requests.heads})`,
);
3
Filter by metadata
Attach schemaless metadata to any vector, then constrain a search with a Pinecone-style operator dict. The filter runs before ranking, so a selective filter is both fast and exact — whole segments that can't match are skipped unread.
// Attach schemaless metadata to any vector, then constrain a search with a
// Pinecone-style operator dict. The filter is applied *before* ranking, so a
// selective filter is fast and exact — whole segments that cannot match are
// skipped unread.
let genre = |value: &str| {
let mut meta = Metadata::new();
meta.insert("genre".into(), MetaValue::Str(value.into()));
meta
};
index.add(vec![
VectorRecord::new("a", vec![0.0, 0.0]).with_metadata(genre("comedy")),
VectorRecord::new("b", vec![0.1, 0.0]).with_metadata(genre("drama")),
VectorRecord::new("c", vec![0.2, 0.0]).with_metadata(genre("comedy")),
])?;
let filter =
Filter::from_json(&serde_json::json!({ "genre": { "$eq": "comedy" } })).expect("valid");
let ids = index.search_ids(&[0.0, 0.0], SearchOptions::exact(5).with_filter(filter))?;
assert_eq!(ids, ["a", "c"]);
println!("filtered (genre=comedy): {ids:?}");
# Attach schemaless metadata to any vector, then constrain a search with a
# Pinecone-style operator dict. The filter is applied *before* ranking, so
# a selective filter is fast and exact — whole segments that cannot match
# are skipped unread.
index.add(
[[0.0, 0.0], [0.1, 0.0], [0.2, 0.0]],
ids=["a", "b", "c"],
metadata=[{"genre": "comedy"}, {"genre": "drama"}, {"genre": "comedy"}],
)
report = index.search_with_report(
[0.0, 0.0],
k=5,
filter={"genre": {"$eq": "comedy"}},
include_metadata=True,
)
ids = [hit.id for hit in report.hits]
assert ids == ["a", "c"]
print("filtered (genre=comedy):", ids)
// Attach schemaless metadata to any vector, then constrain a search with a
// Pinecone-style operator dict. The filter is applied *before* ranking, so a
// selective filter is fast and exact — whole segments that cannot match are
// skipped unread.
await index.add(
[
[0, 0],
[0.1, 0],
[0.2, 0],
],
{
ids: ["a", "b", "c"],
metadata: [{ genre: "comedy" }, { genre: "drama" }, { genre: "comedy" }],
},
);
const report = await index.searchWithReport([0, 0], {
k: 5,
filter: { genre: { $eq: "comedy" } },
includeMetadata: true,
});
const ids = report.hits.map((hit) => hit.id);
console.log("filtered (genre=comedy):", ids);
4
Update and delete
upsert inserts-or-replaces a record by id in one atomic publish — reads immediately see only the new version, and there is only ever one live copy of an id. (add stays insert-only; delete soft-deletes and is reclaimed by compaction.)
// `add` is insert-only; `upsert` inserts-or-replaces by id in one atomic
// publish. Reads immediately see only the new version, and there is only ever
// one live copy of an id — the superseded one is reclaimed by compaction.
index.add(vec![
VectorRecord::new("a", vec![0.0, 0.0]),
VectorRecord::new("b", vec![1.0, 0.0]),
])?;
index.upsert(vec![VectorRecord::new("a", vec![0.0, 9.0])])?; // move "a" away
let near_origin = index.search_ids(&[0.0, 0.0], SearchOptions::exact(3))?;
assert_eq!(near_origin[0], "b"); // "a" is now far from the origin
assert_eq!(near_origin.iter().filter(|id| *id == "a").count(), 1);
println!("after upsert, nearest origin: {near_origin:?}");
# `add` is insert-only; `upsert` inserts-or-replaces by id in one atomic
# publish. Reads immediately see only the new version, and there is only
# ever one live copy of an id — the superseded one is reclaimed by
# compaction.
index.add([[0.0, 0.0], [1.0, 0.0]], ids=["a", "b"])
index.upsert([[0.0, 9.0]], ids=["a"]) # move "a" away from the origin
near_origin = index.search_ids([0.0, 0.0], k=3)
assert near_origin[0] == "b" # "a" is now far from the origin
assert near_origin.count("a") == 1
print("after upsert, nearest origin:", near_origin)
// `add` is insert-only; `upsert` inserts-or-replaces by id in one atomic
// publish. Reads immediately see only the new version, and there is only ever
// one live copy of an id — the superseded one is reclaimed by compaction.
await index.add(
[
[0, 0],
[1, 0],
],
["a", "b"],
);
await index.upsert([[0, 9]], ["a"]); // move "a" away from the origin
const nearOrigin = await index.searchIds([0, 0], { k: 3 });
console.log("after upsert, nearest origin:", nearOrigin);
5
Full-text & hybrid
Turn on text to index BM25 alongside the vectors, then fuse both legs in one query. Reciprocal-rank fusion (the default) needs no tuning; switch to weighted fusion when you want to lean on one leg. Sparse (SPLADE-style) named vectors fuse in the same way.
// Turn on `text` to index BM25 alongside the vectors, then fuse both legs in
// one query. Reciprocal-rank fusion (the default) needs no tuning; switch to
// weighted fusion when you want to lean on one leg.
index.add(vec![
VectorRecord::new("a", vec![0.0, 0.0]).with_text("red apple"),
VectorRecord::new("b", vec![1.0, 0.0]).with_text("green apple pie"),
VectorRecord::new("c", vec![0.0, 1.0]).with_text("blue sky"),
])?;
let query = HybridQuery::new()
.with_vector("", vec![0.0, 0.0])
.with_text("apple");
let report = index.search_hybrid(&query, HybridOptions::new(3))?;
let ids: Vec<_> = report.hits.iter().map(|hit| hit.id.to_string()).collect();
assert!(!ids.is_empty());
println!("hybrid (dense + text): {ids:?}");
# Turn on `text` to index BM25 alongside the vectors, then fuse both legs
# in one query. Reciprocal-rank fusion (the default) needs no tuning;
# switch to weighted fusion when you want to lean on one leg.
index.add(
[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
ids=["a", "b", "c"],
text=["red apple", "green apple pie", "blue sky"],
)
hits = index.search_hybrid(vectors={"": [0.0, 0.0]}, text="apple", k=3)
assert hits
print("hybrid (dense + text):", hits)
// Turn on `text` to index BM25 alongside the vectors, then fuse both legs in
// one query. Reciprocal-rank fusion (the default) needs no tuning; switch to
// weighted fusion when you want to lean on one leg.
await index.add(
[
[0, 0],
[1, 0],
[0, 1],
],
{ ids: ["a", "b", "c"], text: ["red apple", "green apple pie", "blue sky"] },
);
const hits = await index.searchHybrid({ vectors: { "": [0, 0] }, text: "apple" }, { k: 3 });
console.log("hybrid (dense + text):", hits);
6
Run it on object storage
Point the same index at an s3://… URI. Paged routing (the default) avoids loading the complete routing table; a local cache_dir keeps fetched objects on fast disk for disk-cached queries. This snippet runs in CI against live MinIO and SeaweedFS.
// Open the same index straight from object storage. Serving metadata is
// prepared at open; a local `cache_dir` keeps fetched immutable cells on
// disk so repeated queries can issue zero backing-store GETs.
let mut reopened = BorsukIndex::open_with_cache(&uri, Some(PathBuf::from(&cache)))?;
let report = reopened.search_with_report(
&[0.04, 0.07],
SearchOptions::approx(1, LeafMode::PqScan).with_max_candidates_per_segment(2),
)?;
println!(
"nearest on s3: {} ({} object-store requests)",
report.hits[0].id,
report.requests.total(),
);
# Open the same index straight from object storage. Serving metadata is
# prepared at open; a local `cache_dir` keeps fetched immutable cells on
# disk so repeated queries can issue zero backing-store GETs.
reopened = borsuk.open(uri, cache_dir=cache)
report = reopened.search_with_report(
[0.04, 0.07],
k=1,
mode=borsuk.SearchMode.APPROX,
leaf_mode=borsuk.LeafModeName.PQ_SCAN,
max_candidates_per_segment=2,
)
print(
f"nearest on s3: {report.hits[0].id} ({report.requests.total} object-store requests)"
)
// Open the same index straight from object storage. Serving metadata is
// prepared at open; a local `cacheDir` keeps fetched immutable cells on disk
// so repeated queries can issue zero backing-store GETs.
const reopened = await open(uri, { cacheDir: cache });
const report = await reopened.searchWithReport([0.04, 0.07], {
k: 1,
mode: SearchMode.Approx,
leafMode: LeafModeName.PqScan,
maxCandidatesPerSegment: 2,
});
console.log(
`nearest on s3: ${report.hits[0]?.id} (${report.requests.total} object-store requests)`,
);
7
Tune recall against I/O
Approximate search spends three explicit budgets — segments to read, routing metadata to look ahead, and rows to exact-score per segment — instead of hidden magic constants. Tighten them while watching the report; smaller budgets read less but can lower recall.
// Approximate search spends three explicit budgets instead of hidden magic:
// how many segments to read, how much routing metadata to look ahead, and how
// many rows to exact-score per segment. Pick a leaf mode, then tighten budgets
// while watching the report — smaller budgets read less but can lower recall.
let query = [0.1, 0.0, 0.0];
let cheap = index.search_with_report(
&query,
SearchOptions::approx(2, LeafMode::PqScan)
.with_max_segments(1)
.with_max_candidates_per_segment(2),
)?;
let thorough = index.search_with_report(
&query,
SearchOptions::approx(2, LeafMode::PqScan)
.with_max_segments(8)
.with_routing_page_overfetch(8),
)?;
println!(
"cheap: {} segments, {} bytes | thorough: {} segments, {} bytes",
cheap.segments_searched, cheap.bytes_read, thorough.segments_searched, thorough.bytes_read,
);
# Approximate search spends three explicit budgets instead of hidden magic:
# how many segments to read, how much routing metadata to look ahead, and
# how many rows to exact-score per segment. Tighten budgets while watching
# the report — smaller budgets read less but can lower recall.
query = [0.1, 0.0, 0.0]
cheap = index.search_with_report(
query,
k=2,
mode=borsuk.SearchMode.APPROX,
leaf_mode=borsuk.LeafModeName.PQ_SCAN,
max_segments=1,
max_candidates_per_segment=2,
)
thorough = index.search_with_report(
query,
k=2,
mode=borsuk.SearchMode.APPROX,
leaf_mode=borsuk.LeafModeName.PQ_SCAN,
max_segments=8,
routing_page_overfetch=8,
)
print(
f"cheap: {cheap.segments_searched} segments, {cheap.bytes_read} bytes | "
f"thorough: {thorough.segments_searched} segments, {thorough.bytes_read} bytes"
)
// Approximate search spends three explicit budgets instead of hidden magic: how
// many segments to read, how much routing metadata to look ahead, and how many
// rows to exact-score per segment. Tighten budgets while watching the report —
// smaller budgets read less but can lower recall.
const query = [0.1, 0, 0];
const cheap = await index.searchWithReport(query, {
k: 2,
mode: SearchMode.Approx,
leafMode: LeafModeName.PqScan,
maxSegments: 1,
maxCandidatesPerSegment: 2,
});
const thorough = await index.searchWithReport(query, {
k: 2,
mode: SearchMode.Approx,
leafMode: LeafModeName.PqScan,
maxSegments: 8,
routingPageOverfetch: 8,
});
console.log(
`cheap: ${cheap.segmentsSearched} segments, ${cheap.bytesRead} bytes | ` +
`thorough: ${thorough.segmentsSearched} segments, ${thorough.bytesRead} bytes`,
);
8
Serve in production
Open for serving and watch the request rate. Rust exposes an optional byte-bounded decoded cache plus separate global query and cell-decode caps; overlapping reads of one immutable cell are single-flight by default. Every language reads the requests breakdown for monitoring.
// Open for serving. Paged routing avoids retaining every leaf summary. A
// shared decoded-segment cache trades a fixed RAM budget for fewer reads of
// an explicitly hot set; query and leaf-read caps bound working memory.
let index = BorsukIndex::open_with_options(
&uri,
OpenOptions {
segment_cache_max_bytes: Some(256 * 1024 * 1024),
max_active_searches: 4,
max_waiting_searches: 16,
leaf_read_width: 32,
max_inflight_leaf_reads: 48,
..OpenOptions::default()
},
)?;
// Every report carries the object-store requests it issued, so you can chart
// requests-per-query straight from production traffic.
let report =
index.search_with_report(&[0.1, 0.0, 0.0], SearchOptions::approx(2, LeafMode::PqScan))?;
println!(
"requests/query: {} (gets={}, heads={}, lists={})",
report.requests.total(),
report.requests.gets,
report.requests.heads,
report.requests.lists,
);
# Open for serving. Paged routing avoids retaining every leaf summary; a
# local `cache_dir` keeps fetched immutable cells on disk. Every report
# carries the backing-store requests it issued, so you can chart
# requests-per-query straight from production traffic.
index = borsuk.open(uri, cache_dir=cache)
report = index.search_with_report(
[0.1, 0.0, 0.0],
k=2,
mode=borsuk.SearchMode.APPROX,
leaf_mode=borsuk.LeafModeName.PQ_SCAN,
)
print(
f"requests/query: {report.requests.total} "
f"(gets={report.requests.gets}, heads={report.requests.heads}, "
f"lists={report.requests.lists})"
)
// Open for serving. Paged routing avoids retaining every leaf summary; a local
// `cacheDir` keeps fetched immutable cells on disk. Every report carries the
// backing-store requests it issued, so you can chart requests-per-query straight
// from production traffic.
const index = await open(uri, { cacheDir: cache });
const report = await index.searchWithReport([0.1, 0, 0], {
k: 2,
mode: SearchMode.Approx,
leafMode: LeafModeName.PqScan,
});
console.log(
`requests/query: ${report.requests.total} ` +
`(gets=${report.requests.gets}, heads=${report.requests.heads}, lists=${report.requests.lists})`,
);
ELI5 intuition
Think of BORSUK as many sealed boxes of vectors plus a small map. The boxes live on disk or in S3. RAM keeps the map and counters, not every vector. At production scale the map is a tree, not one flat table: root routing index, parent routing pages when needed, L0 routing pages, then bounded vector and graph blobs. A query reads the top map page, drills down to a few promising boxes, opens those boxes, and exact-reranks the candidates before returning ids or vectors.
Writes are fast because new vectors go into fresh L0 boxes. Compaction is the cleanup step after a delivery rush: it groups nearby vectors into read-optimized leaves, rebuilds graph blocks for those leaves, and publishes a new map. Scoped compaction should touch only the boxes being reorganized and the map pages needed to publish them.
BORSUK is not a magic always-perfect shortcut. Exact search can cover the full active index. Approximate search is a budgeted tradeoff: more routing metadata overfetch, segments, bytes, and candidates usually improve recall while reading more data. The report tells you when a query stopped because of a budget, so low I/O is visible instead of pretending the whole index was searched.
Watch a query run in 3D
Fourteen vectors in three clusters, one query, in real 3D. Step through how BORSUK bubbles the vectors into segments, prunes bubbles by their centroid and radius, reads only the promising ones, and exact-reranks the survivors. On the Read step, switch leaf modes to see which rows inside a bubble get exact-scored. Click a step; drag the scene to look around.
Leaf mode (Read step)
Loading the interactive 3D view…
Drag to orbit · click a step to advance
Watch a filtered query run in 3D
Same idea, with metadata. Every vector carries attributes — here a
genre and a
year — and a filter keeps only the rows that match. Crucially the genres
are spread across all three spatial clusters, so a filter is not
the same as spatial proximity. Pick a filter and step through it: the
rejected rows fade, and BORSUK ranks only the survivors — wherever they
sit. (Filtering before ranking, rather than ranking first and
dropping non-matches after, is the whole trick.) Legend:
rock
jazz
pop.
Filter
Loading the interactive 3D view…
Drag to orbit · pick a filter · click a step
Build an index in 3D — from empty to compacted
This is a live simulation, not a canned animation: the grouping, splitting, and merging all run in your browser. Start from nothing and drive the whole lifecycle. Add vectors and watch them route into segment bubbles; keep adding until a bubble is over capacity and splits; tombstone a few with a delete; then compact to reclaim the dead rows and merge the sparse bubbles. Drag to orbit.
Records are ids plus vectors. Callers may add only vectors and receive generated string ids, or add vectors with their own string, binary, or integer ids. There are no payload references in the public API. Explicit integer ids are encoded as compact unsigned varint bytes; shorter ids are preferred because storage uses compact binary ids internally.
index.add_vectors(vec![vec![1.0, 0.0], vec![0.0, 1.0]])?;
index.add_vectors_with_ids(vec![vec![0.9, 0.1]], vec!["alpha".into()])?;
let ids = index.search_ids(&[1.0, 0.0], SearchOptions::exact(10))?;
let vectors = index.search_vectors(&[1.0, 0.0], SearchOptions::exact(10))?;
let alpha = index.get_vector("alpha")?;
The same bounded-memory engine also does in-place updates, full-text and sparse retrieval, hybrid fusion, and a per-query cost report — all over object storage.
Operation
What it does
upsert(vectors, ids)
Insert-or-replace by id in one atomic publish (MVCC generations); reads immediately see only the new version. delete soft-deletes via a tombstone reclaimed by compaction.
search_hybrid(vectors, text, k, fusion)
Fuse a dense vector leg, a BM25 text leg, and sparse named-vector legs with reciprocal-rank (default) or weighted fusion.
search_text(query, k)
Lexical BM25 full-text ranking over an inverted index (opt in with text=True at create).
Named vectors + kind="sparse"
Multiple vectors per record, searched by name. A sparse named vector uses an inverted-index backend (SPLADE-style, huge vocabularies) that never densifies; query it with search_sparse_named.
explain(query, k)
Return the query's object-store GET/HEAD requests, bytes read, routing pruning, cache-hit ratio, latency, and an estimated $/query under a cost model — before or instead of running it.
search_rerank(query, candidate_k, final_k, fn)
The retrieve → rerank → top-k RAG pipeline: pull a wide candidate set, rescore with your own model (e.g. a cross-encoder or MaxSim), keep the best few.
preload=True + warm()
Attempt to decode every active segment into the byte-bounded RAM cache. Graph-enabled indexes also decode and validate their immutable graphs in the same cache entry. Check WarmReport.coverage_complete: only complete coverage guarantees zero segment/graph reads; partial coverage remains a mixed-cache state and auto falls back to the storage scan. The default bound is 512 MiB.
Metadata filtering
Attach a JSON-like object to every vector, then constrain any search to the rows that match — genre = "rock" AND year ≥ 1990, tenant = "acme", in_stock = true. This is the native path used by the Pinecone, turbopuffer, and S3 Vectors migration adapters: keep your attributes next to the embedding, skip the separate database.
Filtering runs inside the search, not as a post-filter on the top-k. A row is eligible for ranking only if it passes the filter, and BORSUK keeps scanning until it has k matches or the budget is spent — so a selective filter never quietly returns fewer than k results. Values may be null, boolean, integer, float, string, timestamp, list, or nested map, addressed with dotted paths (artist.name).
Native indexes can also accept sparse vector inputs, store mostly-zero vectors compactly, declare named vectors, run BM25 full-text search, and fuse named-vector + text hybrid search; see the API reference. Migration adapters expose the vendor-compatible surfaces they map cleanly; Qdrant named dense vectors map to BORSUK named vectors.
let mut meta = Metadata::new();
meta.insert("genre".into(), MetaValue::Str("rock".into()));
meta.insert("year".into(), MetaValue::Int(1975));
index.add(vec![VectorRecord::new("song-1", vec![0.0, 0.0]).with_metadata(meta)])?;
let filter = Filter::from_json(&serde_json::json!({
"genre": "rock", "year": { "$gte": 1990 }
}))?;
let report = index.search_with_report(
&query,
SearchOptions::exact(10).with_filter(filter).with_include_metadata(true),
)?;
The CLI takes the same dialect: borsuk search --uri "$URI" --query '[0,0]' --k 10 --filter '{"genre":"rock","year":{"$gte":1990}}' --include-metadata, and borsuk add reads metadata from plain-JSON records.
Filter operators
A bare value is an equality test; nested objects use $-prefixed operators. Multiple operators on a field, and multiple fields, combine with logical AND.
Operator
Meaning
Example
value
equals (implicit $eq)
{"genre": "rock"}
$eq / $ne
equal / not equal
{"year": {"$ne": 2020}}
$gt $gte $lt $lte
numeric or lexicographic order
{"year": {"$gte": 1990, "$lt": 2000}}
$in / $nin
scalar is / is not in a list
{"genre": {"$in": ["rock", "jazz"]}}
$contains
the field's list contains a scalar
{"tags": {"$contains": "live"}}
$exists
path is present / absent
{"remastered": {"$exists": true}}
$regex
the field's string matches a regular expression
{"title": {"$regex": "^live at"}}
$geoRadius
a [lat, lon] point is within a great-circle radius (metres)
Evaluation is total — a filter never errors on a record, it matches or does not. A missing path fails positive operators and satisfies negative ones ($ne, $nin), mirroring Pinecone and MongoDB; use $exists to test presence. Cross-type comparisons are false, and $eq on a list is equality, not element matching (use $contains).
Why it saves money
Each segment summary carries compact statistics over its metadata — numeric min/max per path plus a presence bloom for strings and value kinds. Before fetching a segment, BORSUK asks could any row here match? If the statistics prove not, it skips the segment entirely: no object-storage GET, no bytes, no scan. A filter like tenant = "acme" over a multi-tenant index reads only the segments that hold that tenant. The search_with_report counters rows_evaluated, rows_passed_filter, and segments_pruned_by_filter quantify it per query; the measured selectivity sweep is in Research & Analysis.
Those resident stats are deliberately coarse so they cost almost no RAM. For exact pruning, each segment also has a small filter index — an exact inverted index over its string/boolean metadata — persisted as a separate sidecar object and fetched only when a query carries a filter, then dropped. It never sits in RAM, so it doesn't grow the footprint at any scale, and it catches what the coarse stats can't: a composite filter like {genre: rock, city: paris} can hit a segment whose bloom holds both values even though no single row has both — the exact index intersects them, sees zero matches, and skips the payload. The sidecar is self-validating, so a missing or corrupt one just falls back to reading the segment: it can only save I/O, never change results.
Migration adapters
Adapters emulate supported parts of the data-plane surface of Pinecone, turbopuffer, Amazon S3 Vectors, Chroma, and Qdrant. Existing code can keep familiar upsert/query/delete shapes while moving storage to a BORSUK root. These are migration aids, not unconditional behavioral drop-ins: service auth, control-plane operations, replication, publish semantics, filtering details, and score conventions can differ. Each namespace (or S3 Vectors index-in-a-bucket) becomes its own BORSUK index under a shared base URI. The per-adapter compatibility matrix records exactly what maps and what does not.
# before: from pinecone import Pinecone; pc = Pinecone(api_key="…")
from borsuk.compat.pinecone import Pinecone
pc = Pinecone(base_uri="file:///data/vectors", dimension=768, metric="cosine")
index = pc.Index("products")
index.upsert([("a", embedding, {"genre": "rock"})], namespace="store-1")
index.query(vector=embedding, top_k=10,
filter={"genre": {"$eq": "rock"}},
include_metadata=True, namespace="store-1")
Five adapters ship today: Pinecone, Amazon S3 Vectors, and turbopuffer (Python + TypeScript), plus Chroma and Qdrant (Python). Pinecone, S3 Vectors, and Chroma reuse BORSUK's native $-operator filter dialect; turbopuffer's tuple filters and Qdrant's must/should/must_not filters are translated automatically. Qdrant named dense vectors map to BORSUK named vectors; unsupported operations fail explicitly. Each namespace (or collection, or S3 index) becomes its own BORSUK index under a shared base URI. These are local, embedded backends over object storage — not network services — so they carry BORSUK's own publish semantics and distances rather than each vendor's auth, replication, or similarity score. Full reference and limits: docs/drop-in.md.
Create-Time Parameters
uri, metric, dimensions, and segment_max_vectors define the physical index. segment_max_vectors controls bounded L0 ingest objects; it is not the global pq-scan routing cell. Finalization assigns every vector independently to a global coarse-PQ cell while retaining its exact row in the physical segment. Read-optimized leaf size is controlled by compaction. routing_page_fanout controls the fallback routing tree width; publish and compaction compute the number of layers from active leaf count.
Runtime Parameters
cache_dir, open-time ram_budget, k, search mode, and leaf mode define how a reader behaves. CPU workers default to one fewer than the available CPUs, clamped to 1–4; BORSUK_CPU_THREADS overrides that process-wide compute budget. Blocking I/O waiters default to 88, while BORSUK_BACKING_GET_CONCURRENCY=64 is the separate process-wide physical-read ceiling. The object-store runtime therefore overlaps network waits without allowing decode and SIMD work to consume every host CPU. The v8 pq-scan path keeps only its global and coarse codebooks plus chunk metadata resident, pages product-code chunks for selected global coarse cells, and exact-reranks from lossless row sidecars. Approximate tuning is the coarse-cell budget plus the global rerank budget. Cache opens read fresh CURRENT, refetch stale active metadata cache files by checksum, and repair corrupt cached segment, graph, or routing page payloads from backing storage. Per-query cache hit/miss counters make that behavior measurable. Compaction is bounded by default; target_segment_max_vectors / targetSegmentMaxVectors controls compacted leaf size, while rebuild is reserved for explicit full source-level rewrites.
RAM Budget
Resident budget checks are hard failures. The library default is 512 MiB. BORSUK reports resident_bytes_estimate; if that exceeds the configured budget, create/open/add/compact returns a RAM budget error instead of dropping segments or returning partial results. Corpus-sized codes and exact vectors stay paged and are not counted as resident state.
Memory-Latency Levers
Open-time controls shape memory and latency under load. max_active_searches and max_waiting_searches bound work and queueing; leaf_read_width limits one query wave, while max_inflight_leaf_reads caps physical reads across the handle. Defaults are 8, 16, 32, and 48. Excess work returns an explicit overload error instead of occupying an unbounded blocking queue.
How memory stays low
Serving a large index within an explicit memory envelope is the point. Memory still depends on dimensions, cell width, concurrency, caches, and runtime overhead; the production caps prevent those factors from multiplying without bound.
Small resident routing state. Open prepares the global product codebook, the second-level coarse product codebook, and compact chunk references. Corpus-sized product codes and vectors remain immutable objects.
The index lives in object storage, not RAM. Global-PQ code cells and fixed-width lossless vector pages are fetched on demand. There is no resident vector arena; CURRENT is one tiny pointer.
Fixed product-code waves. At most 32 selected chunks are retained for one scan wave. Its top candidates are merged into a bounded global heap and all chunk payloads are released before the next wave. This produces the same PQ top-k as retaining every selected chunk.
Index-free global exact pages. Cell-aligned lossless vectors are fixed-width, so row ranges are computed arithmetically with no offset table, dictionary, or decompression state. The 128 MiB LRU is reserved for physical record-sidecar indexes used by late top-k ID reads and fallback paths.
Bloom fast-paths avoid fetches. Resident id, vector-signature, and tombstone blooms answer “not present / not deleted” with zero object-store I/O, so the common case pays nothing.
Concurrency doesn't multiply memory. Active and waiting query caps bound admission; leaf-wave and in-flight read caps separately bound S3 fan-out and retained buffers.
Overlapping cell reads are single-flight. Concurrent users selecting the same immutable checksum share one active fetch/decode. The allocation is released afterward, so this does not silently create an unbounded memory cache.
Shared decoded-segment cache, on demand.segment_cache_max_bytes lets concurrent queries share one decoded Arc<Segment>, so peak memory tracks a fixed byte budget rather than the reader count.
Bounded prefetch & content-addressed reuse. Query waves default to 32 leaf reads under a shared 48-read handle gate, while compaction reuses unchanged routing pages by checksum to bound write amplification and transient memory.
Disk-backed global partitioning. Finalization externally partitions PQ-code/location/lossless-vector rows under BORSUK_BUILD_SCRATCH_DIR (default .borsuk-scratch in the working directory). Scratch may scale with the corpus, while build RAM remains bounded by training, one segment, a 32 MiB code chunk, and a 16 MiB exact page.
These mechanisms are observable: SearchReport exposes bytes_read, routing resident_bytes_estimate, cache hits/misses, records considered vs scored, and the requests breakdown. Benchmark resource traces separately record total process RSS, CPU, disk/cache use, and S3 I/O because routing metadata alone is not a complete memory measurement.
Search Budgets
Approximate search is controlled by three budgets, not hidden magic constants. Raise one budget only when the report shows that specific limit is hurting recall or latency.
Segment payload budget
max_segments caps expensive vector segment reads. It is the main I/O budget: higher values inspect more leaf blobs and usually improve recall by spending more payload bytes.
Routing metadata lookahead
routing_page_overfetch reads extra cheap routing pages before segment payloads are fetched. It helps when close routing bounds tie, without raising the segment payload budget.
Candidate rows per segment
max_candidates_per_segment caps local exact-rerank work inside each fetched segment. Graph modes read graph blocks only when this budget can reduce the row set; if the budget already covers the whole segment, graph I/O is skipped. When this budget is below the segment length, pq-scan and sq-scan decode the segment column-projected and read back only the chosen candidates' vectors — a deliberate memory-for-latency tradeoff: about 3.3× lower per-query decode memory on large segments for roughly 15% more wall-time, with identical results.
Byte and latency stops
max_bytes and max_latency_ms are hard request stops for callers that need an external I/O or wall-time ceiling. The report records the stop reason.
Read-ahead depth
prefetch_depth is the per-query cell-read width (default 16): it changes how many selected cell pipelines can be in flight. Tune it from uncached/disk-cached p95 plus CPU/RSS/request burstiness; it is not a recall knob when all nprobe cells complete, and the shared 24-read gate remains the multi-user ceiling.
Fast S3 Writes
Use generated internal ids for high-scale ingest. add_vectors and vector-only Python/TypeScript add reserve monotonic ids without scanning old segment payloads. Keep external ids in an application map when duplicate validation would dominate writes.
S3 Object Shape
Use the v8 dimension-aware default: it targets about 16 MiB of decoded float32 vectors per physical segment (41,943 rows at 100D, 16,384 at 256D, 5,349 at 784D, and 4,369 at 960D), clamped to 64–131,072 rows. Vector-level global coarse cells are independent from these ingest/rerank units. Routed global-PQ code chunks are consumed in fixed waves, so query memory stays bounded as corpus size grows. Override only from a matched-recall latency/RSS/GET curve.
Read-Shaped Leaves
Bulk append is write-shaped. Follow it with bounded L0 to L1+ compaction and set target_segment_max_vectors to the read leaf size. Compaction groups vector-local rows; it rebuilds graph blocks only for an explicitly graph-enabled index.
Paged Readers
Paged routing is the default, so large S3 indexes do not load every segment summary during open and instead resolve routing pages on demand. Add a local NVMe cache_dir where possible so immutable routing, segment, and optional graph objects are fetched once and reused by disk-cached queries. Opt into --resident-routing only for an index whose complete routing table fits the RAM envelope.
Query Budgets
max_segments spends payload reads, routing_page_overfetch spends routing metadata reads, and max_candidates_per_segment spends exact scoring inside fetched leaves. Reports show which budget stopped the query.
S3 Proof
Local 100M+ attempts validate algorithm shape, object counts, and memory behavior. The s3_soak test then measures request rate (requests per query and per add), QPS, p50/p95 latency, and cache hit ratio against live MinIO and SeaweedFS. Every SearchReport and AddReport also carries a requests breakdown (gets/puts/deletes/heads/lists), so request rate is observable in production, not just in the soak.
Durability & SLA
BORSUK is a library, not a hosted service: it keeps no data outside your object store and runs no always-on tier. So the index's durability and availability are, by construction, exactly the SLA of the bucket you point it at — there is no separate BORSUK SLA to reconcile. On Amazon S3 Standard that is AWS's published 99.999999999% (eleven nines) of designed durability and a 99.9% availability service commitment (designed for 99.99%); GCS Standard and Azure Blob document their own comparable figures. What BORSUK adds on top is the correctness contract — atomic publication, snapshot-isolated reads, read-your-writes, and crash-safe recovery — so what the store keeps durable is always a consistent index, never a half-written one.
Storage format
Binary Tables, One Tiny Pointer
Table-shaped metadata and search indexes use standard Parquet. ANN scan payloads and typed exact vectors use standard Arrow IPC files whose record-batch buffers can be range-read independently. The remaining checked binary control records are small coordination envelopes rather than vector or table containers.
lexical/**/*.parquetroots, term pages, postings, row metadata
quantizer/**/*.parquetIVF coarse quantizer
Architecture
Query Path
Fast writes append L0 blobs. In paged mode, generated-id appends read the top routing index and fill the rightmost append branch when it is readable. Compaction builds vector-local leaves and reuses unchanged routing page objects; parent binary routing pages sit above those leaves when fanout requires them.
“By distance” is precise, not hand-wavy. Here is exactly how a query chooses which segment bubbles to fetch — and why exact search can skip most of them without ever being wrong.
Each bubble has a center and a radius. A segment's centroidc is the average of its vectors; its radiusr is the distance from the centroid to its farthest member. So every vector in the segment lies within r of c.
Compute each bubble's best possible distance. The closest any vector inside a bubble could be to your query q is the distance from q to the center c_s minus the radius r_s — floored at zero:
lb(q, s) = max(0, d(q, c_s) - r_s)
This is a true lower bound: no vector in that segment can be nearer than lb. (When the metric supports it, BORSUK uses even tighter persisted per-dimension bounds; the centroid–radius form is the fallback.)
Sort the bubbles by that bound and read nearest-first. The bubble whose closest-possible point is nearest is the most promising, so it is fetched first, then the next, and so on.
Stop as soon as it is safe. The query keeps the k nearest exact matches found so far. The instant the next bubble's lb is farther than the current k-th match, no unread bubble can contain anything closer — so exact search stops there, guaranteed complete. Approximate search instead stops when a budget (segments, bytes, latency, or an epsilon slack) is spent, trading some recall for less I/O; the report says which budget stopped it.
Worked example
Query at distance 4.0 from bubble A's center (radius 1.5) and 9.0 from bubble B's center (radius 1.0):
lb(A) = 4.0 − 1.5 = 2.5
lb(B) = 9.0 − 1.0 = 8.0
A is read first. If its nearest exact match sits at 3.1, that already beats lb(B) = 8.0, so B is never fetched — its closest conceivable point (8.0) is farther than a hit we already hold.
Then: rows inside a bubble
Reading a bubble does not mean scoring every row. Exact mode scores them all; approximate mode ranks rows by a compact sketch and exact-scores the closest m = max_candidates_per_segment:
C_s = top m by delta(sketch(q), sketch(x))
The sketch is scalar for sq-scan, a UInt8 per-dimension code for pq-scan. Watch this in the query visualization above.
A metadata filter layers on top of this: bubbles whose statistics prove no row matches are skipped before step 3 (extra pruning), and inside the bubbles that are read, a budgeted query ranks only the rows that pass the filter — never a nearest-neighbour list with the non-matches dropped afterward. See the filtered-query visualization and metadata filtering.
Distance metrics
You choose the metric when you create an index — metric="cosine", "euclidean", "inner-product", "minkowski:3", and so on. Over thirty are built in. Every one returns a distance (smaller = more similar), so a search always keeps the k smallest, no matter whether the underlying formula is a distance or a similarity.
The one tradeoff worth knowing. BORSUK skips a segment bubble without reading it when it can prove the bubble holds nothing closer than the current k-th result. That proof comes from a geometric lower bound — the Lp-family metrics (euclidean, manhattan, chebyshev, minkowski:p, gower) get it from the triangle inequality, and cosine and angular — the metrics RAG leans on — get it too: BORSUK measures their bubble geometry as Euclidean distance over unit-normalized vectors (on unit vectors ‖a−b‖² = 2(1−cosine), so the ranking is monotonic in Euclidean distance), while still storing and returning your original vectors unchanged. The remaining metrics still work and approximate-search latency is about the same — but their exact and recall-guaranteed searches over a huge index scan every candidate segment, because nothing can be proven skippable. Reach for an Lp, cosine, or angular metric when you need exact search at scale.
Full equations, input constraints, and per-metric notes are in the API reference.
Leaf Modes
Approximate search first chooses segments, then each selected segment chooses candidate rows. Every approximate mode exact-reranks the selected rows before returning ids or vectors. pq-scan is the production mode — graph-free, compressed, and the lowest, most predictable memory footprint. The graph-backed modes (graph, vamana-pq, hybrid) are experimental: they can lift recall on some datasets but read extra graph objects and cost more memory. Graph-backed modes read graph Parquet only when k < min(max_candidates_per_segment, segment_len) < segment_len.
scalar entry rows, then segment-local graph traversal
If budget can expand
L0 insert segments and graph behavior tests.
vamana-pq
Experimental
PQ entry rows, then segment-local graph traversal
If budget can expand
Compacted L1+ segments declare vamana-pq.
hybrid
Experimental
uses each segment's stored leaf_mode
Per stored mode and budget
Mixed L0 + compacted indexes.
How each mode actually works
In v8, pq-scan means a two-level rotated product-quantization path: an adaptive full-dimensional router assigns vectors to global cells (flat for measured ordinary angular corpora, hierarchical for large angular and Euclidean corpora), then asymmetric-distance lookup tables scan the selected paged product codes. The independently tested product router is a rejected ablation. The older cell-local TurboQuant-4b sketch remains a fallback for filtered, WAL, and non-finalized paths. Publication results name both layers explicitly. vamana-pq remains an experimental graph label, not a claim to implement DiskANN's Vamana construction. Every returned row is exact-scored on its full float32 vector; approximation changes only the shortlist.
flat-scan · production
No sketch at all. Every row in a fetched segment is scored on its full vector, so within a searched segment it is exact. It is the ground truth the other modes approximate and the simplest path to reason about — useful as a baseline and for graph-free testing.
sq-scan · production
Each row stores one scalar routing_code: a cheap signed projection of its vector (an alternating-sign sum of coordinates). Candidate rows are the ones whose code is nearest the query's code by absolute difference, then exact-reranked. It is the cheapest graph-free way to shrink the candidate set, at the cost of a coarse one-dimensional filter.
pq-scan · production (recommended)
A finalized v8 index stores one byte per rotated product-PQ subspace in immutable code chunks. The adaptive full-dimensional coarse router assigns each vector to a global semantic cell, independent of its bounded ingest checkpoint. Search loads only chunks in selected cells, computes ADC distances from the resident codebook, keeps a bounded global shortlist, and exact-reranks lossless rows. Chunks are processed in fixed waves, so 100M vectors do not imply 100M resident codes. Code width is also adaptive: measured defaults use 64 bytes for 96–128D standard corpora, 128 bytes for the 256D NYTimes regime, and 256 bytes for 768+D corpora with at least 100,000 rows. On GIST-960, the code256 / 24-probe / 96-candidate point reaches 0.995 empirical recall and adds only 1.5% to the complete index; exact search remains the separate formal-1.0 path. The older cell-local TurboQuant-4b SRHT sketch is retained for filtered, WAL, and non-finalized fallback searches. Both paths are graph-free; the paged global-PQ path is the production default.
graph · experimental
Alongside the segment, a small proximity graph is stored as numeric row-reference edges in a separate Parquet block. For segments up to 256 rows the graph is an exact k-nearest all-pairs build; above that it uses a bounded, windowed construction (candidates drawn from vector-locality and routing-code orderings inside a fixed window) rather than a full Vamana robust-prune, which keeps write cost from growing quadratically. At query time it seeds entry rows by routing_code and performs a deterministic score-once best-first traversal: a binary heap orders distance then record id, and a dense state table prevents duplicate scoring. Decoded, validated immutable graphs share the byte-accounted segment cache; warm() prepares them before the first graph query. Fresh L0 insert segments are written in this mode. Experimental: graph-enabled indexes store extra objects and the large-segment construction is still being tuned.
vamana-pq · experimental
The same segment-local graph and greedy traversal as graph, but entry rows are seeded by pq_code distance instead of the scalar code. Despite the name it is not the DiskANN/Vamana robust-prune algorithm — it is that greedy graph over PQ-seeded entries. Compaction writes L1+ leaves in this mode. Experimental.
hybrid · experimental
Per-segment dispatch. Every segment records the mode it was written with (L0 as graph, L1+ as vamana-pq), and a hybrid query uses each segment's own mode, reading graph blocks only for the graph-backed segments that have budget to expand. Reach for it when fresh L0 inserts and compacted L1+ leaves coexist, so callers don't have to track the active mix. Experimental.
Production evidence contract
Measure the deployment profile you will ship
Qualify the full corpus at recall at least 0.95, keep startup separate, and report uncached and zero-backing-GET disk-cached p50/p95/p99 together with requests, bytes, CPU, RSS, disk I/O, and cache footprint. Production uses four compute workers, one bounded 24-waiter small-stack I/O pool, and bounded query and cell-read/decode concurrency.
Standard datasets, every leaf method, configuration ablations, resource graphs, scale studies, external comparisons, and reproduction commands are deliberately kept in the dedicated Research and Analysis section and the canonical Markdown research corpus.
Glossary
The vocabulary used across these docs and the API, in plain terms.
Vector / record
The unit you store and search: an id plus a float32 vector. Ids can be strings, integers, or raw bytes; there are no payload references in the public API.
Segment
An immutable Parquet object holding a batch of vectors — BORSUK's unit of storage and I/O, also called a leaf. Segments are never mutated in place; new data is appended as new segments and reorganized later by compaction.
Bubble
The informal name for a segment's spatial summary — its centroid and radius together. A query prunes the bubbles it cannot possibly beat and reads only the rest.
Centroid
The mean of a segment's member vectors (each dimension averaged over the members). It is recomputed as vectors are added and is what a query measures distance to when choosing which segments to read.
Radius
The distance from a segment's centroid to its farthest member. It bounds the bubble, so a query can form the lower bound lb(q, s) = max(0, d(q, c_s) − r_s) and skip any segment whose closest possible point is still too far.
Overlapping segments
Bubbles are allowed to overlap in space — BORSUK never forces the segments to partition the vector space. A vector still belongs to exactly one segment (the one it was routed to at insert time), but the region two bubbles cover can intersect. This is fine and expected: a query simply reads every bubble whose lower bound qualifies, so a point sitting in an overlap is found regardless of which segment owns it, and correctness holds because the read candidates are always exact-reranked on full vectors. The only cost of heavy overlap is extra reads — more bubbles pass the pruning test — which compaction and the incremental split/merge keep in check by tightening bubbles over time. This is also why BORSUK does not need to reassign a vector to its strictly-nearest segment: the lower-bound test already spans all candidates.
Pivot
A routing representative vector derived from segment centroids. Paged readers keep only the top-level routing state resident and fetch lower routing pages as needed; resident-routing mode loads the complete pivot table.
Routing page / routing tree
The paged, hierarchical index over segment summaries. A query walks it from the top layer down to candidate segments, fetching only the pages it needs so routing memory does not grow as a fully resident table.
Segment summary
The routing record for one segment: its id, level, object path, record count, centroid, radius, and bloom filters. In paged mode it is loaded through routing pages; in resident-routing mode the full set is kept in memory.
Manifest
The object that lists the active segments, the tombstone, the pivots, and index metadata for one published version of the index.
CURRENT
A single small pointer object naming the active manifest version. It is the only non-Parquet persistent object, and the compare-and-swap on it is what makes concurrent publishes safe.
Level (L0 / L1+)
LSM-style levels. L0 holds freshly appended insert segments; compaction rewrites them into read-optimized L1+ leaves that pack nearby vectors together.
Compaction
Rewriting segments from a source level into new target-level leaves — sorting nearby vectors together for better locality and physically dropping tombstoned rows in the same pass.
Tombstone
A soft-delete marker. Deleted ids are recorded once and filtered out at query time; the underlying rows are physically reclaimed later by compaction or an explicit purge.
Split / merge
Incremental maintenance operations. An oversized bubble splits into tighter children; a bubble left sparse by deletes merges into its nearest neighbour. Both touch only the affected segments and can run in parallel across nodes.
Leaf mode
The per-segment strategy for choosing which rows to exact-score: flat-scan, sq-scan, pq-scan (production) or graph, vamana-pq, hybrid (experimental). See Leaf modes.
pq_code / routing_code
The compact per-row sketches a leaf mode ranks by before exact scoring — a per-dimension byte code (pq_code) and a scalar projection (routing_code).
Rerank
The always-final step of scoring the selected candidate rows on their full float32 vectors under the index metric. Because rerank is exact, a leaf mode only changes which rows are scored, never the scores.
Resident metadata (resident bytes)
The routing metadata BORSUK estimates as resident: manifest fields, top-level routing state, and — only in resident-routing mode — all segment summaries. Reported as resident_bytes_estimate; it is not total process RSS and does not include decoded cells, runtime allocations, or cache contents.
Recall
The fraction of a query's true nearest neighbours that were actually returned. Exact mode returns all of them; approximate mode reports whether coverage was complete or budget-limited.
Metadata
A schemaless, JSON-like object stored next to each vector — string keys mapping to null, boolean, integer, float, string, timestamp, list, or nested-map values. Kept in a compact binary column, returned only when a search opts in with include_metadata. See Metadata filtering.
Filter
A predicate over metadata that constrains a search to matching rows, written as a Pinecone-style operator dictionary ({"genre": "rock", "year": {"$gte": 1990}}). Applied before ranking, so results are exact rather than a post-filter over an unfiltered top-k.
Prefilter (filter-first search)
The strategy of deciding which rows match the filter first, then ranking only those, instead of ranking the vector-nearest rows and dropping the non-matches afterward. It matters when a filter is selective: filtering first means the query never wastes work scoring rows it will discard, and it cannot miss a matching neighbour just because it fell outside the vector-nearest window. See the filtered-query visualization.
Metadata statistics / segment pruning
Per-segment summaries of the metadata it holds — numeric min/max per dotted path plus a presence bloom over string values and value kinds. A filtered query uses them to prove a segment holds no matching row and skip it before any payload fetch, so selective filters read only the segments that can match. Negated and existence predicates never prune.
Namespace
In the migration adapters, the partition unit inherited from Pinecone/turbopuffer (a namespace) or S3 Vectors (an index within a bucket). Each maps to its own BORSUK index under a shared base URI, so tenants and collections stay physically isolated.
Functionality
The Rust crate, Python package, and TypeScript package support create/open, generated or explicit ids, dense or sparse vector inputs, named vectors, exact and approximate id/vector search, BM25 text search, named-vector + text hybrid search, batch search, Float32 buffer inputs, get-by-id vector loading, stats, scoped compaction, full-level rebuild, obsolete segment garbage collection, metric catalogs, leaf-mode catalogs, vector distance helpers, strict id recall helpers, and tie-aware distance recall helpers.
How your data is stored
Manifests, segment summaries, pivots, routing rows, physical record rows, sparse columns, BM25, and optional graph blocks are Parquet-backed binary data. BM25 and named-sparse search load small field roots during open, route query terms to bounded term pages, then range-read only selected posting and row-metadata row groups; they never download a complete postings file to find one term. Exact block-max bounds avoid later reads without changing recall, while one shared byte gate bounds decoded work across users. The production pq-scan artifact uses a standard Parquet adaptive-IVF/product-PQ descriptor plus standard Arrow IPC ANN bundles. Each cell chunk is one record batch with an independently range-addressable fixed-size scan payload and typed exact-vector values buffer. The descriptor stores their checked byte ranges, so a query fetches only selected scan slices and bounded exact-rerank rows; final IDs come from the physical record sidecar. CURRENT is a fixed binary pointer to the active immutable version and the only custom durable object.
Testing and performance
Correctness gates, release-scale workloads, raw latency distributions, recall curves, and CPU/RAM/NVMe timelines are kept with the reproducible benchmark artifacts and research documentation.
References & further reading
The research and systems BORSUK's design draws on and compares against. IVF/PQ, TurboQuant-style structured rotation, DiskANN, and SPFresh/SPANN are established ingredients. Current object-store ANN work means BORSUK's defensible claim is the embedded bounded-resource composition and its measurement, not invention of those primitives or “first object-store ANN.”
Research
DiskANN — billion-point nearest-neighbour search on a single node (NeurIPS 2019); the Vamana graph on SSD.
FreshDiskANN — streaming inserts/deletes without full rebuild (2021).
SPFresh — incremental in-place update for billion-scale vector search (SOSP 2023); centroid posting-lists with lightweight rebalancing.
SPANN — the disk-resident centroid/posting-list index SPFresh builds on.
HNSW — hierarchical navigable small worlds (Malkov & Yashunin, TPAMI 2018), the in-RAM graph most engines use.
The source repository keeps the long-form documentation beside the implementation:
docs/architecture.md explains segment layout, routing, cache behavior, and compaction.
docs/api.md covers Rust, Python, TypeScript, and CLI usage.
docs/drop-in.md documents migration adapters and their compatibility limits.
docs/storage-format.md documents the Parquet tables, binary CURRENT pointer, and compatibility rules.
docs/benchmarks.md lists performance benchmarks, smoke checks, and tracked measurements.
docs/production-readiness.md defines the correctness, package, storage, performance, memory, API, and object-store gates for a production-ready release.
License
BORSUK uses the Business Source License 1.1. Production use is free unless your company, organization, and affiliates make over US $100,000/year.