Skip to content

[性能] 向量解析缓存上限小于工作集导致命中率恒 0,且只取 id 的扫描带着读整列向量的谓词 #592

Closed

Description

@modusensus

背景

src/store.js 的向量解析缓存上限写死 4000,而活跃带向量行已 6730:扫到第 4001 条开始驱逐自己刚填的条目,扫完只剩最后 4000 条,下一轮又从第一条 miss。真库四轮实测命中率恒 0.0% —— 缓存等于不存在,每次检索全量重解析(约 6730 条 × 1024 维 JSON.parse)。

同一目录下另有一处:searchVector 与 vectorDimensionState 的 SQL 只 SELECT id,但 embedding != '' 要读 embedding 列才能判定,SQLite 就得把整列向量文本(约 134MB)从磁盘搬一遍,读完一条都不用。

这两处都在检索热路径上,属于同一个「同一条查询更便宜」的保证,所以合为一条线。

缺陷 1(perf):缓存上限写死 4000,小于工作集 → 命中率恒 0

A/B 对照(同一 WAL 快照、同一 query、同一库):

臂 每轮耗时
旧实现 1.8–2.4s
新实现(第二轮起) 370–424ms

×5.6。固定调大只是把复发推到下个月(活跃行近 30 天净增 6156 条),所以上限改为跟工作集自适应,并用内存预算封顶。

存储改 Float32Array:真实占用 55.5MB → 29.7MB(省 47%)。前 50 条排序与冷解析逐位一致,分数最大差 0.00e+0。

⚠️ 同步改三处守卫:Array.isArray 对 typed array 返回 false,若只换存储不改守卫,cosine / cosineSimilarity / setEmbedding 会把每个相似度静默算成 0、把「设置向量」读成「清空向量」。对外契约不变(getParsedEmbedding / getEmbeddings 仍返回 number[])。

缺陷 2(perf):只取 id 的扫描却带着 embedding != '' 谓词

同一条 SQL 在真库副本上实测:

臂 SQL 层 单轮检索
保留谓词 265ms 138.1 / 135.6 / 133.5ms
去掉谓词 96ms 47.4 / 46.6 / 49.8ms

命中率两臂均 0% → 100%(因缺陷 1 同时修掉),前 50 条 id 顺序与分数完全一致。

只摘这两处只取 id 的扫描;三处统计类查询(embeddedCount / embeddingModelBreakdown / embeddingDimensionBreakdown)保留谓词 —— 它们要数「已索引」条数,空串行若被算进分母会变成新的假数。

摘谓词的前提是空串行不会把检索搞崩。库里现在 0 条空串(setEmbedding 写 NULL,能造出空串的只有外部直接 UPDATE),所以这是「目前恰好为真」的假设,必须锁住:readVector 对 JSON.parse('') 抛错返回 undefined,空串行既不计分也不崩。

口径陷阱(已写进注释,防止被当证据引用错)

  1. heapUsed 不统计 TypedArray 底层 ArrayBuffer —— 只看 heapUsed 会读成省 96%。必须按 heap + arrayBuffers 计。
  2. 旧实现保持上限 4000 时只装得下 4000/6730 条,常驻 31.9MB 但命中率 0%,与「装满」的 29.7MB 不可比 —— 对照组必须把旧上限抬到能装下工作集。
  3. 「SQL 层 265ms → 96ms」不等于「每轮检索省 170ms」。落到一轮完整检索上只省约 90ms(其余是解析与打分开销,不随谓词变化)。两个口径分开写。

测试

  • test/embedding-cache-f32.test.js:6 条(缺陷 1)+ 2 条空串/坏 JSON 护栏(缺陷 2)+ 1 条读取次数锁
  • 变异验证(四条均咬住):自适应退回固定上限 / cosine 守卫退回 Array.isArray / setEmbedding 守卫退回 / 独立基准分数
  • 变异实验暴露过一个真实缺口:只比「缓存路径与冷路径是否一致」测不出两边一起错,补了一条独立基准分数锁

为什么不写「断言这句 SQL 不含 != ''」的结构锁:那会把测试绑在字面量上,换行、改别名、换 ${archivedFilter} 写法都会无辜变红,红了第一反应是删断言。改为锁行为:数 embeddingCacheStats 的读取增量,断言三行非 NULL 必须各读一次。实测当前实现 3 次(绿);谓词加回后 2 次(红)。

验收

  • 真库副本四轮命中率 0% → 100%
  • 前 50 条 id 顺序与分数完全一致(逐位,最大差 0.00e+0)
  • 空串行与坏 JSON 混入不崩
  • 谓词被加回来时读取次数锁变红
  • 三处统计类查询保留谓词,计数不变
  • npm test 全量 2848 用例 / 2847 通过 / 0 失败 / 1 跳过(src↔lib 一致性守门通过)

