RAG 的查询改写——用户问的问题往往不是好的检索 Query
RAG 的查询改写——用户问的问题往往不是好的检索 Query
上个季度我们做了一个内部法规知识库,接入了公司几千份合规文件。上线第一周,业务同事就来找我了——说系统找不到东西。
我当时第一反应是向量化或者分块有问题,花了三天反复调整 chunk size,从 512 改到 1024,又试了 overlap 从 50 到 200。没用。准确率还是在 50% 上下徘徊。
后来我坐下来,仔细看了那些"找不到"的 case。业务同事问的是:
"我们和供应商签合同,货款结算的时候有什么要注意的?"
但文档里的标题是:
"《第三方供应商应付账款管理规范》第四章 付款流程合规要求"
你看出来问题了吗?用户在用口语,文档里是术语。用户说"货款结算",文档说"应付账款"。用户说"要注意的",文档说"合规要求"。这两句话的语义空间根本不在同一个位置。
这就是 RAG 的第一个深坑:用户输入的问题和文档里的表达之间存在系统性的语义 Gap。
为什么向量检索解决不了这个问题
很多人以为用了向量检索就没事了,毕竟语义相似度嘛,应该能搞定同义词。理论上没错,实际上差很多。
问题在于 Embedding 模型学的是通用语义,但行业文档用的是行业术语。"货款结算"和"应付账款管理"在通用语料里的相似度并不高,因为财务人员写文档的时候用的词和业务人员说话用的词本来就是两套体系。
我用 text-embedding-3-small 算了一下这两句话的余弦相似度,大概在 0.62 左右。听起来还行?但我们的检索阈值设的是 0.75,直接被过滤掉了。
更糟糕的是,这个 Gap 是系统性的,不是偶发的。用户说"报销",文档说"费用报销申请";用户说"请假超了怎么办",文档说"年假结余处理规定";用户说"离职要交接什么",文档说"离职员工知识产权归属条款"。
这不是个别 case,这是整个系统的结构性问题。
光靠调参解决不了结构性问题,得在架构层面加一层:Query Rewriting(查询改写)。
Query Rewriting 的三种主要策略
我在这个问题上研究了挺长时间,实际用过三种方法,每种都有自己的适用场景。
1. 直接改写(Standard Rewriting)
最简单的思路:把用户的口语化问题,用 LLM 改写成更接近文档表达的专业问题。
这个方法的效果取决于 Prompt 写得好不好。我们实际用的 Prompt 是这样的:
你是一个企业合规知识库的检索优化专家。
用户提交了一个口语化的问题,请将其改写为适合在正式文档中检索的专业表述。
要求:
1. 保留原始问题的核心意图
2. 将口语词汇替换为对应的专业术语
3. 补充可能在文档中出现的相关关键词
4. 输出 1-2 个改写后的检索短语
用户问题:{user_query}这个方法简单有效,但有个问题:如果 LLM 对你们行业不够了解,改写出来的词可能还是不对。
2. HyDE(Hypothetical Document Embeddings)
这个方法是我后来才用上的,思路很反直觉:不改写问题,而是让 LLM 生成一个"假设存在的答案文档",然后用这个假设文档去检索。
逻辑是这样的:如果答案文档存在,它应该长什么样子?LLM 生成这个假设答案,它的表述风格和真实文档更接近,所以用假设答案的向量去检索,比用问题本身的向量效果好。
实测下来,HyDE 在我们这个场景里比直接改写提升了约 12 个百分点。
3. 多 Query 扩展(Multi-Query Expansion)
这个是我目前在生产环境用的主力方案。思路是:一个问题,从多个角度生成多个不同的检索 Query,然后把多个 Query 的检索结果合并去重。
覆盖面比单一改写宽很多。用户问"货款结算有什么要注意的",我可以扩展成:
- "应付账款合规要求"(专业术语角度)
- "供应商付款流程规定"(流程角度)
- "采购付款审批规范"(审批角度)
- "第三方结算注意事项"(注意事项角度)
四个 Query 分别检索,合并结果,覆盖率大幅提升。
带查询改写的 RAG 完整流程
Spring AI 实现查询改写层
下面是我们实际用在生产的代码,做了一些简化,但核心逻辑保留了。
首先定义 QueryRewriter 接口和基础实现:
public interface QueryRewriter {
List<String> rewrite(String originalQuery);
}然后是 HyDE 实现:
@Component
@Slf4j
public class HydeQueryRewriter implements QueryRewriter {
private final ChatClient chatClient;
private static final String HYDE_PROMPT = """
你是企业知识库专家。请基于以下问题,生成一段"假设性的文档片段"——
假设文档库中存在能够回答这个问题的段落,那个段落会怎么写?
注意:
- 使用正式、专业的文档语言
- 长度控制在100-200字
- 直接输出文档内容,不要有"以下是"之类的前缀
用户问题:{query}
""";
public HydeQueryRewriter(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@Override
public List<String> rewrite(String originalQuery) {
try {
String hydeDoc = chatClient.prompt()
.user(u -> u.text(HYDE_PROMPT).param("query", originalQuery))
.call()
.content();
log.debug("HyDE generated doc for query '{}': {}", originalQuery, hydeDoc);
return List.of(hydeDoc);
} catch (Exception e) {
log.warn("HyDE rewrite failed for query: {}, falling back to original", originalQuery);
return List.of(originalQuery);
}
}
}多 Query 扩展实现:
@Component
@Slf4j
public class MultiQueryRewriter implements QueryRewriter {
private final ChatClient chatClient;
private static final String MULTI_QUERY_PROMPT = """
你是一个企业知识库检索优化助手。
请基于用户的原始问题,从不同角度生成4个用于文档检索的查询语句。
要求:
1. 每个查询角度不同(如:专业术语、流程步骤、责任主体、合规要求等)
2. 使用文档中可能出现的正式表述
3. 每行输出一个查询,不要编号或其他前缀
原始问题:{query}
输出4个检索查询:
""";
public MultiQueryRewriter(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@Override
public List<String> rewrite(String originalQuery) {
try {
String response = chatClient.prompt()
.user(u -> u.text(MULTI_QUERY_PROMPT).param("query", originalQuery))
.call()
.content();
List<String> queries = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(4)
.collect(Collectors.toList());
// 加入原始 query,避免改写太偏
queries.add(0, originalQuery);
log.debug("Multi-query expanded '{}' to {} queries", originalQuery, queries.size());
return queries;
} catch (Exception e) {
log.warn("Multi-query rewrite failed, falling back to original: {}", originalQuery);
return List.of(originalQuery);
}
}
}组合查询改写器,同时运行 HyDE 和多 Query:
@Service
@Slf4j
public class CombinedQueryRewriter implements QueryRewriter {
private final HydeQueryRewriter hydeRewriter;
private final MultiQueryRewriter multiQueryRewriter;
public CombinedQueryRewriter(HydeQueryRewriter hydeRewriter,
MultiQueryRewriter multiQueryRewriter) {
this.hydeRewriter = hydeRewriter;
this.multiQueryRewriter = multiQueryRewriter;
}
@Override
public List<String> rewrite(String originalQuery) {
List<String> allQueries = new ArrayList<>();
allQueries.add(originalQuery); // 始终包含原始问题
// 并行执行两种改写策略
CompletableFuture<List<String>> hydeFuture = CompletableFuture
.supplyAsync(() -> hydeRewriter.rewrite(originalQuery));
CompletableFuture<List<String>> multiFuture = CompletableFuture
.supplyAsync(() -> multiQueryRewriter.rewrite(originalQuery));
try {
allQueries.addAll(hydeFuture.get(10, TimeUnit.SECONDS));
allQueries.addAll(multiFuture.get(10, TimeUnit.SECONDS));
} catch (TimeoutException e) {
log.warn("Query rewrite timeout, using available results");
} catch (Exception e) {
log.error("Query rewrite error", e);
}
// 去重,保持顺序
return allQueries.stream().distinct().collect(Collectors.toList());
}
}集成到 RAG 检索流程:
@Service
@Slf4j
public class RagRetrievalService {
private final VectorStore vectorStore;
private final CombinedQueryRewriter queryRewriter;
// 单 Query 检索的 Top-K
private static final int SINGLE_QUERY_TOP_K = 5;
public RagRetrievalService(VectorStore vectorStore,
CombinedQueryRewriter queryRewriter) {
this.vectorStore = vectorStore;
this.queryRewriter = queryRewriter;
}
public List<Document> retrieveWithRewriting(String userQuery) {
// 1. 查询改写
List<String> expandedQueries = queryRewriter.rewrite(userQuery);
log.info("Query '{}' expanded to {} queries", userQuery, expandedQueries.size());
// 2. 并行检索所有 Query
Map<String, Document> deduplicatedDocs = new LinkedHashMap<>();
expandedQueries.parallelStream().forEach(query -> {
try {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(SINGLE_QUERY_TOP_K)
);
synchronized (deduplicatedDocs) {
for (Document doc : docs) {
// 以 doc id 去重,保留第一个(通常是相关性最高的)
deduplicatedDocs.putIfAbsent(doc.getId(), doc);
}
}
} catch (Exception e) {
log.warn("Retrieval failed for query: {}", query, e);
}
});
List<Document> mergedDocs = new ArrayList<>(deduplicatedDocs.values());
log.info("Retrieved {} unique documents from {} queries",
mergedDocs.size(), expandedQueries.size());
return mergedDocs;
}
}这个并行检索有个小坑:如果你的向量数据库连接池不够大,并发请求多了会超时。我们第一次上线的时候就栽在这里了,Milvus 连接池默认 10 个,5 个 Query 并发检索就打满了。后来把连接池扩到 50,同时限制并发度,才稳定下来。
实测数据对比
我把我们自己项目的数据整理了一下,这是基于 200 个真实业务问题的测试集:
| 方案 | Top-5 召回率 | 精确答案命中率 | 平均检索耗时 |
|---|---|---|---|
| 原始 Query 直接检索 | 51.3% | 38.7% | 120ms |
| 标准改写(单 Query) | 63.8% | 52.1% | 480ms |
| HyDE | 69.2% | 58.4% | 620ms |
| 多 Query 扩展(4个) | 74.6% | 63.9% | 890ms |
| HyDE + 多 Query 组合 | 81.3% | 70.2% | 1240ms |
组合方案把召回率从 51% 提升到 81%,精确命中率从 38% 提升到 70%,代价是延迟从 120ms 增加到 1.2 秒。
对于我们这个场景(合规咨询,用户可以等几秒),这个延迟完全可以接受。但如果你做的是实时对话,可能需要做一些取舍——只用 HyDE 或者只用 3 个扩展 Query,延迟能控制在 600-700ms。
子问题分解——处理复杂问题的进阶手段
上面的方案处理单一问题很好,但有些问题本身就很复杂,一次检索搞不定。
比如用户问:"我们公司的供应商如果违约了,赔偿金额怎么算,走什么审批流程,最终财务怎么处理?"
这其实是三个问题:
- 供应商违约赔偿金额计算规则
- 赔偿相关的审批流程
- 赔偿款的财务处理
把这个复杂问题拆成三个子问题分别检索,效果比整体检索好很多。
@Component
@Slf4j
public class SubQuestionDecomposer {
private final ChatClient chatClient;
private static final String DECOMPOSE_PROMPT = """
分析以下问题,判断它是否包含多个子问题或多个方面。
如果是复合问题,请将其拆分为独立的子问题,每个子问题可以单独检索回答。
如果是简单问题,直接返回原问题即可。
输出格式:每行一个问题,不要编号或前缀。
问题:{query}
""";
public SubQuestionDecomposer(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public List<String> decompose(String query) {
try {
String response = chatClient.prompt()
.user(u -> u.text(DECOMPOSE_PROMPT).param("query", query))
.call()
.content();
List<String> subQuestions = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty() && s.length() > 5)
.collect(Collectors.toList());
// 如果拆分结果只有一个,说明是简单问题,直接返回
if (subQuestions.size() <= 1) {
return List.of(query);
}
log.info("Decomposed '{}' into {} sub-questions", query, subQuestions.size());
return subQuestions;
} catch (Exception e) {
log.warn("Decomposition failed, using original query", e);
return List.of(query);
}
}
}子问题分解配合多 Query 扩展一起用,覆盖率会更高。但注意,子问题越多,LLM 调用次数就越多,成本和延迟都会线性增长。要根据实际业务场景做取舍,不是所有 Query 都需要分解。
一些反直觉的踩坑经验
1. 改写并不总是变好
有一次,我们的改写模型把"劳动争议"改写成了"员工绩效管理",完全错了。LLM 有时候会过度发散。所以改写结果一定要加入原始 Query 作为保底,不能完全依赖改写。
2. HyDE 生成质量很重要
如果 LLM 对你们行业不熟,生成的假设文档本身就是错的,那检索出来的结果反而更差。我们测试过,对于通用型问题,HyDE 提升明显;对于高度专业化的小众领域,HyDE 有时候会帮倒忙。
3. 改写后的 Query 要记录下来
改写是个黑盒,用户不知道你在背后做了什么。我们把改写过程存入日志,方便后续分析哪类问题改写效果差,形成反馈循环来优化 Prompt。
4. 不要在所有问题上都开改写
对于已经很精准的问题(比如用户搜了一串文档编号),改写会适得其反。我们加了一个"问题复杂度评估"步骤,短的、包含关键词的问题直接跳过改写层。
public boolean needsRewriting(String query) {
// 很短的 query,可能是关键词搜索,不改写
if (query.length() < 10) return false;
// 包含文档编号格式,不改写
if (query.matches(".*[A-Z]{2,}-\\d{4,}.*")) return false;
// 纯数字或英文缩写,不改写
if (query.matches("[A-Z0-9\\s-]+")) return false;
return true;
}总结
回到最开始那个法规知识库的问题,加上查询改写之后,准确率从 50% 提升到了 78%。业务同事再也没来找我抱怨了——虽然后来他们又有新的抱怨:为什么有时候回答慢。那是另一个故事了。
Query Rewriting 是 RAG 里性价比最高的优化之一。它不需要改动你的文档处理流程,不需要重建向量索引,只是在检索之前加一层,但效果立竿见影。
如果你的 RAG 系统现在召回率不太好,先别急着换向量模型,先试试加一层查询改写,大概率能解决大部分问题。
