RAG66 21 分钟

RAG 查询改写不是把问题说长:别替用户补条件

Query rewrite、问题拆解和假设文档,都是把用户的话翻成知识库听得懂的说法,不是让模型另起一个问题。每次改写都要能回到原问,必要时还能撤回。

原理 实现 边界 追问

本篇阅读顺序 · 三遍读法

一篇文章,读出三种能力。

先弄懂它为什么这样工作,再把条件换一换,看方案还能不能站住,最后用代码和证据复盘一遍。顺序固定,读完才知道自己是真的会了,还是只记住了名词。

01 / 基础知识先回答“它为什么这样工作”

沿着直觉、公式和边界读正文,看到变量就问输入、状态、复杂度分别是什么。

02 / 高频追问再回答“条件变了怎么办”

把面试官的追问当作小型设计评审:更大流量、更少上下文、外部失败或安全约束出现时,局部如何重算。

03 / 从零实现把召回、证据与版本过滤做成可复跑实验

沿代码、表格和证据卡复盘,最后用文末的 60 秒回答确认自己没有只记住名词。

问题面试官到底在判断什么
机制系统如何工作
证据代码、指标与取舍
表达30 秒回答骨架

用户问“这个政策今年还能报吗”,知识库里却写着“2026 年差旅费用报销条件”。原句直接检索,可能找不全;让模型随意改写,又可能凭空加上员工级别、城市和金额。好的 query rewrite 是受约束的翻译,不是替用户脑补。

先给一个能复述的答案

先判断问题是否需要改写,再生成一个或多个候选查询;每个候选都要保留原问题、改写理由、添加或删除的词,以及适用的过滤条件。检索后比较原问和改写的证据覆盖,如果改写没有带来增益,回退到原问。多跳问题才做拆解,并为每个子问题设定合并条件,避免“拆得越多越像搜索噪声”。

查询改写的受约束闭环

先判断四种查询状态

状态例子策略
原问足够具体“研发中心 2026Q2 住宿上限”原问 + 精确过滤
口语化但单跳“今年还能报吗”补充时间词,不补业务事实
多实体比较“A 和 B 的接口差异”拆成实体—属性子查询
目标不清“帮我看看这个”先澄清,不做高风险猜测

改写前先分类,能减少一个常见错误:把所有问题都改成一段很长的自然语言,结果关键信息被淹没,过滤字段也失去控制。

改写的最小约束

每个候选 query 都应该带有 lineage:

{
  "original": "这个政策今年还能报吗",
  "rewritten": "2026 年差旅费用报销条件和适用范围",
  "added_terms": ["2026", "差旅费用", "适用范围"],
  "assumptions": ["用户指的是当前租户的差旅政策"],
  "filters": {"tenant": "current", "effective_at": "2026-08-22"},
  "reversible": true
}

assumptions 不是装饰。它让回答阶段知道哪些词来自模型推断,哪些词来自用户原话;一旦假设不成立,系统可以要求澄清,而不是继续扩大检索。

用证据覆盖率判断改写是否值得

可以用一个粗粒度的增益函数比较候选:

gain(q)=coverage(q)coverage(q)αassumption_risk(q)gain(q')=coverage(q')-coverage(q)-\alpha\,assumption\_risk(q')

如果改写后的证据覆盖只提升一点,却引入了大量未经确认的假设,gain 可能为负。实际评测可以用标注的相关文档、引用完整性和最终答案正确率近似这些项。

原问、候选改写与证据覆盖的比较

多跳拆解要有合并条件

“哪个方案更适合我们”可能需要先查方案 A 的限制,再查方案 B 的限制,最后按同一指标比较。拆解时要写出 join key:

def decompose(question: str) -> list[dict]:
    return [
        {"id": "a", "query": "方案 A 的数据保留、延迟和成本", "join": "metrics"},
        {"id": "b", "query": "方案 B 的数据保留、延迟和成本", "join": "metrics"},
    ]

def merge(sub_answers: list[dict]) -> dict:
    if any(not x.get("evidence") for x in sub_answers):
        return {"status": "needs_clarification", "reason": "子问题证据不完整"}
    return {"status": "ready", "rows": align_by_key(sub_answers, key="join")}

没有合并条件的拆解,最后通常只得到几段互不相干的摘要,模型再用语言把缺口抹平。

一个真实的改写案例:先找出缺失字段

假设客服问:“华东仓的冷链赔付怎么算?”原问里至少缺少四个字段:赔付对象、时间版本、损坏类型和仓库范围。系统如果直接生成“2026 年华东仓冷链订单延误赔付标准”,就已经替用户补上了“2026 年”和“延误”两个未经确认的事实。更稳的流程是先把问题拆成已知、待确认、可检索三栏:

字段当前值是否允许自动补全
地域华东仓可以,来自原问
业务冷链赔付可以,来自原问
时间缺失只能使用当前有效版本,不能写死年份
事件缺失必须澄清延误、破损或温控异常

因此第一轮只生成两个低风险候选:华东仓 冷链 赔付 现行规则华东仓 冷链 赔付 需要补充事件类型。如果第一候选能命中制度目录,回答中仍要提示“请确认事件类型”;如果命中多个版本,则进入澄清,而不是让模型从标题猜结论。

候选不是越多越好:给每个候选一张评分卡

线上可以把候选控制在 1~3 个,并把评分拆成可解释的项:

score(q)=0.35intent_fit+0.30term_coverage+0.20evidence_gain0.15assumption_riskscore(q')=0.35\,intent\_fit+0.30\,term\_coverage+0.20\,evidence\_gain-0.15\,assumption\_risk

其中 intent_fit 判断目标是否仍然一致,term_coverage 判断知识库术语是否覆盖,evidence_gain 来自离线标注集,assumption_risk 则对新增的时间、金额、主体和权限字段加惩罚。分数不是让模型自报置信度,而是由检索结果和规则计算出来。

def choose_candidate(original: str, candidates: list[dict], evidence: dict) -> dict:
    ranked = []
    for item in candidates[:3]:
        key = item["query"]
        gain = evidence.get(key, {}).get("coverage", 0.0)
        risk = len(item.get("assumptions", [])) / 4
        score = (0.35 * item.get("intent_fit", 0)
                 + 0.30 * item.get("term_coverage", 0)
                 + 0.20 * gain - 0.15 * risk)
        ranked.append({**item, "score": round(score, 3)})
    best = max(ranked, key=lambda x: x["score"], default=None)
    if not best or best["score"] < 0.45:
        return {"status": "fallback_original", "query": original, "candidates": ranked}
    return {"status": "selected", "query": best["query"], "candidates": ranked}

这段逻辑的关键不在权重是否刚好是 0.35,而在于候选、证据和回退结果都落到 trace 里。调参时可以替换权重,不能把“为什么选它”重新藏回不可观测的 Prompt。

失败分类:改写错了,还是检索错了

上线后不要把所有低质量回答都归因于 embedding。可以按错误发生的位置切片:

失败类型典型表现修复方向
意图漂移原问问退款,候选变成退货流程增加目标一致性判定和拒绝样本
事实臆补自动加入年份、金额、用户等级字段白名单;高风险字段只允许澄清
术语替换失败“回滚”被改成“撤销订单”维护同义词和领域术语表
过度拆解一个单跳问题变成五次检索设定拆解收益阈值和最大子问题数
证据增益为零查询变长但命中仍相同回退原问,检查切片和 embedding
版本污染新旧政策同时进入上下文先做时间/版本过滤,再做改写

每周抽样一批“改写后反而变差”的请求,保留原问、候选、命中文档和最终引用,才能知道究竟是分类器、改写器还是检索器在丢分。

评测矩阵:离线看增益,线上看代价

一套可复用的评测集至少包含口语化单跳、多实体比较、带精确 token 的查询、时间冲突和无答案问题。每条样本同时记录原问基线和改写版本:

query_rewrite_eval:
  - id: policy-014
    type: colloquial_single_hop
    question: "今年还能报吗"
    must_keep: [policy_subject, tenant]
    forbidden_additions: [employee_level, amount]
    target: [recall_at_5, citation_coverage]
  - id: error-009
    type: exact_token
    question: "ERR-429 在 v2.3 怎么处理"
    rewrite: "disabled"
    target: [exact_match, p95]
  - id: noanswer-003
    type: no_answer
    question: "下季度一定会涨价吗"
    target: [abstention_rate, hallucination_rate]

线上则增加三项成本:改写额外 token、额外一次检索的尾延迟、以及澄清率。只有“答案正确率上升”同时伴随“无依据回答下降或持平”,这次改写才值得保留。

交接记录应该长什么样

每次改写最后输出一张小型记录卡,便于面试讲项目,也便于线上追责:

{
  "rewrite_id": "rw_20260822_018",
  "original": "华东仓的冷链赔付怎么算",
  "selected": "华东仓 冷链 赔付 现行规则",
  "added_terms": ["现行规则"],
  "blocked_assumptions": ["事件类型"],
  "evidence_gain": 0.18,
  "decision": "ask_clarification",
  "fallback": "original_query",
  "trace_id": "tr_8c2"
}

当用户补充“温控异常”后,可以沿用同一个 trace_id 生成新版本,而不是覆盖上一轮。这样既能解释为什么系统没有直接给结论,也能比较澄清前后的检索增益。

改写器和检索器要分开验收

如果改写之后答案变差,不能只看最终文本。建议把一次请求拆成三组对照:原问直接检索、改写后检索、原问与改写并行检索。每组都记录同一个查询集、过滤条件和 top_k:

对照能回答什么常见误判
原问当前系统基线把低召回归因于模型
改写术语扩展是否带来增益把臆补造成的命中当进步
并行是否应该保留原问兜底候选过多导致尾延迟

并行结果可以用“证据并集但答案不重复”的方式合并:先按来源去重,再让生成器看到候选 query 的 lineage。不要把两组检索结果直接拼接,否则改写噪声会放大上下文长度。

领域术语表要有主人和版本

术语表不是一份静态同义词文件。它应记录来源、适用租户、优先级和撤回时间:

terms:
  - canonical: "温控异常"
    aliases: ["冷链温度超标", "温度不达标"]
    tenant: acme
    owner: logistics
    effective_at: 2026-06-01
    review: approved
  - canonical: "回滚"
    aliases: ["撤回版本"]
    tenant: platform
    owner: release
    review: pending

改写器只应使用与当前租户和版本匹配的条目;术语表冲突时进入澄清或保留原词。把同义词全局共享,可能会让不同团队的“订单”“工单”发生误替换。

高风险问题的改写策略

法律、财务、权限和安全问题可以采用更保守的三步:

  1. 原问保留精确 token,不做自由扩写;
  2. 只补充可从系统状态确定的时间、租户和版本;
  3. 命中多个有效版本时先展示冲突并问一个最小澄清问题。
{
  "original": "管理员能不能导出员工工资",
  "safe_rewrite": "管理员 工资导出 权限策略 当前租户",
  "forbidden_assumptions": ["管理员角色已获批", "员工已同意"],
  "next": "retrieve_policy_then_ask_if_scope_missing"
}

改写并不等于授权。即使候选 query 召回了正确政策,执行导出仍要走独立的权限和审批边界。

复盘一条错误改写

每次改写回归可以用五个问题快速定位:原问中哪个词被丢了?新增词来自哪里?候选命中了哪份文档?答案引用是否覆盖新增假设?如果换回原问,是否能得到更安全的结果?把这五问写入标注表,能积累比“这个答案不太好”更可训练的失败样本。

rewrite_review:
  missing_terms: ["当前租户"]
  unsupported_additions: ["2026 年"]
  hit_docs: ["policy-v3#p2", "policy-v4#p2"]
  citation_gap: true
  action: "fallback_original_and_clarify_version"

什么时候不该改写

  • 用户给出订单号、错误码、版本号等精确 token;
  • 问题包含法律、财务、权限等高风险限定词;
  • 原问已经有唯一实体和时间范围;
  • 改写模型的置信度低,或无法解释添加了什么;
  • 检索服务正在超时,额外一轮改写只会扩大尾延迟。

这时宁愿返回“需要补充哪个字段”,也不要把不确定性藏在漂亮的查询句子里。

改写输出要可解释、可撤销

改写器应同时返回原问、候选 query、增加和删除的词、触发的术语表版本以及回退理由。检索器只接收通过 schema 校验的候选,不直接执行一段自由文本提示词生成的 query。这样当召回变差时,标注员可以一眼看到是“补了错误年份”,还是“把产品名当成了公司名”。

{
  "original": "上海报销上限是多少",
  "rewritten": "上海 普通员工 差旅住宿 报销上限",
  "added": ["普通员工", "差旅住宿"],
  "removed": [],
  "glossary_version": "finance-2026-08",
  "fallback": "original_if_coverage_below_0.72"
}

在高风险领域,改写只负责提出候选;最终的实体、时间和权限仍由检索与业务层再次确认。

高频追问

L1:query rewrite 解决什么问题?

它把口语或不完整表达映射到知识库的术语,提高召回,但不能改变用户目标。

L2:为什么要保留原问?

原问是语义锚点和审计依据。只有保留它,才能检测改写是否添加了未经确认的事实。

L2:一个候选还是多个候选?

问题歧义较大时可以生成少量候选,用相同证据和预算比较;候选越多,延迟和噪声也会增加。

L3:多跳 RAG 和 query decomposition 有什么关系?

拆解是把复杂目标变成子问题,多跳检索还需要沿实体、关系或时间把子结果组合起来;不是把问题简单切成几句。

L5:如何防止改写幻觉?

记录新增词和假设,对高风险字段使用白名单,改写后做证据覆盖检查;无增益就回退原问。

60 秒面试回答

我把 query rewrite 当作受约束的查询翻译。先分类原问是否需要改写,再生成少量候选,并记录原问、改写理由、新增词、假设和过滤条件。检索后用证据覆盖和假设风险评估增益,没有增益就回退。多跳问题会给子查询设置 join key 和合并条件,精确 token、高风险限定和目标不清的场景则优先保留原问或澄清,而不是让模型猜。

自检清单

  • 能说出何时原问已经足够,不需要改写
  • 改写结果保留原问、假设和新增词
  • 多跳拆解有 join key 与失败路径
  • 有改写增益和回退条件

相关阅读

资料来源