跳到主要内容

存储层:SQLite(memory_items + FTS5)与 Chroma 向量镜像

30 秒导读: 03 章 把一段会话压成了结构化的「观察(observation)」和「摘要(summary)」。本章讲这些东西存到哪、怎么建索引让以后能查回来。 claude-mem 用了两套索引:SQLite 存事实原文并做关键词全文检索,Chroma 存同一批文本的向量语义检索;两边靠一个自增数字 id 和一条叫 watermark 的「水位线」保持同步。


1. 这是什么(零基础也能懂)

一句话定义: 存储层是 claude-mem 的「磁盘」——把压好的观察/摘要/用户 prompt 落成可持久、可检索的记录。

它解决什么问题: 压缩管线产出的是内存里的对象,一关机就没了。要做到「跨会话记忆」,必须:

  1. 可靠落盘 —— 断电、重启、进程被 kill 都不能丢数据,也不能重复。
  2. 查得回来 —— 下次开新会话时,能根据「当前在干什么」快速捞出相关的旧记忆。

一句话直觉/类比: 把它想成一座图书馆的两套目录卡片。

  • SQLite = 书架本身 + 按书名/关键词排的卡片盒:书(事实原文)真实存在这里,你知道确切词时按关键词秒查(FTS5 全文检索)。
  • Chroma = 按「主题相近」排的卡片盒:同一批书的「语义指纹」(向量)存这里,你只记得大概意思、说不出确切词时,靠它找「意思相近」的书。

为什么要两套? 关键词检索精确但死板(查 "auth" 找不到只写了 "login" 的记录);语义检索灵活但模糊。两套并用,查询时再融合(见 05 章)。这就是本章的主题词:双索引(dual index)

本节不碰代码。记住一句话就够:SQLite 是事实来源(source of truth),Chroma 只是它的语义镜像。 镜像坏了可以从 SQLite 重建,反过来不行。


2. 顶层全景(它大概怎么转)

2.1 一张图:数据从压缩产物到两套索引

怎么读这张图:从上往下是「一条观察」的落库与建索引流向;左边是 SQLite 一侧(事实 + 关键词),右边是 Chroma 一侧(向量),底部是让两边对齐的机制。

03 章产出:ParsedObservation / ParsedSummary / 用户 prompt


┌──────────────────────────────┐
│ SessionStore.storeObservations│ ← 事实来源写入(SQLite)
│ · content_hash 去重(幂等) │
│ · ON CONFLICT DO NOTHING │
└──────────────┬───────────────┘
│ 自增 id (INTEGER PK)
┌───────────────┴────────────────┐
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ observations 表 │ AFTER │ ChromaSync │
│ (+ session_ │ 触发器 │ syncObservation() │
│ summaries, │──同步───▶ │ · 每字段拆成一个 doc │
│ user_prompts) │ │ · doc id = obs_<id>_x │
│ │ │ │ · 批量 100 写入 │
│ ▼ │ └──────────┬───────────┘
│ observations_fts │ │ 向量化
│ (FTS5 关键词索引) │ ▼
└────────────────────┘ ┌──────────────────────┐
│ Chroma 集合 │
── 关键词检索一侧 ── │ cm__<project> │
│ (语义向量镜像) │
└──────────┬───────────┘

对齐机制:sqlite id ⇄ chroma doc-id(obs_42_fact_0)
+ watermark 水位(chroma-sync-state.json)

2.2 一个关键事实:这里其实有两套 SQLite schema

读源码时最容易被绕晕的一点——同一个仓库里,「记忆」被存在两套并存的 SQLite 表结构里,别把它们混为一谈:

schema主表FTS 表定义位置谁在用
运行时/遗留observationssession_summariesuser_promptsobservations_fts(外部内容表)src/services/sqlite/SessionStore.tsSessionSearch.tsworker 实时写入;Chroma 镜像的就是它
服务端存储 v1memory_items(统一单表)memory_items_fts(独立内容)src/storage/sqlite/schema.tsHTTP /v1 API(ServerV1Routes.ts)、多租户/鉴权

两者靠 memory_items.legacy_observation_id 这个字段桥接——新表可以回指旧表的一行观察(schema.ts:89)。

  • 本章任务点名要讲的 schema.ts(memory_items + memory_items_fts)较新的统一存储,把观察/摘要/prompt/手动记忆四种 kind 收进一张表。
  • 本章同样点名的 content_hash 去重ChromaSync 镜像,发生在运行时的 observations上。

