面试知识库

11. 训练侧:检索 embedding 与 reranker 微调#

一句话结论:embedding 微调只有「正例全展开」一个杠杆有效(R@100 +12.6%);reranker 离线 ndcg@8 +26% 却不上线,因为线上只消费判别不消费排序。

基准:HEAD 634f4ab。本章代码 2026-09-16 后无变动,事实沿用旧版手册(旧版已按 data/eval 产物与提交正文核对过,下文数字均标「旧版核对过」)。训练脚本 scripts/train/,GPU 侧 scripts/train/gpu/,日志 var/train-logs/globex-train/output/。

速背卡#

#hint(≤15 字)展开一句(≤40 字)
1四轮 17 组,只中一枪embedding 17 组对照只有正例全展开有效,R@100 +12.6%
2展开完必须打散同 query 的多正例同批会被 in-batch negatives 互当负例
3hard negative 是 trade-off拿主召回换配件抑制,complement −9.6%,不是提分工具
4reranker 最优 = 分级 + listwise CE2×2 析因推翻 refdocs 的 ApproxNDCG 主张
5离线 +26%,端到端 0排序腿涨了,线上三个消费点全是判别腿
6先修尺子再训模型K=100 时 recall 0.29 量不出差异,放到 1000 才 0.57
733 条噪声 ±13.2A/B −2.08 落在区间内,叫「没测出差异」不叫变差

30 秒版#

用 ESCI 人工标注(靠 ASIN 和自己的商品库 join)分别微调了召回用的 BGE-M3 和精排用的 bge-reranker-v2-m3。embedding 做了四轮 17 组对照实验,结论是只有「把每条 query 的多个正例全展开」有效,离线 R@100 +12.6%,端到端 Rubric 均分 50.37 → 58.03。reranker 离线 ndcg@8 英文 +26%,但没上线——线上 rerank 分数只用来做判别,自训模型判别更差。这条是我做过最完整的「优化了错误目标」的复盘。

3 分钟版#

  1. 数据。 清洗后 amazon 的 item_id 就是 ASIN,ESCI 的 product_id 也是 ASIN,直接 join:交集 12.67 万 ASIN、29.7 万条标注、5.36 万条有正例 query(b3dc90e,旧版核对过)。E 当正例,S(替代品)/ C(配件)当难负例——把 C 算负例正对着「搜手机出配件」这个老 bad case。
  2. 先修尺子。 库里 90.8% 商品没标注,K=100 时 recall 只有 0.29、mrr 近 0,训练前后量不出差。K 放到 1000 后 recall 0.57,指标才有区分度(6892049)。
  3. embedding 只有一个杠杆。 原来每条 query 只取第一个正例,丢掉 74% 标注(平均 3.94 个正例)。全展开后训练集 3.87 万 → 14.88 万条,R@100 .4434 → .4992(+12.6%,9d30c09)。温度、学习率、课程学习三个假说都被对照推翻。
  4. reranker 的 2×2 析因。 「分级 label × loss」四格里最优是分级 + listwise CE(ListNet,r3),不是 refdocs 主张的分级 + ApproxNDCG(4ad7987)。分级是主要杠杆,ApproxNDCG 在分级下反而有害。
  5. 如实记账。 线上 rerank 分数只有 3 个消费点,全在 app/tools/item_picker.py,全是判别用途。自训模型排序更好、判别更差(I 档挡住率 88.6% → 81.8%),端到端 A/B −2.08 落在 ±13.2 区间内(f82e300、97f518b)。结论写进 item_picker.py:183-199 注释:这道门不要换自训模型。

它解决什么问题#

① 评测尺子量不出训练效果

  • 问题:库 138 万商品、90.8% 无标注,K=100 的 recall 0.29、mrr 近 0。
  • 坏法:直接拿 K=100 跑 A/B。训练涨跌都被噪声淹没,看着像「训练没用」,而且看不见——指标会正常出数,只是没有区分度。
  • 修法:K 放到 1000,recall 0.57;qrels 里把 hard_negatives 拆成 substitutes 和 complements,单独出 complement 指标量「整机 query 召回了多少配件」(6892049)。
  • 代价:评测更慢;complement 指标只在有 ESCI 标注的 9% 商品上成立。

