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
35 changes: 35 additions & 0 deletions changelogs/v2.1.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## What's new

### Features

- **Embedding model swap — Nomic → bge-small-en-v1.5**: Replaced `nomic-ai/nomic-embed-text-v1.5` (137 MB, 768-dim, 8K context) with `Xenova/bge-small-en-v1.5` (33 MB, 384-dim, 512 context). Same MTEB quality score (~62) with 4× smaller download, 5× less peak memory, and 3–26× faster inference depending on text size. Since Cairn chunks notes at ≤4KB anyway, the 512-token context limit is not a constraint.

- **Automatic orphan model cleanup**: When the Embeddings settings panel opens, orphaned model directories (models no longer in `SUPPORTED_EMBEDDING_MODELS`) are automatically pruned from disk. Users upgrading from v2.1.3 will have the old 137 MB Nomic model silently removed with a console log — no manual cleanup needed.

- **Migrated to @huggingface/transformers v4**: Replaced the deprecated `@xenova/transformers` (v2) with the official `@huggingface/transformers` v4.2.0. Brings 120+ architecture support, improved quantization options (q4, q4f16, int8, uint8, bnb4), and ongoing maintenance from the Hugging Face team. The sharp stub continues to work — the stub exports a truthy no-op function so transformers.js's image module passes its `else if (sharp)` check without pulling in the native binary.

### Changes

- **Dropped Intel macOS (darwin-x64) build target**: `onnxruntime-node` 1.24.3 (required by `@huggingface/transformers` v4) no longer ships a `darwin-x64` prebuilt binary. The macOS build target in `electron-builder.yml` is now `arm64` only. Apple Silicon Macs (M1+, shipped since 2020) are fully supported; Intel Mac users should stay on v2.1.3.

- **Embedding dimension changed from 768 to 384**: Existing note embeddings stored with the old Nomic model are automatically detected as stale (model ID mismatch in `reindexNotes`) and will be re-embedded with bge-small on the next reindex. No manual intervention or DB migration required — the `vector` column is `TEXT` (JSON-serialized) and handles any dimension.

- **Reindex prompt on startup**: When the app starts with embeddings enabled and detects that existing note embeddings were created with a different model (e.g. Nomic → bge-small), a modal prompts the user to reindex their notes. Shows progress (note count, percentage) and includes a "Later" dismiss button. The modal stays hidden if embeddings are disabled or all embeddings match the current model.

- **bge-small query prefix**: bge-small-en-v1.5 uses a different query instruction than Nomic. Search queries are prefixed with `Represent this sentence for searching relevant passages:` instead of Nomic's `search_query: ` prefix. Document embedding uses no prefix (Nomic used `search_document: `).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- **Embeddings onboarding step**: New onboarding step (between MCP and Views) lets new users enable local semantic embeddings and download the model (~33 MB) before their first note is indexed. Real-time download progress bar driven by the existing `embeddings:download-progress` IPC stream. Returning users see their saved preference. Settings can be changed later in Settings → Embeddings.

- **Semantic search in global ⌘K panel**: The search panel now has a Sparkles toggle (between the clear button and ESC kbd). When enabled and embeddings are ready, queries debounce (250 ms) through `embeddings.search` and results are merged with keyword `searchAll` hits (deduped by note id; semantic-only hits show the matching section title). Each semantic hit shows a cosine score badge (e.g. "Sparkles 87%"). Falls back silently to keyword-only if the worker is down. Preference persisted in `localStorage` under `search.semanticMode`.

- **`search_notes_semantic` MCP tool**: New read-only MCP tool exposed to Claude Desktop, Cursor, OpenCode, and the in-app Cairn agent. Schema: `{ workspaceId, query, k? }` (default k=5). Embeds the query via the local bge-small model and returns matching notes with similarity scores and matching section titles — better than `search_notes` when concepts are described in different words (e.g. query "auth flow" finds a note titled "Login pipeline"). Refuses gracefully if embeddings are disabled. Added to both the default and plan-mode tool allowlists.

