文章目录


在这里插入图片描述

📖 引言:Embedding 在记忆系统中的角色

在 OpenClaw 的记忆系统中,Embedding(嵌入) 是连接自然语言理解与高效检索的桥梁。当用户提问或系统需要查询历史上下文时,Embedding 引擎会将文本转换为高维向量表示,而向量存储则利用这些数值向量实现快速、准确的语义搜索。

与传统的关键词搜索不同,基于 Embedding 的检索捕捉的是语义相似度。例如:“我昨天提到的项目” 和 “我之前启动的那个工作” 在语义上相近,Embedding 能识别这种相似性,而关键词搜索则可能完全失效。

这一篇我们将深入探讨 OpenClaw 的 Embedding 引擎架构、多种提供商的集成、向量存储方案、分块策略和缓存机制。


🌐 6 大 Embedding 提供商详解

OpenClaw 支持六种 Embedding 提供商,既有云端 API 服务,也有本地离线方案:

1️⃣ OpenAI(文本嵌入-3 系列)

默认模型text-embedding-3-small

特点

  • 业界标准,广泛应用
  • 两个模型选择:text-embedding-3-small(维度 1536)和 text-embedding-3-large(维度 3072)
  • Token 限制:8192 tokens 对两个模型均适用

API 调用方式

POST https://api.openai.com/v1/embeddings
{
  "model": "text-embedding-3-small",
  "input": "Your text here"
}

Token 限制处理

  • 单个请求最多 8192 tokens
  • 超出限制的文本会在分块阶段被自动拆分

特殊处理

  • 支持批处理 API(Batch API),单次可提交最多 50,000 个请求
  • 批处理请求通过文件上传到 OpenAI,然后轮询查询结果
  • 完成窗口:24 小时

2️⃣ Google Gemini(多代模型 + 多模态支持)

默认模型gemini-embedding-001

特点

  • 支持多种 Embedding 模型:gemini-embedding-001gemini-embedding-2-preview
  • gemini-embedding-2-preview 最新,支持多模态(文本+图像)
  • Token 限制:text-embedding-004 限制为 2048 tokensgemini-embedding-2-preview8192 tokens

API 调用方式

POST https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent
{
  "content": {
    "parts": [
      { "text": "Your text here" }
    ]
  },
  "taskType": "RETRIEVAL_DOCUMENT"
}

Task Types(任务类型):
Gemini Embedding API 要求指定任务上下文,OpenClaw 采用以下策略:

  • 查询(Query):RETRIEVAL_QUERY - 用于查询文本
  • 文档(Document):RETRIEVAL_DOCUMENT - 用于索引文本
  • 其他类型:SEMANTIC_SIMILARITYCLASSIFICATIONCLUSTERING 等,根据需求配置

特殊处理

  • gemini-embedding-2-preview 支持 outputDimensionality 参数,可设置为 768、1536 或 3072,允许灵活控制向量维度
  • 支持批处理 API,但端点为 :asyncBatchEmbedContent
  • 支持多模态 Embedding(见后续 “多模态 Embedding” 章节)

3️⃣ Voyage AI(轻量级到重型多档)

默认模型voyage-4-large

特点

  • 不同大小的模型:voyage-3voyage-3-litevoyage-code-3
  • Token 限制各异:
    • voyage-3 / voyage-code-332,000 tokens(业界最高)
    • voyage-3-lite16,000 tokens
    • voyage-4-large:一般为 8192 tokens

API 调用方式

POST https://api.voyageai.com/v1/embeddings
{
  "model": "voyage-4-large",
  "input": ["Your text here"],
  "input_type": "document"
}

特殊处理

  • 支持 input_type 参数:"query""document"
  • 批处理 API,完成窗口:12 小时

4️⃣ Mistral(轻量级开源友好)

默认模型mistral-embed

特点

  • 轻量级模型,适合生产环境
  • 无单个请求 Token 限制公开信息,OpenClaw 按保守策略处理

API 调用方式

POST https://api.mistral.ai/v1/embeddings
{
  "model": "mistral-embed",
  "input": "Your text here"
}

特殊处理

  • 使用 Bearer Token 认证

