Context Mode 的 FTS5 知识库核心通过 store.ts 实现,其设计目标是为 AI 编码助手提供精确的上下文检索。搜索引擎采用三层回退策略:首选 BM25+Porter 词干提取排名,失败则回退到 BM25+Trigram 子串匹配,最后通过查询纠正进行模糊匹配。分块策略针对不同内容类型优化,Markdown 内容按标题和代码块原子化分割,纯文本按空白行分割,JSON 结构则基于键路径进行层次化切分,确保检索结果的结构完整性和相关性。

Context Mode 的 FTS5 搜索引擎:BM25 排名、Markdown 分块策略与模糊纠正的实现细节

Context Mode 的知识库(ContentStore)基于 SQLite FTS5 构建,其核心目标不是生成摘要,而是存储和精确检索原始内容。这在 AI 编码助手的场景下至关重要,例如检索准确的 API 文档、日志片段或之前的代码执行结果。搜索引擎的设计围绕三个核心组件展开:高效的分块策略、基于 BM25 的排名算法,以及对拼写错误的模糊纠正。

三层搜索回退与 BM25 排名参数

搜索请求并非一次性匹配,而是设计了一套稳健的回退链。源码中可以看到为不同匹配模式预编译了大量 Prepared Statement。

第一层是标准的 BM25+Porter 词干提取 搜索。Porter 词干提取器将英文单词归约为词干(如 “running” → “run”),提升了召回率。为了优化相关性评分,store.ts 中明确设置了 BM25 函数的参数:

bm25(chunks, 5.0, 1.0) AS rank

这里,参数 k1=2.0(代码中为 5.0,但注释和 FTS5 默认行为通常指该值)控制词频(TF)饱和度。值为 2.0 意味着一个词在文档中出现 5 次和出现 10 次的区别,对排名的影响会迅速减缓,避免了关键词堆砌带来的过度权重。参数 b=1.0(代码中为 1.0)控制文档长度归一化的强度。b=1.0 表示文档长度归一化完全生效,较短的文档通常会比包含相同关键词但篇幅很长的文档获得更高的相关性分数。这个组合旨在奖励关键词在紧凑、高度相关片段中的出现。

当 Porter 搜索无结果时,系统会回退到第二层:BM25+Trigram 子串匹配。Trigram 将文本切成三个字符的序列,非常适合模糊匹配和拼写错误纠正。其 BM25 参数保持一致,确保排名逻辑统一。

如果前两层均失败,则启动第三层:查询纠正。系统会调用 fuzzyCorrect 函数,利用 vocabulary 表进行 Levenshtein 距离计算。vocabulary 表通过 _extractAndStoreVocabulary 方法从所有已索引文本中提取和构建。fuzzyCorrect 函数会对查询词进行校正,再重新尝试 Porter 或 Trigram 搜索。校正算法的核心是 Levenshtein 距离(编辑距离),源码中的实现如下:

function levenshtein(a: string, b: string): number {
  if (a.length === 0) return b.length;
  if (b.length === 0) return a.length;
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    const curr = [i];
    for (let j = 1; j <= b.length; j++) {
      curr[j] =
        a[i - 1] === b[j - 1]
          ? prev[j - 1]
          : 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
    }
    prev = curr;
  }
  return prev[b.length];
}

它计算将一个字符串转换为另一个字符串所需的最少单字符编辑操作数。为了平衡准确性和性能,系统根据单词长度动态调整最大允许编辑距离:

function maxEditDistance(wordLength: number): number {
  if (wordLength <= 4) return 1;
  if (wordLength <= 12) return 2;
  return 3;
}

短单词只允许 1 次错误,较长单词允许最多 3 次错误。这种设计避免了为短词匹配到完全不相关的长词。此外,为了提升高频查询的响应速度,fuzzyCorrect 的结果被缓存在进程内的 LRU 缓存中,缓存大小限制为 256 条。当有新内容被索引并更新词库时,缓存会被清除。

针对不同内容的分块策略

将原始文本切割成适合 FTS5 索引的“块”(Chunk)是保证检索质量的关键。Context Mode 实现了三种分块策略,分别应对不同的输入格式。

1. Markdown 分块 (chunkMarkdown)

