Embedding in a host app
nidus knows nothing about your domain — it is a general-purpose vector store. A
consuming tool maps its own document type onto a nidus
Record and, if it is async, wraps the store to bridge
into its runtime.
Map your document onto a Record
Section titled “Map your document onto a Record”A Record is an id, a vector, and an open attrs map. Every field of your
document either is one of those or fits an attr. Pick a stable id (it is the
upsert key), embed the content into vector, and project the rest into typed
attrs:
use std::collections::BTreeMap;use nidus::{Record, Value};
struct Chunk { chunk_id: String, embedding: Vec<f32>, path: String, symbol: Option<String>, tags: Vec<String>,}
fn to_record(c: Chunk) -> Record { let mut attrs = BTreeMap::new(); attrs.insert("path".into(), Value::Str(c.path)); // Null vs absent matters: emit Null for "computed, but none", // omit the key entirely for "not computed". attrs.insert("symbol".into(), match c.symbol { Some(s) => Value::Str(s), None => Value::Null, }); attrs.insert("tags".into(), Value::List(c.tags)); Record::new(c.chunk_id, c.embedding, attrs)}The Null-vs-absent distinction preserves
“computed-empty” versus “un-indexed” semantics — don’t collapse them.
Many collections, one dimension
Section titled “Many collections, one dimension”A store has one embedding space. Use collections to partition documents that
share that space — e.g. code, docs, commits — and search any subset or all
of them in one ranked call. If you need two genuinely different embedding models
(different dimensions), use two stores.
Bridging into async
Section titled “Bridging into async”nidus is synchronous: the hot path is CPU-bound (the dot-product sweep) and file IO is blocking, so there is no async core to lock you into a runtime. This is the same shape as a blocking embedded database handle, and you wrap it the same way.
For concurrent searchers plus a single writer, share an RwLock:
use std::sync::Arc;use tokio::sync::RwLock;use nidus::Nidus;
let db = Arc::new(RwLock::new(Nidus::open_dir("/path/to/store", 768)?));
// In an async handler, run the blocking call on the blocking pool:let db = Arc::clone(&db);let hits = tokio::task::spawn_blocking(move || { let guard = db.blocking_read(); guard.search("code", &query, &opts)}).await??;# anyhow::Ok(())Reads (search, get_all, footprint) take &self; writes (upsert,
delete, compact, …) take &mut self. An RwLock therefore admits many
concurrent searchers and one exclusive writer. Use spawn_blocking (or your
runtime’s equivalent) so a long sweep never blocks the async reactor.
Two kinds of parallelism
Section titled “Two kinds of parallelism”There are two independent ways to put cores to work, and they suit opposite workloads:
- Query-level (the default). Many
&selfsearches run at once underArc<RwLock<Nidus>>— one core per in-flight query. This is what you want for a server fielding concurrent requests; it maxes out throughput. - Intra-query (
Config::query_threads). A single large exact search is split acrossquery_threadsworker threads (std::thread::scope, per-chunk heaps merged at the end), cutting one query’s latency. Opt-in — the default of1keeps search single-threaded.
use nidus::{Config, Nidus};
// Split each exact search across 4 worker threads.let db = Nidus::open(Config::new("/path/to/store", 768).query_threads(4))?;# anyhow::Ok(())Pick one. Intra-query threads help when queries arrive one at a time against a
large store on otherwise-idle cores. The plain f32 scan is memory-bandwidth-bound,
so its speedup is real but sublinear (≈1.3–1.4× at 4–8 threads on 100k × 768).
Threads pay off best paired with int8 quantization:
the int8 first pass moves 4× fewer bytes (compute- not bandwidth-bound), so it
splits across the same workers and scales to ≈2.4× at 4 threads. If you already have
query-level concurrency, leave query_threads at 1 — splitting each query then
just oversubscribes the cores your concurrent readers are already using.