"月付套餐怎么退款?"和"按月订阅的退款规则是什么?"措辞不同,却可能需要同一份答案。昨天的检索缓存复用的是证据列表,今天再往前一步:能否直接复用已经生成的回答?
语义缓存通过向量检索寻找相近问题,再返回缓存答案。它能减少重复生成,但命中判断也因此承担了新的责任:两个问题相似,不代表旧答案可以完整回答新问题。
本文用退款规则这个小场景,拆开缓存结构、命中条件与阈值选择,再用一段可运行程序观察错误候选怎样被挡住。
一、语义缓存到底复用了什么
普通精确缓存要求键一致。语义缓存把历史问题转成向量,为新问题寻找距离较近的历史问题,并取出相应答案。RedisVL的SemanticCache已经封装了这类查找、存储与过滤操作。RedisVL语义缓存介绍
一条面向RAG的缓存记录,可以分成四组信息:
| 信息 | 示例 | 用途 |
|---|---|---|
| 问题与向量 | 原始问题、Embedding向量 | 寻找候选 |
| 可复用答案 | 回答文本、证据ID | 返回内容并追溯来源 |
| 适用范围 | 租户、权限、知识版本、产品方案 | 排除不适用候选 |
| 生命周期 | 过期时间、Embedding模型版本 | 失效与索引管理 |
这里复用的是答案。如果只命中检索结果缓存,仍然会重新组织回答;如果命中答案缓存,后续检索与生成可能一起跳过,因此适用条件必须完整。
二、最相似的问题,也可能指向另一个答案
已有问题是:"月付套餐怎么退款?"现在来了三种请求:
- "按月订阅的退款规则是什么?"------同一方案、同一意图,可以考虑复用。
- "年付套餐怎么退款?"------大部分词相同,但方案变了。
- "我这笔订单现在能退多少钱?"------需要订单状态和实时金额,静态规则不足以回答。
第二种问题尤其容易误导人。即使它在向量空间里比第一种更近,也不能因此把月付答案返回给年付用户。

图里相似问题先成为候选,再接受方案检查。向量距离负责找近邻,业务条件负责判断适用范围。
对否定词、数量、地区、币种、套餐、时间范围等会改变答案的条件,应尽量提取为明确字段。无法可靠确定关键条件时,继续正常检索或追问,不把模糊条件当作匹配成功。
三、把命中过程设计成四道检查
建议按以下顺序组织答案缓存:
第一道:确定请求范围。 从已经验证的登录态、授权策略与知识库版本取得租户和权限范围。不能直接相信用户在问题里写出的租户名。
第二道:过滤不适用记录。 在向量检索条件中加入范围字段、业务槽位和版本。过期记录不参与返回。权限策略变更后,即使TTL尚未到期,也要让旧范围失效。
第三道:检查距离。 对范围相同的候选进行相似度判断。距离低于阈值,只能说明通过这一道检查。
第四道:核对答案条件。 对关键限制、否定含义和当前请求要求做一致性检查。缺少关键条件或校验失败,走正常RAG;生成并验证新答案后再写入缓存。

