用户问的是"这玩意儿能退吗",索引里存的是《售后政策 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. 自测
- 口语化查询检索不到,是改写问题还是索引问题?怎么区分?
- HyDE 在什么查询上会反噬,为什么?
- 多路召回融合前为什么必须按
parent_id去重? - 四个路由维度里,哪一类的误判代价最不对称,阈值该往哪偏?
- 为什么"改写时带历史、检索时不带历史"?
- 改写让 Recall 涨了但答案正确率没涨,下一步查什么?