03 · 搜索架构:SQLite FTS5 + Chroma 混合检索
Hybrid Search 双引擎(FTS5 全文 + ChromaDB 向量)通过 intersectWithRanking 融合,3 层 MCP 工作流(search → timeline → get_observations)实现 10x token 节省。
总览
claude-mem 的搜索系统围绕两个存储引擎构建:SQLite FTS5 全文检索 作为离线/降级路径,ChromaDB 向量检索 作为主路径。二者通过 SearchManager → SearchOrchestrator 协调,最终以 3 层 MCP 工作流 对外暴露。
|
|
一、3 层搜索工作流
设计意图
MCP 工具定义(src/servers/mcp-server.ts L183-216)把工作流的哲学直接写进了工具名:
|
|
三层的 token 开销对比(来自 plugin/skills/mem-search/SKILL.md):
| 层 | 每条结果 token 量 | 说明 |
|---|---|---|
| search(索引) | ~50–100 | 仅 ID、时间、标题 |
| timeline(上下文) | 同上,但带时序 | 交叉展示 obs/session/prompt |
| get_observations(全量) | ~500–1000 | narrative + facts + concepts |
不直接返回全量数据的原因:LLM context window 昂贵,一次查 20 条 observation 全量约 10,000–20,000 tokens;而 search 索引层仅需 1,000–2,000 tokens。用户往往只需其中 2-3 条详情——先 filter 再 fetch,节省约 10×。
💡 Tip token 估算实现
src/shared/timeline-formatting.ts中的estimateTokens(text)用Math.round(text.length / 4)估算(4 字符≈1 token)。这是一个常见的英文 token 近似,在TimelineBuilder.formatTimelineL206 用于在 Markdown 表格中展示~N列,帮助用户在 step 2 就感知到 step 3 的代价。
二、Hybrid Search:FTS5 与 Chroma 的融合策略
搜索路径决策树
SearchManager.search() 的三条路径(src/services/worker/SearchManager.ts L153-293):
|
|
PATH 2:Chroma 主路径
|
|
关键设计:Chroma 只负责语义排序(返回相似度排名的 ID 列表),实际数据水合仍由 SQLite 完成。这是一种"索引与存储分离"的架构——Chroma 是索引,SQLite 是 source of truth。
💡 Tip
doc_type元数据的作用 每条 Chroma document 都携带doc_type(observation/session_summary/user_prompt)和sqlite_id两个关键元数据字段(ChromaMetadata类型,src/services/worker/search/types.tsL21-35)。查询结果只用 ID,不读 Chroma 里存的文档内容,避免了 Chroma 和 SQLite 内容不一致的问题。
Hybrid Strategy:元数据过滤 + 语义重排
HybridSearchStrategy(src/services/worker/search/strategies/HybridSearchStrategy.ts)用于 findByConcept / findByType / findByFile:
|
|
融合策略:intersectWithRanking 做的是集合交集,以 Chroma 排名为权重。Chroma 在先(语义相关性高的在前),SQLite 过滤在后(保证满足 concept/file/type 约束)。两者都命中才入选,最终排名完全由 Chroma 决定。
⚠️ Warning 无权重混合,只有交集 当前
HybridSearchStrategy.search()方法体直接return this.emptyResult('hybrid')(L42),说明通用 query 的 hybrid 路径尚未完整实现。实际 query 检索走的是SearchOrchestrator.executeWithFallback→ChromaSearchStrategy.search(纯 Chroma),而非真正的加权融合。Hybrid 策略目前仅在 findByConcept/findByType/findByFile 三个场景生效。
PATH 3:FTS5 降级路径
|
|
FTS5 的内置 rank 字段(BM25 变体)用于 orderBy='relevance' 排序(buildOrderClause L218-229)。
FTS5 表结构(SessionSearch.ts L73-86):
|
|
使用 content= 选项让 FTS5 成为外部内容表——不重复存储内容,FTS5 只维护倒排索引,数据仍在 observations 主表。触发器(observations_ai/au/ad)保持同步。
💡 Tip FTS5 平台可用性检测
SessionSearch在构造时会CREATE VIRTUAL TABLE _fts5_probe USING fts5(test_column)然后 drop(L63-71),探测 FTS5 是否可用,失败则_fts5Available = false,搜索降级为 LIKE 查询(或依赖 Chroma)。这解决了某些 SQLite 编译版本不带 FTS5 的问题。
三、ChromaSync:向量库与 SQLite 的同步机制
一条 Observation 在 Chroma 中变成多个 Document
ChromaSync.formatObservationDocs()(src/services/sync/ChromaSync.ts L102-158)将一条 observation 分解为多个向量文档:
| Chroma Document ID | 内容 | 说明 |
|---|---|---|
obs_{id}_narrative |
obs.narrative | 叙事描述,最关键 |
obs_{id}_text |
obs.text | 旧字段,遗留 |
obs_{id}_fact_0/1/... |
每条 fact 单独一个 doc | 细粒度检索 |
每个 document 携带相同的 baseMetadata(含 sqlite_id、doc_type、project、created_at_epoch)。
为什么拆分:narrative 可能很长,fact 是短句——Chroma/embedding 模型对短文本编码更精准;拆分后单条 fact 也能被独立检索到,再通过 sqlite_id 合并回同一条 observation。
增量同步:Watermark 机制
同步状态由 ChromaSyncState(src/services/sync/ChromaSyncState.ts)管理,每个 project 维护三个 watermark(observations、summaries、prompts),值为已同步的最大 SQLite ID。
backfill 逻辑(backfillObservations L590-688):
|
|
⚠️ Warning 非连续 gap 的保守策略 代码注释(L624-632)详细说明了为什么不能跳过 gap:watermark 是单调递增的单个 ID,无法表示"200 之前同步了,201-250 失败,251 以后又同步了"。一旦有 gap,后续批次即便成功也不能 bump watermark,否则下次启动 backfill 时 watermark 跳过了 gap 区间,那些 document 就永远丢失。这是典型的宁可重复同步、不可漏同步的设计。
全量 vs 增量
| 场景 | 触发 | 机制 |
|---|---|---|
| 正常写入 | 每条 observation 保存后 | syncObservation() 单条同步 |
| 启动 backfill | Worker 启动 | backfillAllProjects() → ensureBackfilled() watermark 增量补齐 |
| watermark 丢失 | 检测到 Chroma 有数据但 watermark=0 | bootstrapWatermarksFromChroma() 扫描 Chroma 中已有 ID 重建 watermark |
| 无全量重建 | — | 项目没有全量删除重建逻辑,只有 watermark 增量 |
Chroma 去重
deduplicateQueryResults()(ChromaSync.ts L896-936):由于一条 observation 被拆成多个 document,同一条 obs 的 narrative 和 facts 可能都命中 query,Chroma 会返回多个 obs_123_* 的 ID。去重逻辑用 entityType:sqliteId 作为 key,保证每个 sqlite_id 只出现一次,且取第一个(语义距离最小的那个)。
四、MCP 服务器工具设计
src/servers/mcp-server.ts 注册了 8 个工具,核心 3 个:
search(Step 1)
|
|
type:observations/sessions/prompts/all(控制搜索哪张表)obs_type:bugfix,feature,decision,discovery,change(逗号分隔,在 SQLite 层过滤type字段)- 返回 Markdown 表格索引,每行约 50-100 tokens
timeline(Step 2)
|
|
anchor 支持三种格式(SearchManager.timeline L510-540):
- 数字:observation ID,直接定位
"S123":session ID,用 session 的时间戳做时间轴中心- ISO timestamp 字符串:纯时间轴定位
get_observations(Step 3)
|
|
注意用 POST 而非 GET,因为 ids 是数组,GET query string 不友好。DataRoutes.ts L97 注册此路由,通过 zod schema 校验 body。
💡 Tip 额外工具:
__IMPORTANTmcp-server 注册了一个名为__IMPORTANT的工具(L184-216),inputSchema 为空对象(无参数),作用是在工具列表顶部展示 3 层工作流说明——利用 LLM 工具描述优先读取的特性,确保 AI 每次 tool call 前都能看到工作流提示。这是一个用工具定义当系统提示的小技巧。
五、Timeline 的 Anchor 机制
为什么需要 anchor
单条 observation 的内容是孤立的——不知道它发生在什么背景下,前面做了什么、后面怎么收尾。timeline 工具的设计是围绕一个锚点展开时序上下文,将 observation、session_summary、user_prompt 按时间排成一条线,让 LLM 在读 step 3 之前先理解"这件事发生在哪个工作流中"。
anchor 定位实现
TimelineBuilder.findAnchorIndex()(src/services/worker/search/TimelineBuilder.ts L72-94):
|
|
找到 anchorIndex 后,取 [anchorIndex - depthBefore, anchorIndex + depthAfter + 1] 窗口切片(filterByDepth L54-69)。
timeline 数据来源
SessionStore.getTimelineAroundObservation()(被 SearchManager.timeline L493/509 调用):从 SQLite 拉取 anchor 时间戳前后的 observations、sessions、prompts,三类一起返回,由 TimelineBuilder.buildTimeline() 按 epoch 合并排序成统一的 TimelineItem[] 数组。
query 模式的自动 anchor
当用户传 query 而非 anchor 时,SearchManager.timeline() L455-493 先做一次语义/FTS5 搜索,取 results[0](最相关的那条)作为 anchor,然后展开它的时序上下文。这样用户可以直接说 “timeline around ‘JWT authentication’” 而不必先知道 ID。
六、关键常量
来自 src/services/worker/search/types.ts L6-11:
|
|
RECENCY_WINDOW_MS 是 Chroma 检索的隐式过滤器(SearchManager L214):没有指定 dateRange 时,默认只看 90 天内的结果,防止陈旧语义向量干扰当前工作。
七、整体数据流总结
参考文件
| 文件 | 行数 | 职责 |
|---|---|---|
src/services/worker/SearchManager.ts |
1658 | 搜索门面,search/timeline/decisions 实现 |
src/services/worker/search/SearchOrchestrator.ts |
216 | 策略路由 |
src/services/worker/search/strategies/HybridSearchStrategy.ts |
172 | 元数据过滤 + Chroma 重排 |
src/services/worker/search/strategies/ChromaSearchStrategy.ts |
195 | 纯语义路径 |
src/services/worker/search/TimelineBuilder.ts |
262 | anchor 展开 + Markdown 格式化 |
src/services/worker/search/ResultFormatter.ts |
~200 | 索引表 Markdown 格式化 |
src/services/sqlite/SessionSearch.ts |
596 | FTS5 全文检索 |
src/services/sync/ChromaSync.ts |
1099 | Chroma 同步、backfill、queryChroma |
src/servers/mcp-server.ts |
648 | MCP 工具注册 |
plugin/skills/mem-search/SKILL.md |
132 | 使用说明(面向 LLM) |