25 · 真实搜索任务与相关性评估
推荐通常从用户历史预测物品,搜索必须同时理解查询与文档。相同的排序基础设施可以复用,但任务定义、标签与分母不能混用。本章从真实数据契约开始,亲手计算MRR、NDCG与召回,再观察“未判断”如何改变解读。
先预测:一个查询有3篇相关论文,前10只找回1篇,Hit与Recall会相同吗?所有未标注论文都是真的不相关吗?
本课程共享第17章的SciFact实验,不复制数据或训练器。完整语料5,183文档,测试300查询/339个正判断;训练809查询,正式线性模型只用字典序前200。新章节的子集实验会逐格标出范围,不替代原300查询结果。
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. 把输入和输出写成契约
文档表有_id/title/text;查询是query_id → text;qrels是query_id → {相关document_idx}。原始ID保留,内部从1编号,0留给共享推荐检索函数。若忘记这个约定,语料的第一篇会被静默屏蔽。
动手:检查ID唯一性、跨表引用与查询分组,而不是仅看总行数。下面只输出数量和形状;原查询/摘要留在本机。
summary = pd.DataFrame([{'split':s, 'queries':len(q), 'positive_qrels':sum(map(len,q.values()))} for s,q in ctx['truth'].items()])
display(summary)
print('documents / vectors:',len(ctx['corpus']), ctx['vectors'].shape)
assert ctx['corpus']['_id'].is_unique
assert not (set(ctx['truth']['train']) & set(ctx['truth']['test']))
assert all(1 <= d <= len(ctx['corpus']) for split in ctx['truth'].values() for truth in split.values() for d in truth)Notebook 保存的输出 · 本页未执行
| split | queries | positive_qrels | |
|---|---|---|---|
| 0 | train | 809 | 919 |
| 1 | test | 300 | 339 |
documents / vectors: 5183 (5183, 384)
2. 一项指标回答一个问题
Recall@k=找回相关数/该查询已知相关总数;Hit@k只问有没有找到;MRR只关心第一个相关结果;二值NDCG把每个相关结果按1/log2(rank+1)折扣,再除以理想排序。都是先对查询计算,再宏平均,不能把所有文档对混成一次平均。
先预测→改变量:只把第一条不相关文档换成相关文档,哪些指标变化?这是构造排名的机制验证,随后才回到真实qrels。
truth = {'q':{1,2,3}}
for prediction in [[4,2,5],[1,2,5]]:
display(pd.DataFrame([metrics({'q':prediction},truth,3)], index=[str(prediction)]))
rel = np.array([0.,1.,0.]); discount=1/np.log2(np.arange(3)+2)
manual_ndcg=(rel*discount).sum()/discount.sum()
assert np.isclose(manual_ndcg, metrics({'q':[4,2,5]},truth,3)['ndcg@3'])
assert metrics({'q':[4,2,5]},truth,3)['hit_rate@3']==1
assert metrics({'q':[4,2,5]},truth,3)['recall@3']==1/3Notebook 保存的输出 · 本页未执行
| recall@3 | ndcg@3 | mrr@3 | hit_rate@3 | |
|---|---|---|---|---|
| [4, 2, 5] | 0.333333 | 0.296082 | 0.5 | 1.0 |
| recall@3 | ndcg@3 | mrr@3 | hit_rate@3 | |
|---|---|---|---|---|
| [1, 2, 5] | 0.666667 | 0.765361 | 1.0 | 1.0 |
3. 判断不完整时,分母来自哪里
SciFact导出仅提供正判断,没有穷尽负判断。未标注文档在本实验指标中按0处理,这是可复现的评估约定,不是对真实世界相关性的证明。known_positive_fraction@10只是已知正判断比例。
下面找出多正例查询,显示它们对宏平均的贡献。解释:为什么按所有命中数汇总再除所有正例数,不等于查询宏平均Recall?
truth=ctx['truth']['test']
rows=[]
for r in ctx['per_query']:
relevant=len(truth[r['qid']]); recall=r['metrics']['bm25']['recall@10']
rows.append({'qid':r['qid'],'relevant':relevant,'recall':recall,'hits':recall*relevant})
q=pd.DataFrame(rows)
display(q[q.relevant>1].head(8))
print('macro recall:',q.recall.mean(),'pooled recall:',q.hits.sum()/q.relevant.sum())
assert np.isclose(q.recall.mean(),ctx['result']['rows'][0]['recall@10'])Notebook 保存的输出 · 本页未执行
| qid | relevant | recall | hits | |
|---|---|---|---|---|
| 8 | 1029 | 3 | 1.00 | 3.0 |
| 9 | 1041 | 2 | 1.00 | 2.0 |
| 23 | 1132 | 2 | 0.50 | 1.0 |
| 63 | 1274 | 3 | 1.00 | 3.0 |
| 81 | 133 | 5 | 0.20 | 1.0 |
| 95 | 1379 | 4 | 0.50 | 2.0 |
| 100 | 141 | 2 | 1.00 | 2.0 |
| 107 | 179 | 4 | 0.75 | 3.0 |
macro recall: 0.7909444444444444 pooled recall: 0.7728613569321534
4. 用人为补判断观察偏差
只改变某个原本未判断文档的相关标签,保持排名不变。指标变化来自标签覆盖,不是检索器变好了。以下新标签明确是构造反事实,绝不写回真实qrels。
r=ctx['per_query'][0]; qid=r['qid']
original=set(truth[qid]); prediction=[int(np.flatnonzero(ctx['corpus']['_id'].values==d)[0])+1 for d in r['top10']['bm25']]
unjudged=next((d for d in prediction if d not in original),None)
if unjudged is not None:
display(pd.DataFrame([metrics({qid:prediction},{qid:original},10),metrics({qid:prediction},{qid:original|{unjudged}},10)],index=['original judgments','constructed extra positive']))
print('真实qrels未修改:',truth[qid]==original)Notebook 保存的输出 · 本页未执行
| recall@10 | ndcg@10 | mrr@10 | hit_rate@10 | |
|---|---|---|---|---|
| original judgments | 0.0 | 0.000000 | 0.0 | 0.0 |
| constructed extra positive | 0.5 | 0.613147 | 1.0 | 1.0 |
真实qrels未修改: True
5. 来源、复现与练习
docs/search_sources.json的20项输入有发布仓库固定提交、字节数和SHA。首次context()核验全部输入。模型、原始数据与逐查询输出均不进入Git;正式run的配置与代码摘要可追溯。
练习:构造两个查询,一个有1个正例,一个有5个正例,让宏平均与汇总Recall产生明显差异;再解释为什么空目标查询不能用0分悄悄混入当前协议。下一章保持这套标签不变,只改变词法检索。