ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写本地优先笔记系统:Python+Markdown+全文搜索实战

手写本地优先笔记系统:Python+Markdown+全文搜索实战 NOTE01 这个代号是我给自己第一个从零动手写的笔记系统起的名字。听起来不起眼但它解决了一个很实在的问题我在用了几轮大厂笔记软件之后越来越受不了“数据在别人服务器上、搜索跑不动、格式锁死”这三件事。所以我基于 Python 和 Markdown 文件搭了一套本地优先、支持全文搜索、能自己掌控所有数据的轻量笔记系统。如果你喜欢折腾工具、想彻底拥有自己笔记资产的开发者或者只是厌倦了被笔记软件绑架内容的人这篇东西应该能给你一些真实可用的思路。整套系统从动手到跑通前后花了不到一个周末。不是因为它功能少而是因为我把“能用、够轻、数据在我手上”排在一切前面。这篇文章会把需求怎么拆、技术怎么选、代码怎么写、坑怎么踩都摊开来讲目录结构、关键参数、索引逻辑都会给出可复现的方案。你可以直接照着搭就算不搭也能从里面抄走几个不错的思路。1. 项目初衷与需求拆解1.1 为什么是 NOTE01项目代号 NOTE01理解成 Notes One 也行理解成“第一个能用的版本”也行。我习惯把所有工具类项目按 NOTE 加编号来管理后续做第二个工具就叫 NOTE02。这么做的好处是每个项目在初期都只有一个清晰的目标不用一开始就把产品想得很大叫一个响亮的名字。这次为什么要动笔写笔记系统说起来有点气人。我之前把几万条笔记陆陆续续放进过主流云笔记产品后来发现三个问题第一导出虽然能做但格式经常乱七八糟图片链接全部失效换一个平台等于重新开始第二搜索在笔记量大了之后慢得离谱好几秒才出结果而且中文分词经常把“机器学习”拆得莫名其妙第三服务器一断或者厂商调整套餐我就担心哪天数据突然少了。所以我给自己定的目标很简单必须本地优先所有笔记都是 Markdown 文件放在我自己的目录里必须能全文搜索几十万字量级下要秒出结果必须不绑定生态想用什么编辑器打开就能打开想搬到别的系统里直接搬走 Markdown 文件。这个项目解决的核心场景就是个人知识库和组织本地资料库。适合的人群也清楚用 Markdown 写作、注重数据资产、愿意花一点时间配置工具的人。它不适合那种想要完整 WYSIWYG 富文本体验、或者需要一个团队协作审批流的人。1.2 需求清单与取舍动手以前我按照“必须有、可以有、不要有”列了一张表这比直接列功能重要得多。必须有支持 Markdown 编辑和预览笔记支持标签和分类全文搜索支持中文数据存储为纯文件可被 Git 管理轻量级运行内存占用低于 200MB支持通过浏览器访问可以有远程仓库自动同步图片粘贴插入后的自动存储移动端只读访问不要有富文本编辑器多人实时协作插件市场数据库存正文这张表最大的价值是帮我挡住了“做加法”的冲动。说实话最开始我还想过是不是要做一个类似带区块链时间戳的功能后来自己都笑了一个本地笔记系统要什么区块链。砍掉这些功能以后整个系统的复杂度直接降了一个量级成本从可能的一周缩到了一个周末。2. 技术选型与架构设计2.1 为什么选 Python FastAPI SQLite技术选型是很多人纠结的地方。我见过朋友做笔记工具一上来就上 Elasticsearch Kubernetes结果笔记没写几条运维倒是写了一堆。我的原则很简单单机部署、单用户、数据量在百万行以内不需要引入分布式。后端我选了 Python FastAPI理由有三个。第一Python 在文本处理和中文分词生态上太成熟了我后面要做搜索索引、解析 front matter、文件监听都能找到很顺手的库。第二FastAPI 自带 OpenAPI 文档前后端对接的时候不用我手动维护接口说明。而且它是异步框架对于我这种文件监听加搜索的场景并发量虽小但异步能避免某个慢操作卡住整个服务。第三SQLite 作为 SQL 数据库足够的轻。我只需要把索引信息存进去正文依然是 Markdown 文件本身所以这个 SQLite 只是一个“索引库”丢了也能靠文件重建不心疼。我最终的结构是Markdown 文件是唯一数据源SQLite 只存索引和元数据的缓存。这个架构有非常大的好处就算索引库被删了我重新跑一遍扫描就能全部找回来数据的可靠性取决于文件系统而不是一个我不完全掌控的数据库。2.2 数据模型一切皆文件笔记目录就像下面这样组织notes/ ├── inbox/ │ └── 2025-01-12-临时想法.md ├── tech/ │ ├── backend/ │ │ └── FastAPI-中间件.md │ └── frontend/ └── life/ └── 健身记录.md每一篇笔记都是标准 Markdown文件顶部留一小块 YAML front matter用来存元数据--- title: FastAPI 中间件到底在什么时候执行 tags: [python, fastapi, 中间件] date: 2025-01-12 status: published --- # 正文开始 中间件执行的时机其实很固定……选择文件而不是数据库存正文关键在于“可迁移性”。你想想如果有一天我想换工具了比如从自建系统换成 Obsidian这些 Markdown 文件搬到目标目录里就能直接用front matter 各家也都能识别个七七八八。而如果你把笔记都锁在某个私有数据库里哪怕这个数据库是 SQLite换个工具也得写迁移脚本。我这里给每个文件起名用日期加语义关键词是为了排序方便。文件名就是天然的 ID重复率低一眼也知道是哪天写的。2.3 索引与搜索方案搜索是最需要认真设计的地方。初期我试过直接用 ripgrep 去扫全部文件说实话速度也不差一万个文件在 NVMe 固态上大概半秒到一秒。但问题是如果每次搜索都全量扫一遍随着笔记量增长响应时间会线性变差而且无法配合多关键词或者按标签过滤。所以最后我用了SQLite FTS5 全文索引。FTS5 是 SQLite 自带的全文搜索扩展在本地场景下比什么 Elasticsearch、Meilisearch 都轻太多。它建立索引之后搜索是查索引而不是扫文件快几个数量级。而且它支持自定义 tokenizer我配合 jieba 把中文文本提前分词后写入索引解决了中文不按空格分词的问题。分词这个点很关键。FTS5 默认的 unicode61 tokenizer 对中文基本等于按字符切分搜索“机器学习”的时候它把文本切成一堆单个汉字相关性很差。我的做法是建索引前用 jieba 对正文做一次分词用空格把词拼起来再交给 FTS5。这样索引里存的就是“机器 学习 到底 怎么 入门”搜索的时候同样分词匹配准确率大幅提升。这里有一个我后来才发现的小坑后面会专门讲先埋个伏笔索引里存了分词结果但要不要连原始正文一起存如果只存分词结果高亮的时候给你输出的就是一堆带空格的词根本没法看。所以我的表设计里单独留了一个原文列搜索命中之后读原文做上下文摘录。3. 核心功能实现与实操细节3.1 笔记的创建与更新流程笔记创建入口有两个一是在网页上写二是用户在本地直接新建 Markdown 文件。无论哪个入口最终都要过一遍解析逻辑更新索引。网页端的创建接口长这样from fastapi import APIRouter, HTTPException from pydantic import BaseModel from pathlib import Path import yaml from datetime import datetime router APIRouter() class NoteCreate(BaseModel): title: str tag: str inbox content: str router.post(/notes) async def create_note(note: NoteCreate): # 用当前日期做文件名前缀避免重名覆盖 today datetime.now().strftime(%Y-%m-%d) safe_title _.join(note.title.split()) filepath Path(notes) / note.tag / f{today}-{safe_title}.md if filepath.exists(): raise HTTPException(status_code409, detail同名笔记已存在) front_matter { title: note.title, tags: [note.tag], date: today, status: published } content ---\n yaml.dump(front_matter, allow_unicodeTrue) ---\n\n note.content filepath.parent.mkdir(parentsTrue, exist_okTrue) filepath.write_text(content, encodingutf-8) # 重建这篇笔记的索引 rebuild_index(filepath) return {id: str(filepath), path: str(filepath)}这段代码看起来简单有几个细节是实际踩过坑才知道的allow_unicodeTrue必须有。如果你用中文标题不设置这个yaml 会把中文转成\u67aa这种一串转义字符直接写进 front matter你拿文本编辑器打开就发现全乱了。文件名用了日期前缀但这里没处理同名冲突之外的更复杂情况。如果同一分钟写两篇同名笔记最好再加一个时间戳后缀我是后来补的。rebuild_index这个函数不要天真地以为每次把全量文件重新扫一遍就行。我一开始确实那么干的结果笔记到两千篇的时候每次新增一篇都要等两三秒体验很糟糕。后来改成只索引变化的文件速度快到无感。3.2 全文搜索接口怎么做到秒出结果搜索接口是整个系统里最有“项目感”的部分。直接看实现思路。表结构设计为CREATE VIRTUAL TABLE notes_fts USING fts5( title, body, path UNINDEXED, tags UNINDEXED, source_txt );其中body存的是分词后的内容source_txt存原始正文搜索结果展示时要靠它。path和tags不参与全文匹配但可以作为过滤条件所以标记为UNINDEXED。查询代码核心其实只有一段import sqlite3 import jieba def search_notes(query: str, tag: str None, limit: int 20): # 查询词同样要做分词 tokens [w for w in jieba.cut(query) if w.strip()] query_fts AND .join(f{t} for t in tokens) conn sqlite3.connect(index.db) conn.row_factory sqlite3.Row sql SELECT path, snippet(notes_fts, 4, «, », …, 16) AS excerpt FROM notes_fts WHERE notes_fts MATCH ? params [query_fts] if tag: sql AND tags ? params.append(tag) sql f LIMIT {limit} rows conn.execute(sql, params).fetchall() return [dict(r) for r in rows]这里snippet函数会自动从source_txt里取命中关键词附近的上下文片段颜色高亮可以在前端做。如果只用分词后的body去匹配你拿到手的是“机器 学习”这样割裂的词没法直接展示。这是非常典型的设计失误我第一版就是这么写的后来重构成了「分词列 原文列」的双列结构。还有人问搜索时为什么要AND而不是OR我试下来AND更符合“搜索笔记”的直觉。你搜“机器学习 入门”的时候想看到的是同时包含“机器学习”和“入门”的笔记而不是只要出现任何一个词就给你出来一百条。精确优先召回靠后这个方向在个人知识库场景里更顺手。3.3 标签体系与聚合页笔记没有严格目录硬分类会用前先打标签。标签主要存在 front matter 的tags字段里。聚合页的逻辑不复杂从 SQLite 的tags列里读取所有标签统计数量生成标签云点每个标签进去再查tags 该标签的笔记列表。但是有个体验细节值得说一说我在设计标签页 URL 时没有用复杂的查询参数而是直接做成/tag/{标签名}。这样路径就是把标签名 URL 编码一下简单也方便在笔记正文里直接粘贴链接。有些笔记里会写“相关内容见 [[机器学习]]”这个双链跳转的解析其实就是查tags或标题里包含“机器学习”的笔记工作量不大但瞬间让系统有了一点知识网络的味道。由于 Markdown 文件本身是开放的标签的维护也完全可以靠文本编辑器批量改。我之前有几百篇笔记标签混乱用一段 Python 脚本全局替换 front matter 里的tags字段几十秒钟搞定这种自由度是云笔记根本给不了的。4. 部署与多端同步4.1 本地运行与 Docker 部署这套系统我本地直接跑非常轻资源占用比开一个浏览器标签页还少。开发调试的时候用 FastAPI 自带的 uvicorn 就行uvicorn main:app --host 0.0.0.0 --port 8210端口 8210 是我随便挑的主要避开容易跟别的开发服务冲突的 8000、8080。生产一点的话我用 systemd 做成系统服务开机自启。这里有一个太容易踩的坑FastAPI 监听地址如果是 127.0.0.1那么局域网内其他设备比如手机是访问不到的。想在手机上看笔记--host必须填0.0.0.0同时注意你的路由器最好开启 AP 隔离防止同一个 WiFi 下的其他设备都能访问你的笔记。如果不想污染本机 Python 环境Docker 是更干净的选择。我提供的Dockerfile大致这样FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8210 CMD [uvicorn, main:app, --host, 0.0.0.0, --port, 8210]搭配docker-compose.yml的时候需要把一个重要目录映射出来services: note01: build: . ports: - 8210:8210 volumes: - ./notes:/app/notes - ./data:/app/datanotes目录是笔记正文data目录是 SQLite 索引。把这两个目录单独映射出来是为了将来升级镜像或者重建容器的时候笔记和索引都还留着。养成“数据和程序分离”的习惯会让运维省很多心。4.2 用 Git 做同步层而不是网盘多端同步是笔记系统绕不开的话题。市面上主流做法是文件同步网盘但我选了 Git 作为同步层。原因还是要回到数据管理和历史版本上。网盘同步只能解决“多设备一致”的问题不能解决“误删之后找回三天前的版本”的问题。Git 天生是版本管理工具每一次 commit 都是一个历史快照误删一篇笔记我可以直接git checkout之前的状态。具体方案是这样的在笔记根目录git init新增文件或修改后执行git add -A git commit -m update可以用 cron 每十五分钟自动提交一次需要多端同步的时候在 GitHub/Gitee 这样的远程托管服务上建一个私有仓库本地git push另一端git pull还写了一个简单的 hook在post-commit里自动触发索引重建这样远端更新下来以后索引也是最新的。类似这样#!/bin/bash # .git/hooks/post-commit cd /path/to/notes python3 scripts/rebuild_index.py --incremental这里有一个真实痛点全局定时提交的 commit message 全都是“update”时间久了根本没法看找历史版本的时候很痛苦。后来我改成提交前读取最近修改过的一个文件名用文件名做 message比如commit -m 更新 FastAPI-中间件.md历史看起来就很有信息量。4.3 备份与恢复别把鸡蛋放一个篮子笔记系统再稳也得做备份。我的备份策略是两路并行的Git 远程仓库是一路本地冷备是另一路。本地冷备我用的是 restic每周六凌晨跑一次加密备份到另一块移动硬盘。cron 配置大概是0 3 * * 6 restic -r /mnt/backup/note01 backup /path/to/notes --tag weekly恢复的话一条命令就能把整个笔记目录还原到某天restic -r /mnt/backup/note01 restore latest --target /tmp/restore-test实际恢复前一定要先恢复到临时目录做检查别直接覆盖原目录万一加密仓库有问题你连原来的文件都丢了那就真的欲哭无泪。这个“恢复演练”的习惯是我两次差点出事之后才补上的。5. 实战踩坑记录与排查技巧5.1 中文文件名和 Git 显示的乱码问题作为一个中文为主的项目文件名里全是中文那是常事。但 Git 在本地 commit 之后执行git status显示出来的是\xe6\x9c\xba\xe5\x99\xa8...完全没法看。原因是 Git 默认把非 ASCII 文件名做了转义status显示的是转义后的路径。解决办法很简单一条设置git config --global core.quotepath false这之后中文文件名就能正常显示了。说起来是小问题但不改的话你会有一种“仓库里是不是乱码了”的错觉非常碍眼。另外如果是 Windows 环境强烈建议统一core.autocrlf的行为。我踩过这种坑在 Windows 上编辑的 Markdown 文件换行符是 CRLF推到远程仓库在 Linux 的服务器上 checkout 下来之后很多工具会把\r当普通字符处理导致预览和搜索都出现诡异的空行。我的做法是git config --global core.autocrlf input全仓库统一以 LF 为准Windows 上提交时自动把 CRLF 转成 LFLinux 上不自动转。这样多端协作的时候行尾不会互相污染。5.2 jieba 分词和 FTS5 搭配的三种坑中文全文索引这块我踩的坑足够单独写一篇。这里列三个最常见的第一是分词粒度不一致导致搜索不到。比如索引文本时jieba 把“自然语言处理”切成了“自然 语言 处理”但是搜索“语言处理”的时候jieba 整体切出一个词“语言处理”两边对不上结果就是搜不到。这个问题的解决思路是查询的时候把用户输入切得更碎一点必要时候对多字词再做一次细粒度切分也就是组合使用 jieba 的搜索引擎模式和默认模式取并集。第二是索引升级之后旧数据没重建。如果你是在已有笔记基础上引入 jieba一定记得写一个重建全量索引的脚本跑一遍别只看新增笔记效果正常就以为没事。我上线第二天搜旧笔记有一半搜不出来就是因为旧索引里还是老式的按字符切分。第三是SQLite FTS5 的match语法对引号、括号很敏感。用户如果搜的内容里带了或者(直接拼进MATCH语句会导致语法错误。最简单的做法是过滤掉这些特殊字符更稳妥的办法是用参数化查询并结合 FTS5 的quote函数但那样代码会稍微绕一点。我在生产中的做法是对查询词做一轮白名单清洗只保留中英文、数字和常用符号这样又安全又简单。5.3 误删笔记怎么从 Git 里捞回来最后说一个让人心跳骤停的场景手滑rm了一篇写了很久的笔记。如果你用了 Git恢复其实非常轻松。先看提交历史git log --oneline -- notes/tech/backend/FastAPI-中间件.md找到被删除前的那条 commit然后恢复git checkout commit-id -- notes/tech/backend/FastAPI-中间件.md这条命令会从指定提交里把文件恢复到当前工作区但不会影响其他文件。比这更极端的情况是你发现不仅文件删了而且 Git 历史的某个分支上也找不到。别慌先查 refloggit reflogGit 的 reflog 会保留最近一段时间所有 HEAD 移动的记录哪怕某个 commit 已经不在任何分支树上只要 reflog 里还有它的哈希就能恢复到那个点。我经历过一次最复杂的情况是误删后马上又做了几次无关 commit原本想用git reset --hard回到删除前的状态结果把自己搞得更乱了。最后的解法是用 reflog 找到删除前那个 commit 的哈希然后单独把需要的文件 checkout 出来干净利落其他 commit 完全不动。所以这里也总结出一条经验遇到误删能精确恢复文件就别用整体回滚。整体回滚容易把后面新加的笔记、新做的修改一起丢掉损失更大。5.4 搜索慢不是磁盘的错如果你照着搭笔记量几千篇发现搜索还是要一秒钟以上不要第一反应怪磁盘。先检查索引覆盖情况。很多人重建索引的时候用了增量模式但增量逻辑里只监听文件mtime变化。如果某个文件是被批量脚本改的但mtime没动索引就不会更新时间长了你会发现索引和文件系统越来越不一致一些新的词搜不到系统表现就是“慢”或者“结果不对”。另外还有一个很隐蔽的问题SQLite 事务没批量提交。逐条插入索引记录的时候如果每条都自动开一个事务几千条插入可能会占用大量时间。正确的做法是把所有需要插入的文档放进一个事务里最后一次性 commit。这个优化做完之后全量重建索引从几十秒降到了两三秒。写在最后的个人体会整套 NOTE01 写下来最深的感受是一个工具能长期用下去靠的不是功能多而是和自己的工作流足够贴合。我现在写笔记已经不用任何编辑器了终端敲一行命令就是一篇新笔记保存后索引自动更新十五分钟之后 Git 自动提交远程仓库接收完毕。这套链路已经稳定跑了好几个月中间还给一个朋友搭了一版复制款他的反馈和我一样一旦开始用文件管理笔记很难再回去了。最后再分享一个小技巧。最初我把 Git 自动提交的定时任务设成每分钟一次结果日志刷得飞快看起来很忙但没啥信息量。后来改成十五分钟一次并且给 commit message 带上最近修改的文件名整个历史变得非常清爽没用的提交次数也少了一半。工具这种东西多一格性能就多一分维护成本适合自己是最重要的标准。
返回列表