第25回 高级 RAG(上)——查询变换与 HyDE

问得不清难得真,先把问题磨成刃。
一纸搜遍千卷书,关键还在提问人。

上回讲评估,看官应当明白:
很多 RAG 翻车,不是检索器太弱,而是“问法太糟”。

你去书库问一句“给我讲讲强化学习”,
那检索器只能回你一堆“看起来都相关”的段落。
可你若问“强化学习里探索与利用怎么权衡?给一个可计算例子”,
证据就好找得多。

所以高级 RAG 的第一刀,不砍检索器,先砍“查询”。
这一回讲两类常用招式:

  • 查询改写(Rewrite):把口语问题改成更像文档的检索句
  • HyDE:先“生成一段假答案/假文档”,再拿它去检索

一、查询变换为什么有效:让问题长得像证据

资料库里的句子往往像“陈述句”:

  • “余弦相似度衡量向量夹角”
  • “CRAG 讨论如何纠错检索”

但用户的问题往往像“口语句”:

  • “余弦相似度是啥意思?”
  • “检索错了怎么办?”

查询变换做的事就是:
把口语问题变成更像资料里的关键词与表达。

常见四种变换:

  1. 重写(Rewrite):同义改写,补齐关键词
  2. 分解(Decompose):把复合问题拆成子问题
  3. Step-back:先问一个更一般的“背景问题”,再回到细节
  4. 扩展(Expand):加上同义词、缩写、别名

看官若问“这算不算作弊”?
不算。
这是承认一个事实:
检索是匹配系统,匹配就得讲匹配的语言。


二、HyDE:先写一段“可能的答案”,再用它当检索钥匙

HyDE 的直觉更有趣:
你先让模型写一段“假答案/假文档”,
这段文字往往包含更丰富的关键词与语义线索,
再用它去检索,就更容易召回真正相关的文档。

这招像什么?
像你做题先写“草稿版解法”,
草稿里会出现你需要的定义、公式、关键术语,
然后你拿着这些术语去翻书,就更容易翻到对的页。

HyDE 最早作为一个检索增强技巧被提出并广泛引用;
在 2024 的 RAG 综述与“预检索阶段(pre-retrieval)”分类中,它也常作为典型方法被讨论。12


三、查询变换也会翻车:改写错了,比不改更糟

查询变换的风险主要有两类:

  1. 语义漂移:把用户意图改偏了
  2. 注入噪声:加了太多无关词,检索更发散

因此工程里常见一个守门策略:
改写不止一版,而是生成多条候选查询,然后检索合并或重排。
这就是把第19回“预算换能力”的思路搬到检索前:
多试几种问法,选最能召回证据的那种。


四、极简可跑代码:用“多查询 + 投票”提升召回

我们继续用第23回的玩具检索器(词袋+余弦),
这次加上查询变换:

  • 原问题 q
  • 重写版 q_rewrite(我们用规则模拟)
  • HyDE 版 q_hypo(用模板生成“假答案”)

对每个查询检索 top-k,然后把证据按“出现次数”投票排序。

import math
import re


DOCS = [
    "HyDE 的思路是先生成一个假文档或假答案,再用它去检索真实证据。",
    "查询改写可以把口语问题变成更像文档的关键词表达,从而提升召回。",
    "RAG 评估要拆开看:检索质量与生成质量是两本账。",
    "长上下文并不必然取代 RAG:检索可以节省成本并提升可控性。",
    "Corrective RAG 讨论检索错了如何纠错与再检索。",
]


def tokenize(s):
    return [w for w in re.split(r"[^0-9A-Za-z\u4e00-\u9fff]+", s) if w]


def bow(tokens):
    v = {}
    for t in tokens:
        v[t] = v.get(t, 0) + 1
    return v


def dot(a, b):
    return sum(a.get(k, 0) * b.get(k, 0) for k in a.keys())


def norm(a):
    return math.sqrt(sum(x * x for x in a.values()))


def cosine(a, b):
    na = norm(a)
    nb = norm(b)
    if na == 0 or nb == 0:
        return 0.0
    return dot(a, b) / (na * nb)


def retrieve(q, k=2):
    qv = bow(tokenize(q))
    scored = []
    for i, d in enumerate(DOCS):
        scored.append((cosine(qv, bow(tokenize(d))), i))
    scored.sort(reverse=True)
    return [i for _, i in scored[:k]]


def rewrite_query(q):
    q = q.strip()
    q = q.replace("啥", "是什么").replace("怎么办", "如何处理")
    if "检索" in q and "错" in q:
        return q + " 纠错 再检索"
    return q


def hyde_query(q):
    return "可能的回答:这问题涉及 " + q.strip() + " 的定义、动机、步骤与常见失败模式。"


def multi_query_vote(q, k=2):
    qs = [q, rewrite_query(q), hyde_query(q)]
    votes = {}
    for qq in qs:
        for idx in retrieve(qq, k=k):
            votes[idx] = votes.get(idx, 0) + 1
    ranked = sorted(votes.items(), key=lambda x: (-x[1], x[0]))
    return [i for i, _ in ranked]


if __name__ == "__main__":
    q = "HyDE 是啥?"
    print("single:", [DOCS[i] for i in retrieve(q, k=2)])
    print("multi :", [DOCS[i] for i in multi_query_vote(q, k=3)[:2]])

你会发现:
原查询太短时,单次检索容易飘;
多查询投票后,命中 HyDE 相关句子的概率更高。

这就是高级 RAG 的常识:
别把检索当成“一锤子买卖”,把它当成可搜索的过程。


五、小结:高级 RAG 先修“问法”,再修“武器”

这一回你要记住三点:

  • 查询变换让问题更像证据,从而提升召回
  • HyDE 用“假答案/假文档”当钥匙,补足语义线索
  • 变换也会漂移,所以要多候选、多检索、再汇总

下一回(第26回)我们继续升级“找”这一环:
只靠向量检索会漏关键词,只靠 BM25 会漏语义。
那就两者都用,再加一层重排,让证据更准。

欲知后事如何,且听下回分解。


幻觉核查

  • HyDE 的方法描述与定位:可核对其论文标题与摘要对“hypothetical document embeddings”的表述。2
  • HyDE 作为预检索技巧的分类:可核对 RAG 文本生成综述对 pre-retrieval 方法的归纳。1
  • 本回代码中的改写与 HyDE 为规则/模板模拟,用于说明流程,不代表真实 LLM 改写质量。

逻辑审计

  • 与第24回衔接:评估揭示“问法”会影响召回,本回以查询变换直接提升检索栏指标。
  • 与导读一致:导读强调“办案式检索循环”,本回把循环前置到“提问方式”,使检索更可控。
  • 为后续铺路:第26回把检索信号做混合与重排;第27回把查询变换与重排纳入模块化架构,进入可训练与可路由的系统。

引用与溯源

Footnotes

  1. Huang, Y., et al. A Survey on Retrieval-Augmented Text Generation for Large Language Models arXiv:2404.10981 (v2: 2024-08-23) https://arxiv.org/abs/2404.10981 2

  2. Gao, L., et al. Precise Zero-Shot Dense Retrieval without Relevance Labels (HyDE; 2022) https://arxiv.org/abs/2212.10496 2