【随笔】RAG语义缓存:问题很相似,答案就能直接复用吗

"月付套餐怎么退款?"和"按月订阅的退款规则是什么?"措辞不同,却可能需要同一份答案。昨天的检索缓存复用的是证据列表,今天再往前一步:能否直接复用已经生成的回答?

语义缓存通过向量检索寻找相近问题,再返回缓存答案。它能减少重复生成,但命中判断也因此承担了新的责任:两个问题相似,不代表旧答案可以完整回答新问题。

本文用退款规则这个小场景,拆开缓存结构、命中条件与阈值选择,再用一段可运行程序观察错误候选怎样被挡住。

一、语义缓存到底复用了什么

普通精确缓存要求键一致。语义缓存把历史问题转成向量,为新问题寻找距离较近的历史问题,并取出相应答案。RedisVL的SemanticCache已经封装了这类查找、存储与过滤操作。RedisVL语义缓存介绍

一条面向RAG的缓存记录,可以分成四组信息:

信息 示例 用途
问题与向量 原始问题、Embedding向量 寻找候选
可复用答案 回答文本、证据ID 返回内容并追溯来源
适用范围 租户、权限、知识版本、产品方案 排除不适用候选
生命周期 过期时间、Embedding模型版本 失效与索引管理

这里复用的是答案。如果只命中检索结果缓存,仍然会重新组织回答;如果命中答案缓存,后续检索与生成可能一起跳过,因此适用条件必须完整。

二、最相似的问题,也可能指向另一个答案

已有问题是:"月付套餐怎么退款?"现在来了三种请求:

  1. "按月订阅的退款规则是什么?"------同一方案、同一意图,可以考虑复用。
  2. "年付套餐怎么退款?"------大部分词相同,但方案变了。
  3. "我这笔订单现在能退多少钱?"------需要订单状态和实时金额,静态规则不足以回答。

第二种问题尤其容易误导人。即使它在向量空间里比第一种更近,也不能因此把月付答案返回给年付用户。

图里相似问题先成为候选,再接受方案检查。向量距离负责找近邻,业务条件负责判断适用范围。

对否定词、数量、地区、币种、套餐、时间范围等会改变答案的条件,应尽量提取为明确字段。无法可靠确定关键条件时,继续正常检索或追问,不把模糊条件当作匹配成功。

三、把命中过程设计成四道检查

建议按以下顺序组织答案缓存:

第一道:确定请求范围。 从已经验证的登录态、授权策略与知识库版本取得租户和权限范围。不能直接相信用户在问题里写出的租户名。

第二道:过滤不适用记录。 在向量检索条件中加入范围字段、业务槽位和版本。过期记录不参与返回。权限策略变更后,即使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查询改写:怎样补齐口语和上下文,又不把用户原本的条件改丢。

👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊

相关推荐
Rain的Java大神之路1 小时前
面了 50 个候选人,90% 都挂在 LeetCode 3 无重复字符的最长子串:滑动窗口的 5 个致命坑
java·后端·leetcode
西凉的悲伤1 小时前
Jsoup 库 HTML 解析详解
java·前端·html·jsoup
十碗饭吃不饱1 小时前
Apache POI 踩坑:setFontFamily 二次设置字体不生效、样式残留问题深度解析
java·word·apache
我命由我123452 小时前
6-2.Python 异常处理 - 异常捕获(捕获指定异常、常见异常类型、异常信息)
java·运维·开发语言·python·http·https·java-ee
小静AI工程实验室2 小时前
AI 应用缓存键怎么设计?租户、模型、提示词与索引版本的 7 项离线验证
缓存
北京地铁1号线2 小时前
后端开发:缓存(Cache)简介
后端·缓存·cache
zaizai10072 小时前
STL深度剖析2
java·c++·算法
周厉2 小时前
WebSocket 使用场景与实现方法:Java + Vue3 全栈实战
java·websocket·vue3·websocket双向通讯
wdfk_prog2 小时前
lwip教程 38:从 `lwipopts.h` 到 Port Contract——lwIP 裁剪、构建选择、OS Port 与 Network Port
java·开发语言