Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b68fb3a
Release v2.1.1 — Local embeddings (semantic search)
ddutchie Jun 18, 2026
88292ab
Fix lint: replace require() with ES import, refactor setState-in-effe…
ddutchie Jun 18, 2026
0996d59
PR-Review
ddutchie Jun 19, 2026
7907a68
Add embeddings pipeline benchmarks, fix per-note progress emission
ddutchie Jun 19, 2026
fa2cb3c
Route ONNX inference through native onnxruntime-node (40× less idle RAM)
ddutchie Jun 19, 2026
fea2772
Clean up dead code after native ONNX migration
ddutchie Jun 19, 2026
f1ae308
Serialize chunk embedding to bound worker memory at ~750 MB
ddutchie Jun 19, 2026
2859212
Fix: compute semantic relationships after bulk reindex
ddutchie Jun 19, 2026
6072d55
Compute semantic relationships even when reindex skips all notes
ddutchie Jun 19, 2026
1fa5834
Switch computeSemanticRelationships from absolute threshold to top-K=5
ddutchie Jun 19, 2026
35a5edd
Add scenario tests showing semantic relationships across topic clusters
ddutchie Jun 19, 2026
c024b76
Section-based embeddings: one vector per markdown section
ddutchie Jun 19, 2026
352cb79
Fix model-aware pipeline cache + loosen topK benchmark ceiling
ddutchie Jun 19, 2026
0d029cf
Fix: enable padding+truncation on tokenizer for batched section embed…
ddutchie Jun 19, 2026
a22e3da
Fix: embed sections sequentially, not batched
ddutchie Jun 19, 2026
ee42107
Fix: add v19 migration for missing relationship_cache columns + per-n…
ddutchie Jun 19, 2026
acbd432
Fix: 'Recompute auto relationships' no longer wipes semantic edges
ddutchie Jun 19, 2026
36a5f59
Knowledge Graph: highlight connections on node select, dim rest
ddutchie Jun 19, 2026
eb43244
Add semantic neighbors MCP tool, KG edge tooltips, section-level Back…
ddutchie Jun 19, 2026
d562b4a
Update v2.1.1 changelog with section embeddings, MCP tool, KG tooltip…
ddutchie Jun 19, 2026
2b92bb4
PR - Review
ddutchie Jun 19, 2026
de5ec87
PR-Review
ddutchie Jun 19, 2026
a08e93c
Update service.bench.test.ts
ddutchie Jun 19, 2026
333ec8f
Licenses
ddutchie Jun 19, 2026
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
59 changes: 59 additions & 0 deletions changelogs/v2.1.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## What's new

### Features

- **Local Embeddings (Semantic Search)**: Notes now have semantic neighbour search powered by a packaged local embedding model (`nomic-ai/nomic-embed-text-v1.5`, int8 quantised, ~91 MB). A background HTTP worker (Node-based, spawnable also as a standalone `cairn-embeddings` binary via `@yao-pkg/pkg`) handles inference through `@xenova/transformers` + `onnxruntime-node`. Embeddings live in a new `note_embeddings` SQLite table (v17 migration, JSON-TEXT vector storage shaped for future `sqlite-vec` swap). The Knowledge Graph gains a `"semantic"` edge type (cosine similarity ≥ 0.78) with an inline opacity slider in the toolbar, and the Notes editor's Backlinks panel gains a "Semantic" section showing the top-5 most similar notes with score bars. Toggle via the Sparkles icon in the editor header.

- **Incremental Auto-Reindex on Save**: Editing a note now triggers a single-note embed reindex in the background (skipped if `content_hash` is unchanged), so the Semantic backlinks section stays current without ever visiting Settings. First-time users run **Reindex notes (embed + search)** once from Settings → Embeddings to backfill existing notes; afterwards, the index stays fresh on its own.

- **Long-Document Chunking**: Notes longer than 4000 characters are now split into overlapping chunks (~200 char overlap, breaking on paragraph/sentence/word boundaries) and their embeddings averaged after L2 normalisation. Previously long notes silently truncated at the tokenizer's 8192-token limit, losing signal.

- **Query Vector Cache**: Repeat searches for the same note content (post-debounce) now hit a 64-entry LRU cache in the main process keyed by `sha256("q|" + model + "|" + queryText)` — skipping the worker round-trip entirely on cache hit.