5️⃣ Ollama(本地运行,自由度高)

默认模型nomic-embed-text

特点

  • 完全本地运行,无 API 依赖
  • 支持任何 Ollama 兼容的 Embedding 模型
  • 无速率限制,隐私性最强
  • 需要 Ollama 服务运行在本地或指定地址

API 调用方式

POST http://localhost:11434/api/embeddings
{
  "model": "nomic-embed-text",
  "prompt": "Your text here"
}

特殊处理

  • 每个请求处理单个文本段落(不支持批量 API)
  • 需要通过配置指定 Ollama 服务地址
  • 内存占用及速度取决于本地硬件

6️⃣ Local(本地 Llama C++ 集成)

默认模型hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf

特点

  • 基于 node-llama-cpp,无需外部服务
  • 直接在 Node.js 进程中运行 GGUF 格式模型
  • Token 限制:保守采用 2048 tokens
  • 最适合小规模、隐私敏感的部署

特殊处理

  • 需要安装可选依赖 node-llama-cpp(在 Node 24+ 上推荐)
  • 支持 Hugging Face 模型 URL(hf: 前缀)或本地文件路径
  • 模型会自动下载到 modelCacheDir 目录
  • 启动时加载模型,首次加载可能较慢

自动选择逻辑
当用户配置 provider: "auto" 时,OpenClaw 按以下优先级尝试:

  1. Local - 仅当模型文件本地存在时
  2. OpenAI - 如果配置了 API Key
  3. Gemini - 如果配置了 API Key
  4. Voyage - 如果配置了 API Key
  5. Mistral - 如果配置了 API Key
  6. 若全部失败,降级到 FTS-only 模式(仅全文搜索,无向量)

🔄 Auto 模式的提供商自动选择逻辑

createEmbeddingProvider() 中实现:

if (requestedProvider === "auto") {
  const missingKeyErrors: string[] = [];
  let localError: string | null = null;

  // 尝试本地模型(如果文件存在)
  if (canAutoSelectLocal(options)) {
    try {
      const local = await createProvider("local");
      return { ...local, requestedProvider };
    } catch (err) {
      localError = formatLocalSetupError(err);
    }
  }

  // 尝试远程提供商(OpenAI、Gemini、Voyage、Mistral)
  for (const provider of REMOTE_EMBEDDING_PROVIDER_IDS) {
    try {
      const result = await createProvider(provider);
      return { ...result, requestedProvider };
    } catch (err) {
      if (isMissingApiKeyError(err)) {
        missingKeyErrors.push(message);
        continue;  // 继续尝试下一个
      }
      throw err;  // 网络错误等致命错误直接抛出
    }
  }

  // 全部失败,返回 null provider(FTS-only)
  return {
    provider: null,
    requestedProvider,
    providerUnavailableReason: reason
  };
}

关键特性

  • 不尝试 Ollama:Auto 模式假设用户无本地 Ollama 实例,避免隐式依赖
  • 仅捕获认证错误:网络/连接错误仍然会被抛出
  • 优雅降级:当所有提供商都无法使用时,系统仍可在 FTS-only 模式下工作

🔁 Embedding 降级回退(Fallback)机制

OpenClaw 支持双层 Fallback 机制:

主 Fallback(Primary Fallback)

if (requestedProvider === "auto") {
  // auto 模式已处理
}

try {
  const primary = await createProvider(requestedProvider);
  return { ...primary, requestedProvider };
} catch (primaryErr) {
  if (fallback && fallback !== "none" && fallback !== requestedProvider) {
    try {
      const fallbackResult = await createProvider(fallback);
      return {
        ...fallbackResult,
        requestedProvider,
        fallbackFrom: requestedProvider,
        fallbackReason: reason  // 包含主提供商的错误原因
      };
    } catch (fallbackErr) {
      // 两者都失败
      if (isMissingApiKeyError(primaryErr) && isMissingApiKeyError(fallbackErr)) {
        return { provider: null, ... };  // 降级到 FTS-only
      }
      throw wrapped;  // 其他错误抛出
    }
  }
}

运行时 Fallback(Batch Fallback)

