☰
用文件夹与命令行搭建离线优先的个人知识库:caveman 实战解析
2026/10/7 9:02:36 网站建设 项目流程

caveman,这个代号我用了快两年。它不是某个开源框架,也不是某个大厂的新产品,而是我自己手上那套“越用越顺手、断了网也不慌”的本地知识管理方案。整套系统核心思路一句话能说清:把笔记、资料、灵感、待办全部存成纯文本,用文件夹当数据库,用文件名当标签,用极简的命令行做检索和归档。听起来很原始,对吧?这就对了,“穴居人”要的就是原始、可靠、不过度设计。

这篇文章写给谁?给那些已经受够了“笔记软件里存了十年资料,最后连自己都搜不到”的人;给那些手头有大量零散文本、剪藏、日志和文档碎片,想做一套“不会倒闭、不依赖任何服务商”的本地存储体系的人;也给想从零开始复刻这套方案,拿它管理个人知识库、写作素材库或离线资料库的开发者。全文没有任何平台依赖,所有代码和配置都是本地文件,你会看到我是怎么拆解设计、怎么建索引、怎么搞检索、怎么踩坑和填坑的。

1. 内容整体设计与思路拆解

1.1 为什么不做成App,而要做成“文件夹+命令”

我当时的需求背景很简单:我有海量的 Markdown 碎片、网页正文剪藏、日志导出、电子书划线和零星想法,分散在十几个目录里。买了各种笔记软件,最终发现两个痛点绕不过去——数据出口锁得死,检索逻辑我改不了。软件一旦停止维护,或者我想换一种索引方式,就只能被它绑架。

所以我就想做一套“原始到不能再原始”的方案,它的第一性原理是:

  • 数据必须是我随时能用编辑器打开的纯文本。没有私有二进制格式,没有加密数据库,没有绑定账号。
  • 存储结构必须透明。文件夹叫什么、里面放什么,我一眼能看懂,不用依赖任何“库”概念。
  • 检索和整理逻辑必须完全可控。想按标题搜、按标签搜、按全文搜、按日期范围搜,应该由我自己写脚本决定,而不是等某个软件更新。

这套方案我取名 caveman,说白了就是回到“记事本+文件夹+命令行”的原始状态。它的本质不是工具,而是一套约定。工具可以随时换,约定一旦建立,迁移成本几乎为零。

对比一下常见的三种路线:

方案数据控制权检索能力迁移成本离线可用
商业笔记软件低中高视产品而定
自建 Wiki/数据库中高中需要环境
caveman 纯文本体系高可控极低完全离线

我自己是重度终端用户,命令行是肌肉记忆的一部分,所以“命令”对我来说不仅不是门槛,反而是最自然的交互方式。对不熟悉终端的朋友来说,这套约定依然成立——你只需要把“跑命令”理解成“双击一个脚本”即可,后面我也给了普通用户可用的 shell 脚本和 Python 脚本方案。

1.2 核心技术点拆解

caveman 不是某个单一技术,而是几层极简技术的组合:

  • 存储层:Markdown + 纯文本文件,UTF-8 编码,统一换行符。
  • 索引层:SQLite 单文件数据库,专门用于记录文件名、路径、修改时间、标签和概要字段,不存正文,只存元数据指针。
  • 检索层:Python 标准库为主,必要时用万能的grep/find配合,全文搜索时对正文进行逐行扫描。
  • 交互层:终端命令。除此之外,可以加一个只读的本地 HTML 预览页,让不习惯终端的人也能浏览器浏览。

选定 SQLite 我当时算过几笔账:对于 10 万量级的文件索引,SQLite 单机性能完全不构成压力;单文件存储备份极其方便;Python 自带sqlite3,不需要额外安装任何数据库服务;整个库文件即使膨胀到几 GB,日常增删改查也是毫秒级响应。最关键一点,SQLite 不是“数据后端的黑盒”,它本身就是一个可审查的文件,你不信任它的时候可以直接用strings和sqlite3命令行工具裸查。

1.3 这套设计避开了什么坑

我见过太多知识管理项目,都倒在同一类问题上:过度设计。文件要存到对象存储,索引要上 Elasticsearch,标签要搞多层级分类,图片要单独管线处理,最后项目越来越复杂,维护成本远远超过了它带来的收益。