所以下面的讲解会明确标注「哪套 schema」。这不是矛盾,是项目正处在把分散旧表往统一新表收敛的过程中——理解这一点,后面全通了。


3. 核心机制(逐个,由浅入深)

3.1 memory_items:一张表装下四种记忆(schema.ts)

要解决的小问题: 旧设计里观察、摘要、prompt 各一张表,字段各不相同,查询要 union 一堆表。新设计想用一张统一表装下所有「记忆条目」。

思路: 用一个 kind 列区分种类,一个 type 列放子类型,其余字段做成两类都能复用的通用列。

看核心定义 src/storage/sqlite/schema.ts:85-105(表 memory_items):

CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY, -- randomUUID(),不是自增
project_id TEXT NOT NULL,
server_session_id TEXT,
legacy_observation_id INTEGER, -- 回指旧 observations 表的桥
kind TEXT NOT NULL CHECK(kind IN ('observation','summary','prompt','manual')),
type TEXT NOT NULL,
title TEXT, subtitle TEXT, text TEXT, narrative TEXT,
facts TEXT NOT NULL DEFAULT '[]', -- ↓ 全是 JSON 文本列
concepts TEXT NOT NULL DEFAULT '[]',
files_read TEXT NOT NULL DEFAULT '[]',
files_modified TEXT NOT NULL DEFAULT '[]',
metadata TEXT NOT NULL DEFAULT '{}',
created_at_epoch INTEGER NOT NULL,
updated_at_epoch INTEGER NOT NULL,
...
);

关键设计:JSON 文本列。 facts / concepts / files_read / files_modified数组,metadata对象——SQLite 没有原生数组/对象类型,claude-mem 的做法是把它们 JSON.stringify 成一个 TEXT 列存进去,读出来再 JSON.parse。这套序列化封装在 src/storage/sqlite/serde.ts(stringifyJson / parseJsonArray / parseJsonObject),写读两端分别在 memory-items.ts:125-129(写)和 memory-items.ts:63-67(读)。

为什么用 JSON 列而不是关系表? 这些数组是「随记忆条目一起读写、不需要独立查询」的从属数据。塞进 JSON 列避免了额外 join,代价是没法用 SQL 直接对数组元素做过滤——但那个活交给了下面的 FTS 和 Chroma。

id 是 UUID 不是自增。 和运行时 observations 表的 INTEGER PRIMARY KEY AUTOINCREMENT(SessionStore.ts:513)不同,memory_itemsrandomUUID()(memory-items.ts:105)。这是服务端/多租户存储的典型选择:id 全局唯一,跨库合并不撞号。

关系表:围绕 memory_items 的四张附属表

SERVER_OWNED_TABLES(schema.ts:7-17)一共九张,和「记忆」直接相关的四张关系如下:

角色与 memory_items 的关系
projects项目(租户)根project_id 外键引用;删项目级联删记忆
server_sessions一次会话server_session_id 引用;删会话时置 NULL(ON DELETE SET NULL)
agent_events生命周期事件流(hook/worker/…)同挂 project_id;和记忆平级
memory_sources记忆的来源出处memory_item_id 外键指向记忆;记录它从哪张 legacy 表、哪个 legacy_id 迁来

memory_sources(schema.ts:107-117)是「双索引对齐」在新 schema 一侧的锚点:它用 (source_type, legacy_table, legacy_id) 三元组记住每条记忆的原始出处,并有唯一索引 ux_memory_sources_legacy_source(schema.ts:200-204)防止同一来源被导入两次。

3.2 memory_items_fts:关键词全文检索(FTS5)

要解决的小问题: 用户查 "怎么配置 auth",要能在成千上万条记忆里按快速命中。SQLite 的 FTS5 扩展就是干这个的:建一张虚拟表,对指定文本列做倒排索引。

看定义 schema.ts:170-182:

CREATE VIRTUAL TABLE IF NOT EXISTS memory_items_fts USING fts5(
memory_item_id UNINDEXED, -- 存但不建索引:仅用来 join 回主表
project_id UNINDEXED, -- 存但不建索引:仅用来按项目过滤
title, subtitle, text, narrative, facts, concepts, -- 这 6 列参与分词
tokenize='porter unicode61'
);

