ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自建知识管理工具atlas:SQLite+FTS5实现可检索的知识地图

自建知识管理工具atlas:SQLite+FTS5实现可检索的知识地图

接手过太多知识管理工具,从最早的文件夹笔记到后来的双链笔记、白板工具,我一度以为“工具越多,知识越整”。结果恰恰相反,越换工具,资料越散,旧笔记像一座座废弃的孤岛,救不回来也沉不下去。后来我花了大概三周时间,自己动手写了一个叫atlas的个人知识地图项目,核心思路特别朴素:把每一条知识当作地图上的一个“地点”,把笔记之间的引用关系当作“道路”,再用一套轻量级的本地服务把这些东西组织成可检索、可漫游、可回溯的知识册。这篇文章就把 atlas 从设计动机、存储模型、核心实现到日常使用流完整拆开讲一遍,适合正在折腾知识管理、想自建个人知识库、或者对轻量级本地应用感兴趣的朋友参考。

1. 从“信息孤岛”到“知识地图”:atlas 的定位与设计初衷

1.1 传统笔记工具到底卡在哪

我见过太多人在笔记软件里收藏了上千篇文章,但真正需要的时候翻不出来。问题不在于“没存”,而在于“存进去之后,知识之间没有连接”。文件夹式的笔记天然是一棵倒立的树,一个节点只能有一个父节点,可真实世界的知识是网状的。比如我研究“SQLite 全文索引优化”这个问题时,它同时关联数据库、搜索引擎、文本处理三条线,树状结构根本无法表达这种多父节点的关联。

后来有了双链笔记,双向链接确实解决了一部分问题,但很多人用着用着就变成了“链接堆砌”——每条笔记都在疯狂加[[链接]],最后关系图密密麻麻一团乱麻,根本看不出知识的主次和结构。我自己就经历过这个阶段,花了两个月给旧笔记补链接,结果发现关系图看起来像一碗炒面,对我回忆知识没有任何帮助。

1.2 atlas 的核心理念:把知识当“地点”,把笔记当“路标”

做 atlas 之前我反复问自己一个问题:地图软件是怎么帮人找路的?答案很朴素:有明确的地点坐标,有道路连通,有图层切换。我想把同样的逻辑搬到知识管理里,于是定了三个核心概念:

  • 地点(Node):一条独立的知识条目,拥有标题、摘要、正文和一组坐标标签。
  • 道路(Link):两条知识之间的关联关系,分为“引用”“扩展”“对比”“前置依赖”四种类型。
  • 图层(Layer):同一个知识库的不同观察视角,比如“按时间看”“按主题看”“按项目看”。

这样设计的好处是,它没有强制要求你把所有知识都织成一张大网,而是允许知识以“景点”的方式松散存在,只有当你主动建立关系时,道路才会出现。这避免了双链笔记那种“为链接而链接”的焦虑。

1.3 这个项目适合谁,不适合谁

说实话,atlas 不是要替代所有笔记软件,它更适合下面几类人:

  • 研究者或写作者,需要反复整理同一批素材并提炼结构;
  • 需要跨项目沉淀经验的工程师,希望把踩坑记录变成可检索的技术地图;
  • 对数据有强控制欲的人,希望笔记以裸文件方式存储,随时可以导出自持。

反过来,如果你只想要一个“打开就写、写完就走”的快速记录工具,atlas 反而是多余的。它在读、查、关联上的优势远大于在“速记”上的优势。认清边界再动手,能省掉很多无用功。

2. 技术底座与存储模型:用最简单的方式搭出一套可扩展的框架

2.1 为什么选 SQLite + 全文索引,不选重型数据库

第一版 atlas 我差点用了 PostgreSQL,后来冷静下来算了一笔账:个人知识库的量级通常就是几千到几万条文本记录,单机访问,没有并发写入压力,这种情况下引入独立数据库服务完全是在给自己找运维麻烦。SQLite 单文件、零配置、备份就是一个cp命令,天然适合这种“个人级数据仓库”场景。

但 SQLite 自带的LIKE查询在文本检索上非常弱,所以我在表结构之外给正文和标题单独建了 FTS5 全文索引。这里有个容易被忽略的细节:FTS5 默认的分词器对中文支持不好,中文文本会被整句切分,搜一个词往往匹配不到。

实际使用中,我给 FTS5 配了tokenize = 'unicode61',同时自己在写入时用 jieba 做了粗粒度分词,把分词结果存到单独一列再丢给 FTS5。这样既保留了 SQLite 的轻量,又让中文搜索达到了可用水平。

2.2 三层数据模型:实体、关系、视图

atlas 的数据模型围绕三个表展开,结构非常简单:

