news 2026/10/6 13:23:25

基于SQLite FTS5的中文全文搜索实现及踩坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SQLite FTS5的中文全文搜索实现及踩坑指南

今天是“30天挑战”的第11天,整个项目刚好走完三分之一。先交代一下背景:我在做的是一个本地优先的 Markdown 知识管理工具 DayNotes,要求数据完全离线、启动速度快、折腾成本低。前 10 天已经完成了文档解析、编辑器、标签体系和列表页,今天集中攻一个绕不过去的功能——全文搜索。如果你也在做类似的知识库、笔记工具或者本地文档管理应用,这一篇应该能帮你少走不少弯路,尤其是涉及到中文分词和桌面端性能的部分,我会把踩过的坑和排查过程原原本本写出来。

1. 第11天的进度线:从“正则匹配”到“全文索引”

每天开工前,我都会花 10 分钟把当天要做的功能拆成小块,写在项目的 TODO 里。今天的标签是“搜索模块”,乍一听范围很大,真正拆开其实就三块:索引怎么建、查询怎么执行、结果怎么展示。想清楚再动手,写代码的速度会快很多。

1.1 前10天做了什么,为什么今天才开始做搜索

DayNotes 前 10 天的功能比较基础:本地目录扫描、Markdown 文件解析、编辑器支持即时预览、标签体系、文档列表的排序和筛选。在最初的设计里,我其实没有考虑索引,搜索直接用最简单粗暴的办法——递归遍历目录,对每个文件的文本内容做正则匹配。

刚开始内容少的时候,这个方法一点问题没有。几十篇文档,遍历一遍也就几十毫秒,用户根本感觉不出来。直到第 10 天我把手上攒了两年的一千多篇 Markdown 笔记全部导进去之后,情况急转直下:每次搜索要遍历一千多个文件,全部读一遍再匹配,慢的时候要两三秒,而且界面直接卡住,因为读取和正则匹配都是在主线程做的。正是在这个节点,我才意识到:不引入真正的全文索引,后面没法用了。

这算是一个挺典型的教训:前期做原型可以偷懒,但当你明显感觉到“内容量上来之后体验崩坏”的瞬间,就是该上正经方案的时候了。DayNotes 的整体存储层早就分开了,元数据在 SQLite,正文按文件路径读取,所以今天加索引不需要动底层结构,这让工作量小了不少。

1.2 技术选型:为什么最终选了SQLite FTS5

桌面端做全文搜索,可选方案其实不少。我把当时认真考虑过的几个方案列了个表,方便对比:

方案优点缺点结论
Elasticsearch功能强、生态成熟要装 Java 环境、起服务、占内存,对单机离线工具太重放弃
Meilisearch / Typesense开箱即用、搜索体验好需要额外进程,部署和升级成本高放弃
SQLite LIKE 通配查询实现简单没有分词、不能排序、一千篇就卡放弃
SQLite FTS5 虚拟表内嵌在库里、零额外服务、支持 BM25 排序中文分词要自己处理采用

最终选 SQLite FTS5 是综合考虑了离线、单机、轻量这三点。DayNotes 的元数据本来就在 SQLite 里,FTS5 虚拟表可以直接建在同一份数据库文件中,不需要额外维护一个索引服务,也不引入新的运行时依赖。对于“本地优先”的工具来说,这个方案是复杂度最低、可控性最高的。

还有一个容易被忽略的好处:FTS5 索引跟随数据库文件走,备份、迁移、同步都统一了。如果将来要支持多设备同步,索引也可以跟着数据库一起处理,不需要担心本地文件和服务状态不一致。这一点在我这种跨平台小工具里非常重要。

2. SQLite FTS5 做中文全文搜索:三个必须绕开的坑

FTS5 本身很成熟,但它是为英文环境设计的,默认行为对中文特别不友好。这几个坑我基本是逐个踩过来的,每一个都会导致“搜索结果完全不可用”。

2.1 unicode61分词器对中文等于没有分词

FTS5 默认的分词器是 unicode61,它按照 Unicode 字符类型做切分。英文、数字这类能很好处理,空格和标点作为分隔符,每个单词建立索引。但中文没有空格,整段文字在 unicode61 眼里就是一个连续的“词语”,所以它只能把整句当成一个 token。

