Skip to main content

sparse_vector/
segments.rs

1//! Segments: an index is a list of immutable files plus what is still in RAM.
2//!
3//! Until 3.0.5 a sparse index was one `sparse.mmap`, rewritten whole at every
4//! commit — so inserting one vector cost what inserting the whole index cost
5//! (320 ms at 200 000 vectors, growing with the file: see
6//! `tests/bench_commit_cost.rs`). A commit now writes **one segment holding
7//! the vectors added since the last one**, and `meta.json` lists what is
8//! active. The cost of a commit is the cost of the delta.
9//!
10//! A segment is a version 3 file: its dimension table is keyed by global
11//! token id and sorted (see [`crate::mmap_index`]). That is what lets two
12//! segments — and therefore two indexes — be merged by walking their tables
13//! together, without remapping anything.
14//!
15//! **Deletions are tombstones.** Removing a document marks its id in the
16//! segments that hold it; a segment written afterwards is not concerned,
17//! which is what makes an update (delete, then insert) land the right way
18//! round. A merge applies them and clears the lists.
19//!
20//! Knowing *which* segment holds an id is what `seg_<id>.ids` is for: the
21//! segment's record ids, sorted, eight bytes each, read only when something
22//! is deleted or updated. It replaces the far larger `sparse_vectors.bin`
23//! (whole vectors, kept only to know which dimensions to touch on a
24//! deletion), and it hands a merge its id list for nothing.
25
26use std::collections::HashSet;
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30
31use crate::mmap_index::{write_file_atomic, MmapPostingData};
32
33/// `meta.json` — what this index is made of.
34pub const META_FILE: &str = "meta.json";
35/// What this build writes; a newer one is refused rather than misread.
36pub const META_VERSION: u32 = 1;
37
38/// One segment of an index.
39#[derive(Clone, Debug, Serialize, Deserialize)]
40pub struct SegmentMeta {
41    /// Names its files: `seg_<id>.mmap`.
42    pub id: String,
43    /// Vectors written into it (before its deletions).
44    pub num_vectors: u32,
45    /// Ids deleted from it since it was written. Sorted, unique.
46    #[serde(default)]
47    pub deleted: Vec<u64>,
48}
49
50impl SegmentMeta {
51    /// Vectors this segment still answers for.
52    pub fn live_vectors(&self) -> usize {
53        (self.num_vectors as usize).saturating_sub(self.deleted.len())
54    }
55}
56
57/// The index's manifest: which segments are active, and what is deleted.
58#[derive(Clone, Debug, Serialize, Deserialize)]
59pub struct IndexMeta {
60    #[serde(default)]
61    pub version: u32,
62    #[serde(default)]
63    pub segments: Vec<SegmentMeta>,
64}
65
66impl Default for IndexMeta {
67    fn default() -> Self {
68        Self { version: META_VERSION, segments: Vec::new() }
69    }
70}
71
72impl IndexMeta {
73    /// Read `meta.json`, or the default when there is none (an index that
74    /// has never been committed, or one from before segments).
75    pub fn read(base: &Path) -> Result<Self, String> {
76        let path = base.join(META_FILE);
77        if !path.exists() {
78            return Ok(Self::default());
79        }
80        let data = std::fs::read(&path)
81            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
82        let meta: Self = serde_json::from_slice(&data)
83            .map_err(|e| format!("cannot parse {}: {e}", path.display()))?;
84        if meta.version > META_VERSION {
85            return Err(format!(
86                "{} was written by a newer version ({}, this build writes {META_VERSION})",
87                path.display(), meta.version,
88            ));
89        }
90        Ok(meta)
91    }
92
93    /// Write `meta.json` atomically: a crash leaves the previous manifest,
94    /// so the segments it names are still the index.
95    pub fn write(&self, base: &Path) -> Result<(), String> {
96        let data = serde_json::to_vec_pretty(self)
97            .map_err(|e| format!("cannot serialize {META_FILE}: {e}"))?;
98        write_file_atomic(&base.join(META_FILE), &data)
99    }
100
101
102    /// Vectors the segments still answer for.
103    pub fn live_vectors(&self) -> usize {
104        self.segments.iter().map(SegmentMeta::live_vectors).sum()
105    }
106
107    /// The files these segments own, for a store that syncs by name.
108    pub fn files(&self) -> Vec<String> {
109        let mut files: Vec<String> = Vec::with_capacity(self.segments.len() * 2 + 1);
110        for s in &self.segments {
111            files.push(segment_file(&s.id));
112            files.push(ids_file(&s.id));
113        }
114        files.push(META_FILE.to_string());
115        files
116    }
117}
118
119/// `seg_<id>.mmap`.
120pub fn segment_file(id: &str) -> String {
121    format!("seg_{id}.mmap")
122}
123
124/// `seg_<id>.ids` — the segment's record ids, sorted, little-endian.
125pub fn ids_file(id: &str) -> String {
126    format!("seg_{id}.ids")
127}
128
129/// Serialize sorted ids for [`ids_file`].
130pub fn encode_ids(ids: &[u64]) -> Vec<u8> {
131    let mut out = Vec::with_capacity(ids.len() * 8);
132    for id in ids {
133        out.extend_from_slice(&id.to_le_bytes());
134    }
135    out
136}
137
138fn decode_ids(data: &[u8]) -> Result<Vec<u64>, String> {
139    if data.len() % 8 != 0 {
140        return Err(format!("id list is {} bytes, not a multiple of 8", data.len()));
141    }
142    Ok(data.chunks_exact(8).map(|c| u64::from_le_bytes(c.try_into().unwrap())).collect())
143}
144
145/// An id for a new segment, from the process, a counter and the clock —
146/// unique within an index without needing to look at what is already there.
147pub fn new_segment_id(counter: u64) -> String {
148    let nanos = std::time::SystemTime::now()
149        .duration_since(std::time::UNIX_EPOCH)
150        .map(|d| d.as_nanos() as u64)
151        .unwrap_or(0);
152    format!("{:08x}{:04x}{:04x}", nanos & 0xffff_ffff, std::process::id() & 0xffff, counter & 0xffff)
153}
154
155/// An open segment: its manifest entry and its mapping.
156pub struct Segment {
157    pub meta: SegmentMeta,
158    pub data: MmapPostingData,
159    /// `meta.deleted` as a set, for the search filter.
160    deleted: HashSet<u64>,
161    /// The segment's record ids, sorted — read on the first deletion or
162    /// update, never for a search.
163    ids: std::cell::OnceCell<Vec<u64>>,
164    base: PathBuf,
165}
166
167impl Segment {
168    pub fn open(base: &Path, meta: SegmentMeta) -> Result<Self, String> {
169        let data = MmapPostingData::open(&base.join(segment_file(&meta.id)))?;
170        let deleted = meta.deleted.iter().copied().collect();
171        Ok(Self { meta, data, deleted, ids: std::cell::OnceCell::new(), base: base.to_path_buf() })
172    }
173
174    /// Whether this segment still answers for `id`.
175    pub fn is_live(&self, id: u64) -> bool {
176        !self.deleted.contains(&id)
177    }
178
179    /// The segment's ids, loaded on first use.
180    pub fn ids(&self) -> Result<&[u64], String> {
181        if let Some(ids) = self.ids.get() {
182            return Ok(ids);
183        }
184        let path = self.base.join(ids_file(&self.meta.id));
185        let data = std::fs::read(&path)
186            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
187        let ids = decode_ids(&data)?;
188        Ok(self.ids.get_or_init(|| ids))
189    }
190
191    /// Whether this segment was written with `id` (deleted or not).
192    pub fn holds(&self, id: u64) -> Result<bool, String> {
193        Ok(self.ids()?.binary_search(&id).is_ok())
194    }
195
196    /// Mark `id` deleted here. Answers whether it changed anything.
197    pub fn tombstone(&mut self, id: u64) -> bool {
198        if !self.deleted.insert(id) {
199            return false;
200        }
201        if let Err(pos) = self.meta.deleted.binary_search(&id) {
202            self.meta.deleted.insert(pos, id);
203        }
204        true
205    }
206
207    pub fn path(&self) -> PathBuf {
208        self.base.join(segment_file(&self.meta.id))
209    }
210
211    pub fn ids_path(&self) -> PathBuf {
212        self.base.join(ids_file(&self.meta.id))
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Merge
218// ---------------------------------------------------------------------------
219
220/// Merge segments into one, applying their tombstones.
221///
222/// This is the operation the format was shaped for: every segment's
223/// dimension table is sorted by **global token id**, so the merge walks the
224/// tables together and concatenates the posting lists of the same token —
225/// nothing is remapped, no id is rewritten, and no dictionary is rebuilt.
226/// Merging two *indexes* is the same call with their segments as input,
227/// which is why segments, index fusion and delta sync are one mechanism and
228/// not three.
229///
230/// Within a token, entries stay sorted by record id and the newest segment
231/// wins a tie — a document updated in a later segment already had its older
232/// copy tombstoned, so a tie only happens between a segment and itself.
233pub fn merge_segments(
234    base: &Path,
235    inputs: &[&Segment],
236    new_id: &str,
237) -> Result<SegmentMeta, String> {
238    use crate::wand::Postings;
239
240    // Every token any input holds, once, in order — a k-way walk over
241    // tables that are already sorted.
242    let mut cursors: Vec<std::iter::Peekable<_>> =
243        inputs.iter().map(|s| s.data.tokens().peekable()).collect();
244    let mut tokens: Vec<u32> = Vec::new();
245    loop {
246        let next = cursors.iter_mut()
247            .filter_map(|c| c.peek().map(|(t, _)| *t))
248            .min();
249        let Some(token) = next else { break };
250        for c in cursors.iter_mut() {
251            if c.peek().is_some_and(|(t, _)| *t == token) {
252                c.next();
253            }
254        }
255        tokens.push(token);
256    }
257
258    // The postings of each token, live entries only.
259    let mut postings: Vec<Postings> = Vec::with_capacity(tokens.len());
260    let mut ids: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
261    let mut pairs: Vec<(u64, f32)> = Vec::new();
262    for &token in &tokens {
263        pairs.clear();
264        for seg in inputs {
265            for e in seg.data.entries_of_token(token) {
266                if seg.is_live(e.record_id) {
267                    pairs.push((e.record_id, e.weight));
268                    ids.insert(e.record_id);
269                }
270            }
271        }
272        // Inputs are each sorted by id, their union is not.
273        pairs.sort_by_key(|(id, _)| *id);
274        postings.push(Postings::from_sorted_pairs(&pairs));
275    }
276
277    let ids: Vec<u64> = ids.into_iter().collect();
278    crate::mmap_index::write_mmap_file(
279        &base.join(segment_file(new_id)),
280        &postings,
281        &tokens,
282        ids.len() as u32,
283    )?;
284    write_file_atomic(&base.join(ids_file(new_id)), &encode_ids(&ids))?;
285
286    Ok(SegmentMeta { id: new_id.to_string(), num_vectors: ids.len() as u32, deleted: Vec::new() })
287}