Skip to content

检索前操作相关技术名词 ​

接下来我们来看一下和检索前操作相关的技术名词,以及具体的落地方针、还有相关的面试题。

检索前处理常见的技术名词有:

  • Query Transformation
  • Query Rewrite
  • Query Expand
  • Multi-query
  • Query Decomposition
  • HyDE

首先需要理解的是 Query Transformation,这个中文叫做“查询转换”。

为什么需要先理解这个呢?

因为它通常指“在正式检索前,对用户 query 做各种变换处理”的 总称,可以算是一个总类。

它下面的具体策略有:

  • Query Rewrite
  • Query Expand
  • Multi-query
  • Query Decomposition
  • HyDE

如果要对这些具体策略做分类,也可以分为 3 类:

  1. Query 改写类:让 query 更“标准、更清晰”
  2. Query 扩展类:提升召回率
  3. Query 结构化类:把复杂问题变成“可检索的问题集合”

1. Query 改写类 ​

Query Rewrite 就属于一种改写策略,翻译成中文意思是“查询改写”。

意思是:在不改变用户核心意图的前提下,把原始 query 改写得更标准、更清晰、更适合检索。

它更像“润色原问题”。

举个例子:用户原话

苹果 15 续航咋样

改写后:

iPhone 15 的电池续航表现如何?

这里没有改变问题本身,只是把口语化、模糊、残缺的表达,变成了更完整、更规范的检索语句。

2. Query 扩展类 ​

Query Expand 属于一种 Query 扩展策略。核心是:给原问题加料,但通常还是围绕一个检索请求展开。

例如,用户问:

笔记本电脑总死机怎么办

Expand 后可能变成:

笔记本电脑死机 原因 解决办法 蓝屏 卡顿 散热 驱动问题

这里重点是补充了相关词:

死机、蓝屏、驱动、散热

目的是让召回更全。

Multi-query 也是一种 Query 扩展策略。核心是:从不同角度生成多条 query,分别检索,再合并结果。

例如,用户问:

如何提高团队研发效率

系统生成多条 query:

  • 提高研发团队效率的方法
  • 软件团队协作效率提升实践
  • 研发流程优化 OKR sprint code review
  • 影响研发效率的常见瓶颈

这里不是简单“加词”,而是生成了多种问法/多种角度。

3. Query 结构化类 ​

Query Decomposition 可以看作是一种 Query 结构化的工作。可以通俗理解成:把一个太大、太复杂、没法直接查的问题,拆成几个更小、更具体、更容易检索的问题。

它属于 Query Transformation 里面的一种,因为它不是直接拿用户原问题去查,而是先“拆问题”再检索。

比如用户问:A 公司和 B 公司哪家更值得去?

这个问题其实很难直接检索,因为“更值得去”太笼统了。

系统会先把它拆成几个子问题:

  • A 公司薪资水平怎么样
  • B 公司薪资水平怎么样
  • A 公司加班情况怎么样
  • B 公司加班情况怎么样
  • A 公司发展前景怎么样
  • B 公司发展前景怎么样

这样每个子问题都更具体,也更容易从知识库里找到对应信息,最后再把结果汇总,给出整体回答。

当然说到这里,你肯定有一个疑问:这不就和前面所介绍的 Multi-query 一样的么?

看起来很像,但 不完全一样。虽然它们最后都可能变成“多条 query”。区别主要在于:为什么要变成多条 query。

Multi-query 核心目标是:同一个问题,换几个不同说法/不同检索角度,尽量多召回一些内容。它本质上还是在查同一件事。

例如:

什么是 RAG

生成 multi-query:

  • RAG 是什么
  • 检索增强生成是什么
  • RAG 的定义
  • RAG 的基本原理

这几条 query 本质上都在问同一个问题,只是换了表达方式和检索角度。

Query Decomposition 核心目标是:一个复杂问题里本来就包含多个子问题,所以先拆开,再分别查。

它本质上是在查不同子任务。

例如:

RAG 和微调有什么区别,哪个更适合企业知识库?

拆解后:

  • RAG 的特点是什么
  • 微调的特点是什么
  • RAG 的成本如何
  • 微调的成本如何
  • 企业知识库的核心需求是什么

这些已经不是同一个问题的不同说法了,而是不同维度的小问题。

总结一下:

  • Multi-query 是“同一问题的多种问法”
  • Query Decomposition 是“复杂问题拆成多个子问题”

