RAG

RAG优化技术:问题改写

在RAG的相似度检索中,检索效果的好坏,很大程度取决于用户问题的表达方式。用户输入的提问是不可控的,往往存在模糊、信息缺失、上下文依赖等问题,难以直接高效地匹配到高质量的相关文档块。因此需要在检索前对用户输入的查询进行优化,从而显著…

TL;DR

在RAG的相似度检索中,检索效果的好坏,很大程度取决于用户问题的表达方式。用户输入的提问是不可控的,往往存在模糊、信息缺失、上下文依赖等问题,难以直接高效地匹配到高质量的相关文档块。因此需要在检索前对用户输入的查询进行优化,从而显著…

在RAG的相似度检索中,检索效果的好坏,很大程度取决于用户问题的表达方式。用户输入的提问是不可控的,往往存在模糊、信息缺失、上下文依赖等问题,难以直接高效地匹配到高质量的相关文档块。因此需要在检索前对用户输入的查询进行优化,从而显著提高检索的准确率和最终生成答案的质量。 而有一个常见的提升检索效果的优化手段,就是问题重写(改写),他的目标就是: 让用户的自然语言提问,转化为更适合检索的查询表达。 问题重写的策略有很多,这边给大家介绍的是三种主流的方法,基本可以涵盖绝大多数的场景,分别是:富化、分解、多样化、回溯提示。

分解(子问题)

分解是指将用户的复杂、多步骤或包含多个子问题的查询,拆解成若干个相互独立、更简单、更具体的子问题。每个子问题可以独立进行检索,最终将多个检索结果合并,用于回答原问题。 比如用户问:iPhone 15 发布的时候,苹果的 CEO 是谁? 这是一个典型的多跳问题 ,直接去检索查询可能得不到合适的结果,那么通过把这个问题分解成多个子问题: Q1:iPhone 15 是什么时候发布的? Q2:2023 年 9 月时,苹果公司的 CEO 是谁? 再比如,Kafka和RocketMQ的异同点是什么? 这个问题就可以分解成多个子问题: Q1:Kafka有哪些特性? Q2:RocketMQ有哪些特性? Q3:Kafka和RocketMQ的特性有哪些区别? 这就是通过分解的方式,在接收到用户请求后,先增加一个大模型调用,通过大模型对原始问题进行逻辑解析和拆分。 将拆分后的子查询列表分别进行检索,收集所有相关文档块。最后将所有文档块和原始问题一并输入给生成模型,得出最终答案。

Prompt示例

## 角色
你是一名专业的查询逻辑分析专家。

## 任务
将给定的“用户原始问题”分解为一系列**相互独立、逻辑清晰**,且可单独用于检索的子查询列表。
你的输出必须是一个标准的JSON数组格式。

## 用户原始问题
{QUESTION}

## 输出格式要求 (JSON Array)
[
  "子查询1",
  "子查询2",
  "子查询3",
  "..."
]

(不强制要求数组元素个数,可根据真实情况输出,至少保留1个)

## 输出
请直接输出JSON数组,不要包含解释或多余的文字。

富化

富化是指在原始查询中添加上下文信息、背景知识或必要的限制条件,以消除歧义、补充缺失的信息,使查询更完整、更具体。 比如 ,用在对话中问:他有什么特点? 这里的"他"就是一种指代,我们需要通过指代消除的方式来让LLM知道他到底是什么。 所以,一般富化的场景主要用在当用户表达模糊或包含历史对话问题,且省略了主语或关键信息时(存在指定性表述)。或者是问题中缺少历史对话细节(例如,增加时间、地点、实体等限制,缩小检索范围。),或者是用到了某个专业术语的简称的时候。 实现方式就是在接收到用户请求的问题后,先增加一个大模型调用,通过大模型对用户的原始问题进行重写。将重写丰富过后的问题,再进行相似度检索,继续后续流程。

Prompt示例

## 角色
你是一个专业的问题重写优化器。

## 任务
根据提供的“对话历史”和“用户原始问题”,重写为一个独立、完整、且包含所有必要背景信息的新查询,用于RAG检索。

