Skip to content

feat(semantic-index): reuse embeddings across merged sessions - #471

Open
Zycheng114514 wants to merge 3 commits into
ChatLab:mainfrom
Zycheng114514:feat/semantic-index-carryover
Open

Zycheng114514 wants to merge 3 commits into
ChatLab:mainfrom
Zycheng114514:feat/semantic-index-carryover

Conversation

@Zycheng114514

Copy link
Copy Markdown
Contributor

拆自已关闭的 #459:按维护者的要求,一个功能一个 PR。

Closes #378

问题

合并会话产出一个全新的聊天库,语义索引按 db_path_hash 分区,旧向量对新会话不可见:合并后要么没有向量,要么全量重新 embedding。#378 还指出合并常被用来绕过自动增量匹配失败,所以影响面不小。

改动

  • 复用:warmup runner 在调用 embedder 之前,先按(模型、策略、chunker 版本与配置、维度、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 没有来源,退化为普通构建。
  • worker 生命周期:worker client 的 carryOver 与 build 一样把目标会话记进活动构建集合,避免无人轮询状态时空闲计时器在构建进行中关掉 worker;没有可承接的源时立即放掉跟踪并重新武装空闲计时器。
  • 只有真正跑过 embedding 的任务才更新本地模型预热状态(全部复用时不能证明模型可加载)。

复用率(合成基准:源会话 10 000 条 / 1 426 chunk 已建索引,fake embedder,默认 chunker 配置)

合并场景 chunk 数 复用 新 embedding 复用率
尾部追加 2 000 条,标题不变(#378 描述的「不同时间导出同一聊天」) 1 712 1 426 286 83.3%
尾部追加 2 000 条,合并后改了会话名 1 712 0 1 712 0%
4 001–6 000 之间均匀插入 1 000 条 + 尾部追加 2 000 条,标题不变 1 855 569 1 286 30.7%

两个结构性限制,都写在代码注释里:

  • embedding 文本的头部含会话标题,所以改名后的合并不复用。这是有意的取舍:复制的向量必须对应完全相同的文本。标题是否应移出 embedding 文本,留给维护者决定。
  • chunk 边界由累计的消息 / 字符预算决定,插入一条消息后后续边界整体位移,所以交错式合并只有插入点之前的 chunk 能复用。

兼容

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

Zycheng114514 and others added 3 commits September 18, 2026 14:25
…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 hellodigua left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

核对了当前合并入口、索引构建链路和 Worker 生命周期,发现以下两个需要在合并前处理的问题。第一个问题已用合成消息验证输入哈希匹配结果;未使用真实用户数据或调用付费 embedding API。

if (sources.length === 0) return { enabled: false }

this.enable(params.targetSessionId)
this.build(params.targetSessionId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 后台承接构建完成后没有自动清除活动标记

carryOver() 在第 145 行将目标加入 activeBuildSessionIds,但这里的 enabled: true 只表示已启用并入队,并不表示构建完成;成功路径不会清除这个标记。随后 scheduleIdleCloseIfNeeded() 会一直因为集合非空而跳过闲置关闭。当前 Worker 协议只返回请求响应,没有构建完成通知;标记只能由后续 status() / statusForSessions() / listEnabledStatuses() 查询刷新,而前端构建状态轮询位于 SemanticIndexSection.vue,不是合并页面的后台常驻轮询。

因此,用户在批量管理中合并已启用索引的会话,随后不打开语义索引设置页面,构建完成或失败后 Worker 仍可能持续驻留,本地模型的内存也无法按原定闲置超时释放。新增测试在构建之后手动调用了 client.status('session-merged'),正好绕过了这个实际使用路径。

建议在客户端跟踪后台构建时自行获取终态,或由 Worker 通知完成,再释放活动标记并恢复闲置关闭;补充“承接构建结束后,没有任何 UI 状态查询也能闲置关闭”的回归测试。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug/Feature] 合并 ChatLab 会话时新会话无语义向量:应移植原会话已建向量 + 仅对新增消息增量生成

2 participants