4. HyDE ​

HyDE 的英语全称为 Hypothetical Document Embeddings,它的核心不是单纯改写,也不只是扩展,而是:

先让模型生成一段“假想答案/假想文档”,再用这段内容去做向量检索。

为什么要这么做?

因为用户原始 query 往往很短、很口语、信息量太少,不一定容易直接搜到最相关内容。

所以系统会先补一步:

  1. 根据用户问题,让大模型先写一段“可能会出现在正确答案里的内容”
  2. 再用这段内容做向量检索
  3. 找到真正相关的文档

通俗例子

用户问:

为什么 RAG 比微调更适合经常更新的知识库?

如果直接拿这句话去检索,可能效果一般。因为它只是一个问题句,信息密度不高。

HyDE 会先让模型生成一段“假想答案”,例如:

RAG 更适合频繁更新的知识库,因为它不需要重新训练模型,只需要更新外部知识库内容。
相比之下,微调通常需要重新训练或重新部署,成本更高,更新周期更长。

然后系统不是直接相信这段话,而是拿这段“像答案的文字”去做检索,去知识库里找真正相关的内容。

你可以把它想成:

  • 普通检索:拿“问题”去搜
  • HyDE:拿“一个像答案的草稿”去搜

因为“答案草稿”通常比“问题句”更完整,语义更丰富,所以更容易和真正相关文档对上。

在工程实践里,HyDE 常常属于:

  • 一个可选增强策略
  • 一个实验项
  • 一个针对特定数据集的优化手段

而不是“RAG 标准组件”。因为做普通 RAG 的时候,先把基础做好,效果往往就够了。真实项目里,大家更常优先做的是这些:

  • chunk 切分优化
  • embedding 模型选择
  • 向量检索 + BM25 混合检索
  • rerank 重排
  • query rewrite / multi-query
  • metadata filter

这些手段通常更直接、收益更稳定、可控性更强。所以很多团队还没走到要上 HyDE 那一步。

HyDE 会多一层生成,链路更长,用了 HyDE,流程会变成:

  1. 先让模型生成“假想答案”
  2. 再拿它去检索
  3. 再生成最终回答

这样会带来:

  • 延迟更高
  • 成本更高
  • 系统更复杂

如果原本检索效果已经不错,团队往往不会主动加这一步。不过大家还是有必要了解这个专业名词。

5. 如何落地 ​

接下来我们来讨论一下这些技术如何落地。先回忆一下我们当前 RAG SDK 的设计:

  • runtime 主要负责定义抽象接口,以及做流程的编排,适合放进 SDK 核心的有:
    • 查询期主流程编排
    • 标准扩展接口
    • 统一输入输出契约
    • 调试信息与可观测字段
    • 通用的结果合并/后处理基础能力
  • adapter 这部分要解决的是“怎么把外部生态接进来”。适合放进官方 adapter 的有:
    • 把 LangChain / LlamaIndex / OpenAI / 其他框架能力适配成 SDK 内置接口
    • 把第三方 retriever / generator / embeddings / vector store 接进来
    • 把外部现成的 query transformation 组件接进来

至于我们上面所讨论的检索前操作,例如:

  • Query Rewrite
  • Multi-query
  • Decomposition
  • HyDE

这些往往和业务场景强相关,很难做成一个真正通用的官方默认件。换句话说,这部分通常由开发者自己实现。

举几个例子:

用户要实现 Query rewrite:

ts
import type {
  QueryPreprocessor,
  RetrievalRequest,
  RuntimeContext,
  RuntimeQueryInput,
} from "@rag-sdk/runtime";

export class SimpleRewritePreprocessor implements QueryPreprocessor {
  async preprocess(
    input: RuntimeQueryInput,
    _context: RuntimeContext,
  ): Promise<RetrievalRequest> {
    const original = input.query;

    // 这里只是演示,真实场景可换成 LLM 改写
    const rewritten = rewriteQuery(original);

    return {
      originalQuery: { query: original },
      effectiveQuery: { query: rewritten },
      rewriteReason: "normalize-user-query",
      strategy: "single-query-rewrite",
      metadata: input.metadata,
    };
  }
}

function rewriteQuery(query: string): string {
  if (query.includes("苹果 15 续航咋样")) {
    return "iPhone 15 的电池续航表现如何?";
  }

  return query.trim();
}

再比如 Multi-query,示意代码如下:

ts
import type {
  RetrievalCandidate,
  RetrievalRequest,
  RuntimeContext,
  RuntimeRetriever,
  RuntimeRetrievalResult,
} from "@rag-sdk/runtime";

type SearchBackend = {
  search(query: string): Promise<RetrievalCandidate[]>;
};

export class MultiQueryRetriever implements RuntimeRetriever {
  constructor(private readonly backend: SearchBackend) {}

  async retrieve(
    request: RetrievalRequest,
    _context: RuntimeContext,
  ): Promise<RuntimeRetrievalResult> {
    const baseQuery = request.effectiveQuery.query;
    const queries = buildMultiQueries(baseQuery);

    const candidateGroups = await Promise.all(
      queries.map((q) => this.backend.search(q)),
    );

    const merged = dedupeCandidates(candidateGroups.flat());

    return {
      candidates: merged,
      retrievalMetadata: {
        strategy: "multi-query",
        generatedQueries: queries,
      },
    };
  }
}

function buildMultiQueries(query: string): string[] {
  return [
    query,
    `${query} 定义`,
    `${query} 原理`,
    `${query} 示例`,
  ];
}

function dedupeCandidates(
  candidates: RetrievalCandidate[],
): RetrievalCandidate[] {
  const seen = new Set<string>();
  const result: RetrievalCandidate[] = [];

  for (const candidate of candidates) {
    if (seen.has(candidate.chunk.id)) continue;
    seen.add(candidate.chunk.id);
    result.push(candidate);
  }

  return result;
}

之后在创建 runtime 的时候,就可以像这样:

ts
const runtime = createRuntime({
  preprocessor: new SimpleRewritePreprocessor(),
  retriever: ...
  postprocessor: ...
  generator: ...
});

🤔思考:检索前操作能不能配置多个?

可以配多个,而且真实项目里经常就是多个串起来用。

比如很常见的链路就是:

  1. Query Rewrite
  2. 再做 Multi-query
  3. 或者先 Decomposition
  4. 然后对子问题再 Rewrite
  5. 最后再去检索

所以从 RAG 方法论上说,检索前操作不是“只能选一个”,而是可以组合的。

如果要设计成接收多个检索前操作的话,preprocessor 的值就应该是接收一个数组:如下:

ts
const runtime = createRuntime({
  preprocessor: [new SimpleRewritePreprocessor(), new MultiQueryRetriever()],
  retriever: ...
  postprocessor: ...
  generator: ...
});

当然,这同样也就意味着 runtime 包内部也要做一定的修改,目前 rumtime 包内部在编排流程的时候,是这么写的:

ts
const request = await measureStage(timings, "pre-retrieval", async () => {
  try {
    return await runtime.preprocessor.preprocess(input, context);
  } catch (error) {
    throw toRuntimeError(error, "pre-retrieval", originalQuery);
  }
});