- **Semantic Map canvas (Insights)**: New "Semantic Map" layout in the Insights view. Plots every embedded note as a dot positioned by its 2D UMAP projection (`dim_x` / `dim_y`), coloured by project. Pan via drag, zoom via wheel or +/− controls, reset via ⟲. Tooltips show note title, project, and model. Both empty-state (no projections yet) and stale-state (some rows have `proj_stale=1`) show a "Compute/Recompute projections" button that runs UMAP inline via the existing `embeddings.recomputeProjections` IPC, with live progress bar driven by the `embeddings:download-progress` broadcast. New IPC channel `embeddings:projections` exposes the projection table to the renderer.

- **`recomputeProjections` exposed in the UI**: Previously UMAP projection recomputation was only reachable via a manual button in Settings → Embeddings with no display consumer. Now surfaced inline on the Semantic Map canvas (both empty state and toolbar), giving users a compelling visualization that justifies the compute.

### Fixes

- **Sharp stub must be truthy**: The sharp stub previously exported `null` (falsy), which caused transformers.js's `image.js` module to throw `Unable to load image processing library` — crashing the embeddings worker at startup on Node 25 (which doesn't define `self`, so the browser branch is also skipped). Fixed by exporting a function (truthy, matching sharp's callable API shape) that throws if ever called. Cairn never invokes it — only text tokenization is used.

- **`get_semantic_neighbors` now reachable via chat**: The in-app chat executor was missing a routing case for `get_semantic_neighbors`, so calls to this MCP tool from the AI chat would fall through to "Unknown tool". Added to the `executeMcpTool` dispatch group alongside `get_knowledge_graph` and `get_neighbors`.
4 changes: 2 additions & 2 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ mac:
category: public.app-category.productivity
target:
- target: dmg
arch: [arm64, x64]
arch: [arm64]
- target: zip
arch: [arm64, x64]
arch: [arm64]
icon: build-resources/icon.icns
hardenedRuntime: true
gatekeeperAssess: false
Expand Down
42 changes: 42 additions & 0 deletions electron/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,48 @@ export function markAllProjectionsStale(db: Database.Database, workspaceId: stri
db.prepare("UPDATE note_embeddings SET proj_stale = 1 WHERE workspace_id = ?").run(workspaceId);
}

export interface NoteProjectionRow {
noteId: string;
dimX: number;
dimY: number;
projStale: number;
embeddedAt: string;
model: string;
}

export function getNoteProjections(
db: Database.Database,
workspaceId: string,
): { rows: NoteProjectionRow[]; anyStale: boolean } {
const raw = db.prepare(`
SELECT note_id, dim_x, dim_y, proj_stale, embedded_at, model
FROM note_embeddings
WHERE workspace_id = ? AND section_idx = 0
`).all(workspaceId) as Array<{
note_id: string;
dim_x: number | null;
dim_y: number | null;
proj_stale: number;
embedded_at: string;
model: string;
}>;
let anyStale = false;
const rows: NoteProjectionRow[] = [];
for (const r of raw) {
if (r.proj_stale) anyStale = true;
if (r.dim_x === null || r.dim_y === null) continue;
rows.push({
noteId: r.note_id,
dimX: r.dim_x,
dimY: r.dim_y,
projStale: r.proj_stale,
embeddedAt: r.embedded_at,
model: r.model,
});
}
return { rows, anyStale };
}

export function getNoteEmbeddings(db: Database.Database, noteId: string): NoteEmbeddingRecord[] {
const rows = db.prepare("SELECT * FROM note_embeddings WHERE note_id = ? ORDER BY section_idx").all(noteId) as NoteEmbeddingRow[];
return rows.map(toNoteEmbedding);
Expand Down
14 changes: 7 additions & 7 deletions electron/embeddings/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import * as path from "path";
import { spawn, type ChildProcess, execSync } from "child_process";

import { findFreePort } from "./port";
import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types";
import type { NomicTask, EmbeddingsStatus } from "./types";
import { EMBED_MODEL_ID, EMBED_DIM } from "./types";
import type { EmbedTask, EmbeddingsStatus } from "./types";
import * as manifest from "./manifest";

const MODELS_DIR = manifest.MODELS_DIR;
Expand Down Expand Up @@ -68,7 +68,7 @@ function isScriptPath(p: string): boolean {
}

export function getDefaultModelId(): string {
return manifest.readDefaultModelId() ?? NOMIC_MODEL_ID;
return manifest.readDefaultModelId() ?? EMBED_MODEL_ID;
}

interface StdoutEvent {
Expand Down Expand Up @@ -140,7 +140,7 @@ export function setLastRecomputeProgress(done: number, total: number): void {

function emitProgress(ev: StdoutEvent): void {
if (ev.kind !== "progress" && ev.kind !== "ready") return;
const modelId = ev.model ?? workerModel ?? NOMIC_MODEL_ID;
const modelId = ev.model ?? workerModel ?? EMBED_MODEL_ID;
for (const l of progressListeners) {
if (ev.kind === "ready") {
l({ kind: "ready", modelId });
Expand Down Expand Up @@ -348,7 +348,7 @@ export function onProgress(listener: ProgressListener): () => void {

export async function embed(
texts: string[],
task: NomicTask,
task: EmbedTask,
model = getDefaultModelId(),
): Promise<number[][]> {
if (texts.length === 0) return [];
Expand Down Expand Up @@ -390,8 +390,8 @@ export async function embed(
dim: number;
model: string;
};
if (parsed.dim !== NOMIC_DIM) {
throw new Error(`embed: expected dim ${NOMIC_DIM}, got ${parsed.dim}`);
if (parsed.dim !== EMBED_DIM) {
throw new Error(`embed: expected dim ${EMBED_DIM}, got ${parsed.dim}`);
}
return parsed.vectors;
} finally {
Expand Down
103 changes: 95 additions & 8 deletions electron/embeddings/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { app } from "electron";
import * as fs from "fs";
import * as path from "path";

import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types";
import { EMBED_MODEL_ID, EMBED_DIM } from "./types";
import type { EmbeddingModelManifestEntry } from "./types";

export const MODELS_DIR = path.join(app.getPath("userData"), "embedding-models");
Expand All @@ -19,13 +19,13 @@ export interface SupportedEmbeddingModel {
}

export const SUPPORTED_EMBEDDING_MODELS: Record<string, SupportedEmbeddingModel> = {
[NOMIC_MODEL_ID]: {
id: NOMIC_MODEL_ID,
name: "Nomic Embed Text v1.5 (int8 quantised)",
repo: "nomic-ai/nomic-embed-text-v1.5",
dim: NOMIC_DIM,
maxTokens: 8192,
sizeBytes: 91_000_000,
[EMBED_MODEL_ID]: {
id: EMBED_MODEL_ID,
name: "BGE Small English v1.5 (int8 quantised)",
repo: "Xenova/bge-small-en-v1.5",
dim: EMBED_DIM,
maxTokens: 512,
sizeBytes: 33_000_000,
},
};

Expand Down Expand Up @@ -58,6 +58,7 @@ function modelFilesPresent(modelId: string): boolean {

export function getEmbeddingModelsManifest(): EmbeddingModelManifestEntry[] {
if (!fs.existsSync(MODELS_DIR)) fs.mkdirSync(MODELS_DIR, { recursive: true });
pruneOrphanedModels();
const manifest = readManifestFile();
let updated = false;
const result: EmbeddingModelManifestEntry[] = [];
Expand Down Expand Up @@ -156,3 +157,89 @@ export function writeDefaultModelId(modelId: string): void {
if (!fs.existsSync(MODELS_DIR)) fs.mkdirSync(MODELS_DIR, { recursive: true });
fs.writeFileSync(DEFAULT_MODEL_PATH, JSON.stringify({ defaultModelId: modelId }), "utf8");
}

/**
* Remove model directories and manifest entries for models no longer in
* SUPPORTED_EMBEDDING_MODELS. This handles the case where a model swap
* (e.g. Nomic → bge-small) leaves the old model's ~131 MB of files
* orphaned on disk. Called automatically by getEmbeddingModelsManifest().
*/
function pruneOrphanedModels(): void {
if (!fs.existsSync(MODELS_DIR)) return;
const manifest = readManifestFile();
let updated = false;

// Find org/name directories on disk that aren't in SUPPORTED_EMBEDDING_MODELS
for (const org of fs.readdirSync(MODELS_DIR)) {
if (org.startsWith(".") || org.endsWith(".json")) continue;
const orgDir = path.join(MODELS_DIR, org);
// Use lstatSync to avoid following symlinks — a symlinked org dir could
// point outside MODELS_DIR, and fs.rmSync(recursive) would follow it.
let orgStat: fs.Stats;
try {
orgStat = fs.lstatSync(orgDir);
} catch {
continue;
}
if (!orgStat.isDirectory() || orgStat.isSymbolicLink()) continue;
for (const name of fs.readdirSync(orgDir)) {
if (name.startsWith(".")) continue;
const modelId = `${org}/${name}`;
if (SUPPORTED_EMBEDDING_MODELS[modelId]) continue;
const modelDir = path.join(orgDir, name);
// Validate resolved path stays within MODELS_DIR to prevent accidental
// deletion of directories outside the cache root via path traversal.
const resolved = path.resolve(modelDir);
if (!resolved.startsWith(MODELS_DIR + path.sep) && resolved !== MODELS_DIR) {
console.warn(`[embeddings] skipping orphan prune for ${modelId}: resolved path outside MODELS_DIR`);
continue;
}
try {
const sizeMB = getDirSizeMB(modelDir);
fs.rmSync(modelDir, { recursive: true, force: true });
console.log(`[embeddings] pruned orphaned model: ${modelId} (${sizeMB} MB)`);
updated = true;
} catch (e) {
console.warn(`[embeddings] failed to prune orphaned model ${modelId}:`, e);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
// Remove empty org dir
try {
if (fs.existsSync(orgDir) && fs.readdirSync(orgDir).length === 0) {
fs.rmSync(orgDir, { recursive: true, force: true });
}
} catch { /* ignore */ }
}

// Clean stale manifest entries
for (const key of Object.keys(manifest)) {
if (!SUPPORTED_EMBEDDING_MODELS[key]) {
delete manifest[key];
updated = true;
}
}

if (updated) writeManifestFile(manifest);

// Reset default model ID if it's no longer supported (e.g. after a model swap)
const defaultId = readDefaultModelId();
if (defaultId && !SUPPORTED_EMBEDDING_MODELS[defaultId]) {
writeDefaultModelId(EMBED_MODEL_ID);
console.log(`[embeddings] reset default model from ${defaultId} to ${EMBED_MODEL_ID}`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function getDirSizeMB(dir: string): number {
let total = 0;
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
total += getDirSizeMB(full);
} else {
total += fs.statSync(full).size;
}
}
} catch { /* ignore */ }
return Math.round(total / 1024 / 1024);
}
11 changes: 0 additions & 11 deletions electron/embeddings/nomic.ts

This file was deleted.

20 changes: 10 additions & 10 deletions electron/embeddings/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { AutoTokenizer, env, type PreTrainedTokenizer } from "@xenova/transformers";
import { AutoTokenizer, env, type PreTrainedTokenizer } from "@huggingface/transformers";
import * as ort from "onnxruntime-node";
import type { NomicTask } from "./types";
import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types";
import { withNomicPrefix } from "./nomic";
import type { EmbedTask } from "./types";
import { EMBED_MODEL_ID, EMBED_DIM } from "./types";
import { withTaskPrefix } from "./prefix";

env.allowLocalModels = false;
env.useBrowserCache = false;
Expand Down Expand Up @@ -40,7 +40,7 @@ export function loadedModelId(): string | null {
}

export async function loadPipeline(
modelId: string = NOMIC_MODEL_ID,
modelId: string = EMBED_MODEL_ID,
onProgress?: ProgressCallback,
): Promise<LoadedPipeline> {
if (_pipeline && _pipeline.modelId === modelId) return _pipeline;
Expand Down Expand Up @@ -90,11 +90,11 @@ export async function loadPipeline(

export async function embed(
texts: string[],
task: NomicTask,
modelId: string = NOMIC_MODEL_ID,
task: EmbedTask,
modelId: string = EMBED_MODEL_ID,
): Promise<number[][]> {
const pipe = await loadPipeline(modelId);
const prefixed = texts.map((t) => withNomicPrefix(task, t));
const prefixed = texts.map((t) => withTaskPrefix(task, t));

const encoded = await pipe.tokenizer(prefixed, { padding: true, truncation: true });
const batchSize = encoded.input_ids.dims[0];
Expand Down Expand Up @@ -142,8 +142,8 @@ export async function embed(
}

for (const v of vectors) {
if (v.length !== NOMIC_DIM) {
throw new Error(`embed: expected dim ${NOMIC_DIM}, got ${v.length}`);
if (v.length !== EMBED_DIM) {
throw new Error(`embed: expected dim ${EMBED_DIM}, got ${v.length}`);
}
}
return vectors;
Expand Down
12 changes: 12 additions & 0 deletions electron/embeddings/prefix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { EmbedTask } from "./types";

export function withTaskPrefix(task: EmbedTask, text: string): string {
switch (task) {
case "search_query":
return `Represent this sentence for searching relevant passages: ${text}`;
case "search_document":
case "clustering":
default:
return text;
}
}
6 changes: 3 additions & 3 deletions electron/embeddings/query-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ describe("query vector cache (LRU)", () => {
});

it("derives keys deterministically from model + query text", () => {
const k1 = queryHash("nomic-ai/nomic-embed-text-v1.5", "hello world");
const k2 = queryHash("nomic-ai/nomic-embed-text-v1.5", "hello world");
const k3 = queryHash("nomic-ai/nomic-embed-text-v1.5", "hello world!");
const k1 = queryHash("Xenova/bge-small-en-v1.5", "hello world");
const k2 = queryHash("Xenova/bge-small-en-v1.5", "hello world");
const k3 = queryHash("Xenova/bge-small-en-v1.5", "hello world!");
const k4 = queryHash("other-model", "hello world");
expect(k1).toBe(k2);
expect(k1).not.toBe(k3);
Expand Down
6 changes: 3 additions & 3 deletions electron/embeddings/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as path from "path";
import * as os from "os";

import { embed, loadPipeline, setCacheDir, isLoaded, loadedModelId, type EmbedProgress } from "./pipeline";
import { EmbedRequest, NOMIC_MODEL_ID, NOMIC_DIM } from "./types";
import { EmbedRequest, EMBED_MODEL_ID, EMBED_DIM } from "./types";

interface StdoutEvent {
kind: "listening" | "ready" | "progress" | "error" | "log";
Expand All @@ -24,7 +24,7 @@ function emit(ev: StdoutEvent): void {
function parseArgs(argv: string[]): { port?: number; cacheDir: string; model: string } {
let port: number | undefined;
let cacheDir = path.join(os.homedir(), ".cache", "huggingface");
let model = NOMIC_MODEL_ID;
let model = EMBED_MODEL_ID;
for (const arg of argv.slice(2)) {
if (arg.startsWith("--port=")) {
const n = Number(arg.slice("--port=".length));
Expand Down Expand Up @@ -121,7 +121,7 @@ async function handleEmbed(
const req = reqParsed.data;
await loadOnce(req.model ?? configuredModel);
const vectors = await embed(req.texts, req.task, req.model ?? configuredModel);
sendJson(res, 200, { vectors, dim: NOMIC_DIM, model: req.model ?? configuredModel });
sendJson(res, 200, { vectors, dim: EMBED_DIM, model: req.model ?? configuredModel });
}

function buildServer(configuredModel: string): http.Server {
Expand Down
Loading