
你有没有想过一个笔记系统用得越久越像被绑架我最近把自己的知识库整目录推翻重新搭了一套叫 caveman 的方案。名字有点自嘲做技术的人谁不想用最酷的新玩具可我就是在这个工具爆炸的年代逆向跑去学穴居人干活——只用最原始、最靠得住的材料。整套方案只有三样东西Markdown 纯文本、Git 版本管理、一个用 Python 标准库随手写的脚本。它不解决协作、实时同步、富媒体编辑这些现代需求它只解决一件事让你写下的东西十年后还在十年后还能被搜到。如果你有下面任何一个症状这篇内容大概率对你有用手机和电脑上一共装了四五个笔记 App数据却互相不流通某天想把笔记导出到本地发现私有格式导出后乱码一堆用了好几年的在线服务突然改版几百篇存了很久的文章差点找不到地方安放。这篇文章会从需求诞生开始讲把 caveman 从设计到落地完整拆开包括目录怎么摆、脚本怎么写、搜索怎么做、版本怎么留。适合喜欢本地优先、数据自持、命令行不反感的朋友也适合想在越来越重的工具链里喘口气的人。1. 为什么项目要叫 caveman1.1 名字背后的动机不是倒退是筛选先说说命名这件事。caveman 字面意思是穴居人听起来像是技术退化但它真正想表达的是另一个意思一个洞窟能保存东西几万年而一个在线服务平均生命周期只有几年。我们每天都在生产大量文字、灵感、项目记录却把它们存在最不稳定的载体上——某个公司的数据库里某个私有格式的文件里某个你不一定永远持有订阅权的账号里。我在给这个项目起名的时候脑子里冒出来的画面是如果有一天所有编辑器都打不开了所有网络服务都连不上了我还能不能从本地把十年积累的东西完整翻出来答案其实很简单只要满足两个条件就能做到文件是纯文本结构足够朴素。穴居人把壁画刻在石壁上我把文字放在 .md 文件里本质都是对抗时间方式不同而已。另一个动机是反工具焦虑。现在写点笔记动不动要先对比应用再折腾数据同步还得担心功能插件升级后配置失效。工具成了负担创作本身反而被挤到一边。caveman 的做法是把工具链压到最少少到每一件你都能完全理解、完全掌控。它更像一种设计态度先问一句“这个需求真的需要引入一个重型工具吗”而不是“这个新工具看起来好酷先用了再说”。1.2 一套不依赖平台的技术组合caveman 最终落地的技术组合固定为以下三件套Markdown 纯文本格式用最简单的语法承载内容任何设备、任何系统都能打开编辑没有版本兼容问题。Git 做版本快照用 git 记录每一次修改可以随时回溯也可以把仓库推到自己的服务器或本地 NAS 做异地备份。Python 标准库脚本索引、搜索、统计、导出全部基于 Python 自带的 sqlite3、re、pathlib 等模块不需要 pip install 任何东西。选这三样几乎不需要犹豫。Markdown 解决了“可读性”Git 解决了“可恢复性”脚本解决了“可检索性”。这三者没有一个属于某个商业公司也没有一个依赖在线服务存活。它们都是被反复验证过的老技术就像石头和火虽然原始但不会因为风潮改变而消失。这套组合有一个明显特点单机可用离了网也完全能干活。白天在公司改晚上在家继续写中间不需要任何云端中转。如果确实需要跨设备备份git remote 指向自己的内网仓库就行。这也是我把 Git 而不是网盘文件夹作为备份核心的原因——网盘只能保存文件git 保存的是每一次修改还能告诉你某个念头最早是哪天冒出来的。2. 核心设计拆解为什么这三样搭配起来很强2.1 一切以纯文本为准在设计最开始的时候我其实犹豫过要不要用数据库做主存储。关系型数据库查询能力强支持复杂统计看起来更适合做知识库。但很快我就否定了这个方案如果用数据库直接存笔记正文那么查看和编辑都得依赖程序一旦脚本坏了、依赖装不上、数据库文件损坏恢复成本就很高。而每个人都自带“阅读器”的格式只有一个纯文本。Markdown 的另一个好处是 diff 友好。我用 git 管理笔记每次提交都能清楚看到哪一行变了、哪些段落新增了。如果用文档格式比如 docx或者富文本数据库存内容diff 就是一团乱码版本追踪基本失去意义。纯文本带来的 git 体验非常顺滑改了一个错别字也能一眼看出来。除了正文我还留了一小段 YAML 格式的文件头front matter用来记录标题、日期、标签。这个设计很克制没有引入复杂的 schema只是给了脚本一个稳定的字段来源。说实话就算没有文件头光靠文件名和正文也能完成大部分功能文件头只是让统计和归档更方便。目录结构最终长这样非常简单caveman/ ├── notes/ │ ├── idea-001.md │ ├── travel/ │ │ └── 2024-11-03-mountain-hike.md │ └── reading/ │ └── book-clean-code.md ├── cache/ │ └── index.db ├── build_index.py ├── search.py └── stats.pynotes 目录放所有内容可以无限套子目录完全按自己的习惯组织。cache 目录放程序生成的数据库索引这个文件可以随时删除重建它不是源头只是检索用的缓存。2.2 Git 不只是备份还是时间机器很多人习惯用网盘或同步文件夹备份笔记我承认那样确实省事但有一个天然缺陷网盘只告诉你“现在文件长什么样”不告诉你“一周前长什么样”。写东西这件事历史版本非常值钱。有时候一个想法改着改着就偏离了你想回头看看最初的那个版本网盘早就把旧版本覆盖了而 git 里这种操作只是 checkout 一条命令的事。Git 还能让我每次修改都留下一个语义化提交信息。我养成了一个习惯晚上收工前把当天写的东西统一提交提交信息就一句话比如“补充项目复盘思路”“整理读书摘抄”。未来翻历史时会惊喜地发现这比任何标签系统都真实因为提交记录记录了你的思考轨迹而不是事后整理出来的伪分类。配合脚本还可以做个定时自动提交。我用 cron 每小时跑一次#!/bin/bash cd /path/to/caveman git add -A git commit -m auto snapshot: $(date %Y-%m-%d %H:%M) || true注意最后那个|| true这是为了在没有变化时不要让脚本报错。git commit 在没有任何改动时会返回非零状态cron 会认为任务失败并给你发邮件加上这个就不会被骚扰了。2.3 为什么脚本必须零依赖caveman 的三个脚本我刻意没有引入任何第三方库只用了 Python 标准库。这个决定看起来有点“轴”但实际体验下来非常值。首先任何一台装了 Python 的设备都能直接跑不用先建虚拟环境、不用处理依赖冲突。其次标准库的 API 十年不坏Python 3 到 3.12 这些模块的接口基本保持稳定代码写完今天能跑五年后大概率也能跑。如果你以前没怎么接触过这类小工具可以直观理解成我不需要一个微波炉那么大的电饭煲只需要一个能刚好煮一碗米饭的小锅。每加一个依赖就相当于多了一个可能坏的零件。零依赖的脚本虽然有些功能要自己写但它足够透明每次运行我都知道它做了什么。3. 实操把 caveman 从零搭起来3.1 初始化仓库和三脚本功能划分开始之前先把仓库和目录结构建出来mkdir -p caveman/notes mkdir -p caveman/cache cd caveman git init三个脚本的分工很明确build_index.py 扫描 notes 下所有 .md 文件把元信息和正文写进 sqlite 索引search.py 接收一个关键词返回匹配的笔记列表和上下文片段stats.py 用来统计笔记数量和总字数给自己一点正反馈。索引是关键环节。我采用的方案是每次全量重建索引。对你没有看错是全量。很多人一上来就想着增量同步、监听文件变化但对个人笔记这种量级来说几百篇 Markdown 文件全量扫描也就几十毫秒增量逻辑反而会增加复杂度。这也是 caveman 的精神——能全量扫描就不做增量能重复做的事就不优化到极致。只有当你的笔记数量达到几万篇级别再考虑用 watchdog 监听文件变化也不迟。3.2 build_index.py把 .md 变成可检索数据先看完整的 build_index.py。代码不多但我把关键步骤写得非常直白#!/usr/bin/env python3 # -*- coding: utf-8 -*- caveman: build a plain-text searchable index. import sqlite3 import re import sys from pathlib import Path ROOT Path(__file__).resolve().parent NOTES ROOT / notes INDEX_DB ROOT / cache / index.db def extract_meta_and_body(path: Path): 从 markdown 里提取 front matter 和正文内容。 text path.read_text(encodingutf-8, errorsignore) meta { title: path.stem, date: , tags: [], } # 只解析以 --- 开头的文件头没有就退回文件名。 match re.match(r^---\s*\n(.*?)\n---\s*\n, text, re.S) if match: block match.group(1) text text[match.end():] # 去掉文件头部分 for line in block.splitlines(): if line.startswith(title:): meta[title] line.split(:, 1)[1].strip() elif line.startswith(date:): meta[date] line.split(:, 1)[1].strip() elif line.startswith(tags:): raw line.split(:, 1)[1].strip() meta[tags] [item.strip() for item in raw.split(,) if item.strip()] return meta, text def main(): INDEX_DB.parent.mkdir(exist_okTrue) conn sqlite3.connect(INDEX_DB) conn.execute( CREATE TABLE IF NOT EXISTS notes ( path TEXT PRIMARY KEY, title TEXT, date TEXT, tags TEXT, content TEXT ) ) seen set() for md_file in sorted(NOTES.rglob(*.md)): rel_path md_file.relative_to(ROOT).as_posix() meta, body extract_meta_and_body(md_file) conn.execute( INSERT OR REPLACE INTO notes (path, title, date, tags, content) VALUES (?, ?, ?, ?, ?), (rel_path, meta[title], meta[date], ,.join(meta[tags]), body), ) seen.add(rel_path) # 删除索引文件已经被移走的记录保持索引和目录一致。 existing conn.execute(SELECT path FROM notes).fetchall() for (row,) in existing: if row not in seen: conn.execute(DELETE FROM notes WHERE path ?, (row,)) conn.commit() conn.close() print(fcaveman index done, {len(seen)} notes indexed.) if __name__ __main__: main()几个细节说一下。errorsignore是显式处理某些历史文件可能存在的编码问题避免一个文件读不出来导致整个脚本崩溃。rel_path用相对路径而不是绝对路径这样仓库挪到别的机器上索引里的路径依然有效。INSERT OR REPLACE让重复执行脚本变得安全不会因为笔记内容更新了就在表里留下多份历史副本。运行很简单python3 build_index.py你会看到输出caveman index done, 12 notes indexed.如果以后新增了笔记只要重新跑一次这个脚本索引就会刷新。这里我强烈建议正式使用后在文件末尾增加一行注释说明这个脚本的用法防止三个月后的自己看到文件一脸茫然。3.3 search.py让搜索不靠“回忆文件名”现在写核心搜索脚本。个人知识库里最大的痛点就是明明写过但想不起来存在哪个文件里、标题是什么。搜索必须做全文匹配而不是只搜标题。#!/usr/bin/env python3 # -*- coding: utf-8 -*- caveman: full text search over indexed notes. import sqlite3 import sys from pathlib import Path DB Path(__file__).resolve().parent / cache / index.db def make_snippet(text: str, keyword: str, width: int 120) - str: 截取关键词周围的一段文字方便快速判断命中内容。 lower_text text.lower() idx lower_text.find(keyword.lower()) if idx -1: return text[: width].strip() start max(0, idx - 40) end min(len(text), start width) snippet text[start: end].strip() if start 0: snippet ... snippet if end len(text): snippet snippet ... return snippet def main(): if len(sys.argv) 2: print(usage: python search.py keyword) return keyword .join(sys.argv[1:]) conn sqlite3.connect(DB) rows conn.execute( SELECT path, title, date, content FROM notes WHERE content LIKE ? OR title LIKE ? ORDER BY date DESC , (f%{keyword}%, f%{keyword}%), ).fetchall() print(ffound {len(rows)} result(s) for: {keyword}) for path, title, date, content in rows[:20]: print(f\n[{date}] {title}) print(f file: {path}) print(f snippet: {make_snippet(content, keyword)}) if __name__ __main__: main()LIKE加百分号的写法是最朴素的全表扫描几百篇笔记完全够用结果干净利落。你可能会问为什么不用 SQLite 的 FTS5 全文索引答案还是极简原则——FTS5 需要额外建虚拟表、处理分词器和中文 tokenizer复杂度成倍上升。而对中文内容来说like %关键词% 的匹配方式简单直接因为中文不需要英文那种空格分词直接用字符串包含判断反而最可靠。实际使用效果python3 search.py 复盘输出类似found 3 result(s) for: 复盘 [2024-11-03] 一次户外徒步的复盘 file: notes/travel/2024-11-03-mountain-hike.md snippet: ...周六走了 18 公里早上六点半出发前半程节奏过快导致下午体力... [2024-10-12] 项目冲刺阶段复盘 file: notes/work/project-october.md snippet: ...原以为瓶颈在接口联调复盘下来发现真正的堵点是需求文档变更...标题、日期、片段一起展示基本不用再打开文件看全文。这个脚本我平均每天要用十几次每次都只有一行命令快得很。3.4 stats.py让积累变可见人都是需要反馈的写笔记也一样。我加了一个 stats.py每次跑完都提醒自己又积累了多少内容#!/usr/bin/env python3 # -*- coding: utf-8 -*- caveman: show basic stats about your notes. import sqlite3 from pathlib import Path DB Path(__file__).resolve().parent / cache / index.db conn sqlite3.connect(DB) total_notes conn.execute(SELECT COUNT(*) FROM notes).fetchone()[0] total_words 0 for (content,) in conn.execute(SELECT content FROM notes): total_words len(content.split()) conn.close() print(fnotes: {total_notes}) print(fwords: {total_words})输出就两行但很有仪式感。每当我想知道这一年到底写了多少东西就敲一下这个脚本看着字数一点点涨上去会有一种踏踏实实的积累感。如果你喜欢更细的统计在这个基础上加按年、按标签分组都很容易。4. 进阶玩法让这套原始方案跟上日常节奏4.1 自动快照和异地备份做完基础三件套之后我开始思考一个更现实的问题如果电脑硬盘坏了怎么办。Git 只保证内容在仓库里有历史但如果仓库只存在于这一台电脑上那坏了就全没了。所以备份这步不能省。最简单的做法是给 git remote 指向一个自建服务器或者 NAS 上的裸仓库然后每天定时 pushgit remote add origin gitmy-nas:caveman.git git push -u origin master配合之前提到的 cron 自动提交可以再加一条定时推送任务。于是整条链路就变成本地写笔记 → 定时自动 commit → 定时推送远端 → 即使本地硬盘物理损坏远端也有一份完整历史。如果连自建服务器都没有那就外接一个移动硬盘每周手动git bundle create caveman.bundle --all打包整个仓库放进保险柜或者抽屉里。这个方案的可靠性在于备份的不是某个瞬间的文件快照而是从第一天开始的全量提交历史。我甚至可以定位到半年前某一天看我当时写下某篇文章时的修改顺序。4.2 用同一个仓库生成个人站点笔记攒多了难免想分享出去或者放到网页上供自己随时翻。caveman 的进阶玩法是只加一个脚本把 Markdown 批量转成 HTML。这里我只以最简单的输出为例保留核心思路——生成一个带搜索框的静态索引页。如果你需要更完整的方案可以直接引入自己熟悉的转换工具比如 pandoc 或者 Python 的 markdown 库但零依赖的精神依然保留在存储层。我实际使用的是按需安装 markdown 库的方案因为已经明确这是“编译发布”用途属于构建工具链而不是日常使用依赖。这就是我反复强调的思想可以使用工具但工具不能变成数据和思想的唯一容器。4.3 多设备之间的协调方式手机上是写临时灵感最频繁的地方。我的做法是只做“入口和出口”不要求手机完成所有管理工作。手机上任意一个支持纯文本编辑的应用比如系统备忘录或者文本编辑器写完直接保存为 .md 文件定时上传到自己的仓库目录下。电脑端执行 pull新的笔记就进来了。反过来如果在电脑上写了大段内容想带去外面路上看先推到远端手机上再拉下来即可。整个过程不依赖任何特定商业产品的同步协议只依赖 git 这个最基础的协议。这套机制跑了大半年最深的体会是多设备同步的本质不是把文件复制到每个地方而是让每个地方都保留同一份历史。复制会造成分叉git 则是多端合并。5. 常见问题与排查技巧实录这里整理我在使用过程中真正遇到过的问题每条都是拿自己的数据试出来的。现象原因解决办法build_index.py 读某些文件时报错文件不是 UTF-8 编码脚本里加上 errorsignore或是用 iconv 转码search.py 搜中文关键词匹配不到关键词里带了多余空格或大小写不一致令用 .join(sys.argv[1:])合并关键词按小写匹配git commit 总报“没有改动”自动提交脚本没判断有无变更commit 前git diff --quiet或加 笔记越攒越多搜索变慢LIKE 全表扫描在超大库上效率下降几千条以内不用管再大就用 FTS5图片附件塞进仓库导致仓库巨大Git 不适合存大体积二进制附件单独目录不入库或者正文只存外链在 Windows 上 clone 后换行符被改git 默认 autocrlf 会转换换行加.gitattributes统一为 LF缓存索引和实际目录不一致运行搜索前忘了重建索引把 build_index.py 和 search.py 串成一条命令别名我挑几个重点展开说说。第一Windows 换行问题。用 git 管理 Markdown 时如果文件在 Windows 上被转成 CRLF提交到远端再被别的设备拉下来有时会出现全文可读但 git diff 显示整篇变红的情况。解决方法是仓库根目录放一个.gitattributes强制固定 LF*.md text eollf这个文件本身也应该提交进 git。有了它不管在哪个平台打开文件的换行符都被规则统一不会再因为换行符闹出“假差异”。第二搜索关键词大小写。英文笔记里常常有 TechNotes、techtips 这类词LIKE 默认区分大小写。我在脚本里统一做了转小写再匹配虽然对中文没影响但英文搜索体验提升明显。这算是一个很简单却被很多人忽略的细节。第三目录调整后的索引清理。如果你把 notes 子目录重命名索引里还会残留旧路径记录。build_index.py 里的删除逻辑专门处理了这种情况。每次全量重建索引都会比对当前真实文件列表把已经被移走的旧记录从索引中清掉。这个逻辑不加也没大碍但加上之后查询结果不会出现幽灵文件。6. 个人经验与后续还想加的东西一套工具用久了你会慢慢发现它的边界。caveman 的边界很清楚它不擅长富媒体不擅长多人协同不擅长对格式有严苛要求的场景。但对我来说这些都不重要我真正需要的是稳定、可检索、背得走的知识沉淀。每次在别的工具里折腾同步失败、导出一堆乱码的时候我都会想起这个“穴居人”方案的好。坚持使用半年后有几个变化是意外的。第一我的写作量反而变大了因为入口足够简单打开终端敲一行命令就能开始写。第二我开始愿意回头翻旧笔记因为 git 历史让“回忆版本”变得毫不费力。第三某些在其他软件里写了两遍都不满意的文章在纯文本里反而写得更顺畅——也许是因为 Markdown 的语法限制让你专注于内容而不是排版。如果未来要扩展我有三个方向。第一个是给 search.py 加一个管道入口让它能对接其他命令比如直接从搜索结果里打开文件省去手动复制路径的步骤。第二个是增加按日汇总功能把每天新增和修改的笔记整理成一条日报。第三个是做一个简单的caveman add命令在终端里直接新建带日期和标题的 Markdown 文件省得手动建目录和文件名。但即便什么都不加现在的状态已经足够让我安心。工具的意义从来不是越多越好而是出了问题你亲手能修换了环境也能跑。caveman 就是那个永远不被淘汰的底牌。