claude-mem 源码分析:搜索架构 — SQLite FTS5 + Chroma 混合检索

03 · 搜索架构:SQLite FTS5 + Chroma 混合检索

Hybrid Search 双引擎(FTS5 全文 + ChromaDB 向量)通过 intersectWithRanking 融合,3 层 MCP 工作流(search → timeline → get_observations)实现 10x token 节省。

总览

claude-mem 的搜索系统围绕两个存储引擎构建:SQLite FTS5 全文检索 作为离线/降级路径,ChromaDB 向量检索 作为主路径。二者通过 SearchManagerSearchOrchestrator 协调,最终以 3 层 MCP 工作流 对外暴露。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
MCP Client
mcp-server.ts          ← MCP 工具注册层(search / timeline / get_observations)
   │  HTTP
Worker HTTP API         ← Express on port 37700+(uid%100)
SearchManager           ← 搜索门面,持有所有依赖
   ├─ SearchOrchestrator  ← 策略调度
   │     ├─ ChromaSearchStrategy
   │     ├─ SQLiteSearchStrategy
   │     └─ HybridSearchStrategy
   ├─ TimelineBuilder    ← anchor 展开
   └─ ResultFormatter    ← Markdown 表格输出

一、3 层搜索工作流

设计意图

MCP 工具定义(src/servers/mcp-server.ts L183-216)把工作流的哲学直接写进了工具名:

1
2
3
4
5
6
// mcp-server.ts L186-214
description: `3-LAYER WORKFLOW (ALWAYS FOLLOW):
1. search(query) → Get index with IDs (~50-100 tokens/result)
2. timeline(anchor=ID) → Get context around interesting results
3. get_observations([IDs]) → Fetch full details ONLY for filtered IDs
NEVER fetch full details without filtering first. 10x token savings.`

三层的 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.formatTimeline L206 用于在 Markdown 表格中展示 ~N 列,帮助用户在 step 2 就感知到 step 3 的代价。


二、Hybrid Search:FTS5 与 Chroma 的融合策略

搜索路径决策树

SearchManager.search() 的三条路径(src/services/worker/SearchManager.ts L153-293):

1
2
3
4
有 query 文本?
├── 否 → PATH 1: 纯 SQLite 过滤(dateRange / project / concepts / files)
├── 是 + Chroma 可用 → PATH 2: Chroma 语义检索(失败则 fallback FTS5)
└── 是 + Chroma 不可用 → PATH 3: FTS5 关键词检索

PATH 2:Chroma 主路径

1
2
3
4
5
6
7
8
9
// SearchManager.ts L192-223(核心片段)
const chromaResults = await this.queryChroma(query, 100, whereFilter);
// 90 天滑动窗口过滤(RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000)
const recentMetadata = chromaResults.metadatas
  .filter(item => item.isRecent);
// 按 doc_type 分桶
obsIds / sessionIds / promptIds
// 再通过 SQLite 按 ID 水合
observations = this.sessionStore.getObservationsByIds(obsIds, obsOptions);

关键设计:Chroma 只负责语义排序(返回相似度排名的 ID 列表),实际数据水合仍由 SQLite 完成。这是一种"索引与存储分离"的架构——Chroma 是索引,SQLite 是 source of truth。

💡 Tip doc_type 元数据的作用 每条 Chroma document 都携带 doc_typeobservation / session_summary / user_prompt)和 sqlite_id 两个关键元数据字段(ChromaMetadata 类型,src/services/worker/search/types.ts L21-35)。查询结果只用 ID,不读 Chroma 里存的文档内容,避免了 Chroma 和 SQLite 内容不一致的问题。

Hybrid Strategy:元数据过滤 + 语义重排

HybridSearchStrategysrc/services/worker/search/strategies/HybridSearchStrategy.ts)用于 findByConcept / findByType / findByFile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// HybridSearchStrategy.ts L111-135
private async rankAndHydrate(queryText, metadataIds, limit) {
  // 1. Chroma 语义搜索,批量大小 = min(元数据结果数, 100)
  const chromaResults = await this.chromaSync.queryChroma(
    queryText,
    Math.min(metadataIds.length, SEARCH_CONSTANTS.CHROMA_BATCH_SIZE)
  );
  // 2. 取交集:保留同时出现在元数据过滤结果和 Chroma 语义结果中的 ID
  //    顺序以 Chroma 的语义排名为准
  const rankedIds = this.intersectWithRanking(metadataIds, chromaResults.ids);
  // 3. 按 Chroma 排名顺序从 SQLite 水合
  observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
}

融合策略intersectWithRanking 做的是集合交集,以 Chroma 排名为权重。Chroma 在先(语义相关性高的在前),SQLite 过滤在后(保证满足 concept/file/type 约束)。两者都命中才入选,最终排名完全由 Chroma 决定。

⚠️ Warning 无权重混合,只有交集 当前 HybridSearchStrategy.search() 方法体直接 return this.emptyResult('hybrid')(L42),说明通用 query 的 hybrid 路径尚未完整实现。实际 query 检索走的是 SearchOrchestrator.executeWithFallbackChromaSearchStrategy.search(纯 Chroma),而非真正的加权融合。Hybrid 策略目前仅在 findByConcept/findByType/findByFile 三个场景生效。

