RAG 检索缓存的双层架构:L1 精确匹配 + L2 语义匹配的设计权衡
Zhongjun Qiu 元婴开发者

在 RAG 系统中,检索延迟和 API 成本是两大痛点。本文记录了我们在 CookHero 项目中设计的 L1+L2 双层缓存架构——用 Redis 做精确匹配,用 Milvus 做语义匹配——以及这个设计背后的思考和踩过的坑。

问题背景:检索太慢,成本太高

CookHero 是一个基于 RAG 的烹饪助手。用户问”红烧肉怎么做”,系统需要:

  1. Embedding 计算:把 query 转成向量(~100ms)
  2. 向量检索:在 Milvus 中搜索相似文档(~150ms)
  3. Rerank:用 Reranker 模型重排结果(~200ms)
  4. 后处理:拉取完整文档、格式化(~50ms)

单次检索 500ms+,对于需要流畅对话的产品来说太慢了。而且 Embedding 和 Rerank 都有 API 成本。

更关键的是,用户的查询高度重复。分析线上日志,我们发现:

  • 30% 的查询完全相同(“红烧肉怎么做”“西红柿炒鸡蛋”)
  • 另外 40% 的查询语义高度相似(“红烧肉做法” vs “红烧肉怎么烧”)

如果能缓存这些结果,性能和成本都能大幅优化。

第一直觉:用 Redis 缓存

最简单的方案:query 作为 key,检索结果作为 value,塞 Redis。

1
2
3
4
cache_key = f"rag:retrieval:{hash(query)}"
cached = await redis.get(cache_key)
if cached:
return pickle.loads(cached)

上线后确实有效,命中率约 25%,命中时延迟降到 10ms 以内。

但问题很快暴露:

  • “红烧肉怎么做”和”红烧肉做法”是两个 key,尽管它们应该返回相同结果
  • “想做个红烧肉”也是不同的 key,又是一次 cache miss

用户不会每次都用完全相同的措辞,精确匹配的命中率有明显天花板

加入语义缓存(L2)

既然问题是”措辞不同但语义相同”,那就用向量相似度来匹配。

思路是:

  1. 把 query 的 embedding 存进 Milvus
  2. 查询时先搜索是否有足够相似的历史 query
  3. 如果有,直接返回对应的缓存结果

这就是 L2 语义缓存。

1
2
3
4
5
6
# 检查语义相似的缓存
query_embedding = embeddings.embed_query(query)
similar = await milvus_cache.search(query_embedding, threshold=0.92)
if similar:
cached_docs, similarity = similar
return cached_docs

为什么用 Milvus 而不是另一个 Redis 模块?

我们确实考虑过 Redis 的向量搜索能力(Redis Stack),但最终选择 Milvus:

  1. 复用现有基础设施:Milvus 已经用于主检索,不想引入新组件
  2. 性能更好:Milvus 的 HNSW 索引在我们的数据规模下延迟更低
  3. 一致的向量模型:缓存和主检索用同一个 embedding 模型,避免表征不一致

相似度阈值的选择

这是个需要调参的地方。我们测试了几个值:

阈值 命中率 误命中率(返回错误结果)
0.85 62% 8.3%
0.90 48% 2.1%
0.92 41% 0.9%
0.95 28% 0.2%

0.92 是我们找到的平衡点。命中率 41%,误命中率不到 1%。

误命中的代价很高——返回错误的菜谱会直接导致用户不满。所以我们宁可保守,牺牲一些命中率。

双层架构的最终设计

最终架构是 L1 + L2:

1
2
3
4
5
6
7
查询进来

L1: Redis 精确匹配(key = hash(query))
↓ miss
L2: Milvus 语义匹配(similarity >= 0.92)
↓ miss
执行完整检索 → 结果同时写入 L1 和 L2

为什么不只用 L2?

一个自然的问题:既然 L2 可以覆盖”语义相似”的场景,为什么还要 L1?

因为 L1 更快、更便宜

  • L1 查询:Redis GET,~1ms,无 embedding 计算
  • L2 查询:需要先算 embedding(100ms),再向量搜索(10ms)

如果 query 完全相同,L1 直接命中,省掉 embedding 计算。这对于热门查询(占 30%)效果显著。

缓存 key 的设计

L1 的 key 设计需要考虑多维度:

1
2
3
4
def _get_cache_key(self, data_source: str, query: str, scope: str | None = None) -> str:
query_hash = hashlib.sha256(query.encode('utf-8')).hexdigest()
scope_label = scope or "global"
return f"rag:retrieval:{data_source}:{scope_label}:{query_hash}"
  • data_source:区分不同数据源(recipes vs personal)
  • scope:区分不同用户的个人数据
  • query_hash:SHA256 保证不同 query 不会碰撞

