1use 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
21const MAX_IVF_CLUSTERS: usize = 1_048_576;
24const 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
87struct 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#[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 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 async fn build_vector_index_locked(
232 &self,
233 dense_fields: &[(Field, IvfFieldConfig)],
234 artifact_update: &crate::merge::VectorArtifactUpdateGuard,
235 ) -> Result<()> {
236 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 for (_, config) in &fields_to_build {
246 validate_explicit_cluster_count(config.num_clusters())?;
247 }
248
249 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 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 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 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 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 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 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 for (_, config) in &dense_fields {
348 validate_explicit_cluster_count(config.num_clusters())?;
349 }
350
351 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 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 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 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 .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 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 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 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 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 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 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 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 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 ¢roids_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, ¢roids_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 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 #[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(¢roids, centroids_filename)
853 .await?;
854
855 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 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 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 #[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 #[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 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 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 #[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 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 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}