use std::io::Write;
use std::sync::Arc;
use rustc_hash::FxHashMap;
use crate::directories::DirectoryWriter;
use crate::dsl::{DenseVectorConfig, Field, FieldType, VectorIndexType};
use crate::error::{Error, Result};
use crate::segment::{SegmentId, SegmentReader};
use super::IndexWriter;
const MAX_IVF_CLUSTERS: usize = 4096;
struct TrainedFieldUpdate {
field_id: u32,
index_type: VectorIndexType,
vector_count: usize,
num_clusters: usize,
centroids_file: String,
codebook_file: Option<String>,
}
struct SizeLimitedWriter<'a, W: Write + ?Sized> {
inner: &'a mut W,
written: usize,
limit: usize,
}
impl<'a, W: Write + ?Sized> SizeLimitedWriter<'a, W> {
fn new(inner: &'a mut W, limit: usize) -> Self {
Self {
inner,
written: 0,
limit,
}
}
}
impl<W: Write + ?Sized> Write for SizeLimitedWriter<'_, W> {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
let next_size = self
.written
.checked_add(buffer.len())
.ok_or_else(|| std::io::Error::other("trained artifact size overflow"))?;
if next_size > self.limit {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"trained artifact exceeds the {}-byte safety limit",
self.limit
),
));
}
let written = self.inner.write(buffer)?;
self.written += written;
Ok(written)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
fn validate_explicit_ivf_num_clusters(config: &DenseVectorConfig) -> Result<()> {
match config.num_clusters {
Some(0) => Err(Error::Schema(
"dense vector num_clusters must be at least 1".to_string(),
)),
Some(value) if value > MAX_IVF_CLUSTERS => Err(Error::Schema(format!(
"dense vector num_clusters must not exceed {MAX_IVF_CLUSTERS}, got {value}"
))),
_ => Ok(()),
}
}
fn effective_ivf_num_clusters(
config: &DenseVectorConfig,
corpus_count: usize,
sample_count: usize,
) -> Result<usize> {
if sample_count == 0 {
return Err(Error::Schema(
"cannot train an IVF vector index without sample vectors".to_string(),
));
}
validate_explicit_ivf_num_clusters(config)?;
let requested = match config.num_clusters {
Some(value) => value,
None => config.optimal_num_clusters(corpus_count),
};
Ok(requested.min(sample_count))
}
impl<D: DirectoryWriter + 'static> IndexWriter<D> {
pub async fn build_vector_index(&self) -> Result<()> {
let dense_fields = self.get_dense_vector_fields();
if dense_fields.is_empty() {
log::info!("No dense vector fields configured for ANN indexing");
return Ok(());
}
let artifact_update = self.segment_manager.begin_vector_artifact_update().await?;
self.build_vector_index_locked(&dense_fields, &artifact_update)
.await
}
async fn build_vector_index_locked(
&self,
dense_fields: &[(Field, DenseVectorConfig)],
artifact_update: &crate::merge::VectorArtifactUpdateGuard,
) -> Result<()> {
let fields_to_build = self.get_fields_to_build(dense_fields).await;
if fields_to_build.is_empty() {
log::info!("All vector fields already built, skipping training");
return Ok(());
}
for (_, config) in &fields_to_build {
if config.uses_ivf() {
validate_explicit_ivf_num_clusters(config)?;
}
}
let snapshot = self.segment_manager.acquire_snapshot().await;
let segment_ids = snapshot.segment_ids();
if segment_ids.is_empty() {
return Ok(());
}
let (all_vectors, total_vectors) = self
.collect_vectors_for_training(segment_ids, &fields_to_build)
.await?;
let mut updates = Vec::with_capacity(fields_to_build.len());
for (field, config) in &fields_to_build {
if let Some(update) = self
.train_field_index(*field, config, &all_vectors, &total_vectors)
.await?
{
updates.push(update);
}
}
if updates.is_empty() {
log::info!("No vectors available for trained vector-index fields");
return Ok(());
}
self.segment_manager
.update_vector_metadata_and_publish(artifact_update, |meta| {
for update in &updates {
meta.init_field(update.field_id, update.index_type);
meta.mark_field_built(
update.field_id,
update.vector_count,
update.num_clusters,
update.centroids_file.clone(),
update.codebook_file.clone(),
);
}
})
.await?;
log::info!("Vector index training complete, ANN will be built during merges");
Ok(())
}
pub async fn rebuild_vector_index(&self) -> Result<()> {
let dense_fields = self.get_dense_vector_fields();
if dense_fields.is_empty() {
return Ok(());
}
let artifact_update = self.segment_manager.begin_vector_artifact_update().await?;
let snapshot = self.segment_manager.acquire_snapshot().await;
let field_ids: Vec<u32> = dense_fields.iter().map(|(field, _)| field.0).collect();
self.reject_rebuild_with_ann_segments(snapshot.segment_ids(), &field_ids)
.await?;
self.segment_manager
.update_vector_metadata_and_publish(&artifact_update, |meta| {
for field_id in &field_ids {
if let Some(field_meta) = meta.vector_fields.get_mut(field_id) {
field_meta.state = super::VectorIndexState::Flat;
field_meta.centroids_file = None;
field_meta.codebook_file = None;
}
}
meta.refresh_total_vectors();
})
.await?;
log::info!("Reset vector index state to Flat, triggering rebuild...");
self.build_vector_index_locked(&dense_fields, &artifact_update)
.await
}
async fn reject_rebuild_with_ann_segments(
&self,
segment_ids: &[String],
field_ids: &[u32],
) -> Result<()> {
for id_str in segment_ids {
let segment_id = SegmentId::from_hex(id_str)
.ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {id_str}")))?;
let reader = SegmentReader::open_with_cache_blocks(
self.directory.as_ref(),
segment_id,
Arc::clone(&self.schema),
self.config.term_cache_blocks,
self.config.store_cache_blocks,
)
.await?;
Self::reject_ann_in_reader(&reader, id_str, field_ids)?;
}
Ok(())
}
fn reject_ann_in_reader(reader: &SegmentReader, id_str: &str, field_ids: &[u32]) -> Result<()> {
for &field_id in field_ids {
if matches!(
reader.vector_indexes().get(&field_id),
Some(crate::segment::VectorIndex::IVF(_))
| Some(crate::segment::VectorIndex::ScaNN(_))
) {
return Err(Error::Schema(format!(
"cannot retrain vector artifacts for field {field_id}: segment {id_str} \
already contains an IVF/ScaNN index built with the current generation; \
rebuild requires all committed segments for the field to be flat"
)));
}
}
Ok(())
}
fn get_dense_vector_fields(&self) -> Vec<(Field, DenseVectorConfig)> {
self.schema
.fields()
.filter_map(|(field, entry)| {
if entry.field_type == FieldType::DenseVector && entry.indexed {
entry
.dense_vector_config
.as_ref()
.filter(|c| c.uses_ivf())
.map(|c| (field, c.clone()))
} else {
None
}
})
.collect()
}
async fn get_fields_to_build(
&self,
dense_fields: &[(Field, DenseVectorConfig)],
) -> Vec<(Field, DenseVectorConfig)> {
let field_ids: Vec<u32> = dense_fields.iter().map(|(f, _)| f.0).collect();
let built: Vec<u32> = self
.segment_manager
.read_metadata(|meta| {
field_ids
.iter()
.filter(|fid| meta.is_field_built(**fid))
.copied()
.collect()
})
.await;
dense_fields
.iter()
.filter(|(field, _)| !built.contains(&field.0))
.cloned()
.collect()
}
async fn collect_vectors_for_training(
&self,
segment_ids: &[String],
fields_to_build: &[(Field, DenseVectorConfig)],
) -> Result<(FxHashMap<u32, Vec<Vec<f32>>>, FxHashMap<u32, usize>)> {
const MAX_TRAINING_VECTORS: usize = 100_000;
let mut all_vectors: FxHashMap<u32, Vec<Vec<f32>>> = FxHashMap::default();
let mut total_vectors: FxHashMap<u32, usize> = FxHashMap::default();
let mut total_skipped = 0usize;
let field_ids: Vec<u32> = fields_to_build.iter().map(|(field, _)| field.0).collect();
for id_str in segment_ids {
let segment_id = SegmentId::from_hex(id_str)
.ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", id_str)))?;
let reader = SegmentReader::open_with_cache_blocks(
self.directory.as_ref(),
segment_id,
Arc::clone(&self.schema),
self.config.term_cache_blocks,
self.config.store_cache_blocks,
)
.await?;
Self::reject_ann_in_reader(&reader, id_str, &field_ids)?;
for (field_id, lazy_flat) in reader.flat_vectors() {
if !fields_to_build.iter().any(|(f, _)| f.0 == *field_id) {
continue;
}
let total = total_vectors.entry(*field_id).or_default();
*total = total.saturating_add(lazy_flat.num_vectors);
let entry = all_vectors.entry(*field_id).or_default();
let remaining = MAX_TRAINING_VECTORS.saturating_sub(entry.len());
if remaining == 0 {
total_skipped += lazy_flat.num_vectors;
continue;
}
let n = lazy_flat.num_vectors;
let dim = lazy_flat.dim;
let quant = lazy_flat.quantization;
let indices: Vec<usize> = if n <= remaining {
(0..n).collect()
} else {
let step = (n / remaining).max(1);
(0..n).step_by(step).take(remaining).collect()
};
if indices.len() < n {
total_skipped += n - indices.len();
}
const BATCH: usize = 1024;
let mut f32_buf = vec![0f32; BATCH * dim];
for chunk in indices.chunks(BATCH) {
let start = chunk[0];
let end = *chunk.last().unwrap();
if end - start + 1 == chunk.len() {
if let Ok(batch_bytes) =
lazy_flat.read_vectors_batch(start, chunk.len()).await
{
let floats = chunk.len() * dim;
f32_buf.resize(floats, 0.0);
crate::segment::dequantize_raw(
batch_bytes.as_slice(),
quant,
floats,
&mut f32_buf,
)
.map_err(crate::Error::Io)?;
for i in 0..chunk.len() {
entry.push(f32_buf[i * dim..(i + 1) * dim].to_vec());
}
}
} else {
f32_buf.resize(dim, 0.0);
for &idx in chunk {
if let Ok(()) = lazy_flat.read_vector_into(idx, &mut f32_buf).await {
entry.push(f32_buf[..dim].to_vec());
}
}
}
}
}
}
if total_skipped > 0 {
let collected: usize = all_vectors.values().map(|v| v.len()).sum();
log::info!(
"Sampled {} vectors for training (skipped {}, max {} per field)",
collected,
total_skipped,
MAX_TRAINING_VECTORS,
);
}
Ok((all_vectors, total_vectors))
}
async fn train_field_index(
&self,
field: Field,
config: &DenseVectorConfig,
all_vectors: &FxHashMap<u32, Vec<Vec<f32>>>,
total_vectors: &FxHashMap<u32, usize>,
) -> Result<Option<TrainedFieldUpdate>> {
let field_id = field.0;
let vectors = match all_vectors.get(&field_id) {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let dim = config.dim;
let sample_count = vectors.len();
let corpus_count = total_vectors
.get(&field_id)
.copied()
.unwrap_or(sample_count);
if !matches!(
config.index_type,
VectorIndexType::IvfRaBitQ | VectorIndexType::ScaNN
) {
return Ok(None);
}
let num_clusters = effective_ivf_num_clusters(config, corpus_count, sample_count)?;
log::info!(
"Training vector index for field {} with {} sampled / {} total vectors, {} clusters (dim={})",
field_id,
sample_count,
corpus_count,
num_clusters,
dim,
);
let centroids_filename = format!("field_{}_centroids.bin", field_id);
let mut codebook_filename: Option<String> = None;
let actual_num_clusters = match config.index_type {
VectorIndexType::IvfRaBitQ => {
self.train_ivf_rabitq(
field_id,
dim,
num_clusters,
config.soar.clone(),
vectors,
¢roids_filename,
)
.await?
}
VectorIndexType::ScaNN => {
codebook_filename = Some(format!("field_{}_codebook.bin", field_id));
self.train_scann(
field_id,
dim,
num_clusters,
config.soar.clone(),
vectors,
¢roids_filename,
codebook_filename.as_ref().unwrap(),
)
.await?
}
_ => unreachable!("non-IVF vector index returned above"),
};
Ok(Some(TrainedFieldUpdate {
field_id,
index_type: config.index_type,
vector_count: corpus_count,
num_clusters: actual_num_clusters,
centroids_file: centroids_filename,
codebook_file: codebook_filename,
}))
}
async fn save_trained_artifact(
&self,
artifact: &impl serde::Serialize,
filename: &str,
) -> Result<()> {
let temp_filename = format!("{filename}.tmp");
let temp_path = std::path::Path::new(&temp_filename);
let final_path = std::path::Path::new(filename);
let mut writer = self.directory.streaming_writer(temp_path).await?;
let encode_result = {
let mut limited = SizeLimitedWriter::new(
writer.as_mut(),
super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
);
bincode::serde::encode_into_std_write(
artifact,
&mut limited,
bincode::config::standard(),
)
};
if let Err(error) = encode_result {
drop(writer);
let _ = self.directory.delete(temp_path).await;
return Err(Error::Serialization(format!(
"failed to serialize trained artifact '{filename}': {error}"
)));
}
if let Err(error) = writer.finish() {
let _ = self.directory.delete(temp_path).await;
return Err(Error::Io(error));
}
if let Err(error) = self.directory.rename(temp_path, final_path).await {
let _ = self.directory.delete(temp_path).await;
return Err(Error::Io(error));
}
self.directory.sync().await?;
Ok(())
}
async fn train_ivf_rabitq(
&self,
field_id: u32,
dim: usize,
num_clusters: usize,
soar: Option<crate::structures::SoarConfig>,
vectors: &[Vec<f32>],
centroids_filename: &str,
) -> Result<usize> {
let mut coarse_config = crate::structures::CoarseConfig::new(dim, num_clusters);
if let Some(soar) = soar {
coarse_config = coarse_config.with_soar(soar);
}
let centroids = crate::structures::CoarseCentroids::train(&coarse_config, vectors);
self.save_trained_artifact(¢roids, centroids_filename)
.await?;
log::info!(
"Saved IVF-RaBitQ centroids for field {} ({} clusters, soar={})",
field_id,
centroids.num_clusters,
centroids.soar_config.is_some()
);
Ok(centroids.num_clusters as usize)
}
#[allow(clippy::too_many_arguments)]
async fn train_scann(
&self,
field_id: u32,
dim: usize,
num_clusters: usize,
soar: Option<crate::structures::SoarConfig>,
vectors: &[Vec<f32>],
centroids_filename: &str,
codebook_filename: &str,
) -> Result<usize> {
let mut coarse_config = crate::structures::CoarseConfig::new(dim, num_clusters);
if let Some(soar) = soar {
coarse_config = coarse_config.with_soar(soar);
}
let centroids = crate::structures::CoarseCentroids::train(&coarse_config, vectors);
self.save_trained_artifact(¢roids, centroids_filename)
.await?;
let pq_config = crate::structures::PQConfig::new(dim);
let codebook = crate::structures::PQCodebook::train(pq_config, vectors, 10);
self.save_trained_artifact(&codebook, codebook_filename)
.await?;
log::info!(
"Saved ScaNN centroids and codebook for field {} ({} clusters)",
field_id,
centroids.num_clusters
);
Ok(centroids.num_clusters as usize)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ivf_config(num_clusters: Option<usize>) -> DenseVectorConfig {
DenseVectorConfig::with_ivf(8, num_clusters, 4)
}
#[test]
fn effective_clusters_follow_corpus_heuristic_but_fit_sample() {
let config = ivf_config(None);
assert_eq!(
effective_ivf_num_clusters(&config, 1_000_000, 73).unwrap(),
73
);
assert_eq!(
effective_ivf_num_clusters(&config, 10_000, 1_000).unwrap(),
100
);
}
#[test]
fn effective_clusters_clamp_explicit_value_to_sample() {
let config = ivf_config(Some(256));
assert_eq!(
effective_ivf_num_clusters(&config, 1_000_000, 17).unwrap(),
17
);
}
#[test]
fn effective_clusters_reject_invalid_explicit_bounds() {
let zero = effective_ivf_num_clusters(&ivf_config(Some(0)), 10_000, 100)
.unwrap_err()
.to_string();
assert!(zero.contains("at least 1"));
let too_many =
effective_ivf_num_clusters(&ivf_config(Some(MAX_IVF_CLUSTERS + 1)), 10_000, 100)
.unwrap_err()
.to_string();
assert!(too_many.contains("must not exceed 4096"));
}
#[test]
fn effective_clusters_reject_empty_training_sample() {
let error = effective_ivf_num_clusters(&ivf_config(None), 10_000, 0)
.unwrap_err()
.to_string();
assert!(error.contains("without sample vectors"));
}
#[test]
fn artifact_writer_enforces_limit_without_writing_past_it() {
let mut output = Vec::new();
let mut writer = SizeLimitedWriter::new(&mut output, 3);
writer.write_all(&[1, 2]).unwrap();
let error = writer.write_all(&[3, 4]).unwrap_err().to_string();
assert!(error.contains("3-byte safety limit"), "{error}");
assert_eq!(output, vec![1, 2]);
}
}