-- 实体表:一条知识条目 CREATE TABLE entities ( id TEXT PRIMARY KEY, title TEXT NOT NULL, summary TEXT, body TEXT, layer TEXT DEFAULT 'default', tags TEXT, created_at TEXT, updated_at TEXT ); -- 关系表:记录两条实体间的有向关联 CREATE TABLE links ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id TEXT NOT NULL, target_id TEXT NOT NULL, link_type TEXT NOT NULL, -- cite / extend / contrast / prerequisite note TEXT, created_at TEXT ); -- 索引表:FTS 全文索引 CREATE VIRTUAL TABLE entity_fts USING fts5( title, summary, body, content='entities', content_rowid='rowid' );

这里我把layer做成了实体上的一个字段,而不是单独一张表。原因是大多数笔记实际上只属于一个主视角,把图层做成字段可以少做一次关联查询。只有当图层之间出现大量交叉归属时,才值得拆成多对多关系表。这也是我当时权衡过的点:先满足 80% 的简单需求,不提前引入复杂性。

2.3 目录结构设计:让数据裸文件可读可迁移

数据表结构再清晰,如果数据只能通过程序访问,一旦工具停止维护,知识就被锁死了。所以我在 atlas 里做了一个很“笨”的设计:每个实体除了进入数据库,还会同步落盘为一个 Markdown 文件,放在atlas_data/entities/目录下,文件名就是实体 ID,文件头部用 YAML 存元信息,正文就是纯 Markdown。

--- id: 20240512-sqlite-fts5 title: SQLite FTS5 中文分词实践 layer: tech tags: [数据库, 全文检索, 中文分词] created_at: 2024-05-12 --- 我在给 atlas 搭建全文索引时,遇到中文分词匹配不到的问题...

这样做的直接好处是:就算 atlas 某天彻底不能运行,我依然能用任何文本编辑器打开这些 Markdown 文件,用 grep 检索内容,甚至写个脚本迁移到其他工具。对个人知识库来说,数据的长期可读性比软件功能的华丽程度重要得多。

3. 从零手写一个可用版本:核心接口与关键实现

3.1 环境准备与初始化

atlas 的后端我用 Python 实现,依赖非常克制:fastapi提供 HTTP API,sqlite3标准库操作数据库,jieba做中文分词,markdown做渲染。前端先不做复杂界面,用一套简单的 Web 页面展示关系图和列表。

初始化时先建数据库和目录结构,核心代码如下:

import sqlite3 from pathlib import Path def init_atlas(data_dir: Path): data_dir.mkdir(parents=True, exist_ok=True) (data_dir / "entities").mkdir(exist_ok=True) conn = sqlite3.connect(data_dir / "atlas.db") conn.executescript(""" CREATE TABLE IF NOT EXISTS entities (...); CREATE TABLE IF NOT EXISTS links (...); CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts USING fts5(...); """) conn.commit() return conn

3.2 实体管理接口:增删改查背后的约束

实体管理的核心不是 CRUD 本身,而是 CRUD 过程中的数据一致性。我在设计接口时额外做了三件事:

  • 标题唯一性校验:同一标题的笔记重复出现,是知识库混乱的最大来源。我在写入时先对标题做一次规范化处理(去掉首尾空格、统一全角半角),再查重,重名直接拒绝写入并提示已有实体 ID。
  • 正文分词幂等:每次更新正文都会重新分词并同步到 FTS 索引,保证搜索结果不滞后。
  • 落盘同步:数据库写入成功后,同步把 Markdown 文件写回磁盘目录。这两步不是原子操作,所以我先写内存数据库、再落盘,如果落盘失败则在下次启动时通过比对updated_at自动补偿。
def create_entity(conn, entity: dict): normalized_title = entity["title"].strip().lower() existed = conn.execute( "SELECT id FROM entities WHERE lower(title) = ?", (normalized_title,) ).fetchone() if existed: raise ValueError(f"title conflict with {existed['id']}") # 写入 entities + FTS + 落盘 Markdown ...

3.3 关系构建与图遍历:怎么做到“相邻知识一目了然”

关系的价值在于“发现相邻知识”。我实现了一个简单的两跳图遍历接口:给定一个实体 ID,找出它直接关联的所有实体,再找这些实体之间相互关联的情况,最后按关联强度排序返回。

def neighbors(conn, entity_id: str, depth: int = 2): visited = set() queue = [(entity_id, 0)] result = [] while queue: current, dist = queue.pop(0) if current in visited or dist > depth: continue visited.add(current) rows = conn.execute(""" SELECT target_id, link_type, note FROM links WHERE source_id = ? UNION ALL SELECT source_id, link_type, note FROM links WHERE target_id = ? """, (current, current)).fetchall() for target, link_type, note in rows: if target not in visited: result.append((target, dist + 1, link_type, note)) queue.append((target, dist + 1)) return result

