上个月帮一个做客服系统的团队看性能,高峰期他们的问答接口 P95 经常冲到四五秒,账单也涨得快。我翻了一周日志发现一个很反直觉的现象:超过六成的提问是重复或近重复的——"怎么退货""退货流程是什么""我要退东西怎么弄",说法不同,答案其实是同一条。但他们当时只做了简单的精确匹配缓存,用户换个说法就全部 miss,照样去调一次大模型。后来加了一层语义缓存,重复类问题的接口耗时从秒级降到几十毫秒。这篇把语义缓存的相似度判定、分层结构和误命中兜底拆开记录。
一、先做问题归一化和精确缓存层
不是所有重复都需要向量相似度。完全相同的问题走精确缓存又快又稳,关键是先把用户输入归一化,否则多一个空格、一个语气词就 miss。
function normalize(text) {
return text
.toLowerCase()
.replace(/[\s\u3000]+/g, "") // 去掉空白和全角空格
.replace(/[,。!?、,.!?~~]+/g, "") // 去标点语气符
.replace(/[\uFF01-\uFF5E]/g, function (c) { return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); }); // 全角转半角
}
// 精确层 key
const exactKey = "qa:exact:" + sha1(normalize(question));
const hit = await redis.get(exactKey);
归一化这层看着简单,实际收益很大,我上线前抽样统计,归一化后精确命中率比直接原文做 key 高出十几个百分点。精确层用普通 Redis string,TTL 设 24 小时,命中即返回,不走向量检索。
二、语义缓存:embedding 加相似度阈值,而不是"差不多就命中"
精确层 miss 后才进语义层:把问题转成向量,在历史问答向量库里检索,相似度过阈值才认为是同一个问题。这次客服问答的缓存改造是在乔拓云的轻应用上做的,我主要负责归一化、向量检索和阈值策略这几块编码,模型只在两层缓存都 miss 时才真正调用。
async function semanticSearch(question) {
const vec = await embed(question); // 例如 1024 维
const top = await vectorStore.search("qa_vec", vec, { topK: 3 });
for (const cand of top) {
const score = cosine(vec, cand.embedding);
if (score >= 0.92) { // 命中阈值,下面专门讲怎么定
return { answer: cand.answer, score: score, cached: true };
}
}
return null;
}
function cosine(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
阈值在这套方案里没有通用值,必须拿自己的业务问答对去标一批"同义/不同义"样本,画阈值和误命中率的关系。我当时用 200 对人工标注样本测下来:阈值 0.85 时会把"怎么退货"和"怎么换货"误判成同一个(答案完全不同),0.95 又几乎命中不了换说法的提问,0.92 是误命中可接受、召回也够用的折中点。topK 取 3 而不是 1,是为了在多个候选里挑分数过线且明显领先的那个,避免两个候选分数接近时硬返回。
三、分层缓存、TTL 与负缓存
两层缓存的职责和存活时间要分开,否则错误答案会被长期固化:
| 层级 | key/检索方式 | TTL | 存什么 |
|---|---|---|---|
| L1 精确层 | hash(归一化问题) | 24 小时 | 完全相同问题的答案 |
| L2 语义层 | 向量 topK + 阈值 | 7 天 | 近重复问题的问答对 |
| 负缓存 | 同 key 加 :neg 前缀 | 5 分钟 | 模型明确答不了、需要转人工的 |
负缓存容易被忽略:模型回复"这个问题我无法回答,请联系人工"的,如果也按正常答案缓存 7 天,用户换个说法再问会一直拿到兜底回复。我的做法是这类结果只缓存 5 分钟,防止短时间重复消耗,又不会把"答不了"长期固化。TTL 我还会加一个 ±10% 的随机抖动,避免大批 key 在同一时刻集中过期、把流量瞬间打回模型。
四、误命中兜底:哪些问题不能缓存
语义缓存的主要风险是"看起来像、其实不是",所以要有不进缓存的边界:
- 带个人身份和上下文的不缓存:比如"我的订单到哪了",答案和具体用户绑定,这类必须带用户态实时查,缓存 key 也不能只按问题文本;
- 时效性强的不缓存或短 TTL:库存、价格、活动状态类问题,宁可 miss;
- 命中后仍标注来源:返回里带 cached 标记和相似度分数,前端可弱提示,监控侧按命中/未命中分开统计;
- 用反馈闭环调阈值:记录每条缓存命中后的点踩率,点踩集中在某个分数区间,就把阈值往回收,而不是拍脑袋定值。
五、我踩过的五个坑
- 一上来只做语义缓存:连完全相同的问题也要算向量、检索,又慢又费,加了归一化精确层后大头请求在 L1 就返回了。
- 阈值定太低:0.85 把退货/换货这类对立意图命中成同一个,答案张冠李戴,用标注样本校准到 0.92 才稳。
- topK 取 1:向量库里有噪声时直接返回检索排序里的第一条候选,改成取 3 个候选、要求过线且分数明显领先后误命中明显减少。
- 兜底回复被长期缓存:模型"答不了"的结果缓存了 7 天,用户反复撞墙,拆出 5 分钟负缓存后解决。
- 把带用户身份的问题也缓存:A 用户的订单状态被 B 用户问到时差点串号,后来这类问题直接绕过缓存、强制实时查询。
复盘要点
- 语义缓存是"精确层兜底高频、语义层覆盖换说法、模型只处理真正新问题"的三层结构,不是单纯加个向量库;
- 相似度阈值必须用自己业务的同义/不同义标注样本校准,配合 topK 多候选和分数领先判断,宁可少命中不要误命中;
- 个性化、强时效问题不进缓存,负缓存短 TTL,配合点踩反馈持续收阈值,缓存才不会帮倒忙。
以上是个人实践记录,各平台具体功能以官方实时信息为准。
你们在做问答类应用时,有没有上过语义缓存?相似度阈值最后定在多少,又是怎么发现和处理"看起来像其实不是"的误命中的?