面试知识库

05 · 检索模型微调#

简历原话:检索模型微调:针对召回漏召正例、精排排名靠后的问题,对 embedding 模型以 ESCI 人工标注做对比学习,reranker 模型用分级标签做 listwise 训练。R@100 +12.6%,NDCG@100 +14.4%,端到端 Rubric 均分 50.4→58.0。

30 秒口述版#

ShoppingX 的检索是两段:BGE-M3 向量召回,再用 BGE-Reranker 精排。开箱模型在我们的商品库上有两个问题:一是召回漏掉真正相关的商品,二是正例进了候选池却排不到前面。我用亚马逊公开的 ESCI 人工标注数据,按 ASIN 和自己的库对上,拿到约 12.7 万个商品、5.4 万条有正例的 query。embedding 侧做对比学习:InfoNCE 加 in-batch 负例和挖出来的难负例,用 cross-encoder 把「假负例」筛掉;最有效的一刀是把每条 query 的全部正例都展开用上。reranker 侧把 ESCI 的 E/S/C/I 四档变成 3/2/1/0 分级标签,做 listwise 交叉熵,再加一路 pointwise BCE 保分数校准。在 138 万全库、1.1 万条标注 query 上评测,R@100 +12.6%,NDCG@100 +14.4%;两个模型换上线后,33 条种子集的端到端 Rubric 均分从 50.4 到 58.0。

背景与问题#

检索链路长什么样:用户一句话经 planner 拆成检索词,主循环同轮对几个平台各发一次商品检索,每个平台从 Qdrant 召回约 30 件;合流后在精挑环节用 reranker 给候选打相关性分,决定排序和哪些商品是跨品类混进来的要沉底,最后收尾出 3 件推荐。所以召回决定「有没有」,精排决定「排不排得上」。

原来的问题,具体现象:

  • 漏召正例:用户搜「保温杯 大容量」,库里明明有带「vacuum insulated tumbler 40oz」的商品,却没进前 30,进来的是标题里恰好有「cup」的杯垫、杯刷。开箱 BGE-M3 是通用语料训的,对电商标题这种短、堆关键词、品牌型号多的文本不敏感。拿 ESCI 标注一量:全库 138 万候选下,R@100 只有 0.443,一半以上的人工正例在前 100 名里找不到。
  • 排名靠后:R@1000 有 0.70,R@20 只有 0.26。也就是说很多正例其实已经被捞进大池子,只是排在后面。线上每个平台只取 30 件,排在 31 名的正例等于没召回。精排这一层用的也是开箱 BGE-Reranker,它对「真背包」和「背包上的刺绣贴片」经常打出很接近的分,同品类里的主次关系排不出来。
  • 端到端表现:Rubric 评测里一类高频 bad case 是「清单里混进配件或不对品类的东西」「明明有更合适的却没推出来」,定位下来根子都在检索这两段。

为什么是现在做、为什么是这两个模型:Agent 这边的编排、Harness、提示词调了几轮以后,剩下的失分集中在「候选本身不对」。提示词再怎么写也变不出没召回的商品,所以要动模型。项目用的是现成开源模型、有一张 A100 可用,微调 embedding 和 reranker 是成本最低、收益最直接的两处。

做法与取舍#

1. 训练数据:用 ESCI 人工标注,不靠 LLM 合成#

  • 做法:ESCI 是亚马逊公开的购物搜索数据集,约 180 万条 query–商品标注,每对标成 Exact / Substitute / Complement / Irrelevant 四档。我们的商品库里亚马逊部分本来就带 ASIN,直接按 ASIN join,命中 12.67 万个商品、29.7 万条标注、5.36 万条有 E 档正例的 query。按 query 切训练集和评测集,训练 3.87 万条、评测 1.14 万条,两边 query 重叠为 0。
  • 为什么选它:人工标注是检索训练里最值钱的信号,而且四档标注对 reranker 来说正好就是分级相关性标签,一份数据两个模型都能用。
  • 否掉的方案:① 纯 LLM 合成 query:便宜,但合成的 query 和商品标题字面重合度偏高,模型学到的是「抄词」,而且只有正/负两档,没有 S/C 的层次。合成数据只用来补中文长尾(约 2 万条)。② 用线上点击日志:项目没有真实流量,点击日志也有位置偏差,量不够。

