Skip to content

Commit 9b755d9

Browse files
some-random-agentclaude
andcommitted
feat(tier2): Reciprocal Rank Fusion for retrieval (default-on, A/B-confirmed)
RRF fuses BM25 + vector + backlink as per-list RANK positions (Sigma w/(K+rank)), replacing weighted-sum, in both the SQLite (_searchDb) and PG (search) paths. Rank-based fusion lets the backlink signal surface a heavily-referenced real answer above a keyword-stuffed decoy: A/B on a hub-recall corpus had weighted rank the decoy #1 and the hub #2, while RRF flips them (hub #1). - config: ZC_RETRIEVAL_FUSION (default rrf; =weighted is the rollback kill-switch), RRF_K=60, per-list weights (RRF_W_BM25/VEC/BACKLINK). Backlink list gated on W_BACKLINK>0 so that stays the backlink kill-switch in both modes. - weighted path left byte-identical; full suite 865/868 (3 pre-existing fixtures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 405f93e commit 9b755d9

3 files changed

Lines changed: 93 additions & 5 deletions

File tree

src/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ export const Config = {
7070
W_BACKLINK: parseFloat(env["ZC_W_BACKLINK"] ?? "0.08"),
7171
BACKLINK_LOG_BASE: parseInt(env["ZC_BACKLINK_LOG_BASE"] ?? "10", 10),
7272

73+
// ── Retrieval fusion (Tier-2 #3) ─────────────────────────────────────────
74+
// How the BM25 + vector (+ backlink) candidate lists are combined.
75+
// "weighted" — historical weighted-sum (W_BM25·bm25 + W_COSINE·cosine) plus the
76+
// additive log-damped backlink boost. Byte-identical to v0.31.0.
77+
// "rrf" — Reciprocal Rank Fusion over per-list RANK positions
78+
// (Σ w/(K+rank)); rank-based, scale-free, robust to score skew.
79+
// Default "rrf" (v0.31.x): A/B on a hub-recall corpus showed RRF surfaces the
80+
// heavily-referenced real answer above a keyword-stuffed decoy where weighted-sum
81+
// fails. Set ZC_RETRIEVAL_FUSION=weighted to roll back. Backlink folds in as a third
82+
// RRF list (kept off when W_BACKLINK=0, so that flag remains the backlink kill-switch).
83+
RETRIEVAL_FUSION: (env["ZC_RETRIEVAL_FUSION"] ?? "rrf") as "weighted" | "rrf",
84+
RRF_K: parseInt(env["ZC_RRF_K"] ?? "60", 10),
85+
RRF_W_BM25: parseFloat(env["ZC_RRF_W_BM25"] ?? "1.0"),
86+
RRF_W_VEC: parseFloat(env["ZC_RRF_W_VEC"] ?? "1.0"),
87+
RRF_W_BACKLINK: parseFloat(env["ZC_RRF_W_BACKLINK"] ?? "0.25"),
88+
7389
// ── Fetch / SSRF ──────────────────────────────────────────────────────────
7490
FETCH_LIMIT: parseInt(env["ZC_FETCH_LIMIT"] ?? "50", 10),
7591
FETCH_TIMEOUT_MS: 15_000,

src/knowledge.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,33 @@ function _searchDb(
403403
} catch { /* table absent on a pre-migration DB — leave map empty */ }
404404
}
405405

