changbozhao2 这个名字听起来就像某个随手敲出来的账号 ID但它其实是我在本地跑了大半年的一个个人知识库自动化项目也是之前那个半成品脚本的第二代版本。干这行的都知道笔记管理是个超级大坑尤其是当你有几百个 Markdown 文件、网页剪藏和随手记的代码片段时检索和归类就成了噩梦。changbozhao2 要解决的就是把这些散落内容自动抓进来、清洗干净、打好标签再提供一个本地全文搜索接口让一切从“有时间再整理”变成“随手丢进去就行”。如果你也经常囤积碎片信息或者单纯想给本地文件加一个好用的检索引擎这篇文章里的思路和代码可以直接拿去改。1. 项目背景与设计思路1.1 第一版的烂摊子第一版其实只花了一个下午写出来当时图省事用一个小脚本把所有 Markdown 文件按文件名排序然后强制拼接成一个 giant.md。想着要用的时候直接 CtrlF 搜索总行了吧结果实践几天就放弃了几百个文件拼在一起开头几万行都是笔记正文搜索功能确实能用但找出来的内容完全没有上下文经常是搜到一句代码却想不起它属于哪个项目。更难受的是网页剪藏下来的 HTML 内容塞进去之后大量标签残留排版直接乱套原来文件夹分类也被彻底抹平了。后来我又试着改进给每个文件加一个标题前缀再手动维护一个 tags 区。但每天新增的笔记多了以后根本没人愿意手动打标签。最痛苦的是当我想查“上个月记录的 FastAPI 部署方案”时我既记不清关键词是什么也不知道文件放在哪个文件夹里。第一版本质上只是做了字符串拼接没有数据建模也没有任何结构化的索引所以它注定会烂在手里。1.2 第二版的设计目标做 changbozhao2 之前我列了几个必须满足的目标。第一是实时性文件新增、修改后系统要能在几秒内自动感知而不是每次手动跑一次脚本。第二是模块化采集、清洗、分词、存储、检索这五个环节必须拆开任何一个环节挂了不会影响其他部分。第三是自动分类不求做到 AI 那么智能但至少能通过标题、内容里的关键词自动生成标签比如“Python”“部署”“前端”“生活记录”这类粗粒度的分类。第四是可检索必须支持全文搜索并且要有排序让结果里最相关的内容排前面。基于这些目标我重新设计了整个流程。采集层负责盯着几个固定的目录比如~/Documents/notes/、~/Downloads/clippings/和~/Snippets/。一旦检测到新文件或修改就交给清洗层。清洗层把 HTML、PDF 文本提取、带特殊符号的文本统一转换成标准 Markdown顺便把乱码和不可见字符清掉。清洗完成后语义层用 jieba 做中文分词再用 TextRank 算法从正文里抽关键词然后生成标签和摘要。最后存储层把这些结构化信息写进 SQLite 的表里并建立全文搜索索引。这套流程看起来简单实际落地时每个环节都有很多细节后面我会逐一展开。1.3 技术选型与架构技术选型上我没有用重型框架因为这是一个本地单人使用的工具越简单越好维护。Python 是我最熟悉的环境生态里 watchdog、jieba、FastAPI 都是现成的。数据库选 SQLite 而不是 MySQL 或者 MongoDB因为我的数据量撑死也就几万篇笔记SQLite 单文件备份特别方便而且它内置的 FTS5 全文搜索扩展在性能和功能上足够支撑这种量级。前端接口层用 FastAPI主要是因为写起来快自带 OpenAPI 文档随便写个网页或者手机端调用都很方便。对比第一版和第二版核心差异可以看下这张表维度第一版demo第二版changbozhao2数据接入手动运行脚本批量拼接watchdog 实时监控目录数据清洗几乎不处理标准化为 Markdown标签分类手工维护jieba TextRank 自动生成搜索能力系统自带搜索低效SQLite FTS5 全文索引接口方式无FastAPI REST 接口存储结构一个巨型文本文件结构化的 SQLite 数据表架构上其实就是一个单向管道文件系统事件触发采集采集结果顺序经过清洗、语义分析、入库最后由查询接口对外服务。每个环节之间通过队列解耦但实际实现时我没用消息队列而是直接用 Python 的queue.Queue做内存队列再配合一个 worker 线程。这个方案对于个人使用完全够用而且大大减少了依赖复杂度。2. 核心模块拆解与实操细节2.1 采集层文件夹监控与剪藏接入采集层是整个管道的入口。我监控了三个主目录每个目录会有不同的子目录结构。~/Documents/notes/放的是我用 Obsidian 或 VS Code 手写的 Markdown 笔记~/Downloads/clippings/放的是浏览器插件保存的网页正文默认是 HTML 格式~/Snippets/放的是从代码仓库和论坛里摘录的代码片段可能是.py、.js、.txt等。监控目录的选择上我最开始只监控了一个目录结果发现很多网页剪藏和截图笔记都放在别的位置导致经常漏文件。后来干脆把监控粒度拆成几组并为每个来源配置不同的处理策略。比如 HTML 剪藏需要先做正文提取而 Markdown 笔记只需要清理无关字符这样能省掉不少计算时间。watchdog 是 Python 生态里很成熟的文件系统监控库底层在 Linux 上使用 inotify在 macOS 上使用 FSEvents在 Windows 上使用 ReadDirectoryChangesW所以跨平台表现都很稳定。我这里只需要监听文件的新增和修改事件所以继承FileSystemEventHandler重写on_created和on_modified就够了。不过文件监控有一个经典问题当你在 macOS 上用 Dropbox 或者 iCloud 同步文件时文件属性变更事件也会触发修改回调导致重复入库。我的做法是加一个“冷却期”过滤器同一路径在 5 秒内触发过的话直接忽略同时在 worker 里对文件内容做哈希去重。这个处理看起来简单但实际特别招人烦后面第 4 节我会专门讲。2.2 清洗层格式标准化与乱码处理文件进入管道之后第一件事就是清洗。拿网页剪藏来说浏览器存下来的 HTML 往往带着大量无用内容导航栏、页脚、广告位、内联样式。如果直接转成 Markdown整个页面都会被塞进来。早期我用过html2text它对简单页面效果不错但稍微复杂一点的页面就会附带一堆链接和图片标记反而把检索结果污染了。后来我换了一个思路先用readability-lxml提取正文内容得到一个干净的 HTML 片段再交给html2text转成 Markdown。这一步可以过滤掉大概 80% 的噪音。对于纯文本或者代码片段转换逻辑就更简单了只需要统一换行符、去掉 BOM、清理零宽空格和其他不可见字符。乱码问题也是高频踩坑点。很多从网页复制下来的文本实际上是 UTF-8 的但文件本身可能是 GBK 编码。单独依赖 Python 的默认 UTF-8 去读取一不小心就抛 UnicodeDecodeError。我写了一个带有编码嗅探的读取函数优先尝试 UTF-8失败后再用chardet检测编码。这个函数是整个清洗层的基础后面所有流程都需要依赖它输出的标准化 Markdown 文本。清洗层的另一个作用是规范化路径信息。比如 Windows 和 macOS 的路径分隔符不一样文件名里的特殊字符#、?、在后续处理时容易出问题。我会把来源文件路径统一转成 URL 友好的相对路径并保留一个绝对路径字段方便后续打开原文件。2.3 语义层分词、关键词抽取与自动标签语义层是 changbozhao2 最核心的改动它把“秀操作”的部分从人工转移到了算法上。对于中文内容最基础的分词工作我交给了 jieba。这是因为 SQLite 的 FTS5 内置的中文 tokenizer 效果很一般必须先分词再用空格拼接才能让全文索引真正理解中文。关键词抽取我用的是 TextRank 思路。它的原理不复杂把文本里的候选词看成一个个节点如果两个词在同一窗口内共现就建立一条边然后像 PageRank 一样迭代计算每个词的权重最后取权重最高的前 N 个词作为关键词。相比 TF-IDFTextRank 不需要事先准备语料库对单篇文档也能得到一个相对合理的结果。我在 changbozhao2 里用jieba.analyse.textrank来完成这一步选词的时候还会加一个过滤规则剔除纯数字、单字和明显停用词比如“我们”“一个”“这种”。标签生成其实就是在关键词基础上再做一次归并。我维护了一个简单的规则表比如关键词里包含“FastAPI”“Flask”“Django”就自动打上“后端开发”的标签包含“Vue”“React”就归到“前端开发”。规则表的缺点是覆盖不全但优点是可控性强、不会出现离谱的分类。为了弥补规则覆盖不足的问题我还加了一个兜底方案把权重最高的前三个关键词本身做为瀑布标签这样就算规则表没命中搜索时依然可以通过关键词找到它。2.4 存储检索层SQLite FTS5 全文搜索存储层原本我考虑过直接用 JSON 文件夹后来发现每次拉全量数据做搜索的速度实在太慢所以只能上一个真正的索引数据库。SQLite FTS5 对全文搜索的支持相当成熟它支持 BM25 排序算法可以通过match语法做快速检索并且支持highlight()函数返回命中上下文。对于个人级别的数据量来说这个组合堪称完美。建表时我会建两张表一张documents表存元数据路径、标题、标签、时间一张documents_fts作为虚拟表存全文索引。为了保证索引和原数据保持一致我用了触发器在documents表插入和更新时同步更新虚拟表。不过 FTS5 默认的分词器对中文不太友好所以我干脆在写入虚拟表时先把文本用 jieba 分词再用空格连接。这样查询的时候也需要把查询词先分词才能获得不错的结果。FTS5 还有一个好处是支持前缀查询。比如我输入“FastAPI 部”系统可以索引到“FastAPI 部署”等长尾内容这对碎片化笔记相当实用。再加上tag字段也存进 FTS 索引搜索一个“后端”就顺带把相关笔记全部带出来了。3. 实现过程与关键代码解析3.1 初始化项目结构整个项目的目录结构大概是这样的changbozhao2/ ├── main.py # 启动入口 ├── config.yaml # 监控目录、数据库路径等配置 ├── collector/ │ ├── __init__.py │ ├── watcher.py # watchdog 封装 │ └── handler.py # 事件处理器 ├── cleaner/ │ ├── __init__.py │ └── pipeline.py # 清洗流水线 ├── semantic/ │ ├── __init__.py │ └── extractor.py # 分词与关键词提取 ├── storage/ │ ├── __init__.py │ ├── database.py # SQLite 连接与建表 │ └── search.py # 搜索封装 ├── api/ │ ├── __init__.py │ └── server.py # FastAPI 接口 └── tests/ └── test_pipeline.py配置文件我用 YAML而不是 JSON因为 YAML 可以写注释方便以后调整规则。config.yaml里最核心的部分就是监控目录列表和目标数据库路径。初始化时程序先读取配置然后创建数据库表最后启动监控和 worker 线程。3.2 监控文件夹的 watchdog 配置监控模块的核心代码如下。这里我刻意把事件处理逻辑丢到后面的队列里避免在事件回调里做耗时操作。watchdog 的回调频率很高如果直接在回调里做文件读取和语义分析很容易把事件线程卡住。import queue from watchdog.events import FileSystemEventHandler class NoteFileHandler(FileSystemEventHandler): def __init__(self, task_queue: queue.Queue): self.task_queue task_queue self._recent {} def _push_task(self, path): # 5秒内同一路径只处理一次缓解重复事件 now time.time() last self._recent.get(path, 0) if now - last 5: return self._recent[path] now self.task_queue.put(path) def on_created(self, event): if event.is_directory: return self._push_task(event.src_path) def on_modified(self, event): if event.is_directory: return self._push_task(event.src_path)这里有个小细节on_created和on_modified我都监听了因为很多编辑器保存文件时是先写入临时文件再重命名如果只监听创建事件可能漏掉最终写入的完整内容。监听修改事件能兜住大部分情况代价就是需要配合冷却时间去重。3.3 清洗流水线的 Python 实现清洗流水线我封装成一个类每个文件进来后按顺序执行多个方法。对于 HTML 文件先提取正文再转 Markdown对于文本文件直接做编码修正和格式归一化。这个类输出的是一个标准化的字典包含title、content、source_path、file_type等字段。# cleaner/pipeline.py import chardet from html2text import HTML2Text from readability import Document def read_with_fallback(path): raw open(path, rb).read() for enc in (utf-8, gbk, latin-1): try: return raw.decode(enc) except UnicodeDecodeError: continue detected chardet.detect(raw) return raw.decode(detected.get(encoding, utf-8), errorsreplace) def normalize_markdown(text): # 去掉 BOM、零宽空格统一换行符 text text.replace(\ufeff, ).replace(\u200b, ) text text.replace(\r\n, \n).replace(\r, \n) # 多个空行压缩为一个 lines [line.strip() for line in text.splitlines()] return \n\n.join([line for line in lines if line]) class CleanPipeline: def run(self, path): raw_text read_with_fallback(path) if path.endswith(.html) or path.endswith(.htm): doc Document(raw_text) raw_text doc.summary() h HTML2Text() h.ignore_links False raw_text h.handle(raw_text) content normalize_markdown(raw_text) return { source_path: path, title: path.rsplit(/, 1)[-1].replace(.md, ), content: content, }normalize_markdown里只做了最核心的清理。实际项目里还可以加上代码块标识的识别把以缩进开头的行转换为 Markdown 的围栏代码块但这个功能我还在继续完善中一开始不需要做太复杂否则很容易陷入边缘 case。3.4 关键词提取代码解读关键词提取直接用 jieba 的 TextRank 就好但参数调整有讲究。topK我一般设置为 8因为标签太多反而干扰检索。allowPOS我限定为名词、动词、形容词这样可以避免一堆副词和助词占据关键词位置。另外jieba 支持的span参数控制共现窗口大小窗口越大词与词之间的关系越稀疏窗口太小又会把大量相邻词绑在一起。我实测下来span5在多数笔记场景下效果比较稳。# semantic/extractor.py import jieba.analyse import jieba.posseg as pseg jieba.setLogLevel(60) RULES { 后端开发: [fastapi, flask, django, 数据库, 接口], 前端开发: [vue, react, 组件, 样式], 工具效率: [obsidian, 脚本, 自动化, 快捷键], 生活记录: [旅行, 美食, 心情, 跑步], } def extract_tags(text, top_k8): keywords jieba.analyse.textrank(text, topKtop_k, withWeightFalse, allowPOS(ns, n, vn, v, a)) tags set() lowered_text text.lower() for category, rules in RULES.items(): for rule in rules: if rule in lowered_text: tags.add(category) break for kw in keywords: tags.add(kw.strip()) return list(tags)[:10]这里有一个很容易忽略的问题TextRank 算法本身对长文本比较友好如果某篇笔记只有几十个字它往往提不出什么关键词。我遇到这种情况时的兜底策略是直接取标题里的词并打上“短笔记”标签避免全文索引里出现空标签的记录。3.5 用 FastAPI 暴露查询接口数据库和接口层是整个项目的门面。FastAPI 让查询接口变得非常简单。我在api/server.py里定义了一个/search接口接受q参数返回命中的文档列表、分数和摘要片段。查询时前端传过来的字符串先经过 jieba 分词再用空格拼接这样可以配合预分词的 FTS 索引获得较好的中文效果。# api/server.py from fastapi import FastAPI from storage.search import search_documents app FastAPI() app.get(/search) def search(q: str, limit: int 10): if not q: return {results: []} results search_documents(q, limit) return {query: q, results: results}接口返回的数据结构我会刻意加上score字段方便后续在 Web 端展示排序。还可以通过/recent接口获取最近入库的文档配合定时任务可以生成“本周新增内容”的日报。不过这些属于锦上添花了核心还是一个稳定的全文检索。4. 常见问题与排查技巧实录4.1 中文文件名和内容乱码监控目录里的文件很多是从网上下载的文件名五花八门比如“%E7%AC%94%E8%AE%B0.md”这种 URL 编码的、带 emoji 的、甚至 GBK 编码的文件名。内容乱码问题更常见尤其实测下来 Windows 下用记事本保存的中文文件经常是 GBK 编码Python 默认读不了。我最终采用的方式是阅读时先尝试 UTF-8失败后用 chardet 检测最后再 fallback 到 GBK。文件名则在数据库里单独存一个display_name字段避免在 URL 里出现 URL 编码但同时保留原路径用于打开文件。4.2 FTS5 对中文分词的支持不足这是整个项目里最明显的坑。SQLite FTS5 官方自带的unicode61tokenizer 只能按空格和标点分词对于没有空格的中文来说一句“自动化部署方案”会被当成一个 token搜索“部署”的时候完全匹配不到。解决思路是在写入索引前先用 jieba 把整段文本切分成“自动化 部署 方案”这种带空格的形式。查询时同样需要做分词再把关键词用空格连接后丢给match语法。这样做之后至少能覆盖九成以上场景。当然它也有代价每次写入时的预处理时间会多几十毫秒但对于个人项目来说完全在接受范围内。4.3 监控事件重复触发导致重复入库重复入库是我调试时最崩溃的问题。编辑器保存文件时某些软件会先删除原文件再创建新文件或者触发多次修改事件数据库里就会出现两三条一模一样的笔记。我的解决办法有两层第一层在事件处理器里加冷却期同一路径 5 秒内不重复处理第二层在入库前计算文件内容的 SHA256 哈希如果哈希已经存在于表里就直接跳过。哈希方案可以杜绝所有内容层面上的重复但要注意前提是文件内容真的没有变化。如果只是想更新标签建议改成“内容变化才更新索引”的逻辑而不是无脑跳过。4.4 性能优化与批量入库一开始我每条笔记都单独 insert数据量少的时候没感觉等笔记数量超过两千篇以后全量重建索引变得很卡。后来我引入了批量写入机制在 worker 里积累 20 条记录后统一 commit写入时间能压缩到原来的三分之一。另外FTS 的索引更新本身也不便宜如果只是更新标签字段可以手动分开更新元数据表和全文索引表不要对整篇内容重复做分词。通过EXPLAIN QUERY PLAN检查查询计划也能发现很多字段因为没有索引而产生全表扫描这类问题要及时补索引。5. 后续扩展与个人体会5.1 可扩展的方向changbozhao2 现在的核心已经足够稳定但它仍然有很明显的扩展空间。比如我可以把监控范围扩展到 WebDAV 目录这样用手机也能往里面丢笔记或者接入 OpenAI 等本地模型对每篇笔记做一个语义向量再用余弦相似度实现“语义搜索”这比 FTS 的关键词匹配更智能。另外还可以做一个简单的 Web 界面把搜索结果和标签云展示出来方便在平板和手机上访问。对很多和我一样爱囤资料的人来说这个工具最大的价值不是技术本身而是“找到一个长期愿意维护的使用习惯”。5.2 踩坑后的几点心得整个项目做下来我最深的体会是不要一上来就追求完美的 AI 分类先把手动规则和关键词分类跑通效果远比预想中要好。很多人看到“自动标签”这四个字就以为必须上一套深度学习模型其实对于个人笔记这种数据量TextRank 加规则表已经能解决 90% 的检索需求。第二个体会是文件监控看起来简单但坑最多尤其是跨平台路径编码和重复事件的问题一定要提前设计好冷却机制。第三个体会是尽量把所有中间结果都落到数据库表里不要长期依赖临时文件否则调试的时候会非常痛苦。现在我自己已经把 changbozhao2 跑成了常驻进程每天往里丢的东西越来越多但搜索依然很快。如果你也想做一个类似的本地知识库工具建议从采集和清洗开始先把数据变成干净的 Markdown再谈搜索。这一步做好后续所有功能都会顺起来。