向量检索对齐的是语义,用户原话里却常常夹着指代、口语和一问多意图。原句直接拿去 embedding,召回容易漂;反过来让模型每次自由改写,又可能改掉工单号、产品名或报错原文。本文用 Node.js 把「保护精确词 → 改写 → 多查询拆分 → 校验失败则回退」做成可开关、可观测的一层,并用一组小回归样例盯住改写有没有伤害精确词。

原话进检索,打偏从哪来

多数 RAG 的第一跳是:用户说了什么,就把整句向量化,再去搜 chunk。这在「问法和文档写法接近」时够用,但线上真实提问很少这么干净。

第一类是指代。『这个还是不行』『上次那个配置』『还是老问题』——句子里几乎没有可检索的实体。向量模型只能在一个很含糊的语义邻域里找,命中哪一篇都像那么回事,也都不像。

第二类是口语。用户说『挂了』『打不开』『卡住了』,文档写的是 crash、timeout、无法启动。语义上有重叠,但不等于近。口语越短,embedding 越容易漂到情绪或空泛的『故障』簇,而不是那条具体的排障说明。

第三类是一问多意图。例如:『这个报错 E1404 咋弄,另外 INC-8821 进度呢』。前半段要排障,后半段要工单状态。一句一个向量,两边的信号互相稀释,哪边都不够近。若检索侧只取 Top-K,常见结果是只召回了其中一侧,另一侧在下游生成里被模型靠记忆补完——这很危险。

这三件事叠加时,『把用户原话直接检索』不是省事,是把查询理解的责任推给了向量空间。向量空间擅长的是相近表述,不是补全省略,更不是拆句子。

所以要加一层查询理解。但加的方式值得克制:不要换成『每次让大模型随便改写成它认为更好的问法』。改写一旦没有边界,失败模式只是从『检索打偏』变成『精确词丢失』。

改写也会改坏:精确词是不变量

查询里有一类词,语义近似是错的,必须原样留下。常见的有:

  • 工单号、变更单号,如 INC-8821
  • 产品代号、SKU、内部模块名
  • 报错码、异常类名,如 E1404、SequelizeConnectionError
  • 用户从终端或日志里粘贴的原文(引号、反引号、书名号里的片段)

这些词往往还要走关键字过滤、元数据等值匹配,或和 BM25 一路融合。改写模型如果把 INC-8821 说成『那个工单』,后面检索再准也救不回来;如果把报错原文改得更『通顺』,和日志库的精确匹配就断了。

我自己排这类问题的原则只有两条:

  1. 改写是给检索增路,不是覆盖原句的唯一来源。
  2. 原句里抽出来的精确词,是整次请求的不变量。改写结果的并集里少一个,就视为失败,回退。

『回退』在这里不是把改写关掉,而是保证底线:至少原句还在。实践里更稳的做法是 原句 + 改写后的子查询一起检索,用融合或去重合并命中。改写挂了,至少原句那一路还在;改写成功,多意图和口语才有机会被展开。

还有一个常被 prompt 掩盖的点:没有会话历史时,指代无法被负责地展开。模型可以猜『这个』是上一轮的服务名,但那是猜测。流水线应该标 needsContext,而不是假装改写成功。猜错比不改更难查。

流水线:可开关,而不是每次自由发挥

把查询理解收成四级,每一级都可以关,失败有明确原因。

0. 开关与短路。 调试、对照实验、或已经是关键字检索的请求,应能跳过改写。开关在配置里,不在 prompt 里。

1. 抽取精确词并占位。 用正则把工单号、报错码、引号块替换成 __EXACT_0__ 这类占位符。后续无论是规则还是 LLM,都只看见占位符。占位符比『请保留专有名词』这类自然语言约束可靠——模型偶尔会改写专有名词,但不太会主动发明 __EXACT_0__ 的编号。

2. 改写。 默认先走轻量规则(口语映射、明显的中文连接词)。规则覆盖不了、且开关允许时,再调用 Chat Completions。Prompt 只做三件事:展开口语、把一问多意图拆成不超过三条独立查询、原样保留占位符。不要让模型『优化成更专业的问法』,那是在鼓励它改精确词。

3. 还原、拆分、校验、回退。 占位符换回原文后,检查:输出是否为空、子查询是否超过上限、原精确词是否都还在并集里。任一失败,queries 退回 [original],并记下 fallbackReason。指代且无历史时,不调用 LLM 去猜,直接短路并打标。

可观测性比改写本身更重要。每一次至少打出:原句、抽到的精确词、改写后子查询、是否回退、回退原因、耗时。没有这组字段,线上很难区分『检索差』和『查询被改坏』。

下面这份脚本零强制依赖:Node.js 18+ 即可跑通规则改写和回归。若环境里有 OPENAI_API_KEY,会走官方 openai SDK 的 Chat Completions(示例模型用 gpt-4o-mini,可换成你网关里的兼容模型)。没有 key 时不会报错,只会走规则路径——这正是『可回退』在工程上的含义。

可运行的 Node.js 实现

