第 28 章

向量检索实现:SQLite + BM25 混合检索、0.7/0.3权重融合与 Embedding 降级链

第28章 向量检索实现:SQLite + BM25 混合检索、0.7/0.3 权重融合与 Embedding 降级链

"最好的向量数据库,是你不需要单独运维的那一个。" —— OpenClaw 架构决策记录


28.1 为什么选择 SQLite 而非独立向量数据库?

在向量检索领域,Pinecone、Weaviate、Qdrant、Milvus 等专用向量数据库如雨后春笋般涌现。OpenClaw 在架构选型时,明确选择了 SQLite + sqlite-vec 扩展,而非任何一个独立的向量数据库。

28.1.1 独立向量数据库的代价

代价 具体表现
运维复杂度 需要单独部署、配置、监控、备份向量数据库服务
网络依赖 每次检索需要网络请求,引入延迟和不可用风险
数据分离 文本数据在 Markdown 文件中,向量数据在远端服务,一致性难保证
成本 云托管向量数据库按索引大小和请求次数计费
认知负担 开发者需要学习专用 API、理解各数据库特有的一致性模型

28.1.2 SQLite 的优势

OpenClaw 的向量检索栈:
┌─────────────────────────────────┐
│  SQLite(单文件数据库)          │
│  + sqlite-vec(向量扩展)        │
│  + FTS5(全文检索扩展)          │
└─────────────────────────────────┘
文件路径:~/.openclaw/memory/<agentId>.sqlite

选择 SQLite 的核心理由:

  1. 零运维:SQLite 是进程内数据库,无需服务器,随应用启动
  2. 文件可移植:整个向量索引是一个单文件,可以复制、备份、迁移
  3. 派生可重建:如前所述,SQLite 是从 Markdown 文件派生的,数据库文件损坏不丢失信息
  4. SQL 原生:可以用标准 SQL 查询、联合、过滤向量结果,无需学习专用查询语言
  5. 足够快:对于 Agent 个人记忆规模(通常数万至数百万条记录),SQLite 的性能完全满足需求

28.2 FTS5 虚表:关键词检索原理

28.2.1 FTS5 是什么

FTS5(Full-Text Search 5)是 SQLite 的内置全文检索扩展,通过**虚表(Virtual Table)**机制实现。虚表的外观和行为与普通表相同,但数据存储和检索逻辑完全自定义。

-- 创建 FTS5 虚表
CREATE VIRTUAL TABLE memory_fts USING fts5(
    content,          -- 要检索的文本内容
    source_file,      -- 来源文件路径
    chunk_id,         -- 对应 memory_embeddings 中的 ID
    tokenize = 'porter unicode61'  -- 分词器
);

28.2.2 BM25 排序算法

FTS5 默认使用 BM25(Best Matching 25) 算法对检索结果排序。BM25 是一种基于词频(TF)和逆文档频率(IDF)的概率检索模型:

$$ \text{BM25}(q, d) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, d) \cdot (k_1 + 1)}{f(q_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)} $$

其中:

28.2.3 FTS5 检索示例

-- 基础关键词检索
SELECT content, chunk_id, rank
FROM memory_fts
WHERE content MATCH 'JWT 验证 RS256'
ORDER BY rank
LIMIT 24;

-- 短语检索
SELECT content, chunk_id, rank
FROM memory_fts
WHERE content MATCH '"JWT 验证"'  -- 精确短语
ORDER BY rank;

-- 布尔检索
SELECT content, chunk_id, rank
FROM memory_fts
WHERE content MATCH 'auth AND (token OR session)'
ORDER BY rank;

28.3 向量存储格式与余弦相似度计算

28.3.1 向量存储表结构

CREATE TABLE memory_embeddings (
    id INTEGER PRIMARY KEY,
    content TEXT NOT NULL,        -- 原始文本块
    embedding FLOAT[768],         -- 向量(维度取决于 Embedding 模型)
    source_file TEXT,             -- 来源文件(如 memory/2026-04-26.md)
    chunk_index INTEGER,          -- 在源文件中的块序号
    created_at INTEGER,           -- Unix 时间戳
    model_name TEXT               -- 生成向量的模型名称
);

FLOAT[768] 是 sqlite-vec 扩展引入的特殊列类型,支持高效的向量运算。

28.3.2 余弦相似度计算

向量相似度使用余弦相似度,完全在 SQL 内完成计算:

$$ \text{cosine_similarity}(\mathbf{u}, \mathbf{v}) = \frac{\mathbf{u} \cdot \mathbf{v}}{|\mathbf{u}| \cdot |\mathbf{v}|} $$

sqlite-vec 将此计算封装为 SQL 函数:

