// RRF (Reciprocal Rank Fusion) ranker for ump-recall sidecar. // // Combines multiple retrieval channels into a single ranked list. The // standard RRF formula is: // score(d) = Σ_i 1 / (k + rank_i(d)) // where rank_i(d) is d's position in channel i's result list (1-indexed). // k=60 is the conventional constant from the original Cormack et al. 2009 // paper and the Qdrant docs. // // Why RRF (vs weighted linear combination of scores): // - Channel scores live in different ranges (UMP: composite 0..1, // Qdrant: cosine 0..1, FTS5: BM25 unbounded). Linear fusion needs // per-channel normalization that's brittle to score-distribution drift. // - RRF only needs ranks, which are stable as long as the underlying // channel keeps roughly the same top-N. Insensitive to scale. // - O(1) per candidate per channel. Tunable via `channel_weight` per // channel for soft boosting (e.g. trust the vector channel more). // // Adding a new channel: // 1. Add a `run*Channel(query, topN)` function below that returns // [{ urn, rank, channel, score?, debug? }, ...] // 2. Push its result into `channels` array in the `fuse()` caller. // No changes needed to rrfFuse() itself. const RRF_K = 60; // standard constant /** * rrfFuse(channelResults, weights) -> ranked list of { urn, score, byChannel: {} } * * @param {Array<{name: string, results: Array<{urn: string, score?: number, debug?: any}>}>} channelResults * @param {Object} weights optional, default 1.0 per channel * @returns {Array<{urn: string, score: number, rank: number, byChannel: Record}>} */ export function rrfFuse(channelResults, weights = {}) { const candidates = new Map(); // urn -> { score, byChannel } const allUrns = new Set(); for (const ch of channelResults) { const w = weights[ch.name] ?? 1.0; ch.results.forEach((hit, idx) => { if (!hit?.urn) return; allUrns.add(hit.urn); const rank = idx + 1; // 1-indexed const contribution = w / (RRF_K + rank); const entry = candidates.get(hit.urn) || { score: 0, byChannel: {} }; entry.score += contribution; entry.byChannel[ch.name] = { rank, score: hit.score, // raw channel score if provided debug: hit.debug, }; candidates.set(hit.urn, entry); }); } // Sort by RRF score descending. Tie-break by sum of raw scores (better // channels win ties), then by urn alphabetically (deterministic). const ranked = Array.from(candidates.entries()) .map(([urn, v]) => ({ urn, score: v.score, byChannel: v.byChannel, })) .sort((a, b) => { if (b.score !== a.score) return b.score - a.score; const aSum = Object.values(a.byChannel).reduce( (acc, x) => acc + (x.score ?? 0), 0, ); const bSum = Object.values(b.byChannel).reduce( (acc, x) => acc + (x.score ?? 0), 0, ); if (bSum !== aSum) return bSum - aSum; return a.urn.localeCompare(b.urn); }) .map((entry, idx) => ({ ...entry, rank: idx + 1 })); return ranked; } /** * Reciprocal Rank @ K — for a single channel's top-K and a known-relevant urn, * returns 1/K if the urn appears in top-K, else 0. */ export function rrAtK(channelHits, relevantUrn, k) { const idx = channelHits.findIndex((h) => h.urn === relevantUrn); if (idx < 0 || idx >= k) return 0; return 1 / (idx + 1); } /** * MRR (Mean Reciprocal Rank) over a list of {hits, relevant}. */ export function mrr(perQuery) { if (!perQuery.length) return 0; const sum = perQuery.reduce( (acc, q) => acc + rrAtK(q.hits, q.relevant, q.hits.length), 0, ); return sum / perQuery.length; } export const RRF_CONSTANT = RRF_K;