ARTICLE DETAIL

资讯详情

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

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

基于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函数把该文档从索引里删掉再重新插入。流程拆开看大概是这么几步文档保存时读取最新的标题和正文用 jieba 对标题和正文做分词先从doc_search里删除该doc_id的所有旧记录再插入一条新记录。这里要注意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 空态、快捷键和索引状态感知细节上我还做了几个不起眼但价值很大的功能。搜索无结果时的空态我一开始只显示了“没有找到匹配内容”一行字后来发现这样很容易让用户陷入死胡同。现在空态里会提示尝试缩短关键词、检查是否有错别字、或者去设置里重建索引。实际使用中很多“搜不到”的问题根源是索引没有跟上给出重建索引的引导能省掉很多用户困惑。全局快捷键CtrlK聚焦搜索框这已经是这类工具的标配了我之前的编辑器里其实已经有了今天只是把触发逻辑统一到搜索组件上。还有一个小细节搜索框里输入全角空格或者只有空格的字符串时不会发起搜索请求避免又一次无意义的 IPC。索引状态感知也是一个容易漏掉的功能。我在设置页里增加了一个“索引信息”面板显示当前索引了多少文档、最近一次重建时间、自建词典的词条数。这看起来像是开发者接口但对个人工具来说它是排查“为什么搜不到”的第一入口。5. 30天挑战过半我重新思考“搜索”这件事今天是第 11 天项目已过三分之一正好借这个机会做一次阶段性复盘。我发现做知识管理工具搜索不仅仅是一个功能模块它在很大程度上决定了用户对这个工具的信任感。5.1 这11天最大的教训接口预留与过早优化回头看我前 10 天的代码最庆幸的是当初做存储层的时候把“元数据”和“正文内容”明确分开了。文档表只存标题、路径、标签、创建时间这些结构化数据正文通过文件路径按需读取。这个设计当时只是出于“Markdown 文件本来就应该直接存在磁盘上”的直觉没想到今天加搜索引擎时几乎不用改动原来的数据层直接在旁边多建一张 FTS5 虚拟表就接上了。这一点其实比“一开始就设计好搜索功能”更重要——前期搜索需求不明确如果强行一开始就设计索引结构大概率会根据错误的假设做出过度设计。更合理的做法是保证层与层之间的边界清晰给未来的功能留出插入位置而不是提前把所有扩展点都实现。与之相对的另一个极端是过早优化。我最初没加搜索原因就是觉得“内容少用正则也行”。事实证明这个决定是对的正是因为内容量到了临界点、体验真实恶化我才理解了为什么需要索引而不是凭空想象出一个性能问题。过早引入 ES 或者重型的搜索服务只会让项目陷入维护泥潭。5.2 明天的计划可配置的词库和重建索引入口虽然今天的搜索功能已经能正常使用了但距离“顺手”还有一段距离。我整理了几个必须要做的东西设置页增加“重建索引”按钮配合进度提示解决用户遇到搜索异常时的自救途径自建词典的可视化管理方便把常用术语直接加进词库不用改配置文件重启标签权重加分让标签命中排在标题命中前面增强检索业务语义搜索历史记录把最近的搜索词存在本地方便重复查找。这些功能都不复杂难点在于接口怎么设计得顺滑。比如重建索引进度提示如果索引量少根本不需要进度条但如果文档量上千就必须给用户一个明确的“在做什么”的状态反馈避免误以为卡死。写到这里我想多说一句个人体会。做本地优先的工具最大的幸福感其实来自“它能自己持续变得好用”这件事。前 10 天写编辑器、写标签系统是给自己造器皿这一天的搜索功能做出来之后我每天记录笔记时终于敢往里面堆量了因为我知道「找得到」这个底线已经被守住了。30 天的项目还在继续明天继续解决新问题。
返回列表