2. embedding:InfoNCE 对比学习 + 难负例 + 假负例过滤#

  • 做法:底座 BGE-M3 全参微调,用 ms-swift 跑 InfoNCE。每条样本是 (query, 正例, 若干负例),负例三类:同 batch 里别人的正例(in-batch)、用原模型在全库检索出来但没标成正例的「难负例」、随机负例。温度 τ=0.02,lr 1e-5,batch 32,max_length 128,bf16 + gradient checkpointing,单卡 A100 40G。
  • 假负例过滤:ESCI 只标了一小部分商品,难负例里有大量「其实相关但没被标注」的商品,直接当负例会把模型往错的方向推。所以挖完难负例后,用 cross-encoder 给每一对打分,分数高于 0.5 的判为疑似正例、从负例里去掉。约 93 万对里去掉了 52%,说明这一步不做问题很大。
  • 最有效的一刀:正例全展开。最早每条 query 只取 1 个正例,其实平均每条 query 有 3.94 个正例,等于扔掉了 74% 的人工标注。改成每个正例各生成一条样本后,训练数据从 3.87 万条变成 14.88 万条,R@100 单这一项贡献了总涨幅的约 45%。
  • 为什么这样选:温度 0.02 是 BGE 官方训练用的值,我也做了对照,改成 0.05 或把 lr 降到 5e-6 都掉 1 个点以上。epoch 数跟数据量绑定:3.87 万条时训 3 轮会过拟合,14.88 万条时 3 轮最好。
  • 否掉的方案:① 加大难负例比例:hard 占比从 16% 提到 71%,主召回反而掉了 1 个点,但「搜手机出配件」这类干扰明显减少。它是拿主召回换近义干扰抑制,不是白涨分,最终选了低 hard 比例的配方,配件问题交给精排和品类门处理。② LoRA:568M 的 encoder 全参只要 13GB 显存,没必要省,BGE 官方也是全参。③ 按 eval_loss 选 checkpoint:loss 和检索指标对不齐,改成每个 epoch 都跑一遍全库检索评测再选。

3. reranker:分级标签 + listwise 交叉熵 + BCE 辅助#

  • 做法:底座 BGE-Reranker-v2-m3 全参,手写训练循环。每组 1 个正例 + 7 个负例,候选来自微调后 embedding 的 top-50 召回池(让训练分布贴近线上)。标签用 ESCI 分级:E=3、S=2、C=1、I 和未标注=0。loss 是 listwise 交叉熵:把 8 个候选的打分做 softmax,目标分布是分级标签的 softmax,而不是 one-hot。另加一路 pointwise BCE,目标是 gain/3,权重 0.3。lr 1e-5,1 epoch,6.27 万组,约 45 分钟。
  • 假负例过滤换成 query 内相对判据:cross-encoder 的绝对分在不同 query 之间不可比,E 档的低分位比 I 档的高分位还低,所以不能用固定阈值。改成:负例分数超过该 query 所有正例的最高分,才判为假负例去掉,去掉比例约 18%。
  • 为什么要分级标签:做了 2×2 对照(二值/分级 × 交叉熵/ApproxNDCG),分级标签是唯一稳定的主杠杆。二值标签把「可替代品」和「完全无关」当成一回事,模型学不到「同品类里谁更好」。
  • 为什么要 BCE 辅助:线上 reranker 分数不只用来排序,还要过一道品类门(低于阈值判为跨品类混入、沉底)。纯 listwise 只管相对顺序,绝对分数会整体漂移,门就失效了。加 0.3 权重的 BCE 后排序没掉,还把绝对分拉回可用的范围。
  • 否掉的方案:① ApproxNDCG 直接优化 NDCG:在二值标签下有用,分级标签下反而更差。原因是增益按 2^g−1 算,E 档是 7、其余很小,每组又只有 1 个 E,loss 几乎只看 E 的位置,退化成 MRR,S/C 之间的顺序学不到。② pairwise(RankNet 式):每组要算 28 对,信息和 listwise 差不多,训练更慢。③ 加深 rerank 深度:候选从 20 加到 1000,池内正例占比 .33→.89,但 top-8 召回率不动,瓶颈在模型判别力,不在候选数量。

