Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/b2-core/src/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,13 @@ pub fn ingest_vault(conn: &Connection, vault_root: &Path, embedder: &dyn Embedde
/// DB-derived pending set for a later embed pass, so saving needs no embedder.
pub fn project_file(ctx: ProjectionCtx, rel_path: &str) -> Result<()> {
let p = project_note_and_chunks(ctx, rel_path, Rechunk::IfBodyChanged)?;
project_edges(ctx.conn, rel_path, &p.body, &p.relations)
project_edges(ctx.conn, rel_path, &p.body, &p.relations)?;
// A saved body may reuse cached vectors (e.g. undo). Complete coverage lets the
// desktop skip embedding, so restore its centroid here, with no model call (S3).
if db::embedding_space_exists(ctx.conn)? && db::note_fully_embedded(ctx.conn, rel_path)? {
db::refresh_note_centroid(ctx.conn, rel_path)?;
}
Ok(())
}

/// A vault file the projection pass could not read and skipped. `reason` describes the
Expand Down Expand Up @@ -497,6 +503,11 @@ pub fn project_vault(ctx: ProjectionCtx, force: bool) -> Result<ProjectOutcome>
// unreferenced ones now the chunk set is final.
if db::embedding_space_exists(conn)? {
db::prune_orphan_vectors(conn)?;
// Copies and external renames can reuse every vector yet have no centroid.
// Coverage is already complete, so no later automatic embed is guaranteed.
for path in db::notes_missing_centroids(conn)? {
db::refresh_note_centroid(conn, &path)?;
}
}

// Resources before phase 2, which resolves `![[img.png]]` against them (spec §3).
Expand Down
130 changes: 130 additions & 0 deletions crates/b2-core/tests/project_embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,136 @@ fn embed_status_reports_the_coverage_fraction() {
);
}

#[test]
fn reopening_and_projecting_reconciles_offline_delta_without_reembedding_unchanged_notes() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("vault");
golden_vault_copy(&root);
write_note(&root, "deleted.md", "A note removed while B2 is closed.");
let vault = Vault::open(&root).unwrap();
vault.reindex().unwrap();
drop(vault);

fs::write(
root.join("concepts/memory.md"),
"Offline revision: axolotl.\n",
)
.unwrap();
write_note(&root, "new.md", "Offline addition: narwhal.");
fs::remove_file(root.join("deleted.md")).unwrap();

let vault = Vault::open(&root).unwrap();
let stale = vault.embed_status().unwrap();
assert_eq!(
(stale.embedded, stale.total),
(3, 3),
"coverage cannot detect disk edits"
);
let projected = vault.project(false).unwrap();
assert_eq!((projected.indexed, projected.notes_pruned), (3, 1));
assert!(vault
.search("axolotl", 5)
.unwrap()
.iter()
.any(|hit| hit.path == "concepts/memory.md" && hit.snippet.contains("axolotl")));
assert!(vault
.search("narwhal", 5)
.unwrap()
.iter()
.any(|hit| hit.path == "new.md" && hit.snippet.contains("narwhal")));
assert!(vault
.list_notes()
.unwrap()
.iter()
.all(|n| n.path != "deleted.md"));
let pending = vault.embed_status().unwrap();
assert_eq!((pending.embedded, pending.total), (1, 3));

let embedded = vault.embed(&mut |_| ControlFlow::Continue(())).unwrap();
assert_eq!(
embedded.embedded, 2,
"only the edited and added note need vectors"
);
vault.project(false).unwrap();
assert_eq!(
vault
.embed(&mut |_| ControlFlow::Continue(()))
.unwrap()
.embedded,
0
);
assert_eq!(
fs::read_to_string(root.join("concepts/memory.md")).unwrap(),
"Offline revision: axolotl.\n"
);
}