其中 runtime.preprocessor.preprocess(input, context) 这一块儿就需要修改,因为 ``runtime.preprocessor` 拿到的是一个数组。

🤔思考:思考另外一个问题,能否提供默认的实现?

在 langchain 中,实际上是提供了部分检索前操作的 API 的,例如 HydeRetriever

这个检索器就是支持 HyDE 特性的一种检索器。我们可以在 adapter 包中添加该检索的封装:

ts
import type { DocumentInterface } from "@langchain/core/documents";
import { HydeRetriever } from "@langchain/classic/retrievers/hyde";

import {
  createLangChainBaseRetrieverRuntimeAdapter,
  type CreateLangChainBaseRetrieverRuntimeAdapterOptions,
} from "./langchain-base-retriever-runtime-adapter.js";

type HydeRetrieverOptions = ConstructorParameters<typeof HydeRetriever>[0];

export type CreateLangChainHydeRuntimeRetrieverAdapterOptions<
  Metadata extends Record<string, any> = Record<string, any>,
> = Omit<
  CreateLangChainBaseRetrieverRuntimeAdapterOptions<Metadata>,
  "retriever"
> & {
  hyde: HydeRetrieverOptions;
};

export function createLangChainHydeRuntimeRetrieverAdapter<
  Metadata extends Record<string, any> = Record<string, any>,
>(
  options: CreateLangChainHydeRuntimeRetrieverAdapterOptions<Metadata>,
) {
  const hydeRetriever = new HydeRetriever(options.hyde);

  return createLangChainBaseRetrieverRuntimeAdapter<Metadata>({
    ...options,
    retriever: hydeRetriever as unknown as {
      invoke(
        query: string,
        config?: unknown,
      ): Promise<DocumentInterface<Metadata>[]>;
    },
    buildRetrievalMetadata:
      options.buildRetrievalMetadata ??
      (({ filteredCandidates, request }) => ({
        provider: "langchain",
        retrieverType: "hyde",
        strategy: request.strategy ?? "hyde",
        effectiveQuery: request.effectiveQuery.query,
        returnedCandidates: filteredCandidates.length,
      })),
  });
}

然后开发者就可以直接使用,不过这里不是作为一种 preprocessor,而是一种 retriever:

ts
import { Document } from "@langchain/core/documents";
import { OpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

import { createDefaultRuntime, NoopQueryPreprocessor } from "@rag-sdk/runtime";
import { createLangChainHydeRuntimeRetrieverAdapter } from "@rag-sdk/adapters";

async function main() {
  const embeddings = new OpenAIEmbeddings({
    model: "text-embedding-3-small",
  });

  const vectorStore = new MemoryVectorStore(embeddings);

  await vectorStore.addDocuments([
    new Document({
      id: "doc-1",
      pageContent: "RAG 更适合频繁更新的知识库,因为它通常不需要重新微调模型。",
      metadata: {
        sourceId: "kb/rag",
        category: "rag",
      },
    }),
    new Document({
      id: "doc-2",
      pageContent: "微调适合把稳定领域知识和输出风格固化进模型参数里。",
      metadata: {
        sourceId: "kb/finetune",
        category: "finetune",
      },
    }),
  ]);

  const llm = new OpenAI({
    model: "gpt-4o-mini",
    temperature: 0,
  });

  const runtime = createDefaultRuntime({
    preprocessor: new NoopQueryPreprocessor({
      strategy: "hyde",
      topK: 4,
    }),
    retriever: createLangChainHydeRuntimeRetrieverAdapter({
      hyde: {
        vectorStore,
        llm,
        k: 4,
      },
      filterByRequest: true,
      buildRetrievalMetadata({ request, filteredCandidates }) {
        return {
          provider: "langchain",
          retrieverType: "hyde",
          strategy: request.strategy ?? "hyde",
          candidateCount: filteredCandidates.length,
        };
      },
    }),
    generator: {
      async generate({ request, chunks }) {
        return {
          answer: [
            `query: ${request.originalQuery.query}`,
            `strategy: ${request.strategy}`,
            `chunks: ${chunks.map((chunk) => chunk.content).join("\n")}`,
          ].join("\n\n"),
        };
      },
    },
  });

  const result = await runtime.run(
    {
      query: "为什么 RAG 比微调更适合经常更新的企业知识库?",
    },
    {
      includeDebug: true,
    },
  );

  console.log(result);
}

void main();

6. 相关面试题 ​

1. 你怎么区分 Query Rewrite、Multi-query、Query Decomposition、HyDE?它们分别适合什么场景?

题目分析

这题很常见,因为面试官通常不是要你背名词,而是看你是否真的理解它们的边界。 考察点:

  • Rewrite 是把问题改得更清晰
  • Multi-query 是同一问题生成多种问法,提升召回
  • Decomposition 是把复杂问题拆成多个子问题
  • HyDE 是先生成假想文档,再拿它去检索

延伸追问通常会是:如果只能先落地一种,你会先选哪种,为什么?

参考答案

我的理解是,这四种都属于检索前处理,但解决的问题不一样。

Query Rewrite 主要解决的是用户问题表达不规范、不完整、太口语化的问题,本质上是把 query 改得更清晰、更标准,但不改变核心意图。 Multi-query 是针对同一个问题,生成多种问法或多个检索角度,目的是提升召回率,适合 query 很短、表述多样、容易漏召回的场景。 Query Decomposition 是把一个复杂问题拆成多个子问题,适合比较型、组合型、多约束型问题,比如“A 和 B 有什么区别,哪个更适合某场景”。 HyDE 则比较特殊,它不是简单改写 query,而是先生成一段假想答案或假想文档,再拿这段文本去做检索,适合 query 和知识库文档表述差异比较大、纯向量检索效果不稳定的场景。

如果是工程优先级,我通常会先考虑 Rewrite 和 Multi-query,因为它们收益比较稳,HyDE 会放在后面按实验效果决定。

2. 在真实 RAG 项目里,什么时候你会考虑上 Multi-query 或 HyDE?什么时候反而不建议用?

题目分析

面试官想听的通常是:

  • Multi-query 适合 query 很短、表述多样、召回不足的场景
  • HyDE 适合 query 和文档表达差异大、纯向量检索不稳定的场景
  • 不建议乱上,因为会增加延迟、成本、链路复杂度,还可能引入噪声

这题答得好不好,关键在于你能不能讲出收益和代价。

参考答案

我的判断标准主要看当前系统的主要瓶颈是不是“召回不足”。

如果用户 query 很短、歧义比较大、同一个意思有很多不同说法,或者知识库里的表达方式比较分散,这时候我会优先考虑 Multi-query,因为它本质上是在扩大召回覆盖面。 如果我发现用户问题和知识库文档的语言风格差异很大,比如用户问得很口语,但文档写得很正式,或者纯向量检索召回不稳定,我会考虑 HyDE,因为它通过生成一段更像文档语言的假想文本来帮助匹配。

但这两种我都不会默认直接上。因为它们都会增加链路复杂度、延迟和成本,而且也可能引入噪声。尤其 HyDE,如果生成的假想内容偏了,检索也可能被带偏。所以我一般会先把 chunk、embedding、hybrid search、rerank 这些基础能力做好,再判断是否值得加 Multi-query 或 HyDE。

3. 一个用户问题既可以做 Query Rewrite,也可以做 Query Decomposition。你会怎么判断该走哪条路径?

题目分析

这题很像中高级面试会问的“策略选择题”。

面试官想看的是你有没有清晰判断标准,例如:

  • 如果只是口语化、不完整、指代不清,优先 Rewrite
  • 如果一个问题里包含多个维度、比较关系、复合约束,优先 Decomposition
  • 有时候也可以先 Rewrite,再决定是否 Decomposition

这题本质是在考:你是否能从 query 特征出发做策略分流。

参考答案

我会先判断这个问题到底是“表达问题”还是“结构问题”。

如果问题的核心意思其实很单一,只是用户说得比较口语、不完整、带指代、关键词不标准,那我会优先做 Query Rewrite。因为这类问题本质上还是一个问题,只是需要改写成更适合检索的形式。 但如果这个问题本身就包含多个维度,比如比较两个方案、同时问优缺点和适用场景,或者一个问题里其实藏了多个子任务,那我会优先做 Query Decomposition。因为这种情况下,不拆开检索的话,往往很难把信息查全。

实际工程里我觉得这两者也不是完全互斥的。比较常见的做法是先做轻量 rewrite,把问题表达整理清楚,再判断它是不是复杂问题,如果是,再进入 decomposition。也就是说,Rewrite 更像基础清洗,Decomposition 更像复杂问题编排。

4. 如果你设计一个 RAG SDK,你会把这些检索前操作放在哪一层?哪些应该做成框架能力,哪些应该交给业务方?

题目分析

理想回答通常会涉及:

  • SDK 核心应该提供 QueryPreprocessor 之类的扩展点
  • adapter 层负责接 LangChain / OpenAI 等外部能力
  • 具体采用 Rewrite / Multi-query / Decomposition / HyDE,通常由业务方按场景决定
  • Rewrite 比较容易标准化
  • Decomposition 更依赖业务编排,不适合一开始就做死

这题很能拉开差距,因为它考察的是抽象能力和落地能力。

参考答案

如果我来设计,我会把它拆成三层。

第一层是 SDK 核心能力,这一层不应该强绑定某一种具体策略,而是提供统一的扩展点和运行时编排能力,比如 QueryPreprocessor、Retriever、Postprocessor 这类接口,让外部可以接入不同的检索前处理策略。 第二层是官方 adapter,这一层主要负责把 LangChain、OpenAI 之类外部生态能力适配成 SDK 的统一契约。比如把 LangChain 的 HydeRetriever 或 SelfQueryRetriever 适配成 SDK 里的 retriever。 第三层是业务方自定义策略,这一层决定具体什么时候用 Rewrite、什么时候用 Multi-query、是否启用 Decomposition、HyDE 的 prompt 怎么写,这些我觉得都应该交给业务方,因为它们强依赖具体数据和业务目标。

所以我的原则是:核心层负责抽象和编排,adapter 层负责生态接入,策略选择和效果优化交给业务方。这样 SDK 才既通用,又不会把业务策略写死。


-EOF-