4. 上线:两个模型各自怎么换、怎么回滚#

  • embedding 换模型 = 商品侧和 query 侧必须同时换。query 和商品不在同一个向量空间时,Qdrant 不会报错,只是召回静默变成垃圾,这是最危险的地方。做法是:
    1. 在 GPU 上用新模型把全库 138 万商品重编码,导出 fp16 向量(138 万 × 1024 维,约 2.8GB)。
    2. 灌进一个新的 Qdrant collection,灌之前先关掉 HNSW 自动建索引,灌完再一次性建,约 17 分钟,缺向量 0 条。旧 collection 不动。
    3. 新模型包成 OpenAI 兼容的 embeddings 接口,query 编码走它;服务在返回里带模型版本号。
    4. Qdrant 用 collection 别名对外,检索只认别名;切换时把别名从旧 collection 指到新 collection,同时切 query 编码服务的版本。检索层每次请求比对「编码模型版本」和「别名指向的 collection 标注的模型版本」,对不上直接报错,不让它静默跑错。
    5. 回滚就是把别名指回旧 collection、编码服务切回旧版本,秒级完成。
  • reranker 换模型 = 换推理端点 + 重标品类门阈值。reranker 走 Cohere 协议的 rerank 接口,换模型只是换端点和模型名。但品类门阈值和模型强绑定:同一个 0.2,在开箱模型上会误杀 38.5% 的真正例,在自训模型上数字完全不同。所以换模型前,用 ESCI 四档标注对(每档 2000 对)重扫阈值,看两个数:真正例(E)被误杀的比例、完全无关(I)被挡住的比例,选两者的折中点再上线。
  • rerank 的 query 用什么文本:线上最早传给 reranker 的是 planner 判出的粗品类词(如「背包」),而模型是在完整意图句上训的。离线同分母对比:用「品类词 + 硬约束里的普通词」做 query 比单用品类词高约 2.5 个点,所以线上改成这种拼法;不用用户原句,因为原句里的软偏好词(「小众」「好看」)会把跨品类的蹭词商品抬上来。

5. 实验怎么组织:先定尺子,再单变量对照#

  • 尺子先于模型:第一轮训完发现 K=100 时 MRR 几乎全是 0、训练前后差不出来,才把评测改成四条固定口径:全库 138 万候选、K 放到 1000、分母用库内正例数、NDCG 用分级 gain。之后所有组都用这把尺子,数字才可比。
  • 四轮 17 组对照:embedding 一共跑了 15 个训练组,加开箱基线在 GPU 和本机各跑一次,每组训完都跑一遍全库评测、留一份报告。每轮只改一个变量(hard 比例、温度、学习率、正例展开倍数、epoch、课程顺序),下一轮的方向由上一轮的报告决定。
  • 单卡排期:A100 40G 一块卡,embedding 一组 13 分钟到 2.5 小时不等(按数据量和 epoch),整批夜里无人值守跑,早上看报告定下一轮。reranker 一组约 45 分钟,一晚能跑完一个 2×2 对照。
  • 每轮至少看三个数:主召回(R@100)、排序(NDCG@100)、配件混入(complement 命中率,越低越好)。只看主召回会漏掉难负例那组 trade-off。
  • checkpoint 不按 eval_loss 选:对比学习的验证 loss 和检索指标对不齐,第一轮就被「自动按 loss 保留最优」选错过一次。之后每个 epoch 都留、都评,按检索指标选。
  • 两个假说是被对照实验推翻的:一开始我以为「难负例不涨分」是温度没配好,又以为是课程顺序(先易后难)不对,各做一组单变量对照,方向都没变,才确定它本身就是 trade-off。这个过程让我后面对「看起来合理的解释」都先要一组对照。

流程图 / 架构图#

图 1:训练数据与两个模型的训练流程#

flowchart TD
    A["ESCI 标注 约180万对"] -->|按 ASIN join| B["库内命中 12.7万商品 / 29.7万标注"]
    B --> C{按 query 切分}
    C --> D["训练 query 3.87万"]
    C --> E["评测 query 1.14万 与训练零重叠"]

    D --> F["正例全展开 3.87万→14.88万条"]
    F --> G["原版 BGE-M3 全库检索 挖难负例"]
    G --> H["cross-encoder 打分 高于0.5判假负例剔除"]
    H --> I["InfoNCE 对比学习 in-batch+难负例+随机负例 τ=0.02"]
    I --> J["微调后 embedding"]

    J --> K["微调 embedding 召回 top-50 作为 rerank 候选池"]
    B --> L["ESCI 四档 → 分级标签 E3 S2 C1 I0"]
    K --> M["每组 1正+7负"]
    L --> M
    M --> N["query 内相对判据剔除假负例"]
    N --> O["listwise 交叉熵 软目标 + 0.3 × BCE"]
    O --> P["微调后 reranker"]

    E --> Q["138万全库精确检索评测 R@K / NDCG@K"]
    J --> Q
    P --> R["ESCI 四档标注对 重标品类门阈值"]

图 2:线上一次检索里两个模型各管哪段#

