This commit is contained in:
Laurent Cozic committed 2026-06-15 16:19:30 +01:00
1 parent 19ad180602
commit e4b8d21700
7 files changed
+196 -347

No files matched your search

+1
View File
@@ -16,6 +16,7 @@
- To compile TypeScript, use `yarn tsc`. To type-check without emitting files, use `yarn tsc --noEmit`.
- Default to no comments. Only add one when the why is non-obvious (workaround, hidden constraint, subtle invariant). Never explain what the code does — names handle that. Keep necessary comments to one or two line where possible.
- SQL queries should only be done from within models (in packages/lib/models).
- In markdown files, do not hard-wrap paragraphs. Let the renderer wrap lines; only insert newlines for actual paragraph or list breaks.
## Styling (desktop app)
+9 -4
View File
@@ -81,15 +81,20 @@ export default class NoteEmbedding extends BaseModel {
return row?.c ?? 0;
}
// Indexable notes not yet embedded — drives the indexer's backfill path.
public static async notYetIndexedNoteIds(limit: number): Promise<string[]> {
// Indexable notes not yet embedded — drives the indexer's initial scan.
// `excludeIds` lets the caller skip notes that already failed this session,
// so a permanently bad note doesn't keep coming back.
public static async notYetIndexedNoteIds(limit: number, excludeIds: string[] = []): Promise<string[]> {
const excludeSql = excludeIds.length
? ` AND n.id NOT IN (${excludeIds.map(() => '?').join(',')})`
: '';
const rows = await this.db().selectAll<{ id: string }>(
`SELECT n.id FROM notes n
WHERE (n.deleted_time IS NULL OR n.deleted_time = 0)
AND (n.is_conflict IS NULL OR n.is_conflict = 0)
AND NOT EXISTS (SELECT 1 FROM note_embeddings_meta m WHERE m.note_id = n.id)
AND NOT EXISTS (SELECT 1 FROM note_embeddings_meta m WHERE m.note_id = n.id)${excludeSql}
LIMIT ?`,
[limit],
[...excludeIds, limit],
);
return rows.map(r => r.id);
}
@@ -807,6 +807,17 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.Database,
},
// Indexer lifecycle: false until the initial full-vault scan finishes;
// after that the indexer only follows the ItemChange feed. Reset to
// false when the model id changes (triggering a re-scan).
'ai.embedding.initialScanDone': {
value: false,
type: SettingItemType.Bool,
public: false,
appTypes: [AppType.Desktop],
storage: SettingStorage.Database,
},
theme: {
value: Setting.THEME_LIGHT,
type: SettingItemType.Int,
@@ -298,6 +298,38 @@ describe('EmbeddingIndexer', () => {
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(2);
});
it('skips a note that fails during the initial scan instead of looping on it', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const good = await Note.save({ title: 'good', body: 'this one works', parent_id: folder.id });
const bad = await Note.save({ title: 'bad', body: 'this one will fail', parent_id: folder.id });
await waitForChangesSince(0, 2);
// Provider that throws for the bad note's body, succeeds for everything else.
let failureCalls = 0;
const failingProvider = new (class extends TestEmbeddingProvider {
public async embed(texts: string[]) {
if (texts.some(t => t.includes('this one will fail'))) {
failureCalls++;
throw new Error('synthetic failure');
}
return super.embed(texts);
}
})();
AiService.instance().setEmbeddingProvider(failingProvider);
// First tick processes both — bad fails, good succeeds.
await EmbeddingIndexer.instance().maintenance();
// Second tick must NOT retry the bad note (would re-incur failureCalls).
await EmbeddingIndexer.instance().maintenance();
await EmbeddingIndexer.instance().maintenance();
expect(failureCalls).toBe(1);
expect(await NoteEmbedding.countByNoteId(good.id)).toBeGreaterThan(0);
expect(await NoteEmbedding.countByNoteId(bad.id)).toBe(0);
expect(Setting.value('ai.embedding.initialScanDone')).toBe(true);
});
it('getStatus counts indexed vs total notes and excludes trashed ones', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
+40 -17
View File
@@ -39,6 +39,10 @@ export default class EmbeddingIndexer {
private maintenanceTimer_: ReturnType<typeof shim.setInterval> = null;
private isRunningInBackground_ = false;
private maintenanceRunning_ = false;
// Notes that threw during this session's initial scan. Skipped for the
// remainder of the session so the scan can complete; reset on process
// restart so a fix to the underlying issue gets a fresh chance.
private initialScanFailures_ = new Set<string>();
public async runInBackground() {
if (this.isRunningInBackground_) return;
@@ -105,11 +109,15 @@ export default class EmbeddingIndexer {
if (!provider) return;
await this.handleModelChange(provider);
// Change feed first so recent edits aren't starved by a large
// backfill of pre-existing notes.
const processed = await this.processChangeBatch(provider);
if (processed < BATCH_SIZE) {
await this.processBackfillBatch(provider, BATCH_SIZE - processed);
// Until the initial scan completes, walk the whole vault one batch
// per tick. Then switch to change-feed-only mode. The tick that
// finishes the scan still runs the change feed so edits made
// during the scan don't wait an extra interval.
if (!Setting.value('ai.embedding.initialScanDone')) {
await this.runInitialScanBatch(provider);
}
if (Setting.value('ai.embedding.initialScanDone')) {
await this.processChangeBatch(provider);
}
} catch (error) {
logger.error('Maintenance run failed:', error);
@@ -128,12 +136,14 @@ export default class EmbeddingIndexer {
await NoteEmbedding.clearAll();
Setting.setValue('ai.embedding.lastProcessedChangeId', 0);
Setting.setValue('ai.embedding.lastIndexedModelId', provider.modelId);
Setting.setValue('ai.embedding.initialScanDone', false);
this.initialScanFailures_.clear();
}
private async processChangeBatch(provider: EmbeddingProvider): Promise<number> {
private async processChangeBatch(provider: EmbeddingProvider): Promise<void> {
const cursor = Setting.value('ai.embedding.lastProcessedChangeId') as number;
const changes = await ItemChange.changesSinceId(cursor, { limit: BATCH_SIZE });
if (!changes.length) return 0;
if (!changes.length) return;
// Collapse duplicates so a note edited multiple times only gets
// embedded once per tick.
@@ -160,24 +170,37 @@ export default class EmbeddingIndexer {
// idempotent — it deletes existing chunks first).
const highestId = changes[changes.length - 1].id;
Setting.setValue('ai.embedding.lastProcessedChangeId', highestId);
return latestPerNote.size;
}
// Picks up notes that aren't yet in the index — existing notes on first
// AI enable, or notes that pre-date the feature.
private async processBackfillBatch(provider: EmbeddingProvider, limit: number): Promise<void> {
if (limit <= 0) return;
// Walks the full notes table on first enable (or after a model swap).
// One batch per tick so the indexer stays responsive on big vaults.
// Per-note failures are remembered in-memory only — they get a fresh
// attempt on the next session, which is when a fix to the underlying
// issue would have been deployed.
private async runInitialScanBatch(provider: EmbeddingProvider): Promise<void> {
// Snap the change-feed cursor at the start of the scan, not the end —
// edits and deletes that happen during the scan are then picked up
// normally by the change feed instead of being lost.
if ((Setting.value('ai.embedding.lastProcessedChangeId') as number) === 0) {
Setting.setValue('ai.embedding.lastProcessedChangeId', await ItemChange.lastChangeId());
}
const noteIds = await NoteEmbedding.notYetIndexedNoteIds(limit);
if (!noteIds.length) return;
const excluded = Array.from(this.initialScanFailures_);
const noteIds = await NoteEmbedding.notYetIndexedNoteIds(BATCH_SIZE, excluded);
logger.info(`Backfill: indexing ${noteIds.length} note(s) not yet in the embeddings index`);
if (!noteIds.length) {
Setting.setValue('ai.embedding.initialScanDone', true);
logger.info(`Initial scan complete (${this.initialScanFailures_.size} note(s) skipped after errors)`);
return;
}
logger.info(`Initial scan: indexing ${noteIds.length} note(s)`);
for (const noteId of noteIds) {
try {
await this.indexNote(noteId, provider);
} catch (error) {
logger.warn(`Backfill failed for note ${noteId}:`, error);
this.initialScanFailures_.add(noteId);
logger.warn(`Initial scan failed for note ${noteId} (will retry next session):`, error);
}
}
}
+52 -277
View File
@@ -1,307 +1,82 @@
# AI embeddings (implementation)
How Joplin builds and queries the local note-embeddings index. See
[ai_primitives.md](ai_primitives.md) for the user-facing spec and the
plugin-API design.
How Joplin builds and queries the local note-embeddings index. See [ai_primitives.md](ai_primitives.md) for the user-facing spec.
## Components
## Overview
```
+-----------------------------+
| Settings (UI) |
| ai.enabled, |
| ai.embedding.enabled, |
| model + indexer cursors |
+-----------+-----------------+
|
BaseApplication.applyEmbeddingIndexerState()
|
v
+-----------------------+ +-------------------------+ +-----------------+
| LocalEmbeddingProvider | | EmbeddingIndexer | | AiIndexStatus |
| (or TestEmbedding- | | (background, 5-min tick) | | (settings panel) |
| Provider in CI) | | | | |
| | | | | |
| embed() / embedQuery() | | - drain item_changes | | polls |
| + model download |<+ - backfill not-yet- | | getStatus() |
+-----------+-------------+ | indexed notes | +--------+---------+
| | - call provider.embed | ^
| | - persist via Note- | |
| | Embedding.saveChunks +----------+
v +-----------+--------------+
+-----------------------+ |
| ONNX runtime | v
| + transformers.js | +--------------------------+
| (via shim.onnxRuntime) | | NoteEmbedding (model) |
+-----------------------+ | - note_embeddings_meta |
| (regular table) |
| - note_embeddings_vec |
| (sqlite-vec virtual, |
| created lazily) |
+-----------+--------------+
^
|
+-----------+--------------+
| SearchService |
| joplin.ai.search() |
| - resolve scope to ids |
| - embedQuery / reuse |
| stored vectors |
| - similaritySearch + |
| relevance preset |
+--------------------------+
Notes ──► EmbeddingIndexer ──► embedding provider ──► NoteEmbedding
(background, 5 min) (local ONNX) (sqlite-vec)
▲
│
SearchService ◄── joplin.ai.search() │
│
AiIndexStatus ──► reads counts ───────┘
(settings panel)
```
## Storage
- **EmbeddingIndexer** watches note changes, chunks each note, asks the active provider to embed the chunks, and persists them.
- **NoteEmbedding** is the storage model. Metadata sits in a regular table; vectors sit in a sqlite-vec virtual table. Joined by rowid.
- **LocalEmbeddingProvider** runs `multilingual-e5-small` via ONNX. The model (~140 MB) is downloaded from a GitHub release on first AI enable.
- **SearchService** backs `joplin.ai.search()`. Takes a text or note-id query and a scope, returns ranked chunks.
- **AiIndexStatus** is the settings panel that shows model + indexer state.
Two tables:
## Indexer behaviour
- **`note_embeddings_meta`** — regular SQLite table created by migration 52.
One row per chunk: `id`, `note_id`, `chunk_index`, `model_id`, `chunk_text`,
`created_time`. The `id` is also used as the rowid in the companion vec
table.
- **`note_embeddings_vec`** — a sqlite-vec `vec0` virtual table created
lazily on first save (`NoteEmbedding.ensureVecTable(dimension)`). Dimension
is fixed at table creation; changing it requires dropping the table.
Stored vectors are L2-normalised so cosine similarity ≈ `1 − L2²/2`.
The indexer runs on a 5-minute timer (matching `OcrService`) and has two modes:
Joined by rowid. Vectors and metadata are deleted together (`deleteByNoteId`,
`clearAll`). Embeddings are local only — they are **never synced**.
- **Initial scan.** On first enable (or after a model swap), walk the entire notes table one 100-note batch per tick. At the start of the scan the change-feed cursor is snapped to the current `lastChangeId` so edits made *during* the scan are picked up normally by the change feed when the scan finishes. The scan is complete when no indexable notes remain that aren't already in `note_embeddings_meta`; at that point `ai.embedding.initialScanDone` flips to true.
- **Change feed.** Once the scan completes, only the change-feed loop runs. Each tick drains `item_changes` past the durable cursor, collapses duplicates, processes deletes/creates/updates, then advances the cursor.
## Indexer
**Order of processing during the scan**: unspecified. The "not yet indexed" query has no `ORDER BY`, so SQLite returns rows in storage order (roughly insertion order). Determinism doesn't matter for the final state — every indexable note ends up embedded.
`EmbeddingIndexer` runs in the background:
**Resume across restarts**: free, because progress is the disk state, not a counter. After a restart the indexer re-enters scan mode (flag still false) and the `NOT EXISTS` query naturally skips notes that already have rows. Already-indexed notes from the previous session don't get re-processed.
- **Cadence**: 5-minute interval matching `OcrService`. The first tick fires
fire-and-forget at startup so model load doesn't block the splash screen.
- **Two-phase tick**:
1. **Change drain.** Read `BATCH_SIZE` rows from `item_changes` past
`ai.embedding.lastProcessedChangeId`. Collapse duplicates (multiple
edits to the same note within a batch → one embed). Process deletes
by removing the note's chunks; process create/update by re-indexing.
Advance the cursor to the highest id seen.
2. **Backfill top-up.** If the change drain processed fewer than
`BATCH_SIZE` notes, fill the remaining slot with
`NoteEmbedding.notYetIndexedNoteIds(remaining)` — picks up existing
notes from before AI was enabled, in arbitrary order.
- **Per-note processing** (`indexNote`):
1. Skip if missing / trashed / conflict / empty body+title.
2. Chunk the body via `chunkText` (see Chunking below).
3. Inject the title **doubled** into chunk 0. The title is often the
densest semantic signal a note carries; doubling boosts its weight in
the chunk's embedding so title-anchored queries match.
4. Call `provider.embed(chunks)`.
5. Persist via `NoteEmbedding.saveChunks` — which deletes the note's
existing chunks first, so the operation is idempotent under crash and
restart.
- **Model change**: on every tick, compare the active provider's `modelId`
to `ai.embedding.lastIndexedModelId`. On mismatch, wipe the index and reset
the cursor.
**Failure handling during the scan**: per-note failures are logged and the note is added to an in-memory skip set for the rest of the session — no retry loop, no log flood. The skip set is **not** persisted, so the next process restart retries the previously-failed notes.
### Durable state
Once the scan completes, the indexer never sweeps the notes table again. A note that failed during the scan only gets re-tried if (a) the user edits it (the change feed picks it up), or (b) the model id changes (full re-scan). It will not be retried just because time passed.
| Setting key | Purpose |
|----------------------------------------|-----------------------------------------------|
| `ai.embedding.enabled` | User kill switch (defaults to true with AI on)|
| `ai.embedding.lastProcessedChangeId` | Cursor into `item_changes` |
| `ai.embedding.lastIndexedModelId` | Detects model swap → triggers re-index |
Per note, the title is injected (doubled) into chunk 0. Without this, notes whose body is just an attachment link would never match title-anchored queries.
### What's deliberately not durable
- Maintenance running flag (`maintenanceRunning_`) and timer handle live in
memory only. A crash mid-tick leaves the cursor at its pre-tick value, so
the next run reprocesses the partial batch. `saveChunks` is idempotent so
this is safe.
The first tick fires fire-and-forget at startup so the model load doesn't block the splash screen. A model id change (e.g. switching providers) wipes the index, clears the scan flag, and rebuilds.
## Chunking
`chunker.ts` splits a note body into fixed-size character windows with 10%
overlap. The window size targets the model's 512-token context:
`chunkSize = TARGET_TOKENS_PER_CHUNK × CHARS_PER_TOKEN`. Two profiles:
| Profile | chars/token | When |
|----------|-------------|----------------------------------------|
| default | 3.5 | Latin scripts (conservative for FR/DE) |
| CJK | 1.2 | Note is ≥ 30% CJK characters |
CJK detection uses a Unicode block regex and `scriptType()` to avoid
switching profiles for a single loanword.
## Provider abstraction
`EmbeddingProvider` (in `services/ai/types.ts`) is the interface every
provider implements:
```ts
interface EmbeddingProvider {
id: string;
modelId: string;
dimension: number;
classification: 'local' | 'remote';
embed(texts: string[]): Promise<number[][]>;
embedQuery?(texts: string[]): Promise<number[][]>;
modelDownloadStatus?(): Promise<ProviderModelDownloadStatus>;
}
```
- `embedQuery` is optional. e5-family models use asymmetric `passage: ` /
`query: ` prefixes — `SearchService` falls back to `embed` when a provider
doesn't expose it.
- `modelDownloadStatus` is optional. Providers without a downloadable
artefact (remote chat providers, the test stub) are treated as
always-ready.
### LocalEmbeddingProvider
Bundled provider for the desktop app. Runs `multilingual-e5-small` quantised
to int8 (`model_quantized.onnx`, ~140 MB) via `onnxruntime-node`, with
tokenisation delegated to `@xenova/transformers`' `AutoTokenizer`.
- **Threading**: `intraOpNumThreads: 2`. Caps CPU use so the background
indexer doesn't peg every core.
- **Single-flight init**: `ensureInitialised()` caches the load promise.
On rejection the cache is dropped so the next call retries cleanly.
- **token_type_ids**: e5's ONNX export demands this input even though
XLM-RoBERTa is single-segment. transformers.js doesn't emit it for XLM-R,
so we synthesise a zero tensor matching the input shape when the session
requires it.
- **Output**: mean-pooled over the attention mask, L2-normalised, returned
as `number[][]` of `dimension` 384.
#### ESM-from-CJS shim
`@xenova/transformers` is ESM-only. We can't `require()` it directly, and
several intermediate approaches fail in Joplin's environment. The current
working solution is `dynamicEsmImport.js`, which uses Node's
`require(esm)` (stable since Node 22.12). See the file's comment for the
list of approaches that didn't work and why.
`engines.node >= 22.12` is enforced at install time so this never fails on a
machine that successfully built the project.
### TestEmbeddingProvider
Deterministic bigram-based stub. Same text → same vector; similar texts
produce closer vectors. Not a real model — it can't capture multi-word
semantics or synonyms. Used by every embedding test that needs the indexer
to do something without downloading the real model.
## Model download
`EmbeddingModelDownloader` downloads the model tarball from a GitHub Release
to `${cacheDir}/ai/embedding-models/<archiveName>/`:
- **Single-flight**: an `inFlight` Map ensures concurrent callers share one
download instead of racing on the same target directory.
- **Marker file**: `config.json` inside the model dir is the "is the model
cached?" signal. A successful extract always leaves it behind.
- **Resilience**: stale tarballs and partial extracts are wiped before each
download attempt. After extraction the marker is verified; missing →
error.
- **Timeout**: `shim.fetchBlob` is called with a 60-second per-socket-idle
timeout. Without it a stalled connection would hang every concurrent
caller indefinitely.
Fixed-size character windows sized to fit the model's 512-token context, with 10% overlap. Two profiles: a Latin-script default and a CJK profile (used when CJK is the dominant script — CJK tokenises ~3× denser).
## Search
`SearchService.search(options)` is the entry point. It backs
`joplin.ai.search()` and any future core search integrations.
```ts
interface SearchOptions {
query: { text: string } | { noteId: string };
scope?: { type: 'all' | 'note' | 'folder' | 'tag', ... };
relevance?: 'strict' | 'normal' | 'loose'; // default 'normal'
}
```
Flow:
1. **Provider check.** Throws if no embedding provider is active.
2. **Resolve query → vectors.**
- `{ text }`: `provider.embedQuery([text])` (or `embed` if not asymmetric).
- `{ noteId }`: `NoteEmbedding.vectorsByNoteId(noteId)` — reuses stored
vectors, no re-embedding pass.
3. **Resolve scope → noteIds.** `'all'` → null (no restriction); `'note'` →
`[noteId]`; `'folder'` → `Note.previews(folderId)`; `'tag'` →
`Tag.noteIds(tagId)`. Empty list short-circuits.
4. **Per query vector, similaritySearch** with `k = relevance.k`.
5. **Score conversion.** L2 distance → cosine similarity in `[0, 1]` via
`1 − d²/2` (exact for unit vectors). Filter by `relevance.minScore`.
6. **Merge + sort.** Dedupe by `(noteId, chunkIndex)` keeping highest score,
sort descending, trim to `k`.
### Relevance presets
Currently tuned for `multilingual-e5-small`:
| Preset | k | minScore |
|---------|---:|---------:|
| strict | 5 | 0.55 |
| normal | 10 | 0.40 |
| loose | 20 | 0.25 |
These are internal — plugins target the preset name, we own the mapping.
When the bundled model changes we re-tune. `RELEVANCE_DEFAULTS` becomes a
per-modelId map at that point.
## Settings UI
`AiIndexStatus.tsx` is the read-only status panel rendered under the AI
section header. Polls `EmbeddingIndexer.getStatus()` on a chained
`setTimeout` (not `setInterval` — that would let slow polls stack up
during model load).
Reports:
- **Model:** `Not started` / `Downloading…` / `Downloaded` /
`Unavailable on this platform`.
- **Indexer:** `Idle` / `Indexing…` / `AI is disabled` /
`Indexing is disabled`.
- **Indexed notes:** `N / total`, excluding trashed and conflict notes.
- `query: { text }` runs the active provider's query-side encoding.
- `query: { noteId }` reuses the note's stored vectors as the query — no re-embedding pass.
- `scope`: `all` / `note` / `folder` / `tag`. Trashed and conflict notes are always excluded.
- `relevance`: `strict` / `normal` / `loose`. Maps internally to model-specific `(k, minScore)` so plugins survive model changes.
- Score is cosine similarity in `[0, 1]`, computed from the L2 distance sqlite-vec returns (vectors are L2-normalised, so `cos = 1 − d²/2`).
## Platform support
| Platform | Embeddings | Notes |
|-------------------------|:----------:|---------------------------------------|
| macOS Apple Silicon | ✓ | |
| macOS Intel | ✗ | `onnxruntime-node` ships no darwin-x64|
| Linux x64 / arm64 | ✓ | |
| Windows x64 / arm64 | ✓ | |
| CLI / mobile / server | ✗ | No ONNX wired in; degrades cleanly |
| Platform | Embeddings | Notes |
|-------------------------|:----------:|----------------------------------------|
| macOS Apple Silicon | ✓ | |
| macOS Intel | ✗ | `onnxruntime-node` ships no darwin-x64 |
| Linux x64 / arm64 | ✓ | |
| Windows x64 / arm64 | ✓ | |
| CLI / mobile / server | ✗ | No ONNX wired in; chat still works |
Unsupported platforms: `shim.onnxRuntime()` returns null, no provider gets
installed, the indexer stays paused, search throws a clear error. Chat
(remote providers) still works.
Unsupported platforms degrade cleanly: the provider isn't installed, the indexer stays paused, search throws a clear error.
## Performance notes
## Performance, in rough numbers
- **Model load** (one-time per process): ~2–5 s on Apple Silicon, 5–15 s on
x86 CPU. Happens silently in the background on app startup.
- **Per-chunk inference**: ~30–80 ms on Apple Silicon, 80–200 ms on x86.
- **Indexer throughput**: serial today — one note at a time, one chunk at a
time within each note (the embed call takes a chunks array but
`EmbeddingIndexer.indexNote` calls it per note). Batching across notes
would be ~5–10× faster for large backlogs; not done for v1.
- **Search latency**: vec0 MATCH is sub-millisecond on tens of thousands of
chunks. Dominated by the query embedding pass.
- **First-time backfill** on a large vault: 100 notes per 5-minute tick.
10k notes ≈ 8 hours of background indexing. Acceptable for now; tighten
pacing or batch sizes if it becomes a problem.
- Model load: 2–15 s (one-time per process, runs in the background).
- Per-chunk inference: 30–200 ms depending on CPU.
- Search latency: dominated by the query-embedding pass; the vec MATCH itself is sub-millisecond on tens of thousands of chunks.
- Big vaults take a while: 100 notes per tick × 5-min interval, so a fresh 10k-note vault is ~8 hours of background indexing. Intentional pacing.
## Testing
## Durable state
- **Unit tests** for `chunker`, `NoteEmbedding`, `EmbeddingIndexer`,
`SearchService`, `EmbeddingModelDownloader`, `LocalEmbeddingProvider`.
All use the `TestEmbeddingProvider` and stubbed shims — no network, no
real model.
- **Gated real-model test**: `LocalEmbeddingProvider.test.ts` includes one
test gated behind `JOPLIN_RUN_REAL_EMBEDDING_TEST=1`. It downloads the
real model and verifies it loads end-to-end. Stops short of inference
because Jest's VM sandbox rejects the model's output `Float32Array` as
cross-realm — verify inference by running the desktop app.
Run with:
```
cd packages/lib && JOPLIN_RUN_REAL_EMBEDDING_TEST=1 yarn test LocalEmbeddingProvider
```
| Setting | Purpose |
|----------------------------------------|----------------------------------|
| `ai.enabled` | Top-level AI toggle |
| `ai.embedding.enabled` | Indexer kill switch (default on) |
| `ai.embedding.lastProcessedChangeId` | Cursor into `item_changes` |
| `ai.embedding.lastIndexedModelId` | Detects model swap → re-index |
| `ai.embedding.initialScanDone` | True once the full-vault scan finished |
+51 -49
View File
@@ -1,6 +1,6 @@
# AI primitives
This spec describes the core AI primitives that will be added to Joplin. The goal is not to ship a single AI feature, but to provide a platform on which features and plugins can be built. The primitives below are validated against five target use cases:
This spec describes the core AI primitives in Joplin. The goal is not to ship a single AI feature, but to provide a platform on which features and plugins can be built. The primitives below are validated against five target use cases:
- **Chat with your note** — a sidebar that can summarise, rewrite, or answer questions about the current note.
- **Chat with your note collection** — ask a question across all notes and get a cited answer.
@@ -22,6 +22,20 @@ The primitives are:
Primitives 1–3 are required for any of the five target use cases to work. Primitive 4 must be in place from day one. Primitive 5 is independently valuable and can ship in parallel.
## Implementation status
| Primitive | Status |
|-------------------------------------|------------------------------------------------------------------------|
| Provider abstraction (chat) | Shipped (Joplin Cloud, OpenAI-compatible, Anthropic) |
| Provider abstraction (embeddings) | Shipped (local ONNX-backed) |
| Local embeddings index | Shipped — multilingual-e5-small, downloaded on first enable |
| Retrieval helpers (`search`) | Shipped as `joplin.ai.search()` |
| Chat helper (`chat`) | Shipped as `joplin.ai.chat()` |
| Privacy & cost guardrails | Shipped (off by default, remote-allow flag, classification, token tally)|
| MCP server | Not started |
Implementation detail for the embeddings stack lives in [ai_embeddings.md](ai_embeddings.md).
## 1. Provider abstraction
A pluggable layer so users can pick their LLM and embedding model independently (cloud, self-hosted, or on-device). No provider is hardcoded.
@@ -39,36 +53,35 @@ Users configure a **list** of providers (each with its own settings — API key,
### Built-in providers
- An **OpenAI-compatible** adapter (covers OpenAI, Ollama, LM Studio, vLLM, OpenRouter, and similar via base-URL override).
- An **Anthropic** adapter.
- A **bundled local embedding model** (see below)
Chat:
- **Joplin Cloud AI** — zero-config for users on Joplin Cloud sync.
- **OpenAI-compatible** adapter (covers OpenAI, Ollama, LM Studio, vLLM, OpenRouter, and similar via base-URL override).
- **Anthropic** adapter.
Embeddings:
- **Bundled local embedding provider** (see below).
### Chat API
Plugins call `joplin.ai.chat(messages, options?)`. The active provider and model are taken from user settings — plugins cannot pick a model. Throws if AI is disabled, if the active provider is remote and the user hasn't allowed remote providers, or if the provider is misconfigured.
## 2. Local embeddings index
Notes are chunked, embedded, and stored locally so retrieval can run without a network call.
Notes are chunked, embedded, and stored locally so retrieval can run without a network call. Embeddings are **not synced**: they are large, model-specific, and re-derivable. The model identifier is stored alongside each chunk so a model change triggers a clear-and-rebuild rather than silent corruption.
### Storage
Indexing runs as a background service: on first enable it walks the entire vault, after which it follows the note-change feed incrementally. New and edited notes become searchable within minutes.
- A new local SQLite table holding `(note_id, chunk_index, model_id, vector)` and the source text of each chunk.
- Implemented using the [sqlite-vec](https://github.com/asg017/sqlite-vec) extension for vector storage and similarity search.
- **Not synced.** Embeddings are large, model-specific, and re-derivable.
- Schema includes the model identifier so a model change triggers a re-index rather than silent corruption.
### Indexing
- Background task following the existing OCR Service pattern: timer-based, polls `ItemChange`, processes in chunks, persists progress in a settings key.
- Chunking strategy: roughly 512–1024 tokens with overlap. Tunable internally.
### Embedding model
- Joplin ships a small embedding model (~100MB, e.g. from the nomic/mxbai/bge family) bundled with the desktop app, or downloaded after installation.
- Runtime: **ONNX Runtime** (`onnxruntime-node`), loaded in-process. No external service, daemon, or Python required.
- The bundled model is the default. Users may switch to a cloud embedding provider via the provider abstraction; doing so triggers a re-index.
The bundled model is the default. Users may switch to a different embedding provider via the provider abstraction; doing so triggers a re-index because vectors from different models aren't comparable.
### Platform scope
- **Desktop and CLI**: full support.
- **Mobile**: deferred. sqlite-vec packaging for iOS/Android and on-device embedding cost on mobile are separate efforts. Mobile may eventually query an existing index produced on desktop, but that is out of scope for the initial work.
- **Desktop on Apple Silicon macOS, Linux x64/arm64, Windows x64/arm64**: full support.
- **macOS Intel**: chat works; embeddings do not (no ONNX prebuild for `darwin-x64`).
- **CLI / mobile / server**: no on-device embeddings. Chat with remote providers still works.
Implementation detail lives in [ai_embeddings.md](ai_embeddings.md).
## 3. Retrieval helpers
@@ -76,36 +89,23 @@ The shared query surface. All five target features differ mainly in *what* they
### API
A single primary call:
Plugins call `joplin.ai.search({ query, scope?, relevance? })`. Returns matching chunks with the source note id, chunk text, and a similarity score.
- **`search({ query, scope, relevance })`** — returns matching chunks with their source note ID, the chunk text, and a similarity score.
- `query`: either plain text (embedded internally using the active embedding provider) or `{ noteId }` to find chunks similar to an existing note. When a note ID is given, Joplin reuses the note's already-indexed chunks as the query vector(s), so no re-embedding is needed. This is what the tag-suggestion and semantic-graph use cases rely on.
- `scope`: where to search. One of `'note'` (with a note ID), `'notebook'` (with a folder ID), `'tag'` (with a tag ID), or `'all'`. Trashed and conflict notes are excluded by default.
- `relevance`: `'strict' | 'normal' | 'loose'`. A preset that maps internally to model-appropriate values for the number of results returned (`k`) and the minimum similarity threshold.
- `query`: either plain text or `{ noteId }` to find chunks similar to an existing note. Note-id queries reuse the note's already-indexed chunks as the query — no re-embedding needed. This is what tag-suggestion and semantic-graph use cases rely on.
- `scope`: `all` (default), `note`, `folder`, or `tag`. Trashed and conflict notes are always excluded.
- `relevance`: `strict` / `normal` / `loose`. A preset that maps internally to model-appropriate values for the number of results returned and the minimum similarity threshold.
Raw thresholds (`k`, `minScore`) are a leaky abstraction: the right values depend on the embedding model, and silently break when the model changes. Plugins calibrated against one model would produce poor results against another with no signal that anything had changed.
The `relevance` preset is the contract plugins write against. Joplin owns the mapping from preset to numeric values per model. When the bundled model changes, the mapping is re-tuned and plugins continue working without modification.
### Default mappings
Reference defaults (subject to per-model calibration):
| `relevance` preset | k | minScore (cosine) |
|----------|----|-------------------|
| strict | 5 | ~0.55 |
| normal | 10 | ~0.40 |
| loose | 20 | ~0.25 |
These are internal values and are not part of the public API contract.
### Hybrid search
Retrieval may be combined internally with the existing FTS-based keyword search. This is an implementation detail; plugins still call `search()` with the same shape.
Retrieval may eventually be combined internally with the existing FTS-based keyword search. Plugins will not see a contract change when hybrid ranking lands.
### Prior art
The [Jarvis](https://github.com/alondmnt/joplin-plugin-jarvis) plugin already exposes a [semantic search API](https://github.com/alondmnt/joplin-plugin-jarvis/blob/master/docs/API.md) to other plugins, supporting both free-text and note-ID queries. It is a useful reference for anyone wanting to prototype against the shape proposed here.
The [Jarvis](https://github.com/alondmnt/joplin-plugin-jarvis) plugin already exposes a [semantic search API](https://github.com/alondmnt/joplin-plugin-jarvis/blob/master/docs/API.md) to other plugins, supporting both free-text and note-ID queries. It is a useful reference.
### Mapping to features
@@ -113,7 +113,7 @@ How each target use case composes the primitives:
| Feature | Retrieval scope | Then |
|--------------------------|-----------------------------|------------------------------------------|
| Chat with note | `note` or `notebook` | Pass chunks as context to chat model |
| Chat with note | `note` or `folder` | Pass chunks as context to chat model |
| Chat with note collection | `all` | Pass top chunks (with note IDs) as context to chat model |
| Fuzzy search | `all` | Show chunks directly as results |
| Tag suggestions | `all`, query = note content | Inspect tags of returned chunks |
@@ -123,18 +123,20 @@ Chat-based features additionally pass each chunk's source note ID into the promp
## 4. Privacy & cost guardrails
Enforced at the provider layer so every feature (core or plugin) inherits these checks automatically.
Enforced at the provider layer so every feature — core or plugin — inherits these checks automatically.
### Requirements
- **AI features off by default.** A single top-level setting plus per-feature toggles.
- **Offline by default**: User must explicitly grant permission to use online features.
- **Per-provider classification** as `local` or `remote`. Surfaced in the provider picker and used by the indicator.
- **Token accounting** per provider, queryable by plugins and shown to users.
- **No silent enablement.** Switching from a local to a remote provider requires explicit user confirmation, with clear text about what data will be sent and where.
- **AI features off by default.** A top-level toggle, plus a per-feature kill switch for the embeddings indexer (users who want chat-only).
- **Offline by default**: remote providers require a separate explicit opt-in.
- **Per-provider classification** as `local` or `remote`. OpenAI-compatible providers can be either depending on the configured base URL (loopback addresses count as local).
- **Token accounting** per provider, queryable by plugins and shown in settings.
- **No silent enablement.** Switching from a local to a remote provider requires an explicit user choice; auto-defaults (e.g. selecting Joplin Cloud AI for Joplin Cloud users on first enable) only apply once.
## 5. MCP server
> Status: not started. Design recorded here for reference.
Joplin runs an optional [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes notes to external AI applications (Claude Desktop, ChatGPT desktop, Cursor, Zed, etc.).
### Scope