两个关键点:

  • porter unicode61 分词器。 unicode61 按 Unicode 规则切词(能正确处理各种语言的字母/数字边界),porter 是词干还原(把 running / ran / runs 归一到 run),让英文查询更宽容。
  • UNINDEXED 列。 memory_item_idproject_id 只是跟着存,不进倒排索引——查询时用来把 FTS 命中 join 回 memory_items 主表、并按项目过滤。真正被分词检索的是那 6 个文本列。

查询长这样(memory-items.ts:246-260,MemoryItemsRepository.search):先把用户输入经 buildFtsQuery(memory-items.ts:86-95)清洗成每个 token 加引号的短语串,再 memory_items_fts MATCH ? 命中、join 回主表。

注意:这里的 memory_items_fts独立内容的 FTS5(数据靠触发器复制进来)。运行时那套 observations_fts 用的是 content='observations' 外部内容表(SessionSearch.ts:76-85)——只存索引不存原文、靠 rowid 指回主表,省空间但触发器写法不同(见 3.4 对比)。两种 FTS5 用法都在这个仓库里,别混。

3.3 三个 AFTER 触发器:让 FTS 永远和主表同步

要解决的小问题: FTS 是主表的副本索引。主表 insert/update/delete 后,FTS 必须跟着变,否则查询结果就和事实对不上。手动维护极易漏,所以交给数据库触发器自动做。

schema.ts:273-302 定义了三个 AFTER 触发器,一一对应主表的三种写操作:

触发器时机干什么
trg_memory_items_fts_insertAFTER INSERT把新行的文本列插进 FTS
trg_memory_items_fts_updateAFTER UPDATE先按 old.id 删旧 FTS 行,再插入新值
trg_memory_items_fts_deleteAFTER DELETEold.id 删对应 FTS 行

update 触发器的「先删后插」值得注意(schema.ts:285-295):因为 memory_item_idUNINDEXED 普通列、不是 FTS5 的特殊 rowid,没法原地改,只能删掉旧的 FTS 记录再插新的。

演示思路(示意,非源码):

-- 主表插一行,人什么都不用管,FTS 自动多一条:
INSERT INTO memory_items (id, title, ...) VALUES ('u-1', '配置 auth', ...);
-- 触发器随即执行 ↓
INSERT INTO memory_items_fts (memory_item_id, title, ...) VALUES ('u-1', '配置 auth', ...);

3.4 启动时的「count 漂移」自愈重建

要解决的小问题: 触发器只在它存在之后发生的写操作上生效。如果某次是先有了数据、后加的 FTS 表/触发器(或历史迁移、外部改库),FTS 就会少行、和主表对不齐。怎么发现并修?

claude-mem 的做法很朴素但有效——启动建 schema 时数一下两边的行数,不等就整体重建 FTS(schema.ts:183-197):

const memoryItemCount = db.prepare('SELECT COUNT(*) AS count FROM memory_items').get();
const ftsItemCount = db.prepare('SELECT COUNT(*) AS count FROM memory_items_fts').get();
if (memoryItemCount.count !== ftsItemCount.count) { // 数量漂移 → 全量重建
const rebuild = db.transaction(() => {
db.run('DELETE FROM memory_items_fts'); // 清空
db.run(`INSERT INTO memory_items_fts (...) SELECT ... FROM memory_items`); // 从主表重灌
});
rebuild();
}

重点看: 这段跑在 ensureServerStorageSchema(schema.ts:21)里,每次拿到 db 都会检查一次(用 WeakSet 保证同一 db 对象只初始化一次,schema.ts:19)。这体现了本章的一条主线思想——FTS 是可丢弃的派生数据:发现不一致时,不去精细修补,直接从「事实来源」主表整表重灌,简单且不会错。

3.5 跨 project 完整性:RAISE(ABORT) 前置校验触发器

要解决的小问题: 记忆是按 project(租户)隔离的。一条记忆挂在哪个 project 下,必须和它所属的 server_session 是同一个 project——否则查询会串租户、隔离被打穿。

schema.ts:213-271 用一组 BEFORE INSERT/UPDATE 触发器在写入前校验,不满足就 RAISE(ABORT) 直接让整条语句失败回滚。共两类:

触发器防的是
trg_agent_events_session_project_* / trg_memory_items_session_project_*插入/改动子行时,其 server_session_id 必须指向同一 project 的会话,否则 RAISE(ABORT, '... must belong to project_id')
trg_server_sessions_project_update一个会话已经有子行(事件/记忆)时,不许再改它的 project_id(否则子行会变成孤儿或跨租户),RAISE(ABORT)