批处理失败时,自动回退到逐条 Embedding:

try {
  return await this.runBatchWithTimeoutRetry({
    provider: params.provider,
    run: params.run
  });
} catch (err) {
  // 记录失败,如果失败次数超过阈值则禁用批处理
  await this.recordBatchFailure({
    provider: params.provider,
    message,
    forceDisable: /asyncBatchEmbedContent not available/i.test(message)
  });
  // 回退到逐条处理
  return await params.fallback();
}

配置示例(OpenClaw 配置文件):

agents:
  defaults:
    memorySearch:
      provider: "openai"
      fallback: "local"  # OpenAI 失败时尝试本地
      model: "text-embedding-3-small"
      remote:
        batch:
          enabled: true
          wait: true
          concurrency: 4

📦 sqlite-vec 向量扩展的加载与使用

加载机制

sqlite-vec 是 SQLite 的向量扩展,提供高效的向量搜索能力。加载过程在 loadSqliteVecExtension() 中:

export async function loadSqliteVecExtension(params: {
  db: DatabaseSync;
  extensionPath?: string;
}): Promise<{ ok: boolean; extensionPath?: string; error?: string }> {
  try {
    const sqliteVec = await import("sqlite-vec");
    const resolvedPath = params.extensionPath?.trim() ? params.extensionPath.trim() : undefined;
    const extensionPath = resolvedPath ?? sqliteVec.getLoadablePath();

    params.db.enableLoadExtension(true);
    if (resolvedPath) {
      params.db.loadExtension(extensionPath);
    } else {
      sqliteVec.load(params.db);  // 使用 npm 包提供的默认路径
    }

    return { ok: true, extensionPath };
  } catch (err) {
    return { ok: false, error: err.message };
  }
}

虚拟表创建

加载成功后,创建向量虚拟表(使用 vec0 引擎):

private ensureVectorTable(dimensions: number): void {
  if (this.vector.dims === dimensions) {
    return;  // 已创建,维度匹配
  }
  if (this.vector.dims && this.vector.dims !== dimensions) {
    this.dropVectorTable();  // 维度不匹配,删除重建
  }
  this.db.exec(
    `CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
      id TEXT PRIMARY KEY,
      embedding FLOAT[${dimensions}]
    )`
  );
  this.vector.dims = dimensions;
}

查询使用

向量查询使用 sqlite-vec 的 vec_distance_*() 函数:

// 检索最相似的 K 个文档
SELECT chunks.id, chunks.text, chunks.path
FROM chunks_vec
INNER JOIN chunks ON chunks_vec.id = chunks.id
WHERE chunks.source = ?
ORDER BY vec_distance_euclidean(chunks_vec.embedding, ?) ASC
LIMIT 10;

支持的距离度量

  • 欧几里得距离vec_distance_euclidean
  • 余弦距离vec_distance_cosine
  • 内积vec_inner_product)等

🗄️ SQLite 存储 Schema 详解

1. files 表 - 文件元数据

CREATE TABLE IF NOT EXISTS files (
  path TEXT PRIMARY KEY,
  source TEXT NOT NULL DEFAULT 'memory',
  hash TEXT NOT NULL,
  mtime INTEGER NOT NULL,
  size INTEGER NOT NULL
);

CREATE INDEX idx_chunks_source ON files(source);

字段说明

  • path:文件路径(主键)
  • source:来源标记('memory''sessions' 等)
  • hash:文件内容哈希,用于检测变更
  • mtime:修改时间戳(毫秒)
  • size:文件大小(字节)

2. chunks 表 - 分块内容

CREATE TABLE IF NOT EXISTS chunks (
  id TEXT PRIMARY KEY,
  path TEXT NOT NULL,
  source TEXT NOT NULL DEFAULT 'memory',
  start_line INTEGER NOT NULL,
  end_line INTEGER NOT NULL,
  hash TEXT NOT NULL,
  model TEXT NOT NULL,
  text TEXT NOT NULL,
  embedding TEXT NOT NULL,  -- JSON 序列化的向量
  updated_at INTEGER NOT NULL
);

