
# RAG 上下文压缩后缓存失效的根因与三个解法
用 RAG 跑生产环境时,上下文压缩(Context Compression)是个好东西——能显著减少 token 用量、降低 LLM 调用成本。但有一个副作用很多人会遇到:**上了压缩之后,缓存命中率直接变成 0,成本不降反升。**
这个问题我排查了两天。本文说清楚根因,给出三个经过验证的解法。
## 问题场景
典型架构:
```
query → 向量检索 → Top-K 相关文档块 → 缓存 → 拼 prompt → LLM
```
在检索和 LLM 之间加一层 Redis 缓存,相同 query 命中缓存直接返回,这个方案本身没问题。**压缩上线之前也跑得好好的。**
上了压缩之后缓存全灭,问题出在哪?
## 根因:压缩引入了不稳定的中间层
上下文压缩有两种常见做法:
**文档块压缩**:每个检索到的块单独做摘要,从 500 字压到 100 字。
**Rerank + 压缩**:先检索 20 个块,Rerank 模型选出最相关的 3 个,再对这 3 个做摘要。
无论哪种,压缩结果和原始文本之间没有固定映射。同一个 query,这次返回的压缩结果是 A,下次可能因为文档略有更新、或者 Rerank 输出的随机性,变成完全不同的 B。
你的缓存 key 用的是 query 向量,但 value 已经变了。缓存找不到完全匹配的 key,命中率归零。
Rerank 的问题更隐蔽:即使文档库完全没变,Rerank 模型有随机性(Top-K=20 选 3),每次选出的块可能不同,缓存永远无法命中。
**核心问题:任何有随机性或状态性的中间层,都会破坏缓存的一致性。**
## 解法一:语义指纹代替向量做 key
不要把 query 向量直接当缓存 key。用 query 的语义指纹——去除停用词、标准化之后 hash——作为 key。
```python
import hashlib
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
STOP_WORDS = {"的", "了", "和", "是", "在", "我", "有", "个", "之"}
def cache_key(query: str) -> str:
normalized = " ".join([
word for word in query.lower().split()
if word not in STOP_WORDS
])
return f"rag:cache:{hashlib.md5(normalized.encode()).hexdigest()}"
def get_cached_answer(query: str) -> str | None:
return r.get(cache_key(query))
def cache_answer(query: str, answer: str, ttl: int = 3600):
r.setex(cache_key(query), ttl, answer)
```
相同语义的问题会命中同一个 key,压缩结果差异不影响缓存层。这个方案适合 query 级别的缓存。
## 解法二:文档块缓存的 key 加上压缩参数
如果你的缓存是在文档块级别(用块 ID 做 key),那 key 需要加上压缩参数的指纹。
```python
import hashlib
import json
def chunk_cache_key(doc_id: str,
model: str = "gpt-3.5-turbo",
max_tokens: int = 200,
version: str = "v2.1") -> str:
params = json.dumps({
"model": model,
"max_tokens": max_tokens,
"version": version,
}, sort_keys=True)
params_hash = hashlib.sha256(params.encode()).hexdigest()[:16]
return f"rag:chunk:{doc_id}:{params_hash}"
# 示例
key = chunk_cache_key("chunk_123", model="gpt-4", max_tokens=150, version="v3.0")
```
压缩模型或参数升级时,key 自动变化,不会出现「参数改了但缓存 key 相同」的脏数据问题。这个方案适合文档块级别的缓存架构。
## 解法三:换掉 Rerank,用固定滑动窗口
这是最干净的解法,但代价最高。
Rerank + 压缩的问题根源是有随机性的中间层。改用固定大小的滑动窗口:不用 Rerank,直接按固定步长切窗口,每次相同 query + 相同文档库,截取结果完全一致,缓存命中率可以恢复到正常水平。
```python
def sliding_window_retrieval(query: str,
doc_chunks: list[dict],
window_size: int = 500,
stride: int = 200,
top_k: int = 3) -> list[str]:
"""
固定窗口大小和步长,检索结果完全可复现。
"""
retrieved = []
for chunk in doc_chunks:
content = chunk["content"]
for start in range(0, len(content), stride):
window = content[start:start + window_size]
if cosine_similarity(query_embedding, window_embedding) > 0.7:
retrieved.append(window)
if len(retrieved) >= top_k:
return retrieved
return retrieved
```
代价:没有压缩,LLM 输入 token 数增加,成本比压缩方案高。但如果缓存命中率能从 0% 升到 60% 以上,总体成本反而更低。
## 怎么选
| 方案 | 改动幅度 | 缓存命中率 | LLM 成本 | 适用场景 |
|------|---------|-----------|---------|---------|
| 语义指纹 key | 小 | 中 | 不变 | query 级别缓存 |
| 压缩参数指纹 | 中 | 高 | 不变 | 文档块级别缓存 |
| 滑动窗口 | 大 | 最高 | 略高 | 精度要求高,成本不敏感 |
实际建议:先试方案一,改动最小。如果缓存命中率能到 50% 就先不动。如果文档块缓存量大(几百个块),加方案二的参数指纹。方案三只在精度要求极高、缓存命中率极低时考虑。
记住一个原则:**缓存层前后,任何不稳定的中间层都会导致缓存失效。** 上新功能之前,先想清楚对缓存一致性的影响。
---
原文:[RAG 上下文压缩后缓存失效的根因与三个解法](https://www.shuwu8.cn/)
更多交流点击入群