为什么用触发器而不是应用层校验? 因为这是不变式(invariant)——不管哪条代码路径写库,数据库层都强制它成立。应用层校验会被绕过,数据库触发器不会。这是「把正确性下沉到存储层」的典型手法。

演示(示意,非源码):

-- 会话 S 属于项目 A。试图把一条属于项目 B 的记忆挂到 S 上:
INSERT INTO memory_items (project_id, server_session_id, ...) VALUES ('B', 'S', ...);
-- BEFORE INSERT 触发器发现 S 不属于 B → RAISE(ABORT) → 整条 INSERT 失败

3.6 content_hash 去重:同一条观察永不重复落库(observations 表)

本节切换到运行时 observations(SessionStore.ts)——这是 worker 实时写入、且被 Chroma 镜像的那套。

要解决的小问题: 压缩管线可能对同一段会话重跑(重试、恢复、重复触发),同一条观察就会被反复插入。得保证幂等:插过的不再插。

思路: 给每条观察算一个内容指纹,用它做唯一约束;插入时冲突就跳过、并返回已存在那行的 id。

指纹算法 src/services/sqlite/observations/store.ts:5-14(computeObservationContentHash):

// 用 (会话 id + 标题 + 叙述) 拼成一条,取 sha256 前 16 位十六进制
createHash('sha256')
.update([memorySessionId || '', title || '', narrative || ''].join('\x00'))
.digest('hex')
.slice(0, 16);

\x00(空字节)做分隔符,避免 "ab"+"c""a"+"bc" 撞成同一串。

落库逻辑 SessionStore.ts:1955-2039(storeObservations,整批包在一个事务里 db.transaction):

INSERT INTO observations (memory_session_id, ..., content_hash, ...)
VALUES (?, ..., ?, ...)
ON CONFLICT(memory_session_id, content_hash) DO NOTHING -- 冲突就静默跳过
RETURNING id;

重点看这个「插了没插到」的分支(SessionStore.ts:2027-2038):

  • RETURNING id 拿到了 → 说明是新行,记下 id。
  • 拿不到(被 DO NOTHING 吞了) → 说明已存在,再 SELECT 一次捞出已有行的 id(SessionStore.ts:2032),照样返回给调用方。

这样无论新旧,调用方都拿到一个稳定 id——去重对上层透明

唯一约束怎么来的: 早期数据没有 content_hash 列。迁移 dedupeObservationsByContentHash(SessionStore.ts:1348-1372)先给历史行补哈希,再用窗口函数 ROW_NUMBER() OVER (PARTITION BY memory_session_id, content_hash ORDER BY id) 把重复组里 rank>1 的老重复行删掉,最后才建唯一索引 ux_observations_session_hash——先清干净再加约束,否则建索引会因既有重复而失败。

3.7 Chroma 向量镜像:把 SQLite 增量搬进语义库

要解决的小问题: SQLite 的 FTS 只会关键词匹配。要做「意思相近」的语义检索,得把每条记忆的文本向量化存进 Chroma(一个向量数据库)。核心挑战是:只搬新增的,别每次全量重灌(向量化很贵)。

镜像逻辑全在 src/services/sync/ChromaSync.ts。三条主线:

(1) 一条记忆 → 多个向量 doc(字段级拆分)。 formatObservationDocs(ChromaSync.ts:138-202)把一条观察的每个可检索字段单独做成一个 Chroma document:

doc id 命名来自哪个字段
obs_<id>_narrativenarrative
obs_<id>_texttext
obs_<id>_fact_<index>facts 数组第 index 项

摘要类似(summary_<id>_learned 等,formatSummaryDocs:204),prompt 是 prompt_<id>(formatUserPromptDoc:476)。每个 doc 都带一份 metadata,里面 sqlite_id 字段回指 SQLite 主键(ChromaSync.ts:151)——这是跨库对齐的另一半锚点。字段级拆分让语义命中更细粒度:一条观察里只要某一个 fact 和查询相近就能被捞出。

(2) 批量写 + 容错 + 只在「全写成功」才推进 watermark。 addDocuments(ChromaSync.ts:286-373)按 BATCH_SIZE = 100(ChromaSync.ts:97)分批写,永不抛异常:某批失败就记日志、继续下一批,并返回真正写成功的条数。调用方据此决定要不要推进水位——syncObservation(ChromaSync.ts:416-418):

