diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 5ad51e2..43c7bed 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -535,10 +535,26 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz" | `PUT` | `/memories/:id` | 局部更新 `{title?,content?,type?,importance?,tags?,reason?,agent_scope?,workspace_scope?}`;content 改写按 human_override 入档 | | `DELETE` | `/memories/:id` | 删除记忆 | | `GET` | `/search?q&mode=keyword\|vector\|hybrid\|auto&topK&occurred_from&occurred_to&agent_scope&workspace_scope` | 搜索(关键词 / 向量 / hybrid=向量领位关键词补位 / 自动)。scope 两参声明调用方身份(不传 = 不解析,行为与旧版一致;任一给出则 strictScope 硬过滤与 A2 软加权随之生效) | -| `GET` | `/context?q&topK&threshold&agent_scope&workspace_scope` | 注入候选一站式聚合(issue #370,#363 承诺):宿主注入管线 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则,返回 `{profile, rules, items, pinnedCount}` 结构化条目,不含渲染文本。`threshold` 是 **importance 阈值**(默认 3,与 `/search` 的相似度阈值同名不同义);`topK` 默认 5(对齐宿主 `maxInjectedItems`);scope 语义与 `/search` 同口径;跨轮轮换 / 热记忆 / 时间前缀不含(调用方自管);recall_runs 注入分账与宿主注入同口径(#217 `mode='inject'`) | +| `GET` | `/context?q&topK&threshold&agent_scope&workspace_scope` | 注入候选一站式聚合(issue #370,#363 承诺):宿主注入管线 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则,返回 `{profile, rules, items, pinnedCount, tier}` 结构化条目,不含渲染文本。`threshold` 是 **importance 阈值**(默认 3,与 `/search` 的相似度阈值同名不同义);`topK` 默认 5(对齐宿主 `maxInjectedItems`);scope 语义与 `/search` 同口径;跨轮轮换 / 热记忆 / 时间前缀不含(调用方自管);recall_runs 注入分账与宿主注入同口径(#217 `mode='inject'`) | | `POST` | `/bootstrap` | 冷启动(v0.8.4):从仓库目录反向构建初始记忆,body `{dir}` 必填;幂等((type,title,scope) 去重 + `_overwrite` 原地刷新),零 LLM、必有产出 | | `POST` | `/maintenance/reclaim` | 无损回收(#275 / #411,与面板同口径):**只有 POST**(独立面没有 GET 预估分支)。字段只从 JSON body 读;`dryRun` 默认 `true`,只有 `dryRun:false` 且 `confirm:true` 才真删;`vacuum` 只允许显式指定 | +#### `/context` 的 `tier` 档位信号(#383) + +`GET /context` 的响应多一个 `tier` 字段,回答「这次注入跑在哪一档」。嵌入降级在调用方视角原本完全静默——`items` 看着正常,只是召回质量悄悄变差,外部集成方无从感知自己长期跑在降级档上。 + +| `tier` | 含义 | +|--------|------| +| `full` | 语义档:查询嵌入成功,且与库里的向量维度可比 | +| `degraded` | 规则档:语义路本次不可用(原因见日志) | +| `unknown` | 语义路未启用(`q` 为空)——那不是降级,但也不宣称 `full` | + +**两套消费者的分工:** `tier` 是**响应读数**,给调用方——三态、fail-closed(判不出来报 `unknown`,不报 `full`),用于在集成侧告警或切换策略。降级的**具体原因**是**日志读数**,给运维——走 daemon 的 stderr(`dsh-mneme: /context degraded to the rule tier ()`),不进响应体。原因枚举五值,互不吞并:`embed-unavailable`(无可用嵌入接口)/ `embed-empty`(返回了空或非数组向量)/ `embed-timeout`(接口挂起,被超时护栏按缺失处理)/ `embed-error`(接口抛错)/ `embed-dimension`(接口成功了,但维度与库里已有向量不一致)。 + +`embed-dimension` 值得单说:它是「拿到了向量却不可比」——`searchVector` 对维度不符的行直接跳过,不抛错、只是命中为零,于是召回静默退回关键词/规则档,而嵌入侧一切正常。修法是重建索引,不是查端点,所以它必须和前四个分开。 + +**预算:** 判档不是免费的。`q` 非空且装配了 embedder 时,一次 `/context` 真的会起一次查询嵌入(一次网络往返 + `embedTimeoutMs` 的超时窗口)——档位要回答的正是「这次嵌入成不成」。这里没有复用 `/health` 的数据源:standalone 的 `/health` 只回 `{ok:true}`,而 `embeddingDimensionBreakdown` 是宿主面的一次全表扫(每行 JSON.parse 一个向量),按请求跑会把语义路成本翻倍。维度探针因此走 `store.storedEmbeddingDimension()`——只读一行,O(1)。 + ### curl 示例 ```bash diff --git a/dsh-mneme/docs/DAEMON.md b/dsh-mneme/docs/DAEMON.md index fe90ec6..59fc0d2 100644 --- a/dsh-mneme/docs/DAEMON.md +++ b/dsh-mneme/docs/DAEMON.md @@ -15,6 +15,8 @@ daemon 是**数据面**,不是第二个宿主: 路由面 = `src/api-standalone.js` 全表(health/status/profile/rules/memories 读写/search/context/maintenance/bootstrap),鉴权同源(Bearer + timingSafeEqual,`GET /health` 免鉴权)。daemon 自身零新路由;#370 的 `/context` 落在 api-standalone 工厂里,宿主外部访问与 daemon 自动同享(参数、scope 语义与 recall_runs 注入分账见包 README 路由表)。daemon 侧唯一自有选项是 `strictPort`(见 §4)。 +`/context` 的档位信号(#383):响应多一个 `tier`(`full` 语义档 / `degraded` 规则档 / `unknown` 语义路未启用,fail-closed),**具体降级原因只在 stderr**——`dsh-mneme: /context degraded to the rule tier ()`,不进响应体。原因是给运维的读数(`embed-unavailable` 无接口 / `embed-empty` 空向量 / `embed-timeout` 挂起 / `embed-error` 抛错 / `embed-dimension` 维度不符),`tier` 是给调用方的读数,两套消费者分开。这里最值得盯的是 `embed-dimension`:嵌入接口一切正常,但库里是旧维度向量,`searchVector` 直接跳过、不抛错,召回静默退回关键词档——修法是重建索引,不是查端点。判档有成本:`q` 非空且装了 embedder 时一次 `/context` 会真起一次查询嵌入(一次往返 + `embedTimeoutMs` 窗口),维度探针本身是 O(1)(只读一行),不复用 `/health`(它只回 `{ok:true}`)也不做全表扫。 + CLI: ```bash diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js index 267f33a..9b12ebc 100644 --- a/dsh-mneme/lib/api-standalone.js +++ b/dsh-mneme/lib/api-standalone.js @@ -226,16 +226,34 @@ async function handlePutBody(res, service, logger, id, text) { * /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款 * (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。 * 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。 + * + * `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举 + * 降级原因。五值,互不吞并: + * embed-unavailable 无可用接口(embedder 上没有 embedSingle / embed) + * embed-empty 接口返回了,但向量是空数组 / 非数组 + * embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 + * embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……) + * embed-dimension 接口成功了,但维度与库里已有向量不一致 + * 前四个是「没拿到向量」,第五个是「拿到了却不可比」——分开是因为修法不同: + * 前者查嵌入配置与端点,后者查「是不是换了模型、旧向量该重建索引了」。 + * 不传则行为与既往逐字节一致。 */ -async function embedQueryVector(embedder, q) { +async function embedQueryVector(embedder, q, diag) { const embedSingle = typeof embedder.embedSingle === "function" ? embedder.embedSingle.bind(embedder) : typeof embedder.embed === "function" ? embedder.embed.bind(embedder) : null; - if (!embedSingle) return undefined; + if (!embedSingle) { + if (diag) diag.reason = "embed-unavailable"; + return undefined; + } const vector = await embedSingle(q); - return Array.isArray(vector) && vector.length ? vector : undefined; + if (!Array.isArray(vector) || !vector.length) { + if (diag) diag.reason = "embed-empty"; + return undefined; + } + return vector; } /** @@ -244,11 +262,16 @@ async function embedQueryVector(embedder, q) { * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃, * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器 * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。 + * + * `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举。 */ -function withEmbedTimeout(promise, timeoutMs) { +function withEmbedTimeout(promise, timeoutMs, diag) { let timer; const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), timeoutMs); + timer = setTimeout(() => { + if (diag) diag.reason = "embed-timeout"; + resolve(undefined); + }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } @@ -671,16 +694,59 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3); const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3; const scope = scopeFromSearchParams(url); - void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined) - .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径) + // #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到 + // 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符) + // 在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个键,不加第二个字段。 + const diag = {}; + void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined) + .catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径) .then((queryVector) => { const pinnedStats = {}; const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats }); + // #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是 + // 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。 + // 三态 fail-closed——判不出来报 unknown,不报 full: + // full 语义档,查询嵌入真的成功了且与库里的向量可比 + // degraded 规则档:embedder 缺 / 嵌入超时 / 不可用向量 / 抛错 / + // 维度不符(拿到了向量但语义召回整个不可用) + // unknown 语义路未启用(空 q)——那不是降级,但也不能宣称 full + // 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报 + // keyword,这里能分开(空 q 直接可观测)。 + // + // 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询 + // 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的 + // 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源: + // standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown + // 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会 + // 把语义路成本翻倍,且它根本不在 standalone 的装配里。所以维度探针用 + // store.storedEmbeddingDimension()——只读一行,O(1)。 + let tier = "unknown"; + if (q && queryVector) { + const storedDim = typeof service.storedEmbeddingDimension === "function" + ? service.storedEmbeddingDimension() + : null; + if (typeof storedDim === "number" && storedDim !== queryVector.length) { + // 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的 + // distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默 + // 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种 + // 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。 + diag.reason = "embed-dimension"; + tier = "degraded"; + } else { + tier = "full"; + } + } else if (q) { + tier = "degraded"; + } + if (tier === "degraded") { + logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${diag.reason ?? (embedder ? "embed-failed" : "no-embedder")})`); + } sendJson(res, 200, { profile: (settings?.getProfile?.() ?? "").trim(), rules: settings?.getRules?.() ?? [], items: service.toApiList(items), - pinnedCount: pinnedStats.shown ?? 0 + pinnedCount: pinnedStats.shown ?? 0, + tier }); }) .catch(() => { diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 94b264a..5d6644b 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -2670,6 +2670,10 @@ export function createService({ store, mirror, config, onWrite, logger, document // passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。 embeddingModelBreakdown: () => store.embeddingModelBreakdown(), embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(), + // #383:O(1) 的维度探针(读一行,不扫表),供 /context 判「维度不符」。 + // 与上面那个全表扫的 breakdown 是两件事:一个是状态页的完整分布,一个是 + // 每请求用得起的布尔信号。透传而非让 api 层摸 store,与同组其他条目一致。 + storedEmbeddingDimension: () => store.storedEmbeddingDimension(), list: (o) => store.list(o), all: () => store.all(), count: (type, opts) => store.count(type, opts), diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 45aca17..e94f866 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -1801,6 +1801,36 @@ export function createStore(path) { }, new Map()); } + /** + * 库里已有向量的维度——**只读一行**,不是 embeddingDimensionBreakdown 的全表扫。 + * + * #383 要的是「查询嵌入成功了,但语义召回其实死了」这个读数。它的成因是维度 + * 不符:searchVector 对 `v.length !== vector.length` 的行直接跳过(见下方 + * distill-tx-embed-gap 注释),换了嵌入模型之后旧维度向量整个不可比,而这条路 + * **不抛错**——嵌入侧、检索侧都一切正常,只是命中数为零。调用方从响应上看不出 + * 任何异常,这正是 issue #383 点名的那一种「悄悄退化」。 + * + * 为什么不用 embeddingDimensionBreakdown:那是给 /semantic 状态页用的全表扫 + * (每行 JSON.parse 一个向量),按请求跑一遍会把语义路的成本翻一倍。这里只要 + * 一个「不一致」的布尔信号,一行足够;混合维度的完整分布仍归状态页。 + * + * @returns {number|null} 首个可解析向量的维度;库里没有可用向量时 null + * (那不是维度不符,是没有东西可比——两种空的读数必须分开)。 + */ + function storedEmbeddingDimension() { + const row = db.prepare( + `SELECT embedding FROM memories + WHERE embedding IS NOT NULL AND embedding != '' + AND forgotten = 0 AND archived = 0 + LIMIT 1` + ).get(); + if (!row) return null; + try { + const parsed = JSON.parse(row.embedding); + return Array.isArray(parsed) && parsed.length ? parsed.length : null; + } catch { return null; } + } + function search(query, { limit = 20, includeArchived = false } = {}) { const q = String(query).trim(); if (!q) return []; @@ -3367,6 +3397,7 @@ export function createStore(path) { hasStaleEmbedding, embeddingModelBreakdown, embeddingDimensionBreakdown, + storedEmbeddingDimension, searchVector, saveDreamRun, lastDreamRunAt, diff --git a/dsh-mneme/src/api-standalone.js b/dsh-mneme/src/api-standalone.js index 267f33a..9b12ebc 100644 --- a/dsh-mneme/src/api-standalone.js +++ b/dsh-mneme/src/api-standalone.js @@ -226,16 +226,34 @@ async function handlePutBody(res, service, logger, id, text) { * /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款 * (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。 * 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。 + * + * `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举 + * 降级原因。五值,互不吞并: + * embed-unavailable 无可用接口(embedder 上没有 embedSingle / embed) + * embed-empty 接口返回了,但向量是空数组 / 非数组 + * embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 + * embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……) + * embed-dimension 接口成功了,但维度与库里已有向量不一致 + * 前四个是「没拿到向量」,第五个是「拿到了却不可比」——分开是因为修法不同: + * 前者查嵌入配置与端点,后者查「是不是换了模型、旧向量该重建索引了」。 + * 不传则行为与既往逐字节一致。 */ -async function embedQueryVector(embedder, q) { +async function embedQueryVector(embedder, q, diag) { const embedSingle = typeof embedder.embedSingle === "function" ? embedder.embedSingle.bind(embedder) : typeof embedder.embed === "function" ? embedder.embed.bind(embedder) : null; - if (!embedSingle) return undefined; + if (!embedSingle) { + if (diag) diag.reason = "embed-unavailable"; + return undefined; + } const vector = await embedSingle(q); - return Array.isArray(vector) && vector.length ? vector : undefined; + if (!Array.isArray(vector) || !vector.length) { + if (diag) diag.reason = "embed-empty"; + return undefined; + } + return vector; } /** @@ -244,11 +262,16 @@ async function embedQueryVector(embedder, q) { * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃, * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器 * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。 + * + * `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举。 */ -function withEmbedTimeout(promise, timeoutMs) { +function withEmbedTimeout(promise, timeoutMs, diag) { let timer; const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), timeoutMs); + timer = setTimeout(() => { + if (diag) diag.reason = "embed-timeout"; + resolve(undefined); + }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } @@ -671,16 +694,59 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3); const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3; const scope = scopeFromSearchParams(url); - void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined) - .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径) + // #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到 + // 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符) + // 在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个键,不加第二个字段。 + const diag = {}; + void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined) + .catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径) .then((queryVector) => { const pinnedStats = {}; const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats }); + // #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是 + // 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。 + // 三态 fail-closed——判不出来报 unknown,不报 full: + // full 语义档,查询嵌入真的成功了且与库里的向量可比 + // degraded 规则档:embedder 缺 / 嵌入超时 / 不可用向量 / 抛错 / + // 维度不符(拿到了向量但语义召回整个不可用) + // unknown 语义路未启用(空 q)——那不是降级,但也不能宣称 full + // 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报 + // keyword,这里能分开(空 q 直接可观测)。 + // + // 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询 + // 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的 + // 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源: + // standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown + // 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会 + // 把语义路成本翻倍,且它根本不在 standalone 的装配里。所以维度探针用 + // store.storedEmbeddingDimension()——只读一行,O(1)。 + let tier = "unknown"; + if (q && queryVector) { + const storedDim = typeof service.storedEmbeddingDimension === "function" + ? service.storedEmbeddingDimension() + : null; + if (typeof storedDim === "number" && storedDim !== queryVector.length) { + // 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的 + // distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默 + // 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种 + // 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。 + diag.reason = "embed-dimension"; + tier = "degraded"; + } else { + tier = "full"; + } + } else if (q) { + tier = "degraded"; + } + if (tier === "degraded") { + logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${diag.reason ?? (embedder ? "embed-failed" : "no-embedder")})`); + } sendJson(res, 200, { profile: (settings?.getProfile?.() ?? "").trim(), rules: settings?.getRules?.() ?? [], items: service.toApiList(items), - pinnedCount: pinnedStats.shown ?? 0 + pinnedCount: pinnedStats.shown ?? 0, + tier }); }) .catch(() => { diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 94b264a..5d6644b 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -2670,6 +2670,10 @@ export function createService({ store, mirror, config, onWrite, logger, document // passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。 embeddingModelBreakdown: () => store.embeddingModelBreakdown(), embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(), + // #383:O(1) 的维度探针(读一行,不扫表),供 /context 判「维度不符」。 + // 与上面那个全表扫的 breakdown 是两件事:一个是状态页的完整分布,一个是 + // 每请求用得起的布尔信号。透传而非让 api 层摸 store,与同组其他条目一致。 + storedEmbeddingDimension: () => store.storedEmbeddingDimension(), list: (o) => store.list(o), all: () => store.all(), count: (type, opts) => store.count(type, opts), diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 45aca17..e94f866 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -1801,6 +1801,36 @@ export function createStore(path) { }, new Map()); } + /** + * 库里已有向量的维度——**只读一行**,不是 embeddingDimensionBreakdown 的全表扫。 + * + * #383 要的是「查询嵌入成功了,但语义召回其实死了」这个读数。它的成因是维度 + * 不符:searchVector 对 `v.length !== vector.length` 的行直接跳过(见下方 + * distill-tx-embed-gap 注释),换了嵌入模型之后旧维度向量整个不可比,而这条路 + * **不抛错**——嵌入侧、检索侧都一切正常,只是命中数为零。调用方从响应上看不出 + * 任何异常,这正是 issue #383 点名的那一种「悄悄退化」。 + * + * 为什么不用 embeddingDimensionBreakdown:那是给 /semantic 状态页用的全表扫 + * (每行 JSON.parse 一个向量),按请求跑一遍会把语义路的成本翻一倍。这里只要 + * 一个「不一致」的布尔信号,一行足够;混合维度的完整分布仍归状态页。 + * + * @returns {number|null} 首个可解析向量的维度;库里没有可用向量时 null + * (那不是维度不符,是没有东西可比——两种空的读数必须分开)。 + */ + function storedEmbeddingDimension() { + const row = db.prepare( + `SELECT embedding FROM memories + WHERE embedding IS NOT NULL AND embedding != '' + AND forgotten = 0 AND archived = 0 + LIMIT 1` + ).get(); + if (!row) return null; + try { + const parsed = JSON.parse(row.embedding); + return Array.isArray(parsed) && parsed.length ? parsed.length : null; + } catch { return null; } + } + function search(query, { limit = 20, includeArchived = false } = {}) { const q = String(query).trim(); if (!q) return []; @@ -3367,6 +3397,7 @@ export function createStore(path) { hasStaleEmbedding, embeddingModelBreakdown, embeddingDimensionBreakdown, + storedEmbeddingDimension, searchVector, saveDreamRun, lastDreamRunAt, diff --git a/dsh-mneme/test/standalone-api.test.js b/dsh-mneme/test/standalone-api.test.js index 3a419d0..c6047ef 100644 --- a/dsh-mneme/test/standalone-api.test.js +++ b/dsh-mneme/test/standalone-api.test.js @@ -588,8 +588,11 @@ test("GET /context returns the injection-shaped aggregate; empty q still serves const res = await fetch(`${base}/context`, { headers: auth }); assert.equal(res.status, 200); const body = await res.json(); - // 形状锁:四键响应——bridge 等调用方按这四个键渲染,漂移(加塞/漏带)在此变红。 - assert.deepEqual(Object.keys(body).sort(), ["items", "pinnedCount", "profile", "rules"]); + // 形状锁:五键响应——bridge 等调用方按这些键渲染,漂移(加塞/漏带)在此变红。 + // #383 新增 tier(档位信号);空 q 时语义路未启用,报 unknown 而不是 full。 + assert.deepEqual(Object.keys(body).sort(), ["items", "pinnedCount", "profile", "rules", "tier"]); + assert.equal(typeof body.tier, "string", "tier 必填"); + assert.equal(body.tier, "unknown", "空 q = 语义路未启用,不宣称档位(fail-closed,不报 full)"); assert.equal(body.profile, "我是后端工程师"); assert.deepEqual(body.rules, ["先验证再修改"]); assert.equal(body.pinnedCount, 0, "pinnedInjectBudget 默认 0(opt-in 纪律,issue #370 取舍 3)"); @@ -749,3 +752,97 @@ test("strictPort: busy port rejects instead of hopping; default path still hops" store.close(); } }); + +// --- GET /context 的档位信号(issue #383)--------------------------------------- +// 嵌入降级在调用方视角是完全静默的:items 看起来正常,只是召回质量悄悄变差。 +// 外部集成方(Mneme Bridge 的 remote 模式)无法据此降级自己的期望或打日志。 +// 三态 fail-closed:判不出来报 unknown,不报 full。 + +/** 可用的 embedder:任何查询都回一个固定向量。 */ +const okEmbedder = { embedSingle: async () => [1, 0, 0], embed: async () => [1, 0, 0] }; + +test("GET /context tier=full when the query embedding succeeds", async () => { + const { base, auth, close } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "full", "嵌入成功 → 语义档"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when no embedder is injected", async () => { + // 缺省(不传 embedder):daemon 与宿主都可能走到这条——embedder 未装配。 + const { base, auth, close } = await setup(); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "无 embedder → 降级到规则档,且必须说出来"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when the query embedding times out", async () => { + // embedder 挂起(不抛错)时由 withEmbedTimeout 兜底——这是最难自查的一种降级, + // 因为接口是好的、只是没有响应。 + const hanging = { embedSingle: () => new Promise(() => {}), embed: () => new Promise(() => {}) }; + const { base, auth, close } = await setup({ embedder: hanging, embedTimeoutMs: 30 }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "嵌入超时 → 降级到规则档"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when the embedder returns an unusable vector", async () => { + // 空数组 / 非数组同样让语义路走不通(embedQueryVector 的既有兜底),一并报降级。 + const empty = { embedSingle: async () => [], embed: async () => [] }; + const { base, auth, close } = await setup({ embedder: empty }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "不可用向量 → 降级到规则档"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when the query vector's dimension differs from the stored one", async () => { + // #383 点名的那一种「悄悄退化」,也是 #508 评审里问「维度不符去哪了」的那一档: + // 嵌入接口一切正常(真的返回了向量),但库里躺着旧维度的向量——searchVector 对 + // `v.length !== vector.length` 的行直接跳过,不抛错、只是命中为零,于是召回静默 + // 退回 BM25/规则档。没有这个读数时,调用方看到的是 tier=full 加一份看着正常的 + // items,而语义召回其实整条是死的。 + const { base, auth, close, store, service } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + // 手工塞一个 4 维向量:模拟「换了嵌入模型,旧向量还没重建索引」。okEmbedder 出 + // 来的是 3 维,所以这一行的维度与查询向量不可比。 + const memory = service.all().find((m) => m.title === "核心决策"); + store.setEmbedding(memory.id, [1, 0, 0, 0]); + assert.equal(store.storedEmbeddingDimension(), 4, "夹具前置:库里是 4 维"); + + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "维度不符 → 语义召回实际不可用,报降级而不是 full"); + } finally { + close(); + } +}); + +test("GET /context:库里还没有向量时不算维度不符(两种空的读数必须分开)", async () => { + // 「没有东西可比」与「维度对不上」是两件事:前者是全新库(或还没建索引), + // 语义路只是还没有内容,不是坏了。storedEmbeddingDimension() 回 null 时必须 + // 继续报 full,否则每个新装用户的第一次 /context 都是 degraded。 + const { base, auth, close, store } = await setup({ embedder: okEmbedder }); + try { + assert.equal(store.storedEmbeddingDimension(), null, "夹具前置:库里没有任何向量"); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "full", "没有已存向量 → 不是维度不符"); + } finally { + close(); + } +});