1. “caveman” 到底是什么项目?
先说结论:这不是一个考古主题网站,也不是原始人生存模拟软件。“caveman” 是我最近在业余时间折腾的一个极简离线优先的个人知识库工具,名字取自“穴居人”那种原始、封闭、自给自足的状态——所有数据都存在本地,不依赖云端,查询快得像石器时代敲石头那样直接。
做这个项目的起因很实在。我过去几年试过 Notion、Obsidian、语雀、飞书文档,甚至拿 Bear 和 Logseq 凑合过,折腾一圈下来发现一个尴尬的事实:越“智能”的工具,依赖越重。某个笔记应用说崩就崩,某天没有网络就连收件箱都打不开,更别提那些一个版本更新就给你塞一堆用不上的“AI 能力”的产品。我需要的是一个能让我完全掌控数据、能在任何机器上快速部署、打开就能搜、关闭就不用管的东西。
所以 caveman 的目标很简单:一个用纯文本存储、带本地索引、支持命令行和简单 Web 界面的知识管理工具,全部代码加起来不到两千行,没有数据库服务端,没有云同步,没有插件市场。它解决的核心问题不是“能不能记”,而是“记下来的东西能不能在五年后还能打开、还能搜到、还不受平台绑架”。
适合谁来参考?如果你跟我一样受够了重客户端和大而全的平台,想用自己的方式管理笔记和文档;如果你想体验一把从零搭一个够用工具的快感;甚至你只是好奇为什么有人愿意拿 Python 写一个号称“回到洞穴时代”的笔记系统——那这篇博文应该能给你一些思路。
2. 为什么非要把工具做“原始”?聊聊设计思路
2.1 离线优先不是倒退,是反脆弱
很多人一听“本地存储、没有同步”就皱眉:现在不都讲多端协同吗?我手机、平板、电脑都要看呢?这个想法没错,但你要分清“协同”和“云端依赖”的差别。我最初也做了 WebDAV 同步,后来亲手删掉了。
理由之一:同步是最容易引入隐性 bug 的地方。两处编辑冲突怎么办?文件锁怎么实现?断网重连后要不要做 diff?这些问题不会让工具“不能用”,但会让工具“不可信”——你永远不知道当前看到的内容是不是最新的。
理由之二:我对工具的核心诉求是“低维护”。一个项目如果为了同步要配服务器、配域名、配 HTTPS 证书、处理各种网络异常,那我还不如回去用商业笔记软件。caveman 不解决同步问题,它只解决“在你这台机器上存储和检索资料”的问题。跨设备共享直接交给 U 盘、移动硬盘或自建的 NAS 共享目录,数据是一堆标准 Markdown 文件,拿到任何系统上都能读。
我个人的理解是:离线优先本质上是一种反脆弱设计。网络是工具,不是前提;数据永久可读才是底线。caveman 的存储层刻意选用了最无聊、最没技术含量但生命周期最长的格式——纯文本。哪怕有一天 Python 从地球上消失,我的笔记也仍然是文本文件,用记事本就能打开。
2.2 为什么用 Python 而不是 Go 或 Rust
选 Python 纯属个人偏好和投入产出比的权衡。我不否认 Go 或 Rust 在性能、静态编译、内存占用上有优势,但 caveman 对性能的要求远没到那个量级:几千篇笔记的全文检索,用 SQLite FTS5 在毫秒级就能完成,瓶颈根本不在语言而在索引方式。
更重要的是开发效率。整个项目从第一个 commit 到可在命令行正常增删改查只花了一个周末。Python 的标准库里有sqlite3、argparse、http.server、pathlib,做这种单机小工具几乎没有外部依赖。你要是在选型时纠结“将来并发上来了怎么办”,那就不是在做工具,是在做预研项目。先跑起来,再谈优化。
2.3 设计理念:数据库只管索引,不做存储
这是 caveman 和很多笔记软件在架构上最大的不同。传统方式是把正文放进 SQLite 或者别的数据库里,文件系统里只有附件或者干脆啥都没有。这种方式的问题在于正文跟你的文件系统脱节了:如果哪一天软件崩溃、数据库损坏,你要恢复数据就得靠备份;如果手动在文件夹里新增了一个 Markdown 文件,那还要软件主动导入才能识别。
caveman 反着来:所有正文都是磁盘上的.md文件,目录结构就是你自己的分类结构;SQLite 只存两样东西——文件的路径和全文索引用到的分词内容。这样做有几个直接好处:
- 任何时刻删掉
index.db,你的数据也不会有任何损失,重新跑一次全量索引就回来了。 - 直接在文件管理器里手工改文件、挪目录、删笔记,caveman 下一次操作时能通过比对文件系统与索引状态自动发现变更。
- 数据不锁死在某个私有格式里,以后想迁移到任何别的工具,省掉导出转换这一步。
这种“文件为主、数据库为辅”的设计,才是 caveman 敢拿“原始”当卖点的底气。你甚至可以完全不装这个工具,用一个普通文件管理器管理这些笔记——caveman 只是帮你加了个搜索和标签的便利层。
3. 动手搭一个:核心架构与关键模块实现
3.1 整体架构长什么样
caveman 由四个部分组成:
- 存储层:磁盘上的一个根目录,内部任何层级的
.md文件都被视为“笔记”。 - 索引层:以 SQLite 数据库存文件元数据和全文索引(FTS5 虚拟表)。
- 命令行入口:负责增删改查、标签管理、重索引、导出等操作。
- 可选 Web 界面:基于 Python 标准库
http.server写的一个只读搜索页面,方便在浏览器里浏览。
开个实话,Web 界面是整个项目中我最没上心的部分。它存在的意义仅仅是满足“在浏览器里也能搜”这个场景,样式简陋到只有一个搜索框加一个结果列表。但你从反面看:正因为它可选,核心命令行工具才能保持小而美。
3.2 文件扫描与变更检测怎么设计
caveman 最关键的两个数据结构是文件哈希和路径映射。每次执行caveman refresh时,工具会遍历根目录下所有.md文件,计算每个文件的 SHA-256 哈希值,然后与数据库中记录的上次哈希值做对比。
如果路径没变但哈希变了,说明内容被外部修改过,需要重新索引;如果路径是新的但库中没有,说明是新增文件;如果库中有记录但文件系统里已经没有该路径,说明被删除了。这种设计让我可以直接在系统文件管理器里操作笔记,而不必每次修改都通过 caveman 的编辑命令。
初版我用的是修改时间戳 mtime 做判断,后来发现不可靠——某些编辑器不会每次保存都更新 mtime,文件复制、同步工具也容易打乱时间信息。后来全部改成哈希对比,扫描时间从原来的快得“不真实”变成每 5000 个文件大概耗时 1 秒多,但换来的是极其确定的结果。对于知识管理这种低频写入场景,准确性的优先级远高于速度。
3.3 全文搜索怎么做才像样
一开始我用的是sqlite3自带的无 FTS 版本,用LIKE '%keyword%'做模糊匹配。对这种数据量来说其实也不会太慢,但有一个致命缺陷:它没法做好分词。中文搜索“自动化”匹配不到“自动”也就罢了,连“自动化测试”和“自动化部署”在排序上也完全没区分度。
后来换成 FTS5,建表语句大概是这样的:
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( title, body, content='docs', content_rowid='id', tokenize='porter unicode61' );配合content='docs'实现外部内容表模式,这样 FTS 表不复制正文,只是用来做倒排索引。查询时用MATCH语法,再加ORDER BY bm25(docs_fts)做相关性排序。对于中文,unicode61默认按单字切分,在搜索短词时效果还行;如果你想要更智能的中文分词,可以自己挂simple分词器外加自定义词典,但我实测下来对个人笔记这种精度完全够用。
z注意一个细节:FTS5 的虚拟表需要手动处理同步删除。外部内容表模式下,如果你删除了 docs 表里的行,FTS 表不会自动感知,需要在应用层触发INSERT INTO docs_fts(docs_fts) VALUES('delete-from-table')。这个是小坑,不处理的话会出现搜到已经删除内容的幽灵结果。
3.4 标签系统:用文件名还是目录结构
标签功能我纠结过很久。方案 A 是传统 frontmatter:在每个 Markdown 文件顶部加一行tags: python, note。方案 B 是通过目录层级作为隐式标签:notes/python/xxx.md就默认带 python 标签。最终我两个都支持,但推荐的用法是 B。
原因是 B 更贴合“文件即数据”的设计——你移动文件到另一个目录,标签就变了,根本不需要改文件内容。而 frontmatter 方案里的标签与文件位置完全无关,虽然灵活,但容易导致笔记散落各处、难归类。实际使用中,我把目录作为一级标签,把 frontmatter 里的 tags 作为二级补充。搜索时用tag:项目这样的语法过滤,整体体验比较顺。
4. 从零到跑通:实操记录
4.1 初始化
假设你已经有一个放笔记的目录,比如~/caveman_notes。初始化只需要两步:
pip install caveman caveman init ~/caveman_notes --name "my knowledge base"这里--name只是给数据库实例起个可读名称。init 会做三件事:创建根目录、生成空的 SQLite 索引文件、写入一个config.json用来存根路径等配置。你也可以先创建项目再加笔记,命令是反过来的:先caveman new project,再caveman add往里丢文件,就会自动建目录。
4.2 导入现有文件
如果你已经把旧笔记整理成了 Markdown 文件,直接拷进根目录即可,然后执行:
caveman refresh这条命令会全量扫描并增量更新索引。我自己的目录里大概有 4000 多个文件,初次索引花了 4 秒左右,之后就只看新增和变更的部分,通常不到 0.5 秒。
最需要注意的是文件名冲突和非法字符。Windows 和 Linux 对文件名的限制不一样,如果以后要在多平台间搬运,文件名里尽量不要用: * ? " < > |这些符号。我踩过一次坑:从 Windows 复制笔记过来,发现有几十个文件名因为含?和:直接失败了,最后写了个小脚本批量重命名才解决。
4.3 日常写入与搜索
日常往库里加笔记的命令很直接:
caveman add "如何用systemd设置定时任务" --tag linux systemd这条命令会做三件事:帮你生成一个以合法文件名保存的 Markdown 文件,放在根目录下一个疏略分类的目录里;自动在tags字段写入linux和systemd;最后触发一次局部索引。文件内容的模板是:
--- tags: [linux, systemd] title: 如何用 systemd 设置定时任务 --- # 如何用 systemd 设置定时任务 (从这里开始写)模板文件是可以自定义的,想改成自己习惯的 frontmatter 格式很简单,在config.json里指定模板路径即可。
搜索是使用频率最高的命令:
caveman find "systemd 定时任务" caveman find "tag:python AND 爬虫"前者做全文相关度排序,后者做精确的标签过滤。搜索结果默认输出文件绝对路径、最后修改时间、匹配片段。如果你在浏览器里更舒服,还可以执行caveman serve,它会启动一个只读的 Web 服务,默认绑定 127.0.0.1:8008,访问后就能在浏览器里搜索浏览。
4.4 数据备份策略
本地存储给安全带来了新的问题:硬盘坏了怎么办?笔记本丢了怎么办?我把备份思路简化成“冷热双份”。
热备份是指保留一个移动硬盘或 NAS 共享盘,用 rsync 每天自动把整个~/caveman_notes目录镜像过去。因为所有数据都是文件,rsync 天然友好。冷备份则是每隔几个月打包一次 tar.gz 放在一个外部硬盘角落里。
备份的核心不是笔记里的 Markdown 文件,因为那部分跟普通文件没区别。麻烦的是你不能只备份 Markdown 文件而忽略索引——索引重建虽然只有 4 秒,但如果你有大量自定义标签和特殊 frontmatter 字段(比如某些笔记记录了长坐标、日期等结构化信息),丢索引虽然不丢内容,但要重新整理标签体系就很痛苦。所以我的 rsync 命令把整个caveman_notes目录带上,包括index.db,一行命令的事:
rsync -av --delete ~/caveman_notes/ /Volumes/Backup/caveman_notes/这里--delete要谨慎,它是把本地删除的文件也同步删除到备份端。如果你希望避免误删误同步,就把--delete去掉,手动清理备份端即可。
5. 踩坑实录:这些问题不处理会很难受
5.1 FTS5 的“幽灵搜索”
我前面提过 FTS5 外部内容表的删除同步问题,具体现象是:你在文件管理器里删掉了一篇笔记,refresh之后文件路径和 docs 表都更新了,但再搜索时那篇被删笔记仍然会出现在结果里。
原因就是 FTS 虚拟表没有被同步执行 DELETE。解决办法是在删除 docs 表记录时,主动对 FTS 表执行 same content 的删除操作。在我的实现中,删除某文件时会执行:
INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete-from-table', :id, :old_title, :old_body);如果你自己造轮子,强烈建议在写删除逻辑时就考虑这一点,不然后期数据一多就总觉得搜索结果“闹鬼”。
5.2 中文分词的无效搜索
FTS5 默认的unicode61分词器会按词法把每个汉字拆开。好处是支持单字匹配,坏处是“词”的边界不够聪明,搜“数据库”时,结果里会出现“数据”和“库”两个词各自匹配的噪声结果。虽然 BM25 排序能把相关性高的放在前面,但当你有大量包含其中某个单字的笔记时,体验会打折扣。
我的妥协方案是:在正文之外,新增一个叫keywords的字段,用简单的人工打标来提升准确率。比如这篇关于 systemd 定时任务的笔记,我会在 frontmatter 的keywords里写上systemd、timer、cron、linux。搜索时把字段权重调高:
SELECT title, snippet(docs_fts, 2, '<b>', '</b>', '...', 12) FROM docs_fts WHERE docs_fts MATCH :query ORDER BY bm25(docs_fts, 10.0, 5.0, 1.0) DESC这里第一个 weight 10 是 title 权重,5 是 keywords,1 是 body。结果非常明显:正文里偶发出现的同音词、同义字的干扰项就少了很多。这不完美,但简单可靠。
5.3 大文件索引会拖慢 refresh
如果你把 PDF、图片也放到笔记目录里,refresh 扫描时就相当于做了一次全量 IO 和哈希。PDF 尤其是重灾区,一份几百 MB 的 PDF 哈希也算不了几秒,但架不住数量多。后来我把根目录下的附件单独建一个_assets子目录,在遍历逻辑里跳过这个目录的索引,只把它的文件路径记录为“附件引用”。这样大文件不会影响日常搜索体验。
5.4 文件移动后标签丢失
当你手动在文件管理器中移动笔记文件,从linux目录搬到python目录,caveman 在下一次 refresh 时通过路径变化会重新解析标签。但如果你依赖的是“目录即标签”的设计,就别忘了移动文件后搜索tag:linux,那篇笔记会从结果里消失——这是符合预期的,但不是每个人都能第一时间反应过来。
为了避免这种“消失了”的惊吓,我在caveman find里默认给结果附上完整的路径信息。看到结果时你就能立刻判断这到底是“没有这个标签”还是“标签变了”,不会对着屏幕疑惑半天。
6. 可选的 Web 界面怎么做到够用
许多人对命令行工具的印象是“能用但丑”,如果不想把自己困在终端里,那就整一个最简单的只读 Web 页面。caveman 的 Web 界面用的不是 Flask、Django,就是 Python 自带的http.server,加一个BaseHTTPRequestHandler子类处理两个请求:GET /返回一个静态 HTML 页面,GET /search?q=xxx返回 JSON 格式的搜索结果。
关键在于别把 Web 服务当主力,它只是给搜索加了一个图形化壳。整段实现大约 150 行,不涉及任何第三方依赖,性能即便在树莓派上也能流畅响应。写这块时我还发现http.server是单线程的——并发一多就会阻塞,但因为只有我自己访问,完全无所谓。如果你也想抄这个思路,提醒一句:不要在这上面折腾 Session、登录、鉴权,本地工具默认绑定 127.0.0.1 就够了。
7. 后续还能怎么玩
caveman 现在还处在“自己用得很舒服”的阶段,下一步想做的方向有三个:第一个是给 SQLite 索引加更细粒度的更新时间记录,这样能实现“最近一段时间新增了什么笔记”的周报式回顾;第二个是把模板系统扩展成支持变量注入,比如自动在标题下方插入当前日期和所在目录所属的项目名;第三个是做一个极简的caveman stats命令,统计总字数、每日新增笔记数量、每周活跃度等,让你对知识库的成长有一个量化感知。
不过说实话,对这种小工具,我最深的体会是:功能做到“够用”就该停手了。每次增加新功能都会引入新的边界情况和维护负担,而知识管理这类低频场景,用户要的第一永远是稳定和可预见。后续无论怎么扩展,我都尽量以“不破坏纯文本存储”为前提,那些必须依赖专有数据库才能实现的功能,原则上不碰。
这个项目最大的收获不是代码本身,而是让我重新审视了工具应有的边界。好的工具不是功能越多越好,而是当你不想打开它时,它也完全不打扰你;当你需要它时,它永远都在,而且用最朴素的方式给你精确的答案。现在我用 caveman 管理两千多篇工作笔记和三千多篇个人文章素材,从来没有因为工具本身原因丢过一次数据,也没在维护上花过超出半小时的时间。如果你也被各种“大而全”的平台折腾得够呛,我建议你也花一个周末想一想:你真正需要的是什么,以及什么东西可以不要。