结论先放这:给 embedding 请求挂一层缓存,相同文本不重复算向量,我这边账单直接砍了一半还多。核心就一句——别让同一段文字算两遍。下面是我踩的坑和那个缓存键到底该怎么设计。

事情是这样的。我做了个文档问答的小东西,用户问问题,我把问题转成向量去知识库里检索。上周翻账单的时候吓一跳,光 embedding 这块的调用量比我预期高了快三倍。点进去看日志才反应过来——同一批文档我重新入库了好几次,每次全量重算;用户问的问题也高度重复,"怎么退款""退款流程"这种,后台一天能撞上几十次。这些向量算了又算,纯纯白烧钱。

加缓存的念头很自然,难点其实在键怎么设。

最开始我图省事,直接拿原文当 key 塞 Redis。跑了半天发现命中率低得可怜。原因很蠢:用户输入里全是空格、换行、大小写的差异,"怎么退款 "结尾多个空格,跟"怎么退款"在程序眼里就是俩 key。还有人复制粘贴带了零宽字符,肉眼根本看不出来。

后来我把键改成了:模型名 + 归一化后文本的哈希。模型名必须带上,因为 text-embedding-3-small 和换个模型出来的向量维度、数值都不一样,混在一个缓存里就是灾难——这个坑我是真栽过,换模型那天检索结果全乱,查了俩小时才定位到是缓存没隔离。

import hashlib, json, redis

r = redis.Redis()

def get_embedding(text, model="text-embedding-3-small"):
    norm = " ".join(text.split()).lower()          # 折叠空白、统一小写
    key = f"emb:{model}:{hashlib.sha256(norm.encode()).hexdigest()}"
    cached = r.get(key)
    if cached:
        return json.loads(cached)
    vec = call_embedding_api(text, model)            # 真正的远程调用
    r.set(key, json.dumps(vec), ex=60*60*24*30)      # 存一个月
    return vec

几个我自己定的取舍,说一下:

  • 归一化只做了折叠空白 + 小写,没去标点。中文场景里标点偶尔有意义,我不敢一刀切。你的语料干净的话可以更激进。

  • 用 sha256 是图哈希短、当 key 不臃肿,原文长一点也不怕。直接拿原文当 key 在中文长文本下又占内存又难看。

  • TTL 给了 30 天。向量本身不会变,但万一哪天我升级了归一化逻辑,旧 key 自然过期,省得手动清。

方案

命中率

我的体感

原文直接当 key

低,大概两成

空格大小写一差就漏

归一化 + 哈希 + 模型名

六七成

调用量肉眼可见地降

上线之后跑了一周,embedding 调用量掉了一半出头。没到夸张的九成,因为我的场景里新文本本来就占大头,但对那种问答高度重复、文档反复入库的活儿,省下来的非常实在。

要说缺点也有。缓存这东西它只管"省调用",检索质量该差还是差,别指望加个缓存就解决了召回问题——那是另一码事。还有就是,如果你的归一化太激进,把本该不同的文本折成同一个 key,会拿到错向量,这种 bug 特别隐蔽,我建议归一化逻辑改动后强制刷一遍缓存版本号。

顺带提一句,这套问答小助手我没自己写多少胶水,是在一个零代码就能拖配智能体的平台上搭的,知识库 RAG、模型挂载它都包了,我主要就管这层 embedding 缓存的优化。第一版搭出来确实有点干,提示词调了好几轮才像样,学习曲线谈不上陡但也不是点两下就完美。

(模型这块我走的讯飞星辰 MaaS,现成 API 直接调,没自己部署算力。)

你们的 embedding 缓存命中率能跑到多少?归一化都做了哪几步?评论区聊聊,我挺好奇有没有人把标点也处理了的。

Logo

一站式 AI 云服务平台

更多推荐