From 8ce9371e56386ec9ccce003b9fda146a7db13d34 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 3 Aug 2026 18:45:20 +0100 Subject: [PATCH] fix: exempt canonical corpus chunks from length normalization and decay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical corpus recall was near-impossible: after fusion, results pass through applyLengthNormalization and applyDecayBoost/applyTimeDecay, both calibrated for conversation memories. - Corpus chunks are line-span document chunks (~3,900 chars against the 500-char anchor -> factor ~0.40). Length is a property of the chunker, not entry quality, and chunk size is already bounded by the indexer. - Corpus rows carry the source file's mtime as their timestamp (toMemoryEntry: timestamp: doc.mtimeMs), so weeks-old reference docs take the decay multiplier down to ~boostMin. Reference truth does not age like chat memory. Measured: a corpus chunk that fused at 0.917 (top FTS hit, exact-keyword floor) finished ~0.15 after both stages — below hardMinScore/minScore — so memory_search and memory_recall returned 0 results for content that is the store's #1 match on both retrieval sides. With the exemption the same query returns it correctly. Corpus ids are always 'corpus:'-prefixed (buildCorpusId), so the guard is a cheap string check with no metadata parsing. --- src/retriever.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/retriever.ts b/src/retriever.ts index 2d69c81ca..755922a8c 100644 --- a/src/retriever.ts +++ b/src/retriever.ts @@ -1634,7 +1634,11 @@ export class MemoryRetriever { const reranked = results.map((result, index) => ({ ...result, - score: clamp01(scored[index].score, result.score * 0.3), + // Corpus rows carry the source file's mtime as their timestamp; + // reference material must not decay like conversation memory. + score: (result.entry.id ?? "").startsWith("corpus:") + ? result.score + : clamp01(scored[index].score, result.score * 0.3), })); return reranked.sort((a, b) => b.score - a.score); @@ -1654,6 +1658,11 @@ export class MemoryRetriever { if (!anchor || anchor <= 0) return results; const normalized = results.map((r) => { + // Canonical corpus chunks are line-span document chunks: their length is + // a property of the chunker, not of entry quality. Normalising them by + // length double-penalises reference material (chunk size is already + // bounded by the indexer). + if ((r.entry.id ?? "").startsWith("corpus:")) return r; const charLen = r.entry.text.length; const ratio = charLen / anchor; // No penalty for entries at or below anchor length. @@ -1688,6 +1697,8 @@ export class MemoryRetriever { const now = Date.now(); const decayed = results.map((r) => { + // Reference chunks keep file mtimes — do not age them. + if ((r.entry.id ?? "").startsWith("corpus:")) return r; const ts = r.entry.timestamp && r.entry.timestamp > 0 ? r.entry.timestamp : now; const ageDays = (now - ts) / 86_400_000;