Skip to content

搜索:Ctrl+K 支持会话聊天内容搜索,并支持命中定位与高亮 - #488

Open
pia wants to merge 7 commits into
xintaofei:mainfrom
pia:feat/message-content-search
Open

搜索:Ctrl+K 支持会话聊天内容搜索,并支持命中定位与高亮#488
pia wants to merge 7 commits into
xintaofei:mainfrom
pia:feat/message-content-search

Conversation

@pia

@pia pia commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

背景

之前左侧的 Ctrl+K 搜索只匹配会话标题。本次改动把用户和助手的聊天文本纳入
搜索范围,在保持低存储占用和强性能的前提下,支持正文搜索、命中定位和结果
高亮。

主要功能

  • Ctrl+K 会话搜索同时匹配标题和聊天正文,正文命中显示上下文摘要。
  • 搜索范围默认是全部文件夹,输入框右侧的下拉框可以限定到具体项目。
  • 点击搜索结果后自动滚动到命中的消息并居中,关键词黄色闪烁约 1.8 秒;
    同一会话有多个命中时,提供“下一条匹配 1 / N”逐个跳转。
  • 设置页新增正文搜索开关和模式选择:自动 / 省空间扫描 / 全文索引。
  • 补全 10 种语言的界面文案。

界面示意

搜索框(可限定文件夹范围):

搜索框

搜索结果(正文命中带上下文摘要):

搜索结果

实现方案

  • 索引内容只包含用户和助手文本;系统提示、思考过程、工具调用和图片都不
    参与索引,单个文本块最多保留 8192 字节。
  • 新增搜索后端模块和每会话一条的文档表,桌面端和服务器端共用同一套核心
    逻辑;codeg-mcp 不变。
  • 后台索引 worker:启动时后台补齐索引、打开会话时增量更新、每 10 分钟
    漂移核对、删除会话时同步清理;内容没有变化时跳过重新计算。
  • 双模式搜索:扫描模式只保存规范化原文,使用参数化 LIKE 查询,几乎不
    增加额外存储;全文模式使用 FTS5 trigram 索引加短词表,可索引文本超过
    40MB 时自动切换,回落到一半以下时切回扫描模式。
  • 结果排序使用 bm25,多词查询取交集;每个会话保存块级偏移,用于命中时
    精确定位到具体消息。
  • 迁移新增 block_offsets 列并把 schema 版本升级到 2,旧索引由后台 worker
    自动重建。

测试

  • 新增 Rust 测试,覆盖文本规范化、查询拆分与转义、trigram 和短词路由、
    增量索引、空会话墓碑、模式切换、结果排序与摘要生成。
  • 新增前端搜索焦点 store 测试。
  • tsc、eslint、prettier、vitest、next build 全部通过;Rust 三种编译模式
    的 check、clippy 和全量测试全部通过。

备注

  • 扫描模式的 LIKE 大小写折叠覆盖 ASCII;带重音的非 ASCII 字母的大小写
    变体仅在全文模式由 trigram 折叠。
  • 不索引系统提示、思考、工具调用以及单块 8192 字节上限,是有意做出的
    取舍,用于控制索引体积和搜索性能。

pia added 7 commits August 15, 2026 09:54
- Persist an empty tombstone document for conversations whose transcript
  parses to zero turns (e.g. cancelled Claude sessions with no jsonl file)
  so the progress denominator counts them as handled and drift resync stops
  re-queueing them every ten minutes.
- Fix search hit navigation to scan merged turn wrappers in reverse and
  scroll the highlighted mark itself to viewport center with a settle pass.
- Window the detail response only AFTER the full turn list has been handed
  to the indexer. Previously the tail-windowed turns were submitted, so
  opening a conversation could overwrite its indexed document with the last
  120 turns and silently drop older history.
- Stop submitting paged older-history slices to the indexer entirely.
- Skip re-normalizing unchanged transcripts via the same source-metadata
  dirtiness probe drift_resync uses, keeping repeated opens cheap.
- Drop match_kind/content_match_count/total_match_count, which no consumer
  reads, and the unused store setter.
@xintaofei

Copy link
Copy Markdown
Owner