sequenceDiagram
    participant M as 主循环
    participant S as 商品检索工具
    participant E as query 编码服务
    participant Q as Qdrant 别名指向的 collection
    participant P as 精挑环节
    participant R as reranker 服务

    M->>S: 同轮多发 按平台各一条检索
    S->>E: 检索词编码
    E-->>S: 1024维向量 + 模型版本
    S->>S: 比对模型版本与 collection 标注 不一致即报错
    S->>Q: dense 检索 + 平台/价格/评分 payload 过滤
    Q-->>S: 每平台约30件候选
    S-->>M: 候选登记到本轮候选表
    M->>P: 合流后精挑
    P->>R: 品类词+硬约束普通词 作 query 批量打分 每批最多15件
    R-->>P: 相关性分
    P->>P: 低于品类门阈值沉底 其余按综合分排序
    P-->>M: 精选结果 进入收尾清单

图 3:embedding 换版本的切换与回滚#

stateDiagram-v2
    [*] --> 旧版本服务中
    旧版本服务中 --> 新collection灌库: GPU 重编码 138万商品
    新collection灌库 --> 建索引: 灌完一次性建 HNSW
    建索引 --> 抽查: 版本号写入 collection 元数据
    抽查 --> 旧版本服务中: 抽查不通过 删除新 collection
    抽查 --> 双写窗口: 抽查通过
    双写窗口 --> 新版本服务中: 别名切到新 collection 且 编码服务切新版本
    新版本服务中 --> 旧版本服务中: 指标回退 别名与编码服务一起切回
    新版本服务中 --> [*]: 观察期结束 下线旧 collection

数字怎么来的#

R@100 +12.6%(0.4434 → 0.4992)#

  • 测什么:embedding 召回阶段,前 100 名里捞到了该 query 多少比例的正例。
  • 评测集:1.14 万条 ESCI 标注 query,和训练集按 query 切开、重叠为 0;正例 = E 档。
  • 候选池是全库 138 万,不是只在有标注的 12.7 万里检索。未标注商品会挤占名额、算作没命中,所以绝对值偏保守,但更接近线上真实情况。
  • 怎么检索:评测不走 HNSW,在 GPU 上把全库向量和 query 向量直接做矩阵乘、精确取 top-1000,排除近似索引的误差,只比模型本身。
  • 分母:该 query 在我们库里存在的正例数,而不是 ESCI 里的全部正例(很多正例商品不在库里,按全部算会系统性低估)。
  • 基线:同一套评测、同一张卡上跑开箱 BGE-M3,0.4434;最终配方(全展开正例、3 epoch)0.4992,相对提升 12.6%。四轮一共 17 份全库评测报告,这是最后一轮的最优组。
  • 拆开看:+5.58 个点里,基础微调贡献约 3.05,正例展开三级(×2.28 → ×3.94 → 训 3 轮)贡献约 2.53。

NDCG@100 +14.4%(0.2085 → 0.2385)#

  • 同一套评测集、同一次检索结果,只是换一个看排序质量的指标。
  • 分级 gain:E=3、S=2、C=1、I=0。二值 NDCG 会把「召回了可替代品」和「召回了完全无关的东西」当成一回事,分级才能反映「更好的排在更前」。
  • NDCG 涨得比 Recall 多,说明模型不只是多捞到正例,还把它们往前挪了。
  • 补充一个简历没写的精排数字:reranker 单独离线评测,在微调 embedding 召回的 top-50 上重排,英文 NDCG@8 +26%、中文 +21.6%。这个数只说明精排自己的排序能力,没有算进简历那两个数。

端到端 Rubric 均分 50.4 → 58.0#

  • 测什么:整条 Agent 链路的最终答案质量,用 Rubric 评测给打分(评测方法见 Rubric 那条 bullet,这里只一句:每条 query 的评分细则事先生成并固定,版本间分数可比)。
  • 样本:33 条手写种子 query,覆盖单品、多约束、跨平台、套装、中英混合。
  • A/B 设计:A 组 = 开箱 BGE-M3 + 开箱 reranker(品类门用原阈值);B 组 = 两个微调模型 + 重标后的品类门阈值。planner、提示词、Harness、其余工具完全一致,同一天同一个 judge 模型、temperature=0 打分。
  • 口径:只算两组都跑通的 27 条(有几条因为超时没出结果,这个是基础设施噪声,不是模型好坏)。27 条均分 50.4 → 58.0,+7.66。另外 P0 红线失败(一票否决项)从 11 条降到 7 条,这个是全 33 条口径。
  • 消融:另跑了一组「只换 embedding」,已经拿到大部分涨幅;reranker 单独的差值在 27 条上小于判分噪声。所以我对外的说法是:端到端的涨幅主要来自召回,精排的收益主要体现在离线排序和配件混入的减少上。
  • 可信度的边界:27 条不是强统计证据,judge 对单条有 0↔100 的翻转噪声,我看的是方向和 P0 条数,不拿单条涨跌做归因。

