ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP上下文管理实战:SQLite+FTS5+BM25实现高效检索

MCP上下文管理实战:SQLite+FTS5+BM25实现高效检索

1. 从 context-mode 说起:一个被低估的 MCP 上下文管理方案

第一次看到context-mode这个词,是在翻 MCP 相关生态项目的时候。当时我正在给一个本地知识库做检索层,试过把整份文档塞进上下文、试过用向量库做语义召回,也试过简单粗暴的关键词匹配,效果都不太理想——要么 token 烧得心疼,要么召回率上不去。直到把context-mode这套思路跑通,才意识到:MCP 场景下的上下文管理,本质上不是一个"塞多少"的问题,而是一个"怎么组织、怎么检索、怎么按需注入"的问题。

context-mode这个标题背后,核心是围绕MCP(Model Context Protocol)构建的一套上下文管理模式,它把SQLite作为本地存储底座,用FTS5做全文索引,再叠加BM25排序算法,最终给上层模型提供"按需取用"的上下文片段。说白了,它解决的是这么一件事:当你的对话历史、项目文档、代码片段、笔记越堆越多,模型不可能每次都全量读取,你需要一个能在毫秒级把"最相关的那几段"捞出来的机制,然后只把这几段喂给模型。

这套东西适合谁?如果你正在做 MCP Server、正在给 IDE 插件接上下文、正在折腾本地 AI 助手,或者单纯想搞明白"为什么我的 RAG 召回总是不准",那这套思路值得你花时间吃透。它不依赖什么高深模型,核心就是 SQLite + FTS5 + BM25 这三样老家伙的组合拳,但组合方式很讲究。下面我按自己的实操路径,把设计思路、关键细节、落地步骤和踩过的坑,一层层拆开讲。

2. 整体设计思路:为什么是 SQLite + FTS5 + BM25

2.1 上下文管理的核心矛盾:全量注入 vs 精准召回

做 MCP 上下文管理,绕不开一个矛盾。模型上下文窗口就那么大,你把所有历史都塞进去,token 成本高不说,还会稀释注意力,导致模型抓不住重点;你只塞一点点,又容易漏掉关键信息,回答质量直接崩。我早期做过一个粗暴实验:把十万条对话记录全量拼进 prompt,结果单次请求 token 直接爆到几十万,响应慢到没法用,而且模型对中间部分的记忆明显衰减——这就是典型的"上下文过载"。

所以正确的思路是分层:原始数据全量落盘,检索层负责快速定位,注入层只把 Top-K 相关片段送进模型。这个分层里,落盘用什么、检索用什么、排序用什么,每一步的选型都决定了最终效果。context-mode的选择是:落盘用 SQLite,检索用 FTS5,排序用 BM25。这三个选择不是随便凑的,背后有很明确的工程考量。

2.2 为什么落盘选 SQLite 而不是向量库

很多人一提 RAG 就上向量库,但我实测下来,在 MCP 这种本地、轻量、单机的场景里,SQLite 的优势非常明显。第一,零依赖,一个.db文件就是全部,不用起服务、不用配集群,MCP Server 打包分发的时候直接带上就行。第二,事务和一致性有保障,写入不会丢。第三,也是最关键的——FTS5 是 SQLite 内置的全文索引模块,你不需要额外引入 Elasticsearch 这种重家伙,就能拿到倒排索引级别的检索能力。

向量库的问题在于:它擅长语义相似,但对精确关键词、代码符号、专有名词的召回反而不稳。比如你搜FTS5这个词,向量库可能给你返回一堆"全文检索"相关的泛化内容,而 FTS5 的精确匹配它未必排第一。MCP 场景里大量是代码、配置、命令这类需要精确命中的内容,所以关键词检索这条线不能丢。我的做法是 SQLite 打底做关键词召回,需要语义的时候再叠加一层,两者互补,而不是二选一。

2.3 FTS5 与 BM25 的分工:索引与排序是两件事

这里有个容易混淆的点,我一开始也没理清:FTS5 负责"能不能找到",BM25 负责"找到的怎么排"。FTS5 是 SQLite 的全文索引引擎,它把文本切词、建倒排表,让你能用MATCH语法快速定位包含某词的记录。但定位到之后,哪条最相关?这就需要排序算法,而 BM25 就是 FTS5 内置的默认排序函数(bm25())。

