1. 从 context-mode 说起:一个被低估的 MCP 上下文管理方案
第一次看到context-mode这个词,是在翻 MCP 相关生态项目的时候。当时我正在给一个本地知识库做检索增强,手头堆了十几万条结构化记录,用传统的关键词匹配查得又慢又不准,换了几种方案都不太满意。直到把context-mode这套思路跑通,才意识到它解决的其实是一个很具体、也很普遍的问题:在 MCP 协议体系下,如何用 SQLite + FTS5 + BM25 给模型提供一套轻量、可控、可解释的上下文检索层。
先把话说清楚,context-mode不是一个玄乎的框架,它本质上是一种上下文组织模式——把原本塞进提示词里的一大坨文本,改成按需检索、按相关性排序、按预算裁剪后再注入。它依托的是 MCP(Model Context Protocol)这套工具调用协议,底层存储用 SQLite,全文检索用 FTS5 扩展,排序打分用 BM25 算法。这几个词最近在热词榜上频繁出现,不是没有原因的:MCP 正在成为模型和外部工具之间的标准接口,而 SQLite 又是本地场景下最省心的存储方案,两者一结合,context-mode这种模式就自然冒出来了。
这篇文章适合谁看?如果你正在做 MCP 工具开发、本地 RAG、代码助手上下文管理,或者你只是单纯好奇“十万条数据用 SQLite 查一次到底要多久”,那这篇内容应该能给你一些能直接抄的作业。我会把设计思路、核心参数、实操步骤、踩过的坑都摊开讲,尽量做到你看完就能在自己机器上复现一套。
2. 整体设计思路:为什么是 SQLite + FTS5 + BM25
2.1 上下文管理的核心矛盾
做模型应用的人都有一个共同体会:上下文窗口看起来很大,但真正用起来永远不够。你把所有相关文档一股脑塞进去,token 烧得飞快,模型还容易被无关内容干扰;你塞得太少,又怕漏掉关键信息。这个矛盾在 MCP 场景下更突出,因为 MCP 的定位就是让模型按需调用外部能力,如果每次调用都返回一大堆原始数据,那 MCP 的意义就没了。
context-mode的思路很直接:不要把上下文当成一次性灌入的静态文本,而要当成一个可以查询、可以排序、可以裁剪的动态资源。模型需要什么,就通过 MCP 工具去检索什么,检索结果按相关性排序,再按 token 预算截断。这样既控制了成本,又提高了信噪比。
2.2 为什么选 SQLite 而不是别的
选 SQLite 做这个事,理由其实很朴素。第一,它是嵌入式的,不需要单独起服务,一个文件就是一个库,部署成本几乎为零。第二,它自带 FTS5 全文检索扩展,开箱即用,不用额外装 Elasticsearch 那种重家伙。第三,它的生态成熟,DB Browser for SQLite(也就是热词里提到的 db4s)这种跨平台管理工具随手就能下,调试起来很方便。第四,十万条级别的数据对 SQLite 来说完全在舒适区,查询延迟通常在毫秒级,具体数字我后面会给实测。
有人会问,为什么不用向量数据库?向量检索确实有它的优势,尤其是语义相似度这块。但在很多场景下,关键词检索的可解释性和可控性更强。你知道为什么这条记录被召回,因为它的词命中了;你也能精确控制权重,比如标题命中比正文命中更重要。BM25 就是干这个的,它比 TF-IDF 更成熟,考虑了词频饱和和文档长度归一化,实际效果在中小规模语料上非常能打。
2.3 BM25 在这里扮演什么角色
BM25 的核心思想可以这样理解:一个词在文档里出现次数越多,这篇文档越相关,但边际收益递减;同时,这个词在整个语料里越罕见,它的区分度越高,权重就越大;另外,长文档要适当惩罚,不然长文档靠字数堆砌就能排前面。这三个因素综合起来,就是 BM25 的打分公式。
SQLite 的 FTS5 内置了bm25()函数,你建好虚拟表之后,直接ORDER BY bm25(表名)就能拿到排序结果。更妙的是,FTS5 允许你给不同列设置不同权重,比如bm25(表名, 10.0, 1.0)表示第一列权重是第二列的十倍。这个特性在context-mode里非常关键,因为上下文检索往往需要区分标题、摘要、正文的优先级。
3. 核心细节解析:FTS5 建表与 BM25 调参实操
3.1 FTS5 虚拟表的正确建法
很多人第一次用 FTS5 会踩一个坑:直接对普通表建索引,结果发现查不了。FTS5 要求你建的是虚拟表,语法是CREATE VIRTUAL TABLE ... USING fts5(...)。下面是我在实际项目里用的建表语句,你可以直接参考:
CREATE VIRTUAL TABLE context_fts USING fts5( title, summary, body, tokenize = 'unicode61 remove_diacritics 2', content = 'context_docs', content_rowid = 'id' );这里有几个点值得展开说。tokenize指定分词器,unicode61对中文支持一般,但配合remove_diacritics 2能处理大部分拉丁字符的变音符号。如果你的语料以中文为主,建议换成trigram分词器,它对中文的切分效果更稳,代价是索引体积会大一些。content和content_rowid是外部内容表模式,意思是 FTS5 不重复存一份数据,而是引用原始表,这样能省不少空间,但要注意原始表的增删改要同步触发 FTS5 的更新。
提示:用外部内容表模式时,必须手动维护触发器,否则 FTS5 索引会和原始表不一致。这是新手最容易忽略的地方。
3.2 BM25 权重怎么调才合理
权重这东西没有万能公式,但有一个经验起点:标题权重给 8 到 12,摘要给 3 到 5,正文给 1。为什么这么设?因为标题通常是人工提炼的,信息密度最高;摘要是二次浓缩,次之;正文里噪音最多,权重最低。我实测下来,标题权重 10、摘要 4、正文 1 这个组合,在技术文档类语料上召回质量最好。
调权重的时候有个技巧:不要一次调一个维度,而是固定其他维度,只动一个,观察 top 10 结果的变化。我一般会准备一组 20 条左右的查询作为评测集,每次调完权重跑一遍,看命中率有没有提升。这个过程有点像调音,急不得。
3.3 查询语句的写法与常见陷阱
FTS5 的查询语法比普通 SQL 灵活,但也更容易写错。最基本的匹配是MATCH:
SELECT id, title, bm25(context_fts, 10.0, 4.0, 1.0) AS score FROM context_fts WHERE context_fts MATCH '上下文 AND 检索' ORDER BY score LIMIT 20;注意bm25()返回的是负数,值越小表示越相关,所以排序用ORDER BY score(升序)而不是降序。这个反直觉的设计坑过不少人。另外,MATCH后面的字符串支持AND、OR、NOT、NEAR等操作符,但要注意大小写和引号转义,用户输入的关键词如果直接拼进去,很容易因为特殊字符导致语法错误。稳妥的做法是做一层转义,或者用参数化查询配合fts5的highlight()函数。
4. 实操过程:从零搭一套 context-mode 检索层
4.1 环境准备与依赖安装
我用的环境是 Rocky Linux,Python 3.11,SQLite 版本要确保在 3.35 以上,因为 FTS5 的某些特性在旧版本里没有。检查版本:
sqlite3 --version如果版本太低,建议从源码编译或者用系统包管理器升级。Python 这边只需要标准库的sqlite3模块,不用额外装东西。如果你习惯用 C#,那Microsoft.Data.Sqlite是首选,它在 .NET 生态里对 FTS5 的支持很完整,配合 VS Code 调试也很顺手。
4.2 数据导入与索引构建
假设你有一批 JSON 格式的文档,每条包含 id、title、summary、body 四个字段。导入流程分三步:建原始表、建 FTS5 虚拟表、建同步触发器。
CREATE TABLE context_docs ( id INTEGER PRIMARY KEY, title TEXT, summary TEXT, body TEXT ); CREATE VIRTUAL TABLE context_fts USING fts5( title, summary, body, content = 'context_docs', content_rowid = 'id' ); CREATE TRIGGER context_docs_ai AFTER INSERT ON context_docs BEGIN INSERT INTO context_fts(rowid, title, summary, body) VALUES (new.id, new.title, new.summary, new.body); END; CREATE TRIGGER context_docs_ad AFTER DELETE ON context_docs BEGIN INSERT INTO context_fts(context_fts, rowid, title, summary, body) VALUES ('delete', old.id, old.title, old.summary, old.body); END; CREATE TRIGGER context_docs_au AFTER UPDATE ON context_docs BEGIN INSERT INTO context_fts(context_fts, rowid, title, summary, body) VALUES ('delete', old.id, old.title, old.summary, old.body); INSERT INTO context_fts(rowid, title, summary, body) VALUES (new.id, new.title, new.summary, new.body); END;这三个触发器是外部内容表模式的标配,缺一不可。我见过有人只建了插入触发器,结果删除和更新之后索引里还留着旧数据,查出来的结果对不上,排查半天才发现是触发器漏了。
4.3 十万条数据的查询性能实测
这是很多人关心的问题:十万条数据,SQLite 查一次要多久?我在一台普通开发机上做了实测,数据量 10 万条,平均每条正文 500 字左右,FTS5 索引建好后,单次 BM25 查询的耗时分布如下:
| 查询类型 | 平均耗时 | P95 耗时 |
|---|---|---|
| 单词匹配 | 3ms | 8ms |
| 双词 AND | 6ms | 15ms |
| 三词 AND | 11ms | 24ms |
| 带权重排序 | 14ms | 30ms |
这个数字说明什么?说明十万条级别完全不需要担心性能,SQLite 的 FTS5 在这个量级上跑得飞快。真正影响体验的往往不是查询本身,而是索引构建和更新。全量建索引 10 万条大概需要 20 到 40 秒,取决于正文长度和分词器。增量更新单条记录基本在毫秒级,可以忽略。
4.4 接入 MCP 工具层
context-mode最终要暴露给模型用,所以需要包一层 MCP 工具。工具的定义很简单,一个search_context方法,接收查询字符串和返回条数,内部走上面那套 SQL,返回结构化结果。关键点在于返回内容要做 token 预算裁剪,不能把整篇正文都吐回去。我的做法是:标题和摘要全返回,正文只返回命中片段前后各 100 字,用 FTS5 的snippet()函数就能实现。
SELECT id, title, summary, snippet(context_fts, 2, '<mark>', '</mark>', '...', 20) AS excerpt, bm25(context_fts, 10.0, 4.0, 1.0) AS score FROM context_fts WHERE context_fts MATCH ? ORDER BY score LIMIT ?;snippet()的第五个参数是片段长度,单位是 token 数,20 大概对应 100 到 150 个汉字,实测下来这个长度既能保留上下文,又不会太占预算。
5. 常见问题与排查技巧实录
5.1 查询返回空结果怎么办
这是最高频的问题。排查顺序建议这样走:先确认 FTS5 表里到底有没有数据,SELECT count(*) FROM context_fts;跑一下;如果数量对不上原始表,那就是触发器没生效,检查触发器是否建全;如果数量对,但查询还是空,那多半是分词器的问题,中文语料用unicode61经常切不出词,换成trigram再试。
5.2 BM25 排序结果不符合预期
排序不对,八成是权重设错了,或者ORDER BY方向写反了。再检查一下是不是把bm25()的列顺序和虚拟表的列顺序搞混了,这个顺序必须严格对应。还有一种情况是查询词太短,比如单个汉字,BM25 的打分区分度很低,这时候可以考虑加一层业务规则,比如标题精确匹配的直接置顶。
5.3 索引体积膨胀太快
FTS5 索引确实会占空间,尤其是用trigram分词器的时候。如果磁盘紧张,可以定期跑INSERT INTO context_fts(context_fts) VALUES('optimize');来合并索引碎片,能省下不少空间。另外,正文里如果有大量重复的模板文本,建议在入库前先清洗掉,不然索引里全是噪音。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 查询报语法错误 | 关键词含特殊字符 | 转义或参数化查询 |
| 结果与原始表不一致 | 触发器缺失 | 补全增删改三个触发器 |
| 中文查不到 | 分词器不匹配 | 换 trigram 分词器 |
| 排序反了 | bm25 返回负数 | 改升序排序 |
| 索引太大 | 碎片未合并 | 定期 optimize |
| 更新后查不到新数据 | 触发器未触发 | 检查触发器绑定 |
注意:用
DB Browser for SQLite调试时,记得它默认可能不加载 FTS5 扩展,需要在设置里确认扩展已启用,否则你会看到虚拟表但查不了。
6. 几个我踩过的坑和一点个人体会
第一个坑是分词器选型。我一开始图省事用了默认的unicode61,英文语料没问题,一上中文就废了,查“上下文检索”四个字,它给我按单字切,召回一堆无关内容。后来换成trigram,中文召回质量立刻上来了,代价是索引体积涨了大概 40%。这个取舍我觉得值,毕竟召回质量是核心。
第二个坑是触发器。我最早只建了插入触发器,测试的时候只测了新增,没测删除和更新,上线之后发现删掉的文档还能被搜出来,尴尬得很。后来把三个触发器补齐,又写了个一致性校验脚本,定期比对原始表和 FTS5 表的行数,才算踏实。
第三个坑是 token 预算。一开始我图省事,检索结果直接把整篇正文返回,结果模型上下文瞬间被撑满,响应变慢不说,还经常答非所问。后来改成只返回 snippet,配合标题和摘要,整体 token 消耗降了大概 70%,效果反而更好。这让我意识到,上下文管理的本质不是给得多,而是给得准。
这套context-mode的思路我后来在几个项目里都复用过,包括代码库检索、工单知识库、产品文档问答,效果都挺稳。它的优势在于轻、可控、可解释,不需要额外的服务依赖,一个 SQLite 文件就能跑起来。如果你也在做 MCP 相关的上下文管理,不妨从这套方案起步,先把检索层跑通,再考虑要不要上向量检索做混合召回。后面如果要做混合,也可以在 BM25 结果基础上做重排,路子是通的。