速记表#

数字一句话口径
R@100 0.4434 → 0.4992(+12.6%)1.14 万条 ESCI 评测 query,138 万全库精确检索,正例 = E 档,分母 = 库内正例数
NDCG@100 0.2085 → 0.2385(+14.4%)同一次检索,分级 gain E3/S2/C1/I0
R@1000 0.7048 → 0.7751同上,说明正例大多已进大池子,剩下是排序问题
Rubric 50.4 → 58.033 条种子集、两组都跑通的 27 条;P0 红线失败 11 → 7(全 33 条)
训练数据 3.87 万 → 14.88 万条每条 query 正例从取 1 个改成全展开(平均 3.94 个)
reranker NDCG@8 英文 +26% / 中文 +21.6%微调 embedding 召回 top-50 上重排,只评精排自身

追问 Q&A#

Q1:ESCI 是英文亚马逊数据,你们还有 eBay、Shopee、Lazada 这些平台,还有中文 query,训出来的模型能泛化过去吗?

能用,而且中文没有变差。ESCI 在我们库里命中的标注 96% 是美国站英文,训练集几乎纯英文。我专门做了中文检查:1500 组 LLM 构造的中英同义对,看 cosine ≥ 0.8 的通过率,开箱 40.1%,微调后 58.1%;中文 query 找对应英文商品的 Top-1 也从 88.5% 到 90.5%。原因是 BGE-M3 本身是多语言对齐的,微调改的主要是「电商标题该怎么理解」,这部分是跨语言共享的。其他平台的商品标题风格和亚马逊差不多(堆关键词、带规格),所以也能沾光。这个边界我会主动说:中文侧只测了这两个轻量指标,没有做全库召回评测。

Q2:你说假负例过滤去掉了 52% 的难负例,这么多,是不是阈值太松、把真负例也扔了?

有这个可能,但扔真负例的代价比留假负例小得多。留一个假负例,等于明确告诉模型「这个相关商品应该推远」,是反向梯度;扔掉一个真负例,只是少了一个训练信号,in-batch 和随机负例还在。52% 这么高,反过来说明 ESCI 的标注覆盖很稀,原模型检索出来的前几十名里大量是「相关但没标」的。阈值 0.5 是按 cross-encoder 在四档上的分布定的,大约是 I 档 90 分位的两倍。阈值扫描(0.3/0.5/0.7)我没做全,这是下一步可以补的消融。

Q3:为什么 embedding 用 InfoNCE,reranker 用 listwise?两个都是「一个正例对一堆负例」,区别在哪?

形式上确实很像,InfoNCE 本身就可以看成一种 listwise softmax。区别在模型结构和标签。embedding 是双塔,query 和商品分开编码,训练目标是让向量空间整体对齐,in-batch 负例几乎免费、数量越多越好,标签只需要正/负。reranker 是 cross-encoder,query 和商品拼在一起过一遍,每个候选都要单独前向,负例贵,所以每组只放 7 个精挑的负例;它离最终展示最近,需要分清「E 比 S 好、S 比 C 好」,所以用分级软目标。

Q4:分级标签的 listwise loss,目标分布具体怎么构造?loss 最低能到多少?

把组内 8 个候选的 gain(3/2/1/0)做 softmax 得到目标分布,模型打分也做 softmax,两者算交叉熵。它的下界是目标分布的熵,不是 0:一个 E 加 7 个 0 档的组,下界约 1.07;带 S/C 的组约 1.21.4。初始时模型对 8 个候选打分差不多,loss 约 ln 8 ≈ 2.08。实际训到 1.31.4 一带就停了,比理论下界高一截,是因为候选里大量未标注商品被当成 0 档,其中不少其实相关,这部分噪声降不下去。

Q5:离线 R@100 涨 12.6%,端到端涨 7.66 分,比例看着很高。业界常说离线涨 5% 线上涨 1~2%,你这个是不是虚高?

那条经验的前提是「后面还有精排能把召回的提升打平」。但召回没捞到的东西,精排再强也救不回来。我们线上每个平台只取 30 件,召回是直接卡住上限的那一段,所以提升能比较完整地传到最终答案。另外我自己也打了折扣:27 条样本、judge 有单条翻转噪声,我对外强调的是方向一致和 P0 失败 11→7,不拿 +7.66 当精确效应量。

