Skip to main content

nodedb_lite/engine/vector/
id_map.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Bidirectional binding between HNSW node ids and document ids.
4//!
5//! Each HNSW index (keyed by its index key: `"{collection}"` for the base
6//! vector, `"{collection}:{field}"` for a named one) binds every document id
7//! to at most one live node, and every node to one document id. Both
8//! directions live behind one type, so no caller can update one side only.
9//!
10//! Bindings are grouped per index key rather than flattened into
11//! `"{index_key}:{node}"` strings. A flat string key makes `chat` a prefix of
12//! `chat2:*` and of the named key `chat:field:*`, so a prefix match leaks
13//! nodes of one index into another. The flat form survives only as the
14//! persisted layout ([`VectorIdMap::to_entries`] / [`VectorIdMap::from_entries`]).
15
16use std::collections::HashMap;
17
18/// The node ↔ document bindings of one HNSW index.
19#[derive(Debug, Default, Clone)]
20pub struct IndexIdMap {
21    by_node: HashMap<u32, String>,
22    by_doc: HashMap<String, u32>,
23}
24
25impl IndexIdMap {
26    /// Bind `doc_id` to `node`. Returns the node `doc_id` was bound to before,
27    /// when that was a different node: the caller tombstones it. A document
28    /// previously bound to `node` loses its binding.
29    pub fn bind(&mut self, doc_id: &str, node: u32) -> Option<u32> {
30        if let Some(prev_doc) = self.by_node.insert(node, doc_id.to_owned())
31            && prev_doc != doc_id
32            && self.by_doc.get(&prev_doc) == Some(&node)
33        {
34            self.by_doc.remove(&prev_doc);
35        }
36        match self.by_doc.insert(doc_id.to_owned(), node) {
37            Some(old) if old != node => {
38                self.by_node.remove(&old);
39                Some(old)
40            }
41            _ => None,
42        }
43    }
44
45    /// The node bound to `doc_id`.
46    pub fn node(&self, doc_id: &str) -> Option<u32> {
47        self.by_doc.get(doc_id).copied()
48    }
49
50    /// The document bound to `node`.
51    pub fn doc_id(&self, node: u32) -> Option<&str> {
52        self.by_node.get(&node).map(String::as_str)
53    }
54
55    /// Remove the binding of `doc_id`. Returns its node.
56    pub fn unbind_doc(&mut self, doc_id: &str) -> Option<u32> {
57        let node = self.by_doc.remove(doc_id)?;
58        self.by_node.remove(&node);
59        Some(node)
60    }
61
62    /// Remove the binding of `node`. Returns its document id.
63    pub fn unbind_node(&mut self, node: u32) -> Option<String> {
64        let doc_id = self.by_node.remove(&node)?;
65        self.by_doc.remove(&doc_id);
66        Some(doc_id)
67    }
68
69    /// Number of bound documents.
70    pub fn len(&self) -> usize {
71        self.by_doc.len()
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.by_doc.is_empty()
76    }
77
78    /// Every `(doc_id, node)` binding, in no fixed order.
79    pub fn iter(&self) -> impl Iterator<Item = (&str, u32)> {
80        self.by_doc.iter().map(|(doc, node)| (doc.as_str(), *node))
81    }
82}
83
84/// The node ↔ document bindings of every HNSW index, keyed by index key.
85#[derive(Debug, Default, Clone)]
86pub struct VectorIdMap {
87    indexes: HashMap<String, IndexIdMap>,
88}
89
90/// The vectors attached to one document id of one collection.
91#[derive(Debug, Default, Clone, PartialEq, Eq)]
92pub struct AttachedVectors {
93    /// The base vector, in index `"{collection}"`.
94    pub base: bool,
95    /// The named vectors' field names, in indexes `"{collection}:{field}"`,
96    /// sorted.
97    pub named: Vec<String>,
98}
99
100impl AttachedVectors {
101    /// Whether any vector is attached.
102    pub fn any(&self) -> bool {
103        self.base || !self.named.is_empty()
104    }
105}
106
107/// One persisted binding: `("{index_key}:{node}", doc_id, node)`.
108pub type PersistedIdEntry = (String, String, u32);
109
110/// The index key of one persisted binding: its composite key minus the
111/// `":{node}"` suffix. `None` when the composite key does not end in its own
112/// node id.
113pub fn persisted_index_key(entry: &PersistedIdEntry) -> Option<&str> {
114    let (composite, _, node) = entry;
115    composite.strip_suffix(&format!(":{node}"))
116}
117
118impl VectorIdMap {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// The bindings of `index_key`, when it has any.
124    pub fn index(&self, index_key: &str) -> Option<&IndexIdMap> {
125        self.indexes.get(index_key)
126    }
127
128    /// Bind `doc_id` to `node` in `index_key`. Returns the node `doc_id` was
129    /// bound to before, when different: the caller tombstones it.
130    pub fn bind(&mut self, index_key: &str, doc_id: &str, node: u32) -> Option<u32> {
131        self.indexes
132            .entry(index_key.to_owned())
133            .or_default()
134            .bind(doc_id, node)
135    }
136
137    /// The node bound to `doc_id` in `index_key`.
138    pub fn node(&self, index_key: &str, doc_id: &str) -> Option<u32> {
139        self.indexes.get(index_key)?.node(doc_id)
140    }
141
142    /// The document bound to `node` in `index_key`.
143    pub fn doc_id(&self, index_key: &str, node: u32) -> Option<&str> {
144        self.indexes.get(index_key)?.doc_id(node)
145    }
146
147    /// Remove the binding of `doc_id` in `index_key`. Returns its node.
148    pub fn unbind_doc(&mut self, index_key: &str, doc_id: &str) -> Option<u32> {
149        let ids = self.indexes.get_mut(index_key)?;
150        let node = ids.unbind_doc(doc_id);
151        if ids.is_empty() {
152            self.indexes.remove(index_key);
153        }
154        node
155    }
156
157    /// Remove the binding of `node` in `index_key`. Returns its document id.
158    pub fn unbind_node(&mut self, index_key: &str, node: u32) -> Option<String> {
159        let ids = self.indexes.get_mut(index_key)?;
160        let doc_id = ids.unbind_node(node);
161        if ids.is_empty() {
162            self.indexes.remove(index_key);
163        }
164        doc_id
165    }
166
167    /// The vectors bound to `doc_id` across the base index `collection` and
168    /// its named indexes `"{collection}:{field}"`.
169    pub fn attached_vectors(&self, collection: &str, doc_id: &str) -> AttachedVectors {
170        let mut attached = AttachedVectors::default();
171        for (index_key, ids) in &self.indexes {
172            if ids.node(doc_id).is_none() {
173                continue;
174            }
175            if index_key == collection {
176                attached.base = true;
177            } else if let Some(field) = index_key
178                .strip_prefix(collection)
179                .and_then(|rest| rest.strip_prefix(':'))
180            {
181                attached.named.push(field.to_owned());
182            }
183        }
184        attached.named.sort();
185        attached
186    }
187
188    /// Remove every binding of `index_key`.
189    pub fn remove_index(&mut self, index_key: &str) {
190        self.indexes.remove(index_key);
191    }
192
193    /// Replace every binding of `index_key` with `ids`.
194    pub fn replace_index(&mut self, index_key: &str, ids: IndexIdMap) {
195        if ids.is_empty() {
196            self.indexes.remove(index_key);
197        } else {
198            self.indexes.insert(index_key.to_owned(), ids);
199        }
200    }
201
202    /// Total bindings across every index.
203    pub fn len(&self) -> usize {
204        self.indexes.values().map(IndexIdMap::len).sum()
205    }
206
207    pub fn is_empty(&self) -> bool {
208        self.indexes.is_empty()
209    }
210
211    /// The persisted layout: one `("{index_key}:{node}", doc_id, node)` per
212    /// binding.
213    pub fn to_entries(&self) -> Vec<PersistedIdEntry> {
214        let mut out = Vec::with_capacity(self.len());
215        for (index_key, ids) in &self.indexes {
216            for (doc_id, node) in ids.iter() {
217                out.push((format!("{index_key}:{node}"), doc_id.to_owned(), node));
218            }
219        }
220        out
221    }
222
223    /// Rebuild from the persisted layout.
224    ///
225    /// The index key is the composite key minus its `":{node}"` suffix; an
226    /// entry whose composite key does not end in its own node id is skipped.
227    /// When one document holds several nodes in one index, the highest node
228    /// wins: node ids grow with each insert, so it is the latest vector. The
229    /// other nodes are returned as `(index_key, node)` for the caller to
230    /// tombstone.
231    pub fn from_entries(entries: Vec<PersistedIdEntry>) -> (Self, Vec<(String, u32)>) {
232        let mut parsed: Vec<(String, String, u32)> = entries
233            .into_iter()
234            .filter_map(|entry| {
235                let index_key = persisted_index_key(&entry)?.to_owned();
236                let (_, doc_id, node) = entry;
237                Some((index_key, doc_id, node))
238            })
239            .collect();
240        parsed.sort_by_key(|(_, _, node)| *node);
241        let mut map = Self::new();
242        let mut displaced = Vec::new();
243        for (index_key, doc_id, node) in parsed {
244            if let Some(old) = map.bind(&index_key, &doc_id, node) {
245                displaced.push((index_key, old));
246            }
247        }
248        (map, displaced)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn rebinding_a_document_returns_the_displaced_node() {
258        let mut map = VectorIdMap::new();
259        assert_eq!(map.bind("c", "a", 0), None);
260        assert_eq!(map.bind("c", "a", 1), Some(0));
261        assert_eq!(map.node("c", "a"), Some(1));
262        assert_eq!(map.doc_id("c", 0), None);
263        assert_eq!(map.doc_id("c", 1), Some("a"));
264        assert_eq!(map.len(), 1);
265    }
266
267    #[test]
268    fn indexes_with_shared_prefixes_stay_separate() {
269        let mut map = VectorIdMap::new();
270        map.bind("chat", "a", 0);
271        map.bind("chat2", "b", 0);
272        map.bind("chat:emb", "c", 0);
273        assert_eq!(map.doc_id("chat", 0), Some("a"));
274        assert_eq!(map.index("chat").map(IndexIdMap::len), Some(1));
275        map.remove_index("chat");
276        assert_eq!(map.doc_id("chat2", 0), Some("b"));
277        assert_eq!(map.doc_id("chat:emb", 0), Some("c"));
278    }
279
280    #[test]
281    fn unbind_removes_both_directions() {
282        let mut map = VectorIdMap::new();
283        map.bind("c", "a", 3);
284        assert_eq!(map.unbind_doc("c", "a"), Some(3));
285        assert_eq!(map.doc_id("c", 3), None);
286        map.bind("c", "b", 4);
287        assert_eq!(map.unbind_node("c", 4), Some("b".to_owned()));
288        assert_eq!(map.node("c", "b"), None);
289        assert!(map.is_empty());
290    }
291
292    #[test]
293    fn persisted_entries_round_trip_named_keys() {
294        let mut map = VectorIdMap::new();
295        map.bind("chat", "x:1", 0);
296        map.bind("chat:emb", "y", 7);
297        let (restored, displaced) = VectorIdMap::from_entries(map.to_entries());
298        assert!(displaced.is_empty());
299        assert_eq!(restored.doc_id("chat", 0), Some("x:1"));
300        assert_eq!(restored.doc_id("chat:emb", 7), Some("y"));
301    }
302
303    #[test]
304    fn attached_vectors_cover_base_and_named_indexes_only() {
305        let mut map = VectorIdMap::new();
306        map.bind("chat", "a", 0);
307        map.bind("chat:emb", "a", 0);
308        map.bind("chat2", "a", 0);
309        map.bind("chat:title", "b", 0);
310        assert_eq!(
311            map.attached_vectors("chat", "a"),
312            AttachedVectors {
313                base: true,
314                named: vec!["emb".to_owned()],
315            }
316        );
317        assert!(!map.attached_vectors("chat", "zzz").any());
318    }
319
320    #[test]
321    fn persisted_duplicates_keep_the_latest_node() {
322        let entries = vec![
323            ("c:5".to_owned(), "a".to_owned(), 5),
324            ("c:2".to_owned(), "a".to_owned(), 2),
325        ];
326        let (map, displaced) = VectorIdMap::from_entries(entries);
327        assert_eq!(map.node("c", "a"), Some(5));
328        assert_eq!(displaced, vec![("c".to_owned(), 2)]);
329    }
330}