基于 Embedding 的语义检索:让 Agent 真正"理解"记忆

从关键词匹配到语义检索,在零依赖的 TS Agent 运行时中引入 Embedding 的完整实践。

上一篇我们用 MMR 解决了检索结果的多样性问题。但有个更根本的问题一直没动——检索本身还是基于关键词匹配的

关键词匹配的问题说了很多年,落到具体场景里感受才真切。

先讲个翻车故事

假设 Agent 记住了一条信息:

用户对辛辣的食物情有独钟

然后用户说了一句话:

这次别点麻辣的

如果用关键词检索,"辛辣"和"麻辣"之间没有字符重叠——这条记忆根本不会被搜到。Agent 只能看到当前对话,完全不知道用户其实很喜欢吃辣。

这就是 TF-IDF 风格关键词匹配的死穴:同义词和近义词的语义鸿沟。字符层面的重叠度不等于语义层面的相关度。

要跨越这道鸿沟,需要引入 Embedding。

Embedding 的核心直觉

Embedding 把文本映射到高维空间的向量。关键性质:语义相近的文本,向量距离也近

你可以想象成三维空间:

"辛辣的食物"  → [0.9, 0.1, 0.8]  ← 辣味方向
"麻辣的火锅"  → [0.85, 0.2, 0.7] ← 语义相近,方向接近
"天气不错"    → [0.2, 0.9, 0.1]  ← 完全不同的方向

"辛辣"和"麻辣"在向量空间中距离很近,而"天气"在另一个方向——即使关键词没有重叠,语义上接近的文本也能被搜到。

所以 Embedding 检索的核心就两步:
1. 把 query 和每条记忆都转成向量
2. 找跟 query 向量最"靠近"的 k 条记忆

第一步:余弦相似度

衡量两个向量"靠近"的程度,最常用的是余弦相似度:

cos(θ) = (A · B) / (|A| × |B|)

分子是点积,衡量方向一致性;分母是模长乘积,用来归一化。结果范围 [-1, 1],1 表示完全同向。

实现很简单,一次遍历算完。不过有个前置检查不能省:

export function cosineSimilarity(a: number[], b: number[]): number {
  if (a.length !== b.length) {
    throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
  }

  let dotProduct = 0, normA = 0, normB = 0;

  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }

  const denominator = Math.sqrt(normA) * Math.sqrt(normB);
  if (denominator === 0) return 0;

  return Math.max(-1, Math.min(1, dotProduct / denominator));
}

这里有个细节:先做 L2 归一化,余弦相似度就直接退化成点积。归一化后的向量模长为 1,点积就是余弦值。这么做的好处是归一化可以提前算好、存下来,检索时只做点积,省一次除法。

第二步:Embedding Provider

Embedding 从哪来?需要一个 Provider。设计成接口,可替换:

export interface EmbeddingProvider {
  readonly name: string;
  embed(text: string): Promise<number[]>;
  embedMany(texts: string[]): Promise<number[][]>;
}

实现 OpenAI 版本时,我用原生 fetch 调用 Embeddings API,不走 SDK。核心逻辑是 embed() → embedMany() → sendRequest() 的调用链,外面包了带指数退避的重试机制(最多 2 次),里面用了 AbortController 做超时控制(30s),返回后对向量做 L2 归一化。下面的 fetch 调用是 sendRequest 的核心:

const response = await fetch(`${this.config.baseUrl}/embeddings`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${this.config.apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: this.config.model,
    input: texts,
    dimensions: this.config.dimensions,
  }),
  signal: controller.signal,
});

if (!response.ok) {
  throw new EmbeddingError(`HTTP ${response.status}: ${errorText}`);
}

为什么选 384 维? text-embedding-3-small 最高支持 1536 维,但 384 对 Agent 记忆检索来说是性能与精度的 sweet spot。更低的维度意味着更快的余弦计算和更少的内存占用。而且 Jina Embeddings v2 的默认维度也是 384——方便将来切换 Provider。

