Skip to content

Commit 8dd9c46

Browse files
joaoh82claude
andauthored
feat(fts): 8c — cell-encoded persistence + on-demand v4→v5 bump (#80)
Phase 8 sub-phase 8c per docs/phase-8-plan.md. Persists FTS posting lists as cell-encoded pages so save/reopen restores the index bit-for-bit instead of re-tokenizing rows. Mirrors Phase 7d.3's HNSW persistence shape across every layer. User-visible: zero-friction. Existing v4 databases (no FTS) keep writing v4 headers; the first save with an FTS index attached promotes the file to v5. v5 readers handle both formats. New: - src/sql/pager/cell.rs: - KIND_FTS_POSTING (0x06) — cell tag for FTS posting cells. - src/sql/pager/fts_cell.rs (NEW): - FtsPostingCell encode/decode. Either a posting cell (term + [(rowid, term_freq)]) or, with empty term, the sidecar carrying the per-doc length map. Sidecar preserves every indexed doc — including zero-token rows — so total_docs stays honest in BM25 post-reopen. - src/sql/fts/posting_list.rs: - serialize_doc_lengths / serialize_postings emit cell payloads in deterministic order. - from_persisted_postings reconstructs the index without tokenization. - src/sql/pager/header.rs: - FORMAT_VERSION_V4 / FORMAT_VERSION_V5 constants (FORMAT_VERSION_BASELINE = V4). - DbHeader gains format_version field; encode_header writes whatever the caller picked, decode_header accepts both versions. - src/sql/pager/mod.rs: - stage_fts_btree / stage_fts_leaves stage one FTS index as a TableLeaf-shaped B-Tree (sidecar first, then per-term cells). - load_fts_postings walks leaves and decodes back into the (doc_lengths, postings) shape. - rebuild_fts_index gains the rootpage != 0 path (cell load), keeping rootpage == 0 as the compatibility replay path for v0.1.x→v0.2.0 upgraders. - save_database stages each FTS index, writes its rootpage to sqlrite_master, and conditionally bumps the header version to v5 (preserving v4 when no FTS index is attached). - parse_fts_create_index_sql helper, mirrors parse_hnsw_create_index_sql. Tests (engine 287 → 303 passing; +16 FTS-persistence-specific): - src/sql/pager/fts_cell.rs (10): posting + sidecar round-trips, empty postings, negative/large rowids, long term, 5000-entry posting list, wrong kind tag, truncated buffer, invalid UTF-8 term, implausible count. - src/sql/fts/posting_list.rs (1): serialize→from_persisted round-trip including zero-token doc. - src/sql/pager/mod.rs (5): persistence path is hit (rootpage != 0 in sqlrite_master), no-FTS save keeps v4, FTS save bumps to v5, empty + zero-token round-trip, 500-doc multi-leaf round-trip. This finishes the load-bearing 8a→8b→8c trio for the v0.2.0 release per docs/phase-8-plan.md. Out of scope (later sub-phases): - Hybrid retrieval worked example → 8d - MCP bm25_search tool → 8e - Docs sweep (fts.md, file-format.md, …) → 8f Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1832d0e commit 8dd9c46

7 files changed

Lines changed: 927 additions & 23 deletions

File tree

src/sql/fts/posting_list.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,70 @@ impl PostingList {
7676
}
7777
}
7878