const written = await this.addDocuments(documents);
if (written === documents.length) { // 只有一条不落地才推进水位
ChromaSyncState.bump(project, 'observations', observationId);
} else {
logger.warn(...); // 部分失败 → 不推进,下次 backfill 重来
}

为什么这么较真? 见代码注释引用的 PR #2282:如果部分写失败还硬推 watermark,下次冷启动的回填就会跳过这些没同步上的记录,永久漏掉。宁可下次重试,也不能假装同步过了。

(3) 「已存在」冲突用「删了重加」和解。 若某批因 id 已存在报错,addDocuments(ChromaSync.ts:332-349)会 chroma_delete_documentschroma_add_documents 重放一遍——保证内容是最新的,同时把这批算作写成功。

3.8 watermark 水位:增量同步的记账本

要解决的小问题: 「只搬新增的」怎么知道搬到哪了?靠一条单调递增的水位线:记住「本 project 的观察已经同步到 id=N」,下次只处理 id > N 的。

水位存在 src/services/sync/ChromaSyncState.ts——一个进程内缓存 + 一个 JSON 文件 chroma-sync-state.json(ChromaSyncState.ts:16-19)。结构是 { project: { observations, summaries, prompts } },三种记忆各一条水位。

bump 的两条纪律(ChromaSyncState.ts:64-72):

bump(project, kind, id) {
if (!Number.isInteger(id) || id <= 0) return; // 非法 id 不记
const current = ...;
if (id <= current[kind]) return; // 只升不降(单调)
current[kind] = id; persist(); // 落盘:写 .tmp 再 rename(原子替换)
}

persist(ChromaSyncState.ts:44-52)先写 *.tmprenameSync——原子替换,避免写一半被 kill 留下损坏的水位文件。

3.9 冷启动回填:backfillAllProjects

要解决的小问题: worker 刚启动,或水位文件丢了,或 Chroma 被清过——SQLite 里有一堆记忆但 Chroma 里没有。得批量补齐,又不能重复搬已有的。

