From 2043eb967181380e5f7cad0d1a5ea12a03caaa45 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 3 Aug 2026 16:55:02 +0100 Subject: [PATCH] fix: apply fusion weighting uniformly and recalibrate the keyword floor (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fuseResults() scored candidates differently by branch: candidates with a vector hit got clamp01(max(v*vectorWeight + b*bm25Weight, keywordFloor)), while BM25-only candidates got their normalised BM25 score directly — bypassing bm25Weight entirely. Since store.bm25Search sigmoid-normalises raw BM25 (floor 0.5 by construction), any keyword-only hit outranked semantic candidates capped at vectorWeight, regardless of configured weights. Score every candidate with the same weighted formula, and keep the exact-keyword floor on all branches — recalibrated to the sigmoid: the old 0.75 threshold corresponds to raw BM25 ~5.5, which nearly every FTS hit clears (the floor value b*0.92 >= 0.69 then outranks any vector hit capped at vectorWeight, quietly re-creating the bypass). 0.95 maps to raw ~14.7 — genuinely exceptional matches like identifiers and exact strings, preserving the floor's documented purpose of keeping those above minScore. Observed on a 7,253-row store: for queries with no relevant corpus content, unrelated keyword-only hits (normalised 0.92) were returned at floored confidence 0.85, above every semantic candidate. With this change they score b*bm25Weight (~0.28, below the default minScore) and are correctly suppressed, while on-topic queries are unaffected. --- src/retriever.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/retriever.ts b/src/retriever.ts index 2d69c81ca..f91fa6975 100644 --- a/src/retriever.ts +++ b/src/retriever.ts @@ -1347,20 +1347,25 @@ export class MemoryRetriever { // BM25 hit acts as a bonus (keyword match confirms relevance). const vectorScore = vectorResult ? vectorResult.score : 0; const bm25Score = bm25Result ? bm25Result.score : 0; - // Weighted fusion: vectorWeight/bm25Weight directly control score blending. - // BM25 high-score floor (>= 0.75) preserves exact keyword matches - // (e.g. API keys, ticket numbers) that may have low vector similarity. + // Weighted fusion: vectorWeight/bm25Weight directly control score blending, + // applied identically to every candidate. Previously the BM25-only branch + // used the normalised BM25 score directly, bypassing bm25Weight — so a + // keyword-only candidate kept its full sigmoid-normalised score (floor 0.5 + // by construction) while a semantic-only candidate was capped at + // vectorWeight, and keyword noise systematically outranked strong semantic + // matches regardless of configured weights (#978). + // + // The exact-keyword floor preserves identifier-like matches (API keys, + // ticket numbers) above minScore even with weak vector similarity. Its + // threshold is calibrated to the sigmoid normalisation in + // store.bm25Search (1/(1+e^(-raw/5))): the old 0.75 threshold maps to a + // raw BM25 of ~5.5, which almost every FTS hit clears — making the floor + // re-create the bypass it was meant to bound. 0.95 maps to raw ~14.7, + // i.e. genuinely exceptional keyword matches. const weightedFusion = (vectorScore * this.config.vectorWeight) + (bm25Score * this.config.bm25Weight); - const fusedScore = vectorResult - ? clamp01( - Math.max( - weightedFusion, - bm25Score >= 0.75 ? bm25Score * 0.92 : 0, - ), - 0.1, - ) - : clamp01(bm25Result!.score, 0.1); + const keywordFloor = bm25Score >= 0.95 ? bm25Score * 0.92 : 0; + const fusedScore = clamp01(Math.max(weightedFusion, keywordFloor), 0.1); fusedResults.push({ entry: baseResult.entry,