/** * BrainBank — Reciprocal Rank Fusion (RRF) * * Combines results from multiple search systems (vector + BM25) * using the RRF algorithm: score = Σ 1/(k + rank_i) * * This is the same algorithm used by Elasticsearch, QMD, and most * production hybrid search systems. Simple but very effective. * * Reference: Cormack et al., "Reciprocal Rank Fusion outperforms * Condorcet and individual Rank Learning Methods" (2009) */ import type { SearchResult } from '@/types.ts'; /** * Fuse ranked lists from different search systems into a single ranked list. * * @param resultSets - Arrays of SearchResult from different systems (e.g. vector, BM25) * @param k - Smoothing constant. Default: 60 (standard value). Higher = less emphasis on top ranks. * @param maxResults - Maximum results to return. */ export function reciprocalRankFusion( resultSets: SearchResult[][], k: number = 60, maxResults: number = 15, ): SearchResult[] { // Build a map: unique key → { bestResult, rrfScore } const fused = new Map(); for (const results of resultSets) { for (let rank = 0; rank < results.length; rank++) { const r = results[rank]; const key = resultKey(r); const rrfContribution = 1.0 / (k + rank + 1); const existing = fused.get(key); if (existing) { existing.rrfScore += rrfContribution; // Keep the result with the higher original score if (r.score > existing.result.score) { existing.result = { ...r }; } } else { fused.set(key, { result: { ...r }, rrfScore: rrfContribution, }); } } } // Sort by RRF score descending, normalize, and return const sorted = Array.from(fused.values()) .sort((a, b) => b.rrfScore - a.rrfScore) .slice(0, maxResults); // Normalize RRF scores to 0..1 range. // Note: A single result always normalizes to 1.0. This is correct for RRF — // the score is relative to the result set, not absolute relevance. // Use minScore filters sparingly with RRF. const maxRRF = sorted[0]?.rrfScore ?? 1; return sorted.map(entry => ({ ...entry.result, score: entry.rrfScore / maxRRF, metadata: { ...entry.result.metadata, rrfScore: entry.rrfScore, }, }) as SearchResult); } /** * Generate a unique key for a search result to detect duplicates across systems. */ function resultKey(r: SearchResult): string { switch (r.type) { case 'code': return `code:${r.filePath}:${r.metadata.startLine}-${r.metadata.endLine}`; case 'commit': return `commit:${r.metadata.hash || r.metadata.shortHash}`; case 'document': return `document:${r.filePath ?? ''}:${r.metadata.collection ?? ''}:${r.metadata.seq ?? ''}:${r.content?.slice(0, 80)}`; case 'collection': return `collection:${r.metadata.id ?? r.content?.slice(0, 80)}`; } } /** * Generic RRF that works on any type — no SearchResult required. * * @param lists - Ranked lists from different search systems. * @param keyFn - Returns a stable unique string per item. * @param scoreFn - Extracts the original score from an item. * @param k - Smoothing constant. Default: 60. * @param maxResults - Maximum results to return. */ export function fuseRankedLists( lists: T[][], keyFn: (item: T) => string, scoreFn: (item: T) => number, k: number = 60, maxResults: number = 15, ): { item: T; score: number }[] { const fused = new Map(); for (const list of lists) { for (let rank = 0; rank < list.length; rank++) { const item = list[rank]; const key = keyFn(item); const contribution = 1.0 / (k + rank + 1); const score = scoreFn(item); const existing = fused.get(key); if (existing) { existing.rrfScore += contribution; if (score > existing.bestScore) { existing.item = item; existing.bestScore = score; } } else { fused.set(key, { item, rrfScore: contribution, bestScore: score }); } } } const sorted = [...fused.values()] .sort((a, b) => b.rrfScore - a.rrfScore) .slice(0, maxResults); const maxRRF = sorted[0]?.rrfScore ?? 1; return sorted.map(e => ({ item: e.item, score: e.rrfScore / maxRRF })); }