第三步:VectorIndex——高维空间的书架

Embedding 向量需要索引才能快速搜索。我实现了一个纯内存的 VectorIndex:

export class VectorIndex {
  private entries = new Map<string, VectorEntry>();
  private dimension: number;

  add(id: string, vector: number[]): void {
    // 自动做 L2 归一化
    this.entries.set(id, { id, vector: l2Normalize(vector) });
  }

  search(query: number[], k: number): VectorSearchResult[] {
    // 边界条件先行
    if (this.entries.size === 0) return [];
    if (k <= 0) return [];

    const normalizedQuery = l2Normalize(query);

    const scored: Array<{ id: string; score: number }> = [];
    for (const [id, entry] of this.entries) {
      const score = cosineSimilarity(normalizedQuery, entry.vector);
      scored.push({ id, score });
    }

    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, k);
  }
}

搜索方式就是暴力 kNN——遍历所有条目算一遍余弦相似度,取 top k。

为什么不做近似搜索(ANN)? Agent 的长期记忆通常 < 10000 条,384 维的暴力搜索实测不到 40ms。在这个量级,IVF 或 HNSW 的构建开销反而可能大于收益。代码简单、零依赖、结果精确——暴力搜索在中小规模下是最好的选择。

第四步:集成到记忆系统

现在问题是:怎么在不破坏现有代码的前提下引入语义检索?

答案是不破坏——加一个可选的 SearchMode

export type SearchMode = 'keyword' | 'semantic' | 'hybrid';

构造时传入:

const mem = new InMemoryLongTermMemory({
  embeddingProvider: new OpenAIEmbeddingProvider(),
  searchMode: 'semantic',
  embeddingDimension: 384,
});

store() 时自动生成 embedding:

async store(item) {
  const id = this.generateId();
  const fullItem = { ...item, id };

  if (this.embeddingProvider && this.vectorIndex) {
    try {
      const embedding = await this.embeddingProvider.embed(item.content);
      fullItem.embedding = embedding;
      this.vectorIndex.add(id, embedding);
    } catch (err) {
      // embedding 失败不阻断流程,后续回退到关键词检索
      console.warn('Failed to generate embedding:', err);
    }
  }

  this.items.set(id, fullItem);
  return id;
}

search() 时分派到不同的检索策略:

async search(query) {
  switch (this.searchMode) {
    case 'semantic': return this.semanticSearch(query);
    case 'hybrid':   return this.hybridSearch(query);
    case 'keyword':
    default:         return this.keywordSearch(query);
  }
}

semanticSearch 的兜底逻辑:如果 embedding provider 不可用、向量索引为空、或者 API 调用失败,静默回退到关键词检索。Agent 不能因为检索升级就卡死。另外,语义检索目前不对余弦相似度做 minRelevance 截断——关键词检索的阈值(如 0.15)是基于重叠词频的经验值,余弦相似度的绝对值在不同语料间波动较大,一刀切的阈值反而可能误伤。

第五步:Hybrid 检索——RRF 融合

纯语义检索也有盲区:对精确的关键词匹配不敏感。比如用户明确说"Python 后端",语义检索可能匹配到"Node.js 后端"(都是后端,语义接近),但关键词检索能精确命中。

所以有了第三种模式:hybrid。同时跑关键词和语义检索,用 Reciprocal Rank Fusion(RRF)融合排序:

score(id) = Σ 1 / (k + rank_i)

k = 60(Cormack et al., SIGIR 2009 提出的标准常数)。两个排序列表各自贡献一个分数,不需要分数归一化。

hybrid 模式还有一个重要的稳定性保障:如果语义检索的 API 调用失败(网络超时、限流等),它会静默降级为纯关键词检索,不会空手而归。

