恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于 SQLite FTS5 与向量检索的轻量级知识库笔记检索服务实战
首页
资讯中心
/
基于 SQLite FTS5 与向量检索的轻量级知识库笔记检索服务实战
基于 SQLite FTS5 与向量检索的轻量级知识库笔记检索服务实战
发布时间:2026/10/19 10:48:52
1. 项目定位与整体设计1.1 笔记管理的痛点和这个服务到底解决什么问题做笔记这件事我一直有个执念笔记不是用来收藏的而是用来二次加工和调用的。可现实往往很残酷——真正动手整理的时候就会发现笔记一旦超过某个量级头疼的不是“记不住”而是“找不到”“对不上”“用不起来”。前年我把散落在各种工具里的资料统一迁到自建知识库之后问题反而更明显了知识库里堆了几千篇文档目录看起来整整齐齐实际上每次检索都要翻半天标签体系混乱旧笔记之间的关联完全靠脑子硬记。某个周末我想查一条两年前看过的技术细节翻了二十分钟没找到最后居然是通过浏览器历史记录才定位到原始网页。那一刻我就决定必须做一个能长期陪在知识库旁边、专门负责“整理和唤醒笔记”的服务。这个项目就叫学习笔记知识库伴侣服务。它不替代任何笔记编辑器也不重新造一个知识库而是安静地待在你现有的知识库旁边扮演“加工层”的角色把零散笔记标准化入库、自动提取标题和标签、建立全文索引与语义关联、提供统一的检索接口再把结果按需同步回知识库。用大白话说知识库是仓库它负责把仓库里的货物分门别类贴好标签、编好索引让你想取货时一秒钟就能找到还能告诉你“这东西跟旁边那箱是配套的”。这个服务适合谁三类人最受益一是搭过个人Wiki、静态博客或本地文档库但检索效率低下的知识管理爱好者二是需要管理大量课程笔记、读书笔记和实验记录的学生或研究者三是像我这种习惯用Markdown写文档、又不想被某个商业笔记软件绑定的开发者。你不需要很懂算法跟着下面的设计思路一步步来就能搭起一套属于自己的笔记检索和关联系统。1.2 整体架构选型为什么是单服务、轻存储、可增量在动手之前我认真纠结过技术方案。最早想过直接上Elasticsearch做全文检索也考虑过接第三方笔记API但最终都放弃了。原因很简单个人知识库的数据量级通常在几千到几万篇笔记单篇几KB到几百KB整体也就几个GBElasticsearch在这种量级下属于典型的“杀鸡用牛刀”还要额外维护一套Java运行时和独立的索引节点长期成本太高。第三方API看起来省事但你的笔记内容要被别人过一道格式和查询能力也受制于人数据导出更是麻烦。最后我定的方案是Python FastAPI SQLite的单体服务。SQLite自带的FTS5全文搜索模块在几万篇文档的规模下表现相当稳配合自定义分词器可以搞定中文检索FastAPI提供REST接口非常顺手自带OpenAPI文档调试和对接知识库都方便整个服务只需要一个Python环境加一个数据库文件部署到一台小主机甚至树莓派上都毫无压力。消息队列、独立索引节点、容器编排这些重型组件全都不要因为对个人项目来说能跑五年不用折腾的方案才是好方案。项目内部我按职责拆成四个模块采集模块负责监听笔记目录的新增和修改解析模块把各种格式的笔记归一化成结构化文本索引模块负责建全文索引和向量索引接口模块对外提供检索、标签查询和同步导出接口。模块之间通过本地目录和SQLite解耦任何一个模块挂了都能单独重启不会像单体应用那样一崩全崩。目录结构也很简单我直接放出来供参考notes-partner/ collector/ # 采集与变更监听 parser/ # 格式解析与元数据提取 indexer/ # 索引构建与更新 server/ # FastAPI 服务端 storage/ # SQLite 数据库文件与向量存储 config.yaml # 全局配置 requirements.txt数据库表设计上我保留了最核心的三张表笔记主表存标题、路径、正文、摘要、时间戳和内容哈希标签表单独存标签名关联表存笔记间的引用关系和“相关笔记”推荐结果。之所以要把标签和关联关系拆出来是因为后面做过滤查询和知识图谱展示时单表查询一定会卡脖子的一开始就该按关系型思路设计。2. 核心模块原理与关键参数2.1 笔记标准化元数据提取与格式清洗的细节所有笔记进库之前都要先过“标准化”这一关。这一步做不好后面检索和关联全都会跟着出问题。我踩过的最大一个坑是早期直接拿原始Markdown全文建索引结果有的笔记开头是YAML front matter有的笔记开头直接就是标题加日期格式五花八门检索时经常把配置文件里的键值对也当成正文匹配进来。标准化阶段的处理逻辑分四步。第一步是格式识别我统一约定以Markdown为主兼容纯文本和HTML如果需要处理Word文档可以额外装Pandoc做一次转换但个人场景下我建议直接把Word另存为Markdown省掉一个依赖。第二步是元数据提取从文件名、首行标题、YAML头、HTML的title标签等位置抓取标题、作者、创建时间、标签、来源链接并把结果整理成规范字段。第三步是正文清洗把代码块、图片链接、HTML标签、多余空行处理干净保留有信息量的文本。第四步是内容去重我使用SHA256对清洗后的正文做哈希如果哈希值不变就跳过入库避免每次修改都生成一条重复记录。元数据模板我用的是YAML front matter方案每篇笔记开头统一加一个配置块解析起来非常规整--- title: 分布式事务笔记 author: me date: 2025-03-18 tags: [分布式, 事务, 学习笔记] source: https://example.com/notes/distributed-tx status: active ---这里有一个关键参数值得单独说明时间格式一律转成ISO 8601也就是YYYY-MM-DD或带时分秒的完整格式而不是“2025年3月18日”或“03/18/2025”这种写法。原因很简单ISO 8601字符串可以直接按字典序排序查询“某天之后的笔记”时用字符串比较就够了不需要再调用时间解析函数。标签字段也要统一处理我所有的标签在入库前都会转小写并去掉首尾空格这样“分布式”和“分布式 ”就不会被识别成两个标签。2.2 检索与关联中文分词、BM25与向量召回的混合策略检索模块是整个服务的大脑。知识库伴侣最核心的价值就在这里——你输入一句话它能把知识库里所有相关笔记拉出来还要能告诉你这些笔记之间是什么关系。先说全文检索。SQLite FTS5默认的分词器对中文很不友好它按空格和标点切词中文一句话连在一起根本切不出有效词。我第一次测试时搜“分布式事务”结果只匹配到含“分布式事务”这个完整连续字符串的文档稍微变一下说法比如“事务的分布式实现”就查不到。解决方案是在应用层先用中文分词库把文本切词再把切好的词用空格拼接后写入一个专门的关键词字段建索引时对该字段启用FTS5。分词库我选了jieba因为它在中文场景下足够成熟词典可自定义遇到专业术语可以手动加词。建表语句大致长这样CREATE VIRTUAL TABLE notes_fts USING fts5( title, body_keywords, contentnotes, content_rowidid, tokenizeunicode61 );注意这里启用了contentnotes外部内容表模式正文不重复存一份到FTS表里FTS表只存索引真正查正文时回原表取数据这样能省将近一半的磁盘空间。只有全文检索还不够。笔记里大量信息是语义层面的“如何保证数据一致性”和“分布式事务的隔离级别”字面上重合度很低但其实是强相关。我加了一层向量召回用本地运行的嵌入模型把每篇笔记的标题和摘要编码成384维向量存入一张向量表查询时把用户的问题也编码成向量用余弦相似度找出Top N篇笔记。嵌入模型我选了sentence-transformers里一个中文效果尚可的轻量模型跑在CPU上也不吃力单篇笔记编码用时大约几十毫秒全库几千篇做一次全量编码也只要几分钟。混合检索的策略很简单就是BM25分数和向量相似度分数的加权融合。两张分数先各自做归一化然后按这个公式合并final_score 0.65 * normalized_bm25 0.35 * normalized_cosine权重我试过很多组最终选了0.65和0.35。关键词精确匹配的笔记通常就是用户要找的所以BM25权重高一些但完全依赖BM25又会错过语义近似的表述所以向量得分不能低于三成。这个比例可以根据你自己的笔记风格调整——如果笔记普遍是口语化随记向量权重可以提到0.5如果是技术文档、术语密集BM25权重应该更高。知识关联这块我用了两个手段。第一个是标签共现两篇笔记共享的标签越多关联度越高第二个是引用分析如果笔记A正文里提到了笔记B的标题或链接就建立一条显式关联。综合两种信号我定期生成一张“相关笔记”推荐表前端或知识库页面可以直接读取这张表展示“你可能还想看”。2.3 知识库联动三种对接方式与增量同步机制伴侣服务的价值要通过与知识库的联动体现出来。我在项目里预留了三种对接方式适配不同的知识库形态。第一种是导出JSON文件。适合对接自建的静态博客或Wiki服务端定时生成一个notes_export.json里面包含每篇笔记的标题、标签、摘要、正文路径和关联列表知识库构建时直接读取这个文件即可。第二种是生成Markdown摘要集。服务端按标签分类把每篇笔记的前200字摘要汇总输出成一个索引文档适合喜欢用阅读器浏览的人。第三种是REST API直连。知识库通过HTTP请求调用检索接口把查询结果直接内嵌到自己的搜索框里。三种方式可以同时启用互不干扰。增量同步是这里最关键的机制。如果每次笔记目录里有任何变动就把全库重建一遍索引几千篇笔记时的耗时还能忍受上万篇之后就没法用了。我的做法是维护一张处理记录表记录每个文件路径的最后修改时间和内容哈希。采集模块启动后先扫描目录比对当前文件状态和历史记录只有新增或哈希变化的文件才进入解析和索引流程。实测下来日常增量同步处理几十个改动文件只需要几秒钟全量重建也控制在几分钟内完全够用。3. 关键配置与部署实操3.1 环境准备与依赖安装在开始之前建议先把环境理清楚。我用的是Python 3.10太老的版本对类型注解和异步支持都差一些不建议低于3.9。建议新建一个虚拟环境避免把系统Python搞乱mkdir notes-partner cd notes-partner python3 -m venv venv source venv/bin/activate pip install fastapi uvicorn jieba pyyaml sentence-transformers这些库各自的作用后面会体现出来。如果只是在Windows上跑把source venv/bin/activate换成venv\Scripts\activate如果没有独立显卡也没有关系嵌入模型跑CPU就够用。安装sentence-transformers时它会自动拉取深度学习框架包体积偏大耐心等一会儿就好。如果网络不佳导致模型下载失败可以提前把模型文件下载好放到本地目录然后在代码里指定本地路径。提示模型首次运行会自动从模型仓库下载权重文件路径建议统一设置在项目目录下的models/文件夹里方便迁移和备份。3.2 从零搭建核心服务初始化、解析入库和检索接口先初始化数据库。我写了一个init_db.py脚本建表语句包括笔记主表、标签表、FTS虚拟表和向量表import sqlite3 conn sqlite3.connect(storage/notes.db) conn.executescript( CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, file_path TEXT UNIQUE, author TEXT DEFAULT me, created_at TEXT, updated_at TEXT, summary TEXT, content TEXT, content_hash TEXT, tags TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( title, body_keywords, contentnotes, content_rowidid, tokenizeunicode61 ); CREATE TABLE IF NOT EXISTS processing_log ( file_path TEXT PRIMARY KEY, modified_time TEXT, content_hash TEXT, last_processed TEXT ); ) conn.commit()注意contentnotes这个外部内容表语法它要求FTS表的字段顺序和映射关系必须与主表对应。如果不小心写错查询时会报“database table is locked”或者“no such column”之类的问题排查起来很费劲。保险起见建表后先插入一行测试数据验证FTS能正常match再继续。解析入库脚本逻辑也不复杂核心就是把清洗好的正文切词再写入主表和FTS表import jieba import hashlib import sqlite3 from pathlib import Path def process_note(path: Path): text path.read_text(encodingutf-8) # 这里省略元数据提取细节假设已经得到 title, tags, content, summary keywords .join(jieba.cut_for_search(content)) content_hash hashlib.sha256(content.encode(utf-8)).hexdigest() conn sqlite3.connect(storage/notes.db) cur conn.cursor() cur.execute( INSERT INTO notes (title, file_path, content, content_hash, tags, summary, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(file_path) DO UPDATE SET titleexcluded.title, contentexcluded.content, content_hashexcluded.content_hash, tagsexcluded.tags, summaryexcluded.summary , (title, str(path), content, content_hash, tags, summary, created_at)) note_id cur.lastrowid cur.execute(DELETE FROM notes_fts WHERE rowid?, (note_id,)) cur.execute( INSERT INTO notes_fts (rowid, title, body_keywords) VALUES (?, ?, ?), (note_id, title, keywords) ) conn.commit()jieba.cut_for_search和普通的jieba.cut不一样前者在切分时还会额外生成一些细粒度的组合词对搜索引擎风格的匹配更友好。比如“分布式事务”会被切出“分布式”“事务”“分布”“事务”等词这样用户搜“分布”或者“事务”都能命中召回率明显更高。检索接口用FastAPI写起来非常快。我暴露了三个核心端点全文检索、标签筛选、关联推荐。全文检索的代码很短from fastapi import FastAPI, Query import sqlite3 app FastAPI() app.get(/search) def search(q: str Query(..., min_length1), limit: int 20): keywords .join(jieba.cut_for_search(q)) conn sqlite3.connect(storage/notes.db) cur conn.cursor() cur.execute( SELECT n.id, n.title, n.summary, n.tags, bm25(notes_fts) AS score FROM notes_fts JOIN notes n ON n.id notes_fts.rowid WHERE notes_fts MATCH ? ORDER BY score LIMIT ? , (keywords, limit)) rows cur.fetchall() return {query: q, results: rows}启动服务一行命令就行开发时用uvicorn server:app --reload --port 8000生产环境可以去掉--reload加--workers 2。3.3 部署与日常运维定时任务、备份和日志服务写好后部署才是真正过日子的事。我自己的部署方式是在一台常开的Linux小主机上用systemd托管服务配置一个简单的服务单元文件[Unit] DescriptionNotes Partner Service Afternetwork.target [Service] WorkingDirectory/home/me/notes-partner ExecStart/home/me/notes-partner/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8000 Restartalways RestartSec5 Userme [Install] WantedBymulti-user.target然后用systemctl enable --now notes-partner开机会自启。采集和索引脚本不需要常驻我放在任务计划里每小时跑一次只处理有变动的笔记0 * * * * cd /home/me/notes-partner ./venv/bin/python indexer.py logs/indexer.log 21备份是我每次想偷懒时都会再次提醒自己的事。整个服务的核心数据就两个地方笔记源目录和storage/notes.db数据库文件。我写了一条简单的备份命令每天凌晨打包一次并保留最近七天15 3 * * * tar czf /backup/notes_partner_$(date \%Y\%m\%d).tar.gz -C /home/me/notes-partner notes/ storage/ find /backup -name notes_partner_*.tar.gz -mtime 7 -delete日志方面我在脚本里配置了标准库的logging按天轮转最多保留30天。别小看日志索引脚本出问题时日志里的异常堆栈能帮你少走很多弯路。尤其是刚部署完的头一个月我几乎每天都翻日志确认有没有解析失败的笔记。4. 常见问题与排障速查4.1 中文检索结果不准关键词匹配不到这是最常见的坑。如果你发现搜“分布式的实现细节”查不到“关于分布式事务的笔记”先排查分词是否正常。在命令行里手动跑一下jieba.cut_for_search(分布式的实现细节)看看切出来的词里有没有“分布式”。如果分词结果没问题再检查FTS表的body_keywords字段是否真的存入了切词结果直接查表确认SELECT body_keywords FROM notes_fts WHERE rowid 123;按我经验八成是建索引时忘了做分词直接把原始正文塞进了FTS表。另一个隐蔽问题是FTS5的unicode61分词器会把标点符号当作token分隔全角逗号、中文引号都可能导致匹配中断解决办法是入库前统一把标点替换成空格。4.2 日期显示乱套按时间排序不对如果你的笔记里日期格式不统一有的写“2025-03-18”有的写“2025年3月”有的写“03/18/2025”排序结果就会混乱。解决办法只有一个字统一。在任何处理脚本里不管从哪个字段拿到日期都调用同一个时间解析函数转成ISO 8601标准格式。我专门写了一个兼容函数处理各种常见格式解析失败的一律记为1900-01-01并把原文路径打印到日志随后人工抽查。日期这块不要心存侥幸靠人工记忆哪些笔记格式特殊是不可持续的。4.3 服务启动失败端口被占用或模块导入报错FastAPI服务启动失败的原因通常很集中。端口被占用是最常见的用lsof -i :8000查看是谁占用了端口或者直接换一个端口启动。模块导入报错一般出现在sentence-transformers或jieba装到系统Python而非虚拟环境里检查一下激活的Python路径到底指向哪里which python。还有一个容易忽略的是数据库文件路径写成了相对路径而systemd的工作目录和手动启动时不同导致找不到storage/notes.db。建议在代码里用绝对路径或者通过环境变量注入数据库位置。4.4 知识库联动失败导出的数据不同步如果导出JSON时知识库读不到最新数据先确认导出脚本有没有真的执行成功。很多人会忽略计划任务里的环境变量问题——任务计划执行时的PATH不包含虚拟环境的bin目录导致脚本找不到Python解释器。解决方法是任务命令里写全路径就像上文的./venv/bin/python indexer.py这能避免一大半定时任务失效问题。另一个情况是导出时数据库被索引脚本锁住SQLite在并发写入时会出现“database is locked”报错解决办法是给所有脚本设置一个较长的timeout参数或者安排导出任务和索引任务错开执行时间。4.5 检索速度变慢索引膨胀运行半年后可能发现检索越来越慢那是因为FTS外部内容表里的旧版本数据没有自动清理。虽然主表内容更新时会删除并重建对应FTS行但词条对应的旧文档位置仍可能残留在索引里时间长了会拖慢查询。定期执行一次INSERT INTO notes_fts(notes_fts) VALUES(rebuild)可以彻底重建FTS索引我安排在每月任务里跑一次。向量表如果也在膨胀多半是旧笔记的向量没有随主表更新我实现时会根据笔记ID先删后插保证向量表始终是干净状态。从我实际使用这半年多的体验来看真正改变我笔记习惯的不是检索速度提升了多少而是“每篇笔记的可发现性”变得完全不同了。以前写笔记是写完就沉底现在每写一篇新笔记检索接口都会把相关的旧笔记带出来我顺藤摸瓜整理了很多之前完全没想到有联系的资料知识库慢慢从一个存放文件的仓库变成了一个能主动提示“这些东西之间有联系”的活的系统。如果让我重做一次这个项目我会先把元数据规范定死再开始写任何采集脚本因为所有解析、检索、关联都建立在这个地基上。最后再分享一个小技巧我会把还没想清楚怎么归类的笔记统一打上inbox标签每天定时调一次接口把一周前的inbox笔记拉出来重读能消化的就补标签归档不能消化的继续留着。这个习惯让我的知识库始终处于流动状态而不是被遗忘的二手资料堆。工具本身不复杂真正值钱的是你愿意花时间跟自己的笔记重新认识一遍。