不做

不改向量路合并逻辑(那是 pin 池那条线的事),不改 SQLite 侧索引结构。实测口径均为真库副本,线上库全程只读。

Activity

  1. modusensus commented on Nov 6, 2026

    @modusensus
    MemberAuthor

    复核结论(PR #594,head 70a79b39)

    已做源码级复核 + 独立复跑,并对 CodeRabbit 的 6 条行内评审逐条读源码核实。

    两处缺陷与本 PR 的对应

    缺陷 落点 状态
    ① 缓存上限写死 4000 < 工作集 6730,扫到第 4001 条开始驱逐自己刚填的 src/store.js:1528-1534 noteSweep(rows, avgDim) 自适应 + Float32Array(55.5MB → 29.7MB) 已修
    ② 只 SELECT id 的 SQL 带着 embedding != '' 谓词,要搬整列向量文本 摘掉 searchVector(:2697-2700)与 vectorDimensionState(:2617)两处谓词;三处统计类查询保留 已修

    三处守卫同步改 isVectorLike(Array.isArray 对 typed array 返 false,不改会让 cosine / cosineSimilarity / setEmbedding 静默算 0)。对外契约不变:getParsedEmbedding / getEmbeddings 仍返 number[](src/store.js:1558-1561 用 Array.from)。

    CodeRabbit 6 条逐条核实

    成立、建议修(1 条)

    • src/store.js:1522 — cacheEmbedding 只在插入前删一条(if (embeddingCache.size >= embeddingCacheCap) embeddingCache.delete(...)),cap 变小后 size 仍可超新 cap,直到后续插入逐条淘汰。noteSweep 在「先扫 archived 大行集、再扫活跃小行集」时会出现 size > cap 的中间态:只是内存短时超标,不丢正确性。建议 cap 调小时按 FIFO 裁到新 cap + 回归用例。

    Major 但当前不可达(1 条)

    • src/store.js:1533 — 用本次扫描的 avgDim 推算 cap,跨维度混扫时低估,机制成立。但 src/store.js:1504 EMBEDDING_CACHE_FLOOR = 4000 是硬下限,且「查询 128 维而库里全是 4096 维」这种维度错配库本机不存在(实测只有 1024 维 6,560 条与 2048 维 373 条)。标为潜在风险、建议记 issue 不修。

    理论成立、实际不可达(2 条)

    • src/store.js:1547 — Float32 无法表示 1e-50 / 1e40。真实嵌入量级 1e-2~1e1,只在人为构造向量时出现。
    • src/store.js:2457 — 缓存存 new Float32Array(vector) 而非 new Float32Array(JSON.parse(json)),NaN / undefined 时缓存与库不一致。方向对,但真实嵌入无 NaN。

    API 宽容度(1 条)

    • src/store.js:2442 — searchVector 仍用 Array.isArray(:2685),不认 Float32Array。但查询向量来自 embedQuery 的 number[],现有调用方无一是 typed array。若改则用 PR 已在三处落地的 isVectorLike 守卫。

    方法论建议(1 条)

    • test/embedding-cache-f32.test.js:140 — 用 SELECT 1 当清缓存手段不如「关闭并重开库」。PR 已用「独立基准分数锁」覆盖「缓存路径与冷路径一起错」这个核心风险(正文自述),属可采纳改进、不阻塞。

    两处注释陈旧(本 PR 未碰,建议随后修)

    • src/store.js:2611 仍写「行数在 4000 条 FIFO 上限内」,:3581 仍写「FIFO 上限 4000,自愈」,而 cap 已改自适应。
    • src/store.js:2619 / :2583 仍称 vectorDimensionState 与 searchVector 行集「逐条相同、语义等价」,但前者硬编码 archived = 0、后者走 archivedFilter,includeArchived=true 时不再等价。

    复跑(本机实测,head 70a79b39)

    项 结果
    scripts/check-sync.js ✓ 95 个文件一致
    npm test 2850 tests / 2849 pass / 0 fail / 1 skip(58.7s)

    CI:6 条 test 腿 + gitleaks + dependency-review + workflow-hygiene + zizmor + CodeRabbit + codecov/patch 全 success,osv-scanner skipping。

    未做

    未在 4096 维或维度错配库上压测;未做长时间运行的内存曲线观测(只有 PR 正文里的 heap + arrayBuffers 口径数据);未跑 ubuntu / node22 / test-host-legacy(由 CI 覆盖)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions