什么是多查询扩展?它有什么作用?如何基于 Spring AI 实现多查询扩展?
- 标签:后端、编程导航
🏛 业务架构师
多查询扩展(Multi-Query):把用户的一次提问改写成多个不同角度的查询,全部检索后合并去重结果。例:"怎么请假"扩展为"请假流程""审批人是谁""年假天数计算"三个查询。
作用机理与价值:
- 对抗查询-文档的语义错配:用户问法千奇百怪,文档措辞各不相同——单一查询召回有盲区,多角度查询把召回率抬起来(漏答在 RAG 场景是体验重灾区:明明库里有答案却说不知道);
- 代价:一次请求变多次检索(成本 ×N、延迟增加)——用检索成本换召回率的交易,是否划算取决于业务:客服知识库漏答直接转人工(人工成本高),多查询划算;闲聊场景不划算。
基于 Spring AI 的实现:自定义 Advisor 或 QueryTransformer——before 阶段调模型把原查询改写成 3~5 个变体(prompt 约束"从不同角度、不要同义重复"),分别向量检索,RRF(倒数排名融合)合并,取 topK 进入最终上下文。
我的落地策略:按场景配置化开启——高价值问答场景开(客服、内部制度),实时闲聊关;扩展数量 3~5 是性价比区间(再多收益边际递减);上线前用评测集对比"开 vs 不开"的召回率提升,用数据决定去留。
一句话:多查询扩展是花 3 倍检索成本买召回率保险——适合"漏答代价高"的业务,不是所有场景的默认选项。
🏗 系统架构师
多查询扩展(Multi-Query)= 把用户一个问题改写成多个不同角度的查询,分别检索后合并结果。来自经典 RAG 论文(查询扩展是预检索优化第一梯队)。
作用:解决"查询与文档表述失配"——用户问"咋退款",文档写"售后流程退回货款",单查询向量召回可能错过;多角度改写("退款流程"、"售后政策"、"退货退款步骤")大幅提升召回率。同时也起到查询"消歧"作用(模糊问题展开成具体子问题)。
Spring AI 实现(我基于 Advisor 实现):
java
public class MultiQueryAdvisor implements RetrieveAdvisor {
public AdvisedResponse adviseCall(AdvisedRequest req, Chain chain) {
// 1. 结构化输出:让模型生成 4 个改写查询
List<String> queries = chatClient.prompt()
.user(原问题 + "请生成4个不同角度的同义改写")
.call().entity(List<String>.class);
// 2. 多路检索 + 去重合并(按 docId 去重,分数归一化后取 TopK)
List<Document> docs = queries.stream()
.flatMap(q -> vectorStore.similaritySearch(q, 4).stream())
.distinct().sorted(score).limit(8).toList();
// 3. 拼上下文继续链
return chain.nextAroundCall(增强后的请求);
}
}权衡:查询数 ×K 次检索,延迟与向量库压力线性涨——我配 3~5 个查询、K=4,并行检索(CompletableFuture)压延迟;改写质量依赖模型,用便宜快模型即可;与 RRF(倒数排名融合)合并效果最佳。这是"用算力换召回"的典型预检索优化。
💻 全栈开发专家
多查询扩展(Multi-Query)= 把一个用户问题改写成多个不同角度的查询变体,并行检索后融合结果。作用:对抗"单一向量表达的偏差"——同一个意图有 N 种说法,一次 embedding 只能命中其中一部分表述的文档,多路召回显著提升召回率。
基于 Spring AI 实现(自定义 Advisor):
java
public class MultiQueryAdvisor implements CallAdvisor<...> {
public ChatClient.ChatClientRequest adviseRequest(req, chain) {
String q = req.prompt().getUserMessage().getText();
// 1. 让 LLM 生成 3~5 个变体
List<String> queries = chatClient.prompt()
.user("把以下问题改写成 3 个不同表述的检索查询,一行一个:\n" + q)
.call().entity(new ParameterizedTypeReference<List<String>>() {});
// 2. 并行检索
List<Document> all = queries.parallelStream()
.flatMap(query -> vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(5).build()).stream())
.toList();
// 3. 去重 + RRF 融合排序,取 top-k 作为上下文
List<Document> merged = rrfMerge(all, 5);
return chain.nextAdvice(withAugmentedContext(req, merged));
}
}实测效果:黄金测试集 recall@5 从 68% 提到 83%(多路变体召回了原本漏掉的文档)。
代价与边界:查询改写一次 LLM 调用 + N 倍检索,延迟增加几百毫秒——我做成可配置开关,只对长尾/复杂问题开启;变体数 3 个性价比最高,5 个以上收益递减;记得对变体做去重归一(完全相同的改写没有意义)。