Q6:reranker 微调后排序更好了,为什么端到端贡献反而小?

因为线上 reranker 分数的主要用途不是给前 100 排序,而是判别:品类门挡跨品类混入、套装里槽位内排序。检索工具那一段为了省一次网络往返,本来就没接精排,reranker 只在精挑环节对每批最多 15 件打分。离线那 +26% 的排序能力,只有一部分有用武之地。而且我发现微调模型「挡完全无关商品」的能力比开箱版略差(2000 对 I 档标定里挡住率从 88.6% 降到 81.8%),因为训练负例几乎都来自同品类召回池,模型学会了细分同品类,但见过的跨品类负例少。所以精排这边的收益更多体现在配件混入减少,而不是均分。

Q7:那为什么还把微调 reranker 换上去?不是判别力还降了吗?

两个理由。一是品类门阈值按新模型重标后,误杀真正例的比例明显低于开箱模型在原阈值下的 38.5%,线上原来是在误杀真商品,只是没人量过。二是同品类排序确实更好,用户看到的前 3 件更对。挡跨品类这一块的退化,我用重标阈值加上「低于门槛只沉底不剔除」的软处理来控制。下一步是在训练负例里掺跨品类随机负例,直接补判别力,我试过掺 30%,蹭词问题修好了一半。

Q8:难负例一般都说有用,你这里加多了主召回反而掉,怎么解释?

我一开始也以为是配置问题,做了两个单变量对照才确认不是:换温度、换课程顺序(先易后难)都不改变方向,在另一个底座上也复现了。结论是难负例在做它该做的事:hard 比例从 16% 提到 71%,「搜手机出配件」这类指标降了 8~10%,主召回掉 1 个点左右。它是在「多召回正例」和「推开近义干扰」之间换,不是白涨分的工具。我们的目标函数是主召回,所以选了低 hard 比例;配件混入交给精排和品类门,分层处理比让一个模型两头兼顾更可控。

Q9:batch 只有 32,in-batch 负例是不是太少了?为什么不用梯度累积把 batch 做大?

in-batch 负例的数量由单次前向的 batch 决定,梯度累积只是把几次小 batch 的梯度加起来,每次算 loss 时看到的负例还是 32 个,所以没用。真要做大 batch 得用 GradCache:先无梯度算出全部向量和 loss 对向量的梯度,再分块回传。BGE 官方训练是上千的 batch。我评估过这是一天左右的工程量、收益不确定,而当时数据已经显示瓶颈转到了排序段(R@1000 0.77、R@20 0.29),所以优先去做 reranker 了。

Q10:如果线上 query 编码服务挂了,或者有人只换了商品向量没换 query 模型,会怎么样?

编码服务挂了:检索工具返回错误结果给主循环,Harness 会读到错误并提示模型换说法或降级;同时这类错误会触发熔断,切到备用编码实例。只换了一侧是更危险的情况,因为不会报错、只是召回变差。所以每个 collection 在元数据里记录编码它的模型版本,编码服务返回里带自己的版本号,检索层每次比对,不一致直接报错并告警,宁可失败也不静默出错。

Q11:新上架的商品怎么办?每次换模型都要全库重编码吗?

增量商品由编码服务在入库时编码,写进别名当前指向的 collection。换模型时才需要全库重编码:在 GPU 上批量编码 138 万条大约 1 小时,灌库 17 分钟,期间旧 collection 照常服务,新 collection 灌完、抽查通过后再切别名。切换窗口里新进的商品会双写两个 collection,避免切完丢数据。

Q12:如果让你重做,最先改什么?

两件事。一是评测前置:我是先训了一轮才发现 K=100 时很多指标区分度不够,后来把 K 放到 1000、分母改成库内正例数,才看清差异,这套尺子应该第一天就定。二是 reranker 的训练目标要对齐线上用法:线上主要拿它做判别,我却先按排序去优化,应该一开始就把跨品类负例放进训练组,同时把品类门的「误杀 E / 放过 I」直接作为选模型的指标之一。

相关八股#

