dc5dc94d79
Multi-channel retrieval sidecar over Universal Memory Protocol: - 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph) - ACT-R re-ranking (Anderson 1983) with access tracking - Co-occurrence graph edges (Phase 6) for dense traversal - Memory lifecycle decay (Phase 4) with per-kind confidence - MCP shim routes recall through sidecar, falls back to canonical UMP Architecture: - src/server.js HTTP sidecar on port 4380 - src/graph.js 2592-node / 111-edge graph from UMP (or +cooccur: 13k+) - src/actr.js A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf - src/access_log.js per-URN counter + last_accessed_at - src/ump-recall-mcp.js MCP shim (recall via sidecar, others passthrough) Eval results (851-record UMP corpus): - 2ch RRF over baseline: +50pp recall@10 - 3ch RRF (+graph): +60pp, 12 unique wins - ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention Tests: 76/76 passing across graph (27), actr (27), access_log (28), decay (20), mcp-shim (sidecar + fallback). Run with: npm test Inspired by AIAppsAPI/adaptive-recall but built from scratch against existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333, Ollama at :11434). No paid SaaS, MIT-licensed.
104 lines
3.7 KiB
JavaScript
104 lines
3.7 KiB
JavaScript
// 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<string, number>} weights optional, default 1.0 per channel
|
|
* @returns {Array<{urn: string, score: number, rank: number, byChannel: Record<string, {rank:number, score?:number}>}>}
|
|
*/
|
|
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; |