Skip to main content

用户问得模糊?查询重写来兜底

作者:程序员马丁

在线博客:https://nageoffer.com

note

Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。

上一篇讲了会话记忆,你的 RAG 系统终于能知道之前聊了什么了。用户先问“iPhone 16 Pro 的退货政策是什么”,再追问“那它的保修期呢”,模型看到了完整的对话历史,知道“它”指的是 iPhone 16 Pro,回答没问题。

但别高兴得太早。

RAG 系统在回答之前,要先去向量数据库检索相关的 chunk。检索系统拿到的 query 是什么?是用户的原始问题——那它的保修期呢。

“它”这个字对检索系统来说没有任何语义信息。向量化之后,“那它的保修期呢”和“iPhone 16 Pro 保修期”的向量距离可能相差甚远。检索召回的结果大概率不是你想要的——可能是“笔记本电脑保修政策”“家电延保服务”这些不相关的内容。

问题出在哪?模型有记忆,但检索系统没有

解法其实很直接:在检索之前,先把“那它的保修期呢”改写成“iPhone 16 Pro 的保修期”,再拿去检索。这就是今天要讲的 Query 改写(Query Rewrite)。

检索系统的失忆问题

1. 模型有记忆,但检索没有

用一张图来看看问题出在哪。

没有 Query 改写的流程——检索用的是原始 query,召回结果不相关:

加入 Query 改写的流程——先改写再检索,召回精准:

区别就在中间多了一步 Query 改写。这一步把用户含糊的追问转化成了一个清晰、完整、独立的检索查询。

2. 不只是“它”的问题

指代消解(把“它”替换成具体实体)是最常被提到的改写场景,但 Query 改写要解决的问题远不止这个。

看几个电商客服场景下的典型问题:

省略上下文

用户在聊了几轮 iPhone 16 Pro 之后,突然问“还有别的颜色吗?”。别的颜色是什么产品的?检索系统不知道。如果直接拿“还有别的颜色吗”去检索,可能召回所有产品的颜色信息,而不是 iPhone 16 Pro 的。

口语化表达

用户说“东西坏了咋整?”。知识库里的文档标题大概率是产品故障维修流程或售后服务指南,不会写东西坏了咋整。口语化的 query 和正式文档之间存在语义鸿沟,检索效果打折扣。

多意图混合

用户问“退货流程是什么,运费谁承担?”。这一句话里其实包含两个独立的问题:退货流程和运费承担方。一次检索很难同时命中两个主题的 chunk。

模糊描述

用户说“那个很贵的手机”。哪个?多少钱算贵?检索系统没有上下文,无法理解这种模糊描述。

这些问题的共同点是:用户的原始 query 对检索系统不够友好。Query 改写要做的事情,就是在检索之前把原始 query 转化为一个独立的、完整的、对检索系统友好的查询。

Query 改写的五种策略

1. 指代消解(Coreference Resolution)

指代消解,说白了就是把代词替换成它指代的具体实体。这是多轮对话中最高频的改写场景。

常见的代词和指代表达:

代词 / 指代表达示例改写结果
它、它的那它的保修期呢?iPhone 16 Pro 的保修期
这个、那个这个支持分期吗?iPhone 16 Pro 支持分期吗?
上面说的上面说的退货条件再详细说说iPhone 16 Pro 拆封后退货条件的详细说明
同样的问题另一款也是这样吗?iPhone 16 Plus 的退货政策和 iPhone 16 Pro 一样吗?

指代消解的关键在于:你必须结合对话历史才能确定代词指的是什么。脱离了对话历史,“它”可以是任何东西。

需要注意一个边界情况:有时候“它”的指代并不明确。比如用户前面同时聊了 iPhone 16 Pro 和 AirPods Pro,然后问“它的保修期呢”,“它”到底指哪个?这种情况下,改写模型需要根据最近的上下文做判断——通常取最近一次被提到的实体。

2. 上下文补全(Context Completion)

人在多轮对话中会自然地省略信息,因为他觉得对方应该知道上下文。但检索系统不知道。