## 对话历史:
{CHAT_HISTORY}

## 原始问题:
{QUESTION}

## 输出
输出富化过后的新问题,不要包含多余的解释性内容

多样化

多样化是指对同一个用户查询生成多个多个语义相近或相关的变体,以提升对知识库内容描述多样性的覆盖,从而增强召回率。主要应对以下情况: 1. 描述风格不一致:文档的表述方式不同,如有的是“接口性能优化”,有的是“提升接口响应速度”。 2. 用户表述差异性:不同用户的表达方式习惯不同。 在接收到用户请求后,先增加一个大模型调用,通过大模型为原始问题生成多个语义相近但措辞不同的查询变体。 将原始查询和所有变体并行进行检索。汇聚所有的检索结果,得到全部文档块,去重后用于后续生成。

Prompt示例

## 角色
你是一名专业的语义扩展专家。

## 任务
为给定的“原始问题”生成**3个**语义相同但**措辞完全不同、且利于检索**的查询变体,以提高检索的召回率。
你的输出必须是一个标准的JSON数组格式。

## 原始问题
{QUESTION}

## 输出格式要求 (JSON Array)
[
  "变体1",
  "变体2",
  "变体3"
]

## 输出
输出富化过后的新问题,不要包含多余的解释性内容

回溯提示

所谓回溯提示,是从Step-Back Prompting 翻译过来的,它的核心思想是先引导模型“后退一步”,从具体问题中抽象出更一般的原理、概念或背景知识;再基于这些抽象信息进行推理或检索,最终回答原始问题。 比如你做了一个企业内部知识库,有人提问:我老舅结婚,我可以请几天假去参加她的婚礼。 这个问题知识库就很难回答,因为他太具体了,需要做step back,即把他转换成更抽象的问题:探亲假的请假政策是怎么样的? 然后使用这个抽象问题抽象问题(或与原始问题结合)去向知识库检索相关信息。再将检索到的通用背景知识与原始具体问题结合,由 LLM 生成更准确、有深度的回答。

Prompt示例

## 角色
你是一个擅长抽象思维和原理推理的专家。

## 任务
请根据用户提出的具体问题,先“后退一步”,将其转化为一个更通用、更本质的问题,聚焦于背后的原理、规律、概念或一般性知识,而不是具体细节。

## 原始问题

{QUESTION}

## 输出
请只输出改写后的“后退问题”,不要解释,不要包含原始问题,也不要回答它。

实战演练

我们在构建索引的时候,导入两篇文档,分别是Mybatis和Redis,用来区分检索效果。 我们再修改一下增强生成的promptTemplate提示词模板,为了能够明显看到差异,去掉了之前默认返回策略。

/**
 * 检索增强生成回答
 */
@GetMapping("/chat")
public String chat(@RequestParam("query") String query) {
    // 1. 相似度检索获取相关文档
    List<Document> similarDocs = embeddingService.similarSearch(query);

    // 2. 构建提示词模板
    String promptTemplate = """
    请基于以下提供的参考文档内容,回答用户的问题。

    参考文档:
    {documents}

    用户问题: {question}
    """;

    log.info("共检索到 {} 个相关文档块。", similarDocs.size());

    // 3. 处理检索到的文档内容
    String documentContent = similarDocs.stream()
    .map(Document::getText)
    .collect(Collectors.joining("\n\n=========文档分隔线===========\n\n"));

    log.info("查询到的文档信息:{}", documentContent);

    // 4. 填充模板参数
    Map<String, Object> params = new HashMap<>();
    params.put("documents", documentContent);
    params.put("question", query);
    PromptTemplate prompt = new PromptTemplate(promptTemplate);
    Prompt realPrompt = prompt.create(Map.of("documents", documentContent, "question", query));

    // 5. 调用大模型生成回答
    String text = chatClient.prompt(realPrompt).call().chatResponse().getResult().getOutput().getText();

    return text;
}

定义问题重写器

package cn.hollis.llm.mentor.rag.rewriter;