BM25 的核心思想其实很朴素:一个词在文档里出现次数越多越相关(词频 TF),但这个词在整个库里越常见就越不值钱(逆文档频率 IDF),同时还要对文档长度做归一化,避免长文档因为词多就占便宜。这三个因素加权,就得到了一个比"出现次数排序"靠谱得多的相关性分数。FTS5 把 BM25 做成了内置函数,你ORDER BY bm25(表名)就能直接用,这是它相比自己手写排序最大的便利。

2.4 context-mode 的整体数据流

把上面几块拼起来,context-mode的数据流大致是这样:原始上下文(对话、文档、代码)先做分块,每块连同元数据(来源、时间、类型)写入 SQLite 主表;同时把可检索的文本内容同步进 FTS5 虚拟表;查询时,用户输入先经过查询预处理(分词、去停用词、可能的同义扩展),然后走 FTS5 的MATCH拿到候选集,再用bm25()排序取 Top-K;最后按 token 预算裁剪,注入到模型上下文里。

这个流程里每一步都有讲究,尤其是分块策略和查询预处理,直接决定召回质量。下面我逐个拆。

3. 核心细节解析:分块、索引与查询预处理

3.1 分块策略:块太大召回粗,块太小上下文碎

分块是整套系统的地基,我在这上面栽过跟头。最早我按固定 512 字符切,结果代码块被从中间切断,检索出来的片段语义不完整,模型看了也懵。后来改成按语义边界切——段落、函数、标题层级作为切分点,块大小控制在 200 到 800 字符之间浮动,效果明显好转。

具体做法是:对文档类内容,按 Markdown 标题层级切,一个三级标题下的内容作为一个块;对代码类内容,按函数或类切,保证一个块是一个完整逻辑单元;对对话类内容,按轮次切,一问一答作为一个块。每个块除了正文,还要存几个关键元数据字段:source(来源标识)、chunk_type(文档/代码/对话)、created_at(时间戳)、token_count(预估 token 数)。这些字段在后续过滤和裁剪时非常有用。

提示:块大小不要一刀切。我实测下来,代码块平均 300 到 500 字符、文档块 400 到 800 字符是比较舒服的区间。块太小会导致召回一堆碎片,模型拼不起来;块太大则单块信息密度低,浪费 token。

3.2 FTS5 虚拟表建表:content 表与 external content 模式

FTS5 建表有两种模式,一种是把内容直接存进 FTS5 表(contentless 的反面),另一种是 external content 模式,FTS5 只存索引,正文存在普通表里。我推荐后者,原因是正文存在普通表里方便做各种字段查询和更新,FTS5 表只负责检索,职责清晰。

建表语句大致长这样:

-- 主表,存正文和元数据 CREATE TABLE chunks ( id INTEGER PRIMARY KEY, source TEXT, chunk_type TEXT, content TEXT, token_count INTEGER, created_at INTEGER ); -- FTS5 虚拟表,external content 模式,只索引 content 字段 CREATE VIRTUAL TABLE chunks_fts USING fts5( content, content='chunks', content_rowid='id', tokenize='unicode61' );

这里tokenize='unicode61'是分词器选择。默认的unicode61对英文和大部分语言够用,它按 Unicode 规则切词。如果你的内容以中文为主,unicode61会把整句中文当成一个 token,检索效果很差,这时候要么用trigram分词器(按三字符滑窗切,对中文友好),要么自己在写入前做分词预处理。我处理中英混合内容时,用的是trigram,代价是索引体积会大一些,但召回率提升明显。

3.3 触发器同步:让主表和索引表保持一致

external content 模式下,主表数据变了,FTS5 索引不会自动更新,需要靠触发器同步。这是很多人容易漏的一步,漏了就会出现"明明写进去了却搜不到"的诡异现象。三个触发器分别对应插入、删除、更新:

CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content); END; CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES('delete', old.id, old.content); END; CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES('delete', old.id, old.content); INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content); END;

注意删除和更新触发器里那个'delete'特殊语法,这是 FTS5 external content 模式规定的删除方式,不能直接DELETE FROM chunks_fts。我第一次写的时候想当然用了普通删除,结果索引和主表对不上,排查了半天。

3.4 查询预处理:别把用户输入直接丢给 MATCH