原始 query省略了什么改写结果
还有别的颜色吗?什么产品的颜色iPhone 16 Pro 还有其他颜色可选吗?
价格呢?什么东西的价格iPhone 16 Pro 256GB 白色钛金属的价格是多少?
能退吗?什么产品、什么情况下退iPhone 16 Pro 拆封后能退货吗?
多久能到?什么产品发货到哪里iPhone 16 Pro 下单后多久能送到?

上下文补全和指代消解经常同时出现。“价格呢”既省略了产品名(上下文补全),又省略了主语(可以理解为“它的价格呢”,指代消解)。实际改写时,大模型会一并处理,不需要你单独区分。

3. 口语化转正式(Colloquial to Formal)

用户的提问方式和知识库里的文档写法通常差异很大。用户说人话,文档写书面语。

口语 query知识库中的正式表达改写结果
东西坏了咋整产品故障报修流程产品故障后的维修和报修流程
快递咋还没到订单物流查询 / 发货时效订单发货后物流状态查询
能不能便宜点优惠活动 / 促销政策 / 折扣信息当前可用的优惠活动和折扣信息
买贵了能补差价不价格保护政策商品降价后是否支持差价补偿

这种改写有一个特点:不依赖对话历史。即使是第一轮对话,口语化的 query 也需要转化成更正式的表达。所以口语化转正式其实在单轮对话的 RAG 中也有价值。

不过要注意,口语化转正式不是“翻译”,而是“意图提取”。“能不能便宜点”的意图不是字面上的“降低价格”,而是“查询有没有优惠”。改写模型需要理解用户的真实意图。

4. 多意图拆分(Intent Decomposition)

用户有时候一句话里包含多个问题:

  • 退货流程是什么,运费谁承担?→ 两个独立意图
  • iPhone 16 Pro 和 iPhone 16 Plus 有什么区别?→ 一个对比意图,不需要拆
  • 我想退货,另外帮我查一下保修期→ 两个完全不相关的意图

拆分后,每个子查询分别去检索,各自召回最相关的 chunk,合并后再生成答案。

不是所有长 query 都需要拆。“iPhone 16 Pro 的价格和颜色”虽然问了两个方面,但通常在同一个产品介绍 chunk 里就能找到,不需要拆成两次检索。拆分的判断标准是:两个意图是否可能分布在不同的 chunk 里。

多意图拆分的实现复杂度比前面几种策略高,而且拆分后每个子查询都要走一遍检索流程,成本翻倍。在实际项目中,如果业务场景下多意图问题不多,可以先不实现这个策略。

5. 关键词扩展(Keyword Expansion)

关键词扩展是补充同义词和相关术语,提高检索的召回率。

原始 query扩展后
七天无理由退货七天无理由退货 退换货政策 无条件退款 退货期限
屏幕碎了屏幕碎裂 屏幕破损 屏幕维修 碎屏险
充不进去电无法充电 充电故障 充电接口问题 电池问题

这种改写主要对**关键词检索(BM25)**有帮助——BM25 是按词匹配的,同义词扩展能提高命中率。对向量检索来说,帮助有限,因为向量检索本身就能理解语义相似性(屏幕碎了和屏幕破损的向量已经很接近了)。

如果你的 RAG 系统用的是混合检索(向量 + BM25),关键词扩展在 BM25 那一路上会有明显提升。

6. 五种策略对比

策略解决的问题依赖对话历史实现复杂度对检索的影响适用场景
指代消解代词无法检索必需,否则检索失败多轮对话(最常用)
上下文补全省略信息无法检索必需,否则检索不精准多轮对话
口语化转正式口语与文档的语义鸿沟有提升,尤其对 BM25单轮 + 多轮
多意图拆分一次检索无法覆盖多个意图有提升,但成本翻倍复杂咨询场景
关键词扩展同义词不匹配对 BM25 有帮助混合检索场景

实际项目中,指代消解和上下文补全是必做的(不做的话多轮对话检索基本不可用)。口语化转正式建议做(投入产出比高)。多意图拆分和关键词扩展根据业务需要决定。

用大模型做 Query 改写

