主题
11.6 用户问得太含糊怎么办?—— 查询重写与意图路由
痛点场景:真实用户不会像搜索引擎一样精确提问。他们张口就是“它怎么又崩了?”“昨天那个东西能退吗?”“为什么老掉线”。如果拿这些口语、带指代、意图模糊的话直接去检索,向量库只会回你一堆废纸。优秀的 RAG 系统要像经验老道的客服一样,先把用户的话“翻译”成知识库听得懂的语言。
痛点:用户的原话 vs 检索器能听懂的话
| 用户原话 | 缺陷 | 重写后的专业 Query |
|---|---|---|
| “它怎么又报 502 了?” | 主语指代不明(“它”是谁) | 用户中心认证网关微服务 502 排查手册 |
| “昨天买的那个能退吗?” | 太宽泛,无关键字段 | 电商订单退货与退款时效政策 |
| “老掉线,烦死了” | 口语 + 情绪词 | 无线网络频繁断连与 DNS 超时故障排查 |
核心矛盾:知识库存的是“陈述句”(“在 macOS 中配置 PATH 需要修改 ~/.zshrc”),用户问的是“疑问句/口语”(“Mac 怎么配环境变量”)。二者在向量空间的距离往往不够近,需要**查询重写(Query Rewriting)**这座桥。
思路:四大重写技术 + 意图路由
1. HyDE(假设性文档嵌入)—— 以答搜答
既然疑问句和陈述句距离远,那就让 LLM 先凭空写一篇“像模像样的假答案”(哪怕有幻觉,但用词和文风贴近真实文档),再拿这篇假答案的向量去检索 —— 真实文档一下就被捞出来了。
2. Multi-Query(多角度查询扩展)—— 三人成行
让 LLM 从不同侧重点生成 3 个同义但不同用词的查询,分别检索后取并集去重。就像去图书馆查资料,一个搜“汽车故障”,一个搜“发动机异响”,一个搜“底盘抖动”,覆盖面更广。
3. Step-Back(抽象回退)—— 谷歌提出
面对过于具体的细节问题,先退一步生成关于“底层原理”的宏观问题一起检索,避免一叶障目。
4. 意图路由(Query Routing)—— 分诊台
企业有多个知识库(HR 库、技术库、财务库),路由裁判根据问题类型把请求分到正确的库,甚至“闲聊直接答、不用检索”,省钱省延迟。
代码实现一:HyDE 完整检索闭环(不是只生成假答案,而是真拿去检索)
python
from langchain_chroma import Chroma
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 知识库:全是“陈述句风格”的技术文档
docs = [
Document(page_content="在 macOS 中配置全局 PATH 环境变量,需要编辑 ~/.zshrc 文件并执行 source 使其生效。"),
Document(page_content="Windows 系统通过系统属性-环境变量面板添加 PATH 条目,无需重启。"),
Document(page_content="若 PATH 配置错误,命令行会提示 command not found,可用 echo $PATH 检查。"),
]
vectorstore = Chroma.from_documents(docs, embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
# 1. 生成假想文档(HyDE)
hyde_prompt = ChatPromptTemplate.from_template(
"请针对问题写一段可能回答它的技术文档(即使细节不完全准确也行,重点是术语与陈述句风格):\n问题:{question}\n假想文档:"
)
hyde_chain = hyde_prompt | llm | StrOutputParser()
question = "Mac 电脑上怎么配置环境变量?"
hypo_doc = hyde_chain.invoke({"question": question})
print("=== HyDE 生成的假想文档 ===")
print(hypo_doc[:120] + "...")
# 2. 用“假答案的向量”去检索真实知识库(关键一步!)
hits = retriever.invoke(hypo_doc)
print("\n=== 用假答案检索命中的真实文档 ===")
for d in hits:
print("-", d.page_content)代码实现二:Multi-Query 多路并发 + 去重合并
python
import asyncio
from typing import List
from langchain_chroma import Chroma
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
[
Document(page_content="汽车发动机出现异响通常与火花塞老化或积碳有关。"),
Document(page_content="底盘抖动可能是轮胎动平衡失效或悬挂胶套磨损导致。"),
Document(page_content="新能源汽车电池在低温下续航会明显衰减。"),
],
embeddings,
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
expand_prompt = ChatPromptTemplate.from_template(
"请从 3 个不同角度把下面的问题改写成更专业、更可检索的查询,每行一个:\n{question}\n"
)
async def multi_query_search(question: str) -> List[Document]:
# 1. LLM 一次生成 3 个扩展查询
queries = (expand_prompt | llm | StrOutputParser()).invoke({"question": question}).strip().splitlines()[:3]
print("扩展查询:", queries)
# 2. 多路并发检索(真正的并行执行)
results = await asyncio.gather(*[asyncio.to_thread(retriever.invoke, q) for q in queries])
# 3. 按内容去重合并(保留元数据)
seen, merged = set(), []
for batch in results:
for d in batch:
if d.page_content not in seen:
seen.add(d.page_content)
merged.append(d)
return merged
merged = asyncio.run(multi_query_search("我的车最近总在响,还抖,是怎么回事?"))
print(f"\n=== 去重后共 {len(merged)} 篇 ===")
for d in merged:
print("-", d.page_content)代码实现三:结构化意图路由(Pydantic 强约束)
python
from typing import Literal
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
class RouteQuery(BaseModel):
"""将用户查询路由到正确的知识库,必要时标注“无需检索”。"""
destination: Literal["hr_kb", "tech_kb", "finance_kb", "chitchat"] = Field(
description="hr_kb=人事, tech_kb=技术, finance_kb=财务, chitchat=闲聊直接答"
)
rewrite: str = Field(description="规范化后的检索关键词")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
router = llm.with_structured_output(RouteQuery)
test = "去上海出差住宿报销发票抬头怎么开?"
result = router.invoke(test)
print(f"问题: {test}")
print(f"路由目标: {result.destination}") # 应输出 finance_kb
print(f"规范化查询: {result.rewrite}")
# 生产用法:根据 destination 分派到不同向量库/链路
if result.destination == "finance_kb":
print("→ 进入财务知识库检索链路")
elif result.destination == "chitchat":
print("→ 直接闲聊回答,省去检索成本")拓展:什么时候该用哪个?
| 场景 | 推荐技术 | 说明 |
|---|---|---|
| 疑问句 vs 陈述句距离远 | HyDE | 低成本、见效快 |
| 问题多角度、想提高召回 | Multi-Query | 并发快,注意去重 |
| 涉及底层原理/规律 | Step-Back | 配合细节问题一起检索 |
| 多知识库并存 | 意图路由 | 分流 + 省成本 + 权限隔离 |
⚠️ 成本提醒:每次重写/扩展都会增加 LLM 调用次数与延迟。不要不加区分地全开,先用离线评测(11.9)确认“哪一项带来了真实收益”。
查询改写也会把问题“翻译错”
改写模型像代写秘书:能把口语整理得漂亮,也可能擅自改掉关键条件。最危险的四类信息是否定词、时间、数字编号和主体。
| 原问题 | 糟糕改写 | 丢掉了什么 |
|---|---|---|
| “哪些费用不能报?” | “可报销费用” | 否定方向完全反了 |
| “2026 年 7 月后上海标准” | “上海差旅标准” | 生效时间丢了,可能命中旧版 |
| “RX-9000 的 ERR-404-X9” | “设备故障处理” | 精确型号和错误码丢了 |
| “它和支付系统谁依赖谁?” | “系统依赖关系” | 对话中的两个实体没补全 |
三条保险:
- 原问题永远作为一路检索保留,改写只能增加候选,不能覆盖原意;
- 改写结果做约束检查,确认编号、日期、否定词和已解析实体没有消失;
- 记录原问题、改写结果与每路命中文档,Badcase 才能追到是哪次“翻译”带偏。
多跳问题先拆任务,别只扩同义词
“比较 2025 与 2026 年上海住宿标准,并说明新版何时生效”至少包含三个子问题:旧标准、新标准、生效日期。一次向量查询可能只命中其中一个。
合理流程是:
- 把问题拆成可独立检索的子问题;
- 每个子问题保留实体、时间与约束;
- 分别检索并给证据标记所属子问题;
- 检查证据覆盖度,不完整就继续检索或拒答;
- 最终生成时明确对比,不能把两个版本揉成一个数字。
这与 Multi-Query 不同:Multi-Query 是“同一道题换几种说法”,分解是“把一道综合题拆成几道小题”。
对话式 RAG 要先变成独立问题
用户经常追问:“那北京呢?”系统不能拿这四个字直接检索。它需要从对话历史恢复为“在 2026 年现行差旅制度中,北京住宿费上限是多少?”。但不能把整段聊天原样塞进查询,否则旧话题会污染检索。
建议维护两份内容:一份是给用户看的完整对话,一份是结构化会话状态,例如当前主题、已确认实体、时间范围和权限主体。每次追问只使用完成指代消解所需的最小状态,并把恢复后的独立问题展示在 Trace 中。
路由要同时考虑主题、权限和不确定性
主题分类正确不代表允许访问。完整路由顺序应是:身份验证 → 可访问知识库集合 → 主题路由 → 低置信度多库召回 → 统一重排。分类器不能把用户路由到其无权访问的库,更不能把“未授权”伪装成“知识库没有答案”。配套脚本新增了“保留原问题”和“多跳检测”护栏。
保留原问题,检查改写是否走偏
改写后的查询应与原问题一起进入运行记录。若用户问“今年还能报销吗”,系统补成“2026 年差旅报销政策”之前,需要从会话或系统时间确认年份;缺少指代信息时,应向用户追问,不应由模型自行补全。
多查询扩展会增加召回和费用,生成的多个问题还可能互相重复。可以先去重,再限制并发数。路由置信度不足时进入通用检索或澄清流程,比强行选择一个知识库更稳妥。
前沿一笔带过:让模型自己学会“要不要检索、检索什么”
快速定位:这一节不要求你现在改架构,只是让你看看“手写路由”这条路的尽头长什么样——知道尽头在哪,反而更放心地把本节的手写方案当基线。
Search-R1 类工作:把“检索”当成强化学习里的一个动作
本节讲的意图路由是人写规则:“命中财务关键词 → 走财务库”。Search-R1 换了个思路:给模型一个“发起检索”的动作接口,让它在训练中用强化学习自己摸索——什么时候该查、查什么关键词、一段话里查几次,全由模型从反馈里学会。
| 维度 | 手写路由 + 结构化输出(本节方案) | Search-R1 类 |
|---|---|---|
| 规则来源 | 人工定义,可读可改可评审 | 模型在训练中习得,藏在权重里 |
| 冷启动成本 | 低,当天能上线 | 高,需要训练环境与算力 |
| 可解释性 | 高(Trace 里能看到路由决策) | 低 |
| 适合场景 | 绝大多数企业知识库 | 多跳问答、检索策略本身很难写死的任务 |
工程结论:现阶段仍推荐**“手写路由 + 结构化输出”作为基线**——它便宜、可解释、可评审,还能顺手产出高质量的路由轨迹,为将来可能的模型训练攒数据。
生成式检索(Generative Retrieval / DSI):把文档 ID “背”下来
另一条路线更激进:干脆不建向量库,让模型把“文档 ID”当成词表的一部分背下来——给它一个问题,它像写下一个词那样直接写出目标文档的 ID,索引与检索合一,整个链路被压成一次生成。
吸引力很明确(省掉向量库、嵌入模型和一整条检索链路),但代价也很明确:
- 语料规模一大,ID 空间就爆炸,模型“背不动”;
- 对增量更新极不友好:新加一篇文档往往要重新训练,而向量库只是插一条记录;
- 因此工业界至今少见大规模落地。
一句话结论
Search-R1 与生成式检索都属于“未来态”。先把本节的 HyDE / Multi-Query / 结构化路由跑通、把评测集建起来更划算——没有评测集,再先进的训练方法也不知道自己有没有变好(见 11.9)。