存储分层:db_tracked 与 db_only
第 1 课确立了「markdown 是事实源、DB 是缓存」的总原则。本课讲一个更细的工程问题:仓库里不是所有 markdown 都该进 git。
先给你一句话定调,后面所有机制都为它服务:storage tiering(存储分层)唯一解决的问题是「这个目录要不要进 git 版本控制」。它不省 embedding 成本、不省数据库体积——这两件事另有机制负责。 所以「分层」不是性能开关,是仓库卫生开关。
下面先讲你要做哪些决策、怎么加配置;再讲加完之后系统内部怎么处理这些页(embedding / 抽取 / 丢弃 / 膨胀);最后讲运维与回退。
一、你要做的决策:哪些目录进 git,哪些不进
gbrain 默认没有 storage: 段。你在一个全新 brain 上跑 sync,所有 .md 都会按「未分层」(unspecified)处理——全部进 git。这本身能工作,但当机器批量生成的内容(抓取的网页、会议转录、媒体缓存)开始堆积时,git 仓库会迅速膨胀、diff 噪声爆炸、clone 变慢。
所以你的决策标准很朴素:问自己「这个目录的内容,是不是人(或 agent)亲手写的、我愿意让它进 git 历史、跨机器同步」。
| 内容特征 | 放进哪个 tier | 理由 |
|---|---|---|
| 你/agent 手写的知识:人、公司、交易、概念、想法、项目、笔记 | db_tracked | 要版本控制、要跨机同步、要 diff 可追溯 |
| 批量机器生成:抓取的推文/文章、会议转录、媒体缓存 | db_only | 可随时从 DB/源头重建,进 git 只会污染历史 |
| 没想清楚 / 两者都不是 | 不写(unspecified) | 默认全进 git,先跑起来再说 |
关键心智:不要试图用分层来「省 embedding 钱」或「减小数据库」——那做不到(见第三节)。分层只管 git。如果你担心数据库体积或 embedding 成本,那是 embed_skip、抽取节流、分 source 控制等别的杠杆,本课第四节会讲。
二、怎么加:在 gbrain.yml 手写 storage 段
配置文件是 brain 仓库根目录的 gbrain.yml。gbrain 不会替你生成 storage: 段——loadStorageConfig 只是去读它,gbrain.yml 不存在、或没有 storage: 段、或两段都为空,分层就完全不生效1。
你手动加一个顶层 storage: 段,里面两个数组1:
storage:
# 版本控制层:你(或 agent)手写、进 git、跨机同步的目录
db_tracked:
- people/
- companies/
- deals/
- concepts/
- ideas/
- projects/
# 数据库层:批量机器生成的内容,只留本地磁盘缓存、不进 git
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
加的时候有几条硬规则(代码强制):
- 目录必须以
/结尾。校验器normalizeAndValidateStorageConfig会自动补尾斜杠,并一次性打印Note: normalized N storage path(s)...告诉你改了什么;你也可以直接写规范形式避免这条提示2。 - 同一个目录不能同时在两个 tier。
matchesTierDir做路径段匹配(media/x/匹配media/x/foo但不匹配media/xerox/foo——这是刻意的,避免前缀碰撞)。若重叠,loadStorageConfig抛StorageConfigError,gbrain 拒绝启动,必须你手动改 yml3。 - 旧键名:v0.22.11 之前叫
git_tracked/supabase_only,现在规范名db_tracked/db_only(引擎无关,PGLite 和 Postgres 通用)。旧键仍能读但每进程告警一次;新旧并存时新键优先、旧键被忽略并告警4。
关于「这些目录是我放还是 gbrain 写」:两个 tier 都是你声明、gbrain 按声明读写的目录。
db_tracked(如people/)设计意图是人维护、进 git;db_only(如media/x/)装批量机器生成内容,通常由 gbrain 的 collector/recipe 机制写——一个 recipe 在 frontmatter 声明output_paths(它往哪写),gbrain 定时任务跑它、抓取外部数据、写成页落盘。但即便没有 collector,你手动把 md 丢进media/x/也完全能工作,gbrain 照样按 db_only 管理。两种来源都行;tier 的意义只是「不进 git、只留库」。
三、加完之后系统怎么处理这些页(重点:embedding / 抽取 / 膨胀)
这是你最该搞清楚的部分——分层之后,不同 tier 的页在「内容处理」上有什么差异。结论先给:在 向量化(embedding)、timeline/link 抽取上,db_tracked 和 db_only 没有区别;区别在于是否进 git,以及 DB 体积/embedding 成本由谁承担。
- embedding 对所有入 DB 的页生效,不分 tier。
gbrain sync的 embed 阶段作用在「本次同步的所有页」(pagesAffected)上,不看 storage tier;gbrain embed --stale也是全库扫描。所以db_only页一样被切成 chunk、调用 embedding 模型、写进向量库——db_only 不省 embedding 钱5。 - timeline / link 抽取同样不分 tier。
sync在 import 后调用extractLinksForSlugs/extractTimelineForSlugs,作用对象还是pagesAffected(本次同步的全部页)。db_only 的抓取页照样被抽时间线、抽引用链接6。 - 真正「被丢弃 / 不 embedding」的是另一套标记,与 tier 无关:
embed_skip:超大但内容干净的页,标记后不 embedding(省向量成本,但页本身还在 DB)。quarantine:高置信垃圾(CAPTCHA 拦截页、运营商错误页),隐藏出搜索,写零 chunk。content_flag:怪异/超大但仍可搜,仅打标记提示 agent 审视。 这三个是内容质量门写在 frontmatter 上的标记,跟「是否进 git」是两回事7。
- 膨胀的真实分布:
- git 膨胀:用
db_only能防——这些目录被 gitignore,永不进 git 历史。这是分层的核心价值。 - 数据库膨胀 + embedding 成本:
db_only页照样全进 DB、照样全 embedding。所以如果你抓了 100 万条推文全丢media/x/,DB 会涨、embedding 账单会涨——分层救不了这个。要控 DB 体积,得靠:控制抓取量、用embed_skip标记超大页、把不同来源拆成独立 source(独立 DB 锁与配额)、或定期 cleanup8。
- git 膨胀:用
一句话面向决策者:分层 = 防 git 膨胀的开关;embedding 成本与 DB 体积需要你在「抓多少、什么标记跳过」上另做决策。
四、tier 判定:getStorageTier 纯函数
内部判定逻辑很简单,了解即可:核心纯函数 getStorageTier(slug, config) 返回 'db_tracked' | 'db_only' | 'unspecified'9。它是字符串前缀匹配(isDbTracked/isDbOnly 各查对应列表),不搞数据库查询、不看页内容/类型。这个函数被 storage status、sync、export 复用——所以你 yml 里怎么写,直接决定每页的归类。
五、sync 如何自动维护 .gitignore(db_only 不进 git 的真正落地)
这是 db_only「不污染 git」最关键的一环,双保险:
第一层:manageGitignore 自动写 .gitignore。gbrain sync 在每次成功 sync 之后(先 sync 成功、再动 .gitignore,避免破坏性半完成状态)调用 manageGitignoreAtGitRoot10。它:把 storage.db_only 每个目录加进仓库根 .gitignore,带稳定注释头 # Auto-managed by gbrain (db_only directories),方便 grep;幂等(已存在不重复加);几个跳过/失败保护:
GBRAIN_NO_GITIGNORE=1→ 完全跳过(给共享仓库用,你不想让 gbrain 碰 .gitignore)11;- 仓库是 git git 子模块(submodule)(
.git是文件且 gitdir 含/modules/)→ 跳过并告警(子模块 .gitignore 不随父仓库更新)12; - git 工作树(worktree)(gitdir 含
/worktrees/)→ 仍管理(独立仓库); - 写失败(权限/只读)→ 只告警、吞掉,绝不拖垮 sync(.gitignore 只是副作用)10;
- PGLite 引擎下页本就存在本地 DB 文件、offload 效果有限,但 .gitignore 仍管理并打一次软告警建议迁 自托管数据库(Postgres)13。
第二层:import 默认遵守 .gitignore(fail-closed 硬保障)。gbrain import <dir> 默认(不带 --include-gitignored)用 git ls-files --cached --others --exclude-standard 枚举文件——天然跳过 .gitignore 里的 db_only 目录。即便第一层写入因故没成功,import 也不会把 db_only 文件 commit 进 git 历史14。sync 在 git add 时还用 pathspec 显式排除每个 db_only 目录(manageGitignore 崩溃也不影响这层排除)15。
六、缺失了怎么重建:gbrain export —restore-only
db_only 内容不进 git,换机器/容器重启/新 clone 后磁盘上没了这些文件,gbrain export --restore-only 能从数据库把缺失的 db_only 文件补回磁盘16:
gbrain export --restore-only --repo /path/to/brain # 补回所有缺失的 db_only 文件
gbrain export --restore-only --type media --repo /path/to/brain # 按页类型过滤
gbrain export --restore-only --slug-prefix media/x/ --repo /path/to/brain # 按 slug 前缀过滤
实现细节(代码 export.ts):必须带 --repo 或已配置默认源,否则硬报错,绝不静默退化成导出整个库17;必须有 storage 配置才能跑,否则不知道该恢复哪些目录,直接拒绝(防误 dump 全库)17;它不对全库 load 再过滤,而是对每个 db_only 目录分别用 slugPrefix 查引擎,只取「DB 有、磁盘 slug+'.md' 不存在」的页写回——在「只有 5K / 200K 页是 db_only」的 brain 上比全量导出快约 40 倍18。
七、引擎派生输出的隐式 db_only
gbrain 自己跑的派生阶段也产出目录——life/events/、atoms/、extracts/、dream-cycle-summaries/(常量 DERIVE_PHASE_DB_ONLY_DEFAULTS)19。它们本质是可重新派生的机器输出。
刻意设计权衡:这些目录在 undeclared_db_only_pages doctor 检查里被当「隐式声明的 db_only」(健康 brain 不因它们告警),但代码故意不把它们合并进 loadStorageConfig——因为合并了 manageGitignore 就会自动 gitignore 它们,而有些 brain 是「文件落地」这些目录的,一旦被 gitignore,import/sync 会静默跳过其文件(正是 #2788 那类静默死亡 bug)。所以 effectiveDbOnlyDirs() 只在 doctor 检查里用,绝不污染你的 .gitignore19。
八、两个关联的 doctor 检查
db_only_collector_collision:某 collector recipe 声明的output_paths落在你声明的db_only路径内部时告警。原因(经典静默陷阱):该 db_only 目录已被 gitignore → git-walking sync 永远看不到 collector 写的文件 →gbrain import也遵守 .gitignore → collector 跑得「绿」却什么都没进 DB。manageGitignore写 .gitignore 那一刻也发同样警告(配方扫描失败不阻挡维护)20。修复:把 collector 输出移出 db_only 路径,或从storage.db_only去掉那个前缀。undeclared_db_only_pages:DB 有页、磁盘无对应文件、且不在任何声明或派生默认 db_only 前缀内时告警——「页进 DB 但落盘失败/被删」的信号21。
九、storage status:只读的健康面板
gbrain storage status [--json] 是只读命令(也被 MCP 暴露),单次递归 walk 仓库 + 一次 listPages,按 tier 统计页数和磁盘占用,列出「DB 有、磁盘缺」的 db_only 文件(前 10 个,JSON 给全),并打印配置校验告警22。输出示例:
Storage Status
==============
Repository: /data/brain
Total pages: 15,243
Storage Tiers:
DB tracked: 2,156 pages
DB only: 12,887 pages
Unspecified: 200 pages
Disk Usage:
DB tracked: 45.2 MB
DB only: 2.1 GB
Missing Files (need restore):
media/x/tweet-1234567890
... and 47 more
Use: gbrain export --restore-only --repo "/data/brain"
十、完整数据流总结
- 你在
gbrain.yml写storage: { db_tracked: [...], db_only: [...] }—— 决策「谁进 git」。 - 你(或 collector recipe) 把 md 放进对应目录。db_tracked 的进 git;db_only 的本来会进 git。
gbrain sync→ import 默认遵守 .gitignore,不 commit db_only 文件;sync 成功后manageGitignore把它们写进.gitignore(双保险:即便写入失败,sync 的 pathspec 排除仍生效)。- 数据库持有全部页(db_tracked + db_only + unspecified),且全部被 embedding、全部被抽取 timeline/link——tier 不影响这一步(见第三节)。
- 换机器/重启 →
gbrain export --restore-only从 DB 把缺失的 db_only 文件补回磁盘。 gbrain doctor持续检查 collector 碰撞、未声明 db_only 页等静默陷阱。
决策者记住三句:分层只管 git,不管 embedding;db_only 防 git 膨胀,不防 DB/embedding 膨胀;要控后两者,用 跳过向量化标记(embed_skip) / 抽取节流 / 分 source / 控抓取量。
练习题
你在一个新 brain 上加了 storage 段,把 media/ 设为 db_only。media/ 下的抓取页在 gbrain sync 后会不会被 embedding?
把 media/ 设为 db_only 主要防的是什么膨胀?
gbrain.yml 里同一个目录同时出现在 db_tracked 和 db_only 会怎样?
为什么 import 即使 .gitignore 没写好也不会把 db_only 文件 commit 进 git?
换机器后 db_only 目录的磁盘文件没了,怎么找回?
db_only_collector_collision 这个 doctor 检查防的是什么?
参考出处
Footnotes
-
代码
src/core/storage-config.ts(loadStorageConfig(repoPath)从仓库根gbrain.yml读顶层storage:段;raw.db_tracked ?? []、raw.db_only ?? []表示无配置即空;gbrain.yml不存在/无storage:段/两段皆空 → 返回 null 或空,分层不生效;专用窄解析器替代会静默返回{}的 gray-matter)。 ↩ ↩2 -
代码
src/core/storage-config.ts的normalizeAndValidateStorageConfig(自动补尾斜杠p → p + '/',一次性打印Note: normalized N storage path(s)...;缺尾斜杠只为提示,不报错)。 ↩ -
代码
src/core/storage-config.ts(matchesTierDir用路径段匹配,media/x/匹配media/x/foo但不匹配media/xerox/foo;同一目录同时出现在 db_tracked 与 db_only 时loadStorageConfig抛StorageConfigError,需手动改 gbrain.yml 去重叠)。 ↩ -
代码
src/core/storage-config.ts(STORAGE_KEYS含规范键db_tracked/db_only与废弃别名git_tracked/supabase_only;normalizeStorageConfig中旧键加载、每进程告警一次、新旧并存时新键优先旧键被忽略;头注释说明 v0.22.11 前命名)。 ↩ -
代码
src/commands/sync.ts(embed 阶段作用在pagesAffected——本次同步的全部页,不看 storage tier;gbrain embed --stale为全库扫描;db_only 页同样被 chunk + embedding 写向量库,分层不省 embedding 成本)。 ↩ -
代码
src/commands/sync.ts(import 后调用extractLinksForSlugs/extractTimelineForSlugs,作用对象为pagesAffected全部页;db_only 页同样被抽取 timeline 与 link,tier 不区分)。 ↩ -
代码
src/core/embed-skip.ts(embed_skip:超大但干净,不 embedding,5 处 stale/全部页查询的统一过滤谓词isEmbedSkipped/EMBED_SKIP_FILTER_FRAGMENT)、src/core/quarantine.ts(quarantine:高置信垃圾隐藏出搜索写零 chunk;content_flag:怪异/超大但仍可搜仅打标记)。三者均为内容质量门写的 frontmatter 标记,与 storage tier 无关。 ↩ -
代码
src/commands/sync.ts与src/core/embed-skip.ts(db_only 页照样进 DB + embedding,故分层不控 DB 体积/embedding 成本;控体积杠杆为embed_skip标记超大页、--no-extract/抽取节流、--all多 source 独立 DB 锁与配额、以及控制抓取量)。 ↩ -
代码
src/core/storage-config.ts(getStorageTier(slug, config): 'db_tracked' | 'db_only' | 'unspecified';isDbTracked/isDbOnly纯字符串前缀匹配;被storage status、sync、export复用)。 ↩ -
代码
src/commands/sync.ts的manageGitignore(repoPath, engineKind)(每次成功 sync 后由manageGitignoreAtGitRoot调用;把db_only目录加进仓库根.gitignore并带稳定注释头# Auto-managed by gbrain (db_only directories);幂等;写失败只告警吞掉不拖垮 sync;PGLite 下仍管理并软告警)。 ↩ ↩2 -
代码
src/commands/sync.ts的manageGitignore(if (process.env.GBRAIN_NO_GITIGNORE === '1') return;—— 逃生舱,共享仓库不想让 gbrain 改 .gitignore 时设此变量)。 ↩ -
代码
src/commands/sync.ts的manageGitignore(检测.git是文件且 gitdir 含/modules/→ 判定 submodule,跳过 .gitignore 维护并告警;gitdir 含/worktrees/则仍管理;畸形.git失败-closed 仍管理)。 ↩ -
代码
src/commands/sync.ts的manageGitignore与src/commands/storage.ts的warnIfPGLite(PGLite 下页本就存在本地 DB 文件、offload 效果有限,但 .gitignore 仍管理;_pgliteTierWarned每进程一次软告警,建议gbrain migrate --to supabase)。 ↩ -
代码
src/commands/import.ts(collectSyncableFiles默认(不带--include-gitignored)走gitListSyncableFiles,用git ls-files --cached --others --exclude-standard枚举,天然跳过.gitignore中文件;非 git 目录才回退 FS walk)。 ↩ -
代码
src/commands/sync.ts(#2964:db_only 排除不依赖manageGitignore写入成功——sync 在git add时用:(exclude,literal)dirpathspec 显式排除每个 db_only 目录;并做 sniff-test 防「yaml 提到 db_only 但解析为空」时静默 commit db_only 内容)。 ↩ -
代码
src/commands/export.ts的--restore-only(对每个storage.db_only目录用slugPrefix查引擎,只取磁盘缺失且isDbOnly为真的页写回;注释说明 5K/200K 场景下比全量导出快 ~40x)。 ↩ -
代码
src/commands/export.ts(if (restoreOnly && !repoPath)与if (restoreOnly && !storageConfig)均process.exit(1)硬报错,绝不 fallback 到 cwd 或退化为全库导出)。 ↩ ↩2 -
代码
src/commands/export.ts(restore-only 路径对storageConfig.db_only逐个listPages({slugPrefix: dir}),seen去重、existsSync判缺失、isDbOnly兜底过滤后写盘)。 ↩ -
代码
src/core/storage-config.ts(DERIVE_PHASE_DB_ONLY_DEFAULTS = ['life/events/','atoms/','extracts/','dream-cycle-summaries/'];effectiveDbOnlyDirs只在undeclared_db_only_pagesdoctor 检查里当隐式声明用,故意不并入loadStorageConfig,避免自动 gitignore 这些目录导致文件落地型 brain 的 import/sync 静默跳过——#2788 类 bug)。 ↩ ↩2 -
代码
src/commands/sync.ts的manageGitignore(findDbOnlyCollisions(getConfiguredCollectorOutputs(), storageConfig.db_only)在写 .gitignore 时告警:collector 输出落在 db_only 内 → 被 gitignore → sync/import 静默跳过其文件);文档docs/storage-tiering.md§「Behavior Changes」 同述。 ↩ -
代码
src/core/doctor-categories.ts的undeclared_db_only_pages(DB 有页、无对应磁盘文件、且不在任何声明或派生默认 db_only 前缀内时告警)。 ↩ -
代码
src/commands/storage.ts的getStorageStatus/formatStorageStatusHuman(gbrain storage status [--json],单次walkBrainRepo+ 一次listPages,按getStorageTier统计页数与磁盘占用,列出缺失 db_only 文件前 10 个,只读、被 MCP 暴露;PGLite 下 tier 效果有限但 .gitignore 仍管)。 ↩