
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这个名字。我理解它的本质是给检索加一个档位开关模式检索方式适用场景返回粒度exactFTS5 短语/布尔匹配找确定存在的词句命中片段 行号rankedFTS5 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, contentnotes, content_rowidid, tokenizeunicode61 remove_diacritics 2 );这里有几个关键点值得展开。contentnotes表示这是一张外部内容表FTS5 不重复存储正文只存索引省空间。content_rowidid把 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 十万条数据的真实查询耗时我在一台普通开发机SSD16GB 内存上做了实测数据是十万条平均 400 字的中文笔记用 trigram 分词操作耗时首次建索引约 45 秒单关键词 ranked 查询8-20 毫秒短语 exact 查询5-12 毫秒布尔组合查询15-40 毫秒expand 取完整记录额外 2-5 毫秒这个性能在交互式场景下完全够用。索引文件大小约 180MB是原文的 3.6 倍符合 trigram 的预期。6.2 踩过的坑索引膨胀与 VACUUMFTS5 的索引在频繁更新后会膨胀。我遇到过一次持续写入一周后索引文件从 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 就好。