RedisVL支持TTL与可过滤字段,可以把范围过滤落到缓存查询中。它提供的是工具能力,哪些业务条件必须完全相同,需要应用自己定义。RedisVL缓存使用指南
缓存里如果保存了受限证据生成的答案,就必须保存对应的授权范围。答案文字看起来很普通,也不代表它可以跨用户或跨租户分享。
四、完整示例:距离更小,仍然拒绝命中
下面用Python3.10及以上版本演示判定逻辑,不需要Redis服务。二维向量是人工构造的教学数据,用来隔离"距离"和"业务条件"两个因素,不能把它们当成真实Embedding模型的测量结果。
python
from dataclasses import dataclass, replace
from math import sqrt
@dataclass(frozen=True)
class Request:
tenant: str
policy: str
version: str
intent: str
plan: str
vector: tuple[float, ...]
def distance(a, b):
dot = sum(x * y for x, y in zip(a, b))
return 1 - dot / (sqrt(sum(x*x for x in a)) * sqrt(sum(y*y for y in b)))
cached = Request("team-a", "public", "v3", "refund_rule", "monthly", (1.0, 0.0))
answer = "月付方案在规则规定的条件内申请退款。"
expires_at = 100
def lookup(req, now, threshold):
scope = lambda r: (r.tenant, r.policy, r.version, r.intent, r.plan)
if scope(req) != scope(cached):
return "MISS(scope)"
if now >= expires_at:
return "MISS(expired)"
if distance(req.vector, cached.vector) > threshold:
return "MISS(distance)"
return "HIT"
paraphrase = replace(cached, vector=(0.99, 0.10))
annual = replace(cached, plan="annual", vector=(0.999, 0.01))
other = replace(cached, tenant="team-b", vector=(1.0, 0.0))
for name, req in [("paraphrase", paraphrase), ("annual", annual), ("other-tenant", other)]:
print(f"{name}: distance={distance(req.vector, cached.vector):.6f}, {lookup(req, 50, 0.01)}")
print("strict threshold:", lookup(paraphrase, 50, 0.001))
print("expired:", lookup(paraphrase, 100, 0.01))
保存为semantic_demo.py,执行python semantic_demo.py,本次输出如下:
text
paraphrase: distance=0.005063, HIT
annual: distance=0.000050, MISS(scope)
other-tenant: distance=0.000000, MISS(scope)
strict threshold: MISS(distance)
expired: MISS(expired)
annual的距离更小,却因方案不同被拒绝;另一个租户的向量完全一样,也被范围检查拒绝。把阈值从0.01收紧到0.001后,同义改写也不再命中;到了过期时刻,距离仍然不变,但缓存失效。
这个示例只返回判定状态,便于看清每个分支。真实服务通过检查后返回answer,未命中则调用正常RAG流程。接入向量库时,还应验证向量维度、非零范数、Embedding模型一致性,并把这里的范围字段变成查询过滤条件。
五、阈值要怎样选,才不被命中率带偏
示例采用余弦距离:distance = 1 - cosine_similarity。距离越小越接近,因此降低距离阈值会收紧匹配。如果某个接口返回相似度分数,方向可能相反,先确认指标定义,再调参数。
选择阈值时,至少准备三类问题对:
| 样本类型 | 例子 | 期望 |
|---|---|---|
| 同义改写 | 月付退款规则/按月订阅如何退费 | 条件一致时命中 |
| 相近反例 | 月付退款/年付退款 | 拒绝不适用答案 |
| 条件变化 | 同一问题但权限、版本或时间已变化 | 旧记录失效 |
对同一批样本逐个尝试阈值,记录正确命中、错误命中与漏命中。错误命中会把错误答案直接交给用户,漏命中通常只是多做一次正常检索和生成。因此先压低错误命中,再讨论能节省多少调用。
上线后也不能只看命中率。还应抽检缓存答案是否满足当前问题,记录拒绝原因、来源版本,以及未命中时的端到端耗时。语义缓存本身需要Embedding、向量查询与校验;流量很低、问题几乎不重复时,这些成本未必值得增加。
六、哪些请求值得缓存,哪些应该继续查
相对稳定的产品说明、公开FAQ、固定版本操作规则,适合从严格范围开始尝试。用户私有数据、实时价格、订单金额和需要执行操作的请求,则要检查数据的新鲜度及副作用,不能拿相似问题的旧回答代替实时处理。
多轮对话还有一个常见陷阱:"那企业版呢?"单看这一句话无法确定完整问题。缓存匹配应使用补全上下文后的请求表示,并纳入影响答案的对话条件。
知识库更新时,切换知识版本或删除受影响缓存;Embedding模型升级时,按模型版本隔离或重建向量。不能把不同模型产生的向量放在同一个可比较空间里,只因为它们维度相同。
七、🧠 思维导图
#mermaid-svg-c56gEqmwfDuxujz2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c56gEqmwfDuxujz2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c56gEqmwfDuxujz2 .error-icon{fill:#552222;}#mermaid-svg-c56gEqmwfDuxujz2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c56gEqmwfDuxujz2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c56gEqmwfDuxujz2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c56gEqmwfDuxujz2 .marker.cross{stroke:#333333;}#mermaid-svg-c56gEqmwfDuxujz2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c56gEqmwfDuxujz2 p{margin:0;}#mermaid-svg-c56gEqmwfDuxujz2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-c56gEqmwfDuxujz2 .cluster-label text{fill:#333;}#mermaid-svg-c56gEqmwfDuxujz2 .cluster-label span{color:#333;}#mermaid-svg-c56gEqmwfDuxujz2 .cluster-label span p{background-color:transparent;}#mermaid-svg-c56gEqmwfDuxujz2 .label text,#mermaid-svg-c56gEqmwfDuxujz2 span{fill:#333;color:#333;}#mermaid-svg-c56gEqmwfDuxujz2 .node rect,#mermaid-svg-c56gEqmwfDuxujz2 .node circle,#mermaid-svg-c56gEqmwfDuxujz2 .node ellipse,#mermaid-svg-c56gEqmwfDuxujz2 .node polygon,#mermaid-svg-c56gEqmwfDuxujz2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-c56gEqmwfDuxujz2 .rough-node .label text,#mermaid-svg-c56gEqmwfDuxujz2 .node .label text,#mermaid-svg-c56gEqmwfDuxujz2 .image-shape .label,#mermaid-svg-c56gEqmwfDuxujz2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-c56gEqmwfDuxujz2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-c56gEqmwfDuxujz2 .rough-node .label,#mermaid-svg-c56gEqmwfDuxujz2 .node .label,#mermaid-svg-c56gEqmwfDuxujz2 .image-shape .label,#mermaid-svg-c56gEqmwfDuxujz2 .icon-shape .label{text-align:center;}#mermaid-svg-c56gEqmwfDuxujz2 .node.clickable{cursor:pointer;}#mermaid-svg-c56gEqmwfDuxujz2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-c56gEqmwfDuxujz2 .arrowheadPath{fill:#333333;}#mermaid-svg-c56gEqmwfDuxujz2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-c56gEqmwfDuxujz2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-c56gEqmwfDuxujz2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c56gEqmwfDuxujz2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-c56gEqmwfDuxujz2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c56gEqmwfDuxujz2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-c56gEqmwfDuxujz2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-c56gEqmwfDuxujz2 .cluster text{fill:#333;}#mermaid-svg-c56gEqmwfDuxujz2 .cluster span{color:#333;}#mermaid-svg-c56gEqmwfDuxujz2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-c56gEqmwfDuxujz2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-c56gEqmwfDuxujz2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-c56gEqmwfDuxujz2 .icon-shape,#mermaid-svg-c56gEqmwfDuxujz2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c56gEqmwfDuxujz2 .icon-shape p,#mermaid-svg-c56gEqmwfDuxujz2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-c56gEqmwfDuxujz2 .icon-shape .label rect,#mermaid-svg-c56gEqmwfDuxujz2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c56gEqmwfDuxujz2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-c56gEqmwfDuxujz2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-c56gEqmwfDuxujz2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} RAG语义缓存
缓存结构
问题向量与答案
范围版本与过期
命中条件
先过滤适用范围
距离与关键条件
阈值评估
同义改写与相近反例
优先减少错误命中
失效管理
权限和知识更新
模型与上下文变化
八、总结
总结要点
语义缓存复用答案,必须检查答案的适用条件。租户、权限、版本和业务槽位不一致时,即使向量距离很小,也应拒绝返回旧答案。
阈值需要用反例验证。只测试同义改写,容易得到漂亮的命中率,却看不到月付与年付、肯定与否定这些真正影响答案的差异。
缓存失效是设计的一部分。权限变化、知识更新和Embedding模型切换,都要有明确的隔离或淘汰机制;未命中时保持正常RAG路径可用。
下一篇讨论RAG查询改写:怎样补齐口语和上下文,又不把用户原本的条件改丢。
👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