查询重写:HyDE、多查询与分解 本节摘要:用户敲下的查询,不是你的检索器想要的查询。改写在检索之前架桥,让索引看到更接近答案样貌的东西。本节实现三种重写器:HyDE(Hypothetical Document Embeddings)——让 LLM 写一个假答案文档,嵌入它去检索;多查询展开(Multi-Query)——把一个查询改写成 N 个释义,各检索一次再用 RRF 合并;查询分解(Decomposition)——把复杂问题拆成子问题,各检索后合并。三者在同一固定语料上对比:HyDE 赢在措辞错配,多查询赢在释义方差,分解赢在多主题。配套一个确定性 mock LLM,让重写回路离线运行。读完本节,你能根据查询长度、连词、术语密度为每条查询选对策略。
本节摘要:用户敲下的查询,不是你的检索器想要的查询。改写在检索之前架桥,让索引看到更接近答案样貌的东西。本节实现三种重写器:HyDE(Hypothetical Document Embeddings)——让 LLM 写一个假答案文档,嵌入它去检索;多查询展开(Multi-Query)——把一个查询改写成 N 个释义,各检索一次再用 RRF 合并;查询分解(Decomposition)——把复杂问题拆成子问题,各检索后合并。三者在同一固定语料上对比:HyDE 赢在措辞错配,多查询赢在释义方差,分解赢在多主题。配套一个确定性 mock LLM,让重写回路离线运行。读完本节,你能根据查询长度、连词、术语密度为每条查询选对策略。
对应原课程:Phase 19 · Lesson 67 ·
query-rewriting-hyde(原英文phases/19-capstone-projects/67-query-rewriting-hyde/docs/en.md)。本节属第 20 章「毕业项目」的进阶 RAG 赛道。
阅读完本节,你应当能够:
用户输入「我们团队在上传失败且预算耗尽时怎么办?」。语料里有一篇文档说「AbortMultipartOnFail 在上传失败时中止进行中的 S3 分片上传,并扣减每桶重试预算」。查询与文档不共享任何名词短语:BM25 漏掉;双塔把该文档排第三或第四,因为查询向量落在偏爱「取消任务」文档而非「中止上传」文档的嵌入区域;第 66 节的两段式重排若答案进了 top-N 还能救,若连 top-N 都没进,重排器永远看不到它。
解法是在查询碰到检索器之前先改写。2023 年 Gao 等人的论文《Precise Zero-Shot Dense Retrieval without Relevance Labels》提出 HyDE:让 LLM 写一篇本会回答该查询的文档,嵌入这篇假想文档,用其嵌入作为检索向量。假想文档落在正确的嵌入区域,因为它用的是语料的口吻;查询向量不会。
两种近亲技术与 HyDE 搭配。多查询展开(微软 GraphRAG 用过的术语)生成 N 个查询释义,各检索一次再合并;分解(2024 年斯坦福 DSPy 的「子查询分解」)把「上传失败且预算耗尽时怎么办」拆成「上传失败时怎么办」与「重试预算耗尽时怎么办」,两次检索、一份合并结果,答案的两半都可达。本节实现这三种并跑在同一固定语料上。
HyDE 用 LLM 写的假想文档向量替换用户的查询向量。提示很短:
你是领域专家。写一段回答下列问题的文字。用本领域文档会用的词汇与措辞。 不要拒绝,不要说不知道。 问题:{user_query} 段落:
LLM 的答案作为事实答案是错的,因为它不知道你的语料——没关系,检索器不在乎事实正确性,只在乎 token 分布。假想段落含「abort」「multipart」「bucket」「budget」这些词,因为这就是该主题的文档会用的词。嵌入它,向量落在真实段落附近。生产里把假想文档限制在两三句,太长收集噪声,太短丢失 HyDE 需要的词法信号。
生成用户查询的 N 个释义,最简提示:
用 {N} 种不同方式改写下列问题。每个改写必须保留原意。编号 1 到 {N}。不要加解释。
每个释义取 top-k,把 N 个排名列表用 RRF(第 65 节同算法)合并。便宜、可并行、确定性。多查询在用户措辞只是多种等价问法之一、且任一改写会问得更好时胜出;在所有改写因原查询同样地坏而同样地坏时失败。
单次检索无法满足多面问题。分解让 LLM 把问题拆成子问题,系统按子问题检索。提示:
下列问题可能需要多个不同主题的信息。把它分解成子问题列表。每个子问题必须能独立回答。 若问题已是原子问题,原样返回。 问题:{user_query}
按子问题检索,合并。分解适合含连词、多子句对比、两个无关主题的问题;不适合原子问题——那时分解器的职责是返回原问题,别发明假子问题。
三者互补:HyDE 桥接查询-语料的 token 鸿沟,多查询覆盖释义方差,分解覆盖多主题查询。生产系统三者都跑,按查询选策略(第 69 节端到端系统展示选择器)。
本节离线运行。Mock LLM 是一个以用户查询为键的小查找表,外加对未见查询的兜底。查找表含:每个固定查询的写好的假想段落、三个释义、一个分解;对未知查询,做一个确定性变换:取查询的内容词,经同义词表扩展后返回。
💡 要紧的是 mock 的形状,不是数据。生产里把 mock 换成真实模型调用,检索器不变。
code/main.py 实现:
MockLLM —— 上文描述的确定性替身。HyDERewriter —— 调 LLM 写假想文档,返回带假想文本与检索器应使用查询的 RewriteResult。MultiQueryRewriter —— 调 LLM 取 N 个释义,返回查询列表。DecomposeRewriter —— 调 LLM 分解,返回子问题。retrieve_with_rewriter —— 接一个重写器与一个检索器,跑改写并融合结果。检索器形状复用第 65 节(混合 BM25 + 稠密),融合用同一个 RRF,唯一新形状是重写器接口——很小。
@dataclass class RewriteResult: hypothetical: str # HyDE 的假想文档;非 HyDE 时为空 queries: list[str] # 实际去检索的查询列表(>=1) def retrieve_with_rewriter(rewriter, retriever, query, k=5): rw = rewriter.rewrite(query) # 改写 rankings = [retriever.search(q, k=k) for q in rw.queries] if rw.hypothetical: rankings.append(retriever.search_by_vector(embed(rw.hypothetical), k)) fused = rrf(rankings, k=60) # 第 65 节的 RRF return fused[:k]
运行:
python3 code/main.py
输出是每策略的排名与一份最终小结:HyDE 赢在措辞错配查询,多查询赢在释义方差查询,分解赢在多主题查询,兜底(无重写器)在三者中至少一个上失败。
| 重写器 | 用 LLM 干什么 | 何时赢 | 何时输 |
|---|---|---|---|
| HyDE | 写假答案文档 | 查询与语料 token 鸿沟大 | 假想里幻觉了语料特有标识符 |
| 多查询 | 改写 N 个释义 | 用户措辞是多种等价问法之一 | 改写都因原查询同样地坏 |
| 分解 | 拆成子问题 | 多主题、含连词 | 原子问题(过拆有害) |
| 步退(Step-back) | 问更一般的问题 | 问题太具体检索不到 | (练习项) |
业界对比:LlamaIndex 的 query_transformations、LangChain 的 MultiQueryRetriever、微软 GraphRAG 都实现这些范式。它们的共识与本节一致:三者互补,按查询选策略,可并行三路再用 RRF 合并(成本是三次 LLM 调用 + 一次融合,质量是三者覆盖的并集)。
HyDE 幻觉语料特有标识符。 模型发明一个函数名,假想文档对该文档的 BM25 分崩塌,因为发明的名成了索引里没有的高权 token。给假想长度设上限,并在融合里降低 BM25 权重。
多查询改写全收敛。 弱模型产出三个近乎相同的释义,N 次检索返回同一 top-k,RRF 合并不比单次检索好。在改写提示里加显式多样性指令,按 Jaccard 检测重复。
分解过拆。 分解器把原子问题变成列表,各检索返回同一文档但排名降低,合并比原查询更差。在扇出前加一道「这些子问题足够不同吗」的检查。
延迟倍增。 HyDE 成本一次 LLM 调用;多查询一次 LLM 调用生成 N 个改写 + N 次检索;分解一次 LLM 调用分解 + M 次检索。检索并行,LLM 调用是下限。
RewriteResult + retrieve_with_rewriter:本节的重写阶段是第 69 节「端到端 RAG 系统」在检索器之前的一环。生产模式:
下一节,我们将进入「RAG 评估」——同时给检索与答案打分的六大指标(精度、召回、MRR、nDCG、忠实度、答案相关性),让你能定位错答出在哪一段。