Skip to main content

next_plaid/
index.rs

1//! Index creation and management for PLAID
2
3use 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
16/// CPU implementation of fused compress_into_codes + residual computation.
17fn compress_and_residuals_cpu(
18    embeddings: &Array2<f32>,
19    codec: &ResidualCodec,
20) -> (Array1<usize>, Array2<f32>) {
21    use rayon::prelude::*;
22
23    // Use CPU-only version to ensure no CUDA is called
24    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/// Configuration for index creation
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct IndexConfig {
45    /// Number of bits for quantization (typically 2 or 4)
46    pub nbits: usize,
47    /// Batch size for processing
48    pub batch_size: usize,
49    /// Random seed for reproducibility
50    pub seed: Option<u64>,
51    /// Number of K-means iterations (default: 4)
52    #[serde(default = "default_kmeans_niters")]
53    pub kmeans_niters: usize,
54    /// Maximum number of points per centroid for K-means (default: 256)
55    #[serde(default = "default_max_points_per_centroid")]
56    pub max_points_per_centroid: usize,
57    /// Number of samples for K-means training.
58    /// If None, uses heuristic: min(1 + 16 * sqrt(120 * num_documents), num_documents)
59    #[serde(default)]
60    pub n_samples_kmeans: Option<usize>,
61    /// Threshold for start-from-scratch mode (default: 999).
62    /// When the number of documents is <= this threshold, raw embeddings are saved
63    /// to embeddings.npy for potential rebuilds during updates.
64    #[serde(default = "default_start_from_scratch")]
65    pub start_from_scratch: usize,
66    /// Force CPU execution for K-means even when CUDA feature is enabled.
67    /// Useful for small batches where GPU initialization overhead exceeds benefits.
68    #[serde(default)]
69    pub force_cpu: bool,
70    /// FTS5 tokenizer for full-text search over metadata.
71    /// Default: `Unicode61` (word-level). Use `Trigram` for code / substring search.
72    #[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/// Metadata for the index
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Metadata {
107    /// Number of chunks in the index
108    pub num_chunks: usize,
109    /// Number of bits for quantization
110    pub nbits: usize,
111    /// Number of partitions (centroids)
112    pub num_partitions: usize,
113    /// Total number of embeddings
114    pub num_embeddings: usize,
115    /// Average document length
116    pub avg_doclen: f64,
117    /// Total number of documents
118    #[serde(default)]
119    pub num_documents: usize,
120    /// Embedding dimension (columns of centroids matrix)
121    #[serde(default)]
122    pub embedding_dim: usize,
123    /// Whether the index has been converted to next-plaid compatible format.
124    /// If false or missing, the index may need fast-plaid to next-plaid conversion.
125    #[serde(default)]
126    pub next_plaid_compatible: bool,
127}
128
129impl Metadata {
130    /// Load metadata from a JSON file, inferring num_documents from doclens if not present.
131    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 num_documents is 0 (default), infer from doclens files
139        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/// Chunk metadata
159#[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(&centroids_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(&centroid_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
530// ============================================================================
531// Standalone Index Creation Functions
532// ============================================================================
533
534/// Create index files on disk from embeddings and centroids.
535///
536/// This is a standalone function that creates all necessary index files
537/// without constructing an in-memory Index object. Both Index and MmapIndex
538/// can use this function to create their files, then load them in their
539/// preferred format.
540///
541/// # Arguments
542///
543/// * `embeddings` - List of document embeddings
544/// * `centroids` - Pre-computed centroids from K-means
545/// * `index_path` - Directory to save the index
546/// * `config` - Index configuration
547///
548/// # Returns
549///
550/// Metadata about the created index
551pub 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    // Calculate statistics
569    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    // Sample documents for codec training
573    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    // Collect sample embeddings for training
591    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    // Train codec: compute residuals and quantization parameters
611    let avg_residual = Array1::zeros(embedding_dim);
612    let initial_codec =
613        ResidualCodec::new(config.nbits, centroids.clone(), avg_residual, None, None)?;
614
615    // Compute codes for heldout samples
616    // Use CPU-only version when force_cpu is set to avoid CUDA initialization overhead
617    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    // Compute residuals
624    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    // Compute cluster threshold from residual distances
633    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    // Compute average residual per dimension
641    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    // Compute quantization buckets
647    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    // Flatten residuals for quantile computation
656    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    // Save codec components
669    use ndarray_npy::WriteNpyExt;
670
671    let centroids_path = index_dir.join("centroids.npy");
672    atomic_write_file(&centroids_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    // Process documents in chunks
702    let n_chunks = (num_documents as f64 / config.batch_size as f64).ceil() as usize;
703
704    // Save plan
705    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        // Collect document lengths
724        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        // Concatenate all embeddings in the chunk for batch processing
728        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        // BATCH: Compress embeddings and compute residuals
739        // Try CUDA fused operation first, fall back to CPU (skip CUDA if force_cpu is set)
740        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        // BATCH: Quantize all residuals at once
780        let batch_packed = codec.quantize_residuals(&batch_residuals)?;
781
782        // Track codes for IVF building
783        for &len in &chunk_doclens {
784            doc_lengths.push(len);
785        }
786        all_codes.extend(batch_codes.iter().copied());
787
788        // Save chunk metadata
789        let chunk_meta = ChunkMetadata {
790            num_documents: end - start,
791            num_embeddings: batch_codes.len(),
792            embedding_offset: 0, // Will be updated later
793        };
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        // Save chunk doclens
804        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        // Save chunk codes
813        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        // Save chunk residuals
821        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    // Update chunk metadata with global offsets
829    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    // Build IVF (Inverted File)
850    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    // Deduplicate document IDs per centroid
862    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(&centroid_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    // Save global metadata
891    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, // Created by next-plaid, always compatible
900    };
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
913/// Create index files with automatic K-means centroid computation.
914///
915/// This is a standalone function that runs K-means to compute centroids,
916/// then creates all index files on disk.
917///
918/// # Arguments
919///
920/// * `embeddings` - List of document embeddings
921/// * `index_path` - Directory to save the index
922/// * `config` - Index configuration
923///
924/// # Returns
925///
926/// Metadata about the created index
927pub 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    // Pre-initialize CUDA if available (first init can take 10-20s due to driver initialization)
937    // Skip if force_cpu is set to avoid unnecessary initialization overhead
938    #[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    // Build K-means configuration from IndexConfig
949    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, // Let the heuristic decide
955        force_cpu: config.force_cpu,
956    };
957
958    // Compute centroids using fast-plaid's approach
959    let centroids = compute_kmeans(embeddings, &kmeans_config)?;
960
961    // Create the index files
962    let metadata = create_index_files(embeddings, centroids, index_path, config)?;
963
964    // If below start_from_scratch threshold, save raw embeddings for potential rebuilds
965    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}
972// ============================================================================
973// Memory-Mapped Index for Low Memory Usage
974// ============================================================================
975
976/// A memory-mapped PLAID index for multi-vector search.
977///
978/// This struct uses memory-mapped files for the large arrays (codes and residuals)
979/// instead of loading them entirely into RAM. Only small tensors (centroids,
980/// bucket weights, IVF) are loaded into memory.
981///
982/// # Memory Usage
983///
984/// Only small tensors (~50 MB for SciFact 5K docs) are loaded into RAM,
985/// with code and residual data accessed via OS-managed memory mapping.
986///
987/// # Usage
988///
989/// ```ignore
990/// use next_plaid::MmapIndex;
991///
992/// let index = MmapIndex::load("/path/to/index")?;
993/// let results = index.search(&query, &params, None)?;
994/// ```
995pub struct MmapIndex {
996    /// Path to the index directory
997    pub path: String,
998    /// Index metadata
999    pub metadata: Metadata,
1000    /// Residual codec for quantization/decompression
1001    pub codec: ResidualCodec,
1002    /// IVF data (concatenated passage IDs per centroid)
1003    pub ivf: Array1<i64>,
1004    /// IVF lengths (number of passages per centroid)
1005    pub ivf_lengths: Array1<i32>,
1006    /// IVF offsets (cumulative offsets into ivf array)
1007    pub ivf_offsets: Array1<i64>,
1008    /// Document lengths (number of tokens per document)
1009    pub doc_lengths: Array1<i64>,
1010    /// Cumulative document offsets for indexing into codes/residuals
1011    pub doc_offsets: Array1<usize>,
1012    /// Memory-mapped codes array (public for search access)
1013    pub mmap_codes: crate::mmap::MmapNpyArray1I64,
1014    /// Memory-mapped residuals array (public for search access)
1015    pub mmap_residuals: crate::mmap::MmapNpyArray2U8,
1016}
1017
1018impl MmapIndex {
1019    /// Load a memory-mapped index from disk.
1020    ///
1021    /// This creates merged files for codes and residuals if they don't exist,
1022    /// then memory-maps them for efficient access.
1023    ///
1024    /// If the index was created by fast-plaid, it will be automatically converted
1025    /// to next-plaid compatible format on first load.
1026    pub fn load(index_path: &str) -> Result<Self> {
1027        use ndarray_npy::ReadNpyExt;
1028
1029        let index_dir = Path::new(index_path);
1030
1031        // Load metadata (infers num_documents from doclens if not present)
1032        let mut metadata = Metadata::load_from_path(index_dir)?;
1033
1034        // Check if conversion from fast-plaid format is needed
1035        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                // Delete any existing merged files since the source files changed
1041                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            // Mark as compatible and save metadata
1058            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        // Load codec with memory-mapped centroids for reduced RAM usage.
1071        // Other small tensors (bucket weights, etc.) are still loaded into memory.
1072        let codec = ResidualCodec::load_mmap_from_dir(index_dir)?;
1073
1074        // Load IVF (small tensor)
1075        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        // Compute IVF offsets
1090        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        // Load document lengths from all chunks
1097        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        // Compute document offsets for indexing
1107        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        // Compute padding needed for StridedTensor compatibility
1113        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    /// Get candidate documents from IVF for given centroid indices.
1142    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    /// Get document embeddings by decompressing codes and residuals.
1159    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        // Get codes and residuals from mmap
1168        let codes_slice = self.mmap_codes.slice(start, end);
1169        let residuals_view = self.mmap_residuals.slice_rows(start, end);
1170
1171        // Convert codes to Array1<usize>
1172        let codes: Array1<usize> = Array1::from_iter(codes_slice.iter().map(|&c| c as usize));
1173
1174        // Convert residuals to owned Array2
1175        let residuals = residuals_view.to_owned();
1176
1177        // Decompress
1178        self.codec.decompress(&residuals, &codes.view())
1179    }
1180
1181    /// Get codes for a batch of document IDs (for approximate scoring).
1182    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    /// Decompress embeddings for a batch of document IDs.
1197    pub fn decompress_documents(&self, doc_ids: &[usize]) -> Result<(Array2<f32>, Vec<usize>)> {
1198        // Compute total tokens
1199        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        // Gather all codes and residuals
1216        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            // Append codes
1230            let codes_slice = self.mmap_codes.slice(start, end);
1231            all_codes.extend(codes_slice.iter().map(|&c| c as usize));
1232
1233            // Copy residuals
1234            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    /// Search for similar documents.
1248    ///
1249    /// # Arguments
1250    ///
1251    /// * `query` - Query embedding matrix [num_tokens, dim]
1252    /// * `params` - Search parameters
1253    /// * `subset` - Optional subset of document IDs to search within
1254    ///
1255    /// # Returns
1256    ///
1257    /// Search result containing top-k document IDs and scores.
1258    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    /// Search for multiple queries in batch.
1268    ///
1269    /// # Arguments
1270    ///
1271    /// * `queries` - Slice of query embedding matrices
1272    /// * `params` - Search parameters
1273    /// * `parallel` - If true, process queries in parallel using rayon
1274    /// * `subset` - Optional subset of document IDs to search within
1275    ///
1276    /// # Returns
1277    ///
1278    /// Vector of search results, one per query.
1279    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    /// Get the number of documents in the index.
1290    pub fn num_documents(&self) -> usize {
1291        self.doc_lengths.len()
1292    }
1293
1294    /// Get the total number of embeddings in the index.
1295    pub fn num_embeddings(&self) -> usize {
1296        self.metadata.num_embeddings
1297    }
1298
1299    /// Get the number of partitions (centroids).
1300    pub fn num_partitions(&self) -> usize {
1301        self.metadata.num_partitions
1302    }
1303
1304    /// Get the average document length.
1305    pub fn avg_doclen(&self) -> f64 {
1306        self.metadata.avg_doclen
1307    }
1308
1309    /// Get the embedding dimension.
1310    pub fn embedding_dim(&self) -> usize {
1311        self.codec.embedding_dim()
1312    }
1313
1314    /// Release all memory-mapped file handles.
1315    ///
1316    /// On Windows, files that are memory-mapped cannot be deleted, renamed, or
1317    /// truncated (OS error 1224 / ERROR_USER_MAPPED_FILE). This method replaces
1318    /// file-backed mmaps with anonymous (non-file) mmaps so that subsequent
1319    /// file operations on the index directory can proceed.
1320    ///
1321    /// After calling this, the index is not usable for search — it must be
1322    /// reloaded via `Self::load()`.
1323    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    /// Reconstruct embeddings for specific documents.
1330    ///
1331    /// This method retrieves the compressed codes and residuals for each document
1332    /// from memory-mapped files and decompresses them to recover the original embeddings.
1333    ///
1334    /// # Arguments
1335    ///
1336    /// * `doc_ids` - Slice of document IDs to reconstruct (0-indexed)
1337    ///
1338    /// # Returns
1339    ///
1340    /// A vector of 2D arrays, one per document. Each array has shape `[num_tokens, dim]`.
1341    ///
1342    /// # Example
1343    ///
1344    /// ```ignore
1345    /// use next_plaid::MmapIndex;
1346    ///
1347    /// let index = MmapIndex::load("/path/to/index")?;
1348    /// let embeddings = index.reconstruct(&[0, 1, 2])?;
1349    ///
1350    /// for (i, emb) in embeddings.iter().enumerate() {
1351    ///     println!("Document {}: {} tokens x {} dim", i, emb.nrows(), emb.ncols());
1352    /// }
1353    /// ```
1354    pub fn reconstruct(&self, doc_ids: &[i64]) -> Result<Vec<Array2<f32>>> {
1355        crate::embeddings::reconstruct_embeddings(self, doc_ids)
1356    }
1357
1358    /// Reconstruct a single document's embeddings.
1359    ///
1360    /// Convenience method for reconstructing a single document.
1361    ///
1362    /// # Arguments
1363    ///
1364    /// * `doc_id` - Document ID to reconstruct (0-indexed)
1365    ///
1366    /// # Returns
1367    ///
1368    /// A 2D array with shape `[num_tokens, dim]`.
1369    pub fn reconstruct_single(&self, doc_id: i64) -> Result<Array2<f32>> {
1370        crate::embeddings::reconstruct_single(self, doc_id)
1371    }
1372
1373    /// Create a new index from document embeddings with automatic centroid computation.
1374    ///
1375    /// This method:
1376    /// 1. Computes centroids using K-means
1377    /// 2. Creates index files on disk
1378    /// 3. Loads the index using memory-mapped I/O
1379    ///
1380    /// Note: During creation, data is temporarily held in RAM for processing,
1381    /// then written to disk and loaded as mmap.
1382    ///
1383    /// # Arguments
1384    ///
1385    /// * `embeddings` - List of document embeddings, each of shape `[num_tokens, dim]`
1386    /// * `index_path` - Directory to save the index
1387    /// * `config` - Index configuration
1388    ///
1389    /// # Returns
1390    ///
1391    /// The created MmapIndex
1392    pub fn create_with_kmeans(
1393        embeddings: &[Array2<f32>],
1394        index_path: &str,
1395        config: &IndexConfig,
1396    ) -> Result<Self> {
1397        // Use standalone function to create files
1398        create_index_with_kmeans_files(embeddings, index_path, config)?;
1399
1400        // Load as memory-mapped index
1401        Self::load(index_path)
1402    }
1403
1404    /// Update the index with new documents, matching fast-plaid behavior.
1405    ///
1406    /// This method adds new documents to an existing index with three possible paths:
1407    ///
1408    /// 1. **Start-from-scratch mode** (num_documents <= start_from_scratch):
1409    ///    - Loads existing embeddings from `embeddings.npy` if available
1410    ///    - Combines with new embeddings
1411    ///    - Rebuilds the entire index from scratch with fresh K-means
1412    ///    - Clears `embeddings.npy` if total exceeds threshold
1413    ///
1414    /// 2. **Buffer mode** (total_new < buffer_size):
1415    ///    - Adds new documents to the index without centroid expansion
1416    ///    - Saves embeddings to buffer for later centroid expansion
1417    ///
1418    /// 3. **Centroid expansion mode** (total_new >= buffer_size):
1419    ///    - Deletes previously buffered documents
1420    ///    - Expands centroids with outliers from combined buffer + new embeddings
1421    ///    - Re-indexes all combined embeddings with expanded centroids
1422    ///
1423    /// # Arguments
1424    ///
1425    /// * `embeddings` - New document embeddings to add
1426    /// * `config` - Update configuration
1427    ///
1428    /// # Returns
1429    ///
1430    /// Vector of document IDs assigned to the new embeddings
1431    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        // Release mmap handles before any file operations (delete, rename,
1448        // truncate). On Windows, files that are memory-mapped cannot be
1449        // modified, causing OS error 1224 (ERROR_USER_MAPPED_FILE).
1450        // The index will be fully reloaded from disk at the end of this method.
1451        self.release_mmaps();
1452
1453        // ==================================================================
1454        // Start-from-scratch mode (fast-plaid update.py:312-346)
1455        // ==================================================================
1456        if self.metadata.num_documents <= config.start_from_scratch {
1457            // Load existing embeddings if available
1458            let existing_embeddings = load_embeddings_npy(index_path)?;
1459
1460            // Check if embeddings.npy is in sync with the index.
1461            // If not (e.g., after delete when index was above threshold), we can't do
1462            // start-from-scratch mode because we don't have all the old embeddings.
1463            // Fall through to buffer mode instead.
1464            if existing_embeddings.len() == self.metadata.num_documents {
1465                // New documents start after existing documents
1466                let start_doc_id = existing_embeddings.len() as i64;
1467
1468                // Combine existing + new embeddings
1469                let combined_embeddings: Vec<Array2<f32>> = existing_embeddings
1470                    .into_iter()
1471                    .chain(embeddings.iter().cloned())
1472                    .collect();
1473
1474                // Build IndexConfig from UpdateConfig for create_with_kmeans
1475                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                // Rebuild index from scratch with fresh K-means
1488                *self = Self::create_with_kmeans(&combined_embeddings, &path_str, &index_config)?;
1489
1490                // If we've crossed the threshold, clear embeddings.npy
1491                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 the document IDs assigned to the new embeddings
1498                return Ok((start_doc_id..start_doc_id + num_new_docs as i64).collect());
1499            }
1500            // else: embeddings.npy is out of sync, fall through to buffer mode
1501        }
1502
1503        // Load buffer
1504        let buffer = load_buffer(index_path)?;
1505        let buffer_len = buffer.len();
1506        let total_new = embeddings.len() + buffer_len;
1507
1508        // Track the starting document ID for the new embeddings
1509        let start_doc_id: i64;
1510
1511        // Load codec for update operations
1512        let mut codec = ResidualCodec::load_from_dir(index_path)?;
1513
1514        // Check buffer threshold
1515        if total_new >= config.buffer_size {
1516            // Centroid expansion path (matches fast-plaid update.py:376-422)
1517
1518            // 1. Get number of buffered docs that were previously indexed
1519            let num_buffered = load_buffer_info(index_path)?;
1520
1521            // 2. Delete buffered docs from index (they were indexed without centroid expansion)
1522            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                // Reload metadata after delete
1529                self.metadata = Metadata::load_from_path(index_path)?;
1530            }
1531
1532            // New embeddings start after buffer is re-indexed
1533            start_doc_id = (self.metadata.num_documents + buffer_len) as i64;
1534
1535            // 3. Combine buffer + new embeddings
1536            let combined: Vec<Array2<f32>> = buffer
1537                .into_iter()
1538                .chain(embeddings.iter().cloned())
1539                .collect();
1540
1541            // 4. Expand centroids with outliers from combined embeddings
1542            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                    // Reload codec with new centroids
1547                    codec = ResidualCodec::load_from_dir(index_path)?;
1548                }
1549            }
1550
1551            // 5. Clear buffer
1552            clear_buffer(index_path)?;
1553
1554            // 6. Update index with ALL combined embeddings (buffer + new)
1555            update_index(
1556                &combined,
1557                &path_str,
1558                &codec,
1559                Some(config.batch_size),
1560                true,
1561                config.force_cpu,
1562            )?;
1563        } else {
1564            // Small update: add to buffer and index without centroid expansion
1565            // New documents start at current num_documents
1566            start_doc_id = self.metadata.num_documents as i64;
1567
1568            // Accumulate buffer: combine existing buffer with new embeddings
1569            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 without threshold update
1576            update_index(
1577                embeddings,
1578                &path_str,
1579                &codec,
1580                Some(config.batch_size),
1581                false,
1582                config.force_cpu,
1583            )?;
1584        }
1585
1586        // Reload self as mmap
1587        *self = Self::load(&path_str)?;
1588
1589        // Return the document IDs assigned to the new embeddings
1590        Ok((start_doc_id..start_doc_id + num_new_docs as i64).collect())
1591    }
1592
1593    /// Update the index with new documents and optional metadata.
1594    ///
1595    /// # Arguments
1596    ///
1597    /// * `embeddings` - New document embeddings to add
1598    /// * `config` - Update configuration
1599    /// * `metadata` - Optional metadata for new documents
1600    ///
1601    /// # Returns
1602    ///
1603    /// Vector of document IDs assigned to the new embeddings
1604    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        // Validate metadata length if provided
1611        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        // Perform the update and get document IDs
1622        let doc_ids = self.update(embeddings, config)?;
1623
1624        // Add metadata if provided, using the assigned document IDs
1625        if let Some(meta) = metadata {
1626            crate::filtering::update(&self.path, meta, &doc_ids)?;
1627        }
1628
1629        Ok(doc_ids)
1630    }
1631
1632    /// Update an existing index or create a new one if it doesn't exist.
1633    ///
1634    /// # Arguments
1635    ///
1636    /// * `embeddings` - Document embeddings to add
1637    /// * `index_path` - Directory for the index
1638    /// * `index_config` - Configuration for index creation
1639    /// * `update_config` - Configuration for updates
1640    ///
1641    /// # Returns
1642    ///
1643    /// A tuple of (MmapIndex, `Vec<i64>`) containing the index and document IDs
1644    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            // Index exists, load and update
1655            let mut index = Self::load(index_path)?;
1656            let doc_ids = index.update(embeddings, update_config)?;
1657            Ok((index, doc_ids))
1658        } else {
1659            // Index doesn't exist, create new
1660            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    /// Append embeddings to an existing index without loading the full MmapIndex.
1668    ///
1669    /// Faster than `update_or_create` for incremental updates because it does not
1670    /// eagerly regenerate the merged code/residual files (628MB+ on large indices).
1671    /// NOTE: this *defers* that cost rather than removing it — `update_index` clears
1672    /// the merged files, and the next search/load lazily regenerates them. So an
1673    /// `index`-only run (no search after) is fast, but the first search following an
1674    /// update still pays the merge. Returns the doc IDs assigned to `embeddings`.
1675    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    /// Update an existing index or create a new one, with metadata and automatic
1702    /// FTS5 full-text indexing.
1703    ///
1704    /// This is the primary entry point for streaming document ingestion. On each
1705    /// call, embeddings and their metadata are added to the index. The FTS5
1706    /// full-text search index over metadata is kept in sync automatically.
1707    ///
1708    /// # Arguments
1709    ///
1710    /// * `embeddings` - Document embeddings to add
1711    /// * `index_path` - Directory for the index
1712    /// * `index_config` - Configuration for index creation (used only on first call)
1713    /// * `update_config` - Configuration for updates
1714    /// * `metadata` - Optional metadata for the documents (one JSON object per embedding)
1715    ///
1716    /// # Returns
1717    ///
1718    /// A tuple of (MmapIndex, `Vec<i64>`) containing the index and assigned document IDs
1719    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            // Index metadata into FTS5 for full-text search
1757            crate::text_search::index(index_path, meta, &doc_ids, &index_config.fts_tokenizer)?;
1758        }
1759
1760        Ok((index, doc_ids))
1761    }
1762
1763    /// Reload the index from disk.
1764    ///
1765    /// This should be called after delete operations to refresh the in-memory
1766    /// representation with the updated on-disk state.
1767    pub fn reload(&mut self) -> Result<()> {
1768        let path = self.path.clone();
1769        // Release mmap handles before reloading so that merge_*_chunks can
1770        // rename/overwrite the merged files on Windows (OS error 1224).
1771        self.release_mmaps();
1772        *self = Self::load(&path)?;
1773        Ok(())
1774    }
1775
1776    /// Delete documents from the index.
1777    ///
1778    /// This performs the deletion on disk but does NOT reload the in-memory index.
1779    /// Call `reload()` after all delete operations are complete to refresh the index.
1780    ///
1781    /// # Arguments
1782    ///
1783    /// * `doc_ids` - Slice of document IDs to delete (0-indexed)
1784    ///
1785    /// # Returns
1786    ///
1787    /// The number of documents actually deleted
1788    pub fn delete(&mut self, doc_ids: &[i64]) -> Result<usize> {
1789        self.delete_with_options(doc_ids, true)
1790    }
1791
1792    /// Delete documents from the index with control over metadata deletion.
1793    ///
1794    /// This performs the deletion on disk but does NOT reload the in-memory index.
1795    /// Call `reload()` after all delete operations are complete to refresh the index.
1796    ///
1797    /// # Arguments
1798    ///
1799    /// * `doc_ids` - Slice of document IDs to delete
1800    /// * `delete_metadata` - If true, also delete from metadata.db if it exists
1801    ///
1802    /// # Returns
1803    ///
1804    /// The number of documents actually deleted
1805    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        // Release mmap handles before deletion. delete_from_index calls
1810        // clear_merged_files which removes the memory-mapped merged files.
1811        // On Windows this fails with OS error 1224 if the mmaps are active.
1812        self.release_mmaps();
1813
1814        // Perform the deletion using standalone function
1815        let deleted = crate::delete::delete_from_index(doc_ids, &path)?;
1816
1817        // Also delete from metadata.db if requested
1818        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                // filtering::delete re-sequences the surviving _subset_ IDs. When the
1823                // deleted IDs are exactly the tail of the ID space, every survivor
1824                // keeps its ID, so the FTS5 rows for survivors stay aligned and only
1825                // the deleted rows need removing — O(deleted) instead of the
1826                // O(total documents) drop-and-rebuild.
1827                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                    // FTS rowids are stable _content_id_ values, unaffected by
1840                    // the _subset_ re-sequencing; filtering::delete removed the
1841                    // deleted docs' FTS rows inside its own transaction.
1842                } else if is_suffix_delete {
1843                    crate::text_search::delete(&path, &valid)?;
1844                } else {
1845                    // Survivor IDs shifted; FTS5 rowids no longer match
1846                    // METADATA. For split-schema DBs this rebuild also
1847                    // migrates the FTS to the content-id keyed layout, so it
1848                    // runs at most once per legacy index.
1849                    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    /// FTS5 must stay aligned with METADATA `_subset_` IDs after deletes.
1875    /// Suffix deletes keep every survivor's ID, so only the deleted FTS rows
1876    /// are removed (O(deleted)); any other delete shifts survivor IDs and
1877    /// must fall back to the full rebuild.
1878    #[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        // Suffix delete: survivors 0..=2 keep their IDs; only rows 3 and 4
1932        // leave the FTS index.
1933        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        // Non-suffix delete: survivor IDs shift (bravo→0, charlie→1), so the
1943        // FTS index must be rebuilt against the new numbering.
1944        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        // Create test embeddings (5 documents)
1962        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            // Normalize rows
1971            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        // Index doesn't exist - should create new
1990        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        // Verify index was created
1998        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        // Helper to create embeddings
2011        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        // First call - creates index with 5 documents
2042        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 previous index to release mmap handles before updating.
2050        // On Windows, files cannot be modified while memory-mapped.
2051        drop(index1);
2052
2053        // Second call - updates existing index with 3 more documents
2054        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}