18.07-Embedding缓存
要点
- 同一段文本的 embedding 几乎永远不变——embedding 模型是确定性的,缓存 TTL 可以设得很长
- embedding 缓存是 RAG 系统的必备优化——文档切块后 embedding 计算量大、价格不便宜
- 缓存键用文本 hash(SHA-256),不是文本本身——避免超长 URL 和特殊字符问题
- Cache API(节点级)适合热点 embedding;KV(全球)适合跨节点共享;Vectorize 自带持久化不需要缓存
- 批处理时缓存命中率极高——同一批文档切块重复出现的概率大
- 失效策略:文本变了就重新算(hash 自然不同);模型升级时版本号前缀变更
1. 为什么 embedding 值得缓存
embedding 有几个特点让它成为缓存的理想对象:
- 确定性:同一个 embedding 模型,同一段文本,输出永远相同(除非模型升级)
- 计算成本:embedding 虽然比 LLM 便宜,但也不是免费的——每次调用都有 token 费用
- 计算延迟:embedding 调用 50-200ms,比 KV 读取(5ms)慢一个数量级
- 重复性高:RAG 系统里同一批文档会被反复查询,同一段文本的 embedding 会被算多次
算一下:1000 个文档切块,每个 512 token。每次查询都要算 1000 个 embedding?不——embedding 算一次就够了,存起来复用。
2. 缓存键设计
2.1 用文本 hash,不用文本本身
直接拿文本做 cache key 有几个问题:
- 文本可能很长(512 token × 几字节 = 几 KB),cache key 太大
- 特殊字符(换行、引号、emoji)需要 URL 编码,麻烦
- 相同文本的 hash 相同,天然去重
用 SHA-256 hash:
async function textHash(text: string): Promise<string> {
const buf = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(text)
)
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}2.2 缓存键包含模型名
不同 embedding 模型输出的向量不同。缓存键必须包含模型标识:
async function embeddingCacheKey(model: string, text: string): Promise<string> {
const hash = await textHash(text)
return `embed:${model}:${hash}`
}否则换模型后会读到旧模型的向量,搜索质量下降。
3. 实现
3.1 用 Cache API(节点级)
// src/lib/embed.ts
export async function embedWithCacheAPI(
env: { AI: Ai },
model: string,
text: string
): Promise<number[]> {
const hash = await textHash(text)
const cacheKey = new Request(`https://cache.example/embed/${model}/${hash}`)
// 1. 查缓存
const cached = await caches.default.match(cacheKey)
if (cached) {
const data = (await cached.json()) as { values: number[] }
return data.values
}
// 2. miss,调用模型
const { data } = await env.AI.run(model, { text: [text] })
const values = data[0]
// 3. 写缓存,TTL 30 天(embedding 几乎永不过期)
const response = Response.json(
{ values },
{ headers: { 'Cache-Control': 'public, max-age=2592000' } } // 30 天
)
await caches.default.put(cacheKey, response.clone())
return values
}Cache API 是节点级缓存——东京节点写的,大阪读不到。但 embedding 是确定性输出,不同节点各自缓存一份也没问题(反正结果一样)。
3.2 用 KV(全球共享)
export async function embedWithKV(
env: { AI: Ai; EMBED_KV: KVNamespace },
model: string,
text: string
): Promise<number[]> {
const key = await embeddingCacheKey(model, text)
// 1. 查 KV
const cached = await env.EMBED_KV.get(key, { type: 'json' })
if (cached) {
return (cached as { values: number[] }).values
}
// 2. miss,调用模型
const { data } = await env.AI.run(model, { text: [text] })
const values = data[0]
// 3. 写 KV,TTL 30 天
await env.EMBED_KV.put(key, JSON.stringify({ values }), {
expirationTtl: 2592000,
})
return values
}KV 是全球共享——东京写入,大阪立即可读(实际有几分钟传播延迟,但 embedding 场景无所谓)。
3.3 两层叠用
export async function embedSmart(
env: { AI: Ai; EMBED_KV: KVNamespace },
model: string,
text: string
): Promise<number[]> {
const key = await embeddingCacheKey(model, text)
// L1:Cache API(节点级,最快)
const cacheKey = new Request(`https://cache.example/embed/${key}`)
const l1 = await caches.default.match(cacheKey)
if (l1) {
const data = (await l1.json()) as { values: number[] }
return data.values
}
// L2:KV(全球共享)
const l2 = await env.EMBED_KV.get(key, { type: 'json' })
if (l2) {
const values = (l2 as { values: number[] }).values
// 回填 L1
const response = Response.json(
{ values },
{ headers: { 'Cache-Control': 'public, max-age=2592000' } }
)
await caches.default.put(cacheKey, response.clone())
return values
}
// L3:调用模型
const { data } = await env.AI.run(model, { text: [text] })
const values = data[0]
// 回填 L2 和 L1
await env.EMBED_KV.put(key, JSON.stringify({ values }), {
expirationTtl: 2592000,
})
const response = Response.json(
{ values },
{ headers: { 'Cache-Control': 'public, max-age=2592000' } }
)
await caches.default.put(cacheKey, response.clone())
return values
}L1 命中最快(0.1ms),L2 次之(5ms),只有完全新的文本才跑模型(100ms+)。
4. 批处理 embedding
文档上传后通常要切成多个 chunk 分别 embedding。批处理时缓存命中率很高——同一批文档可能重复出现某些 chunk。
4.1 批量接口
大多数 embedding 模型支持批量输入(一次传多条文本):
const { data } = await env.AI.run(model, {
text: ['text1', 'text2', 'text3'], // 一次处理多条
})
// data[0], data[1], data[2] 分别是三段的 embedding4.2 批量 + 缓存
export async function embedBatch(
env: { AI: Ai; EMBED_KV: KVNamespace },
model: string,
texts: string[]
): Promise<number[][]> {
const results: (number[] | null)[] = new Array(texts.length).fill(null)
const toCompute: { index: number; text: string }[] = []
// 1. 批量查缓存
await Promise.all(
texts.map(async (text, index) => {
const key = await embeddingCacheKey(model, text)
const cached = await env.EMBED_KV.get(key, { type: 'json' })
if (cached) {
results[index] = (cached as { values: number[] }).values
} else {
toCompute.push({ index, text })
}
})
)
// 2. 只算 miss 的
if (toCompute.length > 0) {
const { data } = await env.AI.run(model, {
text: toCompute.map((t) => t.text),
})
// 3. 回填缓存和结果数组
await Promise.all(
toCompute.map(async ({ index, text }, i) => {
const values = data[i]
results[index] = values
const key = await embeddingCacheKey(model, text)
await env.EMBED_KV.put(key, JSON.stringify({ values }), {
expirationTtl: 2592000,
})
})
)
}
return results as number[][]
}批处理时缓存命中率高——同一篇文档的相邻 chunk 在不同文档里可能重复出现。
5. 失效策略
embedding 缓存几乎不需要主动失效——同一段文本的 embedding 永远相同。但有两种情况需要处理。
5.1 模型升级
embedding 模型换了(比如 bge-base-en-v1.5 → bge-base-en-v2.0),新模型输出的向量跟旧模型不同。
解决:缓存 key 里包含模型名(前面已经做了),旧模型的缓存还在,但新模型不会读到。旧缓存等 TTL 过期自然清理。
如果想立即清理,改个版本号前缀:
async function embeddingCacheKey(model: string, text: string): Promise<string> {
const hash = await textHash(text)
return `embed:v2:${model}:${hash}` // v1 → v2,旧缓存自然失效
}5.2 文本本身变了
RAG 系统里文档更新后,旧的 chunk 不再使用。但旧 chunk 的 embedding 还在缓存里——没关系,因为查询用的是新文档的新 chunk,hash 不同,自然不会命中旧缓存。
旧缓存会慢慢过期(30 天 TTL),不需要主动清理。
6. RAG 场景的命中率
RAG 系统的 embedding 缓存命中率通常很高:
- 文档切块:1000 个文档,切 10 万 chunk。每次查询只算查询文本的 embedding(1 次),文档 embedding 早就缓存了
- 重复查询:相似问题会触发相同的文档 chunk,embedding 完全复用
- 文档更新:新文档上传后,新 chunk 的 embedding 算一次,后续查询复用
实测一个中等规模的 RAG 系统:
- 日均查询:10 万次
- 文档 embedding 缓存命中率:95%(只有新文档上传时才 miss)
- 查询 embedding 缓存命中率:30-50%(取决于用户问题的多样性)
每天省下 9 万多次 embedding 调用——按 Cloudflare AI 的价格,$0.0001/千 token,能省几十美元/月。听起来不多,但延迟收益更大(95% 的请求不用等 100ms 跑 embedding)。
7. Vectorize 是否需要缓存
Cloudflare Vectorize(向量索引)本身就持久化了——你把 embedding 写进去,它就一直在。查询时直接从索引取,不需要重新算 embedding。
但 Vectorize 只存 document 的 embedding。查询时还是要算 query 的 embedding——这个可以缓存。
// 文档上传时:算 embedding 并存进 Vectorize
await env.VECTOR_INDEX.upsert([{
id: chunkId,
values: await embedSmart(env, model, chunk.text),
}])
// 查询时:算 query 的 embedding(可缓存)
const queryEmbedding = await embedSmart(env, model, query)
const results = await env.VECTOR_INDEX.query(queryEmbedding, { topK: 5 })8. 小结
embedding 缓存是 RAG 系统的必备优化:
- 缓存键:文本 hash + 模型名,避免超长 key 和特殊字符
- 两层叠用:Cache API(L1,节点级,最快)+ KV(L2,全球共享)
- 批处理:批量查缓存,只算 miss 的部分,回填缓存
- TTL:30 天起步——embedding 几乎永不过期
- 失效:模型升级改版本号前缀;文本变化不需要主动失效(hash 不同)
RAG 场景的命中率通常 95%+——文档 embedding 算一次,后续查询全部复用。省下 embedding 调用成本的同时,延迟收益更大(95% 的请求不用等 100ms)。
下一篇讲限流算法——缓存讲完了,进入限流与性能优化。限流的四种基本算法:固定窗口、滑动窗口、漏桶、令牌桶,各自适合什么场景。