Skip to main content

hermes_core/index/
vector_builder.rs

1//! Vector index building for IndexWriter
2//!
3//! Training is **manual-only** — decoupled from commit.
4//! Call `build_vector_index()` explicitly when ready.
5//! ANN indexes are built naturally during subsequent merges.
6
7use std::io::Write;
8use std::sync::Arc;
9
10use rustc_hash::FxHashMap;
11
12use crate::directories::DirectoryWriter;
13use crate::dsl::{
14    BinaryDenseVectorConfig, BinaryIndexType, DenseVectorConfig, Field, FieldType, VectorIndexType,
15};
16use crate::error::{Error, Result};
17use crate::segment::{SegmentId, SegmentReader};
18
19use super::IndexWriter;
20
21/// Maximum supported IVF centroid count. Query-side `nprobe` and serialized
22/// cluster identifiers use the same practical bound.
23const MAX_IVF_CLUSTERS: usize = 1_048_576;
24/// Faiss-style clustering quality floor: fewer points per centroid generally
25/// overfits the training sample and leaves unstable/empty cells.
26const MIN_TRAINING_POINTS_PER_CENTROID: usize = 39;
27
28struct TrainedFieldUpdate {
29    field_id: u32,
30    index_type: super::metadata::VectorFieldIndexType,
31    vector_count: usize,
32    num_clusters: usize,
33    centroids_file: String,
34    codebook_file: Option<String>,
35}
36
37#[derive(Clone)]
38enum IvfFieldConfig {
39    Float(DenseVectorConfig),
40    Binary(BinaryDenseVectorConfig),
41}
42
43impl IvfFieldConfig {
44    fn dim(&self) -> usize {
45        match self {
46            Self::Float(config) => config.dim,
47            Self::Binary(config) => config.dim,
48        }
49    }
50
51    fn index_type(&self) -> super::metadata::VectorFieldIndexType {
52        match self {
53            Self::Float(config) => config.index_type.into(),
54            Self::Binary(config) => config.index_type.into(),
55        }
56    }
57
58    fn num_clusters(&self) -> Option<usize> {
59        match self {
60            Self::Float(config) => config.num_clusters,
61            Self::Binary(config) => config.num_clusters,
62        }
63    }
64
65    fn optimal_num_clusters(&self, vector_count: usize) -> usize {
66        match self {
67            Self::Float(config) => config.optimal_num_clusters(vector_count),
68            Self::Binary(config) => config.optimal_num_clusters(vector_count),
69        }
70    }
71}
72
73enum TrainingSample {
74    Float(Vec<Vec<f32>>),
75    Binary(Vec<u8>),
76}
77
78impl TrainingSample {
79    fn len(&self, dim: usize) -> usize {
80        match self {
81            Self::Float(vectors) => vectors.len(),
82            Self::Binary(codes) => codes.len() / dim.div_ceil(8),
83        }
84    }
85}
86
87/// Write adapter that rejects an artifact before its serialized form exceeds
88/// the same bound enforced by the loader. Encoding directly through this
89/// adapter avoids materializing a second, potentially hundreds-of-megabytes
90/// copy of the trained structure.
91struct SizeLimitedWriter<'a, W: Write + ?Sized> {
92    inner: &'a mut W,
93    written: usize,
94    limit: usize,
95}
96
97impl<'a, W: Write + ?Sized> SizeLimitedWriter<'a, W> {
98    fn new(inner: &'a mut W, limit: usize) -> Self {
99        Self {
100            inner,
101            written: 0,
102            limit,
103        }
104    }
105}
106
107impl<W: Write + ?Sized> Write for SizeLimitedWriter<'_, W> {
108    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
109        let next_size = self
110            .written
111            .checked_add(buffer.len())
112            .ok_or_else(|| std::io::Error::other("trained artifact size overflow"))?;
113        if next_size > self.limit {
114            return Err(std::io::Error::new(
115                std::io::ErrorKind::InvalidData,
116                format!(
117                    "trained artifact exceeds the {}-byte safety limit",
118                    self.limit
119                ),
120            ));
121        }
122        let written = self.inner.write(buffer)?;
123        self.written += written;
124        Ok(written)
125    }
126
127    fn flush(&mut self) -> std::io::Result<()> {
128        self.inner.flush()
129    }
130}
131
132fn validate_explicit_cluster_count(num_clusters: Option<usize>) -> Result<()> {
133    match num_clusters {
134        Some(0) => Err(Error::Schema(
135            "dense vector num_clusters must be at least 1".to_string(),
136        )),
137        Some(value) if value > MAX_IVF_CLUSTERS => Err(Error::Schema(format!(
138            "dense vector num_clusters must not exceed {MAX_IVF_CLUSTERS}, got {value}"
139        ))),
140        _ => Ok(()),
141    }
142}
143
144fn effective_field_num_clusters(
145    config: &IvfFieldConfig,
146    corpus_count: usize,
147    sample_count: usize,
148) -> Result<usize> {
149    if sample_count == 0 {
150        return Err(Error::Schema(
151            "cannot train an IVF vector index without sample vectors".to_string(),
152        ));
153    }
154    validate_explicit_cluster_count(config.num_clusters())?;
155    let centroid_bytes = match config {
156        IvfFieldConfig::Float(config) => config.dim.saturating_mul(size_of::<f32>()),
157        IvfFieldConfig::Binary(config) => config.dim.div_ceil(8),
158    };
159    let artifact_limit = super::metadata::MAX_TRAINED_ARTIFACT_BYTES
160        .saturating_sub(1024)
161        .checked_div(centroid_bytes.max(1))
162        .unwrap_or(0)
163        .max(1);
164    let quality_limit = if config.num_clusters().is_some() {
165        sample_count
166    } else {
167        (sample_count / MIN_TRAINING_POINTS_PER_CENTROID)
168            .max(16)
169            .min(sample_count)
170    };
171    let requested = config.optimal_num_clusters(corpus_count);
172    if config.num_clusters().is_some() && requested > artifact_limit {
173        return Err(Error::Schema(format!(
174            "configured IVF codebook needs {} bytes for {} centroids, exceeding the {}-byte artifact limit",
175            requested.saturating_mul(centroid_bytes),
176            requested,
177            super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
178        )));
179    }
180    Ok(requested.min(quality_limit).min(artifact_limit))
181}
182
183/// Validate the configured centroid count and cap it to the training sample.
184///
185/// Corpus size drives the automatic heuristic, but training cannot produce
186/// more distinct centroids than the number of sampled vectors. Keeping this
187/// decision here avoids relying on a panic-prone, implicit clamp inside the
188/// trainer and gives callers a schema error for invalid explicit values.
189#[cfg(test)]
190fn effective_ivf_num_clusters(
191    config: &DenseVectorConfig,
192    corpus_count: usize,
193    sample_count: usize,
194) -> Result<usize> {
195    if sample_count == 0 {
196        return Err(Error::Schema(
197            "cannot train an IVF vector index without sample vectors".to_string(),
198        ));
199    }
200
201    effective_field_num_clusters(
202        &IvfFieldConfig::Float(config.clone()),
203        corpus_count,
204        sample_count,
205    )
206}
207
208impl<D: DirectoryWriter + 'static> IndexWriter<D> {
209    /// Train vector index from accumulated Flat vectors (manual, not auto-triggered).
210    ///
211    /// 1. Acquires a snapshot (segments safe to read)
212    /// 2. Collects vectors for training
213    /// 3. Trains centroids/codebooks
214    /// 4. Updates metadata (marks fields as Built)
215    /// 5. Publishes to ArcSwap — merges will use these automatically
216    ///
217    /// Existing flat segments get ANN during normal merges. No rebuild needed.
218    pub async fn build_vector_index(&self) -> Result<()> {
219        let dense_fields = self.get_ivf_vector_fields();
220        if dense_fields.is_empty() {
221            log::info!("No dense vector fields configured for ANN indexing");
222            return Ok(());
223        }
224
225        let artifact_update = self.segment_manager.begin_vector_artifact_update().await?;
226        self.build_vector_index_locked(&dense_fields, &artifact_update)
227            .await
228    }
229
230    /// Build while the SegmentManager's artifact-update gate is held.
231    async fn build_vector_index_locked(
232        &self,
233        dense_fields: &[(Field, IvfFieldConfig)],
234        artifact_update: &crate::merge::VectorArtifactUpdateGuard,
235    ) -> Result<()> {
236        // Check which fields need building (skip already built)
237        let fields_to_build = self.get_fields_to_build(dense_fields).await;
238        if fields_to_build.is_empty() {
239            log::info!("All vector fields already built, skipping training");
240            return Ok(());
241        }
242
243        // Reject malformed explicit settings before opening segments or
244        // allocating the bounded training samples.
245        for (_, config) in &fields_to_build {
246            validate_explicit_cluster_count(config.num_clusters())?;
247        }
248
249        // Acquire snapshot — segments won't be deleted while we read them
250        let snapshot = self.segment_manager.acquire_snapshot().await;
251        let segment_ids = snapshot.segment_ids();
252        if segment_ids.is_empty() {
253            return Ok(());
254        }
255
256        // Collect vectors for training
257        let (all_vectors, total_vectors) = self
258            .collect_vectors_for_training(segment_ids, &fields_to_build)
259            .await?;
260
261        self.train_and_publish_fields(
262            &fields_to_build,
263            &all_vectors,
264            &total_vectors,
265            artifact_update,
266        )
267        .await
268    }
269
270    /// Train every requested field from pre-collected samples, then durably
271    /// publish the artifacts. If any field fails, all durable field states
272    /// remain Flat and the successfully written files are merely unreferenced
273    /// retry targets.
274    async fn train_and_publish_fields(
275        &self,
276        fields_to_build: &[(Field, IvfFieldConfig)],
277        all_vectors: &FxHashMap<u32, TrainingSample>,
278        total_vectors: &FxHashMap<u32, usize>,
279        artifact_update: &crate::merge::VectorArtifactUpdateGuard,
280    ) -> Result<()> {
281        let mut updates = Vec::with_capacity(fields_to_build.len());
282        for (field, config) in fields_to_build {
283            if let Some(update) = self
284                .train_field_index(*field, config, all_vectors, total_vectors)
285                .await?
286            {
287                updates.push(update);
288            }
289        }
290
291        if updates.is_empty() {
292            // Fail loud: training was explicitly requested and produced
293            // nothing — reporting success would leave callers believing the
294            // fields are Built.
295            let field_ids: Vec<u32> = fields_to_build.iter().map(|(field, _)| field.0).collect();
296            return Err(Error::Schema(format!(
297                "cannot train vector index: no training vectors were collected for \
298                 field(s) {field_ids:?}; commit documents containing these fields \
299                 before building"
300            )));
301        }
302
303        // Durable metadata and the complete validated ArcSwap set advance in a
304        // single cancellation-safe SegmentManager transaction.
305        self.segment_manager
306            .update_vector_metadata_and_publish(artifact_update, |meta| {
307                for update in &updates {
308                    meta.init_field(update.field_id, update.index_type);
309                    meta.mark_field_built(
310                        update.field_id,
311                        update.vector_count,
312                        update.num_clusters,
313                        update.centroids_file.clone(),
314                        update.codebook_file.clone(),
315                    );
316                }
317            })
318            .await?;
319
320        log::info!("Vector index training complete, ANN will be built during merges");
321
322        Ok(())
323    }
324
325    /// Rebuild vector index by retraining centroids/codebooks.
326    ///
327    /// Rebuilding a global artifact generation is only safe while every
328    /// committed segment is still flat. IVF-PQ segments embed the artifact
329    /// versions they were built with and cannot be interpreted by freshly
330    /// trained centroids/codebooks.
331    pub async fn rebuild_vector_index(&self) -> Result<()> {
332        let dense_fields = self.get_ivf_vector_fields();
333        if dense_fields.is_empty() {
334            return Ok(());
335        }
336
337        // Raise the producer gate and drain operations that may already have
338        // captured the previous trained generation. New producers continue in
339        // flat mode until this guard drops.
340        let artifact_update = self.segment_manager.begin_vector_artifact_update().await?;
341        let snapshot = self.segment_manager.acquire_snapshot().await;
342        let field_ids: Vec<u32> = dense_fields.iter().map(|(field, _)| field.0).collect();
343        self.reject_rebuild_with_ann_segments(snapshot.segment_ids(), &field_ids)
344            .await?;
345
346        // Reject malformed explicit settings before collecting samples.
347        for (_, config) in &dense_fields {
348            validate_explicit_cluster_count(config.num_clusters())?;
349        }
350
351        // Collect the retraining samples BEFORE the durable Built -> Flat
352        // reset: a read failure (propagated by collect_vectors_for_training)
353        // or an empty sample for a Built field must not destructively
354        // downgrade the published artifact generation.
355        let (all_vectors, total_vectors) = self
356            .collect_vectors_for_training(snapshot.segment_ids(), &dense_fields)
357            .await?;
358        let built_fields: Vec<u32> = self
359            .segment_manager
360            .read_metadata(|meta| {
361                field_ids
362                    .iter()
363                    .filter(|field_id| meta.is_field_built(**field_id))
364                    .copied()
365                    .collect()
366            })
367            .await;
368        let starved_built: Vec<u32> = built_fields
369            .into_iter()
370            .filter(|field_id| {
371                let dim = dense_fields
372                    .iter()
373                    .find(|(field, _)| field.0 == *field_id)
374                    .map_or(1, |(_, config)| config.dim());
375                all_vectors
376                    .get(field_id)
377                    .is_none_or(|sample| sample.len(dim) == 0)
378            })
379            .collect();
380        if !starved_built.is_empty() {
381            return Err(Error::Schema(format!(
382                "cannot retrain vector index: no training vectors could be collected \
383                 for built field(s) {starved_built:?}; the existing trained artifacts \
384                 are left in place"
385            )));
386        }
387
388        // Reset metadata and the ArcSwap set together. Old fixed-name artifact
389        // files are left in place until the atomic writer replaces them; this
390        // avoids a cancellation window and does not accumulate generations.
391        self.segment_manager
392            .update_vector_metadata_and_publish(&artifact_update, |meta| {
393                for field_id in &field_ids {
394                    if let Some(field_meta) = meta.vector_fields.get_mut(field_id) {
395                        field_meta.state = super::VectorIndexState::Flat;
396                        field_meta.centroids_file = None;
397                        field_meta.codebook_file = None;
398                    }
399                }
400                meta.refresh_total_vectors();
401            })
402            .await?;
403
404        log::info!("Reset vector index state to Flat, retraining from collected samples...");
405
406        self.train_and_publish_fields(
407            &dense_fields,
408            &all_vectors,
409            &total_vectors,
410            &artifact_update,
411        )
412        .await
413    }
414
415    // ========================================================================
416    // Helper methods
417    // ========================================================================
418
419    async fn reject_rebuild_with_ann_segments(
420        &self,
421        segment_ids: &[String],
422        field_ids: &[u32],
423    ) -> Result<()> {
424        for id_str in segment_ids {
425            let segment_id = SegmentId::from_hex(id_str)
426                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {id_str}")))?;
427            let reader = SegmentReader::open_with_cache_blocks(
428                self.directory.as_ref(),
429                segment_id,
430                Arc::clone(&self.schema),
431                self.config.term_cache_blocks,
432                self.config.store_cache_blocks,
433            )
434            .await?;
435            Self::reject_ann_in_reader(&reader, id_str, field_ids)?;
436        }
437        Ok(())
438    }
439
440    fn reject_ann_in_reader(reader: &SegmentReader, id_str: &str, field_ids: &[u32]) -> Result<()> {
441        for &field_id in field_ids {
442            if matches!(
443                reader.vector_indexes().get(&field_id),
444                Some(crate::segment::VectorIndex::IvfPq(_))
445                    | Some(crate::segment::VectorIndex::BinaryIvf(_))
446            ) {
447                return Err(Error::Schema(format!(
448                    "cannot retrain vector artifacts for field {field_id}: segment {id_str} \
449                     already contains an IVF index built with the current generation; \
450                     rebuild requires all committed segments for the field to be flat"
451                )));
452            }
453        }
454        Ok(())
455    }
456
457    /// Get all dense vector fields that need ANN indexes
458    fn get_ivf_vector_fields(&self) -> Vec<(Field, IvfFieldConfig)> {
459        self.schema
460            .fields()
461            .filter_map(|(field, entry)| {
462                if entry.field_type == FieldType::DenseVector && entry.indexed {
463                    entry
464                        .dense_vector_config
465                        .as_ref()
466                        // Flat is a pre-build storage state; the production ANN
467                        // path is trained once and shared by every segment.
468                        .filter(|c| c.uses_ivf())
469                        .map(|c| (field, IvfFieldConfig::Float(c.clone())))
470                } else if entry.field_type == FieldType::BinaryDenseVector && entry.indexed {
471                    entry
472                        .binary_dense_vector_config
473                        .as_ref()
474                        .filter(|config| config.index_type == BinaryIndexType::Ivf)
475                        .map(|config| (field, IvfFieldConfig::Binary(config.clone())))
476                } else {
477                    None
478                }
479            })
480            .collect()
481    }
482
483    /// Get fields that need building (not already built)
484    async fn get_fields_to_build(
485        &self,
486        dense_fields: &[(Field, IvfFieldConfig)],
487    ) -> Vec<(Field, IvfFieldConfig)> {
488        let field_ids: Vec<u32> = dense_fields.iter().map(|(f, _)| f.0).collect();
489        let built: Vec<u32> = self
490            .segment_manager
491            .read_metadata(|meta| {
492                field_ids
493                    .iter()
494                    .filter(|fid| meta.is_field_built(**fid))
495                    .copied()
496                    .collect()
497            })
498            .await;
499        dense_fields
500            .iter()
501            .filter(|(field, _)| !built.contains(&field.0))
502            .cloned()
503            .collect()
504    }
505
506    /// Collect a deterministic uniform sample over the complete committed
507    /// corpus. Counting first prevents early segments from monopolizing the
508    /// training budget when an index has many generations of segments.
509    async fn collect_vectors_for_training(
510        &self,
511        segment_ids: &[String],
512        fields_to_build: &[(Field, IvfFieldConfig)],
513    ) -> Result<(FxHashMap<u32, TrainingSample>, FxHashMap<u32, usize>)> {
514        // At the one-billion-vector default, two million training points allow
515        // about 51K stable cells at the 39-points-per-centroid quality floor.
516        // The byte bound keeps high-dimensional float training below 6 GiB;
517        // k-means' contiguous work matrix can temporarily double that amount.
518        const MAX_TRAINING_VECTORS: usize = 2_000_000;
519        const MAX_TRAINING_SAMPLE_BYTES: usize = 6usize.saturating_mul(1024 * 1024 * 1024);
520
521        let mut total_vectors: FxHashMap<u32, usize> = FxHashMap::default();
522        let field_ids: Vec<u32> = fields_to_build.iter().map(|(field, _)| field.0).collect();
523
524        // First pass: validate generations and count every field globally.
525        for id_str in segment_ids {
526            let segment_id = SegmentId::from_hex(id_str)
527                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", id_str)))?;
528            let reader = SegmentReader::open_with_cache_blocks(
529                self.directory.as_ref(),
530                segment_id,
531                Arc::clone(&self.schema),
532                self.config.term_cache_blocks,
533                self.config.store_cache_blocks,
534            )
535            .await?;
536
537            // `build_vector_index` is also effectively a retrain whenever
538            // metadata says Flat. A crash-interrupted rebuild from an older
539            // Hermes version can leave that state beside committed ANN
540            // segments, so validate generation safety during the same segment
541            // scan that collects the samples.
542            Self::reject_ann_in_reader(&reader, id_str, &field_ids)?;
543
544            for (field, _) in fields_to_build {
545                if let Some(flat) = reader.flat_vectors().get(&field.0) {
546                    let total = total_vectors.entry(field.0).or_default();
547                    *total = total.saturating_add(flat.num_vectors);
548                }
549            }
550        }
551
552        // Draw sorted global vector ordinals independently per field. Mapping
553        // them back onto segment-local indexes preserves uniform probability
554        // without reading vectors that were not selected.
555        let mut selected_ordinals: FxHashMap<u32, Vec<usize>> = FxHashMap::default();
556        for (field, config) in fields_to_build {
557            let total = total_vectors.get(&field.0).copied().unwrap_or(0);
558            if total == 0 {
559                continue;
560            }
561            let bytes_per_sample = match config {
562                IvfFieldConfig::Float(config) => config.dim.saturating_mul(size_of::<f32>()),
563                IvfFieldConfig::Binary(config) => config.dim.div_ceil(8),
564            };
565            let limit = MAX_TRAINING_VECTORS.min(
566                MAX_TRAINING_SAMPLE_BYTES
567                    .checked_div(bytes_per_sample.max(1))
568                    .unwrap_or(0)
569                    .max(1),
570            );
571            let take = total.min(limit);
572            let mut rng = <rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(
573                0x4845_524d_4553_4956 ^ field.0 as u64 ^ total as u64,
574            );
575            const SAMPLE_BLOCK: usize = 256;
576            let mut ordinals = Vec::with_capacity(take);
577            if take == total {
578                ordinals.extend(0..total);
579            } else {
580                let blocks = take.div_ceil(SAMPLE_BLOCK);
581                for block in 0..blocks {
582                    let block_len = SAMPLE_BLOCK.min(take - ordinals.len());
583                    let stratum_start = block.saturating_mul(total) / blocks;
584                    let stratum_end = (block + 1).saturating_mul(total) / blocks;
585                    let latest_start = stratum_end.saturating_sub(block_len);
586                    let start = if latest_start > stratum_start {
587                        rand::Rng::random_range(&mut rng, stratum_start..=latest_start)
588                    } else {
589                        stratum_start
590                    };
591                    ordinals.extend(start..start + block_len);
592                }
593            }
594            selected_ordinals.insert(field.0, ordinals);
595        }
596
597        let mut all_vectors: FxHashMap<u32, TrainingSample> = FxHashMap::default();
598        let mut global_offsets: FxHashMap<u32, usize> = FxHashMap::default();
599        let mut sample_cursors: FxHashMap<u32, usize> = FxHashMap::default();
600
601        // Second pass: fetch only selected vector ordinals.
602        for id_str in segment_ids {
603            let segment_id = SegmentId::from_hex(id_str)
604                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {id_str}")))?;
605            let reader = SegmentReader::open_with_cache_blocks(
606                self.directory.as_ref(),
607                segment_id,
608                Arc::clone(&self.schema),
609                self.config.term_cache_blocks,
610                self.config.store_cache_blocks,
611            )
612            .await?;
613
614            for (field, config) in fields_to_build {
615                let Some(lazy_flat) = reader.flat_vectors().get(&field.0) else {
616                    continue;
617                };
618                let base = *global_offsets.entry(field.0).or_default();
619                let end = base.saturating_add(lazy_flat.num_vectors);
620                *global_offsets.get_mut(&field.0).unwrap() = end;
621                let ordinals = &selected_ordinals[&field.0];
622                let cursor = sample_cursors.entry(field.0).or_default();
623                let first = *cursor;
624                while *cursor < ordinals.len() && ordinals[*cursor] < end {
625                    *cursor += 1;
626                }
627                let indices: Vec<usize> = ordinals[first..*cursor]
628                    .iter()
629                    .map(|ordinal| ordinal - base)
630                    .collect();
631                if indices.is_empty() {
632                    continue;
633                }
634
635                let entry = all_vectors.entry(field.0).or_insert_with(|| match config {
636                    IvfFieldConfig::Float(_) => TrainingSample::Float(Vec::new()),
637                    IvfFieldConfig::Binary(_) => TrainingSample::Binary(Vec::new()),
638                });
639                if let TrainingSample::Binary(codes) = entry {
640                    let byte_len = config.dim().div_ceil(8);
641                    let mut run_start = 0;
642                    while run_start < indices.len() {
643                        let mut run_end = run_start + 1;
644                        while run_end < indices.len()
645                            && indices[run_end] == indices[run_end - 1] + 1
646                        {
647                            run_end += 1;
648                        }
649                        let bytes = lazy_flat
650                            .read_vectors_batch(indices[run_start], run_end - run_start)
651                            .await
652                            .map_err(crate::Error::Io)?;
653                        codes.extend_from_slice(bytes.as_slice());
654                        debug_assert_eq!(
655                            bytes.len(),
656                            (run_end - run_start).saturating_mul(byte_len)
657                        );
658                        run_start = run_end;
659                    }
660                    continue;
661                }
662
663                let TrainingSample::Float(vectors) = entry else {
664                    unreachable!()
665                };
666                let dim = lazy_flat.dim;
667                let mut run_start = 0;
668                while run_start < indices.len() {
669                    let mut run_end = run_start + 1;
670                    while run_end < indices.len() && indices[run_end] == indices[run_end - 1] + 1 {
671                        run_end += 1;
672                    }
673                    let run_len = run_end - run_start;
674                    let bytes = lazy_flat
675                        .read_vectors_batch(indices[run_start], run_len)
676                        .await
677                        .map_err(crate::Error::Io)?;
678                    let mut decoded = vec![0.0; run_len.saturating_mul(dim)];
679                    crate::segment::dequantize_raw(
680                        bytes.as_slice(),
681                        lazy_flat.quantization,
682                        decoded.len(),
683                        &mut decoded,
684                    )
685                    .map_err(crate::Error::Io)?;
686                    vectors.extend(decoded.chunks_exact(dim).map(<[f32]>::to_vec));
687                    run_start = run_end;
688                }
689            }
690        }
691
692        let total: usize = total_vectors.values().sum();
693        let collected: usize = selected_ordinals.values().map(Vec::len).sum();
694        if collected < total {
695            log::info!(
696                "Sampled {} vectors for training (skipped {}, max {} vectors / {} bytes per field)",
697                collected,
698                total - collected,
699                MAX_TRAINING_VECTORS,
700                MAX_TRAINING_SAMPLE_BYTES,
701            );
702        }
703
704        Ok((all_vectors, total_vectors))
705    }
706
707    /// Train index for a single field
708    async fn train_field_index(
709        &self,
710        field: Field,
711        config: &IvfFieldConfig,
712        all_vectors: &FxHashMap<u32, TrainingSample>,
713        total_vectors: &FxHashMap<u32, usize>,
714    ) -> Result<Option<TrainedFieldUpdate>> {
715        let field_id = field.0;
716        let sample = match all_vectors.get(&field_id) {
717            Some(sample) if sample.len(config.dim()) > 0 => sample,
718            _ => return Ok(None),
719        };
720
721        let dim = config.dim();
722        let sample_count = sample.len(dim);
723        let corpus_count = total_vectors
724            .get(&field_id)
725            .copied()
726            .unwrap_or(sample_count);
727        let num_clusters = effective_field_num_clusters(config, corpus_count, sample_count)?;
728
729        log::info!(
730            "Training vector index for field {} with {} sampled / {} total vectors, {} clusters (dim={})",
731            field_id,
732            sample_count,
733            corpus_count,
734            num_clusters,
735            dim,
736        );
737
738        let centroids_filename = format!("field_{}_centroids.bin", field_id);
739        let mut codebook_filename = None;
740
741        let actual_num_clusters = match (config, sample) {
742            (IvfFieldConfig::Float(config), TrainingSample::Float(vectors))
743                if config.index_type == VectorIndexType::IvfPq =>
744            {
745                codebook_filename = Some(format!("field_{}_codebook.bin", field_id));
746                self.train_ivf_pq(
747                    field_id,
748                    dim,
749                    num_clusters,
750                    config.ivf_routing,
751                    config.soar.clone(),
752                    vectors,
753                    &centroids_filename,
754                    codebook_filename.as_ref().unwrap(),
755                )
756                .await?
757            }
758            (IvfFieldConfig::Binary(config), TrainingSample::Binary(codes)) => {
759                let mut binary_config = crate::structures::BinaryIvfConfig::new(dim, num_clusters);
760                binary_config.max_train_samples = sample_count;
761                binary_config.routing = config.ivf_routing;
762                let quantizer = crate::segment::block_in_place_if_multithread(|| {
763                    crate::structures::BinaryCoarseQuantizer::train(
764                        binary_config,
765                        codes,
766                        sample_count,
767                    )
768                })
769                .map_err(Error::Io)?;
770                self.save_trained_artifact(&quantizer, &centroids_filename)
771                    .await?;
772                quantizer.num_clusters as usize
773            }
774            _ => {
775                return Err(Error::Internal(format!(
776                    "training sample kind does not match field {field_id}"
777                )));
778            }
779        };
780
781        Ok(Some(TrainedFieldUpdate {
782            field_id,
783            index_type: config.index_type(),
784            vector_count: corpus_count,
785            num_clusters: actual_num_clusters,
786            centroids_file: centroids_filename,
787            codebook_file: codebook_filename,
788        }))
789    }
790
791    /// Serialize a trained structure to bincode and save to an index-level file.
792    async fn save_trained_artifact(
793        &self,
794        artifact: &impl serde::Serialize,
795        filename: &str,
796    ) -> Result<()> {
797        let temp_filename = format!("{filename}.tmp");
798        let temp_path = std::path::Path::new(&temp_filename);
799        let final_path = std::path::Path::new(filename);
800        let mut writer = self.directory.streaming_writer(temp_path).await?;
801        let encode_result = {
802            let mut limited = SizeLimitedWriter::new(
803                writer.as_mut(),
804                super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
805            );
806            bincode::serde::encode_into_std_write(
807                artifact,
808                &mut limited,
809                bincode::config::standard(),
810            )
811        };
812        if let Err(error) = encode_result {
813            drop(writer);
814            let _ = self.directory.delete(temp_path).await;
815            return Err(Error::Serialization(format!(
816                "failed to serialize trained artifact '{filename}': {error}"
817            )));
818        }
819        if let Err(error) = writer.finish() {
820            let _ = self.directory.delete(temp_path).await;
821            return Err(Error::Io(error));
822        }
823        if let Err(error) = self.directory.rename(temp_path, final_path).await {
824            let _ = self.directory.delete(temp_path).await;
825            return Err(Error::Io(error));
826        }
827        self.directory.sync().await?;
828        Ok(())
829    }
830
831    /// Train the global IVF-PQ centroids and residual codebook.
832    #[allow(clippy::too_many_arguments)]
833    async fn train_ivf_pq(
834        &self,
835        field_id: u32,
836        dim: usize,
837        num_clusters: usize,
838        routing: crate::dsl::IvfRoutingMode,
839        soar: Option<crate::structures::SoarConfig>,
840        vectors: &[Vec<f32>],
841        centroids_filename: &str,
842        codebook_filename: &str,
843    ) -> Result<usize> {
844        let mut coarse_config =
845            crate::structures::CoarseConfig::new(dim, num_clusters).with_routing(routing);
846        if let Some(soar) = soar {
847            coarse_config = coarse_config.with_soar(soar);
848        }
849        let centroids = crate::segment::block_in_place_if_multithread(|| {
850            crate::structures::CoarseCentroids::train(&coarse_config, vectors)
851        });
852        self.save_trained_artifact(&centroids, centroids_filename)
853            .await?;
854
855        // Faiss' established PQ training ceiling is 256 samples per one-byte
856        // subquantizer centroid. More points multiply every subspace's Lloyd
857        // work without improving the 256-way codebook materially. Train on
858        // residuals, because segment encoding also quantizes x - coarse(x).
859        const PQ_CENTROIDS: usize = 256;
860        const PQ_TRAINING_POINTS_PER_CENTROID: usize = 256;
861        let pq_sample_count = vectors
862            .len()
863            .min(PQ_CENTROIDS * PQ_TRAINING_POINTS_PER_CENTROID);
864        let pq_residuals = crate::segment::block_in_place_if_multithread(|| {
865            (0..pq_sample_count)
866                .map(|sample_index| {
867                    let vector_index = sample_index.saturating_mul(vectors.len()) / pq_sample_count;
868                    let vector = &vectors[vector_index];
869                    let cluster = centroids
870                        .probe(vector, 1, routing)
871                        .cluster_ids
872                        .first()
873                        .copied()
874                        .unwrap_or(0);
875                    centroids.compute_residual(vector, cluster)
876                })
877                .collect::<Vec<_>>()
878        });
879        let pq_config = crate::structures::PQConfig::new(dim);
880        let codebook = crate::segment::block_in_place_if_multithread(|| {
881            crate::structures::PQCodebook::train(pq_config, &pq_residuals, 10)
882        });
883        self.save_trained_artifact(&codebook, codebook_filename)
884            .await?;
885
886        log::info!(
887            "Saved IVF-PQ centroids and residual codebook for field {} ({} clusters, {} PQ samples)",
888            field_id,
889            centroids.num_clusters,
890            pq_sample_count,
891        );
892        Ok(centroids.num_clusters as usize)
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    fn ivf_config(num_clusters: Option<usize>) -> DenseVectorConfig {
901        DenseVectorConfig::with_ivf_pq(8, num_clusters, 4)
902    }
903
904    #[test]
905    fn effective_clusters_follow_corpus_heuristic_but_fit_sample() {
906        let config = ivf_config(None);
907
908        assert_eq!(
909            effective_ivf_num_clusters(&config, 1_000_000, 73).unwrap(),
910            16
911        );
912        assert_eq!(
913            effective_ivf_num_clusters(&config, 10_000, 1_000).unwrap(),
914            25
915        );
916    }
917
918    #[test]
919    fn effective_clusters_clamp_explicit_value_to_sample() {
920        let config = ivf_config(Some(256));
921        assert_eq!(
922            effective_ivf_num_clusters(&config, 1_000_000, 17).unwrap(),
923            17
924        );
925    }
926
927    #[test]
928    fn effective_clusters_reject_invalid_explicit_bounds() {
929        let zero = effective_ivf_num_clusters(&ivf_config(Some(0)), 10_000, 100)
930            .unwrap_err()
931            .to_string();
932        assert!(zero.contains("at least 1"));
933
934        let too_many =
935            effective_ivf_num_clusters(&ivf_config(Some(MAX_IVF_CLUSTERS + 1)), 10_000, 100)
936                .unwrap_err()
937                .to_string();
938        assert!(too_many.contains("must not exceed 1048576"));
939    }
940
941    #[test]
942    fn effective_clusters_reject_empty_training_sample() {
943        let error = effective_ivf_num_clusters(&ivf_config(None), 10_000, 0)
944            .unwrap_err()
945            .to_string();
946        assert!(error.contains("without sample vectors"));
947    }
948
949    #[test]
950    fn artifact_writer_enforces_limit_without_writing_past_it() {
951        let mut output = Vec::new();
952        let mut writer = SizeLimitedWriter::new(&mut output, 3);
953        writer.write_all(&[1, 2]).unwrap();
954        let error = writer.write_all(&[3, 4]).unwrap_err().to_string();
955        assert!(error.contains("3-byte safety limit"), "{error}");
956        assert_eq!(output, vec![1, 2]);
957    }
958
959    // ===== rebuild destructive-downgrade regression tests =====
960
961    use std::path::Path;
962    use std::sync::atomic::{AtomicBool, Ordering};
963
964    use crate::directories::{
965        Directory, DirectoryWriter as DirectoryWriterTrait, FileHandle, RamDirectory, RangeReadFn,
966    };
967    use crate::dsl::{Document, SchemaBuilder};
968    use crate::index::{IndexConfig, IndexWriter};
969
970    const READ_FAIL_DOCS: usize = 5;
971    const READ_FAIL_DIM: usize = 4;
972    /// Flat entry layout of a single-field, flat-only `.vectors` file written
973    /// by the segment builder (data-first format): header (16 bytes) + raw f32
974    /// vectors + doc-id map + TOC + footer. Only the raw vector region is read
975    /// by training collection; segment open touches the header, doc-id map,
976    /// TOC, and footer, which all live outside this byte range.
977    const VEC_REGION_START: u64 = 16;
978    const VEC_REGION_END: u64 = VEC_REGION_START + (READ_FAIL_DOCS * READ_FAIL_DIM * 4) as u64;
979
980    /// RamDirectory wrapper whose `.vectors` handles fail range reads of the
981    /// raw vector region while `fail_vector_reads` is armed. Segment open
982    /// keeps succeeding, so exactly the training-collection batch reads fail —
983    /// the I/O the rebuild path used to swallow with `if let Ok`.
984    #[derive(Clone, Default)]
985    struct VectorReadFailDirectory {
986        inner: RamDirectory,
987        fail_vector_reads: Arc<AtomicBool>,
988    }
989
990    #[async_trait::async_trait]
991    impl Directory for VectorReadFailDirectory {
992        async fn exists(&self, path: &Path) -> std::io::Result<bool> {
993            self.inner.exists(path).await
994        }
995
996        async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
997            self.inner.file_size(path).await
998        }
999
1000        async fn open_read(&self, path: &Path) -> std::io::Result<FileHandle> {
1001            self.inner.open_read(path).await
1002        }
1003
1004        async fn read_range(
1005            &self,
1006            path: &Path,
1007            range: std::ops::Range<u64>,
1008        ) -> std::io::Result<crate::directories::OwnedBytes> {
1009            self.inner.read_range(path, range).await
1010        }
1011
1012        async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
1013            self.inner.list_files(prefix).await
1014        }
1015
1016        async fn open_lazy(&self, path: &Path) -> std::io::Result<FileHandle> {
1017            let handle = self.inner.open_lazy(path).await?;
1018            if path.extension().is_some_and(|ext| ext == "vectors") {
1019                let armed = Arc::clone(&self.fail_vector_reads);
1020                let len = handle.len();
1021                let read_fn: RangeReadFn = Arc::new(move |range: std::ops::Range<u64>| {
1022                    let handle = handle.clone();
1023                    let armed = Arc::clone(&armed);
1024                    Box::pin(async move {
1025                        if armed.load(Ordering::SeqCst)
1026                            && range.start >= VEC_REGION_START
1027                            && range.end <= VEC_REGION_END
1028                        {
1029                            return Err(std::io::Error::other("injected vector data read failure"));
1030                        }
1031                        handle.read_bytes_range(range).await
1032                    })
1033                });
1034                return Ok(FileHandle::lazy(len, read_fn));
1035            }
1036            Ok(handle)
1037        }
1038    }
1039
1040    #[async_trait::async_trait]
1041    impl DirectoryWriterTrait for VectorReadFailDirectory {
1042        async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
1043            self.inner.write(path, data).await
1044        }
1045
1046        async fn delete(&self, path: &Path) -> std::io::Result<()> {
1047            self.inner.delete(path).await
1048        }
1049
1050        async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
1051            self.inner.rename(from, to).await
1052        }
1053
1054        async fn sync(&self) -> std::io::Result<()> {
1055            self.inner.sync().await
1056        }
1057
1058        async fn streaming_writer(
1059            &self,
1060            path: &Path,
1061        ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
1062            self.inner.streaming_writer(path).await
1063        }
1064    }
1065
1066    /// Regression: rebuild_vector_index used to durably reset Built fields to
1067    /// Flat first and then swallow per-batch vector read errors with
1068    /// `if let Ok` during training collection, reporting success while the
1069    /// published trained generation had been destroyed. Read failures must
1070    /// propagate, and the durable Built -> Flat reset must not happen.
1071    #[tokio::test]
1072    async fn rebuild_propagates_vector_read_errors_without_downgrading_built_state() {
1073        let mut sb = SchemaBuilder::default();
1074        let embedding = sb.add_dense_vector_field_with_config(
1075            "embedding",
1076            true,
1077            true,
1078            DenseVectorConfig::with_ivf_pq(READ_FAIL_DIM, Some(1), 1),
1079        );
1080        let schema = sb.build();
1081
1082        let dir = VectorReadFailDirectory::default();
1083        let config = IndexConfig {
1084            merge_policy: Box::new(crate::merge::NoMergePolicy),
1085            num_indexing_threads: 1,
1086            ..Default::default()
1087        };
1088        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1089            .await
1090            .unwrap();
1091        for i in 0..READ_FAIL_DOCS {
1092            let mut doc = Document::new();
1093            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1094            writer.add_document(doc).unwrap();
1095        }
1096        writer.commit().await.unwrap();
1097        writer.build_vector_index().await.unwrap();
1098        assert!(
1099            writer
1100                .segment_manager
1101                .read_metadata(|meta| meta.is_field_built(embedding.0))
1102                .await
1103        );
1104        assert!(writer.segment_manager.trained().is_some());
1105
1106        // Vector data reads now fail (transient I/O error).
1107        dir.fail_vector_reads.store(true, Ordering::SeqCst);
1108        let error = writer
1109            .rebuild_vector_index()
1110            .await
1111            .expect_err("failed sample collection must fail the rebuild")
1112            .to_string();
1113        assert!(
1114            error.contains("injected vector data read failure"),
1115            "{error}"
1116        );
1117
1118        // The published generation survives: no durable Built -> Flat reset.
1119        assert!(
1120            writer
1121                .segment_manager
1122                .read_metadata(|meta| meta.is_field_built(embedding.0))
1123                .await,
1124            "a failed rebuild must not durably downgrade the field to Flat"
1125        );
1126        assert!(
1127            writer.segment_manager.trained().is_some(),
1128            "a failed rebuild must not clear the published trained artifacts"
1129        );
1130    }
1131
1132    /// Regression: rebuild_vector_index used to return Ok(()) after durably
1133    /// resetting a Built field to Flat even when no training vectors could be
1134    /// collected at all, silently discarding the trained generation. An empty
1135    /// training sample for a Built field must be a hard error raised BEFORE
1136    /// the durable reset.
1137    #[tokio::test]
1138    async fn rebuild_errors_before_reset_when_built_field_has_no_training_vectors() {
1139        let mut sb = SchemaBuilder::default();
1140        let title = sb.add_text_field("title", true, true);
1141        let embedding = sb.add_dense_vector_field_with_config(
1142            "embedding",
1143            true,
1144            true,
1145            DenseVectorConfig::with_ivf_pq(4, Some(1), 1),
1146        );
1147        let schema = sb.build();
1148
1149        let dir = RamDirectory::new();
1150        let config = IndexConfig {
1151            merge_policy: Box::new(crate::merge::NoMergePolicy),
1152            num_indexing_threads: 1,
1153            ..Default::default()
1154        };
1155        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1156            .await
1157            .unwrap();
1158        // Committed segments carry no vectors for the field.
1159        for i in 0..3 {
1160            let mut doc = Document::new();
1161            doc.add_text(title, format!("doc {i}"));
1162            writer.add_document(doc).unwrap();
1163        }
1164        writer.commit().await.unwrap();
1165
1166        // Metadata says Built while no committed segment holds vectors for the
1167        // field — the state a crash/degradation can leave behind. Rebuilding
1168        // must refuse to destroy the referenced artifacts.
1169        writer
1170            .segment_manager
1171            .update_metadata(|meta| {
1172                meta.init_field(embedding.0, VectorIndexType::IvfPq);
1173                meta.mark_field_built(
1174                    embedding.0,
1175                    5,
1176                    1,
1177                    format!("field_{}_centroids.bin", embedding.0),
1178                    None,
1179                );
1180            })
1181            .await
1182            .unwrap();
1183
1184        let error = writer
1185            .rebuild_vector_index()
1186            .await
1187            .expect_err("an empty training sample must fail the rebuild")
1188            .to_string();
1189        assert!(error.contains("no training vectors"), "{error}");
1190        assert!(
1191            writer
1192                .segment_manager
1193                .read_metadata(|meta| meta.is_field_built(embedding.0))
1194                .await,
1195            "an empty training sample must not durably downgrade the field to Flat"
1196        );
1197    }
1198}