搜索 / NOTEBOOK 课程

30 · 失败分析、延迟口径与统一服务

深入搜索 · 真实语料/明确子集 · 公式与单变量实验 · 共享服务

真实数据实验只读 · 已保存的实验

已有输出来自保存的教学实验,并非打开页面时重新执行。 代码和原始输出保持不变;数据与历史注释保留原始语言。

30 · 失败分析、延迟口径与统一服务

最后把前五章接回同一个系统:从逐查询误差定位召回或排序问题,区分测量阶段,再逐文档校验实际服务与离线结果。

先预测:NDCG平均上涨,是否可能让一部分查询从首名命中变成完全未命中?同一模型HTTP耗时为什么会比表格里的推理耗时更长?

本章使用完整300查询的既有逐查询记录;不会用失败分析反向调参后仍称独立测试。

学习导航: 上一章 · 综合实验

PYTHON · 01
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. 平均指标后面有输赢分布

线性重排相对BM25的增益不代表每个查询都获益。按查询差值计数并画分布,展示退化而不只挑成功例子。零差值与浮点微误差要区分。

PYTHON · 02
rows=[]
for r in ctx['per_query']:
    a=r['metrics']['bm25']['ndcg@10']; b=r['metrics']['linear_rerank']['ndcg@10']
    rows.append({'qid':r['qid'],'bm25':a,'linear':b,'delta':b-a})
errors=pd.DataFrame(rows)
print('300 queries: wins/ties/losses',int((errors.delta>1e-10).sum()),int((errors.delta.abs()<=1e-10).sum()),int((errors.delta < -1e-10).sum()))
display(errors.sort_values('delta').head(8))
errors.delta.plot.hist(bins=20,title='Linear rerank minus BM25: all 300 test queries');plt.show()
Notebook 保存的输出 · 本页未执行
300 queries: wins/ties/losses 63 211 26
qid bm25 linear delta
223 660 1.000000 0.000000 -1.000000
139 303 0.630930 0.000000 -0.630930
76 1303 1.000000 0.430677 -0.569323
7 1024 1.000000 0.500000 -0.500000
264 830 0.386853 0.000000 -0.386853
226 690 0.386853 0.000000 -0.386853
105 163 1.000000 0.630930 -0.369070
177 507 1.000000 0.630930 -0.369070
<Figure size 640x480 with 1 Axes>
Notebook 保存的图表

2. 候选丢失还是排序降位

对TinyBERT退化最严重的固定8个查询,重新计算同一个融合前20。目标根本不在20内是候选召回失败;在20内但掉出前10是排序问题;即使二者都发生,也应逐正例判断。对于多正例查询,下面用“至少一个相关是否还在候选/前10”作粗分类,不把它当细粒度归因。

PYTHON · 03
failure_ids=[r['qid'] for r in ctx['result']['failures']]
failure_vectors=query_vectors(failure_ids); diagnostics=[]
by_id={r['qid']:r for r in ctx['per_query']}
for q,v in zip(failure_ids,failure_vectors):
    candidates=topk(score_query(q,v)[2],20); truth=ctx['truth']['test'][q]
    diagnostics.append({'qid':q,'relevant_in_candidates20':len(set(candidates)&truth), 'known_relevant':len(truth),
                        'hybrid_ndcg10':by_id[q]['metrics']['hybrid_rrf']['ndcg@10'],
                        'tinybert_ndcg10':by_id[q]['metrics']['tinybert_rerank']['ndcg@10']})
display(pd.DataFrame(diagnostics))
print('Local ctx contains query text for inspection; raw text is not saved to Git.')
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
qid relevant_in_candidates20 known_relevant hybrid_ndcg10 tinybert_ndcg10
0 1363 1 1 0.333333 0.000000
1 659 1 1 1.000000 0.000000
2 660 1 1 0.315465 0.000000
3 70 2 2 0.624051 0.000000
4 94 1 1 1.000000 0.000000
5 785 1 1 0.000000 0.000000
6 743 1 1 0.630930 0.386853
7 124 1 1 1.000000 0.430677
Local ctx contains query text for inspection; raw text is not saved to Git.

