1. 从记忆到检索
在前面的章节中,我们已经多次接触到向量化。介绍 RAG 流程时,我们提到过向量化与索引;讲解记忆调度时,又把向量数据库类比为硬盘;到了 LangChain 实战,则直接使用 VectorStoreRetriever 检索记忆。这些内容都指向两个尚未展开的问题:为什么文本要转换成向量,向量检索又是如何工作的?
这一篇我们就来补上这部分知识。弄清向量化与语义检索之后,RAG 中检索环节的实现逻辑也就更容易理解了。
如果把 RAG 理解为一套先查询、再回答的流程,那么向量化就承担了搜索引擎的作用。没有它,AI 女友的长期记忆即使已经存入仓库,也很难在需要时准确找回来。
2. 为什么传统搜索不够用
在讲向量化之前,我们可以先看看传统关键词搜索在 AI 伴侣场景中会遇到什么问题。
场景:用户说“我想去上次那个很浪漫的地方”,记忆库里存着这样一条记录:“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 模型当然不会只输出两个维度。常见的向量有 768 维(如 bge-base)和 1536 维(如 OpenAI text-embedding-3-small)。维度越高,对语义的表达通常越精细,但存储和计算成本也会随之增加。
从这个角度看,Embedding 可以理解为:文本 高维空间中的一个点。语义越相似,这些点在空间中的距离就越近。
4. 衡量语义相似度
文本被转换成向量之后,判断两段文本是否相似,就可以转化为判断两个向量是否相近。为了完成这个判断,我们需要选择合适的距离度量方式。
余弦相似度(Cosine Similarity)余弦相似度是最常用的度量方式。它关注两个向量的方向是否一致,而不关心向量本身的长度。
它的取值范围是 ,数值越接近 1,表示两个向量越相似。由于计算时不依赖向量长度,所以即使一边是一句话、另一边是一段话,只要语义相同,也能得到较高的相似度。
欧氏距离(Euclidean Distance)欧氏距离衡量的是两个向量在空间中的直线距离。
欧氏距离越小,表示两个向量越相似。不过,它的结果会受向量长度影响;长文本的向量模通常更大,因此可能引入偏差。
在实际的 AI 伴侣项目中,通常会优先使用余弦相似度。用户的表达有长有短,我们关心的是意思是否相近,而不是字数是否相当。余弦相似度只关注方向、忽略长度,正好适合这类检索需求。
5. 记忆的存储与检索
接下来,我们把 Embedding 和相似度搜索连接起来,看看 AI 伴侣项目中的一段记忆,是如何被提取、存储,又如何在后续对话中被找回的。
第一步:记忆产生与切分
一轮对话结束后,后台异步任务会分析其中的内容,并提取出值得长期保存的记忆片段。
01// 原始对话02const conversation = `03用户: 今天我生日,我们去了 Blue Note 听爵士04AI: 生日快乐!你喜欢今晚的演出吗?05用户: 超喜欢!以后每年生日都来这里06`0708// 提取的记忆片段(由 LLM 或规则引擎完成)09const memories = [10"用户的生日活动:去 Blue Note 听爵士乐",11"用户希望每年生日都去 Blue Note",12"用户喜欢爵士乐演出"13]
整段对话不会被原封不动地直接存入记忆库,而是要先经过切分和提炼。短文本的语义更集中,可以让检索结果更精准,同时也能减少不必要的存储开销。
第二步:向量化
接着,系统会对每个记忆片段调用 Embedding 模型,得到与之对应的向量。
1// 调用 Embedding API2const response = await ai.run(3'@cf/baai/bge-base-en-v1.5',4{ text: ["用户的生日活动:去 Blue Note 听爵士乐"] }5)67// 返回结果:一个 768 维的浮点数数组8// [0.023, -0.156, 0.891, ..., 0.034]
第三步:存入向量数据库
向量生成后,需要连同元数据一起写入向量数据库。这些元数据包括原始文本、时间戳和用户 ID 等信息,后续可以用它们进行过滤或展示。
01// 写入 CloudFlare Vectorize02await vectorIndex.upsert([03{04id: "mem_20240314_001",05values: embeddingVector, // 768维向量06metadata: {07text: "用户的生日活动:去 Blue Note 听爵士乐",08userId: "user_123",09timestamp: "2024-03-14T20:30:00Z",10type: "event",11importance: 0.85 // 重要性评分12}13}14])
第四步:检索召回
当用户在之后的某次对话中说“我们去上次过生日的地方吧”时,系统需要找回与这句话相关的记忆。
01// 1. 将用户的查询向量化02const queryEmbedding = await ai.run(03'@cf/baai/bge-base-en-v1.5',04{ text: ["上次过生日的地方"] }05)0607// 2. 在向量数据库中检索最相似的记忆08const results = await vectorIndex.query(09queryEmbedding.data[0],10{11topK: 5, // 返回最相似的5条12filter: { userId: "user_123" }, // 只搜这个用户的记忆13returnMetadata: true14}15)1617// 3. 返回结果(按相似度排序)18// [19// { id: "mem_20240314_001", score: 0.94,20// metadata: { text: "用户的生日活动:去 Blue Note 听爵士乐" } },21// { id: "mem_20240314_002", score: 0.87,22// metadata: { text: "用户希望每年生日都去 Blue Note" } },23// ...24// ]
返回结果中的 score: 0.94 就是余弦相似度。用户说的是“上次过生日的地方”,记忆里存的却是“用户的生日活动:去 Blue Note 听爵士乐”。虽然两者的字面差异很大,语义却高度相似,所以会得到较高的分数。
第五步:注入上下文
完成检索后,这些记忆片段会被拼接到发给 LLM 的 Prompt 中。
1const systemPrompt = `2你是小薇,用户的 AI 女友。34【相关记忆】5- 用户的生日活动:去 Blue Note 听爵士乐(2024-03-14)6- 用户希望每年生日都去 Blue Note(2024-03-14)78请基于以上记忆,自然地回应用户。9`
有了这些上下文,AI 就可以回复:“你说的是 Blue Note 吧!上次生日在那里听爵士超棒的,我也好想再去~”
至此,语义检索的过程就完整了:存储时对记忆进行向量化,检索时计算相似度,找到相关记忆后,再将它们注入上下文。
6. CloudFlare Vectorize 的作用
这个项目使用 CloudFlare Vectorize 作为向量数据库。Vectorize 是 CloudFlare 提供的边缘向量数据库服务,可以与 Workers AI 和 D1 直接配合使用。
选择 Vectorize 的原因
| 维度 | CloudFlare Vectorize | Pinecone / Milvus |
|---|---|---|
| 部署位置 | 边缘节点,离用户更近 | 独立集群,需要跨网络请求 |
| 与 Workers AI 集成 | 原生集成,同一生态 | 需要额外的网络调用 |
| 冷启动延迟 | 极低(边缘计算) | 较高(需要唤醒实例) |
| 成本模型 | 按用量计费,起步免费 | 按实例时长计费,起步有成本 |
| 适用规模 | 中小规模(百万级向量) | 大规模(十亿级向量) |
对于 AI 伴侣的用户规模,Vectorize 的边缘部署和低延迟更符合项目需求。用户发出一条消息后,记忆检索的耗时需要控制在 50ms 以内。边缘节点与用户的物理距离更近,这一点对降低检索延迟很重要。
Vectorize 的基本用法
01// 创建索引(通常在项目初始化时执行一次)02// wrangler vectorize create memory-index03// --dimensions=768 --metric=cosine0405// 在 Worker 中使用06export default {07async fetch(request, env) {08const { text, userId } = await request.json()0910// 1. 生成 Embedding11const embedding = await env.AI.run(12'@cf/baai/bge-base-en-v1.5',13{ text: [text] }14)1516// 2. 检索相似记忆17const matches = await env.VECTORIZE.query(18embedding.data[0],19{ topK: 5, filter: { userId } }20)2122return Response.json(matches)23}24}
在这段代码中,生成 Embedding 和检索向量都在 CloudFlare 的边缘网络内完成,不需要跨云通信。Embedding 模型(Workers AI)与向量数据库(Vectorize)位于同一个数据中心,因此可以减少额外的网络延迟。
7. 优化检索质量
接入向量检索只是第一步。在实际工程中,记忆的切分方式、检索范围和结果排序,都会直接影响 AI 女友的记忆质量。
记忆片段的粒度控制
如果记忆粒度太粗,例如把整段对话存为一条,检索时的语义就不够集中,容易召回无关内容。如果粒度太细,每句话都单独存储,又会丢失上下文,导致检索到的片段缺乏连贯性。
项目中通常可以按一个完整事件或一个明确事实进行切分:
1// ❌ 太粗:整段对话作为一条记忆2"2024-03-14的完整对话记录(500字)"34// ❌ 太细:每句话一条记忆5"用户说了生日快乐"67// ✅ 合适:一个事件/事实一条记忆8"用户生日当天去了 Blue Note 爵士酒吧庆祝"9"用户表示以后每年生日都想去 Blue Note"
元数据过滤 + 向量检索的组合
只依赖向量相似度并不总是足够。例如,用户提到“昨天晚上的事”时,系统应该先根据时间缩小检索范围,再在这个范围内进行语义匹配。
1const results = await vectorIndex.query(queryVector, {2topK: 5,3filter: {4userId: "user_123",5// 先用元数据缩小范围6timestamp: { $gte: "2024-03-13T00:00:00Z" }7}8})
这种结构化过滤 + 语义排序的混合检索策略,在实际项目中通常比纯向量搜索更有效。
重排序(Reranking)向量检索返回的 Top-K 结果不一定已经按最合理的顺序排列。我们可以使用轻量的 Cross-Encoder 模型对候选结果做二次排序,进一步提高准确度。这一步只需要对 5-10 条候选结果进行推理,成本较低,但对排序质量的改善很明显。
8. 总结
回到文章开头的问题,AI 伴侣的长期记忆之所以能够被找回来,关键就在于 Embedding 与语义检索。整个过程可以归纳为以下几点:
- 传统关键词搜索无法处理语义相似、字面不同的表达,AI 伴侣场景因此需要使用语义检索。
- Embedding 会将文本映射为高维向量,语义相近的文本在向量空间中距离更近。
- 余弦相似度是衡量语义相似性的首选指标,它只关注向量方向,不受向量长度影响。
- 完整的处理过程是:记忆产生 切分提炼 向量化 存入向量库 检索召回 注入上下文。
- CloudFlare Vectorize 的边缘部署特性,为实时记忆检索提供了低延迟保障。
- 工程上还需要控制记忆的切分粒度、通过元数据预先过滤,并使用 Reranking 进行精排。
下一篇我们会继续设计情绪状态机,看看 AI 女友的记仇、害羞和吃醋是如何实现的。