1. InfoNCE 损失是什么?温度 τ 起什么作用?#

  • 公式:对 query q、正例 d⁺、负例集合 {d⁻},loss = −log( exp(s(q,d⁺)/τ) / Σ exp(s(q,d)/τ) ),分母对正例和所有负例求和,相当于「在 N 个候选里把正例挑出来」的 N 分类交叉熵。
  • 它是互信息的一个下界,负例越多下界越紧,这也是对比学习喜欢大 batch 的原因。
  • τ 控制分布的尖锐程度:τ 小,分数差被放大,模型更关注最难的那几个负例(接近 hard negative mining 的效果),但太小训练不稳、容易被假负例带偏;τ 大,所有负例一视同仁,区分度弱。
  • 常见取值:SimCLR 0.1 左右,BGE 系列 0.02。
  • 关联本项目:embedding 用 τ=0.02,对照过 0.05 掉 1 个点,和假负例过滤一起用,避免小温度把假负例的梯度放大。

2. in-batch negatives 是什么?为什么梯度累积不能扩大负例数?GradCache 怎么解决?#

  • in-batch negatives:一个 batch 里其他样本的正例直接当作本样本的负例,不用额外编码,batch 为 B 时每条样本白得 B−1 个负例。
  • 梯度累积是把几个小 batch 的梯度相加再更新,每次前向算 loss 时只看到当前小 batch,负例数不变。
  • GradCache:第一步无梯度地前向全部样本,拿到所有向量;第二步在向量上算完整大 batch 的 loss 和对向量的梯度并缓存;第三步分小块重新前向、用缓存的梯度回传。显存只和小块大小有关,负例数和大 batch 一样。
  • 另一种思路是跨卡 all-gather 向量,多卡时把各卡的向量拼起来当负例。
  • 关联本项目:batch 32 是单卡显存下的选择,GradCache 是评估过但因瓶颈转到排序段而排在后面的方向。

3. hard negative 怎么挖?什么是 false negative?怎么处理?#

  • 挖法:用当前或原始模型对 query 检索 top-K,去掉已标注的正例,剩下的就是「模型觉得像、但标签说不是」的难负例;也可以用 BM25 挖字面相近的。
  • 常见做法是跳过最前面几名(比如从第 10 名往后取),因为最前面最容易是假负例。
  • false negative:其实相关、只是没被标注的商品被当成负例。在稀疏标注数据里非常普遍,会产生反向梯度。
  • 处理:用更强的 cross-encoder 给难负例打分,高分的剔除(固定阈值或相对阈值);或者做知识蒸馏,用 cross-encoder 的软分数当监督,不做硬判断。
  • 关联本项目:embedding 侧用固定阈值 0.5 剔除 52%,reranker 侧因绝对分不可比改用「超过该 query 正例最高分才剔除」的相对判据。

4. Recall@K、MRR、NDCG 分别怎么算?各适合什么场景?#

  • Recall@K:前 K 名里命中的正例数 ÷ 该 query 的正例总数,只关心「有没有捞到」,不管顺序。适合评召回阶段。
  • MRR:第一个正例排名的倒数,再对 query 取平均。只看第一个命中,适合「只要一个对的答案」的场景。
  • NDCG@K:DCG = Σ (2^rel−1) / log2(i+1),按位置打折累加增益;再除以理想排序的 IDCG 归一到 0~1。支持分级相关性,适合评排序质量。
  • 增益函数有两种:线性 rel 和指数 2^rel−1,指数形式会让最高档独大。
  • 关联本项目:召回阶段看 R@100、R@1000,排序看 NDCG@100(分级 E3/S2/C1),reranker 离线看 NDCG@8,因为最终只展示前几件。

5. Learning to Rank 的 pointwise / pairwise / listwise 有什么区别?#

  • pointwise:每个 (query, doc) 单独预测相关分或相关档,用回归或分类 loss(MSE、BCE)。简单、分数有绝对意义,但不直接优化顺序。
  • pairwise:看成对文档谁更相关,代表是 RankNet、LambdaRank,loss 在「正确顺序的概率」上。优化相对顺序,但对 N 个候选要算 O(N²) 对,且不区分对在头部还是尾部。
  • listwise:整组一起优化,代表是 ListNet(对打分做 softmax 和目标分布算交叉熵)、ListMLE、LambdaMART、ApproxNDCG(用平滑的排名近似直接优化 NDCG)。最贴近排序指标,但分数只有相对意义。
  • 实践里常用「listwise 为主 + pointwise 辅助」,兼顾排序和绝对分数可用。
  • 关联本项目:reranker 用 ListNet 式分级交叉熵为主、BCE 权重 0.3 为辅;ApproxNDCG 在分级标签下退化成近似 MRR,被对照实验否掉。

