Skip to main content

nodedb_lite/nodedb/trait_impl/
vector.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Vector engine helpers for `NodeDbLite`.
4
5use std::collections::HashSet;
6
7use loro::LoroValue;
8
9use nodedb_types::document::Document;
10use nodedb_types::error::{NodeDbError, NodeDbResult};
11use nodedb_types::filter::MetadataFilter;
12use nodedb_types::result::SearchResult;
13
14use crate::engine::vector::nodes::{encode_sidecar, unbind_node, upsert_node};
15use crate::engine::vector::resident::{check_insert_widths, lock_resident};
16use crate::engine::vector::row::{
17    EMBEDDING_DIM_FIELD, VECTOR_FIELD_TAG, VECTOR_ROW_FIELDS, VectorSlot, detach_vector_row,
18    is_vector_primary,
19};
20use crate::engine::vector::search::{
21    AnnTuning, SearchFilter, VectorSearchRequest, run_vector_search,
22};
23use crate::nodedb::LockExt;
24use crate::nodedb::NodeDbLite;
25use crate::nodedb::convert::value_to_loro;
26use crate::storage::engine::StorageEngine;
27
28/// Internal fields stripped from search-result metadata for a single-vector collection.
29pub(super) const INTERNAL_FIELDS_BASE: &[&str] = &[EMBEDDING_DIM_FIELD];
30/// Internal fields stripped from search-result metadata for a named-vector collection
31/// (adds `__field` which records which named vector the row belongs to).
32pub(super) const INTERNAL_FIELDS_NAMED: &[&str] = VECTOR_ROW_FIELDS;
33
34/// One search issued through the `NodeDb` trait.
35///
36/// `index_key` names the HNSW index. `collection` names the CRDT collection
37/// that holds the row metadata. `exclude_fields` lists the vector-internal row
38/// fields that never reach result metadata.
39pub(super) struct TraitVectorSearch<'a> {
40    pub index_key: &'a str,
41    pub collection: &'a str,
42    pub query: &'a [f32],
43    pub k: usize,
44    pub filter: Option<&'a MetadataFilter>,
45    pub exclude_fields: &'a [&'a str],
46    pub allowed_ids: Option<&'a HashSet<String>>,
47}
48
49impl<S: StorageEngine> NodeDbLite<S> {
50    /// Shared vector search implementation.
51    ///
52    /// When `allowed_ids` is `Some`, translates the set of string doc-IDs to a
53    /// `RoaringBitmap` of the nodes they hold in `index_key`'s index and
54    /// passes it as the `prefilter_bitmap`, so only documents from the allowed
55    /// set are returned. Nodes of any other index never enter the bitmap.
56    pub(super) async fn vector_search_internal(
57        &self,
58        search: TraitVectorSearch<'_>,
59    ) -> NodeDbResult<Vec<SearchResult>> {
60        let TraitVectorSearch {
61            index_key,
62            collection,
63            query,
64            k,
65            filter,
66            exclude_fields,
67            allowed_ids,
68        } = search;
69        let prefilter = allowed_ids.map(|ids| {
70            let id_map = self.vector_state.vector_id_map.lock_or_recover();
71            let mut bm = roaring::RoaringBitmap::new();
72            if let Some(index_ids) = id_map.index(index_key) {
73                bm.extend(ids.iter().filter_map(|doc_id| index_ids.node(doc_id)));
74            }
75            bm
76        });
77        run_vector_search(
78            &self.vector_state,
79            &self.crdt,
80            VectorSearchRequest {
81                index_key,
82                collection,
83                query,
84                k,
85                filter: SearchFilter {
86                    metadata: filter,
87                    exclude_fields,
88                    prefilter_bitmap: prefilter.as_ref(),
89                },
90                tuning: AnnTuning::default(),
91                skip_payload_fetch: false,
92            },
93        )
94        .await
95    }
96
97    /// Insert a single embedding into the collection's default HNSW index and
98    /// merge its metadata (including the embedding dimension) into the CRDT
99    /// row with the same id. Other row fields are kept. Re-inserting an id
100    /// replaces its vector. Lazily creates the HNSW index on first insert.
101    pub(super) async fn vector_insert_impl(
102        &self,
103        collection: &str,
104        id: &str,
105        embedding: &[f32],
106        metadata: Option<Document>,
107    ) -> NodeDbResult<()> {
108        if self.governor.worst_engine_pressure() == nodedb_mem::PressureLevel::Emergency {
109            return Err(nodedb_types::error::NodeDbError::storage(
110                crate::error::LiteError::Backpressure {
111                    detail: "vector insert rejected: memory governor is at Emergency pressure"
112                        .into(),
113                },
114            ));
115        }
116
117        // A vector of another width than the collection's index, loaded back
118        // if it was evicted, is refused before its durable row is written.
119        check_insert_widths(&self.vector_state, collection, [embedding.len()])
120            .await
121            .map_err(NodeDbError::from)?;
122
123        // Make the vector durable BEFORE it enters the in-memory index. The
124        // durable row is the source of truth that the index and the pagedb
125        // segment are both derived from, so it must never be the copy that is
126        // missing after a crash — and a flush that found no durable row would
127        // write no segment at all.
128        if !embedding.is_empty() {
129            let op = crate::engine::vector::durable::put_op(collection, id, embedding);
130            self.storage
131                .batch_write(std::slice::from_ref(&op))
132                .await
133                .map_err(NodeDbError::storage)?;
134        }
135
136        // Replaces the node `id` held before, so the id stays one node.
137        let internal_id = upsert_node(&self.vector_state, collection, id, embedding)
138            .await
139            .map_err(NodeDbError::from)?;
140        // A sidecar install error is a bad request (e.g. unsupported codec).
141        encode_sidecar(&self.vector_state, collection, internal_id, embedding)
142            .map_err(|e| NodeDbError::bad_request(e.to_string()))?;
143
144        {
145            let mut crdt = self.crdt.lock_or_recover();
146            let mut fields = vec![(EMBEDDING_DIM_FIELD, LoroValue::I64(embedding.len() as i64))];
147            if let Some(meta) = &metadata {
148                for (k, v) in &meta.fields {
149                    fields.push((k.as_str(), value_to_loro(v)));
150                }
151            }
152            // A merge: the vector attaches to the row and keeps its fields.
153            crdt.set_fields(collection, id, &fields)
154                .map_err(NodeDbError::from)?;
155        }
156
157        // Enqueue for sync to Origin (no-op when sync is disabled).
158        #[cfg(not(target_arch = "wasm32"))]
159        if let Some(q) = &self.vector_outbound {
160            crate::sync::reconcile_outbound_enqueue(
161                q.enqueue_insert(collection, id, embedding.to_vec(), embedding.len(), "")
162                    .await,
163                "vector insert",
164                collection,
165                id,
166            )
167            .map_err(nodedb_types::error::NodeDbError::storage)?;
168        }
169
170        self.update_memory_stats();
171        Ok(())
172    }
173
174    /// Tombstone the base embedding of `id` and detach it from its CRDT row.
175    /// See [`Self::vector_delete_slot`].
176    pub(super) async fn vector_delete_impl(&self, collection: &str, id: &str) -> NodeDbResult<()> {
177        self.vector_delete_slot(collection, "", id).await
178    }
179
180    /// Delete the named vector `field_name` of `id` in `collection`, keeping
181    /// the row's document fields and any other vector attached to it. An
182    /// empty `field_name` names the base vector, as `vector_delete` does.
183    pub async fn vector_delete_field(
184        &self,
185        collection: &str,
186        field_name: &str,
187        id: &str,
188    ) -> NodeDbResult<()> {
189        self.vector_delete_slot(collection, field_name, id).await
190    }
191
192    /// Tombstone one embedding of `id` in its HNSW index and detach it from
193    /// its CRDT row: only the fields that vector owns alone go, and document
194    /// fields stay. The whole row goes once no vector remains attached and
195    /// the collection is vector-primary or no document field remains. The
196    /// tombstoned HNSW node keeps its slot; search skips it, and nothing here
197    /// compacts the index.
198    async fn vector_delete_slot(
199        &self,
200        collection: &str,
201        field_name: &str,
202        id: &str,
203    ) -> NodeDbResult<()> {
204        let (index_key, slot) = if field_name.is_empty() {
205            (collection.to_string(), VectorSlot::Base)
206        } else {
207            (
208                format!("{collection}:{field_name}"),
209                VectorSlot::Named(field_name),
210            )
211        };
212
213        // Drop the durable row FIRST. It is the source of truth the index is
214        // rebuilt from, so leaving it behind would resurrect a deleted vector
215        // on the next rebuild — the in-memory tombstone below does not survive
216        // one. Ordering also matters: if the process dies between the two, a
217        // surviving durable row would come back, whereas a removed row simply
218        // leaves the tombstoned slot to be rebuilt away.
219        crate::engine::vector::durable::remove(&*self.storage, &index_key, id)
220            .await
221            .map_err(NodeDbError::from)?;
222
223        // The index, loaded back if it was evicted, takes the tombstone:
224        // an evicted index that missed it would bring the vector back.
225        let (internal_id, remaining) = {
226            let mut indices = lock_resident(&self.vector_state, &index_key)
227                .await
228                .map_err(NodeDbError::from)?;
229            let node = unbind_node(
230                &self.vector_state,
231                indices.get_mut(&index_key),
232                &index_key,
233                id,
234            );
235            let remaining = self
236                .vector_state
237                .vector_id_map
238                .lock_or_recover()
239                .attached_vectors(collection, id);
240            (node, remaining)
241        };
242
243        if internal_id.is_some() {
244            // Persist the updated sidecar after every delete. Deletes change
245            // the sidecar's encoded-vector set in a way that cannot be
246            // reconstructed cheaply from HNSW vectors alone (a deleted slot
247            // is tombstoned and has no live vector to re-encode). Persisting
248            // here ensures restarts don't re-surface deleted entries.
249            if let Err(e) =
250                crate::engine::vector::sidecar::persist_sidecar(&self.vector_state, &index_key)
251                    .await
252            {
253                tracing::warn!(
254                    error = %e,
255                    index_key,
256                    "sidecar persist after delete failed; in-memory sidecar still valid"
257                );
258            }
259        }
260
261        let vector_primary = is_vector_primary(&*self.storage, collection)
262            .await
263            .map_err(NodeDbError::from)?;
264        {
265            let mut crdt = self.crdt.lock_or_recover();
266            detach_vector_row(&mut crdt, collection, id, slot, &remaining, vector_primary)
267                .map_err(NodeDbError::storage)?;
268        }
269
270        // Enqueue for sync to Origin (no-op when sync is disabled).
271        #[cfg(not(target_arch = "wasm32"))]
272        if let Some(q) = &self.vector_outbound {
273            crate::sync::reconcile_outbound_enqueue(
274                q.enqueue_delete(collection, id, field_name).await,
275                "vector delete",
276                collection,
277                id,
278            )
279            .map_err(nodedb_types::error::NodeDbError::storage)?;
280        }
281
282        Ok(())
283    }
284
285    /// Insert an embedding into a named-vector sub-index of a collection.
286    ///
287    /// Each named field gets its own HNSW index keyed by `"{collection}:{field_name}"`
288    /// so a single document can carry multiple independent embeddings. The CRDT row
289    /// records the `__field` tag so search results can be re-associated with the
290    /// originating field; the row's other fields are kept. Re-inserting an id
291    /// replaces its vector. When `field_name` is empty, this is equivalent to
292    /// [`Self::vector_insert_impl`] (no `__field` tag, index keyed by collection).
293    pub(super) async fn vector_insert_field_impl(
294        &self,
295        collection: &str,
296        field_name: &str,
297        id: &str,
298        embedding: &[f32],
299        metadata: Option<Document>,
300    ) -> NodeDbResult<()> {
301        let index_key = if field_name.is_empty() {
302            collection.to_string()
303        } else {
304            format!("{collection}:{field_name}")
305        };
306
307        check_insert_widths(&self.vector_state, &index_key, [embedding.len()])
308            .await
309            .map_err(NodeDbError::from)?;
310
311        // Durable row first — see `vector_insert_impl`. Keyed by `index_key` so
312        // each named-vector sub-index rebuilds from its own rows.
313        if !embedding.is_empty() {
314            let op = crate::engine::vector::durable::put_op(&index_key, id, embedding);
315            self.storage
316                .batch_write(std::slice::from_ref(&op))
317                .await
318                .map_err(NodeDbError::storage)?;
319        }
320
321        // Replaces the node `id` held before, so the id stays one node.
322        let internal_id = upsert_node(&self.vector_state, &index_key, id, embedding)
323            .await
324            .map_err(NodeDbError::from)?;
325        encode_sidecar(&self.vector_state, &index_key, internal_id, embedding)
326            .map_err(|e| NodeDbError::bad_request(e.to_string()))?;
327
328        {
329            let mut crdt = self.crdt.lock_or_recover();
330            let mut fields = vec![
331                (EMBEDDING_DIM_FIELD, LoroValue::I64(embedding.len() as i64)),
332                (VECTOR_FIELD_TAG, LoroValue::String(field_name.into())),
333            ];
334            if let Some(meta) = &metadata {
335                for (k, v) in &meta.fields {
336                    fields.push((k.as_str(), value_to_loro(v)));
337                }
338            }
339            // A merge: the vector attaches to the row and keeps its fields.
340            crdt.set_fields(collection, id, &fields)
341                .map_err(NodeDbError::from)?;
342        }
343
344        // Enqueue for sync to Origin (no-op when sync is disabled).
345        #[cfg(not(target_arch = "wasm32"))]
346        if let Some(q) = &self.vector_outbound {
347            crate::sync::reconcile_outbound_enqueue(
348                q.enqueue_insert(
349                    collection,
350                    id,
351                    embedding.to_vec(),
352                    embedding.len(),
353                    field_name,
354                )
355                .await,
356                "vector field insert",
357                collection,
358                id,
359            )
360            .map_err(nodedb_types::error::NodeDbError::storage)?;
361        }
362
363        self.update_memory_stats();
364        Ok(())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    //! Vector delete on a collection declared vector-primary: the row exists
371    //! for its vector, so it goes with the last vector even when it carries
372    //! payload fields.
373
374    use nodedb_client::NodeDb;
375    use nodedb_types::collection::CollectionType;
376    use nodedb_types::collection_config::{PartitionStrategy, PrimaryEngine};
377    use nodedb_types::document::Document;
378    use nodedb_types::id::DatabaseId;
379    use nodedb_types::sync::wire::CollectionDescriptor;
380    use nodedb_types::value::Value;
381
382    use crate::PagedbStorageMem;
383    use crate::engine::vector::row::is_vector_primary;
384    use crate::nodedb::NodeDbLite;
385    use crate::nodedb::collection::CollectionMeta;
386    use crate::storage::engine::StorageEngine;
387
388    async fn open_db() -> std::sync::Arc<NodeDbLite<PagedbStorageMem>> {
389        let storage = PagedbStorageMem::open_in_memory()
390            .await
391            .expect("in-memory storage");
392        NodeDbLite::open(storage).await.expect("open")
393    }
394
395    /// Persist `name`'s collection meta with a vector-primary descriptor, as an
396    /// inbound schema announcement does.
397    async fn declare_vector_primary(db: &NodeDbLite<PagedbStorageMem>, name: &str) {
398        let descriptor = CollectionDescriptor {
399            tenant_id: 1,
400            database_id: DatabaseId::DEFAULT,
401            name: name.into(),
402            collection_type: CollectionType::document(),
403            bitemporal: false,
404            crdt: false,
405            fields: Vec::new(),
406            primary: PrimaryEngine::Vector,
407            vector_primary: None,
408            partition_strategy: PartitionStrategy::default(),
409            declared_primary_key: None,
410            descriptor_version: 1,
411        };
412        let meta = CollectionMeta {
413            name: name.into(),
414            collection_type: "document".into(),
415            created_at_ms: 0,
416            fields: Vec::new(),
417            config_json: None,
418            descriptor_json: Some(sonic_rs::to_string(&descriptor).expect("descriptor json")),
419            bitemporal: false,
420            crdt: false,
421        };
422        db.storage
423            .put(
424                nodedb_types::Namespace::Meta,
425                format!("collection:{name}").as_bytes(),
426                &sonic_rs::to_vec(&meta).expect("meta json"),
427            )
428            .await
429            .expect("put meta");
430    }
431
432    #[tokio::test]
433    async fn vector_delete_on_vector_collection_removes_row() {
434        let db = open_db().await;
435        declare_vector_primary(&db, "vp").await;
436        assert!(
437            is_vector_primary(&*db.storage, "vp")
438                .await
439                .expect("read meta")
440        );
441
442        let mut payload = Document::new("v1");
443        payload.set("title", Value::String("hello".into()));
444        db.vector_insert("vp", "v1", &[1.0, 0.0], Some(payload))
445            .await
446            .expect("vector_insert");
447        db.vector_delete("vp", "v1").await.expect("vector_delete");
448
449        assert!(
450            db.document_get("vp", "v1")
451                .await
452                .expect("document_get")
453                .is_none(),
454            "a vector-primary row goes with its vector"
455        );
456    }
457
458    #[tokio::test]
459    async fn undeclared_collection_is_not_vector_primary() {
460        let db = open_db().await;
461        assert!(
462            !is_vector_primary(&*db.storage, "plain")
463                .await
464                .expect("read meta")
465        );
466    }
467}