日期:2026-07-31
我们维护的一套 RAG 问答,平时挺稳,一到大促类活动或白天咨询高峰就吃紧。排查发现瓶颈不在大模型推理,而在向量检索:热门问题被反复问,每次都要重新算 embedding、再跑一遍 Milvus 近邻检索,底座 GPU 和向量库一起被高频小请求拖着走,P99 延迟忽高忽低。我们算过一笔账,top 5% 的热门 query 占了约 40% 的检索量,却每次都走完整链路,纯属浪费。方向很清晰:给检索加缓存,把热门请求拦在更前面的层级。
我们在 RAG 链路上叠了三级缓存。第一级是 query 级语义缓存:对归一化后的 query 算语义指纹,命中直接返回历史检索结果,连 embedding 都不用重算。第二级是 embedding KV 缓存:同一 query 文本复用已算好的向量,避免重复推理。第三级是检索结果缓存:把"向量 + top-k 文档"按知识库版本存起来,知识未更新就直接复用。三层都挂监控,命中率、节省的 GPU 算力、时延下降一目了然。缓存失效不靠定时清,而是和知识库版本号绑定——知识一更新,相关缓存精准失效。
挑战一是语义缓存的命中判定。字符串完全一样太苛刻,我们用"归一化 query(去停用词、统一全半角)+ 语义指纹(短文本 embedding 聚类)"双判:归一化相同直接命中,归一化不同但语义指纹相近也认为是同一意图命中。这样"怎么退换货"和"退货怎么操作"能命中同一条。
挑战二是缓存与知识更新的强一致。RAG 知识会变,缓存命中的若是旧知识就出幻觉。我们把缓存 key 带上知识库版本号(version tag),知识更新时只失效对应版本的缓存,其他版本不受影响,既准又不过度清。
挑战三是命中率与延迟的权衡。缓存不是越大越好,我们用 LRU + 命中率阈值做淘汰,冷 query 不占热缓存,监控实时反馈调整 TTL,避免内存膨胀。
上线后的脱敏示意值:整体缓存命中率约 37%(其中语义缓存约 21%、embedding 缓存约 11%、结果缓存约 5%);检索 P99 时延从约 420 毫秒降到约 180 毫秒;底座 GPU 在高峰期的检索相关负载下降约 34%;单请求综合成本(含 embedding 推理)下降约 29%。热门活动的峰值稳定了不少,没再出现时延尖刺。
给 RAG 加缓存是性价比最高的优化之一,但有几个坑。第一,先缓存热门 query,不要一上来就全量缓存,命中率低还占内存。第二,语义缓存用"归一化+语义指纹"双判,比纯字符串匹配命中率高得多。第三,缓存失效必须和知识版本绑定,否则缓存的旧知识比没缓存更危险——幻觉就藏在过期缓存里。第四,淘汰策略用命中率驱动,别拍脑袋设 TTL。我们把这套多级缓存做成了可插拔中间件,新接 RAG 场景默认开启。
给 RAG 加缓存是我们做过性价比最高的优化之一,但踩过的坑也不少。最早我们想全量缓存,结果命中率低、内存暴涨,还因为缓存了旧知识闹过一次幻觉。后来改成"先缓存热门、失效绑知识版本",才真正跑顺。
语义缓存用"归一化加语义指纹"双判,比纯字符串匹配命中率高得多。"怎么退换货"和"退货怎么操作"这种同义不同字,归一化不同但语义指纹相近,能命中同一条历史检索,省掉重复的 embedding 和向量库查询。热门活动高峰稳了不少。
缓存失效必须和知识版本绑定,这点怎么强调都不为过。RAG 的知识会变,缓存命中的若是旧知识,比没缓存更危险——幻觉就藏在过期缓存里。我们让缓存 key 带 kb_version,知识更新只失效对应版本,其他版本照常命中,既准又不过度清。
淘汰策略用命中率驱动,别拍脑袋设固定 TTL。冷 query 不该占着热缓存,监控实时反馈调整,内存和命中率才能平衡。现在这套多级缓存做成了可插拔中间件,新接 RAG 场景默认开启,几乎零成本拿到一波延迟和算力下降。
顺带提一句成本视角。缓存省下的不只是一点延迟,更是真金白银的算力。我们粗略估算,语义缓存叠加 embedding 缓存后,高峰时段底座的检索相关 GPU 占用降了三成多,同等 SLA 下可以少开一两张卡。对 RAG 这种"读多写少、热 query 集中"的场景,缓存的投入产出比极高,几乎是所有优化里最该优先做的一项。
再补一句关于工程落地的体会。缓存看起来只是加一层存储,真正麻烦的是一致性和失效策略,我们在这上面花的功夫远多于加缓存本身。知识库一旦更新,旧的检索结果如果还被命中,就会把过期信息喂给模型,这种错误比慢更可怕,也更难排查。所以我们的经验很明确:缓存方案先想清楚失效机制,再想命中率,顺序反了必然返工。此外缓存监控要和可观测体系打通,命中率掉下来能第一时间告警,而不是等用户反馈答案变傻了才发现。
案例片段(已脱敏): ```
三级缓存读取(伪代码节选)
fp = semantic_fingerprint(normalize(query)) # 归一化+语义指纹 hit = kv.get(('retrieval', fp, kb_version)) if hit: return hit # 命中,跳过 embedding+检索 emb = embedding_cache.get(normalize(query)) or model.embed(query) topk = vector_db.search(emb, top_k=K) kv.set(('retrieval', fp, kb_version), topk, ttl=adaptive_ttl())
知识更新时:invalidate_prefix(('retrieval', '*', old_version))
``` 缓存 key 带 kb_version,知识更新精准失效,避免命中旧知识。