-- 向量相似度检索(查询向量为 :query_embedding)
SELECT 
    id,
    content,
    source_file,
    vec_cosine_similarity(embedding, :query_embedding) AS vector_score
FROM memory_embeddings
ORDER BY vector_score DESC
LIMIT 24;

为什么选余弦相似度而非欧氏距离?

余弦相似度衡量的是方向相似性,而非绝对距离。对于语义嵌入向量而言,两段文本的语义相似度主要体现在向量方向上,与向量模长(可能因文本长度而异)无关。


28.4 Union 策略 vs Intersection 策略

28.4.1 两种策略的定义

在混合检索中,BM25 和向量检索各自返回一批候选结果。如何合并这两个结果集有两种策略:

Intersection(交集)策略

BM25 Top-K ∩ Vector Top-K → 只保留同时出现在两个结果集中的文档

Union(并集)策略

BM25 Top-K ∪ Vector Top-K → 合并两个结果集,统一排序

28.4.2 OpenClaw 选择 Union 策略的原因

OpenClaw 明确采用 Union 策略,理由如下:

考量 Intersection Union(OpenClaw 的选择)
召回率 低(只保留两者都找到的) 高(两个后端都有机会贡献结果)
互补性 无法利用各后端的互补优势 BM25 擅长精确词汇,Vector 擅长语义,互补
降级能力 任一后端失败则无结果 任一后端失败仍有另一后端的结果
适用场景 高精度需求(可接受低召回) 通用记忆检索(需要高召回)

对于 Agent 记忆检索场景,高召回率通常比高精度更重要——宁可多检索几条不完全相关的片段,也不要遗漏真正重要的信息。


28.5 candidateMultiplier:4 过度获取策略

28.5.1 为什么要过度获取候选?

在混合检索中,每个后端(BM25 和 Vector)各取 k × candidateMultiplier 条候选,而非直接取最终 k 条。默认 candidateMultiplier = 4

如果最终目标是返回 6 条结果,每个后端先取 6 × 4 = 24 条候选。

28.5.2 过度获取的必要性

BM25 Top-24:   [A, B, C, D, E, F, G, H, ...]
Vector Top-24: [A, C, E, X, Y, Z, ...]

Union 后去重: [A, B, C, D, E, F, G, H, X, Y, Z, ...]

按加权分数重排后 Top-6: [A, E, C, X, B, G]

如果不过度获取(每个后端只取 6 条):

BM25 Top-6:   [A, B, C, D, E, F]
Vector Top-6: [A, C, E, X, Y, Z]

Union: [A, B, C, D, E, F, X, Y, Z](最多 12 条)

重排后 Top-6: [A, E, C, X, B, D]

过度获取确保了在重排时有足够的候选多样性。特别是当 BM25 和 Vector 的结果集重合度很高时,不过度获取可能导致最终候选池过小。

28.5.3 参数配置

vectorSearch:
  finalK: 6                    # 最终返回结果数
  candidateMultiplier: 4       # 每个后端取 finalK × 4 = 24 条候选
  # 实际每个后端各取 24 条,Union 后最多 48 条,重排后取 Top-6

28.6 权重融合:0.7 向量 + 0.3 BM25

28.6.1 融合公式

$$ \text{finalScore} = 0.7 \times vectorScore + 0.3 \times bm25Score $$

28.6.2 设计理由

选择 0.7:0.3 的权重比例,基于以下工程判断:

考量 说明
语义理解优先 Agent 的查询往往是自然语言,语义匹配比精确词汇匹配更重要
BM25 作为锚点 BM25 善于匹配特定术语(如函数名、代码变量),权重 0.3 确保不被完全忽略
抗噪能力 向量相似度有时对语义相近但无关的内容过度打分,BM25 的词汇约束有助于校正
实验结果 根据 OpenClaw 开发团队在内部测试集上的消融实验,0.7/0.3 的检索质量指标最优

28.6.3 在代码中的实现

function hybridScore(vectorScore: number, bm25Score: number): number {
    const VECTOR_WEIGHT = 0.7;
    const BM25_WEIGHT = 0.3;
    return VECTOR_WEIGHT * vectorScore + BM25_WEIGHT * bm25Score;
}

// 对所有候选进行重排
const reranked = candidates
    .map(chunk => ({
        ...chunk,
        finalScore: hybridScore(chunk.vectorScore, chunk.normalizedBm25Score)
    }))
    .sort((a, b) => b.finalScore - a.finalScore)
    .slice(0, finalK);

28.7 BM25 得分规范化公式

28.7.1 为什么需要规范化?

BM25 返回的原始分数(在 SQLite FTS5 中,rank 列实际上是负数,绝对值越小表示相关性越高)与向量相似度分数(0 到 1 之间的浮点数)处于完全不同的数量级,无法直接加权融合。