前面讲了五种改写策略,但在实际实现中,你不需要为每种策略单独写一套规则。用大模型做改写,一个 Prompt 就能覆盖大部分场景——指代消解、上下文补全、口语化转正式,大模型一次性搞定。

1. 改写 Prompt 的设计

1.1 基础版改写 Prompt

这个 Prompt 适合大多数场景,简单直接:

你是一个查询改写助手。根据对话历史和用户的最新问题,将问题改写为一个独立的、完整的检索查询。

要求:
1. 如果最新问题中包含代词(它、这个、那个等)或省略了关键信息,请结合对话历史补全
2. 如果问题已经足够完整清晰,请原样输出,不要画蛇添足
3. 只输出改写后的查询,不要输出任何解释、前缀或多余内容
4. 改写后的查询应该是一个独立的句子,不依赖对话历史也能理解

对话历史:
{history}

用户最新问题:{query}

改写后的查询:

看几个改写效果:

对话历史原始 query改写结果
用户问了 iPhone 16 Pro 退货政策那它的保修期呢?iPhone 16 Pro 的保修期是多久?
用户在聊 AirPods Pro 的颜色价格呢?AirPods Pro 的价格是多少?
无历史(第一轮)东西坏了咋整?产品故障后的维修流程
用户在聊 iPhone 16 Pro还有别的吗?iPhone 16 Pro 还有其他配置或颜色可选吗?

基础版 Prompt 能很好地处理指代消解和上下文补全,对口语化转正式也有一定效果。

1.2 进阶版改写 Prompt

如果你的业务场景需要支持多意图拆分,可以用进阶版 Prompt。输出格式改为 JSON,方便程序解析:

你是一个查询改写助手。根据对话历史和用户的最新问题,将问题改写为适合检索的查询。

要求:
1. 补全代词和省略的上下文信息
2. 将口语化表达转化为更正式、更适合检索的表达
3. 如果问题包含多个独立意图,拆分为多个子查询
4. 如果问题已经完整清晰且只有一个意图,只输出一个查询
5. 以 JSON 格式输出,格式为:{"queries": ["查询1", "查询2"]}
6. 不要输出 JSON 以外的任何内容

对话历史:
{history}

用户最新问题:{query}

进阶版的输出示例:

原始 query输出 JSON
那它的保修期呢?{"queries": ["iPhone 16 Pro 的保修期是多久"]}
退货流程和运费谁承担?{"queries": ["退货流程是什么", "退货运费由谁承担"]}
东西坏了咋整?{"queries": ["产品故障后的维修和报修流程"]}

基础版够用就用基础版。进阶版虽然功能更强,但 JSON 解析增加了复杂度,模型偶尔也会输出格式不规范的 JSON。除非你的业务确实有多意图拆分的需求,否则基础版是更稳的选择。

2. 改写效果的好坏取决于什么

改写质量受几个因素影响:

对话历史的质量:如果对话历史被摘要压缩得太狠,关键实体可能已经丢了。比如摘要里只写了“客户咨询手机售后”,没有提到具体型号,改写时就无法把“它”替换成具体产品名。所以会话记忆的摘要质量直接影响改写质量。

Prompt 的设计:Prompt 里的规则要明确——什么时候改写、什么时候原样输出。如果 Prompt 没有说“已经完整的 query 不需要改写”,模型可能会画蛇添足,把一个本来就很好的 query 改得面目全非。

模型的能力:小模型在复杂指代消解(多个实体交替出现)和口语化理解上可能不够准确。改写任务对模型要求不高,Qwen2.5-7B-Instruct 级别的模型就能胜任大部分场景。

常见的改写失败案例:

失败类型原始 query错误改写问题原因
过度改写它多少钱?iPhone 16 Pro 256GB 沙漠色钛金属版在京东平台的售价是多少?用户没提过平台和颜色,模型自己加的
改写不足那个也是这个价吗?那个也是这个价吗?“那个”和“这个”都没有替换
偏离原意能不能便宜点?如何投诉商品定价过高?用户想问优惠,不是投诉

遇到这类问题,通常通过调整 Prompt 来解决。比如加一条“不要添加用户没有提到的信息”可以抑制过度改写。

3. 改写的成本与时机

