What
Opening a note in the desktop app calls Vault::similar (the Similar & unlinked discovery pane). On a real vault, discover::candidates was quadratic in a bad way:
- A full brute-force scan of the entire
chunks_vec space per anchor chunk. For an anchor with A chunks over a vault of N chunks, that's A complete scans + sorts of the whole vector space.
- An N+1 chunk→note resolution: for every one of the
A × N hit rows, a separate SELECT note_b2id FROM chunks WHERE id = ?1.
Observed
Real vault (~/_PRIMARY_VAULT): N = 38,598 chunks; anchor __TODO.md has A = 12 chunks (12 × 38,598 = 463,176).
-
Clicking __TODO.md → ~1 min before the middle pane rendered.
-
B2_LOG_FILE for that single open = 277 MB / 926,592 lines, dominated by:
463,188 × SELECT id, chunk_id, chunk_offset FROM "main"."chunks_vec_rowids" WHERE rowid = ? (vec0 per-row probe, A×N)
463,176 × SELECT note_b2id FROM chunks WHERE id = ?1 (the N+1)
-
The similar façade span reported time.busy: 130s. Without logging, a release build still took ~51 s.
Also: the UI blocked the body render on discovery
ui/src/main.ts openNote fetched the note body, then await refreshDiscovery() before painting — so the already-in-hand body waited on the slow similar call. That is the "~1 min to render in the middle pane."
Root cause
discover::candidates (crates/b2-core/src/discover.rs) looped for anchor_chunk { vector_search_all(...) } — one whole-space scan each — and resolved every hit's note with a per-row query. Exact max-sim needs to see every chunk, but not to rescan the space A times or round-trip per hit.
Fix
discover::candidates → one streaming pass over chunks_vec (db::for_each_stored_vector), scoring every chunk against the anchor's A vectors in-process (squared-L2, sqrt applied once per surfaced candidate → identical ranking/score). Kills the A× rescan and the N+1 (chunk→note is one bulk db::chunk_note_map load).
ui/src/main.ts openNote paints the body immediately and loads discovery asynchronously (a "Finding similar notes…" hint), with a stale-guard added to refreshDiscovery so fast navigation can't clobber the new note's pane.
Result (real vault, b2 similar __TODO.md)
|
before |
after |
| release, no logging |
~51 s |
~4.4 s |
debug / just app |
130 s (logged) |
~10 s |
B2_LOG for one open |
277 MB / 926K lines |
12 MB / 38.7K lines |
| middle-pane render |
blocked on discovery |
instant |
Results are unchanged and deterministic across runs.
Regression test: crates/b2-core/tests/discover_query_count.rs locks both properties — chunk→note is one bulk query, and the vector space is scanned exactly once (not once per anchor chunk).
Residual (tracked separately)
Discovery is still exact brute-force over the whole vault on every open (~4.4 s release; ~38.6K vec-shadow-probe log lines per open). That deeper, heuristic-level concern is filed as a follow-up.
What
Opening a note in the desktop app calls
Vault::similar(the Similar & unlinked discovery pane). On a real vault,discover::candidateswas quadratic in a bad way:chunks_vecspace per anchor chunk. For an anchor withAchunks over a vault ofNchunks, that'sAcomplete scans + sorts of the whole vector space.A × Nhit rows, a separateSELECT note_b2id FROM chunks WHERE id = ?1.Observed
Real vault (
~/_PRIMARY_VAULT):N = 38,598chunks; anchor__TODO.mdhasA = 12chunks (12 × 38,598 = 463,176).Clicking
__TODO.md→ ~1 min before the middle pane rendered.B2_LOG_FILEfor that single open = 277 MB / 926,592 lines, dominated by:The
similarfaçade span reportedtime.busy: 130s. Without logging, a release build still took ~51 s.Also: the UI blocked the body render on discovery
ui/src/main.tsopenNotefetched the note body, thenawait refreshDiscovery()before painting — so the already-in-hand body waited on the slowsimilarcall. That is the "~1 min to render in the middle pane."Root cause
discover::candidates(crates/b2-core/src/discover.rs) loopedfor anchor_chunk { vector_search_all(...) }— one whole-space scan each — and resolved every hit's note with a per-row query. Exact max-sim needs to see every chunk, but not to rescan the spaceAtimes or round-trip per hit.Fix
discover::candidates→ one streaming pass overchunks_vec(db::for_each_stored_vector), scoring every chunk against the anchor'sAvectors in-process (squared-L2,sqrtapplied once per surfaced candidate → identical ranking/score). Kills theA×rescan and the N+1 (chunk→note is one bulkdb::chunk_note_mapload).ui/src/main.tsopenNotepaints the body immediately and loads discovery asynchronously (a "Finding similar notes…" hint), with a stale-guard added torefreshDiscoveryso fast navigation can't clobber the new note's pane.Result (real vault,
b2 similar __TODO.md)just appB2_LOGfor one openResults are unchanged and deterministic across runs.
Regression test:
crates/b2-core/tests/discover_query_count.rslocks both properties — chunk→note is one bulk query, and the vector space is scanned exactly once (not once per anchor chunk).Residual (tracked separately)
Discovery is still exact brute-force over the whole vault on every open (~4.4 s release; ~38.6K vec-shadow-probe log lines per open). That deeper, heuristic-level concern is filed as a follow-up.