feat(TRUEREF-0020): add embedding profiles, default local embeddings, and version-scoped semantic retrieval

- Add embedding_profiles table with provider registry pattern
- Install @xenova/transformers as runtime dependency
- Update snippet_embeddings with composite PK (snippet_id, profile_id)
- Seed default local profile using Xenova/all-MiniLM-L6-v2
- Add provider registry (local-transformers, openai-compatible)
- Update EmbeddingService to persist and retrieve by profileId
- Add version-scoped VectorSearch with optional versionId filtering
- Add searchMode (auto|keyword|semantic|hybrid) to HybridSearchService
- Update API /context route to load active profile, support searchMode/alpha params
- Extend MCP query-docs tool with searchMode and alpha parameters
- Update settings API to work with embedding_profiles table
- Add comprehensive test coverage for profiles, registry, version scoping

Status: 445/451 tests passing, core feature complete
This commit is contained in:
Giancarmine Salucci
2026-03-25 19:16:37 +01:00
parent fef6f66930
commit 169df4d984
19 changed files with 2668 additions and 246 deletions

View File

@@ -25,16 +25,18 @@ function createTestDb(): Database.Database {
client.pragma('foreign_keys = ON');
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
const migrationSql = readFileSync(
join(migrationsFolder, '0000_large_master_chief.sql'),
'utf-8'
);
const statements = migrationSql
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
client.exec(stmt);
// Run all migrations in order
const migrations = ['0000_large_master_chief.sql', '0001_quick_nighthawk.sql', '0002_silky_stellaris.sql'];
for (const migrationFile of migrations) {
const migrationSql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
const statements = migrationSql
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
client.exec(stmt);
}
}
const ftsSql = readFileSync(join(import.meta.dirname, '../db/fts.sql'), 'utf-8');
@@ -104,16 +106,17 @@ function seedEmbedding(
client: Database.Database,
snippetId: string,
values: number[],
profileId = 'local-default',
model = 'test-model'
): void {
const f32 = new Float32Array(values);
client
.prepare(
`INSERT OR REPLACE INTO snippet_embeddings
(snippet_id, model, dimensions, embedding, created_at)
VALUES (?, ?, ?, ?, ?)`
(snippet_id, profile_id, model, dimensions, embedding, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(snippetId, model, values.length, Buffer.from(f32.buffer), NOW_S);
.run(snippetId, profileId, model, values.length, Buffer.from(f32.buffer), NOW_S);
}
// ---------------------------------------------------------------------------
@@ -621,4 +624,203 @@ describe('HybridSearchService', () => {
const results = await svc.search('default alpha hybrid', { repositoryId: repoId });
expect(Array.isArray(results)).toBe(true);
});
it('filters by versionId — excludes snippets from other versions', async () => {
const client = createTestDb();
const repoId = seedRepo(client);
const docId = seedDocument(client, repoId);
// Create two versions
client
.prepare(
`INSERT INTO repository_versions (id, repository_id, tag, state, total_snippets, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run('/test/repo/v1.0', repoId, 'v1.0', 'indexed', 0, NOW_S);
client
.prepare(
`INSERT INTO repository_versions (id, repository_id, tag, state, total_snippets, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run('/test/repo/v2.0', repoId, 'v2.0', 'indexed', 0, NOW_S);
// Create embedding profile
client
.prepare(
`INSERT INTO embedding_profiles (id, provider_kind, title, enabled, is_default, model, dimensions, config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run('test-profile', 'local-transformers', 'Test', 1, 1, 'test-model', 4, '{}', NOW_S, NOW_S);
// Snippet A in version 1.0
const snippetA = seedSnippet(client, {
repositoryId: repoId,
documentId: docId,
content: 'version 1 text'
});
client
.prepare('UPDATE snippets SET version_id = ? WHERE id = ?')
.run('/test/repo/v1.0', snippetA);
// Seed embedding for snippetA
const embedA = [0.1, 0.2, 0.3, 0.4];
const f32A = new Float32Array(embedA);
client
.prepare(
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(snippetA, 'test-profile', 'test-model', 4, Buffer.from(f32A.buffer), NOW_S);
// Snippet B in version 2.0
const snippetB = seedSnippet(client, {
repositoryId: repoId,
documentId: docId,
content: 'version 2 text'
});
client
.prepare('UPDATE snippets SET version_id = ? WHERE id = ?')
.run('/test/repo/v2.0', snippetB);
// Seed embedding for snippetB
const embedB = [0.2, 0.3, 0.4, 0.5];
const f32B = new Float32Array(embedB);
client
.prepare(
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(snippetB, 'test-profile', 'test-model', 4, Buffer.from(f32B.buffer), NOW_S);
const vs = new VectorSearch(client);
const query = new Float32Array([0.1, 0.2, 0.3, 0.4]);
// Query with versionId v1.0 should only return snippetA
const resultsV1 = vs.vectorSearch(query, {
repositoryId: repoId,
versionId: '/test/repo/v1.0',
profileId: 'test-profile'
});
expect(resultsV1.map((r) => r.snippetId)).toContain(snippetA);
expect(resultsV1.map((r) => r.snippetId)).not.toContain(snippetB);
// Query with versionId v2.0 should only return snippetB
const resultsV2 = vs.vectorSearch(query, {
repositoryId: repoId,
versionId: '/test/repo/v2.0',
profileId: 'test-profile'
});
expect(resultsV2.map((r) => r.snippetId)).not.toContain(snippetA);
expect(resultsV2.map((r) => r.snippetId)).toContain(snippetB);
// Query without versionId should return both
const resultsAll = vs.vectorSearch(query, {
repositoryId: repoId,
profileId: 'test-profile'
});
expect(resultsAll.map((r) => r.snippetId)).toContain(snippetA);
expect(resultsAll.map((r) => r.snippetId)).toContain(snippetB);
});
it('searchMode=keyword never calls provider.embed()', async () => {
const client = createTestDb();
const repoId = seedRepo(client);
const docId = seedDocument(client, repoId);
const snippetId = seedSnippet(client, {
repositoryId: repoId,
documentId: docId,
content: 'keyword only test'
});
client.exec(
`INSERT INTO snippets_fts (id, repository_id, version_id, title, breadcrumb, content)
VALUES ('${snippetId}', '${repoId}', NULL, NULL, NULL, 'keyword only test')`
);
let embedCalled = false;
const mockProvider: EmbeddingProvider = {
name: 'mock',
dimensions: 4,
model: 'test-model',
async embed() {
embedCalled = true;
return [];
},
async isAvailable() {
return true;
}
};
const searchService = new SearchService(client);
const hybridService = new HybridSearchService(client, searchService, mockProvider);
const results = await hybridService.search('keyword', {
repositoryId: repoId,
searchMode: 'keyword'
});
expect(embedCalled).toBe(false);
expect(results.length).toBeGreaterThan(0);
});
it('searchMode=semantic uses only vector search', async () => {
const client = createTestDb();
const repoId = seedRepo(client);
const docId = seedDocument(client, repoId);
// Create profile
client
.prepare(
`INSERT INTO embedding_profiles (id, provider_kind, title, enabled, is_default, model, dimensions, config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run('test-profile', 'local-transformers', 'Test', 1, 1, 'test-model', 4, '{}', NOW_S, NOW_S);
const snippetId = seedSnippet(client, {
repositoryId: repoId,
documentId: docId,
content: 'semantic test'
});
// Seed embedding
const embed = [0.5, 0.5, 0.5, 0.5];
const f32 = new Float32Array(embed);
client
.prepare(
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(snippetId, 'test-profile', 'test-model', 4, Buffer.from(f32.buffer), NOW_S);
const mockProvider: EmbeddingProvider = {
name: 'mock',
dimensions: 4,
model: 'test-model',
async embed() {
return [
{
values: new Float32Array([0.5, 0.5, 0.5, 0.5]),
dimensions: 4,
model: 'test-model'
}
];
},
async isAvailable() {
return true;
}
};
const searchService = new SearchService(client);
const hybridService = new HybridSearchService(client, searchService, mockProvider);
const results = await hybridService.search('semantic', {
repositoryId: repoId,
searchMode: 'semantic',
profileId: 'test-profile'
});
// Should return results (alpha=1 pure vector mode)
expect(results.length).toBeGreaterThan(0);
});
});