针对 OpenAI 关于嵌入的口头机器学习设计题的完整解答:双编码器与交叉编码器、InfoNCE 损失、批内负样本与难负样本、假负样本、温度参数、ANN 服务、混合检索,以及 pointwise、pairwise 和 listwise 重排序。

由 The Forward Deployed 编辑团队 审阅

本文属于 OpenAI 系统设计题库。该题目能代表该轮面试的水平。分析与解答为本站原创。

问题描述

这一轮是口头面试。通常没有画图工具,面试官往往从事搜索相关工作。预期他们会沿着你的训练目标一路追问到细节:具体的损失函数、把 batch 翻倍会有什么变化、负样本如何选取,然后再深入服务端:近似搜索、混合检索和重排序。不要求有深入的 RAG 产品经验,但要求熟练掌握检索的基础知识。

澄清问题

  • 搜索的对象是什么? 练习设定:一个包含 1 亿条来自文档和帮助文章的段落语料库,语言为英文。
  • 查询长什么样? 较短,通常不超过十个词,既有自然语言问题,也有精确的关键词。
  • 延迟要求? 检索在 p95 下低于 100 毫秒,包括重排序。
  • 有哪些标注数据? 带点击的搜索日志、一些问答对,以及一个小规模的人工标注集。
  • 这是给 RAG 用还是给人用? 两者都有。RAG 最关心前 10 到 20 的召回率;人最关心前 3。

嵌入搜索为什么难

两股力量相互牵制。

模型在搜索时必须快。这就要求使用双编码器:查询和文档分别编码,文档离线编码,这样搜索就变成最近邻查找。但分开编码意味着模型从未同时看到查询和文档,这限制了它判断相关性的精确度。

而且训练信号很稀薄。大多数文档从未针对大多数查询被标注过。模型主要从被告知是错误的内容中学习,也就是负样本,而负样本的选取决定了模型学到什么。

因此核心矛盾是速度与精度,通过一条流水线来解决:用快速的双编码器保证召回,再用慢而精确的重排序器确定最终排序。

flowchart LR Q([Query]):::user --> E[Query encoder]:::svc --> ANN[(ANN index<br/>100 M vectors)]:::store ANN -->|top 100| R[Cross-encoder reranker]:::svc -->|top 10| OUT([Results]):::user D[Documents]:::store -->|offline| DE[Document encoder]:::svc --> ANN classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 训练双编码器以保证前 100 的召回率,再让交叉编码器修正排序。负样本决定了双编码器学到什么。

关键概念

双编码器与交叉编码器

双编码器(bi-encoder)使用相同或孪生的网络将查询映射为一个向量,将文档映射为一个向量。相关性就是二者的余弦相似度。文档只需离线嵌入一次。

交叉编码器(cross-encoder)将查询和文档作为一个输入并输出一个分数。查询中的每个词元都可以关注文档中的每个词元,因此准确度高得多。它在搜索时必须对每个查询-文档对运行一次,因此只能扩展到较短的候选列表。

后期交互

一种折中方案是每个词元保留一个向量,而不是每个文档一个向量,并用查询词元与文档词元之间最大相似度之和来打分。它比单一向量更精确,比交叉编码器更便宜,代价是索引规模大得多。

对比学习

模型通过对比来学习:将查询拉向其正例文档,并推离负例文档。损失函数只关心相对相似度,而这正是排序所需要的。

k 处召回率

相关文档出现在前 k 个结果中的查询占比。对于为重排序器提供输入的第一阶段检索器来说,100 处召回率才是关键指标。重排序器无法提升一个从未被检索到的文档。

核心思想。 双编码器负责扩展,交叉编码器负责判断,而对比训练则教会双编码器什么应该排在什么之上。

需求

在继续阅读之前。 检索系统必须做到什么?对于第一阶段,你会优化哪个指标?

1.1 功能需求

  • 从日志和标注对中训练一个嵌入模型。
  • 嵌入 1 亿个段落,并在段落变化时保持索引更新。
  • 使用稠密检索和关键词检索为查询获取候选。
  • 对候选进行重排序并返回前 10 个。
  • 进行离线和在线评估,并比较模型版本。

1.2 非功能需求

  • 100 处召回率高于目标,例如在标注集上达到 95%。
  • 延迟。 端到端 p95 低于 100 毫秒。
  • 索引成本。 向量索引必须控制在合理的内存预算内。
  • 刷新。 新段落需在几分钟内可被检索。

1.3 约束与属性