- **Packaged Worker Binary**: `scripts/build-embeddings-binary.js` bundles `electron/embeddings/server.ts` into a standalone `cairn-embeddings` executable via `@yao-pkg/pkg` (mirrors `build-mcp-binary.js`). In dev, the worker is spawned via the system Node binary (`/opt/homebrew/bin/node` etc.) with `--max-old-space-size=4096` to sidestep Electron-as-Node native module edge cases; in packaged builds, the binary is loaded from `app.asar.unpacked/dist-embeddings/`.

### Fixes

- **`chunkLongText` Infinite Loop → OOM Crash**: When `end` reached `text.length` exactly, `start = end - CHUNK_OVERLAP` (200) still satisfied `start < text.length`, so the loop pushed the same chunk forever, exhausting the V8 heap and crashing the worker with SIGTRAP. Now breaks out of the loop the moment `end >= text.length`, plus defensive guards for `end <= start` and `nextStart <= start` (no forward progress).

- **Search Returned No Results Despite Reindex**: `recomputeProjections` re-embedded every note with `task='clustering'`, which — because `note_embeddings.note_id` is the primary key with `ON CONFLICT DO UPDATE` — silently overwrote the `search_document` rows that `searchAdjacent` filters by. Recompute now reads existing `search_document` embeddings and only runs UMAP (no re-embed); the two maintenance buttons are clearly labelled ("Reindex notes (embed + search)" vs "Recompute graph clusters (UMAP only)") to prevent confusion.

- **Model Install Never Detected as Installed**: `modelFilesPresent` looked for HuggingFace cache slugs (`nomic-ai--nomic-embed-text-v1.5`) but `@xenova/transformers` stores model files in a nested directory layout (`nomic-ai/nomic-embed-text-v1.5/config.json` + `onnx/*.onnx`). Now checks the actual layout, so a previously-downloaded model shows as installed immediately.

- **Worker Killed During In-Flight Requests**: `stopWorker` would SIGTERM the child even when an embed request was in flight, surfacing as `socket hang up` errors on the renderer. Now refuses to kill when `inFlightEmbeds > 0` (logs the call stack); only `dispose()` on app quit forces a kill.

- **Progress State Lost on Settings Remount**: `reindexProgress` (the `done/total` bar) was renderer-only state, reset whenever the Settings view unmounted. Now `getStatus()` exposes `lastReindexDone/Total` + `lastRecomputeDone/Total` from the main-process lock slots, so the bar restores on remount.

- **Duplicate Concurrent Reindex Calls**: No mutex existed, so rapid clicks or cross-window triggers could kick off overlapping reindex passes. Now `withLock` enforces a single concurrent `reindex` and `recomputeProjections`; duplicates are rejected with `Error: "Reindex already in progress"` and surfaced as `Blocked: ...` in the UI.

- **Notes With Zero Backlinks Hid Semantic Section**: `BacklinksPanel` returned `null` when `total === 0`, so enabling the Sparkles toggle on a note with no backlinks produced no feedback. Now stays visible whenever semantic is enabled, showing "searching…" while loading and "No similar notes found. Reindex from Settings → Embeddings to refresh." when search returns 0 hits.

- **Worker Spawn Used Electron-as-Node**: Native ONNX runtime binaries under `ELECTRON_RUN_AS_NODE=1` can crash or fail to load on some platforms. Dev mode now resolves the system Node binary via `which node` / `where node` and spawns the worker with that instead, with `--max-old-space-size=4096` for the model-load phase.

- **Worker Process Had No Uncaught Handlers**: Native crashes inside `onnxruntime-node` surfaced as `worker exited (code=null)` with no signal name or stderr. Now logs `code` + `signal` on exit, dumps the last 1KB of stderr, and the worker itself emits `uncaughtException` / `unhandledRejection` as `{kind:"error", msg:"..."}` JSON-line events before exiting.

### Changes

- **Single embedding task**: All embedding operations now store under `task='search_document'`. The previous `clustering` task is gone — `recomputeProjections` reuses `search_document` rows for UMAP, halving DB rows and removing the upsert-overwrite footgun. A `pruneOrphanedClusteringRows` query runs at the start of any full reindex to clean up legacy rows from prior versions.

- **UI: Semantic folded into Backlinks**: The right-hand `SemanticHubsPanel` (separate `w-72` column) is removed; semantic results now render as the first section inside the bottom Backlinks panel, auto-opening it when results arrive. Header reads e.g. `"3 similar + 2 backlinks"`. The Sparkles icon in the editor header still toggles visibility.

