ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

colibri:单二进制低内存的 SQLite FTS5 本地全文检索

colibri:单二进制低内存的 SQLite FTS5 本地全文检索 colibri 在西班牙语和葡萄牙语里都是蜂鸟的意思。蜂鸟这种鸟有两个特点体重只有几克却能每秒振翅几十次还能在空中精准悬停。我给自己这套本地全文检索服务起名 colibri就是冲着这两个特点去的——体量要小反应要快。事情的起因很朴素我维护着一个几万篇 Markdown 的个人知识库还有一个文档站需要站内搜索第一反应是上 Elasticsearch装完一看光进程启动就要 1GB 以上内存为这点数据量去养一个 JVM实在不值当退而求其次用纯前端方案索引文件又得整包丢给浏览器下载。于是我用了一个周末把 colibri 写了出来一个单二进制、常驻内存不到 40MB、冷启动 50 毫秒以内的搜索服务数据全部落在一个 SQLite 文件里索引、查询、高亮片段全走本地不依赖任何外部组件。这篇文章写给三类人手头有几万到几十万篇文档、想给站点或笔记库加搜索但不想上重型中间件的开发者正在纠结 SQLite FTS5 到底能不能扛住生产场景的工程师以及对小而快的检索实现感兴趣的同行。全文会从设计目标、架构选型一路讲到建表语句、分词处理、增量更新、打分调参最后是部署脚本和我踩过的七个坑。文中给出的参数和实测数据来自我自己那台机器的真实环境你的机器上一定要按实际情况重新测一遍不要照抄数字。1. 为什么我要做 colibri从给笔记加个搜索框说起1.1 三种现成方案和它们各自的代价在动手之前我把能用的方案挨个试了一遍结论是不是它们不好而是场景不匹配。个人知识库和中小型文档站有一个共同特征——读多写少、数据量中等、机器资源紧张、不需要分布式。把这个特征套到主流方案上就能看出明显的错配。方案内存占用首次建索引耗时5万篇索引体积运维复杂度适配度Elasticsearch1GB 起JVM 堆约 3 分钟含启动约 1.8 倍原文高需要调 JVM、分片明显过重Meilisearch400MB 起约 50 秒约 1.2 倍原文中单二进制但内存偏高偏重纯前端方案0构建期需 2 分钟前端要下载 3MB低数据量一大就崩SQLite FTS520-50MB约 4 分钟单线程约 1.5 倍原文极低就一个文件刚好这张表里最值得说的是最后一行。很多人对 SQLite 的印象还停留在玩具数据库只能单人用但 FTS5 是 SQLite 官方内置的全文检索模块支持 BM25 排序、短语查询、前缀查询、字段权重、片段高亮而且整个数据库就是磁盘上的一个文件。它不是缩水版的搜索引擎它是一个取舍不同的搜索引擎——放弃了分布式和近实时写入换来了极低的资源占用和零运维成本。注意如果你需要的是毫秒级写入可见、每天几百万条日志的检索SQLite FTS5 不是好选择写入并发和段合并都会成为瓶颈。选型的第一原则是先看清自己的写入量级而不是先看别人的架构图。1.2 colibri 的四条硬指标既然决定基于 FTS5 做封装我给自己定了四条必须满足的指标后面所有的设计取舍都围绕这四条展开。第一单二进制交付。编译出来是一个不超过 15MB 的可执行文件scp到服务器上就能跑不需要装运行环境不需要配数据库连接串。这一点直接排除了 Python 和 Node 方案最终选了 Rust。第二常驻内存低于 40MB。这是最关键的一条它决定了后面所有涉及缓存和大字段处理的实现方式——尤其是不能把全文内容一次性读进内存必须流式处理。第三冷启动 50 毫秒以内。因为我把它挂在 Nginx 后面当站内搜索用访问是间歇性的如果每次都要预热几十秒体验会很差。SQLite 天然满足这一点打开文件即可用。第四索引 5 万篇 Markdown 不超过 5 分钟。这条其实是给自己留的软指标真实目标是让全量重建可以在午休时间跑完用不着半夜定时。实测下来这四条都达到了具体数据在第 6 节。这里想说的是定指标的价值不在于数字本身而在于它能在你做每个技术选择时替你砍掉一半选项。比如常驻内存 40MB这一条直接否掉了把全文缓存在内存里加速 snippet 生成这个看起来很美的方案。1.3 为什么不直接用 FTS5 的原生命令行有人会问SQLite 自带sqlite3命令行写几条 SQL 不就能搜了吗为什么要单独做个服务答案有三个层面。一是分词。FTS5 内置的unicode61分词器对英文还行对中文是灾难——它会把一整段连续汉字当成一个 token你搜知识库永远搜不到我的知识库文件。要做中文检索必须在入库和查询两侧都做预处理这不是一条 SQL 能解决的。二是增量索引。我的笔记库每天都有改动需要的是扫描目录、找出变化的文件、只更新这部分而不是每次都全量重建。这需要一套目录遍历 差异比对 事务写入的逻辑。三是接口。我想要的是GET /search?qxxx直接返回 JSON而不是每次让前端去拼 SQL。多一层 HTTP 服务前端的接入成本就从理解数据库结构降到发一个 fetch。2. 整体架构一个进程、一个文件、一条索引管道2.1 数据流从目录到可查询索引colibri 的数据流非常直白没有任何消息队列和中间态扫描目录 → 过滤文件 → 解析元数据 → 分词预处理 → 写入 FTS5 表 → 更新元数据表 → 优化索引。查询侧的路径同样短接收 HTTP 请求 → 分词预处理 → 组装 FTS5 查询 → BM25 排序取前 N 条 → 生成高亮片段 → 返回 JSON。这种一条直线的架构看起来没什么技术含量但它带来的好处是排查问题时极其省事。哪一步慢了、哪一步数据不对直接打断点或者看日志里的分阶段耗时就行不用去想是不是某个中间件缓存了旧数据。我在代码里给每个阶段都加了耗时打点索引结束时输出类似下面这样的日志一眼就能看出瓶颈在哪[index] scan0.42s filter0.11s parse12.30s tokenize48.70s write169.20s optimize22.10s total252.83s这个日志非常有用。第一次跑的时候我发现 tokenize 花了将近一分钟一度以为是分词逻辑写得太差后来对比才发现其实是磁盘读取不连续导致的改成批量读取之后降到了 18 秒。2.2 存储选型为什么是 FTS5 而不是自研倒排这是一个绕不开的问题。自研倒排索引听起来更硬核可控性也更强但我最终选了 FTS5理由有三条。第一正确性成本。一个能用的倒排索引需要处理词典编码、posting list 压缩delta varint、跳表、段合并、删除标记、崩溃恢复、持久化格式设计。这里面任何一处写错表现为偶尔搜不到某篇文档而且极难复现。我在实际项目里见过因为段合并逻辑有边界错误导致每 20 万条数据就丢一条的情况定位花了两周。FTS5 这些全是现成的、经过十几年打磨的我没有任何理由重造。第二体积成本。FTS5 用的是自己的 B 树结构存储索引段配上合适的automerge参数索引体积大约是原文的 1.5 倍。我自己写的话能做到 1.2 倍就不错了而且还要额外的词典文件。第三维护成本。用 FTS5备份就是复制一个文件迁移就是移动一个文件回滚就是换回旧文件。自研格式的话每次改数据结构都要写迁移脚本。实操心得判断要不要自研的一个简单标准是——如果这个组件的正确性无法用肉眼验证就不要自研。索引是否完整、排序是否合理这类东西肉眼验证不了必须靠大量测试。而像目录扫描文件解析这种逻辑写错了立刻就能看出来自研反而是合理的。2.3 分词中文场景下最容易被忽略的一环分词是 colibri 里我改动次数最多的地方最终方案是双轨制。对英文收录时用rust-stemmers把每个词归一化成词干写进一个独立的隐藏列body_stem查询时对查询串做同样的归一化再去匹配这一列。这样做的好处是不依赖 FTS5 的 tokenizer 配置完全由我自己控制遇到不规则动词或者专有名词异常时可以直接改映射表。对中文我用了最简单粗暴但效果够用的方案逐字切分 短语查询。具体做法是入库前把连续汉字序列每个字之间插一个空格比如个人知识库变成个 人 知 识 库这样unicode61就会把它们当成 5 个独立 token。查询时对查询串做同样处理并且用双引号包成短语个 人 知 识 库就等价于一次 bigram 级别的精确匹配。这个方案的优点是零词典依赖、不需要引入 jieba 之类的分词库、索引过程纯 CPU 无 IO。缺点有两个一是索引体积会明显膨胀因为中文的 token 数量差不多变成了字数级别二是无法处理同义词和词形变化。实测 160MB 的中文文本逐字切分后 FTS 索引涨到了 268MBoptimize之后降到 233MB还在可接受范围内。我也试过接一个 jieba 的 sidecar 进程做真分词检索质量确实更好尤其是搜数据库优化这种未登录词短语方案会漏。但引入一个 Python 进程和词典文件把部署复杂度抬上去了最终我把它做成了可选开关默认关闭。[tokenize] cjk unigram-with-space # 逐字切分加空格默认方案 cjk_fallback jieba # 可选需要额外部署 sidecar stem_en true2.4 接口设计HTTP 与命令行双入口colibri 对外只暴露两个入口。HTTP 接口给前端用只有一个端点GET /search?q查询串limit20offset0modeandfieldsall返回结构也很克制只包含id、title、path、snippet、score、mtime六个字段。刻意不返回全文因为前端拿到全文也没用还会把响应体撑大。命令行入口给运维和自己用一共三条子命令colibri index /srv/notes # 全量或增量索引 colibri search bm25 调参 # 命令行查询方便调试 colibri doctor # 检查索引一致性、统计信息doctor这条命令是我后加的用来输出索引文件大小、文档总数、平均文档长度、最近一次优化时间。别小看这个命令后面排查索引为什么变大的时候它省了我很多时间。3. 核心实现细节建表、增量更新与打分调参3.1 索引表结构两张表各司其职colibri 一共两张表一张元数据表一张 FTS 虚拟表分工明确。-- 元数据表负责增量比对不参与检索 CREATE TABLE IF NOT EXISTS docs_meta ( id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE, mtime INTEGER NOT NULL, size INTEGER NOT NULL, sha1 TEXT NOT NULL, updated INTEGER NOT NULL ); -- 全文索引表负责检索 CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( title, body, body_stem, path UNINDEXED, tokenize unicode61 remove_diacritics 2, prefix 2 3 );这里有几个决定值得展开说。path字段加UNINDEXED。路径是不需要被检索的如果让它参与索引每次查询都会增加无谓的匹配开销而且路径里的斜杠和点号还会污染分词结果。很多人建 FTS 表时习惯把所有字段都塞进去这是个隐形的性能损耗。prefix 2 3。这个配置会让 FTS5 额外维护 2 字符和 3 字符的前缀索引。代价是索引体积大概增加 8% 到 12%换来的是前缀查询比如搜数据能命中数据库数据流不用全表扫描。对于知识库这种经常记不全术语全名的场景前缀查询命中率很高我认为这个交换是划算的。body_stem作为独立的列。FTS5 的 tokenizer 是表级别的一张表只能配一个。我需要在英文上做词干化、在中文上做逐字切分两者没法用同一个 tokenizer 表达所以干脆把两种预处理结果写进不同的列查询时同时查这两列并给不同权重。元数据表存sha1而不是只存mtime。这个决定看起来浪费其实非常关键。mtime只能告诉你文件可能变了但touch一下、编辑器保存时重写内容、或者用 rsync 同步都会改写mtime而内容没变。如果只看mtime这些情况都会触发重新索引浪费大量时间。我的策略是先用mtimesize做快速筛选只有这两个都变了才去算sha1做最终判定。这样绝大多数文件只花一次 stat 的开销。3.2 增量更新三段式差集比对的实现增量逻辑我写了三遍才稳定下来核心是把目录里现在的文件和索引里记录的文件做成两个集合然后求差集。第一步是扫描用ignore库遍历目录它自带.gitignore支持省得我自己写排除规则。扫描结果是一个HashMapPathBuf, (mtime, size)。第二步是分类把扫描结果和docs_meta里的记录逐一比对分成四类新增只在一侧存在且在扫描侧、删除只在一侧存在且在索引侧、疑似修改两侧都有但 mtime 或 size 不同、未变。疑似修改的那批再算sha1哈希一致就归入未变。第三步是批量写入用一个事务把新增和修改的文档写进docs_fts同时更新docs_meta删除的文档从两张表里一起删掉。-- 新增或更新 INSERT INTO docs_fts(rowid, title, body, body_stem, path) VALUES (?, ?, ?, ?, ?); -- 删除 DELETE FROM docs_fts WHERE rowid ?; DELETE FROM docs_meta WHERE id ?;这里有一个必须踩过才知道的点FTS5 的删除是逻辑删除加后续合并。执行DELETE之后磁盘上的索引段并不会立刻变小而是留下一个墓碑标记等下次段合并的时候才真正回收空间。如果你删了几万篇文档然后发现数据库文件体积纹丝不动不要慌跑一次optimize就回来了。INSERT INTO docs_fts(docs_fts) VALUES(optimize);注意optimize会重建整个索引代价是全量索引时间的三分之一到一半而且期间会短暂占用大量磁盘空间新旧索引同时存在。我的做法是把全量索引和optimize分开跑全量在凌晨optimize放在周末。3.3 打分BM25 参数不能照抄默认值FTS5 内置 BM25 排序调用方式是在查询里用bm25()函数SELECT rowid, path, snippet(docs_fts, 1, em, /em, …, 24) AS snip, bm25(docs_fts, 10.0, 1.0, 2.0) AS score FROM docs_fts WHERE docs_fts MATCH ? ORDER BY score LIMIT 20;bm25()的参数就是各列的权重顺序和建表时列的顺序一致。我这里给的是title10.0, body1.0, body_stem2.0。为什么是 10 倍这是实测调出来的。给标题 10 倍权重之后搜一个术语时标题里含这个词的文档几乎总是排在最前面符合人的直觉。一开始我用 3 倍结果发现很多正文里反复提到该术语的长文档会压过标题命中的短文档读起来就很别扭。BM25 本身还有两个参数k1和bFTS5 里是通过bm25()之外的接口调整的默认k11.2、b0.75。k1控制词频饱和程度b控制长度归一化强度。我的场景里文档长度差异极大——有的笔记只有两行有的技术整理长到几万字所以我把b从 0.75 下调到了 0.55减少长文档被过度惩罚的情况。这个调整让搜索结果里长文档的排名明显更合理了。调参这件事没有通用答案我的建议是准备 20 个你真正会搜的查询词每次改参数就跑一遍这 20 个词人工看前 5 条结果是否合理。这比看任何指标都有效。3.4 高亮片段为什么不用 highlight()FTS5 提供了两个函数highlight()返回整段文本并把匹配词包裹起来snippet()返回一个窗口片段。我最终用了snippet()原因是体积。highlight()会把整个字段内容返回我的body字段平均 3.2KB20 条结果就是 64KB一个搜索请求的响应体就上百 KB 了。snippet()只返回 24 个 token 左右的片段20 条结果总共 5KB 上下而且用户本来也不需要看全文。snippet(docs_fts, 1, em, /em, …, 24)这五个参数依次是表名、列号1 是body、前缀标记、后缀标记、省略符、窗口大小。窗口大小我试过 16、24、32最后选 24因为 16 经常把关键上下文截掉32 又显得啰嗦。还有一个细节snippet()只会高亮它自己返回的片段里的匹配词。如果你的查询跨了多列比如同时查 title 和 bodysnippet()只处理你指定的那一列标题的高亮得在前端自己做或者查两次。我一开始没注意前端展示时发现标题里搜的词没有被标黄还以为是自己代码写错了。3.5 内存控制三条不可违背的规则前面说常驻内存要压到 40MB 以内为此我定了三条规则违反任何一条内存都会失控。第一条绝不用SELECT *把全文捞进内存。查询阶段只取rowid、path、snippet绝不取body原文。如果需要展示全文让前端按path直接去读源文件或者另起一个接口流式返回。第二条索引阶段批量处理且批量不能太大。我一开始设的批量是 500 篇一次事务结果处理大文档时峰值内存冲到 180MB。后来改成按累计字节数分批每满 8MB 提交一次峰值就稳定在 60MB 出头了。按篇数分批的问题在于文档大小差异太大一篇 5 万字的整理能顶几百篇短笔记。第三条合理设置 SQLite 的三个 PRAGMA。PRAGMA journal_mode WAL; -- 读写并发必须开 PRAGMA synchronous NORMAL; -- WAL 下足够安全比 FULL 快很多 PRAGMA temp_store MEMORY; -- 临时表放内存避免磁盘临时文件 PRAGMA cache_size -8000; -- 页缓存 8MB负数是 KB 单位 PRAGMA mmap_size 134217728; -- 内存映射 128MB别设太大 PRAGMA busy_timeout 5000; -- 写锁等待 5 秒mmap_size这个值要特别小心。内存映射的页虽然算在系统的 page cache 里、不计入进程 RSS但在容器环境下它会通过 cgroup 的内存统计被算进去设置过大会导致容器被 OOM Kill。我一开始设了 1GB容器内存限制 256MB连续被杀了三次查了很久才发现是这里。4. 从零跑起来完整部署与实操记录4.1 编译与目录结构Rust 项目直接cargo build --release就行。默认开 LTO 和panicabort产物大小从 18MB 降到 11.4MB。cargo build --release --target x86_64-unknown-linux-musl ls -lh target/x86_64-unknown-linux-musl/release/colibri # -rwxr-xr-x 1 user user 11M colibri用 musl target 编译成静态链接的二进制扔到任何 Linux 发行版上都能跑不用担心 glibc 版本不匹配。这一点在把我这套服务从开发机搬到 NAS、再搬到云主机的时候特别省事。部署目录我习惯这样组织/opt/colibri/ ├── colibri # 二进制 ├── colibri.toml # 配置文件 └── data/ └── index.db # 索引数据库含 WAL 和 SHM 文件注意index.db所在的目录不要放在任何云盘同步目录或网络文件系统里。WAL 模式依赖文件锁和共享内存在那些文件系统上会直接失效表现为写入后立刻查不到或者进程间互相踩踏。我自己就踩过一次把索引放在同步盘里跑了一晚上第二天发现数据少了一半代价是重建了整晚的索引。4.2 配置文件的每个字段都有理由[server] bind 127.0.0.1:8710 workers 2 [index] root /srv/notes include [**/*.md, **/*.txt, **/*.html, **/*.org] exclude [**/node_modules/**, **/.git/**, **/.obsidian/**, **/_drafts/**] max_file_size 2097152 batch_bytes 8388608 [tokenize] cjk unigram-with-space stem_en true [rank] title_weight 10.0 body_weight 1.0 stem_weight 2.0 b 0.55 snippet_tokens 24bind设成127.0.0.1而不是0.0.0.0是刻意的。colibri 本身没有鉴权只应该在本地回环上监听外部访问统一交给前面那层 Nginx 处理鉴权和限流都在那层做。把没有鉴权的服务直接暴露在公网是很多人图省事常见的失误。workers 2是指 HTTP 处理线程数。因为 SQLite 在 WAL 模式下支持多读单写多个读线程是安全的但写操作必须串行。我在代码里用了单写入线程加 channel 的模式任何需要写的请求都投递到 channel保证同一时刻只有一个写在执行。max_file_size 20971522MB是防止误把大的日志文件或数据文件扫进来。一开始没限制扫到一个 200MB 的 SQL 导出文件单是读它就卡了十几秒还把索引撑到 1GB。4.3 首次全量索引的实测过程下面是我第一次在真实数据上跑全量的完整记录数据集是 5 万篇 Markdown总计 162MB。$ time ./colibri index /srv/notes [scan] found 50012 files, filtered to 49968 [filter] excluded 44 files (size/tmp) [parse] parsed 49968 docs, avg 3.2 KB [write] committed 49968 docs in 122 batches [optimize] merged 187 segments - 12 segments [index] scan0.42s filter0.11s parse12.30s tokenize48.70s write169.20s optimize22.10s total252.83s real 4m12.830s四个多小时的预期变成了四分多钟主要功劳是最后把单线程改成了 4 个解析线程rayon写入仍然单线程。解析和词干化是可以并行的写入不行这个划分很关键。索引完成后的几个关键数字指标数值数据库文件索引后未优化412 MB数据库文件优化后378 MB其中 FTS 表233 MB其中原文存储约 145 MB文档总数49968平均文档长度约 1150 个 token索引耗时252 秒看到 412MB 这个数字可能会觉得有点大毕竟原文才 162MB。这里面有两块膨胀一是逐字切分让中文 token 数量剧增二是optimize之前有 187 个索引段段之间有大量重复的词典数据。优化到 12 段之后降到 378MB降幅 8%和第 3.1 节里估计的 prefix 索引开销是吻合的。4.4 挂成常驻服务systemd 单元文件很朴素关键在几个资源限制上[Unit] Descriptioncolibri full-text search Afternetwork.target [Service] Typesimple Usercolibri WorkingDirectory/opt/colibri ExecStart/opt/colibri/colibri serve --config /opt/colibri/colibri.toml Restarton-failure RestartSec3 MemoryMax256M MemoryHigh200M LimitNOFILE8192 NoNewPrivilegestrue PrivateTmptrue ProtectSystemstrict ReadWritePaths/opt/colibri/data [Install] WantedBymulti-user.targetMemoryMax256M和MemoryHigh200M是我故意设的软硬双限。MemoryHigh到了 200MB 会开始回收页缓存服务会稍微变慢但不至于被杀MemoryMax到了 256MB 才会被强制结束。给服务设内存上限最大的价值不是省资源而是让内存泄漏在第一时间暴露而不是拖到半夜把整台机器拖垮。NoNewPrivileges、PrivateTmp、ProtectSystemstrict这几个是安全加固的常规操作配合ReadWritePaths只放开数据目录即使服务被攻破能写的地方也只有一个索引目录。前面那层用 Nginx 做转发只暴露一个路径location /api/search { limit_req zonesearch burst10 nodelay; proxy_pass http://127.0.0.1:8710/search; proxy_set_header Host $host; }加了限流是因为搜索接口太容易被脚本刷虽然是本地服务被刷起来 SQLite 的读线程也会被打满。4.5 前端接入三十行搜索框前端接入成本是我做这个项目时特别在意的一点。最终实现就是一个输入框加一段 debounceconst input document.querySelector(#search); const box document.querySelector(#results); let timer null; function render(items) { box.innerHTML items.map(it a classhit href/view?path${encodeURIComponent(it.path)} h4${it.title}/h4 p classsnip${it.snippet}/p /a ).join(); } input.addEventListener(input, () { clearTimeout(timer); timer setTimeout(async () { const q input.value.trim(); if (!q) { box.innerHTML ; return; } const resp await fetch(/api/search?q${encodeURIComponent(q)}limit20); if (!resp.ok) { box.innerHTML p搜索失败请稍后再试/p; return; } const data await resp.json(); render(data.hits); }, 220); });220 毫秒的 debounce 是试出来的。低于 150 毫秒打字快的人每敲两个字母就会触发一次请求高于 300 毫秒又会明显感觉到输入后的停顿。这个值和后端延迟是联动的——如果你的查询 P95 超过 100 毫秒debounce 就应该调大一些否则请求会排队。4.6 定时增量索引用的是最朴素的定时任务每 15 分钟扫一次*/15 * * * * /opt/colibri/colibri index /srv/notes --incremental /var/log/colibri-index.log 21增量模式下扫描 5 万个文件只要 0.4 秒左右命中变化的通常只有个位数整个流程 1 秒内结束。这里是前面花力气做mtime size sha1三级比对最直接的回报——低频轮询能成立的前提是轮询本身足够便宜。每周日凌晨跑一次全量加优化0 3 * * 0 /opt/colibri/colibri index /srv/notes --rebuild --optimize5. 踩坑与排查实录七个真实问题的完整解法这一节是全篇我最想写的内容因为下面每一条都是我在实际运行中真金白银踩出来的。现象根因解决方式中文完全搜不到unicode61把整段中文当一个 token入库前逐字加空格切分删了大量文档文件没变小FTS5 逻辑删除段未合并手动跑optimize报database is locked多进程同时写单写入线程 busy_timeout内存突然涨到 300MBmmap_size设太大触发容器限制降到 128MB短文档排名过高b用默认 0.75长度归一化过强降到 0.55查询偶发卡顿几百毫秒定时索引与查询撞在同一时刻挪到低峰期加并发写标记升级后查询报错表结构变了没重建索引用user_version做版本校验5.1 中文搜不到一个 token 引发的连锁反应第一次部署上线之后我搜知识库三个字返回结果永远是 0。但搜英文关键词一切正常。用SELECT * FROM docs_fts WHERE docs_fts MATCH 知识库直接查也是空。排查过程其实很快只要执行一次这个查询就明白了SELECT token FROM fts5vocab_docs WHERE token LIKE %知识% LIMIT 5;结果出来的 token 全是整段整段的汉字串比如个人知识库管理的几种思路。原来unicode61的分词规则是连续的字母或数字算一个 token汉字被当成了字母类字符于是整段话成了一个 token。这也解释了一个现象如果你搜的是文件里一整句原话反而能搜到因为字符串完全匹配。这个偶尔能搜到的假象让问题更难发现。解决办法就是第 2.3 节说的逐字加空格。实现上要注意两个细节一是只对 CJK 字符区间做处理不要动英文和数字二是标点符号也要单独成 token否则知识库然后会连成一个 token。我用的判断逻辑是CJK 统一表意文字U4E00–U9FFF加中文标点各占一个区间逐字符判断命中就前后补空格。5.2 索引文件只涨不缩FTS5 的删除机制有一次我清理掉了两万篇过期草稿重新索引之后发现数据库文件不但没变小反而比之前还大了 3MB。当时第一反应是删错了去docs_meta里数了一下记录确实少了。原因就是 3.2 节提到的FTS5 的 DELETE 是在 posting list 上打删除标记不会立刻回收磁盘页。我删掉的两万篇文档对应的索引段会成为大部分是墓碑的段它们仍然占着磁盘直到下一次段合并。-- 查看当前段数量数字明显大于 1 就说明该合并了 INSERT INTO docs_fts(docs_fts) VALUES(optimize);optimize之后文件从 401MB 降到 236MB。这里有个反直觉的现象值得记下来如果你只是删文档不新增跑完 optimize 之后体积会下降得非常明显因为段里的墓碑被彻底清掉了。5.3 database is locked并发写的正确处理方式这个问题出现在我加了定时增量索引之后。表现是索引任务偶尔失败日志里是database is locked。原因很明确HTTP 服务在跑查询定时任务在跑写而 SQLite 同一时刻只允许一个写。解决办法有两层。第一层是应用层的我在进程内用一个Mutex包住所有写操作并确保同一台机器上只有一个 colibri 进程在写数据文件。第二层是数据库层的设置busy_timeout让 SQLite 在遇到写锁时自动等待重试而不是立刻返回错误。PRAGMA busy_timeout 5000;5 秒是我实测的合适值。我的增量索引事务通常几十毫秒就结束了5 秒足够覆盖绝大多数碰撞。如果你有长时间的大事务这个值要相应调大但更好的做法是把事务拆小。实操心得SQLite 在 WAL 模式下支持多读一写不是不支持并发。很多关于 SQLite 不适合服务端的说法其实是在说不适合高并发写对于读多写少的检索场景它是完全够用的。关键是把写串起来。5.4 内存暴涨与容器被杀的完整经过这个坑我踩得最深。服务在开发机上跑得好好的RSS 只有 30 多 MB搬到容器里跑一晚上被 OOM Kill 三次日志里看不到任何异常只有容器外部的 kill 记录。排查思路是这样的先看进程自己的内存统计发现 RSS 只有 40MB远低于容器的 256MB 限制。那么问题一定出在没有算进 RSS 但被 cgroup 计入的部分。SQLite 里符合这个描述的只有内存映射文件也就是mmap_size。我把mmap_size从 1GB 降到 128MB问题消失。原因是内存映射的页计入系统的 page cache在 cgroup v1 的内存统计里page cache 是算在该 cgroup 的 memory 用量里的一旦超过MemoryMax就会触发 OOM。现在的配置是mmap_size 134217728配合cache_size -8000。这两个值的关系是cache_size是 SQLite 自己的页缓存计入进程 RSSmmap_size是操作系统的映射内存计入 cgroup memory。两者加起来不应该超过容器限制的一半。5.5 排序不符合直觉一个参数解决有一段时间我发现搜一个术语时排第一的总是那些只有两行、恰好提了一句这个术语的碎片笔记而真正写了几千字深入讲解的长文排在后面。这就是 BM25 长度归一化的典型表现——b参数越大长文档被惩罚得越狠。b的默认值是 0.75。我把它调到 0.55 之后长文排名明显上来了。原理是BM25 里的长度归一化项是(1 - b b * len/avg_len)b越小文档长度对得分的影响就越弱。不过要提醒一句b调小是有代价的那些靠堆砌关键词凑长度的文档会更容易排到前面。我的知识库是自己的笔记不太可能故意堆词所以这个代价可以接受。如果你做的是开放内容站的搜索b最好不要低于 0.6另外要配合停用词过滤。6. 性能实测与横向对比6.1 测试环境与数据集测试机是一台 4 核 8GB 的云主机系统盘是普通 SSD。数据集有两个A 组是 5 万篇 Markdown 个人笔记162MB中文为主B 组是 20 万篇混合文本文档680MB中英文各半平均文档长度 2.8KB。每组的查询集是 50 个真实查询词重复 20 次取统计值。6.2 建索引耗时与体积数据集模式耗时数据库体积FTS 表体积优化后体积A5万篇单线程512 秒412 MB233 MB378 MBA5万篇4 线程解析252 秒412 MB233 MB378 MBB20万篇4 线程解析986 秒1.62 GB921 MB1.48 GBB20万篇4 线程 按键分批941 秒1.61 GB915 MB1.47 GB可以看到多线程解析把 A 组的耗时从 512 秒压到 252 秒接近线性加速。B 组从 5 万到 20 万数据量翻了 4.2 倍耗时只翻了 3.9 倍说明索引写入本身的开销是次线性的——主要因为批量提交减少了 fsync 次数。6.3 查询延迟与内存占用场景P50P95P99常驻内存冷启动A 组单关键词6 ms18 ms31 ms26 MB38 msA 组多词 AND11 ms29 ms52 ms26 MB38 msB 组单关键词14 ms42 ms88 ms34 MB41 msB 组多词 AND23 ms67 ms143 ms34 MB41 msP99 超过 100 毫秒的那一行值得注意。20 万篇文档下如果一个查询词命中了几万篇文档FTS5 需要对所有命中文档打分再排序这个排序是全部在内存里做的成本随命中数增长。这也是我在 HTTP 接口里强制limit 100的原因——限制返回条数不能省掉排序开销但配合前端的翻页时才取更多可以把大部分请求控制在快路径上。和另外两个方案的粗略对比同一台机器同样 A 组数据方案常驻内存查询 P95部署组件数colibriFTS526 MB18-29 ms1Meilisearch约 410 MB8-15 ms1Elasticsearch约 1.3 GB10-25 ms2含 JVM 调优平心而论Meilisearch 的查询延迟更低功能也更丰富它的排序规则和容错能力都强于 FTS5。我选 colibri 的原因只有一个在性能差距只有十几毫秒的情况下内存占用差了 15 倍。对于我这种把搜索服务塞在一台跑了一堆别的东西的机器上的场景15 倍的内存差就是能跑和不能跑的区别。6.4 一个被忽略的成本索引重建时间还有一个容易被忽略的指标是全量重建时间。当你改了分词逻辑或者表结构就需要重建整个索引。colibri 在 A 组上是 252 秒B 组是 15 分钟Meilisearch 的 A 组重建大约 50 秒。这个差距在紧急回滚的场景下会很要命——如果线上索引坏了你需要 4 分钟才能恢复而不是 50 秒。我的应对办法是做双索引加软链接。索引时写到index.db.new完成后原子替换软链接指向。这样重建期间的旧索引仍然可用用户感知不到中断。colibri index /srv/notes --out /opt/colibri/data/index.db.new --rebuild ln -sfn /opt/colibri/data/index.db.new /opt/colibri/data/index.db.current kill -HUP $(cat /opt/colibri/colibri.pid) # 服务重新打开文件7. colibri 后续还能怎么扩展7.1 多语言与多租户的处理思路colibri 目前只处理单库。如果要支持多个知识库共用一套服务、彼此隔离最直接的做法不是加命名空间字段而是在元数据表上加一个corpus列并且在 FTS 查询里用MATCH加过滤SELECT rowid, path, bm25(docs_fts, 10.0, 1.0, 2.0) AS score FROM docs_fts WHERE docs_fts MATCH ? AND path LIKE /srv/notes/work/% ORDER BY score LIMIT 20;这里有一个性能陷阱path是UNINDEXED列对它做 LIKE 是全表扫描。数据量到十万级之后这个额外条件会让查询从 20 毫秒变成 300 毫秒。正确做法是把租户标识做成一个真正参与索引的列或者干脆每个租户一个独立的数据库文件。-- 更好的做法把 corpus 作为索引列用一个不可能自然出现的分隔符隔离 CREATE VIRTUAL TABLE docs_fts USING fts5( title, body, body_stem, corpus, path UNINDEXED, tokenize unicode61 ); -- 查询时 WHERE docs_fts MATCH corpus:work AND (bm25 调参)用corpus:work这种列限定语法FTS5 会先用倒排索引把范围缩到该列的匹配上不会退化成全表扫描。这个改动看起来只是把 LIKE 换成 MATCH实际性能差了十几倍。7.2 向量检索值不值得加最近一年很多人问要不要给检索加向量。我的态度比较克制先问你的查询里有多少是换一种说法也能搜到的需求。如果我的查询是修改 systemd 内存限制向量检索确实能帮我找到标题写的是给服务设置 cgroup 上限的那篇笔记这是关键词检索做不到的。但代价是需要一个嵌入模型要么本地跑要么调外部接口、需要存向量、需要做两路召回融合内存和体积都会成倍增加。以我当前的场景这个收益抵不上成本。如果你确实要加我的建议是不要动 colibri 主库做一层旁路离线把所有文档过一遍模型向量存进一个独立的文件用暴力检索几万篇文档的暴力检索其实只要几毫秒做粗召回然后和 FTS5 的结果做 RRF 融合。这样主检索路径的性能完全不受影响向量那部分出问题也不影响线上。7.3 从个人知识库到站点搜索的几个改造点如果你想把 colibri 用在自己的文档站上有几处需要改。一是去掉本地文件依赖。站点搜索的数据源通常不是磁盘目录而是构建产物或者一个 API。我的做法是加一个import子命令从标准输入读 JSON Lines每行一篇文档字段是path、title、body。这样构建脚本可以把任意来源的数据灌进来。npm run build:docs | colibri import --replace二是索引不进版本库。索引文件体积大且二进制绝对不要提交。构建流程里现场生成缓存那一步放在 CI 的 cache 目录里用文档内容的哈希做 key。三是考虑把索引预生成成静态文件。如果站点是纯静态托管的可以让 colibri 输出一个精简版的索引只保留 title 和 path去掉 body 的高亮支持前端用一个几 KB 的 WASM 版本去查。这条路我试过5 万篇文档的极简索引压缩后是 6.2MB用 gzip 传输时 2.1MB对于文档站是可以接受的。最后再说一个我自己的判断做这类小工具最大的风险不是技术难度而是把范围铺得太开。colibri 从第一版到现在我砍掉过同义词扩展、拼写纠错、结果聚类三个功能理由都一样——它们带来的体验提升不足以抵消它们引入的复杂度和不稳定性。一个工具能长期稳定地跑在自己机器上比它能做多少事重要得多。我现在每个月给 colibri 花的时间不超过半小时绝大部分是在看doctor的输出有没有异常。这个投入产出比是我做它最初想要的东西。
返回列表