授权与合规声明
本文为原创技术分享,所有示例代码均运行于作者自建的隔离环境,仅供学习交流。文中涉及的第三方模型与服务以各自官方文档为准,请在服务条款范围内使用。转载请保留出处。
一、为什么召回差常怪错地方(先量 query-doc 表述 gap)
先把"分块"从嫌疑名单里拿掉
RAG 上线后最常见的抱怨是"检索不出来"。团队第一反应往往是调分块大小、换切分策略、加重叠窗口。但翻一下线上日志就会发现,真正卡住的多半是用户的提问方式和文档写法对不上:用户说"怎么让模型不瞎编",文档标题却是"生成式模型的事实性约束与引用策略"。两边说的是同一件事,向量却离得很远。
先量再改,是本文贯穿的主线。把一批真实 query 和候选文档摆在一起,人工或自动判一下"用户想找的,文档里到底有没有、用的什么词"。如果文档里明明有答案,只是措辞不同,那问题在 query 侧,而不在分块。分块改的是"文档怎么切",改不了"用户怎么问"。
三种典型的表述错位
- 口语化:用户用大白话,文档用术语。比如"卡死了"对应文档里的"请求超时与降级处理"。
- 省略指代:多轮对话里"它"“这个方法”"上面那个"没有主语,单句无法检索。
- 用词不同:同一个概念,用户和文档各说各的词,比如"大模型乱说话"和"模型幻觉"。
这三类错位都不该靠改分块来解决,因为无论怎么切,文档里的词还是那个词,和用户问法的距离不会变。
一个低成本的量化起点
取最近一段时间的真实 query(或抽样标注一小批),对每条手动标出"理想答案在哪些文档里"。这就是后面第五章算命中率的基准集。在没做改写前先跑一遍基线召回,后面所有改写策略都和这条基线对比,才有判断依据。
# 基线召回统计示意:query 与已标注的相关文档集合求交集defhit_rate(baseline:dict,retrieved:dict)->float:hits=0forq,goldinbaseline.items():hits+=1ifgold&retrieved[q]else0returnhits/len(baseline)注:以上为逻辑示意,gold 为人工标注的相关文档集合,retrieved[q] 为本次检索返回文档标识。
二、多查询展开(multi-query)
思路:一题拆多角
多查询展开让模型把一个问题改写成多个不同表述,分别检索后合并结果。比如"怎么防止模型胡说"可以展开成"生成式模型事实性控制方法"“如何降低大模型幻觉”"引用外部知识约束输出"三个角度。不同角度覆盖了不同措辞,漏掉的概率就小了。
它解决的是"用词不同"和"口语化"两类错位:原 query 只命中一种说法,而文档可能用了另外的说法。
实现:让模型生成多个改写 query
用一次轻量调用产出 N 个改写,再分别走检索。注意让模型只输出改写、不要额外解释。
fromtypingimportList PROMPT="把下面这个问题改写成 {n} 个不同表述,覆盖不同用词和角度,每行一个,不要编号:\n{q}"defmulti_query(q:str,n:int=3,client=None,model:str="your-model")->List[str]:resp=client.chat.completions.create(model=model,messages=[{"role":"user","content":PROMPT.format(n=n,q=q)}],)return[line.strip(" -")forlineinresp.choices[0].message.content.splitlines()ifline.strip()]注:接口字段以你所用服务商的官方文档为准,示例仅展示调用结构。
适用与注意
多查询对"用词不同""口语化"收益明显。落地时要做三件事:合并去重、对每路返回做轻量重排避免某一角度刷屏、控制展开数量。一般 3 到 5 个即可,过多会拉高延迟且边际收益递减。如果某一路改写明显偏离原意,应直接丢弃该路结果。
三、HyDE 假设性文档嵌入
思路:先造一个答案再去查
HyDE 的做法是:先让模型针对 query 生成一段"假设答案",再用这段答案的向量去检索。理由是——答案的用词往往比问题更接近文档。问题"怎么降幻觉"很短,但模型生成的假设答案里会自然出现"事实性约束"“引用”"检索增强"等文档常用词,向量更贴。
它适合"问题短、答案结构相对固定"的场景,用假设文档把稀疏的 query 向量拉向文档密集区。
实现
生成假设文档,取嵌入,再检索。可以把 HyDE 向量与原始 query 向量做加权融合,而不是只信假设文档。
defhyde_retrieve(q:str,client=None,embed=None,model:str="your-model",top_k:int=5):hypo=client.chat.completions.create(model=model,messages=[{"role":"user","content":f"针对这个问题写一段可能出现在技术文档里的回答:\n{q}"}],).choices[0].message.content vec=embed(hypo)returnvector_search(vec,top_k=top_k)注:vector_search 为你检索后端的占位函数,按实际接口替换。
局限
HyDE 在领域术语密集、答案结构固定的场景更稳;在需要严格事实、不能容忍模型"编"答案的领域要谨慎,因为假设答案可能把检索带偏。一个稳妥的折中是:用原始 query 向量和 HyDE 向量做加权融合,权重通过第五章的评测来确定,而不是拍脑袋给固定值。
四、指代消解与上下文补全
问题来源:多轮对话里的"它"
用户第二轮问"它支持批量吗",单看这句没法检索——"它"指第一轮提到的某个框架。把上下文里被省略的主语补回去,才能变成可检索的完整 query。这类错位在带对话历史的助手类应用里尤其常见。
实现:补全成完整 query
把历史轮次和当前句一起交给模型,输出一条自包含的 query。关键是"只补主语,不改意图"。
defrewrite_with_context(history:list,current:str,client=None,model:str="your-model")->str:ctx="\n".join(f"{m['role']}:{m['content']}"forminhistory)prompt=("以下是多轮对话,请把最后一句补全为不依赖上下文也能检索的完整问题,""只补全被省略的指代,不要改动原意:\n"f"{ctx}\n最后一句:{current}\n补全后:")returnclient.chat.completions.create(model=model,messages=[{"role":"user","content":prompt}]).choices[0].message.content.strip()注意
补全要"只补主语,不改意图"。如果模型擅自把问题改成了另一个方向,召回就会跑偏。建议对补全结果做简短校验,必要时回退到原始 query。一个实用的兜底:当补全结果与原 query 的重叠词过少时,直接放弃补全、用原句检索。
五、改写前后对比怎么测(命中率)
标注 query 集
用第一章攒下的基准集:每条 query 对应一组人工标注的相关文档。这个集子宁可少而准,不要为了数量堆一批标注质量差的样本——噪声标注会让你分不清是改写没用还是标注错了。
计算命中率
对每个 query,取改写后合并检索返回的前 K 篇,看是否命中标注文档。改写前后各算一次 recall@K,差值就是改写带来的提升。
defrecall_at_k(baseline:dict,pred:dict,k:int)->float:hits=0forq,goldinbaseline.items():topk=pred[q][:k]hits+=1ifgold&set(topk)else0returnhits/len(baseline)看哪个指标
别只看整体均值。按 query 类型拆开看:口语化、含指代、用词不同的三类,分别算提升。如果只有某一类明显受益,就只对那类开改写,其余走原 query。这样既能拿到收益,又避免给已经问得很清楚的用户引入噪声。
这份资料是什么:这一章讲怎么用标注集量化改写前后的命中率,评测模板已整理好。放在资料包里,扫码即可获取:
六、何时不用改写
query 已经很规范
如果你们的用户多是内部工程师,query 本身就带术语、表述清晰,改写几乎不带来收益,反而可能把清晰的问法搅乱。此时继续在 query 侧投入,性价比很低。
改写引入噪声
改写会放大错误:模型一旦把 query 理解偏,多个改写会一起跑偏,召回反而下降。当基线召回已经很高(比如接近满命中),再加改写往往是负优化,因为它在已经正确的检索上叠加了不确定性。
判据
一句话:改写前后召回@K 没有显著提升,就关掉它。判据来自实测差值,不是拍脑袋。给改写策略都配上"只在提升超过阈值时启用"的开关,是控制噪声最直接的方式。
七、落地清单
先量 gap 再决策
上线前先跑基线:真实 query 集加标注相关文档,算原始召回。gap 在 query 侧才上改写;gap 在分块或切分才去动文档侧。先量再改,避免盲目优化。
三策略怎么选
- 用词不同、口语化:多查询展开优先。
- 问题短、答案结构固定:试 HyDE,并把假设向量与原始向量做加权融合。
- 多轮对话、含指代:上下文补全,且只补主语不改意图。
上线阈值
给改写设开关和灰度:只对"改写后召回提升超过阈值"的 query 类型启用,其余回退原 query。保留可关闭的总开关,避免某次模型更新后改写整体劣化却关不掉。结论回到开头那句话——先量 query 与文档的表述 gap,再决定改分块还是改 query。
这份资料是什么:第七张落地清单把三种策略的启用条件和灰度开关都列好了,配套清单已整理。放在资料包里,扫码即可获取:
附表 A:本文引用事实与出处对照表
| 本文结论 | 依据 / 出处 | 本文位置 |
|---|---|---|
| RAG 召回不佳常源于 query 与文档表述不一致,而非分块策略 | 待验证(行业实践共识,无一手出处) | 第一章 |
| 多查询展开通过多角度改写提升召回覆盖 | 待验证(相关查询改写研究,具体论文与年份未核验) | 第二章 |
| HyDE 用假设答案的向量去检索,使 query 向量更贴近文档 | Gao 等《Precise Zero-Shot Dense Retrieval without Relevance Labels》(发表年份与会议待验证) | 第三章 |
| 多轮对话中的指代省略会降低可检索性,需上下文补全 | 待验证 | 第四章 |
| 用标注 query 集计算 recall@K 可量化改写前后收益 | 待验证(通用检索评测方法) | 第五章 |
| query 已规范时改写可能引入噪声、降低精度 | 待验证(基于本文实验判据的推论) | 第六、七章 |
附表 B:术语速查表
| 术语 | 含义 |
|---|---|
| RAG | 检索增强生成,先检索相关文档再让模型生成答案 |
| 查询改写 | 把用户原始 query 改写成更易被检索命中的形式 |
| multi-query | 多查询展开,把一题拆成多个角度分别检索再合并 |
| HyDE | 假设性文档嵌入,先生成假设答案再用其向量去检索 |
| 指代消解 | 把"它""这个方法"等省略指代补全为完整表述 |
| 召回率 / recall@K | 前 K 篇检索结果中命中相关文档的比例 |
| 基线召回 | 未做改写时的原始召回水平,作为对比基准 |
写在最后:这篇用到的资料
写这篇文章时,我把 RAG 查询改写的三种策略伪代码、召回对比评测模板都整理了一遍,顺手也整理了几份配套的东西:
- 查询改写策略集:multi-query、HyDE、指代补全的可改代码。
- 召回对比评测模板:改写前后命中率怎么量化。
- AI 大模型学习路线:从应用落地到工程化的系统路径。
资料是我自己整理的,放在下面这个码上,扫码即可获取:
资料较多,建议先看「全套 AGI 大模型学习路线」,再挑一个实战项目跟练。