向量检索对齐的是语义,用户原话里却常常夹着指代、口语和一问多意图。原句直接拿去 embedding,召回容易漂;反过来让模型每次自由改写,又可能改掉工单号、产品名或报错原文。本文用 Node.js 把「保护精确词 → 改写 → 多查询拆分 → 校验失败则回退」做成可开关、可观测的一层,并用一组小回归样例盯住改写有没有伤害精确词。
原话进检索,打偏从哪来
多数 RAG 的第一跳是:用户说了什么,就把整句向量化,再去搜 chunk。这在「问法和文档写法接近」时够用,但线上真实提问很少这么干净。
第一类是指代。『这个还是不行』『上次那个配置』『还是老问题』——句子里几乎没有可检索的实体。向量模型只能在一个很含糊的语义邻域里找,命中哪一篇都像那么回事,也都不像。
第二类是口语。用户说『挂了』『打不开』『卡住了』,文档写的是 crash、timeout、无法启动。语义上有重叠,但不等于近。口语越短,embedding 越容易漂到情绪或空泛的『故障』簇,而不是那条具体的排障说明。
第三类是一问多意图。例如:『这个报错 E1404 咋弄,另外 INC-8821 进度呢』。前半段要排障,后半段要工单状态。一句一个向量,两边的信号互相稀释,哪边都不够近。若检索侧只取 Top-K,常见结果是只召回了其中一侧,另一侧在下游生成里被模型靠记忆补完——这很危险。
这三件事叠加时,『把用户原话直接检索』不是省事,是把查询理解的责任推给了向量空间。向量空间擅长的是相近表述,不是补全省略,更不是拆句子。
所以要加一层查询理解。但加的方式值得克制:不要换成『每次让大模型随便改写成它认为更好的问法』。改写一旦没有边界,失败模式只是从『检索打偏』变成『精确词丢失』。
改写也会改坏:精确词是不变量
查询里有一类词,语义近似是错的,必须原样留下。常见的有:
- 工单号、变更单号,如
INC-8821 - 产品代号、SKU、内部模块名
- 报错码、异常类名,如
E1404、SequelizeConnectionError - 用户从终端或日志里粘贴的原文(引号、反引号、书名号里的片段)
这些词往往还要走关键字过滤、元数据等值匹配,或和 BM25 一路融合。改写模型如果把 INC-8821 说成『那个工单』,后面检索再准也救不回来;如果把报错原文改得更『通顺』,和日志库的精确匹配就断了。
我自己排这类问题的原则只有两条:
- 改写是给检索增路,不是覆盖原句的唯一来源。
- 原句里抽出来的精确词,是整次请求的不变量。改写结果的并集里少一个,就视为失败,回退。
『回退』在这里不是把改写关掉,而是保证底线:至少原句还在。实践里更稳的做法是 原句 + 改写后的子查询一起检索,用融合或去重合并命中。改写挂了,至少原句那一路还在;改写成功,多意图和口语才有机会被展开。
还有一个常被 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 的召回与重排,只把查询从『用户说的那句』变成『可观测的一组检索键』。先把不变量守住,再考虑要不要上更重的改写模型。
评论