② 74% 的人工标注被白白丢掉

  • 问题:构样本时每条 query 只取第一个正例,平均 3.94 个正例只用了 1 个。
  • 坏法:不改就只是「数据少」,训练照跑、loss 照降,永远发现不了——四轮实验里前三轮都在调超参,就是没往数据形态上想。
  • 修法:正例全展开,3.87 万 → 14.88 万条;写出前按行打散。
  • 代价:必须打散。同一 query 展开出的样本按序写出必然相邻、必然同 batch,INFONCE_USE_BATCH=True 下它自己的另一个正例会被当负例。打散后 32 条滑窗内同 query 比例降到 0.87%(9d30c09)。

③ hard negative 当成提分工具

  • 问题:直觉是难负例越多越好,ANN 挖了一大批。
  • 坏法:hard 占比 71.2% 的配方全面输给 16.4% 的配方,complement .4207 → .3754(−9.6%)。它换来的是对近义干扰的抑制,代价是主召回掉。
  • 修法:定性为 trade-off 而非提分杠杆;合成数据的负例改用随机采样(134dd5c)。
  • 代价:配件抑制能力上不去,只能靠 item_picker 的品类门在下游兜。

④ reranker 照抄 refdocs 的 loss 选型

  • 问题:refdocs 主张分级 label + ApproxNDCG。
  • 坏法:只跑这一组,涨了分不清是分级还是 loss 的功劳,跌了不知道怪谁。
  • 修法:2×2 析因(4ad7987,recall@8 / ndcg@8):r1 二值 CE .2370/.1798、r4 二值 ApproxNDCG .2491/.1866、r3 分级 CE .2592/.2003、r2 分级 ApproxNDCG .2454/.1900。分级是主要杠杆;ApproxNDCG 只在二值下有用。
  • 代价:ms-swift 表达不了分级 label,自写 scripts/train/gpu/train_reranker_graded.py 一份训练循环要维护。

⑤ 优化了错误目标(本章重点)

  • 问题:花两天把 reranker 的排序能力训到离线 ndcg@8 +26%,线上一分收益吃不到。
  • 坏法:线上 rerank 分数只有 3 个消费点——品类门降权沉底、套装轮槽内逐出、展示相对门,全是「这件相不相关」的判别;item_search 有意不做精排,没有任何地方消费「谁排前面」。不看这一层就会直接上线,把判别变差的模型换上去:同等误杀率下挡住 I 档从 88.6% 掉到 81.8%,而端到端 A/B 测不出来(噪声 ±13.2),坏了也看不见。
  • 修法:查清消费点后放弃上线,把标定表和理由写进 app/tools/item_picker.py:183-199 注释,含 r3 纯 listwise 破坏分数校准的证据(真实候选低于 .2 的比例 58.9% → 11.7%,门等于没开)。
  • 代价:训练投入不产出线上收益;要吃这份收益得先给召回段补一条精排腿,代码里没有实现,未验证。

机制怎么跑#

embedding 一侧(M21):

  1. join:scripts/train/build_esci_pairs.py 按 ASIN 把 ESCI 标注拼到商品库,出训练三元组 + 评测 qrels(E/S/C/I 四档)。
  2. 挖负例:scripts/train/mine_ann_negatives.py 用 ANN 挖候选并剔除标注 E;score_negatives.py 在 GPU 上用 cross-encoder 过假负闸(绝对阈值 0.5,闸掉 52.24%)。
  3. 合成 query:scripts/train/build_synth_queries.py 让 LLM 生成 L1–L4 难度,synth_quality_gate.py 不过门整批废掉。
  4. 训练:scripts/train/to_swift_format.py 转格式 → scripts/train/gpu/train_one.sh 跑 ms-swift infonce 全参微调(568M 参数,tuner_type: full)。
  5. 评测:scripts/train/eval_on_gpu.py 全库暴力检索(与本地 eval_recall.py 同口径),eval_crosslingual.py 过中文闸。
  6. 入库:scripts/train/encode_corpus_gpu.py 编 138 万 × 1024(fp16 2.8 GB)→ load_vectors_to_qdrant.py 灌进独立 collection。
  7. 端到端:scripts/train/gpu/embed_server.py 起 OpenAI 兼容 /v1/embeddings → run_ab_rubric.sh 做 A/B,两组唯一差异是向量空间。

