1use std::cell::RefCell;
10use std::collections::HashMap;
11use std::fs;
12use std::fs::File;
13use std::io::{BufReader, BufWriter, Write};
14use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use ndarray::{s, Array1, Array2, Axis};
19use rayon::prelude::*;
20
21use crate::codec::ResidualCodec;
22use crate::error::Error;
23use crate::error::Result;
24use crate::index::Metadata;
25use crate::kmeans::compute_kmeans;
26use crate::kmeans::ComputeKmeansConfig;
27use crate::utils::atomic_write_file;
28use crate::utils::quantile;
29
30const DEFAULT_BATCH_SIZE: usize = 50_000;
32
33type ProgressCallback = Box<dyn Fn(&str, &str)>;
35
36thread_local! {
37 static UPDATE_PROGRESS: RefCell<Option<ProgressCallback>> = const { RefCell::new(None) };
38}
39
40struct ProgressGuard;
41
42impl Drop for ProgressGuard {
43 fn drop(&mut self) {
44 UPDATE_PROGRESS.with(|slot| {
45 *slot.borrow_mut() = None;
46 });
47 }
48}
49
50pub fn with_update_progress<F, R>(callback: F, operation: impl FnOnce() -> R) -> R
55where
56 F: Fn(&str, &str) + 'static,
57{
58 UPDATE_PROGRESS.with(|slot| {
59 *slot.borrow_mut() = Some(Box::new(callback));
60 });
61 let _guard = ProgressGuard;
62 operation()
63}
64
65fn emit_update_progress(stage: &str, message: &str) {
66 UPDATE_PROGRESS.with(|slot| {
67 if let Some(callback) = slot.borrow().as_ref() {
68 callback(stage, message);
69 }
70 });
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct UpdateConfig {
76 pub batch_size: usize,
78 pub kmeans_niters: usize,
80 pub max_points_per_centroid: usize,
82 pub n_samples_kmeans: Option<usize>,
84 pub seed: u64,
86 pub start_from_scratch: usize,
88 pub buffer_size: usize,
90 #[serde(default)]
92 pub force_cpu: bool,
93}
94
95impl Default for UpdateConfig {
96 fn default() -> Self {
97 Self {
98 batch_size: DEFAULT_BATCH_SIZE,
99 kmeans_niters: 4,
100 max_points_per_centroid: 256,
101 n_samples_kmeans: None,
102 seed: 42,
103 start_from_scratch: crate::default_start_from_scratch(),
104 buffer_size: 100,
105 force_cpu: false,
106 }
107 }
108}
109
110impl UpdateConfig {
111 pub fn to_kmeans_config(&self) -> ComputeKmeansConfig {
113 ComputeKmeansConfig {
114 kmeans_niters: self.kmeans_niters,
115 max_points_per_centroid: self.max_points_per_centroid,
116 seed: self.seed,
117 n_samples_kmeans: self.n_samples_kmeans,
118 num_partitions: None,
119 force_cpu: self.force_cpu,
120 }
121 }
122}
123
124pub fn load_buffer(index_path: &Path) -> Result<Vec<Array2<f32>>> {
133 use ndarray_npy::ReadNpyExt;
134
135 let buffer_path = index_path.join("buffer.npy");
136 let lengths_path = index_path.join("buffer_lengths.json");
137
138 if !buffer_path.exists() {
139 return Ok(Vec::new());
140 }
141
142 let flat: Array2<f32> = match Array2::read_npy(File::open(&buffer_path)?) {
144 Ok(arr) => arr,
145 Err(_) => return Ok(Vec::new()),
146 };
147
148 if lengths_path.exists() {
150 let lengths: Vec<i64> =
151 serde_json::from_reader(BufReader::new(File::open(&lengths_path)?))?;
152
153 let mut result = Vec::with_capacity(lengths.len());
154 let mut offset = 0;
155
156 for &len in &lengths {
157 let len_usize = len as usize;
158 if offset + len_usize > flat.nrows() {
159 break;
160 }
161 let doc_emb = flat.slice(s![offset..offset + len_usize, ..]).to_owned();
162 result.push(doc_emb);
163 offset += len_usize;
164 }
165
166 return Ok(result);
167 }
168
169 Ok(vec![flat])
171}
172
173pub fn save_buffer(index_path: &Path, embeddings: &[Array2<f32>]) -> Result<()> {
177 use ndarray_npy::WriteNpyExt;
178
179 let buffer_path = index_path.join("buffer.npy");
180
181 if embeddings.is_empty() {
184 return Ok(());
185 }
186
187 let dim = embeddings[0].ncols();
188 let total_rows: usize = embeddings.iter().map(|e| e.nrows()).sum();
189
190 let mut flat = Array2::<f32>::zeros((total_rows, dim));
191 let mut offset = 0;
192 let mut lengths = Vec::new();
193
194 for emb in embeddings {
195 let n = emb.nrows();
196 flat.slice_mut(s![offset..offset + n, ..]).assign(emb);
197 lengths.push(n as i64);
198 offset += n;
199 }
200
201 atomic_write_file(&buffer_path, |file| {
202 flat.write_npy(file)?;
203 Ok(())
204 })?;
205
206 let lengths_path = index_path.join("buffer_lengths.json");
208 atomic_write_file(&lengths_path, |file| {
209 let mut writer = BufWriter::new(file);
210 serde_json::to_writer(&mut writer, &lengths)?;
211 writer.flush()?;
212 Ok(())
213 })?;
214
215 let info_path = index_path.join("buffer_info.json");
217 let buffer_info = serde_json::json!({ "num_docs": embeddings.len() });
218 atomic_write_file(&info_path, |file| {
219 let mut writer = BufWriter::new(file);
220 serde_json::to_writer(&mut writer, &buffer_info)?;
221 writer.flush()?;
222 Ok(())
223 })?;
224
225 Ok(())
226}
227
228pub fn load_buffer_info(index_path: &Path) -> Result<usize> {
232 let info_path = index_path.join("buffer_info.json");
233 if !info_path.exists() {
234 return Ok(0);
235 }
236
237 let info: serde_json::Value = serde_json::from_reader(BufReader::new(File::open(&info_path)?))?;
238
239 Ok(info.get("num_docs").and_then(|v| v.as_u64()).unwrap_or(0) as usize)
240}
241
242pub fn clear_buffer(index_path: &Path) -> Result<()> {
244 let buffer_path = index_path.join("buffer.npy");
245 let lengths_path = index_path.join("buffer_lengths.json");
246 let info_path = index_path.join("buffer_info.json");
247
248 if buffer_path.exists() {
249 fs::remove_file(&buffer_path)?;
250 }
251 if lengths_path.exists() {
252 fs::remove_file(&lengths_path)?;
253 }
254 if info_path.exists() {
255 fs::remove_file(&info_path)?;
256 }
257
258 Ok(())
259}
260
261pub fn load_embeddings_npy(index_path: &Path) -> Result<Vec<Array2<f32>>> {
266 use ndarray_npy::ReadNpyExt;
267
268 let emb_path = index_path.join("embeddings.npy");
269 let lengths_path = index_path.join("embeddings_lengths.json");
270
271 if !emb_path.exists() {
272 return Ok(Vec::new());
273 }
274
275 let flat: Array2<f32> = Array2::read_npy(File::open(&emb_path)?)?;
277
278 if lengths_path.exists() {
280 let lengths: Vec<i64> =
281 serde_json::from_reader(BufReader::new(File::open(&lengths_path)?))?;
282
283 let mut result = Vec::with_capacity(lengths.len());
284 let mut offset = 0;
285
286 for &len in &lengths {
287 let len_usize = len as usize;
288 if offset + len_usize > flat.nrows() {
289 break;
290 }
291 let doc_emb = flat.slice(s![offset..offset + len_usize, ..]).to_owned();
292 result.push(doc_emb);
293 offset += len_usize;
294 }
295
296 return Ok(result);
297 }
298
299 Ok(vec![flat])
301}
302
303pub fn save_embeddings_npy(index_path: &Path, embeddings: &[Array2<f32>]) -> Result<()> {
309 use ndarray_npy::WriteNpyExt;
310
311 if embeddings.is_empty() {
312 return Ok(());
313 }
314
315 let dim = embeddings[0].ncols();
316 let total_rows: usize = embeddings.iter().map(|e| e.nrows()).sum();
317
318 let mut flat = Array2::<f32>::zeros((total_rows, dim));
319 let mut offset = 0;
320 let mut lengths = Vec::with_capacity(embeddings.len());
321
322 for emb in embeddings {
323 let n = emb.nrows();
324 flat.slice_mut(s![offset..offset + n, ..]).assign(emb);
325 lengths.push(n as i64);
326 offset += n;
327 }
328
329 let emb_path = index_path.join("embeddings.npy");
331 atomic_write_file(&emb_path, |file| {
332 flat.write_npy(file)?;
333 Ok(())
334 })?;
335
336 let lengths_path = index_path.join("embeddings_lengths.json");
338 atomic_write_file(&lengths_path, |file| {
339 let mut writer = BufWriter::new(file);
340 serde_json::to_writer(&mut writer, &lengths)?;
341 writer.flush()?;
342 Ok(())
343 })?;
344
345 Ok(())
346}
347
348pub fn clear_embeddings_npy(index_path: &Path) -> Result<()> {
350 let emb_path = index_path.join("embeddings.npy");
351 let lengths_path = index_path.join("embeddings_lengths.json");
352
353 if emb_path.exists() {
354 fs::remove_file(&emb_path)?;
355 }
356 if lengths_path.exists() {
357 fs::remove_file(&lengths_path)?;
358 }
359 Ok(())
360}
361
362pub fn embeddings_npy_exists(index_path: &Path) -> bool {
364 index_path.join("embeddings.npy").exists()
365}
366
367pub fn load_cluster_threshold(index_path: &Path) -> Result<f32> {
373 use ndarray_npy::ReadNpyExt;
374
375 let thresh_path = index_path.join("cluster_threshold.npy");
376 if !thresh_path.exists() {
377 return Err(Error::Update("cluster_threshold.npy not found".into()));
378 }
379
380 let arr: Array1<f32> = Array1::read_npy(File::open(&thresh_path)?)?;
381 Ok(arr[0])
382}
383
384pub fn update_cluster_threshold(
386 index_path: &Path,
387 new_residual_norms: &Array1<f32>,
388 old_total_embeddings: usize,
389) -> Result<()> {
390 use ndarray_npy::{ReadNpyExt, WriteNpyExt};
391
392 let new_count = new_residual_norms.len();
393 if new_count == 0 {
394 return Ok(());
395 }
396
397 let new_threshold = quantile(new_residual_norms, 0.75);
398
399 let thresh_path = index_path.join("cluster_threshold.npy");
400 let final_threshold = if thresh_path.exists() {
401 let old_arr: Array1<f32> = Array1::read_npy(File::open(&thresh_path)?)?;
402 let old_threshold = old_arr[0];
403 let total = old_total_embeddings + new_count;
404 (old_threshold * old_total_embeddings as f32 + new_threshold * new_count as f32)
405 / total as f32
406 } else {
407 new_threshold
408 };
409
410 atomic_write_file(&thresh_path, |file| {
411 Array1::from_vec(vec![final_threshold]).write_npy(file)?;
412 Ok(())
413 })?;
414
415 Ok(())
416}
417
418const OUTLIER_CENTROID_TILE: usize = 8;
423const OUTLIER_EMBEDDING_TILE: usize = 64;
424const OUTLIER_BLOCKS_MIN_LEN: usize = 4;
425const OUTLIER_THRESHOLD_RECHECK_REL_EPS: f32 = 1e-5;
426
427#[inline]
428fn squared_norm(row: &[f32]) -> f32 {
429 let mut sum0 = 0.0f32;
430 let mut sum1 = 0.0f32;
431 let mut sum2 = 0.0f32;
432 let mut sum3 = 0.0f32;
433
434 let mut i = 0;
435 while i + 4 <= row.len() {
436 sum0 += row[i] * row[i];
437 sum1 += row[i + 1] * row[i + 1];
438 sum2 += row[i + 2] * row[i + 2];
439 sum3 += row[i + 3] * row[i + 3];
440 i += 4;
441 }
442
443 let mut total = sum0 + sum1 + sum2 + sum3;
444 while i < row.len() {
445 total += row[i] * row[i];
446 i += 1;
447 }
448
449 total
450}
451
452#[inline]
457fn min_distance_sq_precise(row: &[f32], centroids_flat: &[f32], dim: usize) -> f32 {
458 let mut min_dist_sq = f32::INFINITY;
459
460 for centroid in centroids_flat.chunks_exact(dim) {
461 let mut dist_sq = 0.0f64;
462 let mut d = 0;
463 while d < dim {
464 let diff = row[d] as f64 - centroid[d] as f64;
465 dist_sq += diff * diff;
466 d += 1;
467 }
468
469 min_dist_sq = min_dist_sq.min(dist_sq as f32);
470 }
471
472 min_dist_sq
473}
474
475#[allow(clippy::needless_range_loop)]
490fn find_outliers(
491 flat_embeddings: &Array2<f32>,
492 centroids: &Array2<f32>,
493 threshold_sq: f32,
494) -> Vec<usize> {
495 let n = flat_embeddings.nrows();
496 let k = centroids.nrows();
497 let dim = flat_embeddings.ncols();
498
499 if n == 0 || k == 0 {
500 return Vec::new();
501 }
502
503 let embeddings_owned;
504 let embeddings_flat = if let Some(slice) = flat_embeddings.as_slice_memory_order() {
505 slice
506 } else {
507 embeddings_owned = flat_embeddings.as_standard_layout().to_owned();
508 embeddings_owned
509 .as_slice_memory_order()
510 .expect("standard-layout embeddings should be contiguous")
511 };
512 let centroids_owned;
513 let centroids_flat = if let Some(slice) = centroids.as_slice_memory_order() {
514 slice
515 } else {
516 centroids_owned = centroids.as_standard_layout().to_owned();
517 centroids_owned
518 .as_slice_memory_order()
519 .expect("standard-layout centroids should be contiguous")
520 };
521
522 let centroid_norms_sq: Vec<f32> = centroids_flat
523 .par_chunks_exact(dim)
524 .map(squared_norm)
525 .collect();
526
527 let row_stride = dim * OUTLIER_EMBEDDING_TILE;
528 embeddings_flat
529 .par_chunks(row_stride)
530 .with_min_len(OUTLIER_BLOCKS_MIN_LEN)
531 .enumerate()
532 .flat_map_iter(|(block_idx, rows_block)| {
533 let row_count = rows_block.len() / dim;
534 let row_offset = block_idx * OUTLIER_EMBEDDING_TILE;
535
536 let mut min_dists = vec![f32::INFINITY; row_count];
537 let emb_norms: Vec<f32> = rows_block.chunks_exact(dim).map(squared_norm).collect();
538
539 let mut centroid_idx = 0;
540 while centroid_idx + OUTLIER_CENTROID_TILE <= k {
541 let centroid_bases: [usize; OUTLIER_CENTROID_TILE] =
542 std::array::from_fn(|j| (centroid_idx + j) * dim);
543
544 let mut dots = [[0.0f32; OUTLIER_CENTROID_TILE]; OUTLIER_EMBEDDING_TILE];
545
546 let mut dim_idx = 0;
547 while dim_idx < dim {
548 let centroid_vals: [f32; OUTLIER_CENTROID_TILE] =
549 std::array::from_fn(|j| centroids_flat[centroid_bases[j] + dim_idx]);
550
551 for row_idx in 0..row_count {
552 let x = rows_block[row_idx * dim + dim_idx];
553 for j in 0..OUTLIER_CENTROID_TILE {
554 dots[row_idx][j] += x * centroid_vals[j];
555 }
556 }
557
558 dim_idx += 1;
559 }
560
561 for row_idx in 0..row_count {
562 let emb_norm_sq = emb_norms[row_idx];
563 for j in 0..OUTLIER_CENTROID_TILE {
564 let dist_sq = emb_norm_sq + centroid_norms_sq[centroid_idx + j]
565 - 2.0 * dots[row_idx][j];
566 min_dists[row_idx] = min_dists[row_idx].min(dist_sq);
567 }
568 }
569
570 centroid_idx += OUTLIER_CENTROID_TILE;
571 }
572
573 while centroid_idx < k {
574 let centroid = ¢roids_flat[centroid_idx * dim..(centroid_idx + 1) * dim];
575 for row_idx in 0..row_count {
576 let row = &rows_block[row_idx * dim..(row_idx + 1) * dim];
577 let mut dot = 0.0f32;
578 let mut dim_idx = 0;
579 while dim_idx < dim {
580 dot += row[dim_idx] * centroid[dim_idx];
581 dim_idx += 1;
582 }
583
584 let dist_sq = emb_norms[row_idx] + centroid_norms_sq[centroid_idx] - 2.0 * dot;
585 min_dists[row_idx] = min_dists[row_idx].min(dist_sq);
586 }
587
588 centroid_idx += 1;
589 }
590
591 min_dists
592 .into_iter()
593 .enumerate()
594 .filter_map(move |(row_idx, min_dist_sq)| {
595 let final_min_dist_sq = if (min_dist_sq - threshold_sq).abs()
596 <= threshold_sq.abs().max(1.0) * OUTLIER_THRESHOLD_RECHECK_REL_EPS
597 {
598 let row = &rows_block[row_idx * dim..(row_idx + 1) * dim];
599 min_distance_sq_precise(row, centroids_flat, dim)
600 } else {
601 min_dist_sq
602 };
603
604 (final_min_dist_sq > threshold_sq).then_some(row_offset + row_idx)
605 })
606 })
607 .collect()
608}
609
610pub fn update_centroids(
622 index_path: &Path,
623 new_embeddings: &[Array2<f32>],
624 cluster_threshold: f32,
625 config: &UpdateConfig,
626) -> Result<usize> {
627 use ndarray_npy::{ReadNpyExt, WriteNpyExt};
628
629 let centroids_path = index_path.join("centroids.npy");
630 if !centroids_path.exists() {
631 return Ok(0);
632 }
633
634 let existing_centroids: Array2<f32> = Array2::read_npy(File::open(¢roids_path)?)?;
636
637 let dim = existing_centroids.ncols();
639 let total_tokens: usize = new_embeddings.iter().map(|e| e.nrows()).sum();
640
641 if total_tokens == 0 {
642 return Ok(0);
643 }
644
645 let mut flat_embeddings = Array2::<f32>::zeros((total_tokens, dim));
646 let mut offset = 0;
647
648 for emb in new_embeddings {
649 let n = emb.nrows();
650 flat_embeddings
651 .slice_mut(s![offset..offset + n, ..])
652 .assign(emb);
653 offset += n;
654 }
655
656 emit_update_progress(
658 "centroid_expansion",
659 "finding embeddings outside existing centroids",
660 );
661 let threshold_sq = cluster_threshold * cluster_threshold;
662 let outlier_indices = find_outliers(&flat_embeddings, &existing_centroids, threshold_sq);
663
664 let num_outliers = outlier_indices.len();
665 if num_outliers == 0 {
666 return Ok(0);
667 }
668
669 let mut outliers = Array2::<f32>::zeros((num_outliers, dim));
671 for (i, &idx) in outlier_indices.iter().enumerate() {
672 outliers.row_mut(i).assign(&flat_embeddings.row(idx));
673 }
674
675 let target_k =
678 ((num_outliers as f64 / config.max_points_per_centroid as f64).ceil() as usize).max(1) * 4;
679 let k_update = target_k.min(num_outliers); let kmeans_config = ComputeKmeansConfig {
683 kmeans_niters: config.kmeans_niters,
684 max_points_per_centroid: config.max_points_per_centroid,
685 seed: config.seed,
686 n_samples_kmeans: config.n_samples_kmeans,
687 num_partitions: Some(k_update),
688 force_cpu: config.force_cpu,
689 };
690
691 let outlier_docs: Vec<Array2<f32>> = outlier_indices
693 .iter()
694 .map(|&idx| flat_embeddings.slice(s![idx..idx + 1, ..]).to_owned())
695 .collect();
696
697 emit_update_progress("kmeans", "clustering outlier embeddings");
698 let new_centroids = compute_kmeans(&outlier_docs, &kmeans_config)?;
699 let k_new = new_centroids.nrows();
700
701 let new_num_centroids = existing_centroids.nrows() + k_new;
703 let mut final_centroids = Array2::<f32>::zeros((new_num_centroids, dim));
704 final_centroids
705 .slice_mut(s![..existing_centroids.nrows(), ..])
706 .assign(&existing_centroids);
707 final_centroids
708 .slice_mut(s![existing_centroids.nrows().., ..])
709 .assign(&new_centroids);
710
711 emit_update_progress("index_write", "writing updated centroids");
713 atomic_write_file(¢roids_path, |file| {
714 final_centroids.write_npy(file)?;
715 Ok(())
716 })?;
717
718 let ivf_lengths_path = index_path.join("ivf_lengths.npy");
720 if ivf_lengths_path.exists() {
721 let old_lengths: Array1<i32> = Array1::read_npy(File::open(&ivf_lengths_path)?)?;
722 let mut new_lengths = Array1::<i32>::zeros(new_num_centroids);
723 new_lengths
724 .slice_mut(s![..old_lengths.len()])
725 .assign(&old_lengths);
726 atomic_write_file(&ivf_lengths_path, |file| {
727 new_lengths.write_npy(file)?;
728 Ok(())
729 })?;
730 }
731
732 let meta_path = index_path.join("metadata.json");
734 if meta_path.exists() {
735 let mut meta: serde_json::Value =
736 serde_json::from_reader(BufReader::new(File::open(&meta_path)?))?;
737
738 if let Some(obj) = meta.as_object_mut() {
739 obj.insert("num_partitions".to_string(), new_num_centroids.into());
740 }
741
742 atomic_write_file(&meta_path, |file| {
743 let mut writer = BufWriter::new(file);
744 serde_json::to_writer_pretty(&mut writer, &meta)?;
745 writer.flush()?;
746 Ok(())
747 })?;
748 }
749
750 Ok(k_new)
751}
752
753pub fn update_index(
772 embeddings: &[Array2<f32>],
773 index_path: &str,
774 codec: &ResidualCodec,
775 batch_size: Option<usize>,
776 update_threshold: bool,
777 force_cpu: bool,
778) -> Result<usize> {
779 emit_update_progress("index_write", "writing index chunks");
780 let batch_size = batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
781 let index_dir = Path::new(index_path);
782
783 let metadata_path = index_dir.join("metadata.json");
785 let metadata = Metadata::load_from_path(index_dir)?;
786
787 let num_existing_chunks = metadata.num_chunks;
788 let old_num_documents = metadata.num_documents;
789 let old_total_embeddings = metadata.num_embeddings;
790 let num_centroids = codec.num_centroids();
791 let embedding_dim = codec.embedding_dim();
792 let nbits = metadata.nbits;
793
794 let mut start_chunk_idx = num_existing_chunks;
796 let mut append_to_last = false;
797 let mut current_emb_offset = old_total_embeddings;
798
799 if start_chunk_idx > 0 {
801 let last_idx = start_chunk_idx - 1;
802 let last_meta_path = index_dir.join(format!("{}.metadata.json", last_idx));
803
804 if last_meta_path.exists() {
805 let last_meta: serde_json::Value =
806 serde_json::from_reader(BufReader::new(File::open(&last_meta_path).map_err(
807 |e| Error::IndexLoad(format!("Failed to open chunk metadata: {}", e)),
808 )?))?;
809
810 if let Some(nd) = last_meta.get("num_documents").and_then(|x| x.as_u64()) {
811 if nd < 2000 {
812 start_chunk_idx = last_idx;
813 append_to_last = true;
814
815 if let Some(off) = last_meta.get("embedding_offset").and_then(|x| x.as_u64()) {
816 current_emb_offset = off as usize;
817 } else {
818 let embs_in_last = last_meta
819 .get("num_embeddings")
820 .and_then(|x| x.as_u64())
821 .unwrap_or(0) as usize;
822 current_emb_offset = old_total_embeddings - embs_in_last;
823 }
824 }
825 }
826 }
827 }
828
829 let num_new_documents = embeddings.len();
831 let n_new_chunks = (num_new_documents as f64 / batch_size as f64).ceil() as usize;
832
833 let mut new_codes_accumulated: Vec<Vec<usize>> = Vec::new();
834 let mut new_doclens_accumulated: Vec<i64> = Vec::new();
835 let mut all_residual_norms: Vec<f32> = Vec::new();
836
837 let packed_dim = embedding_dim * nbits / 8;
838
839 for i in 0..n_new_chunks {
840 let global_chunk_idx = start_chunk_idx + i;
841 let chk_offset = i * batch_size;
842 let chk_end = (chk_offset + batch_size).min(num_new_documents);
843 let chunk_docs = &embeddings[chk_offset..chk_end];
844
845 let mut chk_doclens: Vec<i64> = chunk_docs.iter().map(|d| d.nrows() as i64).collect();
847 let total_tokens: usize = chk_doclens.iter().sum::<i64>() as usize;
848
849 let mut batch_embeddings = ndarray::Array2::<f32>::zeros((total_tokens, embedding_dim));
851 let mut offset = 0;
852 for doc in chunk_docs {
853 let n = doc.nrows();
854 batch_embeddings
855 .slice_mut(s![offset..offset + n, ..])
856 .assign(doc);
857 offset += n;
858 }
859
860 let batch_codes = if force_cpu {
863 codec.compress_into_codes_cpu(&batch_embeddings)
864 } else {
865 codec.compress_into_codes(&batch_embeddings)
866 };
867
868 let mut batch_residuals = batch_embeddings;
870 {
871 let centroids = &codec.centroids;
872 batch_residuals
873 .axis_iter_mut(Axis(0))
874 .into_par_iter()
875 .zip(batch_codes.as_slice().unwrap().par_iter())
876 .for_each(|(mut row, &code)| {
877 let centroid = centroids.row(code);
878 row.iter_mut()
879 .zip(centroid.iter())
880 .for_each(|(r, c)| *r -= c);
881 });
882 }
883
884 if update_threshold {
886 for row in batch_residuals.axis_iter(Axis(0)) {
887 let norm = row.dot(&row).sqrt();
888 all_residual_norms.push(norm);
889 }
890 }
891
892 let batch_packed = codec.quantize_residuals(&batch_residuals)?;
894
895 let mut chk_codes_list: Vec<usize> = batch_codes.iter().copied().collect();
897 let mut chk_residuals_list: Vec<u8> = batch_packed.iter().copied().collect();
898
899 let mut code_offset = 0;
901 for &len in &chk_doclens {
902 let len_usize = len as usize;
903 let codes: Vec<usize> = batch_codes
904 .slice(s![code_offset..code_offset + len_usize])
905 .iter()
906 .copied()
907 .collect();
908 new_codes_accumulated.push(codes);
909 new_doclens_accumulated.push(len);
910 code_offset += len_usize;
911 }
912
913 if i == 0 && append_to_last {
915 use ndarray_npy::ReadNpyExt;
916
917 let old_doclens_path = index_dir.join(format!("doclens.{}.json", global_chunk_idx));
918
919 if old_doclens_path.exists() {
920 let old_doclens: Vec<i64> =
921 serde_json::from_reader(BufReader::new(File::open(&old_doclens_path)?))?;
922
923 let old_codes_path = index_dir.join(format!("{}.codes.npy", global_chunk_idx));
924 let old_residuals_path =
925 index_dir.join(format!("{}.residuals.npy", global_chunk_idx));
926
927 let old_codes: Array1<i64> = Array1::read_npy(File::open(&old_codes_path)?)?;
928 let old_residuals: Array2<u8> = Array2::read_npy(File::open(&old_residuals_path)?)?;
929
930 let mut combined_codes: Vec<usize> =
932 old_codes.iter().map(|&x| x as usize).collect();
933 combined_codes.extend(chk_codes_list);
934 chk_codes_list = combined_codes;
935
936 let mut combined_residuals: Vec<u8> = old_residuals.iter().copied().collect();
937 combined_residuals.extend(chk_residuals_list);
938 chk_residuals_list = combined_residuals;
939
940 let mut combined_doclens = old_doclens;
941 combined_doclens.extend(chk_doclens);
942 chk_doclens = combined_doclens;
943 }
944 }
945
946 {
948 use ndarray_npy::WriteNpyExt;
949
950 let codes_arr: Array1<i64> = chk_codes_list.iter().map(|&x| x as i64).collect();
951 let codes_path = index_dir.join(format!("{}.codes.npy", global_chunk_idx));
952 atomic_write_file(&codes_path, |file| {
953 codes_arr.write_npy(file)?;
954 Ok(())
955 })?;
956
957 let num_tokens = chk_codes_list.len();
958 let residuals_arr =
959 Array2::from_shape_vec((num_tokens, packed_dim), chk_residuals_list)
960 .map_err(|e| Error::Shape(format!("Failed to reshape residuals: {}", e)))?;
961 let residuals_path = index_dir.join(format!("{}.residuals.npy", global_chunk_idx));
962 atomic_write_file(&residuals_path, |file| {
963 residuals_arr.write_npy(file)?;
964 Ok(())
965 })?;
966 }
967
968 let doclens_path = index_dir.join(format!("doclens.{}.json", global_chunk_idx));
970 atomic_write_file(&doclens_path, |file| {
971 let mut writer = BufWriter::new(file);
972 serde_json::to_writer(&mut writer, &chk_doclens)?;
973 writer.flush()?;
974 Ok(())
975 })?;
976
977 let chk_meta = serde_json::json!({
979 "num_documents": chk_doclens.len(),
980 "num_embeddings": chk_codes_list.len(),
981 "embedding_offset": current_emb_offset,
982 });
983 current_emb_offset += chk_codes_list.len();
984
985 let meta_path = index_dir.join(format!("{}.metadata.json", global_chunk_idx));
986 atomic_write_file(&meta_path, |file| {
987 let mut writer = BufWriter::new(file);
988 serde_json::to_writer_pretty(&mut writer, &chk_meta)?;
989 writer.flush()?;
990 Ok(())
991 })?;
992 }
993
994 if update_threshold && !all_residual_norms.is_empty() {
996 let norms = Array1::from_vec(all_residual_norms);
997 update_cluster_threshold(index_dir, &norms, old_total_embeddings)?;
998 }
999
1000 let mut partition_pids_map: HashMap<usize, Vec<i64>> = HashMap::new();
1002
1003 for (pid_counter, doc_codes) in (old_num_documents as i64..).zip(new_codes_accumulated.iter()) {
1004 for &code in doc_codes {
1005 partition_pids_map
1006 .entry(code)
1007 .or_default()
1008 .push(pid_counter);
1009 }
1010 }
1011
1012 {
1014 use ndarray_npy::{ReadNpyExt, WriteNpyExt};
1015
1016 let ivf_path = index_dir.join("ivf.npy");
1017 let ivf_lengths_path = index_dir.join("ivf_lengths.npy");
1018
1019 let old_ivf: Array1<i64> = if ivf_path.exists() {
1020 Array1::read_npy(File::open(&ivf_path)?)?
1021 } else {
1022 Array1::zeros(0)
1023 };
1024
1025 let old_ivf_lengths: Array1<i32> = if ivf_lengths_path.exists() {
1026 Array1::read_npy(File::open(&ivf_lengths_path)?)?
1027 } else {
1028 Array1::zeros(num_centroids)
1029 };
1030
1031 let mut old_offsets = vec![0i64];
1033 for &len in old_ivf_lengths.iter() {
1034 old_offsets.push(old_offsets.last().unwrap() + len as i64);
1035 }
1036
1037 let mut new_ivf_data: Vec<i64> = Vec::new();
1039 let mut new_ivf_lengths: Vec<i32> = Vec::with_capacity(num_centroids);
1040
1041 for centroid_id in 0..num_centroids {
1042 let old_start = old_offsets[centroid_id] as usize;
1044 let old_len = if centroid_id < old_ivf_lengths.len() {
1045 old_ivf_lengths[centroid_id] as usize
1046 } else {
1047 0
1048 };
1049
1050 let mut pids: Vec<i64> = if old_len > 0 && old_start + old_len <= old_ivf.len() {
1051 old_ivf.slice(s![old_start..old_start + old_len]).to_vec()
1052 } else {
1053 Vec::new()
1054 };
1055
1056 if let Some(new_pids) = partition_pids_map.get(¢roid_id) {
1058 pids.extend(new_pids);
1059 }
1060
1061 pids.sort_unstable();
1063 pids.dedup();
1064
1065 new_ivf_lengths.push(pids.len() as i32);
1066 new_ivf_data.extend(pids);
1067 }
1068
1069 let new_ivf = Array1::from_vec(new_ivf_data);
1071 atomic_write_file(&ivf_path, |file| {
1072 new_ivf.write_npy(file)?;
1073 Ok(())
1074 })?;
1075
1076 let new_lengths = Array1::from_vec(new_ivf_lengths);
1077 atomic_write_file(&ivf_lengths_path, |file| {
1078 new_lengths.write_npy(file)?;
1079 Ok(())
1080 })?;
1081 }
1082
1083 let new_total_chunks = start_chunk_idx + n_new_chunks;
1085 let new_tokens_count: i64 = new_doclens_accumulated.iter().sum();
1086 let num_embeddings = old_total_embeddings + new_tokens_count as usize;
1087 let total_num_documents = old_num_documents + num_new_documents;
1088
1089 let new_avg_doclen = if total_num_documents > 0 {
1090 let old_sum = metadata.avg_doclen * old_num_documents as f64;
1091 (old_sum + new_tokens_count as f64) / total_num_documents as f64
1092 } else {
1093 0.0
1094 };
1095
1096 let new_metadata = Metadata {
1097 num_chunks: new_total_chunks,
1098 nbits,
1099 num_partitions: num_centroids,
1100 num_embeddings,
1101 avg_doclen: new_avg_doclen,
1102 num_documents: total_num_documents,
1103 embedding_dim,
1104 next_plaid_compatible: true,
1105 };
1106
1107 emit_update_progress("metadata_write", "writing index metadata");
1108 atomic_write_file(&metadata_path, |file| {
1109 let mut writer = BufWriter::new(file);
1110 serde_json::to_writer_pretty(&mut writer, &new_metadata)?;
1111 writer.flush()?;
1112 Ok(())
1113 })?;
1114
1115 crate::mmap::clear_merged_files(index_dir)?;
1118
1119 Ok(num_new_documents)
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use std::sync::{
1126 atomic::{AtomicUsize, Ordering},
1127 Arc,
1128 };
1129
1130 #[test]
1131 fn test_update_config_default() {
1132 let config = UpdateConfig::default();
1133 assert_eq!(config.batch_size, 50_000);
1134 assert_eq!(config.buffer_size, 100);
1135 assert_eq!(
1136 config.start_from_scratch,
1137 crate::default_start_from_scratch()
1138 );
1139 }
1140
1141 #[test]
1142 fn test_update_progress_clears_after_panic() {
1143 let stale_count = Arc::new(AtomicUsize::new(0));
1144 let stale_count_for_callback = Arc::clone(&stale_count);
1145 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1146 with_update_progress(
1147 move |_, _| {
1148 stale_count_for_callback.fetch_add(1, Ordering::SeqCst);
1149 },
1150 || panic!("forced progress panic"),
1151 );
1152 }));
1153
1154 assert!(result.is_err());
1155
1156 emit_update_progress("after_panic", "must not hit stale callback");
1157 assert_eq!(stale_count.load(Ordering::SeqCst), 0);
1158
1159 let fresh_count = Arc::new(AtomicUsize::new(0));
1160 let fresh_count_for_callback = Arc::clone(&fresh_count);
1161 with_update_progress(
1162 move |_, _| {
1163 fresh_count_for_callback.fetch_add(1, Ordering::SeqCst);
1164 },
1165 || emit_update_progress("fresh", "fresh callback"),
1166 );
1167 assert_eq!(fresh_count.load(Ordering::SeqCst), 1);
1168 }
1169
1170 #[test]
1171 fn test_find_outliers() {
1172 let centroids = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 1.0, 1.0]).unwrap();
1174
1175 let embeddings =
1177 Array2::from_shape_vec((3, 2), vec![0.1, 0.1, 0.9, 0.9, 5.0, 5.0]).unwrap();
1178
1179 let outliers = find_outliers(&embeddings, ¢roids, 1.0);
1181
1182 assert_eq!(outliers.len(), 1);
1184 assert_eq!(outliers[0], 2);
1185 }
1186
1187 #[test]
1188 fn test_buffer_roundtrip() {
1189 use tempfile::TempDir;
1190
1191 let dir = TempDir::new().unwrap();
1192
1193 let embeddings = vec![
1195 Array2::from_shape_vec((3, 4), (0..12).map(|x| x as f32).collect()).unwrap(),
1196 Array2::from_shape_vec((2, 4), (12..20).map(|x| x as f32).collect()).unwrap(),
1197 Array2::from_shape_vec((5, 4), (20..40).map(|x| x as f32).collect()).unwrap(),
1198 ];
1199
1200 save_buffer(dir.path(), &embeddings).unwrap();
1202
1203 let loaded = load_buffer(dir.path()).unwrap();
1205
1206 assert_eq!(loaded.len(), 3, "Should have 3 documents, not 1");
1207 assert_eq!(loaded[0].nrows(), 3, "First doc should have 3 rows");
1208 assert_eq!(loaded[1].nrows(), 2, "Second doc should have 2 rows");
1209 assert_eq!(loaded[2].nrows(), 5, "Third doc should have 5 rows");
1210
1211 assert_eq!(loaded[0], embeddings[0]);
1213 assert_eq!(loaded[1], embeddings[1]);
1214 assert_eq!(loaded[2], embeddings[2]);
1215 }
1216
1217 #[test]
1218 fn test_buffer_info_matches_buffer_len() {
1219 use tempfile::TempDir;
1220
1221 let dir = TempDir::new().unwrap();
1222
1223 let embeddings: Vec<Array2<f32>> = (0..5)
1225 .map(|i| {
1226 let rows = i + 2; Array2::from_shape_fn((rows, 4), |(r, c)| (r * 4 + c) as f32)
1228 })
1229 .collect();
1230
1231 save_buffer(dir.path(), &embeddings).unwrap();
1232
1233 let info_count = load_buffer_info(dir.path()).unwrap();
1235 let loaded = load_buffer(dir.path()).unwrap();
1236
1237 assert_eq!(info_count, 5, "buffer_info should report 5 docs");
1238 assert_eq!(
1239 loaded.len(),
1240 5,
1241 "load_buffer should return 5 docs to match buffer_info"
1242 );
1243 }
1244}