3. 先写计时边界,再报数字

正式表格含查询tokenization/编码、检索、融合和重排;不含模型加载、建索引、磁盘和HTTP。第4章只测索引,API返回的计时与HTTP客户端墙钟也不完全相同。对相同warm查询重复5次只是抖动观察,不能当容量测试或生产QPS。

接下来调用既有18580服务,不启动第二个服务器、不接管Jupyter;若未运行,按README先运行./lab.sh up。

PYTHON · 04
import json, time, urllib.request
base='http://127.0.0.1:18580'
def call_search(query,method):
    request=urllib.request.Request(base+'/api/search',data=json.dumps({'query':query,'method':method,'limit':10}).encode(),headers={'Content-Type':'application/json'})
    with urllib.request.urlopen(request,timeout=90) as response: return json.load(response)
qid=ctx['per_query'][0]['qid']; query=ctx['queries'][qid]
call_search(query,'hybrid_rrf')  # explicit warmup, not included below
latency=[]
for _ in range(5):
    start=time.perf_counter(); answer=call_search(query,'hybrid_rrf'); wall=(time.perf_counter()-start)*1000
    latency.append({'http_wall_ms':wall,'reported_search_ms':answer['latency_ms']})
display(pd.DataFrame(latency))
Notebook 保存的输出 · 本页未执行
http_wall_ms reported_search_ms
0 18.029292 11.091667
1 16.898375 11.355250
2 17.979375 11.189958
3 16.962208 10.923458
4 16.424583 10.975125

4. API与离线逐文档核验

同一个run_id、同一查询、同一方法,前10文档ID应逐项一致。质量分数接近不足以证明服务加载了正确模型。任意新查询没有qrels,因此服务只返回文档和排序分数,不显示虚构NDCG。

PYTHON · 05
expected=by_id[qid]
checks=[]
for method in ['bm25','dense','hybrid_rrf','linear_rerank','tinybert_rerank']:
    response=call_search(query,method)
    ids=[x['document_id'] for x in response['items']]
    assert response['run_id']==ctx['receipt']['run_id']
    assert ids==expected['top10'][method]
    checks.append({'method':method,'documents':len(ids),'matches_offline':True})
display(pd.DataFrame(checks))
Notebook 保存的输出 · 本页未执行
method documents matches_offline
0 bm25 10 True
1 dense 10 True
2 hybrid_rrf 10 True
3 linear_rerank 10 True
4 tinybert_rerank 10 True

5. 从搜索推广到同一搜推广告系统

共用的是实验身份/来源与SHA、结果目录、稳定排序、服务与教学入口;不共用不相容的业务标签。推荐未交互不是真负曝光,搜索未判断不是真不相关,广告CTR不是搜索相关概率。拍卖收入必须与真实点击预测的评估分开,模拟结果不能冒充业务收益。

下一轮实验卡片:问题是什么?只改哪个变量?使用哪些训练/选择/审计查询?固定哪些预算?哪项失败会推翻假设?每次正式运行走begin/finish创建新记录,不能覆盖旧结果。第17章保留作为已完成实验总览,本六章的探索不会悄悄替换服务。

练习:选择一个退化查询,在本机查看真实文本与候选标题,提出可被验证的截断/领域假设;写出所需新评估数据,先记录计划,再训练。不要仅凭个例编造改善原因。

继续阅读原文

本章来源

作者课程中文

王树森 · 搜索引擎技术

基础第4讲:搜索链路

固定版本:六模块目录、9份PDF。查询处理、召回、排序、查询推荐四模块主要是目录,Lab实验为独立教学实现。

学习标记可选,仅保存在当前浏览器中。打开页面不会自动标记已学。