第一阶段召回率是属性。 检索器漏掉的任何东西都将永远丢失。延迟是约束。 它决定了我们必须使用双编码器、近似索引和较短的重排序列表。

粗略估算

2.1 索引内存

1 亿个段落 × 768 维 × 4 字节 = float32 下约 307 GB。float16 下约 154 GB。若使用乘积量化(每个向量 64 字节),编码约 6.4 GB,再加上图或列表结构的开销。假设量化会损失一些召回率,而重排序阶段可以挽回大部分。

2.2 嵌入语料库

如果一块 GPU 每秒嵌入 3,000 个段落,那么 1 亿个段落大约需要 33,000 秒,在一块 GPU 上略超过 9 小时,在 10 块 GPU 上则不到 1 小时。每次更换模型时重新嵌入整个语料库成本高昂,但可行。要为此做好规划,因为新模型会改变每一个向量。

2.3 重排序成本

使用约 1 亿参数的交叉编码器对每个查询的 100 个候选进行重排序,每对约 256 个 token,即每个查询 25,600 个 token。在 GPU 上批量处理时,这只需几十毫秒。重排序 1,000 个候选时,延迟会增加十倍。这就是为什么列表长度是 100。

2.4 训练样本

包含 5,000 万条点击的查询-段落对的搜索日志,对于微调来说已经绰绰有余。以 4,096 对为一个批次,遍历一遍数据大约需要 12,000 步。

核心思想。 量化让索引变得可负担,重新嵌入是一项计划内的成本,而重排序列表的长度由延迟决定。

API 设计

3.1 搜索服务

POST /v1/search
  {query, k: 10, filters: {...}, mode: hybrid|dense|keyword}
  -> {results: [{passage_id, score, snippet}], debug?: {dense_rank, keyword_rank, rerank_score}}

3.2 索引维护

POST /v1/passages:upsert  [{passage_id, text, metadata}]
POST /v1/passages:delete  [passage_id]
POST /v1/index/versions   {model_version}   -- build a new index for a new model
POST /v1/index/versions/:v:activate

新的嵌入模型意味着要并行构建一个新索引,经过评估后再启用。来自两个模型的向量不能混用。

数据模型

training_pair (query, positive_passage_id, source: click|qa|nli|synthetic, weight) hard_negative (query, passage_id, miner: bm25|model_v3, teacher_score) passage (passage_id, text, metadata, updated_at) vector_index (model_version) passage_id -> vector keyword_index passage_id -> terms eval_query (query, relevant_passage_ids[], graded_relevance[])

高层设计

5.1 仅用关键词搜索

BM25 根据词频和词的稀有程度对文档打分。它速度快、无需训练,并且在精确词匹配上表现出色。但它无法理解语义:"can I bring my dog to work" 找不到"宠物政策"页面。

5.2 修复 1:使用批内负样本训练的双编码器

在查询-段落对上训练双编码器。对于批次中的每个查询,它自己的段落是正样本,批次中的其他所有段落都是负样本。搜索就变成了 ANN 查找。

flowchart LR B[Batch of B pairs]:::svc --> QE[Encode queries]:::svc B --> PE[Encode passages]:::svc QE --> S[B x B similarity matrix]:::new PE --> S S --> L[Softmax over each row:<br/>diagonal is the positive]:::new classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

基于语义的召回实现了。但模型仍然会混淆共享同一主题的段落,因为随机的批内负样本太容易区分了。

5.3 修复 2:难负样本

为每个查询添加一些看起来相关但实际上不相关的段落:BM25 排名靠前的结果,或早期模型排名靠前但不是标注正样本的结果。这样模型就必须学习细微的区分。

5.4 修复 3:混合检索

让 BM25 与稠密检索并行运行,并融合两个列表。精确词查询恢复了它们的精确性。

5.5 修复 4:重排序器

用交叉编码器对融合后的前 100 条结果进行重排。前 10 条的质量会显著提升,而这正是用户和 RAG 提示词所看到的部分。

5.6 组合后的系统

sequenceDiagram
  autonumber
  participant C as Client
  participant S as Search service
  participant QE as Query encoder
  participant ANN as Vector index
  participant KW as Keyword index
  participant RR as Cross-encoder
  C->>S: query
  par
    S->>QE: embed query
    QE-->>S: vector
    S->>ANN: top 100 by cosine
  and
    S->>KW: top 100 by BM25
  end
  S->>S: reciprocal rank fusion
  S->>RR: rerank fused top 100
  RR-->>S: scores
  S-->>C: top 10

