☰
基于 SQLite FTS5 与向量检索的轻量级知识库笔记检索服务实战
2026/10/19 10:48:44 网站建设 项目流程

1. 项目定位与整体设计

1.1 笔记管理的痛点,和这个服务到底解决什么问题

做笔记这件事,我一直有个执念:笔记不是用来收藏的,而是用来二次加工和调用的。可现实往往很残酷——真正动手整理的时候就会发现,笔记一旦超过某个量级,头疼的不是“记不住”,而是“找不到”“对不上”“用不起来”。前年我把散落在各种工具里的资料统一迁到自建知识库之后,问题反而更明显了:知识库里堆了几千篇文档,目录看起来整整齐齐,实际上每次检索都要翻半天,标签体系混乱,旧笔记之间的关联完全靠脑子硬记。某个周末我想查一条两年前看过的技术细节,翻了二十分钟没找到,最后居然是通过浏览器历史记录才定位到原始网页。那一刻我就决定,必须做一个能长期陪在知识库旁边、专门负责“整理和唤醒笔记”的服务。

这个项目就叫学习笔记(知识库伴侣服务)。它不替代任何笔记编辑器,也不重新造一个知识库,而是安静地待在你现有的知识库旁边,扮演“加工层”的角色:把零散笔记标准化入库、自动提取标题和标签、建立全文索引与语义关联、提供统一的检索接口,再把结果按需同步回知识库。用大白话说,知识库是仓库,它负责把仓库里的货物分门别类贴好标签、编好索引,让你想取货时一秒钟就能找到,还能告诉你“这东西跟旁边那箱是配套的”。

这个服务适合谁?三类人最受益:一是搭过个人Wiki、静态博客或本地文档库但检索效率低下的知识管理爱好者;二是需要管理大量课程笔记、读书笔记和实验记录的学生或研究者;三是像我这种习惯用Markdown写文档、又不想被某个商业笔记软件绑定的开发者。你不需要很懂算法,跟着下面的设计思路一步步来,就能搭起一套属于自己的笔记检索和关联系统。

1.2 整体架构选型:为什么是单服务、轻存储、可增量

在动手之前,我认真纠结过技术方案。最早想过直接上Elasticsearch做全文检索,也考虑过接第三方笔记API,但最终都放弃了。原因很简单:个人知识库的数据量级通常在几千到几万篇笔记,单篇几KB到几百KB,整体也就几个GB,Elasticsearch在这种量级下属于典型的“杀鸡用牛刀”,还要额外维护一套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, content='notes', content_rowid='id', tokenize='unicode61' );

注意这里启用了content='notes'外部内容表模式,正文不重复存一份到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, content='notes', content_rowid='id', tokenize='unicode61' ); CREATE TABLE IF NOT EXISTS processing_log ( file_path TEXT PRIMARY KEY, modified_time TEXT, content_hash TEXT, last_processed TEXT ); """) conn.commit()

注意content='notes'这个外部内容表语法,它要求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(encoding="utf-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 title=excluded.title, content=excluded.content, content_hash=excluded.content_hash, tags=excluded.tags, summary=excluded.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_length=1), 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] Description=Notes Partner Service After=network.target [Service] WorkingDirectory=/home/me/notes-partner ExecStart=/home/me/notes-partner/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8000 Restart=always RestartSec=5 User=me [Install] WantedBy=multi-user.target

然后用systemctl enable --now notes-partner开机会自启。采集和索引脚本不需要常驻,我放在任务计划里每小时跑一次,只处理有变动的笔记:

0 * * * * cd /home/me/notes-partner && ./venv/bin/python indexer.py >> logs/indexer.log 2>&1

备份是我每次想偷懒时都会再次提醒自己的事。整个服务的核心数据就两个地方:笔记源目录和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笔记拉出来重读,能消化的就补标签归档,不能消化的继续留着。这个习惯让我的知识库始终处于流动状态,而不是被遗忘的二手资料堆。工具本身不复杂,真正值钱的是你愿意花时间,跟自己的笔记重新认识一遍。

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

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

立即咨询