reranker 一侧(M22):

  1. export_rerank_candidates.py 按 e15 排名分三层取候选(1-50 / 50-200 / 200-500)→ build_rerank_train.py 过 query 内相对假负闸 → gpu/train_reranker_graded.py(r2–r7)→ eval_rerank.py 出三组对照 → gpu/rerank_server.py + run_ab_reranker.sh。

线上接入:

  1. embedding:app/recall/towers.py:74 读 EMBED_MODEL,填了走远程 OpenAI 兼容 /embeddings,不填走本地确定性回退;collection 来自 QDRANT_COLLECTION。切微调模型只要改 .env 指向 embed_server 和 e15 的 collection。线上是否已切,仓库里看不到,未验证。
  2. reranker:app/tools/item_picker.py:200 的 _RERANK_FLOOR(PICK_RERANK_FLOOR,默认 0.2)线上仍是现成 BAAI/bge-reranker-v2-m3。
  3. 编码口径三处必须一致:eval_on_gpu.py、encode_corpus_gpu.py、线上服务都用 CLS 池化 + L2 归一化。任一处不一致,query 和商品就不在同一个向量空间——不报错,只是结果变差。

演进时间线#

日期提交改了什么为什么
08-07b3dc90eESCI 按 ASIN join 商品库,出 12.67 万交集手上唯一对症的人工标注信号
08-076892049评测 K 100 → 1000,qrels 拆 substitutes / complementsK=100 量不出训练差异
08-08ae8adabcross-encoder 假负闸(绝对阈值 0.5,闸掉 52.24%)解开「禁止 ANN 自挖负例」的禁令但加闸
08-087aca1a9改全参 + batch 32 + τ 0.02 + max_length 128对齐 FlagEmbedding 主流配方,弃 LoRA
08-08134dd5c合成 query 加 wait_for + 每批写盘并发槽被挂起请求占死,2.6 小时白跑
08-099d30c09正例全展开 + 打散;温度 / lr / 课程学习三假说被推翻四轮 17 组里唯一有效杠杆
08-0939862ba138 万点灌 Qdrant(先 indexing_threshold=0)+ 端到端 A/B建 HNSW 把 OrbStack VM 打爆过
08-099ae9f34放弃「加深 rerank 池子」这条零训练路线K 20 → 1000,recall@8 只 .2426 → .2437
08-09f84452f假负闸改 query 内相对判据四档分数倒挂,绝对阈值会删掉人工确认的 S/C
08-0973ec423中英配对集验证中文不退(n=800)中文是线上入口,只用英文训有风险
08-104ad79872×2 析因定选型:分级 + listwise CE(r3)单跑一组分不清功劳归属
08-109d82cc4否掉中文合成数据(r5ce),最终选 r3合成数据无 S/C 分级,稀释主要杠杆
08-1039e9a29r6 加 pointwise BCE(权重 0.3)救分数校准纯 listwise 把门训废了
08-1075b4ec0 a51fc38r7 补 30% 随机负例;结论改成分项描述M22 照搬时漏了随机负例这一层
08-10f82e300 97f518b端到端 A/B 判「没测出差异」,结论写进 item_picker.py 注释排序腿在当前架构下没有消费点
08-10740051crerank query 拼上 must 硬约束词线上传粗品类词、模型按意图句训,形态不一致

数字与证据#

