Skip to main content

akar_storage/
vector_index.rs

1//! Vector index table — wraps `HnswIndex` with BufferManager-backed persistence.
2//!
3//! Follows the `OnDiskHashIndex` persistence pattern:
4//! - Header page (metric, dims, num_vectors, entry_point, max_level)
5//! - Data pages (serialized HNSW nodes + connections)
6//!
7//! Page layout:
8//! - Page 0: Header
9//! - Pages 1..=data_page_count: Node data (serialized vectors + connections)
10
11use crate::buffer_manager::BufferManager;
12use akar_common::error::StorageError;
13use akar_common::types::Value;
14use akar_vector::hnsw::{DistanceMetric, HnswIndex};
15
16/// Default page size for vector index storage.
17/// Header page layout (page 0):
18/// - bytes 0..7:   magic number (0x484E5357 = "HNSW")
19/// - bytes 8..15:  num_vectors (u64 LE)
20/// - bytes 16..23: entry_point (i64 LE, -1 for none)
21/// - bytes 24..27: max_level (u32 LE)
22/// - bytes 28..31: dimensions (u32 LE)
23/// - byte  32:     metric (0=Cosine, 1=Euclidean, 2=L1, 3=L2Squared, 4=DotProduct)
24/// - bytes 33..47: reserved
25const HEADER_SIZE: usize = 48;
26
27fn serialize_header(
28    num_vectors: u64,
29    entry_point: Option<usize>,
30    max_level: usize,
31    dimensions: u32,
32    metric: &DistanceMetric,
33) -> Vec<u8> {
34    let mut buf = vec![0u8; HEADER_SIZE];
35    buf[0..8].copy_from_slice(&0x484E5357u64.to_le_bytes()); // magic
36    buf[8..16].copy_from_slice(&num_vectors.to_le_bytes());
37    let ep = entry_point.map(|v| v as i64).unwrap_or(-1);
38    buf[16..24].copy_from_slice(&ep.to_le_bytes());
39    buf[24..28].copy_from_slice(&(max_level as u32).to_le_bytes());
40    buf[28..32].copy_from_slice(&dimensions.to_le_bytes());
41    let metric_byte = match metric {
42        DistanceMetric::Cosine => 0u8,
43        DistanceMetric::Euclidean => 1,
44        DistanceMetric::L1 => 2,
45        DistanceMetric::L2Squared => 3,
46        DistanceMetric::DotProduct => 4,
47    };
48    buf[32] = metric_byte;
49    buf
50}
51
52fn deserialize_header(buf: &[u8]) -> Option<(u64, Option<usize>, usize, u32, DistanceMetric)> {
53    if buf.len() < HEADER_SIZE {
54        return None;
55    }
56    let magic = u64::from_le_bytes(buf[0..8].try_into().ok()?);
57    if magic != 0x484E5357 {
58        return None;
59    }
60    let num_vectors = u64::from_le_bytes(buf[8..16].try_into().ok()?);
61    let ep_raw = i64::from_le_bytes(buf[16..24].try_into().ok()?);
62    let entry_point = if ep_raw < 0 { None } else { Some(ep_raw as usize) };
63    let max_level = u32::from_le_bytes(buf[24..28].try_into().ok()?) as usize;
64    let dimensions = u32::from_le_bytes(buf[28..32].try_into().ok()?);
65    let metric = match buf[32] {
66        0 => DistanceMetric::Cosine,
67        1 => DistanceMetric::Euclidean,
68        2 => DistanceMetric::L1,
69        3 => DistanceMetric::L2Squared,
70        4 => DistanceMetric::DotProduct,
71        _ => return None,
72    };
73    Some((num_vectors, entry_point, max_level, dimensions, metric))
74}
75
76/// A persisted vector index that wraps `HnswIndex` with BufferManager-backed storage.
77///
78/// # Persistence
79///
80/// - `save()` serializes the in-memory HNSW index to disk pages
81/// - `load()` reads disk pages back into memory
82/// - `flush()` writes dirty pages back to the BufferManager
83#[derive(Debug, Clone)]
84pub struct VectorIndexTable {
85    pub index_id: u64,
86    pub name: String,
87    pub table_name: String,
88    pub column_name: String,
89    pub dimensions: u32,
90    pub hnsw: HnswIndex,
91    /// Number of pages allocated for this index.
92    page_count: u64,
93    /// BufferManager file name (used for register_file / pin / unpin).
94    file_name: String,
95    /// Whether in-memory state has changed since last save.
96    dirty: bool,
97}
98
99impl VectorIndexTable {
100    /// Create a new vector index table with the given parameters.
101    pub fn new(
102        index_id: u64,
103        name: String,
104        table_name: String,
105        column_name: String,
106        metric: DistanceMetric,
107        dimensions: u32,
108    ) -> Self {
109        Self {
110            index_id,
111            name,
112            table_name,
113            column_name,
114            dimensions,
115            hnsw: HnswIndex::new(metric),
116            page_count: 1, // header page always exists
117            file_name: format!("vi_{index_id}"),
118            dirty: false,
119        }
120    }
121
122    /// Get a reference to the underlying HNSW index.
123    pub fn hnsw(&self) -> &HnswIndex {
124        &self.hnsw
125    }
126
127    /// Get a mutable reference to the underlying HNSW index.
128    pub fn hnsw_mut(&mut self) -> &mut HnswIndex {
129        self.dirty = true;
130        &mut self.hnsw
131    }
132
133    /// Get the distance metric.
134    pub fn metric(&self) -> DistanceMetric {
135        self.hnsw.metric()
136    }
137
138    /// Save the in-memory HNSW index to BufferManager-backed pages.
139    ///
140    /// Writes the header page (page 0) and all data pages. Every vector is
141    /// stored whole on a single page; a vector that does not fit in one page
142    /// is an error rather than a silent drop (P52.17).
143    pub fn save(&mut self, bm: &mut BufferManager) -> Result<(), StorageError> {
144        if !bm.is_file_registered(&self.file_name) {
145            return Err(StorageError::Index(format!(
146                "Vector index file '{}' not registered with BufferManager",
147                self.file_name
148            )));
149        }
150
151        let nodes = self.hnsw.nodes();
152        let num_vectors = nodes.len() as u64;
153        let entry_point = self.hnsw.entry_point();
154        let max_level = self.hnsw.max_level();
155
156        // Serialize header
157        let header = serialize_header(
158            num_vectors,
159            entry_point,
160            max_level,
161            self.dimensions,
162            &self.hnsw.metric(),
163        );
164
165        // Write header page
166        let frame = bm
167            .pin_mut(&self.file_name, 0)
168            .map_err(|e| StorageError::Index(format!("Failed to pin header page: {e}")))?;
169        let data = &mut frame.data;
170        let write_len = header.len().min(data.len());
171        data[..write_len].copy_from_slice(&header[..write_len]);
172        frame.is_dirty = true;
173        bm.unpin(&self.file_name, 0);
174
175        // Serialize and write vector data pages
176        let data_page_start = 1;
177        let mut page_idx = data_page_start;
178        let mut offset = 0usize;
179
180        while offset < num_vectors as usize {
181            let frame = bm
182                .pin_mut(&self.file_name, page_idx)
183                .map_err(|e| StorageError::Index(format!("Failed to pin data page {page_idx}: {e}")))?;
184            let page_data = &mut frame.data;
185            let capacity = page_data.len();
186            page_data.fill(0u8);
187            let mut pos = 0usize;
188            let mut written_this_page = 0usize;
189
190            while offset < num_vectors as usize {
191                let (id, vec_data) = nodes[offset];
192                let vec_len = vec_data.len().saturating_mul(8);
193                let entry_len = 8 + 4 + vec_len;
194                if pos + entry_len > capacity {
195                    // Doesn't fit on this page. If the page is still empty the
196                    // vector is too large to persist at all — fail loudly
197                    // instead of silently dropping it (P52.17).
198                    if written_this_page == 0 {
199                        bm.unpin(&self.file_name, page_idx);
200                        return Err(StorageError::Index(format!(
201                            "Vector at id {id} ({vec_len} bytes) does not fit in a {} byte page",
202                            capacity
203                        )));
204                    }
205                    break; // Continue on the next page
206                }
207                // Write vector ID
208                page_data[pos..pos + 8].copy_from_slice(&(id as u64).to_le_bytes());
209                pos += 8;
210                // Write vector data length
211                page_data[pos..pos + 4].copy_from_slice(&(vec_len as u32).to_le_bytes());
212                pos += 4;
213                // Write vector data
214                let mut vec_bytes = Vec::with_capacity(vec_len);
215                for &f in vec_data {
216                    vec_bytes.extend_from_slice(&f.to_le_bytes());
217                }
218                page_data[pos..pos + vec_len].copy_from_slice(&vec_bytes);
219                pos += vec_len;
220                written_this_page += 1;
221                offset += 1;
222            }
223
224            frame.is_dirty = true;
225            bm.unpin(&self.file_name, page_idx);
226            page_idx += 1;
227        }
228
229        self.page_count = page_idx;
230        self.dirty = false;
231        Ok(())
232    }
233
234    /// Load the HNSW index from BufferManager-backed pages.
235    ///
236    /// Reads the header page and exactly `num_vectors` entries from the data
237    /// pages, rebuilding the in-memory index. Zeroed page tails (which the
238    /// page padding leaves behind) are ignored instead of being reconstructed
239    /// as phantom empty vectors (P52.17). After loading, the index is fully
240    /// searchable.
241    pub fn load(&mut self, bm: &mut BufferManager) -> Result<(), StorageError> {
242        if !bm.is_file_registered(&self.file_name) {
243            return Err(StorageError::Index(format!(
244                "Vector index file '{}' not registered with BufferManager",
245                self.file_name
246            )));
247        }
248
249        // Read header page
250        let frame = bm
251            .pin(&self.file_name, 0)
252            .map_err(|e| StorageError::Index(format!("Failed to pin header page: {e}")))?;
253        let header_data = &frame.data;
254        let (num_vectors, _entry_point, _max_level, dimensions, metric) =
255            deserialize_header(header_data).ok_or(StorageError::Index("Invalid vector index header".into()))?;
256        self.dimensions = dimensions;
257        bm.unpin(&self.file_name, 0);
258
259        // Rebuild the HNSW index
260        let mut new_hnsw = HnswIndex::new(metric);
261
262        // Read data pages from page 1 onward until all vectors are loaded.
263        let data_page_start = 1u64;
264        let mut page_idx = data_page_start;
265        let mut loaded = 0usize;
266
267        while (loaded as u64) < num_vectors {
268            let frame_result = bm.pin(&self.file_name, page_idx);
269            let frame = match frame_result {
270                Ok(f) => f,
271                Err(_) => break, // No more pages
272            };
273            let page_data = &frame.data;
274            let capacity = page_data.len();
275            let mut pos = 0usize;
276            let remaining = num_vectors - loaded as u64;
277            let mut loaded_this_page = 0u64;
278
279            while loaded_this_page < remaining && pos + 8 <= capacity {
280                let id = u64::from_le_bytes(page_data[pos..pos + 8].try_into().unwrap()) as usize;
281                pos += 8;
282
283                if pos + 4 > capacity {
284                    break;
285                }
286                let vec_len = u32::from_le_bytes(page_data[pos..pos + 4].try_into().unwrap()) as usize;
287                pos += 4;
288
289                if pos + vec_len > capacity {
290                    break;
291                }
292                if vec_len == 0 {
293                    // Zeroed page tail (padding from save) — no more real data.
294                    break;
295                }
296
297                let dims = vec_len / 8;
298                let mut vec_data = Vec::with_capacity(dims);
299                for i in 0..dims {
300                    let f = f64::from_le_bytes(page_data[pos + i * 8..pos + (i + 1) * 8].try_into().unwrap());
301                    vec_data.push(f);
302                }
303                pos += vec_len;
304
305                new_hnsw.insert(vec_data, id);
306                loaded_this_page += 1;
307            }
308
309            bm.unpin(&self.file_name, page_idx);
310            page_idx += 1;
311            loaded += loaded_this_page as usize;
312
313            if loaded_this_page == 0 {
314                // Empty/zeroed page — nothing more to read.
315                break;
316            }
317            // Safety: prevent infinite loop if pages keep reading
318            if page_idx > 1024 * 1024 {
319                break;
320            }
321        }
322
323        self.hnsw = new_hnsw;
324        self.dirty = false;
325        Ok(())
326    }
327
328    /// Flush dirty pages to disk via the BufferManager.
329    pub fn flush(&mut self, bm: &mut BufferManager) -> Result<(), StorageError> {
330        if self.dirty {
331            self.save(bm)?;
332        }
333        bm.flush_all()
334            .map_err(|e| StorageError::Index(format!("Failed to flush vector index: {e}")))
335    }
336
337    /// Register the vector index file with the BufferManager.
338    pub fn register_file(&self, bm: &mut BufferManager, db_path: &std::path::Path) {
339        let file_path = db_path.join(format!("{}.idx", self.file_name));
340        bm.register_file(&self.file_name, file_path);
341    }
342
343    /// Check whether the in-memory state differs from disk.
344    pub fn is_dirty(&self) -> bool {
345        self.dirty
346    }
347}
348
349/// Helper: extract a `Vec<f64>` from a `Value` (expects `Value::List` of numbers).
350pub fn extract_f64_list_from_value(val: &Value) -> Result<Vec<f64>, StorageError> {
351    akar_common::types::extract_f64_list(val).map_err(|e| StorageError::TypeMismatch {
352        expected: "numeric vector".into(),
353        actual: e,
354    })
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::buffer_manager::BufferManagerConfig;
361    use crate::page::DEFAULT_PAGE_SIZE;
362    use akar_common::memory::MemoryManager;
363    use std::sync::Arc;
364
365    fn setup_bm(db_path: &std::path::Path) -> BufferManager {
366        let mm = Arc::new(MemoryManager::new(64 * 1024 * 1024));
367        BufferManager::new(db_path.to_path_buf(), mm, BufferManagerConfig::default())
368    }
369
370    #[test]
371    fn test_vector_index_save_load_roundtrip() {
372        let dir = tempfile::tempdir().unwrap();
373        let mut bm = setup_bm(dir.path());
374
375        let mut idx = VectorIndexTable::new(
376            1,
377            "vec_idx".into(),
378            "items".into(),
379            "embedding".into(),
380            DistanceMetric::Cosine,
381            3,
382        );
383        idx.register_file(&mut bm, dir.path());
384        idx.hnsw_mut().insert(vec![1.0, 2.0, 3.0], 0);
385        idx.hnsw_mut().insert(vec![4.0, 5.0, 6.0], 1);
386        idx.hnsw_mut().insert(vec![7.0, 8.0, 9.0], 2);
387        assert_eq!(idx.hnsw.len(), 3);
388        idx.save(&mut bm).unwrap();
389
390        // Reload into a fresh instance.
391        let mut loaded = VectorIndexTable::new(
392            1,
393            "vec_idx".into(),
394            "items".into(),
395            "embedding".into(),
396            DistanceMetric::Cosine,
397            0,
398        );
399        loaded.register_file(&mut bm, dir.path());
400        loaded.load(&mut bm).unwrap();
401
402        // Exactly the persisted vectors are rebuilt — no phantom empties (P52.17).
403        assert_eq!(loaded.hnsw.len(), 3);
404        assert_eq!(loaded.dimensions, 3);
405        let hits = loaded.hnsw.search(&[1.0, 2.0, 3.0], 3);
406        assert_eq!(hits.len(), 3);
407        assert_eq!(hits[0].1, 0, "nearest vector to [1,2,3] must be the first inserted");
408        // All persisted vectors must be present in the result set.
409        let mut ids: Vec<usize> = hits.iter().map(|&(_, id)| id).collect();
410        ids.sort_unstable();
411        assert_eq!(ids, vec![0, 1, 2]);
412    }
413
414    #[test]
415    fn test_vector_index_roundtrip_preserves_ids() {
416        // P51.17: node ids (row offsets) must survive persist/load, even when
417        // sparse (rows with a NULL vector are skipped during populate).
418        let dir = tempfile::tempdir().unwrap();
419        let mut bm = setup_bm(dir.path());
420
421        let mut idx = VectorIndexTable::new(
422            2,
423            "sparse_idx".into(),
424            "items".into(),
425            "embedding".into(),
426            DistanceMetric::Euclidean,
427            2,
428        );
429        idx.register_file(&mut bm, dir.path());
430        idx.hnsw_mut().insert(vec![0.0, 0.0], 0);
431        idx.hnsw_mut().insert(vec![1.0, 1.0], 2); // row 1 skipped
432        idx.hnsw_mut().insert(vec![2.0, 2.0], 5);
433        idx.save(&mut bm).unwrap();
434
435        let mut loaded = VectorIndexTable::new(
436            2,
437            "sparse_idx".into(),
438            "items".into(),
439            "embedding".into(),
440            DistanceMetric::Euclidean,
441            0,
442        );
443        loaded.register_file(&mut bm, dir.path());
444        loaded.load(&mut bm).unwrap();
445
446        assert_eq!(loaded.hnsw.len(), 3);
447        assert!(loaded.hnsw().get_vector(5).is_some(), "id 5 must be restored");
448        assert!(loaded.hnsw().get_vector(1).is_none());
449        let hits = loaded.hnsw().search(&[2.0, 2.0], 3);
450        assert_eq!(hits[0].1, 5, "nearest vector must still address row 5");
451    }
452
453    #[test]
454    fn test_vector_index_save_errors_when_vector_too_large() {
455        let dir = tempfile::tempdir().unwrap();
456        let mut bm = setup_bm(dir.path());
457
458        let mut idx = VectorIndexTable::new(
459            1,
460            "vec_idx".into(),
461            "items".into(),
462            "embedding".into(),
463            DistanceMetric::Cosine,
464            1,
465        );
466        idx.register_file(&mut bm, dir.path());
467        // A vector that cannot fit in one page must fail loudly, not silently
468        // drop data (P52.17).
469        let huge = vec![1.0f64; DEFAULT_PAGE_SIZE];
470        idx.hnsw_mut().insert(huge, 0);
471        assert!(idx.save(&mut bm).is_err());
472    }
473}