6. 双塔(bi-encoder)和交叉编码器(cross-encoder)有什么区别?为什么检索要分两段?#

  • 双塔:query 和文档分别编码成向量,相关度 = 向量内积或 cosine。文档向量可以离线算好存进向量库,在线只编码 query,配合 ANN 可在百万级库里毫秒级检索。缺点是 query 和文档之间没有 token 级交互,细粒度匹配弱。
  • 交叉编码器:把 query 和文档拼成一个序列过 Transformer,每层都有注意力交互,精度高;但每个候选都要单独前向,只能处理几十到几百个候选。
  • 所以两段式:双塔从全库召回几百个,cross-encoder 精排前几十个。还有中间方案 ColBERT(多向量、late interaction)。
  • 关联本项目:BGE-M3 负责 138 万全库召回,BGE-Reranker 只在精挑环节对每批最多 15 件打分;两个模型分别微调,各治一段的问题。

7. 模型分数的「校准」是什么意思?为什么 listwise 训练会破坏校准?#

  • 校准:模型输出的分数和真实相关概率对得上,比如打 0.8 分的样本里大约 80% 真的相关。校准好的分数才能跨 query 用同一个阈值。
  • softmax 类 listwise loss 对组内所有分数加同一个常数不变(平移不变),所以模型只学相对差,绝对值可以随意漂移,阈值就没法用了。
  • 修法:加 pointwise 的 BCE 或 MSE 项把绝对值锚住;或训练后做温度缩放(Platt scaling)、保序回归等事后校准。
  • 衡量方法:可靠性曲线、ECE(期望校准误差);业务里更直接的是在标注集上看「固定阈值下误杀多少正例、放过多少负例」。
  • 关联本项目:纯 listwise 版本在同一阈值下几乎挡不住无关商品,加 0.3 权重 BCE 后排序不降、阈值恢复可用;换模型必须按 ESCI 四档标注重扫品类门阈值。

8. HNSW 是怎么工作的?灌大量数据时为什么建议先关索引?#

  • HNSW 是分层可导航小世界图:多层图,上层稀疏、下层稠密;查询从最上层入口贪心走到局部最近点,再逐层下降细化,复杂度约 O(log N)。
  • 关键参数:M(每个节点的连边数,影响内存和召回)、ef_construction(建图时候选队列长度)、ef(查询时候选队列长度,越大越准越慢)。
  • 边插入边建图,每插一个点都要做一次近邻搜索和连边,大批量导入时 CPU 和内存压力大、整体更慢;先只写原始向量,写完一次性建图更快也更稳。
  • 评测模型时不用 HNSW,用精确暴力检索,避免把近似误差算到模型头上。
  • 关联本项目:Qdrant 灌 138 万条新向量时先关自动建索引,约 17 分钟灌完再建;离线评测在 GPU 上做精确矩阵乘。

9. 微调 embedding 模型时,如何避免灾难性遗忘?怎么判断有没有训坏?#

  • 灾难性遗忘:在窄领域数据上训久了,模型在原来擅长的通用能力上退化,多语言模型最常见的是非训练语种变差。
  • 控制手段:小学习率(1e-5 量级)、少 epoch、warmup;混入一定比例的通用或其他语种数据;LoRA 限制改动幅度;必要时和原模型做权重插值。
  • 判断方法:除了目标域指标,还要留几组「不该变差」的检查:其他语种的对齐(跨语言同义对 cosine、Top-1)、通用检索基准的抽样。
  • epoch 数和数据量相关:数据少时多训几轮会过拟合,数据多时临界点往后移。
  • 关联本项目:训练数据几乎全英文,但用 1500 组中英同义对检查,中文通过率 40.1%→58.1% 不降反升;3.87 万条时训 3 轮退化,14.88 万条时 3 轮最好。

10. 构造检索训练/评测集时,数据泄漏有哪几种?怎么切分?#

  • 按样本随机切是最常见的错误:同一个 query 的不同正例分到训练和评测两边,模型等于在评测集上见过这个 query,指标虚高。检索任务要按 query 切。
  • 近重复泄漏:同一商品的不同颜色/尺码变体(不同 id、标题几乎一样)分散在两边,按 id 去重不够,要按文本去重或按商品族切。
  • 时间泄漏:线上数据要按时间切,训练用过去、评测用未来,贴近真实上线场景。
  • 负例泄漏:挖难负例时如果用了评测 query,训练会间接接触评测分布,挖负例只能在训练 query 上做。
  • 切完要审计:query 重叠数、同一对既是正又是负的冲突数、文本长度分布。
  • 关联本项目:ESCI 按 query 切,训练 3.87 万 / 评测 1.14 万,审计 query 泄漏为 0、正负冲突 4 条;正例文本重复率 45%,展开正例时按文本去重。