这会导致什么后果?如果你的笔记里有“知识笔记软件”这个词,你搜索“笔记”,FTS5 是匹配不到的,因为它建立索引的最小单位是整句话,而不是“知识”“笔记”“软件”这些词。我当时第一次跑通搜索,输入“笔记”,结果返回空,一度以为自己建表语句写错了。

更麻烦的是,FTS5 还默认把超长 token 给截断,默认情况下每个分词单元的索引上限是 10 个字符。中文一句话远超过这个长度,后面的内容根本不会进索引,也就是说搜索“一篇长文中后半段的某个词”,永远搜不到。

要解决这个问题,就得绕开默认分词器,在写入索引之前自己做分词,把分词结果按约定格式填进去。这也是我换到 jieba 的根本原因。

2.2 用 jieba 预分词:索引侧和查询侧要配合

确定用 jieba 之后,一个比较自然的思路是:写入时先把标题和正文分词,用空格把词拼起来,存到 FTS5 表里,查询时也对用户输入分词,再拼成查询语句。

建表语句我改成了这样:

CREATE VIRTUAL TABLE IF NOT EXISTS doc_search USING fts5( doc_id UNINDEXED, title, content_seg, content_raw, tokenize = 'unicode61' );

这里content_seg存的是分词后的文本,content_raw存原始正文,用来在结果列表里做上下文摘要。查询时配合 jieba 做同样的分词处理:

import jieba def build_query(text): words = [w.strip() for w in jieba.cut(text) if w.strip()] return " OR ".join(f'"{w}"' for w in words)

这里有一个很关键也很容易写错的细节:查询时拼出来的每个词都要加双引号,否则 FTS5 会把用户输入当成一个完整的短语去匹配,分好词也没用。我当时在这个地方吃了亏——索引侧分词做好了,查询侧忘了给词加引号,结果搜“笔记软件”时命中的是完整的“笔记软件”短语,而不是“笔记”和“软件”两个词的任意匹配。

另外,jieba 的默认词库对通用中文处理得不错,但对专业领域术语识别很差。我的笔记里有大量技术名词,比如“Rust”“Tauri”“unmount”这类中英混合词,默认词典经常切得稀碎。解决办法是维护一个自定义词典文件,把高频术语加进去。

2.3 索引同步机制:增删改怎么保持一致性

建立一个索引只是第一步,真正让人头疼的是后续的持续同步。文档会新增、修改、删除,索引如果不跟着变,搜索就变成垃圾数据展示。

我在设计上采用了一个非常朴素的方案:在应用层做同步,不搞数据库触发器。文档保存的时候,顺带调用一个sync_doc_to_index函数,把该文档从索引里删掉再重新插入。

流程拆开看大概是这么几步:

  1. 文档保存时读取最新的标题和正文;
  2. 用 jieba 对标题和正文做分词;
  3. 先从doc_search里删除该doc_id的所有旧记录;
  4. 再插入一条新记录。

这里要注意,FTS5 虚拟表没有主键约束,如果用INSERT OR REPLACE去按doc_id覆盖,会因为doc_id不是真正的主键而插入重复行。所以逻辑上必须是“先删后插”,不能偷懒。

刚一开始我图省事,只在文档保存时同步,没有处理批量导入的场景。结果第 10 天我导入一千多篇文档,导入完成后索引是空的,因为批量写入路径压根没有调用同步函数。后来我在导入流程的末尾统一执行了一次全量重建索引:

# 先删除整个虚拟表 DROP TABLE IF EXISTS doc_search; # 再建表 # 然后从 docs 表里重新读取全部分词写入

全量重建索引其实没有想象中那么慢,一千多篇 Markdown 文档全部重新分词再写入,在我的笔记本上大概也就是两秒多。所以日常增量靠保存时同步,批量操作后做一次重建,索引一致性的问题就基本解决了。

3. 本轮踩坑实录:从“搜不出”到“排序不对”的完整排查链路

这一节我要完整记录今天遇到的三个问题的排查过程。之所以写这么细,是因为这些问题的表象和根因离得很远,光看报错信息完全无从下手,必须自己一步步推。

3.1 搜索“笔记”搜不出“知识笔记”:分词器的锅

问题出现得非常突然。第一轮功能做完之后,我输入“笔记”测试,结果返回零条。数据库里明明有十几篇标题带“笔记”的文档,为什么搜不到?