每次 Query 改写都要调一次大模型 API,增加大约 200~500ms 的延迟和一小笔 Token 费用。虽然单次成本不高,但并不是每次请求都需要改写。

什么时候可以跳过改写

  • 第一轮对话:没有对话历史,不存在指代消解和上下文补全的需求。如果 query 本身够完整(iPhone 16 Pro 的退货政策是什么),直接检索就行
  • query 本身已经完整:用户的问题明确包含了主体、动作和对象,不包含代词和省略

什么时候必须改写

  • query 包含代词:它、这个、那个、上面的
  • query 很短且缺少主体:还有吗?多少钱?怎么办?
  • 多轮对话中的追问(非第一轮)

可以用一个简单的规则做预判断,减少不必要的 API 调用:

/**
* 判断是否需要 Query 改写
*/
public boolean needsRewrite(String query, List<Message> history) {
// 第一轮对话且 query 足够长,大概率不需要改写
if (history.isEmpty() && query.length() > 15) {
return false;
}
// 包含代词,需要改写
if (query.matches(".*[它它的这个那个这些那些上面].+")) {
return true;
}
// query 太短,大概率省略了上下文
if (query.length() < 10 && !history.isEmpty()) {
return true;
}
// 有对话历史的情况下,默认都改写(安全起见)
return !history.isEmpty();
}

实际项目中,更稳的做法是:有对话历史就一律改写。改写 API 用小模型(如 Qwen2.5-7B-Instruct),成本很低,但可以避免因为规则没覆盖到而漏改的情况。

Java 实战:Query 改写的实现与效果

1. 基础改写器实现

下面是一个完整可运行的 Query 改写器,基于 Java + OkHttp 调用 SiliconFlow API:

public class QueryRewriter {

private static final String API_URL = "https://api.siliconflow.cn/v1/chat/completions";
private static final String API_KEY = "you api key";
// 改写用小模型就够了,成本低、速度快
private static final String MODEL = "Qwen/Qwen2.5-7B-Instruct";
private static final OkHttpClient client = new OkHttpClient();
private static final Gson gson = new Gson();

/**
* 改写 Prompt 模板
*/
private static final String REWRITE_PROMPT = """
你是一个查询改写助手。根据对话历史和用户的最新问题,\
将问题改写为一个独立的、完整的检索查询。

要求:
1. 如果最新问题中包含代词(它、这个、那个等)或省略了关键信息,\
请结合对话历史补全
2. 如果问题已经足够完整清晰,请原样输出,不要画蛇添足
3. 不要添加用户没有提到的信息
4. 只输出改写后的查询,不要输出任何解释、前缀或多余内容
5. 改写后的查询应该是一个独立的句子,脱离对话历史也能理解

对话历史:
%s

用户最新问题:%s

改写后的查询:""";

/**
* 执行 Query 改写
*
* @param history 对话历史(role + content 的列表)
* @param currentQuery 用户当前问题
* @return 改写后的查询
*/
public static String rewrite(List<Message> history,
String currentQuery) throws IOException {
// 构建对话历史文本
StringBuilder historyText = new StringBuilder();
if (history.isEmpty()) {
historyText.append("(无历史对话)");
} else {
for (Message msg : history) {
String roleName = "user".equals(msg.role) ? "用户" : "助手";
historyText.append(roleName).append(":")
.append(msg.content).append("\n");
}
}

// 构建改写请求
JsonObject body = getJsonObject(currentQuery, historyText);

Request request = new Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer " + API_KEY)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.toString(),
MediaType.parse("application/json")))
.build();

try (Response response = client.newCall(request).execute()) {
assert response.body() != null;
String responseBody = response.body().string();
JsonObject json = gson.fromJson(responseBody, JsonObject.class);
return json.getAsJsonArray("choices")
.get(0).getAsJsonObject()
.getAsJsonObject("message")
.get("content").getAsString().trim();
}
}

