Skip to main content

nodedb_vector/collection/
build.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HNSW builds for a `VectorCollection`: the requests the owning core sends
4//! to its builder thread, and installing the finished graphs.
5//!
6//! Every request carries one vector per local node id, soft-deleted nodes
7//! included, so a built graph keeps the ids its segment had. Installing a
8//! build applies the tombstones the segment holds at that moment, so a
9//! delete that landed while the build ran is kept.
10//!
11//! - A seal build promotes a building segment to sealed.
12//! - A rebuild replaces a sealed segment in place, from that segment's own
13//!   vectors, and quantizes it again under the collection's config.
14
15use nodedb_mem::ScopedMemory;
16use nodedb_types::VectorQuantization;
17
18use crate::error::VectorError;
19use crate::hnsw::HnswIndex;
20use crate::index_config::IndexType;
21
22use super::lifecycle::VectorCollection;
23use super::lifecycle_insert_ops::sealed_vector;
24use super::segment::{BuildKind, BuildRequest, SealedSegment};
25
26impl VectorCollection {
27    /// Segment ids of the building segments, oldest first.
28    pub fn building_segment_ids(&self) -> Vec<u32> {
29        self.building.iter().map(|b| b.segment_id).collect()
30    }
31
32    /// A seal build request for building segment `segment_id`, read from its
33    /// flat vectors. `None` when no building segment has that id.
34    pub fn build_request_for(&self, key: &str, segment_id: u32) -> Option<BuildRequest> {
35        let seg = self.building.iter().find(|b| b.segment_id == segment_id)?;
36        let vectors = (0..seg.flat.len() as u32)
37            .filter_map(|i| seg.flat.get_vector_raw(i).map(<[f32]>::to_vec))
38            .collect();
39        Some(BuildRequest {
40            key: key.to_string(),
41            segment_id,
42            kind: BuildKind::Seal,
43            vectors,
44            dim: self.dim,
45            params: self.params.clone(),
46        })
47    }
48
49    /// Base ids of the non-empty sealed segments, in segment order.
50    pub fn sealed_base_ids(&self) -> Vec<u32> {
51        self.sealed
52            .iter()
53            .filter(|s| !s.index.is_empty())
54            .map(|s| s.base_id)
55            .collect()
56    }
57
58    /// A rebuild request for the sealed segment at `base_id`, under the
59    /// current HNSW params, read from that segment only: the growing and
60    /// building segments keep their own vectors. `None` when no sealed
61    /// segment starts at `base_id`.
62    ///
63    /// Fails with [`VectorError::VectorUnavailable`] when a node's vector
64    /// cannot be read.
65    pub fn rebuild_request_for(
66        &mut self,
67        key: &str,
68        base_id: u32,
69    ) -> Result<Option<BuildRequest>, VectorError> {
70        let Some(seg) = self.sealed.iter().find(|s| s.base_id == base_id) else {
71            return Ok(None);
72        };
73        let len = seg.index.len();
74        let mut vectors = Vec::with_capacity(len);
75        for local in 0..len as u32 {
76            let v = sealed_vector(seg, local).ok_or(VectorError::VectorUnavailable {
77                id: base_id + local,
78            })?;
79            vectors.push(v);
80        }
81        let segment_id = self.next_segment_id;
82        self.next_segment_id += 1;
83        Ok(Some(BuildRequest {
84            key: key.to_string(),
85            segment_id,
86            kind: BuildKind::Rebuild { base_id, len },
87            vectors,
88            dim: self.dim,
89            params: self.params.clone(),
90        }))
91    }
92
93    /// Install a seal build: promote building segment `segment_id` to sealed
94    /// with its current tombstones. Returns `false`, changing nothing, when
95    /// no building segment has that id (a truncate dropped it) or the graph
96    /// does not hold one node per vector of the segment.
97    pub fn complete_build(
98        &mut self,
99        segment_id: u32,
100        index: HnswIndex,
101        memory: ScopedMemory,
102    ) -> bool {
103        let Some(pos) = self
104            .building
105            .iter()
106            .position(|b| b.segment_id == segment_id)
107        else {
108            return false;
109        };
110        let mut index = index;
111        if index.len() != self.building[pos].flat.len() {
112            tracing::error!(
113                segment_id,
114                built = index.len(),
115                expected = self.building[pos].flat.len(),
116                "HNSW build does not match its segment; segment stays on brute force"
117            );
118            return false;
119        }
120        let building = self.building.remove(pos);
121        for local in 0..building.flat.len() as u32 {
122            if building.flat.is_deleted(local) {
123                index.delete(local);
124            }
125        }
126        let seg = self.sealed_segment_from(segment_id, building.base_id, index, &memory);
127        self.sealed.push(seg);
128        self.builds_completed += 1;
129        self.refresh_codec_dispatch();
130        true
131    }
132
133    /// Install a rebuild of the sealed segment at `base_id`, read when it held
134    /// `len` nodes. The old segment's tombstones carry over and the segment is
135    /// quantized again under the collection's config. Returns `false`,
136    /// changing nothing, when no sealed segment at `base_id` still holds
137    /// `len` nodes, or the graph does not hold `len` nodes.
138    pub fn complete_rebuild(
139        &mut self,
140        segment_id: u32,
141        base_id: u32,
142        len: usize,
143        index: HnswIndex,
144        memory: ScopedMemory,
145    ) -> bool {
146        let Some(pos) = self
147            .sealed
148            .iter()
149            .position(|s| s.base_id == base_id && s.index.len() == len)
150        else {
151            return false;
152        };
153        if index.len() != len {
154            tracing::error!(
155                base_id,
156                built = index.len(),
157                expected = len,
158                "HNSW rebuild does not match its segment; the old segment stays"
159            );
160            return false;
161        }
162        let mut index = index;
163        for local in 0..len as u32 {
164            if self.sealed[pos].index.is_deleted(local) {
165                index.delete(local);
166            }
167        }
168        let seg = self.sealed_segment_from(segment_id, base_id, index, &memory);
169        let old = std::mem::replace(&mut self.sealed[pos], seg);
170        self.drop_sealed(old);
171        self.builds_completed += 1;
172        self.refresh_codec_dispatch();
173        true
174    }
175
176    /// Record a build that failed. The segment stays as it was.
177    pub fn note_build_failed(&mut self) {
178        self.builds_failed += 1;
179    }
180
181    /// A sealed segment for `index`: quantized under the collection's config
182    /// and placed on the storage tier the memory budget allows.
183    fn sealed_segment_from(
184        &mut self,
185        segment_id: u32,
186        base_id: u32,
187        index: HnswIndex,
188        memory: &ScopedMemory,
189    ) -> SealedSegment {
190        let use_codec_dispatch = self.codec_dispatch_tag().is_some();
191        let use_pq = !use_codec_dispatch && self.index_config.index_type == IndexType::HnswPq;
192        let (sq8, pq) = if use_codec_dispatch {
193            (None, None)
194        } else if use_pq {
195            (
196                None,
197                Self::build_pq_for_index(&index, self.index_config.pq_m, memory.clone()),
198            )
199        } else {
200            (Self::build_sq8_for_index(&index), None)
201        };
202        let (tier, mmap_vectors) = self.resolve_tier_for_build(segment_id, base_id, &index, memory);
203        SealedSegment {
204            index,
205            base_id,
206            sq8,
207            pq,
208            tier,
209            mmap_vectors,
210        }
211    }
212
213    /// Drop a replaced sealed segment, removing its mmap file.
214    fn drop_sealed(&mut self, seg: SealedSegment) {
215        let mmap_path = seg.mmap_vectors.as_ref().map(|m| m.path().to_path_buf());
216        // Unmap before the file goes.
217        drop(seg);
218        if let Some(path) = mmap_path {
219            self.mmap_segment_count = self.mmap_segment_count.saturating_sub(1);
220            if let Err(e) = std::fs::remove_file(&path) {
221                tracing::warn!(
222                    path = %path.display(),
223                    error = %e,
224                    "vector rebuild: replaced mmap segment file not removed"
225                );
226            }
227        }
228    }
229
230    /// The codec-dispatch tag the collection's quantization selects.
231    fn codec_dispatch_tag(&self) -> Option<&'static str> {
232        match self.quantization {
233            VectorQuantization::RaBitQ => Some("rabitq"),
234            VectorQuantization::Bbq => Some("bbq"),
235            _ => None,
236        }
237    }
238
239    /// Rebuild the collection-level codec-dispatch index over the sealed
240    /// segments when the quantization selects one.
241    fn refresh_codec_dispatch(&mut self) {
242        let Some(tag) = self.codec_dispatch_tag() else {
243            return;
244        };
245        if let Err(e) = self.build_codec_dispatch(tag).map(|_| ()) {
246            // Without the codec index the sealed segments are searched by
247            // their own HNSW graphs, which answer the same queries.
248            tracing::error!(error = %e, tag, "codec-dispatch build failed; searching sealed segments directly");
249            self.codec_dispatch = None;
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use nodedb_types::Surrogate;
257
258    use super::*;
259    use crate::hnsw::HnswParams;
260    use crate::test_support::test_memory;
261
262    fn vector(i: usize) -> Vec<f32> {
263        vec![(i + 1) as f32, (i % 7 + 1) as f32, (i % 11 + 1) as f32]
264    }
265
266    fn l2() -> HnswParams {
267        HnswParams {
268            metric: crate::distance::DistanceMetric::L2,
269            ..HnswParams::default()
270        }
271    }
272
273    fn build(req: &BuildRequest) -> HnswIndex {
274        let mut index = HnswIndex::with_seed(req.dim, req.params.clone(), 7);
275        for v in &req.vectors {
276            index.insert(v.clone()).unwrap();
277        }
278        index
279    }
280
281    /// Ten L2 vectors bound to surrogates 1..=10, with a seal threshold of 10.
282    fn collection() -> VectorCollection {
283        let mut coll = VectorCollection::with_seal_threshold(3, l2(), 10);
284        for i in 0..10 {
285            coll.insert_with_surrogate(vector(i), Surrogate::new(i as u32 + 1))
286                .unwrap();
287        }
288        coll
289    }
290
291    #[test]
292    fn a_seal_build_keeps_ids_and_the_deletes_made_meanwhile() {
293        let mut coll = collection();
294        coll.delete(2);
295        let req = coll.seal("k").unwrap();
296        assert_eq!(req.vectors.len(), 10, "deleted vectors keep their slot");
297        coll.delete(4);
298
299        assert!(coll.complete_build(req.segment_id, build(&req), test_memory()));
300
301        assert!(coll.building.is_empty());
302        assert!(!coll.is_live(2) && !coll.is_live(4) && coll.is_live(9));
303        assert_eq!(coll.vector_for_id(7), Some(vector(7)));
304        assert_eq!(coll.search(&vector(7), 1, 64).unwrap()[0].id, 7);
305        assert_eq!(coll.get_surrogate(7), Some(Surrogate::new(8)));
306    }
307
308    #[test]
309    fn a_build_of_the_wrong_size_is_refused() {
310        let mut coll = collection();
311        let req = coll.seal("k").unwrap();
312        let mut short = HnswIndex::new(3, l2());
313        short.insert(vector(0)).unwrap();
314        assert!(!coll.complete_build(req.segment_id, short, test_memory()));
315        assert_eq!(coll.building.len(), 1, "the segment stays on brute force");
316    }
317
318    #[test]
319    fn a_rebuild_keeps_ids_codes_and_tombstones() {
320        let mut coll = VectorCollection::with_pq_config(3, l2(), 3);
321        coll.set_seal_threshold(10);
322        for i in 0..10 {
323            coll.insert_with_surrogate(vector(i), Surrogate::new(i as u32 + 1))
324                .unwrap();
325        }
326        let req = coll.seal("k").unwrap();
327        coll.complete_build(req.segment_id, build(&req), test_memory());
328        coll.delete(1);
329
330        let req = coll.rebuild_request_for("k", 0).unwrap().unwrap();
331        assert_eq!(
332            req.kind,
333            BuildKind::Rebuild {
334                base_id: 0,
335                len: 10
336            }
337        );
338        coll.delete(6);
339        let BuildKind::Rebuild { base_id, len } = req.kind else {
340            panic!("a rebuild request");
341        };
342        assert!(coll.complete_rebuild(req.segment_id, base_id, len, build(&req), test_memory()));
343
344        assert_eq!(coll.sealed.len(), 1);
345        assert!(
346            coll.sealed[0].pq.is_some(),
347            "the segment is quantized again"
348        );
349        assert!(!coll.is_live(1) && !coll.is_live(6));
350        for i in [0usize, 3, 9] {
351            assert_eq!(coll.search(&vector(i), 1, 64).unwrap()[0].id, i as u32);
352            assert_eq!(
353                coll.get_surrogate(i as u32),
354                Some(Surrogate::new(i as u32 + 1))
355            );
356        }
357    }
358
359    #[test]
360    fn a_rebuild_of_a_compacted_segment_is_refused() {
361        let mut coll = collection();
362        let req = coll.seal("k").unwrap();
363        coll.complete_build(req.segment_id, build(&req), test_memory());
364        coll.delete(3);
365        let req = coll.rebuild_request_for("k", 0).unwrap().unwrap();
366        assert_eq!(coll.compact(), 1);
367        let BuildKind::Rebuild { base_id, len } = req.kind else {
368            panic!("a rebuild request");
369        };
370        assert!(!coll.complete_rebuild(req.segment_id, base_id, len, build(&req), test_memory()));
371        assert_eq!(coll.sealed[0].index.len(), 9, "the compacted segment stays");
372    }
373
374    #[test]
375    fn an_unbuilt_segment_survives_a_checkpoint_as_building() {
376        let mut coll = collection();
377        coll.delete(5);
378        coll.seal("k").unwrap();
379        let bytes = coll.checkpoint_to_bytes(None).unwrap();
380        let restored = VectorCollection::from_checkpoint(&bytes, None, test_memory()).unwrap();
381
382        let ids = restored.building_segment_ids();
383        assert_eq!(ids.len(), 1);
384        let req = restored.build_request_for("k", ids[0]).unwrap();
385        assert_eq!(req.vectors.len(), 10);
386        assert!(!restored.is_live(5));
387        assert_eq!(restored.search(&vector(8), 1, 64).unwrap()[0].id, 8);
388    }
389}