这个实现朴素,但在几千条数据量级下性能完全够用。真正需要优化的点在于结果的组织:我按“直接关联”和“间接关联”分成两组展示,避免把关系图搞成一团乱麻。这也是 atlas 和那些自动把所有节点全画出来的关系图工具的关键差异——关系图要克制,只显示当前节点两跳以内的邻居,才能起到导航作用。

3.4 视图层:热力图、时间线、关系图的取舍

视图层我做了三个:

  • 列表视图:支持按图层、标签、时间筛选,展示标题和摘要,适合快速扫读。
  • 关系视图:基于上面说的两跳邻居接口,渲染当前节点的局部关系网络,适合漫游时发现意外关联。
  • 时间线视图:按created_at聚合每月的笔记数量,直观看出自己的知识产出节奏。

这里我没有做花哨的热力图,原因是前期数据量不够时,热力图很容易变成一张“全绿”的无意义图片。等数据积累到上千条再考虑也不迟。做工具的教训就是:不要为了界面好看而造数据指标,要和真实使用习惯对齐。

4. 日常使用流设计:不改变习惯的前提下完成知识收敛

4.1 导入流程:旧笔记批量迁移

工具做得再好,如果迁移成本太高,最后还是会吃灰。我把导入流程专门做成了一个独立脚本,支持从 Markdown 文件夹批量导入。导入逻辑很简单:递归扫描目录下所有.md文件,读 YAML 头部的元信息,正文写入body,文件名作为标题,再把目录名映射成图层名。

python -m atlas.importers.markdown_folder \ --source ~/Documents/old_notes \ --map-dir-layer notes:writing, tech:dev

实际跑下来,我迁移了 600 多篇旧笔记,其中大概有 15% 出现标题重复或 YAML 头缺失,脚本会把这些文件单独放到一个conflicts/目录下,不会直接覆盖已有数据。这个“先隔离再处理”的思路帮我避免了很多数据事故。

4.2 每日记录与周度回捞

知识管理最大的敌人不是工具不够好,而是“没有固定节奏”。我给 atlas 设计了一个非常轻量的使用循环:

  • 每日记录:用极简的“随手记”接口写下当天的收获,只填标题和正文,不强制打标签、不强制建链接。
  • 每周回捞:每周末抽半小时,把本周随手记的条目做一次梳理,该建关系的建关系,该合并的合并。

回捞这个动作是 atlas 整个体系里价值最高的部分。随手记的时候我最放松,记录的是原始想法;回捞的时候我带着整理心态,能发现很多原本没意识到的联系。比如我有一篇随手记写“SQLite 锁等待超时”,另一篇写“FastAPI 异步接口并发问题”,回捞时才发现两者背后都指向同一个根源:单连接串行写入。于是我把这两篇用contrast关系连了起来,还顺手补了一篇综合笔记。

4.3 标签 vs 关系:我踩过的“标签膨胀”坑

我最开始给 atlas 设计了完整的标签体系,结果用了两周就发现标签数量膨胀到收不住——“性能”和“性能优化”是两个标签,“数据库”和“数据库优化”又是两个。后来我彻底转变了用法:标签只表达“稳定身份”,关系表达“临时关联”。

  • 标签只保留固定的领域词,比如“数据库”“前端”“写作”,不再新增细粒度标签。
  • 凡是临时的、主题性的关联,全部通过“关系”来表达。

这个转变非常关键。标签是慢变量,关系是快变量。把两者职责分开后,维护成本立刻降了下来。如果你也在做自己的知识库,强烈建议先想清楚这个边界。

5. 实测中遇到的典型问题与排查链路

5.1 全文索引突然搜不到刚写入的内容

有一天我写完一条笔记,立刻搜索刚出现的关键词,结果什么都搜不到。第一反应是索引没建,但检查 FTS 表发现数据是有的。接着排查发现:我在写入实体时先更新了entities表,再把分词结果插入entity_fts,但这两个操作在同一个事务里,而 FTS5 的外部内容表模式需要保证content表和 FTS 索引的同步顺序。

问题根因是:FTS5 在content='entities'模式下,删除和更新操作需要显式同步删除旧索引,我只处理了新增,漏了更新场景。修复方案是给 FTS 表挂了触发器:

CREATE TRIGGER entities_ai AFTER INSERT ON entities BEGIN INSERT INTO entity_fts(rowid, title, summary, body) VALUES (new.rowid, new.title, new.summary, new.body); END; CREATE TRIGGER entities_ad AFTER DELETE ON entities BEGIN INSERT INTO entity_fts(entity_fts, rowid, title, summary, body) VALUES ('delete', old.rowid, old.title, old.summary, old.body); END; CREATE TRIGGER entities_au AFTER UPDATE ON entities BEGIN INSERT INTO entity_fts(entity_fts, rowid, title, summary, body) VALUES ('delete', old.rowid, old.title, old.summary, old.body); INSERT INTO entity_fts(rowid, title, summary, body) VALUES (new.rowid, new.title, new.summary, new.body); END;

