做过RAG落地的人应该都有这种体验:检索链路搭好了、向量化了、TopK也调了,但用户一问某些问题,召回结果就是不对味。比如用户问“那个能折叠的蓝牙键盘多少钱”,文档里明明写着“便携式折叠键盘,支持蓝牙连接,售价199”,可检索Top5里就是没有它。问题出在向量检索往往默认“问题和文档是同一个语言体系”,但用户问法、术语粒度、口语习惯和文档表达方式之间,天然就有一道鸿沟。这时候最值得动的,不是换embedding模型,也不是堆更大容量的向量库,而是先把“查询”这个入口处理好。查询转换和查询分解,是高级RAG里专门针对“入口问题”的一类技术。查询转换负责把用户问题改写成更适合检索的形式,查询分解负责把复杂问题拆成多个简单子问题分别检索。这两块做好了,往往比换一个更强的检索模型带来的收益更直接。这篇文章我会把它们的原理、适用场景、工程实现细节和我在实际项目里踩过的坑全部过一遍,希望能给你一个不用走弯路的方向。1. 先搞清一件事:你的RAG到底“失灵”在哪一环很多团队做RAG的第一版,都是经典的“三件套”:文档切块、Embedding建索引、向量检索取TopK喂给大模型。Demo阶段一切正常,到了真实流量就露馅。这时候不要急着换模型,先判断RAG到底在哪一环丢了信息。查询端出问题的情况,占比其实非常高。1.1 三张图看懂朴素RAG的死角第一类问题是词面不匹配:用户说的是口语,文档写的是书面语。“能折叠的蓝牙键盘”这个说法在文档里可能根本不存在,“折叠”“蓝牙”不会以同样的词组出现在同一句话里,而Embedding模型对这种短Query的语义理解又经常被泛化到歧义方向。第二类是查询过泛:用户问的是“你们有哪些支持无线连接的外设”,这个问题指向的是整个产品目录,不是一个固定文档块。向量检索本质上是在做“找相似的文本片段”,面对广谱问题,它没有任何一个片段能高置信度命中,结果就是召回了七八个低相关片段,大模型只能靠猜来拼答案。第三类是查询过复合:用户问“有没有既能折叠又支持蓝牙还带数字小键盘的型号”,这个查询同时包含三个约束条件。向量化时三个条件被压缩进一个向量里,互相稀释语义,最后没有一个条件被充分匹配。向量检索对“复合语义”的容忍度很低,因为TopK相似度计算是全局相似,不是逐条件约束。1.2 为什么向量检索兜不住这些问题很多人以为embedding模型越大会越懂人话,但实际体验是:大模型参数涨了,检索命中率并没有线性增长。原因是向量检索做的是“近似最近邻搜索”,它本质上是在一个大矩阵里找距离最近的K个点,计算的是整体相似度。用户的一个查询如果包含多个意图,最终向量是这些意图的“平均态”,而文档块往往是某一个意图的“纯净态”,这两者之间的距离天然就远。还有一个容易被忽略的点:切块粒度问题会让查询端劣势放大。假设答案是跨三个章节拼起来的,比如售后政策、产品规格、适配列表各一段,那单查询模式下即使切块粒度完美,TopK也大概率只命中其中一两块。这种场景光靠调切块参数救不回来,需要在查询阶段就做“多路获取”的规划——这正是查询分解想解决的问题。所以我才说,RAG项目一开始就值得在查询端投入设计,而不是等检索效果不佳了再回头补。下面两个章节分别讲查询转换和查询分解这两条技术路线。2. 查询转换:让“问法”贴近“文档的写法”查询转换的核心思想很朴素:既然用户问法跟文档表达不匹配,那就先把用户问题翻译成一组“更贴近文档语言”的查询,再去做检索。它不是一种技术,而是一类技术的统称,常见的有多查询扩写、HyDE、StepBack、查询重写等。每种的适用场景和代价都不同。2.1 多查询扩写:把一问变成五问这是我在项目里最先试、也最推荐先试的方案。思路是:让大模型基于原始问题,扩写出多个不同角度、不同措辞的查询,然后分别执行检索,最后把多路结果合并去重后交给生成模型。举个例子,用户问“这个键盘方便带出门吗”,扩写出来的查询可以包括:折叠键盘便携性折叠键盘重量与尺寸蓝牙键盘旅行携带体验折叠键盘是否适合通勤使用便携键盘收纳方式这样做的本质是增加检索的“覆盖面”。原始问题只有一种表达方式,embedding模型对这个表达可能理解偏差,但五个角度的查询里总有一两个能和文档空间对齐。多查询扩写不要求大模型多聪明,只要它能从不同侧面描述同一个意图就够了。实现上有个关键细节:扩写后的检索结果要设置一个合并逻辑,不能简单把各路TopK拼起来。我一般会按段落来源做去重,同一文档块被多个查询命中时,取最高相似度那一次,然后按相似度总分排序输出。这里注意,别把总分数直接累加,因为不同查询的得分范围不稳定,累加会让“被重复命中的文档块”霸榜。2.2 HyDE:先编一个答案再去搜HyDE的全称是Hypothetical Document Embeddings,思路是反着来的:先用大模型基于用户问题生成一个“假设性的答案文档”,也就是凭空编一段可能包含答案的文字,然后用这段“伪文档”去做向量检索,拿它来匹配真实文档。原理上,这是为了解决“查询和文档长度不对称”的问题。用户查询通常只有十几个词,而文档块往往几百字。向量模型在编码短句和长文本时,特征分布是有偏移的。既然短Query匹配长文档容易失真,那就先把Query“加长”成文档格式,再做相似度计算。我在实际测试中发现,HyDE对“调查类问题”帮助很大,比如“这种折叠键盘的电池寿命如何”,模型生成的假设文档会比较完整地描述电池容量、充电方式、续航时间这些内容,检索时能精确命中相关段落。但对事实密度很高的问答,HyDE有风险:假设文档里编的内容如果包含错误细节,检索时可能反而命中那些恰好包含错误关键词的无关文档,导致检索精度不升反降。所以HyDE的使用要加一道“信任门槛”。我通常的做法是:只有多查询扩写结果的整体相似度分数偏低时,才调HyDE作为补充召回手段,不会让它单独决定最终结果。你可以理解为,HyDE是一个“激进派”的召回通道,适合用来捞长尾,不适合当主力。2.3 StepBack与查询重写:顺着问题层级往上跳StepBack Prompting来自一篇用“后退一步”方式来引导推理的工作,应用到RAG里非常自然。有些用户问题过于具体,甚至具体到“文档里根本没有这个词”,比如“322扫雷键盘什么时候补货”——文档里写的是“型号KB-322,预计下月恢复供应”。此时如果直接检索“322扫雷键盘”,很可能命不中。StepBack的做法是让模型把问题泛化一级,变成“KB-322供货状态”或者“KB-322什么时候有货”,然后再检索。查询重写则更贴近“翻译”逻辑:把用户的口语表达、错别字、模糊指代,改写为文档体系更习惯的表达。比如用户说“你们家那款四百块左右能连电脑的键盘”,重写结果是“价格约400元、支持电脑连接的键盘型号”。这一步对搜索词命中率的提升非常直接,因为它把“语义相似”变成了“词面匹配”,而词面匹配在BM25这类稀疏检索里是强项,就算只用关键词检索都能命中。实际操作里,StepBack和查询重写往往是同一个Prompt里完成的:让模型先判断问题是否过于具体或口语化,若是,则输出改写后的标准查询。用一个模型调用搞定,不需要额外路由。2.4 查询转换各方法对比与选型逻辑我整理了一张对比表,方便你根据实际场景快速选型:方法一句话原理最佳场景主要风险多查询扩写生成多个角度的查询,多路检索合并用户意图模糊、查询表达单一检索延迟成倍增加,需做去重HyDE用模型生成假设文档,再以文搜文查询过短、文档为长描述的检索假设内容偏差会误导检索StepBack把过于具体的问题泛化一级用户问题包含代码、型号、小众术语泛化过头会丢失关键限定词查询重写把口语改写为文档体系的标准表达口语化严重、错别字多、指代模糊改写失真,信息被简化选型上我的建议是:第一优先级永远是多查询扩写,因为它的下限高、逻辑直观、对任何RAG场景都有正收益;如果查询过短且没有明确实体,再加HyDE;如果用户问的是型号、代码、专有名词,优先用StepBack;如果面向C端且口语化严重,查询重写必须做。这四种不是互斥的,而是可以组合成一条流水线。3. 查询分解:面对多跳问题,一次只能解决一个子问题如果说查询转换解决的是“怎么问”的问题,查询分解解决的就是“问哪些”的问题。它针对的是多跳(Multi-hop)问题——单靠一次检索无论如何都拿不全信息的场景。这类问题在知识库问答、企业文档问答里非常常见。3.1 什么是多跳问题:拆开才搜得到我拿一个实际场景举例:用户问“我们公司采购的那批KB-322键盘,如果要退货,应该联系哪个客服团队?”要回答这个问题,系统得知道三件事:KB-322属于哪条产品线、那条产品线的退货政策由哪个部门负责、该部门的客服联系方式是什么。这三件事大概率在三个不同文档块里,而且彼此之间没有直接链接。如果只检索一次,向量检索最多命中和“退货”最像的那一块,其余信息只能靠大模型编。多跳问题的最显著特征是:单一查询条件无法唯一确定目标文档。它拥有多个实体、多个关系链。向量检索面对这类问题时,天然劣势在于它没有“推理”,只有“匹配”。哪怕embedding模型再强,它也无法把跨文档的关联信息压缩进一个向量里。逻辑链必须由外部流程显式拆解。所以查询分解的本质,是把一个“需要推理的检索问题”,翻译成多个“可以匹配的检索子问题”。子问题之间可能是独立的平行关系,也可能是有依赖的串行关系,工程上要根据问题的结构来选。3.2 分解的两种主流姿态:平行拆解与串行追问平行拆解适用于“并列条件型”问题。用户问“支持蓝牙、有数字小键盘、价格低于300元的键盘有哪些”,这类问题里的每个条件都是独立的过滤维度,可以一次性拆成三个子查询,分别检索,最后做条件交集。这种拆法实现成本低,因为子查询之间没有依赖,可以并行执行,总延迟跟单次检索差不多。串行追问适用于“链式依赖型”问题,比如前面说的退货案例。第一步先查“KB-322属于哪条产品线”,得到结果后,第二步再查“该产品线的退货部门”,第三步查“该部门的联系渠道”。每一步的查询条件都依赖上一步的答案。这种拆法延迟会累加,但信息质量明显更高,因为它模拟了人类查资料的路径。工程实现上,我经常用“路由器 + 状态机”的模式来描述查询分解:先让大模型判断用户问题是单步还是多步,如果是多步,则输出一串有依赖关系的子查询。然后系统按顺序执行子查询,把上一步的结果注入下一步查询的上下文里。这里有个容易犯的错误:让大模型在初始阶段就把所有子问题一次性列出来,然后平行执行。这对链式问题基本无效,因为后续子问题严重依赖前一步的检索结果,预猜出来的子问题往往和真实文档结构脱节。3.3 子查询合并的工程细节查询分解之后,必然面对“多路检索结果如何组装”的问题。组装不好,信息就变成了大杂烩。我在工程实践里总结出三个原则。第一是去重。多个子查询可能命中同一个文档块,需要按块ID做去重,保留与对应子查询关联度最高的那次。第二是父文档回溯。子查询命中的往往是切得很细的小块,如果直接把它们拼起来喂给大模型,上下文可能丢失足够的背景信息。我通常会从命中的小块回溯到它所属的父文档或相邻章节,把父级文档纳入上下文,这样大模型能看到“完整的一章”而不只是一句话。第三是顺序控制。平行拆解的子查询结果,要按照用户问题里的条件顺序排列;串行追问的结果,则要按照逻辑链顺序排列。顺序影响大模型对因果关系的判断,这点在很多测评里都有体现。另外一个值得注意的问题:子查询结果的上下文总量会迅速膨胀。三个子查询每个取Top3,一旦做父文档回溯,可能直接塞进几千个token。所以合并前需要做一次“上下文预算规划”——每个子查询最多保留几个块、是否启用父文档、总token上限是多少,这些都要提前设好,否则大模型会被无关信息干扰,生成质量反而下降。4. 一个可复用的端到端实现:查询优化层的完整链路前面讲了理论,这部分给一个可以直接落地的结构。我不依赖任何重框架,用最朴素的代码描述调度逻辑,你拿到之后可以平移到自己栈里。4.1 系统流程与调用顺序我设计的查询优化层放在检索之前,由四个模块组成:意图分类器、查询转换器、查询分解器、合并排序器。整体调用顺序如下:接收用户原始查询;意图分类器判断问题类型:单步还是多步、是否需要扩写;若需要扩写,调用查询转换器生成多个查询变体;若需要分解,调用查询分解器生成子查询序列;执行检索,多路并行召回;合并排序器做去重、父文档回溯、上下文组装;把最终上下文交给生成模型输出答案。这个流程看起来简单,但每一步都有细节。下面给出核心代码骨架。4.2 核心代码与Prompts先用一段Python描述意图分类和多查询扩写的调度逻辑:def optimize_query(raw_query: str, llm_client) -> list[str]: # 第一步:意图分类,判断是否单步可解 classification_prompt = f""" 判断以下用户问题是否适合直接检索,还是需要扩写/分解。 问题:{raw_query} 输出:single 或 multi_keywords 或 multi_step """ decision = llm_client.chat(classification_prompt).strip() if decision == "single": return [raw_query] # 第二步:多查询扩写 expansion_prompt = f""" 请基于原始问题,从不同角度生成5个用于检索的查询变体。 要求:每个查询独立完整,适合在知识库中进行向量检索, 覆盖同义词、上下位词、不同表述方式。 原始问题:{raw_query} 直接输出查询列表,每行一个,不要解释。 """ expanded = llm_client.chat(expansion_prompt) queries = [q.strip() for q in expanded.splitlines() if q.strip()] return queries[:5]接下来是查询分解器的调度逻辑,处理多步依赖型问题:def decompose_query(raw_query: str, llm_client) -> list[str]: decompose_prompt = f""" 将以下问题拆解为按顺序执行的子查询。 要求:每个子查询都能独立在知识库中检索; 后一个子查询如果需要依赖前一个结果,请用 {{previous}} 占位符表示。 问题:{raw_query} 输出格式:每行一个子查询,按执行顺序排列。 """ subqueries = llm_client.chat(decompose_prompt).splitlines() executed_results = [] context_blocks = [] for sub in subqueries: # 把上一步结果注入当前子查询 sub_filled = sub.replace("{previous}", executed_results[-1] if executed_results else "") hit_blocks = vector_search(sub_filled, top_k=3) # 父文档回溯:把命中小块的父级章节一并拉回 for block in hit_blocks: parent = get_parent_section(block.id) if parent and parent.id not in context_blocks: context_blocks.append(parent.id) executed_results.append(summarize_hits(hit_blocks)) # 简单截断,防止上下文膨胀 if total_tokens(context_blocks) > 4000: break return context_blocks这段代码最核心的地方在于:串行分解时,每一步的检索结果会先被“压缩成一句话摘要”,再作为下一步的查询条件注入。不要直接把整段文档拼进下一步的查询里,因为查询是给检索模型用的,越短越精确越好。摘要这一步扮演的是“中间推理变量”的角色。合并排序器的伪代码也不复杂:def merge_and_rank(all_hits, max_context_token=4000): # 按块ID去重,保留最高分 unique = {} for hit in all_hits: if hit.id not in unique or hit.score > unique[hit.id].score: unique[hit.id] = hit # 按分数排序 + 父文档回溯 ranked = sorted(unique.values(), key=lambda x: x.score, reverse=True) final_blocks = [] token_count = 0 for hit in ranked: parent = get_parent_section(hit.id) for blk in [hit] + parent.blocks: if token_count + blk.tokens > max_context_token: return final_blocks final_blocks.append(blk) token_count += blk.tokens return final_blocks4.3 端到端效果:一个固定测试集上的对比我在一个内部知识库问答场景里做过一次对照实验,测试集是50条真实用户问题,覆盖设备参数、售后政策、产品兼容性三类。检索用的同一个向量模型和同一批切块,只改查询端逻辑。结果如下:配置命中率(@5)最终回答准确率平均耗时朴素单查询Top546%38%0.8s多查询扩写(5路)68%61%1.6s多查询 + 查询分解82%74%2.3s多查询 + 分解 + 父文档回溯88%79%2.7s命中率和准确率都明显上升,代价是延迟从0.8秒涨到2.7秒。对于企业知识库问答这个量级,2.7秒完全可接受。但如果你的场景是高频实时对话,可能需要用缓存、异步预取等手段来平衡,下面详细说。5. 生产环境里我踩过的坑和调参经验理论再漂亮,落地时一定会有意外。这部分纯粹是我踩坑后的复盘,每条都对应真实发生过的问题。5.1 别让转换层变成“幻觉放大器”查询转换层本质上也是大模型在生成内容,所以它同样会产生幻觉。有一次多查询扩写时,模型把用户问题里的“KB-322”扩写成了“KB-322 Pro Max”,文档里根本没有这个型号,但有一篇文档恰好提到了“Pro Max”字样,结果检索命中了完全无关的内容,最终答案被带偏。我的修复方案是给扩写结果加一道“实体守住”约束:Prompt里明确要求,扩写查询必须完整保留原始问题里的品牌名、型号、数字、价格等实体词,不允许替换或增删。你可以理解成,扩写只允许改变句式和描述角度,不允许动“硬信息”。加了这一条之后,扩写导致的偏移明显减少。5.2 延迟与成本的取舍:N不是越大越好多查询扩写里的N(扩写数量)是个值得精调的参数。N=5听起来很稳妥,但每一路检索都是真实的成本和时间开销。我在延迟敏感的客服场景里,把N从5降到3,命中率只掉了4个百分点,延迟却降了40%。另外强烈建议做结果缓存。很多用户问题的高频改写版本是固定的,比如“怎么退换货”“保修多久”。把查询优化层输入的原始Query做哈希,缓存对应的扩写结果和检索结果,能省下大量重复计算。注意缓存Key要用原始Query,而不是扩写后的Query,否则缓存命中率会很低。5.3 一定要先有评估,再来谈优化这是我见过最多团队犯的错:查询转换、分解都做了,最后用几条测试样例试一下觉得效果不错就上线了。但真实用户问题的分布远比你试的几条复杂。没有系统评估,你根本说不清是查询优化带来的提升,还是大模型运气好。我的建议是建一个100~200条的小型标注集,按问题类型分层:口语化问题、型号精确问题、多跳问题、复合条件问题。每类问题标注出标准答案所在的文档块ID。每次改动查询逻辑,都在这个集上跑一遍命中率和准确率。这个标注集本身成本不高,但对后续所有优化决策都是决定性的。没有评估集的RAG优化,就是在盲人摸象。5.4 什么时候别用查询分解这里说点反直觉的:查询分解不是万能的,有两种情况强烈不建议用。第一种是子问题无法独立检索的场景,比如用户问了一个需要全局统计的问题,“所有键盘型号的平均价格是多少”,拆成子查询后每个子查询依然无法独立得到答案,多此一举。第二种是时间敏感型问题,拆解带来的延迟可能让用户等得不耐烦,不如直接单查询+生成来得快。判断标准就一条:子查询的结果是否真的能作为独立单元被检索到且有价值。如果不能,就用最简单的查询转换策略兜底。我在实际项目里感受到,RAG优化的收益分布很不均匀,查询端优化的性价比高得出奇。改一次查询转换逻辑,往往比换一次向量模型、调三天切块参数带来的提升都大。这不是说模型和切块不重要,而是说查询入口这个环节太容易被轻视了。很多人都默认用户问题天生适合检索,但实际上用户根本不会按你的文档结构来提问。把查询端当作一个需要专门设计的模块来对待,你会发现RAG能回答的问题范围一下宽了很多。如果你现在只打算做一件事,先去加一个多查询扩写,成本最低、收益最稳,做完之后你大概率会认同这句话。
做过RAG落地的人应该都有这种体验:检索链路搭好了、向量化了、TopK也调了,但用户一问某些问题,召回结果就是不对味。比如用户问“那个能折叠的蓝牙键盘多少钱”,文档里明明写着“便携式折叠键盘,支持蓝牙连接,售价199”,可检索Top5里就是没有它。问题出在向量检索往往默认“问题和文档是同一个语言体系”,但用户问法、术语粒度、口语习惯和文档表达方式之间,天然就有一道鸿沟。这时候最值得动的,不是换embedding模型,也不是堆更大容量的向量库,而是先把“查询”这个入口处理好。查询转换和查询分解,是高级RAG里专门针对“入口问题”的一类技术。查询转换负责把用户问题改写成更适合检索的形式,查询分解负责把复杂问题拆成多个简单子问题分别检索。这两块做好了,往往比换一个更强的检索模型带来的收益更直接。这篇文章我会把它们的原理、适用场景、工程实现细节和我在实际项目里踩过的坑全部过一遍,希望能给你一个不用走弯路的方向。1. 先搞清一件事:你的RAG到底“失灵”在哪一环很多团队做RAG的第一版,都是经典的“三件套”:文档切块、Embedding建索引、向量检索取TopK喂给大模型。Demo阶段一切正常,到了真实流量就露馅。这时候不要急着换模型,先判断RAG到底在哪一环丢了信息。查询端出问题的情况,占比其实非常高。1.1 三张图看懂朴素RAG的死角第一类问题是词面不匹配:用户说的是口语,文档写的是书面语。“能折叠的蓝牙键盘”这个说法在文档里可能根本不存在,“折叠”“蓝牙”不会以同样的词组出现在同一句话里,而Embedding模型对这种短Query的语义理解又经常被泛化到歧义方向。第二类是查询过泛:用户问的是“你们有哪些支持无线连接的外设”,这个问题指向的是整个产品目录,不是一个固定文档块。向量检索本质上是在做“找相似的文本片段”,面对广谱问题,它没有任何一个片段能高置信度命中,结果就是召回了七八个低相关片段,大模型只能靠猜来拼答案。第三类是查询过复合:用户问“有没有既能折叠又支持蓝牙还带数字小键盘的型号”,这个查询同时包含三个约束条件。向量化时三个条件被压缩进一个向量里,互相稀释语义,最后没有一个条件被充分匹配。向量检索对“复合语义”的容忍度很低,因为TopK相似度计算是全局相似,不是逐条件约束。1.2 为什么向量检索兜不住这些问题很多人以为embedding模型越大会越懂人话,但实际体验是:大模型参数涨了,检索命中率并没有线性增长。原因是向量检索做的是“近似最近邻搜索”,它本质上是在一个大矩阵里找距离最近的K个点,计算的是整体相似度。用户的一个查询如果包含多个意图,最终向量是这些意图的“平均态”,而文档块往往是某一个意图的“纯净态”,这两者之间的距离天然就远。还有一个容易被忽略的点:切块粒度问题会让查询端劣势放大。假设答案是跨三个章节拼起来的,比如售后政策、产品规格、适配列表各一段,那单查询模式下即使切块粒度完美,TopK也大概率只命中其中一两块。这种场景光靠调切块参数救不回来,需要在查询阶段就做“多路获取”的规划——这正是查询分解想解决的问题。所以我才说,RAG项目一开始就值得在查询端投入设计,而不是等检索效果不佳了再回头补。下面两个章节分别讲查询转换和查询分解这两条技术路线。2. 查询转换:让“问法”贴近“文档的写法”查询转换的核心思想很朴素:既然用户问法跟文档表达不匹配,那就先把用户问题翻译成一组“更贴近文档语言”的查询,再去做检索。它不是一种技术,而是一类技术的统称,常见的有多查询扩写、HyDE、StepBack、查询重写等。每种的适用场景和代价都不同。2.1 多查询扩写:把一问变成五问这是我在项目里最先试、也最推荐先试的方案。思路是:让大模型基于原始问题,扩写出多个不同角度、不同措辞的查询,然后分别执行检索,最后把多路结果合并去重后交给生成模型。举个例子,用户问“这个键盘方便带出门吗”,扩写出来的查询可以包括:折叠键盘便携性折叠键盘重量与尺寸蓝牙键盘旅行携带体验折叠键盘是否适合通勤使用便携键盘收纳方式这样做的本质是增加检索的“覆盖面”。原始问题只有一种表达方式,embedding模型对这个表达可能理解偏差,但五个角度的查询里总有一两个能和文档空间对齐。多查询扩写不要求大模型多聪明,只要它能从不同侧面描述同一个意图就够了。实现上有个关键细节:扩写后的检索结果要设置一个合并逻辑,不能简单把各路TopK拼起来。我一般会按段落来源做去重,同一文档块被多个查询命中时,取最高相似度那一次,然后按相似度总分排序输出。这里注意,别把总分数直接累加,因为不同查询的得分范围不稳定,累加会让“被重复命中的文档块”霸榜。2.2 HyDE:先编一个答案再去搜HyDE的全称是Hypothetical Document Embeddings,思路是反着来的:先用大模型基于用户问题生成一个“假设性的答案文档”,也就是凭空编一段可能包含答案的文字,然后用这段“伪文档”去做向量检索,拿它来匹配真实文档。原理上,这是为了解决“查询和文档长度不对称”的问题。用户查询通常只有十几个词,而文档块往往几百字。向量模型在编码短句和长文本时,特征分布是有偏移的。既然短Query匹配长文档容易失真,那就先把Query“加长”成文档格式,再做相似度计算。我在实际测试中发现,HyDE对“调查类问题”帮助很大,比如“这种折叠键盘的电池寿命如何”,模型生成的假设文档会比较完整地描述电池容量、充电方式、续航时间这些内容,检索时能精确命中相关段落。但对事实密度很高的问答,HyDE有风险:假设文档里编的内容如果包含错误细节,检索时可能反而命中那些恰好包含错误关键词的无关文档,导致检索精度不升反降。所以HyDE的使用要加一道“信任门槛”。我通常的做法是:只有多查询扩写结果的整体相似度分数偏低时,才调HyDE作为补充召回手段,不会让它单独决定最终结果。你可以理解为,HyDE是一个“激进派”的召回通道,适合用来捞长尾,不适合当主力。2.3 StepBack与查询重写:顺着问题层级往上跳StepBack Prompting来自一篇用“后退一步”方式来引导推理的工作,应用到RAG里非常自然。有些用户问题过于具体,甚至具体到“文档里根本没有这个词”,比如“322扫雷键盘什么时候补货”——文档里写的是“型号KB-322,预计下月恢复供应”。此时如果直接检索“322扫雷键盘”,很可能命不中。StepBack的做法是让模型把问题泛化一级,变成“KB-322供货状态”或者“KB-322什么时候有货”,然后再检索。查询重写则更贴近“翻译”逻辑:把用户的口语表达、错别字、模糊指代,改写为文档体系更习惯的表达。比如用户说“你们家那款四百块左右能连电脑的键盘”,重写结果是“价格约400元、支持电脑连接的键盘型号”。这一步对搜索词命中率的提升非常直接,因为它把“语义相似”变成了“词面匹配”,而词面匹配在BM25这类稀疏检索里是强项,就算只用关键词检索都能命中。实际操作里,StepBack和查询重写往往是同一个Prompt里完成的:让模型先判断问题是否过于具体或口语化,若是,则输出改写后的标准查询。用一个模型调用搞定,不需要额外路由。2.4 查询转换各方法对比与选型逻辑我整理了一张对比表,方便你根据实际场景快速选型:方法一句话原理最佳场景主要风险多查询扩写生成多个角度的查询,多路检索合并用户意图模糊、查询表达单一检索延迟成倍增加,需做去重HyDE用模型生成假设文档,再以文搜文查询过短、文档为长描述的检索假设内容偏差会误导检索StepBack把过于具体的问题泛化一级用户问题包含代码、型号、小众术语泛化过头会丢失关键限定词查询重写把口语改写为文档体系的标准表达口语化严重、错别字多、指代模糊改写失真,信息被简化选型上我的建议是:第一优先级永远是多查询扩写,因为它的下限高、逻辑直观、对任何RAG场景都有正收益;如果查询过短且没有明确实体,再加HyDE;如果用户问的是型号、代码、专有名词,优先用StepBack;如果面向C端且口语化严重,查询重写必须做。这四种不是互斥的,而是可以组合成一条流水线。3. 查询分解:面对多跳问题,一次只能解决一个子问题如果说查询转换解决的是“怎么问”的问题,查询分解解决的就是“问哪些”的问题。它针对的是多跳(Multi-hop)问题——单靠一次检索无论如何都拿不全信息的场景。这类问题在知识库问答、企业文档问答里非常常见。3.1 什么是多跳问题:拆开才搜得到我拿一个实际场景举例:用户问“我们公司采购的那批KB-322键盘,如果要退货,应该联系哪个客服团队?”要回答这个问题,系统得知道三件事:KB-322属于哪条产品线、那条产品线的退货政策由哪个部门负责、该部门的客服联系方式是什么。这三件事大概率在三个不同文档块里,而且彼此之间没有直接链接。如果只检索一次,向量检索最多命中和“退货”最像的那一块,其余信息只能靠大模型编。多跳问题的最显著特征是:单一查询条件无法唯一确定目标文档。它拥有多个实体、多个关系链。向量检索面对这类问题时,天然劣势在于它没有“推理”,只有“匹配”。哪怕embedding模型再强,它也无法把跨文档的关联信息压缩进一个向量里。逻辑链必须由外部流程显式拆解。所以查询分解的本质,是把一个“需要推理的检索问题”,翻译成多个“可以匹配的检索子问题”。子问题之间可能是独立的平行关系,也可能是有依赖的串行关系,工程上要根据问题的结构来选。3.2 分解的两种主流姿态:平行拆解与串行追问平行拆解适用于“并列条件型”问题。用户问“支持蓝牙、有数字小键盘、价格低于300元的键盘有哪些”,这类问题里的每个条件都是独立的过滤维度,可以一次性拆成三个子查询,分别检索,最后做条件交集。这种拆法实现成本低,因为子查询之间没有依赖,可以并行执行,总延迟跟单次检索差不多。串行追问适用于“链式依赖型”问题,比如前面说的退货案例。第一步先查“KB-322属于哪条产品线”,得到结果后,第二步再查“该产品线的退货部门”,第三步查“该部门的联系渠道”。每一步的查询条件都依赖上一步的答案。这种拆法延迟会累加,但信息质量明显更高,因为它模拟了人类查资料的路径。工程实现上,我经常用“路由器 + 状态机”的模式来描述查询分解:先让大模型判断用户问题是单步还是多步,如果是多步,则输出一串有依赖关系的子查询。然后系统按顺序执行子查询,把上一步的结果注入下一步查询的上下文里。这里有个容易犯的错误:让大模型在初始阶段就把所有子问题一次性列出来,然后平行执行。这对链式问题基本无效,因为后续子问题严重依赖前一步的检索结果,预猜出来的子问题往往和真实文档结构脱节。3.3 子查询合并的工程细节查询分解之后,必然面对“多路检索结果如何组装”的问题。组装不好,信息就变成了大杂烩。我在工程实践里总结出三个原则。第一是去重。多个子查询可能命中同一个文档块,需要按块ID做去重,保留与对应子查询关联度最高的那次。第二是父文档回溯。子查询命中的往往是切得很细的小块,如果直接把它们拼起来喂给大模型,上下文可能丢失足够的背景信息。我通常会从命中的小块回溯到它所属的父文档或相邻章节,把父级文档纳入上下文,这样大模型能看到“完整的一章”而不只是一句话。第三是顺序控制。平行拆解的子查询结果,要按照用户问题里的条件顺序排列;串行追问的结果,则要按照逻辑链顺序排列。顺序影响大模型对因果关系的判断,这点在很多测评里都有体现。另外一个值得注意的问题:子查询结果的上下文总量会迅速膨胀。三个子查询每个取Top3,一旦做父文档回溯,可能直接塞进几千个token。所以合并前需要做一次“上下文预算规划”——每个子查询最多保留几个块、是否启用父文档、总token上限是多少,这些都要提前设好,否则大模型会被无关信息干扰,生成质量反而下降。4. 一个可复用的端到端实现:查询优化层的完整链路前面讲了理论,这部分给一个可以直接落地的结构。我不依赖任何重框架,用最朴素的代码描述调度逻辑,你拿到之后可以平移到自己栈里。4.1 系统流程与调用顺序我设计的查询优化层放在检索之前,由四个模块组成:意图分类器、查询转换器、查询分解器、合并排序器。整体调用顺序如下:接收用户原始查询;意图分类器判断问题类型:单步还是多步、是否需要扩写;若需要扩写,调用查询转换器生成多个查询变体;若需要分解,调用查询分解器生成子查询序列;执行检索,多路并行召回;合并排序器做去重、父文档回溯、上下文组装;把最终上下文交给生成模型输出答案。这个流程看起来简单,但每一步都有细节。下面给出核心代码骨架。4.2 核心代码与Prompts先用一段Python描述意图分类和多查询扩写的调度逻辑:def optimize_query(raw_query: str, llm_client) -> list[str]: # 第一步:意图分类,判断是否单步可解 classification_prompt = f""" 判断以下用户问题是否适合直接检索,还是需要扩写/分解。 问题:{raw_query} 输出:single 或 multi_keywords 或 multi_step """ decision = llm_client.chat(classification_prompt).strip() if decision == "single": return [raw_query] # 第二步:多查询扩写 expansion_prompt = f""" 请基于原始问题,从不同角度生成5个用于检索的查询变体。 要求:每个查询独立完整,适合在知识库中进行向量检索, 覆盖同义词、上下位词、不同表述方式。 原始问题:{raw_query} 直接输出查询列表,每行一个,不要解释。 """ expanded = llm_client.chat(expansion_prompt) queries = [q.strip() for q in expanded.splitlines() if q.strip()] return queries[:5]接下来是查询分解器的调度逻辑,处理多步依赖型问题:def decompose_query(raw_query: str, llm_client) -> list[str]: decompose_prompt = f""" 将以下问题拆解为按顺序执行的子查询。 要求:每个子查询都能独立在知识库中检索; 后一个子查询如果需要依赖前一个结果,请用 {{previous}} 占位符表示。 问题:{raw_query} 输出格式:每行一个子查询,按执行顺序排列。 """ subqueries = llm_client.chat(decompose_prompt).splitlines() executed_results = [] context_blocks = [] for sub in subqueries: # 把上一步结果注入当前子查询 sub_filled = sub.replace("{previous}", executed_results[-1] if executed_results else "") hit_blocks = vector_search(sub_filled, top_k=3) # 父文档回溯:把命中小块的父级章节一并拉回 for block in hit_blocks: parent = get_parent_section(block.id) if parent and parent.id not in context_blocks: context_blocks.append(parent.id) executed_results.append(summarize_hits(hit_blocks)) # 简单截断,防止上下文膨胀 if total_tokens(context_blocks) > 4000: break return context_blocks这段代码最核心的地方在于:串行分解时,每一步的检索结果会先被“压缩成一句话摘要”,再作为下一步的查询条件注入。不要直接把整段文档拼进下一步的查询里,因为查询是给检索模型用的,越短越精确越好。摘要这一步扮演的是“中间推理变量”的角色。合并排序器的伪代码也不复杂:def merge_and_rank(all_hits, max_context_token=4000): # 按块ID去重,保留最高分 unique = {} for hit in all_hits: if hit.id not in unique or hit.score > unique[hit.id].score: unique[hit.id] = hit # 按分数排序 + 父文档回溯 ranked = sorted(unique.values(), key=lambda x: x.score, reverse=True) final_blocks = [] token_count = 0 for hit in ranked: parent = get_parent_section(hit.id) for blk in [hit] + parent.blocks: if token_count + blk.tokens > max_context_token: return final_blocks final_blocks.append(blk) token_count += blk.tokens return final_blocks4.3 端到端效果:一个固定测试集上的对比我在一个内部知识库问答场景里做过一次对照实验,测试集是50条真实用户问题,覆盖设备参数、售后政策、产品兼容性三类。检索用的同一个向量模型和同一批切块,只改查询端逻辑。结果如下:配置命中率(@5)最终回答准确率平均耗时朴素单查询Top546%38%0.8s多查询扩写(5路)68%61%1.6s多查询 + 查询分解82%74%2.3s多查询 + 分解 + 父文档回溯88%79%2.7s命中率和准确率都明显上升,代价是延迟从0.8秒涨到2.7秒。对于企业知识库问答这个量级,2.7秒完全可接受。但如果你的场景是高频实时对话,可能需要用缓存、异步预取等手段来平衡,下面详细说。5. 生产环境里我踩过的坑和调参经验理论再漂亮,落地时一定会有意外。这部分纯粹是我踩坑后的复盘,每条都对应真实发生过的问题。5.1 别让转换层变成“幻觉放大器”查询转换层本质上也是大模型在生成内容,所以它同样会产生幻觉。有一次多查询扩写时,模型把用户问题里的“KB-322”扩写成了“KB-322 Pro Max”,文档里根本没有这个型号,但有一篇文档恰好提到了“Pro Max”字样,结果检索命中了完全无关的内容,最终答案被带偏。我的修复方案是给扩写结果加一道“实体守住”约束:Prompt里明确要求,扩写查询必须完整保留原始问题里的品牌名、型号、数字、价格等实体词,不允许替换或增删。你可以理解成,扩写只允许改变句式和描述角度,不允许动“硬信息”。加了这一条之后,扩写导致的偏移明显减少。5.2 延迟与成本的取舍:N不是越大越好多查询扩写里的N(扩写数量)是个值得精调的参数。N=5听起来很稳妥,但每一路检索都是真实的成本和时间开销。我在延迟敏感的客服场景里,把N从5降到3,命中率只掉了4个百分点,延迟却降了40%。另外强烈建议做结果缓存。很多用户问题的高频改写版本是固定的,比如“怎么退换货”“保修多久”。把查询优化层输入的原始Query做哈希,缓存对应的扩写结果和检索结果,能省下大量重复计算。注意缓存Key要用原始Query,而不是扩写后的Query,否则缓存命中率会很低。5.3 一定要先有评估,再来谈优化这是我见过最多团队犯的错:查询转换、分解都做了,最后用几条测试样例试一下觉得效果不错就上线了。但真实用户问题的分布远比你试的几条复杂。没有系统评估,你根本说不清是查询优化带来的提升,还是大模型运气好。我的建议是建一个100~200条的小型标注集,按问题类型分层:口语化问题、型号精确问题、多跳问题、复合条件问题。每类问题标注出标准答案所在的文档块ID。每次改动查询逻辑,都在这个集上跑一遍命中率和准确率。这个标注集本身成本不高,但对后续所有优化决策都是决定性的。没有评估集的RAG优化,就是在盲人摸象。5.4 什么时候别用查询分解这里说点反直觉的:查询分解不是万能的,有两种情况强烈不建议用。第一种是子问题无法独立检索的场景,比如用户问了一个需要全局统计的问题,“所有键盘型号的平均价格是多少”,拆成子查询后每个子查询依然无法独立得到答案,多此一举。第二种是时间敏感型问题,拆解带来的延迟可能让用户等得不耐烦,不如直接单查询+生成来得快。判断标准就一条:子查询的结果是否真的能作为独立单元被检索到且有价值。如果不能,就用最简单的查询转换策略兜底。我在实际项目里感受到,RAG优化的收益分布很不均匀,查询端优化的性价比高得出奇。改一次查询转换逻辑,往往比换一次向量模型、调三天切块参数带来的提升都大。这不是说模型和切块不重要,而是说查询入口这个环节太容易被轻视了。很多人都默认用户问题天生适合检索,但实际上用户根本不会按你的文档结构来提问。把查询端当作一个需要专门设计的模块来对待,你会发现RAG能回答的问题范围一下宽了很多。如果你现在只打算做一件事,先去加一个多查询扩写,成本最低、收益最稳,做完之后你大概率会认同这句话。