import com.alibaba.fastjson2.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@Slf4j
public class QuestionRewriteService {

    @Autowired
    private ChatModel chatModel;

    //分解提示词
    private static final String DECOMPOSE_PROMPT = """
            # 角色
            你是一名专业的查询逻辑分析专家。

            # 任务
            将给定的“用户原始问题”分解为一系列**相互独立、逻辑清晰**,且可单独用于检索的子查询列表。
            你的输出必须是一个标准的JSON数组格式。

            # 用户原始问题
            {QUESTION}

            # 输出格式要求 (JSON Array)
            [
              "子查询1",
              "子查询2",
              "子查询3",
              "..."
            ]

            (不强制要求数组元素个数,可根据真实情况输出,至少保留1个)

            # 输出
            请直接输出JSON数组,不要包含解释或多余的文字。  """;

    //问题的富化
    private static final String ENRICH_PROMPT = """
            # 角色
            你是一个专业的问题重写优化器。

            # 任务
            根据提供的“对话历史”和“用户原始问题”,重写为一个独立、完整、且包含所有必要背景信息的新查询,用于RAG检索。

            ## 对话历史:
            {CHAT_HISTORY}

            ## 原始问题:
            {QUESTION}

            # 输出
            输出富化过后的新问题,不要包含多余的解释性内容
            """;

    //问题的多样化
    private static final String DIVERSIFY_PROMPT = """
            # 角色
            你是一名专业的语义扩展专家。

            # 任务
            为给定的“原始问题”生成**3个**语义相同但**措辞完全不同、且利于检索**的查询变体,以提高检索的召回率。
            你的输出必须是一个标准的JSON数组格式。

            # 原始问题
            {QUESTION}

            # 输出格式要求 (JSON Array)
            [
              "变体1",
              "变体2",
              "变体3"
            ]

            # 输出
            输出富化过后的新问题,不要包含多余的解释性内容
            """;

    private static final String STEP_BACK = """
             # 角色
            你是一个擅长抽象思维和原理推理的专家。

            # 任务
            请根据用户提出的具体问题,先“后退一步”,将其转化为一个更通用、更本质的问题,聚焦于背后的原理、规律、概念或一般性知识,而不是具体细节。

            # 原始问题

            {QUESTION}

            # 输出
            请只输出改写后的“后退问题”,不要解释,不要包含原始问题,也不要回答它。
            """;

    private static final String QUESTION = "QUESTION";
    private static final String CHAT_HISTORY = "CHAT_HISTORY";

    /**
     * 问题分解
     *
     * @param question
     * @return
     */
    public List<String> decompose(String question) {
        log.info("===========进入问题分解流程===========");
        log.info("原始问题: {}", question);
        PromptTemplate promptTemplate = new PromptTemplate(DECOMPOSE_PROMPT);
        promptTemplate.add(QUESTION, question);

        String result = chatModel.call(promptTemplate.create()).getResult().getOutput().getText();
        log.info("===========问题分解完成,结果: {} ===========", result);
        return JSON.parseArray(result, String.class);
    }

    /**
     * 问题富化
     */
    public String enrich(String chatHistory, String question) {
        log.info("===========进入问题富化流程===========");
        log.info("对话历史: {}", chatHistory);
        log.info("原始问题: {}", question);
        PromptTemplate promptTemplate = new PromptTemplate(ENRICH_PROMPT);
        promptTemplate.add(CHAT_HISTORY, chatHistory);
        promptTemplate.add(QUESTION, question);

        String result = chatModel.call(promptTemplate.create()).getResult().getOutput().getText();
        log.info("===========问题富化完成,结果: {} ===========", result);
        return result;
    }

    /**
     * 问题多样化
     */
    public List<String> diversify(String question) {
        log.info("===========进入问题多样化流程===========");
        log.info("原始问题: {}", question);
        PromptTemplate promptTemplate = new PromptTemplate(DIVERSIFY_PROMPT);
        promptTemplate.add(QUESTION, question);

        String result = chatModel.call(promptTemplate.create()).getResult().getOutput().getText();
        log.info("===========问题多样化完成,结果: {} ===========", result);
        return JSON.parseArray(result, String.class);
    }