先说结论:这个 PR 的工作量和完成度都很可观 —— 设计文档带实测数据、双运行模式共用核心切得干净、10 种语言文案一个不落(我逐个 locale 核对过 10/10),e2d6c7f3 那个「窗口化必须在投递索引之后」的修复更是找得很准,注释把「为什么」写清楚了。这些都很棒 👍

不过我把后端和前端通读了一遍,并对几处存疑的地方做了实测复现,发现有 3 个问题会让功能在特定条件下直接不可用,还有若干条建议在合并前处理。下面按优先级列一下,附上复现证据,供你参考。


🔴 P0-1:FTS 模式下 1–2 字符查询直接报错(已复现)

m20260814_000001_message_search.rs:148message_search_short 用了 detail=none,而 commands/search.rs:264-268 生成的是列限定语法:

ShortTermQuery::CjkUnigram { token }  => format!("words : \"{token}\""),
ShortTermQuery::CjkBigram  { phrase } => format!("bigrams : \"{phrase}\""),
ShortTermQuery::LatinPrefix{ token }  => format!("words : \"{token}\"*"),

SQLite FTS5 不支持 detail=none 上的列查询。我在仓库里加了个临时测试跑通复现(代码已还原):

PROBE1 = Err(AppCommandError { code: DatabaseError,
  detail: "... (code: 1) fts5: column queries are not supported (detail=none)" })

裸 sqlite3 3.51 同样复现,换 detail=column 就正常了。

触发条件:设置页选「全文索引」,或 auto 模式下可索引文本 ≥ 40MB —— sync_mode_and_progress 两条分支都写 (MODE_FTS, true)

影响:整个 search_conversations 返回 Err,前端 doSearchcatch { setResults([]) } 把错误吞掉,用户只看到「无结果」。而且 title_summaries 也在同一个函数里,所以标题搜索会一起失效。中文用户最常用的恰恰是二字词,这条命中率会很高。

修法:把 message_search_short 拆成两张单列 FTS 表(words / bigrams 各一张),列限定语法就不需要了;或改 detail=column。两者都要新迁移 DROP + 重建现有虚拟表。


🔴 P0-2:限定文件夹 + FTS 模式 → 内容命中被静默丢光(已复现)

commands/search.rs:248-257limit = visible_conversation_count(folder_ids, agent_type),但 query_indexed_term 的 SQL 里没有任何文件夹/agent 过滤

SELECT d.conversation_id, bm25(t) AS rank FROM t
JOIN message_search_document d ON d.id = t.rowid
WHERE t MATCH ? AND d.text LIKE ? ESCAPE '\' ORDER BY rank LIMIT ?

LIMIT 是在全库文档上截断的,可见性过滤发生在之后的 Rust 侧。目标文件夹只有 3 个会话、全库有 5000 个匹配文档时,取到的是全局前 3 条,几乎必然不含目标文件夹。

复现(30 个噪声会话在别的文件夹,1 个匹配会话在目标文件夹,限定目标文件夹搜索):

PROBE2 folder-scoped hits = 0

设计文档 §9.2 的论证是「索引一行对应一个会话,因此任何单个词最多只能命中可见会话数;该默认值保证不会静默漏召回」—— 这个前提只有在 SQL 里带上可见性过滤时才成立,所以恰好落在了本 PR 主打的「限定项目范围」上。多词查询也受害(各词各自截断到 N 条再求交集,交集可能为空)。

修法message_search_document 已经有 conversation_id,直接 JOIN conversationdeleted_at IS NULL / kind != 'loop' / parent_id IS NULL / folder_id IN / agent_type 放进这条 SQL,LIMIT 才有意义。顺带能干掉 visible_conversation_count 这个当上限用的调用。


🔴 P0-3:当前 contentless 布局下 bm25() 恒为 0

裸 sqlite3 3.51 实测(同一 trigram tokenizer,三条命中次数与长度差异极大的文档):

content=''        + detail=none    -> [0.0, 0.0, 0.0]
content=''        + detail=column  -> [0.0, 0.0, 0.0]     ← 加 columnsize=1 也仍是 0
content=''        + detail=full    -> [-4.89e-06, -5.79e-06, -4.57e-06]
(非 contentless)  detail=column  -> [-4.89e-06, -5.79e-06, -4.57e-06]

是「contentless + 非 full」这个组合让 bm25 失效,不是 detail=column 本身有问题。