private static @NonNull JsonObject getJsonObject(String currentQuery, StringBuilder historyText) {
String prompt = String.format(REWRITE_PROMPT,
historyText.toString(), currentQuery);

JsonObject body = new JsonObject();
body.addProperty("model", MODEL);
body.addProperty("temperature", 0.1);
body.addProperty("max_tokens", 256);
JsonArray messages = new JsonArray();
JsonObject userMsg = new JsonObject();
userMsg.addProperty("role", "user");
userMsg.addProperty("content", prompt);
messages.add(userMsg);
body.add("messages", messages);
return body;
}

/**
* 简单的消息数据结构
*/
public static class Message {
public String role;
public String content;

public Message(String role, String content) {
this.role = role;
this.content = content;
}
}

public static void main(String[] args) throws IOException {
// ===== 场景 1:指代消解 =====
System.out.println("===== 场景 1:指代消解 =====");
List<Message> history1 = List.of(
new Message("user", "iPhone 16 Pro 的退货政策是什么?"),
new Message("assistant",
"iPhone 16 Pro 因屏幕定制工艺,拆封后不支持七天无理由退货。" +
"如有质量问题,可联系售后处理。")
);
String rewritten1 = rewrite(history1, "那它的保修期呢?");
System.out.println("原始 query:那它的保修期呢?");
System.out.println("改写结果:" + rewritten1);

// ===== 场景 2:上下文补全 =====
System.out.println("\n===== 场景 2:上下文补全 =====");
List<Message> history2 = List.of(
new Message("user", "iPhone 16 Pro 有什么颜色?"),
new Message("assistant",
"iPhone 16 Pro 有沙漠色钛金属、自然色钛金属、" +
"白色钛金属和黑色钛金属四种颜色。")
);
String rewritten2 = rewrite(history2, "价格呢?");
System.out.println("原始 query:价格呢?");
System.out.println("改写结果:" + rewritten2);

// ===== 场景 3:口语化转正式 =====
System.out.println("\n===== 场景 3:口语化转正式 =====");
List<Message> history3 = List.of(); // 无历史,第一轮对话
String rewritten3 = rewrite(history3, "东西坏了咋整?");
System.out.println("原始 query:东西坏了咋整?");
System.out.println("改写结果:" + rewritten3);

// ===== 场景 4:已经完整的 query,不需要改写 =====
System.out.println("\n===== 场景 4:不需要改写 =====");
List<Message> history4 = List.of();
String rewritten4 = rewrite(history4,
"iPhone 16 Pro 的退货政策是什么?");
System.out.println("原始 query:iPhone 16 Pro 的退货政策是什么?");
System.out.println("改写结果:" + rewritten4);
}
}

2. 改写前后的效果对比

运行结果(示意):

===== 场景 1:指代消解 =====
原始 query:那它的保修期呢?
改写结果:iPhone 16 Pro 的保修期是多久?

===== 场景 2:上下文补全 =====
原始 query:价格呢?
改写结果:iPhone 16 Pro 的价格是多少?

===== 场景 3:口语化转正式 =====
原始 query:东西坏了咋整?
改写结果:东西坏了怎么修理?

===== 场景 4:不需要改写 =====
原始 query:iPhone 16 Pro 的退货政策是什么?
改写结果:iPhone 16 Pro的退货政策是什么?

几个关键观察:

  • 场景 1:代词“它”被成功替换成了 iPhone 16 Pro,检索系统现在能精准命中 iPhone 16 Pro 保修相关的 chunk
  • 场景 2:省略的主体被补全了,“价格呢”不再是一个孤立的、无法检索的 query
  • 场景 3:即使没有对话历史,口语化的表达也被转化成了更适合检索的正式表达
  • 场景 4:query 本身已经完整清晰,模型原样输出,没有画蛇添足——这一点很重要,过度改写比不改写更危险

3. 改写在 RAG 流程中的位置

把 Query 改写加入后,多轮对话 RAG 的完整流程是这样的:

注意两个细节:

  1. 检索用改写后的 query,但 Prompt 里放的是用户原始问题。改写的目的是让检索更精准,不是改变用户的问题。模型生成答案时,应该针对用户的原始问题回答,而不是改写后的 query。保险点改写问题前后放进去也可以。
  2. Query 改写在会话记忆读取之后、检索之前。这个位置很关键——改写需要对话历史作为输入,改写的结果用于检索