- **Embeddings Settings (`src/components/settings/EmbeddingsSettings.tsx`)**: New section with model install/remove/setDefault controls, real-time download progress bars, worker status display, and maintenance buttons (Reindex / Recompute) with progress bars that survive view switches.

- **Build pipeline**: `package.json` adds `--external:@xenova/transformers --external:onnxruntime-node --external:umap-js` to `compile`/`compile:watch` (dev) and a new `compile` target for `embeddings-server.bundle.js`. New script `build:embeddings-binary` runs `scripts/build-embeddings-binary.js`.

- **`main.ts` cleanup**: Imports `dispose as disposeEmbeddingsWorker` from `./embeddings/client` and calls it in `before-quit` so the worker never lingers.

---

## Changed files

| Area | Files | Change |
|------|-------|--------|
| Core embeddings | `electron/embeddings/{types,cosine,nomic,projection,pipeline,port,server,client,manifest,service}.ts` | New module: types/Zod schemas, cosine + topK, nomic prefix, UMAP projection, transformers.js pipeline, HTTP worker, spawn/health/dispose lifecycle, model manifest, reindex/search/recompute service layer |
| DB layer | `electron/db/schema.ts`, `electron/db/queries.ts`, `electron/mcp/db.ts`, `electron/mcp-server.ts` | v17 migration creating `note_embeddings`; query helpers (`upsertNoteEmbedding`, `getAllEmbeddingsForWorkspace`, `markEmbeddingProjectionFresh`, `pruneOrphanedClusteringRows`); `ensureEmbeddingsTable` wired into MCP bootstrap |
| Graph | `electron/db/graph-queries.ts`, `src/types/index.ts`, `src/store/slices/graph.ts`, `src/components/graph/{ForceGraphCanvas,RadialTreeCanvas,KnowledgeGraphView}.tsx` | `"semantic"` edge type; `computeSemanticRelationships` (O(n²) cosine, 0.78 threshold); slider in KG toolbar; edge colour + filter in both canvases |
| IPC + preload | `electron/ipc/{embeddings-handlers,settings-handlers,handlers,registry,db-handlers}.ts`, `electron/preload.ts`, `electron/lib/config-cache.ts` | New `db:embeddings:*` + `embeddings:*` handlers with mutex + progress broadcasts; preload `embeddings` namespace with `onProgress`; `app:{get,save}EmbeddingsSettings`; cached config extension; incremental reindex hook on note save |
| UI | `src/components/settings/{EmbeddingsSettings,settings-view}.tsx`, `src/components/notes/{note-editor,BacklinksPanel}.tsx`, `src/hooks/useDebouncedValue.ts` | Embeddings settings UI; semantic section folded into BacklinksPanel (replaces right-hand SemanticHubsPanel); Sparkles toggle in editor header; debounced query hook |
| Build | `package.json`, `scripts/build-embeddings-binary.js`, `electron/main.ts` | deps (`@xenova/transformers`, `onnxruntime-node`, `umap-js`); esbuild externals + new compile target for worker bundle; `build:embeddings-binary` script; worker dispose on app quit |
| Tests | `electron/embeddings/{cosine,projection,chunking,query-cache}.test.ts` | New unit tests for cosine similarity, topK, UMAP projection (deterministic seeded), chunking (terminates on long input, correct chunk count), query cache (LRU eviction, hash-keyed hit) |
163 changes: 163 additions & 0 deletions docs/plans/local-embeddings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Local Embeddings + Vector Graph — Implementation Plan

**Status:** in-progress
**Started:** 2026-06-18
**Scope:** Offline-first semantic features for Notes + Knowledge Graph using
`nomic-ai/nomic-embed-text-v1.5` via `@xenova/transformers`, running as a
packaged HTTP binary so both the Electron main process and the MCP server
can call it.

## Architecture

```
Electron Main ─────┐ ┌──── MCP Server (pkg binary)
│ HTTP 127.0.0.1:<free> │ fetch()
embeddingsClient ──┴──► cairn-embeddings ◄───┘
(spawn+health, (pkg-packaged Node 22 binary;
kill on quit) owns ONNX Runtime, exposes
POST /embed {texts, task} → {vectors}
GET /health)
↓
nomic-ai/nomic-embed-text-v1.5 (int8 quantised, ~87 MB)
↓
SQLite writes go through whichever process made the call:
- Electron main → electron/db/queries.ts (Electron ABI db handle)
- MCP → mcp/db.ts (Node 22 ABI db handle)
```

