2872 字
约 9 分钟
13
S20-J07 · 查询改写与路由:把用户问题变成能检索的查询

用户问的是"这玩意儿能退吗",索引里存的是《售后政策 v3 · 第 4 条:七日无理由退货的适用情形》。两句话语义几乎不重叠,向量的余弦相似度也救不了。能不能检索到,往往在检索之前就决定了:查询怎么改写、走哪个库、要不要检索。


1. 原始问题检索不到的四类病因

病因 例子 可观测信号
口语化/简称 "退个货要几天" vs "退款到账时效" 高频词与索引词表几乎不交集
多跳 "去年买的那台机器保修多久" 需先定位订单,再查政策
指代 "它支持吗""那个功能怎么开" 查询里出现无先行词的代词
隐含约束 "我们组能用吗" 缺实体(组/租户/时间)与权限维度

判断依据不是"改写看起来更好",而是改写前后的 Recall@k 对照。没有基线的改写只是风格练习。


2. 五种改写手段与各自代价

手段 机制 额外成本 风险
同义扩展 加同义词/术语表映射 一次规则或小模型调用 扩错方向,召回噪声
HyDE 先生成假设答案,用答案检索 一次完整生成 假设答案本身是幻觉,检索跟着跑偏
Multi-Query 生成 N 个改写,并行召回 N 次检索 + 1 次生成 延迟与 token 线性上涨
Step-back 抽象为上位问题再检索 一次生成 + 一次检索 抽象过头,丢失具体约束
子问题分解 拆成可独立检索的问句 一次生成 + M 次检索 拆分错误导致子问题都检索不到

代价可以这么记账:改写是一次生成,多路召回是 N 次检索。前者便宜、后者贵。所以默认只做一次改写;只有在 Recall@k 明显偏低时才升到多路。

REWRITE_PROMPT = """把用户问题改写为 3 条检索友好的查询,覆盖不同措辞。
要求:保留所有实体与数值约束;不要回答问题;不要引入原文未出现的产品名。
用户问题:{q}
只输出 JSON 数组,元素为字符串。"""

HyDE 的适用边界很窄:问题模糊但领域明确时它有效("怎么提升这个指标"→ 先编一段通用做法,再去检索近似文档);问题本身精确(含型号、错误码)时用它反而稀释关键词。


3. 多路召回后的融合与去重

顺序融合(RRF)不需要两条路径的分数可比,这是它比加权求和更常用的原因:BM25 的分数与余弦相似度量纲完全不同,归一化本身就是猜。

def rrf(rank_lists, k=60):
    s = {}
    for lst in rank_lists:
        for r, doc in enumerate(lst):
            s[doc] = s.get(doc, 0.0) + 1.0 / (k + r + 1)
    return sorted(s, key=s.get, reverse=True)

merged = rrf([bm25_hits, dense_hits, rewritten_hits], k=60)[:8]

去重要在融合之前按 parent_id 做,否则同一段落被三条查询各命中一次,会以三倍权重霸占头部。这是多路改写最典型的自伤方式:分数涨了,多样性没了。


4. 路由的四个维度与判定方式

维度 决策内容 判定方式 误判代价
要不要检索 闲聊/常识 vs 需外部知识 分类器或小模型自判 该检索没检索 → 编造;不该检索却检索 → 答非所问
走哪个索引 政策库 / 代码库 / 工单库 意图分类 + 实体识别 走错库 → 召回全空,但模型仍会硬答
关键词还是向量 精确编号 vs 语义描述 查询含 ID/错误码则偏 BM25 编号走向量 → 近似码召回
走哪个模型 小模型快答 vs 大模型深答 复杂度/长度/子问题数 复杂度误判 → 质量或成本双双失控

阈值不要拍脑袋:把路由判定与"人工认为该走哪条"做混淆矩阵,看两类错误的业务代价是否对称。多数场景里"该检索却没检索"的代价远高于反向,所以阈值应偏向检索。

def route(q, has_history, tenant):
    if is_chitchat(q) and not has_history:
        return {"retrieve": False, "model": "small"}
    idx = "code" if CODE_RE.search(q) else ("policy" if POLICY_RE.search(q) else "general")
    mode = "bm25" if ID_RE.search(q) else "hybrid"
    return {"retrieve": True, "index": idx, "mode": mode,
            "filters": {"tenant": tenant}, "model": "large" if len(q) > 60 else "small"}

规则路由的好处是可解释、可测试;模型自判的好处是覆盖面。生产上常用规则兜底 + 模型判主:模型给出 index 与 mode,规则负责校正明显情形(含订单号必须进订单库)。


5. 指代消解与历史注入的边界

历史轮数 收益 风险
0(仅当前句) 无污染 代词无法解析,多轮对话直接失效
最近 2–3 轮 覆盖绝大多数指代 话题切换后引入无关上下文
全量历史 长程引用可解 预算爆炸,早期话题污染当前查询

