B2 · docs
Deep dive · the write path

Indexing & the embedding pipeline

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.

1The shape of a reindex

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.

Per note, phase 1 does four things
  • Parse & stamp. Read the file losslessly; if it has no b2id, mint a ULID and write one surgical frontmatter line (B2's one always-allowed write during ingest) — logged as b2id.stamped.
  • Project the note rowupsert_note, which records the body's body_hash (the incremental signal used next time).
  • Decide whether to (re)embed — the heuristic below.
  • Embed the pending chunks, batched, writing vectors into chunks_vec.

2The incremental heuristic — embed only what changed

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).

walk vault — every *.md, sorted .b2/ and dotfolders skipped parse · stamp b2id if missing upsert_note → stores body_hash body_hash unchanged & all vectors present? …and not --force reuse existing chunks + vectors embed nothing — the fast path re-chunk → embed_batch(32) replace_chunks · write chunks_vec Phase 2 · derive edges always — resolver complete yes · reuse no / forced / gone
reuse (no work) re-embed the decision

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.

The two signals
SignalSourceWhy it's necessary
body_hash unchangeddb::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 embeddeddb::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.
Incremental ≡ full rebuild still holds. The skip only reuses vectors that a fresh embed would reproduce byte-for-byte (the embedder is a pure function of chunk text). So a drop-&-rebuild and a long run of incremental reindexes converge on the identical index — the fast path is an optimization, never a divergence. reindex reports what it did: indexed · embedded · stamped, where embedded is the count that actually re-ran.
Ground truth: reindex_is_incremental_and_force_reembeds_everything (embed.rs) — a fresh index embeds every note; a second reindex with nothing changed embeds 0; editing one note's body re-embeds exactly it; --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.

3Chunking — anchored paragraphs

crates/b2-core/src/chunk.rs

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.

4The embedder seam — one interface, batched

crates/b2-core/src/embed.rs (seam + fake) · crates/b2-embed/ (real)

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 methodsmodel_id, dim, embed (one passage), embed_query (asymmetric-ready, defaults to symmetric), and embed_batch (many passages at once).

FakeEmbedder — the CI default

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.

LocalEmbedder — the real model

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.

changed chunks (id, text)… batch ≤ 32 EMBED_BATCH tokenize + pad to batch-longest BERT forward pass Accelerate BLAS · macOS CLS + L2-norm row 0, masked chunks_vec vec0 · FLOAT[768]

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.

Ground truth: embed_batch_matches_embed_per_element (embed.rs) pins the fake's batch = map(embed); batched_equals_single_per_row (b2-embed, an #[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.

5The embedding space & the model swap

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.

Ground truth: changing_dim_recreates_the_vector_space_and_clears_vectors and ingest_populates_chunks_vec_and_records_meta (embed.rs) pin the swap discipline and the meta record; the façade's search path pins the fail-fast on mismatch.

6Progress & force — the operator view

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.

Ground truth: reindex_with_progress_reports_cumulative_and_fully_embeds (embed.rs) — progress is reported per batch, cumulative and non-decreasing, ending exactly at the chunk total, with every chunk embedded; reindex_is_incremental_and_force_reembeds (cli.rs) pins the embedded count and --force through the binary.

7What's deferred (and why it's clean)

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:

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.