# TRUEREF-0008 — Hybrid Semantic Search Engine **Priority:** P1 **Status:** Pending **Depends On:** TRUEREF-0006, TRUEREF-0007 **Blocks:** TRUEREF-0010 (enhances it) --- ## Overview Combine FTS5 BM25 keyword search with vector similarity search (cosine similarity over embeddings) using Reciprocal Rank Fusion (RRF) to produce a hybrid ranking that outperforms either approach alone. When embeddings are not available, the system transparently falls back to FTS5-only mode. --- ## Acceptance Criteria - [ ] `HybridSearchService` that coordinates FTS5 and vector search - [ ] Cosine similarity search over stored embeddings - [ ] Reciprocal Rank Fusion for combining ranked lists - [ ] Graceful degradation to FTS5-only when embeddings unavailable - [ ] Query embedding generated at search time via the configured provider - [ ] Results deduplicated by snippet ID (same snippet may appear in both result sets) - [ ] Configurable `alpha` parameter: weight between FTS5 (0.0) and vector (1.0) - [ ] Performance: < 300ms for searches over 100k snippets - [ ] Unit tests with mock embedding provider --- ## Architecture ``` query text │ ├──── FTS5 Search ──────────────┐ │ (BM25 ranking) │ │ │ └──── Vector Search ────────────┤ (cosine similarity) │ (embed query first) │ │ RRF Fusion │ Final ranked list ``` --- ## Vector Search Implementation SQLite does not natively support vector operations, so cosine similarity is computed in JavaScript after loading candidate embeddings: ```typescript function cosineSimilarity(a: Float32Array, b: Float32Array): number { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } async vectorSearch( queryEmbedding: Float32Array, repositoryId: string, limit: number = 50 ): Promise> { // Load all embeddings for the repository (filtered) const rows = this.db.prepare(` SELECT se.snippet_id, se.embedding FROM snippet_embeddings se JOIN snippets s ON s.id = se.snippet_id WHERE s.repository_id = ? `).all(repositoryId) as { snippet_id: string; embedding: Buffer }[]; // Compute cosine similarity for each const scored = rows.map(row => { const embedding = new Float32Array( row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4 ); return { snippetId: row.snippet_id, score: cosineSimilarity(queryEmbedding, embedding), }; }); // Sort descending by score, return top-k return scored .sort((a, b) => b.score - a.score) .slice(0, limit); } ``` **Performance note:** For repositories with > 50k snippets, pre-filtering by FTS5 candidates before computing cosine similarity is recommended. This is a future optimization — for v1, in-memory computation is acceptable. --- ## Reciprocal Rank Fusion ```typescript function reciprocalRankFusion( ...rankings: Array> ): Array<{ id: string; rrfScore: number }> { const K = 60; // RRF constant (standard value) const scores = new Map(); for (const ranking of rankings) { ranking.forEach(({ id }, rank) => { const current = scores.get(id) ?? 0; scores.set(id, current + 1 / (K + rank + 1)); }); } return Array.from(scores.entries()) .map(([id, rrfScore]) => ({ id, rrfScore })) .sort((a, b) => b.rrfScore - a.rrfScore); } ``` --- ## Hybrid Search Service ```typescript export interface HybridSearchOptions { repositoryId: string; versionId?: string; type?: 'code' | 'info'; limit?: number; alpha?: number; // 0.0 = FTS5 only, 1.0 = vector only, 0.5 = balanced } export class HybridSearchService { constructor( private db: BetterSQLite3.Database, private searchService: SearchService, private embeddingProvider: EmbeddingProvider | null ) {} async search(query: string, options: HybridSearchOptions): Promise { const limit = options.limit ?? 20; const alpha = options.alpha ?? 0.5; // Always run FTS5 search const ftsResults = this.searchService.searchSnippets(query, { repositoryId: options.repositoryId, versionId: options.versionId, type: options.type, limit: limit * 3 // get more candidates for fusion }); // If no embedding provider or alpha = 0, return FTS5 results directly if (!this.embeddingProvider || alpha === 0) { return ftsResults.slice(0, limit); } // Embed the query and run vector search const [queryEmbedding] = await this.embeddingProvider.embed([query]); const vectorResults = await this.vectorSearch( queryEmbedding.values, options.repositoryId, limit * 3 ); // Normalize result lists for RRF const ftsRanked = ftsResults.map((r, i) => ({ id: r.snippet.id, score: i })); const vecRanked = vectorResults.map((r, i) => ({ id: r.snippetId, score: i })); // Apply RRF const fused = reciprocalRankFusion(ftsRanked, vecRanked); // Fetch full snippet data for top results const topIds = fused.slice(0, limit).map((r) => r.id); return this.fetchSnippetsByIds(topIds, options.repositoryId); } } ``` --- ## Configuration The hybrid search alpha value can be set per-request or globally via settings: ```typescript // Default config stored in settings table under key 'search_config' export interface SearchConfig { alpha: number; // 0.5 default maxResults: number; // 20 default enableHybrid: boolean; // true if embedding provider is configured } ``` --- ## Files to Create - `src/lib/server/search/hybrid.search.service.ts` - `src/lib/server/search/vector.search.ts` - `src/lib/server/search/rrf.ts` - `src/lib/server/search/hybrid.search.service.test.ts`