最近在做一个本地优先的 MCP 记忆中间件 memory-arbiter,给 ZCode / Cursor / Claude Code 这类 AI 编程工具共享记忆。它的核心是个检索引擎——AI 客户端提问,我用关键词从 SQLite 里捞相关记忆,只把相关的塞进上下文,替代传统的"把整个 MEMORY.md 灌进 prompt"。
做了 7 个版本,检索这块迭代了三轮。最后一版上线时,同一套 15 条黄金查询 + 18 条 pairwise 约束的评测集,排序准确率(pairwise)从 77.8% 干到了 100%。
这篇不讲产品,讲检索工程:每一轮遇到什么问题、根因是什么、怎么修的、A/B 数据怎么变。如果你在做 FTS5 / 向量召回 / 混合检索,应该能从踩坑里捡到点东西。
先剧透一个反主流的点:整个引擎默认不绑任何 embedding 模型,语义检索是可选开关,默认关闭。这是设计哲学,最后会讲为什么。
第一轮:字面检索,以及三个坑(v0.2.x)
底座是 SQLite + FTS5 trigram tokenizer + BM25 排序。选它是因为零依赖、单文件、本地优先。
听起来简单,实际上线 dogfooding 后被自己坑了三次。
坑 1:FTS5 特殊字符静默降级
用户搜 "v0.2.1 发版任务",命中 0 条,回退到 LIKE 全表扫描。根因是 search.py 把原始 query 直传 MATCH ?,含 . / : / * / ( 这些字符会触发 fts5: syntax error,然后静默降级成全表 LIKE——不报错,只是慢,且排序质量塌掉。
修法:加 _sanitize_fts_query(),按空白切 token,每个用双引号包成 FTS5 phrase,AND 连接。phrase 内特殊字符失效。
教训:FTS5 的 MATCH 不是字符串拼接,所有外部输入必须 sanitize。而"静默降级"比报错更危险——你看不见它坏了。
坑 2:中文查询被 phrase 勒死
修完特殊字符后,搜中文又开始 miss。根因很反常识:FTS5 trigram tokenizer 只索引 ≥3 字符的 token。也就是说 "营销"(2 字)作为 phrase 查 trigram 索引,永远 0 命中——索引里压根没有 2 字 token。
设计规格原本写的是"拆 bigram 用 OR",实测才发现 bigram 在 trigram 索引下 0 命中。改成拆 3-gram 不加引号 OR 连接:
营销交付系统 → (营销交 OR 销交付 OR 交付系 OR 付系统)
共享 trigram 命中即可。
教训:tokenizer 的分词粒度决定了你能查什么。trigram 索引下,短查询天然查不到,必须靠 OR 拆分拓宽召回。
坑 3:superseded 记忆污染搜索
修完中文,搜 "营销交付运营",排前面的居然是一条我已经标记为废弃(superseded)的旧设计规格。根因:三处 SQL 都只过滤 status != 'deleted',superseded 漏网了。全库 37 条里 6 条 superseded 全是项目自己的发版流水账,查版本号噪音率 46%。
修法:默认加 include_superseded=False 过滤掉;即便审计模式显式包含,ORDER BY 里也插一档 CASE status WHEN 'superseded' THEN 1 ELSE 0 END 软降权,保证废弃记录永远压不过 active 记录。
教训:BM25 只看词频,不懂"这条已经过时了"。状态信号必须显式进排序,不能只靠 WHERE 过滤——审计场景你还需要看见废弃记录,但又不能让它淹掉现状。
第二轮:宽召回 + 软重排(v0.3.0)
三坑修完,跑了个 15 条黄金查询的小样本 baseline:Top-1 26.7%,Top-3 33.3%。惨。
分析后发现主要问题不是召回,是排序。长正文记忆(比如发版流水账反复提版本号)BM25 分数虚高,压过短而精准的业务记忆;多词中文查询经常 FTS 直接 miss,掉进 recent 回退,返回一堆最近的高信任记录,跟查询无关。
于是重构成两阶段架构:
query → _wide_recall(多路召回合并候选池)
→ _soft_rerank(多信号加权打分)
→ 结果
宽召回:FTS 主路(phrase AND)+ FTS-OR(token 宽松匹配)+ LIKE-sub(subject 命中)+ LIKE-con(content 兜底)。四路并集进候选池,宁可多召回,不漏。
软重排:候选池里每条按多信号加权打分:
- subject 精确命中:cap = 10(最强信号)
- tags 命中:cap = 7
- content 命中:cap = 3
- 信任度(
user_confirmed>agent_generated)、时间新旧、状态作为小幅加分
关键设计:各信号有 cap。content 命中再多次,分数也封顶在 3,不会因为正文长就压过 subject 命中。这是治理 BM25 词频偏差的核心。
这一版统一口径重跑 A/B,Top-3 从 60% 提到 66.7%,pairwise 从 77.8% 提到 88.9%。
第三轮:可选语义检索(v0.3.0 → v0.3.1)
宽召回 + 软重排之后,剩下的失败 case 集中在同义词和口语化查询——字面零重合,FTS 召不回来。典型的就是该上向量了。
但这里我做了个反主流的决定:不绑 embedding 模型。
主流 RAG 框架基本都内置 sentence-transformers 或者直接调 OpenAI embedding。我不绑,理由三点:
- 项目定位是"零依赖、轻量、本地优先",加 sentence-transformers 拖一堆 torch 依赖,违背初衷。
- 模型迭代快,绑死一个等于绑死用户。
- 大部分用户的查询是术语 / 明确关键词,trigram 已经够用,语义是少数场景的增强。
所以我只提供向量存储和 KNN 查询能力(sqlite-vec 的 vec0 虚表),向量由用户自己生成灌入。新工具 memory_store_embedding(memory_id, embedding) 是灌向量的入口,用什么模型用户自己定——GGUF、sentence-transformers、OpenAI API 都行,引擎只认 768 维 float 数组。
关键工程问题:语义候选怎么进排序?
这是整个 v0.3.1 最有意思的设计。语义召回的候选,BM25 分数是 0(字面零重合),如果直接进软重排,会被 content 命中(cap=3)压死。
加一个保底分 _VEC_FLOOR_SCORE = 2.5:语义候选的 relevance 如果低于 2.5,抬到 2.5。
为什么是 2.5?这是精心调的:
- 略低于 content cap(3.0):真正的 content 命中仍能压过纯语义候选。
- 高于正文噪音:字面零命中但语义相关的记忆,能被救进 Top-3。
- 远低于 subject/tags cap(7/10):语义候选永远盖不过标题/标签精确命中。
效果:语义只在"字面完全捞不到"的场景起作用,不会乱抢已经能命中的结果。
默认关闭
ENABLE_SQLITE_VEC 默认 false。没装 sqlite-vec 的用户不会被噪音警告骚扰,行为跟上一版完全一致。需要语义的用户显式开启 + 自己灌向量。
开启语义很容易,难的是保证"关闭时行为完全不变"和"开启时只在需要时介入"。
A/B 数据(统一口径)
同一套 15 条黄金查询 + 18 条 pairwise 约束,三版本对比:
| 指标 | bm25 (v0.2.6) | hybrid (v0.3.0) | hybrid + 语义 (v0.3.1) |
|---|---|---|---|
| Top-1 | 46.7% | 53.3% | 53.3% |
| Top-3 | 60.0% | 66.7% | 73.3% |
| Pairwise | 77.8% | 88.9% | 100.0% |
怎么读这组数据:
- Top-1 没变:语义检索不改变冠军,该排第一的字面就能排第一。
- Top-3 +6.6pp:多救回几条进前三,这些就是字面零重合、靠语义捞回来的。
- Pairwise 到 100%:语义检索最硬的贡献是排序质量——该在前的都在前,18 条约束全部满足。
性能代价:纯字面 2.0ms → 加语义 16.7ms(其中 embedding 生成 7.6ms + 检索 9.1ms)。灌向量是一次性的(41 条 / 13 秒)。sqlite-vec 的 KNN 在 M 系列 Mac 上根本不是瓶颈。
反思
1. dogfooding 是最有效的测试。 v0.2.2 的 FTS5 bug、v0.2.6 的 superseded 污染,全是用 memory-arbiter 检索自己发版任务时暴露的。工具修自己的 bug,这个闭环比任何测试集都管用。
2. 不绑模型是对的。 v0.3.1 灌向量时我复用了已有的 embeddinggemma-300m GGUF,没装任何新依赖。如果当初绑了 sentence-transformers,这次换模型就是破坏性变更。把"用什么模型"留给用户,引擎只管存储和查询,工程上干净得多。
3. 默认关闭比默认开启更难设计。
那个 _VEC_FLOOR_SCORE = 2.5 就是为了让语义候选不越界——增强但不捣乱。
结尾
项目地址:github.com/billy12151/…(MIT,pip install memory-arbiter-mcp 可装)。
如果你也在做检索引擎,不管是不是 MCP 场景,这几招应该都用得上:
- FTS5 trigram 不索引 2 字 token,中文短查询靠 OR 拆分救
- 状态信号(superseded/deleted)必须进 ORDER BY,不能只靠 WHERE
- 多信号软重排,每个信号设 cap,治理 BM25 词频偏差
- 向量候选给保底分,但保底分要低于 content cap,防止越界
- 默认关闭语义检索,保证"不开启时行为零变化"
检索工程是个细活,没有银弹,只有一层层治理。共勉。