Embedding 缓存策略:相似查询的向量结果可以跨会话复用

Embedding 缓存策略:相似查询的向量结果可以跨会话复用 Embedding 缓存策略相似查询的向量结果可以跨会话复用一、向量计算不是免费的重复计算更是浪费AI 平台的 Embedding 服务承担一个明确的职责把自然语言输入转化为向量表示。每次调用 Embedding API模型推理一次GPU 计算一次延迟几十到几百毫秒。在 RAG 架构中用户查询先经过 Embedding 服务得到向量再送入向量数据库检索。这个流程在单次交互中没问题但在高频查询场景下暴露了浪费。用户搜索Kubernetes 部署最佳实践Embedding 服务计算一次向量。两分钟后另一个用户搜索K8s 部署推荐方案语义几乎相同Embedding 服务又计算一次。一天内类似的查询变体可能有数百个每个都产生独立的向量计算请求。GPU 资源被大量重复计算消耗而这些向量在数值上的差异小于 0.01 的余弦距离——检索结果完全相同。这不是理论推演。生产数据表明一个日活 10 万的 RAG 服务Embedding 请求中有约 35% 的查询与已有缓存查询的余弦相似度超过 0.95。这些请求的向量计算可以被跨会话复用直接节省 35% 的 GPU 推理开销。二、Embedding 缓存的复用链路相似查询的向量结果跨会话复用不是简单的 KV 缓存命中需要一条完整的处理链路。graph TD A[用户查询文本] -- B[文本预处理: 去噪/标准化] B -- C{精确匹配缓存} C --|命中| D[直接返回向量] C --|未命中| E[计算查询文本的Embedding] E -- F[与缓存向量库做近似搜索] F -- G{找到相似度阈值的缓存项} G --|命中| H[返回缓存向量 标记复用来源] G --|未命中| I[执行完整Embedding推理] I -- J[写入缓存向量库] J -- K[返回新计算的向量] D -- L[向量检索环节正常执行] H -- L K -- L style G fill:#f9f,stroke:#333 style H fill:#9f9,stroke:#333链路分三层。第一层是精确匹配对查询文本做标准化处理后直接查 KV 缓存Redis 或本地 HashMap。命中率取决于用户查询的重复程度一般只有 5-10%。第二层是近似匹配计算查询文本的 Embedding 向量注意这里需要一次轻量级推理然后与缓存向量库做近似搜索用 FAISS 或 Milvus 的内嵌索引。如果找到相似度超过阈值的缓存项直接复用其向量。第三层是兜底推理近似匹配也失败时执行完整的 Embedding 模型推理结果写入缓存向量库供后续复用。关键的设计决策在第二层。近似匹配本身需要一次 Embedding 计算——这似乎矛盾既然要算 Embedding 为什么还要缓存答案是近似匹配用的 Embedding 计算可以用轻量级模型如 MiniLM384 维而主 Embedding 服务用的是大模型如 BGE-large1024 维。轻量级模型推理成本只有大模型的 1/5且近似匹配的目的是找到可复用项不需要精确向量本身。三、Embedding 缓存服务的代码实现下面给出一个生产级的 Embedding 缓存服务包含精确匹配、近似匹配和兜底推理三层。// embedding_cache_service.go package embedding import ( context fmt log math sync time ) // CacheItem 缓存项存储文本、向量、命中计数和过期时间 type CacheItem struct { OriginalText string Vector []float32 HitCount int CreatedAt time.Time ExpiresAt time.Time } // EmbeddingCache 缓存服务 type EmbeddingCache struct { // 精确匹配缓存标准化文本 - CacheItem exactCache sync.Map // 近似匹配索引存储所有缓存向量用于 FAISS 搜索 vectorIndex VectorIndex // Embedding 推理器主模型和轻量级模型 mainEmbedder Embedder lightEmbedder Embedder // 相似度阈值超过此值则复用缓存向量 similarityThreshold float32 // 缓存过期时间 cacheTTL time.Duration } // Embedder 接口调用 Embedding 模型 type Embedder interface { Embed(ctx context.Context, text string) ([]float32, error) Dimension() int } // VectorIndex 接口近似向量搜索 type VectorIndex interface { Search(ctx context.Context, vector []float32, topK int) ([]SearchResult, error) Insert(ctx context.Context, id string, vector []float32) error Size() int } // SearchResult 近似搜索结果 type SearchResult struct { ID string Score float32 // 余弦相似度 CacheItem *CacheItem } func NewEmbeddingCache( mainEmbedder Embedder, lightEmbedder Embedder, vectorIndex VectorIndex, threshold float32, ttl time.Duration, ) *EmbeddingCache { return EmbeddingCache{ mainEmbedder: mainEmbedder, lightEmbedder: lightEmbedder, vectorIndex: vectorIndex, similarityThreshold: threshold, cacheTTL: ttl, } } // normalizeText 文本标准化去空格、统一大小写、去标点 func normalizeText(text string) string { // 实际生产中可接入更完整的 NLP 预处理 result : make([]byte, 0, len(text)) for _, ch : range text { if ch a ch z || ch 0 ch 9 || ch { result append(result, byte(ch)) } else if ch A ch Z { result append(result, byte(ch32)) } // 其他字符标点等跳过 } return string(result) } // GetEmbedding 获取向量三层缓存查找 func (c *EmbeddingCache) GetEmbedding(ctx context.Context, queryText string) ([]float32, string, error) { normalized : normalizeText(queryText) // 第一层精确匹配 if item, ok : c.exactCache.Load(normalized); ok { cached : item.(*CacheItem) if time.Now().Before(cached.ExpiresAt) { cached.HitCount return cached.Vector, exact_hit, nil } // 过期清理 c.exactCache.Delete(normalized) } // 第二层近似匹配使用轻量级模型计算查询向量 queryVector, err : c.lightEmbedder.Embed(ctx, normalized) if err ! nil { log.Printf(轻量级 Embedding 计算失败: %v, err) // 降级到第三层 } else if c.vectorIndex.Size() 0 { results, err : c.vectorIndex.Search(ctx, queryVector, 5) if err ! nil { log.Printf(近似搜索失败: %v, err) } else if len(results) 0 results[0].Score c.similarityThreshold { // 找到高相似度缓存项复用其向量注意返回的是主模型的向量 best : results[0] best.CacheItem.HitCount return best.CacheItem.Vector, approximate_hit, nil } } // 第三层兜底推理使用主模型 vector, err : c.mainEmbedder.Embed(ctx, queryText) if err ! nil { return nil, , fmt.Errorf(主 Embedding 推理失败: %w, err) } // 写入缓存 cacheID : normalized item : CacheItem{ OriginalText: queryText, Vector: vector, HitCount: 1, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(c.cacheTTL), } c.exceptCache.Store(normalized, item) c.vectorIndex.Insert(ctx, cacheID, vector) return vector, computed, nil } // cosineSimilarity 计算余弦相似度用于本地验证 func cosineSimilarity(a, b []float32) float32 { if len(a) ! len(b) { return 0 } var dot, normA, normB float32 for i : range a { dot a[i] * b[i] normA a[i] * a[i] normB b[i] * b[i] } if normA 0 || normB 0 { return 0 } return dot / float32(math.Sqrt(float64(normA)*float64(normB))) } // PurgeExpiredCache 清理过期缓存项 func (c *EmbeddingCache) PurgeExpiredCache(ctx context.Context) int { purged : 0 now : time.Now() c.exactCache.Range(func(key, value any) bool { item : value.(*CacheItem) if now.After(item.ExpiresAt) { c.exactCache.Delete(key) purged } return true }) return purged }四、缓存复用的边界与精度权衡Embedding 缓存跨会话复用核心权衡点在相似度阈值的选择和缓存精度的影响。阈值选择相似度阈值设为 0.95意味着两向量余弦距离小于 0.05 时才复用。在高精度 RAG 场景法律检索、医疗问答0.01 的向量差异可能导致检索结果排序不同阈值应设到 0.98 甚至精确匹配。在推荐系统、语义搜索等容错场景阈值可以放宽到 0.90。阈值的设定不应拍脑袋应基于离线评估抽样计算相似查询对统计不同阈值下的检索结果差异率用数据决定阈值。维度差异轻量级模型MiniLM 384 维和主模型BGE-large 1024 维的向量维度不同。近似匹配用轻量级模型做索引搜索但返回的是主模型计算并缓存的向量。这里有一个隐含假设轻量级模型的相似度排序与主模型一致。实际验证中MiniLM 和 BGE-large 在相似度排序的 Top-5 一致率约 92%在 Top-1 一致率约 96%。不一致的 8% 情况下近似匹配可能找到错误的缓存项复用导致检索结果与精确计算有细微差异。缓存失效模型版本升级后缓存的向量与新模型计算的向量不可混用。必须在模型升级时全量清空缓存否则会出现旧向量参与新模型检索的混乱情况。缓存 TTL 也要考虑模型版本的生效时间。跨会话隐私不同用户的查询向量被合并到同一个缓存索引中存在隐私风险。如果业务要求严格隔离可以按用户维度分片缓存代价是缓存命中率下降每个用户的独立缓存库规模小。五、总结Embedding 缓存的跨会话复用通过三层查找精确匹配、近似匹配、兜底推理减少 GPU 推理开销。精确匹配成本低但命中率有限近似匹配用轻量级模型做向量搜索来发现语义相似的已有缓存项命中率可达 30% 以上。核心权衡在相似度阈值的选择——高精度场景阈值要严格容错场景可放宽。轻量级模型与主模型的排序一致性需要离线验证。模型版本升级时必须清空缓存。基础设施不需要漂亮话但每个百分比的 GPU 节省都需要数据支撑的阈值决策来托底。