文件
mynode/packages/lib/models/NoteEmbedding.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

176 行
6.2 KiB
TypeScript

import { setupDatabaseAndSynchronizer, switchClient, db } from '../testing/test-utils';
import NoteEmbedding from './NoteEmbedding';
// These tests exercise the storage layer end-to-end against a real database with
// sqlite-vec loaded. Vectors are hand-crafted unit vectors so KNN ordering is
// deterministic — no embedding model required.
describe('NoteEmbedding', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
const skipIfNoVec = () => {
if (!db(1).sqliteVecAvailable()) {
// eslint-disable-next-line no-console
console.warn('Skipping NoteEmbedding vector test: sqlite-vec unavailable');
return true;
}
return false;
};
it('reports zero counts on an empty index', async () => {
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(0);
expect(await NoteEmbedding.countByNoteId('note-1')).toBe(0);
expect(await NoteEmbedding.byNoteId('note-1')).toEqual([]);
});
it('similaritySearch returns [] on a fresh profile (vec table not yet created)', async () => {
if (skipIfNoVec()) return;
// saveChunks creates the vec table lazily. Calling similaritySearch
// before any save must not crash with "no such table".
const results = await NoteEmbedding.similaritySearch([1, 0, 0, 0], { k: 5 });
expect(results).toEqual([]);
});
it('clearAll is a no-op on a fresh profile (vec table not yet created)', async () => {
if (skipIfNoVec()) return;
await expect(NoteEmbedding.clearAll()).resolves.toBeUndefined();
});
it('saves and reads back chunks for a note', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'test-model-v1', [
{ chunkIndex: 0, chunkText: 'first chunk', vector: [1, 0, 0, 0] },
{ chunkIndex: 1, chunkText: 'second chunk', vector: [0, 1, 0, 0] },
]);
const rows = await NoteEmbedding.byNoteId('note-1');
expect(rows.length).toBe(2);
expect(rows[0].chunk_index).toBe(0);
expect(rows[0].chunk_text).toBe('first chunk');
expect(rows[0].model_id).toBe('test-model-v1');
expect(rows[1].chunk_index).toBe(1);
expect(rows[1].chunk_text).toBe('second chunk');
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
expect(await NoteEmbedding.countByNoteId('note-1')).toBe(2);
});
it('replaces existing chunks on subsequent saveChunks for the same note', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'old', vector: [1, 0, 0, 0] },
{ chunkIndex: 1, chunkText: 'old', vector: [0, 1, 0, 0] },
]);
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'new', vector: [0, 0, 1, 0] },
]);
const rows = await NoteEmbedding.byNoteId('note-1');
expect(rows.length).toBe(1);
expect(rows[0].chunk_text).toBe('new');
});
it('similaritySearch returns chunks in distance order', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'aligned-with-x', vector: [1, 0, 0, 0] },
{ chunkIndex: 1, chunkText: 'aligned-with-y', vector: [0, 1, 0, 0] },
{ chunkIndex: 2, chunkText: 'aligned-with-z', vector: [0, 0, 1, 0] },
]);
const results = await NoteEmbedding.similaritySearch([0.9, 0.1, 0, 0], { k: 3 });
expect(results.length).toBe(3);
expect(results[0].chunkText).toBe('aligned-with-x');
expect(results[1].chunkText).toBe('aligned-with-y');
expect(results[2].chunkText).toBe('aligned-with-z');
expect(results[0].distance).toBeLessThan(results[1].distance);
expect(results[1].distance).toBeLessThan(results[2].distance);
});
it('similaritySearch respects the noteIds scope filter', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'note-1 chunk', vector: [1, 0, 0, 0] },
]);
await NoteEmbedding.saveChunks('note-2', 'm', [
{ chunkIndex: 0, chunkText: 'note-2 chunk', vector: [0.95, 0.05, 0, 0] },
]);
const all = await NoteEmbedding.similaritySearch([1, 0, 0, 0], { k: 10 });
expect(all.map(r => r.noteId).sort()).toEqual(['note-1', 'note-2']);
const scoped = await NoteEmbedding.similaritySearch([1, 0, 0, 0], { k: 10, noteIds: ['note-2'] });
expect(scoped.length).toBe(1);
expect(scoped[0].noteId).toBe('note-2');
});
it('similaritySearch returns [] when noteIds is an explicit empty array', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'something', vector: [1, 0, 0, 0] },
]);
// An empty noteIds means "search within zero notes" — not "no filter".
const results = await NoteEmbedding.similaritySearch([1, 0, 0, 0], { k: 10, noteIds: [] });
expect(results).toEqual([]);
});
it('deleteByNoteId removes only that note\'s chunks from both tables', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'one', vector: [1, 0, 0, 0] },
]);
await NoteEmbedding.saveChunks('note-2', 'm', [
{ chunkIndex: 0, chunkText: 'two', vector: [0, 1, 0, 0] },
]);
await NoteEmbedding.deleteByNoteId('note-1');
expect(await NoteEmbedding.countByNoteId('note-1')).toBe(0);
expect(await NoteEmbedding.countByNoteId('note-2')).toBe(1);
// And the vec table is in sync: a search returns only note-2's chunk.
const results = await NoteEmbedding.similaritySearch([1, 0, 0, 0], { k: 10 });
expect(results.length).toBe(1);
expect(results[0].noteId).toBe('note-2');
});
it('clearAll wipes everything from both tables', async () => {
if (skipIfNoVec()) return;
await NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'a', vector: [1, 0, 0, 0] },
]);
await NoteEmbedding.saveChunks('note-2', 'm', [
{ chunkIndex: 0, chunkText: 'b', vector: [0, 1, 0, 0] },
]);
await NoteEmbedding.clearAll();
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(0);
const results = await NoteEmbedding.similaritySearch([1, 0, 0, 0], { k: 10 });
expect(results.length).toBe(0);
});
it('rejects mismatched vector dimensions', async () => {
if (skipIfNoVec()) return;
await expect(
NoteEmbedding.saveChunks('note-1', 'm', [
{ chunkIndex: 0, chunkText: 'a', vector: [1, 0, 0, 0] },
{ chunkIndex: 1, chunkText: 'b', vector: [1, 0, 0] },
]),
).rejects.toThrow(/dimension/);
});
});