这样设计避免了跨数据源、跨用户的缓存污染。

用户个人数据的特殊处理

CookHero 支持用户上传个人食谱。这带来一个问题:

  • 用户 A 问”我收藏的红烧肉做法”,应该返回 A 的个人食谱
  • 用户 B 问同样的话,应该返回 B 的个人食谱

个人数据的缓存必须按用户隔离

1
2
3
4
5
6
7
# 个人数据源使用 user_id 作为 scope
if source_name == "personal":
scope = user_id
else:
scope = None # 全局数据源不区分用户

cached = await cache_manager.get(source_name, query, scope)

L2 的 Milvus 缓存同样需要隔离,我们在 metadata 中存储 scope 字段:

1
2
3
# Milvus 中按 scope 过滤
expr = f'scope == "{user_id}"' if user_id else 'scope == "global"'
results = await milvus_cache.search(query_embedding, threshold, expr=expr)

缓存写入策略

写入时机

我们选择检索完成后同步写入,而不是异步:

1
2
3
4
5
6
# 检索完成后
docs, scores = await retrieval_module.hybrid_search(query, top_k)

# 同步写入缓存
if self.cache_manager:
await self.cache_manager.set(source_name, query, docs, scope)

考虑过异步写入(发消息队列、后台任务处理),但增加了复杂度。同步写入在当前规模下延迟可接受(<10ms),暂时没必要优化。

TTL 设置

缓存不能永久保存,因为:

  1. 底层数据会更新:用户可能修改个人食谱
  2. 数据量会膨胀:长期积累会占用大量存储

我们设置 TTL = 1 小时

1
await redis.setex(cache_key, 3600, pickle.dumps(documents))

1 小时是个折衷:

  • 足够长,覆盖同一用户的多轮对话
  • 足够短,数据更新后不会长期不一致

对于 L2(Milvus),我们目前没有设置 TTL,而是通过定期清理任务删除老数据。这是因为 Milvus 没有原生 TTL 支持,需要额外维护。

遇到的几个坑

1. 序列化选择

最初用 JSON 序列化 Document 对象,结果发现 LangChain 的 Document 有嵌套结构和特殊类型,JSON 处理起来很麻烦。

改用 pickle 后简单很多,但要注意:

  • pickle 有安全风险(反序列化攻击),但缓存是内部系统,可控
  • 版本兼容性问题:Document 类结构变化可能导致反序列化失败

我们的处理是:反序列化失败时当作 cache miss 处理,不阻塞主流程。

1
2
3
4
5
try:
docs = pickle.loads(cached_data)
except Exception as e:
logger.warning("Cache deserialization failed: %s", e)
return None # 当作 miss

2. Embedding 维度不匹配

换 embedding 模型后(从 1024 维换到 768 维),L2 缓存全部失效,向量搜索报维度不匹配错误。

教训:L2 缓存需要和 embedding 模型绑定。换模型时要清空缓存。

我们现在在 collection 名称中包含模型标识:

1
vector_collection = f"cookhero_cache_{embedding_model_name}"

3. 缓存穿透

有些 query 就是检索不到结果(比如”怎么做核弹”),但每次都要走完整检索流程。

解决方案:缓存空结果,TTL 设短一点(5 分钟):

1
2
3
# 即使检索结果为空,也缓存
if not docs:
await cache_manager.set(source_name, query, [], scope, ttl=300)

还在考虑的优化

  1. 查询归一化:在哈希之前对 query 做标准化(去标点、统一大小写),提高 L1 命中率
  2. 缓存预热:对热门查询提前生成缓存,而不是等首次请求
  3. 分层 TTL:热门查询 TTL 更长,冷门查询 TTL 更短
  4. 缓存失效广播:底层数据更新时主动清除相关缓存

总结

RAG 缓存设计的核心权衡是:

  • 精确匹配 vs 语义匹配:前者快但覆盖率低,后者慢但覆盖率高
  • 命中率 vs 准确率:阈值太低命中率高但可能返回错误结果
  • 复杂度 vs 收益:双层架构比单层复杂,但收益明显

我们的经验是:

  1. L1 + L2 双层值得做,收益大于复杂度成本
  2. 语义阈值要保守,误命中代价远高于 miss
  3. 用户隔离不能忘,否则会有严重的数据泄露/污染问题
  4. 失败要优雅降级,缓存是优化手段,不能变成故障点
 REWARD AUTHOR
 Comments
Comment plugin failed to load
Loading comment plugin