查询改写是在检索执行之前,把用户原始输入或整段对话历史转换为检索侧可消费的查询表示,通常由语言模型完成。其产出形态由下游检索通道决定。本文梳理原始输入直接进入检索时的失效形式与常见的产出形态,并以多表示生成为例展开实现方式、消费路径与边界条件。
不同检索通道对查询形态有各自的隐含要求,把用户输入原样交给检索系统,会在以下几类场景下损失召回:
这几类失效的共同点是:问题出在查询侧的表达,而非语料或索引。
查询改写的产出并不固定。工程实现中常见的产出形态如下:
| 产出形态 | 产出物 | 消费方式 | 适用场景 |
|---|---|---|---|
| 上下文消解重写 | 单个独立完整的查询 | 作为查询送入检索 | 多轮对话中的指代与省略 |
| 多表示生成 | 面向不同通道的多个表示 | 分别送入对应通道,结果按名次合并 | 多条分值尺度不同的检索路径 |
| 元数据过滤抽取 | 结构化过滤条件,如实体名、时间范围 | 检索前的硬过滤,不参与相似度计算 | 语料带有可过滤的元数据字段 |
| 关键词扩展 | 多组面向不同召回目标的词元集合 | 分别用于字段名、指标、取值等目标的匹配 | 结构化数据的模式对齐 |
| 假设文档生成 | 一篇假设性的说明文档 | 编码为向量后检索 | 查询短,与文档形态差异较大 |
| 多查询扇出与分解 | 多个查询变体或子问题 | 各自检索后融合 | 单一表述覆盖不全,或问题含多个子需求 |
几点补充:
本文后续以"多表示生成"为主线展开,原因是它同时涉及结构化输出与多路检索的衔接,其余形态可以视为同一调用结构下输出字段的变化。
采用"多表示生成"时,改写器为每条检索通道各产出一个表示。各通道匹配查询的方式不同,同一个信息需求因此需要不同的表示形式:
| 表示形式 | 匹配机制 | 适配通道 | 典型粒度 |
|---|---|---|---|
| 词元列表(keywords) | 词元的字面重合与统计权重 | BM25、稀疏向量检索 | 术语、专有名词、标识符、日期 |
| 自然语言查询(searchQuery) | 向量空间中的语义相似度 | 稠密向量检索 | 概念化描述 |
两类表示的构造约束方向相反:
改写器是一次独立的语言模型调用,其输入与输出契约分别固定在两处:
输出字段的构成由消费方决定,下文示例的字段配置对应词元匹配通道与语义匹配通道这一组合。
字段的语义由 schema 中的描述文本承担。这段描述是约束模型输出行为的主要手段,它决定了模型如何理解每个字段的用途与粒度。
创建改写器使用的模型实例:
import { createAlibaba } from '@ai-sdk/alibaba';
const alibaba = createAlibaba({
apiKey: process.env.ALIBABA_API_KEY,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
});
// 改写任务输出短、结构固定,可选用延迟较低的档位
const rewriterModel = alibaba('qwen3.7-flash');档位选择属于工程取舍。改写器位于检索之前的串行路径上,其推理耗时直接叠加到用户可感知的响应时间;同时改写本身是受限的信息抽取任务,不涉及长链条推理。两方面的权衡决定档位,替换模型只需更换此处的实例。
定义改写结果的 schema,并在字段描述中固定用途与粒度:
import { z } from 'zod';
const queryPlanSchema = z.object({
keywords: z
.array(z.string())
.describe(
'用于关键词检索的词元列表。使用语料中可能出现的具体术语,包含专有名词、标识符与同义词。',
),
searchQuery: z
.string()
.describe(
'用于语义检索的自然语言查询。描述信息需求本身,可以比关键词更宽泛,不必与语料共享词元。',
),
});
type QueryPlan = z.infer<typeof queryPlanSchema>;两处描述的差异决定了输出的行为差异:keywords 的描述强调字面匹配与同义展开,searchQuery 的描述强调概念化与宽泛度。省略描述时,模型只能依据字段名推断用途,两类输出会趋同。
执行改写调用:
import {
convertToModelMessages,
generateText,
Output,
type UIMessage,
} from 'ai';
export const rewriteQuery = async (
messages: UIMessage[],
): Promise<QueryPlan> => {
const result = await generateText({
model: rewriterModel,
instructions: `你是一个检索查询改写器。
阅读完整对话上下文,判断用户当前的信息需求。
生成一个用于关键词检索的词元列表,以及一个用于语义检索的自然语言查询。
词元列表使用语料中可能出现的具体术语;语义查询描述信息需求本身,可以更宽泛。`,
output: Output.object({
schema: queryPlanSchema,
}),
messages: await convertToModelMessages(messages),
});
return result.output;
};调用中的三个关键点:
instructions:声明任务边界与两类输出的差异,与 schema 描述共同约束模型行为。convertToModelMessages:把前端的 UIMessage 序列转换为模型可消费的消息序列,保留完整对话历史。output: Output.object({ schema }):声明结构化输出,result.output 为按 schema 解析后的对象,其类型由 schema 推导。构造一段多轮对话作为输入:
const messages: UIMessage[] = [
{
id: '1',
role: 'user',
parts: [
{ type: 'text', text: '订单查询接口在高峰期会返回 504' },
],
},
{
id: '2',
role: 'assistant',
parts: [
{ type: 'text', text: '下游依赖的超时配置是怎样的?' },
],
},
{
id: '3',
role: 'user',
parts: [
{ type: 'text', text: '调的是支付网关,重试应该怎么设?' },
],
},
];
const plan = await rewriteQuery(messages);
console.dir(plan, { depth: null });对末尾这条追问,两类表示分别指向不同的检索目标。具体内容随模型与语料变化,以下为一次调用输出的形态:
| 字段 | 输出形态 | 检索目标 |
|---|---|---|
| keywords | ["支付网关", "重试", "超时", "504"] |
包含上述术语的文档 |
| searchQuery | 下游依赖调用失败后的重试与降级处理策略 | 讨论调用链路稳定性设计的文档,不要求出现上述术语 |
差异集中在两处:
keywords 中的每一项都需要在语料中有对应的字面出现,BM25 才会给出非零分值;searchQuery 的表述可以不出现在任何文档中,向量空间中的相似度仍然有效。searchQuery 覆盖"调用链路稳定性"这一上位概念,可以命中《分布式调用链路的稳定性设计》一类未出现上述词元的文档。接入检索管线:
const plan = await rewriteQuery(messages);
// 词元列表进入关键词匹配通道
const keywordHits = await searchByKeywords(plan.keywords);
// 自然语言查询进入语义匹配通道
const semanticHits = await searchBySemantics(
plan.searchQuery,
);
// 两路结果按名次合并
const merged = reciprocalRankFusion([
keywordHits,
semanticHits,
]);其中 searchByKeywords 与 searchBySemantics 为管线中已有的检索函数,reciprocalRankFusion 负责合并两路结果。
plan.keywords 作为词元列表传入 BM25 或稀疏向量检索。plan.searchQuery 先经嵌入模型编码为查询向量,再与文档向量比较。两个通道的查询表示由同一次调用产出,无需为每条检索路径单独发起模型调用。
更换输出 schema 与消费端的分发方式,同一套调用结构可以得到其它产出形态。
单查询重写与元数据过滤抽取的组合:
// 单查询 + 元数据过滤:所有通道共用同一条查询
const singleQueryPlanSchema = z.object({
rewrittenQuery: z
.string()
.describe('消解指代后的独立完整查询'),
entityName: z
.string()
.optional()
.describe('提问中提及的实体名称,用于检索前过滤'),
});
// 调用结构与前述一致,schema 已替换
const plan = await rewriteQuery(messages);
// 同一条查询送入所有通道,过滤条件在检索内生效
const hits = await Promise.all([
searchByVector(plan.rewrittenQuery, plan.entityName),
searchByWeb(plan.rewrittenQuery),
]);多组关键词扩展:
// 按召回目标拆分字段,各组词元分别用于不同的匹配目标
const keywordPlanSchema = z.object({
columnTerms: z
.array(z.string())
.describe('用于匹配字段名的词元'),
metricTerms: z
.array(z.string())
.describe('用于匹配指标名的词元'),
valueTerms: z
.array(z.string())
.describe('用于匹配字段取值的词元'),
});两种扩展与前述示例的差别只在字段构成与消费端的分发方式:改写器的调用、结构化输出的解析、失败降级路径均可直接复用。选择哪种产出形态,依据是检索侧需要什么形态的输入,而不是改写器本身能生成什么。
改写质量取决于对话历史能否提供指代对象:
改写器在生成词元列表时可能补充输入中未出现的限定条件。例如输入为"部署流程",改写结果若引入"生产环境"这一限定词,检索范围会被收窄到生产环境相关文档,其它环境的同类文档被排除在外。这类偏移在关键词通道上影响直接:BM25 的分值是各词元贡献的累加,多余词元既引入噪声,也改变了排序依据。控制方式是在 schema 描述中限定词元的来源范围,并将词元数量维持在较小规模。
改写改变的是查询的表示形式,不改变语料内容。语料中不存在对应信息时,任何改写都无法召回。改写带来的收益来自查询表示与检索通道的匹配程度,而非知识的补充。这一边界决定了改写的作用位置:它处理查询侧的表达问题,检索质量的下限仍由语料与索引决定。
改写是一次额外的模型调用,位于检索之前的关键路径上。以下场景中其收益不足以覆盖成本:
判断依据是输入的表达完整度:输入越接近独立完整的查询,改写的边际收益越低。
改写位于检索管线的最前端,其耗时叠加在后续所有环节之前:
同一轮对话可能触发多次检索,例如检索工具被连续调用。改写结果可以按对话历史的指纹缓存,在历史未发生变化时直接复用,避免重复调用。
改写是增强环节,其失败不应中断检索。结构化输出失败时 Output.object 抛出 NoObjectGeneratedError,调用失败则以其它错误形式抛出。两种情况都应回退到原始输入,而降级路径需要在调用处显式实现。
实现降级包装:
import {
NoObjectGeneratedError,
type UIMessage,
} from 'ai';
// QueryPlan 与 rewriteQuery 见上文的定义
export const resolveQueryPlan = async (
messages: UIMessage[],
fallbackText: string,
): Promise<QueryPlan> => {
try {
return await rewriteQuery(messages);
} catch (error) {
// 区分失败原因,便于监控与告警
const reason = NoObjectGeneratedError.isInstance(error)
? 'schema 未满足'
: '调用失败';
console.warn(`查询改写降级:${reason}`);
// 回退到原始输入,检索退化为未改写形态
return { keywords: [], searchQuery: fallbackText };
}
};降级路径需要在调用处显式实现。回退查询的构造方式应保证其至少能被语义通道消费,因此 searchQuery 取原始用户输入,keywords 留空以避免无效的词元匹配。
检索管线中三个环节处理的问题不同:
| 环节 | 作用位置 | 解决的问题 |
|---|---|---|
| 查询改写 | 召回之前 | 查询表示与检索通道不匹配 |
| 名次融合 | 多路召回之后 | 各通道分值尺度不可比 |
| 重排 | 融合之后 | 候选集内的精细相关性排序 |
改写决定用什么查询去检索,融合决定如何合并多路结果,重排决定如何对候选精排。三者顺序固定:先确定查询,再执行召回,最后合并与精排。改写的输入是查询表示,不涉及候选集;融合与重排的输入是候选集,不再改变查询。