PATH 3:FTS5 降级路径

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// SessionSearch.ts L255-278(FTS5 核心查询)
const sql = `
  SELECT o.*, o.discovery_tokens
  FROM observations o
  JOIN observations_fts ON observations_fts.rowid = o.id
  WHERE observations_fts MATCH ?
  ${filterClause ? 'AND ' + filterClause : ''}
  ${orderClause}
  LIMIT ? OFFSET ?
`;
const escapedQuery = '"' + query.replace(/"/g, '""') + '"';

FTS5 的内置 rank 字段(BM25 变体)用于 orderBy='relevance' 排序(buildOrderClause L218-229)。

FTS5 表结构SessionSearch.ts L73-86):

1
2
3
4
5
CREATE VIRTUAL TABLE observations_fts USING fts5(
  title, subtitle, narrative, text, facts, concepts,
  content='observations',  -- 外部内容表,节省磁盘
  content_rowid='id'
);

使用 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_iddoc_typeprojectcreated_at_epoch)。

为什么拆分:narrative 可能很长,fact 是短句——Chroma/embedding 模型对短文本编码更精准;拆分后单条 fact 也能被独立检索到,再通过 sqlite_id 合并回同一条 observation。

增量同步:Watermark 机制

同步状态由 ChromaSyncStatesrc/services/sync/ChromaSyncState.ts)管理,每个 project 维护三个 watermark(observationssummariesprompts),值为已同步的最大 SQLite ID

backfill 逻辑(backfillObservations L590-688):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 只拉取 id > watermark 的记录(增量)
const observations = db.prepare(`
  SELECT * FROM observations
  WHERE project = ? AND id > ?
  ORDER BY id ASC
`).all(project, watermark);

// 按 BATCH_SIZE=100 批次写入 Chroma
// 只有整批写入成功才 bump watermark
if (writtenInBatch < batch.length) { hadGap = true; continue; }
if (hadGap) { continue; }  // 一旦出现 gap,后续批次也不 bump
ChromaSyncState.bump(project, 'observations', lastSyncedId);

⚠️ 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)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// mcp-server.ts L219-239
{
  name: 'search',
  description: 'Step 1: Search memory. Returns index with IDs.',
  params: {
    query, limit, project, type, obs_type,
    dateStart, dateEnd, offset, orderBy
  }
  // 代理到 /api/search(GET)
}
  • typeobservations / sessions / prompts / all(控制搜索哪张表)
  • obs_typebugfix,feature,decision,discovery,change(逗号分隔,在 SQLite 层过滤 type 字段)
  • 返回 Markdown 表格索引,每行约 50-100 tokens

timeline(Step 2)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// mcp-server.ts L241-258
{
  name: 'timeline',
  params: {
    anchor,       // 数字 ID 或 "S123"(session)
    query,        // 自动找 anchor(二选一)
    depth_before, // 默认 10
    depth_after,  // 默认 10
    project
  }
}

anchor 支持三种格式SearchManager.timeline L510-540):

  • 数字:observation ID,直接定位
  • "S123":session ID,用 session 的时间戳做时间轴中心
  • ISO timestamp 字符串:纯时间轴定位

get_observations(Step 3)

1
2
3
4
5
6
7
8
9
// mcp-server.ts L260-277
{
  name: 'get_observations',
  params: {
    ids: number[],  // required,批量
    orderBy, limit, project
  }
  // POST /api/observations/batch
}

注意用 POST 而非 GET,因为 ids 是数组,GET query string 不友好。DataRoutes.ts L97 注册此路由,通过 zod schema 校验 body。

💡 Tip 额外工具:__IMPORTANT mcp-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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private findAnchorIndex(items, anchorId, anchorEpoch): number {
  if (typeof anchorId === 'number') {
    // 按 ID 精确定位 observation
    return items.findIndex(item =>
      item.type === 'observation' && item.data.id === anchorId
    );
  }
  if (typeof anchorId === 'string' && anchorId.startsWith('S')) {
    // 按 session ID 定位 session 节点
    const sessionNum = parseInt(anchorId.slice(1), 10);
    return items.findIndex(item =>
      item.type === 'session' && item.data.id === sessionNum
    );
  }
  // 纯时间戳:找第一个 epoch >= anchorEpoch 的节点
  const index = items.findIndex(item => item.epoch >= anchorEpoch);
  return index === -1 ? items.length - 1 : index;
}

找到 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:

1
2
3
4
5
6
export const SEARCH_CONSTANTS = {
  RECENCY_WINDOW_DAYS: 90,
  RECENCY_WINDOW_MS: 90 * 24 * 60 * 60 * 1000,  // Chroma 结果的时间窗口
  DEFAULT_LIMIT: 20,
  CHROMA_BATCH_SIZE: 100   // 单次 Chroma query 的最大返回数
} as const;

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)