做法是改写时带历史,检索时不带:把历史压成一句"上下文摘要 + 当前问题",用它去检索;而不是把整段对话塞进检索请求。摘要里要显式保留实体,否则"它"被摘要成"该功能"之后依然指代不明。

[上下文] 用户上一轮询问"Pro 版年付的退款规则",实体:Pro、年付。
[当前] 那按月付的呢?
[改写] Pro 版月付的退款规则是什么

截断策略按轮数而非 token:保留最近 3 轮 + 显式话题锚点,比"保留最近 2000 token"更稳定,因为 token 截断会切在句子中间,留下半个指代。


6. 时间与实体约束的抽取

"上个月""上周""我们组""这个客户"都不是检索友好的词,必须变成结构化过滤条件。这类抽取适合用固定 schema 的抽取调用,而不是让改写模型"顺手带上"。

{ "type": "object", "required": ["time_range"],
  "properties": {
    "time_range": { "type": "object",
      "required": ["from", "to"],
      "properties": { "from": {"type": "string"}, "to": {"type": "string"} } },
    "entity": { "type": ["object", "null"],
      "properties": { "kind": {"enum": ["team", "customer", "product"]},
                      "value": {"type": "string"} } } } }

两个坑:相对时间必须在请求时锚定成绝对区间,并记录时区与"今天"的基准,否则缓存命中时会把上周的"上个月"重放出来;"我们组"这类词依赖会话身份,抽取不到就退化为不过滤并在答案里标注不确定,而不是猜一个组。


7. 改写的副作用

副作用 表现 缓解
过度改写 查询变长、约束丢失,召回变泛 强约束"保留实体与数值";改写结果与原句一起检索
HyDE 幻觉文档 假设答案编出不存在的产品名 假设答案只用于检索、不进入最终 prompt
术语表污染 同义词扩到错误领域("桶"→"Bucket") 术语表按域拆分,只加载当前域
查询漂移 三轮改写后偏离原意 保留原查询作为其中一路,永不丢弃

最后一条值得单独强调:原查询必须始终作为一路召回。它是最忠实的意图载体,改写只是补充假设。丢掉它,等于把纠错能力一起丢掉。


8. 评估:改写前后比什么

def ab_recall(queries, rewriter, k=10):
    base = {q["qid"]: set(hybrid(q["query"], k)) for q in queries}
    new = {}
    for q in queries:
        hits = []
        for rq in rewriter(q["query"]):        # 多路:并集
            hits += hybrid(rq, k)
        new[q["qid"]] = set(hits[:k])
    return mean_recall_at_k(base), mean_recall_at_k(new)

比三件事:Recall@k(有没有捞回来)、MRR(名次有没有提前)、端到端答案正确率(有没有真帮上)。三者常不同向——多路改写能让 Recall 涨而 MRR 跌(噪声混进头部),也能让 MRR 涨而答案正确率不变(检索对了,生成没用上)。只报一个数就会得出相反结论。

样本要按查询族分层,否则闲聊类查询会把整体均值拉平,掩盖政策类查询的真实退化。


9. 成本账

方案 生成调用 检索调用 适用
不改写 0 1 查询本身是关键词
单次改写 1(小模型) 2(原句 + 改写) 默认起点
Multi-Query N=3 1 4 Recall 明显偏低
HyDE 1(长输出) 2 问题模糊、领域明确
分解 + 逐子问题 1 + M 2M 多跳、比较型问题

单次改写里最省的一档是术语表映射 + 规则扩展,零生成成本,能覆盖缩写与同义词;把它作为第一层,只有在它失效时才调用模型改写。按所用的模型版本核对单价,别用旧价格表算新账。


10. 排错

现象 先查 再查
改写后召回更差 是否丢了原查询这一路 改写是否引入了不存在的实体
多轮对话答非所问 历史是否带错轮次 指代是否被摘要成更模糊的词
同一问题两次结果不同 相对时间是否未锚定 路由是否命中了不同索引
路由总走 general 规则正则是否被转义错误 分类器训练分布是否覆盖该意图
延迟翻倍 多路召回的并发是否真的并发 重排是否被串行执行

11. 边界

场景 更合适的做法 理由
查询本身就是精确 ID 直接走 BM25 / 精确查询 改写只会稀释关键 token
单轮 FAQ 机器人 不做路由,固定检索 决策分支的收益小于其故障面
语料极小且稳定 全量入上下文 检索链路整体可省
强合规场景 改写结果留档可审计 需要复现"当时检索了什么"

12. 自测

  1. 口语化查询检索不到,是改写问题还是索引问题?怎么区分?
  2. HyDE 在什么查询上会反噬,为什么?
  3. 多路召回融合前为什么必须按 parent_id 去重?
  4. 四个路由维度里,哪一类的误判代价最不对称,阈值该往哪偏?
  5. 为什么"改写时带历史、检索时不带历史"?
  6. 改写让 Recall 涨了但答案正确率没涨,下一步查什么?
S20-J07 · 查询改写与路由:把用户问题变成能检索的查询
https://www.clxhxhhr.top/posts/6049/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。