写作归档

向量与语义检索

类型
文章
日期
2026/10/15
阅读
12 分钟
字数
3,091

1. 概述

在讲向量化之前,我们可以先看看传统关键词匹配搜索在 AI Agent 的搜索场景中会遇到什么问题

用户说:我想去上次那个很浪漫的地方

记忆库里存着这样一条记录:2024-03-14,和用户去了 Blue Note 爵士酒吧,烛光晚餐,气氛非常好。

如果使用关键词「浪漫」搜索,由于记录中没有出现这两个字,系统就无法命中它。但对人来说,烛光晚餐 和 气氛非常好 都很容易与浪漫联系起来。

关键词搜索的局限就在这里:它只能匹配字面,无法理解语义。

在 AI 伴侣场景中,同一个意思往往会有很多种表达方式:

“我心情不好”、“今天好丧”与“感觉整个人都垮了”

“想吃上次那个”与“你还记得我爱吃什么吗”

“我们第一次见面”与“最初认识的时候”

这些表述在字面上差异很大,语义却高度相似。因此,我们需要的是能够理解意思、而不只是匹配字符的搜索方式,也就是语义检索。

3. Embedding

Embedding(嵌入)是把文本转换成一组数字,也就是向量的过程。这组数字并非随意生成,而是由专门训练的神经网络模型计算得到。其中最重要的特征是:语义相近的文本,会被映射到向量空间中相近的位置。

为了更直观地理解这个过程,我们先用一个简化的二维向量表示几段文本:

文本向量(简化)含义
“烛光晚餐,气氛很好”[0.82, 0.75]浪漫 + 积极
“想去浪漫的地方”[0.80, 0.73]浪漫 + 积极
“今天加班到12点”[0.15, 0.20]工作 + 疲惫
“好累啊想休息”[0.18, 0.25]工作 + 疲惫

从这组简化数据可以看出,前两条文本的向量很接近,后两条也是如此,但两组之间的差异明显更大。Embedding 做的事情,就是把意思相近转换成可以计算的数字相近。

Embedding 向量空间示意图:语义相近的文本在空间中距离更近

真实的 Embedding 模型当然不会只输出两个维度。常见的向量有 768 维(如 bge-base)和 1536 维(如 OpenAI text-embedding-3-small)。维度越高,对语义的表达通常越精细,但存储和计算成本也会随之增加。

从这个角度看,Embedding 可以理解为:文本 →→ 高维空间中的一个点。语义越相似,这些点在空间中的距离就越近。

4. 衡量语义相似度

文本被转换成向量之后,判断两段文本是否相似,就可以转化为判断两个向量是否相近。为了完成这个判断,我们需要选择合适的距离度量方式。

余弦相似度(Cosine Similarity)

余弦相似度是最常用的度量方式。它关注两个向量的方向是否一致,而不关心向量本身的长度。

cosine_sim(A,B)=∥A∥×∥B∥A⋅B​

它的取值范围是 [−1,1][−1,1],数值越接近 1,表示两个向量越相似。由于计算时不依赖向量长度,所以即使一边是一句话、另一边是一段话,只要语义相同,也能得到较高的相似度。

欧氏距离(Euclidean Distance)

欧氏距离衡量的是两个向量在空间中的直线距离。

d(A,B)=∑i=1n​(Ai​−Bi​)2​

欧氏距离越小,表示两个向量越相似。不过,它的结果会受向量长度影响;长文本的向量模通常更大,因此可能引入偏差。

在实际的 AI 伴侣项目中,通常会优先使用余弦相似度。用户的表达有长有短,我们关心的是意思是否相近,而不是字数是否相当。余弦相似度只关注方向、忽略长度,正好适合这类检索需求。

5. 记忆的存储与检索

接下来,我们把 Embedding 和相似度搜索连接起来,看看 AI Agent 项目中的一段记忆,是如何被提取、存储,又如何在后续对话中被找回的

存储与检索流程:写入路径和读取路径

第一步:记忆产生与切分

一轮对话结束后,后台异步任务会分析其中的内容,并提取出值得长期保存的记忆片段。

index.ts