caveman 刻意避开了这些:

  • 不做多级分类,只做本地文件夹 + 扁平标签。标签就是文件名里的#tag片段,或者 YAML front matter 里的 keywords 字段。
  • 不做实时同步,所有同步逻辑由外部脚本控制(rsync 或 Syncthing),系统本身不关心同步。
  • 不做富文本、附件内联,图片、PDF 按二进制文件原样存放,索引里只记录路径。
  • 不做云端依赖,纯本地优先,网络对它而言可有可无。

一句话总结这套设计的灵魂:约定优于配置,路径优于数据库,纯文本优于富文本。

2. 核心细节解析与实操要点

2.1 目录结构约定

caveman 的目录结构我花了很长时间打磨,最终的约定如下:

caveman/ ├── inbox/ # 收集箱,所有新内容先进这里 ├── notes/ # 常青笔记,按主题分子目录 │ ├── tech/ │ ├── life/ │ └── work/ ├── journal/ # 日志,按 YYYY/MM 分目录 ├── assets/ # 附件:图片、PDF、音频等 ├── archive/ # 超过 90 天未修改的内容移入 ├── templates/ # 新建笔记用的模板 └── caveman.db # SQLite 索引数据库

inbox是整个系统的胃。所有捕获的碎片——网页剪藏、微信收藏、随手打的草稿、临时备忘——一律先丢进inbox。等有空时再做二次处理,处理动作只有两个:提炼进notes,或者留着不处理。这样做的最大好处是“捕获”和“整理”在时间上被彻底分离,不会因为整理压力而放弃捕获。

文件名格式我统一为YYYYMMDD_HHMM_短slug,比如20250921_1830_git-rebase-notes.md。文件名本身自带了时间戳信息,这给后续按时间范围检索提供了极大的遍历便利。

2.2 标签怎么组织

caveman 的标签规则只有两条:

  • 标签一律写在 YAML front matter 的tags字段里,逗号分隔。
  • 标签字符限制为小写字母、数字、连字符,禁止空格和特殊符号。
--- title: Git Rebase 实操笔记 date: 2025-09-21 18:30:00 tags: git, dev, workflow --- 正文内容...

为什么这样干?因为标签一旦允许任意字符,检索时就要考虑转义、分词、大小写问题。限制成“小写加连字符”后,SQLite 里做等值匹配就够了,全文扫描时也完全不用处理边界情况。我吃过乱写的亏,比如tags: 重要/临时/工作,最后检索时含/的 tag 变得非常难查,干脆从源头约束掉。

2.3 索引器到底该记什么字段

索引器不存正文,它只负责把每个文件的基本信息登记到 SQLite 表中。我的建表语句经过了几轮迭代,最终稳定为:

CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, title TEXT, ext TEXT, size INTEGER, mtime REAL, tags TEXT, summary TEXT, created_at REAL ); CREATE INDEX IF NOT EXISTS idx_path ON files(path); CREATE INDEX IF NOT EXISTS idx_mtime ON files(mtime); CREATE INDEX IF NOT EXISTS idx_tags ON files(tags);

summary字段值得单独说。它不是必须的,但对于几百字以内的碎片笔记,索引时直接把正文前 200 个字符截出来存进这个字段,浏览列表时就可以不打开文件直接看概览,速度体验提升非常明显。mtime存浮点时间戳,方便做增量索引判断。

2.4 增量索引的设计逻辑

每次跑索引不可能全盘扫描一遍,几万文件倒是无所谓,文件一旦上了几十万个,全量扫描就会慢到让人不想用。增量索引的做法是:

  • 遍历时记录每个文件的mtime。
  • 数据库里查既有记录的mtime,两者相同就跳过。
  • 路径不存在了说明文件被移动或删除,从索引中移除。

我的爬虫脚本大概长这样,逻辑很直白:

import os import sqlite3 import time ROOT = "/path/to/caveman" EXTENSIONS = {".md", ".txt", ".markdown"} def index_file(db, full_path, rel_path): mtime = os.path.getmtime(full_path) size = os.path.getsize(full_path) row = db.execute("SELECT mtime FROM files WHERE path = ?", (rel_path,)).fetchone() if row and abs(row[0] - mtime) < 1e-6: return False title = os.path.splitext(os.path.basename(rel_path))[0] tags, summary = "", "" try: with open(full_path, "r", encoding="utf-8") as fh: head = fh.read(400) if head.startswith("---"): fm_end = head.find("---", 3) if fm_end != -1: fm = head[3:fm_end] for line in fm.splitlines(): if line.startswith("tags:"): tags = line.replace("tags:", "").strip() elif line.startswith("summary:"): summary = line.replace("summary:", "").strip() if not summary: summary = " ".join(head.split())[:200] except Exception as e: print(f"read error: {full_path} -> {e}") db.execute( "INSERT INTO files(path, title, ext, size, mtime, tags, summary, created_at) " "VALUES(?,?,?,?,?,?,?,?) " "ON CONFLICT(path) DO UPDATE SET title=excluded.title, size=excluded.size, " "mtime=excluded.mtime, tags=excluded.tags, summary=excluded.summary", (rel_path, title, os.path.splitext(rel_path)[1].lower(), size, mtime, tags, summary, time.time()), ) return True

增量索引是我踩坑最多的地方。最开始我用path当主键直接 replace,结果 mtime 没变也会整行重写。后来改成先比较 mtime 再决定是否 update,实测扫描 10 万文件,终端秒开。

2.5 全文搜索的取舍

SQLite 可以加 FTS5 虚拟表做全文索引,但我的实践结论是:个人知识库场景,直接线性扫描局部文件远比你想象的轻快。普通人的笔记库也就几千个文件,每个文件几 KB 到几十 KB,grep -rni全库扫描一秒内出结果。盲目给每个文件建 FTS 索引反而会出现索引维护成本大于检索收益的情况。

我推荐组合检索方案:

  • 元数据查询:走 SQLite,按标题、标签、时间范围过滤。
  • 正文关键词:走极简 Python 扫描,或者直接grep。
  • 混合查询:先用 SQLite 缩小候选范围到几百个文件,再做正文扫描。

“两阶段检索”的思路,比单一大而全的方案高效得多,还省掉了 FTS 的所有调参成本。

3. 实操过程与核心环节实现

3.1 初始化环境的完整过程

第一步,创建目录骨架。不要手工 mkdir 一长串,直接写进脚本:

mkdir -p caveman/{inbox,notes,notes/tech,notes/life,notes/work,journal,assets,archive,templates} cd caveman python3 -m venv .venv source .venv/bin/activate pip install pyyaml # 只有这个第三方依赖,甚至这也非必须

第二步,初始化数据库:

sqlite3 caveman.db < schema.sql

第三步,写通用入口脚本cm,统一接收子命令。结构上参考了 git 的做法,子命令分发,降低单个脚本复杂度。我的核心命令只有六条:

  • cm index [path]:更新索引
  • cm find <keyword>:按文件名和标题搜索
  • cm grep <text>:扫码正文
  • cm tags [tag]:按标签列出或过滤
  • cm recent [n]:最近 n 条内容
  • cm inbox:列出收集箱未整理内容

3.2 标签过滤的实现

标签过滤最简单,直接 SQL:

SELECT path, title, mtime FROM files WHERE tags LIKE '%' || ? || '%' ORDER BY mtime DESC;

LIKE 的隐患是可能把git-workflow误匹配进git查询里,但对我这种编码规则 + 手动维护的实际场景,误报影响很小,检索速度快,完全可接受。真要精确匹配,可以在分词阶段把 tag 拆分后存成单独表,但个人知识库真没必要。

3.3 正文检索的实作

正文检索我用两个方案,小库直接用 grep,大库用 Python 流式扫描。

grep -rni --include='*.md' --include='*.txt' '关键字' /path/to/caveman/

Python 方案的精髓是“生成器逐块扫描”,避免把大文件一次读进内存:

import os import sys def scan_files(paths, keyword): keyword = keyword.lower() for p in paths: with open(p, "r", encoding="utf-8", errors="ignore") as f: for num, line in enumerate(f, 1): if keyword in line.lower(): yield p, num, line.strip() if __name__ == "__main__": root = sys.argv[1] kw = sys.argv[2] targets = [] for dirpath, _, files in os.walk(root): for f in files: if f.endswith((".md", ".txt", ".markdown")): targets.append(os.path.join(dirpath, f)) for path, num, text in scan_files(targets, kw): print(f"{path}:{num}: {text[:120]}")

errors="ignore"在碰到零散二进制垃圾文件时很管用,避免整个检索中断。这样的顺序扫描,实测 1.5 万个文本文件(约 1.2 GB)按关键词扫一遍也就两三秒,我完全能接受。

3.4 HTML 预览与静态导出

终端不是所有人的舒适区,所以我还做了个cm serve子命令,把检索结果和目录浏览渲染成一个个自包含 HTML 文件。做法不引入任何前端框架,Python 标准库html转义 + 字符串拼接就够用。

关键点在于,生成的 HTML 最多只做目录列表和文件预览,不做在线编辑,这样能保证整个系统始终是“文件系统为权威”,HTML 只是缓存视图。后续你可以挂到任意静态服务器上,甚至局域网里别人也能浏览你公开的资料库。

3.5 定时自动索引

手动索引不够自动化,我用 cron 做了每日一次的全量增量索引:

0 3 * * * cd /path/to/caveman && .venv/bin/cm index >> logs/index.log 2>&1

为什么不监听文件变化做实时索引?因为个人写入频率没高到需要实时索引的程度。每天一次增量扫描足够,而且日志能告诉我哪些阶段耗时最久,方便调优。

4. 常见问题与排查技巧实录

4.1 数据库文件损坏了怎么办

SQLite 单文件数据库偶尔会损坏,尤其是断电或进程被 kill 时。我的处理流程分三级防护:

  • 第一层:每日备份,cp caveman.db caveman.db.bak,保留最近 7 份。
  • 第二层:定期执行PRAGMA integrity_check;自查。
  • 第三层:真正损坏时,索引丢了其实也无所谓——重新跑一遍cm index就全回来了,因为原始数据全在文件系统里。

这个“索引冗余于数据”的设计,是整个体系里最值钱的一条决策。索引不是资产,原始文件才是。我把这个原则写在了项目 README 的第一行。

4.2 文件名和正文编码混乱

剪藏网页经常出现各种编码问题。我的硬性约定是:进入 caveman 的任何文本文件必须先转成 UTF-8。外部内容进来时统一过一遍iconv或 Python 的encode/decode,转不掉的直接丢给archive而不是杀掉文件。

不要在索引器层面对编码做太多宽容处理,否则大概率会出现“有的文件能搜到,有的搜不到”这种幽灵问题。

4.3 检索结果为什么总漏文件

最常见的三个原因:

  • 扩展名不在索引规则内,比如某些剪藏存成了.html。
  • 文件太大(比如超过 10 MB 的日志导出),我的扫描器默认跳过这种巨型文件。
  • 符号链接没开启,os.walk默认不递归进入链接目录。

我的解决办法是在索引器里加include_exts配置项,把.html、.org、.rst一视同仁纳入索引。巨型文件单独建一个big-files.txt映射清单,搜索时走映射而不是正文扫描。这些细节一开始没考虑到,第二年才慢慢全部补齐。

4.4 标签查询速度和准确性矛盾

整体来说 LIKE 方案能应付绝大多数场景,但如果你想彻底解决误匹配问题,我这里给出一个升级版的拆分表方案:

CREATE TABLE file_tags ( file_id INTEGER, tag TEXT, PRIMARY KEY (file_id, tag), FOREIGN KEY (file_id) REFERENCES files(id) );

每次索引更新时,先从files.tags字段拆出 tag 数组,逐条插入file_tags。查询时WHERE tag = ?精确匹配。这个方案现在运行稳定,数据量翻了几倍后检索依旧准、快。

4.5 大量归档文件导致索引变慢

archive目录是历史包袱的堆积地,里面文件永远不会改动,每次增量索引仍然要 stat 一遍。优化办法是把 archive 单独建一个只读索引库,主库索引只扫描inbox、notes、journal这些活跃目录。查询时默认搜主库,没结果再查 archive 库。

