diff --git a/README.md b/README.md index 6b0333e0..8dacad8b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ npm version npm downloads CI -tests +tests license Awesome

diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index a7a5a9ae..0f34f734 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -5,6 +5,21 @@ - **品牌视觉换新:README 头图与插件列表图标**。三处 README(根、`dsh-mneme/`、`mcp/`)顶部改用 `images/mneme-banner-hero.webp`(1672×941,282 KiB)——原图 2.1 MiB 的 PNG 会让 `images/` 目录体积翻倍。根目录用相对路径,两个子目录用 raw 绝对地址:npm 详情页不解析相对图片路径,`../images/...` 在 npmjs.com 上必然裂图。 - **插件列表图标从手绘 SVG 换成位图**:`package.json` 的 `icon` 改为 `assets/icon.png`(256×256 RGBA、圆角透明、120 KiB,仍在宿主 `iconOf()` 的 256 KiB 上限内);`assets/icon.svg` 删除。列表图标不再要求与客户端内联 mark(`MNEME_MARK_D`)同形,`test/client.test.js` 的图标用例改验 PNG 魔数、IHDR 边长、位深与 RGBA 通道——宿主对坏文件是静默回落成通用插画,不报错。 +- **远程 embedding 下第一步的注入拿不到向量:预取还没回来,渲染已经同步跑完了(#683 第 1 条)**。`prefetchQueryVector` 在 `agent/inbox/claimed` 就发出去了(`src/inject.js:430`),渲染体在 `systemPrompt.context` 里同步读 `queryVectorCache`(`:555`)——本地 embedding 几毫秒,HTTP embedding 一个往返 20–200ms,于是本机看不出问题,走远程 embedding 的用户第一步恒退化为只有 BM25 词法路。上报者(#683,xueyue33)说的正是这条。 + - **不能按建议写在 `agent/pre-step` 里等**。查宿主源码(`@deepseek-ai/dsh-agent-loop` 的 `src/agent.ts:290-324` 的 `preStep()`):`:294` 先 `inbox.claim()`,`:301` 就 `await systemPrompt.assemble(...)`,`:303` 才 `dispatch.waterfall('agent/pre-step', …)`。**assemble 在 pre-step 之前**,监听器跑到时记忆文本早已渲染进消息,在那里等只能影响下一步。 + - **唯一还能改这块文本的位置是 `system-prompt/assemble`**:`@deepseek-ai/dsh-system-prompt` 的 `src/index.ts:685` 在 context 求值之后、装配结果被拿去渲染之前走异步瀑布,且 `:622` 的 `assemble(context)` 会把带 `agent` 的 `AssembleContext` 传进来(`@deepseek-ai/dsh-agent` 的 `src/dispatch.ts:176`),所以监听器拿得到 session、能重放同一个渲染体。 + - **等到就重算,等不到原样放过**:渲染体提成 `memoryContext` 常量(瀑布与 `systemPrompt.context` 共用一份实现,避免两处漂移);监听器只在「这次查询的预取还在飞」时才等,`PREFETCH_WAIT_MS = 250` 封顶、计时器经 `sleepUnref` 卸载(不吊住进程);拿到向量就把装配结果里 `memory` 条目的 `text` 换成重算的那份。没有预取、预取失败、超时三种情况一律 `return next()`——注入防线不能变成新的卡顿点。 + - **重算不会污染注入历史**:同一步本来就会求值两次(assemble + 注入侧刷新),`recordInjection` 对同一 query 是覆盖 `last.ids` 而不是追加,embedding 请求由 `inflightPrefetches` 去重。 + - **`text` 必须保持同步**:`PromptContext.text` 的类型是 `string | ((context: AssembleContext) => string)`,等待只能发生在瀑布里。测试夹具刻意复刻这条约束(先同步求值各 context,再走异步瀑布),任何把等待写进 `text` 的改法都会让用例直接挂掉。 + - **测试**:`test/issue-683-stale-vector-and-fusion.test.js` 前 4 条(等到向量并重算、没有预取不等也不重算、预取失败不重算不抛、超过上限放弃等待)。反向对照:撤掉瀑布注册后第 1 条立刻变红。 + +- **没有 embedding 的老记忆被判 0 分:词法精确命中反而输给「意思像但只有向量」的行(#683 第 2 条)**。`src/service.js` 的 `scopedSim`(`:2103-2113`)里 `0.5 * (s ?? 0)`——索引建立前入库的行没有向量,`sim.get(m.id)` 是 `undefined`,于是整条词法路白跑。上报者给的修法是 `s ?? 0.5` 中性基线。 + - **账要算清**:两条路各排一份名单,各自的第 1 名都拿到 `1/(60+1)` 的 RRF 分、归一化后各贡献 0.25;余弦只占剩下的一半权重。于是词法命中那行的融合分本来是 0.25,纯向量命中那行是 `0.5×余弦 + 0.25`——余弦只要落在 (0, 0.5) 区间,「词面撞上但没有向量」就被系统性压到下面,而这正是专有名词、issue 编号、函数名这类查询最该命中的一行。 + - **两路都缺的行仍然沉底**:`:2106` 的 `if (s === undefined && rrf === undefined) return -1;` 一字未动,中性基线不会把没有信号的行复活。 + - **测试**:同文件后 2 条(余弦 0.42 时词法命中回到首位;两路都缺的行不会被基线抬到有信号行之前)。反向对照:把 `?? 0.5` 改回 `?? 0` 后第 1 条立刻变红。 + +- 测试 3440 → **3446**。 + ## [0.14.0] - 2026-11-12 - **注入的规则池只看得到前 200 条记忆:候选池此前固定 `store.list({ limit: 200 })`,合格行 8400+ 条里 8200 条永远进不了窗(#619)**。这个窗口按 `importance DESC` 取,于是注入位实际上由「谁更重要」决定一个固定的前 200 名,而真正的排序规则(summary 优先档 → preference → importance×quality×heat×scope)只在这 200 条**内部**生效。实测本机库 9067 条活跃记忆里,按注入闸门(`injectType` / 非 narrative / importance ≥ 3)合格的有 8421 条,其中 8210 条永远进不了候选窗;窗口内 200 条的 importance 分布是**全部 5**——也就是说 summary 优先档这条规则,在最需要它的场景里根本没机会触发。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index d7a81535..834b616d 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -11,7 +11,7 @@ $ cat ~/.dsh/memory/preferences.md npm version npm downloads CI -tests +tests license Awesome

@@ -751,7 +751,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 3440 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 3446 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -760,7 +760,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 3440 个测试 +npm test # 运行 3446 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/inject.js b/dsh-mneme/lib/inject.js index 4060ee75..fabec015 100644 --- a/dsh-mneme/lib/inject.js +++ b/dsh-mneme/lib/inject.js @@ -537,12 +537,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess }) : null; - const disposers = [ - ...(guideSection ? [guideSection] : []), - ctx.systemPrompt.context({ - name: "memory", - order: 90, - text: (ctx) => { + // #683:渲染体留一份引用——assemble 瀑布要在等到向量之后用**同一份**实现重算, + // 两处各写一遍迟早漂移。 + const memoryContext = { + name: "memory", + order: 90, + text: (ctx) => { if (sessionGate?.isOff(sessionOf(ctx))) return ""; // Bug4: pass the latest user query so injection prefers semantically // relevant memories; liveStepQuery is best-effort (empty → legacy). @@ -636,7 +636,55 @@ export function createInjector(ctx, service, settings, config, gate = null, sess }; return finalBody; } - }), + }; + + // #683 第 1 条:远程 embedding 有 HTTP 往返,而记忆块是在 assemble() 里**同步** + // 求值的,claim 与 assemble 之间只夹着一次 await tools.resolve(),抢不到那趟网。 + // + // 这里一定要说清「为什么不是 agent/pre-step」:宿主 preStep 的顺序是 + // inbox.claim() → await tools.resolve() → await systemPrompt.assemble() → 最后 + // 才 await dispatch.waterfall('agent/pre-step', ...)(dsh-agent-loop 的 + // src/agent.ts:301 与 :303)。pre-step 的监听器运行时模板早就渲染完、消息也拼好 + // 了,等到的向量只能影响下一步。唯一还能改这张图纸的位置是 + // system-prompt/assemble 这条异步瀑布:它在各 context 求值之后、装配结果被拿去 + // 渲染消息之前跑,而且返回值就是权威文本(dsh-system-prompt 的 + // src/index.ts:685)。所以流程是「等 → 用同一份渲染体重算这一块 → 交给下游」。 + const PREFETCH_WAIT_MS = 250; + const sleepUnref = (ms) => new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + if (typeof timer?.unref === "function") timer.unref(); + }); + // 只在「这一帧确实因为没向量而落空、且确有一趟预取在飞」时等。正常步、工具轮、 + // 压缩、admission 刷新都命中不了这两个条件,因此这条不为它们加任何延迟。 + async function awaitStepQueryVector(promptCtx) { + let query = ""; + try { query = liveStepQuery(promptCtx); } catch { return false; } + if (!query || queryVectorCache.has(query)) return false; + const inflight = inflightPrefetches.get(query); + if (!inflight) return false; + await Promise.race([inflight.catch(() => null), sleepUnref(PREFETCH_WAIT_MS)]); + return queryVectorCache.has(query); + } + const offAssembleWait = typeof ctx?.on === "function" + ? ctx.on("system-prompt/assemble", async (assembly, promptCtx, next) => { + try { + if (await awaitStepQueryVector(promptCtx)) { + const list = Array.isArray(assembly?.contexts) ? assembly.contexts : null; + const at = list ? list.findIndex((entry) => entry?.name === "memory") : -1; + // 重算是安全的:同一步本来就会被求值两次(assemble + refreshContext), + // recordInjection 对同一查询是覆盖而不是追加(见其 last.query 分支), + // 嵌入那一趟也已由 inflight 去重,不会多打一次网络。 + if (list && at >= 0) list[at] = { ...list[at], text: memoryContext.text(promptCtx) }; + } + } catch { /* 等待是尽力而为:装配绝不能因为这份优化失败 */ } + return next(); + }) + : null; + + const disposers = [ + ...(guideSection ? [guideSection] : []), + ctx.systemPrompt.context(memoryContext), + ...(offAssembleWait ? [offAssembleWait] : []), ctx.systemPrompt.context({ name: "user-settings", order: 85, diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 6f0bc6b2..f9036cf0 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -2104,7 +2104,11 @@ export function createService({ store, mirror, config, onWrite, logger, document const s = sim.get(m.id); const rrf = fusedRrf?.get(m.id); if (s === undefined && rrf === undefined) return -1; - const fused = 0.5 * (s ?? 0) + 0.5 * (rrf ?? 0) * RRF_NORM; + // #683 第 2 条:余弦缺失时用 0.5 中性基线,不再判 0 分。老行大量没有 + // embedding(索引建立前入库的),而 0 会把「词法精确命中但没向量」的 + // 行系统性压到「意思像但只有向量」的行之下——两路融合的本来目的就是 + // 让这两类各守一段,缺失一项不该等于这一项判了负分。 + const fused = 0.5 * (s ?? 0.5) + 0.5 * (rrf ?? 0) * RRF_NORM; return fused * scopeMultOf(m); }; candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a)); diff --git a/dsh-mneme/src/inject.js b/dsh-mneme/src/inject.js index 4060ee75..fabec015 100644 --- a/dsh-mneme/src/inject.js +++ b/dsh-mneme/src/inject.js @@ -537,12 +537,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess }) : null; - const disposers = [ - ...(guideSection ? [guideSection] : []), - ctx.systemPrompt.context({ - name: "memory", - order: 90, - text: (ctx) => { + // #683:渲染体留一份引用——assemble 瀑布要在等到向量之后用**同一份**实现重算, + // 两处各写一遍迟早漂移。 + const memoryContext = { + name: "memory", + order: 90, + text: (ctx) => { if (sessionGate?.isOff(sessionOf(ctx))) return ""; // Bug4: pass the latest user query so injection prefers semantically // relevant memories; liveStepQuery is best-effort (empty → legacy). @@ -636,7 +636,55 @@ export function createInjector(ctx, service, settings, config, gate = null, sess }; return finalBody; } - }), + }; + + // #683 第 1 条:远程 embedding 有 HTTP 往返,而记忆块是在 assemble() 里**同步** + // 求值的,claim 与 assemble 之间只夹着一次 await tools.resolve(),抢不到那趟网。 + // + // 这里一定要说清「为什么不是 agent/pre-step」:宿主 preStep 的顺序是 + // inbox.claim() → await tools.resolve() → await systemPrompt.assemble() → 最后 + // 才 await dispatch.waterfall('agent/pre-step', ...)(dsh-agent-loop 的 + // src/agent.ts:301 与 :303)。pre-step 的监听器运行时模板早就渲染完、消息也拼好 + // 了,等到的向量只能影响下一步。唯一还能改这张图纸的位置是 + // system-prompt/assemble 这条异步瀑布:它在各 context 求值之后、装配结果被拿去 + // 渲染消息之前跑,而且返回值就是权威文本(dsh-system-prompt 的 + // src/index.ts:685)。所以流程是「等 → 用同一份渲染体重算这一块 → 交给下游」。 + const PREFETCH_WAIT_MS = 250; + const sleepUnref = (ms) => new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + if (typeof timer?.unref === "function") timer.unref(); + }); + // 只在「这一帧确实因为没向量而落空、且确有一趟预取在飞」时等。正常步、工具轮、 + // 压缩、admission 刷新都命中不了这两个条件,因此这条不为它们加任何延迟。 + async function awaitStepQueryVector(promptCtx) { + let query = ""; + try { query = liveStepQuery(promptCtx); } catch { return false; } + if (!query || queryVectorCache.has(query)) return false; + const inflight = inflightPrefetches.get(query); + if (!inflight) return false; + await Promise.race([inflight.catch(() => null), sleepUnref(PREFETCH_WAIT_MS)]); + return queryVectorCache.has(query); + } + const offAssembleWait = typeof ctx?.on === "function" + ? ctx.on("system-prompt/assemble", async (assembly, promptCtx, next) => { + try { + if (await awaitStepQueryVector(promptCtx)) { + const list = Array.isArray(assembly?.contexts) ? assembly.contexts : null; + const at = list ? list.findIndex((entry) => entry?.name === "memory") : -1; + // 重算是安全的:同一步本来就会被求值两次(assemble + refreshContext), + // recordInjection 对同一查询是覆盖而不是追加(见其 last.query 分支), + // 嵌入那一趟也已由 inflight 去重,不会多打一次网络。 + if (list && at >= 0) list[at] = { ...list[at], text: memoryContext.text(promptCtx) }; + } + } catch { /* 等待是尽力而为:装配绝不能因为这份优化失败 */ } + return next(); + }) + : null; + + const disposers = [ + ...(guideSection ? [guideSection] : []), + ctx.systemPrompt.context(memoryContext), + ...(offAssembleWait ? [offAssembleWait] : []), ctx.systemPrompt.context({ name: "user-settings", order: 85, diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 6f0bc6b2..f9036cf0 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -2104,7 +2104,11 @@ export function createService({ store, mirror, config, onWrite, logger, document const s = sim.get(m.id); const rrf = fusedRrf?.get(m.id); if (s === undefined && rrf === undefined) return -1; - const fused = 0.5 * (s ?? 0) + 0.5 * (rrf ?? 0) * RRF_NORM; + // #683 第 2 条:余弦缺失时用 0.5 中性基线,不再判 0 分。老行大量没有 + // embedding(索引建立前入库的),而 0 会把「词法精确命中但没向量」的 + // 行系统性压到「意思像但只有向量」的行之下——两路融合的本来目的就是 + // 让这两类各守一段,缺失一项不该等于这一项判了负分。 + const fused = 0.5 * (s ?? 0.5) + 0.5 * (rrf ?? 0) * RRF_NORM; return fused * scopeMultOf(m); }; candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a)); diff --git a/dsh-mneme/test/issue-683-stale-vector-and-fusion.test.js b/dsh-mneme/test/issue-683-stale-vector-and-fusion.test.js new file mode 100644 index 00000000..916a59ad --- /dev/null +++ b/dsh-mneme/test/issue-683-stale-vector-and-fusion.test.js @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createInjector } from "../src/inject.js"; +import { createSettings } from "../src/settings.js"; +import { createVectorIndex } from "../src/vector-index.js"; + +// #683 回归。两处都来自 #620 评论区承诺过、但没合进 0.14.0 的后续: +// +// ① 预取向量在被用掉之前没人等它。这里要顺带钉住一个**结论性事实**:宿主的 +// preStep 是 claim → assemble → 才派发 agent/pre-step(dsh-agent-loop 的 +// src/agent.ts:301 与 :303),所以在 agent/pre-step 里等是等不到的,唯一还能 +// 改装配结果的位置是 system-prompt/assemble 这条异步瀑布。 +// ② 余弦缺失时被当成 0 分,把「词法精确命中但没有 embedding」的老行系统性压到 +// 「意思像但只有向量」的行之下。 + +const userMessage = (text) => ({ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text }] } }); +const claimed = (text) => ({ content: [{ type: "text", text }], source: { kind: "user" }, id: "m1" }); +const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms)); + +// --- 组 1:装配前等在飞的预取 ------------------------------------------------ +// +// 夹具刻意保留宿主的两条真实约束:context 的 text 是**同步**的(等不了), +// assemble 瀑布是**异步**的(唯一能等的地方)。任何把等待写进 text 的改法都会 +// 让这里同步返回、断言直接挂掉。 +function setupAssembly(embedImpl, over = {}) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const settings = createSettings(store.db); + const contexts = []; + const handlers = new Map(); + const ctx = { + on(event, fn) { + handlers.set(event, fn); + return () => handlers.delete(event); + }, + systemPrompt: { + context(def) { contexts.push(def); return () => {}; } + } + }; + // 查询向量是这条链路唯一能被直接观测的产物:记下每次 injectCandidates 拿到什么。 + const renders = []; + const orig = service.injectCandidates.bind(service); + service.injectCandidates = (args) => { + renders.push({ query: args.query, queryVector: args.queryVector }); + return orig(args); + }; + const calls = []; + service.setEmbedder({ + embedSingle: async (text) => { + calls.push(text); + return embedImpl(text); + } + }); + const injector = createInjector(ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3, ...over }); + const memoryDef = contexts.find((def) => def.name === "memory"); + const fireClaim = (message, session) => handlers.get("agent/inbox/claimed")?.({ message, agent: { session } }); + // 复刻宿主的一次装配:先同步求值各 context,再走异步瀑布。 + const assemble = async (session) => { + const promptCtx = { agent: { session } }; + const assembly = { + contexts: contexts.map((def) => ({ name: def.name, text: def.text(promptCtx) })) + }; + const waterfall = handlers.get("system-prompt/assemble"); + if (waterfall) await waterfall(assembly, promptCtx, () => Promise.resolve(assembly)); + return assembly; + }; + return { store, service, contexts, injector, calls, renders, fireClaim, handlers, memoryDef, assemble }; +} + +const session = (id) => ({ id, events: [], snapshotEvents() { return this.events; } }); + +test("#683-1: 装配时若预取还在飞,等它并重算记忆块(远程 embedding 的 Step 1)", async () => { + const { renders, fireClaim, assemble } = setupAssembly(async () => { + await tick(20); // 模拟远程 HTTP 往返 + return [1, 0, 0]; + }); + const s = session("w1"); + fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), s); + const assembly = await assemble(s); + + const first = renders[0]; + assert.equal(first.query, "夜航App令牌桶限流方案怎么设计", "Step 1 的查询文本由认领票据补齐"); + assert.equal(first.queryVector, undefined, "夹具前提:第一次求值时向量还没回来"); + assert.equal(renders.length, 2, "等到了向量就要重算一次,不能带着空向量交差"); + assert.deepEqual(renders[1].queryVector, [1, 0, 0], "重算必须带着刚回来的向量"); + const memory = assembly.contexts.find((entry) => entry.name === "memory"); + assert.ok(memory, "重算的落点是装配结果里的记忆块"); +}); + +test("#683-1: 没有在飞的预取时,装配不等(不拖慢正常步)", async () => { + const { renders, assemble } = setupAssembly(async () => [1, 0, 0]); + const s = session("w2"); + const started = Date.now(); + await assemble(s); + const elapsed = Date.now() - started; + assert.ok(elapsed < 100, `没有预取就不该有等待,实测 ${elapsed}ms`); + assert.equal(renders.length, 1, "没等到向量就不该重算"); +}); + +test("#683-1: 预取失败时装配照常返回,不重算也不抛", async () => { + const { renders, fireClaim, assemble } = setupAssembly(async () => { + await tick(5); + throw new Error("embedding 端点不通"); + }); + const s = session("w3"); + fireClaim(claimed("换一条查询看失败路径"), s); + const assembly = await assemble(s); + assert.equal(renders.length, 1, "拿不到向量就没有重算的理由"); + assert.ok(assembly.contexts.some((entry) => entry.name === "memory"), "记忆块本身仍在装配结果里"); +}); + +test("#683-1: 预取超过等待上限时放弃等待,装配不被卡住", async () => { + const { renders, fireClaim, assemble } = setupAssembly(async () => { + await tick(900); // 比 PREFETCH_WAIT_MS(250ms)长得多 + return [1, 0, 0]; + }); + const s = session("w4"); + fireClaim(claimed("这条路会超时的查询"), s); + const started = Date.now(); + await assemble(s); + const elapsed = Date.now() - started; + assert.ok(elapsed < 700, `等待必须有上限,实测 ${elapsed}ms`); + assert.equal(renders.length, 1, "没等到就别重算"); +}); + +// --- 组 2:余弦缺失的中性基线 ------------------------------------------------- +// +// 两把尺子:同一个库、同一条查询,只改「词法命中的那行有没有向量」。 +// 两条路各排出一份名单,两边的第 1 名都拿到 1/(60+1) 的 RRF 分,归一化后各贡献 +// 0.25;余弦只占剩下的一半权重。于是词法命中那行的融合分是 +// 0.5×(余弦或基线) + 0.25 +// 旧行为把它当 0 分(0.25),新行为给中性基线(0.5),而纯向量命中的那行由 +// 0.5×余弦 + 0.25 决定——余弦落在 (0, 0.5) 区间时,两者正好换位。 +function seedFusionPair(service) { + const vecOnly = service.saveWithDedupe({ + type: "project", title: "星图渲染重构", content: "绘制管线迁移与图层合并", importance: 3 + }).memory; + const lexHit = service.saveWithDedupe({ + type: "decision", title: "夜航限流选型", content: "决定采用令牌桶做夜航App的限流方案", importance: 3 + }).memory; + return { vecOnly, lexHit }; +} + +// 余弦 0.42:旧行为下词法命中 0.25 < 纯向量 0.46;新行为下 0.5 > 0.46。 +const VEC_ONLY_SIM = 0.42; +const vecOnlyEmbedding = () => [VEC_ONLY_SIM, Math.sqrt(1 - VEC_ONLY_SIM ** 2), 0]; + +test("#683-2: 余弦缺失按中性基线算,词法精确命中不再被判 0 分", () => { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const { vecOnly, lexHit } = seedFusionPair(service); + const vectorIndex = createVectorIndex({ store }); + service.setVectorIndex(vectorIndex); + vectorIndex.saveEmbedding(vecOnly.id, vecOnlyEmbedding()); + + const picked = service.injectCandidates({ + query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3, queryVector: [1, 0, 0] + }); + assert.equal(picked[0].id, lexHit.id, "没有 embedding 不是「这一路零分」,不该把词法精确命中压下去"); + assert.ok(picked.some((m) => m.id === vecOnly.id), "纯向量命中仍在池内,融合不是二选一"); + store.close(); +}); + +test("#683-2: 两路都缺的候选仍沉底(中性基线不得复活无信号行)", () => { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const { vecOnly, lexHit } = seedFusionPair(service); + const noise = service.saveWithDedupe({ + type: "project", title: "无关存档", content: "与这次查询既不同词也不同义的一段记录", importance: 5 + }).memory; + const vectorIndex = createVectorIndex({ store }); + service.setVectorIndex(vectorIndex); + vectorIndex.saveEmbedding(vecOnly.id, vecOnlyEmbedding()); + + const picked = service.injectCandidates({ + query: "夜航App令牌桶限流方案怎么设计的", maxItems: 3, threshold: 3, queryVector: [1, 0, 0] + }); + const order = picked.map((m) => m.id); + assert.ok(order.includes(lexHit.id), "词法命中在池内"); + assert.ok(order.includes(vecOnly.id), "向量命中在池内"); + assert.ok(!order.includes(noise.id) || order.indexOf(noise.id) > order.indexOf(lexHit.id), + "两路都没有的行不能被中性基线抬到有信号的行之前"); + store.close(); +}); \ No newline at end of file