深度剖析

6.1 InfoNCE 损失

在继续阅读之前。 在白板上写出这个损失函数,并说明每一项的作用。
For query q, positive passage p+, and negatives p1 ... pN:

            exp( sim(q, p+) / t )
L = -log  ---------------------------------------------
          exp( sim(q, p+) / t ) + sum_i exp( sim(q, pi) / t )

sim = cosine similarity of L2-normalized vectors
t   = temperature

可以把它理解为对候选段落的一个 softmax 分类器:这些段落中哪一个是正例?损失就是选错(即正确选项的负对数概率)。梯度会把 q 拉向 p+,并把它推离每个负例,推力大小取决于该负例当前的相似程度。模型已经排在正例远之后的负例几乎不贡献任何梯度。难负例——也就是模型会混淆的那些——主导了梯度。

这个损失通常会被做成对称形式:同时对每个段落判断它属于哪个查询,然后对两个方向取平均。

6.2 批量翻倍时会发生什么变化

在继续阅读之前。 你把批量大小从 4,096 翻倍到 8,192。损失、梯度和最终模型会发生什么变化?

使用批内负例时,每个查询现在要面对 8,191 个负例,而不是 4,095 个。任务更难了,所以在相同模型质量下损失值会上升。批中出现难负例的概率也上升了,因此梯度携带更多有用的信号。经验上,对比学习模型会随批量增大而提升,这就是为什么训练要把一个批次分散到多个 GPU 上,并在计算相似度矩阵之前跨设备汇聚嵌入向量。

代价包括:B × B 相似度矩阵的内存开销、汇聚嵌入的通信开销,以及假负例(即批内实际上彼此相关的样本对)出现概率的升高。

6.3 难负例与假负例

在继续阅读之前。 你把 BM25 排名前 20 的结果挖掘为难负例,但其中一些实际上是相关的。会发生什么,又该如何修复?

模型被告知一个相关段落是错的,于是把它推开。当这样的假负例很多时,恰恰在那些有多个正确段落的查询上,召回率会下降。

修复方法,按强度排序:

  • 跳过挖掘结果中最靠前的几条,它们最有可能是真正的正例。
  • 用交叉编码器教师模型对挖掘出的负例打分,丢弃任何得分接近正例的负例。
  • 把教师模型的分数用作软标签:将交叉编码器蒸馏到双编码器中,这样一段文本可以是部分相关,而不是被强制归为零。

随着模型改进,刷新挖掘出的负例。旧模型挖掘出的负例对新模型来说会变得太容易。

区分答案水平的关键:负例

弱:仅使用随机负例

使用随机或批内负例进行训练,无法解释模型为什么会混淆相似的段落。

良好:批内负例加上挖掘的难负例

能解释难负例如何驱动梯度,并从 BM25 或早期模型中挖掘难负例。

强:能管理假负例

使用交叉编码器过滤挖掘出的负样本,随着模型改进不断刷新它们,并将教师分数蒸馏到双编码器中。

6.4 温度

温度 t 在 softmax 之前对相似度进行缩放。较低的温度(约 0.01 到 0.05)会使分布更尖锐:损失集中在少数最难的负样本上,这有助于区分近似重复项,但可能使训练不稳定并放大假负样本。较高的温度(接近 1)会将注意力分散到所有负样本上,缓慢地学习粗粒度结构。许多方案将 t 作为参数学习,或在验证集上对其进行调优。

6.5 正样本对的来源

来源优势注意事项
被点击的搜索结果数量丰富,匹配真实查询位置偏差:用户点击的是排名靠前的结果
文档的标题和正文免费,覆盖每个文档标题不是查询
问题与被采纳的答案接近真实意图领域有限
自然语言推理数据标签干净;矛盾样本可构成难负样本与搜索领域不同
同一句子的两个 dropout 掩码无需标注信号弱;仅可作为起点
由 LLM 生成的合成查询覆盖每个段落可能太简单或与段落过于相似

一个常见的方案是先在大量含噪的样本对上预训练,然后在更干净的领域内样本对上结合难负样本进行微调。

6.6 服务:ANN 索引

在继续阅读之前。 HNSW 如何在不与每个向量比较的情况下找到近邻?它有哪些可调参数?

HNSW 构建一个分层图。每个向量与少量近邻相连。上层稀疏,像一条快速通道;下层稠密。搜索从顶层开始,贪心地朝查询方向行进,下降一层,然后重复。它只访问极小一部分向量。

