Skip to main content

grafeo_core/index/vector/
accessor.rs

1//! Vector accessor trait for reading vectors by node ID.
2//!
3//! This module provides the [`VectorAccessor`] trait, which decouples vector
4//! storage from vector indexing. The HNSW index is topology-only (neighbor
5//! lists only, no stored vectors) and reads vectors through this trait from
6//! [`PropertyStorage`], the single source of truth, halving memory usage
7//! for vector workloads.
8//!
9//! # Example
10//!
11//! ```
12//! use grafeo_core::index::vector::VectorAccessor;
13//! use grafeo_common::types::NodeId;
14//! use std::sync::Arc;
15//!
16//! // Closure-based accessor for tests
17//! let accessor = |id: NodeId| -> Option<Arc<[f32]>> {
18//!     Some(vec![1.0, 2.0, 3.0].into())
19//! };
20//! assert!(accessor.get_vector(NodeId::new(1)).is_some());
21//! ```
22
23use std::sync::Arc;
24
25use grafeo_common::types::{NodeId, PropertyKey, Value};
26
27use crate::graph::GraphStore;
28
29/// Trait for reading vectors by node ID.
30///
31/// HNSW is topology-only: vectors live in property storage, not in
32/// HNSW nodes. This trait provides the bridge for reading them.
33pub trait VectorAccessor: Send + Sync {
34    /// Returns the vector associated with the given node ID, if it exists.
35    fn get_vector(&self, id: NodeId) -> Option<Arc<[f32]>>;
36}
37
38/// Reads vectors from a graph store's property storage for a given property key.
39///
40/// This is the primary accessor used by the engine when performing vector
41/// operations. It reads directly from the property store, avoiding any
42/// duplication.
43pub struct PropertyVectorAccessor<'a> {
44    store: &'a dyn GraphStore,
45    property: PropertyKey,
46}
47
48impl<'a> PropertyVectorAccessor<'a> {
49    /// Creates a new accessor for the given store and property key.
50    #[must_use]
51    pub fn new(store: &'a dyn GraphStore, property: impl Into<PropertyKey>) -> Self {
52        Self {
53            store,
54            property: property.into(),
55        }
56    }
57}
58
59impl VectorAccessor for PropertyVectorAccessor<'_> {
60    fn get_vector(&self, id: NodeId) -> Option<Arc<[f32]>> {
61        match self.store.get_node_property(id, &self.property) {
62            Some(Value::Vector(v)) => Some(v),
63            _ => None,
64        }
65    }
66}
67
68/// Reads vectors from a spill-backed store first, falling back to
69/// property storage for vectors that haven't been spilled (e.g., new inserts).
70///
71/// Created by the engine when a vector index has been spilled to disk.
72/// The mmap-backed store serves the bulk of reads (zero-copy from page cache),
73/// while the property store catches any vectors inserted after the spill.
74pub struct SpillableVectorAccessor<'a> {
75    store: &'a dyn GraphStore,
76    property: PropertyKey,
77    spill_storage: Arc<dyn super::storage::VectorStorage>,
78}
79
80impl<'a> SpillableVectorAccessor<'a> {
81    /// Creates a new accessor that checks `spill_storage` first, then falls
82    /// back to the property store.
83    #[must_use]
84    pub fn new(
85        store: &'a dyn GraphStore,
86        property: impl Into<PropertyKey>,
87        spill_storage: Arc<dyn super::storage::VectorStorage>,
88    ) -> Self {
89        Self {
90            store,
91            property: property.into(),
92            spill_storage,
93        }
94    }
95}
96
97impl VectorAccessor for SpillableVectorAccessor<'_> {
98    fn get_vector(&self, id: NodeId) -> Option<Arc<[f32]>> {
99        // Try spill storage first (mmap-backed, serves most reads)
100        if let Some(v) = self.spill_storage.get(id) {
101            return Some(v);
102        }
103        // Fall back to property store (new inserts after spill)
104        match self.store.get_node_property(id, &self.property) {
105            Some(Value::Vector(v)) => Some(v),
106            _ => None,
107        }
108    }
109}
110
111/// An accessor that dispatches to either a property store or a spill-backed store.
112///
113/// This enum avoids dynamic dispatch (`Box<dyn VectorAccessor>`) so it can be
114/// passed to `HnswIndex::search(&impl VectorAccessor)` without requiring `Sized`
115/// workarounds.
116#[non_exhaustive]
117pub enum VectorAccessorKind<'a> {
118    /// Direct property store lookup (default, no spill).
119    Property(PropertyVectorAccessor<'a>),
120    /// Spill-backed: checks MmapStorage first, falls back to property store.
121    Spilled(SpillableVectorAccessor<'a>),
122}
123
124impl VectorAccessor for VectorAccessorKind<'_> {
125    fn get_vector(&self, id: NodeId) -> Option<Arc<[f32]>> {
126        match self {
127            Self::Property(a) => a.get_vector(id),
128            Self::Spilled(a) => a.get_vector(id),
129        }
130    }
131}
132
133/// Blanket implementation for closures, useful in tests.
134impl<F> VectorAccessor for F
135where
136    F: Fn(NodeId) -> Option<Arc<[f32]>> + Send + Sync,
137{
138    fn get_vector(&self, id: NodeId) -> Option<Arc<[f32]>> {
139        self(id)
140    }
141}
142
143#[cfg(all(test, feature = "lpg"))]
144mod tests {
145    use super::*;
146    use crate::graph::lpg::LpgStore;
147
148    #[test]
149    fn test_closure_accessor() {
150        let vectors: std::collections::HashMap<NodeId, Arc<[f32]>> = [
151            (NodeId::new(1), Arc::from(vec![1.0_f32, 0.0, 0.0])),
152            (NodeId::new(2), Arc::from(vec![0.0_f32, 1.0, 0.0])),
153        ]
154        .into_iter()
155        .collect();
156
157        let accessor = move |id: NodeId| -> Option<Arc<[f32]>> { vectors.get(&id).cloned() };
158
159        assert!(accessor.get_vector(NodeId::new(1)).is_some());
160        assert_eq!(accessor.get_vector(NodeId::new(1)).unwrap().len(), 3);
161        assert!(accessor.get_vector(NodeId::new(3)).is_none());
162    }
163
164    #[test]
165    fn test_property_vector_accessor() {
166        let store = LpgStore::new().unwrap();
167        let id = store.create_node(&["Test"]);
168        let vec_data: Arc<[f32]> = vec![1.0, 2.0, 3.0].into();
169        store.set_node_property(id, "embedding", Value::Vector(vec_data.clone()));
170
171        let accessor = PropertyVectorAccessor::new(&store, "embedding");
172        let result = accessor.get_vector(id);
173        assert!(result.is_some());
174        assert_eq!(result.unwrap().as_ref(), vec_data.as_ref());
175
176        // Non-existent node
177        assert!(accessor.get_vector(NodeId::new(999)).is_none());
178
179        // Wrong property type
180        store.set_node_property(id, "name", Value::from("hello"));
181        let name_accessor = PropertyVectorAccessor::new(&store, "name");
182        assert!(name_accessor.get_vector(id).is_none());
183    }
184}
185
186#[cfg(all(test, feature = "lpg", feature = "vector-index"))]
187mod spill_tests {
188    use super::*;
189    use crate::graph::lpg::LpgStore;
190    use crate::index::vector::storage::{RamStorage, VectorStorage};
191
192    #[test]
193    fn spill_accessor_returns_vector_from_spill_storage() {
194        let store = LpgStore::new().unwrap();
195        let alix_id = store.create_node(&["Person"]);
196        let spill_vec: Vec<f32> = vec![0.1, 0.2, 0.3];
197        let spill = Arc::new(RamStorage::new(3));
198        spill.insert(alix_id, &spill_vec).unwrap();
199
200        let accessor = SpillableVectorAccessor::new(&store as &dyn GraphStore, "embedding", spill);
201        let result = accessor.get_vector(alix_id);
202        assert!(result.is_some());
203        assert_eq!(result.unwrap().as_ref(), spill_vec.as_slice());
204    }
205
206    #[test]
207    fn spill_accessor_falls_back_to_property_store() {
208        let store = LpgStore::new().unwrap();
209        let gus_id = store.create_node(&["Person"]);
210        let prop_vec: Arc<[f32]> = vec![0.4, 0.5, 0.6].into();
211        store.set_node_property(gus_id, "embedding", Value::Vector(prop_vec.clone()));
212
213        let spill: Arc<dyn VectorStorage> = Arc::new(RamStorage::new(3));
214        let accessor = SpillableVectorAccessor::new(&store as &dyn GraphStore, "embedding", spill);
215        let result = accessor.get_vector(gus_id);
216        assert!(result.is_some());
217        assert_eq!(result.unwrap().as_ref(), prop_vec.as_ref());
218    }
219
220    #[test]
221    fn spill_accessor_prefers_spill_over_property_store() {
222        let store = LpgStore::new().unwrap();
223        let vincent_id = store.create_node(&["Person"]);
224        let prop_vec: Arc<[f32]> = vec![1.0, 0.0, 0.0].into();
225        store.set_node_property(vincent_id, "embedding", Value::Vector(prop_vec));
226
227        let spill_vec: Vec<f32> = vec![0.0, 1.0, 0.0];
228        let spill = Arc::new(RamStorage::new(3));
229        spill.insert(vincent_id, &spill_vec).unwrap();
230
231        let accessor = SpillableVectorAccessor::new(&store as &dyn GraphStore, "embedding", spill);
232        let result = accessor.get_vector(vincent_id).unwrap();
233        assert_eq!(result.as_ref(), spill_vec.as_slice());
234    }
235
236    #[test]
237    fn spill_accessor_returns_none_when_missing() {
238        let store = LpgStore::new().unwrap();
239        let spill: Arc<dyn VectorStorage> = Arc::new(RamStorage::new(3));
240        let accessor = SpillableVectorAccessor::new(&store as &dyn GraphStore, "embedding", spill);
241        assert!(accessor.get_vector(NodeId::new(999)).is_none());
242    }
243
244    #[test]
245    fn accessor_kind_property_dispatches() {
246        let store = LpgStore::new().unwrap();
247        let jules_id = store.create_node(&["Person"]);
248        let vec_data: Arc<[f32]> = vec![0.7, 0.8, 0.9].into();
249        store.set_node_property(jules_id, "embedding", Value::Vector(vec_data.clone()));
250
251        let accessor =
252            VectorAccessorKind::Property(PropertyVectorAccessor::new(&store, "embedding"));
253        let result = accessor.get_vector(jules_id);
254        assert!(result.is_some());
255        assert_eq!(result.unwrap().as_ref(), vec_data.as_ref());
256        assert!(accessor.get_vector(NodeId::new(999)).is_none());
257    }
258
259    #[test]
260    fn accessor_kind_spilled_dispatches() {
261        let store = LpgStore::new().unwrap();
262        let mia_id = store.create_node(&["Person"]);
263        let spill_vec: Vec<f32> = vec![0.3, 0.6, 0.9];
264        let spill = Arc::new(RamStorage::new(3));
265        spill.insert(mia_id, &spill_vec).unwrap();
266
267        let accessor = VectorAccessorKind::Spilled(SpillableVectorAccessor::new(
268            &store as &dyn GraphStore,
269            "embedding",
270            spill,
271        ));
272        let result = accessor.get_vector(mia_id);
273        assert!(result.is_some());
274        assert_eq!(result.unwrap().as_ref(), spill_vec.as_slice());
275    }
276
277    #[test]
278    fn accessor_kind_spilled_uses_fallback() {
279        let store = LpgStore::new().unwrap();
280        let butch_id = store.create_node(&["Person"]);
281        let prop_vec: Arc<[f32]> = vec![0.2, 0.4, 0.6].into();
282        store.set_node_property(butch_id, "embedding", Value::Vector(prop_vec.clone()));
283
284        let spill: Arc<dyn VectorStorage> = Arc::new(RamStorage::new(3));
285        let accessor = VectorAccessorKind::Spilled(SpillableVectorAccessor::new(
286            &store as &dyn GraphStore,
287            "embedding",
288            spill,
289        ));
290        let result = accessor.get_vector(butch_id);
291        assert!(result.is_some());
292        assert_eq!(result.unwrap().as_ref(), prop_vec.as_ref());
293    }
294}