向量、混合检索与重排序
理解 embedding、关键词召回、RRF 与重排序,先证明证据被找对,再让模型回答。
建议先读:文档解析与知识入库
本页内容
检索的任务是找到足够的证据#
用户输入“我买的 S-42 能退吗”,相关材料也许写的是“七日内可申请售后”,没有出现“能退”两个字。语义检索能帮助连接不同措辞;但产品编号、版本号和错误码通常又需要精确匹配。本章用于建立组合检索的判断方法。达标后,你应能解释每一阶段的输入输出,运行一个离线排序实验,并用标注问题集定位漏检。
前置是文档切块、版本与权限模型。先不要把“有向量数据库”当成“有高质量知识库”。存储解决索引和查询能力,是否找到正确证据取决于数据、表示、过滤、候选规模与排序策略。
embedding 表示相似性,不表示事实真假#
embedding 把文本映射成数值向量,让语义相近的内容在某种空间里距离更近。向量每一维通常不对应可直接命名的业务字段。不能把第一维叫“退款程度”,也不能把某个距离直接解释成“答案正确率百分之九十”。
文档与查询必须使用兼容的 embedding 模型、维度与预处理方法。维度一致只是必要条件,不同模型即使输出同样长度,空间也不一定可以比较。更换模型通常需要重建文档向量,或维护独立版本的索引并在完成后切换,不能只更新查询端。
在 OpenAI embedding 接口中,model 选择模型,input 提供字符串或批量输入,encoding_format 可指定 float,部分型号支持 dimensions。返回 data 中有 embedding 和 index,批量结果应按 index 对应输入,不能在分批并发后靠数组到达顺序拼接。官方向量文档
常见相似度包括余弦、点积和欧氏距离。余弦比较方向,公式是点积除以两个向量长度。零向量会让分母为零,维度不等或存在 NaN 也必须拒绝。是否需要预先归一化取决于模型与索引实现,不应复制别处代码后忽略数据约定。
三个阶段承担不同职责#
召回先追求“有用材料不要漏掉”。关键词召回擅长精确名称和稀有词,BM25 等算法还考虑词频与文档长度;向量召回擅长相近语义。混合检索把两者候选合并,不要求两个原始分数处于相同尺度。
RRF 按排名而非原始分数融合,常见计算方式是为每个候选累加 1/(常数+名次)。在多路列表都靠前的候选更容易上升。常数控制头部名次差异的影响,不是置信度阈值。实际搜索产品对候选窗口与参数有自己的定义,需按官方实现核对。Elastic RRF 文档
重排序在较小候选集合上更细致地比较查询与片段。有的系统使用交叉编码模型,有的加入产品编号、日期与来源权威性规则。它只能重排已经召回的材料,无法挽救完全没进入候选的关键条款。把重排序预算加倍,却不检查召回率,可能花更多钱仍然漏答。
完整示例:两路召回与编号重排序#
环境:Node.js 22;文件 retrieval.mjs;无依赖;执行 node retrieval.mjs。所有二维向量均为手工 fixture;关键词计数不是 BM25,最后的编号提升规则也不是神经重排序模型。本例只帮助你看清数据如何流动。
const rows = [
{ id: 'a', tenant: 't1', groups: ['support'], active: true,
text: 'S-42 退货 七天', vector: [0.8, 0.2] },
{ id: 'b', tenant: 't1', groups: ['support'], active: true,
text: 'V-99 退货 三十天', vector: [0.99, 0.01] },
{ id: 'secret', tenant: 't2', groups: ['support'], active: true,
text: 'S-42 退货 内部特别规则', vector: [1, 0] },
];
function cosine(a, b) {
if (a.length !== b.length || a.length === 0 ||
![...a, ...b].every(Number.isFinite)) throw new Error('无效向量');
const denominator = Math.hypot(...a) * Math.hypot(...b);
if (denominator === 0) throw new Error('不接受零向量');
return a.reduce((sum, value, index) => sum + value * b[index], 0) / denominator;
}
function rrf(lists, constant = 60) {
const scores = new Map();
for (const list of lists) {
// 每一路必须去重,否则同一候选会重复得分。
[...new Set(list)].forEach((id, index) => {
scores.set(id, (scores.get(id) ?? 0) + 1 / (constant + index + 1));
});
}
return [...scores].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([id]) => id);
}
// 在召回之前限制租户、权限和当前版本。
const visible = rows.filter(row => row.tenant === 't1' &&
row.groups.includes('support') && row.active);
const terms = ['S-42', '退货'];
const lexical = [...visible].map(row => ({
id: row.id, score: terms.filter(term => row.text.includes(term)).length,
})).filter(row => row.score > 0).sort((a, b) => b.score - a.score).map(row => row.id);
const semantic = [...visible].sort((a, b) =>
cosine([1, 0], b.vector) - cosine([1, 0], a.vector)).map(row => row.id);
const fused = rrf([lexical, semantic]);
const byId = new Map(visible.map(row => [row.id, row]));
const reranked = [...fused].sort((a, b) =>
Number(byId.get(b).text.includes('S-42')) - Number(byId.get(a).text.includes('S-42')));
const relevant = new Set(['a']);
const top = reranked.slice(0, 2);
const hitCount = top.filter(id => relevant.has(id)).length;
console.log('关键词=' + lexical.join(','));
console.log('向量=' + semantic.join(','));
console.log('最终=' + top.join(','));
console.log('Recall@2=' + hitCount / relevant.size);
console.log('Precision@2=' + hitCount / top.length);
预期关键词顺序为 a,b,向量顺序为 b,a,最终为 a,b,Recall@2 为一,Precision@2 为零点五。secret 虽然向量最相似,也不会参与排序,因为它属于其他租户。把 a.active 改为 false 后,系统应暴露漏检,而不是把 b 的三十天规则错误当成 S-42 的答案。
第一段刻意制造词面与语义排序不同的情况。cosine 先验证维度与有限数值,让错误在边界出现。rrf 根据名次合并,没有直接把词频分数与余弦相加。最后评测使用独立标注的 relevant 集合,说明排序代码不能自己宣布什么算正确。
评测要回答“错在何处”#
为每个问题标注一个或多个必要证据块,保存问题、用户权限、资料版本及相关集合。Recall@k 衡量必要证据找回多少;Precision@k 衡量前 k 条里多少有用;MRR 关注第一个相关结果的位置。多段推理问题需要多条证据,只有第一条相关不代表足够回答。
标注粒度也重要。若一个答案需要“申请条件”和“例外情况”,只标注含关键词的主段落会使检索成绩看起来很好,却无法生成完整回答。评测集应包含同义表达、精确编号、版本差异、无答案问题、跨权限问题以及长文档表格问题。
在线调试按阶段查:原文有没有;解析是否正确;切块是否保留前提;过滤是否过严或过松;候选是否召回;重排序是否把它降下去;最终预算裁剪是否删掉。这样的定位顺序比直接更换生成模型有效得多。
top_k 不是越大越好。更多候选会增加重排序耗时与输入噪声;但过小会漏掉跨段落证据。应该先看召回曲线和耗时,再选择候选规模。相似度阈值也需要在自己的模型、语言和数据上校准,不能照搬某篇文章里的零点八。
权限过滤如果只在 top_k 之后执行,可能先召回十条无权结果,再删到一条都不剩;这既降低可用召回,也扩大暴露面。应让过滤参与实际检索计划,并在取原文与输出来源时复查。文档删除、权限变化和索引版本也必须进入缓存失效逻辑。
关键词检索怎样处理中文与产品编号#
关键词检索首先面对的不是排序,而是词项如何产生。英文可以较容易按空格观察单词,中文需要分词或字符片段策略;S-42、ERR_AUTH_03、订单号和带版本的接口路径又希望完整保留。若查询分析器把 S-42 拆成 S 和 42,文档分析器却保留整体,即使原文确实存在,也可能无法匹配。分析器必须作为索引合同的一部分管理。
常见做法是为不同字段采用不同表示。标题和正文使用适合自然语言的分析器,产品编号使用精确字段,别名和规范名称保存在可维护的词表里。用户输入“S42”是否等价于“S-42”,应根据业务编号规则决定,不能把所有连字符都删除。过度规范化会让本来不同的产品碰撞,最后生成模型再聪明也无法知道哪个编号原本正确。
同义词也存在方向和语境。售后业务里“退货”和“退款”有关,但不是所有退款都要求退货;把两者无条件互换可能召回错误流程。可以使用查询扩展提高候选召回,同时保留原查询用于重排序和解释。扩展词要有版本与评测,尤其不能让模型临时生成的产品编号成为新的事实。模型改写查询是一个可评测的子步骤,不是替原问题重新定义需求。
BM25 在匹配词项之后计算相关分数。一个词在文档中出现越多,分数通常提高,但增长逐渐饱和,避免重复一百遍“退款”就占据榜首。一个词在整个语料越少见,通常越有区分能力;文档长度归一化则减少长文档因包含更多词而天然占优。三个因素分别对应词频、逆文档频率和长度归一化,不是简单的关键词次数相加。
示例二:真正计算一个小型 BM25 分数#
环境为 Node.js 22,无依赖。保存为 bm25.mjs,执行 node bm25.mjs。这里的词项数组由人工给出,故意不冒充通用中文分词器;打分采用写明的 BM25 变体,参数默认值属于本程序。预期顺序为 a、b、c,未知词返回空列表,参数错误被断言捕获。
import assert from 'node:assert/strict';
function searchBM25(docs, queryTerms, { k1 = 1.2, b = 0.75 } = {}) {
if (!Number.isFinite(k1) || k1 <= 0 || !Number.isFinite(b) ||
b < 0 || b > 1) throw new Error('INVALID_PARAMETERS');
if (!docs.length) return [];
const averageLength = docs.reduce((sum, d) => sum + d.terms.length, 0) /
docs.length;
if (averageLength === 0) return [];
const df = new Map();
for (const doc of docs) {
// 文档频率只计“该文档是否出现”,不是出现次数。
for (const term of new Set(doc.terms)) {
df.set(term, (df.get(term) ?? 0) + 1);
}
}
const query = [...new Set(queryTerms)];
return docs.map(doc => {
const frequencies = new Map();
for (const term of doc.terms) {
frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
}
let score = 0;
for (const term of query) {
const frequency = frequencies.get(term) ?? 0;
if (frequency === 0) continue;
const documentFrequency = df.get(term);
const idf = Math.log(1 +
(docs.length - documentFrequency + 0.5) /
(documentFrequency + 0.5));
const lengthFactor = 1 - b + b * doc.terms.length / averageLength;
score += idf * (frequency * (k1 + 1)) /
(frequency + k1 * lengthFactor);
}
return { id: doc.id, score };
}).filter(row => row.score > 0)
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
}
const docs = [
{ id: 'a', terms: ['S-42', '退货', '七天'] },
{ id: 'b', terms: ['S-42', '激活', '禁止', '退货'] },
{ id: 'c', terms: ['S-99', '退货', '三十天'] }
];
const result = searchBM25(docs, ['S-42', '退货']);
assert.deepEqual(result.map(x => x.id), ['a', 'b', 'c']);
assert.deepEqual(searchBM25(docs, ['不存在的编号']), []);
assert.deepEqual(searchBM25([], ['退货']), []);
assert.throws(() => searchBM25(docs, ['退货'], { b: 2 }),
/INVALID_PARAMETERS/);
console.log(result.map(row => ({ id: row.id, score: row.score.toFixed(3) })));
df 使用 Set 去掉单篇文档中的重复词,表达“多少文档出现该词”;frequencies 则保留同篇出现次数。这两个统计如果混淆,重复词会同时改变词频和稀有程度,公式失去原来的含义。query 也去重,因此这个教学变体没有额外的查询词频加权,读者可以明确知道重复输入同一个词是否影响结果。
k1 调整词频饱和速度,b 调整文档长度归一化程度。这里明确选择一点二和零点七五作为程序默认,不能据此宣称所有搜索引擎的默认值相同。不同实现还可能使用不同的分片统计、分析器与字段权重。复制参数之前,先用同一语料核对排序结果,再查所选引擎对应版本的实现,例如 Lucene BM25Similarity。
这个结果里 a 排在 b 前面,并不证明 b 不重要。用户问“能不能退”需要一般期限,也需要激活例外;b 稍长因此分数略低,但它可能是防止误答的关键。检索排序优化不能只追求第一名看起来最像问题,还要考虑最终答案所需事实的覆盖。这也是为什么下游需要多条证据,而不是只把第一条交给模型。
向量索引和相似度各自承担什么#
向量模型产生表示,向量索引负责从大量表示中快速找近邻。对几百条向量逐个计算余弦很直观,但语料达到百万条时会消耗更多时间与内存,近似最近邻索引用一定召回损失换速度。索引参数影响图搜索范围、候选数量和资源占用,具体名字与默认值依赖存储实现,不能把一个产品的参数搬到另一个产品上。
应把近似索引的损失与 embedding 的语义能力分开测。先在可控子集上做精确搜索,作为同一向量空间的基线;再比较近似索引能否找回精确搜索的近邻。若近似结果接近精确结果,但业务相关文档仍然没出现,问题可能是表示、切块或语料,而不是继续增大索引搜索参数。这种对照能避免在错误层面花资源。
距离也有方向约定:相似度通常越大越相近,距离通常越小越相近。部分引擎会对内部距离做变换后返回分数。把所有名为 score 的字段都降序排序,可能正好反了;把余弦阈值用于欧氏距离也没有意义。适配器应标明 metric、scoreMeaning 与排序方向,内部融合尽量使用经过统一的排名或明确的归一化策略。
向量长度与数值检查应发生在写入和查询两侧。少一维、包含 NaN、来自旧模型或输入为空,都应形成可定位错误。批量计算还要保留输入 ID 与返回 index 的关联,防止并发完成顺序把 A 文档向量存到 B 文档名下。这样的错配不会一定触发数据库异常,却会让检索表现像随机失败,非常难从最终答案反推原因。
混合检索的候选窗口与重排序预算#
可以把每一路召回看成一个有界列表,先在当前身份和版本范围内取候选,再按稳定文档身份去重融合。RRF 的参数常数改变名次差距的影响,候选窗口决定每一路有多少机会贡献结果;两者不是同一个开关。若关键词列表只有十条而向量列表有一百条,融合输出会受窗口差异影响,评测时应记录这些配置。
使用排名融合的好处是不必直接比较余弦零点八与关键词分数十七,但它也丢掉了原始分数差距。两个结果虽然分别排第一和第二,第一名可能领先很多,也可能几乎打平。若后续业务要求精确置信度,需要独立校准,而不能把融合分数重新命名为“可信度”。检索分数只表达排序依据,不是答案正确概率。
重排序输入通常包含问题和候选文本对,可以比独立向量表示更细致地比较条件与否定关系。代价是每个候选都要额外处理,所以通常先召回较宽候选,再对较小集合重排。真实参数取舍要看候选数、块长度、模型成本和端到端延迟,不能只比较重排序接口本身的耗时。
规则可以补充明确的业务约束。例如查询明确指向 S-42 时,标记为 S-99 的片段应降权或剔除;但没有产品元数据的通用售后政策仍可能有用。不要把“文本里必须出现编号”写成无条件规则,否则通用条款全部丢失。最好在入库时建立适用范围元数据,让排序不必从每段自然语言里猜产品归属。
练习:让检索评测暴露遗漏与误召回#
建立一个评测器,输入每个问题的独立相关集合和候选顺序,计算 Recall@k、Precision@k、MRR;没有答案的问题单独统计是否错误召回,不把空集合的召回率强行算成一。再验证权限过滤一定发生在截取前 k 条之前。提示是先确定指标分母,再处理去重和空集合,不能让重复返回同一个块刷高分。
参考答案:完整指标与权限顺序实验
Node.js 22,无需安装。保存为 retrieval-eval.mjs,执行 node retrieval-eval.mjs,预期平均召回率为零点七五,平均 MRR 为零点七五,无答案误召回率为一。这个坏成绩是故意保留的 fixture,用于验证评测器不会替系统美化结果。
import assert from 'node:assert/strict';
function grade(retrieved, relevant, k) {
if (!Number.isInteger(k) || k < 1) throw new Error('INVALID_K');
const top = [...new Set(retrieved)].slice(0, k);
const gold = new Set(relevant);
const hits = top.filter(id => gold.has(id)).length;
const first = top.findIndex(id => gold.has(id));
return {
recall: gold.size ? hits / gold.size : null,
precision: top.length ? hits / top.length : 0,
mrr: gold.size ? (first < 0 ? 0 : 1 / (first + 1)) : null,
falsePositive: gold.size === 0 && top.length > 0
};
}
const cases = [
{ id: 'q1', gold: ['a', 'b'], ranked: ['a', 'c', 'b'] },
{ id: 'q2', gold: ['d'], ranked: ['c', 'd'] },
{ id: 'q3', gold: [], ranked: ['c'] }
];
const rows = cases.map(c => ({ id: c.id, ...grade(c.ranked, c.gold, 2) }));
const answered = rows.filter(row => row.recall !== null);
const emptyGold = rows.filter(row => row.recall === null);
const mean = (xs) => xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
const report = {
recall: mean(answered.map(row => row.recall)),
mrr: mean(answered.map(row => row.mrr)),
noAnswerFalsePositiveRate: mean(emptyGold.map(row => Number(row.falsePositive)))
};
assert.equal(report.recall, 0.75);
assert.equal(report.mrr, 0.75);
assert.equal(report.noAnswerFalsePositiveRate, 1);
assert.equal(grade(['a', 'a'], ['a', 'b'], 2).recall, 0.5);
assert.equal(grade(['a', 'c', 'b'], ['a', 'b'], 3).recall, 1);
// 假定存储已经完成候选权限检查;本段只比较过滤与截取顺序。
const candidates = [{ id: 'secret', tenant: 't2' },
{ id: 'a', tenant: 't1' }, { id: 'b', tenant: 't1' }];
const visible = item => item.tenant === 't1';
const correct = candidates.filter(visible).slice(0, 2).map(x => x.id);
const tooLate = candidates.slice(0, 2).filter(visible).map(x => x.id);
assert.deepEqual(correct, ['a', 'b']);
assert.deepEqual(tooLate, ['a']);
console.log(report);
这个实现的 Precision 分母是实际返回条数,上限为 k。有些评测协议采用固定 k 作分母,尤其希望惩罚少返回结果时;两种约定都需要写清楚,不能直接横向比较。这里的 MRR 也只观察前 k 条,因此更准确地说是截断 MRR。把指标名字、截断范围与空集合规则记录下来,才能让另一个工程师复现数字。
权限实验不意味着可以先把全库敏感正文下载到应用再过滤。代码只用三个无敏感内容的元数据对象演示顺序差异;真实查询应尽量让过滤进入数据库检索计划,原文展开时再次授权。评测样本的 gold 也必须与测试身份匹配,否则把用户无权看的文档标成“漏检”,会反过来鼓励不安全的检索策略。
如何建立足够可信的检索评测集#
标注时不要只保存唯一 chunkId。切块策略更新后,同一事实可能换了块编号,指标会把正确召回误判成失败。可以保存文档版本、原文范围和必要事实标识,再映射到本次索引块;仍要保留严格的版本边界,因为新版删除的旧条款不能继续当正确证据。标注成本较高,但它让后续策略比较有共同依据。
问题分组比总体平均数更能指导修改。产品编号类适合观察精确召回,同义问法类适合观察语义表示,跨段条件类适合观察覆盖,权限类适合观察误暴露,资料更新类适合观察旧版本残留。总体提高一点点时,某个高价值问题组可能显著变差;发布前应看每组变化和具体失败案例,不能只看一个总分。
阈值校准需要包含无答案样本。若测试集里每个问题都有答案,系统总能返回一些内容,看起来召回很好;真实用户问到资料没有覆盖的新产品时,同样策略会把邻近产品的规定塞进上下文。应观察有答案召回与无答案误召回的权衡,再决定何时继续检索、追问或返回资料不足。不同资料域可能需要不同策略,而不是全库共用一个神奇阈值。
最后把检索评测与生成评测分开保存。检索输入是问题、身份、版本和索引配置,输出是证据集合与排序;生成输入是固定证据包,输出是答案。先证明正确证据能进入预算,再判断模型是否忠于证据。否则换了生成模型恰好蒙对答案,可能掩盖检索仍然缺失关键条款的问题。
一次候选追踪应该留下哪些信息#
排查“正确条款不见了”时,可以为每次查询记录阶段快照:过滤后的候选范围、各召回通道的文档编号及名次、融合名次、重排序名次、最终进入上下文的编号。分数可以保存必要精度,但不必把每个敏感正文都写进普通日志。通过同一个查询关联号把这些快照串起来,才能区分是从未召回、被排序淘汰,还是被上下文预算裁掉。
一个有价值的失败实验是逐步缩小候选窗口。先固定索引、模型和问题集,只改变每路候选数量,画出召回与耗时的变化;再固定候选,比较是否开启重排序。如果两项同时改变,性能变好时很难判断谁带来收益,性能变差时也难定位原因。离线评测应尽量控制变量,线上试验则观察实际问题分布是否与离线样本一致。
没有必要在第一版就引入复杂的自适应检索。先让常见问题有可解释的候选路径,再考虑根据查询类型分配预算。例如精确错误码可以优先查编号字段,再补语义背景;开放式流程问题可以提高章节级覆盖。路由器本身也可能判断错误,所以保留退路并记录路由类型,比把所有分支藏在一个不可观察的模型提示里更容易维护。
当评测提示漏检时,不要立刻放宽权限过滤。应先检查标注身份与当前权限是否一致、文档是否已删除、索引是否包含预期版本。正确拒绝访问在业务上是成功,即使它降低了某个没有考虑权限的相关性指标。检索系统的完成标准始终是找到当前用户可使用的足够证据,而不是从全库找到最相似的所有文字。
验收与自测#
验收应能给出各阶段候选 ID 和分数、正确处理零向量与维度错误、过滤越权及失效文档,并至少用十个标注问题计算检索指标。真实 embedding 质量、近似索引召回率与在线延迟尚未验证。
- 问:余弦零点九是否等于百分之九十可信?答:不是,它只是选定表示空间里的相似度。
- 问:相同维度的不同模型向量能直接混查吗?答:通常不能,空间语义可能不同。
- 问:重排序能补回没被召回的资料吗?答:不能,需要先修复候选召回。
本章示例验证记录#
已离线运行手工向量融合、BM25 与检索评测三个程序,验证排序、非法参数、空结果、重复候选、截断指标及权限过滤顺序。真实中文分析器、向量模型、近似索引和远程重排序质量未联调。
官方资料#
进一步阅读 OpenAI Embeddings、Elastic RRF 与 OpenAI Retrieval。示例算法与真实搜索引擎能力有意分开标注。