    /**
     * 问题回退
     *
     * @param question
     * @return
     */
    public String stepBack(String question) {
        log.info("===========进入问题回退流程===========");
        log.info("原始问题: {}", question);
        PromptTemplate promptTemplate = new PromptTemplate(STEP_BACK);
        promptTemplate.add(QUESTION, question);

        String result = chatModel.call(promptTemplate.create()).getResult().getOutput().getText();
        log.info("===========问题回退完成,结果: {} ===========", result);
        return result;
    }

    // 组合方法
    public List<String> rewriteQuery(String query) {
        log.info("===========进入问题重写组合策略流程===========");
        log.info("原始问题: {}", query);

        //回退
        String stepBackQuery = this.stepBack(query);

        // 分解
        List<String> decomposedQueries = this.decompose(stepBackQuery);

        // 多样化
        List<String> finalQueries = new ArrayList<>();
        for (String subQuery : decomposedQueries) {
            List<String> variations = this.diversify(subQuery);
            finalQueries.addAll(variations);
        }

        if (finalQueries.isEmpty()) {
            finalQueries.add(query);
        }

        log.info("===========组合重写完成,最终查询列表: {} ===========", finalQueries);
        return finalQueries;
    }
}

这边提供了3种问题重写方法,和一种组合方法,实际开发应用中,不一定每次都用需要把所有方法都用上,应当仔细分析使用场景,灵活组合运用。 不含问题重写的接口 我们先用之前chat接口来进行测试,我们打印一下检索到的文档块个数。发现根本都没有检索到相关内容。但是接口仍然会有结果,这是因为模型本身具备这样的知识,但并不是我们文档的内容。 接下来我们构造一个带有问题重写的chat接口

@GetMapping("/chatWithQueryRewrite")
public String chatWithQueryRewrite(@RequestParam("query") String query) {
    List<String> rewriteQuery = queryRewriteService
.rewriteQuery(query);
    // set用作文档去重
    Set<Document> similarDocs = new LinkedHashSet<>();
    for (String q : rewriteQuery) {
        List<Document> docs = embeddingService.similarSearch(q);
        if (docs != null && !docs.isEmpty()) {
            similarDocs.addAll(docs);
        }
    }
    // 2. 构建提示词模板
    String promptTemplate = """
    请基于以下提供的参考文档内容,回答用户的问题。

    参考文档:
    {documents}

    用户问题: {question}
    """;

    log.info("共检索到 {} 个相关文档块。", similarDocs.size());

    // 3. 处理检索到的文档内容
    String documentContent = similarDocs.stream()
    .map(Document::getText)
    .collect(Collectors.joining("\n\n=========文档分隔线===========\n\n"));

    log.info("查询到的文档信息:{}", documentContent);

    // 4. 填充模板参数
    Map<String, Object> params = new HashMap<>();
    params.put("documents", documentContent);
    params.put("question", query);
    PromptTemplate prompt = new PromptTemplate(promptTemplate);
    Prompt realPrompt = prompt.create(Map.of("documents", documentContent, "question", query));

    // 5. 调用大模型生成回答
    String text = chatClient.prompt(realPrompt).call().chatResponse().getResult().getOutput().getText();

    return text;
}

调用结果 我们可以看到增加了问题重写后的检索功能,通过回退、富化、分解、多样化的组合处理,使得问题本身更加的丰富,相比于普通RAG检索到0个文本块,带有问题重写的RAG检索到了5个文本块,大模型基于这些文本块作为背景,回复的内容则更加符合用户需求。

注意事项

问题重写,是生产级 RAG 的必备操作,但并非重写策略越多、逻辑越复杂,效果就越好。很多时候,我们的核心在于在性能与质量之间找到一个平衡点。过多冗余的流程会显著增加问答系统的响应时间,直接导致用户体验下降。因此,需结合自身业务需求,充分分析用户的提问习惯、交互方式、以及产品特点,灵活组合选用问题重写策略,才能最大化其价值。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。