文件
mynode/packages/lib/services/ai/EmbeddingIndexer.test.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

462 行
18 KiB
TypeScript

import { setupDatabaseAndSynchronizer, switchClient, db, msleep, withWarningSilenced } from '../../testing/test-utils';
import Setting from '../../models/Setting';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import ItemChange from '../../models/ItemChange';
import NoteEmbedding from '../../models/NoteEmbedding';
import AiService from './AiService';
import EmbeddingIndexer, { coarseStateFrom } from './EmbeddingIndexer';
import { IndexStatus } from './types';
import TestEmbeddingProvider from './testing/TestEmbeddingProvider';
// Note.save() and Note.delete() record ItemChange rows fire-and-forget
// (`void ItemChange.add(...)`). In production that's fine because the indexer
// runs on a 5-minute timer. In tests we need to wait for the write to land.
const waitForChangesSince = async (changeId: number, expectedCount: number) => {
for (let i = 0; i < 50; i++) {
const changes = await ItemChange.changesSinceId(changeId);
if (changes.length >= expectedCount) return;
await msleep(20);
}
throw new Error(`Timed out waiting for ${expectedCount} ItemChange row(s) past ${changeId}`);
};
// End-to-end pipeline test: create notes → run the indexer → confirm
// similaritySearch returns the right note via the test embedding provider.
// Uses the test stub provider so no real model is needed.
describe('EmbeddingIndexer', () => {
let provider: TestEmbeddingProvider;
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
provider = new TestEmbeddingProvider();
AiService.instance().setEmbeddingProvider(provider);
});
afterEach(async () => {
AiService.instance().setEmbeddingProvider(null);
await EmbeddingIndexer.instance().stopRunInBackground();
});
const skipIfNoVec = () => {
if (!db(1).sqliteVecAvailable()) {
// eslint-disable-next-line no-console
console.warn('Skipping EmbeddingIndexer test: sqlite-vec unavailable');
return true;
}
return false;
};
it('does nothing when no provider is installed', async () => {
if (skipIfNoVec()) return;
AiService.instance().setEmbeddingProvider(null);
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 't', body: 'cats are nice', parent_id: folder.id });
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(0);
});
it('ai.embedding.enabled defaults to true', async () => {
// Indexing should be on by design when AI is enabled, with the toggle
// available as a kill switch for users who want chat-only.
expect(Setting.value('ai.embedding.enabled')).toBe(true);
});
it('indexes a new note end-to-end via the test provider', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const note = await Note.save({
title: 'About cats',
body: 'Cats and kittens are wonderful animals',
parent_id: folder.id,
});
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
const rows = await NoteEmbedding.byNoteId(note.id);
expect(rows.length).toBeGreaterThan(0);
expect(rows[0].model_id).toBe('test-model-v1');
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
});
it('returns the more relevant note first from similaritySearch', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const catNote = await Note.save({
title: 'cats',
body: 'Cats and kittens love to play and sleep all day',
parent_id: folder.id,
});
await Note.save({
title: 'cars',
body: 'Engines and pistons need regular maintenance and oil',
parent_id: folder.id,
});
await waitForChangesSince(0, 2);
await EmbeddingIndexer.instance().maintenance();
const [queryVec] = await provider.embed(['kittens']);
const results = await NoteEmbedding.similaritySearch(queryVec, { k: 5 });
expect(results.length).toBeGreaterThan(0);
// The first match must come from the cat note, not the car note.
expect(results[0].noteId).toBe(catNote.id);
});
it('removes embeddings for a deleted note on the next maintenance', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const note = await Note.save({ title: 't', body: 'some body text', parent_id: folder.id });
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.countByNoteId(note.id)).toBeGreaterThan(0);
const cursorAfterFirst = Setting.value('ai.embedding.lastProcessedChangeId') as number;
await Note.delete(note.id);
await waitForChangesSince(cursorAfterFirst, 1);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(0);
});
it('removes embeddings for a locked note on the next maintenance', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const note = await Note.save({ title: 't', body: 'some body text', parent_id: folder.id });
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.countByNoteId(note.id)).toBeGreaterThan(0);
const cursorAfterFirst = Setting.value('ai.embedding.lastProcessedChangeId') as number;
await Note.save({ id: note.id, is_locked: 1 });
await waitForChangesSince(cursorAfterFirst, 1);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(0);
});
it('skips trashed and conflict notes', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const trashedNote = await Note.save({
title: 'trashed',
body: 'this is in the trash',
parent_id: folder.id,
});
await Note.save({ ...trashedNote, deleted_time: Date.now() });
const conflictNote = await Note.save({
title: 'conflict',
body: 'this is a conflict',
parent_id: folder.id,
is_conflict: 1,
});
await waitForChangesSince(0, 2);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.countByNoteId(trashedNote.id)).toBe(0);
expect(await NoteEmbedding.countByNoteId(conflictNote.id)).toBe(0);
});
it('skips notes with neither a title nor a body', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const note = await Note.save({ title: '', body: '', parent_id: folder.id });
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(0);
});
it('indexes a note that has only a title (empty body)', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
const note = await Note.save({ title: 'My therapist phone number', body: '', parent_id: folder.id });
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
// A note with a title only is real signal — the user can ask "what's my
// therapist's number" and expect this to come up. So it must be indexed.
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(1);
});
it('embeds the title alongside the body so title-anchored queries match', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
// Body has nothing about dogs — only the title carries that signal.
// Without title indexing, this note would be invisible to "dog walker".
const dogNote = await Note.save({
title: 'Pet sitters for my dog',
body: 'see attached pdf for contact details',
parent_id: folder.id,
});
await Note.save({
title: 'shopping list',
body: 'milk eggs bread butter cheese',
parent_id: folder.id,
});
await waitForChangesSince(0, 2);
await EmbeddingIndexer.instance().maintenance();
const [queryVec] = await provider.embed(['dog walker']);
const results = await NoteEmbedding.similaritySearch(queryVec, { k: 5 });
expect(results.length).toBeGreaterThan(0);
expect(results[0].noteId).toBe(dogNote.id);
});
it('advances the change-id cursor', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 't', body: 'something', parent_id: folder.id });
await waitForChangesSince(0, 1);
expect(Setting.value('ai.embedding.lastProcessedChangeId')).toBe(0);
await EmbeddingIndexer.instance().maintenance();
expect(Setting.value('ai.embedding.lastProcessedChangeId') as number).toBeGreaterThan(0);
});
it('clears the index and resets the cursor when the model id changes', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 't', body: 'something to index', parent_id: folder.id });
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
// Swap the provider for one with a different modelId — simulates the
// user picking a different embedding model.
AiService.instance().setEmbeddingProvider(new TestEmbeddingProvider({ modelId: 'test-model-v2' }));
await EmbeddingIndexer.instance().maintenance();
// The whole index should be rebuilt under the new model.
expect(Setting.value('ai.embedding.lastIndexedModelId')).toBe('test-model-v2');
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
});
it('does not concurrently overlap maintenance runs', async () => {
if (skipIfNoVec()) return;
// Two maintenance calls back-to-back should be safe even when the
// first hasn't returned yet — the second short-circuits via the
// in-flight flag.
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 't', body: 'body', parent_id: folder.id });
await waitForChangesSince(0, 1);
await Promise.all([
EmbeddingIndexer.instance().maintenance(),
EmbeddingIndexer.instance().maintenance(),
]);
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
});
it('getStatus reports unavailable when no provider is active', async () => {
AiService.instance().setEmbeddingProvider(null);
const status = await EmbeddingIndexer.instance().getStatus();
expect(status.modelDownloadStatus).toBe('unavailable');
});
it('getStatus distinguishes between AI-off and indexing-off', async () => {
Setting.setValue('ai.enabled', false);
try {
const status = await EmbeddingIndexer.instance().getStatus();
expect(status.indexerState).toBe('ai-disabled');
} finally {
Setting.setValue('ai.enabled', true);
}
Setting.setValue('ai.embedding.enabled', false);
try {
const status = await EmbeddingIndexer.instance().getStatus();
expect(status.indexerState).toBe('index-disabled');
} finally {
Setting.setValue('ai.embedding.enabled', true);
}
});
it('getStatus reports downloaded for providers without a download stage', async () => {
// The bigram test provider doesn't expose modelDownloadStatus, so the
// reporter should treat it as "always ready" — i.e. 'downloaded' from
// the UI's point of view.
const status = await EmbeddingIndexer.instance().getStatus();
expect(status.modelDownloadStatus).toBe('downloaded');
});
it('backfills notes whose item_changes are already past the cursor', async () => {
if (skipIfNoVec()) return;
// Simulate "existing notes on first AI enable": the notes have
// already produced item_changes, but the cursor is set past them as
// if the indexer had nothing to chew on from the change feed.
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 'A', body: 'apples', parent_id: folder.id });
await Note.save({ title: 'B', body: 'bananas', parent_id: folder.id });
const locked = await Note.save({ title: 'C', body: 'cherries', parent_id: folder.id, is_locked: 1 });
await waitForChangesSince(0, 3);
const lastChange = await ItemChange.lastChangeId();
Setting.setValue('ai.embedding.lastProcessedChangeId', lastChange);
await EmbeddingIndexer.instance().maintenance();
await EmbeddingIndexer.instance().maintenance();
// The unlocked notes should be indexed via the backfill path even though no
// new item_changes were available.
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(2);
expect(await NoteEmbedding.countByNoteId(locked.id)).toBe(0);
expect(Setting.value('ai.embedding.initialScanDone')).toBe(true);
expect((await EmbeddingIndexer.instance().getStatus()).totalNotes).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 withWarningSilenced(/synthetic failure/, async () => {
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('does not start in background when vector search is unavailable', async () => {
// On platforms where the sqlite-vec extension fails to load (see
// #15761) the indexer would otherwise pull every note into the
// expensive embedding provider and then throw at saveChunks — burning
// CPU/memory on every tick for no result. Confirm we bail at startup
// and surface the state to the UI instead.
Setting.setValue('ai.enabled', true);
const spy = jest.spyOn(NoteEmbedding, 'vectorSearchAvailable').mockReturnValue(false);
try {
await withWarningSilenced(/Not starting background indexer: sqlite-vec extension is not loaded/, async () => {
await EmbeddingIndexer.instance().runInBackground();
});
const status = await EmbeddingIndexer.instance().getStatus();
expect(status.indexerState).toBe('vector-search-unavailable');
} finally {
spy.mockRestore();
Setting.setValue('ai.enabled', false);
await EmbeddingIndexer.instance().stopRunInBackground();
}
});
test.each([
{ name: 'unavailable wins over disabled',
status: { indexerState: 'vector-search-unavailable', modelDownloadStatus: 'unavailable', notesIndexed: 0, totalNotes: 0 },
expected: 'unavailable' },
{ name: 'ai-disabled → disabled',
status: { indexerState: 'ai-disabled', modelDownloadStatus: 'downloaded', notesIndexed: 0, totalNotes: 0 },
expected: 'disabled' },
{ name: 'index-disabled → disabled',
status: { indexerState: 'index-disabled', modelDownloadStatus: 'downloaded', notesIndexed: 0, totalNotes: 0 },
expected: 'disabled' },
{ name: 'no provider but enabled → preparing',
status: { indexerState: 'idle', modelDownloadStatus: 'unavailable', notesIndexed: 0, totalNotes: 0 },
expected: 'preparing' },
{ name: 'model downloading → preparing',
status: { indexerState: 'idle', modelDownloadStatus: 'downloading', notesIndexed: 0, totalNotes: 0 },
expected: 'preparing' },
{ name: 'model not-started → preparing',
status: { indexerState: 'idle', modelDownloadStatus: 'not-started', notesIndexed: 0, totalNotes: 0 },
expected: 'preparing' },
{ name: 'running with model ready → indexing',
status: { indexerState: 'running', modelDownloadStatus: 'downloaded', notesIndexed: 1, totalNotes: 5 },
expected: 'indexing' },
{ name: 'idle with model ready → ready',
status: { indexerState: 'idle', modelDownloadStatus: 'downloaded', notesIndexed: 3, totalNotes: 3 },
expected: 'ready' },
] as { name: string; status: IndexStatus; expected: string }[])('coarseStateFrom: $name', ({ status, expected }) => {
expect(coarseStateFrom(status)).toBe(expected);
});
it('getPluginStatus: ready is false until at least one note is indexed', async () => {
if (skipIfNoVec()) return;
Setting.setValue('ai.enabled', true);
// note_embeddings isn't reset by test-utils — start clean.
await NoteEmbedding.clearAll();
try {
const before = await EmbeddingIndexer.instance().getPluginStatus();
expect(before.state).toBe('ready');
expect(before.ready).toBe(false);
expect(before.modelId).toBe(provider.modelId);
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 't', body: 'something', parent_id: folder.id });
await waitForChangesSince(0, 1);
await EmbeddingIndexer.instance().maintenance();
const after = await EmbeddingIndexer.instance().getPluginStatus();
expect(after.state).toBe('ready');
expect(after.ready).toBe(true);
expect(after.notesIndexed).toBe(1);
} finally {
Setting.setValue('ai.enabled', false);
}
});
it('getPluginStatus: modelId is null when no provider is active', async () => {
const original = AiService.instance().getActiveEmbeddingProvider();
AiService.instance().setEmbeddingProvider(null);
try {
const status = await EmbeddingIndexer.instance().getPluginStatus();
expect(status.modelId).toBeNull();
expect(status.ready).toBe(false);
} finally {
AiService.instance().setEmbeddingProvider(original);
}
});
it('getStatus counts indexed vs total notes and excludes trashed ones', async () => {
if (skipIfNoVec()) return;
const folder = await Folder.save({ title: 'f' });
await Note.save({ title: 'A', body: 'apples are red', parent_id: folder.id });
await Note.save({ title: 'B', body: 'bananas are yellow', parent_id: folder.id });
const trashed = await Note.save({ title: 'C', body: 'cherries', parent_id: folder.id });
// Move the third note to trash so it should be excluded from totalNotes.
await Note.batchDelete([trashed.id], { toTrash: true });
await waitForChangesSince(0, 3);
await EmbeddingIndexer.instance().maintenance();
const status = await EmbeddingIndexer.instance().getStatus();
expect(status.totalNotes).toBe(2);
expect(status.notesIndexed).toBeGreaterThanOrEqual(1);
expect(status.notesIndexed).toBeLessThanOrEqual(2);
});
});