Skip to main content

nodedb_lite/engine/vector/
segment.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Vector segment payloads in node-id order, and the check that a stored
4//! segment serves the nodes of the index it is attached to.
5//!
6//! A segment backs a graph-only checkpoint positionally: slot `i` holds the
7//! vector of node `i`. The payload is therefore laid out in node-id order,
8//! and each slot's surrogate records the document bound to that node as a
9//! [`doc_fingerprint`]. On load, [`segment_serves_index`] compares those
10//! fingerprints with the id map, so a segment written for another node
11//! order is refused and the index is rebuilt from the durable rows.
12
13use std::collections::HashMap;
14
15use nodedb_vector::segment_backing::VectorSegmentBacking;
16
17use crate::engine::vector::{HnswIndex, IndexIdMap};
18use crate::error::LiteError;
19use crate::storage::engine::StorageEngine;
20
21/// Where one node's segment vector comes from.
22pub(crate) enum NodeSource {
23    /// The durable row of the bound document.
24    Bound(String),
25    /// A node bound to no document (a tombstone): its own vector, kept so
26    /// graph traversal through it scores the same after a reload.
27    Local(Vec<f32>),
28}
29
30/// The surrogate recorded for a node bound to `doc_id`. Never 0, the
31/// surrogate of an unbound slot.
32pub(crate) fn doc_fingerprint(doc_id: &str) -> u64 {
33    // FNV-1a: stable across builds and platforms.
34    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
35    for byte in doc_id.as_bytes() {
36        hash ^= u64::from(*byte);
37        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
38    }
39    hash.max(1)
40}
41
42/// Where each node of `index` takes its segment vector from, in node-id
43/// order, read under the caller's lock on the index map. `None` when a node
44/// is bound to no document and its own vector is unreadable.
45pub(crate) fn node_sources(index: &HnswIndex, ids: Option<&IndexIdMap>) -> Option<Vec<NodeSource>> {
46    (0..index.len() as u32)
47        .map(|node| match ids.and_then(|m| m.doc_id(node)) {
48            Some(doc_id) => Some(NodeSource::Bound(doc_id.to_owned())),
49            None => index
50                .get_vector_or_backing(node)
51                .map(|v| NodeSource::Local(v.into_owned())),
52        })
53        .collect()
54}
55
56/// Build segment vectors in node-id order from durable bound rows.
57///
58/// Empty sources return `None`. Missing rows and invalid widths return corruption errors.
59pub(crate) async fn segment_payload<S: StorageEngine>(
60    storage: &S,
61    index_key: &str,
62    sources: Vec<NodeSource>,
63) -> Result<Option<(usize, Vec<Vec<f32>>, Vec<u64>)>, LiteError> {
64    if sources.is_empty() {
65        return Ok(None);
66    }
67    let mut durable: HashMap<String, Vec<f32>> =
68        crate::engine::vector::durable::load_collection(storage, index_key)
69            .await?
70            .into_iter()
71            .collect();
72    let mut vectors = Vec::with_capacity(sources.len());
73    let mut surrogates = Vec::with_capacity(sources.len());
74    for source in sources {
75        match source {
76            NodeSource::Bound(doc_id) => {
77                let vector = durable.remove(&doc_id).ok_or_else(|| LiteError::Corrupted {
78                    detail: format!("vector segment '{index_key}' has no durable row for '{doc_id}': restore the vector row"),
79                })?;
80                surrogates.push(doc_fingerprint(&doc_id));
81                vectors.push(vector);
82            }
83            NodeSource::Local(vector) => {
84                surrogates.push(0);
85                vectors.push(vector);
86            }
87        }
88    }
89    let dim = vectors.first().map_or(0, Vec::len);
90    if dim == 0 {
91        return Err(LiteError::Corrupted {
92            detail: format!(
93                "vector segment '{index_key}' has zero dimensions: restore valid vector rows"
94            ),
95        });
96    }
97    if let Some(vector) = vectors.iter().find(|v| v.len() != dim) {
98        return Err(LiteError::Corrupted {
99            detail: format!(
100                "vector segment '{index_key}' expects {dim} dimensions, found {}: restore matching vector rows",
101                vector.len()
102            ),
103        });
104    }
105    Ok(Some((dim, vectors, surrogates)))
106}
107
108/// Whether `backing` serves every node of an index of `node_count` nodes:
109/// it holds a slot per node, and each bound node's slot carries that node's
110/// document fingerprint. With no bindings to compare against, only an empty
111/// index is served.
112pub(crate) fn segment_serves_index(
113    backing: &dyn VectorSegmentBacking,
114    node_count: usize,
115    ids: Option<&IndexIdMap>,
116) -> bool {
117    if backing.len() < node_count {
118        return false;
119    }
120    let Some(ids) = ids else {
121        return node_count == 0;
122    };
123    ids.iter().all(|(doc_id, node)| {
124        (node as usize) < node_count && backing.get_surrogate(node) == Some(doc_fingerprint(doc_id))
125    })
126}
127
128/// Attach `backing` to `index` when it serves the index's nodes: one slot
129/// per node, each bound node's slot carrying its document's fingerprint, and
130/// every slot `index` reads through the backing holding a vector of the
131/// index's width. Fails, leaving `index` without the backing, otherwise.
132pub(crate) fn attach_verified<B: VectorSegmentBacking + 'static>(
133    index: &mut HnswIndex,
134    backing: B,
135    ids: Option<&IndexIdMap>,
136) -> Result<(), LiteError> {
137    if !segment_serves_index(&backing, index.len(), ids) {
138        return Err(LiteError::Corrupted {
139            detail: format!(
140                "vector segment of {} slots does not carry the documents bound to the \
141                 index's {} nodes in node order",
142                backing.len(),
143                index.len()
144            ),
145        });
146    }
147    index.with_backing(std::sync::Arc::new(backing))?;
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn fingerprints_are_stable_and_never_zero() {
157        assert_eq!(doc_fingerprint("a"), doc_fingerprint("a"));
158        assert_ne!(doc_fingerprint("a"), doc_fingerprint("b"));
159        assert_ne!(doc_fingerprint(""), 0);
160    }
161    #[tokio::test]
162    async fn payload_requires_durable_bound_rows_and_consistent_widths() {
163        use crate::storage::pagedb_storage::PagedbStorageMem;
164        let storage = PagedbStorageMem::open_in_memory().await.unwrap();
165        assert!(
166            segment_payload(&storage, "vectors", Vec::new())
167                .await
168                .unwrap()
169                .is_none()
170        );
171        let missing = segment_payload(
172            &storage,
173            "vectors",
174            vec![NodeSource::Bound("missing".into())],
175        )
176        .await
177        .unwrap_err();
178        assert!(matches!(missing, LiteError::Corrupted { .. }));
179        assert!(missing.to_string().contains("missing"));
180        let zero = segment_payload(&storage, "vectors", vec![NodeSource::Local(Vec::new())])
181            .await
182            .unwrap_err();
183        assert!(matches!(zero, LiteError::Corrupted { .. }));
184        let mismatch = segment_payload(
185            &storage,
186            "vectors",
187            vec![
188                NodeSource::Local(vec![1.0, 2.0]),
189                NodeSource::Local(vec![3.0]),
190            ],
191        )
192        .await
193        .unwrap_err();
194        assert!(matches!(mismatch, LiteError::Corrupted { .. }));
195        storage
196            .batch_write(&[crate::engine::vector::durable::put_op(
197                "vectors",
198                "bound",
199                &[4.0, 5.0],
200            )])
201            .await
202            .unwrap();
203        let payload = segment_payload(
204            &storage,
205            "vectors",
206            vec![
207                NodeSource::Bound("bound".into()),
208                NodeSource::Local(vec![1.0, 2.0]),
209            ],
210        )
211        .await
212        .unwrap()
213        .unwrap();
214        assert_eq!(payload.0, 2);
215        assert_eq!(payload.1, vec![vec![4.0, 5.0], vec![1.0, 2.0]]);
216        assert_eq!(payload.2, vec![doc_fingerprint("bound"), 0]);
217    }
218}