Mirrors the existing `llama-server.ts` spawn/findFreePort/checkHealth/dispose
pattern at `main.ts:303-313` (`before-quit`). The MCP server reaches the binary
via HTTP `fetch()` — no second wire protocol to maintain.

## Nomic task prefixes (correctness-critical)

Prefix **must** be prepended to the raw input string **before** tokenisation.

| Task | Prefix | Usage |
|-------------------|----------------------|----------------------------------------|
| `search_document` | `search_document: ` | Indexing notes |
| `search_query` | `search_query: ` | Sidebar "Semantic Hubs" similarity |
| `clustering` | `clustering: ` | Graph projection / UMAP |

## Storage shape

`note_embeddings` table (v17 migration in `schema.ts`):

```
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE
workspace_id TEXT NOT NULL
model TEXT NOT NULL -- e.g. "nomic-ai/nomic-embed-text-v1.5"
task TEXT NOT NULL -- "search_document" | "clustering"
content_hash TEXT NOT NULL -- sha256 of embedded text → dirty-check
vector TEXT NOT NULL -- JSON number[768]; swap for BLOB at vec0
embedded_at TEXT NOT NULL
dim_x REAL -- UMAP projection (clustering task only)
dim_y REAL
proj_stale INTEGER NOT NULL DEFAULT 1
```

Indexes: `idx_emb_ws(workspace_id)`, `idx_emb_proj(proj_stale)`.

Bridge to eventual `sqlite-vec` swap: the `vector` column is JSON-in-TEXT,
which fits the existing `db-mappers.ts` `parseJsonArray` convention. The
swap is one migration: copy JSON → BLOB → `vec0` virtual table + drop TEXT
column. `cosine.ts` API does not change.

## Tasks

### Phase 1 — Core (Electron main-side)
- [x] 1a. Install deps: `@xenova/transformers`, `onnxruntime-node`, `umap-js`
- [x] 1b. `electron/embeddings/types.ts` — Zod request/response + manifest types
- [x] 1c. `electron/embeddings/cosine.ts` — pure typed cosine + topK
- [x] 1d. `electron/embeddings/nomic.ts` — task prefixes + embed wrapper
- [x] 1e. `electron/embeddings/projection.ts` — UMAP runner (pure ESM, no bsqlite)

### Phase 2 — Worker binary (packaged, HTTP)
- [x] 2a. `electron/embeddings/server.ts` — HTTP entry (`/embed`, `/health`)
- [x] 2b. `scripts/build-embeddings-binary.js` — pkg bundler (clone of build-mcp-binary.js)
- [x] 2c. `electron/embeddings/client.ts` — spawn + findFreePort + checkHealth + dispose
- [x] 2d. `electron/embeddings/manifest.ts` — model registry + default-model.json
(clone of `llama-server.ts` lines 26–169 + 222–455)

