在 SQLite 中为你的 AI 工作计划建立索引,而不是在 Markdown 文件夹中
AI 协作会让你深陷规划文档的泥潭。将 Markdown 作为单一事实来源,添加一个带 FTS5 的 SQLite 索引,即可快速查询整个历史记录。
与 AI 助手协作会产生大量文字内容。每个稍复杂的任务都始于一份计划、一份设计笔记、一份清单,这些文件在文件夹中堆积如山,直到你有数百个文件,却无法找到所需的那一个。本文介绍了一个为我解决此问题的小模式:将 markdown 文件保留在原处作为事实来源 (source of truth),并在其上构建一个 SQLite 索引,这样你就可以在毫秒内列出、筛选和全文搜索所有历史记录。该索引是一个派生缓存。你可以随时丢弃它,并根据文件重新构建。
Markdown 坟场问题
当你与 AI 一起规划工作时,编写计划的成本很低,所以你会写很多计划。每个功能一个文件,每个 bug 一个文件,每次重构一个文件。每个文件都有一个状态、一个粗略的设计以及一个记录所发生事情的运行日志。单个来看,它们都很有用。但堆积起来,它们就成了一片坟场。
这种失败模式很具体。三个月后,你记得曾就如何处理信用卡退款的边界情况做过一个决定,但你不记得那个决定在哪份计划里。于是你求助于 grep。grep 在数百个文件中搜索速度很慢,它无法判断哪个文档是最近的或活跃的,而且它返回的是没有上下文结构的原始匹配行。你从二十个文件中得到了四十个匹配结果,但仍然需要逐个打开文件,才能找出是哪份计划、它的状态是什么,以及那个决定是否仍然有效。
更深层次的问题是,AI 和你面临着同样的问题。当你开始一项新任务时,最有价值的上下文是你已经做出的一系列相关决策。如果这些上下文被困在一个你和 AI 助手都无法查询的文件夹里,那么每项新任务都将从零开始,而你则需要重新审议几个月前就已敲定的决策。
将 Markdown 作为事实来源,并在其上添加索引
解决方案不是将计划移入数据库。Markdown 是正确的存储格式。它对人类可读,对 AI 可读,能在 git 中进行差异比较,并且在您围绕它构建的任何工具中都能存续。为了表格中的几行数据而将其丢弃是得不偿失的。
相反,应将每个计划保存为一个带有少量 YAML frontmatter 块的 Markdown 文件,并将这些文件视为唯一的事实来源。然后,构建一个 SQLite 索引,该索引读取这些文件并将两样东西镜像到表中:来自 frontmatter 的结构化元数据,以及用于搜索的全文文本。索引用于响应查询。文件保存着事实。
一个计划文件看起来像这样:
---
id: 41
status: active
project: web-api
tags: [area:billing, tech:go, type:feature, scope:minor]
created: 2026-05-02
---
# Credit refund on subscription downgrade
## Decision
Refunds are prorated by remaining days, not by unused credits...
因为索引是派生出来的,所以它被放在 .gitignore 中。您提交的是 Markdown,而不是数据库。任何克隆了仓库的人都可以运行一个命令在本地重建索引,如果索引不同步,您可以删除它并重新生成。正是这个特性使得整个模式变得安全:数据库永远不会成为您必须保护的第二个事实来源,因为它在设计上就是可一次性使用的。
模式:元数据加全文
索引的核心是两个表。一个表每行存放一个计划,其 frontmatter 字段提升为列。另一个表存放标签,每行一个标签,以便您可以对它们进行筛选和连接。
CREATE TABLE plans (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL, -- backlog, active, completed
project TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE tags (
plan_id INTEGER NOT NULL REFERENCES plans(id),
axis TEXT NOT NULL, -- area, tech, type, scope
value TEXT NOT NULL,
PRIMARY KEY (plan_id, axis, value)
);
将 frontmatter 提升为列,就将模糊的文件夹浏览转变成了精确的查询。“显示 web API 的活动计费计划”不再是手动扫描,而变成了一个 WHERE 子句。body 列包含完整的 markdown,这样搜索引擎就有内容可读,并且 show 命令无需再次读取文件即可打印计划。
笔记,即任务期间发生事件的运行日志,有自己的表,这样您就可以在不重写计划正文的情况下向其追加内容:
CREATE TABLE notes (
plan_id INTEGER NOT NULL REFERENCES plans(id),
created_at TEXT NOT NULL,
text TEXT NOT NULL
);
每个笔记都是一行带时间戳的记录。当您完成一个会话时,您会追加一句关于所做更改的话。之后,笔记历史读起来就像是决策的变更日志,这也是最值得展示给接手任务的 AI 的第一件事。
FTS5 实现即时搜索
SQLite 内置了 FTS5,这是一个全文搜索扩展。你不需要安装任何东西。你创建一个虚拟表来索引你想要搜索的列,将其指向真实表,然后使用 MATCH 子句进行查询。
CREATE VIRTUAL TABLE plans_fts USING fts5(
title,
body,
content='plans',
content_rowid='id',
tokenize='unicode61'
);
content='plans' 选项使其成为一个外部内容索引。FTS5 只存储搜索结构,并从 plans 表中读取实际文本,因此你不需要为每个计划的正文保留第二份副本。搜索会连接回基表以获取你想要显示的列:
SELECT p.id, p.title, p.status
FROM plans_fts f
JOIN plans p ON p.id = f.rowid
WHERE plans_fts MATCH 'credit AND refund'
ORDER BY rank
LIMIT 10;
与 grep 的区别不仅仅是速度,尽管在数百个文档上,查询在你手指离开回车键之前就已经返回了。区别在于其结果是结构化的。每个命中结果都会返回其 id、标题和状态,并按相关性排序,因此活跃的计划会排在提到相同词语的三个废弃计划之上。你可以添加 snippet() 来获取匹配项周围的高亮摘录,如果你想让标题的权重高于正文,还可以使用 bm25() 进行排序。
FTS5 还能处理 grep 不擅长的查询:多词短语、布尔 AND 和 OR、使用 refund* 的前缀匹配以及邻近匹配。unicode61 分词器会统一大小写并根据标点符号进行分割,这通常是你处理混合技术文本时所期望的。
一种保持一致的标签方案
自由形式的标签会腐化。如果不加管理,一个计划会得到 billing 标签,另一个会得到 payments,第三个会得到 credits,然后就没有任何一个过滤器能同时找到这三者了。解决方法是引入少量结构:每个标签都带有一个为其轴(axis)命名的命名空间前缀。
area:billing area:auth area:worker
tech:go tech:sqlite tech:redis
type:feature type:bugfix type:refactor
scope:patch scope:minor scope:major
每个轴回答一个不同的问题。area 是产品的组成部分,tech 是技术栈,type 是工作类型,scope 是规模大小。一个计划在每个轴上带有一到两个标签。前缀有两个有用的作用。它使词汇表自文档化,因为你可以列出某个轴上的所有值,从而一目了然地看到整个受控词汇表。并且它能确保过滤器不会误判,因为对 area:billing 的查询不会意外匹配到标记为 tech:billing-lib 的计划。
将标签存储为 (axis, value) 行而不是逗号连接的字符串,是使其可查询的关键。按一个标签筛选是一次连接(join)。按两个标签筛选是两次连接。列出某个轴上的词汇表就是执行 SELECT DISTINCT value FROM tags WHERE axis = 'area'。如果标签存在于单个文本字段中,那么这一切都无法实现。
你每天运行的命令
如果必须编写 SQL 才能使用它,那么这一切就没什么价值了。该索引通过一个封装了常用查询的小型命令行工具来体现其价值。这些是我经常使用的命令:
| 命令 | 功能 |
|---|---|
plan list |
活跃和积压的计划,最近的在前 |
plan list --status active |
仅显示进行中的计划 |
plan list --tag area:billing |
某个产品领域的所有内容 |
plan show 41 |
某个计划的元数据、摘要和最近的笔记 |
plan search "credit refund" |
对所有正文进行 FTS5 全文搜索 |
plan note 41 "switched to cursor pagination" |
追加一条带时间戳的笔记 |
plan index --full |
从 Markdown 文件重建整个索引 |
对于 AI 协作而言,最重要的模式是处理任何任务时的第一步。在阅读代码、打开文件之前,你先运行 plan search 或 plan list --tag 来查找相关的过往工作,然后用 plan show 加载相关决策。这能一步到位地为助手提供真实的上下文,而不是让它冷启动。这就好比说“我们在计划 41 中决定了这一点,理由如下”,而不是从头重新推导出相同的答案,两者之间有天壤之别。
# Start a task by loading context, not by grepping
plan list --tag area:billing --status active
plan show 41
plan notes 41 --last 5
为什么选择 SQLite 而不是更大型的数据库
SQLite 几乎完美地契合这项工作。它是一个单一文件,因此索引就是一个可以删除和重新生成的产物,除了该路径外,无需在 gitignore 中添加任何内容。没有需要运行的服务器,没有需要管理的端口,也没有必须保持活动的守护进程才能列出你的计划。FTS5 是内置的,所以搜索功能只需一条 CREATE VIRTUAL TABLE 语句即可实现,别无他求。而且它是事务性的,所以重建索引要么完全成功,要么保持旧索引原封不动。
更重要的一点是,这种模式同时利用了两种格式的优点。Markdown 是人类和 AI 助手擅长读写的格式,也是 git 跟踪的内容。关系型索引则是机器擅长查询的。你无需做出选择。你用 Markdown 读写,用 SQL 查询,并通过一个重建步骤来保持后者与前者同步。一个更重量级的数据库会增加操作负担,却带不来这项工作所需的任何额外好处,因为数据集是小型的、本地的、单写入者的,并且是可任意处置的。
权衡取舍,以及何时一个文件夹就足够了
这是一个实实在在的成本,并非唾手可得的胜利。索引必须保持同步。每当 markdown 文件发生变化,数据库就会变得过时,直到你重新建立索引。你可以将大部分工作隐藏在文件观察器或在提交时重新索引的 git 钩子之后,但耦合依然存在,而一个悄无声息地返回旧结果的过时索引比完全没有索引还要糟糕。你需要让重建过程成本低廉,并且理想情况下是自动的。
此外,还有构建和维护的成本。需要有人编写解析器、模式和命令行包装器,并随着 frontmatter 的演变而保持其正常工作。这是一次性支付的固定成本,只有在数量超过某个阈值后才能回本。
所以,要坦诚地面对规模问题。如果你只有几十个计划,这样做就是过度设计。一个存放 markdown 文件的文件夹,加上一个支持模糊文件搜索的编辑器或普通的 grep 命令,就足以在你输入查询的瞬间找到任何你需要的东西,从而省去所有这些繁琐的装置。当数量达到数百个,当 grep 开始返回噪音而非答案,当找不到过去某个决策的代价是你会重新做出一个不同的决策并因此产生一个 bug 时,索引才开始体现其价值。到那时,一个可查询的索引才不再是一个玩具,而是开始在每个任务上为你节省实实在在的时间。
我最终确定的规则很简单。计划少于一百个时,使用文件夹。超过一百个时,就在文件夹之上再加一个 SQLite 索引。无论哪种方式,都要将 markdown 文件作为唯一信源,这样一来,当文件夹不再够用时,添加索引只是一个构建步骤,而不是一次迁移。