Appearance
Context Mode 内置一套基于 SQLite FTS5 的知识库搜索系统,用于在会话内索引和检索文档。搜索采用双策略融合:FTS5 全文索引(Porter 词干提取,模糊匹配词形变化)+ Trigram 分词(子串匹配,如 useEff → useEffect),通过 Reciprocal Rank Fusion(RRF)合并两路结果,再经 Proximity Reranking(近邻加分)和 Levenshtein 模糊纠错输出最终排序。本文拆解这套搜索架构的设计细节。
为什么需要内置知识库
AI 编程助手在处理大型项目时,经常需要"查资料"——看 API 文档、翻 README、查配置说明。传统做法是把文档直接读进上下文,但一份 API 文档可能 10-50 KB。
Context Mode 的做法:先把文档索引到 FTS5 知识库(ctx_index),之后用关键词搜索(ctx_search),只返回匹配的精简片段。
# Before: 读整份文档 = 30 KB
Read("docs/api-reference.md")
# After: 搜索关键部分 = 0.8 KB
ctx_search({ queries: ["authentication", "JWT"] })
# 返回 5 条匹配片段,每条 150 字左右搜索架构
用户查询: "useEffect cleanup"
↓
┌────┴────┐
↓ ↓
FTS5 搜索 Trigram 搜索
(Porter (子串匹配)
词干) ↓
↓ 结果集 B
结果集 A ↓
↓ ┌────┴────┐
└────→ RRF 融合 ←──────┘
↓
Proximity Reranking
(近邻加分)
↓
Levenshtein 纠错
(拼写修正)
↓
Smart Snippet
(窗口截取)
↓
最终结果(5 条)FTS5 全文索引
Porter 词干提取
FTS5 的 Porter Stemmer 把英文单词还原到词干形式:
| 原词 | 词干 | 含义 |
|---|---|---|
| running | run | 现在分词 → 基础形 |
| ran | run | 过去式 → 基础形 |
| authentication | auth | 长词 → 词根 |
| hooks | hook | 复数 → 单数 |
这意味着搜索 "run" 能同时匹配 "running"、"ran"、"runs"。对英文技术文档非常实用——同一个概念经常有多种词形。
FTS5 查询语法
sql
-- 基本搜索
SELECT * FROM knowledge WHERE content MATCH 'useEffect cleanup';
-- 短语搜索(连续出现)
SELECT * FROM knowledge WHERE content MATCH '"context window"';
-- 前缀搜索
SELECT * FROM knowledge WHERE content MATCH 'auth*';Trigram 分词
问题:FTS5 匹配不了子串
FTS5 按单词分词——搜索 "useEff" 不会匹配 "useEffect",因为 "useEff" 不是一个完整单词。
解法:Trigram Tokenizer
Trigram 把文本切成连续 3 字符的窗口:
"useEffect" → ["use", "seE", "eEf", "Eff", "ffc", "ect"]搜索 "useEff" → 切成 ["use", "seE", "eEf"] → 三个 trigram 都命中 → 匹配成功。
这对代码搜索特别有用:
| 搜索词 | 匹配目标 | FTS5(按词) | Trigram(按子串) |
|---|---|---|---|
useEff | useEffect | ✗ | ✓ |
ctx_exe | ctx_execute | ✗ | ✓ |
getStat | getState, getStatus | ✗ | ✓ |
Reciprocal Rank Fusion(RRF)
两路搜索(FTS5 + Trigram)各返回一个排序结果。RRF 融合算法把它们合并成一个统一排序:
RRF_score(doc) = Σ 1 / (k + rank_i)其中 k = 60(常数),rank_i 是文档在第 i 路搜索中的排名。
例子:
文档 A: FTS5 排名 #1, Trigram 排名 #3
RRF = 1/(60+1) + 1/(60+3) = 0.0164 + 0.0159 = 0.0323
文档 B: FTS5 排名 #2, Trigram 排名 #1
RRF = 1/(60+2) + 1/(60+1) = 0.0161 + 0.0164 = 0.0325
→ 文档 B 排在文档 A 前面(两路都排名靠前的胜出)为什么用 RRF 而不是简单权重加和?
RRF 只关心排名,不关心分数。这避免了两路搜索分数不可比的问题——FTS5 的 BM25 分数和 Trigram 的匹配数量量纲不同,直接加权不合理。
Proximity Reranking
多词查询时,匹配词在文档中出现的位置越近,排名越高。
搜索 "context window savings":
文档 A: "...context window management..." (相邻)
文档 B: "...context of the conversation...the window..." (分散)文档 A 排在文档 B 前面,因为 "context" 和 "window" 在 A 中紧邻出现。
实现在 RRF 融合之后作为一轮 reranking:检查原始文本中匹配词的间距,间距越小加分越多。
Levenshtein 模糊纠错
用户搜索 "useEfct"(少了一个 f),Levenshtein 距离会计算它与 "useEffect" 的编辑距离 = 1,在阈值内自动纠正为 "useEffect" 再重新搜索。
流程:
1. 原始查询 "useEfct" → FTS5 搜索 → 结果少
2. 检查每个词的 Levenshtein 距离
3. "useEfct" ↔ "useEffect" → 距离 1(≤ 阈值)
4. 替换为 "useEffect" → 重新搜索 → 结果丰富TIP
Levenshtein 纠错只在首次搜索结果不足时触发,不是每次都做——避免性能开销。
Smart Snippet 窗口
搜索结果不是简单截断,而是在匹配词附近取一个"窗口":
原文(1000 字):
"......在处理 context window 时,需要考虑 token 预算。
每次 MCP 工具调用都会增加消耗。context-mode 的沙盒
机制通过只返回 stdout 来解决这个问题......"
Smart Snippet:
"...需要考虑 token 预算。每次 MCP 工具调用都会增加消耗。
context-mode 的沙盒机制通过只返回 stdout 来解决..."
↑ ↑
匹配词前 50 字 匹配词后 50 字窗口大小可配置(默认 150 字),确保上下文完整的同时控制长度。
TTL 缓存
ctx_fetch_and_index 从 URL 抓取内容并索引,带 24 小时 TTL:
Day 1: 首次抓取 https://docs.example.com/api → 索引 + 缓存
Day 1 (6h 后): 再次请求同一 URL → 命中缓存,不重新抓取
Day 2: 缓存过期 → 重新抓取
Day 14: 未访问的缓存条目自动清理渐进式限流
防止模型滥用搜索工具(重复搜索同一关键词消耗 MCP 开销):
第 1-5 次搜索: 正常返回
第 6-10 次搜索: 警告 + 返回结果
第 11+ 次搜索: 拦截,建议改用 ctx_batch_execute 合并查询SQLite 后端自动选择
| 环境 | 后端 | 原因 |
|---|---|---|
| Bun | bun:sqlite | 内置,性能最优 |
| Linux + Node ≥ 22.13 | node:sqlite | 避免 SIGSEGV |
| 其他 | better-sqlite3 | 通用方案 |
FAQ
Q: 知识库的容量上限是多少?
没有硬性上限。SQLite 单文件支持 TB 级数据,FTS5 索引性能在百万条内都表现良好。实际使用中,一次会话索引的文档量通常在几十到几百篇。
Q: 和 RAG(向量检索)相比有什么优势?
FTS5 + BM25 是精确匹配——搜索 "OAuth authentication" 就找包含这两个词的文档。向量检索是语义匹配——搜索 "OAuth authentication" 可能返回 "第三方登录" 的文档。两者各有所长:FTS5 适合精确查技术关键词,向量检索适合语义模糊查询。Context Mode 选择 FTS5 是因为技术文档搜索需要精确性。
Q: 搜索结果是否跨项目?
不跨项目。每个项目有独立的 FTS5 数据库,搜索范围限于当前项目已索引的内容。