Skip to main content

nodedb_vector/collection/
lifecycle_compact.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Truncate, compact, and snapshot operations for `VectorCollection`.
4
5use nodedb_types::Surrogate;
6
7use super::lifecycle::VectorCollection;
8use crate::flat::FlatIndex;
9
10/// One exported vector: global node id, full-precision data, optional surrogate.
11pub type ExportedVector = (u32, Vec<f32>, Option<Surrogate>);
12
13impl VectorCollection {
14    /// Drop every vector, sealed segment, in-flight build, surrogate binding,
15    /// and payload bitmap row. Returns the number of live vectors dropped.
16    ///
17    /// Configuration survives: dimension, HNSW params, index config,
18    /// quantization, seal threshold, memory budget, and the registered
19    /// payload index fields. `next_id` and `next_segment_id` keep counting
20    /// so a build completion for a segment sealed before the truncate finds
21    /// no matching entry in `building` and is ignored, and an mmap file name
22    /// is never reused. The mmap file of each dropped sealed segment is
23    /// removed from disk.
24    ///
25    /// An `IvfPq` collection drops its trained index too and buffers again:
26    /// its next training reads the vectors inserted after the truncate.
27    pub fn truncate(&mut self) -> usize {
28        let dropped = self.live_count();
29        self.clear_segments();
30        self.ivf = None;
31        self.surrogate_map.clear();
32        self.surrogate_to_local.clear();
33        self.multi_doc_map.clear();
34        self.payload.clear_rows();
35        dropped
36    }
37
38    /// Empty the growing segment and drop every sealed segment, in-flight
39    /// build and codec-dispatch index. The next insert keeps the id counter.
40    /// The mmap file of each dropped sealed segment is removed from disk.
41    pub(super) fn clear_segments(&mut self) {
42        self.growing = FlatIndex::new(self.dim, self.params.metric);
43        self.growing_base_id = self.next_id;
44        for seg in self.sealed.drain(..) {
45            let mmap_path = seg.mmap_vectors.as_ref().map(|m| m.path().to_path_buf());
46            // Unmap before the file goes.
47            drop(seg);
48            if let Some(path) = mmap_path
49                && let Err(e) = std::fs::remove_file(&path)
50            {
51                tracing::warn!(
52                    path = %path.display(),
53                    error = %e,
54                    "vector truncate: mmap segment file not removed"
55                );
56            }
57        }
58        self.building.clear();
59        self.mmap_segment_count = 0;
60        self.codec_dispatch = None;
61    }
62
63    /// Compact sealed segments and the IVF-PQ index by removing tombstoned
64    /// nodes.
65    ///
66    /// Rewrites `surrogate_map` and `multi_doc_map` for every sealed
67    /// segment so that global ids continue to resolve to the correct
68    /// surrogate after local-id renumbering. IVF-PQ entries keep their ids.
69    pub fn compact(&mut self) -> usize {
70        let mut total_removed = self.ivf.as_mut().map_or(0, |ivf| ivf.compact());
71        for seg in &mut self.sealed {
72            let base_id = seg.base_id;
73            let (removed, id_map) = seg.index.compact_with_map();
74            total_removed += removed;
75            if removed == 0 {
76                continue;
77            }
78
79            let segment_end = base_id as u64 + id_map.len() as u64;
80            let global_keys: Vec<u32> = self
81                .surrogate_map
82                .keys()
83                .copied()
84                .filter(|&k| (k as u64) >= base_id as u64 && (k as u64) < segment_end)
85                .collect();
86            // Two-phase: remove old entries first, then insert new ones
87            // so we don't clobber a freshly-remapped entry with a later
88            // tombstone removal.
89            let mut new_entries: Vec<(u32, Surrogate)> = Vec::with_capacity(global_keys.len());
90            for old_global in &global_keys {
91                let surrogate = self.surrogate_map.remove(old_global);
92                let old_local = (old_global - base_id) as usize;
93                let new_local = id_map[old_local];
94                if new_local != u32::MAX
95                    && let Some(s) = surrogate
96                {
97                    new_entries.push((base_id + new_local, s));
98                } else if let Some(s) = surrogate {
99                    // Tombstoned — drop reverse mapping too.
100                    self.surrogate_to_local.remove(&s);
101                }
102            }
103            for (k, s) in new_entries {
104                self.surrogate_map.insert(k, s);
105                self.surrogate_to_local.insert(s, k);
106            }
107
108            // Rewrite multi_doc_map entries for this segment.
109            for ids in self.multi_doc_map.values_mut() {
110                ids.retain_mut(|vid| {
111                    let v = *vid;
112                    if (v as u64) >= base_id as u64 && (v as u64) < segment_end {
113                        let old_local = (v - base_id) as usize;
114                        let new_local = id_map[old_local];
115                        if new_local == u32::MAX {
116                            false
117                        } else {
118                            *vid = base_id + new_local;
119                            true
120                        }
121                    } else {
122                        true
123                    }
124                });
125            }
126        }
127        total_removed
128    }
129
130    /// The multi-vector documents of this index, by document surrogate, in
131    /// ascending order. `export_snapshot` rows carry no membership, so a
132    /// snapshot records it here: a one-vector document is otherwise
133    /// indistinguishable from a single-vector row.
134    pub fn multi_vector_documents(&self) -> Vec<Surrogate> {
135        let mut documents: Vec<Surrogate> = self.multi_doc_map.keys().copied().collect();
136        documents.sort_unstable();
137        documents
138    }
139
140    /// Export all live vectors for snapshot.
141    ///
142    /// # Errors
143    ///
144    /// Propagates [`crate::HnswIndex::export_vectors`] if a sealed segment's
145    /// vectors cannot be materialized. A snapshot that silently dropped or
146    /// zeroed those vectors would restore an index with missing data.
147    pub fn export_snapshot(&self) -> Result<Vec<ExportedVector>, crate::error::VectorError> {
148        let mut result = Vec::new();
149
150        if let Some(ivf) = &self.ivf {
151            for (vid, data) in ivf.live_vectors() {
152                let surrogate = self.surrogate_map.get(&vid).copied();
153                result.push((vid, data, surrogate));
154            }
155        }
156
157        for i in 0..self.growing.len() as u32 {
158            let vid = self.growing_base_id + i;
159            if let Some(data) = self.growing.get_vector(i) {
160                let surrogate = self.surrogate_map.get(&vid).copied();
161                result.push((vid, data.to_vec(), surrogate));
162            }
163        }
164
165        for seg in &self.sealed {
166            let vectors = seg.index.export_vectors()?;
167            for (i, vec_data) in vectors.into_iter().enumerate() {
168                let vid = seg.base_id + i as u32;
169                let surrogate = self.surrogate_map.get(&vid).copied();
170                result.push((vid, vec_data, surrogate));
171            }
172        }
173
174        for seg in &self.building {
175            for i in 0..seg.flat.len() as u32 {
176                let vid = seg.base_id + i;
177                if let Some(data) = seg.flat.get_vector(i) {
178                    let surrogate = self.surrogate_map.get(&vid).copied();
179                    result.push((vid, data.to_vec(), surrogate));
180                }
181            }
182        }
183
184        Ok(result)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::hnsw::HnswParams;
192    use nodedb_types::{PayloadIndexKind, Value};
193    use std::collections::HashMap;
194
195    #[test]
196    fn truncate_drops_every_row_and_keeps_config() {
197        let mut coll = VectorCollection::with_seal_threshold(2, HnswParams::default(), 2);
198        coll.payload.add_index("owner", PayloadIndexKind::Equality);
199        let mut fields = HashMap::new();
200        fields.insert("owner".to_string(), Value::String("a".into()));
201        for (i, v) in [[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]].into_iter().enumerate() {
202            let s = Surrogate::new(i as u32 + 1);
203            let id = coll.insert_with_surrogate(v.to_vec(), s).unwrap();
204            coll.payload.insert_row(id, &fields);
205        }
206        assert!(
207            coll.seal("k").is_some(),
208            "threshold reached, growing sealed"
209        );
210        assert_eq!(coll.live_count(), 3);
211        let next_before = coll.next_id;
212
213        assert_eq!(coll.truncate(), 3);
214        assert_eq!(coll.live_count(), 0);
215        assert!(coll.surrogate_to_local.is_empty());
216        assert!(coll.surrogate_map.is_empty());
217        assert!(coll.building.is_empty());
218        assert!(coll.sealed.is_empty());
219        assert_eq!(coll.dim(), 2);
220        assert_eq!(coll.next_id, next_before, "ids stay monotonic");
221        assert_eq!(coll.growing_base_id, next_before);
222        assert!(
223            coll.payload.field_names().any(|f| f == "owner"),
224            "registered payload index survives"
225        );
226        let hits = coll
227            .payload
228            .pre_filter(&super::super::payload_index::FilterPredicate::Eq {
229                field: "owner".to_string(),
230                value: Value::String("a".into()),
231            })
232            .expect("owner is indexed");
233        assert!(hits.is_empty(), "payload rows cleared");
234
235        let s = Surrogate::new(42);
236        let id = coll.insert_with_surrogate(vec![0.5, 0.5], s).unwrap();
237        assert_eq!(coll.local_for_surrogate(s), Some(id));
238        assert_eq!(coll.live_count(), 1);
239    }
240
241    #[test]
242    fn a_one_vector_multi_vector_document_is_listed() {
243        let mut coll = VectorCollection::with_seal_threshold(2, HnswParams::default(), 64);
244        coll.insert_with_surrogate(vec![1.0, 0.0], Surrogate::new(1))
245            .unwrap();
246        let one: [&[f32]; 1] = [&[0.0, 1.0]];
247        coll.insert_multi_vector(&one, Surrogate::new(9)).unwrap();
248        let two: [&[f32]; 2] = [&[0.5, 0.5], &[0.2, 0.8]];
249        coll.insert_multi_vector(&two, Surrogate::new(5)).unwrap();
250
251        assert_eq!(
252            coll.multi_vector_documents(),
253            vec![Surrogate::new(5), Surrogate::new(9)],
254            "every multi-vector document is listed, the one-vector one included; \
255             the single-vector row is not"
256        );
257    }
258}