Skip to content

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”“设备故障处理”精确型号和错误码丢了
“它和支付系统谁依赖谁?”“系统依赖关系”对话中的两个实体没补全

三条保险:

  1. 原问题永远作为一路检索保留,改写只能增加候选,不能覆盖原意;
  2. 改写结果做约束检查,确认编号、日期、否定词和已解析实体没有消失;
  3. 记录原问题、改写结果与每路命中文档,Badcase 才能追到是哪次“翻译”带偏。

多跳问题先拆任务,别只扩同义词 ​

“比较 2025 与 2026 年上海住宿标准,并说明新版何时生效”至少包含三个子问题:旧标准、新标准、生效日期。一次向量查询可能只命中其中一个。

合理流程是:

  1. 把问题拆成可独立检索的子问题;
  2. 每个子问题保留实体、时间与约束;
  3. 分别检索并给证据标记所属子问题;
  4. 检查证据覆盖度,不完整就继续检索或拒答;
  5. 最终生成时明确对比,不能把两个版本揉成一个数字。

这与 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)。


权威官方参考 ​