29 · 混合召回与真实重排训练
融合解决不同召回通道的互补,重排解决同一候选集内部的顺序。二者都不能找回候选集之外的文档。本章真实构建训练对、按查询切分、拟合排序器并检查候选上限。
先预测:把BM25与余弦分数直接相加,会不会被分数单位主导?随机切分文档对会不会让同一查询同时出现在训练与验证?
神经模型仍冻结;本章新拟合的是低成本线性模型。原300查询测试已经看过,新增40查询的审计仅是诊断,不冒充全新盲测。
from pathlib import Path
import os, sys
root = Path.cwd()
if not (root / 'reclab').exists(): root = root.parent
os.chdir(root); sys.path.insert(0, str(root))
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from reclab.search_lessons import context, query_vectors, score_query, labels
from reclab.search import BM25, rrf
from reclab.retrieval import topk, metrics
ctx = context()
print('Shared search run:', ctx['receipt']['run_id'])Notebook 保存的输出 · 本页未执行
Shared search run: 20261030T124927-d1467622
1. RRF只使用排名
BM25与余弦分数的单位不同。RRF按sum(1/(60+rank))相加,减少分值标度的影响,但丢掉了分数差距信息。只改变召回深度,先在30个训练查询观察变化;RRF常数固定60。
qids=sorted(ctx['truth']['train'])[:30]; qv=query_vectors(qids)
rows=[]
for depth in [10,50,100]:
pred={q:topk(score_query(q,v,depth=depth)[2],10) for q,v in zip(qids,qv)}
rows.append({'depth_per_channel':depth,**metrics(pred,labels(qids,'train'),10)})
display(pd.DataFrame(rows))
print('Training-query exploration only; official depth remains 100.')Notebook 保存的输出 · 本页未执行
| depth_per_channel | recall@10 | ndcg@10 | mrr@10 | hit_rate@10 | |
|---|---|---|---|---|---|
| 0 | 10 | 0.900000 | 0.735520 | 0.689286 | 0.900000 |
| 1 | 50 | 0.866667 | 0.724944 | 0.685000 | 0.866667 |
| 2 | 100 | 0.833333 | 0.715308 | 0.681667 | 0.833333 |
Training-query exploration only; official depth remains 100.
2. 查询级切分,而不是候选对级切分
每个查询会产生20个高度相关的训练对。随机按对切分会使同一查询同时出现在训练和选择集,夸大泛化。这里沿用正式run的200训练查询,前160拟合、后40选择;另外固定40测试查询仅报告诊断,不按其结果选超参。
模型特征:[BM25、余弦、RRF、查询词覆盖、log篇幅],输入为候选对数×5。只有正qrels被观察到,其余是未判断弱负例,不能称真实负曝光。
from reclab.search_lessons import candidate_rows,fit_ranker,rerank_rows,split_training_queries
train_ids=ctx['result']['train_queries']
fit_ids,select_ids=split_training_queries(train_ids)
audit_ids=sorted(ctx['truth']['test'])[:40]
assert not(set(fit_ids)&set(select_ids) or set(train_ids)&set(audit_ids))
fit_rows=candidate_rows(fit_ids); select_rows=candidate_rows(select_ids); audit_rows=candidate_rows(audit_ids)
x=np.concatenate([r['x'] for r in fit_rows]); y=np.concatenate([r['y'] for r in fit_rows])
print('fit/select/audit queries:',len(fit_ids),len(select_ids),len(audit_ids))
print('fit feature shape:',x.shape,'positive labels:',int(y.sum()),'unjudged weak negatives:',int((y==0).sum()))Notebook 保存的输出 · 本页未执行
fit/select/audit queries: 160 40 40 fit feature shape: (3200, 5) positive labels: 166 unjudged weak negatives: 3034
3. 实际拟合:只增加特征
固定候选、标签、正则系数C=1、随机种子和训练查询,仅比较BM25单特征与五特征。StandardScaler只在拟合查询上学习均值方差;不能先对全数据fit scaler。选择集用于对比,不使用测试成绩决定保留哪组结果。单个BM25特征若学到正系数,只是单调变换,不能改变BM25顺序;对照考察的是新增特征的信息,而不是“训练过”三个字。
两组真实训练新建共享search实验记录,config.workshop标识将其与正式服务隔离;目录仅保存select成绩和模型系数,下面额外的40查询诊断只显示在本章,不用于选型。
from reclab.search_lessons import ranking_workshop
workshop_id,trained=ranking_workshop(fit_rows,select_rows)
print('Shared search workshop run:',workshop_id)
rows=[]
for name,(ranker,columns) in trained.items():
for split,items,truth_ids in [('select',select_rows,select_ids),('audit_diagnostic',audit_rows,audit_ids)]:
pred=rerank_rows(ranker,items,columns)
rows.append({'features':name,'split':split,**metrics(pred,labels(truth_ids,'train' if split=='select' else 'test'),10)})
display(pd.DataFrame(rows))
print('These new 160-query fits do not overwrite the official 200-query ranker.')Notebook 保存的输出 · 本页未执行
Shared search workshop run: 20261030T131425-ebe3235e
| features | split | recall@10 | ndcg@10 | mrr@10 | hit_rate@10 | |
|---|---|---|---|---|---|---|
| 0 | bm25_only | select | 0.8250 | 0.670245 | 0.622044 | 0.825 |
| 1 | bm25_only | audit_diagnostic | 0.7875 | 0.674287 | 0.641111 | 0.800 |
| 2 | five_features | select | 0.7625 | 0.667839 | 0.639653 | 0.800 |
| 3 | five_features | audit_diagnostic | 0.8125 | 0.702456 | 0.671280 | 0.825 |
These new 160-query fits do not overwrite the official 200-query ranker.
4. 系数可解释,但不是因果效应
标准化后的线性系数表示其他特征固定时,打分如何随一个特征变化。相关特征、候选筛选偏差和不完整标签会影响解释;不能称为“增加某特征就产生某业务收益”。Logistic的sigmoid虽输出0~1,但当前弱标签与候选采样使它不能被当作校准相关概率,更不是CTR。
ranker,columns=trained['five_features']
feature_names=['bm25','cosine','rrf','query_term_coverage','log_characters']
display(pd.DataFrame({'feature':feature_names,'standardized_coefficient':ranker[1].coef_[0]}))
assert np.array_equal(ranker[0].mean_,x.mean(0)) or np.allclose(ranker[0].mean_,x.mean(0))
print('Scaler fitted only on 160-query training candidates.')Notebook 保存的输出 · 本页未执行
| feature | standardized_coefficient | |
|---|---|---|
| 0 | bm25 | 0.566910 |
| 1 | cosine | 0.549268 |
| 2 | rrf | 0.637531 |
| 3 | query_term_coverage | 0.544137 |
| 4 | log_characters | 0.243744 |
Scaler fitted only on 160-query training candidates.
5. 冻结交叉编码器与候选召回上限
双编码器分别编码,交叉编码器把query/document联合输入,能建模token间交互,但每个候选都需推理。仅在前3个诊断查询上实际运行TinyBERT,候选固定20。它可能降位正确文档;冻结模型不保证领域适配。
from reclab.search_lessons import models
m=models(); cross={}
for row in audit_rows[:3]:
scores=m.rerank(ctx['queries'][row['qid']],[ctx['texts'][d-1] for d in row['ids']])
cross[row['qid']]=[row['ids'][i] for i in np.lexsort((row['ids'],-scores))]
q3=audit_ids[:3]
display(pd.DataFrame([metrics(cross,labels(q3),10)],index=['TinyBERT: 3-query demonstration']))
upper=np.mean([len(set(r['ids'])&ctx['truth']['test'][r['qid']])/len(ctx['truth']['test'][r['qid']]) for r in audit_rows])
linear=rerank_rows(ranker,audit_rows)
assert np.isclose(metrics(linear,labels(audit_ids),100)['recall@100'],upper)
print('40-query candidate Recall@20 upper bound:',upper)Notebook 保存的输出 · 本页未执行
/Users/lynnlee/Documents/Codex/2026-10-30/task/soutui-lab/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
| recall@10 | ndcg@10 | mrr@10 | hit_rate@10 | |
|---|---|---|---|---|
| TinyBERT: 3-query demonstration | 0.666667 | 0.666667 | 0.666667 | 0.666667 |
40-query candidate Recall@20 upper bound: 0.8875
练习:固定五特征与相同候选,仅改变C,在40个选择查询上比较;保留全部结果。若要宣称最终改善,需要新查询审计或其他预先定义的评估,而不是反复挑这40个已见测试查询。新模型属于本章局部练习,不替换正式服务。