@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
97 lines • 3.75 kB
JavaScript
/**
* ADR-121 Phase 11 — Reciprocal Rank Fusion (RRF).
*
* When you have N ranked result lists for the same query (or for N
* variants of the same intent) — e.g. from a question-reformulation
* pipeline, from a hybrid lexical+vector search, or from multiple
* embedding models — RRF combines them into a single fused ranking
* without needing the underlying scores to be comparable.
*
* Cormack, Clarke, Büttcher (SIGIR 2009):
*
* RRF_score(item) = Σ over lists i where item appears: 1 / (k_rrf + rank_i)
*
* where rank_i is the item's 1-based position in list i and k_rrf is a
* small constant (the original paper recommends 60 — small enough that
* top results dominate, large enough that mid-list ranks still
* contribute). The score is heuristic but practically excellent: RRF
* consistently matches or beats supervised learning-to-rank in TREC
* evaluations despite needing zero training data.
*
* This module ships the algorithm as a pure function so callers can
* compose it with any source of ranked lists — AnnRouter results,
* lexical search, MMR-reranked picks, etc. The CLI's
* `embeddings_search_text_ensemble` tool composes
* `search_text_batch` + `reciprocalRankFusion` for the common case of
* "expand one user query into N variants, retrieve top-k for each,
* fuse to a single ranking".
*/
/**
* Iterative RRF over N ranked lists. Time complexity:
* O(N · L) where N = number of lists, L = avg list length
*
* Lists are 1-indexed inside the formula (first item = rank 1).
*/
export function reciprocalRankFusion(lists, options) {
if (!Array.isArray(lists) || lists.length === 0)
return [];
const kRrf = options.kRrf ?? 60;
if (kRrf <= 0) {
throw new Error('kRrf must be > 0');
}
if (options.listWeights && options.listWeights.length !== lists.length) {
throw new Error(`listWeights.length (${options.listWeights.length}) must match lists.length (${lists.length})`);
}
const weights = options.listWeights ?? lists.map(() => 1);
// id → accumulated fused state
const acc = new Map();
for (let i = 0; i < lists.length; i++) {
const list = lists[i];
const w = weights[i];
for (let j = 0; j < list.length; j++) {
const item = list[j];
const rank = j + 1; // 1-based per SIGIR 2009
const contribution = w / (kRrf + rank);
const entry = acc.get(item.id);
if (entry) {
entry.score += contribution;
entry.ranks[i] = rank;
entry.listOccurrences += 1;
}
else {
const ranks = new Array(lists.length).fill(null);
ranks[i] = rank;
acc.set(item.id, {
payload: item.payload,
score: contribution,
ranks,
listOccurrences: 1,
});
}
}
}
// Sort by score desc, stable on id for determinism on ties.
const sorted = Array.from(acc.entries())
.map(([id, v]) => ({
id,
payload: v.payload,
score: v.score,
ranks: v.ranks,
listOccurrences: v.listOccurrences,
}))
.sort((a, b) => {
if (b.score !== a.score)
return b.score - a.score;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const k = Math.max(0, Math.min(options.k, sorted.length));
return sorted.slice(0, k);
}
/**
* Convenience: same as reciprocalRankFusion but returns just the fused
* ids in rank order.
*/
export function rrfIds(lists, options) {
return reciprocalRankFusion(lists, options).map(h => h.id);
}
//# sourceMappingURL=rrf.js.map