FTS5 的MATCH语法有它自己的规则,用户输入如果直接丢进去,很容易踩坑。比如用户输入里带AND、OR、NOT、*、"这些符号,会被当成 FTS5 的查询语法解析,轻则报错,重则返回莫名其妙的结果。所以查询预处理这一步不能省。

我的预处理流程是:先做特殊字符转义或剥离,再把用户输入按空格拆成词项,每个词项用双引号包起来做短语匹配,词项之间默认用OR连接(提高召回),如果用户明确要精确匹配再切AND。举个例子,用户输入context-mode 怎么用,预处理后变成"context" OR "mode" OR "怎么用"。这样既避免了语法冲突,又保证了召回。

注意:如果你的内容是中文,预处理时还要考虑分词。trigram分词器下不需要额外分词,但如果是unicode61,中文查询词需要先切好再拼 MATCH 语句,否则整句中文匹配不上。

4. 实操落地:从建库到检索的完整流程

4.1 环境准备与依赖选择

这套方案对环境的依赖极低,核心就是 SQLite。Python 的话,标准库sqlite3就够用,但要确认编译时带了 FTS5 支持——绝大多数发行版自带的 SQLite 都带,但偶尔会遇到精简版没有。验证方法很简单,跑一句CREATE VIRTUAL TABLE test USING fts5(x),不报错就说明支持。

如果你用 Python,我建议直接用标准库sqlite3,不要引入 ORM。原因是 FTS5 的MATCH语法和bm25()函数在 ORM 里表达起来很别扭,直接写 SQL 反而清晰。连接的时候记得开几个 PRAGMA 优化:

import sqlite3 conn = sqlite3.connect("context.db") conn.execute("PRAGMA journal_mode=WAL") # 读写并发更好 conn.execute("PRAGMA synchronous=NORMAL") # 性能与安全的平衡 conn.execute("PRAGMA cache_size=-64000") # 64MB 缓存

WAL模式特别重要,MCP Server 往往是边写边读,默认的 rollback journal 模式下读写会互相阻塞,换成 WAL 之后并发体验好很多。synchronous=NORMAL是在 WAL 模式下的推荐值,兼顾性能和安全。

4.2 建库建表与索引初始化

把前面说的主表、FTS5 虚拟表、三个触发器一次性建好,封装成一个初始化函数。这里有个细节:chunks_fts建表时如果指定了content='chunks',那么 FTS5 表本身不存正文,查询时如果要拿正文,需要 JOIN 回主表。查询语句大致是:

SELECT c.id, c.source, c.content, bm25(chunks_fts) AS score FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid WHERE chunks_fts MATCH ? ORDER BY score LIMIT ?;

注意bm25()返回的是负数,值越小(越负)表示越相关,所以ORDER BY score升序排就是最相关的在前。这个反直觉的点我第一次用的时候也愣了一下,特意查了文档才确认。

4.3 数据写入与批量导入的性能优化

十万条数据这个量级,逐条 INSERT 会很慢,因为每条都是一次事务提交。正确做法是用事务批量提交:

def bulk_insert(conn, rows): conn.execute("BEGIN") conn.executemany( "INSERT INTO chunks(source, chunk_type, content, token_count, created_at) " "VALUES (?, ?, ?, ?, ?)", rows ) conn.execute("COMMIT")

我实测过,十万条记录,逐条提交要几分钟,批量事务提交能压到几秒到十几秒,差距是数量级的。如果数据量再大,可以分批,比如每五千条提交一次,避免单个事务过大导致内存压力。

关于"十万条数据 SQLite 查询要多久"这个高频问题,我的实测数据是:建好 FTS5 索引后,单次MATCH查询加 BM25 排序取 Top-10,在普通笔记本上稳定在10 到 50 毫秒之间,具体取决于查询词命中的文档数和索引大小。这个性能对 MCP 场景完全够用,用户基本感知不到延迟。

4.4 检索接口封装与 token 预算裁剪

检索出来 Top-K 之后,不能直接全塞给模型,还要按 token 预算裁剪。我的做法是给每次注入设一个 token 上限(比如 2000),然后按 BM25 分数从高到低累加,累加到接近上限就停。同时给每个块预留一点元信息开销(来源、类型),避免裁剪后模型不知道这段内容从哪来。

