How reindex turns a Markdown vault into the searchable
b2.sqlite index — the chunking, the embedder seam, and the heuristics that keep a
routine reindex cheap without ever letting the index drift from the Markdown it projects. A companion to
the ingest and embedder
sections of the architecture.
Ingest is the write path that realizes the governing equation
index = projection of (Markdown). It runs in two phases so link
resolution never depends on file order: phase 1 projects every note + its chunks (filling the
path → b2id resolver for the whole vault), phase 2 derives edges against that now-complete
resolver. Everything expensive — embedding — happens in phase 1, and that is where the heuristics live.
b2id, mint a ULID
and write one surgical frontmatter line (B2's one always-allowed write during ingest) —
logged as b2id.stamped.upsert_note, which records the body's
body_hash (the incremental signal used next time).chunks_vec.A full re-embed of a large vault is the one genuinely slow step (a real transformer, on
CPU). So a routine reindex must not redo finished work. Per note, the decision is: if the body is
unchanged and its chunks already all have vectors, reuse them and embed nothing. Otherwise re-chunk
and re-embed. --force bypasses the skip; a model swap forces it implicitly (see §4).
Note the asymmetry: edges are always re-derived (phase 2, cheap) because a link's
target may have moved even when this note's body didn't — only embedding is skipped. A
frontmatter-only edit (e.g. a relations: entry written by b2 link) therefore
re-projects the note and its edges but embeds nothing, which is what makes b2 link cheap too.
| Signal | Source | Why it's necessary |
|---|---|---|
| body_hash unchanged | db::note_body_hash | the stored hash of the note body, read before the upsert overwrites it — same body ⇒
chunk_body yields identical chunks, deterministically. |
| fully embedded | db::note_fully_embedded | every chunk of the note has a row in chunks_vec. Catches the model-swap case,
where the body is unchanged but the vector space was just emptied — so we must still re-embed. |
reindex reports what it did:
indexed · embedded · stamped, where embedded is the count that
actually re-ran.--force re-embeds all.
reindex_yields_identical_vectors pins the byte-identical reuse, and
one_note_reindex_equals_full (graph.rs) pins the edge-set equality.Today's chunker is deliberately minimal: a paragraph splitter — maximal runs of non-blank
lines, one chunk each. Every chunk records char_start..char_end addressing the exact body
slice that produced it, so a chunk stays anchored for later highlight/explain. The chunks
schema already carries token_count and heading_path for a future qmd-style
~900-token, overlap-aware chunker — swapping it in is a pure re-projection (drop & rebuild), no schema
or invariant change. Chunk text is also the FTS5 source: the chunks_ai/ad/au triggers keep
chunks_fts in lockstep, so keyword search sees a chunk the moment it lands — before it is
embedded.
Producing embeddings is orthogonal to the store, so it sits behind a trait. The engine is
built and tested against a deterministic fake; the real local model drops in through the same seam with no
schema or flow change. The trait is five methods — model_id, dim,
embed (one passage), embed_query (asymmetric-ready, defaults to symmetric), and
embed_batch (many passages at once).
blake3-hashes text into a vector: identical text → identical
vector, deterministically. KNN is reproducible and drop-&-rebuild yields byte-identical vectors —
not semantic, a stand-in so the fast suite stays model-free. Its embed_batch is
the trait default: a faithful map over embed.
A candle-backed BERT sentence embedder
(BAAI/bge-base-en-v1.5, 768-dim), pure-Rust inference compiled into the binary. CLS-pool +
L2-normalize (so sqlite-vec's L2 distance ranks by cosine); an asymmetric query prefix.
b2 init downloads + verifies it into a shared XDG cache; loading fails fast if the
files are absent — never a surprise mid-command download.
Embedding is the reindex hot path, so the write side hands whole
batches of chunks to embed_batch (up to EMBED_BATCH = 32). The real model
turns a batch into one padded forward pass instead of N single ones — a large CPU win — and on
macOS candle's matmuls run on Apple's Accelerate BLAS.
Right-padding + the attention mask make each row's CLS vector identical to embedding that text alone — so batching is a pure speedup, never a change in result.
#[ignore]d real-model test, cosine > 0.9999) pins the real model's batch =
single, row for row. The retrieval eval (--example eval, out of CI) confirms Accelerate left
ranking quality unchanged.The vector column's dimension is a DDL literal, so chunks_vec is created at
embed time at the model's dim, not in the base migration. meta records
(embed_model_id, embed_dim) — the only place a model swap is detectable.
ensure_embedding_space drops and recreates chunks_vec
empty. Every note is then "not fully embedded", so the incremental heuristic (§2) re-embeds the whole
vault on that reindex — automatically, no --force needed.reindex, so changing the configured model can't silently wipe vectors on the next
read command.search compares the index's recorded model to the
active one and returns ModelMismatch ("run b2 reindex") rather than fusing
incomparable vectors into silently-wrong results.meta record; the façade's search path pins the fail-fast on mismatch.Because a cold index of a large vault is real work, the embed phase reports
ReindexProgress after every batch, and the CLI renders a live line — on an interactive stderr
only, so --json and piped/captured output stay pure data. The count tracks the notes that
actually embed (3/3 below, matching the report's embedded), not
position in the full list — an incremental run reuses most notes untouched, so the line reflects real
work rather than jumping to something like 812/1200.
# incremental by default — only changed notes re-embed; reindex needs an explicit vault b2 reindex ~/vault Indexing /Users/you/vault embedding 3/3 · notes/spaced-repetition.md (11 chunks) # live, in place (TTY only) Indexed 1200 notes (3 embedded, 0 stamped) # re-embed everything in place, without deleting the index b2 reindex --force
Timestamps like indexed_at come from SQLite,
keeping b2-core free of any wall-clock — the façade is the one boundary where real ids
and time enter, exactly as it is for the id generator.
embedded count and --force through the binary.Incremental reindex makes the steady state cheap. Making the first cold index of a large vault non-blocking is a separate, composable step — documented in the task backlog:
b2 status — detach and return immediately; search/similar
read the index live (SQLite WAL already permits one writer + concurrent readers across processes).Embedder seam; a raw-speed lever, measured against the eval before it becomes the default.Each drops in behind an existing seam or invariant — none needs a schema or flow change, which is the whole point of the deterministic-seam discipline.