CREATE INDEX idx_chunks_path ON chunks(path);
CREATE INDEX idx_chunks_source ON chunks(source);

字段说明

  • id:分块唯一标识(由路径、行号、内容哈希生成)
  • start_line / end_line:分块在源文件中的行号范围
  • hash:分块文本的内容哈希
  • model:生成该 Embedding 的模型名称
  • embedding:JSON 格式的浮点数数组(用于文本搜索时的回显)
  • updated_at:索引时间戳

3. embedding_cache 表 - Embedding 缓存

CREATE TABLE IF NOT EXISTS embedding_cache (
  provider TEXT NOT NULL,
  model TEXT NOT NULL,
  provider_key TEXT NOT NULL,
  hash TEXT NOT NULL,
  embedding TEXT NOT NULL,
  dims INTEGER,
  updated_at INTEGER NOT NULL,
  PRIMARY KEY (provider, model, provider_key, hash)
);

CREATE INDEX idx_embedding_cache_updated_at ON embedding_cache(updated_at);

字段说明

  • provider:提供商名称('openai''gemini' 等)
  • model:模型名称
  • provider_key:区分不同认证身份(如不同 API Key 对应的哈希)
  • hash:文本内容哈希(缓存键)
  • embedding:缓存的向量(JSON 格式)
  • dims:向量维度
  • updated_at:缓存时间戳

自动清理
配置 cache.maxEntries 后,超过数量的旧条目自动删除(按 updated_at ASC 排序)

4. chunks_fts 虚拟表 - FTS5 全文搜索

CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
  text,
  id UNINDEXED,
  path UNINDEXED,
  source UNINDEXED,
  model UNINDEXED,
  start_line UNINDEXED,
  end_line UNINDEXED
);

字段说明

  • text:被索引的分块文本(支持 MATCH 查询)
  • 其他字段:标记为 UNINDEXED(仅用于结果返回,不参与搜索)

FTS5 特性

  • 支持布尔查询:text MATCH 'word1 AND word2 OR word3'
  • 支持短语搜索:text MATCH '"exact phrase"'
  • 支持通配符:text MATCH 'word*'

5. chunks_vec 虚拟表 - 向量搜索

CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
  id TEXT PRIMARY KEY,
  embedding FLOAT[${dimensions}]
);

字段说明

  • id:分块 ID,与 chunks 表的 id 字段一一对应
  • embedding:Float32Array 格式的向量

🔪 分块策略(Chunking):Token 计数、重叠、边界处理

分块参数配置

在 OpenClaw 配置中:

agents:
  defaults:
    memorySearch:
      chunking:
        tokens: 512        # 每个分块的目标 token 数
        overlap: 64        # 相邻分块的重叠 token 数

分块算法

chunkMarkdown() 函数的核心逻辑:

export function chunkMarkdown(
  content: string,
  chunking: { tokens: number; overlap: number }
): MemoryChunk[] {
  const lines = content.split("\n");
  const maxChars = Math.max(32, chunking.tokens * 4);      // 1 token ≈ 4 字符
  const overlapChars = Math.max(0, chunking.overlap * 4);  // 重叠大小

  const chunks: MemoryChunk[] = [];
  let current: Array<{ line: string; lineNo: number }> = [];
  let currentChars = 0;

  const flush = () => {
    // 将当前积累的行写入一个分块
    const text = current.map(entry => entry.line).join("\n");
    const startLine = current[0].lineNo;
    const endLine = current[current.length - 1].lineNo;
    chunks.push({
      startLine,
      endLine,
      text,
      hash: hashText(text),
      embeddingInput: buildTextEmbeddingInput(text)
    });
  };

  const carryOverlap = () => {
    // 从当前分块的末尾保留重叠部分,作为下一分块的开头
    if (overlapChars <= 0 || current.length === 0) {
      current = [];
      currentChars = 0;
      return;
    }
    let acc = 0;
    const kept: Array<{ line: string; lineNo: number }> = [];
    for (let i = current.length - 1; i >= 0; i -= 1) {
      const entry = current[i];
      acc += entry.line.length + 1;
      kept.unshift(entry);
      if (acc >= overlapChars) break;
    }
    current = kept;
    currentChars = kept.reduce((sum, entry) => sum + entry.line.length + 1, 0);
  };

  for (let i = 0; i < lines.length; i += 1) {
    const line = lines[i];
    const lineNo = i + 1;
    
    // 长行会被进一步拆分
    const segments: string[] = [];
    if (line.length === 0) {
      segments.push("");
    } else {
      for (let start = 0; start < line.length; start += maxChars) {
        segments.push(line.slice(start, start + maxChars));
      }
    }

    for (const segment of segments) {
      const lineSize = segment.length + 1;
      
      // 检查是否超出单分块大小限制
      if (currentChars + lineSize > maxChars && current.length > 0) {
        flush();
        carryOverlap();
      }
      
      current.push({ line: segment, lineNo });
      currentChars += lineSize;
    }
  }
  
  flush();  // 处理最后一个分块
  return chunks;
}

