上了语义缓存之后,报表很好看:命中率 62 %,模型调用成本降了 40 %。两周后客服开始收到奇怪投诉:"同一个问题,上午问和下午问,答案不一样";更麻烦的是有人发现"问 2025 版的退款政策,回答的是 2024 版"。
这两类投诉指向同一个根因:语义缓存把"精确匹配"换成了"概率匹配"。精确缓存的错误只有一种——miss(没命中,回源)。语义缓存的错误多了一种——错命中:它认为两个问题"意思差不多",于是把上一题的答案原样给出。而"意思差不多"这个判断,本身就是一个模型输出的近似值。
1. key 从字符串变成了向量
| 维度 | 精确缓存 | 语义缓存 |
|---|---|---|
| key | 归一化后的字符串哈希 | 问题向量 + 相似度阈值 |
| 命中判定 | 完全相等 | cos(θ) ≥ 阈值 |
| 错误类型 | 只有 miss | 错命中(最危险) |
| 失效单位 | 单个 key | 一个邻域(近似问题一片) |
| 命中率 | 低(措辞变化就 miss) | 高(措辞自由) |
第四行是很多人没算到的账:精确缓存失效时清掉一条;语义缓存里一条答案可能服务几十个"近似问题",一条错了就是一片错了。
// 一次典型的错命中路径
缓存中: "2024 年退款政策是什么" → 答案 A(7 天无理由)
用户问: "2025 年退款政策是什么" → 向量相似度 0.97(只差一个年份数字)
命中 → 返回答案 A ← 措辞几乎相同,实体不同
// 相似度模型对"年份数字"不敏感,对"句式"很敏感
2. 阈值是一条交换曲线,不是调参
阈值越高,命中率越低、正确率越高;反之亦然。关键是这条曲线的拐点在哪里,以及拐点之后错命中的代价能不能承受。
| 阈值 | 命中率(经验值) | 错命中率 | 适用 |
|---|---|---|---|
| 0.95 | 20–30 % | 很低 | 强合规、不能出错 |
| 0.90 | 45–55 % | 低(需实体校验) | 大多数业务 |
| 0.85 | 60–70 % | 中(同义不同实体高发) | 低风险问答 |
| 0.80 | 75 %+ | 高 | 不建议 |
// 只看命中率会误导,三个数要一起看
$ curl -s localhost:9000/metrics | grep -E 'cache_hit|cache_miss|entity_reject'
// cache_hit 62% cache_miss 28% entity_reject 10% ← 第二道闸挡掉的那部分
// 若 entity_reject 接近 0 而投诉在涨,说明错命中根本没被拦住
第二行是实践中的常用起点,但它必须配实体校验才安全。第三行就是"命中率 62 % 但那两类投诉"的位置——数字好看,代价转移到了客服。
3. 三类错命中,判据在"实体是否一致"
| 类型 | 例子 | 修法 |
|---|---|---|
| 同义不同实体 | 2024 版 vs 2025 版;A 产品 vs B 产品 | 实体校验:抽取关键实体必须相等 |
| 时间敏感 | "现在几点开门"、"今天有什么活动" | 不缓存,或极短 TTL |
| 权限不同 | 员工问 vs 客户问同一个问题 | 权限维度进 key |
| 个性化 | "我的订单到哪了" | 完全不缓存 |
第一行的修法很具体:在相似度之外,再跑一次轻量实体比对——把问题里的数字、版本号、产品名、地区抽出来,要求完全一致才允许命中。成本远低于一次模型调用,但能挡掉绝大多数错命中。
// 两道闸:向量相似度 + 关键实体一致
def cache_lookup(q):
v = embed(q)
cand = index.search(v, k=3, threshold=0.88)
for c in cand:
if extract_entities(c.question) == extract_entities(q): // 第二道闸
return c.answer
return None // 回源
// 判据:实体抽取宁可漏,不可错——漏了只是多花一次调用
4. 什么不该进语义缓存
| 内容 | 为什么 | 处理 |
|---|---|---|
| 含时间敏感词(今天/现在/最新) | 答案会过期 | 正则拦截 + 不缓存 |
| 含用户私有数据 | 越权泄露 | 用户维度进 key 或整体禁用 |
| 有副作用的回答("已帮你下单") | 重放有副作用 | 只缓存"信息类"回答 |
| 高度个性化(我的、我的订单) | 无法跨用户复用 | 单用户缓存 |
| 结论依赖实时数据 | 立刻过期 | 极短 TTL 或不缓存 |
第二行是安全事故级的问题:如果缓存 key 只有问题向量,员工问"我的社保基数"与客户问同一句话会命中同一条答案。权限维度必须进 key,而不是只在检索层过滤——这是两个不同的地方(S20-A22 讲的是检索的权限过滤,缓存是另一处漏点)。
// 缓存 key 的最小组成(少一项就是事故)
semantic_cache:
key: [question_vector, tenant_id, user_role, locale, policy_version]
ttl: 3600 // 与知识库更新周期对齐
entity_check: true // 数字/版本/产品名必须相等
deny_patterns: ["今天", "现在", "最新", "我的"]
5. 失效:为什么 TTL 是唯一可靠的
知识库更新之后,问题向量没变,但正确答案变了。语义缓存的 key 是"问题",它感知不到答案侧的变化。
| 失效手段 | 能否感知知识更新 | 代价 |
|---|---|---|
| TTL | 能(延迟 = TTL) | 一段时间内仍答旧内容 |
| 知识版本号进 key | 能(立即) | 更新时缓存全失效(命中率骤降) |
| 答案溯源 → 主动清理 | 能(需索引) | 工程量大 |
| 不做失效 | 不能 | 长期答旧内容 |
// 版本切换时的两种策略(同一时间轴)
策略一 版本进 key + 立即全失效:命中率 62% → 3%,成本回到原样
策略二 版本进 key + 旧缓存留到 TTL:命中率 62% → 55% 缓慢回落
// 两种都能保证"新问题用新答案",差别只在旧答案还服务多久
第二行是"立即正确但代价高"的方案:知识库版本一升,全部 key 换空间,等于缓存清空。实践中常见折中:版本号进 key + 旧版本缓存保留到 TTL 自然过期,允许老用户继续命中旧版本一段时间——但这必须是有意识的决定,而不是忘了处理。
6. 排错:五种"上了缓存更糟"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 同问题两次答案不一致 | 中间发生过回源刷新 | 缓存写入带 TTL 与版本 |
| 问 A 答 B(不同版本/产品) | 阈值过低,无实体校验 | 加实体比对 |
| 命中率很高但投诉变多 | 错命中被当成功 | 降阈值前先上第二道闸 |
| 用户看到别人的数据 | 权限维度没进 key | key 加 tenant/role |
| 知识更新后仍答旧内容 | 无失效机制 | 版本号进 key + TTL |
第三行是本篇的核心判断:命中率不是这个机制的成功指标。真正要看的是"命中且正确"的比例,以及"错命中导致的返工/投诉"成本——这两个数往往在报表里缺席。
7. 边界:什么情况不值得上语义缓存
| 场景 | 为什么不必 | 替代 |
|---|---|---|
| QPS 低、调用成本占比小 | 省下的钱不够维护成本 | 直接调用 |
| 问题高度个性化 | 命中率天然极低 | 会话级缓存 |
| 答案必须实时 | 缓存必然过期 | 实时查询 + 预计算 |
| 合规要求可追溯 | 缓存答案来源模糊 | 每次都走 RAG 全链路 |
| 问题集合很小且固定 | 精确缓存就够 | 精确 key |
判断口径一句话:当"错命中的代价"能被业务吸收时,语义缓存才成立——信息类问答可以,政策/价格/额度类要加实体校验,涉及权限与资金的干脆不用。而"命中率"只能作为观测值,不能作为验收目标。
8. 自测
- 语义缓存与精确缓存的错误类型差别是什么?为什么前者更危险?
- 阈值提高会同时改变哪两个指标?拐点之后代价转移到哪里?
- "2024 版 vs 2025 版"属于哪类错命中?两道闸的设计是什么?
- 为什么权限维度必须进缓存 key,而不是只在检索层过滤?
- 知识库更新后,为什么 TTL 是唯一可靠手段?"版本号进 key"的代价是什么?
- 什么判据能让你决定"这个场景不该上语义缓存"?
9. 一句话总结
语义缓存把"精确匹配"换成了"概率匹配",于是多出一种错误:错命中。它的安全边界由两道闸决定——阈值(≥0.9)+ 关键实体一致(数字/版本/产品名);而权限、租户、locale 必须进 key,否则是越权事故。失效只能靠"版本号进 key + TTL",因为 key 感知不到答案侧的变化。判据:命中率只是观测值,"命中且正确"才是验收口径;错命中代价吸收不了的场景,不该上这个机制。