所以 PR 描述里的「结果排序使用 bm25」在 FTS 模式下是空转:content_results.sort_by 全部同分,最终顺序取决于 HashSet<i32> 的迭代顺序(commands/search.rs:94-95),跨进程不稳定。设计 §9.3 承诺的「相同相关度按 updated_at DESC」也没实现,排序里没有任何时间兜底。

可选路线

  1. 保留 content='',把 detail 提到 full —— 两张表都适用,但与 §6.2 选 detail=none 的体积理由(1.34×)冲突,需要重算体积。
  2. 改用 external-content + detail=column —— 只对 message_search_trigram 成立(它只有一个 text 列,正好能以 content='message_search_document', content_rowid='id' 做 backing table,正文不会二次复制,实测 bm25 非零且有区分度)。但对 message_search_short 不成立:它是 words/bigrams 两列,backing table 没有这两列,bm25() 会报 SQL logic error(rebuild / delete 同理)。另外 external-content 的删除需要提供旧列值或改用触发器,当前「先更新 backing row、再删旧 FTS row」的顺序会留下陈旧词项。
  3. 明确放弃 BM25,改成确定性排序(至少 updated_at DESC 兜底),两张表都适用。

个人倾向第 3 条:会话搜索的直觉预期本来就是「最近的排前面」,而且不用动 §6.2 的体积核算。

💡 补充一句:这三条目前都没有测试覆盖fts_mode_uses_trigram_index 这个测试名字叫 trigram,但查询词「会话」是 2 字符且 short_fts_enabled=false,实际走的是 scan_term_candidates 兜底分支 —— trigram 那条 JOIN SQL 在整个仓库里没有任何测试执行过,短词表的 MATCH 一次都没跑过。所以 7 个 CI job 全绿并不能说明这两条路径可用。


🟠 建议合并前处理

1. 漂移检测无法发现外部转录变化,索引可能永久停在旧内容
get_folder_conversation_coresummary.message_count = turns.len() 只改返回值,DB 行不动(conversations.rs:1117 附近,紧邻注释也明说「the row itself is left alone」)。而 process_turnsindexer.rs:172)的提前返回和 drift_resyncindexer.rs:128)的 dirty 判定,比的都是DB 行与文档行 —— 两边同源,等于自我比对。用户在终端里直接跑 codex/claude 把转录写长了,drift 会永远认为不脏。设计 §10.2 写的是「用现有解析器 list_conversations 得到廉价摘要」再比对,换成比 DB 行之后,漂移检测就失去意义了。另外 §10.3 的 import / ACP TurnComplete / BackgroundActivity 三个钩子目前一个都没接。

2. FTS 模式切换不是原子的,失败后不可恢复
indexer.rs:236-246set_search_mode 提交,再 rebuild_fts_from_documents。提交后查询侧立刻按新 mode 路由到 FTS(commands/search.rs:53),而此时表还是空的。更麻烦的是失败路径:rebuild 报错后 mode 已是 fts,下次 state.mode != mode 为 false → 永远不会重试,用户会永久停在「mode=fts 但索引为空」且无任何提示的状态。

3. 备份的「包含会话内容」开关被绕过
正文现在进了 message_search_document.text,而备份始终对整个 DB 执行 VACUUM INTObackup/core.rs:67),BackupOptions 里只有 include_external_transcripts 一个开关(用户看到的文案就是「包含会话内容」,zh-CN.json:4097)。所以用户关掉该开关导出备份,归档里仍然含有全部聊天正文。这条属于数据治理层面的行为变化,建议合并前明确处理。

4. 首建/漂移回填是 O(N × 语料)
indexer.rs:33pending: HashSet 全文件只有 remove、没有任何 insert,于是 indexer.rs:100if pending.lock().await.is_empty() 恒为 true。本应「队列排空后做一次」的 sync_mode_and_progress 变成了每处理一条会话跑一次,而它内部有 SELECT SUM(LENGTH(CAST(text AS BLOB))) 全表扫描 + 一次 UPDATE + 一次事件广播。升级后首启要回填全部会话(v2 迁移还把旧索引整表清空),2000 条会话 ≈ 2000 次全语料扫描。设计 §10.1 的「有界队列 / 按 conversation_id 合并 / 最多 2 个并发解析」三项都没实现(解析本身确实已经在 spawn_blocking 里跑,这点是满足的)。