分块特性

  1. 行边界对齐:分块尽量在行尾处切割,避免中断句子
  2. 重叠上下文:分块间保留 overlap 数量的 token 重叠,保证语义连贯性
  3. 长行处理:超过 maxChars 的单行会被进一步分段
  4. Token 估计:使用简单比例 1 token ≈ 4 字符(UTF-8)

Token 限制强制

在索引前,通过 enforceEmbeddingMaxInputTokens() 确保分块不超过提供商限制:

export function enforceEmbeddingMaxInputTokens(
  provider: EmbeddingProvider,
  chunks: MemoryChunk[],
  hardMaxInputTokens?: number
): MemoryChunk[] {
  const providerMaxInputTokens = resolveEmbeddingMaxInputTokens(provider);
  const maxInputTokens = hardMaxInputTokens
    ? Math.min(providerMaxInputTokens, hardMaxInputTokens)
    : providerMaxInputTokens;

  const out: MemoryChunk[] = [];
  for (const chunk of chunks) {
    if (estimateUtf8Bytes(chunk.text) <= maxInputTokens) {
      out.push(chunk);
    } else {
      // 进一步拆分
      for (const text of splitTextToUtf8ByteLimit(chunk.text, maxInputTokens)) {
        out.push({
          startLine: chunk.startLine,
          endLine: chunk.endLine,
          text,
          hash: hashText(text)
        });
      }
    }
  }
  return out;
}

📋 批量 Embedding 处理(Batch API)

批处理流程

对于支持批处理的提供商(OpenAI、Gemini、Voyage),OpenClaw 采用异步批处理优化吞吐量:

1. 收集要索引的分块 → 2. 分组(按大小限制)→ 3. 提交批处理任务
                                              ↓
                               4. 轮询查询批处理状态
                                              ↓
                               5. 任务完成后拉取结果

OpenAI 批处理(Batch API)

async function submitOpenAiBatch(params: {
  openAi: OpenAiEmbeddingClient;
  requests: OpenAiBatchRequest[];
  agentId: string;
}): Promise<OpenAiBatchStatus> {
  // 1. 构建 JSONL 文件并上传
  const inputFileId = await uploadBatchJsonlFile({
    client: params.openAi,
    requests: params.requests  // 格式: { custom_id, method: "POST", url, body }
  });

  // 2. 创建批处理任务
  return await postJsonWithRetry<OpenAiBatchStatus>({
    url: `${baseUrl}/batches`,
    body: {
      input_file_id: inputFileId,
      endpoint: "/v1/embeddings",
      completion_window: "24h"
    }
  });

  // 3. 轮询状态(由 waitForOpenAiBatch 处理)
  // 4. 拉取输出文件并解析
}

请求格式

{
  "custom_id": "chunk-123",
  "method": "POST",
  "url": "/v1/embeddings",
  "body": {
    "model": "text-embedding-3-small",
    "input": "Your text chunk"
  }
}

响应格式

{
  "custom_id": "chunk-123",
  "result": {
    "status_code": 200,
    "body": {
      "data": [
        { "index": 0, "embedding": [0.1, 0.2, ...] }
      ]
    }
  }
}

Gemini 批处理