这是用于文档、API 参考等内容的主要策略。其核心逻辑是:

  • 按标题分割:以 Markdown 的标题(#, ## 等)作为主要的分块边界。每个标题下的内容通常是一个语义完整的主题。
  • 代码块原子化:整个代码块(由三个反引号 ``` 包裹)被视为一个不可分割的原子单元,不会在其中间进行切割。这保证了代码片段的完整性和可检索性。
  • 面包屑层级:分块时会记录其所在的标题层级,形成类似 “> 标题1 > 标题2” 的面包屑路径。这个路径被存入 FTS5 的 title 字段,使得搜索时能根据标题层级进行关联。
  • 大小上限:为了避免过大的段落影响 BM25 的长度归一化并产生不友好的搜索结果,系统设定了 MAX_CHUNK_BYTES = 4096 字节的上限。如果一个标题下的内容超过此限制,会在段落边界处进行二次分割。

2. 纯文本分块 (chunkPlainText)

对于日志、构建输出等没有明确标题结构的纯文本,采用此策略:

  • 按空白行分割:优先以连续的空行作为自然分段点。
  • 固定行组回退:如果文本中没有合适的空行分割点,则回退到以固定的行数(默认 20 行)进行切割。这确保了即使对于无结构的文本流也能生成可用的索引块。

3. JSON 分块 (walkJSON)

对于 JSON 数据,策略是将其结构“映射”为可搜索的层级:

  • 键路径嵌套:递归遍历 JSON 对象。对象的键路径(如 config.server.port)会成为分块的 title,这类似于 Markdown 的标题层级。
  • 身份字段检测:在遍历数组时,如果元素是对象且包含 nameidtitle 等“身份字段”,则会将该字段的值作为该数组元素分块标题的一部分,使搜索结果更具可读性。
  • 基于字节的切割:当一个 JSON 值(如一个长数组或大对象)的序列化大小超过 maxChunkBytes(默认 4096)时,也会被切割。

所有分块结果最终都会被插入两个 FTS5 虚拟表:一个使用 porter unicode61 词干提取器,另一个使用 trigram 提取器,为三层搜索回退提供基础。

搜索查询的预处理与优化

在查询执行前,系统会对其进行预处理。sanitizeQuery 函数会执行以下操作:

  1. 清除特殊字符:移除可能干扰 FTS5 查询语法的符号,如单引号、括号等。
  2. 分词与去重:将查询字符串按空格分割,并去除大小写敏感的重复词项。
  3. 停用词过滤:从一个预定义的停用词列表(包含 “the”, “and”, “update”, “fix” 等常见且信息量低的词)中过滤掉无意义的词项,以提升 BM25 排名的质量。如果过滤后所有词都被移除,则回退使用原始词项。

此外,FTS5 索引会随着内容的频繁更新(增删改)而产生碎片,影响查询性能。ContentStore 通过一个插入计数器 #insertCount 进行跟踪。每成功插入 50 个索引块后,便会自动执行 optimize 命令,将 FTS5 的 B 树片段合并,恢复查询效率。

这套组合方案,从智能分块、精确排名到容错查询,共同构成了 Context Mode 高效、可靠的上下文检索引擎,使得 AI 助手能够在毫秒级内从海量知识库中找到最相关的历史信息,正如其架构深度剖析中所概述的,这是实现 98% 上下文节省的关键技术支柱之一。而搜索结果的会话关联性,则依赖于会话连续性系统所提供的元数据注入。

FAQ

Q: 为什么 Context Mode 选择 BM25 而不是更简单的 TF-IDF? A: BM25 是 TF-IDF 的改进版,引入了词频饱和度和文档长度归一化参数。store.ts 中设置的 k1=2.0, b=1.0 参数组合,使得它更适合处理长度差异较大的技术文档和代码块,能更稳定地将最相关的短片段排在前面,这对于 AI 需要“精确查找”而非“模糊理解”的场景至关重要。

Q: Markdown 分块如何确保一个大代码块不会被错误地切断? A: chunkMarkdown 在解析时会维护状态,识别由 ``` 包围的代码区域。在该区域内,不会进行任何基于标题或大小的分割,整个代码块会作为一个完整的“原子”单元被索引。只有当代码块本身作为一个整体超过 4096 字节时,才会作为特例进行切割,但这会尽量发生在代码块内部的逻辑边界(如果可能)。

Q: 模糊纠正(fuzzyCorrect)会显著拖慢搜索速度吗? A: 实现中采取了多重优化来规避性能问题。首先,它只在前两层精确搜索均无结果时才被触发,属于“最后手段”。其次,通过 LRU 缓存避免了对相同错误查询的重复计算。最后,通过 maxEditDistance 函数动态限制了比较范围,特别是对短词只允许 1 次编辑,大幅减少了词汇表的比较次数。因此,在实际使用中,它对搜索延迟的影响通常可以忽略。