// 原始对话
const conversation = `
  用户: 今天我生日,我们去了 Blue Note 听爵士
  AI: 生日快乐!你喜欢今晚的演出吗?
  用户: 超喜欢!以后每年生日都来这里
`

// 提取的记忆片段(由 LLM 或规则引擎完成)
const memories = [
  "用户的生日活动:去 Blue Note 听爵士乐",
  "用户希望每年生日都去 Blue Note",
  "用户喜欢爵士乐演出"
]

整段对话不会被原封不动地直接存入记忆库,而是要先经过切分和提炼。短文本的语义更集中,可以让检索结果更精准,同时也能减少不必要的存储开销。

第二步:向量化

接着,系统会对每个记忆片段调用 Embedding 模型,得到与之对应的向量。

index.ts

// 调用 Embedding API
const response = await ai.run(
  '@cf/baai/bge-base-en-v1.5',
  { text: ["用户的生日活动:去 Blue Note 听爵士乐"] }
)

// 返回结果:一个 768 维的浮点数数组
// [0.023, -0.156, 0.891, ..., 0.034]

第三步:存入向量数据库

向量生成后,需要连同元数据一起写入向量数据库。这些元数据包括原始文本、时间戳和用户 ID 等信息,后续可以用它们进行过滤或展示。

index.ts

// 写入 CloudFlare Vectorize
await vectorIndex.upsert([
  {
    id: "mem_20240314_001",
    values: embeddingVector,       // 768维向量
    metadata: {
      text: "用户的生日活动:去 Blue Note 听爵士乐",
      userId: "user_123",
      timestamp: "2024-03-14T20:30:00Z",
      type: "event",
      importance: 0.85              // 重要性评分
    }
  }
])

第四步:检索召回

当用户在之后的某次对话中说“我们去上次过生日的地方吧”时,系统需要找回与这句话相关的记忆。

index.ts

// 1. 将用户的查询向量化
const queryEmbedding = await ai.run(
  '@cf/baai/bge-base-en-v1.5',
  { text: ["上次过生日的地方"] }
)

// 2. 在向量数据库中检索最相似的记忆
const results = await vectorIndex.query(
  queryEmbedding.data[0],
  {
    topK: 5,                        // 返回最相似的5条
    filter: { userId: "user_123" }, // 只搜这个用户的记忆
    returnMetadata: true
  }
)

// 3. 返回结果(按相似度排序)
// [
//   { id: "mem_20240314_001", score: 0.94,
//     metadata: { text: "用户的生日活动:去 Blue Note 听爵士乐" } },
//   { id: "mem_20240314_002", score: 0.87,
//     metadata: { text: "用户希望每年生日都去 Blue Note" } },
//   ...
// ]

返回结果中的 score: 0.94 就是余弦相似度。用户说的是“上次过生日的地方”,记忆里存的却是“用户的生日活动:去 Blue Note 听爵士乐”。虽然两者的字面差异很大,语义却高度相似,所以会得到较高的分数。

第五步:注入上下文

完成检索后,这些记忆片段会被拼接到发给 LLM 的 Prompt 中。

const systemPrompt = `
你是小薇,用户的 AI 女友。

【相关记忆】
- 用户的生日活动:去 Blue Note 听爵士乐(2024-03-14)
- 用户希望每年生日都去 Blue Note(2024-03-14)

请基于以上记忆,自然地回应用户。
`

有了这些上下文,AI 就可以回复:“你说的是 Blue Note 吧!上次生日在那里听爵士超棒的,我也好想再去~”

至此,语义检索的过程就完整了:存储时对记忆进行向量化,检索时计算相似度,找到相关记忆后,再将它们注入上下文。

6. CloudFlare Vectorize 的作用

这个项目使用 CloudFlare Vectorize 作为向量数据库。Vectorize 是 CloudFlare 提供的边缘向量数据库服务,可以与 Workers AI 和 D1 直接配合使用。

选择 Vectorize 的原因

维度CloudFlare VectorizePinecone / Milvus
部署位置边缘节点,离用户更近独立集群,需要跨网络请求
与 Workers AI 集成原生集成,同一生态需要额外的网络调用
冷启动延迟极低(边缘计算)较高(需要唤醒实例)
成本模型按用量计费,起步免费按实例时长计费,起步有成本
适用规模中小规模(百万级向量)大规模(十亿级向量)

