Skip to main content

sparse_vector/
mmap_index.rs

1//! Flat binary mmap format for sparse-vector posting lists.
2//!
3//! Layout (every struct is `#[repr(C)]` for a stable layout):
4//!
5//! ```text
6//! [FileHeader]                    16 bytes
7//! [DimHeader × num_dims]          16 bytes × N
8//! [PostingEntry × total_entries]  16 bytes × M
9//! [Footer]                        8 bytes    (version 2)
10//! ```
11//!
12//! **Version 3: a dimension is its global token id.** `DimHeader` used to
13//! carry a padding word and to be addressed by its position — a dense index
14//! local to the file, which only `sparse_dims.bin` could translate back.
15//! The padding word now holds the `token_id`, the table is sorted by it, and
16//! a dimension is looked up by binary search. Same 16 bytes, and three
17//! consequences: merging two files is a merge-join on sorted ids with no
18//! remapping (so merging two *indexes* is the same operation), the dims
19//! side file is not needed to search, and nothing local to a file leaks into
20//! what it means. Versions 1 and 2 keep the dense reading.
21//!
22//! **Version 2 adds the footer** — a CRC-32 of everything before it, then the
23//! magic again — and the file is written to a temporary and renamed over the
24//! destination. Until 3.0.5 the writer opened the destination itself and
25//! wrote in place: an interrupted commit (a crash, a full disk) left a
26//! truncated index that opened without complaining and answered wrong.
27//! A version 1 file still opens, without those checks.
28//!
29//! What is verified at open is the **length** the headers imply — the cheap
30//! check that catches a truncation. The CRC covers the whole file, so
31//! verifying it means reading it: [`MmapPostingData::verify_checksum`] does
32//! that on demand, and `LUCIVY_SPARSE_VERIFY_CRC=1` makes every open do it.
33//!
34//! Each entry carries a `max_next_weight` ceiling. Files written here store
35//! the inclusive ceiling of the wand module (`tail_max`: the maximum weight
36//! over the entry and everything after it); readers fold
37//! `max(weight, max_next_weight)`, so a file whose ceiling excludes the
38//! entry itself reads identically.
39
40use std::collections::HashMap;
41use std::path::Path;
42
43use memmap2::Mmap;
44
45use crate::index::{run_search, SparseVector};
46use crate::wand::{MmapCursor, Postings};
47
48const MAGIC: u32 = 0x53505253; // "SPRS"
49/// Written by this version: a CRC-32 footer, an atomic rename, and a
50/// dimension table keyed by global token id.
51const FORMAT_VERSION: u32 = 3;
52/// From this version on, `DimHeader::token_id` is meaningful and the table
53/// is sorted by it.
54const GLOBAL_DIMS_VERSION: u32 = 3;
55/// Read too: files written before 3.0.6, footerless.
56const MIN_READABLE_VERSION: u32 = 1;
57/// `[crc32: u32][magic: u32]`.
58const FOOTER_SIZE: usize = 8;
59
60#[repr(C)]
61struct FileHeader {
62    magic: u32,
63    version: u32,
64    num_dims: u32,
65    num_vectors: u32,
66}
67
68#[repr(C)]
69struct DimHeader {
70    offset: u64,
71    count: u32,
72    /// The dimension's global token id (version 3 and up); the padding word
73    /// of versions 1 and 2, where the table position was the dimension.
74    token_id: u32,
75}
76
77/// On-disk posting entry.
78#[repr(C)]
79#[derive(Clone, Copy, Debug)]
80pub struct PostingEntry {
81    pub record_id: u64,
82    pub weight: f32,
83    /// Ceiling over the rest of the list (see the module docs).
84    pub max_next_weight: f32,
85}
86
87/// Mmap'd posting data — read-only view of `sparse.mmap`.
88pub struct MmapPostingData {
89    mmap: Mmap,
90    num_dims: usize,
91    num_vectors: usize,
92    /// Of the file on disk: 1 has no footer, 2 has the CRC.
93    version: u32,
94}
95
96impl MmapPostingData {
97    pub fn open(path: &Path) -> Result<Self, String> {
98        let file = std::fs::File::open(path)
99            .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
100        let mmap = unsafe { Mmap::map(&file) }
101            .map_err(|e| format!("cannot mmap {}: {e}", path.display()))?;
102
103        if mmap.len() < std::mem::size_of::<FileHeader>() {
104            return Err("sparse.mmap too small for header".into());
105        }
106        let header = unsafe { &*(mmap.as_ptr() as *const FileHeader) };
107        if header.magic != MAGIC {
108            return Err(format!("bad magic: {:#x}", header.magic));
109        }
110        if header.version < MIN_READABLE_VERSION || header.version > FORMAT_VERSION {
111            return Err(format!("unsupported version: {} (this build reads {MIN_READABLE_VERSION}..={FORMAT_VERSION})",
112                header.version));
113        }
114        let versioned = Self {
115            mmap,
116            num_dims: header.num_dims as usize,
117            num_vectors: header.num_vectors as usize,
118            version: header.version,
119        };
120        // The length the headers imply. A commit cut in half — a crash, a
121        // full disk — used to leave a shorter file that opened fine and
122        // answered from whatever it still held.
123        versioned.check_length(path)?;
124        if std::env::var("LUCIVY_SPARSE_VERIFY_CRC").is_ok_and(|v| v != "0") {
125            versioned.verify_checksum(path)?;
126        }
127        Ok(versioned)
128    }
129
130    /// Bytes the file must have, from its own headers.
131    fn expected_len(&self) -> usize {
132        let dim_headers = std::mem::size_of::<FileHeader>()
133            + self.num_dims * std::mem::size_of::<DimHeader>();
134        let entries: usize = (0..self.num_dims)
135            .map(|i| {
136                let ptr = unsafe {
137                    self.mmap.as_ptr().add(
138                        std::mem::size_of::<FileHeader>() + i * std::mem::size_of::<DimHeader>(),
139                    ) as *const DimHeader
140                };
141                unsafe { (*ptr).count as usize }
142            })
143            .sum();
144        dim_headers + entries * std::mem::size_of::<PostingEntry>()
145            + if self.version >= 2 { FOOTER_SIZE } else { 0 }
146    }
147
148    fn check_length(&self, path: &Path) -> Result<(), String> {
149        // The dimension headers must be there before their counts are read.
150        let headers_end = std::mem::size_of::<FileHeader>()
151            + self.num_dims * std::mem::size_of::<DimHeader>();
152        if self.mmap.len() < headers_end {
153            return Err(format!(
154                "{}: truncated — {} bytes for {} dimension headers",
155                path.display(), self.mmap.len(), self.num_dims,
156            ));
157        }
158        let expected = self.expected_len();
159        if self.mmap.len() != expected {
160            return Err(format!(
161                "{}: truncated or corrupt — {} bytes, its headers describe {expected}",
162                path.display(), self.mmap.len(),
163            ));
164        }
165        Ok(())
166    }
167
168    /// Recompute the CRC-32 of the whole file and compare it with the
169    /// footer's. Reads every byte; a version 1 file has no footer and passes.
170    pub fn verify_checksum(&self, path: &Path) -> Result<(), String> {
171        if self.version < 2 {
172            return Ok(());
173        }
174        let len = self.mmap.len();
175        let body = &self.mmap[..len - FOOTER_SIZE];
176        let stored = u32::from_le_bytes(self.mmap[len - FOOTER_SIZE..len - 4].try_into().unwrap());
177        let magic = u32::from_le_bytes(self.mmap[len - 4..].try_into().unwrap());
178        if magic != MAGIC {
179            return Err(format!("{}: footer magic is {magic:#x}", path.display()));
180        }
181        let mut hasher = crc32fast::Hasher::new();
182        hasher.update(body);
183        let actual = hasher.finalize();
184        if actual != stored {
185            return Err(format!("{}: checksum mismatch — {actual:#x}, the file says {stored:#x}",
186                path.display()));
187        }
188        Ok(())
189    }
190
191    pub fn num_dims(&self) -> usize {
192        self.num_dims
193    }
194
195    pub fn num_vectors(&self) -> usize {
196        self.num_vectors
197    }
198
199    /// The file's format version (3 and up: dimensions are global token ids).
200    pub fn version(&self) -> u32 {
201        self.version
202    }
203
204    /// Whether a dimension is addressed by its global token id (version 3)
205    /// or by a dense position this file alone defines (versions 1 and 2).
206    pub fn has_global_dims(&self) -> bool {
207        self.version >= GLOBAL_DIMS_VERSION
208    }
209
210    /// The dimension headers, in file order — sorted by `token_id` from
211    /// version 3 on.
212    fn dim_headers(&self) -> &[DimHeader] {
213        let ptr = unsafe { self.mmap.as_ptr().add(std::mem::size_of::<FileHeader>()) }
214            as *const DimHeader;
215        unsafe { std::slice::from_raw_parts(ptr, self.num_dims) }
216    }
217
218    /// The position of a global token id in this file's table, by binary
219    /// search. `None` on a version 1 or 2 file, whose table is dense and
220    /// says nothing about token ids.
221    pub fn dim_of_token(&self, token_id: u32) -> Option<usize> {
222        if !self.has_global_dims() {
223            return None;
224        }
225        self.dim_headers()
226            .binary_search_by_key(&token_id, |dh| dh.token_id)
227            .ok()
228    }
229
230    /// Every `(token_id, position)` of this file, in sorted order — what a
231    /// merge walks. Empty on a version 1 or 2 file.
232    pub fn tokens(&self) -> impl Iterator<Item = (u32, usize)> + '_ {
233        let global = self.has_global_dims();
234        self.dim_headers().iter().enumerate()
235            .filter(move |_| global)
236            .map(|(i, dh)| (dh.token_id, i))
237    }
238
239    /// The entries of a global token id, empty when this file does not hold
240    /// that dimension.
241    pub fn entries_of_token(&self, token_id: u32) -> &[PostingEntry] {
242        match self.dim_of_token(token_id) {
243            Some(i) => self.entries(i),
244            None => &[],
245        }
246    }
247
248    /// The entries of a remapped dimension, sorted by record id; empty for
249    /// an empty or unknown dimension.
250    pub fn entries(&self, dim_idx: usize) -> &[PostingEntry] {
251        if dim_idx >= self.num_dims {
252            return &[];
253        }
254        let dim_headers_offset = std::mem::size_of::<FileHeader>();
255        let dh_ptr = unsafe {
256            self.mmap
257                .as_ptr()
258                .add(dim_headers_offset + dim_idx * std::mem::size_of::<DimHeader>())
259        } as *const DimHeader;
260        let dh = unsafe { &*dh_ptr };
261
262        if dh.count == 0 {
263            return &[];
264        }
265
266        let entries_ptr =
267            unsafe { self.mmap.as_ptr().add(dh.offset as usize) } as *const PostingEntry;
268        unsafe { std::slice::from_raw_parts(entries_ptr, dh.count as usize) }
269    }
270
271    /// Cursor over a remapped dimension, `None` when it has no postings.
272    pub fn cursor(&self, dim_idx: usize) -> Option<MmapCursor<'_>> {
273        MmapCursor::open(self, dim_idx as u32)
274    }
275
276    /// Load a global token id's entries into an in-RAM list. Empty when the
277    /// file does not hold that dimension — the lookup a version 3 file
278    /// answers itself, and the only correct one once the table is sorted by
279    /// token rather than laid out by position.
280    pub fn load_postings_of_token(&self, token_id: u32) -> Postings {
281        match self.dim_of_token(token_id) {
282            Some(i) => self.load_postings(i),
283            None => Postings::new(),
284        }
285    }
286
287    /// Load a dimension's entries into an in-RAM list, recomputing the
288    /// ceilings from the weights. `dim_idx` is a **position in this file**,
289    /// which is the caller's dense dimension only on a version 1 or 2 file.
290    pub fn load_postings(&self, dim_idx: usize) -> Postings {
291        let pairs: Vec<(u64, f32)> = self
292            .entries(dim_idx)
293            .iter()
294            .map(|e| (e.record_id, e.weight))
295            .collect();
296        Postings::from_sorted_pairs(&pairs)
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Search using mmap data (no RAM postings needed)
302// ---------------------------------------------------------------------------
303
304/// This file's own translation of the query's token ids into its dimension
305/// positions: its header table on a version 3 file, the caller's `dim_map`
306/// on a version 1 or 2 one (where a position means nothing outside the
307/// file). The map is only read for the query's dimensions, never cloned.
308fn dims_for<'a>(
309    mmap: &'a MmapPostingData,
310    dim_map: &'a HashMap<u32, usize>,
311    query: &SparseVector,
312) -> HashMap<u32, usize> {
313    if !mmap.has_global_dims() {
314        return query.indices.iter()
315            .filter_map(|t| dim_map.get(t).map(|&d| (*t, d)))
316            .collect();
317    }
318    query.indices.iter()
319        .filter_map(|&t| mmap.dim_of_token(t).map(|d| (t, d)))
320        .collect()
321}
322
323/// Top-`limit` search straight from the mapping, without loading anything
324/// into RAM. `dim_map` is only used for a version 1 or 2 file; a version 3
325/// one carries its own dimensions (see [`dims_for`]).
326pub fn search_mmap<F: Fn(u64) -> bool>(
327    mmap: &MmapPostingData,
328    dim_map: &HashMap<u32, usize>,
329    query: &SparseVector,
330    limit: usize,
331    filter: &F,
332) -> Vec<(u64, f32)> {
333    if query.is_empty() || mmap.num_vectors() == 0 {
334        return Vec::new();
335    }
336    let dims = dims_for(mmap, dim_map, query);
337    run_search(query, &dims, limit, filter, |dim| mmap.cursor(dim as usize))
338}
339
340/// [`search_mmap`] restricted to `allowed` ids (see
341/// [`run_search_allowed`](crate::index::run_search_allowed)).
342pub fn search_mmap_allowed(
343    mmap: &MmapPostingData,
344    dim_map: &HashMap<u32, usize>,
345    query: &SparseVector,
346    limit: usize,
347    allowed: &[u64],
348) -> Vec<(u64, f32)> {
349    if query.is_empty() || mmap.num_vectors() == 0 {
350        return Vec::new();
351    }
352    let dims = dims_for(mmap, dim_map, query);
353    crate::index::run_search_allowed(query, &dims, limit, allowed, |dim| mmap.cursor(dim as usize))
354}
355
356// ---------------------------------------------------------------------------
357// Write mmap format
358// ---------------------------------------------------------------------------
359
360/// Write the format from in-RAM posting lists. `postings[i]` is the list of
361/// the dimension whose global token id is `dim_tokens[i]`; the file is
362/// written with its header table **sorted by token id**, which is what makes
363/// two files mergeable without remapping anything.
364///
365/// Empty dimensions are dropped rather than written: a dense table had to
366/// keep them to preserve positions, a keyed one does not.
367pub fn write_mmap_file(
368    path: &Path,
369    postings: &[Postings],
370    dim_tokens: &[u32],
371    num_vectors: u32,
372) -> Result<(), String> {
373    use std::io::Write;
374
375    if dim_tokens.len() != postings.len() {
376        return Err(format!(
377            "{} posting lists for {} dimension ids", postings.len(), dim_tokens.len()));
378    }
379    // Sorted by token id, empties dropped.
380    let mut order: Vec<usize> = (0..postings.len()).filter(|&i| !postings[i].is_empty()).collect();
381    order.sort_by_key(|&i| dim_tokens[i]);
382    let num_dims = order.len() as u32;
383    let header_size = std::mem::size_of::<FileHeader>();
384    let dim_headers_size = num_dims as usize * std::mem::size_of::<DimHeader>();
385    let entries_start = header_size + dim_headers_size;
386
387    write_atomic(path, |file| {
388    let mut out = CrcWriter { inner: file, hasher: crc32fast::Hasher::new() };
389    let out = &mut out;
390    let header = FileHeader {
391        magic: MAGIC,
392        version: FORMAT_VERSION,
393        num_dims,
394        num_vectors,
395    };
396    out.write_all(as_bytes(&header))
397        .map_err(|e| format!("write header: {e}"))?;
398
399    let mut current_offset = entries_start;
400    for &i in &order {
401        let dh = DimHeader {
402            offset: current_offset as u64,
403            count: postings[i].len() as u32,
404            token_id: dim_tokens[i],
405        };
406        out.write_all(as_bytes(&dh))
407            .map_err(|e| format!("write dim header: {e}"))?;
408        current_offset += postings[i].len() * std::mem::size_of::<PostingEntry>();
409    }
410
411    for &i in &order {
412        let p = &postings[i];
413        for x in p.as_slice() {
414            let entry = PostingEntry {
415                record_id: x.id,
416                weight: x.weight,
417                max_next_weight: x.tail_max,
418            };
419            out.write_all(as_bytes(&entry))
420                .map_err(|e| format!("write entry: {e}"))?;
421        }
422    }
423    // Footer: the CRC-32 of everything above, then the magic again.
424    let crc = out.hasher.clone().finalize();
425    out.inner.write_all(&crc.to_le_bytes()).map_err(|e| format!("write checksum: {e}"))?;
426    out.inner.write_all(&MAGIC.to_le_bytes()).map_err(|e| format!("write footer magic: {e}"))?;
427    Ok(())
428    })
429}
430
431/// A writer that checksums what goes through it, so the file is not
432/// buffered a second time to be hashed.
433struct CrcWriter<'a> {
434    inner: &'a mut std::io::BufWriter<std::fs::File>,
435    hasher: crc32fast::Hasher,
436}
437
438impl std::io::Write for CrcWriter<'_> {
439    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
440        let n = self.inner.write(buf)?;
441        self.hasher.update(&buf[..n]);
442        Ok(n)
443    }
444    fn flush(&mut self) -> std::io::Result<()> {
445        self.inner.flush()
446    }
447}
448
449/// Write into `path` through a temporary in the same directory, flushed and
450/// synced, then renamed over the destination. A crash or a full disk leaves
451/// the previous file intact, never half of the new one. `File::create` on
452/// the destination did the opposite: it truncated first and wrote after.
453fn write_atomic(
454    path: &Path,
455    body: impl FnOnce(&mut std::io::BufWriter<std::fs::File>) -> Result<(), String>,
456) -> Result<(), String> {
457    use std::io::Write;
458
459    let name = path.file_name().ok_or_else(|| format!("{}: no file name", path.display()))?;
460    let tmp = path.with_file_name(format!("{}.tmp", name.to_string_lossy()));
461    let file = std::fs::File::create(&tmp)
462        .map_err(|e| format!("cannot create {}: {e}", tmp.display()))?;
463    let mut out = std::io::BufWriter::new(file);
464
465    let written = body(&mut out).and_then(|()| {
466        out.flush().map_err(|e| format!("flush {}: {e}", tmp.display()))?;
467        out.get_ref().sync_all().map_err(|e| format!("sync {}: {e}", tmp.display()))
468    });
469    drop(out);
470    if let Err(e) = written {
471        let _ = std::fs::remove_file(&tmp);
472        return Err(e);
473    }
474    std::fs::rename(&tmp, path)
475        .map_err(|e| format!("cannot rename {} onto {}: {e}", tmp.display(), path.display()))?;
476    // The rename itself must reach the disk, or a crash can lose it while
477    // keeping the file it replaced. Best effort: not every platform lets a
478    // directory be opened.
479    if let Some(dir) = path.parent() {
480        if let Ok(d) = std::fs::File::open(dir) {
481            let _ = d.sync_all();
482        }
483    }
484    Ok(())
485}
486
487/// `data` into `path`, atomically — the sidecars of a sparse shard
488/// (`vectors.bin`, `dims.bin`), which `fs::write` truncated in place just
489/// like the postings did. Their bytes are unchanged: no footer, no header,
490/// the same bincode a previous version wrote and reads.
491pub fn write_file_atomic(path: &Path, data: &[u8]) -> Result<(), String> {
492    use std::io::Write;
493    write_atomic(path, |out| out.write_all(data).map_err(|e| format!("write {}: {e}", path.display())))
494}
495
496/// Reinterpret a `#[repr(C)]` struct without padding as bytes.
497fn as_bytes<T: Sized>(val: &T) -> &[u8] {
498    unsafe { std::slice::from_raw_parts(val as *const T as *const u8, std::mem::size_of::<T>()) }
499}