/** * BrainBank — Composite Vector Search * * Generic orchestrator for domain-specific vector searches. * Supports optional query decomposition: complex queries are split into * multiple focused sub-queries, each embedded and searched independently. * Results are merged with file-level deduplication (best score wins). * Plugin-agnostic — strategies are discovered at wiring time. */ import type { EmbeddingProvider, SearchResult } from '@/types.ts'; import type { SearchStrategy, SearchOptions, DomainVectorSearch } from '@/search/types.ts'; import { QueryDecomposer } from '@/search/query-decomposer.ts'; const _debug = !!process.env.BRAINBANK_DEBUG; function dbg(msg: string): void { if (_debug) process.stderr.write(`[composite] ${msg}\n`); } export interface CompositeVectorConfig { strategies: Map; embedding: EmbeddingProvider; /** Default K values per strategy name. Strategies not listed default to 0. */ defaults?: Record; } export class CompositeVectorSearch implements SearchStrategy { /** Default K when no source override is provided. */ private static readonly DEFAULT_K = 6; private _decomposer?: QueryDecomposer; constructor(private _c: CompositeVectorConfig) { // Auto-create decomposer if ANTHROPIC_API_KEY is available if (process.env.ANTHROPIC_API_KEY) { this._decomposer = new QueryDecomposer(); } } /** Search across all registered domain strategies with score-based merge. */ async search(query: string, options: SearchOptions = {}): Promise { const src = options.sources ?? {}; const { minScore = 0.25, useMMR = true, mmrLambda = 0.7 } = options; // ── Query Decomposition ────────────────────────────────── // For complex queries, decompose into sub-queries for better coverage. // Each sub-query produces a different embedding that captures a different // facet of the original intent. let queries: string[]; if (this._decomposer) { queries = await this._decomposer.decompose(query); } else { queries = [query]; } // Embed all queries (original + sub-queries) in batch for efficiency const queryVecs = await this._c.embedding.embedBatch(queries); // ── Multi-Vector Search ────────────────────────────────── // Run each query vector through all strategies, collect all results const allResults: SearchResult[] = []; let requestedK = 0; for (let qi = 0; qi < queryVecs.length; qi++) { const qVec = queryVecs[qi]; const qText = queries[qi]; for (const [name, strategy] of this._c.strategies) { const k = src[name] ?? this._c.defaults?.[name] ?? CompositeVectorSearch.DEFAULT_K; if (k <= 0) continue; requestedK = Math.max(requestedK, k); const hits = strategy.search(qVec, k, minScore, useMMR, mmrLambda, qText); allResults.push(...hits); } } if (allResults.length === 0) return []; // ── File-level dedup: keep best score per filePath ─────── // When multiple sub-queries find the same file, keep the highest-scoring version const bestByFile = new Map(); for (const r of allResults) { const key = r.filePath ?? `_${r.content?.slice(0, 50)}`; const existing = bestByFile.get(key); if (!existing || r.score > existing.score) { bestByFile.set(key, r); } } const deduped = [...bestByFile.values()]; if (queries.length > 1) { dbg(`Multi-query dedup: ${allResults.length} raw → ${deduped.length} unique files (${queries.length} queries)`); } // Sort by raw rrfScore, cap to requestedK * 4 to survive post-search path filtering deduped.sort((a, b) => b.score - a.score); const capped = deduped.slice(0, requestedK * 4); // Normalize scores 0-1 globally const maxScore = capped[0].score; if (maxScore > 0) { for (const r of capped) r.score = r.score / maxScore; } return capped; } }