排查第一步是验证原始数据有没有进索引。我直接打开 SQLite,查doc_search表里有多少条记录,确认数据确实写入了。第二步是看匹配行为,单独执行:

SELECT doc_id, title FROM doc_search WHERE doc_search MATCH '"笔记"';

返回空。换一个查法:

SELECT doc_id, title FROM doc_search WHERE doc_search MATCH '"知识笔记"';

居然能查到。这一步基本确认了问题出在分词:索引里根本没有“笔记”这个 token,只有“知识笔记”这种整句 token。原因就是前面说的 unicode61 分词器不切分中文。

排查到这里,方向已经很清楚了:不是数据问题,不是查询语法问题,是分词策略问题。解决方式不做展开——换成 jieba 预分词之后,重建索引,再搜“笔记”,能正常命中了。一个容易忽略的点是,重建索引之后旧 token 还残留在虚拟表里,所以排查时一定记住先 DROP 再重建。

3.2 输入一个关键字CPU就飙升:IPC通信和全表扫描

分词问题解决后,搜索的核心功能能用了,但随之而来的是性能问题。我在输入框里打了三个字,应用窗口就出现明显的卡顿,系统监视器一看,CPU 占用直接顶满。

一开始我以为是 FTS5 索引查询本身慢,后来仔细一想,FTS5 对一千多篇文档的索引查询应该是毫秒级,不可能是瓶颈。于是我把排查重点放在调用链路上。

DayNotes 用的框架里,渲染进程和主进程之间通过 IPC 通信。我的搜索逻辑在主进程里执行,每次输入框有内容变化,渲染进程就发一次 IPC 请求。关键在于,我监听的是input事件,每敲一个字符都会触发一次请求。如果一句搜索词有五个字,输入过程中就发了五次请求,而且主进程每次都要连接数据库、执行查询、把结果序列化回传。

还有一个隐藏的性能杀手:我在主进程的搜索函数里,拿到搜索结果后,会读取命中文档的完整内容来做上下文摘要。一千多篇文档匹配到几十篇,每篇都要读文件、截取摘要,这个操作比索引查询本身慢得多。

解决分两层:

  • 渲染进程侧加防抖,用户停止输入 300ms 后才发请求;
  • 主进程侧只查结果的前 50 条,并且摘要直接从 FTS5 表里存好的content_raw字段截取,不额外读磁盘文件。

防抖代码很简单,大概是这样的:

let timer; inputElement.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(() => { search(inputElement.value); }, 300); });

加完之后,即使连续输入整句话,实际查询也只触发一次,CPU 占用基本可以忽略。

3.3 标题命中的结果排到了正文后面:rank排序修正

功能能跑、性能也上去了,第三个问题浮出水面:搜索结果排序不对。按常识,标题里包含关键词的文章,优先级应该高于正文里碰巧出现一次关键词的文章。但实际结果恰恰相反,正文提到的排在前面,标题命中的却排到了后面。

FTS5 默认的排序依据是 BM25 算法,它会综合考虑词频、文档长度等因素打分。这个打分本身没问题,但它完全不理解“标题命中”这件事在业务上的重要性。对于知识管理工具来说,标题命中往往意味着这篇文章就是讲这个主题的,正文命中可能只是顺带提到。

修正方式是给排序加权重。FTS5 对每一行会算出一个rank值,rank越小越靠前。我在ORDER BY里人为加上一个判断,如果标题里包含搜索词,就给这行减一个固定值,让它排上去:

SELECT doc_id, title, rank FROM doc_search WHERE doc_search MATCH ? ORDER BY rank + CASE WHEN title LIKE '%' || ? || '%' THEN -20 ELSE 0 END LIMIT 50;

这种加权方式虽然粗暴,但对于个人工具完全够用。再进一步,还可以给标签命中更高的权重,这个今天没做,列进了后面的计划里。

排查过程中有一个值得记录的细节:很多人会直接把搜索词拼进 SQL 里,这在本地单机工具里问题不大,但一旦数据源来自第三方,就有 SQL 注入风险。FTS5 的正规写法是用MATCH ?传参,我全程都用占位符,这个习惯值得长期保持。

4. 搜索框背后容易被忽略的交互与性能细节

搜索模块的核心打通之后,剩下的工作主要围绕“好用”展开。功能能跑只是起点,真正决定用户感受的往往是那些技术栈之外的小细节。

4.1 300ms防抖加过期请求丢弃

防抖解决了“打字过程中反复请求”的问题,但还有一个并发场景没处理:如果用户在防抖生效之前快速按了回车,上一个请求还没返回,新的请求就发出去了。这种情况下,两个请求的返回顺序是不确定的,先发出的请求后返回,就会把较新的结果覆盖掉,造成搜索结果落后于输入框内容。

解决办法是在渲染进程维护一个自增的请求编号:

let requestId = 0; async function search(keyword) { const currentId = ++requestId; const results = await window.api.search(keyword); if (currentId !== requestId) return; // 过期结果直接丢弃 renderResults(results); }

这个模式在很多场景下都通用,尤其是桌面端和前端交互。思路很简单:每次都把请求编号递增,哪个结果回来时发现自己已经不是最新编号了,就放弃渲染。

4.2 搜索高亮的正确姿势:先转义再渲染

搜索结果列表里,匹配的关键词需要高亮,否则用户看不出为什么这篇被搜出来了。我一开始直接用正则替换原始正文,把命中词替换成<mark>命中词</mark>,然后塞进渲染层。写完一测,发现一个严重安全漏洞——如果正文本身包含 HTML 标签,比如一篇讲前端开发的笔记里写了<div>,这个标签会被渲染层当成真正的 DOM 执行。

正确顺序必须是:先把原始文本做 HTML 转义,再做高亮替换。比如:

function escapeHtml(text) { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } function highlight(text, terms) { const safe = escapeHtml(text); const escapedTerms = terms.map(escapeHtml); let result = safe; for (const term of escapedTerms) { result = result.replaceAll(term, (match) => `<mark>${match}</mark>`); } return result; }

先转义再替换,既能保证高亮生效,又不会让原始 HTML 破坏页面结构。顺便一提,replaceAll里面用函数作为参数是为了避免$&这种特殊替换变量的坑,写的时候容易被忽略。

4.3 空态、快捷键和索引状态感知

细节上我还做了几个不起眼但价值很大的功能。

搜索无结果时的空态,我一开始只显示了“没有找到匹配内容”一行字,后来发现这样很容易让用户陷入死胡同。现在空态里会提示:尝试缩短关键词、检查是否有错别字、或者去设置里重建索引。实际使用中,很多“搜不到”的问题根源是索引没有跟上,给出重建索引的引导能省掉很多用户困惑。

全局快捷键Ctrl+K聚焦搜索框,这已经是这类工具的标配了,我之前的编辑器里其实已经有了,今天只是把触发逻辑统一到搜索组件上。还有一个小细节:搜索框里输入全角空格或者只有空格的字符串时,不会发起搜索请求,避免又一次无意义的 IPC。

索引状态感知也是一个容易漏掉的功能。我在设置页里增加了一个“索引信息”面板,显示当前索引了多少文档、最近一次重建时间、自建词典的词条数。这看起来像是开发者接口,但对个人工具来说,它是排查“为什么搜不到”的第一入口。

5. 30天挑战过半,我重新思考“搜索”这件事

今天是第 11 天,项目已过三分之一,正好借这个机会做一次阶段性复盘。我发现做知识管理工具,搜索不仅仅是一个功能模块,它在很大程度上决定了用户对这个工具的信任感。

5.1 这11天最大的教训:接口预留与过早优化

回头看我前 10 天的代码,最庆幸的是,当初做存储层的时候把“元数据”和“正文内容”明确分开了。文档表只存标题、路径、标签、创建时间这些结构化数据,正文通过文件路径按需读取。这个设计当时只是出于“Markdown 文件本来就应该直接存在磁盘上”的直觉,没想到今天加搜索引擎时,几乎不用改动原来的数据层,直接在旁边多建一张 FTS5 虚拟表就接上了。

这一点其实比“一开始就设计好搜索功能”更重要——前期搜索需求不明确,如果强行一开始就设计索引结构,大概率会根据错误的假设做出过度设计。更合理的做法是保证层与层之间的边界清晰,给未来的功能留出插入位置,而不是提前把所有扩展点都实现。

与之相对的另一个极端是过早优化。我最初没加搜索,原因就是觉得“内容少,用正则也行”。事实证明这个决定是对的:正是因为内容量到了临界点、体验真实恶化,我才理解了为什么需要索引,而不是凭空想象出一个性能问题。过早引入 ES 或者重型的搜索服务,只会让项目陷入维护泥潭。

5.2 明天的计划:可配置的词库和重建索引入口

虽然今天的搜索功能已经能正常使用了,但距离“顺手”还有一段距离。我整理了几个必须要做的东西:

  • 设置页增加“重建索引”按钮,配合进度提示,解决用户遇到搜索异常时的自救途径;
  • 自建词典的可视化管理,方便把常用术语直接加进词库,不用改配置文件重启;
  • 标签权重加分,让标签命中排在标题命中前面,增强检索业务语义;
  • 搜索历史记录,把最近的搜索词存在本地,方便重复查找。

这些功能都不复杂,难点在于接口怎么设计得顺滑。比如重建索引进度提示,如果索引量少根本不需要进度条,但如果文档量上千,就必须给用户一个明确的“在做什么”的状态反馈,避免误以为卡死。

写到这里,我想多说一句个人体会。做本地优先的工具,最大的幸福感其实来自“它能自己持续变得好用”这件事。前 10 天写编辑器、写标签系统,是给自己造器皿;这一天的搜索功能做出来之后,我每天记录笔记时终于敢往里面堆量了,因为我知道「找得到」这个底线已经被守住了。30 天的项目还在继续,明天继续解决新问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 13:22:51

C#开发U盘禁用工具:守护进程+白名单+审计日志完整方案

最近在公司做终端安全加固时接到一个需求&#xff1a;禁止员工随意插入U盘拷贝资料。需求听起来简单&#xff0c;但真正落地才发现坑不少——普通策略禁用U盘后&#xff0c;换台电脑改个注册表就能绕过&#xff1b;光监听插入事件&#xff0c;又不处理开机前已插好的U盘&#x…

作者头像 李华
网站建设 2026/10/6 13:22:30

MySQL慢SQL优化:Explain执行计划关键字段与实战调优

很多搞后端的朋友第一次接触Explain&#xff0c;是在慢SQL压测被领导叫过去的时候。我也不例外&#xff1a;线上有个订单统计页面&#xff0c;运营点一下要等十几秒&#xff0c;一翻日志全是同一条SELECT。当时我做的第一件事不是改代码&#xff0c;而是把这条SQL丢进工具里跑了…

作者头像 李华
网站建设 2026/10/6 13:22:26

主动降噪(ANC)从原理到实战:降噪耳机如何凭空消声

先说点实在的。ANC这三个字母&#xff0c;这几年在耳机圈、手机圈几乎天天见&#xff0c;但你要是去问十个买了降噪耳机的人&#xff0c;至少有六七个其实说不清它到底是怎么把地铁轰隆隆的噪声变没的&#xff0c;更别说自己做一套可用的降噪方案了。我在这个方向断续折腾了两三…

作者头像 李华
网站建设 2026/10/6 13:21:22

对话墨子:用兼爱非攻与三表法构建AI伦理审查框架

话不多说&#xff0c;先把这个标题拆开&#xff1a; “No135: AI中国故事-对话墨子——兼爱非攻与AI伦理&#xff1a;平等主义、实用主义与技术中立” 。第一眼看到这个题目&#xff0c;我以为是又一篇蹭国潮热点的泛泛之谈&#xff0c;但真正把这个对话做下来之后&#xff0…

作者头像 李华
网站建设 2026/10/6 13:21:06

再生龙Clonezilla镜像还原实战:从启动盘到系统恢复

前阵子有个朋友打电话来&#xff0c;说公司一台存程序的服务器系统起不来了&#xff0c;里面有一堆部署好的服务和配置&#xff0c;问我该怎么办。我第一句话是&#xff1a;“你有没有做过系统镜像备份&#xff1f;”电话那头沉默了十几秒。最后我们跑了一趟机房&#xff0c;拆…

作者头像 李华
网站建设 2026/10/6 13:20:16

学成在线PSD素材切图全流程:从量尺寸到页面落地

简介&#xff1a;这是面向网页设计初学者与前端开发者的“学成在线”页面实现素材&#xff0c;源自黑马程序员及学成在线案例。整套资源将设计稿与前端代码配套呈现&#xff0c;既能用于练习 HTML/CSS 页面还原&#xff0c;也可作为课程实训或个人作品集的参考素材&#xff0c;…

作者头像 李华