5. 打开会话时全量克隆 turns 进无界通道
conversations.rs:1461web/handlers/conversations.rs:151result.turns.clone() 在「打开会话」热路径上无条件深拷贝整份转录,哪怕文档根本没变(去重判断在 worker 里、克隆之后)。通道又是 unbounded,连续快速打开多个大会话会把整份转录堆在队列里。建议改成只投递 conversation_idprocess_parse 这条路已经有了),或传 Arc<Vec<MessageTurn>>

6. 切「全文索引」会在命令线程里同步重建整个 FTS
set_search_settings_core 直接调 sync_mode_and_progress,其中 rebuild_fts_from_documents 在单个事务里 DELETE 两张表后 .all(&txn) 把整个语料一次性读进内存再逐行 INSERT。长事务持有 SQLite 写锁会阻塞全 App 的写操作,前端只显示一个「保存中…」。建议交给后台 worker 分批提交。

7. 还有两类查询假阴性

  • commands/search.rs:269format!("{}%", escape_like(term)) 没有前导 %,而这个 pattern 是拿去和整篇文档正文做 LIKE 的 —— 即使修好 P0-1,搜 AIJSID 也只能命中正文恰好以它开头的会话。前缀语义应该只留在 FTS 侧的 words : "x"* 上。
  • trigram 会折叠 ÉCOLE/éco,但随后的 d.text LIKE ?commands/search.rs:290)是 ASCII-only LIKE,会把命中过滤掉。这与 PR 描述里「带重音的非 ASCII 字母的大小写变体仅在全文模式由 trigram 折叠」的说法正好相反。

8. 高亮会定位到错误的 turn(单词查询也会)
data-search-turn-ids 挂的是 item.sourceTurns.map(t => t.id) —— 连续的 assistant turn 会合并成同一个 DOM 元素。而 search-command-dialog.tsx:213-221occurrenceByTurn按 turn_id 累加的,highlightSearchOccurrence 却从整个合并元素的开头开始数。于是「turn B 的第 0 次命中」会被高亮成 turn A 里的那一处。

9. 多词查询的定位与计数错位
后端 matches 是按词产出的,但 highlightSearchOccurrence 用的 needle 是整条 query 原文。查「foo bar」时前端在 DOM 里找整串,通常一个都找不到 → 只滚动不高亮;「下一条匹配 1/N」的 N 来自后端计数、跳转按整串计数,两边对不上。

顺带一提:后端算好的 block_index / char_start / char_end(content 类型)在前端只出现在 message-list-view.tsx:1150-1152 的一个 matchKey 字符串拼接里,从不用于定位。真正定位靠 turn_id + 前端重新在 DOM 里搜字符串。如果改成真正使用后端偏移,第 8、9 两条可以一起解掉。

10. 前端高亮直接改 React 托管的 DOM,会留下重复文本
highlightSearchOccurrenceRange.surroundContents 往消息正文插 <mark>。这里不会导致崩溃(Range 边界始终落在单个 Text 节点内,extractContents 会把选中片段克隆进 <mark>,React 原来的 Text 节点仍留在原位)—— 但会产生 DOM 漂移:React 之后更新该 Text 节点时只改原节点,<mark> 里的克隆内容不受影响,正文就出现 changed<mark>world</mark> 这类重复文本,clearSearchMarks 再拼回去就成了 changedworld。而且动画是 forwards 到透明,<mark> 会一直留在 DOM 里直到下次搜索或卸载,命中的会话若还在流式输出,风险窗口就是整个会话生命周期。更稳的做法是 CSS Custom Highlight API(CSS.highlights + Range,完全不动 DOM,WKWebView 17.4+ / Chromium 105+ 都支持),或把命中位置透传进 markdown 渲染层由 React 自己渲染 <mark>

11. 搜索请求无代次控制
search-command-dialog.tsx:122doSearch 没有请求代次或取消逻辑(shouldFilter={false} 又关掉了 cmdk 自己的过滤),慢的旧请求可以覆盖新结果。更明确的一处是 handleSelectConversation(同文件 207 行)把响应里的 conv.matches当前 state 里的 query 组合进 setFocus —— 两者不同步时,跳转位置来自查询 A、高亮文本来自查询 B。