406+
// Tier-2 #3: Reciprocal Rank Fusion. Precompute per-list RANK positions (1-indexed)
407+
// ONLY in RRF mode — the weighted path below is left byte-identical. Backlink is folded
408+
// in as a third list, gated on W_BACKLINK>0 so that flag stays the backlink kill-switch.
409+
const useRRF = Config.RETRIEVAL_FUSION === "rrf";
410+
let bm25RankMap: Map<string, number> | null = null;
411+
let cosRankMap: Map<string, number> | null = null;
412+
let blRankMap: Map<string, number> | null = null;
413+
if (useRRF) {
414+
const entries = Array.from(candidateMap.entries());
415+
// BM25: lower FTS5 rank = more relevant.
416+
bm25RankMap = new Map([...entries].sort((a, b) => a[1].rank - b[1].rank).map(([s], i) => [s, i + 1]));
417+
// Vector: higher cosine = more relevant (only when a query embedding exists).
418+
if (queryVector) {
419+
const withCos = entries.map(([s]) => {
420+
const v = embeddingMap.get(s);
421+
return { s, cos: v ? cosineSimilarity(queryVector, v) : -Infinity };
422+
}).sort((a, b) => b.cos - a.cos);
423+
cosRankMap = new Map(withCos.map((x, i) => [x.s, i + 1]));
424+
}
425+
// Backlink: higher weighted_in = stronger hub (only sources with inbound links).
426+
if (Config.W_BACKLINK > 0 && backlinkMap.size > 0) {
427+
const withBl = entries.map(([s]) => ({ s, w: backlinkMap.get(s) ?? 0 }))
428+
.filter((x) => x.w > 0).sort((a, b) => b.w - a.w);
429+
blRankMap = new Map(withBl.map((x, i) => [x.s, i + 1]));
430+
}
431+
}
432+
406433
const ranks = Array.from(candidateMap.values()).map((r) => r.rank);
407434
const minRank = Math.min(...ranks);
408435
const maxRank = Math.max(...ranks);
@@ -428,7 +455,21 @@ function _searchDb(
428455
const blBoost = wIn > 0
429456
? Config.W_BACKLINK * (Math.log(1 + wIn) / Math.log(1 + Config.BACKLINK_LOG_BASE))
430457
: 0;
431-
const hybridScore = baseScore + blBoost;
458+
// Tier-2 #3: RRF fuses per-list RANK positions (scale-free, robust to score skew);
459+
// the weighted path fuses normalized scores + additive boost (byte-identical to v0.31.0).
460+
let hybridScore: number;
461+
if (useRRF) {
462+
const K = Config.RRF_K;
463+
const br = bm25RankMap!.get(source);
464+
const cr = cosRankMap?.get(source);
465+
const blr = blRankMap?.get(source);
466+
hybridScore =
467+
(br ? Config.RRF_W_BM25 / (K + br) : 0) +
468+
(cr ? Config.RRF_W_VEC / (K + cr) : 0) +
469+
(blr ? Config.RRF_W_BACKLINK / (K + blr) : 0);
470+
} else {
471+
hybridScore = baseScore + blBoost;
472+
}
432473

433474
const firstTerm = queries[0]?.toLowerCase().split(" ")[0] ?? "";
434475
const idx = row.content.toLowerCase().indexOf(firstTerm);

src/store-postgres.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -519,16 +519,47 @@ export class PostgresStore implements Store {
519519
const embMap = new Map(embRes.rows.map(r => [r.source, r.vector]));
520520
const maxBm25 = Math.max(...bm25Res.rows.map(r => r.rank), 1);
521521

522-
const scored = bm25Res.rows.map(row => {
523-
const bm25Norm = row.rank / maxBm25;
524-
let cosScore = 0;
522+
// Compute cosine for every BM25 candidate up front (needed by both fusion modes).
523+
const withCos = bm25Res.rows.map(row => {
524+
let cosScore = 0;
525525
const storedVecStr = embMap.get(row.source);
526526
if (storedVecStr) {
527527
// Parse pgvector "[x1,x2,...,xN]" string back to Float32Array
528528
const nums = storedVecStr.slice(1, -1).split(",").map(Number);
529529
cosScore = cosineSimilarity(new Float32Array(nums), qEmbed.vector);
530530
}
531-
const hybrid = Config.W_BM25 * bm25Norm + Config.W_COSINE * cosScore + blBoost(row.source);
531+
return { row, cosScore };
532+
});
533+
534+
// Tier-2 #3: RRF fuses per-list RANK positions (scale-free); weighted fuses
535+
// normalized scores + additive backlink boost (byte-identical to v0.31.0).
536+
const useRRF = Config.RETRIEVAL_FUSION === "rrf";
537+
// BM25 rank = position in bm25Res.rows (already ORDER BY rank DESC).
538+
const bm25RankMap = new Map(bm25Res.rows.map((r, i) => [r.source, i + 1]));
539+
let cosRankMap: Map<string, number> | null = null;
540+
let blRankMap: Map<string, number> | null = null;
541+
if (useRRF) {
542+
cosRankMap = new Map([...withCos].sort((a, b) => b.cosScore - a.cosScore).map((x, i) => [x.row.source, i + 1]));
543+
if (Config.W_BACKLINK > 0 && blMap.size > 0) {
544+
blRankMap = new Map(bm25Res.rows.map(r => ({ s: r.source, w: blMap.get(r.source) ?? 0 }))
545+
.filter(x => x.w > 0).sort((a, b) => b.w - a.w).map((x, i) => [x.s, i + 1]));
546+
}
547+
}
548+
549+
const scored = withCos.map(({ row, cosScore }) => {
550+
let hybrid: number;
551+
if (useRRF) {
552+
const K = Config.RRF_K;
553+
const br = bm25RankMap.get(row.source);
554+
const cr = cosRankMap?.get(row.source);
555+
const blr = blRankMap?.get(row.source);
556+
hybrid =
557+
(br ? Config.RRF_W_BM25 / (K + br) : 0) +
558+
(cr ? Config.RRF_W_VEC / (K + cr) : 0) +
559+
(blr ? Config.RRF_W_BACKLINK / (K + blr) : 0);
560+
} else {
561+
hybrid = Config.W_BM25 * (row.rank / maxBm25) + Config.W_COSINE * cosScore + blBoost(row.source);
562+
}
532563
return { ...row, vectorScore: cosScore, hybridScore: hybrid };
533564
});
534565

0 commit comments

Comments
 (0)