语义检索要先把文本变成向量,这一步叫 embedding。它看起来只是个技术细节,但在真实项目里,不缓存会直接拖垮你的成本和响应时间。
这篇讲清楚 embedding 是什么、为什么必须缓存,以及缓存键到底该用什么。
一、它解决什么问题
先说 embedding 是什么。用一句话:
把一段文本变成一串数字,意思相近的文本,得到的数字也相近。
「毛玻璃风格」和「frosted glass style」这两句话,字面上一个共同字符都没有。但把它们各自变成一串数字之后,这两串数字在数学上距离很近。
这就是语义检索的全部基础——计算机不会「理解」意思,它只会比较数字。embedding 就是把「意思」翻译成「可以比较的数字」的那一步。
比如 1024 维的 embedding,就是把一句话变成 1024 个浮点数:
"毛玻璃" → [0.023, -0.041, 0.118, ..., 0.007] (1024 个数)然后比较两句话,就是比较这两个数组的余弦相似度。
二、不用它会怎样
关键问题来了:这个转换不是本地算的,是一次远程 API 调用。
每次调用有三个代价:
| 代价 | 具体表现 |
|---|---|
| 要钱 | 按 token 计费,调得越多花得越多 |
| 要时间 | 一次网络往返,几十到几百毫秒 |
| 有速率限制 | 短时间调太多会被限流甚至拒绝 |
现在想想不缓存的后果。用户打开检索页面,为了让他能搜,你得先把所有候选文档转成向量。如果每次都重新算——用户每搜一次,你就要把所有文档重新 embedding 一次。
StyleKit 这边:148 个风格,每个切成 4 个 chunk,大约 600 段文本。用户搜一次,你调 600 次 embedding。搜十次,6000 次。
这不是「慢一点」的问题,是检索功能直接不可用——用户搜一次要等几十秒,账单还会一直涨。
三、代码是怎么做的
缓存键用 content hash,不用别的
docs/RAG_SEMANTIC_RETRIEVAL.md 的 §3.1 写得很明确:
缓存:以
content_hash为键缓存向量,内容不变不重算。
为什么是内容哈希,而不是用文档 ID 或者文件名?
因为要判断的是「这段文本变没变」,不是「这个文档变没变」。
想象一下:我把某个风格的 philosophy 改了一个字。如果用文档 ID 做缓存键,ID 没变,缓存就认为「还是老样子」,直接返回旧向量。你的向量库里存的就是和当前文本不匹配的向量——检索结果会变得莫名其妙,而且极难排查。
用内容哈希就天然正确:
hash("玻璃拟态的本质是光学,不是配色。") → a3f2...
hash("玻璃拟态的本质是设计,不是配色。") → 7b91... ← 改了字,哈希就变了内容没变,哈希不变,缓存命中,不重算。内容变了,哈希变了,自动重算。
哈希值本身就是「内容指纹」,它不会撒谎。
为什么在这个项目里特别重要
缓存的价值取决于「重复率」和「变化频率」。
在这个仓库里,变化频率是真实的:7 个月 1223 次提交。风格文档是天天在动的——改 philosophy 的措辞、补 doList、调 keywords。
而对一次检索请求来说,600 个 chunk 里通常只有极少数发生变化。
- 不缓存:每次全量重算 600 条
- 有缓存:只重算变化的那几条
差距是「能用」和「不能用」的区别。
批量与并发
另一个真实限制:DashScope 单次 embedding 请求最多 25 条文本。
所以 600 个 chunk 要分批:
600 chunk ÷ 25 条/批 = 24 批这 24 批不能一次性全发出去——会被限流。所以文档里写的是分批 + 并发 3:
- 并发数控制同时最多 3 个请求在飞,避免触发限流
- 全量首次构建约 600 chunk,耗时约 30 秒
注意这是首次。之后再跑,命中的 chunk 直接读缓存,只处理变化的。
一个工程上的判断
文档里还写了模型选型:DashScope text-embedding-v4,1024 维。
维度为什么选 1024?v4 支持 512 / 768 / 1024 / 1536 四档。1024 是质量和成本的折中点——维度越高表达力越强,但存储和计算也越贵。
另外还留了备选:本地的 bge-m3,没有 API 成本也没有网络依赖,但会引入模型体积和冷启动开销。所以决定是:先把 provider 抽象成接口,用 DashScope 起步,后续可以换。
这比「一开始就选一个最完美的方案」务实得多——留好接口,比一次选对更重要。
四、面试官会追问什么
问:embedding 缓存的 key 为什么用内容哈希,不用文档 ID?
答:因为要判断的是「这段文本变没变」。文档 ID 只能告诉你「是哪篇文档」,改了一个字 ID 也不会变,缓存会返回过期向量,而向量和当前文本不匹配是极其难排查的问题。内容哈希是内容指纹,内容变哈希就变,自动失效,不需要任何额外的失效逻辑。
问:如果文档只是改了一个标点,哈希变了,是不是要做一次无意义的重新计算?
答:是,这是内容哈希的代价——它对任何字符变化都敏感。但相比「该失效时没失效」造成的脏数据,这个代价是可以接受的:一次多余的 embedding 调用成本很低,而库里混入不一致的向量会污染全部检索结果。
问:批量大小和并发数是怎么定的?
答:批量上限是服务方规定的(DashScope 单批最多 25 条),不是我能调的。并发数 3 是我自己定的,用来在「尽快跑完」和「不触发限流」之间取平衡——并发开太高会被服务端拒绝,反而更慢。
问:缓存要不要设置过期时间?
答:对内容寻址的缓存,不需要时间过期——键就是内容,内容不变结果就不变,设 TTL 只会造成无意义的重新计算。要处理的是「模型换了怎么办」:换了 embedding 模型,向量空间变了,旧向量全部失效。所以键里除了 content hash,还应该带上模型标识。
五、一句话版本
Embedding 是把文本变成数字的远程调用,要钱要时间有限流;缓存键必须用内容哈希,因为要判断的是「这段文本变没变」,而哈希不会撒谎。
六、自测
- 用一句话解释 embedding 是什么,不要用「向量空间」这个词。
- 为什么用文档 ID 做缓存键会出问题?举一个具体的失败场景。
- 内容哈希做缓存键的代价是什么?为什么这个代价可以接受?
- 600 个 chunk、单批上限 25 条,要分几批?为什么不能一次性全发出去?
- 如果哪天把 embedding 模型从 v4 换成别的模型,缓存会怎样?需要做什么处理?