🟡 其它(不阻塞,记录一下)

  • 滞回是死代码indexer.rs:228-234 第 4 个分支和兜底分支返回值完全相同,滞回不存在,跌破阈值立刻回落,每次翻转触发一次全量 rebuild。设计 §8.2 的「降到 50% 以下才切回」没生效。
  • 每次内容搜索拉全工作区摘要commands/search.rs:80-89list_all 不带 limit,返回范围内全部会话摘要,只为做可见性过滤。与设计目标 p95 ≤ 45ms 冲突,修 P0-2 时可一并解决。
  • 索引进度可能永远 <100%process_parse 失败只 warn!drift_resync 每 10 分钟重新排队再失败,Ctrl+K 面板顶上的「索引中 x%」会永久挂着。设计 §10.4 的退避重试没实现。另外 indexed_conversation_count 数的是全部文档行(含孤儿),分子分母口径不一致。
  • 孤儿文档不回收message_search_document 没有外键/级联,App 未运行时的删除会让文档永久残留,占空间还会算进 total_indexed_text_bytes 推高模式切换。
  • user_enabled=false 不阻止索引:开关只在 search_conversations_core:49 生效。关掉「内容搜索」后正文照样被抄进 DB、照样跑后台索引,已有文档也不清理。实施计划里确实只规定了「关闭后查询只返回标题」,所以这更像是需要产品上拍板的语义问题(开关叫「内容搜索」,用户大概率理解成「别存我的正文」)。
  • schema 里三列建了但从不读写scan_ms_per_mb / last_calibration_at / short_threshold_mb;设计 §8.2 本机校准、§8.3 p95 看门狗、§8.4 短词独立阈值都没做。建议要么实现,要么把列和文档一起裁掉。
  • 查询长度上限没做:设计 §9.1 的「单次查询最多 256 字符」未实现。粘贴一整段报错去搜(恰恰是正文搜索最典型的用法)会生成巨大的 LIKE pattern 和 ~N 个 gram 的 AND 表达式。实测没触发 FTS5 深度上限,但代价随长度线性上升且每次按键都跑。
  • drift_resync 是 N+1 查询:每 10 分钟对每个会话单独 SELECT 一次文档行,一条 LEFT JOIN 即可。
  • 小项:set_search_settings_coreEventEmitter::Noop,改设置不广播;search-settings-section.tsxsave() 没有 catch,失败会变 unhandled rejection 且 UI 无提示(其他设置区块用的是 toast.error);globals.css#fde047 硬编码没走仓库既有的 CSS 变量体系,暗色主题未适配。

关于「每会话一行 vs 每消息一行」

我一开始怀疑行粒度是问题根源,后来确认不是 —— 表里已经有 conversation_id,直接 JOIN 就能在 LIMIT 前过滤,与行粒度无关;而且每条 turn 仍可能有多个 Text block,改成每消息一行也不能自然消掉 block_offsets。所以不建议把布局重构当作合并前置,值得先做基准再决定。

真正建议先定下来的是两件事:一是 P0-3 那三条路线选哪条;二是双模式(scan LIKE ↔ FTS trigram)自适应切换是否有必要现在就上 —— 它是 P0-1/2/3 和「模式切换非原子」的共同温床。按设计文档自己的实测(29MB 语料 scan p95 = 26ms,100MB = 87ms),先只上 scan 模式把功能发出去、等真有用户越过 40MB 再补 FTS,可能比现在「两条路径、只有一条被测过」更稳妥。


建议补充的测试

真实的 FTS 短词/trigram 查询、folder/agent 的 SQL 过滤、排序确定性;「DB 元数据相同但 turns 变了」、import / TurnComplete / BackgroundActivity 的索引刷新;rebuild 失败与并发模式切换;关闭「包含会话内容」后归档 DB 里不含正文;以及前端的异步请求乱序、合并 turn、多 block、多词查询、React 更新期间的高亮行为。


整体方向我是认可的,规范化边界(只取 user/assistant 的 Text 块、8KB 截断、SHA-256 diff)划得清楚,注入面也控制得不错(LIKE 走绑定参数 + 显式 ESCAPE,fts_quote 处理了引号加倍)。主要是 FTS 那条路径缺测试,几个问题就这么漏过去了。P0 三条修掉、外部漂移和备份那两条明确一下,这个功能就很能打了 💪 辛苦!

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.

2 participants