async function submitGeminiBatch(params: {
  gemini: GeminiEmbeddingClient;
  requests: GeminiBatchRequest[];
  agentId: string;
}): Promise<GeminiBatchStatus> {
  // 1. 构建 JSONL 并上传到 Google Cloud Storage
  const fileId = await uploadFileToGCS({
    jsonl,
    displayName: `memory-embeddings-${Date.now()}`
  });

  // 2. 提交异步批处理任务
  return await postJson({
    url: `${baseUrl}/models/${modelPath}:asyncBatchEmbedContent`,
    body: {
      batch: {
        displayName: `memory-embeddings-${agentId}`,
        inputConfig: {
          file_name: fileId
        }
      }
    }
  });

  // 3. 轮询 batches/{batchName} 直至完成
  // 4. 下载输出文件
}

请求格式

{
  "key": "chunk-123",
  "request": {
    "content": {
      "parts": [{ "text": "Your text chunk" }]
    },
    "taskType": "RETRIEVAL_DOCUMENT"
  }
}

Voyage 批处理

async function submitVoyageBatch(params: {
  client: VoyageEmbeddingClient;
  requests: VoyageBatchRequest[];
  agentId: string;
}): Promise<VoyageBatchStatus> {
  // 1. 上传 JSONL 文件
  const inputFileId = await uploadBatchJsonlFile({
    client: params.client,
    requests: params.requests  // 格式: { custom_id, body: { input: "text" } }
  });

  // 2. 创建批处理任务
  return await postJsonWithRetry({
    url: `${baseUrl}/batches`,
    body: {
      input_file_id: inputFileId,
      endpoint: "/embeddings",
      completion_window: "12h",
      request_params: {
        model: params.client.model,
        input_type: "document"
      }
    }
  });
}

批处理配置与容错

agents:
  defaults:
    memorySearch:
      remote:
        batch:
          enabled: true           # 启用批处理
          wait: true              # 等待批处理完成(vs 异步轮询)
          concurrency: 4          # 并发提交的批处理组数
          pollIntervalMs: 5000    # 轮询间隔
          timeoutMs: 600000       # 总超时时间(10 分钟)

失败处理

  • 批处理失败后自动回退到逐条 Embedding
  • 如果批处理连续失败 2 次以上,自动禁用批处理,使用逐条模式
  • 某些错误(如 asyncBatchEmbedContent not available)会强制禁用

💾 Embedding 缓存策略

缓存命中机制

在索引前,通过 loadEmbeddingCache() 检查已缓存的向量:

private loadEmbeddingCache(hashes: string[]): Map<string, number[]> {
  if (!this.cache.enabled || !this.provider) {
    return new Map();
  }

  const baseParams = [this.provider.id, this.provider.model, this.providerKey];
  const batchSize = 400;  // 单次查询的最大哈希数
  
  for (let start = 0; start < unique.length; start += batchSize) {
    const batch = unique.slice(start, start + batchSize);
    const rows = this.db.prepare(
      `SELECT hash, embedding FROM embedding_cache
       WHERE provider = ? AND model = ? AND provider_key = ? AND hash IN (...)`
    ).all(...baseParams, ...batch);
    
    for (const row of rows) {
      out.set(row.hash, parseEmbedding(row.embedding));
    }
  }
  return out;
}

缓存键(四元组):

  • provider:提供商 ID
  • model:模型名称
  • provider_key:认证身份哈希(防止不同 API Key 的向量混淆)
  • hash:文本内容哈希

缓存写入

新生成的向量自动写入 embedding_cache 表:

private upsertEmbeddingCache(entries: Array<{ hash: string; embedding: number[] }>): void {
  const stmt = this.db.prepare(
    `INSERT INTO embedding_cache (...) VALUES (?, ?, ?, ?, ?, ?, ?)
     ON CONFLICT(...) DO UPDATE SET
       embedding=excluded.embedding,
       dims=excluded.dims,
       updated_at=excluded.updated_at`
  );
  
  for (const entry of entries) {
    stmt.run(
      this.provider.id,
      this.provider.model,
      this.providerKey,
      entry.hash,
      JSON.stringify(entry.embedding),
      entry.embedding.length,
      Date.now()
    );
  }
}

