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 互当负例 |
| 3 | hard negative 是 trade-off | 拿主召回换配件抑制,complement −9.6%,不是提分工具 |
| 4 | reranker 最优 = 分级 + listwise CE | 2×2 析因推翻 refdocs 的 ApproxNDCG 主张 |
| 5 | 离线 +26%,端到端 0 | 排序腿涨了,线上三个消费点全是判别腿 |
| 6 | 先修尺子再训模型 | K=100 时 recall 0.29 量不出差异,放到 1000 才 0.57 |
| 7 | 33 条噪声 ±13.2 | A/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 分钟版#
- 数据。 清洗后 amazon 的
item_id就是 ASIN,ESCI 的product_id也是 ASIN,直接 join:交集 12.67 万 ASIN、29.7 万条标注、5.36 万条有正例 query(b3dc90e,旧版核对过)。E 当正例,S(替代品)/ C(配件)当难负例——把 C 算负例正对着「搜手机出配件」这个老 bad case。 - 先修尺子。 库里 90.8% 商品没标注,K=100 时 recall 只有 0.29、mrr 近 0,训练前后量不出差。K 放到 1000 后 recall 0.57,指标才有区分度(
6892049)。 - embedding 只有一个杠杆。 原来每条 query 只取第一个正例,丢掉 74% 标注(平均 3.94 个正例)。全展开后训练集 3.87 万 → 14.88 万条,R@100 .4434 → .4992(+12.6%,
9d30c09)。温度、学习率、课程学习三个假说都被对照推翻。 - reranker 的 2×2 析因。 「分级 label × loss」四格里最优是分级 + listwise CE(ListNet,r3),不是 refdocs 主张的分级 + ApproxNDCG(
4ad7987)。分级是主要杠杆,ApproxNDCG 在分级下反而有害。 - 如实记账。 线上 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):
- join:
scripts/train/build_esci_pairs.py按 ASIN 把 ESCI 标注拼到商品库,出训练三元组 + 评测 qrels(E/S/C/I 四档)。 - 挖负例:
scripts/train/mine_ann_negatives.py用 ANN 挖候选并剔除标注 E;score_negatives.py在 GPU 上用 cross-encoder 过假负闸(绝对阈值 0.5,闸掉 52.24%)。 - 合成 query:
scripts/train/build_synth_queries.py让 LLM 生成 L1–L4 难度,synth_quality_gate.py不过门整批废掉。 - 训练:
scripts/train/to_swift_format.py转格式 →scripts/train/gpu/train_one.sh跑 ms-swift infonce 全参微调(568M 参数,tuner_type: full)。 - 评测:
scripts/train/eval_on_gpu.py全库暴力检索(与本地eval_recall.py同口径),eval_crosslingual.py过中文闸。 - 入库:
scripts/train/encode_corpus_gpu.py编 138 万 × 1024(fp16 2.8 GB)→load_vectors_to_qdrant.py灌进独立 collection。 - 端到端:
scripts/train/gpu/embed_server.py起 OpenAI 兼容/v1/embeddings→run_ab_rubric.sh做 A/B,两组唯一差异是向量空间。
reranker 一侧(M22):
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。
线上接入:
- embedding:
app/recall/towers.py:74读EMBED_MODEL,填了走远程 OpenAI 兼容/embeddings,不填走本地确定性回退;collection 来自QDRANT_COLLECTION。切微调模型只要改.env指向embed_server和 e15 的 collection。线上是否已切,仓库里看不到,未验证。 - reranker:
app/tools/item_picker.py:200的_RERANK_FLOOR(PICK_RERANK_FLOOR,默认 0.2)线上仍是现成BAAI/bge-reranker-v2-m3。 - 编码口径三处必须一致:
eval_on_gpu.py、encode_corpus_gpu.py、线上服务都用 CLS 池化 + L2 归一化。任一处不一致,query 和商品就不在同一个向量空间——不报错,只是结果变差。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 08-07 | b3dc90e | ESCI 按 ASIN join 商品库,出 12.67 万交集 | 手上唯一对症的人工标注信号 |
| 08-07 | 6892049 | 评测 K 100 → 1000,qrels 拆 substitutes / complements | K=100 量不出训练差异 |
| 08-08 | ae8adab | cross-encoder 假负闸(绝对阈值 0.5,闸掉 52.24%) | 解开「禁止 ANN 自挖负例」的禁令但加闸 |
| 08-08 | 7aca1a9 | 改全参 + batch 32 + τ 0.02 + max_length 128 | 对齐 FlagEmbedding 主流配方,弃 LoRA |
| 08-08 | 134dd5c | 合成 query 加 wait_for + 每批写盘 | 并发槽被挂起请求占死,2.6 小时白跑 |
| 08-09 | 9d30c09 | 正例全展开 + 打散;温度 / lr / 课程学习三假说被推翻 | 四轮 17 组里唯一有效杠杆 |
| 08-09 | 39862ba | 138 万点灌 Qdrant(先 indexing_threshold=0)+ 端到端 A/B | 建 HNSW 把 OrbStack VM 打爆过 |
| 08-09 | 9ae9f34 | 放弃「加深 rerank 池子」这条零训练路线 | K 20 → 1000,recall@8 只 .2426 → .2437 |
| 08-09 | f84452f | 假负闸改 query 内相对判据 | 四档分数倒挂,绝对阈值会删掉人工确认的 S/C |
| 08-09 | 73ec423 | 中英配对集验证中文不退(n=800) | 中文是线上入口,只用英文训有风险 |
| 08-10 | 4ad7987 | 2×2 析因定选型:分级 + listwise CE(r3) | 单跑一组分不清功劳归属 |
| 08-10 | 9d82cc4 | 否掉中文合成数据(r5ce),最终选 r3 | 合成数据无 S/C 分级,稀释主要杠杆 |
| 08-10 | 39e9a29 | r6 加 pointwise BCE(权重 0.3)救分数校准 | 纯 listwise 把门训废了 |
| 08-10 | 75b4ec0 a51fc38 | r7 补 30% 随机负例;结论改成分项描述 | M22 照搬时漏了随机负例这一层 |
| 08-10 | f82e300 97f518b | 端到端 A/B 判「没测出差异」,结论写进 item_picker.py 注释 | 排序腿在当前架构下没有消费点 |
| 08-10 | 740051c | rerank query 拼上 must 硬约束词 | 线上传粗品类词、模型按意图句训,形态不一致 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 12.67 万 ASIN / 29.7 万标注 / 5.36 万有正例 query | ESCI 与 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 epoch | args.json、gpu/train_one.sh | ✓ 旧版核对过;τ 取值只在 7aca1a9/9d30c09 正文 |
train_runtime 1110.9 / 4181.8 / 6247.3 s | v1-e2 / e10 / e15 | 训练日志 | ✓ 旧版核对过 |
| 假负闸闸掉率 52.24%(92.8 万中 48.5 万) | M21 绝对阈值 0.5 | ae8adab 正文 | ✓ 旧版核对过 |
| 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 → 7 | M21 端到端 A/B | data/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.2 | M22 端到端 A/B | data/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 矛盾,文档没更新。
坑与易混点#
CLAUDE.md§1/§7 仍写「不做 LLM 训练 / 微调」,与本章 M21/M22 及第 12 章 M23 矛盾,文档没更新(不改代码,只记在这)。docs/milestones/里没有 M21/M22 文档(只有 M23),这两个里程碑的设计理由只在提交正文和训练日志里。- 离线指标和线上收益不是一回事:ndcg 量排序,
item_picker的门量判别。换模型必须按item_picker.py:183的四档标定表重标_RERANK_FLOOR。 - 评测口径两套:GPU 上是分块矩阵乘暴力检索,线上是 Qdrant ANN;等价性只靠 CLS 池化 + L2 归一化这个约定保证,不一致不会报错。
- 变体商品按文本去重而不是按 id:同款不同 ASIN 文本逐字相同,会既当正例又当负例(冲突 17 → 0),代价是颜色 / 尺码的区分信号全丢。
本章和别章的接口#
item_picker的品类门、槽内逐出、展示相对门(本章 Q8 说的三个消费点)在第 4 章。- Rubric 口径、judge 单条 0↔100 翻转、90 条种子集在第 8 章。
- M23 planner 的 SFT 与 GRPO 在第 12 章——种子集扩到 90 条就是因为本章测出的 ±13.2。