ringdb/engine.rs
1use std::path::Path;
2use std::time::Instant;
3
4use serde::{Serialize, de::DeserializeOwned};
5
6use crate::backend::{CpuBackend, RingComputeBackend};
7use crate::config::RingDbConfig;
8use crate::error::{Result, RingDbError};
9use crate::payload::{PayloadStore, PayloadStoreBuilder};
10use crate::persist::{read_f32_file, read_meta, write_f32_file, write_meta};
11use crate::query::{DiskQuery, QueryResult, RangeQuery, RingQuery};
12
13/// Builder for a ring-query vector database.
14///
15/// Insert vectors (and their associated payloads) with
16/// [`add_vector()`](Self::add_vector), then call [`build()`](Self::build) to
17/// transfer ownership to the compute backend and obtain a [`SealedRingDb`]
18/// that can be queried.
19///
20/// `T` is the payload type stored alongside each vector. Use `T = ()` when
21/// no payload is needed.
22///
23/// # Example — no payload
24///
25/// ```
26/// use ringdb::{RingDb, RingDbConfig, RingQuery};
27///
28/// let config = RingDbConfig::new(4);
29/// let mut db = RingDb::new(config).unwrap();
30///
31/// db.add_vector(&[1.0, 0.0, 0.0, 0.0], ()).unwrap();
32/// db.add_vector(&[0.0, 1.0, 0.0, 0.0], ()).unwrap();
33///
34/// let db = db.build().unwrap();
35/// let result = db.query(&RingQuery { query: &[1.0f32, 0.0, 0.0, 0.0], d: 1.0, lambda: 0.1 }).unwrap();
36/// println!("hits: {:?}", result.ids);
37/// ```
38///
39/// # Example — with payload
40///
41/// ```
42/// use ringdb::{RingDb, RingDbConfig, RingQuery};
43/// use serde::{Serialize, Deserialize};
44///
45/// #[derive(Serialize, Deserialize)]
46/// struct Meta { label: String }
47///
48/// let mut db: RingDb<Meta> = RingDb::new(RingDbConfig::new(2)).unwrap();
49/// db.add_vector(&[1.0, 0.0], Meta { label: "dog".into() }).unwrap();
50/// db.add_vector(&[0.0, 1.0], Meta { label: "cat".into() }).unwrap();
51///
52/// let db = db.build().unwrap();
53/// let result = db.query(&RingQuery { query: &[1.0f32, 0.0], d: 1.0, lambda: 0.1 }).unwrap();
54/// let payloads = db.fetch_payloads(&result.ids).unwrap();
55/// ```
56pub struct RingDb<T = ()> {
57 config: RingDbConfig,
58 backend: Box<dyn RingComputeBackend>,
59 n_vectors: usize,
60
61 /// Staging buffer: f32 vectors, row-major, `n_vectors × dims`.
62 vectors: Vec<f32>,
63
64 /// Staging buffer: per-vector squared L2 norm.
65 norms_sq: Vec<f32>,
66
67 /// Streams payloads to a temp file as they arrive; never accumulates in RAM.
68 payload_builder: PayloadStoreBuilder<T>,
69}
70
71impl<T: Serialize + DeserializeOwned> RingDb<T> {
72 /// Create a new empty `RingDb` with the given configuration.
73 pub fn new(config: RingDbConfig) -> Result<Self> {
74 Ok(Self {
75 config,
76 backend: Box::new(CpuBackend::new()),
77 n_vectors: 0,
78 vectors: Vec::new(),
79 norms_sq: Vec::new(),
80 payload_builder: PayloadStoreBuilder::new()?,
81 })
82 }
83
84 /// Insert a single vector and its associated payload.
85 ///
86 /// Vectors are assigned sequential IDs starting from 0.
87 /// The slice length must equal `dims`.
88 pub fn add_vector(&mut self, vector: &[f32], payload: T) -> Result<()> {
89 let dims = self.config.dims;
90 if vector.len() != dims {
91 return Err(crate::error::RingDbError::DimensionMismatch {
92 expected: dims,
93 got: vector.len(),
94 });
95 }
96
97 let norm_sq: f32 = vector.iter().map(|x| x * x).sum();
98 self.norms_sq.push(norm_sq);
99 self.vectors.extend_from_slice(vector);
100 self.payload_builder.push(payload)?;
101 self.n_vectors += 1;
102 Ok(())
103 }
104
105 /// Transfer ownership of the accumulated data to the compute backend and
106 /// seal the database.
107 ///
108 /// Vector data is moved into the backend (zero-cost for the CPU backend).
109 /// Payloads are serialized and moved into a cold anonymous mmap — the
110 /// staging `Vec<T>` is dropped immediately after.
111 ///
112 /// If [`RingDbConfig::persist_dir`] is set the following files are written
113 /// to that directory before sealing:
114 ///
115 /// | File | Content |
116 /// |------|---------|
117 /// | `meta.bin` | `dims` + `n_vectors` as little-endian u64 |
118 /// | `vectors.bin` | raw f32 vectors (row-major) |
119 /// | `norms_sq.bin` | raw f32 squared norms |
120 /// | `payloads.bin` | concatenated bincode payload bytes |
121 /// | `offsets.bin` | byte offsets (u64) into `payloads.bin` |
122 ///
123 /// The database can be reloaded later with [`RingDb::load()`].
124 pub fn build(mut self) -> Result<SealedRingDb<T>> {
125 let dims = self.config.dims;
126 let n_vectors = self.n_vectors;
127
128 if let Some(dir) = self.config.persist_dir.clone() {
129 std::fs::create_dir_all(&dir)?;
130
131 write_meta(&dir.join("meta.bin"), dims, n_vectors)?;
132 write_f32_file(&dir.join("vectors.bin"), &self.vectors)?;
133 write_f32_file(&dir.join("norms_sq.bin"), &self.norms_sq)?;
134
135 let payload_store = self
136 .payload_builder
137 .finish_persisted(&dir.join("payloads.bin"), &dir.join("offsets.bin"))?;
138
139 self.backend
140 .upload_f32_dataset(dims, self.vectors, self.norms_sq)?;
141
142 return Ok(SealedRingDb {
143 config: self.config,
144 backend: self.backend,
145 n_vectors,
146 payload_store,
147 });
148 }
149
150 self.backend
151 .upload_f32_dataset(dims, self.vectors, self.norms_sq)?;
152 let payload_store = self.payload_builder.finish()?;
153 Ok(SealedRingDb {
154 config: self.config,
155 backend: self.backend,
156 n_vectors,
157 payload_store,
158 })
159 }
160
161 /// Reconstruct a [`SealedRingDb`] from a directory previously written by
162 /// [`RingDb::build()`] when [`RingDbConfig::persist_dir`] was set.
163 ///
164 /// Reads `meta.bin`, `vectors.bin`, `norms_sq.bin`, `payloads.bin`, and
165 /// `offsets.bin` from `dir`, re-uploads the vectors and norms to the
166 /// requested backend, and memory-maps the payload file.
167 ///
168 /// Pass [`BackendPreference::Cpu`] for the CPU backend (the only option
169 /// today; CUDA and others will be added later).
170 ///
171 /// # Errors
172 ///
173 /// Returns [`RingDbError::Corrupt`] if any file is missing, has an
174 /// unexpected size, or the dimension/count metadata is inconsistent.
175 ///
176 /// # Example
177 ///
178 /// ```no_run
179 /// use ringdb::{RingDb, RingDbConfig, SealedRingDb};
180 /// use ringdb::BackendPreference;
181 /// use std::path::Path;
182 ///
183 /// // --- save ---
184 /// let mut db = RingDb::<()>::new(RingDbConfig::new(4).with_persist_dir("/tmp/mydb")).unwrap();
185 /// db.add_vector(&[1.0, 0.0, 0.0, 0.0], ()).unwrap();
186 /// let _sealed = db.build().unwrap(); // writes files to /tmp/mydb
187 ///
188 /// // --- load ---
189 /// let loaded = RingDb::<()>::load(Path::new("/tmp/mydb"), BackendPreference::Cpu).unwrap();
190 /// ```
191 pub fn load(
192 dir: &Path,
193 backend_preference: crate::config::BackendPreference,
194 ) -> Result<SealedRingDb<T>> {
195 let (dims, n_vectors) = read_meta(&dir.join("meta.bin"))?;
196
197 let vectors = read_f32_file(&dir.join("vectors.bin"))?;
198 let norms_sq = read_f32_file(&dir.join("norms_sq.bin"))?;
199
200 let expected_vec_len = n_vectors * dims;
201 if vectors.len() != expected_vec_len {
202 return Err(RingDbError::Corrupt(format!(
203 "vectors.bin has {} f32 values, expected {} (n_vectors={} × dims={})",
204 vectors.len(),
205 expected_vec_len,
206 n_vectors,
207 dims,
208 )));
209 }
210 if norms_sq.len() != n_vectors {
211 return Err(RingDbError::Corrupt(format!(
212 "norms_sq.bin has {} f32 values, expected {}",
213 norms_sq.len(),
214 n_vectors,
215 )));
216 }
217
218 let mut backend: Box<dyn RingComputeBackend> = match backend_preference {
219 crate::config::BackendPreference::Cpu => Box::new(CpuBackend::new()),
220 };
221 backend.upload_f32_dataset(dims, vectors, norms_sq)?;
222
223 let payload_store =
224 PayloadStore::load(&dir.join("payloads.bin"), &dir.join("offsets.bin"))?;
225
226 Ok(SealedRingDb {
227 config: RingDbConfig::new(dims)
228 .with_persist_dir(dir)
229 .with_backend_preference(backend_preference),
230 backend,
231 n_vectors,
232 payload_store,
233 })
234 }
235
236 /// Number of vectors currently staged.
237 pub fn len(&self) -> usize {
238 self.n_vectors
239 }
240
241 /// Returns `true` if no vectors have been inserted.
242 pub fn is_empty(&self) -> bool {
243 self.n_vectors == 0
244 }
245
246 /// Number of dimensions per vector.
247 pub fn dims(&self) -> usize {
248 self.config.dims
249 }
250
251 /// Name of the backend currently in use.
252 pub fn backend_name(&self) -> &str {
253 self.backend.name()
254 }
255}
256
257/// Sealed (immutable) ring-query database.
258///
259/// Obtained by calling [`RingDb::build()`] or [`RingDb::load()`]. Vectors can
260/// no longer be inserted — only queries and payload fetches are allowed.
261///
262/// The hot side (vectors + norms) is owned by the compute backend.
263/// The cold side (payloads) lives in an anonymous mmap managed by
264/// [`PayloadStore`].
265pub struct SealedRingDb<T = ()> {
266 config: RingDbConfig,
267 backend: Box<dyn RingComputeBackend>,
268 n_vectors: usize,
269 payload_store: PayloadStore<T>,
270}
271
272impl<T: Serialize + DeserializeOwned> SealedRingDb<T> {
273 /// Execute a ring query and return matching vector IDs.
274 ///
275 /// The ring `[d - lambda, d + lambda]` is converted to `[d_min, d_max]`
276 /// internally; negative lower bounds are clamped to 0.
277 pub fn query(&self, q: &RingQuery<'_>) -> Result<QueryResult> {
278 let dims = self.config.dims;
279 if q.query.len() != dims {
280 return Err(crate::error::RingDbError::DimensionMismatch {
281 expected: dims,
282 got: q.query.len(),
283 });
284 }
285
286 let d_min = (q.d - q.lambda).max(0.0);
287 let d_max = q.d + q.lambda;
288
289 let t = Instant::now();
290 let ids = self.backend.ring_query_f32(dims, q.query, d_min, d_max)?;
291 let elapsed = t.elapsed();
292
293 Ok(QueryResult {
294 ids,
295 backend_used: self.backend.name(),
296 elapsed,
297 })
298 }
299
300 /// Execute a range query and return matching vector IDs.
301 ///
302 /// Returns all vectors whose Euclidean distance to the query lies in
303 /// `[d_min, d_max]`.
304 pub fn query_range(&self, q: &RangeQuery<'_>) -> Result<QueryResult> {
305 let dims = self.config.dims;
306 if q.query.len() != dims {
307 return Err(crate::error::RingDbError::DimensionMismatch {
308 expected: dims,
309 got: q.query.len(),
310 });
311 }
312
313 let t = Instant::now();
314 let ids = self
315 .backend
316 .ring_query_f32(dims, q.query, q.d_min, q.d_max)?;
317 let elapsed = t.elapsed();
318
319 Ok(QueryResult {
320 ids,
321 backend_used: self.backend.name(),
322 elapsed,
323 })
324 }
325
326 /// Execute a disk query and return matching vector IDs.
327 ///
328 /// Returns all vectors within Euclidean distance `d_max` of the query
329 /// (i.e. the full ball of radius `d_max`, equivalent to `d_min = 0`).
330 pub fn query_disk(&self, q: &DiskQuery<'_>) -> Result<QueryResult> {
331 let dims = self.config.dims;
332 if q.query.len() != dims {
333 return Err(crate::error::RingDbError::DimensionMismatch {
334 expected: dims,
335 got: q.query.len(),
336 });
337 }
338
339 let t = Instant::now();
340 let ids = self.backend.ring_query_f32(dims, q.query, 0.0, q.d_max)?;
341 let elapsed = t.elapsed();
342
343 Ok(QueryResult {
344 ids,
345 backend_used: self.backend.name(),
346 elapsed,
347 })
348 }
349
350 /// Fetch the payload for a single vector ID.
351 ///
352 /// Reads and deserializes from the cold mmap. Call this after
353 /// [`query`](Self::query) to retrieve metadata for the matching vectors.
354 pub fn fetch_payload(&self, id: u32) -> Result<T> {
355 self.payload_store.fetch(id)
356 }
357
358 /// Fetch payloads for a slice of vector IDs, in order.
359 pub fn fetch_payloads(&self, ids: &[u32]) -> Result<Vec<T>> {
360 self.payload_store.fetch_many(ids)
361 }
362
363 /// Number of vectors stored.
364 pub fn len(&self) -> usize {
365 self.n_vectors
366 }
367
368 /// Returns `true` if the database contains no vectors.
369 pub fn is_empty(&self) -> bool {
370 self.n_vectors == 0
371 }
372
373 /// Number of dimensions per vector.
374 pub fn dims(&self) -> usize {
375 self.config.dims
376 }
377
378 /// Name of the backend currently in use.
379 pub fn backend_name(&self) -> &str {
380 self.backend.name()
381 }
382}