RAG 查询扩展实战:HyDE 与多 query 改写怎么选、怎么组合

上一篇解决了多轮对话里的指代和省略:先把「它多少钱」还原成独立 query,再送检索。但独立 query 也不天然适合检索。

用户说「设备老是卡住」,文档写的是「设备响应超时」;用户说「打印有黑道」,文档写的是「输出页面出现纵向条纹」。问题很完整,向量检索却可能因为措辞差异捞不到目标文档。

这类问题该做查询扩展:用 HyDE 或多 query 改写增加检索入口。这篇沿用前面的 pgvector + bge-m3 环境,给出最小实现、RRF 融合代码和一组分类型评测,最后说清两套方案怎么选。

一、先分清:查询重写和查询扩展不是一回事

这两个技术经常混在一起,实际解决的问题不同:

技术 输入的问题 目标 输出
查询重写(上一篇) 「它多少钱」,离开上下文无法理解 补全指代和省略 一个独立 query
查询扩展(本篇) 「设备老是卡住」,语义完整但措辞不好检索 增加与用户措辞不同的检索入口 假设答案或多个 query 变体

多轮场景里两者可以串联:先重写还原意图,再判断是否需要扩展。 如果顺序反过来,先把「它多少钱」扩成三个没有主语的问题,后面只会更乱。

本篇评测里的 query 都已经是独立问题,只动查询扩展这一个变量。

二、HyDE:先写一段假设答案,再拿答案去检索

HyDE(Hypothetical Document Embeddings)的思路很直接:用户问题通常很短,知识库文档却很长,两者在语义空间里的形态差异很大。先让 LLM 生成一段「可能出现在文档里的答案」,再用这段文字做向量检索,缩小问题与文档之间的表达差异。

最小实现只需要一次额外的 LLM 调用:

python 复制代码
HYDE_PROMPT = """请根据用户问题,写一段可能出现在企业知识库中的答案。
只用于检索,不要求事实完全正确,但不要新增具体型号、数字、日期或政策条件。
使用知识库文档常用的客观说明风格,80~120字,只输出答案正文。

用户问题:{query}
"""

def hyde_search(query: str) -> list[dict]:
    hypothetical_doc = llm_call(
        HYDE_PROMPT.format(query=query),
        model="cheap-small-model",
        temperature=0,
    )
    return vector_search(hypothetical_doc, top_k=20)

比如用户问「设备老是卡住」,假设答案可能写成「设备响应超时可能由网络连接、资源占用或任务队列阻塞导致」。这段文字比原 query 更容易靠近知识库里的故障排查文档。

这里有一条硬约束:假设答案只用于检索,不能作为事实上下文交给最终回答,更不能直接展示给用户。 HyDE 允许「猜」,最终答案必须以检索到的真实文档为准。

三、多 query:一次生成多个检索视角,用 RRF 合并结果

多 query 改写不生成答案,而是让模型给出几个保持原意的问法:

text 复制代码
原 query:设备老是卡住
变体 1:设备响应缓慢或超时的常见原因
变体 2:设备运行过程中出现卡顿如何排查
变体 3:设备任务处理延迟的故障解决方法

实现上建议让模型直接返回 JSON,解析失败就退回原 query,别让一次格式异常拖垮整条检索链路:

python 复制代码
import json

EXPAND_PROMPT = """将用户问题改写成3个适合知识库检索的不同问法。
要求:
- 保持原始意图,不新增条件、实体、型号或约束
- 分别覆盖术语表达、故障现象、处理动作三个角度
- 每个问法不超过60字
- 只输出JSON:{"queries": ["...", "...", "..."]}

用户问题:{query}
"""

def expand_queries(query: str) -> list[str]:
    raw = llm_call(
        EXPAND_PROMPT.format(query=query),
        model="cheap-small-model",
        temperature=0,
        response_format={"type": "json_object"},
    )
    try:
        candidates = json.loads(raw).get("queries", [])
    except (json.JSONDecodeError, AttributeError):
        candidates = []

    variants = []
    for item in candidates:
        q = item.strip()
        if q and q != query and len(q) <= 120:
            variants.append(q)

    # 原 query 永远保留,变体最多取3个
    return [query] + variants[:3]

注意最后一行:原 query 一定要参加检索。 只检索模型生成的变体,相当于把用户意图完全交给一次 LLM 调用;变体一旦跑偏,原始检索入口也没了。

多路结果别直接拼接,用 RRF 融合

每路各取 top20,按 chunk_id 去重并累加 RRF 分数:

python 复制代码
def rrf_fuse(result_lists: list[list[dict]], k: int = 60,
             top_n: int = 5) -> list[dict]:
    scores: dict[str, float] = {}
    docs: dict[str, dict] = {}

    for results in result_lists:
        for rank, doc in enumerate(results, start=1):
            chunk_id = doc["chunk_id"]
            docs[chunk_id] = doc
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)

    top_ids = sorted(scores, key=scores.get, reverse=True)[:top_n]
    return [docs[chunk_id] for chunk_id in top_ids]


def multi_query_search(query: str) -> list[dict]:
    queries = expand_queries(query)
    # 生产环境用异步任务并行,避免把每路延迟简单相加
    result_lists = [vector_search(q, top_k=20) for q in queries]
    return rrf_fuse(result_lists, top_n=5)

这里继续使用第 26 篇的 RRF,而不直接相加向量相似度:不同 query 的分数分布不一致,排名比原始分数更稳。k=60 沿用前面的默认值,没必要先在这个参数上花精力。

四、实测:扩展有没有用,要看 query 类型

测试环境与前几篇一致:

text 复制代码
PostgreSQL 16 + pgvector 0.8
embedding:bge-m3,1024 维,cosine 距离
每路召回 top20,RRF k=60,最终取 top5
扩展模型:同一个低价小模型,temperature=0
延迟统计包含查询扩展与检索,不含最终答案生成

评测集复用第 27 篇的 100 条独立 query 和 ground truth。原始 query 的 recall@5 是 86%。我把它们改写成更贴近一线用户的口语和模糊表达,人工确认意图及相关文档不变,再比较四种方案:

方案 总体 recall@5 P95 延迟 每次扩展 Token
改写后的原 query,不扩展 72% 210ms 0
纯 HyDE 80% 950ms 约160
多 query + RRF 84% 1410ms 约230
原 query + 多 query + RRF 86% 1480ms 约230

分类型结果更有参考价值:

query 类型 样本数 不扩展 纯 HyDE 多 query + RRF 保留原 query 的组合
术语错位型 40 65% 78% 80% 83%
模糊描述型 35 69% 77% 83% 86%
其他独立问题 25 88% 88% 92% 92%

这组数据只代表当前设备知识库和评测集,不能当成通用基准。但有三个结论比较稳定:

  1. 查询扩展补的是措辞差异,对精确型号和错误码帮助有限。 后者仍应走第 26 篇的 BM25 混合检索。
  2. 多 query 的整体表现好于纯 HyDE,代价是更多检索请求和更高延迟。 语义空间里的「假设答案」有时也会带偏方向。
  3. 保留原 query 是便宜且有效的保险。 它只增加一路向量检索,在这次测试里把总体 recall@5 从 84% 拉到 86%,并减少了变体漂移造成的回退。

五、四个容易踩的坑

1. HyDE 把不存在的事实写进假设答案

用户问「A 产品为什么连不上」,模型可能假设成「A 产品连接超时与授权过期有关」。如果知识库里没有授权相关内容,检索就会被带到错误主题。

防御方法:prompt 禁止新增型号、数字和条件;线上记录假设答案与原 query;badcase 复盘时先看扩展文本,再怀疑 embedding。

2. 多 query 把一个问题扩成三个问题

「支持哪些部署方式」被扩成「支持私有化吗、支持国产系统吗、部署多少钱」,后两个问题已经改变原意。召回率可能上升,最终答案却会混入用户没问的内容。

防御方法:限制变体数量和长度,保留原 query,并把「不得新增约束」写进 prompt。涉及价格、日期、型号的 query,出现实体变化就直接丢弃该变体。

3. 只追求召回率,不看重复 chunk 和最终上下文

多路检索很容易把同一文档的相邻 chunk 全部送进 top5。recall@5 好看了,上下文窗口却被重复内容占满。

工程上可以在 RRF 后加一道文档级限流:同一 doc_id 最多保留两个 chunk,其余位置让给不同文档。若后面还有 reranker,先给 reranker 更宽的候选集,再做最终截断。

4. 所有 query 一律扩展

原本就检索得很好的问题,也要白付一次 LLM 调用和几路检索延迟。更合理的方式是先跑原 query,满足以下条件再扩展:

  • 原 query 短且缺少明确实体;
  • 首路检索分数低于历史同类型 query 的正常区间;
  • 日志中属于高频术语错位或模糊描述类型;
  • 首路结果进入低置信拒答流程之前,给一次扩展补救机会。

阈值不要拍脑袋,按第 27 篇的分类型评测集分别标定。

六、选型与组合:我现在的默认方案

场景 建议
多轮追问、指代和省略严重 先做查询重写,别直接扩展
用户措辞口语化、术语错位明显 原 query + 2~3 个变体 + RRF
问题很短,文档以完整说明文为主 可以试 HyDE,但假设答案只用于检索
型号、错误码、条款号精确匹配 向量 + BM25,查询扩展不是主解
原 query 已有稳定高召回 直接检索,不增加延迟
实时性要求高、调用量很大 只对低置信 query 触发扩展

当前项目里的默认链路是:

text 复制代码
多轮 query
  → 指代消解 / 查询重写
  → 原 query 先检索
  → 低置信时生成 2~3 个变体
  → 原 query 与变体并行召回
  → RRF 融合
  → rerank
  → 生成与答案评估

一句话总结:HyDE 和多 query 都在增加检索入口,真正需要控制的是意图漂移与额外延迟。保留原 query、分类型评测、只对低置信请求触发扩展,比单纯堆更多变体更重要。


原文首发于公众号「程语AI」。我是成风,十几年研发,现一人公司做 AI 应用工程化,欢迎评论区交流。

相关推荐
ServBay2 小时前
Claude Fable 5.5 测试曝光,但它在悄悄给你打分
后端·ai编程·claude
孟健2 小时前
Claude Code 同需求写两版对比:装不装 frontend-design 差多远?
前端·ai编程·claude
OpsEye3 小时前
大模型 Token 计量不准,是什么原因,怎么解决??
javascript·ai编程
西安小哥4 小时前
把 200K 上下文跑满之后,我给编码智能体立了四条规矩
aigc·ai编程
西安小哥4 小时前
让大模型稳定输出 JSON,我换了三次方案,最后败给一个土办法
ai编程
ryn483984 小时前
01 一个功能模块,怎么分层才不乱
前端·ai编程·claude
文谦南宁AI普工4 小时前
CVSS 10.0 沙箱逃逸 + 假扩展后门:给 Agent 开发者的供应链与沙箱安全复盘(附可跑的配置审计器)
安全·agent·ai编程
vivo互联网技术4 小时前
把 Agent 的判断与行动变成可恢复的软件事实|KDC 系列 工程篇
架构·agent·ai编程