Appearance
查询改写与前处理:用户的问题往往不适合直接检索
1. 本节产出
一个查询前处理模块:指代消解(「它/这个/我们公司」→ 具体名词)、多查询扩展、以及一个 Hyde 开关。并且能用评测集说明「哪些类型的查询改写收益最大」。
2. 前置依赖
- 02-09 混合检索
- 02-11 引用溯源与拒答
- 01-06 对话记忆:指代消解依赖历史
3. 为什么原始问题不适合直接检索
三个真实案例:
| 用户问题 | 直接检索的问题 | 改写后 |
|---|---|---|
| 「它支持多少并发?」 | 「它」指什么不知道,检索到一堆无关内容 | 「API 网关支持多少并发?」 |
| 「那个报错怎么解决」 | 「那个」完全没有信息量 | 「解决 Connection reset by peer 报错」 |
| 「我们公司的报销标准」 | 「我们公司」是代词,文档里写的是公司名 | 「ACME 公司的差旅报销标准」 |
根因:用户是在对话上下文里提问的,但检索看到的是孤立的字符串。
第二类问题是「问法不匹配」:
| 用户问题 | 文档里的表述 |
|---|---|
| 「服务器挂了怎么办」 | 「服务不可用的应急处理流程」 |
| 「怎么让员工少离职」 | 「降低人员流失率的措施」 |
向量检索能处理一部分同义(这是它的优势),但处理不了「口语 vs 书面语」和「问题 vs 陈述」的错配。
4. 核心原理
4.1 四种改写技术
| 技术 | 做法 | 成本 | 收益 |
|---|---|---|---|
| 指代消解 | 用历史对话把代词替换成具体名词 | 低(1 次轻量调用) | 高,多轮场景必做 |
| 查询扩展 | 让模型生成 2-3 个同义问法,都去检索 | 中(1 次调用 + N 次检索) | 中高 |
| HyDE | 先让模型生成一个「假答案」,用假答案去检索 | 中 | 中,对陈述型文档有效 |
| 关键词抽取 | 从问题里抽出关键实体,用于 BM25 | 低 | 中 |
4.2 指代消解:收益最高的一档
历史:
用户:API 网关的限流规则是什么?
助手:API 网关采用令牌桶限流,单租户默认 100 QPS...
当前:
用户:它支持动态调整吗?
↑ 必须消解
改写后:
API 网关的限流规则支持动态调整吗?实现方式:把最近 N 轮对话 + 当前问题交给轻量模型,让它输出一个独立的、完整的问题。
java
String standalone = chatClient.prompt()
.system("把用户的追问改写成一个独立完整的问题,不出现代词。只输出改写后的问题。")
.user("历史:" + history + "\n追问:" + question)
.options(OpenAiChatOptions.builder().temperature(0.0).maxTokens(60).build())
.call().content();注意 maxTokens(60):改写结果很短,限制长度既省钱又防止模型开始回答问题。
4.3 多查询扩展:什么时候划算
原问题:「年假怎么算」
扩展:
1. 「年假如何计算」
2. 「年休假天数规定」
3. 「员工年假标准」
→ 三个查询都检索,结果用 RRF 融合| 优点 | 缺点 |
|---|---|
| 召回率提升明显(尤其对表述差异大的文档) | 检索成本 ×3、延迟上升 |
建议:不要默认开启。先在评测集上测,只有当「原查询召回失败但扩展查询能召回」的比例超过 10% 时才值得开。
4.4 HyDE 的原理与局限
HyDE(Hypothetical Document Embeddings):
1. 让模型根据问题生成一个「假想的答案文档」
2. 用这个假答案的向量去检索
3. 因为假答案在表述上更接近真实文档(都是陈述句)为什么有效:把「问题」变成「陈述」,减小了与文档的语体差异。
局限:
- 假答案可能包含错误信息,把检索带偏;
- 多一次模型调用,延迟 +300ms 以上;
- 对「精确匹配类」查询(订单号)有害无益。
判断:HyDE 适合文档以陈述句为主、查询以疑问句为主的场景。先测后开。
5. 代码走查
5.1 改写策略接口
java
// ch02-rag/src/main/java/com/aitech/rag/rewrite/QueryRewriter.java
public interface QueryRewriter {
List<String> rewrite(String question, List<Message> history);
}5.2 指代消解实现
java
// ch02-rag/src/main/java/com/aitech/rag/rewrite/CoreferenceRewriter.java
@Component
public class CoreferenceRewriter implements QueryRewriter {
private final ChatClient light; // 轻量模型即可
@Override
public List<String> rewrite(String question, List<Message> history) {
// 历史为空或问题不含代词时跳过——省一次调用
if (history.isEmpty() || !containsPronoun(question)) {
return List.of(question);
}
String standalone = light.prompt()
.system("""
把用户的追问改写成一个独立、完整、无代词的问题。
只输出改写结果,不要解释,不要回答。
""")
.user(u -> u.text("历史对话:\n{history}\n\n追问:{q}")
.param("history", format(history))
.param("q", question))
.options(OpenAiChatOptions.builder()
.temperature(0.0)
.maxTokens(60)
.build())
.call().content();
log.info("rewrite: {} → {}", question, standalone);
return List.of(standalone.trim());
}
private boolean containsPronoun(String q) {
return q.matches(".*[它他她这个那个其此].*");
}
}containsPronoun 这个短路判断很重要:不含代词时不做改写,能省掉大量不必要的模型调用。
5.3 多查询扩展
java
// ch02-rag/src/main/java/com/aitech/rag/rewrite/MultiQueryRewriter.java
@Component
@ConditionalOnProperty(name = "ai.rag.multi-query", havingValue = "true")
public class MultiQueryRewriter implements QueryRewriter {
@Override
public List<String> rewrite(String question, List<Message> history) {
BeanOutputConverter<Queries> conv = new BeanOutputConverter<>(Queries.class);
Queries qs = conv.convert(light.prompt()
.user(u -> u.text("""
生成 3 个与原问题语义相同但表述不同的检索查询。
{format}
原问题:{q}
""")
.param("format", conv.getFormat())
.param("q", question))
.options(OpenAiChatOptions.builder().temperature(0.3).build())
.call().content());
// 原问题也保留:扩展查询可能偏离原意
return Stream.concat(Stream.of(question), qs.items().stream()).toList();
}
public record Queries(List<String> items) {}
}保留原问题是重要细节:扩展查询可能跑偏,原问题作为基准保证不丢失。
5.4 接入检索链
java
// 改写 → 多查询检索 → RRF 融合
List<String> queries = rewriter.rewrite(question, history);
List<RetrievalResult> all = queries.stream()
.map(q -> pipeline.retrieve(q, tenantId, acl))
.flatMap(List::stream)
.toList();
return rrf.dedupAndMerge(all, topK); // 需要去重,同一文档可能被多个查询命中6. 跑起来
bash
git checkout ch02-13-query-rewriting
mvn -q test -Dtest=QueryRewritingEvaluationTest期望输出(按查询类型分组):
查询类型 样本数 改写前召回@5 改写后召回@5 提升
指代类 18 0.39 0.83 +44pt
口语化类 22 0.64 0.77 +13pt
术语/编号类 15 0.87 0.80 -7pt ← 改写有害
普通类 32 0.81 0.83 +2pt| 检查项 | 通过标准 |
|---|---|
| 指代类查询 | 改写后召回率显著提升 |
| 编号类查询 | 不应被改写(或改写后不下降) |
| 短路生效 | 无代词时日志不出现改写调用 |
| 成本可控 | 改写调用 Token 数远小于主流程 |
| 降级 | 改写服务失败时用原问题继续检索 |
「编号类查询改写后下降」这个结果是真实的,它说明改写不是万能的。正确的做法是分类处理:只对含代词或明显口语化的问题做改写。
7. 生产避坑
- 改写不是越多越好,要分类处理。对含编号、人名、错误码的查询做改写,反而会稀释精确匹配信号。做法:先用规则判断是否含精确标识符,含则跳过改写。
- 多查询扩展会把检索成本乘以查询数。3 个查询就是 3 倍向量检索 + 3 倍关键词检索。上线前算清楚成本,必要时只对「首次检索分数偏低」的查询做二次扩展(自适应策略)。
- 改写结果必须打日志。这是排查「为什么搜不到」的第一手材料——很多时候问题出在改写跑偏了,而日志是唯一证据。没有日志的改写等于黑盒。
8. 延伸与锚点
- 思考题:文档更新了,旧的片段还在库里。怎么做到「改一篇文档不用重灌全库」?(02B 完成,进入 02C 生产化——答案在下一课时)
- 代码锚点:
git checkout ch02-13-query-rewriting - 下一课时:02-14 增量更新与版本管理
- 对应课件:L02-13 查询改写