nodedb_lite/engine/vector/
pagedb_backing.rs1use pagedb::SegmentReader;
25use pagedb::vfs::Vfs;
26
27use nodedb_vector::segment_backing::VectorSegmentBacking;
28
29use crate::error::LiteError;
30
31const NDVS_MAGIC: [u8; 4] = *b"NDVS";
34const NDVS_FORMAT_VERSION: u16 = 1;
35const NDVS_HEADER_SIZE: usize = 32;
36const NDVS_FOOTER_SIZE: usize = 46;
37const F32_BYTES: usize = 4;
39const SID_BYTES: usize = 8;
41
42#[inline]
45const fn vec_pad(vec_bytes: usize) -> usize {
46 (8 - (vec_bytes % 8)) % 8
47}
48
49pub struct PagedbBacking {
59 data: Box<[u8]>,
61 dim: usize,
62 count: usize,
63 vec_offset: usize,
65 sid_offset: usize,
67}
68
69impl PagedbBacking {
70 pub async fn open<V: Vfs + Clone>(reader: SegmentReader<V>) -> Result<Self, LiteError> {
82 let meta_page_count = reader.meta().page_count;
83 let index_pages = u64::from(reader.index_page_count());
87 let data_page_count = meta_page_count
88 .checked_sub(2 + index_pages)
89 .ok_or_else(|| LiteError::Storage {
90 detail: format!(
91 "vector segment page_count={meta_page_count} too small \
92 (index_pages={index_pages})"
93 ),
94 })?;
95 if data_page_count == 0 {
96 return Err(LiteError::Storage {
97 detail: "vector segment has no data pages".to_owned(),
98 });
99 }
100 let count_u32 = u32::try_from(data_page_count).map_err(|_| LiteError::Storage {
101 detail: format!("vector segment has too many data pages: {data_page_count}"),
102 })?;
103
104 let pages = reader
106 .read_range(1, count_u32)
107 .await
108 .map_err(|e| LiteError::Storage {
109 detail: format!("pagedb vector segment read_range failed: {e}"),
110 })?;
111
112 let total: usize = pages.iter().map(|p| p.len()).sum();
114 let mut flat = Vec::with_capacity(total);
115 for page in pages {
116 flat.extend_from_slice(&page);
117 }
118
119 Self::from_bytes(flat.into_boxed_slice())
120 }
121
122 pub(crate) fn from_bytes(data: Box<[u8]>) -> Result<Self, LiteError> {
124 if data.len() < NDVS_HEADER_SIZE + NDVS_FOOTER_SIZE {
125 return Err(LiteError::Storage {
126 detail: format!(
127 "vector segment too small: {} bytes (min {})",
128 data.len(),
129 NDVS_HEADER_SIZE + NDVS_FOOTER_SIZE
130 ),
131 });
132 }
133
134 if data[0..4] != NDVS_MAGIC {
136 return Err(LiteError::Storage {
137 detail: "vector segment: bad NDVS magic".to_owned(),
138 });
139 }
140
141 let version = u16::from_le_bytes([data[4], data[5]]);
143 if version != NDVS_FORMAT_VERSION {
144 return Err(LiteError::Storage {
145 detail: format!(
146 "vector segment: unsupported NDVS version {version} \
147 (expected {NDVS_FORMAT_VERSION})"
148 ),
149 });
150 }
151
152 let dim = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
154 let count = u64::from_le_bytes([
155 data[12], data[13], data[14], data[15], data[16], data[17], data[18], data[19],
156 ]) as usize;
157
158 let vec_bytes = dim
159 .checked_mul(count)
160 .and_then(|n| n.checked_mul(F32_BYTES))
161 .ok_or_else(|| LiteError::Storage {
162 detail: "vector segment: vector data size overflow".to_owned(),
163 })?;
164
165 let vec_offset = NDVS_HEADER_SIZE;
166 let sid_offset = NDVS_HEADER_SIZE + vec_bytes + vec_pad(vec_bytes);
167 let min_len = sid_offset
168 .checked_add(count.saturating_mul(SID_BYTES))
169 .and_then(|n| n.checked_add(NDVS_FOOTER_SIZE))
170 .ok_or_else(|| LiteError::Storage {
171 detail: "vector segment: layout size overflow".to_owned(),
172 })?;
173
174 if data.len() < min_len {
175 return Err(LiteError::Storage {
176 detail: format!(
177 "vector segment too small for declared count={count} dim={dim}: \
178 need {min_len} bytes, have {}",
179 data.len()
180 ),
181 });
182 }
183
184 Ok(Self {
185 data,
186 dim,
187 count,
188 vec_offset,
189 sid_offset,
190 })
191 }
192
193 pub fn count(&self) -> usize {
195 self.count
196 }
197}
198
199impl VectorSegmentBacking for PagedbBacking {
200 fn len(&self) -> usize {
201 self.count
202 }
203
204 fn dim(&self) -> usize {
205 self.dim
206 }
207
208 fn get_vector(&self, id: u32) -> Option<&[f32]> {
209 let id = id as usize;
210 if id >= self.count {
211 return None;
212 }
213 let byte_start = self.vec_offset + id * self.dim * F32_BYTES;
214 let bytes = &self.data[byte_start..byte_start + self.dim * F32_BYTES];
215 Some(unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const f32, self.dim) })
223 }
224
225 fn get_surrogate(&self, id: u32) -> Option<u64> {
226 let id = id as usize;
227 if id >= self.count {
228 return None;
229 }
230 let offset = self.sid_offset + id * SID_BYTES;
231 let bytes = &self.data[offset..offset + SID_BYTES];
232 Some(u64::from_le_bytes(
233 bytes.try_into().expect("surrogate slice is always 8 bytes"),
234 ))
235 }
236
237 fn prefetch(&self, id: u32) {
238 let id = id as usize;
239 if id < self.count {
240 let byte_start = self.vec_offset + id * self.dim * F32_BYTES;
242 let _ = self.data.get(byte_start);
243 }
244 }
245}
246
247pub(crate) fn build_ndvs_bytes(
255 dim: usize,
256 vectors: &[Vec<f32>],
257 surrogate_ids: &[u64],
258) -> Result<Vec<u8>, LiteError> {
259 if !surrogate_ids.is_empty() && surrogate_ids.len() != vectors.len() {
265 return Err(LiteError::Storage {
266 detail: format!(
267 "NDVS build: {} surrogate ids for {} vectors (must match or be empty)",
268 surrogate_ids.len(),
269 vectors.len()
270 ),
271 });
272 }
273 if let Some((i, v)) = vectors.iter().enumerate().find(|(_, v)| v.len() != dim) {
274 return Err(LiteError::Storage {
275 detail: format!(
276 "NDVS build: vector {i} has {} dimensions, expected {dim}. An empty \
277 vector here means the source index carries no vector data — e.g. a \
278 graph-only checkpoint whose external backing was not consulted — and \
279 serializing it would publish a segment that cannot be read back.",
280 v.len()
281 ),
282 });
283 }
284
285 let count = vectors.len();
286 let vec_bytes = dim
287 .checked_mul(count)
288 .and_then(|n| n.checked_mul(F32_BYTES))
289 .ok_or_else(|| LiteError::Storage {
290 detail: "NDVS build: vector data size overflow".to_owned(),
291 })?;
292 let pad = vec_pad(vec_bytes);
293 let sid_bytes = count
294 .checked_mul(SID_BYTES)
295 .ok_or_else(|| LiteError::Storage {
296 detail: "NDVS build: surrogate block size overflow".to_owned(),
297 })?;
298 let body_len = NDVS_HEADER_SIZE + vec_bytes + pad + sid_bytes;
299 let total_len = body_len + NDVS_FOOTER_SIZE;
300 let mut buf: Vec<u8> = Vec::with_capacity(total_len);
301
302 buf.extend_from_slice(&NDVS_MAGIC);
304 buf.extend_from_slice(&NDVS_FORMAT_VERSION.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&(dim as u32).to_le_bytes()); buf.extend_from_slice(&(count as u64).to_le_bytes()); buf.push(0u8); buf.push(0u8); buf.extend_from_slice(&[0u8; 10]); debug_assert_eq!(buf.len(), NDVS_HEADER_SIZE);
313
314 for v in vectors {
316 let bytes: &[u8] =
319 unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * F32_BYTES) };
320 buf.extend_from_slice(bytes);
321 }
322
323 buf.extend_from_slice(&[0u8; 8][..pad]);
325
326 for i in 0..count {
328 let sid = surrogate_ids.get(i).copied().unwrap_or(0);
329 buf.extend_from_slice(&sid.to_le_bytes());
330 }
331
332 debug_assert_eq!(buf.len(), body_len);
333
334 let checksum = crc32c::crc32c(&buf);
336
337 buf.extend_from_slice(&NDVS_FORMAT_VERSION.to_le_bytes()); let mut created_by = [0u8; 32];
340 let ver = env!("CARGO_PKG_VERSION").as_bytes();
341 let copy_len = ver.len().min(31);
342 created_by[..copy_len].copy_from_slice(&ver[..copy_len]);
343 buf.extend_from_slice(&created_by); buf.extend_from_slice(&checksum.to_le_bytes()); buf.extend_from_slice(&(NDVS_FOOTER_SIZE as u32).to_le_bytes()); buf.extend_from_slice(&NDVS_MAGIC); debug_assert_eq!(buf.len(), total_len);
349
350 Ok(buf)
351}
352
353#[cfg(test)]
356mod tests {
357 use super::*;
358 use pagedb::options::{OpenOptions, RetainPolicy};
359 use pagedb::vfs::memory::MemVfs;
360 use pagedb::{Db, RealmId, SegmentKind};
361
362 fn test_open_options() -> OpenOptions {
363 OpenOptions::default().with_commit_history_retain(RetainPolicy::Disabled)
364 }
365
366 async fn open_test_db() -> Db<MemVfs> {
367 let vfs = MemVfs::new();
368 let kek = [0u8; 32];
369 let realm = RealmId::new([0u8; 16]);
370 Db::open(vfs, kek, 4096, realm, test_open_options())
371 .await
372 .expect("in-memory pagedb open")
373 }
374
375 fn test_vectors(dim: usize, n: usize) -> Vec<Vec<f32>> {
376 (0..n)
377 .map(|i| (0..dim).map(|j| (i * dim + j) as f32 * 0.1).collect())
378 .collect()
379 }
380
381 async fn write_segment_to_db(
383 db: &Db<MemVfs>,
384 name: &str,
385 dim: usize,
386 vectors: &[Vec<f32>],
387 surrogates: &[u64],
388 ) {
389 let realm = RealmId::new([0u8; 16]);
390 let ndvs = build_ndvs_bytes(dim, vectors, surrogates).expect("build ok");
391
392 const PAGE_BODY_CAP: usize = 4096 - 40; let chunks: Vec<&[u8]> = ndvs.chunks(PAGE_BODY_CAP).collect();
395
396 let mut writer = db
397 .create_segment(realm, SegmentKind::Unspecified)
398 .await
399 .expect("create segment ok");
400 writer
401 .append_extent(&chunks)
402 .await
403 .expect("append_extent ok");
404 let meta = writer.seal().await.expect("seal ok");
405
406 let mut txn = db.begin_write().await.expect("begin_write ok");
407 txn.link_segment(name, &meta).await.expect("link ok");
408 txn.commit().await.expect("commit ok");
409 }
410
411 #[tokio::test]
412 async fn roundtrip_with_pagedb_segment() {
413 let db = open_test_db().await;
414 let dim = 4usize;
415 let vecs = test_vectors(dim, 5);
416 let surrogates: Vec<u64> = (10..15).collect();
417
418 write_segment_to_db(&db, "vec/hnsw/col1", dim, &vecs, &surrogates).await;
419
420 let txn = db.begin_read().await.expect("begin_read ok");
422 let reader = txn
423 .open_segment("vec/hnsw/col1")
424 .await
425 .expect("open_segment ok");
426 let backing = PagedbBacking::open(reader).await.expect("open backing ok");
427
428 assert_eq!(backing.len(), 5);
429 assert_eq!(backing.dim(), dim);
430 assert!(!backing.is_empty());
431
432 for i in 0..5usize {
433 let got = backing.get_vector(i as u32).expect("vector present");
434 assert_eq!(got, vecs[i].as_slice(), "vector {i} mismatch");
435 let sid = backing.get_surrogate(i as u32).expect("surrogate present");
436 assert_eq!(sid, surrogates[i], "surrogate {i} mismatch");
437 }
438 }
439
440 #[tokio::test]
441 async fn out_of_bounds_returns_none() {
442 let db = open_test_db().await;
443 let dim = 3usize;
444 let vecs = test_vectors(dim, 2);
445 let surrogates: Vec<u64> = vec![100, 200];
446
447 write_segment_to_db(&db, "vec/hnsw/oob", dim, &vecs, &surrogates).await;
448
449 let txn = db.begin_read().await.expect("begin_read ok");
450 let reader = txn
451 .open_segment("vec/hnsw/oob")
452 .await
453 .expect("open_segment ok");
454 let backing = PagedbBacking::open(reader).await.expect("open ok");
455
456 assert!(backing.get_vector(2).is_none(), "id=2 out of bounds");
457 assert!(
458 backing.get_surrogate(2).is_none(),
459 "surrogate id=2 out of bounds"
460 );
461 backing.prefetch(999); }
463
464 #[test]
465 fn is_send_sync() {
466 fn assert_send_sync<T: Send + Sync>() {}
467 assert_send_sync::<PagedbBacking>();
468 }
469
470 #[tokio::test]
471 async fn bit_identical_with_plain_ndvs() {
472 let dim = 3usize;
474 let vectors: Vec<Vec<f32>> = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
475 let surrogates: Vec<u64> = vec![42, 99];
476
477 let our_bytes = build_ndvs_bytes(dim, &vectors, &surrogates).expect("build ok");
478
479 assert_eq!(&our_bytes[0..4], b"NDVS", "magic mismatch");
481 assert_eq!(
482 u16::from_le_bytes([our_bytes[4], our_bytes[5]]),
483 1u16,
484 "version mismatch"
485 );
486
487 let got_dim =
489 u32::from_le_bytes([our_bytes[8], our_bytes[9], our_bytes[10], our_bytes[11]]);
490 let got_count = u64::from_le_bytes(our_bytes[12..20].try_into().unwrap());
491 assert_eq!(got_dim as usize, dim);
492 assert_eq!(got_count as usize, 2);
493
494 let vec_offset = NDVS_HEADER_SIZE;
496 let v0_f32: [f32; 3] = [1.0, 2.0, 3.0];
497 let expected_v0: &[u8] =
498 unsafe { std::slice::from_raw_parts(v0_f32.as_ptr() as *const u8, 12) };
499 assert_eq!(&our_bytes[vec_offset..vec_offset + 12], expected_v0);
500 let v1_f32: [f32; 3] = [4.0, 5.0, 6.0];
501 let expected_v1: &[u8] =
502 unsafe { std::slice::from_raw_parts(v1_f32.as_ptr() as *const u8, 12) };
503 assert_eq!(&our_bytes[vec_offset + 12..vec_offset + 24], expected_v1);
504
505 let sid_offset = vec_offset + 24;
508 let sid0 = u64::from_le_bytes(our_bytes[sid_offset..sid_offset + 8].try_into().unwrap());
509 let sid1 = u64::from_le_bytes(
510 our_bytes[sid_offset + 8..sid_offset + 16]
511 .try_into()
512 .unwrap(),
513 );
514 assert_eq!(sid0, 42u64);
515 assert_eq!(sid1, 99u64);
516
517 let footer_trailing_magic_offset = our_bytes.len() - 4;
519 assert_eq!(&our_bytes[footer_trailing_magic_offset..], b"NDVS");
520
521 let db = open_test_db().await;
523 write_segment_to_db(&db, "vec/hnsw/bitcheck", dim, &vectors, &surrogates).await;
524 let txn = db.begin_read().await.expect("read txn");
525 let reader = txn
526 .open_segment("vec/hnsw/bitcheck")
527 .await
528 .expect("open seg");
529 let backing = PagedbBacking::open(reader).await.expect("open backing");
530
531 for (i, v) in vectors.iter().enumerate() {
532 assert_eq!(
533 backing.get_vector(i as u32).expect("vector"),
534 v.as_slice(),
535 "vector {i} roundtrip"
536 );
537 }
538 assert_eq!(backing.get_surrogate(0).expect("sid0"), 42);
539 assert_eq!(backing.get_surrogate(1).expect("sid1"), 99);
540 }
541}