Files
cc-web/.planning/conversation-search-proposal/findings.md

156 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 调研发现
> 本文件只记录事实、证据与方案判断,不包含可执行指令。
## 已知需求
- 当前检索只能依赖会话标题。
- 用户无法稳定记住标题,需要按“曾聊过的内容”找回会话。
## 待确认
- 标题过滤发生在前端还是后端。
- 消息正文的数据来源、格式、规模与读取成本。
- 是否已有数据库全文索引或可复用的检索依赖。
## 项目上下文
- Trellis 当前任务是 `07-30-sidebar-title-refresh-storm`,与本次只读调研不同;本次不创建或切换 Trellis 任务,避免干扰现有开发上下文。
- `codebase-memory-mcp` 项目 `home-cc-web` 索引状态为 ready,共 4312 个节点、9113 条边,可直接用于代码定位,无需重建索引。
## 现有检索入口
- 前端已有独立的 `normalizeSessionSearchQuery`、`syncSessionSearchUi`、`getSessionSearchText`、`sessionMatchesSearch`,集中位于 `public/app.js` 约 3960 行附近。
- 回归脚本中的旧/简化夹具只按 title 匹配,但生产实现的 `getSessionSearchText` 实际拼接了 title、projectName、cwd、完整 ID 和短 ID。用户的体感仍成立:**没有消息正文检索**,而其余字段通常也不容易记住。
- 搜索 `searchQuery` / `searchTerm` 未命中,说明这不是一个已经下沉到服务端的通用查询参数。
- 后端存在 `listConversationSummaries`、`sendSessionList`、`handleLoadSession` 等候选链路,下一步需确认列表摘要是否携带正文、消息是否仅在打开会话时加载。
## 列表与存储初步结论
- `sessionMatchesSearch` 由 `renderSessionList` 调用;检索完全发生在会话列表渲染期。
- `listConversationSummaries` 会遍历 `SESSIONS_DIR/*.json`,通过 `loadSessionMetaFromFile` 只提取元数据,返回字段包含 title、agent、status、updatedAt、cwd、projectName 等,但不含消息正文或正文摘要。
- `sendSessionList` 同样依赖 `loadSessionMetaFromFile`,说明普通侧边栏列表和 MCP 会话列表都刻意走轻量元数据路径。
- 完整会话由 `loadSession(id)` 读取并解析 JSON,`handleLoadSession` 在打开单个会话时才发送历史消息;因此把所有历史消息塞进现有 session_list 再由前端检索,会破坏当前“轻量列表、按需加载”的架构边界。
- `loadSessionMetaFromFile` 已针对超大 JSON 做头尾预览和大小阈值处理,侧面说明会话文件可能很大,不能在每次输入搜索词时全量扫描解析所有 JSON。
## 规模与边界常量
- 默认 `SESSIONS_DIR` 是项目下的 `sessions/`,可通过 `CC_WEB_SESSIONS_DIR` 覆盖。
- 默认单会话保存上限约 10 MiB、加载上限约 32 MiB;超过 512 KiB 时列表元数据不再完整解析文件,而是只读约 128 KiB 头尾预览。
- 默认持久化最多 180 条消息、单条消息正文最多约 96 KiB。即使消息条数受限,全部会话逐文件全文扫描仍会产生明显同步 I/O 和 JSON 解析开销。
- 会话读写目前是 JSON 文件制,不是数据库;因此推荐方案应新增旁路搜索索引,不能把搜索做成 `sendSessionList` 内的全文件扫描。
## 当前真实数据规模(2026-08-03,只输出聚合值)
- 133 个会话 JSON,总计约 81.8 MB;文件中位数约 337 KB,P95 约 2.63 MB,最大约 4.09 MB。
- 共 2197 条持久化消息;单会话消息数中位数 7、P95 67、最大 128。
- 现存消息 content 全部是字符串,角色分布为 user 1133、assistant 979、system 85,解析错误 0。
- 当前 `package.json` 唯一运行依赖是 `ws`,没有 SQLite、全文检索或分词库。
- 数据量尚未大到需要 Elasticsearch/Meilisearch 这类独立服务,但已经足以让“每次键入都同步读取并解析全部会话文件”的朴素方案产生明显卡顿。
## 实测检索基线(2026-08-03)
- 排除 system 后,可检索的 user/assistant 消息为 2112 条,规范化正文总量约 4.26 MB。
- 一次性读取全部 133 个会话并解析/规范化正文约 867 ms;所以不能在搜索请求内现读原文件,但可以后台建索引。
- 正文已在内存时,对全部 2112 条消息执行一次最坏情况的精确子串扫描平均约 0.98 ms。
- 结论:当前规模最合适的不是外部搜索服务或原生数据库,而是“服务端内存文档索引 + 持久化增量缓存”。它天然支持中文子串、依赖为零,未来正文规模大两个数量级时再切换倒排/FTS 后端。
## 运行时兼容约束
- 开发/普通启动基线是 Node 18.19;发布包用 `bun build --compile --target=bun-linux-x64-baseline` 生成 CentOS 7 兼容单文件。
- 当前代码只在删除 Codex 本地会话时 best-effort 调用宿主机 `sqlite3` CLI,不能把它视为必备依赖。
- `node:sqlite` 不适用于 Node 18,`bun:sqlite` 又无法覆盖 Node 启动路径;`better-sqlite3` 等原生扩展会增加 Bun 单文件和老 glibc 发布风险。因此 SQLite FTS5 不应作为第一阶段主路径。
## 现有 UI 行为
- 搜索输入每次触发 `input` 都立即调用 `renderSessionList`,无 debounce、无异步状态。
- 搜索时现有逻辑已经自动展开折叠项目和旧会话,这一交互可复用。
- 搜索命中只决定“显示/隐藏会话”,当前列表项没有命中片段、命中字段、匹配条数或定位消息能力。
## 接入点与一致性
- WebSocket 服务端已有按 `msg.type` 分发的 switch,可新增 `search_sessions`;前端 `handleServerMessage` 可新增 `session_search_results` / `session_search_status`。
- `saveSession` 是所有会话持久化的中心入口,拥有 26 个直接调用者;适合作为“按会话 debounce 后增量 upsert 索引”的统一钩子,但不能每次保存都同步重建。
- 删除、重命名、新建分别有集中处理函数,可分别触发 remove、metadata update、initial upsert。
- 现有回归测试里的搜索夹具仍只匹配 title,与生产 `getSessionSearchText` 不一致;实现时应新增独立全文检索契约测试,并修正夹具,避免测试继续掩盖真实语义。
## 命中定位可行性
- 搜索结果可以返回持久化数组中的 `messageIndex`,现有 DOM 消息节点已经写入 `data-message-index`。
- 旧消息已有 `load_history_page(before)` 分页接口,可以按目标 index 逐页补载;因此“点击结果 → 打开会话 → 自动加载目标页 → 滚动并高亮命中消息”不需要改会话存储格式。
- 现有 `createSessionListItem` 点击只调用 `openSession(session.id)`;可扩展为接受可选 searchMatch,而不影响普通列表点击。
## 消息可索引范围
- `normalizeSession` 保留 `messages` 数组;持久化链路会限制消息数、正文长度和工具调用体积。
- 第一版应只索引 title、projectName/cwd、用户消息和助手可见文本;默认排除 system 文本、tool input、tool result、附件二进制/元数据,减少噪声、索引体积和敏感信息暴露。
## 候选方案比较
1. 前端预加载全部消息后过滤:实现表面简单,但会把当前约 4.26 MB 且持续增长的正文发给浏览器,破坏轻量列表和按需加载,不推荐。
2. 每次查询直接遍历 `sessions/*.json`:无需新文件,但当前一次全量解析已约 867 ms,会阻塞 Node 事件循环,不可接受。
3. 服务端内存文档索引 + 持久化增量缓存:当前最坏正文扫描约 0.98 ms,中文精确子串天然可用,无新增依赖;推荐。
4. SQLite FTS5 / FlexSearch / Meilisearch:倒排检索和模糊能力更强,但当前数据规模用不上;SQLite 与 Node 18/Bun baseline 双运行时存在部署成本,外部服务还有运维和隐私成本,保留为规模升级路径。
## 推荐架构
- 新增独立 `SessionSearchIndex` 模块,接口固定为 `initialize/upsert/remove/search/status`,具体后端首期使用内存文档集合,未来可替换而不改 WebSocket/UI 协议。
- 索引粒度为“消息文档”:sessionId、messageIndex、role、timestamp、原始 snippet 文本、规范化文本;另保存每会话 title/project/cwd/id 元数据。
- 规范化使用 Unicode NFKC、lowercase、合并空白;首期做精确短语 + 多词 AND,不做拼音、向量语义或重型分词。
- 缓存存放到 `sessions/_search/index-v1.json`,与原始会话处于同一数据权限边界;缓存是派生数据,版本不匹配、损坏或缺失时自动后台重建。
- 启动先载入缓存,再通过文件 size + mtime 校验;只解析新增/变化的会话并移除已删除项。首次全量建索引异步分批执行,不阻塞服务监听。
- `saveSession` 成功后按 sessionId debounce 增量 upsert;rename 立即更新元数据;delete 立即 remove;缓存写盘合并并使用原子替换。
- 搜索请求只访问内存索引,绝不在请求路径读取全部会话文件。
## 协议与结果形状
- 请求:`search_sessions { requestId, query, agent, limit }`;query 最大 200 字符,limit 默认 30、最大 50。
- 响应:`session_search_results { requestId, query, indexState, tookMs, results }`。
- 单条结果只返回 sessionId、title、projectName、updated、score、matchType、matchedMessageCount,以及最多 2 个 200 字符左右的 snippet;不返回完整历史。
- 前端使用 requestId 丢弃乱序旧响应;输入 debounce 建议 160–220 ms。
## 排序与展示
- 搜索模式改为扁平相关性列表,清空查询后恢复现有按项目/置顶分组;避免“置顶/项目顺序”压过真正的命中相关性。
- 分值优先级:标题前缀 > 标题包含 > 用户消息精确短语 > 助手消息精确短语 > 多词 AND > 项目/cwd/id;最后只给近期和置顶小幅加分。
- 每条结果展示项目、命中来源(标题/用户消息/助手回复/路径)、相对时间、命中片段和命中数;关键词高亮必须基于文本节点,不能拼接未转义 HTML。
- 一个字符仅做现有元数据过滤;至少两个字符才发起正文搜索,避免单字产生大量无意义结果。
- 索引构建时继续提供标题/项目本地过滤,并显示“正在建立内容索引 x%”;完成后自动重跑当前查询。
## 分期建议
- P0(核心找回):正文索引、增量缓存、WebSocket 查询、相关性列表、命中片段、索引状态、独立回归测试。
- P1(精准定位):点击内容命中后按 messageIndex 打开会话、自动补载历史页、滚动并短暂高亮目标消息。
- P2(规模升级,可选):增加时间/项目/角色筛选;当可检索正文超过 100 MB、会话超过 5000 或 p95 查询持续超过 50 ms 时,再切换纯 JS 倒排或 FTS 后端。
## 安全与可运维性
- 默认排除 system、tool input、tool result 和附件;搜索词不写日志,日志只记录 tookMs、文档数、缓存命中/重建状态。
- 缓存文件权限应收紧到当前用户,写入采用临时文件 + 原子 rename;缓存删除不会丢业务数据,只会触发重建。
- 服务端限制查询长度、结果数和 snippet 长度;前端高亮不得使用未经 escape 的 `innerHTML`,防止历史消息形成持久型 XSS。
## 预计实施范围
- 新增 `lib/session-search-index.js`:索引构建、缓存、规范化、排序、snippet 和状态。
- 修改 `server.js`:初始化索引、生命周期钩子、WebSocket 请求/响应、状态与限流。
- 修改 `public/app.js`:debounce、异步请求状态、乱序响应保护、扁平结果渲染;P1 增加命中定位。
- 修改 `public/index.html` / `public/style.css`:占位文案、索引状态、snippet/高亮,并覆盖移动端和现有主题。
- 修改 `scripts/regression.js`:新增独立搜索契约、增量一致性、缓存恢复与前端安全断言。
- 不修改现有会话 JSON schema,不引入第三方运行依赖,不要求一次性数据迁移。
## 验收标准
- 能用只出现在历史用户消息或助手回复中的中英文片段找到正确会话;标题、项目、路径、ID 的原能力不回退。
- system/tool/附件内容默认不产生命中;搜索响应不包含完整会话正文。
- 新建/新增消息在持久化后 2 秒内可检索,重命名立即生效,删除后不再命中;重启、缓存缺失、缓存损坏均能自动收敛。
- 搜索结果按相关性而非项目顺序展示,snippet 正确转义;乱序响应不会覆盖新查询。
- 当前规模 p95 服务端查询低于 50 ms,WebSocket 单次响应受 50 条和 snippet 上限约束;全量重建不阻塞正常会话操作。
- `npm run regression` 通过,并对 Node 启动与 Bun baseline 单文件构建分别验证。
## 工作量与风险
- P0 属于中等改动,预计 1.5–2 个开发日;P1 命中定位约 0.5–1 个开发日。主要成本在异步状态、缓存一致性和回归覆盖,不在搜索算法。
- 最大风险是索引陈旧和构建期阻塞;通过中心 save 钩子 + 启动 mtime/size 对账 + 分批异步重建解决。
- 缓存会复制约 4.26 MB 当前正文,需和 sessions 同权限、禁止误打进空白发布包或日志;它不是唯一数据源,可随时重建。
- 活跃助手流式内容可能要到下一次持久化才可检索;首期以历史找回为目标,这个延迟可接受并应在测试中明确。