在 RAG 系统中,检索延迟和 API 成本是两大痛点。本文记录了我们在 CookHero 项目中设计的 L1+L2 双层缓存架构——用 Redis 做精确匹配,用 Milvus 做语义匹配——以及这个设计背后的思考和踩过的坑。
问题背景:检索太慢,成本太高
CookHero 是一个基于 RAG 的烹饪助手。用户问”红烧肉怎么做”,系统需要:
- Embedding 计算:把 query 转成向量(~100ms)
- 向量检索:在 Milvus 中搜索相似文档(~150ms)
- Rerank:用 Reranker 模型重排结果(~200ms)
- 后处理:拉取完整文档、格式化(~50ms)
单次检索 500ms+,对于需要流畅对话的产品来说太慢了。而且 Embedding 和 Rerank 都有 API 成本。
更关键的是,用户的查询高度重复。分析线上日志,我们发现:
- 30% 的查询完全相同(“红烧肉怎么做”“西红柿炒鸡蛋”)
- 另外 40% 的查询语义高度相似(“红烧肉做法” vs “红烧肉怎么烧”)
如果能缓存这些结果,性能和成本都能大幅优化。
第一直觉:用 Redis 缓存
最简单的方案:query 作为 key,检索结果作为 value,塞 Redis。
1 | cache_key = f"rag:retrieval:{hash(query)}" |
上线后确实有效,命中率约 25%,命中时延迟降到 10ms 以内。
但问题很快暴露:
- “红烧肉怎么做”和”红烧肉做法”是两个 key,尽管它们应该返回相同结果
- “想做个红烧肉”也是不同的 key,又是一次 cache miss
用户不会每次都用完全相同的措辞,精确匹配的命中率有明显天花板。
加入语义缓存(L2)
既然问题是”措辞不同但语义相同”,那就用向量相似度来匹配。
思路是:
- 把 query 的 embedding 存进 Milvus
- 查询时先搜索是否有足够相似的历史 query
- 如果有,直接返回对应的缓存结果
这就是 L2 语义缓存。
1 | # 检查语义相似的缓存 |
为什么用 Milvus 而不是另一个 Redis 模块?
我们确实考虑过 Redis 的向量搜索能力(Redis Stack),但最终选择 Milvus:
- 复用现有基础设施:Milvus 已经用于主检索,不想引入新组件
- 性能更好:Milvus 的 HNSW 索引在我们的数据规模下延迟更低
- 一致的向量模型:缓存和主检索用同一个 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 | 查询进来 |
为什么不只用 L2?
一个自然的问题:既然 L2 可以覆盖”语义相似”的场景,为什么还要 L1?
因为 L1 更快、更便宜:
- L1 查询:Redis GET,~1ms,无 embedding 计算
- L2 查询:需要先算 embedding(100ms),再向量搜索(10ms)
如果 query 完全相同,L1 直接命中,省掉 embedding 计算。这对于热门查询(占 30%)效果显著。
缓存 key 的设计
L1 的 key 设计需要考虑多维度:
1 | def _get_cache_key(self, data_source: str, query: str, scope: str | None = None) -> str: |
- data_source:区分不同数据源(recipes vs personal)
- scope:区分不同用户的个人数据
- query_hash:SHA256 保证不同 query 不会碰撞
这样设计避免了跨数据源、跨用户的缓存污染。
用户个人数据的特殊处理
CookHero 支持用户上传个人食谱。这带来一个问题:
- 用户 A 问”我收藏的红烧肉做法”,应该返回 A 的个人食谱
- 用户 B 问同样的话,应该返回 B 的个人食谱
个人数据的缓存必须按用户隔离:
1 | # 个人数据源使用 user_id 作为 scope |
L2 的 Milvus 缓存同样需要隔离,我们在 metadata 中存储 scope 字段:
1 | # Milvus 中按 scope 过滤 |
缓存写入策略
写入时机
我们选择检索完成后同步写入,而不是异步:
1 | # 检索完成后 |
考虑过异步写入(发消息队列、后台任务处理),但增加了复杂度。同步写入在当前规模下延迟可接受(<10ms),暂时没必要优化。
TTL 设置
缓存不能永久保存,因为:
- 底层数据会更新:用户可能修改个人食谱
- 数据量会膨胀:长期积累会占用大量存储
我们设置 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 | try: |
2. Embedding 维度不匹配
换 embedding 模型后(从 1024 维换到 768 维),L2 缓存全部失效,向量搜索报维度不匹配错误。
教训:L2 缓存需要和 embedding 模型绑定。换模型时要清空缓存。
我们现在在 collection 名称中包含模型标识:
1 | vector_collection = f"cookhero_cache_{embedding_model_name}" |
3. 缓存穿透
有些 query 就是检索不到结果(比如”怎么做核弹”),但每次都要走完整检索流程。
解决方案:缓存空结果,TTL 设短一点(5 分钟):
1 | # 即使检索结果为空,也缓存 |
还在考虑的优化
- 查询归一化:在哈希之前对 query 做标准化(去标点、统一大小写),提高 L1 命中率
- 缓存预热:对热门查询提前生成缓存,而不是等首次请求
- 分层 TTL:热门查询 TTL 更长,冷门查询 TTL 更短
- 缓存失效广播:底层数据更新时主动清除相关缓存
总结
RAG 缓存设计的核心权衡是:
- 精确匹配 vs 语义匹配:前者快但覆盖率低,后者慢但覆盖率高
- 命中率 vs 准确率:阈值太低命中率高但可能返回错误结果
- 复杂度 vs 收益:双层架构比单层复杂,但收益明显
我们的经验是:
- L1 + L2 双层值得做,收益大于复杂度成本
- 语义阈值要保守,误命中代价远高于 miss
- 用户隔离不能忘,否则会有严重的数据泄露/污染问题
- 失败要优雅降级,缓存是优化手段,不能变成故障点