Skip to content

缓存策略:精确缓存 + 语义缓存能省多少钱 ​

1. 本节产出 ​

两级缓存:精确缓存(问题字符串完全匹配)+ 语义缓存(向量相似匹配)。并且有明确的失效策略——文档更新后相关缓存必须被清除,否则用户会拿到过期答案。

2. 前置依赖 ​

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,文档更新时清掉推荐,精确
版本号进 keykey 含知识库版本号,发布新版本自动全量失效简单粗暴但有效

推荐组合:TTL + 按 sourceId 失效。

4.4 什么不该缓存 ​

类型原因
含个人信息的查询缓存会导致 A 看到 B 的结果
实时数据类查询「当前库存」缓存了就是错的
带用户上下文的追问同一个字符串对不同用户含义不同
写操作类绝不能缓存

第一条是安全红线:缓存 key 里只有租户不够,如果问题里含「我的工资是多少」,缓存会让同租户的其他人命中同一个答案。

5. 代码走查 ​

5.1 缓存 key(含租户与版本) ​

java
// src/main/java/com/example/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
// src/main/java/com/example/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
// src/main/java/com/example/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
// src/main/java/com/example/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=CacheStrategyTest
bash
# 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. 生产避坑 ​

  1. 语义缓存的阈值必须很高(0.95+)。这是本节唯一的「数值型硬要求」:阈值低一点,命中率上升,但会把不同问题判定为相同,返回错误答案。错误的答案比慢的答案糟糕得多——宁可少缓存,不可缓存错。
  2. 缓存 key 必须包含 tenantId,语义缓存过滤也要带租户。缓存是二次引入的越权漏洞高发区,因为功能测试通常用单租户跑,根本测不出来。做法:写专门的跨租户断言测试。
  3. 文档更新必须触发缓存失效。没有失效机制的话,用户会一直拿到旧答案,而且表现是「答案看起来正常但内容是过期的」——这种错误最难被发现,因为系统不报错。

8. 延伸与锚点 ​

  • 思考题:灌入 1000 篇文档时,串行处理要几小时。怎么并行化且不影响在线检索?(答案在下一课时)
  • 代码锚点:git checkout ch02-15-cache
  • 下一课时:02-16 异步化与批处理
  • 对应课件:L02-15 缓存策略