自动清理

protected pruneEmbeddingCacheIfNeeded(): void {
  if (!this.cache.enabled) return;
  
  const max = this.cache.maxEntries;
  const count = this.db.prepare(
    `SELECT COUNT(*) as c FROM embedding_cache`
  ).get().c;
  
  if (count > max) {
    const excess = count - max;
    this.db.prepare(
      `DELETE FROM embedding_cache
       WHERE rowid IN (
         SELECT rowid FROM embedding_cache
         ORDER BY updated_at ASC
         LIMIT ?
       )`
    ).run(excess);  // 删除最旧的条目
  }
}

配置示例

agents:
  defaults:
    memorySearch:
      cache:
        enabled: true
        maxEntries: 10000  # 最多缓存 10,000 个向量

🖼️ 多模态 Embedding 支持(Gemini embedding-2-preview 图片)

多模态支持检测

OpenClaw 通过 supportsMemoryMultimodalEmbeddings() 检测提供商是否支持多模态:

export function supportsMemoryMultimodalEmbeddings(params: {
  provider: string;
  model: string;
}): boolean {
  if (params.provider !== "gemini") {
    return false;
  }
  const normalized = normalizeGeminiEmbeddingModelForMemory(params.model);
  return normalized === "gemini-embedding-2-preview";
}

支持提供商

  • Gemini embedding-2-preview
  • ❌ OpenAI、Voyage、Mistral、Ollama、Local(仅支持文本)

多模态文件格式

支持的文件类型:

const MEMORY_MULTIMODAL_SPECS = {
  image: {
    extensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif"]
  },
  audio: {
    extensions: [".mp3", ".wav", ".ogg", ".opus", ".m4a", ".aac", ".flac"]
  }
};

多模态分块构建

buildMultimodalChunkForIndexing() 将媒体文件转换为 Embedding Input:

export async function buildMultimodalChunkForIndexing(entry: SessionFileEntry) {
  const modality = classifyMemoryMultimodalPath(entry.path, settings);
  
  // 1. 读取文件内容
  const content = await fs.readFile(entry.absPath);
  if (content.byteLength > settings.maxFileBytes) {
    return null;  // 文件过大,跳过
  }

  // 2. Base64 编码
  const base64 = content.toString("base64");

  // 3. 构建 EmbeddingInput
  const embeddingInput: EmbeddingInput = {
    text: buildMemoryMultimodalLabel(modality, entry.path),
    parts: [
      { type: "text", text: labelText },
      {
        type: "inline-data",
        mimeType: resolveMimeType(modality),
        data: base64
      }
    ]
  };

  return { chunk, structuredInputBytes };
}

Gemini 多模态 API 格式

export function buildGeminiEmbeddingRequest(params: {
  input: EmbeddingInput;
  taskType: GeminiTaskType;
  outputDimensionality?: number;
  modelPath?: string;
}): GeminiEmbeddingRequest {
  return {
    content: {
      parts: params.input.parts?.map(part =>
        part.type === "text"
          ? { text: part.text }
          : {
              inlineData: {
                mimeType: part.mimeType,
                data: part.data  // Base64 编码的媒体数据
              }
            }
      ) ?? [{ text: params.input.text }]
    },
    taskType: params.taskType,
    outputDimensionality: params.outputDimensionality
  };
}

🔍 向量归一化与验证(sanitizeAndNormalizeEmbedding)

所有 Embedding 都经过 sanitizeAndNormalizeEmbedding() 处理,确保数值稳定性和一致性:

export function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
  // 1. 清理非法值(NaN、Infinity)
  const sanitized = vec.map((value) =>
    Number.isFinite(value) ? value : 0
  );

  // 2. 计算 L2 范数(欧几里得模)
  const magnitude = Math.sqrt(
    sanitized.reduce((sum, value) => sum + value * value, 0)
  );

  // 3. 若向量非零,则归一化为单位向量
  if (magnitude < 1e-10) {
    return sanitized;  // 零向量保持不变
  }
  return sanitized.map((value) => value / magnitude);
}