79+
/// Phase 8c — emit `(rowid, doc_len)` pairs for every indexed doc,
80+
/// in ascending rowid order. The pager writes these into the FTS
81+
/// index's doc-lengths sidecar cell; reload feeds them back to
82+
/// [`Self::from_persisted_postings`].
83+
pub fn serialize_doc_lengths(&self) -> Vec<(i64, u32)> {
84+
self.doc_lengths
85+
.iter()
86+
.map(|(id, len)| (*id, *len))
87+
.collect()
88+
}
89+
90+
/// Phase 8c — emit `(term, [(rowid, term_freq)])` triples in
91+
/// lexicographic term order; per-term entries are in ascending
92+
/// rowid order (the underlying `BTreeMap` already guarantees this).
93+
/// One element per unique indexed term; pager writes one cell per
94+
/// element.
95+
pub fn serialize_postings(&self) -> Vec<(String, Vec<(i64, u32)>)> {
96+
self.postings
97+
.iter()
98+
.map(|(term, postings)| {
99+
let entries = postings.iter().map(|(id, freq)| (*id, *freq)).collect();
100+
(term.clone(), entries)
101+
})
102+
.collect()
103+
}
104+
105+
/// Phase 8c — rebuild a `PostingList` directly from the persisted
106+
/// doc-lengths sidecar + per-term postings. No tokenization runs;
107+
/// the resulting index is byte-equivalent to what was saved
108+
/// (assuming the input came from `serialize_*`).
109+
///
110+
/// `doc_lengths` is the full `(rowid, doc_len)` map written into
111+
/// the sidecar cell. `postings` is one `(term, [(rowid, tf)])`
112+
/// element per term cell.
113+
pub fn from_persisted_postings<I, J>(doc_lengths: I, postings: J) -> Self
114+
where
115+
I: IntoIterator<Item = (i64, u32)>,
116+
J: IntoIterator<Item = (String, Vec<(i64, u32)>)>,
117+
{
118+
let mut doc_lengths_map: BTreeMap<i64, u32> = BTreeMap::new();
119+
let mut total_tokens: u64 = 0;
120+
for (rowid, len) in doc_lengths {
121+
doc_lengths_map.insert(rowid, len);
122+
total_tokens += len as u64;
123+
}
124+
125+
let mut postings_map: BTreeMap<String, BTreeMap<i64, u32>> = BTreeMap::new();
126+
for (term, entries) in postings {
127+
let inner: BTreeMap<i64, u32> = entries.into_iter().collect();
128+
// An empty posting list shouldn't be persisted, but if it
129+
// somehow was, drop it on load — `remove()` would have
130+
// pruned the same way at runtime.
131+
if !inner.is_empty() {
132+
postings_map.insert(term, inner);
133+
}
134+
}
135+
136+
Self {
137+
postings: postings_map,
138+
doc_lengths: doc_lengths_map,
139+
total_tokens,
140+
}
141+
}
142+
79143
/// Tokenize `text` and add its postings under `rowid`. If `rowid` is
80144
/// already indexed, its previous postings are removed first — i.e.
81145
/// `insert` is idempotent for re-indexing the same row.
@@ -413,6 +477,33 @@ mod tests {
413477
assert_eq!(res[0].0, 1);
414478
}
415479

480+
#[test]
481+
fn serialize_round_trips_through_from_persisted() {
482+
// Phase 8c — the (de)serialize pair must reproduce the exact
483+
// in-memory state that was saved. Emptiness, multi-term, and
484+
// re-insert idempotence all need to round-trip.
485+
let mut pl = PostingList::new();
486+
pl.insert(1, "rust embedded database");
487+
pl.insert(2, "rust web framework");
488+
pl.insert(3, ""); // zero-token doc — exercises the sidecar
489+
pl.insert(4, "rust rust rust embedded power");
490+
491+
let docs = pl.serialize_doc_lengths();
492+
let postings = pl.serialize_postings();
493+
let roundtripped = PostingList::from_persisted_postings(docs, postings);
494+
495+
assert_eq!(roundtripped.len(), pl.len(), "doc count");
496+
assert_eq!(roundtripped.avg_doc_len(), pl.avg_doc_len(), "avg_doc_len");
497+
// Every query result + score must match.
498+
let q = pl.query("rust", &Bm25Params::default());
499+
let q2 = roundtripped.query("rust", &Bm25Params::default());
500+
assert_eq!(q, q2, "query results must match after round-trip");
501+
// Zero-token doc 3 stays in the corpus stats so total_docs is
502+
// honest, even though it'll never match a query.
503+
assert!(roundtripped.matches(1, "rust"));
504+
assert!(!roundtripped.matches(3, "rust"));
505+
}
506+
416507
#[test]
417508
fn synthetic_thousand_doc_corpus_top_ten_is_stable() {
418509
// 1000 deterministic docs. Most are noise; only 5 contain the

src/sql/pager/cell.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,30 @@ pub const KIND_INDEX: u8 = 0x04;
7373
/// the first varint after the kind tag — exactly the `node_id` here.
7474
pub const KIND_HNSW: u8 = 0x05;
7575

76+
/// Phase 8c: a single FTS posting-list cell. Body layout (after the
77+
/// shared `cell_length | kind_tag` prefix):
78+
///
79+
/// ```text
80+
/// cell_id zigzag varint sequential id assigned at save time;
81+
/// acts as the B-Tree slot key so
82+
/// `peek_rowid` works uniformly
83+
/// term_len varint length of the term in bytes
84+
/// (0 → this cell is the doc-lengths
85+
/// sidecar, value below is doc_len)
86+
/// term term_len bytes ASCII-lowercased term (per Phase 8 Q3)
87+
/// count varint number of (rowid, value) pairs
88+
/// for each:
89+
/// rowid zigzag varint the row this posting refers to
90+
/// value varint term frequency for this (term, row),
91+
/// or doc length when term_len == 0
92+
/// ```
93+
///
94+
/// One sidecar cell with `term_len == 0` holds `(rowid, doc_len)`
95+
/// pairs so reload reproduces every indexed doc — including any with
96+
/// zero-token text — without re-tokenizing. All remaining cells are
97+
/// posting cells, one per term.
98+
pub const KIND_FTS_POSTING: u8 = 0x06;
99+
76100
/// Value type tag stored in each non-NULL value block.
77101
pub mod tag {
78102
pub const INTEGER: u8 = 0;

0 commit comments

Comments
 (0)