两个参数很重要。每个节点的连接数会提升召回率和内存占用。查询时的搜索广度会提升召回率和延迟。调整它们,以便在延迟预算内达到召回率目标。

对于非常大的语料库,采用乘积量化的倒排文件索引可进一步减少内存:对向量聚类,只搜索最近的簇,并存储压缩编码。召回率会略有下降,使用完整向量或交叉编码器进行重排可以将其恢复。

6.7 混合融合

稠密检索分数与 BM25 分数处于不同的量级,因此直接相加需要调优。倒数排名融合可以避免这一点:对每个段落,按它在各列表中出现的 1 / (k + rank) 之和打分,k 约为 60。在任一列表中排名靠前的段落分数会上升;在两个列表中都靠前的段落分数上升最多。它无需校准,因此是常见的默认选择。

6.8 重排:逐点、逐对、逐列表

在继续阅读之前。 比较这三种方法。什么时候你会从逐点方法转向逐列表方法?

在问题抛出之前就准备好答案。在这里给出模糊的回答是面试中常见的失分点。

系列训练信号优点缺点
逐点(Pointwise)每个查询-文档对被标注为相关或不相关;预测该标签简单、可并行、易于标注分数在不同查询之间不可比;忽略列表中的其他文档
逐对(Pairwise)两个文档;预测哪个更好学习相对顺序,而这正是排序所需要的每个查询产生大量文档对;排在第 50 位的错误与排在第 1 位的错误代价相同
逐列表(Listwise)整个列表;优化诸如 nDCG 之类的排序指标与指标一致;对靠前的位置赋予最高权重更复杂;LLM 逐列表重排器受上下文长度限制

一个务实的答案:从逐点交叉编码器开始,因为它强大且训练成本低。当靠前的几个位置承载了大部分价值时(例如只接收五个文档的 RAG 提示),以及当文本之外的特征很重要时(新鲜度、热度、权威性),再转向逐列表方法。采用逐列表目标的梯度提升排序器在这些场景中仍然常见。LLM 可以一次对 20 个文档进行逐列表重排,对更长的列表使用滑动窗口,但成本要高得多。

区分答案水平的关键:重排序

弱提到了重排器

添加了重排器,但没有解释它为什么优于双编码器,也没有说明它是如何训练的。

良好解释了三种系列

比较逐点、逐对和逐列表方法,并选择逐点作为默认方案。

强根据靠前位置的价值来选择

从产品出发选择系列(面向用户还是只接收五个文档的 RAG 提示),在有帮助的地方加入非文本特征,并将 LLM 逐列表重排的成本与其收益进行权衡。

6.9 评测

离线,在留出的标注集上:第一阶段用 recall@100,最终列表用 nDCG@10 和 MRR。按查询类型切片:精确词查询、长自然语言问题、冷门主题。平均值会掩盖用户抱怨的那些切片。

在线:点击率、到成功点击的时间,以及改写率——结果不好时改写率会上升。对于 RAG,要衡量下游的答案质量,因为只有答案改善时,更好的检索才有意义。切换模型之前先做 A/B 测试。

6.10 用代码实现训练循环

在继续阅读之前。 写出使用批内负样本的双编码器一次训练步骤的核心代码。
import torch
import torch.nn.functional as F

def training_step(query_encoder, passage_encoder, queries, positives, hard_negs, tau=0.05):
    # queries: B inputs; positives: B inputs; hard_negs: B*H inputs
    q = F.normalize(query_encoder(queries), dim=-1)          # B x d
    p = F.normalize(passage_encoder(positives), dim=-1)      # B x d
    n = F.normalize(passage_encoder(hard_negs), dim=-1)      # (B*H) x d

    candidates = torch.cat([p, n], dim=0)                    # (B + B*H) x d
    logits = q @ candidates.T / tau                          # B x (B + B*H)
    labels = torch.arange(q.size(0), device=q.device)        # positive i sits at column i
    return F.cross_entropy(logits, labels)

逐步讲解。每个查询的正样本位于候选列表中它自己的索引处;批内所有其他正样本,以及每个挖掘出的困难负样本,都作为负样本。对每一行做交叉熵正是 InfoNCE 损失。将 B 翻倍会增加列数,也就是每个查询有更多负样本,这正是 6.2 节讨论的效果。

在多 GPU 训练中,在构建

p

和

n

之前先从所有设备收集

candidates

,这样每个查询都能看到来自整个全局批次的负样本。这正是内存和通信开销的来源。

6.11 选择嵌入维度

