1. 语义缓存:让高频问题实现毫秒级响应
在构建基于RAG(检索增强生成)的智能问答系统时,我们经常会遇到一个棘手的问题:当用户反复询问语义相同但表述不同的问题时,系统每次都要完整执行向量召回、重排序和LLM生成的全流程。这不仅导致响应延迟(通常需要2-5秒),还产生了不必要的计算成本。今天我们就来解决这个问题。
传统Redis缓存采用精确匹配策略,对于"iPhone 15 Pro Max多少钱?"和"iPhone 15 Pro Max价格是多少?"这样的同义问题会视为完全不同的问题,导致缓存命中率低下。而语义缓存通过向量相似度比较,能够识别这些语义相近的查询,显著提升缓存命中率。
2. 语义缓存核心设计
2.1 工作原理与技术选型
语义缓存的核心思想是将用户查询向量化,然后在向量数据库中检索相似的历史查询。当相似度超过预设阈值时,直接返回历史答案,避免重复计算。这种设计带来了三个关键优势:
- 高命中率:能覆盖"价格"、"多少钱"、"售价"、"报价"等各种同义表达
- 低延迟:命中缓存时响应时间可控制在100ms以内
- 成本节约:大幅减少LLM调用次数,降低API成本
在技术选型上,我们选择Redis作为向量存储后端,主要基于以下考虑:
- Redis具备出色的读写性能,适合缓存场景
- 支持设置TTL(生存时间),自动清理过期缓存
- 与Spring生态集成良好,维护成本低
2.2 系统架构与关键组件
整个语义缓存系统由三个核心组件构成:
- 向量化模块:将用户查询转换为向量表示
- 相似度检索模块:在向量库中查找相似历史查询
- 缓存管理模块:处理缓存的存储、更新和过期
在Spring AI Alibaba框架中,我们通过在BEFORE_MODEL位置插入Hook来实现语义缓存。这个位置的选择非常关键,因为:
- 它位于模型调用之前
- 可以跳过后续所有处理节点(包括RAG和LLM生成)
- 对性能提升效果最明显
3. 实现细节与核心代码
3.1 缓存服务实现
以下是语义缓存服务的完整实现,包含核心方法和关键配置:
java复制@Service
public class SemanticCacheService {
@Autowired
@Qualifier("redisVectorStore")
private VectorStore redisVectorStore;
@Autowired
private JedisPooled jedisPooled;
// 相似度阈值,建议0.85-0.92之间
private static final double SIMILARITY_THRESHOLD = 0.90;
// 缓存有效期10分钟
private static final long CACHE_TTL_SECONDS = 60 * 10;
public String getIfPresent(String userQuestion) {
List<Document> results = redisVectorStore.similaritySearch(
SearchRequest.builder()
.similarityThreshold(SIMILARITY_THRESHOLD)
.topK(1)
.query(userQuestion)
.build()
);
if (results.isEmpty()) {
return null;
}
return results.getFirst().getMetadata().get("answer").toString();
}
public void put(String userQuestion, String llmAnswer) {
if (llmAnswer == null || llmAnswer.trim().isBlank()) {
return;
}
String docId = UUID.randomUUID().toString();
String redisKey = "embedding:" + docId;
Document doc = new Document(
docId,
userQuestion,
Map.of("answer", llmAnswer)
);
redisVectorStore.add(List.of(doc));
try {
jedisPooled.expire(redisKey, CACHE_TTL_SECONDS);
} catch (Exception e) {
log.error("设置缓存TTL失败", e);
}
}
}
3.2 Hook实现与集成
Hook是连接缓存系统与AI处理流程的关键桥梁。以下是经过优化的Hook实现:
java复制@Slf4j
@RequiredArgsConstructor
@HookPositions({HookPosition.BEFORE_MODEL})
public class SemanticCacheHook extends MessagesModelHook {
private final SemanticCacheService semanticCacheService;
public static final String CACHE_HIT_KEY = "cache_hit";
@Override
public String getName() {
return "semantic_cache_check";
}
@Override
public List<JumpTo> canJumpTo() {
return List.of(JumpTo.end);
}
@Override
public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) {
previousMessages = previousMessages.stream().distinct().toList();
String queryToSearch = previousMessages.stream()
.filter(msg -> msg instanceof UserMessage)
.map(msg -> ((UserMessage) msg).getText())
.reduce((first, second) -> second)
.orElse("");
if (queryToSearch.isBlank()) {
return new AgentCommand(previousMessages);
}
String cache = semanticCacheService.getIfPresent(queryToSearch);
if (cache != null && !cache.isBlank()) {
config.metadata().ifPresent(meta -> meta.put(CACHE_HIT_KEY, cache));
return new AgentCommand(JumpTo.end, previousMessages);
}
return new AgentCommand(null, previousMessages);
}
}
4. 性能优化与调优
4.1 参数调优建议
语义缓存的性能很大程度上取决于几个关键参数:
-
相似度阈值:0.85-0.92是经验值
- 过高会导致漏判(false negative)
- 过低会导致误判(false positive)
-
缓存TTL:根据业务场景调整
- 高频变化数据:5-10分钟
- 稳定数据:可延长至数小时
-
TopK值:通常设为1即可满足需求
4.2 性能对比数据
以下是三种场景下的性能对比:
| 场景 | 无缓存 | 精确缓存 | 语义缓存 |
|---|---|---|---|
| "iPhone 15多少钱?" | 全流程 | 命中 | 命中 |
| "iPhone 15价格是多少?" | 全流程 | miss | 命中 ✅ |
| "15 Pro Max售价" | 全流程 | miss | 命中 ✅ |
| 平均响应时间 | 2-5秒 | <100ms | <100ms |
| Token成本 | 每次都有 | 仅首次 | 仅首次 |
5. 常见问题与解决方案
5.1 缓存一致性问题
当底层数据发生变化时,缓存可能返回过时答案。解决方案:
- 设置合理的TTL
- 对关键数据建立变更监听机制
- 实现手动缓存清除接口
5.2 相似度阈值选择
阈值选择需要平衡召回率和准确率:
- 从0.85开始测试
- 根据业务场景逐步调整
- 建立评估指标(如用户满意度)
5.3 内存管理
大量缓存可能占用过多内存:
- 限制最大缓存条目数
- 实现LRU淘汰策略
- 监控内存使用情况
6. 扩展与进阶
语义缓存可以进一步优化:
- 多级缓存:结合本地缓存和分布式缓存
- 动态阈值:根据查询频率自动调整相似度阈值
- 反馈学习:根据用户反馈优化缓存策略
在实际项目中,我们通过语义缓存将高频问题的响应时间从秒级降至毫秒级,同时减少了约60%的LLM调用成本。这种优化对于高并发场景尤为重要。