作用

  1. 数值稳定:移除 NaN 和 Infinity,防止数据库存储和计算错误
  2. 向量归一化:所有非零向量的 L2 范数都为 1.0
  3. 距离度量一致性:归一化后,欧几里得距离与余弦距离等价

好处

  • 便于使用余弦相似度进行搜索
  • 提高向量搜索性能(某些加速库要求单位向量)
  • 减少浮点数值误差的影响

📊 Embedding 操作性能优化

并发策略

protected getIndexConcurrency(): number {
  return this.batch.enabled ? this.batch.concurrency : 4;
}

逻辑

  • 启用批处理时:使用配置的 concurrency 值(通常 2-8)
  • 关闭批处理时:使用固定的 4 个并发线程

超时配置

private resolveEmbeddingTimeout(kind: "query" | "batch"): number {
  const isLocal = this.provider?.id === "local";
  if (kind === "query") {
    // 查询超时
    return isLocal ? 5 * 60_000 : 60_000;  // 本地 5 分钟,远程 1 分钟
  } else {
    // 批处理超时
    return isLocal ? 10 * 60_000 : 2 * 60_000;  // 本地 10 分钟,远程 2 分钟
  }
}

重试策略

protected async embedBatchWithRetry(texts: string[]): Promise<number[][]> {
  let attempt = 0;
  let delayMs = 500;  // 初始延迟
  
  while (true) {
    try {
      return await this.provider.embedBatch(texts);
    } catch (err) {
      if (!this.isRetryableEmbeddingError(message) || attempt >= 3) {
        throw err;
      }
      // 指数退避 + 随机抖动
      delayMs = Math.min(8000, delayMs * 2);
      const waitMs = delayMs * (1 + Math.random() * 0.2);
      await new Promise(resolve => setTimeout(resolve, waitMs));
      attempt += 1;
    }
  }
}

private isRetryableEmbeddingError(message: string): boolean {
  return /(rate.limit|too many requests|429|resource exhausted|5\d\d|tokens per day)/i.test(message);
}

🎯 小结与下篇预告

本文深入讲解了 OpenClaw 记忆系统的 Embedding 引擎与向量存储 架构:

核心要点

  • 6 种 Embedding 提供商支持不同的使用场景,从云端高性能到本地离线方案
  • Auto 模式优雅地根据可用资源自动选择最合适的提供商
  • Fallback 机制确保系统的容错能力
  • sqlite-vec 向量虚拟表提供了高效的语义搜索能力
  • 分块策略在语义完整性与处理效率间取得平衡
  • 批处理 API 大幅提升大规模索引的吞吐量
  • 多模态支持使得系统能够理解文本之外的信息
  • 缓存与优化显著降低了重复计算的成本

下篇展望

本系列第 3 篇将聚焦 记忆检索与重排序,讨论:

  • 混合搜索策略(向量 + FTS 融合)
  • 查询时的 Embedding 和排序
  • 重排序(Re-ranking)算法提升精度
  • 记忆上下文的动态组装
  • 与 LLM 的集成模式

📚 参考文献

  1. OpenClaw 源代码

    • src/memory/embeddings.ts - Embedding 提供商工厂
    • src/memory/embeddings-{openai,gemini,voyage,mistral,ollama}.ts - 各提供商实现
    • src/memory/batch-{openai,gemini,voyage}.ts - 批处理 API 实现
    • src/memory/sqlite-vec.ts - sqlite-vec 扩展加载
    • src/memory/memory-schema.ts - 数据库 Schema 定义
    • src/memory/manager-embedding-ops.ts - Embedding 操作管理
  2. 相关技术文档

    • OpenAI Embeddings API: https://platform.openai.com/docs/guides/embeddings
    • Google Gemini API: https://ai.google.dev/docs
    • Voyage AI Embeddings: https://docs.voyageai.com/docs/embeddings
    • Mistral AI Embeddings: https://docs.mistral.ai/capabilities/embeddings/
    • sqlite-vec: https://github.com/asg017/sqlite-vec
  3. 向量检索与相似度

    • Cosine Similarity vs Euclidean Distance
    • Vector Normalization in Information Retrieval
    • Embedding-based Search Systems
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