生产环境的注意事项

1. 改写质量的监控

Query 改写是一个容易默默出错的环节——改写结果不好,检索不到相关内容,模型给出一个兜底回答或者答非所问,用户可能只是觉得这个 AI 不够聪明,不会意识到是改写环节出了问题。

建议记录每次改写的完整信息:

{
"session_id": "session-001",
"original_query": "那它的保修期呢?",
"rewritten_query": "iPhone 16 Pro 的保修期是多久?",
"history_length": 2,
"rewrite_latency_ms": 320,
"timestamp": "2025-03-07T10:30:00Z"
}

定期抽检改写日志,关注几个指标:

  • 改写率:所有请求中触发了改写的比例。如果太低,可能是判断规则太严格,该改写的没改写
  • 过度改写率:人工标注后发现模型画蛇添足的比例。如果太高,需要调整 Prompt
  • 改写后检索提升率:对比改写前后的检索命中率(需要配合人工标注或自动化评测)

2. 改写失败的兜底

改写 API 可能因为网络超时、模型服务不可用、返回格式异常等原因失败。这时候应该用原始 query 兜底,而不是报错。

public String safeRewrite(List<Message> history, String query) {
try {
String rewritten = rewrite(history, query);
// 基本校验:改写结果不能为空,不能太长
if (rewritten != null && !rewritten.isEmpty()
&& rewritten.length() < 500) {
return rewritten;
}
} catch (Exception e) {
log.warn("Query 改写失败,使用原始 query: {}", e.getMessage());
}
return query; // 兜底:返回原始 query
}

Query 改写是锦上添花,不是雪中送炭。即使改写失败,用原始 query 检索也能有一定的效果(只是精度可能差一些)。千万不要因为改写失败就让整个 RAG 流程挂掉。

3. 改写缓存

同一个 session 内,用户可能重复提问或者问类似的问题。可以用一个简单的缓存避免重复调用改写 API:

// 缓存 key = sessionId + 原始 query 的哈希
Map<String, String> rewriteCache = new ConcurrentHashMap<>();

public String rewriteWithCache(String sessionId, List<Message> history,
String query) throws IOException {
String cacheKey = sessionId + ":" + query.hashCode();
return rewriteCache.computeIfAbsent(cacheKey,
k -> {
try {
return rewrite(history, query);
} catch (IOException e) {
return query;
}
});
}

注意:缓存的粒度要包含 sessionId,因为同样的 query 在不同的对话上下文中,改写结果可能不同。“价格呢?”在聊 iPhone 时改写成 iPhone 的价格,在聊 AirPods 时改写成 AirPods 的价格。

小结与下一篇预告

这篇讲了 Query 改写,核心要点回顾:

  1. 检索系统的失忆问题:会话记忆让模型有了上下文理解能力,但检索系统拿到的还是用户的原始 query,包含代词和省略信息的 query 检索效果很差
  2. 五种改写策略:指代消解(必做)、上下文补全(必做)、口语化转正式(推荐)、多意图拆分(按需)、关键词扩展(混合检索场景有用)
  3. 用大模型做改写:一个 Prompt 覆盖大部分改写场景,用小模型即可,成本低延迟小
  4. Prompt 设计是关键:要明确告诉模型不要画蛇添足,避免过度改写
  5. 改写在 RAG 流程中的位置:会话记忆之后、检索之前。检索用改写后的 query,生成回答用用户原始问题

结合前面几篇,多轮对话 RAG 的完整链路已经清晰了:

到这里,RAG 系统已经具备了完整的多轮对话能力。但还有一个问题没有解决:用户发来一条消息,系统怎么知道应该去检索知识库,还是去调用工具,还是直接闲聊?

比如用户说“今天天气怎么样”,这明显不是一个知识库检索的问题;用户说“帮我查一下我的年假余额”,这应该调 Function Call;用户说“iPhone 16 Pro 的退货政策是什么”,这才需要走 RAG 检索。

下一篇咱们来聊 意图识别与问题路由——让 RAG 系统在收到用户消息后,自动判断应该走哪条路:知识库检索、工具调用、还是直接对话,做出正确的路由决策。