From 5ee3dba35d634dccfc4ef3479eb755b0174f752c Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Fri, 8 May 2026 14:23:40 +0800 Subject: [PATCH] fix: apply per-tier topK truncation in searchMemory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retrieval ranker's limit was the SUM of per-tier topK values (e.g. topK={tier1:3,tier2:3,tier3:3} → limit=9), allowing merged results to exceed the caller's expected total. Now searchMemory enforces per-tier caps before returning hits, so topK=3 per tier returns at most 3 results per tier instead of up to 9 total. Fixes [BUG-2026-05-08] skill-type overflow in search results. Co-authored-by: Cursor --- .../core/pipeline/memory-core.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 9a7e7858..b3cefc7d 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -1845,7 +1845,7 @@ export function createMemoryCore( contextHints: query.filters ?? {}, ts, }); - const hits: RetrievalHitDTO[] = result.packet.snippets.map((snip) => ({ + let hits: RetrievalHitDTO[] = result.packet.snippets.map((snip) => ({ tier: inferTier(snip.refKind), refId: snip.refId, refKind: @@ -1855,6 +1855,27 @@ export function createMemoryCore( score: snip.score ?? 0, snippet: snip.body, })); + + // Per-tier truncation: the retrieval pipeline's ranker limit is + // the SUM of per-tier topK, but callers expect each tier's topK + // to cap that tier's contribution. Without this, merged results + // can exceed the caller's expected total. + if (query.topK) { + const tierCaps: Partial> = {}; + if (query.topK.tier1 !== undefined) tierCaps[1] = query.topK.tier1; + if (query.topK.tier2 !== undefined) tierCaps[2] = query.topK.tier2; + if (query.topK.tier3 !== undefined) tierCaps[3] = query.topK.tier3; + const tierCounts: Record = {}; + hits = hits.filter((h) => { + const cap = tierCaps[h.tier]; + if (cap === undefined) return true; + const count = tierCounts[h.tier] ?? 0; + if (count >= cap) return false; + tierCounts[h.tier] = count + 1; + return true; + }); + } + // Build the logs-page payload BEFORE returning so the row // reflects the exact shape the adapter sees. `candidates` lists // everything tiered/retrieved; `filtered` is what the injector