#[test]
fn projection_restores_discovery_for_copied_notes_with_cached_vectors() {
let tmp = tempfile::TempDir::new().unwrap();
let (vault, root) = opened_vault(tmp.path());
vault.reindex().unwrap();
fs::copy(root.join("concepts/memory.md"), root.join("copy.md")).unwrap();

vault.project(false).unwrap();
let status = vault.embed_status().unwrap();
assert_eq!(
(status.embedded, status.total),
(3, 3),
"all vectors are reusable"
);
let conn = index_conn(&root);
assert_eq!(
count(&conn, "note_centroids"),
3,
"complete coverage must also be discoverable"
);
assert!(vault
.similar("notes/spaced-repetition.md", 10)
.unwrap()
.iter()
.any(|n| n.path == "copy.md"));
}

#[test]
fn editor_undo_restores_the_centroid_from_cached_vectors_without_embedding() {
let tmp = tempfile::TempDir::new().unwrap();
let (vault, root) = opened_vault(tmp.path());
vault.reindex().unwrap();
let conn = index_conn(&root);
let original = vault.read("concepts/memory.md").unwrap();
let centroid = || -> Vec<u8> {
conn.query_row(
"SELECT centroid FROM note_centroids WHERE note_path = 'concepts/memory.md'",
[],
|row| row.get(0),
)
.unwrap()
};
let before = centroid();
vault
.write(
"concepts/memory.md",
"Different body awaiting vectors.\n",
&original.revision,
)
.unwrap();
assert_eq!(
count(&conn, "note_centroids"),
1,
"new text needs embedding"
);
let edited = vault.read("concepts/memory.md").unwrap();
vault
.write("concepts/memory.md", &original.body, &edited.revision)
.unwrap();
assert_eq!(
centroid(),
before,
"undo restores the same summary without running the embedder"
);
}

/// A note with no body has no chunks, so it counts as embedded and forecasts no work.
/// Otherwise the fraction never reaches M/M, and every surface keyed on
/// `embedded < total` (caveats, auto-embed passes) stays stuck.
Expand Down
10 changes: 10 additions & 0 deletions docs/index-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ keyword-searchable (search degrades to BM25-only). The desktop uses exactly this
paint the tree, embed in the background. S2 is untouched: a projected-but-unembedded index is
a smaller projection, never a wrong one.

The desktop projects on every vault open, including startup: stored embedding coverage
cannot reveal files added, edited or deleted while the app was closed. After projection,
coverage gates the automatic embed pass. While running, filesystem pulses and saves are
debounced; requests arriving during an index run remain pending for a trailing pass,
because an embed operates on the pending chunks it read at its start.

Projection also restores missing centroids for notes whose vectors are already cached
(copies, renames or an editor undo), without a model call. Complete vector coverage must
not leave those notes waiting for an embed pass just to become discoverable.

**The incremental skip: embed only what changed.** A full re-embed is the one genuinely slow
step (a real transformer, on CPU), so a routine reindex must not redo finished work. Per
note: if the body is unchanged and its chunks already all have vectors, reuse them and embed
Expand Down
11 changes: 6 additions & 5 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,12 @@ Metal produce different vectors, so switching re-embeds the vault once on the ne

What the window adds over the terminal:

- **You never run reindex.** It indexes the vault on open, and a native fs-watch keeps up
with edits made outside the app (an external editor, a `git pull`), re-projecting and
re-embedding what changed. Projection and embedding are decoupled, so a cold vault is
browsable and keyword-searchable in seconds while the vectors stream in behind. A manual
reindex is still there in Settings → Index, with live progress and a Cancel.
- **Automatic indexing.** Every vault open scans for offline additions, edits and deletions,
even when the previous index was fully embedded. While B2 runs, the filesystem watcher
coalesces external changes after 300 ms of quiet; in-app edits save after one idle second.
Embedding follows after two quiet seconds, with changes during indexing queued for a
follow-up pass. Keyword search works as soon as projection finishes, while missing vectors
fill in the background. Settings → Index offers a manual Reindex, live progress and Cancel.
- **Discovery sits next to what you're reading.** The similar-but-unlinked notes are a pane,
not a command you remember to run: always the ranked nearest, with a strength band grading
each candidate within the list where a statistic exists (a vault under a dozen candidates
Expand Down
12 changes: 6 additions & 6 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading