Files
trueref/docs/features/TRUEREF-0008.md
2026-03-27 02:23:01 +01:00

6.0 KiB

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:

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<Array<{ snippetId: string; score: number }>> {
  // 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

function reciprocalRankFusion(
	...rankings: Array<Array<{ id: string; score: number }>>
): Array<{ id: string; rrfScore: number }> {
	const K = 60; // RRF constant (standard value)
	const scores = new Map<string, number>();

	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

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<SnippetSearchResult[]> {
		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:

// 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