数字指什么来源状态
12.67 万 ASIN / 29.7 万标注 / 5.36 万有正例 queryESCI 与 138 万商品库的交集b3dc90e 正文✓ 旧版核对过
3.87 万 → 14.88 万条(日志 37661 → 148523,val 993)正例展开前后训练集var/train-logs/globex-train/output/*/logging.jsonl✓ 旧版核对过;正文与日志条数略有出入
R@100 .4434 → .4992(+12.6%)、NDCG +14.4%正例全展开的离线收益9d30c09 正文✓ 旧版核对过;评测 JSON 在 GPU 机上
超参:全参 / lr 1e-5 / batch 32 / max_length 128 / infonce / τ 0.02 / gradient_checkpointing 开e15 与 v1-e2 同配方,e15 跑 3 epochargs.json、gpu/train_one.sh✓ 旧版核对过;τ 取值只在 7aca1a9/9d30c09 正文
train_runtime 1110.9 / 4181.8 / 6247.3 sv1-e2 / e10 / e15训练日志✓ 旧版核对过
假负闸闸掉率 52.24%(92.8 万中 48.5 万)M21 绝对阈值 0.5ae8adab 正文✓ 旧版核对过
cosine 通过率 40.13% → 58.07%;zh→en Top-1 88.53% → 90.47%微调后中文不退反进9d30c09 正文✓ 旧版核对过
33 条 45.25 → 48.89;共同跑通 27 条 50.37 → 58.03;P0 失败 11 → 7M21 端到端 A/Bdata/eval/rubric_A_base_0809_1357.json、rubric_B_e15_0809_1357.json✓ 旧版重算与提交一致
recall@8 .2426 → .2437(K 20 → 1000)加深 rerank 池子无效9ae9f34 正文✓ 旧版核对过
英文 @K=50 r3 对 base +18.7% / +26.0% / +24.8%;中文 +16.6% / +21.6% / +20.4%reranker 最终选型(recall / ndcg / map @8)9d82cc4 正文✓ 旧版核对过
误杀 E / 挡住 I:base+0.20 38.5%/88.6%;r6 30.2%/71.0%;r3 3.9%/20.7%品类门标定,ESCI 四档各 2000 对39e9a29、app/tools/item_picker.py:183-189✓ 当前代码注释可查
共同跑通 24 条 47.92 → 45.84;P0 6 → 8;overall_pass 18 → 16;95% 区间 ±13.2M22 端到端 A/Bdata/eval/rubric_A_baseCE_0810_0347.json、rubric_B_tuned_0810_0347.json;±13.2 来自 f82e300✓ 旧版重算与提交一致
线上是否已切到微调向量.env 指向哪个 collection仓库看不到未验证

追问 10 题#

Q1. 为什么把 ESCI 的 C(配件)当负例? 按搜索相关性,手机壳不是「手机」这个 query 的答案。线上「搜手机出配件」查下来是数据问题(黑名单闸、品类过滤两方案都被实测否掉),这批人工标注是最对症的信号。证据:scripts/train/build_esci_pairs.py docstring、6892049。

Q2. ⚠ 正例展开后为什么必须打散? gpu/train_one.sh 里 INFONCE_USE_BATCH=True 开着 in-batch negatives。同一 query 展开的样本按序写出必然同 batch,它自己的另一个正例会被当负例。打散后 32 条滑窗内同 query 比例 0.87%。展开有边际递减:每多一档增量 +1.25 → +0.90 → +0.38pt(9d30c09)。

Q3. ⚠ hard negative 为什么不提分? hard 占比 71.2% 的配方全面输给 16.4% 的,complement .4207 → .3754(−9.6%),在 e6 / e10 两个底座上同一模式。它是拿主召回换近义干扰抑制。M22 照搬时漏了随机负例这层:M21 是 ann 61.2% / random 28.3% / esci 10.5%,M22 是 ann 91.1% / random 0% / esci 8.9%(75b4ec0),模型没见过完全无关商品,跨品类判别变差。

Q4. 离线 +12.6% 到端到端兑现多少? 33 条种子集 A/B,两组唯一差异是向量空间:共同跑通 27 条 50.37 → 58.03,P0 失败 11 → 7(39862ba)。样本不够下强结论:judge 单条会 0↔100 翻转,B 组跑挂 4 条、A 组 2 条(B 的 query 编码走 SSH 隧道到远程 GPU)。后来测出 33 条噪声是 ±13.2,M23 才把种子集扩到 90 条。

Q5. reranker 为什么做 2×2 析因而不是直接照 refdocs 训? 只跑一组分不清功劳归属。四格结果见上表:分级是主要杠杆,ApproxNDCG 在二值下有用、在分级下有害。原因:增益 2^gain−1、E=7/S=3/C=1,每组只有 1 个 E,DCG 基本只看它的位置,退化成 MRR,S 与 C 的相对顺序被压掉;ListNet 的 softmax(gain) 完整保留档位比例。每组只有 8 个候选,sigmoid 近似排名的梯度也弱。

Q6. 只用英文 ESCI 训会不会把中文训崩? 73ec423 用 M21 留下的中英配对集(同 query_id、同标注,n=800,K=50)验过:中文全正向,但 r1 的中文收益只有英文一半(ndcg +7.1% vs +13.4%);最终 r3 中文 ndcg@8 +21.6%。加中文合成数据反而更差(r5ce):合成 query 没有 S/C 分级,含分级的组占比从 33% 稀释到 22%。

Q7. 假负闸为什么 M21 用绝对阈值、M22 改相对判据? 同一个 cross-encoder 在四档上的分数倒挂:E 的 p10 只有 .0027,S 的 p90 到 .6679、C 的 p90 到 .8927(f84452f)。任何绝对阈值都会连人工确认的 S/C 一起删。改成 query 内相对判据:候选分数高于该 query 已知正例最高分才剔除。分位选 q=1.0(闸掉 18.04%)最保守,宁可留噪声。

Q8. ⚠ 离线 ndcg +26%,为什么不上线? 线上 rerank 分数只有 3 个消费点——品类门、套装轮槽内逐出、展示相对门,全是判别用途,item_search 有意不做精排。排序收益没有消费点,判别变差却要全额承担(I 档挡住率 88.6% → 81.8%)。item_picker.py 注释原话:要吃它,得先给召回段补精排腿。两条出路在 97f518b 正文,代码里没有实现,未验证。

Q9(压力题). M22 端到端 −2.08,你怎么证明不是把效果做差了? 证明不了,也证明不了更好。共同跑通 24 条,涨 11 / 跌 7 / 平 6,单条差值标准差 32.9、标准误 6.7,−2.08 落在 ±13.2 内,叫「没测出差异」。结论「不要换」靠的是另外两条有统计量的离线证据:四档标定(各 2000 对)上 I 档挡住率更差;以及 query 形态不一致(线上传粗品类词、模型按意图句训,740051c 已把 must 硬约束词拼进 rerank query)。单个 backpack 反转 case 样本量为 1,只当线索。

Q10(压力题). CLAUDE.md 写「不做训练」,这些是真跑的吗? 真跑的,本地留有日志与超参:var/train-logs/globex-train/output/ 下 embedding 15 个目录(v1–v3、e5–e15)、reranker 8 个(r1–r7、r5ce)。能复核的只有 e 系列和 r1 的 logging.jsonl / args.json,r2–r7 只剩 config.json。GPU 是 huzhou 机器的 A100 40G(ae8adab 正文:4090 被别人占着)。项目 CLAUDE.md §1/§7 至今仍写「不做 LLM 训练 / 微调」,与 M21–M23 矛盾,文档没更新。

坑与易混点#

  1. CLAUDE.md §1/§7 仍写「不做 LLM 训练 / 微调」,与本章 M21/M22 及第 12 章 M23 矛盾,文档没更新(不改代码,只记在这)。
  2. docs/milestones/ 里没有 M21/M22 文档(只有 M23),这两个里程碑的设计理由只在提交正文和训练日志里。
  3. 离线指标和线上收益不是一回事:ndcg 量排序,item_picker 的门量判别。换模型必须按 item_picker.py:183 的四档标定表重标 _RERANK_FLOOR。
  4. 评测口径两套:GPU 上是分块矩阵乘暴力检索,线上是 Qdrant ANN;等价性只靠 CLS 池化 + L2 归一化这个约定保证,不一致不会报错。
  5. 变体商品按文本去重而不是按 id:同款不同 ASIN 文本逐字相同,会既当正例又当负例(冲突 17 → 0),代价是颜色 / 尺码的区分信号全丢。

本章和别章的接口#

  • item_picker 的品类门、槽内逐出、展示相对门(本章 Q8 说的三个消费点)在第 4 章。
  • Rubric 口径、judge 单条 0↔100 翻转、90 条种子集在第 8 章。
  • M23 planner 的 SFT 与 GRPO 在第 12 章——种子集扩到 90 条就是因为本章测出的 ±13.2。