Appearance
缓存策略:精确缓存 + 语义缓存能省多少钱
1. 本节产出
两级缓存:精确缓存(问题字符串完全匹配)+ 语义缓存(向量相似匹配)。并且有明确的失效策略——文档更新后相关缓存必须被清除,否则用户会拿到过期答案。
2. 前置依赖
- 02-14 增量更新:文档变更是缓存失效的触发源
- 02-12 多租户隔离:缓存 key 必须含租户
3. 为什么缓存是 RAG 降本的第一手段
一个真实分布:某企业知识库一周的查询日志里,
| 查询类型 | 占比 |
|---|---|
| 完全重复的字符串 | 12% |
| 语义相同但表述不同 | 31% |
| 全新查询 | 57% |
意味着:43% 的查询理论上可以命中缓存。 按每次问答 0.01 元、日查询 5000 次算,一年可省约 7800 元——而这还只是直接成本,没算延迟改善带来的体验提升。
精确缓存只能覆盖 12%,语义缓存才是大头。 这是本节要讲的重点。
4. 核心原理
4.1 两级缓存
请求
│
▼ L1 精确缓存:hash(tenantId + question + version)
│ 命中率约 12%,耗时 < 5ms
│
▼ L2 语义缓存:向量相似检索已缓存的问答对
│ 命中率约 20~30%,耗时 10~30ms
│
▼ 未命中 → 完整 RAG 链路(300~800ms)4.2 语义缓存怎么判断「相同」
1. 把问题向量化
2. 在「缓存向量库」里检索最相似的已缓存问题
3. 相似度 > 阈值(如 0.95)→ 判定为同一问题,返回缓存答案阈值必须很高(0.95+)。设 0.85 会导致「年假怎么算」和「病假怎么算」被判定为同一个问题,返回错误答案——这种错误比慢一点严重得多。
4.3 缓存失效:最容易被忽略的部分
文档更新 → 依赖该文档的缓存答案全部过期三条失效策略:
| 策略 | 做法 | 适用 |
|---|---|---|
| TTL | 缓存 24 小时过期 | 兜底,必配 |
| 按 sourceId 失效 | 记录答案引用了哪些 sourceId,文档更新时清掉 | 推荐,精确 |
| 版本号进 key | key 含知识库版本号,发布新版本自动全量失效 | 简单粗暴但有效 |
推荐组合:TTL + 按 sourceId 失效。
4.4 什么不该缓存
| 类型 | 原因 |
|---|---|
| 含个人信息的查询 | 缓存会导致 A 看到 B 的结果 |
| 实时数据类查询 | 「当前库存」缓存了就是错的 |
| 带用户上下文的追问 | 同一个字符串对不同用户含义不同 |
| 写操作类 | 绝不能缓存 |
第一条是安全红线:缓存 key 里只有租户不够,如果问题里含「我的工资是多少」,缓存会让同租户的其他人命中同一个答案。
5. 代码走查
5.1 缓存 key(含租户与版本)
java
// ch02-rag/src/main/java/com/aitech/rag/cache/CacheKey.java
public final class CacheKey {
/** 强制要求租户参数,避免遗漏导致越权 */
public static String exact(String tenantId, String question, String kbVersion) {
String raw = tenantId + "|" + kbVersion + "|" + question.trim().toLowerCase();
return DigestUtils.sha256Hex(raw);
}
}提示:key 构造函数强制要求
tenantId,是为了让「忘记加租户」在编译期就不可能发生。这是 02-12 提到的越权漏洞的直接防御。
5.2 精确缓存
java
// ch02-rag/src/main/java/com/aitech/rag/cache/ExactCache.java
@Service
public class ExactCache {
private final RedisTemplate<String, String> redis;
private final Duration ttl = Duration.ofHours(24);
public Optional<Answer> get(String tenantId, String question, String kbVersion) {
String key = CacheKey.exact(tenantId, question, kbVersion);
String json = redis.opsForValue().get(key);
return json == null ? Optional.empty() : Optional.of(Json.decode(json, Answer.class));
}
public void put(String tenantId, String question, String kbVersion,
Answer answer, Set<String> sourceIds) {
String key = CacheKey.exact(tenantId, question, kbVersion);
redis.opsForValue().set(key, Json.encode(answer), ttl);
// 记录反向索引:sourceId → 缓存 key 列表(用于精确失效)
sourceIds.forEach(sid ->
redis.opsForSet().add("cache:src:" + sid, key));
}
/** 文档更新时调用 */
public void evictBySource(String sourceId) {
Set<String> keys = redis.opsForSet().members("cache:src:" + sourceId);
if (keys != null && !keys.isEmpty()) {
redis.delete(keys);
log.info("失效缓存 {} 条,sourceId={}", keys.size(), sourceId);
}
}
}5.3 语义缓存
java
// ch02-rag/src/main/java/com/aitech/rag/cache/SemanticCache.java
@Service
public class SemanticCache {
private static final double THRESHOLD = 0.95; // 必须很高
public Optional<Answer> get(String tenantId, String question) {
List<CachedQa> hits = cacheStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(1)
.similarityThreshold(THRESHOLD)
.filterExpression("tenantId == '" + tenantId + "'")
.build());
if (hits.isEmpty()) return Optional.empty();
log.info("语义缓存命中:{} ≈ {}", question, hits.get(0).text());
return Optional.of(hits.get(0).answer());
}
}过滤条件里带 tenantId 是硬要求。语义缓存本质上也是一个检索,同样存在越权风险。
5.4 接入链路
java
// ch02-rag/src/main/java/com/aitech/rag/chain/CachedRagChain.java
@Service
public class CachedRagChain {
public Answer answer(String question, String tenantId, List<String> acl) {
// L1 精确
var hit1 = exactCache.get(tenantId, question, kbVersion());
if (hit1.isPresent()) {
Metrics.recordCacheHit("exact");
return hit1.get();
}
// L2 语义
var hit2 = semanticCache.get(tenantId, question);
if (hit2.isPresent()) {
Metrics.recordCacheHit("semantic");
return hit2.get();
}
// 未命中 → 完整链路
Answer answer = ragChain.answer(question, tenantId, acl);
// 写缓存(只缓存有引用的答案,拒答不缓存)
if (answer.hasCitations()) {
exactCache.put(tenantId, question, kbVersion(), answer, answer.sourceIds());
semanticCache.put(tenantId, question, answer);
}
return answer;
}
}注意 answer.hasCitations():拒答结果不缓存。否则文档补充后,用户仍会拿到「资料中没有」的旧答案。
6. 跑起来
bash
git checkout ch02-15-cache
docker compose up -d
mvn -q test -Dtest=CacheStrategyTestbash
# 1. 首次查询(未命中)
curl -X POST http://localhost:8080/api/ask -d '{"q":"年假怎么算","tenantId":"acme"}'
# 期望:X-Cache: MISS,耗时 600ms
# 2. 完全重复(L1 命中)
curl -X POST http://localhost:8080/api/ask -d '{"q":"年假怎么算","tenantId":"acme"}'
# 期望:X-Cache: HIT-EXACT,耗时 < 10ms
# 3. 语义相同(L2 命中)
curl -X POST http://localhost:8080/api/ask -d '{"q":"年假是如何计算的","tenantId":"acme"}'
# 期望:X-Cache: HIT-SEMANTIC
# 4. 不同租户(必须未命中)
curl -X POST http://localhost:8080/api/ask -d '{"q":"年假怎么算","tenantId":"beta"}'
# 期望:X-Cache: MISS(关键安全验证)
# 5. 文档更新后失效
curl -X POST http://localhost:8080/api/publish -d '{"sourceId":"handbook","version":"v3"}'
curl -X POST http://localhost:8080/api/ask -d '{"q":"年假怎么算","tenantId":"acme"}'
# 期望:X-Cache: MISS(缓存已失效)| 检查项 | 通过标准 |
|---|---|
| L1 命中 | 完全重复时命中,耗时 < 10ms |
| L2 命中 | 语义相同时命中 |
| 跨租户不命中 | 不同租户相同问题必须 MISS |
| 失效生效 | 文档发布后缓存被清除 |
| 拒答不缓存 | 拒答结果不写入缓存 |
7. 生产避坑
- 语义缓存的阈值必须很高(0.95+)。这是本节唯一的「数值型硬要求」:阈值低一点,命中率上升,但会把不同问题判定为相同,返回错误答案。错误的答案比慢的答案糟糕得多——宁可少缓存,不可缓存错。
- 缓存 key 必须包含 tenantId,语义缓存过滤也要带租户。缓存是二次引入的越权漏洞高发区,因为功能测试通常用单租户跑,根本测不出来。做法:写专门的跨租户断言测试。
- 文档更新必须触发缓存失效。没有失效机制的话,用户会一直拿到旧答案,而且表现是「答案看起来正常但内容是过期的」——这种错误最难被发现,因为系统不报错。
8. 延伸与锚点
- 思考题:灌入 1000 篇文档时,串行处理要几小时。怎么并行化且不影响在线检索?(答案在下一课时)
- 代码锚点:
git checkout ch02-15-cache - 下一课时:02-16 异步化与批处理
- 对应课件:L02-15 缓存策略