实测优化后索引时间从十几秒降到两秒多。核心思路是:冷热数据分治,别让历史包袱拖累日常搜索体验。

5. 压力测试与容量扩展

5.1 数据量级的实测结果

我用一台很普通的机器做了压测:CPU 是四年前的移动端标压,内存 16 GB,硬盘是 SATA SSD。

指标实测结果
10 万文件全量索引首轮耗时约 280 秒
10 万文件增量索引耗时约 3.2 秒
文件名匹配查询毫秒级
标签过滤查询毫秒级
1.2 GB 正文关键词扫描约 2.5 秒

结论很明确:个人知识库就算用十年,数据量也远够不上瓶颈。caveman 架构的上限远远高于个人日常使用需求。

5.2 扩展方向一:附件缺失检测

附件散落在assets目录,笔记里引用的图片可能已经删除。我加了一条cm check命令,扫描所有 Markdown 文件里形如![...](path)的引用,再核对磁盘上是否存在。跑一次就能找出几十张“幽灵引用”,对写作和笔记完整性检查非常有用。

5.3 扩展方向二:无网络环境下的完全使用

整套系统没有任何网络调用。换新电脑时,直接拷走整个 caveman 目录重建运行环境,零成本迁移。甚至在某次出差时,整个网络环境都受限,我在飞机上用 caveman 完成了整周写作任务和素材整理。离线优先不是锦上添花,而是这套体系的核心性格。

5.4 扩展方向三:多终端同步取舍

同步我走的是文件级方案,不需要应用层参与。手机、笔记本、台式机之间用 Syncthing 同步整个目录。唯独要注意:caveman.db不能多端同时写,否则会有锁冲突。我的方案是手机端不跑索引,只改文件;电脑端是权威索引方,冲突时以电脑端数据库为准。

6. 维护复盘与长期经验

6.1 坚持“捕捉而非整理”两年后的形态

从最初几百个文件,到现在几万条笔记、素材、日志、附件,caveman 的目录形态基本没变过。最大的变化是notes下的技术子目录多了不少分类,但整体结构依然保持最初的设计。事实证明,简单可靠的结构比复杂周全的设计更有生命力。

两年里最明显的收益是“再也不怕软件倒闭了”。你换任何新笔记软件,只要它能导入 Markdown,caveman 就能无缝迁移;反过来也一样,任何软件想迁进 caveman,把文件导成纯文本丢进来即可。

6.2 容易忽略的坑:元数据和正文同步

手动改文件里的 front matter 标签,但忘了重新跑索引,搜索结果就会和实际不一致。这个问题太常见了。我的建议是,所有入口动作都收口到命令脚本中,不要手动用其他编辑器改动已有索引的文件。甚至可以把cm index绑定到 shell 提示符里,每次打开终端自动执行一遍增量索引。

6.3 备份才是真正的安全底线

数据库每天备份、目录本身每周做一次全量快照到移动硬盘。我对云备份一直持保留态度,纯文本数据用压缩包加密后放本地存储才是最可控的做法。失去让该系统存活压力,真正能杀死它的只有硬盘损坏和人为误删。

6.4 扩展建议:让系统“有记忆”

我在第二年加入了cm log子命令,用于快速追加一条带时间戳的流水记录。比如正在调试某个库,随手输入:

cm log "尝试 XX 库的 YY 功能,遇到 Z 问题"

这些记录按小时写入journal/2025/09/21.md。几个月后回溯项目的演进路径时,这些流水账比正式笔记真实得多。这种“少加工、多记录”的设计风格,其实才是 caveman 最核心的理念——先让数据存在,让检索成为可能,至于美观和整理,统统往后放。

我自己在这套体系里最大的体会是:知识管理的问题不是工具不够,而是人们总期望一个更好的工具能替代“手动建立整理习惯”。caveman 没用任何神秘技术,它的优势不过是把“纯文本兜底、命令行动手、约定高于配置”这几个有点古董味的原则坚持了足够久。试着从一个小小的inbox目录开始,跑上几十天,你会慢慢感受到这种原始方案带来的踏实感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询