Skip to content

查询改写与前处理:用户的问题往往不适合直接检索 ​

1. 本节产出 ​

一个查询前处理模块:指代消解(「它/这个/我们公司」→ 具体名词)、多查询扩展、以及一个 Hyde 开关。并且能用评测集说明「哪些类型的查询改写收益最大」。

2. 前置依赖 ​

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
// src/main/java/com/example/rag/rewrite/QueryRewriter.java
public interface QueryRewriter {
    List<String> rewrite(String question, List<Message> history);
}

5.2 指代消解实现 ​

java
// src/main/java/com/example/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
// src/main/java/com/example/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. 生产避坑 ​

  1. 改写不是越多越好,要分类处理。对含编号、人名、错误码的查询做改写,反而会稀释精确匹配信号。做法:先用规则判断是否含精确标识符,含则跳过改写。
  2. 多查询扩展会把检索成本乘以查询数。3 个查询就是 3 倍向量检索 + 3 倍关键词检索。上线前算清楚成本,必要时只对「首次检索分数偏低」的查询做二次扩展(自适应策略)。
  3. 改写结果必须打日志。这是排查「为什么搜不到」的第一手材料——很多时候问题出在改写跑偏了,而日志是唯一证据。没有日志的改写等于黑盒。

8. 延伸与锚点 ​

  • 思考题:文档更新了,旧的片段还在库里。怎么做到「改一篇文档不用重灌全库」?(02B 完成,进入 02C 生产化——答案在下一课时)
  • 代码锚点:git checkout ch02-13-query-rewriting
  • 下一课时:02-14 增量更新与版本管理
  • 对应课件:L02-13 查询改写