这里还想提醒一句:如果是用unicode61分词器,中文检索依然可能因为分词粒度对不上而搜不到。我当时最终方案是写入时用 jieba 分词后,把分词结果以空格连接存进body_tokens字段,再对该字段建 FTS。实测之后,类似“全文索引”“中文分词”这种复合词都能稳定命中。

5.2 关系环导致的递归死循环

给关系图加“两跳遍历”时,我一开始没有加visited集合,结果笔记 A 引用 B、B 引用 C、C 又引用 A 时,遍历直接陷入死循环,接口超时。后来加了访问标记,问题解决。但这里真正要小心的不是“防止死循环”这个动作本身,而是环状关系在知识库中是常态,不是异常。

所以在遍历结果里,我保留了环状关系的展示,只是不让它继续递归扩展。这样做的好处是,读者能直观看到“三个知识点互为支撑”,而不是被工具强行抹平。

5.3 SQLite 并发写入冲突与锁等待

atlas 早期版本直接在 FastAPI 的异步接口里用同一个 SQLite 连接执行写入。当我在浏览器里连续快速创建多条笔记时,偶尔会出现database is locked错误。排查过程分了三步:

  1. 先确认是否有长事务没提交。检查后发现没有。
  2. 再确认是否多个连接同时写。发现是同一个连接被异步任务并发调用。
  3. 解决方案是给写入操作加一个全局写锁,并设置busy_timeout。
write_lock = threading.Lock() def safe_write(conn, sql, params): with write_lock: conn.execute(sql, params) conn.commit()

个人知识库的写入频率本身不高,加锁带来的性能损失可以忽略不计,但换来的是绝对稳定的写入行为。如果你也打算在本地 Web 服务里用 SQLite,建议一开始就把“单写者”这个模型确定下来,否则后面会遇到各种奇怪的锁问题。

6. 给想把 atlas 用起来的人:一些后置经验与建议

6.1 先小规模跑通,再全量迁移

这是我踩过最大的坑换来的经验。一开始我雄心勃勃想把过去五年的笔记一次性迁移进 atlas,结果导入后发现大量旧笔记质量参差不齐,要么是随手粘贴的链接,要么是残缺的摘抄,把整个库的检索体验都拉低了。

建议的做法是:先只迁移最近三个月的笔记,跑通“记录—回捞—检索—关联”这个完整循环,确认自己真的能用起来,再分批导入历史数据。同时,导入历史数据时不要把内容原封不动塞进去,最好在导入时顺手做一次“修剪”——删掉无意义的收藏,把值得长期留存的提炼成完整条目。

6.2 定期巡检与导出备份策略

数据安全这件事,再怎么强调都不过分。atlas 的数据包含 SQLite 数据库文件和 Markdown 裸文件,我的备份策略是每天凌晨执行一次快照,保留最近 30 天版本:

#!/usr/bin/env bash # /usr/local/bin/atlas_backup.sh BACKUP_DIR="$HOME/atlas_backups/$(date +%Y%m%d)" mkdir -p "$BACKUP_DIR" cp -r "$HOME/atlas_data" "$BACKUP_DIR/" find "$HOME/atlas_backups" -type d -mtime +30 -exec rm -rf {} \;

除了自动备份,我还坚持每月手动导出一次全量 Markdown 文件,放到另一块移动硬盘上。这么做不是为了应对硬盘损坏,而是给自己留一条“随时可以脱离工具独立阅读”的后路。知识管理工具最怕的就是变成单向门:存进去容易,拿出来难。

6.3 下一步可以怎么扩展

atlas 目前还处于“个人可用”的阶段,后面有几个我计划延伸的方向,也供你参考:

  • 定时自动抓取:针对我经常查阅的几类技术文档站点,每周自动抓取更新内容,生成“变更摘要”实体,并和现有的技术笔记自动建立extend关系。
  • 多人共建:在现有实体表上增加owner字段,把同步方式改为基于 Git 的协作流,让团队里每个人维护自己负责的主题图层。
  • 自然语言查询:接入一个本地跑得动的小模型,把“我上周看过哪些关于 SQLite 的笔记”这类自然语言问题转成查询条件,降低检索门槛。

这些扩展都建立在现有数据模型之上,不需要推翻重来。这也是我把数据模型设计得克制的原因:结构越简单,扩展的空间反而越大。

最后分享一个实际使用中的心得:atlas 真正改变我的不是“搜索更快”或“关系更清晰”,而是让我养成了一个习惯——每周固定时间回捞整理当周的零散记录。工具只是提供了一个固定节奏的容器,真正让知识沉淀下来的,是那个雷打不动的整理动作。如果你也想建一套自己的知识地图,不妨也从“先定节奏、再做工具”开始,可能比你想的更有效。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进