需要将 BM25 分数规范化到 [0, 1] 区间。

28.7.2 规范化公式

OpenClaw 使用基于排名的规范化(Rank-based Normalization):

$$ \text{bm25_score}(r) = \frac{1}{1 + r} $$

其中 $r$ 是 BM25 检索结果中的排名(rank),从 0 开始:

排名 $r$ 规范化 BM25 分数
0(第 1 名) $\frac{1}{1+0} = 1.0$
1(第 2 名) $\frac{1}{1+1} = 0.5$
2(第 3 名) $\frac{1}{1+2} = 0.333$
3(第 4 名) $\frac{1}{1+3} = 0.25$
7(第 8 名) $\frac{1}{1+7} = 0.125$
23(第 24 名) $\frac{1}{1+23} = 0.042$

这个公式确保:

28.7.3 实现代码

function normalizeBm25Score(rank: number): number {
    // rank 从 0 开始,rank=0 表示 BM25 排名第一
    return 1 / (1 + rank);
}

// 对 BM25 结果按排名分配规范化分数
const bm25Results = await bm25Search(query, limit: 24);
const normalizedBm25 = bm25Results.map((result, index) => ({
    ...result,
    normalizedBm25Score: normalizeBm25Score(index)  // index 即为 rank
}));

28.8 Graceful Degradation:任意后端失败不影响另一个

28.8.1 设计原则

混合检索的每个后端(BM25 和 Vector)都独立地以 .catch(() => []) 包裹,确保任一后端抛出异常,另一个后端的结果仍然可用:

const [vectorResults, bm25Results] = await Promise.all([
    searchByVector(queryEmbedding, limit: 24)
        .catch((err) => {
            logger.warn("Vector search failed, falling back to BM25 only", err);
            return [];  // 返回空数组,不影响 BM25 结果
        }),
    searchByBm25(query, limit: 24)
        .catch((err) => {
            logger.warn("BM25 search failed, falling back to vector only", err);
            return [];  // 返回空数组,不影响 Vector 结果
        })
]);

// Union 合并
const combined = unionByChunkId(vectorResults, bm25Results);

28.8.2 降级场景

场景 结果
两个后端正常 完整混合检索结果
Vector 后端失败(如 Embedding 不可用) 仅 BM25 结果(纯关键词检索)
BM25 后端失败(FTS5 索引损坏) 仅 Vector 结果(纯语义检索)
两个后端都失败 空结果集(不抛出异常,任务继续)

28.8.3 为什么用 Union 而非 Promise.all 直接失败

如果不使用 .catch(() => []) 的隔离机制:

// 危险写法:任一后端失败会导致整个检索失败
const [vectorResults, bm25Results] = await Promise.all([
    searchByVector(queryEmbedding),  // 如果失败,整个 Promise.all 都失败
    searchByBm25(query)
]);

有了 Graceful Degradation,即使向量数据库出现问题(例如 Embedding 服务不可用),记忆检索依然能够工作,Agent 不会因为检索系统部分故障而完全失去记忆访问能力。


28.9 Embedding 四级降级链

28.9.1 降级链全图

查询文本
    │
    ▼
[1] 本地 GGUF 模型(embeddinggemma-300M-Q8_0.gguf)
    │ 失败/不可用
    ▼
[2] OpenAI text-embedding-3-small(1536 维)
    │ 失败/不可用
    ▼
[3] Gemini gemini-embedding-001(768 维)
    │ 失败/不可用
    ▼
[4] 纯 BM25(退出向量检索,仅关键词)

28.9.2 第一级:本地 GGUF 模型

embedding:
  primary:
    type: local_gguf
    model_path: ~/.openclaw/models/embeddinggemma-300M-Q8_0.gguf
    dimensions: 768
    file_size: ~600MB

优势

劣势

28.9.3 第二级:OpenAI text-embedding-3-small

embedding:
  fallback_1:
    type: openai
    model: text-embedding-3-small
    dimensions: 1536
    api_key: ${OPENAI_API_KEY}

1536 维的高维向量提供了优秀的语义区分度。成本较低(约 $0.02/1M tokens),适合作为第一个云端降级选项。

28.9.4 第三级:Gemini gemini-embedding-001

embedding:
  fallback_2:
    type: gemini
    model: gemini-embedding-001
    dimensions: 768
    api_key: ${GEMINI_API_KEY}

作为第二个云端选项,维度为 768,与本地 GGUF 模型相同。适合在 OpenAI 服务不可用时使用。

28.9.5 第四级:纯 BM25

当所有 Embedding 方案均失败时,系统退出向量检索,回退到纯 BM25 关键词检索:

async function getEmbedding(text: string): Promise<Float32Array | null> {
    try { return await localGgufEmbed(text); } catch {}
    try { return await openaiEmbed(text); } catch {}
    try { return await geminiEmbed(text); } catch {}
    return null;  // 返回 null,触发纯 BM25 模式
}

// 在检索流程中
const embedding = await getEmbedding(query);
if (embedding === null) {
    // 降级到纯 BM25
    return await searchByBm25(query, limit: finalK);
}
// 正常混合检索流程
return await hybridSearch(query, embedding);

28.9.6 维度不兼容问题

不同模型生成的向量维度不同(768 vs 1536),因此不能混用:

-- 索引时记录使用的模型
UPDATE memory_embeddings 
SET model_name = 'text-embedding-3-small'
WHERE id = :id;

当降级到不同维度的模型时,需要重建向量索引(对文本数据重新 Embedding)。OpenClaw 在检测到模型变更时,会自动触发后台重建任务:

if (currentModel !== storedModel) {
    logger.info("Embedding model changed, scheduling background re-embedding");
    scheduleBackgroundReembedding();  // 异步重建,不阻塞当前检索
}

28.10 Chunking:文本分块的工程权衡

28.10.1 为什么需要分块?

Embedding 模型通常有输入 Token 上限(如 512 或 8192 tokens)。更重要的是,过长的文本块会导致:

  1. 语义稀释:一个 Embedding 向量需要表达过多内容,语义精度下降
  2. 检索粒度粗:返回大块文本,其中大部分与查询无关
  3. Token 浪费:注入 Context 时占用过多空间

28.10.2 OpenClaw 的 Chunking 参数

参数 默认值 说明
目标大小 400 tokens(~1600 字符) 每个块的目标 Token 数
Overlap 80 tokens 相邻块重叠的 Token 数
批处理上限 8000 tokens/批 单次 Embedding API 请求的最大 Token 量
并发数 4 并行 Embedding 请求数

28.10.3 400 tokens 目标大小的权衡

太小(< 100 tokens):
✓ 检索精度高
✗ 上下文不足,难以理解孤立片段

太大(> 1000 tokens):
✓ 上下文完整
✗ 语义稀释,检索精度低;注入时占用大量 Context

400 tokens(~1600 字符):
✓ 约等于一个完整的段落或函数定义
✓ 语义足够集中
✓ 注入 Context 时合理

28.10.4 80 tokens Overlap 的作用

Overlap 确保跨越块边界的信息不会丢失:

原始文本:[...段落A末尾...][...段落B开头...]
                                 ↑ 块1结束
                        ↑ 块2开始(80 tokens 的重叠)

块1:[段落A大部分 + 段落B前80 tokens]
块2:[段落B前80 tokens + 段落B大部分]
                   ↑ 重叠部分

如果没有 Overlap,恰好横跨两块的一段重要讨论可能在检索时被"切断",两块都只包含一半,都不会得到高相关性分数。

28.10.5 批处理与并发的工程细节

const BATCH_MAX_TOKENS = 8000;
const CONCURRENT_REQUESTS = 4;

// 将 chunks 按 Token 数分批
const batches = groupIntoBatches(chunks, BATCH_MAX_TOKENS);

// 并发处理,但限制并发数为 4(避免 API 限流)
const results = await pLimit(CONCURRENT_REQUESTS)(
    batches.map(batch => () => embedBatch(batch))
);

限制并发数为 4 是对 API 速率限制(Rate Limit)的保护:大多数 Embedding API 对并发请求有限制,超过限制会导致 429 错误。


28.11 混合检索完整流程

用户查询 → 生成查询 Embedding(四级降级链)
                │
        ┌───────┴───────┐
        ▼               ▼
BM25 检索(FTS5)  向量检索(sqlite-vec)
各取 24 条候选      各取 24 条候选
    .catch(()=>[])      .catch(()=>[])
        │               │
        └───────┬───────┘
                ▼
        Union 合并去重(按 chunk_id)
                │
                ▼
        BM25 得分规范化:bm25Score = 1/(1+rank)
                │
                ▼
        加权融合:finalScore = 0.7×vectorScore + 0.3×bm25Score
                │
                ▼
        按 finalScore 降序排列
                │
                ▼
        取 Top-6 结果
                │
                ▼
        注入 Context Window

28.12 本章小结

OpenClaw 的向量检索实现,是一个以实用主义为核心的工程设计:

$$ \text{finalScore} = 0.7 \times vectorScore + 0.3 \times \frac{1}{1 + rank_{bm25}} $$


下一章:第29章 — Workspace 文件体系全解:AGENTS.md / SOUL.md / USER.md / HEARTBEAT.md 的作用与写法

本章评分
4.5  / 5  (3 评分)

💬 留言讨论