private async hybridSearch(query, maxResults) {
  const keywordResults = await this.keywordSearch(query, maxResults * 2);
  const semanticResults = await this.semanticSearch(query, maxResults * 2);

  // RRF 融合
  const RRF_K = 60;
  const scoreMap = new Map<string, number>();

  keywordResults.forEach((item, i) =>
    scoreMap.set(item.id, (scoreMap.get(item.id) ?? 0) + 1 / (RRF_K + i + 1))
  );
  semanticResults.forEach((item, i) =>
    scoreMap.set(item.id, (scoreMap.get(item.id) ?? 0) + 1 / (RRF_K + i + 1))
  );

  return Array.from(scoreMap.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, maxResults)
    .map(([id]) => this.items.get(id)!);
}

测试写起来也很有意思

因为不想在测试里真的调 OpenAI API,我实现了一个 MockEmbeddingProvider:

class MockEmbeddingProvider {
  readonly name = 'mock';

  private mapping = new Map([
    ['喜欢辣', [1, 0, 1]],
    ['喜欢辣的食物', [1, 0, 1]],
    ['用户喜欢吃辣的食物', [1, 0, 1]],
    ['喜欢编程和技术', [0, 1, 1]],
    ['用户喜欢编程和技术', [0, 1, 1]],
    ['Python 做后端', [0, 1, 0.5]],
    ['用户用 Python 做后端', [0, 1, 0.5]],
    ['今天天气不错', [0.5, 0, 0]],
  ]);

  async embed(text: string): Promise<number[]> {
    return this.mapping.get(text) ?? [0, 0, 0];
  }

  async embedMany(texts: string[]): Promise<number[][]> {
    return Promise.all(texts.map((t) => this.embed(t)));
  }
}

用 3 维向量模拟不同语义方向。测试验证:搜索"喜欢辣"时,尽管 query 和存储的记忆内容不完全相同,语义检索通过向量相似度能正确匹配到"用户喜欢吃辣的食物"。这是关键词检索做不到的。

效果怎么样

用几组对比来感受一下:

场景 关键词检索 语义检索 混合检索
用户说"麻辣" vs 记忆中"辛辣" ❌ 漏掉 ✅ 匹配 ✅ 匹配
用户说"TypeScript" vs 记忆中"TS" ❌ 漏掉 ✅ 可能 ✅ 靠关键词兜底
用户说"Python 后端" vs 记忆中"Python" ✅ 匹配 ✅ 匹配 ✅ 更高分
全新话题,记忆中没有对应关键词 ✅ 语义接近的 ✅ 综合

混合模式的 RRF 融合在大多数场景下表现最好,代价是多一次语义检索的 API 调用和略微增加的时间。

几个设计上的取舍

1. Embedding 是同步生成还是在 store 时生成?

我在 store() 里同步生成。好处是检索时不需要再调 API,直接查向量索引就行。代价是 store() 变慢了。但记记忆是低频操作(用户说几句话才 consolidate 一次),检索是高频操作,这笔账算下来值。

2. API key 和提供者解耦

EmbeddingProvider 是外部注入的,记忆存储不关心它从哪来。线上用 OpenAI,本地开发可以用 NoopEmbeddingProvider(返回空向量,回退到关键词),也可以接本地的 Ollama embedding 模型。

3. 向量索引和记忆存储分开

VectorIndex 只存向量,不存内容。这样做的灵活性在于:将来切换到 PostgreSQL + pgvector 时,只需要把 VectorIndex 替换掉,数据存储层完全不变。


这一篇做完之后,记忆系统变成了一个多策略可配置的检索器。默认还是零依赖的关键词模式,但插上 Embedding Provider 就能获得语义检索能力,而且两种模式可以并存融合。结合上一篇的 MMR 重排,现在检索流程变成了:关键词/语义/混合 → 时间加权 → MMR 多样性重排 → 注入 Agent 上下文。每一层都可插拔。

下一步,我打算做记忆合并——用 LLM 把短期的碎片信息提炼成结构化的长期记忆。之前的规则合并策略提取质量有天花板。换成 LLM 驱动后,能提取出更抽象、更精炼的记忆。

评论

此博客中的热门博文

我写了半年 prompt,这是我发现的最好的技巧