用 Spring AI Alibaba 打造智能查询增强引擎
系列导读 :在上一篇文章《基于 Spring AI Alibaba 构建混合 RAG Agent》中,我们描绘了一套融合"侦探的灵活"与"会计的严谨"的架构蓝图。其中,查询增强(Query Enhancement)被置于六大核心步骤之首。很多开发者会有疑问:"大模型上下文窗口那么大,直接把历史对话丢进去不就行了?为什么还要多此一举去改写问题?"
本文将深入代码一线,通过真实的
QueryEnhancementHook实现,为你揭示:没有查询增强的 RAG,就像让一个记忆力超群但耳朵背的专家去工作------他记得住所有书,却听不清你在问什么。
一、为什么"丰富的上下文"救不了检索?
在 RAG(检索增强生成)的流程中,存在一个致命的时序错位:
- 用户提问:"它多少钱?"(依赖上文)
- 检索阶段 (Retrieval):系统拿着"它多少钱?"去向量数据库搜索。👉 失败!数据库里没有"它",只有"iPhone 15"。
- 生成阶段 (Generation):系统把检索到的(可能是错误的)文档 + 完整的历史对话丢给 LLM。
- LLM 回答:LLM 看着历史对话说:"哦,你刚才问的是 iPhone,所以它是 8999 元。"
发现问题了吗 ?
虽然 LLM 在最后一步通过上下文猜对了答案,但检索阶段已经失败了。LLM 是基于"错误检索到的文档"在强行解释,或者完全依靠它训练时的内部知识在回答(这会导致幻觉,且无法利用企业私有知识库)。
查询增强的核心价值 ,就是在第 2 步检索发生之前 ,把"它多少钱?"改写成"iPhone 15 Pro Max 最新价格是多少?"。
只有问题清晰了,检索才能精准;只有检索精准了,RAG 才有意义。
二、代码实战:Spring AI Alibaba 中的 QueryEnhancementHook
基于 Spring AI Alibaba 强大的 AgentHook 机制,我们可以轻松在 Agent 执行前拦截请求,完成"翻译"工作。以下是我们生产环境中的核心实现。
1. Hook 的定义与入口
我们定义了一个 QueryEnhancementHook,它继承自 AgentHook,并标注 @HookPositions({HookPosition.BEFORE_AGENT}),确保它在任何工具调用或检索发生之前执行。
java
@Slf4j
@RequiredArgsConstructor
@HookPositions({HookPosition.BEFORE_AGENT})
public class QueryEnhancementHook extends AgentHook {
private final ChatClient chatClient; // 用于执行重写的小模型
@Override
public String getName() {
return "query_enhancement";
}
@Override
public CompletableFuture<Map<String, Object>> beforeAgent(OverAllState state, RunnableConfig config) {
// ... 核心逻辑见下文拆解
}
}
2. 核心三步走策略
在 beforeAgent 方法中,我们并没有盲目地调用大模型,而是执行了严密的三步走策略:
第一步:精准提取"当前意图"
用户的消息列表里可能包含多轮对话,我们需要锁定最后一条用户消息。
java
// 提取历史消息
List<Message> messages = (List<Message>) state.value("messages").orElse(List.of());
// 【关键】获取最后一个有效的用户查询
String originalQuery = messages.stream()
.filter(msg -> msg instanceof UserMessage)
.map(msg -> ((UserMessage) msg).getText())
.reduce((first, second) -> second) // 取最后一条
.orElse("");
if (originalQuery.trim().isEmpty()) {
return CompletableFuture.completedFuture(Map.of()); // 无内容则跳过
}
第二步:注入"长期记忆" (User Profile)
这是本架构的亮点之一。传统的查询重写只依赖短期对话历史,而忽略了用户是谁 。
我们通过 config.store() 加载用户的长期画像(如职位、偏好、过往关注点)。用户画像更新以及持久化应该在每次与LLM交互后进行,后面会有详细介绍。
java
// 加载长期记忆:用户档案 & 偏好
String userContext = loadUserContext(config);
// loadUserContext 内部逻辑:
// 1. 从 Store 获取 user_profiles/{userId}_profile
// 2. 获取 user_profiles/{userId}_preferences
// 3. 格式化为 prompt 的一部分,例如:
// 【用户档案】
// - 职位: 高级Java架构师
// - 偏好: 喜欢简洁的代码示例,不喜欢长篇理论
场景举例:
- 用户问:"推荐个方案。"
- 无画像:重写为"推荐一个技术方案。"(太泛)
- 有画像 :重写为"为高级Java架构师 推荐一个高并发微服务架构方案 ,要求简洁的代码示例。"(精准命中知识库)
第三步:LLM 智能重写 (The Magic)
构造一个精心设计的 Prompt,调用 chatClient 进行重写。这里我们采用了降级策略:如果 LLM 挂了,直接返回原查询,保证系统可用性。
java
private String enhanceQueryLogic(String currentQuery, List<Message> history, String userContext) {
// 构建对话历史上下文
String conversationHistory = history.stream()
.map(m -> m.getMessageType() + ": " + m.getText())
.collect(Collectors.joining("\n"));
// 动态指令:长文本压缩 vs 短文本补全
String instruction = currentQuery.length() > 500
? "用户输入过长,请压缩至 500 字以内,保留关键实体,消除歧义。"
: "结合【对话历史】和【用户背景】,消除指代歧义(如'它'),补充隐含主语,使查询成为独立完整的陈述句。";
String promptText = String.format("""
### 角色
你是一个专业的查询重写专家。
### 用户背景(来自长期记忆)
%s
### 对话历史(短期记忆)
%s
### 当前用户输入
%s
### 任务指令
%s
### 输出要求
1. 仅输出重写后的查询文本,不要任何解释。
2. 必须利用【用户背景】补全缺失的主语或偏好。
3. 必须保留所有具体参数(日期、ID、版本)。
4. 如果原句已清晰,则原样输出。
### 重写后的查询
""", userContext, conversationHistory, currentQuery, instruction);
try {
// 调用 LLM 执行重写
String result = chatClient.prompt().user(promptText).call().content();
return result != null ? result.trim() : currentQuery;
} catch (Exception e) {
log.warn("LLM 查询增强失败,降级使用原始查询", e);
return currentQuery; // 故障安全
}
}
这里会从短期记忆中加载对话历史并注入上下文,长对话可能超过 LLM 的上下文窗口。常见的解决方案包括:
- 修剪消息。在调用 LLM 之前移除前 N 条或后 N 条消息
- 删除消息。从 Graph 状态中永久删除消息
- 总结消息。总结历史中较早的消息并用摘要替换它们【推荐】
- 自定义策略。自定义策略(例如消息过滤等)
这允许 Agent 在 reasoning-acting 循环中持续跟踪对话而不超过 LLM 的上下文窗口。后面会介绍我的实现。
3. 状态更新:无缝替换
一旦获得增强后的查询,我们不是把它放在一边,而是直接替换 state 中的消息列表。这样,后续的 RAG 节点、Tool Router 拿到的就是已经"翻译"好的完美问题,无需任何额外适配。
java
if (!enhancedQuery.equals(originalQuery)) {
log.info("查询已增强:[{}] -> [{}]", originalQuery, enhancedQuery);
// 构建新消息列表,仅替换最后一条 UserMessage
List<Message> enhancedMessages = new ArrayList<>(messages);
int lastUserIndex = findLastUserMessageIndex(messages); // 辅助方法:倒序查找
if (lastUserIndex != -1) {
enhancedMessages.set(lastUserIndex, new UserMessage(enhancedQuery));
}
// 返回修改后的 state,Spring AI 会自动应用
return CompletableFuture.completedFuture(Map.of("messages", enhancedMessages));
}
三、效果对比:有无增强的天壤之别
让我们看两个真实场景的对比,感受查询增强的威力。
场景 A:多轮对话中的指代消解
| 步骤 | 用户行为 | 无增强 (传统 RAG) | 有增强(混合 RAG) |
|---|---|---|---|
| Round 1 | 用户:"2026年的病假流程是什么?" | 检索:"2026年病假流程" ✅ 成功 | 检索:"2026年病假流程" ✅ 成功 |
| Round 2 | 用户:"那事假呢?" | 检索:"那事假呢" ❌ 失败 (向量库无此句) 结果:胡乱匹配或返回空 | 重写:"2026年的事假申请流程是什么?" ✅ 成功 (精准命中) |
| Round 3 | 用户:"需要审批吗?" | 检索:"需要审批吗" ❌ 失败 (不知道指哪个流程) | 重写:"2026年事假申请流程需要审批吗?" ✅ 成功 (上下文完整) |
场景 B:个性化偏好注入
| 维度 | 无增强 | 有增强 |
|---|---|---|
| 用户画像 | (系统未知) | 职位:运维工程师 ;偏好:只要 Shell 脚本,不要 Python |
| 用户提问 | "怎么重启服务?" | "怎么重启服务?" |
| 重写结果 | "怎么重启服务?" | "Linux 环境下 重启服务的Shell 脚本命令是什么?" |
| 检索结果 | 混杂了 Java、Python、Windows 的通用教程 | 精准命中 Linux Shell 相关文档 |
| 最终体验 | 用户需要二次追问"我要 Shell 版的" | 一次命中,用户感觉 AI"懂我" |
四、避坑指南与最佳实践
在实现过程中,我们也踩过一些坑,总结以下几点供参考:
- 不要过度重写 :
- 如果用户的问题已经很清晰(如"什么是 Spring Boot?"),LLM 可能会画蛇添足。
- 对策:在 Prompt 中明确强调"如果当前输入已经非常清晰且无指代,则原样输出"。
- 性能与成本的平衡 :
- 查询增强需要额外调用一次 LLM。
- 对策:
- 使用小模型(如 Qwen-Turbo 或本地部署的 7B 模型)专门做重写,速度快、成本低。
- 配合语义缓存(本系列下一篇重点),高频问题直接缓存,根本不需要触发重写和检索。
- 容错设计 :
- LLM 可能会超时或报错。
- 对策 :务必像代码中那样做
try-catch降级。增强是"锦上添花",不能因为增强失败导致整个对话不可用。Fail-fast, fallback to original.
五、结语
查询增强(Query Enhancement)绝不是多余的步骤,它是连接人类模糊语言 与机器精准检索的唯一桥梁。
通过 Spring AI Alibaba 的 AgentHook,我们不仅实现了指代消解,更将长期用户画像融入到了检索的源头。这使得我们的 RAG 系统不再是一个冷冰冰的搜索引擎,而是一个能听懂"弦外之音"、记得住"老客喜好"的智能助手。
下一步预告 :
问题清楚了,检索也精准了,但如果每次都要重新查一遍,速度够快吗?下一篇我们将深入语义缓存(Semantic Cache)的实现,揭秘如何让高频问题实现"毫秒级"响应,进一步降低 Token 成本!