入口 ChromaSync.backfillAllProjects(ChromaSync.ts:928-986,static,worker 启动时 fire-and-forget 调):

  • 再入保护: 静态标志 backfillInProgress(ChromaSync.ts:914)防止两次回填叠着跑。
  • 水位丢失时先自举:chroma-sync-state.json 不存在(ChromaSyncState.exists()),先 bootstrapWatermarksFromChroma——反查 Chroma 里已有的最大 id(ChromaSync.ts:598-610)当作初始水位,避免把 Chroma 里已有的又搬一遍。
  • 并发上限 3: BACKFILL_CONCURRENCY_LIMIT = 3(ChromaSync.ts:911)。项目按 3 个一组切块,Promise.allSettled 并行跑完一组再下一组(ChromaSync.ts:962-982)——限制同时进行的向量化,避免 CPU/内存被打爆;allSettled 保证一个项目失败不拖垮其他。
  • 单调水位的「gap 守卫」: 共享回填循环 backfillKind(ChromaSync.ts:657-721)有个精巧点:一旦某批只写了一半(hadGap = true),后面所有批都不再推进水位——因为水位是单条数字 id,没法表达「同步到 200,201–250 有洞,251 又好了」。硬推过洞会永久丢掉 201–250(代码注释引用 PR #2282)。

4. 「双索引」如何对齐(本章的核心精华)

把前面的机制拧成一句话:SQLite 是事实来源,Chroma 是它的语义镜像;两者靠「id 命名 + watermark」对齐。

4.1 两个方向的锚点

对齐锚点在 SQLite 一侧在 Chroma 一侧
单条记录观察的自增 id(如 42)doc id obs_42_fact_0、metadata sqlite_id: 42
同步进度记录的最大已同步 idwatermark(chroma-sync-state.jsonobservations: 42)
新旧 schema 桥observations.idmemory_items.legacy_observation_id / memory_sources.legacy_id

查询时怎么合:Chroma 语义命中返回一串 doc id,deduplicateQueryResults(ChromaSync.ts:868-908)用正则 obs_(\d+)_ / summary_(\d+)_ / prompt_(\d+) 把 doc id 反解回 SQLite 数字 id,并按 entityType:id 去重(同一条观察的多个字段 doc 只算一次)。拿到 id 后再回 SQLite 取完整原文——Chroma 只负责「找到是哪几条」,原文永远从事实来源取。这套「向量选 id、SQLite 取正文」的融合细节见 05 章

4.2 为什么这套设计稳

  • 镜像可重建。 Chroma 丢了/坏了,靠水位反查 + backfill 就能从 SQLite 重新灌满;反之不行。所以事实只信 SQLite。
  • 同步幂等且可恢复。 content_hash 保证 SQLite 端不重;doc id 确定性命名 + 「已存在则删重加」保证 Chroma 端不重;watermark「只在全写成功才推进」保证进程被 kill 也不会漏。
  • 贵操作只做一次。 向量化只对 id > watermark 的新记录做,冷启动还先反查 Chroma 现状,绝不全量重灌。

4.3 存进来的数据从哪来 / 查出去怎么用

  • 上游(数据形态): 存进这两套索引的 ParsedObservation / ParsedSummary 是怎么从会话压出来、XML 骨架 prompt 如何解析落库,见 03 章
  • 下游(查询融合): SessionStart 如何把检索到的记忆注入新会话、MCP 三层检索如何融合 SQLite 关键词与 Chroma 语义两路结果,见 05 章

5. 边界与局限(诚实)

  • 两套 schema 并存,尚未收敛。 memory_items(新,schema.ts)和 observations(旧,SessionStore.ts)同时存在;运行时写入、content_hash 去重、Chroma 镜像目前跑在旧的 observations 一侧,新 memory_items 主要服务 /v1 HTTP API。legacy_observation_id 是二者的桥,但代码里看不出旧数据被自动全量迁进新表的路径(inferred:属演进中的过渡态)。
  • watermark 是单条数字 id,天然无法表达「空洞」。 一旦中间某条同步失败,只能靠「gap 守卫」停在洞前、下次重试补齐,而不能跳过洞继续。这是刻意的正确性取舍(见 3.9)。
  • Chroma 不可用时静默降级。 addDocuments 遇到 ChromaUnavailableError 返回 0、不推进水位、不抛错(ChromaSync.ts:293-301)——记忆照样进了 SQLite(仍可关键词检索),只是暂时没有语义镜像,等下次 backfill 补。
  • FTS 是派生数据,策略是「不修补、直接重建」。 count 漂移检测只看行数(schema.ts:185),不校验内容逐字一致;若行数恰好相等但内容错位(极端情况),不会被这层发现。

6. 代码地图(导航索引)

主题文件路径符号名
服务端存储 schema 版本src/storage/sqlite/schema.ts:5SERVER_STORAGE_SCHEMA_VERSION(=33)
统一记忆表 + JSON 列src/storage/sqlite/schema.ts:85-105memory_items
记忆来源出处(新 schema 桥)src/storage/sqlite/schema.ts:107-117memory_sources
FTS5 关键词索引(porter+unicode61)src/storage/sqlite/schema.ts:170-182memory_items_fts
count 漂移检测 + 全量重建src/storage/sqlite/schema.ts:183-197ensureServerStorageSchema(重建块)
三个 FTS 同步触发器src/storage/sqlite/schema.ts:273-302trg_memory_items_fts_insert/update/delete
跨 project 完整性校验src/storage/sqlite/schema.ts:213-271trg_*_session_project_*(RAISE(ABORT))
JSON 列序列化src/storage/sqlite/serde.tsstringifyJson / parseJsonArray / parseJsonObject
新表 CRUD + FTS 查询src/storage/sqlite/memory-items.ts:97MemoryItemsRepository(create / search)
观察落库 + 去重(旧表)src/services/sqlite/SessionStore.ts:1955storeObservations(ON CONFLICT DO NOTHING)
内容指纹src/services/sqlite/observations/store.ts:5computeObservationContentHash
历史去重迁移src/services/sqlite/SessionStore.ts:1348dedupeObservationsByContentHash
旧表外部内容 FTSsrc/services/sqlite/SessionSearch.ts:74createFTSTablesAndTriggers(observations_fts)
一条记忆→多向量 docsrc/services/sync/ChromaSync.ts:138formatObservationDocs
批量写 + 容错 + 只全写才推水位src/services/sync/ChromaSync.ts:286addDocuments / syncObservation
冷启动回填 + 并发限制src/services/sync/ChromaSync.ts:928backfillAllProjects(BACKFILL_CONCURRENCY_LIMIT=3)
共享回填循环 + gap 守卫src/services/sync/ChromaSync.ts:657backfillKind
doc-id 反解 SQLite idsrc/services/sync/ChromaSync.ts:868deduplicateQueryResults
watermark 水位记账src/services/sync/ChromaSyncState.ts:64ChromaSyncState.bump / persist