更大的维度在每个向量中存储更多信息,通常能略微提升召回率,但也会让索引线性增长并拖慢每次距离计算。实际操作中:从 768 维升到 1,536 维,1 亿段文本的 float16 索引会从约 154 GB 翻倍到约 307 GB。

有两种技术可以缓解这一权衡。一是用嵌套目标训练,使前 256 维本身就有用,然后第一阶段存储短向量,再用完整向量对候选重新打分。二是量化:第一阶段使用 8 位甚至二进制编码,对前几百个候选进行全精度重打分。在同一评测集上测量每个方案的 recall@100,选择满足召回目标的最小表示。

区分答案层次的关键:实现

弱:只用文字描述损失

无法写出该步骤,也无法解释负样本如何进入批次。

良好:能写出该步骤

构建相似度矩阵,将正样本放在对角线上,然后应用交叉熵。

强:能扩展并合理设定规模

跨 GPU 汇聚嵌入以获得全局负样本,解释内存开销,并根据每字节实测召回率来选择维度和量化方案。

变体

7.1 多语言搜索

用平行语料训练,使一种语言的查询能找到另一种语言的段落。按语言分别检查召回率;高资源语言会在平均值中掩盖弱语言。

7.2 代码搜索

代码包含精确的标识符和结构。关键词搜索的权重更高,按函数切分优于固定窗口。

7.3 无标注的领域适配

用 LLM 为领域内段落生成合成查询,用交叉编码器过滤,然后在其上微调。在专业语料上往往优于通用模型。

7.4 语料规模扩大十倍时

达到 10 亿段文本时,仅 float16 索引就约 1.5 TB。第一阶段要激进量化,并按段落 ID 将 ANN 索引分片到多台机器上,并行查询所有分片再合并。为换用新模型而重新嵌入整个语料会变成多天的批处理任务,因此应在后台运行,并行构建新索引,仅在评测通过后切换。在新索引服务真实流量且无回退之前,保留旧索引。

可迁移的模式

嵌入搜索是先廉价的召回,后昂贵的精度。快速模型把数百万缩小到一百个,慢速模型对这一百个排序。同样的级联结构也用于推荐系统、广告排序和欺诈检测。可迁移的技能是知道哪个阶段负责哪个指标:第一阶段负责召回,最后阶段负责头部精度。

复习:30 秒答案

  • 双编码器负责召回,交叉编码器负责排序。 首先优化 recall@100。
  • 使用批内负样本的 InfoNCE。 更大的批次提供更多、更难的负样本。
  • 挖掘困难负样本,过滤其中的假负样本。 使用交叉编码器教师模型,并进行蒸馏。
  • 服务部署用 HNSW 或 IVF-PQ。 根据延迟预算调整链接数和搜索广度。
  • 混合检索加排序融合,然后重排。 默认用逐点式,当前几位排序最关键时用列表式。

测验

+为什么批内负样本基本上是免费的?

批次中其他段落本来就已经为各自的查询编码过了。将它们复用为负样本只需额外计算一个相似度矩阵,不需要额外的前向传播。

+把批次翻倍时损失值会发生什么变化,这是坏事吗?

损失会上升,因为每个查询现在面对两倍的负样本,任务更难了。这不是坏事:更难的任务带来更有信息量的梯度,最终检索质量通常会提升。

+为什么困难负样本主导梯度?

在 softmax 中,每个负样本的拉力按其当前与查询的相似度加权。简单负样本的概率已经极小,几乎不产生贡献;令人混淆的负样本承担了大部分梯度。

+什么是假负样本,为什么它有害?

假负样本是被标注或挖掘为负样本、但实际上相关的段落。训练会把它推离查询,这恰恰在多个段落都正确的情况下降低了召回率。

+什么时候列表式重排值得付出代价?

当前几位排序承载大部分价值时,例如只使用五个段落的 RAG 提示,以及当排序目标应当重点加权列表顶部时。否则,逐点式交叉编码器是更便宜的默认选择。

+在训练步骤中,负样本从哪里来?

来自批次中其他每个查询的正样本,加上追加到候选列表中的挖掘出的困难负样本。每个查询自己的正样本位于其索引处,对该行做交叉熵就是 InfoNCE 损失。

+如何在不损失太多召回率的情况下减小索引大小?

第一阶段存储更短或量化的向量,然后用全精度向量或交叉编码器对顶部候选重新打分。测量 top-100 召回率,选出满足目标的最小表示。

参考资料与延伸阅读

NextStreaming Chat Interface