### Phase 3 — DB layer
- [x] 3a. v17 migration in `electron/db/schema.ts` (after v16)
- [x] 3b. Query helpers in `electron/db/queries.ts`:
`upsertNoteEmbedding`, `getNoteEmbedding`, `getAllEmbeddingsForWorkspace`,
`getStaleProjectionIds`, `upsertProjection`
- [x] 3c. `ensureEmbeddingsTable(db)` in `electron/mcp/db.ts`
(MCP doesn't call `applySchema`; mirrors `ensureMcpActiveWritesTable`)

### Phase 4 — IPC + preload
- [x] 4a. `electron/ipc/embeddings-handlers.ts`:
`db:embeddings:reindex` (write), `db:embeddings:search` (read),
`db:embeddings:recomputeProjections` (read),
`embeddings:status`, `embeddings:models:list|install|remove|setDefault`,
`embeddings:download-progress` event
- [x] 4b. Register `registerEmbeddingsHandlers(ctx)` in `handlers.ts:52`
- [x] 4c. Add `db:embeddings:search`, `db:embeddings:recomputeProjections` to
`readChannels` in `registry.ts:35-53`
- [x] 4d. `electron/preload.ts` — add `embeddings` namespace (mirror `llama`)

### Phase 5 — Settings (model management UI)
- [x] 5a. Extend `CachedConfig` in `electron/lib/config-cache.ts` with
`embeddingsConfig?` + `saveCachedConfig("embeddings", ...)`
- [x] 5b. Add `app:{get,save}EmbeddingsSettings` to
`electron/ipc/settings-handlers.ts`
- [x] 5c. Extend `AIConfig` (or new `EmbeddingsConfig`) in
`src/store/slices/ui.ts` — dual-write pattern mirrors `setAIConfig:167-176`
- [x] 5d. Hydrate embeddings config in `src/store/index.ts:hydrateFromElectron`
- [x] 5e. New `src/components/settings/EmbeddingsSettings.tsx`
(clone of `AISettings.tsx` model manager, lines 567–726)
- [x] 5f. Add `"embeddings"` section in `settings-view.tsx` (union + nav + render)

### Phase 6 — Knowledge Graph extensions
- [x] 6a. Add `"semantic"` to `GraphEdgeType` in `src/types/index.ts:276`
- [x] 6b. Mirror in local `EdgeType` at `electron/db/graph-queries.ts:41`
- [x] 6c. Push `"semantic"` into `autoTypes` array (pass 6)
- [x] 6d. Add similarity block in `computeAutoRelationships` (after keyword block):
`SEMANTIC_THRESHOLD = 0.78`, uses `getAllEmbeddingsForWorkspace("search_document")`
- [x] 6e. Add `"semantic"` to `DEFAULT_GRAPH_FILTERS.edgeTypes` and `edgeTypeLabel`
in `src/store/slices/graph.ts`
- [x] 6f. `ForceGraphCanvas.tsx:34` — `edgeColor()` case for `"semantic"`
- [x] 6g. `RadialTreeCanvas.tsx:71` — `crossEdgeColor()` case for `"semantic"`
- [x] 6h. Semantic opacity slider in `KnowledgeGraphView.tsx:~300` (next to Spacing)

### Phase 7 — Note editor (Semantic Hubs panel)
- [x] 7a. `src/hooks/useDebouncedValue.ts` — generic hook
- [x] 7b. `src/components/notes/SemanticHubsPanel.tsx` — top-5 adjacent notes
- [x] 7c. Wire into `note-editor.tsx:842` (flex row with editor + panel)
- [x] 7d. 1200ms semantic debounce ref in `handleContentChange`, cleared in `flushPending`

### Phase 8 — Build + verify
- [x] 8a. `package.json` deps + esbuild externals + new compile target for the worker
- [x] 8b. `main.ts:~173` — bootstrap (lazy spawn on first IPC request)
- [x] 8c. `main.ts:~310` — `embeddingsClient.dispose()` in `before-quit`
- [x] 8d. `npm run type-check:all` — must pass
- [x] 8e. `npm run compile` — must produce all bundles
- [x] 8f. Vitest tests: `cosine.test.ts`, `projection.test.ts`, `nomic.test.ts`

## Critical implementation notes (gotchas)

1. **Nomic prefix before tokenisation.** `nomic.ts:withNomicPrefix` runs on the
raw string before the pipeline call. Never feed the worker pre-tokenized input.
2. **`normalize: true` at pipeline output.** Cosine becomes dot product; `cosine.ts`
skips the L2 division. Guard behind a fast path: assume normalized, fall back if
magnitude ≠ 1 within ε.
3. **v17 migration's `vector TEXT (JSON)` is a deliberate bridge** to `sqlite-vec`.
Don't bake `JSON.parse` into call sites — funnel through `queries.ts` accessors.
4. **MCP server can read/write embeddings but must NOT construct a `Database` with
the wrong ABI.** It uses its existing Node-22-ABI handle from `mcp-server.ts:140`.
`note_embeddings` table must be ensured inline in `mcp/db.ts` because MCP does
not call `applySchema`. Symmetric to `ensureMcpActiveWritesTable`.
5. **`semantic` edge weight handling in renderer.** The `edgeFingerprint` memo at
`ForceGraphCanvas.tsx:139-155` re-simulates on any edge-set change. The
`semanticThreshold` slider filters inside the memo so it trims the data the
simulation sees (not just a visual filter).
6. **1200ms semantic debounce > 2× the 300ms save debounce.** Every semantic search
runs against content already saved to SQLite. `flushPending` clears both timers.
7. **esbuild + template-literal gotcha** (AGENTS.md): backticks inside template
literals must be unescaped at the template level.
8. **`zod` import style** in all new Electron files: `import * as z from "zod"`).
9. **CSS** all colours via `var(--token)` / `color-mix(...)` — never raw rgba or
Tailwind colour names. SVG `fontSize` × `useFontScale()`.
10. **No comments unless explicitly requested** (AGENTS.md).
Loading