def retrieve(conn, query, top_k=10, token_budget=2000): processed = preprocess_query(query) rows = conn.execute( "SELECT c.id, c.source, c.content, c.token_count, bm25(chunks_fts) AS score " "FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid " "WHERE chunks_fts MATCH ? ORDER BY score LIMIT ?", (processed, top_k) ).fetchall() selected, used = [], 0 for row in rows: if used + row[3] > token_budget: break selected.append(row) used += row[3] return selected

这个裁剪逻辑看着简单,但很关键。我见过有人检索完直接全量注入,结果 token 超限被截断,反而把最相关的部分截掉了,得不偿失。

5. 常见问题与排查技巧实录

5.1 搜不到内容:先查触发器再查分词

"明明写进去了却搜不到"是最高频的问题。排查顺序我总结成三步:第一步,确认触发器建了没有,SELECT * FROM sqlite_master WHERE type='trigger'看一眼;第二步,确认分词器选对了没有,中文内容用unicode61基本搜不到,换成trigram试试;第三步,确认查询预处理有没有把关键词转义坏,手动跑一句最简单的MATCH '关键词'验证。

5.2 排序不合理:检查 bm25 权重配置

BM25 默认对所有列一视同仁,但如果你 FTS5 表里索引了多个字段(比如标题和正文分开),可以给不同字段配不同权重。bm25(chunks_fts, 10.0, 1.0)表示第一列权重 10、第二列权重 1。我处理带标题的文档时,会给标题列更高权重,这样标题命中的块排得更靠前,符合直觉。

5.3 性能下降:索引膨胀与 VACUUM

数据频繁增删之后,SQLite 文件会膨胀,查询变慢。定期跑VACUUM能回收空间、整理碎片。另外 FTS5 索引本身也会随数据增长,如果发现查询明显变慢,可以跑INSERT INTO chunks_fts(chunks_fts) VALUES('optimize')来合并索引段,这个操作对检索性能提升挺明显,建议数据量大变动后执行一次。

5.4 常见问题速查表

现象可能原因排查/解决
写入后搜不到触发器缺失或分词器不匹配检查触发器,中文换 trigram
查询报语法错用户输入含 FTS5 特殊符号查询预处理转义/加引号
排序结果反直觉bm25 返回负数,排序方向搞反ORDER BY score 升序
查询变慢索引膨胀、碎片多VACUUM + optimize
中文召回差unicode61 不切中文换 trigram 或预分词
并发读写阻塞未开 WALPRAGMA journal_mode=WAL

5.5 几个我踩过的坑

第一个坑是 external content 模式下删除触发器写错,导致索引残留,搜出来的是已经删掉的旧内容,排查了很久才发现是触发器语法问题。第二个坑是查询预处理没做,用户输入带引号直接把 MATCH 语句搞崩。第三个坑是忘了给token_count字段填值,裁剪时全是 0,导致预算控制失效。这些坑都不难,但没人提醒的话确实要花时间。

6. 这套方案还能怎么扩展

context-mode这套 SQLite + FTS5 + BM25 的组合,本身是个很扎实的底座,但它不是终点。我在实际项目里做过几个扩展,效果不错,分享出来供参考。

第一个扩展是混合检索:FTS5 负责关键词召回,再叠一层轻量向量检索(比如用本地小模型生成 embedding 存进 SQLite 的 BLOB 字段),两路结果用 RRF(Reciprocal Rank Fusion)融合。这样既保住了精确匹配,又补上了语义召回,实测召回率比单路高不少。

第二个扩展是时间衰减:MCP 场景里,越近的上下文往往越相关。可以在 BM25 分数基础上乘一个时间衰减因子,让新内容排得更靠前。实现上就是在 SQL 里把created_at一起查出来,在应用层做加权。

第三个扩展是多租户隔离:如果 MCP Server 要服务多个项目,可以在chunks表加一个tenant_id字段,查询时带上过滤条件。FTS5 的 MATCH 和普通 WHERE 可以共存,性能影响很小。

这套东西我前后迭代了几个月,从最初的全量注入,到后来的 FTS5 单路检索,再到现在的混合方案,每一步都是被实际问题逼出来的。如果你也在做 MCP 相关的上下文管理,建议先把 SQLite + FTS5 + BM25 这条最朴素的路线跑通,把分块、预处理、裁剪这几个环节打磨好,再考虑加向量、加衰减这些进阶玩法。底座稳了,上层怎么加都不慌。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进