1use std::collections::BTreeMap;
4use std::fs::{self, File};
5use std::io::{BufReader, BufWriter, Write};
6use std::path::Path;
7
8use ndarray::{s, Array1, Array2, Axis};
9use serde::{Deserialize, Serialize};
10
11use crate::codec::ResidualCodec;
12use crate::error::{Error, Result};
13use crate::kmeans::{compute_kmeans, ComputeKmeansConfig};
14use crate::utils::{atomic_write_file, quantile, quantiles};
15
16fn compress_and_residuals_cpu(
18 embeddings: &Array2<f32>,
19 codec: &ResidualCodec,
20) -> (Array1<usize>, Array2<f32>) {
21 use rayon::prelude::*;
22
23 let codes = codec.compress_into_codes_cpu(embeddings);
25 let mut residuals = embeddings.clone();
26
27 let centroids = &codec.centroids;
28 residuals
29 .axis_iter_mut(Axis(0))
30 .into_par_iter()
31 .zip(codes.as_slice().unwrap().par_iter())
32 .for_each(|(mut row, &code)| {
33 let centroid = centroids.row(code);
34 row.iter_mut()
35 .zip(centroid.iter())
36 .for_each(|(r, c)| *r -= c);
37 });
38
39 (codes, residuals)
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct IndexConfig {
45 pub nbits: usize,
47 pub batch_size: usize,
49 pub seed: Option<u64>,
51 #[serde(default = "default_kmeans_niters")]
53 pub kmeans_niters: usize,
54 #[serde(default = "default_max_points_per_centroid")]
56 pub max_points_per_centroid: usize,
57 #[serde(default)]
60 pub n_samples_kmeans: Option<usize>,
61 #[serde(default = "default_start_from_scratch")]
65 pub start_from_scratch: usize,
66 #[serde(default)]
69 pub force_cpu: bool,
70 #[serde(default)]
73 pub fts_tokenizer: crate::text_search::FtsTokenizer,
74}
75
76fn default_start_from_scratch() -> usize {
77 crate::default_start_from_scratch()
78}
79
80fn default_kmeans_niters() -> usize {
81 4
82}
83
84fn default_max_points_per_centroid() -> usize {
85 256
86}
87
88impl Default for IndexConfig {
89 fn default() -> Self {
90 Self {
91 nbits: 4,
92 batch_size: 50_000,
93 seed: Some(42),
94 kmeans_niters: 4,
95 max_points_per_centroid: 256,
96 n_samples_kmeans: None,
97 start_from_scratch: crate::default_start_from_scratch(),
98 force_cpu: false,
99 fts_tokenizer: crate::text_search::FtsTokenizer::default(),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Metadata {
107 pub num_chunks: usize,
109 pub nbits: usize,
111 pub num_partitions: usize,
113 pub num_embeddings: usize,
115 pub avg_doclen: f64,
117 #[serde(default)]
119 pub num_documents: usize,
120 #[serde(default)]
122 pub embedding_dim: usize,
123 #[serde(default)]
126 pub next_plaid_compatible: bool,
127}
128
129impl Metadata {
130 pub fn load_from_path(index_path: &Path) -> Result<Self> {
132 let metadata_path = index_path.join("metadata.json");
133 let mut metadata: Metadata = serde_json::from_reader(BufReader::new(
134 File::open(&metadata_path)
135 .map_err(|e| Error::IndexLoad(format!("Failed to open metadata: {}", e)))?,
136 ))?;
137
138 if metadata.num_documents == 0 {
140 let mut total_docs = 0usize;
141 for chunk_idx in 0..metadata.num_chunks {
142 let doclens_path = index_path.join(format!("doclens.{}.json", chunk_idx));
143 if let Ok(file) = File::open(&doclens_path) {
144 if let Ok(chunk_doclens) =
145 serde_json::from_reader::<_, Vec<i64>>(BufReader::new(file))
146 {
147 total_docs += chunk_doclens.len();
148 }
149 }
150 }
151 metadata.num_documents = total_docs;
152 }
153
154 Ok(metadata)
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ChunkMetadata {
161 pub num_documents: usize,
162 pub num_embeddings: usize,
163 #[serde(default)]
164 pub embedding_offset: usize,
165}
166
167#[derive(Debug, Clone)]
168pub struct EncodedIndexChunk {
169 pub codes: Array1<i64>,
170 pub residuals: Array2<u8>,
171 pub doclens: Vec<i64>,
172}
173
174pub struct PreparedCodecArtifacts {
175 pub codec: ResidualCodec,
176 pub cluster_threshold: f32,
177 pub bucket_cutoffs: Array1<f32>,
178 pub bucket_weights: Array1<f32>,
179 pub avg_res_per_dim: Array1<f32>,
180}
181
182pub fn prepare_codec_artifacts(
183 embeddings: &[Array2<f32>],
184 centroids: Array2<f32>,
185 config: &IndexConfig,
186) -> Result<PreparedCodecArtifacts> {
187 let embedding_dim = centroids.ncols();
188 let total_embeddings: usize = embeddings.iter().map(|e| e.nrows()).sum();
189 let num_documents = embeddings.len();
190
191 if num_documents == 0 {
192 return Err(Error::IndexCreation("No documents provided".into()));
193 }
194
195 let sample_count = ((16.0 * (120.0 * num_documents as f64).sqrt()) as usize)
196 .min(num_documents)
197 .max(1);
198
199 let mut rng = if let Some(seed) = config.seed {
200 use rand::SeedableRng;
201 rand_chacha::ChaCha8Rng::seed_from_u64(seed)
202 } else {
203 use rand::SeedableRng;
204 rand_chacha::ChaCha8Rng::from_entropy()
205 };
206
207 use rand::seq::SliceRandom;
208 let mut indices: Vec<usize> = (0..num_documents).collect();
209 indices.shuffle(&mut rng);
210 let sample_indices: Vec<usize> = indices.into_iter().take(sample_count).collect();
211
212 let heldout_size = (0.05 * total_embeddings as f64).min(50000.0) as usize;
213 let mut heldout_embeddings: Vec<f32> = Vec::with_capacity(heldout_size * embedding_dim);
214 let mut collected = 0;
215
216 for &idx in sample_indices.iter().rev() {
217 if collected >= heldout_size {
218 break;
219 }
220 let emb = &embeddings[idx];
221 let take = (heldout_size - collected).min(emb.nrows());
222 for row in emb.axis_iter(Axis(0)).take(take) {
223 heldout_embeddings.extend(row.iter());
224 }
225 collected += take;
226 }
227
228 let heldout = Array2::from_shape_vec((collected, embedding_dim), heldout_embeddings)
229 .map_err(|e| Error::IndexCreation(format!("Failed to create heldout array: {}", e)))?;
230
231 let avg_residual = Array1::zeros(embedding_dim);
232 let initial_codec =
233 ResidualCodec::new(config.nbits, centroids.clone(), avg_residual, None, None)?;
234
235 let heldout_codes = if config.force_cpu {
236 initial_codec.compress_into_codes_cpu(&heldout)
237 } else {
238 initial_codec.compress_into_codes(&heldout)
239 };
240
241 let mut residuals = heldout.clone();
242 for i in 0..heldout.nrows() {
243 let centroid = initial_codec.centroids.row(heldout_codes[i]);
244 for j in 0..embedding_dim {
245 residuals[[i, j]] -= centroid[j];
246 }
247 }
248
249 let distances: Array1<f32> = residuals
250 .axis_iter(Axis(0))
251 .map(|row| row.dot(&row).sqrt())
252 .collect();
253 let cluster_threshold = quantile(&distances, 0.75);
254
255 let avg_res_per_dim: Array1<f32> = residuals
256 .axis_iter(Axis(1))
257 .map(|col| col.iter().map(|x| x.abs()).sum::<f32>() / col.len() as f32)
258 .collect();
259
260 let n_options = 1 << config.nbits;
261 let quantile_values: Vec<f64> = (1..n_options)
262 .map(|i| i as f64 / n_options as f64)
263 .collect();
264 let weight_quantile_values: Vec<f64> = (0..n_options)
265 .map(|i| (i as f64 + 0.5) / n_options as f64)
266 .collect();
267
268 let flat_residuals: Array1<f32> = residuals.iter().copied().collect();
269 let bucket_cutoffs = Array1::from_vec(quantiles(&flat_residuals, &quantile_values));
270 let bucket_weights = Array1::from_vec(quantiles(&flat_residuals, &weight_quantile_values));
271
272 let codec = ResidualCodec::new(
273 config.nbits,
274 centroids,
275 avg_res_per_dim.clone(),
276 Some(bucket_cutoffs.clone()),
277 Some(bucket_weights.clone()),
278 )?;
279
280 Ok(PreparedCodecArtifacts {
281 codec,
282 cluster_threshold,
283 bucket_cutoffs,
284 bucket_weights,
285 avg_res_per_dim,
286 })
287}
288
289pub fn encode_index_chunk(
290 embeddings: &[Array2<f32>],
291 codec: &ResidualCodec,
292 force_cpu: bool,
293) -> Result<EncodedIndexChunk> {
294 let embedding_dim = codec.embedding_dim();
295 let packed_dim = embedding_dim * codec.nbits / 8;
296 let doclens: Vec<i64> = embeddings.iter().map(|d| d.nrows() as i64).collect();
297 let total_tokens: usize = doclens.iter().sum::<i64>() as usize;
298
299 #[cfg(not(feature = "_cuda"))]
300 let _ = force_cpu;
301
302 let mut batch_embeddings = Array2::<f32>::zeros((total_tokens, embedding_dim));
303 let mut offset = 0;
304 for doc in embeddings {
305 let n = doc.nrows();
306 batch_embeddings
307 .slice_mut(s![offset..offset + n, ..])
308 .assign(doc);
309 offset += n;
310 }
311
312 let (batch_codes, batch_residuals) = {
313 #[cfg(feature = "_cuda")]
314 {
315 let force_gpu = crate::is_force_gpu();
316 if !force_cpu {
317 if let Some(ctx) = crate::cuda::get_global_context() {
318 match crate::cuda::compress_and_residuals_cuda_batched(
319 &ctx,
320 &batch_embeddings.view(),
321 &codec.centroids_view(),
322 None,
323 ) {
324 Ok(result) => result,
325 Err(e) => {
326 if force_gpu {
327 panic!(
328 "FORCE_GPU is set but CUDA compress_and_residuals failed: {}",
329 e
330 );
331 }
332 println!(
333 "[next-plaid] CUDA compress_and_residuals failed: {}, falling back to CPU",
334 e
335 );
336 compress_and_residuals_cpu(&batch_embeddings, codec)
337 }
338 }
339 } else if force_gpu {
340 panic!("FORCE_GPU is set but CUDA context is unavailable");
341 } else {
342 compress_and_residuals_cpu(&batch_embeddings, codec)
343 }
344 } else {
345 compress_and_residuals_cpu(&batch_embeddings, codec)
346 }
347 }
348 #[cfg(not(feature = "_cuda"))]
349 {
350 compress_and_residuals_cpu(&batch_embeddings, codec)
351 }
352 };
353
354 let batch_packed = codec.quantize_residuals(&batch_residuals)?;
355 let (raw_residuals, residuals_offset) = batch_packed.into_raw_vec_and_offset();
356 if residuals_offset != Some(0) {
357 return Err(Error::Shape(format!(
358 "Unexpected residual packing offset: {:?}",
359 residuals_offset
360 )));
361 }
362 let residuals = Array2::from_shape_vec((batch_codes.len(), packed_dim), raw_residuals)
363 .map_err(|e| Error::Shape(format!("Failed to reshape residuals: {}", e)))?;
364 let codes: Array1<i64> = batch_codes.iter().map(|&x| x as i64).collect();
365
366 Ok(EncodedIndexChunk {
367 codes,
368 residuals,
369 doclens,
370 })
371}
372
373pub fn write_index_from_encoded_chunks(
374 chunks: &[EncodedIndexChunk],
375 codec_artifacts: &PreparedCodecArtifacts,
376 index_path: &str,
377 config: &IndexConfig,
378) -> Result<Metadata> {
379 use ndarray_npy::WriteNpyExt;
380
381 let index_dir = Path::new(index_path);
382 fs::create_dir_all(index_dir)?;
383
384 let embedding_dim = codec_artifacts.codec.embedding_dim();
385 let num_centroids = codec_artifacts.codec.num_centroids();
386 let total_embeddings: usize = chunks.iter().map(|c| c.codes.len()).sum();
387 let num_documents: usize = chunks.iter().map(|c| c.doclens.len()).sum();
388 let avg_doclen = if num_documents > 0 {
389 total_embeddings as f64 / num_documents as f64
390 } else {
391 0.0
392 };
393
394 let centroids_path = index_dir.join("centroids.npy");
395 atomic_write_file(¢roids_path, |file| {
396 codec_artifacts
397 .codec
398 .centroids_view()
399 .to_owned()
400 .write_npy(file)?;
401 Ok(())
402 })?;
403 atomic_write_file(&index_dir.join("bucket_cutoffs.npy"), |file| {
404 codec_artifacts.bucket_cutoffs.write_npy(file)?;
405 Ok(())
406 })?;
407 atomic_write_file(&index_dir.join("bucket_weights.npy"), |file| {
408 codec_artifacts.bucket_weights.write_npy(file)?;
409 Ok(())
410 })?;
411 atomic_write_file(&index_dir.join("avg_residual.npy"), |file| {
412 codec_artifacts.avg_res_per_dim.write_npy(file)?;
413 Ok(())
414 })?;
415 atomic_write_file(&index_dir.join("cluster_threshold.npy"), |file| {
416 Array1::from_vec(vec![codec_artifacts.cluster_threshold]).write_npy(file)?;
417 Ok(())
418 })?;
419
420 let n_chunks = chunks.len();
421 let plan = serde_json::json!({
422 "nbits": config.nbits,
423 "num_chunks": n_chunks,
424 });
425 atomic_write_file(&index_dir.join("plan.json"), |file| {
426 writeln!(file, "{}", serde_json::to_string_pretty(&plan)?)?;
427 Ok(())
428 })?;
429
430 let mut all_codes: Vec<usize> = Vec::with_capacity(total_embeddings);
431 let mut doc_lengths: Vec<i64> = Vec::with_capacity(num_documents);
432 let mut current_offset = 0usize;
433
434 for (chunk_idx, chunk) in chunks.iter().enumerate() {
435 let chunk_meta = ChunkMetadata {
436 num_documents: chunk.doclens.len(),
437 num_embeddings: chunk.codes.len(),
438 embedding_offset: current_offset,
439 };
440 current_offset += chunk.codes.len();
441
442 atomic_write_file(
443 &index_dir.join(format!("{}.metadata.json", chunk_idx)),
444 |file| {
445 let mut writer = BufWriter::new(file);
446 serde_json::to_writer_pretty(&mut writer, &chunk_meta)?;
447 writer.flush()?;
448 Ok(())
449 },
450 )?;
451 atomic_write_file(
452 &index_dir.join(format!("doclens.{}.json", chunk_idx)),
453 |file| {
454 let mut writer = BufWriter::new(file);
455 serde_json::to_writer(&mut writer, &chunk.doclens)?;
456 writer.flush()?;
457 Ok(())
458 },
459 )?;
460 atomic_write_file(
461 &index_dir.join(format!("{}.codes.npy", chunk_idx)),
462 |file| {
463 chunk.codes.write_npy(file)?;
464 Ok(())
465 },
466 )?;
467 atomic_write_file(
468 &index_dir.join(format!("{}.residuals.npy", chunk_idx)),
469 |file| {
470 chunk.residuals.write_npy(file)?;
471 Ok(())
472 },
473 )?;
474
475 doc_lengths.extend_from_slice(&chunk.doclens);
476 all_codes.extend(chunk.codes.iter().map(|&x| x as usize));
477 }
478
479 let mut code_to_docs: BTreeMap<usize, Vec<i64>> = BTreeMap::new();
480 let mut emb_idx = 0;
481 for (doc_id, &len) in doc_lengths.iter().enumerate() {
482 for _ in 0..len {
483 let code = all_codes[emb_idx];
484 code_to_docs.entry(code).or_default().push(doc_id as i64);
485 emb_idx += 1;
486 }
487 }
488
489 let mut ivf_data: Vec<i64> = Vec::new();
490 let mut ivf_lengths: Vec<i32> = vec![0; num_centroids];
491 for (centroid_id, ivf_len) in ivf_lengths.iter_mut().enumerate() {
492 if let Some(docs) = code_to_docs.get(¢roid_id) {
493 let mut unique_docs = docs.clone();
494 unique_docs.sort_unstable();
495 unique_docs.dedup();
496 *ivf_len = unique_docs.len() as i32;
497 ivf_data.extend(unique_docs);
498 }
499 }
500
501 atomic_write_file(&index_dir.join("ivf.npy"), |file| {
502 Array1::from_vec(ivf_data).write_npy(file)?;
503 Ok(())
504 })?;
505 atomic_write_file(&index_dir.join("ivf_lengths.npy"), |file| {
506 Array1::from_vec(ivf_lengths).write_npy(file)?;
507 Ok(())
508 })?;
509
510 let metadata = Metadata {
511 num_chunks: n_chunks,
512 nbits: config.nbits,
513 num_partitions: num_centroids,
514 num_embeddings: total_embeddings,
515 avg_doclen,
516 num_documents,
517 embedding_dim,
518 next_plaid_compatible: true,
519 };
520 atomic_write_file(&index_dir.join("metadata.json"), |file| {
521 let mut writer = BufWriter::new(file);
522 serde_json::to_writer_pretty(&mut writer, &metadata)?;
523 writer.flush()?;
524 Ok(())
525 })?;
526
527 Ok(metadata)
528}
529
530pub fn create_index_files(
552 embeddings: &[Array2<f32>],
553 centroids: Array2<f32>,
554 index_path: &str,
555 config: &IndexConfig,
556) -> Result<Metadata> {
557 let index_dir = Path::new(index_path);
558 fs::create_dir_all(index_dir)?;
559
560 let num_documents = embeddings.len();
561 let embedding_dim = centroids.ncols();
562 let num_centroids = centroids.nrows();
563
564 if num_documents == 0 {
565 return Err(Error::IndexCreation("No documents provided".into()));
566 }
567
568 let total_embeddings: usize = embeddings.iter().map(|e| e.nrows()).sum();
570 let avg_doclen = total_embeddings as f64 / num_documents as f64;
571
572 let sample_count = ((16.0 * (120.0 * num_documents as f64).sqrt()) as usize)
574 .min(num_documents)
575 .max(1);
576
577 let mut rng = if let Some(seed) = config.seed {
578 use rand::SeedableRng;
579 rand_chacha::ChaCha8Rng::seed_from_u64(seed)
580 } else {
581 use rand::SeedableRng;
582 rand_chacha::ChaCha8Rng::from_entropy()
583 };
584
585 use rand::seq::SliceRandom;
586 let mut indices: Vec<usize> = (0..num_documents).collect();
587 indices.shuffle(&mut rng);
588 let sample_indices: Vec<usize> = indices.into_iter().take(sample_count).collect();
589
590 let heldout_size = (0.05 * total_embeddings as f64).min(50000.0) as usize;
592 let mut heldout_embeddings: Vec<f32> = Vec::with_capacity(heldout_size * embedding_dim);
593 let mut collected = 0;
594
595 for &idx in sample_indices.iter().rev() {
596 if collected >= heldout_size {
597 break;
598 }
599 let emb = &embeddings[idx];
600 let take = (heldout_size - collected).min(emb.nrows());
601 for row in emb.axis_iter(Axis(0)).take(take) {
602 heldout_embeddings.extend(row.iter());
603 }
604 collected += take;
605 }
606
607 let heldout = Array2::from_shape_vec((collected, embedding_dim), heldout_embeddings)
608 .map_err(|e| Error::IndexCreation(format!("Failed to create heldout array: {}", e)))?;
609
610 let avg_residual = Array1::zeros(embedding_dim);
612 let initial_codec =
613 ResidualCodec::new(config.nbits, centroids.clone(), avg_residual, None, None)?;
614
615 let heldout_codes = if config.force_cpu {
618 initial_codec.compress_into_codes_cpu(&heldout)
619 } else {
620 initial_codec.compress_into_codes(&heldout)
621 };
622
623 let mut residuals = heldout.clone();
625 for i in 0..heldout.nrows() {
626 let centroid = initial_codec.centroids.row(heldout_codes[i]);
627 for j in 0..embedding_dim {
628 residuals[[i, j]] -= centroid[j];
629 }
630 }
631
632 let distances: Array1<f32> = residuals
634 .axis_iter(Axis(0))
635 .map(|row| row.dot(&row).sqrt())
636 .collect();
637 #[allow(unused_variables)]
638 let cluster_threshold = quantile(&distances, 0.75);
639
640 let avg_res_per_dim: Array1<f32> = residuals
642 .axis_iter(Axis(1))
643 .map(|col| col.iter().map(|x| x.abs()).sum::<f32>() / col.len() as f32)
644 .collect();
645
646 let n_options = 1 << config.nbits;
648 let quantile_values: Vec<f64> = (1..n_options)
649 .map(|i| i as f64 / n_options as f64)
650 .collect();
651 let weight_quantile_values: Vec<f64> = (0..n_options)
652 .map(|i| (i as f64 + 0.5) / n_options as f64)
653 .collect();
654
655 let flat_residuals: Array1<f32> = residuals.iter().copied().collect();
657 let bucket_cutoffs = Array1::from_vec(quantiles(&flat_residuals, &quantile_values));
658 let bucket_weights = Array1::from_vec(quantiles(&flat_residuals, &weight_quantile_values));
659
660 let codec = ResidualCodec::new(
661 config.nbits,
662 centroids.clone(),
663 avg_res_per_dim.clone(),
664 Some(bucket_cutoffs.clone()),
665 Some(bucket_weights.clone()),
666 )?;
667
668 use ndarray_npy::WriteNpyExt;
670
671 let centroids_path = index_dir.join("centroids.npy");
672 atomic_write_file(¢roids_path, |file| {
673 codec.centroids_view().to_owned().write_npy(file)?;
674 Ok(())
675 })?;
676
677 let cutoffs_path = index_dir.join("bucket_cutoffs.npy");
678 atomic_write_file(&cutoffs_path, |file| {
679 bucket_cutoffs.write_npy(file)?;
680 Ok(())
681 })?;
682
683 let weights_path = index_dir.join("bucket_weights.npy");
684 atomic_write_file(&weights_path, |file| {
685 bucket_weights.write_npy(file)?;
686 Ok(())
687 })?;
688
689 let avg_res_path = index_dir.join("avg_residual.npy");
690 atomic_write_file(&avg_res_path, |file| {
691 avg_res_per_dim.write_npy(file)?;
692 Ok(())
693 })?;
694
695 let threshold_path = index_dir.join("cluster_threshold.npy");
696 atomic_write_file(&threshold_path, |file| {
697 Array1::from_vec(vec![cluster_threshold]).write_npy(file)?;
698 Ok(())
699 })?;
700
701 let n_chunks = (num_documents as f64 / config.batch_size as f64).ceil() as usize;
703
704 let plan_path = index_dir.join("plan.json");
706 let plan = serde_json::json!({
707 "nbits": config.nbits,
708 "num_chunks": n_chunks,
709 });
710 atomic_write_file(&plan_path, |file| {
711 writeln!(file, "{}", serde_json::to_string_pretty(&plan)?)?;
712 Ok(())
713 })?;
714
715 let mut all_codes: Vec<usize> = Vec::with_capacity(total_embeddings);
716 let mut doc_lengths: Vec<i64> = Vec::with_capacity(num_documents);
717
718 for chunk_idx in 0..n_chunks {
719 let start = chunk_idx * config.batch_size;
720 let end = (start + config.batch_size).min(num_documents);
721 let chunk_docs = &embeddings[start..end];
722
723 let chunk_doclens: Vec<i64> = chunk_docs.iter().map(|d| d.nrows() as i64).collect();
725 let total_tokens: usize = chunk_doclens.iter().sum::<i64>() as usize;
726
727 let mut batch_embeddings = Array2::<f32>::zeros((total_tokens, embedding_dim));
729 let mut offset = 0;
730 for doc in chunk_docs {
731 let n = doc.nrows();
732 batch_embeddings
733 .slice_mut(s![offset..offset + n, ..])
734 .assign(doc);
735 offset += n;
736 }
737
738 let (batch_codes, batch_residuals) = {
741 #[cfg(feature = "_cuda")]
742 {
743 let force_gpu = crate::is_force_gpu();
744 if !config.force_cpu {
745 if let Some(ctx) = crate::cuda::get_global_context() {
746 match crate::cuda::compress_and_residuals_cuda_batched(
747 &ctx,
748 &batch_embeddings.view(),
749 &codec.centroids_view(),
750 None,
751 ) {
752 Ok(result) => result,
753 Err(e) => {
754 if force_gpu {
755 panic!("FORCE_GPU is set but CUDA compress_and_residuals failed: {}", e);
756 }
757 eprintln!(
758 "[next-plaid] CUDA compress_and_residuals failed: {}, falling back to CPU",
759 e
760 );
761 compress_and_residuals_cpu(&batch_embeddings, &codec)
762 }
763 }
764 } else if force_gpu {
765 panic!("FORCE_GPU is set but CUDA context is unavailable");
766 } else {
767 compress_and_residuals_cpu(&batch_embeddings, &codec)
768 }
769 } else {
770 compress_and_residuals_cpu(&batch_embeddings, &codec)
771 }
772 }
773 #[cfg(not(feature = "_cuda"))]
774 {
775 compress_and_residuals_cpu(&batch_embeddings, &codec)
776 }
777 };
778
779 let batch_packed = codec.quantize_residuals(&batch_residuals)?;
781
782 for &len in &chunk_doclens {
784 doc_lengths.push(len);
785 }
786 all_codes.extend(batch_codes.iter().copied());
787
788 let chunk_meta = ChunkMetadata {
790 num_documents: end - start,
791 num_embeddings: batch_codes.len(),
792 embedding_offset: 0, };
794
795 let chunk_meta_path = index_dir.join(format!("{}.metadata.json", chunk_idx));
796 atomic_write_file(&chunk_meta_path, |file| {
797 let mut writer = BufWriter::new(file);
798 serde_json::to_writer_pretty(&mut writer, &chunk_meta)?;
799 writer.flush()?;
800 Ok(())
801 })?;
802
803 let doclens_path = index_dir.join(format!("doclens.{}.json", chunk_idx));
805 atomic_write_file(&doclens_path, |file| {
806 let mut writer = BufWriter::new(file);
807 serde_json::to_writer(&mut writer, &chunk_doclens)?;
808 writer.flush()?;
809 Ok(())
810 })?;
811
812 let chunk_codes_arr: Array1<i64> = batch_codes.iter().map(|&x| x as i64).collect();
814 let codes_path = index_dir.join(format!("{}.codes.npy", chunk_idx));
815 atomic_write_file(&codes_path, |file| {
816 chunk_codes_arr.write_npy(file)?;
817 Ok(())
818 })?;
819
820 let residuals_path = index_dir.join(format!("{}.residuals.npy", chunk_idx));
822 atomic_write_file(&residuals_path, |file| {
823 batch_packed.write_npy(file)?;
824 Ok(())
825 })?;
826 }
827
828 let mut current_offset = 0usize;
830 for chunk_idx in 0..n_chunks {
831 let chunk_meta_path = index_dir.join(format!("{}.metadata.json", chunk_idx));
832 let mut meta: serde_json::Value =
833 serde_json::from_reader(BufReader::new(File::open(&chunk_meta_path)?))?;
834
835 if let Some(obj) = meta.as_object_mut() {
836 obj.insert("embedding_offset".to_string(), current_offset.into());
837 let num_emb = obj["num_embeddings"].as_u64().unwrap_or(0) as usize;
838 current_offset += num_emb;
839 }
840
841 atomic_write_file(&chunk_meta_path, |file| {
842 let mut writer = BufWriter::new(file);
843 serde_json::to_writer_pretty(&mut writer, &meta)?;
844 writer.flush()?;
845 Ok(())
846 })?;
847 }
848
849 let mut code_to_docs: BTreeMap<usize, Vec<i64>> = BTreeMap::new();
851 let mut emb_idx = 0;
852
853 for (doc_id, &len) in doc_lengths.iter().enumerate() {
854 for _ in 0..len {
855 let code = all_codes[emb_idx];
856 code_to_docs.entry(code).or_default().push(doc_id as i64);
857 emb_idx += 1;
858 }
859 }
860
861 let mut ivf_data: Vec<i64> = Vec::new();
863 let mut ivf_lengths: Vec<i32> = vec![0; num_centroids];
864
865 for (centroid_id, ivf_len) in ivf_lengths.iter_mut().enumerate() {
866 if let Some(docs) = code_to_docs.get(¢roid_id) {
867 let mut unique_docs: Vec<i64> = docs.clone();
868 unique_docs.sort_unstable();
869 unique_docs.dedup();
870 *ivf_len = unique_docs.len() as i32;
871 ivf_data.extend(unique_docs);
872 }
873 }
874
875 let ivf = Array1::from_vec(ivf_data);
876 let ivf_lengths = Array1::from_vec(ivf_lengths);
877
878 let ivf_path = index_dir.join("ivf.npy");
879 atomic_write_file(&ivf_path, |file| {
880 ivf.write_npy(file)?;
881 Ok(())
882 })?;
883
884 let ivf_lengths_path = index_dir.join("ivf_lengths.npy");
885 atomic_write_file(&ivf_lengths_path, |file| {
886 ivf_lengths.write_npy(file)?;
887 Ok(())
888 })?;
889
890 let metadata = Metadata {
892 num_chunks: n_chunks,
893 nbits: config.nbits,
894 num_partitions: num_centroids,
895 num_embeddings: total_embeddings,
896 avg_doclen,
897 num_documents,
898 embedding_dim,
899 next_plaid_compatible: true, };
901
902 let metadata_path = index_dir.join("metadata.json");
903 atomic_write_file(&metadata_path, |file| {
904 let mut writer = BufWriter::new(file);
905 serde_json::to_writer_pretty(&mut writer, &metadata)?;
906 writer.flush()?;
907 Ok(())
908 })?;
909
910 Ok(metadata)
911}
912
913pub fn create_index_with_kmeans_files(
928 embeddings: &[Array2<f32>],
929 index_path: &str,
930 config: &IndexConfig,
931) -> Result<Metadata> {
932 if embeddings.is_empty() {
933 return Err(Error::IndexCreation("No documents provided".into()));
934 }
935
936 #[cfg(feature = "_cuda")]
939 if !config.force_cpu {
940 if crate::is_force_gpu() {
941 crate::cuda::get_global_context()
942 .expect("FORCE_GPU is set but CUDA context failed to initialize");
943 } else {
944 let _ = crate::cuda::get_global_context();
945 }
946 }
947
948 let kmeans_config = ComputeKmeansConfig {
950 kmeans_niters: config.kmeans_niters,
951 max_points_per_centroid: config.max_points_per_centroid,
952 seed: config.seed.unwrap_or(42),
953 n_samples_kmeans: config.n_samples_kmeans,
954 num_partitions: None, force_cpu: config.force_cpu,
956 };
957
958 let centroids = compute_kmeans(embeddings, &kmeans_config)?;
960
961 let metadata = create_index_files(embeddings, centroids, index_path, config)?;
963
964 if embeddings.len() <= config.start_from_scratch {
966 let index_dir = std::path::Path::new(index_path);
967 crate::update::save_embeddings_npy(index_dir, embeddings)?;
968 }
969
970 Ok(metadata)
971}
972pub struct MmapIndex {
996 pub path: String,
998 pub metadata: Metadata,
1000 pub codec: ResidualCodec,
1002 pub ivf: Array1<i64>,
1004 pub ivf_lengths: Array1<i32>,
1006 pub ivf_offsets: Array1<i64>,
1008 pub doc_lengths: Array1<i64>,
1010 pub doc_offsets: Array1<usize>,
1012 pub mmap_codes: crate::mmap::MmapNpyArray1I64,
1014 pub mmap_residuals: crate::mmap::MmapNpyArray2U8,
1016}
1017
1018impl MmapIndex {
1019 pub fn load(index_path: &str) -> Result<Self> {
1027 use ndarray_npy::ReadNpyExt;
1028
1029 let index_dir = Path::new(index_path);
1030
1031 let mut metadata = Metadata::load_from_path(index_dir)?;
1033
1034 if !metadata.next_plaid_compatible {
1036 eprintln!("Checking index format compatibility...");
1037 let converted = crate::mmap::convert_fastplaid_to_nextplaid(index_dir)?;
1038 if converted {
1039 eprintln!("Index converted to next-plaid compatible format.");
1040 let merged_codes = index_dir.join("merged_codes.npy");
1042 let merged_residuals = index_dir.join("merged_residuals.npy");
1043 let codes_manifest = index_dir.join("merged_codes.manifest.json");
1044 let residuals_manifest = index_dir.join("merged_residuals.manifest.json");
1045 for path in [
1046 &merged_codes,
1047 &merged_residuals,
1048 &codes_manifest,
1049 &residuals_manifest,
1050 ] {
1051 if path.exists() {
1052 let _ = fs::remove_file(path);
1053 }
1054 }
1055 }
1056
1057 metadata.next_plaid_compatible = true;
1059 let metadata_path = index_dir.join("metadata.json");
1060 atomic_write_file(&metadata_path, |file| {
1061 let mut writer = BufWriter::new(file);
1062 serde_json::to_writer_pretty(&mut writer, &metadata)?;
1063 writer.flush()?;
1064 Ok(())
1065 })
1066 .map_err(|e| Error::IndexLoad(format!("Failed to update metadata: {}", e)))?;
1067 eprintln!("Metadata updated with next_plaid_compatible: true");
1068 }
1069
1070 let codec = ResidualCodec::load_mmap_from_dir(index_dir)?;
1073
1074 let ivf_path = index_dir.join("ivf.npy");
1076 let ivf: Array1<i64> = Array1::read_npy(
1077 File::open(&ivf_path)
1078 .map_err(|e| Error::IndexLoad(format!("Failed to open ivf.npy: {}", e)))?,
1079 )
1080 .map_err(|e| Error::IndexLoad(format!("Failed to read ivf.npy: {}", e)))?;
1081
1082 let ivf_lengths_path = index_dir.join("ivf_lengths.npy");
1083 let ivf_lengths: Array1<i32> = Array1::read_npy(
1084 File::open(&ivf_lengths_path)
1085 .map_err(|e| Error::IndexLoad(format!("Failed to open ivf_lengths.npy: {}", e)))?,
1086 )
1087 .map_err(|e| Error::IndexLoad(format!("Failed to read ivf_lengths.npy: {}", e)))?;
1088
1089 let num_centroids = ivf_lengths.len();
1091 let mut ivf_offsets = Array1::<i64>::zeros(num_centroids + 1);
1092 for i in 0..num_centroids {
1093 ivf_offsets[i + 1] = ivf_offsets[i] + ivf_lengths[i] as i64;
1094 }
1095
1096 let mut doc_lengths_vec: Vec<i64> = Vec::with_capacity(metadata.num_documents);
1098 for chunk_idx in 0..metadata.num_chunks {
1099 let doclens_path = index_dir.join(format!("doclens.{}.json", chunk_idx));
1100 let chunk_doclens: Vec<i64> =
1101 serde_json::from_reader(BufReader::new(File::open(&doclens_path)?))?;
1102 doc_lengths_vec.extend(chunk_doclens);
1103 }
1104 let doc_lengths = Array1::from_vec(doc_lengths_vec);
1105
1106 let mut doc_offsets = Array1::<usize>::zeros(doc_lengths.len() + 1);
1108 for i in 0..doc_lengths.len() {
1109 doc_offsets[i + 1] = doc_offsets[i] + doc_lengths[i] as usize;
1110 }
1111
1112 let max_len = doc_lengths.iter().cloned().max().unwrap_or(0) as usize;
1114 let last_len = *doc_lengths.last().unwrap_or(&0) as usize;
1115 let padding_needed = max_len.saturating_sub(last_len);
1116
1117 let merged_codes_path =
1118 crate::mmap::merge_codes_chunks(index_dir, metadata.num_chunks, padding_needed)?;
1119 let merged_residuals_path =
1120 crate::mmap::merge_residuals_chunks(index_dir, metadata.num_chunks, padding_needed)?;
1121
1122 let (mmap_codes, mmap_residuals) = (
1123 crate::mmap::MmapNpyArray1I64::from_npy_file(&merged_codes_path)?,
1124 crate::mmap::MmapNpyArray2U8::from_npy_file(&merged_residuals_path)?,
1125 );
1126
1127 Ok(Self {
1128 path: index_path.to_string(),
1129 metadata,
1130 codec,
1131 ivf,
1132 ivf_lengths,
1133 ivf_offsets,
1134 doc_lengths,
1135 doc_offsets,
1136 mmap_codes,
1137 mmap_residuals,
1138 })
1139 }
1140
1141 pub fn get_candidates(&self, centroid_indices: &[usize]) -> Vec<i64> {
1143 let mut candidates: Vec<i64> = Vec::new();
1144
1145 for &idx in centroid_indices {
1146 if idx < self.ivf_lengths.len() {
1147 let start = self.ivf_offsets[idx] as usize;
1148 let len = self.ivf_lengths[idx] as usize;
1149 candidates.extend(self.ivf.slice(s![start..start + len]).iter());
1150 }
1151 }
1152
1153 candidates.sort_unstable();
1154 candidates.dedup();
1155 candidates
1156 }
1157
1158 pub fn get_document_embeddings(&self, doc_id: usize) -> Result<Array2<f32>> {
1160 if doc_id >= self.doc_lengths.len() {
1161 return Err(Error::Search(format!("Invalid document ID: {}", doc_id)));
1162 }
1163
1164 let start = self.doc_offsets[doc_id];
1165 let end = self.doc_offsets[doc_id + 1];
1166
1167 let codes_slice = self.mmap_codes.slice(start, end);
1169 let residuals_view = self.mmap_residuals.slice_rows(start, end);
1170
1171 let codes: Array1<usize> = Array1::from_iter(codes_slice.iter().map(|&c| c as usize));
1173
1174 let residuals = residuals_view.to_owned();
1176
1177 self.codec.decompress(&residuals, &codes.view())
1179 }
1180
1181 pub fn get_document_codes(&self, doc_ids: &[usize]) -> Vec<Vec<i64>> {
1183 doc_ids
1184 .iter()
1185 .map(|&doc_id| {
1186 if doc_id >= self.doc_lengths.len() {
1187 return vec![];
1188 }
1189 let start = self.doc_offsets[doc_id];
1190 let end = self.doc_offsets[doc_id + 1];
1191 self.mmap_codes.slice(start, end).to_vec()
1192 })
1193 .collect()
1194 }
1195
1196 pub fn decompress_documents(&self, doc_ids: &[usize]) -> Result<(Array2<f32>, Vec<usize>)> {
1198 let mut total_tokens = 0usize;
1200 let mut lengths = Vec::with_capacity(doc_ids.len());
1201 for &doc_id in doc_ids {
1202 if doc_id >= self.doc_lengths.len() {
1203 lengths.push(0);
1204 } else {
1205 let len = self.doc_offsets[doc_id + 1] - self.doc_offsets[doc_id];
1206 lengths.push(len);
1207 total_tokens += len;
1208 }
1209 }
1210
1211 if total_tokens == 0 {
1212 return Ok((Array2::zeros((0, self.codec.embedding_dim())), lengths));
1213 }
1214
1215 let packed_dim = self.mmap_residuals.ncols();
1217 let mut all_codes = Vec::with_capacity(total_tokens);
1218 let mut all_residuals = Array2::<u8>::zeros((total_tokens, packed_dim));
1219 let mut offset = 0;
1220
1221 for &doc_id in doc_ids {
1222 if doc_id >= self.doc_lengths.len() {
1223 continue;
1224 }
1225 let start = self.doc_offsets[doc_id];
1226 let end = self.doc_offsets[doc_id + 1];
1227 let len = end - start;
1228
1229 let codes_slice = self.mmap_codes.slice(start, end);
1231 all_codes.extend(codes_slice.iter().map(|&c| c as usize));
1232
1233 let residuals_view = self.mmap_residuals.slice_rows(start, end);
1235 all_residuals
1236 .slice_mut(s![offset..offset + len, ..])
1237 .assign(&residuals_view);
1238 offset += len;
1239 }
1240
1241 let codes_arr = Array1::from_vec(all_codes);
1242 let embeddings = self.codec.decompress(&all_residuals, &codes_arr.view())?;
1243
1244 Ok((embeddings, lengths))
1245 }
1246
1247 pub fn search(
1259 &self,
1260 query: &Array2<f32>,
1261 params: &crate::search::SearchParameters,
1262 subset: Option<&[i64]>,
1263 ) -> Result<crate::search::SearchResult> {
1264 crate::search::search_one_mmap(self, query, params, subset)
1265 }
1266
1267 pub fn search_batch(
1280 &self,
1281 queries: &[Array2<f32>],
1282 params: &crate::search::SearchParameters,
1283 parallel: bool,
1284 subset: Option<&[i64]>,
1285 ) -> Result<Vec<crate::search::SearchResult>> {
1286 crate::search::search_many_mmap(self, queries, params, parallel, subset)
1287 }
1288
1289 pub fn num_documents(&self) -> usize {
1291 self.doc_lengths.len()
1292 }
1293
1294 pub fn num_embeddings(&self) -> usize {
1296 self.metadata.num_embeddings
1297 }
1298
1299 pub fn num_partitions(&self) -> usize {
1301 self.metadata.num_partitions
1302 }
1303
1304 pub fn avg_doclen(&self) -> f64 {
1306 self.metadata.avg_doclen
1307 }
1308
1309 pub fn embedding_dim(&self) -> usize {
1311 self.codec.embedding_dim()
1312 }
1313
1314 fn release_mmaps(&mut self) {
1324 self.mmap_codes = crate::mmap::MmapNpyArray1I64::empty();
1325 self.mmap_residuals = crate::mmap::MmapNpyArray2U8::empty();
1326 self.codec.centroids = crate::codec::CentroidStore::Owned(Array2::zeros((0, 0)));
1327 }
1328
1329 pub fn reconstruct(&self, doc_ids: &[i64]) -> Result<Vec<Array2<f32>>> {
1355 crate::embeddings::reconstruct_embeddings(self, doc_ids)
1356 }
1357
1358 pub fn reconstruct_single(&self, doc_id: i64) -> Result<Array2<f32>> {
1370 crate::embeddings::reconstruct_single(self, doc_id)
1371 }
1372
1373 pub fn create_with_kmeans(
1393 embeddings: &[Array2<f32>],
1394 index_path: &str,
1395 config: &IndexConfig,
1396 ) -> Result<Self> {
1397 create_index_with_kmeans_files(embeddings, index_path, config)?;
1399
1400 Self::load(index_path)
1402 }
1403
1404 pub fn update(
1432 &mut self,
1433 embeddings: &[Array2<f32>],
1434 config: &crate::update::UpdateConfig,
1435 ) -> Result<Vec<i64>> {
1436 use crate::codec::ResidualCodec;
1437 use crate::update::{
1438 clear_buffer, clear_embeddings_npy, embeddings_npy_exists, load_buffer,
1439 load_buffer_info, load_cluster_threshold, load_embeddings_npy, save_buffer,
1440 update_centroids, update_index,
1441 };
1442
1443 let path_str = self.path.clone();
1444 let index_path = std::path::Path::new(&path_str);
1445 let num_new_docs = embeddings.len();
1446
1447 self.release_mmaps();
1452
1453 if self.metadata.num_documents <= config.start_from_scratch {
1457 let existing_embeddings = load_embeddings_npy(index_path)?;
1459
1460 if existing_embeddings.len() == self.metadata.num_documents {
1465 let start_doc_id = existing_embeddings.len() as i64;
1467
1468 let combined_embeddings: Vec<Array2<f32>> = existing_embeddings
1470 .into_iter()
1471 .chain(embeddings.iter().cloned())
1472 .collect();
1473
1474 let index_config = IndexConfig {
1476 nbits: self.metadata.nbits,
1477 batch_size: config.batch_size,
1478 seed: Some(config.seed),
1479 kmeans_niters: config.kmeans_niters,
1480 max_points_per_centroid: config.max_points_per_centroid,
1481 n_samples_kmeans: config.n_samples_kmeans,
1482 start_from_scratch: config.start_from_scratch,
1483 force_cpu: config.force_cpu,
1484 ..Default::default()
1485 };
1486
1487 *self = Self::create_with_kmeans(&combined_embeddings, &path_str, &index_config)?;
1489
1490 if combined_embeddings.len() > config.start_from_scratch
1492 && embeddings_npy_exists(index_path)
1493 {
1494 clear_embeddings_npy(index_path)?;
1495 }
1496
1497 return Ok((start_doc_id..start_doc_id + num_new_docs as i64).collect());
1499 }
1500 }
1502
1503 let buffer = load_buffer(index_path)?;
1505 let buffer_len = buffer.len();
1506 let total_new = embeddings.len() + buffer_len;
1507
1508 let start_doc_id: i64;
1510
1511 let mut codec = ResidualCodec::load_from_dir(index_path)?;
1513
1514 if total_new >= config.buffer_size {
1516 let num_buffered = load_buffer_info(index_path)?;
1520
1521 if num_buffered > 0 && self.metadata.num_documents >= num_buffered {
1523 let start_del_idx = self.metadata.num_documents - num_buffered;
1524 let docs_to_delete: Vec<i64> = (start_del_idx..self.metadata.num_documents)
1525 .map(|i| i as i64)
1526 .collect();
1527 crate::delete::delete_from_index_keep_buffer(&docs_to_delete, &path_str)?;
1528 self.metadata = Metadata::load_from_path(index_path)?;
1530 }
1531
1532 start_doc_id = (self.metadata.num_documents + buffer_len) as i64;
1534
1535 let combined: Vec<Array2<f32>> = buffer
1537 .into_iter()
1538 .chain(embeddings.iter().cloned())
1539 .collect();
1540
1541 if let Ok(cluster_threshold) = load_cluster_threshold(index_path) {
1543 let new_centroids =
1544 update_centroids(index_path, &combined, cluster_threshold, config)?;
1545 if new_centroids > 0 {
1546 codec = ResidualCodec::load_from_dir(index_path)?;
1548 }
1549 }
1550
1551 clear_buffer(index_path)?;
1553
1554 update_index(
1556 &combined,
1557 &path_str,
1558 &codec,
1559 Some(config.batch_size),
1560 true,
1561 config.force_cpu,
1562 )?;
1563 } else {
1564 start_doc_id = self.metadata.num_documents as i64;
1567
1568 let combined_buffer: Vec<Array2<f32>> = buffer
1570 .into_iter()
1571 .chain(embeddings.iter().cloned())
1572 .collect();
1573 save_buffer(index_path, &combined_buffer)?;
1574
1575 update_index(
1577 embeddings,
1578 &path_str,
1579 &codec,
1580 Some(config.batch_size),
1581 false,
1582 config.force_cpu,
1583 )?;
1584 }
1585
1586 *self = Self::load(&path_str)?;
1588
1589 Ok((start_doc_id..start_doc_id + num_new_docs as i64).collect())
1591 }
1592
1593 pub fn update_with_metadata(
1605 &mut self,
1606 embeddings: &[Array2<f32>],
1607 config: &crate::update::UpdateConfig,
1608 metadata: Option<&[serde_json::Value]>,
1609 ) -> Result<Vec<i64>> {
1610 if let Some(meta) = metadata {
1612 if meta.len() != embeddings.len() {
1613 return Err(Error::Config(format!(
1614 "Metadata length ({}) must match embeddings length ({})",
1615 meta.len(),
1616 embeddings.len()
1617 )));
1618 }
1619 }
1620
1621 let doc_ids = self.update(embeddings, config)?;
1623
1624 if let Some(meta) = metadata {
1626 crate::filtering::update(&self.path, meta, &doc_ids)?;
1627 }
1628
1629 Ok(doc_ids)
1630 }
1631
1632 pub fn update_or_create(
1645 embeddings: &[Array2<f32>],
1646 index_path: &str,
1647 index_config: &IndexConfig,
1648 update_config: &crate::update::UpdateConfig,
1649 ) -> Result<(Self, Vec<i64>)> {
1650 let index_dir = std::path::Path::new(index_path);
1651 let metadata_path = index_dir.join("metadata.json");
1652
1653 if metadata_path.exists() {
1654 let mut index = Self::load(index_path)?;
1656 let doc_ids = index.update(embeddings, update_config)?;
1657 Ok((index, doc_ids))
1658 } else {
1659 let num_docs = embeddings.len();
1661 let index = Self::create_with_kmeans(embeddings, index_path, index_config)?;
1662 let doc_ids: Vec<i64> = (0..num_docs as i64).collect();
1663 Ok((index, doc_ids))
1664 }
1665 }
1666
1667 pub fn update_append(
1676 embeddings: &[Array2<f32>],
1677 index_path: &str,
1678 update_config: &crate::update::UpdateConfig,
1679 ) -> Result<Vec<i64>> {
1680 use crate::codec::ResidualCodec;
1681 use crate::update::update_index;
1682
1683 let index_dir = std::path::Path::new(index_path);
1684 let metadata = Metadata::load_from_path(index_dir)?;
1685 let codec = ResidualCodec::load_from_dir(index_dir)?;
1686 let start_doc_id = metadata.num_documents as i64;
1687 let num_new_docs = embeddings.len();
1688
1689 update_index(
1690 embeddings,
1691 index_path,
1692 &codec,
1693 Some(update_config.batch_size),
1694 false,
1695 update_config.force_cpu,
1696 )?;
1697
1698 Ok((start_doc_id..start_doc_id + num_new_docs as i64).collect())
1699 }
1700
1701 pub fn update_or_create_with_metadata(
1720 embeddings: &[Array2<f32>],
1721 index_path: &str,
1722 index_config: &IndexConfig,
1723 update_config: &crate::update::UpdateConfig,
1724 metadata: Option<&[serde_json::Value]>,
1725 ) -> Result<(Self, Vec<i64>)> {
1726 if let Some(meta) = metadata {
1727 if meta.len() != embeddings.len() {
1728 return Err(Error::Config(format!(
1729 "Metadata length ({}) must match embeddings length ({})",
1730 meta.len(),
1731 embeddings.len()
1732 )));
1733 }
1734 }
1735
1736 let index_dir = std::path::Path::new(index_path);
1737 let metadata_json_path = index_dir.join("metadata.json");
1738
1739 let (index, doc_ids) = if metadata_json_path.exists() {
1740 let mut index = Self::load(index_path)?;
1741 let doc_ids = index.update(embeddings, update_config)?;
1742 (index, doc_ids)
1743 } else {
1744 let num_docs = embeddings.len();
1745 let index = Self::create_with_kmeans(embeddings, index_path, index_config)?;
1746 let doc_ids: Vec<i64> = (0..num_docs as i64).collect();
1747 (index, doc_ids)
1748 };
1749
1750 if let Some(meta) = metadata {
1751 if crate::filtering::exists(index_path) {
1752 crate::filtering::update(index_path, meta, &doc_ids)?;
1753 } else {
1754 crate::filtering::create(index_path, meta, &doc_ids)?;
1755 }
1756 crate::text_search::index(index_path, meta, &doc_ids, &index_config.fts_tokenizer)?;
1758 }
1759
1760 Ok((index, doc_ids))
1761 }
1762
1763 pub fn reload(&mut self) -> Result<()> {
1768 let path = self.path.clone();
1769 self.release_mmaps();
1772 *self = Self::load(&path)?;
1773 Ok(())
1774 }
1775
1776 pub fn delete(&mut self, doc_ids: &[i64]) -> Result<usize> {
1789 self.delete_with_options(doc_ids, true)
1790 }
1791
1792 pub fn delete_with_options(&mut self, doc_ids: &[i64], delete_metadata: bool) -> Result<usize> {
1806 let path = self.path.clone();
1807 let old_num_documents = self.metadata.num_documents as i64;
1808
1809 self.release_mmaps();
1813
1814 let deleted = crate::delete::delete_from_index(doc_ids, &path)?;
1816
1817 if delete_metadata && deleted > 0 {
1819 let index_path = std::path::Path::new(&path);
1820 let db_path = index_path.join("metadata.db");
1821 if db_path.exists() {
1822 let mut valid: Vec<i64> = doc_ids
1828 .iter()
1829 .copied()
1830 .filter(|&id| id >= 0 && id < old_num_documents)
1831 .collect();
1832 valid.sort_unstable();
1833 valid.dedup();
1834 let suffix_start = old_num_documents - valid.len() as i64;
1835 let is_suffix_delete = valid.first().is_some_and(|&min| min >= suffix_start);
1836
1837 crate::filtering::delete(&path, doc_ids)?;
1838 if crate::text_search::is_content_id_keyed(&path) {
1839 } else if is_suffix_delete {
1843 crate::text_search::delete(&path, &valid)?;
1844 } else {
1845 crate::text_search::rebuild(&path)?;
1850 }
1851 }
1852 }
1853
1854 Ok(deleted)
1855 }
1856}
1857
1858#[cfg(test)]
1859mod tests {
1860 use super::*;
1861
1862 #[test]
1863 fn test_index_config_default() {
1864 let config = IndexConfig::default();
1865 assert_eq!(config.nbits, 4);
1866 assert_eq!(config.batch_size, 50_000);
1867 assert_eq!(config.seed, Some(42));
1868 assert_eq!(
1869 config.start_from_scratch,
1870 crate::default_start_from_scratch()
1871 );
1872 }
1873
1874 #[test]
1879 fn test_delete_keeps_fts_aligned() {
1880 use ndarray::Array2;
1881 use tempfile::tempdir;
1882
1883 let temp_dir = tempdir().unwrap();
1884 let index_path = temp_dir.path().to_str().unwrap();
1885
1886 let mut embeddings: Vec<Array2<f32>> = Vec::new();
1887 for i in 0..5 {
1888 let mut doc = Array2::<f32>::zeros((5, 32));
1889 for j in 0..5 {
1890 for k in 0..32 {
1891 doc[[j, k]] = (i as f32 * 0.1) + (j as f32 * 0.01) + (k as f32 * 0.001);
1892 }
1893 }
1894 for mut row in doc.rows_mut() {
1895 let norm: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
1896 if norm > 0.0 {
1897 row.iter_mut().for_each(|x| *x /= norm);
1898 }
1899 }
1900 embeddings.push(doc);
1901 }
1902
1903 let config = IndexConfig {
1904 nbits: 2,
1905 batch_size: 50,
1906 seed: Some(42),
1907 kmeans_niters: 2,
1908 max_points_per_centroid: 256,
1909 n_samples_kmeans: None,
1910 start_from_scratch: 999,
1911 force_cpu: false,
1912 ..Default::default()
1913 };
1914 let mut index = MmapIndex::create_with_kmeans(&embeddings, index_path, &config).unwrap();
1915
1916 let words = ["alpha", "bravo", "charlie", "delta", "echo"];
1917 let metadata: Vec<serde_json::Value> = words
1918 .iter()
1919 .map(|w| serde_json::json!({ "text": w }))
1920 .collect();
1921 let doc_ids: Vec<i64> = (0..5).collect();
1922 crate::filtering::create(index_path, &metadata, &doc_ids).unwrap();
1923 crate::text_search::index(
1924 index_path,
1925 &metadata,
1926 &doc_ids,
1927 &crate::text_search::FtsTokenizer::default(),
1928 )
1929 .unwrap();
1930
1931 let deleted = index.delete_with_options(&[3, 4], true).unwrap();
1934 assert_eq!(deleted, 2);
1935 index.reload().unwrap();
1936
1937 let hits = crate::text_search::search(index_path, "charlie", 10).unwrap();
1938 assert_eq!(hits.passage_ids, vec![2]);
1939 let gone = crate::text_search::search(index_path, "delta", 10).unwrap();
1940 assert!(gone.passage_ids.is_empty());
1941
1942 let deleted = index.delete_with_options(&[0], true).unwrap();
1945 assert_eq!(deleted, 1);
1946
1947 let hits = crate::text_search::search(index_path, "charlie", 10).unwrap();
1948 assert_eq!(hits.passage_ids, vec![1]);
1949 let gone = crate::text_search::search(index_path, "alpha", 10).unwrap();
1950 assert!(gone.passage_ids.is_empty());
1951 }
1952
1953 #[test]
1954 fn test_update_or_create_new_index() {
1955 use ndarray::Array2;
1956 use tempfile::tempdir;
1957
1958 let temp_dir = tempdir().unwrap();
1959 let index_path = temp_dir.path().to_str().unwrap();
1960
1961 let mut embeddings: Vec<Array2<f32>> = Vec::new();
1963 for i in 0..5 {
1964 let mut doc = Array2::<f32>::zeros((5, 32));
1965 for j in 0..5 {
1966 for k in 0..32 {
1967 doc[[j, k]] = (i as f32 * 0.1) + (j as f32 * 0.01) + (k as f32 * 0.001);
1968 }
1969 }
1970 for mut row in doc.rows_mut() {
1972 let norm: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
1973 if norm > 0.0 {
1974 row.iter_mut().for_each(|x| *x /= norm);
1975 }
1976 }
1977 embeddings.push(doc);
1978 }
1979
1980 let index_config = IndexConfig {
1981 nbits: 2,
1982 batch_size: 50,
1983 seed: Some(42),
1984 kmeans_niters: 2,
1985 ..Default::default()
1986 };
1987 let update_config = crate::update::UpdateConfig::default();
1988
1989 let (index, doc_ids) =
1991 MmapIndex::update_or_create(&embeddings, index_path, &index_config, &update_config)
1992 .expect("Failed to create index");
1993
1994 assert_eq!(index.metadata.num_documents, 5);
1995 assert_eq!(doc_ids, vec![0, 1, 2, 3, 4]);
1996
1997 assert!(temp_dir.path().join("metadata.json").exists());
1999 assert!(temp_dir.path().join("centroids.npy").exists());
2000 }
2001
2002 #[test]
2003 fn test_update_or_create_existing_index() {
2004 use ndarray::Array2;
2005 use tempfile::tempdir;
2006
2007 let temp_dir = tempdir().unwrap();
2008 let index_path = temp_dir.path().to_str().unwrap();
2009
2010 let create_embeddings = |count: usize, offset: usize| -> Vec<Array2<f32>> {
2012 let mut embeddings = Vec::new();
2013 for i in 0..count {
2014 let mut doc = Array2::<f32>::zeros((5, 32));
2015 for j in 0..5 {
2016 for k in 0..32 {
2017 doc[[j, k]] =
2018 ((i + offset) as f32 * 0.1) + (j as f32 * 0.01) + (k as f32 * 0.001);
2019 }
2020 }
2021 for mut row in doc.rows_mut() {
2022 let norm: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
2023 if norm > 0.0 {
2024 row.iter_mut().for_each(|x| *x /= norm);
2025 }
2026 }
2027 embeddings.push(doc);
2028 }
2029 embeddings
2030 };
2031
2032 let index_config = IndexConfig {
2033 nbits: 2,
2034 batch_size: 50,
2035 seed: Some(42),
2036 kmeans_niters: 2,
2037 ..Default::default()
2038 };
2039 let update_config = crate::update::UpdateConfig::default();
2040
2041 let embeddings1 = create_embeddings(5, 0);
2043 let (index1, doc_ids1) =
2044 MmapIndex::update_or_create(&embeddings1, index_path, &index_config, &update_config)
2045 .expect("Failed to create index");
2046 assert_eq!(index1.metadata.num_documents, 5);
2047 assert_eq!(doc_ids1, vec![0, 1, 2, 3, 4]);
2048
2049 drop(index1);
2052
2053 let embeddings2 = create_embeddings(3, 5);
2055 let (index2, doc_ids2) =
2056 MmapIndex::update_or_create(&embeddings2, index_path, &index_config, &update_config)
2057 .expect("Failed to update index");
2058 assert_eq!(index2.metadata.num_documents, 8);
2059 assert_eq!(doc_ids2, vec![5, 6, 7]);
2060 }
2061}