Repository navigation
feat(semantic-index): reuse embeddings across merged sessions - #471
Zycheng114514 wants to merge 3 commits into
Conversation
…inputs Merging or re-importing a session produces a new chat database, so its vectors live under a new db_path_hash and every chunk is embedded again even when the text has not changed at all. Before embedding a batch, look for an already indexed chunk in another chat database whose embedding input is byte-identical, and copy its vector instead. The key is embedding_input_hash, which every chunk row already stores, so existing indexes are reusable without a backfill; model, strategy, chunker identity and dim must all match for the copied vector to mean anything. A run that only copied vectors proves nothing about the local model, so it no longer clears the model preload error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
carryOver enables the merged session and queues its build as soon as at least one source session is indexed with the current model, so the user does not have to ask for the index again: chunks whose embedding input is unchanged copy the source vectors and only the new messages are embedded. The worker client tracks the merged session as an active build for the duration, the way build does. Nothing polls status after a merge, so without it the idle timer could terminate the worker while the build was still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge route knew which sessions were merged but had no way to tell anyone, so the new session started with no vectors at all. Remember the source sessionId on each exported merge handle and, once the merged output has been imported, call onMergedSessionImported with the new session and its sources. Desktop and CLI Web wire it to the semantic index, which carries the existing vectors over. The callback runs in the background: the merge response must not wait for an index build, and a failure there must not fail the merge. Closes ChatLab#378 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hellodigua
left a comment
There was a problem hiding this comment.
核对了当前合并入口、索引构建链路和 Worker 生命周期,发现以下两个需要在合并前处理的问题。第一个问题已用合成消息验证输入哈希匹配结果;未使用真实用户数据或调用付费 embedding API。
| if (sources.length === 0) return { enabled: false } | ||
|
|
||
| this.enable(params.targetSessionId) | ||
| this.build(params.targetSessionId) |
There was a problem hiding this comment.
[P2] 默认合并命名使正常入口的向量复用失效
当前实际入口 src/components/common/Settings/BatchManageTab.vue:452-457 会把合并后的名称自动设置为 原名称(合并);该名称经 buildMergedOutput() 写入新会话的 meta,随后 chunker.ts 又把它加入 embedding 输入的 [来源] 头部。因此这里自动启动构建时,普通名称下所有 chunk 的 embedding_input_hash 都与源会话不同,findReusableVector() 无法命中,最终仍会全量调用 embedder。API 模式下用户只是合并会话,也会自动产生全量 embedding 开销。
我用与本 PR 一致的 chunker 对 1,000 条合成消息做了验证:共 249 个 chunk,消息 ID 改变但名称不变时匹配 249/249;仅按现有 UI 规则把名称改为 Project(合并),匹配变为 0/249。PR 已说明手动改名会失去复用,但这里是现有合并入口必经的自动改名,并非额外边界场景。
建议先协调同源合并的默认命名与 embedding 来源身份,并补一条经过真实默认命名流程的回归验证,确认相同消息确实减少 embedding 调用。不要仅放宽输入哈希匹配,否则会复制与当前输入文本不对应的向量。
| let enabled = false | ||
| try { | ||
| const result = await this.call<{ enabled: boolean }>('carryOver', [params]) | ||
| enabled = result.enabled |
There was a problem hiding this comment.
[P2] 后台承接构建完成后没有自动清除活动标记
carryOver() 在第 145 行将目标加入 activeBuildSessionIds,但这里的 enabled: true 只表示已启用并入队,并不表示构建完成;成功路径不会清除这个标记。随后 scheduleIdleCloseIfNeeded() 会一直因为集合非空而跳过闲置关闭。当前 Worker 协议只返回请求响应,没有构建完成通知;标记只能由后续 status() / statusForSessions() / listEnabledStatuses() 查询刷新,而前端构建状态轮询位于 SemanticIndexSection.vue,不是合并页面的后台常驻轮询。
因此,用户在批量管理中合并已启用索引的会话,随后不打开语义索引设置页面,构建完成或失败后 Worker 仍可能持续驻留,本地模型的内存也无法按原定闲置超时释放。新增测试在构建之后手动调用了 client.status('session-merged'),正好绕过了这个实际使用路径。
建议在客户端跟踪后台构建时自行获取终态,或由 Worker 通知完成,再释放活动标记并恢复闲置关闭;补充“承接构建结束后,没有任何 UI 状态查询也能闲置关闭”的回归测试。
拆自已关闭的 #459:按维护者的要求,一个功能一个 PR。
Closes #378
问题
合并会话产出一个全新的聊天库,语义索引按
db_path_hash分区,旧向量对新会话不可见:合并后要么没有向量,要么全量重新 embedding。#378 还指出合并常被用来绕过自动增量匹配失败,所以影响面不小。改动
embedding_input_hash)查任意聊天库里现成的向量。embedding_input_hash是送去 embedding 的完整文本的哈希(每个 chunk 行本来就有,旧库无需回填),所以只有文本逐字相同时才复制向量(vec0 读回 Float32 字节再插入,cosine 距离为 0),其余照常 embedding;WarmupResult.chunksReused记录复用数。新增索引idx_chunk_reuse(CREATE INDEX IF NOT EXISTS,旧库打开即生效)。API provider 首批前维度未知时先正常 embedding,之后复用。SemanticIndexService.carryOver({ targetSessionId, sourceSessionIds }):至少有一个源会话已启用且模型一致时,启用目标会话并立即排队构建。合并路由(/_web/merge)导入成功后在后台触发承接,不阻塞合并响应;桌面 internal API 与 CLI Web 都接上,承接在语义索引 worker 内执行。export-for-merge生成的 handle 记录来源 sessionId;上传得到的 handle 没有来源,退化为普通构建。carryOver与build一样把目标会话记进活动构建集合,避免无人轮询状态时空闲计时器在构建进行中关掉 worker;没有可承接的源时立即放掉跟踪并重新武装空闲计时器。复用率(合成基准:源会话 10 000 条 / 1 426 chunk 已建索引,fake embedder,默认 chunker 配置)
两个结构性限制,都写在代码注释里:
兼容
embedding_index.db只新增一个索引;聊天库 schema 不变;不涉及.chatlab-meta.json门禁。有一个测试用旧版本写出的索引库(没有新索引)证明升级后无需任何回填即可命中复用。测试
store(复用查找命中 / 未命中 / 排除自身、复制向量距离为 0、旧库无回填可复用)、runner(A 已索引后 A∪B 只对含 B 消息的 chunk 调用 embedder,精确计数;标题不同则全部重新 embedding)、service(未配置 / 源未启用 no-op;启用源 → 目标启用 + 排队构建)、worker client(承接后空闲计时器不会在构建进行中关闭 worker;无可承接源时正常关闭)、merge 路由(回调收到正确的源 id 集合;响应不等待回调;回调异常不影响响应)。
pnpm run type-check:all、eslint、prettier、全量pnpm test全绿。拆分后的验证
2026-09-18 变基到
origin/main@3b44facf(v0.37.2) 后重跑:pnpm run type-check:all、改动文件 eslint 与 prettier、git diff --check、pnpm --filter chatlab-mcp build后全量pnpm test(2 338 条,失败 0,跳过 1 条仓库原有的条件跳过)。🤖 Generated with Claude Code