Skip to content

Desktop: opening a note stalls ~1 min + writes a 277 MB debug log — discover::candidates rescans the whole vector space per anchor chunk + N+1 #37

Description

@samkeen

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::candidatesone 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 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions