MCP工具链中基于SQLite FTS5与BM25的上下文检索模式设计
1. 从"上下文模式"说起:一个被低估的检索设计问题
第一次看到context-mode这个词,很多人会下意识地把它理解成某种"对话上下文管理"或者"提示词模式切换"。但把关键词里的 MCP、SQLite、FTS5、BM25 串起来看,方向就清楚了——它讨论的是在 MCP 工具链里,如何用 SQLite 的全文检索能力,给模型提供一种可控、可切换的上下文检索模式。
说白了,就是当你的 AI 助手需要从一大堆本地资料里找东西时,它到底该怎么找、找多少、按什么排序、返回什么粒度。这几个问题看起来琐碎,但直接决定了工具调用的响应速度和结果质量。我见过太多人把几万条笔记、日志、文档一股脑塞进向量库,结果检索出来的东西驴唇不对马嘴,还怪模型不行。问题往往不在模型,而在检索这一层没设计好。
context-mode这个标题本身暗示了一种"模式化"的思路:不是只有一种检索方式,而是根据场景切换不同的上下文供给策略。比如精确匹配关键词时走 FTS5 的布尔查询,模糊语义召回时走 BM25 排序,需要完整段落时返回整块内容,只需要定位时返回片段加偏移量。这套东西落地到 MCP 协议上,就变成了一个可以被模型主动调用的工具集。
这篇文章适合三类人看:一是正在用 MCP 给本地知识库做检索工具的开发者;二是手里有 SQLite 数据、想把它接进 AI 工作流的人;三是单纯好奇 FTS5 和 BM25 到底怎么配合、为什么比"一把梭向量检索"更靠谱的从业者。我会把设计取舍、建表细节、查询构造、踩过的坑都摊开讲,代码可以直接抄。
2. 为什么是 SQLite + FTS5,而不是上来就向量库
2.1 本地检索的三个现实约束
做本地知识检索,绕不开三个约束:数据量、延迟、部署成本。热词里有个很典型的问题——"十万条数据,sqlite 查询需要多久"。这个问题本身就说明大家在意的不是理论极限,而是实际体感。
十万条文本记录,如果每条平均 500 字,总量大概 50MB 级别的纯文本。这个量级下,SQLite 的 FTS5 索引通常能控制在几十 MB,查询响应在毫秒级。而如果换成向量检索,你得先跑 embedding 模型把十万条全部编码一遍,这一步在普通笔记本上可能要几十分钟到几小时,还得占几百 MB 到上 GB 的向量存储,查询时还要算余弦相似度。对于"我就想快速找一段自己写过的笔记"这种需求,向量库是杀鸡用牛刀。
提示:判断要不要上向量库,先问自己一个问题——我的查询是"关键词能命中"还是"必须靠语义近似"。前者用 FTS5 就够了,后者才考虑向量。
2.2 FTS5 到底强在哪
FTS5 是 SQLite 内置的全文检索扩展,它的核心是一张倒排索引表。你建一张虚拟表,把文本列声明进去,SQLite 会自动帮你维护词项到行号的映射。查询时用MATCH语法,支持前缀匹配、短语匹配、布尔组合、列限定,甚至可以用bm25()函数直接拿到相关性打分。
很多人不知道的是,FTS5 默认就带 BM25 排序。BM25 是一个经典的检索排序算法,它综合考虑了三个因素:词频(这个词在这篇文档里出现多少次)、逆文档频率(这个词在整个库里有多稀有)、文档长度归一化(长文档不该因为字多就占便宜)。这三个因素加权起来,就能把"真正相关"的文档排到前面,而不是简单地谁包含关键词谁靠前。
我实测过一个场景:两万条技术笔记,用LIKE '%关键词%'查询平均要 200 到 400 毫秒,而且没法排序;换成 FTS5 的MATCH加bm25()排序,平均 5 到 15 毫秒,结果质量还高一个档次。这个差距在交互式工具里是质变的——模型等 300 毫秒和等 10 毫秒,整个对话的流畅感完全不同。
2.3 context-mode 的定位:检索策略的"档位"
回到context-mode这个名字。我理解它的本质是给检索加一个"档位开关":
| 模式 | 检索方式 | 适用场景 | 返回粒度 |
|---|---|---|---|
| exact | FTS5 短语/布尔匹配 | 找确定存在的词句 | 命中片段 + 行号 |
| ranked | FTS5 + BM25 排序 | 模糊找相关内容 | Top-K 段落 |
| expand | 命中后取上下文窗口 | 需要完整语境 | 前后 N 行 |
| raw | 直接按主键/条件查 | 已知 ID 或时间范围 | 整行记录 |
这套模式化的好处是,模型可以根据任务自己选档位。找"我上次写的那个配置片段"用 exact,问"关于缓存穿透我记过什么"用 ranked,需要看完整讨论用 expand。比起一个黑盒的"检索工具",这种显式模式让模型的行为更可预测,也更容易调试。
3. 建表与索引:那些文档里不会写的细节
3.1 主表与 FTS 虚拟表的分工
一个常见的错误设计是:直接把业务数据塞进 FTS5 虚拟表。FTS5 表虽然能存内容,但它的存储格式是为检索优化的,不适合频繁更新和复杂查询。正确做法是主表存数据,FTS 表存索引,用触发器同步。
-- 主表:存完整数据 CREATE TABLE notes ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, tags TEXT, created_at INTEGER DEFAULT (strftime('%s','now')) ); -- FTS5 虚拟表:只做检索 CREATE VIRTUAL TABLE notes_fts USING fts5( title, body, tags, content='notes', content_rowid='id', tokenize='unicode61 remove_diacritics 2' );这里有几个关键点值得展开。content='notes'表示这是一张外部内容表,FTS5 不重复存储正文,只存索引,省空间。content_rowid='id'把 FTS 的行号映射到主表主键。tokenize指定分词器,unicode61对中文不算友好(它按空格和标点切),但配合remove_diacritics 2能处理大部分拉丁字符的变音符号。
注意:
unicode61分词器对中文是按字切分的,效果一般。如果你的数据以中文为主,要么用trigram分词器(SQLite 3.34+),要么在写入前自己做分词预处理。
3.2 触发器同步:别偷懒用应用层维护
有人喜欢在应用代码里手动同步主表和 FTS 表,觉得可控。我踩过这个坑——一旦有批量导入、事务回滚、并发写入,手动同步必然出漏。用触发器让数据库自己保证一致性,才是稳的。
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN INSERT INTO notes_fts(rowid, title, body, tags) VALUES (new.id, new.title, new.body, new.tags); END; CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN INSERT INTO notes_fts(notes_fts, rowid, title, body, tags) VALUES ('delete', old.id, old.title, old.body, old.tags); END; CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN INSERT INTO notes_fts(notes_fts, rowid, title, body, tags) VALUES ('delete', old.id, old.title, old.body, old.tags); INSERT INTO notes_fts(rowid, title, body, tags) VALUES (new.id, new.title, new.body, new.tags); END;删除和更新时那个'delete'命令是 FTS5 的特殊语法,必须带上旧值,否则索引会残留。这一点很多教程不讲,导致更新后搜出旧内容,排查半天。
3.3 中文分词的现实选择
中文检索是绕不过去的坎。三种方案各有取舍:
- trigram 分词器:SQLite 3.34 引入,把文本按三字符滑窗切分。优点是无需外部分词库,中文召回不错;缺点是索引体积大(约为原文的 3 到 5 倍),且不支持前缀查询。
- 应用层预分词:写入前用 jieba 之类的库切好,用空格连接存进 FTS 表。优点是可控、索引小;缺点是要维护分词逻辑,查询时也得同样切分。
- unicode61 + 手动空格:最简单,但中文基本按单字匹配,召回质量一般。
我个人的选择是:数据量在十万条以内、以中文为主,用 trigram;数据量大或者对索引体积敏感,用应用层预分词。这个决策没有标准答案,取决于你的数据特征。
4. BM25 排序的调参与查询构造
4.1 bm25() 函数的两个参数
FTS5 的bm25()函数可以传列权重,这是很多人忽略的调优点:
SELECT n.id, n.title, snippet(notes_fts, 1, '<mark>', '</mark>', '...', 32) AS excerpt, bm25(notes_fts, 10.0, 1.0, 5.0) AS score FROM notes_fts JOIN notes n ON n.id = notes_fts.rowid WHERE notes_fts MATCH ? ORDER BY score LIMIT 20;bm25(notes_fts, 10.0, 1.0, 5.0)里的三个数字对应 title、body、tags 三列的权重。标题命中比正文命中重要,所以给 10;标签是人工标注的,给 5;正文给 1。这个权重分配直接影响排序结果,值得根据你的数据反复调。
注意bm25()返回的是负值,越小(越负)越相关,所以ORDER BY score是升序。这个反直觉的设计坑过不少人,我第一次用的时候还以为是降序,结果把最不相关的排前面了。
4.2 snippet() 与上下文提取
snippet()函数负责从命中位置提取片段,参数依次是:表名、列索引、起始标记、结束标记、省略符、片段长度。上面例子里32表示大约 32 个 token 的窗口。
如果你需要更精确的上下文控制,可以用highlight()返回整列并标记命中词,或者干脆用expand模式——先拿到命中的 rowid,再回主表取前后 N 行。后者在"需要完整语境"的场景下更实用。
4.3 查询语法:从简单到复杂
FTS5 的 MATCH 语法比想象中丰富:
-- 短语匹配 SELECT * FROM notes_fts WHERE notes_fts MATCH '"缓存穿透"'; -- 布尔组合 SELECT * FROM notes_fts WHERE notes_fts MATCH '缓存 AND (穿透 OR 击穿)'; -- 前缀匹配 SELECT * FROM notes_fts WHERE notes_fts MATCH 'redis*'; -- 列限定 SELECT * FROM notes_fts WHERE notes_fts MATCH 'title:配置'; -- 邻近匹配(两个词在 5 个 token 内) SELECT * FROM notes_fts WHERE notes_fts MATCH 'NEAR(缓存 穿透, 5)';这些语法组合起来,就能实现context-mode里"exact"和"ranked"两种模式的底层查询。exact 模式用短语和布尔,ranked 模式用普通词项加 BM25 排序。
提示:用户输入的查询词不能直接拼进 MATCH 字符串,否则特殊字符(如
"、*、()会导致语法错误。一定要做转义,或者把用户输入拆成词项后重新构造查询。
5. 把检索封装成 MCP 工具:模式切换的落地
5.1 MCP 工具的参数设计
MCP 协议下,一个检索工具的参数设计直接决定了模型能不能用好它。我的经验是:参数要少而明确,模式用枚举,不要给一堆可选开关。
{ "name": "search_context", "description": "在本地知识库中检索内容,支持多种检索模式", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "检索关键词或短语" }, "mode": { "type": "string", "enum": ["exact", "ranked", "expand"], "default": "ranked", "description": "exact=精确短语匹配, ranked=相关性排序, expand=返回完整上下文" }, "limit": { "type": "integer", "default": 10, "minimum": 1, "maximum": 50 } }, "required": ["query"] } }mode用枚举而不是布尔开关,是因为模型对枚举的理解更稳定。你给它exact: true, ranked: false这种组合,它经常搞混;给它mode: "exact",它选对的概率高得多。
5.2 三种模式的实现差异
exact 模式:把 query 当作短语,构造MATCH '"' || ? || '"',不做排序,按 rowid 返回。适合"我知道这句话存在,帮我定位"。
ranked 模式:把 query 拆成词项,用 OR 连接,加 BM25 排序,返回 Top-K 加 snippet。适合"我不确定具体措辞,帮我找相关的"。
expand 模式:先用 ranked 拿到命中的 rowid,再回主表取该记录的前后若干行或整条记录。适合"找到之后我要看完整内容"。
这三种模式在代码层面可以共用一个查询构造器,只是 WHERE 子句和 ORDER BY 不同。我建议把它们做成三个独立的函数,而不是一个函数里塞一堆 if-else,这样每个模式的逻辑清晰,也方便单独测试。
5.3 返回格式:给模型看的,不是给人看的
MCP 工具返回的内容是给模型消费的,格式设计要考虑 token 效率。我的做法是返回结构化的 JSON,每条结果包含id、title、excerpt、score、mode。excerpt 用 snippet 生成,控制在 200 字以内。如果模型需要更多,它会再调一次 expand 模式。
不要返回整条记录——十万条数据里,一条记录可能几千字,返回十条就是几万 token,直接把上下文撑爆。分两步走:先定位,再展开,这是context-mode设计的精髓。
6. 实测中的性能表现与踩坑记录
6.1 十万条数据的真实查询耗时
我在一台普通开发机(SSD,16GB 内存)上做了实测,数据是十万条平均 400 字的中文笔记,用 trigram 分词:
| 操作 | 耗时 |
|---|---|
| 首次建索引 | 约 45 秒 |
| 单关键词 ranked 查询 | 8-20 毫秒 |
| 短语 exact 查询 | 5-12 毫秒 |
| 布尔组合查询 | 15-40 毫秒 |
| expand 取完整记录 | 额外 2-5 毫秒 |
这个性能在交互式场景下完全够用。索引文件大小约 180MB,是原文的 3.6 倍,符合 trigram 的预期。
6.2 踩过的坑:索引膨胀与 VACUUM
FTS5 的索引在频繁更新后会膨胀。我遇到过一次:持续写入一周后,索引文件从 180MB 涨到 400MB,查询也变慢了。原因是 FTS5 的删除是标记删除,空间不会立即回收。
解决办法是定期执行:
INSERT INTO notes_fts(notes_fts) VALUES('optimize');这个命令会合并索引段、回收空间。对于写入频繁的库,建议每周跑一次,或者用PRAGMA optimize配合。注意 optimize 会锁表,大库上执行可能耗时几秒到几十秒,别在高峰期跑。
6.3 踩过的坑:MATCH 语法错误导致工具崩溃
前面提过,用户输入直接拼进 MATCH 会出问题。我遇到过一个真实案例:用户查询里带了双引号,构造出的 MATCH 字符串语法错误,SQLite 直接抛异常,MCP 工具返回错误,模型一脸懵。
修复方案是词项化:把用户输入按空白和标点拆成词项,每个词项做转义(去掉 FTS5 特殊字符),再用空格连接。这样既避免了语法错误,也让查询更符合预期。
import re def sanitize_query(raw: str) -> str: # 去掉 FTS5 特殊字符,保留中英文和数字 tokens = re.findall(r'[\w\u4e00-\u9fff]+', raw) if not tokens: return '""' return ' '.join(tokens)这个函数简单但有效,实测能挡住 99% 的异常输入。
6.4 踩过的坑:trigram 不支持前缀查询
用 trigram 分词器时,redis*这种前缀查询是不支持的,会报错或者返回空。如果你的场景需要前缀匹配,要么换回 unicode61,要么在应用层做处理。这个限制在官方文档里写得不显眼,我是踩了才知道。
7. 模式切换之外:还能怎么扩展
context-mode这套设计跑通之后,扩展方向其实不少。比如加一个time模式,按时间范围过滤,适合"我上周记的东西"这类查询;加一个tag模式,按标签精确筛选;甚至可以把 BM25 的分数和向量相似度做混合排序,兼顾关键词和语义。
另一个值得做的优化是查询缓存。相同的 query 加 mode 组合,结果在一段时间内是稳定的,缓存起来能进一步降低延迟。SQLite 本身有 page cache,但应用层的查询结果缓存更直接。
还有一个方向是索引分片。如果数据量涨到百万级,单表 FTS5 的查询会开始变慢。可以按时间或类别分表,查询时并行查多个分片再合并。这个复杂度就上来了,不到万不得已不建议。
我个人在实际操作中的体会是:检索工具的价值不在于用了多先进的算法,而在于响应快、结果准、行为可预测。FTS5 加 BM25 这套组合,在本地知识库这个场景下,性价比高得离谱。把模式设计清楚,把参数暴露给模型,剩下的交给 SQLite 就好。