把下面存成 query-rewrite.mjs,然后执行 node query-rewrite.mjs。它会跑一组回归样例,并打印一条带结构化日志的演示。

import path from "node:path";
import { fileURLToPath } from "node:url";

const EXACT_PATTERNS = [
  /\bINC-\d+\b/g,
  /\bTICKET-\d+\b/g,
  /\b[A-Z]{2,5}-\d{3,}\b/g,
  /\bE\d{3,5}\b/g,
  /\b[A-Za-z][A-Za-z0-9]*(?:Error|Exception)\b/g,
  /「[^」]{1,80}」/g,
  /“[^”]{1,80}”/g,
  /`[^`]{1,80}`/g,
  /"([^"]{1,80})"/g,
];

const COLLOQUIAL = [
  [/咋弄/g, "如何处理"],
  [/咋办/g, "如何处理"],
  [/挂了/g, "崩溃或停止服务"],
  [/打不开/g, "无法启动或无法访问"],
  [/卡住了/g, "无响应或超时"],
];

const DEIXIS = /(这个|那个|上次|刚才|还是老问题|老样子)/;

function extractExactTerms(text) {
  const terms = [];
  const occupied = new Set();
  for (const re of EXACT_PATTERNS) {
    re.lastIndex = 0;
    let m;
    while ((m = re.exec(text)) !== null) {
      const start = m.index;
      const end = start + m[0].length;
      const overlap = [...occupied].some((k) => {
        const [s, e] = k.split(":").map(Number);
        return start < e && end > s;
      });
      if (overlap) continue;
      occupied.add(`${start}:${end}`);
      terms.push(m[0]);
    }
  }
  return [...new Set(terms)];
}

function protect(text, terms) {
  let next = text;
  const table = terms.map((term, i) => {
    const token = `__EXACT_${i}__`;
    next = next.split(term).join(token);
    return { term, token };
  });
  return { masked: next, table };
}

function restore(text, table) {
  return table.reduce((acc, { term, token }) => acc.split(token).join(term), text);
}

function ruleRewrite(masked) {
  let out = masked;
  for (const [re, to] of COLLOQUIAL) out = out.replace(re, to);
  return out;
}

function splitQueries(text) {
  const parts = text
    .split(/\n+|(?:另外|还有|以及|再问一下)[,,::]?/)
    .map((s) => s.replace(/^[0-9]+[.、)]\s*/, "").trim())
    .filter((s) => s.length >= 2);
  const uniq = [...new Set(parts)];
  return uniq.slice(0, 3);
}

function missingTerms(queries, terms) {
  const blob = queries.join("\n");
  return terms.filter((t) => !blob.includes(t));
}

export function createPipeline(options = {}) {
  const {
    enabled = true,
    maxQueries = 3,
    llmRewriter = null,
    hasHistory = false,
  } = options;

  return {
    async run(original) {
      const started = Date.now();
      const base = {
        original,
        protectedTerms: extractExactTerms(original),
        queries: [original],
        usedFallback: false,
        fallbackReason: null,
        needsContext: false,
        elapsedMs: 0,
      };

      if (!enabled) {
        base.elapsedMs = Date.now() - started;
        return base;
      }

      if (DEIXIS.test(original) && !hasHistory) {
        base.needsContext = true;
        base.usedFallback = true;
        base.fallbackReason = "deixis_without_history";
        base.elapsedMs = Date.now() - started;
        return base;
      }

      const { masked, table } = protect(original, base.protectedTerms);
      let rewrittenMasked = ruleRewrite(masked);

      if (llmRewriter) {
        try {
          const llmOut = await llmRewriter(masked);
          if (typeof llmOut === "string" && llmOut.trim()) {
            rewrittenMasked = llmOut.trim();
          }
        } catch (err) {
          base.usedFallback = true;
          base.fallbackReason = `llm_error:${err.message}`;
          base.elapsedMs = Date.now() - started;
          return base;
        }
      }

      const restored = restore(rewrittenMasked, table);
      let queries = splitQueries(restored).slice(0, maxQueries);
      if (queries.length === 0) queries = [original];

      const lost = missingTerms(queries, base.protectedTerms);
      if (lost.length) {
        base.usedFallback = true;
        base.fallbackReason = `missing_terms:${lost.join(",")}`;
        base.elapsedMs = Date.now() - started;
        return base;
      }

      if (!queries.includes(original)) queries = [original, ...queries];
      base.queries = [...new Set(queries)].slice(0, maxQueries + 1);
      base.elapsedMs = Date.now() - started;
      return base;
    },
  };
}

export async function openaiRewriter(masked) {
  const { default: OpenAI } = await import("openai");
  const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
  const resp = await client.chat.completions.create({
    model: process.env.REWRITE_MODEL || "gpt-4o-mini",
    temperature: 0,
    messages: [
      {
        role: "system",
        content:
          "你改写检索查询。只做:展开口语;若有多个意图则拆成最多 3 行,一行一个查询。必须原样保留所有 __EXACT_n__ 占位符。不要解释,不要添加未出现的编号或产品名。",
      },
      { role: "user", content: masked },
    ],
  });
  return resp.choices[0]?.message?.content ?? "";
}

function assert(cond, msg) {
  if (!cond) throw new Error(msg);
}

async function runRegression() {
  const p = createPipeline({ enabled: true, hasHistory: false });

  const t1 = await p.run("服务打不开,报 SequelizeConnectionError");
  assert(!t1.usedFallback, "口语改写不应回退");
  assert(t1.queries.some((q) => q.includes("无法启动或无法访问")), "口语应被展开");
  assert(t1.protectedTerms.includes("SequelizeConnectionError"), "异常类名应抽出");
  assert(
    t1.queries.every((q) => q.includes("SequelizeConnectionError")),
    "异常类名不得丢失"
  );

  const t2 = await p.run("这个报错 E1404 咋弄,另外 INC-8821 进度呢");
  assert(t2.usedFallback, "含指代且无历史应回退");
  assert(t2.fallbackReason === "deixis_without_history", "原因应可解释");
  assert(t2.queries.length === 1 && t2.queries[0].includes("INC-8821"), "回退须保留原句");

  const pHist = createPipeline({ enabled: true, hasHistory: true });
  const t3 = await pHist.run("E1404 咋弄,另外 INC-8821 进度呢");
  assert(!t3.usedFallback, "无指代时应继续改写");
  assert(t3.protectedTerms.includes("E1404") && t3.protectedTerms.includes("INC-8821"));
  assert(missingTerms(t3.queries, t3.protectedTerms).length === 0, "拆分后精确词并集完整");
  assert(t3.queries[0] === t3.original, "原句必须保留为一路查询");

  const off = createPipeline({ enabled: false });
  const t4 = await off.run("挂了 INC-1");
  assert(t4.queries[0] === "挂了 INC-1" && t4.usedFallback === false, "关闭时原样放行");

  console.log("regression ok");
  console.log(JSON.stringify(t3, null, 2));
}

const isMain =
  process.argv[1] &&
  fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);

if (isMain) {
  await runRegression();
}

接入现有 RAG 时,用 result.queries 做多路检索(原句一路,改写子查询一路),不要只搜最后一条『模型认为最好的问法』。LLM 适配器按需打开:

const llm =
  process.env.OPENAI_API_KEY ? openaiRewriter : null;
const pipeline = createPipeline({ enabled: true, hasHistory: Boolean(sessionTurns), llmRewriter: llm });
const q = await pipeline.run(userText);
await retrieveAll(q.queries);

openai 包和 chat.completions.create 都是现有 API;没有 key、SDK 没装、或接口报错时,流水线应回退到原句,而不是让请求失败。规则路径不依赖任何模型,适合做 CI 里的必跑分支。

用小回归样例盯住『有没有改坏』

改写层最容易在『prompt 又优化了一版』之后静默变坏。不要用大评测集先上,先养 20 条以内的黄金样例,每次改规则或 prompt 都跑。样例按失败模式来,而不是按业务方觉得重要的问法来:

  • 精确词保真: 工单号、报错码、异常类名、引号中的日志原文,改写前后必须逐字出现在输出并集里。
  • 多意图上限: 拆分不得超过 3 条,避免检索扇出把延迟和噪声一起放大。
  • 指代无历史: 必须回退并给出 deixis_without_history,禁止模型补实体。
  • 开关: enabled: false 时输出恒等于原句,用来做 A/B 对照。
  • 失败可解释: 人为把占位符从假改写结果里删掉,断言走 missing_terms: 回退,而不是继续检索。

这组断言不衡量『改写是否更懂用户』,只衡量『改写有没有伤害精确词、有没有在不该猜的时候猜』。前者需要带标注的召回评估,可以后做;后者是改写层自己的契约,丢了就没有下一步。

线上把 fallbackReason 做成计数指标即可。若 missing_terms 突然升高,优先查 prompt 或模型换代,而不是先调检索 Top-K。若 deixis_without_history 占比高,说明缺的是会话记忆接入,不是更强的改写模型。

不要把回归做成『模型和某条人工改写逐字相等』。模型一换,字面差会全红,真正该盯的不变量反而被淹没。精确词集合、是否回退、子查询条数、原句是否仍在 queries 里,这四项稳定、可自动判定,足够当改写层的护栏。

总结

用户原话不适合无处理地丢进向量检索:指代缺实体,口语和文档不对齐,一问多意图会把一条向量拉向两个目标。但查询改写的风险对称——工单号、产品名、报错原文一旦被『说得更自然』,精确匹配那一路就断了。

更稳的做法是加一层可开关的查询理解,而不是每次让模型自由发挥。先抽出精确词并占位,再改写和有上限地拆分,最后用并集校验决定是否回退;原句始终保留为检索的一路。没有会话历史就不要展开指代。用很小的回归样例锁住精确词和回退原因,用结构化日志区分『检索差』和『查询被改坏』。

这层东西不替代 RAG 的召回与重排,只把查询从『用户说的那句』变成『可观测的一组检索键』。先把不变量守住,再考虑要不要上更重的改写模型。