Skip to main content

sparse_vector/
handle.rs

1//! Sparse vector index handle with mmap persistence.
2//!
3//! Commit writes a flat binary mmap format (sparse.mmap) + bincode side files.
4//! Open mmap's the posting data (O(1)), vectors + dims loaded lazily.
5//! Search uses mmap iterators when available (no RAM postings or vectors needed).
6//! Mutations load postings + vectors into RAM on first access, set dirty flag.
7//!
8//! Two storage modes:
9//! - **Filesystem** (`create`/`open`): files live directly in the given directory.
10//! - **BlobStore** (`create_with_store`/`open_with_store`): source of truth is the
11//!   BlobStore; a local tmpdir is used as mmap cache. Cleaned up on Drop.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Arc, Mutex};
17
18use crate::blob_store::BlobStore;
19use crate::index::{SparseIndex, SparseVector};
20use crate::mmap_index::{self, MmapPostingData};
21use crate::segments::{self, IndexMeta, Segment, SegmentMeta};
22use crate::wand::Postings;
23
24const MMAP_FILE: &str = "sparse.mmap";
25const VECTORS_FILE: &str = "sparse_vectors.bin";
26const DIMS_FILE: &str = "sparse_dims.bin";
27/// Legacy bincode file (read-only fallback).
28const LEGACY_FILE: &str = "sparse.bin";
29
30/// Files that make up a sparse index (new format).
31/// What the single-file layout wrote, and what a commit removes once the
32/// index is made of segments. A segmented index's file list is its
33/// manifest's (`IndexMeta::files`), which changes at every commit.
34const STALE_FILES: &[&str] = &[MMAP_FILE, VECTORS_FILE, DIMS_FILE, LEGACY_FILE];
35
36/// BlobStore key prefix — ensures no collision with other index types (FTS, etc.)
37const BLOB_PREFIX: &str = "Sparse_";
38
39/// Monotonic counter for unique tmpdir names.
40static CACHE_SEQ: AtomicUsize = AtomicUsize::new(0);
41
42/// Storage backend for persistence.
43enum StorageBackend {
44    /// Files live directly in `path`. No external store.
45    Filesystem,
46    /// Source of truth is a BlobStore. `path` is a local tmpdir cache for mmap.
47    Store {
48        store: Arc<dyn BlobStore>,
49        index_name: String,
50    },
51}
52
53struct Inner {
54    /// In segmented mode: the vectors inserted since the last commit, and
55    /// nothing else. In legacy mode: the whole index, once loaded.
56    index: SparseIndex,
57    /// Legacy single-file index (`sparse.mmap`, versions 1 to 3 written
58    /// before segments). `None` in segmented mode.
59    mmap: Option<MmapPostingData>,
60    /// True if RAM postings are loaded (always true after create or mutation).
61    postings_loaded: bool,
62    /// True if vectors HashMap is loaded (always true after create or mutation).
63    vectors_loaded: bool,
64    /// Cached doc count (valid even when vectors not loaded): the segments'
65    /// live vectors plus what is in RAM.
66    num_vectors: usize,
67    dirty: bool,
68    /// The committed segments, oldest first, and the manifest that names
69    /// them. Empty in legacy mode.
70    segments: Vec<Segment>,
71    meta: IndexMeta,
72    /// Segments this handle has written, for the next segment's id.
73    written: u64,
74}
75
76/// An index is segmented when `meta.json` is there. One that is not gets
77/// converted by its next commit — the whole-file write it did anyway.
78impl Inner {
79    fn segmented(&self) -> bool {
80        self.mmap.is_none() || !self.meta.segments.is_empty()
81    }
82}
83
84/// The fields of an index that has nothing yet.
85fn empty_inner() -> Inner {
86    Inner {
87        index: SparseIndex::new(),
88        mmap: None,
89        postings_loaded: true,
90        vectors_loaded: true,
91        num_vectors: 0,
92        dirty: false,
93        segments: Vec::new(),
94        meta: IndexMeta::default(),
95        written: 0,
96    }
97}
98
99pub struct SparseHandle {
100    inner: Mutex<Inner>,
101    path: PathBuf,
102    backend: StorageBackend,
103}
104
105impl SparseHandle {
106    // -----------------------------------------------------------------------
107    // Filesystem lifecycle (existing API, unchanged behavior)
108    // -----------------------------------------------------------------------
109
110    /// Create a new empty sparse index at the given path.
111    pub fn create(path: &str) -> Result<Self, String> {
112        std::fs::create_dir_all(Path::new(path))
113            .map_err(|e| format!("cannot create directory {path}: {e}"))?;
114        let handle = Self {
115            inner: Mutex::new(empty_inner()),
116            path: PathBuf::from(path),
117            backend: StorageBackend::Filesystem,
118        };
119        handle.commit_inner()?;
120        Ok(handle)
121    }
122
123    /// Open an existing sparse index.
124    /// Tries new mmap format first, falls back to legacy bincode.
125    pub fn open(path: &str) -> Result<Self, String> {
126        Self::open_backed(Path::new(path), StorageBackend::Filesystem)
127    }
128
129    /// Open whatever is in `base`: segments (`meta.json`), the single-file
130    /// mmap that came before them, or the bincode that came before that.
131    fn open_backed(base: &Path, backend: StorageBackend) -> Result<Self, String> {
132        if base.join(segments::META_FILE).exists() {
133            Self::open_segmented(base, backend)
134        } else if base.join(MMAP_FILE).exists() {
135            Self::open_mmap(base, backend)
136        } else {
137            Self::open_legacy(base)
138        }
139    }
140
141    /// Open a segmented index: the manifest, then each segment it names.
142    /// Nothing is read into RAM — a search walks the mappings.
143    fn open_segmented(base: &Path, backend: StorageBackend) -> Result<Self, String> {
144        let meta = IndexMeta::read(base)?;
145        let mut segments = Vec::with_capacity(meta.segments.len());
146        for sm in &meta.segments {
147            segments.push(Segment::open(base, sm.clone())?);
148        }
149        let num_vectors = meta.live_vectors();
150        Ok(Self {
151            inner: Mutex::new(Inner {
152                num_vectors,
153                segments,
154                meta,
155                ..empty_inner()
156            }),
157            path: base.to_path_buf(),
158            backend,
159        })
160    }
161
162    // -----------------------------------------------------------------------
163    // BlobStore lifecycle
164    // -----------------------------------------------------------------------
165
166    /// Create a new empty sparse index backed by a BlobStore.
167    ///
168    /// `cache_base` is the root directory for mmap caches. Inside it, a unique
169    /// subdirectory `{pid}/{index_name}_{seq}` is created automatically.
170    /// Source of truth is the store.
171    pub fn create_with_store(
172        store: Arc<dyn BlobStore>,
173        index_name: &str,
174        cache_base: &Path,
175    ) -> Result<Self, String> {
176        let blob_name = format!("{BLOB_PREFIX}{index_name}");
177        let cache_dir = Self::make_cache_dir(cache_base, &blob_name)?;
178
179        let handle = Self {
180            inner: Mutex::new(empty_inner()),
181            path: cache_dir,
182            backend: StorageBackend::Store {
183                store,
184                index_name: blob_name,
185            },
186        };
187        handle.commit_inner()?;
188        Ok(handle)
189    }
190
191    /// Open an existing sparse index from a BlobStore.
192    ///
193    /// `cache_base` is the root directory for mmap caches. Blobs are materialized
194    /// from the store into `{cache_base}/{pid}/{index_name}_{seq}/`, then mmap'd.
195    pub fn open_with_store(
196        store: Arc<dyn BlobStore>,
197        index_name: &str,
198        cache_base: &Path,
199    ) -> Result<Self, String> {
200        let blob_name = format!("{BLOB_PREFIX}{index_name}");
201        let cache_dir = Self::make_cache_dir(cache_base, &blob_name)?;
202
203        // Materialize all blobs from store to cache_dir
204        let files = store
205            .list(&blob_name)
206            .map_err(|e| format!("cannot list blobs for {blob_name}: {e}"))?;
207
208        for file_name in &files {
209            let data = store
210                .load(&blob_name, file_name)
211                .map_err(|e| format!("cannot load {blob_name}/{file_name}: {e}"))?;
212            // The local cache of a blob-backed shard is opened like any
213            // other index: an interrupted download must not leave half a file.
214            mmap_index::write_file_atomic(&cache_dir.join(file_name), &data)?;
215        }
216
217        let backend = StorageBackend::Store {
218            store,
219            index_name: blob_name,
220        };
221
222        // Open from cache_dir (same logic as filesystem open)
223        if cache_dir.join(segments::META_FILE).exists() || cache_dir.join(MMAP_FILE).exists() {
224            Self::open_backed(&cache_dir, backend)
225        } else {
226            // Empty index (no files in store yet) — create fresh
227            let handle = Self {
228                inner: Mutex::new(empty_inner()),
229                path: cache_dir,
230                backend,
231            };
232            handle.commit_inner()?;
233            Ok(handle)
234        }
235    }
236
237    /// Create a unique cache directory for BlobStore mmap files.
238    ///
239    /// Layout: `{base}/{pid}/{index_name}_{seq}/`
240    /// - PID isolates between processes
241    /// - Atomic seq isolates between threads / multiple opens
242    fn make_cache_dir(base: &Path, index_name: &str) -> Result<PathBuf, String> {
243        let seq = CACHE_SEQ.fetch_add(1, Ordering::Relaxed);
244        let pid = std::process::id();
245        let dir = base
246            .join(format!("{pid}"))
247            .join(format!("{index_name}_{seq}"));
248        std::fs::create_dir_all(&dir)
249            .map_err(|e| format!("cannot create cache dir {}: {e}", dir.display()))?;
250        Ok(dir)
251    }
252
253    // -----------------------------------------------------------------------
254    // Shared open helpers
255    // -----------------------------------------------------------------------
256
257    /// Open using the new mmap format.
258    /// Only mmap + dims are loaded. Postings and vectors are lazy.
259    fn open_mmap(base: &Path, backend: StorageBackend) -> Result<Self, String> {
260        let mmap = MmapPostingData::open(&base.join(MMAP_FILE))?;
261
262        // A version 3 file names its own dimensions, in order: the side
263        // file is what a dense table needed, and it may not even be there.
264        let (dim_map, dim_reverse): (HashMap<u32, usize>, Vec<u32>) = if mmap.has_global_dims() {
265            let reverse: Vec<u32> = mmap.tokens().map(|(t, _)| t).collect();
266            let map = reverse.iter().enumerate().map(|(i, &t)| (t, i)).collect();
267            (map, reverse)
268        } else {
269            let dims_data = std::fs::read(base.join(DIMS_FILE))
270                .map_err(|e| format!("cannot read {DIMS_FILE}: {e}"))?;
271            bincode::deserialize(&dims_data)
272                .map_err(|e| format!("cannot deserialize dims: {e}"))?
273        };
274
275        let num_dims = mmap.num_dims();
276        let num_vectors = mmap.num_vectors();
277        let empty_postings: Vec<Postings> = (0..num_dims).map(|_| Postings::new()).collect();
278        // Empty vectors — will be loaded lazily on first mutation
279        let index =
280            SparseIndex::from_parts(dim_map, dim_reverse, empty_postings, HashMap::new());
281
282        Ok(Self {
283            inner: Mutex::new(Inner {
284                index,
285                mmap: Some(mmap),
286                postings_loaded: false,
287                vectors_loaded: false,
288                num_vectors,
289                ..empty_inner()
290            }),
291            path: base.to_path_buf(),
292            backend,
293        })
294    }
295
296    /// Open using legacy bincode format (sparse.bin).
297    fn open_legacy(base: &Path) -> Result<Self, String> {
298        let data_path = base.join(LEGACY_FILE);
299        let data = std::fs::read(&data_path)
300            .map_err(|e| format!("cannot read {}: {e}", data_path.display()))?;
301        let index: SparseIndex = bincode::deserialize(&data)
302            .map_err(|e| format!("cannot deserialize sparse index: {e}"))?;
303        let num_vectors = index.len();
304        Ok(Self {
305            inner: Mutex::new(Inner { index, num_vectors, ..empty_inner() }),
306            path: base.to_path_buf(),
307            backend: StorageBackend::Filesystem,
308        })
309    }
310
311    // -----------------------------------------------------------------------
312    // Lazy loading
313    // -----------------------------------------------------------------------
314
315    /// Ensure RAM postings are loaded (materializes from mmap if needed).
316    fn ensure_postings_loaded(inner: &mut Inner) {
317        if inner.postings_loaded {
318            return;
319        }
320        if let Some(ref mmap) = inner.mmap {
321            // A version 3 file's table is sorted by token id, so a position
322            // in the file is not the RAM index's dimension: each list is
323            // loaded by its token. Reading by position there loaded every
324            // dimension under the wrong one, silently.
325            if mmap.has_global_dims() {
326                let tokens: Vec<u32> = inner.index.dim_reverse().to_vec();
327                let postings = inner.index.postings_mut();
328                for (i, pl) in postings.iter_mut().enumerate() {
329                    // A dimension the mapping does not name has no postings
330                    // here rather than someone else's (a dims side file that
331                    // disagrees with the mapping used to index out of bounds).
332                    *pl = match tokens.get(i) {
333                        Some(&token) => mmap.load_postings_of_token(token),
334                        None => Postings::new(),
335                    };
336                }
337            } else {
338                let postings = inner.index.postings_mut();
339                for (i, pl) in postings.iter_mut().enumerate() {
340                    *pl = mmap.load_postings(i);
341                }
342            }
343        }
344        inner.postings_loaded = true;
345    }
346
347    /// Ensure vectors HashMap is loaded (deserializes from disk if needed).
348    fn ensure_vectors_loaded(inner: &mut Inner, path: &Path) -> Result<(), String> {
349        if inner.vectors_loaded {
350            return Ok(());
351        }
352        // A segmented index keeps no vectors on disk: `index` holds the
353        // delta, which starts empty, and a segment's ids are what tells
354        // whether it holds a document (see `segments::Segment::holds`).
355        if inner.segmented() {
356            inner.vectors_loaded = true;
357            return Ok(());
358        }
359        let vectors_path = path.join(VECTORS_FILE);
360        // A single file that names its own dimensions (version 3) does not
361        // need this one: it was kept to know which dimensions a deletion
362        // touches, and the ids of the segment it converts into are read from
363        // its posting lists. A dense file still needs it.
364        if !vectors_path.exists()
365            && inner.mmap.as_ref().is_some_and(|m| m.has_global_dims())
366        {
367            inner.vectors_loaded = true;
368            return Ok(());
369        }
370        let data = std::fs::read(&vectors_path)
371            .map_err(|e| format!("cannot read {}: {e}", vectors_path.display()))?;
372        let vectors: HashMap<u64, SparseVector> = bincode::deserialize(&data)
373            .map_err(|e| format!("cannot deserialize vectors: {e}"))?;
374        inner.index.set_vectors(vectors);
375        inner.vectors_loaded = true;
376        Ok(())
377    }
378
379    // -----------------------------------------------------------------------
380    // Public API (called from bridge)
381    // -----------------------------------------------------------------------
382
383    pub fn insert(&self, node_id: u64, vector: &SparseVector) -> Result<(), String> {
384        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
385        Self::ensure_vectors_loaded(&mut inner, &self.path)?;
386        Self::ensure_postings_loaded(&mut inner);
387        // An update: the copies already committed are hidden, and the one
388        // going into RAM will be written into a later segment, which no
389        // tombstone covers.
390        Self::tombstone_committed(&mut inner, node_id)?;
391        inner.index.insert(node_id, vector);
392        inner.num_vectors = Self::count(&inner);
393        inner.dirty = true;
394        Ok(())
395    }
396
397    pub fn remove(&self, node_id: u64) -> Result<bool, String> {
398        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
399        Self::ensure_vectors_loaded(&mut inner, &self.path)?;
400        Self::ensure_postings_loaded(&mut inner);
401        let from_segments = Self::tombstone_committed(&mut inner, node_id)?;
402        let from_ram = inner.index.remove(node_id);
403        let removed = from_segments || from_ram;
404        if removed {
405            inner.num_vectors = Self::count(&inner);
406            inner.dirty = true;
407        }
408        Ok(removed)
409    }
410
411    /// Hide `node_id` in every segment that was written with it. Answers
412    /// whether any segment was holding it.
413    fn tombstone_committed(inner: &mut Inner, node_id: u64) -> Result<bool, String> {
414        let mut hit = false;
415        for seg in &mut inner.segments {
416            if seg.holds(node_id)? && seg.tombstone(node_id) {
417                hit = true;
418            }
419        }
420        if hit {
421            // The manifest owns the tombstones; keep it in step with the
422            // open segments so a commit writes them out.
423            for (sm, seg) in inner.meta.segments.iter_mut().zip(inner.segments.iter()) {
424                sm.deleted = seg.meta.deleted.clone();
425            }
426        }
427        Ok(hit)
428    }
429
430    /// Live documents: the segments' minus their tombstones, plus RAM.
431    fn count(inner: &Inner) -> usize {
432        inner.meta.live_vectors() + inner.index.len()
433    }
434
435    pub fn search(&self, query: &SparseVector, limit: usize) -> Vec<(u64, f32)> {
436        let inner = self.inner.lock().unwrap();
437        if !inner.segments.is_empty() {
438            return Self::search_segments(&inner, query, limit, None);
439        }
440        if !inner.dirty {
441            if let Some(ref mmap) = inner.mmap {
442                return mmap_index::search_mmap(
443                    mmap,
444                    inner.index.dim_map(),
445                    query,
446                    limit,
447                    &|_| true,
448                );
449            }
450        }
451        inner.index.search(query, limit)
452    }
453
454    /// Search every segment, then what is still in RAM, and keep the best
455    /// `limit`. A live document sits in exactly one of them — a tombstone
456    /// hides the copies a later insert replaced — so the merge has nothing
457    /// to deduplicate: it is a sort and a truncation, the same one the
458    /// sharded handle does across shards.
459    ///
460    /// `allowed` is passed **down** rather than applied as a predicate: a
461    /// selective set is answered by a binary search per lane, where a
462    /// predicate walks every posting of every lane
463    /// ([`crate::index::run_search_allowed`] weighs the two). Only a segment
464    /// that actually holds tombstones pays for taking them out of the set
465    /// first; the usual case hands the ids straight through.
466    ///
467    /// The WAND pruning happens inside each segment rather than over the
468    /// whole index; that is the price of segments, and what a merge buys
469    /// back.
470    fn search_segments(
471        inner: &Inner,
472        query: &SparseVector,
473        limit: usize,
474        allowed: Option<&[u64]>,
475    ) -> Vec<(u64, f32)> {
476        let no_dims = HashMap::new();
477        let mut all: Vec<(u64, f32)> = Vec::new();
478        for seg in &inner.segments {
479            if seg.data.num_vectors() == 0 {
480                continue;
481            }
482            let hits = match allowed {
483                Some(ids) if seg.meta.deleted.is_empty() => {
484                    mmap_index::search_mmap_allowed(&seg.data, &no_dims, query, limit, ids)
485                }
486                Some(ids) => {
487                    let live: Vec<u64> = ids.iter().copied().filter(|&id| seg.is_live(id)).collect();
488                    mmap_index::search_mmap_allowed(&seg.data, &no_dims, query, limit, &live)
489                }
490                None => mmap_index::search_mmap(
491                    &seg.data, &no_dims, query, limit, &|id| seg.is_live(id)),
492            };
493            all.extend(hits);
494        }
495        if !inner.index.is_empty() {
496            all.extend(match allowed {
497                Some(ids) => inner.index.search_filtered(query, limit, ids),
498                None => inner.index.search(query, limit),
499            });
500        }
501        all.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
502        all.truncate(limit);
503        all
504    }
505
506    /// Top-`limit` records among `allowed_ids` only.
507    ///
508    /// A sparse score is a plain dot product with no corpus statistics, so
509    /// this is exactly [`Self::search`] intersected with the set: the same
510    /// documents in the same order, with the same scores (to a few units in
511    /// the last place — the two paths add a document's lanes in a different
512    /// order). Pinned by `tests/test_filter_truth.rs`.
513    ///
514    /// **Hand over sorted, unique ids** when you can: the set is then read
515    /// where it is, and the filter costs between ×0.15 (a very selective
516    /// set, which is faster than searching everything) and ×1.3 of an
517    /// unfiltered search at any size — 540 000 ids answer in 0.22 ms where
518    /// they took 6.0 ms before (`tests/bench_filter_selectivity.rs`). An
519    /// unsorted set is copied, sorted and deduplicated at **every** query.
520    pub fn search_filtered(
521        &self,
522        query: &SparseVector,
523        limit: usize,
524        allowed_ids: &[u64],
525    ) -> Vec<(u64, f32)> {
526        let inner = self.inner.lock().unwrap();
527        if !inner.segments.is_empty() {
528            return Self::search_segments(&inner, query, limit, Some(allowed_ids));
529        }
530        if !inner.dirty {
531            if let Some(ref mmap) = inner.mmap {
532                return mmap_index::search_mmap_allowed(
533                    mmap,
534                    inner.index.dim_map(),
535                    query,
536                    limit,
537                    allowed_ids,
538                );
539            }
540        }
541        inner.index.search_filtered(query, limit, allowed_ids)
542    }
543
544    /// Merge every segment into one, applying the tombstones — the walk
545    /// over sorted token tables described in [`crate::segments`]. What it
546    /// buys: one mapping to search instead of N, WAND pruning over the whole
547    /// index again, and the deleted documents' bytes back.
548    ///
549    /// Commits are cheap because they append; this is where that is paid,
550    /// once, when the caller decides. Nothing is lost if it is interrupted:
551    /// the manifest is only rewritten once the merged segment is on disk.
552    pub fn compact(&self) -> Result<(), String> {
553        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
554        if inner.dirty {
555            drop(inner);
556            self.commit_inner()?;
557            inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
558        }
559        if inner.segments.len() < 2 {
560            return Ok(());
561        }
562
563        inner.written += 1;
564        let new_id = segments::new_segment_id(inner.written);
565        let sources: Vec<&Segment> = inner.segments.iter().collect();
566        let merged = segments::merge_segments(&self.path, &sources, &new_id)?;
567        let dropped: Vec<String> = inner.segments.iter()
568            .flat_map(|s| [segments::segment_file(&s.meta.id), segments::ids_file(&s.meta.id)])
569            .collect();
570
571        // The manifest is what makes the merge real; until it is written,
572        // the index is still its old segments.
573        inner.meta.segments = vec![merged.clone()];
574        inner.meta.write(&self.path)?;
575        inner.segments = vec![Segment::open(&self.path, merged)?];
576        inner.num_vectors = Self::count(&inner);
577
578        if let StorageBackend::Store { ref store, ref index_name } = self.backend {
579            for file in [segments::segment_file(&new_id), segments::ids_file(&new_id), segments::META_FILE.to_string()] {
580                let data = std::fs::read(self.path.join(&file))
581                    .map_err(|e| format!("cannot read cache {file}: {e}"))?;
582                store.save(index_name, &file, &data)
583                    .map_err(|e| format!("cannot save {index_name}/{file} to store: {e}"))?;
584            }
585        }
586        // The old segments, now that nothing names them.
587        for file in dropped {
588            let _ = std::fs::remove_file(self.path.join(&file));
589            if let StorageBackend::Store { ref store, ref index_name } = self.backend {
590                let _ = store.delete(index_name, &file);
591            }
592        }
593        Ok(())
594    }
595
596    /// Segments a commit leaves before merging them, from
597    /// `LUCIVY_SPARSE_MAX_SEGMENTS` (`0` never merges on its own).
598    ///
599    /// **Eight, and not for search speed** — the segment count does not
600    /// measurably change it. Three runs of `tests/bench_segment_search.rs`
601    /// on an idle machine, 40 000 documents, 200 real BGE-M3 queries:
602    /// 0.06 ms on one segment, 0.07-0.08 ms on a hundred, with the same
603    /// numbers on a corpus drawn from text. Splitting the index splits the
604    /// posting lists with it, and WAND prunes inside each piece; what a
605    /// segment adds is a binary search per query dimension.
606    ///
607    /// What the cap is really for:
608    ///
609    /// - **files and mappings** — two files and one mapping per segment, per
610    ///   shard, all of them open;
611    /// - **the write path** — an insert or a delete asks every segment
612    ///   whether it holds the id (`Segment::holds`);
613    /// - **deleted bytes**, which only a merge reclaims.
614    ///
615    /// A merge costs O(index), so a higher cap is cheaper in merge work and
616    /// dearer in files; eight bounds an index to sixteen files a shard while
617    /// leaving seven commits out of eight paying only for their delta.
618    ///
619    /// Two numbers were published here before this one and both were wrong:
620    /// ×5.3 on twenty segments, measured on vectors spread uniformly with
621    /// every weight at 1.0 — a corpus where WAND cannot prune at all — and
622    /// ×7.8 on a hundred, measured on the real vectors while the machine was
623    /// busy producing them. See the bench's own notes.
624    fn max_segments() -> usize {
625        static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
626        *CAP.get_or_init(|| {
627            std::env::var("LUCIVY_SPARSE_MAX_SEGMENTS").ok()
628                .and_then(|v| v.parse().ok())
629                .unwrap_or(8)
630        })
631    }
632
633    /// How many segments the index is made of — what a compaction policy
634    /// watches, and what a search pays per query dimension.
635    pub fn num_segments(&self) -> usize {
636        self.inner.lock().map(|i| i.segments.len()).unwrap_or(0)
637    }
638
639    pub fn len(&self) -> usize {
640        self.inner.lock().unwrap().num_vectors
641    }
642
643    pub fn is_empty(&self) -> bool {
644        self.len() == 0
645    }
646
647    /// Write index to disk in the new mmap format, then re-mmap.
648    /// If store-backed, also persists to BlobStore.
649    /// Write what is in RAM as a **new segment** and point the manifest at
650    /// it. What was already committed is not touched: the cost of a commit
651    /// is the cost of the delta, where it used to be the cost of the whole
652    /// index (`tests/bench_commit_cost.rs`).
653    ///
654    /// An index written before segments is converted here, once: its whole
655    /// content becomes segment zero — the full write it did at every commit
656    /// anyway — and `meta.json` appears next to it.
657    pub fn commit_inner(&self) -> Result<(), String> {
658        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
659
660        let converting = inner.mmap.is_some();
661        if converting {
662            // The old file holds everything; bring it into RAM so the
663            // segment written below is the whole index, then let it go.
664            Self::ensure_postings_loaded(&mut inner);
665            Self::ensure_vectors_loaded(&mut inner, &self.path)?;
666        } else if !inner.dirty && !inner.meta.segments.is_empty() {
667            // Nothing new, and the manifest is already on disk. A tombstone
668            // set `dirty`, so this only skips a genuinely idle commit.
669            return Ok(());
670        }
671
672        // ── The new segment ────────────────────────────────────────────
673        let mut written: Vec<String> = Vec::new();
674        let has_delta = !inner.index.is_empty()
675            || inner.index.postings().iter().any(|p| !p.is_empty());
676        if has_delta {
677            inner.written += 1;
678            let id = segments::new_segment_id(inner.written);
679            let file = segments::segment_file(&id);
680            mmap_index::write_mmap_file(
681                &self.path.join(&file),
682                inner.index.postings(),
683                inner.index.dim_reverse(),
684                inner.index.len() as u32,
685            )?;
686            // A segment's ids are the ids in its posting lists. In the
687            // normal path the RAM index holds them as keys; when converting
688            // an older index they are only in the postings, which have just
689            // been loaded from its file — the vectors side file may not even
690            // exist any more.
691            let mut ids: Vec<u64> = if converting {
692                let mut set = std::collections::HashSet::new();
693                for p in inner.index.postings() {
694                    for x in p.as_slice() { set.insert(x.id); }
695                }
696                set.into_iter().collect()
697            } else {
698                inner.index.vectors().keys().copied().collect()
699            };
700            ids.sort_unstable();
701            let ids_name = segments::ids_file(&id);
702            mmap_index::write_file_atomic(&self.path.join(&ids_name), &segments::encode_ids(&ids))?;
703            inner.meta.segments.push(SegmentMeta {
704                id,
705                num_vectors: ids.len() as u32,
706                deleted: Vec::new(),
707            });
708            written.push(file);
709            written.push(ids_name);
710        }
711
712        // ── The manifest, last: it is what makes the segment part of the
713        // index, and it is written atomically. A crash before this leaves
714        // an orphan file and an index that is exactly what it was.
715        inner.meta.version = segments::META_VERSION;
716        inner.meta.write(&self.path)?;
717        written.push(segments::META_FILE.to_string());
718
719        // ── Reopen what was just written, drop the RAM delta ───────────
720        let metas: Vec<SegmentMeta> = inner.meta.segments.clone();
721        let mut opened = Vec::with_capacity(metas.len());
722        for sm in metas {
723            opened.push(Segment::open(&self.path, sm)?);
724        }
725        inner.segments = opened;
726        inner.index = SparseIndex::new();
727        inner.mmap = None;
728        inner.postings_loaded = true;
729        inner.vectors_loaded = true;
730        inner.dirty = false;
731        inner.num_vectors = Self::count(&inner);
732
733        // ── Sync to the store, and drop what the old format left ───────
734        if let StorageBackend::Store { ref store, ref index_name } = self.backend {
735            for file in &written {
736                let data = std::fs::read(self.path.join(file))
737                    .map_err(|e| format!("cannot read cache {file}: {e}"))?;
738                store
739                    .save(index_name, file, &data)
740                    .map_err(|e| format!("cannot save {index_name}/{file} to store: {e}"))?;
741            }
742        }
743        // Whatever the old format left — the single mmap and its two side
744        // files, or the bincode before them — is not part of a segmented
745        // index. Dropped after the manifest names the segments, never before.
746        for &stale in STALE_FILES {
747            let path = self.path.join(stale);
748            if path.exists() {
749                let _ = std::fs::remove_file(&path);
750                if let StorageBackend::Store { ref store, ref index_name } = self.backend {
751                    let _ = store.delete(index_name, stale);
752                }
753            }
754        }
755
756        // Merge when the segments have piled up: cheap commits are paid for
757        // here, once every `max_segments()` of them (see `max_segments`).
758        let cap = Self::max_segments();
759        let pile = inner.segments.len();
760        drop(inner);
761        if cap > 0 && pile > cap {
762            self.compact()?;
763        }
764        Ok(())
765    }
766}
767
768impl Drop for SparseHandle {
769    fn drop(&mut self) {
770        // Only clean up cache_dir for store-backed handles (tmpdir we created).
771        // Filesystem handles use the user's data directory — never delete it.
772        if let StorageBackend::Store { .. } = &self.backend {
773            let _ = std::fs::remove_dir_all(&self.path);
774        }
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::blob_store::MemBlobStore;
782
783    fn tmp_path(name: &str) -> PathBuf {
784        std::env::temp_dir().join(name)
785    }
786
787    fn cleanup(path: &Path) {
788        let _ = std::fs::remove_dir_all(path);
789    }
790
791    // -----------------------------------------------------------------------
792    // Filesystem tests (unchanged)
793    // -----------------------------------------------------------------------
794
795    #[test]
796    fn create_writes_a_manifest_and_a_segment_per_commit() {
797        let p = tmp_path("sparse_mmap_create_test");
798        cleanup(&p);
799        let path = p.to_str().unwrap();
800
801        // An empty index is its manifest, and nothing else: no segment is
802        // written for no documents.
803        let handle = SparseHandle::create(path).unwrap();
804        assert!(p.join(crate::segments::META_FILE).exists());
805        assert_eq!(segment_files(&p).len(), 0);
806        assert!(!p.join(MMAP_FILE).exists(), "the single-file format is not written any more");
807
808        handle.insert(1, &SparseVector::new(vec![7], vec![1.0])).unwrap();
809        handle.commit_inner().unwrap();
810        assert_eq!(segment_files(&p).len(), 1);
811
812        // A second commit writes a second segment, not a rewrite of the first.
813        handle.insert(2, &SparseVector::new(vec![7], vec![1.0])).unwrap();
814        handle.commit_inner().unwrap();
815        assert_eq!(segment_files(&p).len(), 2);
816
817        let handle2 = SparseHandle::open(path).unwrap();
818        assert_eq!(handle2.len(), 2);
819        assert_eq!(handle2.search(&SparseVector::new(vec![7], vec![1.0]), 10).len(), 2);
820
821        cleanup(&p);
822    }
823
824    /// The `seg_*.mmap` files of an index directory.
825    fn segment_files(base: &Path) -> Vec<String> {
826        let mut names: Vec<String> = std::fs::read_dir(base).unwrap()
827            .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
828            .filter(|n| n.starts_with("seg_") && n.ends_with(".mmap"))
829            .collect();
830        names.sort();
831        names
832    }
833
834    #[test]
835    fn persistence_roundtrip_mmap() {
836        let p = tmp_path("sparse_mmap_roundtrip_test");
837        cleanup(&p);
838        let path = p.to_str().unwrap();
839
840        let handle = SparseHandle::create(path).unwrap();
841        handle
842            .insert(42, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
843            .unwrap();
844        handle
845            .insert(99, &SparseVector::new(vec![2, 3], vec![0.8, 0.2]))
846            .unwrap();
847        handle.commit_inner().unwrap();
848        drop(handle);
849
850        // Reopen — should use mmap path
851        let handle2 = SparseHandle::open(path).unwrap();
852        assert_eq!(handle2.len(), 2);
853
854        // Search via mmap (no RAM postings loaded)
855        let results = handle2.search(&SparseVector::new(vec![2], vec![1.0]), 10);
856        assert_eq!(results.len(), 2);
857        assert_eq!(results[0].0, 99);
858        assert!((results[0].1 - 0.8).abs() < 1e-6);
859        assert_eq!(results[1].0, 42);
860        assert!((results[1].1 - 0.3).abs() < 1e-6);
861
862        cleanup(&p);
863    }
864
865    #[test]
866    fn mmap_search_filtered() {
867        let p = tmp_path("sparse_mmap_filtered_test");
868        cleanup(&p);
869        let path = p.to_str().unwrap();
870
871        let handle = SparseHandle::create(path).unwrap();
872        handle
873            .insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
874            .unwrap();
875        handle
876            .insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]))
877            .unwrap();
878        handle
879            .insert(3, &SparseVector::new(vec![1], vec![0.7]))
880            .unwrap();
881        handle.commit_inner().unwrap();
882        drop(handle);
883
884        let handle2 = SparseHandle::open(path).unwrap();
885        let results = handle2.search_filtered(&SparseVector::new(vec![1], vec![1.0]), 10, &[1, 3]);
886        assert_eq!(results.len(), 2);
887        assert_eq!(results[0].0, 3); // 0.7
888        assert_eq!(results[1].0, 1); // 0.5
889
890        cleanup(&p);
891    }
892
893    #[test]
894    fn mutation_after_mmap_open() {
895        let p = tmp_path("sparse_mmap_mutation_test");
896        cleanup(&p);
897        let path = p.to_str().unwrap();
898
899        let handle = SparseHandle::create(path).unwrap();
900        handle
901            .insert(1, &SparseVector::new(vec![10], vec![1.0]))
902            .unwrap();
903        handle.commit_inner().unwrap();
904        drop(handle);
905
906        // Reopen, mutate (triggers postings load from mmap), search
907        let handle2 = SparseHandle::open(path).unwrap();
908        handle2
909            .insert(2, &SparseVector::new(vec![10], vec![2.0]))
910            .unwrap();
911
912        let results = handle2.search(&SparseVector::new(vec![10], vec![1.0]), 10);
913        assert_eq!(results.len(), 2);
914        assert_eq!(results[0].0, 2); // 2.0
915        assert_eq!(results[1].0, 1); // 1.0
916
917        // Commit and reopen again
918        handle2.commit_inner().unwrap();
919        drop(handle2);
920
921        let handle3 = SparseHandle::open(path).unwrap();
922        let results = handle3.search(&SparseVector::new(vec![10], vec![1.0]), 10);
923        assert_eq!(results.len(), 2);
924        assert_eq!(results[0].0, 2);
925
926        cleanup(&p);
927    }
928
929    #[test]
930    fn legacy_fallback() {
931        let p = tmp_path("sparse_mmap_legacy_test");
932        cleanup(&p);
933        let path = p.to_str().unwrap();
934
935        // Write legacy format manually
936        std::fs::create_dir_all(&p).unwrap();
937        let mut index = SparseIndex::new();
938        index.insert(7, &SparseVector::new(vec![1], vec![0.42]));
939        let data = bincode::serialize(&index).unwrap();
940        std::fs::write(p.join(LEGACY_FILE), data).unwrap();
941
942        // Open should fall back to legacy
943        let handle = SparseHandle::open(path).unwrap();
944        assert_eq!(handle.len(), 1);
945        let results = handle.search(&SparseVector::new(vec![1], vec![1.0]), 10);
946        assert_eq!(results[0].0, 7);
947
948        // Commit converts it to segments and drops the old files.
949        handle.commit_inner().unwrap();
950        assert!(p.join(crate::segments::META_FILE).exists());
951        assert_eq!(segment_files(&p).len(), 1);
952        assert!(!p.join(LEGACY_FILE).exists());
953        assert!(!p.join(MMAP_FILE).exists());
954        assert_eq!(handle.search(&SparseVector::new(vec![1], vec![1.0]), 10)[0].0, 7);
955
956        cleanup(&p);
957    }
958
959    #[test]
960    fn many_docs_mmap_roundtrip() {
961        let p = tmp_path("sparse_mmap_many_docs_test");
962        cleanup(&p);
963        let path = p.to_str().unwrap();
964
965        let handle = SparseHandle::create(path).unwrap();
966        for i in 0..500u64 {
967            let token = (i % 50) as u32;
968            let weight = (i as f32) / 500.0;
969            handle
970                .insert(
971                    i,
972                    &SparseVector::new(vec![token, token + 50], vec![weight, weight * 0.5]),
973                )
974                .unwrap();
975        }
976        handle.commit_inner().unwrap();
977        drop(handle);
978
979        let handle2 = SparseHandle::open(path).unwrap();
980        assert_eq!(handle2.len(), 500);
981
982        let results = handle2.search(&SparseVector::new(vec![0, 50], vec![1.0, 1.0]), 5);
983        assert_eq!(results.len(), 5);
984        // Doc 450 has weight 0.9 for token 0, 0.45 for token 50 → score 1.35
985        assert_eq!(results[0].0, 450);
986
987        cleanup(&p);
988    }
989
990    // -----------------------------------------------------------------------
991    // BlobStore tests
992    // -----------------------------------------------------------------------
993
994    fn test_cache_base() -> PathBuf {
995        std::env::temp_dir().join("sparse_test_cache")
996    }
997
998    #[test]
999    fn blob_store_create_and_search() {
1000        let store = Arc::new(MemBlobStore::new());
1001        let cb = test_cache_base();
1002        let handle = SparseHandle::create_with_store(store.clone(), "test_idx", &cb).unwrap();
1003
1004        handle
1005            .insert(42, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
1006            .unwrap();
1007        handle
1008            .insert(99, &SparseVector::new(vec![2, 3], vec![0.8, 0.2]))
1009            .unwrap();
1010        handle.commit_inner().unwrap();
1011
1012        // The store holds the manifest and the segment's two files.
1013        let names = store.list("Sparse_test_idx").unwrap();
1014        assert!(names.iter().any(|n| n == crate::segments::META_FILE), "{names:?}");
1015        assert_eq!(names.iter().filter(|n| n.ends_with(".mmap")).count(), 1, "{names:?}");
1016        assert_eq!(names.iter().filter(|n| n.ends_with(".ids")).count(), 1, "{names:?}");
1017
1018        // Search should work (mmap from cache)
1019        let results = handle.search(&SparseVector::new(vec![2], vec![1.0]), 10);
1020        assert_eq!(results.len(), 2);
1021        assert_eq!(results[0].0, 99);
1022    }
1023
1024    #[test]
1025    fn blob_store_close_reopen() {
1026        let store = Arc::new(MemBlobStore::new());
1027        let cb = test_cache_base();
1028
1029        // Create, insert, commit, drop
1030        {
1031            let handle = SparseHandle::create_with_store(store.clone(), "reopen_idx", &cb).unwrap();
1032            handle
1033                .insert(1, &SparseVector::new(vec![10], vec![1.0]))
1034                .unwrap();
1035            handle
1036                .insert(2, &SparseVector::new(vec![10, 20], vec![0.5, 0.8]))
1037                .unwrap();
1038            handle.commit_inner().unwrap();
1039        }
1040        // Handle dropped → cache_dir cleaned up
1041
1042        // Reopen from store
1043        let handle2 = SparseHandle::open_with_store(store.clone(), "reopen_idx", &cb).unwrap();
1044        assert_eq!(handle2.len(), 2);
1045
1046        let results = handle2.search(&SparseVector::new(vec![10], vec![1.0]), 10);
1047        assert_eq!(results.len(), 2);
1048        assert_eq!(results[0].0, 1); // 1.0
1049        assert_eq!(results[1].0, 2); // 0.5
1050    }
1051
1052    #[test]
1053    fn blob_store_mutation_after_reopen() {
1054        let store = Arc::new(MemBlobStore::new());
1055        let cb = test_cache_base();
1056
1057        {
1058            let handle = SparseHandle::create_with_store(store.clone(), "mut_idx", &cb).unwrap();
1059            handle
1060                .insert(1, &SparseVector::new(vec![5], vec![1.0]))
1061                .unwrap();
1062            handle.commit_inner().unwrap();
1063        }
1064
1065        let handle2 = SparseHandle::open_with_store(store.clone(), "mut_idx", &cb).unwrap();
1066        handle2
1067            .insert(2, &SparseVector::new(vec![5], vec![2.0]))
1068            .unwrap();
1069        handle2.commit_inner().unwrap();
1070
1071        // Reopen again — should have both docs
1072        drop(handle2);
1073        let handle3 = SparseHandle::open_with_store(store.clone(), "mut_idx", &cb).unwrap();
1074        assert_eq!(handle3.len(), 2);
1075
1076        let results = handle3.search(&SparseVector::new(vec![5], vec![1.0]), 10);
1077        assert_eq!(results.len(), 2);
1078        assert_eq!(results[0].0, 2); // 2.0
1079        assert_eq!(results[1].0, 1); // 1.0
1080    }
1081
1082    #[test]
1083    fn blob_store_delete_and_reopen() {
1084        let store = Arc::new(MemBlobStore::new());
1085        let cb = test_cache_base();
1086
1087        {
1088            let handle = SparseHandle::create_with_store(store.clone(), "del_idx", &cb).unwrap();
1089            handle
1090                .insert(1, &SparseVector::new(vec![1], vec![1.0]))
1091                .unwrap();
1092            handle
1093                .insert(2, &SparseVector::new(vec![1], vec![2.0]))
1094                .unwrap();
1095            handle.commit_inner().unwrap();
1096        }
1097
1098        // Reopen, delete, commit
1099        let handle2 = SparseHandle::open_with_store(store.clone(), "del_idx", &cb).unwrap();
1100        assert_eq!(handle2.len(), 2);
1101        handle2.remove(1).unwrap();
1102        assert_eq!(handle2.len(), 1);
1103        handle2.commit_inner().unwrap();
1104        drop(handle2);
1105
1106        // Reopen — should have 1 doc
1107        let handle3 = SparseHandle::open_with_store(store.clone(), "del_idx", &cb).unwrap();
1108        assert_eq!(handle3.len(), 1);
1109
1110        let results = handle3.search(&SparseVector::new(vec![1], vec![1.0]), 10);
1111        assert_eq!(results.len(), 1);
1112        assert_eq!(results[0].0, 2);
1113    }
1114
1115    #[test]
1116    fn blob_store_multiple_indexes_isolated() {
1117        let store = Arc::new(MemBlobStore::new());
1118        let cb = test_cache_base();
1119
1120        let h1 = SparseHandle::create_with_store(store.clone(), "idx_a", &cb).unwrap();
1121        let h2 = SparseHandle::create_with_store(store.clone(), "idx_b", &cb).unwrap();
1122
1123        h1.insert(1, &SparseVector::new(vec![1], vec![1.0]))
1124            .unwrap();
1125        h1.insert(2, &SparseVector::new(vec![1], vec![0.5]))
1126            .unwrap();
1127        h2.insert(10, &SparseVector::new(vec![1], vec![3.0]))
1128            .unwrap();
1129
1130        h1.commit_inner().unwrap();
1131        h2.commit_inner().unwrap();
1132
1133        assert_eq!(h1.len(), 2);
1134        assert_eq!(h2.len(), 1);
1135
1136        // Store has separate blobs
1137        assert_eq!(store.list("Sparse_idx_a").unwrap().len(), 3);
1138        assert_eq!(store.list("Sparse_idx_b").unwrap().len(), 3);
1139    }
1140
1141    #[test]
1142    fn blob_store_survives_cache_cleanup() {
1143        let store = Arc::new(MemBlobStore::new());
1144        let cb = test_cache_base();
1145
1146        {
1147            let handle = SparseHandle::create_with_store(store.clone(), "surv_idx", &cb).unwrap();
1148            for i in 0..50u64 {
1149                handle
1150                    .insert(i, &SparseVector::new(vec![(i % 10) as u32], vec![i as f32]))
1151                    .unwrap();
1152            }
1153            handle.commit_inner().unwrap();
1154        }
1155        // Cache cleaned up on drop
1156
1157        // Reopen from store
1158        let handle2 = SparseHandle::open_with_store(store.clone(), "surv_idx", &cb).unwrap();
1159        assert_eq!(handle2.len(), 50);
1160
1161        let results = handle2.search(&SparseVector::new(vec![0], vec![1.0]), 5);
1162        // Docs with token 0: 0 (w=0.0), 10, 20, 30, 40. Doc 0 has weight 0,
1163        // which is not indexed (see `index`), so it is not a hit.
1164        assert_eq!(results.len(), 4);
1165        assert_eq!(results[0].0, 40);
1166    }
1167
1168    #[test]
1169    fn blob_store_search_filtered_after_reopen() {
1170        let store = Arc::new(MemBlobStore::new());
1171        let cb = test_cache_base();
1172
1173        {
1174            let handle = SparseHandle::create_with_store(store.clone(), "filt_idx", &cb).unwrap();
1175            handle
1176                .insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
1177                .unwrap();
1178            handle
1179                .insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]))
1180                .unwrap();
1181            handle
1182                .insert(3, &SparseVector::new(vec![1], vec![0.7]))
1183                .unwrap();
1184            handle.commit_inner().unwrap();
1185        }
1186
1187        let handle2 = SparseHandle::open_with_store(store.clone(), "filt_idx", &cb).unwrap();
1188        let results =
1189            handle2.search_filtered(&SparseVector::new(vec![1], vec![1.0]), 10, &[1, 3]);
1190        assert_eq!(results.len(), 2);
1191        assert_eq!(results[0].0, 3); // 0.7
1192        assert_eq!(results[1].0, 1); // 0.5
1193    }
1194}