# 向量、混合检索与重排序

## 检索的任务是找到足够的证据

用户输入“我买的 S-42 能退吗”，相关材料也许写的是“七日内可申请售后”，没有出现“能退”两个字。语义检索能帮助连接不同措辞；但产品编号、版本号和错误码通常又需要精确匹配。本章用于建立组合检索的判断方法。达标后，你应能解释每一阶段的输入输出，运行一个离线排序实验，并用标注问题集定位漏检。

前置是文档切块、版本与权限模型。先不要把“有向量数据库”当成“有高质量知识库”。存储解决索引和查询能力，是否找到正确证据取决于数据、表示、过滤、候选规模与排序策略。

## embedding 表示相似性，不表示事实真假

embedding 把文本映射成数值向量，让语义相近的内容在某种空间里距离更近。向量每一维通常不对应可直接命名的业务字段。不能把第一维叫“退款程度”，也不能把某个距离直接解释成“答案正确率百分之九十”。

文档与查询必须使用兼容的 embedding 模型、维度与预处理方法。维度一致只是必要条件，不同模型即使输出同样长度，空间也不一定可以比较。更换模型通常需要重建文档向量，或维护独立版本的索引并在完成后切换，不能只更新查询端。

在 OpenAI embedding 接口中，model 选择模型，input 提供字符串或批量输入，encoding_format 可指定 float，部分型号支持 dimensions。返回 data 中有 embedding 和 index，批量结果应按 index 对应输入，不能在分批并发后靠数组到达顺序拼接。[官方向量文档](https://developers.openai.com/api/docs/guides/embeddings)

常见相似度包括余弦、点积和欧氏距离。余弦比较方向，公式是点积除以两个向量长度。零向量会让分母为零，维度不等或存在 NaN 也必须拒绝。是否需要预先归一化取决于模型与索引实现，不应复制别处代码后忽略数据约定。

## 三个阶段承担不同职责

召回先追求“有用材料不要漏掉”。关键词召回擅长精确名称和稀有词，BM25 等算法还考虑词频与文档长度；向量召回擅长相近语义。混合检索把两者候选合并，不要求两个原始分数处于相同尺度。

RRF 按排名而非原始分数融合，常见计算方式是为每个候选累加 1/(常数+名次)。在多路列表都靠前的候选更容易上升。常数控制头部名次差异的影响，不是置信度阈值。实际搜索产品对候选窗口与参数有自己的定义，需按官方实现核对。[Elastic RRF 文档](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion)

重排序在较小候选集合上更细致地比较查询与片段。有的系统使用交叉编码模型，有的加入产品编号、日期与来源权威性规则。它只能重排已经召回的材料，无法挽救完全没进入候选的关键条款。把重排序预算加倍，却不检查召回率，可能花更多钱仍然漏答。

## 完整示例：两路召回与编号重排序

环境：Node.js 22；文件 `retrieval.mjs`；无依赖；执行 `node retrieval.mjs`。所有二维向量均为手工 fixture；关键词计数不是 BM25，最后的编号提升规则也不是神经重排序模型。本例只帮助你看清数据如何流动。

```js
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，未知词返回空列表，参数错误被断言捕获。

```js bm25.mjs
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](https://lucene.apache.org/core/10_3_1/core/org/apache/lucene/search/similarities/BM25Similarity.html)。

这个结果里 a 排在 b 前面，并不证明 b 不重要。用户问“能不能退”需要一般期限，也需要激活例外；b 稍长因此分数略低，但它可能是防止误答的关键。检索排序优化不能只追求第一名看起来最像问题，还要考虑最终答案所需事实的覆盖。这也是为什么下游需要多条证据，而不是只把第一条交给模型。

## 向量索引和相似度各自承担什么

向量模型产生表示，向量索引负责从大量表示中快速找近邻。对几百条向量逐个计算余弦很直观，但语料达到百万条时会消耗更多时间与内存，近似最近邻索引用一定召回损失换速度。索引参数影响图搜索范围、候选数量和资源占用，具体名字与默认值依赖存储实现，不能把一个产品的参数搬到另一个产品上。

应把近似索引的损失与 embedding 的语义能力分开测。先在可控子集上做精确搜索，作为同一向量空间的基线；再比较近似索引能否找回精确搜索的近邻。若近似结果接近精确结果，但业务相关文档仍然没出现，问题可能是表示、切块或语料，而不是继续增大索引搜索参数。这种对照能避免在错误层面花资源。

距离也有方向约定：相似度通常越大越相近，距离通常越小越相近。部分引擎会对内部距离做变换后返回分数。把所有名为 score 的字段都降序排序，可能正好反了；把余弦阈值用于欧氏距离也没有意义。适配器应标明 metric、scoreMeaning 与排序方向，内部融合尽量使用经过统一的排名或明确的归一化策略。

向量长度与数值检查应发生在写入和查询两侧。少一维、包含 NaN、来自旧模型或输入为空，都应形成可定位错误。批量计算还要保留输入 ID 与返回 index 的关联，防止并发完成顺序把 A 文档向量存到 B 文档名下。这样的错配不会一定触发数据库异常，却会让检索表现像随机失败，非常难从最终答案反推原因。

## 混合检索的候选窗口与重排序预算

可以把每一路召回看成一个有界列表，先在当前身份和版本范围内取候选，再按稳定文档身份去重融合。RRF 的参数常数改变名次差距的影响，候选窗口决定每一路有多少机会贡献结果；两者不是同一个开关。若关键词列表只有十条而向量列表有一百条，融合输出会受窗口差异影响，评测时应记录这些配置。

使用排名融合的好处是不必直接比较余弦零点八与关键词分数十七，但它也丢掉了原始分数差距。两个结果虽然分别排第一和第二，第一名可能领先很多，也可能几乎打平。若后续业务要求精确置信度，需要独立校准，而不能把融合分数重新命名为“可信度”。检索分数只表达排序依据，不是答案正确概率。

重排序输入通常包含问题和候选文本对，可以比独立向量表示更细致地比较条件与否定关系。代价是每个候选都要额外处理，所以通常先召回较宽候选，再对较小集合重排。真实参数取舍要看候选数、块长度、模型成本和端到端延迟，不能只比较重排序接口本身的耗时。

规则可以补充明确的业务约束。例如查询明确指向 S-42 时，标记为 S-99 的片段应降权或剔除；但没有产品元数据的通用售后政策仍可能有用。不要把“文本里必须出现编号”写成无条件规则，否则通用条款全部丢失。最好在入库时建立适用范围元数据，让排序不必从每段自然语言里猜产品归属。

## 练习：让检索评测暴露遗漏与误召回

建立一个评测器，输入每个问题的独立相关集合和候选顺序，计算 Recall@k、Precision@k、MRR；没有答案的问题单独统计是否错误召回，不把空集合的召回率强行算成一。再验证权限过滤一定发生在截取前 k 条之前。提示是先确定指标分母，再处理去重和空集合，不能让重复返回同一个块刷高分。

<details><summary>参考答案：完整指标与权限顺序实验</summary>

Node.js 22，无需安装。保存为 `retrieval-eval.mjs`，执行 `node retrieval-eval.mjs`，预期平均召回率为零点七五，平均 MRR 为零点七五，无答案误召回率为一。这个坏成绩是故意保留的 fixture，用于验证评测器不会替系统美化结果。

```js retrieval-eval.mjs
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);
```

</details>

这个实现的 Precision 分母是实际返回条数，上限为 k。有些评测协议采用固定 k 作分母，尤其希望惩罚少返回结果时；两种约定都需要写清楚，不能直接横向比较。这里的 MRR 也只观察前 k 条，因此更准确地说是截断 MRR。把指标名字、截断范围与空集合规则记录下来，才能让另一个工程师复现数字。

权限实验不意味着可以先把全库敏感正文下载到应用再过滤。代码只用三个无敏感内容的元数据对象演示顺序差异；真实查询应尽量让过滤进入数据库检索计划，原文展开时再次授权。评测样本的 gold 也必须与测试身份匹配，否则把用户无权看的文档标成“漏检”，会反过来鼓励不安全的检索策略。

## 如何建立足够可信的检索评测集

标注时不要只保存唯一 chunkId。切块策略更新后，同一事实可能换了块编号，指标会把正确召回误判成失败。可以保存文档版本、原文范围和必要事实标识，再映射到本次索引块；仍要保留严格的版本边界，因为新版删除的旧条款不能继续当正确证据。标注成本较高，但它让后续策略比较有共同依据。

问题分组比总体平均数更能指导修改。产品编号类适合观察精确召回，同义问法类适合观察语义表示，跨段条件类适合观察覆盖，权限类适合观察误暴露，资料更新类适合观察旧版本残留。总体提高一点点时，某个高价值问题组可能显著变差；发布前应看每组变化和具体失败案例，不能只看一个总分。

阈值校准需要包含无答案样本。若测试集里每个问题都有答案，系统总能返回一些内容，看起来召回很好；真实用户问到资料没有覆盖的新产品时，同样策略会把邻近产品的规定塞进上下文。应观察有答案召回与无答案误召回的权衡，再决定何时继续检索、追问或返回资料不足。不同资料域可能需要不同策略，而不是全库共用一个神奇阈值。

最后把检索评测与生成评测分开保存。检索输入是问题、身份、版本和索引配置，输出是证据集合与排序；生成输入是固定证据包，输出是答案。先证明正确证据能进入预算，再判断模型是否忠于证据。否则换了生成模型恰好蒙对答案，可能掩盖检索仍然缺失关键条款的问题。

## 一次候选追踪应该留下哪些信息

排查“正确条款不见了”时，可以为每次查询记录阶段快照：过滤后的候选范围、各召回通道的文档编号及名次、融合名次、重排序名次、最终进入上下文的编号。分数可以保存必要精度，但不必把每个敏感正文都写进普通日志。通过同一个查询关联号把这些快照串起来，才能区分是从未召回、被排序淘汰，还是被上下文预算裁掉。

一个有价值的失败实验是逐步缩小候选窗口。先固定索引、模型和问题集，只改变每路候选数量，画出召回与耗时的变化；再固定候选，比较是否开启重排序。如果两项同时改变，性能变好时很难判断谁带来收益，性能变差时也难定位原因。离线评测应尽量控制变量，线上试验则观察实际问题分布是否与离线样本一致。

没有必要在第一版就引入复杂的自适应检索。先让常见问题有可解释的候选路径，再考虑根据查询类型分配预算。例如精确错误码可以优先查编号字段，再补语义背景；开放式流程问题可以提高章节级覆盖。路由器本身也可能判断错误，所以保留退路并记录路由类型，比把所有分支藏在一个不可观察的模型提示里更容易维护。

当评测提示漏检时，不要立刻放宽权限过滤。应先检查标注身份与当前权限是否一致、文档是否已删除、索引是否包含预期版本。正确拒绝访问在业务上是成功，即使它降低了某个没有考虑权限的相关性指标。检索系统的完成标准始终是找到当前用户可使用的足够证据，而不是从全库找到最相似的所有文字。

## 验收与自测

验收应能给出各阶段候选 ID 和分数、正确处理零向量与维度错误、过滤越权及失效文档，并至少用十个标注问题计算检索指标。真实 embedding 质量、近似索引召回率与在线延迟尚未验证。

1. 问：余弦零点九是否等于百分之九十可信？答：不是，它只是选定表示空间里的相似度。
2. 问：相同维度的不同模型向量能直接混查吗？答：通常不能，空间语义可能不同。
3. 问：重排序能补回没被召回的资料吗？答：不能，需要先修复候选召回。

## 本章示例验证记录

已离线运行手工向量融合、BM25 与检索评测三个程序，验证排序、非法参数、空结果、重复候选、截断指标及权限过滤顺序。真实中文分析器、向量模型、近似索引和远程重排序质量未联调。

## 官方资料

进一步阅读 [OpenAI Embeddings](https://developers.openai.com/api/docs/guides/embeddings)、[Elastic RRF](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion) 与 [OpenAI Retrieval](https://developers.openai.com/api/docs/guides/retrieval)。示例算法与真实搜索引擎能力有意分开标注。