对于 AI 伴侣的用户规模,Vectorize 的边缘部署和低延迟更符合项目需求。用户发出一条消息后,记忆检索的耗时需要控制在 50ms 以内。边缘节点与用户的物理距离更近,这一点对降低检索延迟很重要。

Vectorize 的基本用法

index.ts

// 创建索引(通常在项目初始化时执行一次)
// wrangler vectorize create memory-index
//   --dimensions=768 --metric=cosine

// 在 Worker 中使用
export default {
  async fetch(request, env) {
    const { text, userId } = await request.json()

    // 1. 生成 Embedding
    const embedding = await env.AI.run(
      '@cf/baai/bge-base-en-v1.5',
      { text: [text] }
    )

    // 2. 检索相似记忆
    const matches = await env.VECTORIZE.query(
      embedding.data[0],
      { topK: 5, filter: { userId } }
    )

    return Response.json(matches)
  }
}

在这段代码中,生成 Embedding 和检索向量都在 CloudFlare 的边缘网络内完成,不需要跨云通信。Embedding 模型(Workers AI)与向量数据库(Vectorize)位于同一个数据中心,因此可以减少额外的网络延迟。

7. 优化检索质量

接入向量检索只是第一步。在实际工程中,记忆的切分方式、检索范围和结果排序,都会直接影响 AI 女友的记忆质量。

记忆片段的粒度控制

如果记忆粒度太粗,例如把整段对话存为一条,检索时的语义就不够集中,容易召回无关内容。如果粒度太细,每句话都单独存储,又会丢失上下文,导致检索到的片段缺乏连贯性。

项目中通常可以按一个完整事件或一个明确事实进行切分:

// ❌ 太粗:整段对话作为一条记忆
"2024-03-14的完整对话记录(500字)"

// ❌ 太细:每句话一条记忆
"用户说了生日快乐"

// ✅ 合适:一个事件/事实一条记忆
"用户生日当天去了 Blue Note 爵士酒吧庆祝"
"用户表示以后每年生日都想去 Blue Note"

元数据过滤 + 向量检索的组合

只依赖向量相似度并不总是足够。例如,用户提到“昨天晚上的事”时,系统应该先根据时间缩小检索范围,再在这个范围内进行语义匹配。

const results = await vectorIndex.query(queryVector, {
  topK: 5,
  filter: {
    userId: "user_123",
    // 先用元数据缩小范围
    timestamp: { $gte: "2024-03-13T00:00:00Z" }
  }
})

这种结构化过滤 + 语义排序的混合检索策略,在实际项目中通常比纯向量搜索更有效。

重排序(Reranking)

向量检索返回的 Top-K 结果不一定已经按最合理的顺序排列。我们可以使用轻量的 Cross-Encoder 模型对候选结果做二次排序,进一步提高准确度。这一步只需要对 5-10 条候选结果进行推理,成本较低,但对排序质量的改善很明显。

8. 总结

回到文章开头的问题,AI Agent 的长期记忆之所以能够被找回来,关键就在于 Embedding 与语义检索。整个过程可以归纳为以下几点:

  1. 传统关键词搜索只能匹配字面字符,难以处理语义相似、表述不同的内容,AI Agent 场景因此需要语义检索。
  2. Embedding 会把文本映射为高维向量,语义相近的文本在向量空间中彼此更近。
  3. 衡量语义相似性时,余弦相似度是首选指标:它只关注向量方向,不受向量长度影响。
  4. 完整的处理链路是:记忆产生 → 切分提炼 → 向量化 → 存入向量库 → 检索召回 → 注入上下文。
  5. CloudFlare Vectorize 的边缘部署特性,为实时记忆检索提供了低延迟保障。
  6. 工程上还需控制记忆的切分粒度、借助元数据预先过滤,并用重排序(Reranking)对结果做精排。

下一篇我们会继续设计情绪状态机,看看 AI 女友的记仇、害羞和吃醋是如何实现的。

0 条

还没有评论。

发表评论