Skip to main content

docbert_plaid/
persistence.rs

1//! Save and load a built [`Index`] to/from disk.
2//!
3//! The on-disk format is a single little-endian binary file with a small
4//! header followed by a sequence of plain f32/u32/u64 blobs. The layout
5//! is deliberately boring — there's no compression, no extra framing,
6//! and no schema evolution today; we can layer any of that on top later
7//! without changing the semantic API.
8//!
9//! Layout, every field little-endian:
10//!
11//! ```text
12//! magic           : 8 bytes, b"PLAIDIDX"
13//! version         : u32     (currently 1)
14//! dim             : u32
15//! nbits           : u32
16//! k_centroids    : u32
17//! max_kmeans_iters: u32
18//! n_documents     : u64
19//! centroids       : dim * k_centroids     f32
20//! bucket_cutoffs  : 2^nbits - 1            f32
21//! bucket_weights  : 2^nbits                f32
22//! doc_ids         : n_documents            u64
23//! token_counts    : n_documents            u32  (tokens per document)
24//! encoded_tokens  : for each token, u32 centroid_id then `dim` u8 codes
25//! ```
26//!
27//! The inverted file is *not* persisted — it's derivable from the
28//! encoded tokens in O(n_tokens) time on load and storing it would just
29//! duplicate state that's already on disk elsewhere.
30
31use std::{
32    fs::File,
33    io::{BufReader, BufWriter, Read, Write},
34    path::Path,
35};
36
37use crate::{
38    PlaidError,
39    Result,
40    codec::{ResidualCodec, packed_bytes_per_vector},
41    index::{Index, IndexParams, build_inverted_file_from_flat},
42};
43
44const MAGIC: &[u8; 8] = b"PLAIDIDX";
45/// Format versions in the wild:
46///
47/// - `1`: unpacked residual codes, one byte per residual dimension.
48/// - `2`: LSB-first bit-packed codes at `nbits ∈ {1, 2, 4, 8}`;
49///   `(dim * nbits) / 8` bytes per token.
50const FORMAT_VERSION: u32 = 2;
51
52/// Write `index` to `path`, creating or truncating the file as needed.
53///
54/// # Errors
55///
56/// Returns [`PlaidError::Io`] for any underlying I/O failure, or
57/// [`PlaidError::InvalidIndex`] if an encoded token's `codes` buffer
58/// doesn't match the codec's advertised packed length (a caller bug
59/// worth flagging loudly on write rather than producing a file that
60/// would fail to load back).
61///
62/// [`PlaidError::Io`]: crate::PlaidError::Io
63/// [`PlaidError::InvalidIndex`]: crate::PlaidError::InvalidIndex
64pub fn save(index: &Index, path: &Path) -> Result<()> {
65    let file = File::create(path)?;
66    let mut writer = BufWriter::new(file);
67    write_index(index, &mut writer)?;
68    writer.flush()?;
69    Ok(())
70}
71
72/// Read an [`Index`] back from `path`.
73///
74/// # Errors
75///
76/// Returns [`PlaidError::Io`] for any read failure, or
77/// [`PlaidError::InvalidIndex`] if the file's magic, version, or
78/// header fields don't match this crate's format.
79///
80/// [`PlaidError::Io`]: crate::PlaidError::Io
81/// [`PlaidError::InvalidIndex`]: crate::PlaidError::InvalidIndex
82pub fn load(path: &Path) -> Result<Index> {
83    let file = File::open(path)?;
84    let mut reader = BufReader::new(file);
85    read_index(&mut reader)
86}
87
88fn write_index<W: Write>(index: &Index, w: &mut W) -> Result<()> {
89    w.write_all(MAGIC)?;
90    write_u32(w, FORMAT_VERSION)?;
91
92    let params = &index.params;
93    write_u32(w, params.dim as u32)?;
94    write_u32(w, params.nbits)?;
95    write_u32(w, params.k_centroids as u32)?;
96    write_u32(w, params.max_kmeans_iters as u32)?;
97    write_u64(w, index.doc_ids.len() as u64)?;
98
99    write_f32_slice(w, &index.codec.centroids)?;
100    write_f32_slice(w, &index.codec.bucket_cutoffs)?;
101    write_f32_slice(w, &index.codec.bucket_weights)?;
102
103    write_u64_slice(w, &index.doc_ids)?;
104
105    let token_counts: Vec<u32> = (0..index.num_documents())
106        .map(|i| index.doc_token_count(i) as u32)
107        .collect();
108    write_u32_slice(w, &token_counts)?;
109
110    let packed_bytes = packed_bytes_per_vector(params.dim, params.nbits);
111    if index.doc_residual_bytes.len() != index.num_tokens() * packed_bytes {
112        return Err(PlaidError::InvalidIndex(format!(
113            "doc_residual_bytes length {} is not tokens ({}) × packed_bytes ({packed_bytes})",
114            index.doc_residual_bytes.len(),
115            index.num_tokens(),
116        )));
117    }
118    for i in 0..index.num_tokens() {
119        write_u32(w, index.doc_centroid_ids[i])?;
120        w.write_all(
121            &index.doc_residual_bytes[i * packed_bytes..(i + 1) * packed_bytes],
122        )?;
123    }
124
125    Ok(())
126}
127
128fn read_index<R: Read>(r: &mut R) -> Result<Index> {
129    let mut magic = [0u8; 8];
130    r.read_exact(&mut magic)?;
131    if &magic != MAGIC {
132        return Err(PlaidError::InvalidIndex(
133            "not a docbert-plaid index (magic bytes mismatch)".into(),
134        ));
135    }
136
137    let version = read_u32(r)?;
138    if version != FORMAT_VERSION {
139        return Err(PlaidError::InvalidIndex(format!(
140            "unsupported plaid index version {version}, expected {FORMAT_VERSION}",
141        )));
142    }
143
144    let dim = read_u32(r)? as usize;
145    let nbits = read_u32(r)?;
146    let k_centroids = read_u32(r)? as usize;
147    let max_kmeans_iters = read_u32(r)? as usize;
148    let n_documents = read_u64(r)? as usize;
149
150    if dim == 0 || k_centroids == 0 || !matches!(nbits, 1 | 2 | 4 | 8) {
151        return Err(PlaidError::InvalidIndex(
152            "plaid index header has invalid dim/k_centroids/nbits".into(),
153        ));
154    }
155
156    let params = IndexParams {
157        dim,
158        nbits,
159        k_centroids,
160        max_kmeans_iters,
161    };
162
163    let centroids = read_f32_vec(r, k_centroids * dim)?;
164    let num_buckets = 1usize << nbits;
165    let bucket_cutoffs = read_f32_vec(r, num_buckets - 1)?;
166    let bucket_weights = read_f32_vec(r, num_buckets)?;
167
168    let doc_ids = read_u64_vec(r, n_documents)?;
169    let token_counts = read_u32_vec(r, n_documents)?;
170    let packed_bytes = packed_bytes_per_vector(dim, nbits);
171
172    let total_tokens: usize = token_counts.iter().map(|&c| c as usize).sum();
173    let mut doc_centroid_ids: Vec<u32> = Vec::with_capacity(total_tokens);
174    let mut doc_residual_bytes: Vec<u8> =
175        Vec::with_capacity(total_tokens * packed_bytes);
176    let mut doc_offsets: Vec<usize> = Vec::with_capacity(n_documents + 1);
177    doc_offsets.push(0);
178
179    for count in token_counts.iter() {
180        for _ in 0..*count {
181            let centroid_id = read_u32(r)?;
182            if (centroid_id as usize) >= k_centroids {
183                return Err(PlaidError::InvalidIndex(format!(
184                    "centroid_id {centroid_id} out of range 0..{k_centroids}",
185                )));
186            }
187            doc_centroid_ids.push(centroid_id);
188            let start = doc_residual_bytes.len();
189            doc_residual_bytes.resize(start + packed_bytes, 0);
190            r.read_exact(&mut doc_residual_bytes[start..start + packed_bytes])?;
191        }
192        doc_offsets.push(doc_centroid_ids.len());
193    }
194    let ivf = build_inverted_file_from_flat(
195        &doc_centroid_ids,
196        &doc_offsets,
197        k_centroids,
198    );
199
200    let codec = ResidualCodec {
201        nbits,
202        dim,
203        centroids,
204        bucket_cutoffs,
205        bucket_weights,
206    };
207    codec.validate()?;
208
209    Ok(Index {
210        params,
211        codec,
212        doc_ids,
213        doc_centroid_ids,
214        doc_residual_bytes,
215        doc_offsets,
216        ivf,
217    })
218}
219
220fn write_u32<W: Write>(w: &mut W, v: u32) -> Result<()> {
221    w.write_all(&v.to_le_bytes())?;
222    Ok(())
223}
224
225fn write_u64<W: Write>(w: &mut W, v: u64) -> Result<()> {
226    w.write_all(&v.to_le_bytes())?;
227    Ok(())
228}
229
230fn write_f32_slice<W: Write>(w: &mut W, slice: &[f32]) -> Result<()> {
231    w.write_all(bytemuck::cast_slice(slice))?;
232    Ok(())
233}
234
235fn write_u32_slice<W: Write>(w: &mut W, slice: &[u32]) -> Result<()> {
236    w.write_all(bytemuck::cast_slice(slice))?;
237    Ok(())
238}
239
240fn write_u64_slice<W: Write>(w: &mut W, slice: &[u64]) -> Result<()> {
241    w.write_all(bytemuck::cast_slice(slice))?;
242    Ok(())
243}
244
245fn read_u32<R: Read>(r: &mut R) -> Result<u32> {
246    let mut buf = [0u8; 4];
247    r.read_exact(&mut buf)?;
248    Ok(u32::from_le_bytes(buf))
249}
250
251fn read_u64<R: Read>(r: &mut R) -> Result<u64> {
252    let mut buf = [0u8; 8];
253    r.read_exact(&mut buf)?;
254    Ok(u64::from_le_bytes(buf))
255}
256
257fn read_f32_vec<R: Read>(r: &mut R, n: usize) -> Result<Vec<f32>> {
258    let mut out = vec![0.0f32; n];
259    r.read_exact(bytemuck::cast_slice_mut(&mut out))?;
260    Ok(out)
261}
262
263fn read_u32_vec<R: Read>(r: &mut R, n: usize) -> Result<Vec<u32>> {
264    let mut out = vec![0u32; n];
265    r.read_exact(bytemuck::cast_slice_mut(&mut out))?;
266    Ok(out)
267}
268
269fn read_u64_vec<R: Read>(r: &mut R, n: usize) -> Result<Vec<u64>> {
270    let mut out = vec![0u64; n];
271    r.read_exact(bytemuck::cast_slice_mut(&mut out))?;
272    Ok(out)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::index::{DocumentTokens, build_index};
279
280    fn small_corpus() -> Vec<DocumentTokens> {
281        vec![
282            DocumentTokens {
283                doc_id: 10,
284                tokens: vec![0.0, 0.0, 0.1, 0.2, -0.1, 0.1],
285                n_tokens: 3,
286            },
287            DocumentTokens {
288                doc_id: 20,
289                tokens: vec![10.0, 10.0, 10.2, 9.9, 9.8, 10.1],
290                n_tokens: 3,
291            },
292            DocumentTokens {
293                doc_id: 30,
294                tokens: vec![0.3, -0.2, 9.7, 10.2],
295                n_tokens: 2,
296            },
297        ]
298    }
299
300    fn default_params() -> IndexParams {
301        IndexParams {
302            dim: 2,
303            nbits: 2,
304            k_centroids: 2,
305            max_kmeans_iters: 50,
306        }
307    }
308
309    #[test]
310    fn round_trip_preserves_codec_parameters_and_doc_ids() {
311        let tmp = tempfile::tempdir().unwrap();
312        let path = tmp.path().join("index.plaid");
313        let index = build_index(&small_corpus(), default_params()).unwrap();
314
315        save(&index, &path).unwrap();
316        let loaded = load(&path).unwrap();
317
318        assert_eq!(loaded.params.dim, index.params.dim);
319        assert_eq!(loaded.params.nbits, index.params.nbits);
320        assert_eq!(loaded.params.k_centroids, index.params.k_centroids);
321        assert_eq!(
322            loaded.params.max_kmeans_iters,
323            index.params.max_kmeans_iters
324        );
325        assert_eq!(loaded.doc_ids, index.doc_ids);
326    }
327
328    #[test]
329    fn round_trip_preserves_codec_tables_byte_for_byte() {
330        let tmp = tempfile::tempdir().unwrap();
331        let path = tmp.path().join("index.plaid");
332        let index = build_index(&small_corpus(), default_params()).unwrap();
333
334        save(&index, &path).unwrap();
335        let loaded = load(&path).unwrap();
336
337        assert_eq!(loaded.codec.centroids, index.codec.centroids);
338        assert_eq!(loaded.codec.bucket_cutoffs, index.codec.bucket_cutoffs);
339        assert_eq!(loaded.codec.bucket_weights, index.codec.bucket_weights);
340    }
341
342    #[test]
343    fn round_trip_preserves_encoded_tokens() {
344        let tmp = tempfile::tempdir().unwrap();
345        let path = tmp.path().join("index.plaid");
346        let index = build_index(&small_corpus(), default_params()).unwrap();
347
348        save(&index, &path).unwrap();
349        let loaded = load(&path).unwrap();
350
351        assert_eq!(loaded.num_documents(), index.num_documents());
352        for i in 0..index.num_documents() {
353            assert_eq!(loaded.doc_tokens_vec(i), index.doc_tokens_vec(i));
354        }
355    }
356
357    #[test]
358    fn round_trip_rebuilds_the_inverted_file() {
359        let tmp = tempfile::tempdir().unwrap();
360        let path = tmp.path().join("index.plaid");
361        let index = build_index(&small_corpus(), default_params()).unwrap();
362
363        save(&index, &path).unwrap();
364        let loaded = load(&path).unwrap();
365
366        assert_eq!(loaded.ivf.num_centroids(), index.ivf.num_centroids());
367        assert_eq!(
368            loaded.ivf.total_doc_postings(),
369            index.ivf.total_doc_postings(),
370        );
371        for c in 0..index.ivf.num_centroids() {
372            let want = index.ivf.docs_for_centroid(c);
373            let got = loaded.ivf.docs_for_centroid(c);
374            assert_eq!(got, want, "IVF list for centroid {c} differs");
375        }
376    }
377
378    #[test]
379    fn round_trip_preserves_search_results_exactly() {
380        let tmp = tempfile::tempdir().unwrap();
381        let path = tmp.path().join("index.plaid");
382        let index = build_index(&small_corpus(), default_params()).unwrap();
383        save(&index, &path).unwrap();
384        let loaded = load(&path).unwrap();
385
386        let query = [0.05f32, 0.1, 9.9, 10.1];
387        let params = crate::search::SearchParams {
388            top_k: 3,
389            n_probe: 2,
390            n_candidate_docs: None,
391            centroid_score_threshold: None,
392        };
393
394        let a = crate::search::search(&index, &query, params).unwrap();
395        let b = crate::search::search(&loaded, &query, params).unwrap();
396        assert_eq!(a, b);
397    }
398
399    #[test]
400    fn round_trip_handles_empty_documents() {
401        let tmp = tempfile::tempdir().unwrap();
402        let path = tmp.path().join("index.plaid");
403        let mut docs = small_corpus();
404        docs.push(DocumentTokens {
405            doc_id: 999,
406            tokens: vec![],
407            n_tokens: 0,
408        });
409        let index = build_index(&docs, default_params()).unwrap();
410
411        save(&index, &path).unwrap();
412        let loaded = load(&path).unwrap();
413
414        assert_eq!(loaded.doc_ids, index.doc_ids);
415        assert_eq!(loaded.doc_token_count(loaded.num_documents() - 1), 0);
416    }
417
418    #[test]
419    fn load_rejects_files_with_wrong_magic() {
420        let tmp = tempfile::tempdir().unwrap();
421        let path = tmp.path().join("bogus.plaid");
422        std::fs::write(&path, b"NOTPLAID").unwrap();
423
424        let err = load(&path).unwrap_err();
425        assert!(
426            matches!(err, PlaidError::InvalidIndex(ref m) if m.contains("magic")),
427            "expected InvalidIndex with magic message, got {err:?}",
428        );
429    }
430
431    #[test]
432    fn load_rejects_unknown_format_version() {
433        let tmp = tempfile::tempdir().unwrap();
434        let path = tmp.path().join("future.plaid");
435        let mut buf = Vec::new();
436        buf.extend_from_slice(MAGIC);
437        buf.extend_from_slice(&999u32.to_le_bytes());
438        std::fs::write(&path, &buf).unwrap();
439
440        let err = load(&path).unwrap_err();
441        assert!(
442            matches!(err, PlaidError::InvalidIndex(ref m) if m.contains("version")),
443            "expected InvalidIndex with version message, got {err:?}",
444        );
445    }
446
447    #[test]
448    fn load_rejects_legacy_unpacked_format_version_one() {
449        // Version 1 (pre-packing) indexes must be rebuilt. The loader
450        // should reject them with a clear version mismatch.
451        let tmp = tempfile::tempdir().unwrap();
452        let path = tmp.path().join("legacy.plaid");
453        let mut buf = Vec::new();
454        buf.extend_from_slice(MAGIC);
455        buf.extend_from_slice(&1u32.to_le_bytes());
456        std::fs::write(&path, &buf).unwrap();
457
458        let err = load(&path).unwrap_err();
459        assert!(
460            matches!(err, PlaidError::InvalidIndex(ref m) if m.contains("version")),
461            "expected InvalidIndex with version message, got {err:?}",
462        );
463    }
464}