import type { GraphNode } from "./types.js"; import type { SearchResult } from "./search.js"; export interface SemanticSearchOptions { limit?: number; threshold?: number; types?: string[]; } /** * Compute cosine similarity between two vectors. * Returns 0 if either vector has zero magnitude. */ export function cosineSimilarity(a: number[], b: number[]): number { let dot = 0; let magA = 0; let magB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; magA += a[i] * a[i]; magB += b[i] * b[i]; } magA = Math.sqrt(magA); magB = Math.sqrt(magB); if (magA === 0 || magB === 0) return 0; return dot / (magA * magB); } /** * Semantic search engine using vector embeddings. * Stores pre-computed embeddings for graph nodes and performs * cosine similarity search against query embeddings. */ export class SemanticSearchEngine { private nodes: GraphNode[]; private embeddings: Map; constructor(nodes: GraphNode[], embeddings: Record) { this.nodes = nodes; this.embeddings = new Map(Object.entries(embeddings)); } hasEmbeddings(): boolean { return this.embeddings.size > 0; } addEmbedding(nodeId: string, embedding: number[]): void { this.embeddings.set(nodeId, embedding); } search( queryEmbedding: number[], options?: SemanticSearchOptions, ): SearchResult[] { const limit = options?.limit ?? 10; const threshold = options?.threshold ?? 0; const typeFilter = options?.types; const scored: Array<{ nodeId: string; score: number }> = []; for (const node of this.nodes) { if (typeFilter && !typeFilter.includes(node.type)) continue; const embedding = this.embeddings.get(node.id); if (!embedding) continue; const similarity = cosineSimilarity(queryEmbedding, embedding); if (similarity >= threshold) { scored.push({ nodeId: node.id, score: 1 - similarity }); } } scored.sort((a, b) => a.score - b.score); return scored.slice(0, limit); } updateNodes(nodes: GraphNode[]): void { this.nodes = nodes; } }