📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来
FDE 学习系列教程 · 第三阶段 · 第 10 周 · Day 3 预计时长:3 小时 | 难度:★★★★☆ | 前置知识:Day 61-67(RAG 链路、BGE-M3、向量库、混合检索) 对标大纲课时 :3.2.7 重排序 ------ Cross-encoder Reranker、Cohere Rerank、BGE-Reranker(大纲推荐工具:BGE-Reranker、Cohere Rerank) 📌 一句话目标:把 Day 67 混合检索召回的 Top-20 交给 BGE-Reranker 精排,让真正能回答问题的片段稳定进入 Top-3,并用 NDCG@5、Precision@3、MRR 量化证明排序变准。
🧑🤝🧑 开场:找回来了,还得把它顶到第一
先接上 Day 67 最后一段留下的问题:
Day 67 已经做到:
BM25 认专名 + BGE-M3 懂语义 + RRF 合两路
Recall@5:0.70 → 0.95 ✅ 该找的基本找回来了
但还没有做到:
MRR:0.836,不是 1.0 ⚠️ 正确答案不总在第 1
Top-3 里仍混着"主题相关、却答非所问"的块
真正能回答问题的块,有时排第 2、第 3,甚至第 4
Day 68 要做:
混合召回 Top-20 → BGE-Reranker 逐对精排 → 只留最强 Top-3~5
这不是一个小问题。 RAG 真正交给 DeepSeek 的上下文,通常只有 Top-3 到 Top-5。正确片段虽然"被召回",但如果排在第 8,跟没召回几乎没有区别;更危险的是,排在它前面的弱相关片段会抢走模型注意力,让答案被带偏。 回到主线项目:
第二阶段:设备告警工单闭环
FastAPI + MySQL + Docker + 飞书推送
↓
第 7~8 周:v0.1 智能工单助手(Day 60 冻结)
工单分类 + 信息提取 + 巡检报告
↓
第 9 周:知识库问答 v0.2
BGE-M3 → 文档解析 → 分块 → Chroma → 带引用回答
↓
Day 66:Chroma 换成 Qdrant
↓
Day 67:向量 + BM25 + RRF,召回变全
↓
Day 68(今天)🆕:Top-20 再精排,把真正相关的顶到 Top-3
今天不改分块,不重灌向量,也不替换 Day 67 的混合检索。 我们只在"召回"和"生成"中间插一层:Reranker(重排序器) 。 今天八件事:① 分清召回和精排的职责;② 看懂双塔与交叉编码器的根本差别;③ 本地跑通 bge-reranker-v2-m3;④ 搭出 20 → 5 的重排管线;⑤ 用 Day 67 的 20 条评测集量化;⑥ 设计阈值与拒答;⑦ 测延迟、候选数和缓存;⑧ 接回 v0.2 的回答 API。
💡 今天的关键词不是"再检索一次",而是"重新审题"。 召回阶段问"这段大概像不像";精排阶段问"把问题和这段放在一起看,它到底能不能回答"。
📖 一、召回与精排:两阶段各管一件事
1.1 海选求全,面试求准
先用最直白的类比:
-
**召回(Recall)**像海选:宁可多叫来一些人,也别把真正合适的人漏掉。
-
**精排(Rerank)**像面试:候选人已经不多了,再逐个深聊,把最合适的排前面。
10 万个知识块 │ │ ① ANN 向量检索:语义海选,快 ▼ 100 条 │ │ ② BM25 / sparse:专名补召回 ▼ 20 条 ← Day 67 到这里:求全 │ │ ③ Cross-Encoder Reranker:逐对深度判断 ▼ 5 条 ← Day 68 做这里:求准 │ │ ④ 拼进 Prompt ▼ DeepSeek 回答
两阶段目标不同,所以指标也不同:
| 阶段 | 核心问题 | 主要目标 | 常看指标 | 典型技术 |
|---|---|---|---|---|
| 召回 | "正确资料有没有进候选池?" | 求全,少漏 | Recall@20、Recall@50 | BGE-M3、ANN、BM25、RRF |
| 精排 | "正确资料排得够不够靠前?" | 求准,少混 | Precision@3、NDCG@5、MRR | Cross-Encoder、BGE-Reranker |
| 生成 | "回答是否忠于资料?" | 有据、完整、可读 | Faithfulness、答案正确率 | DeepSeek + Prompt |
📌 一句话分工:召回负责"不漏人",精排负责"不排错人"。
1.2 为什么"有召回"还不够
假设问题是:
"料筒温度没降下来,能不能打开防护罩?"
Day 67 的混合检索可能返回:
| 原名次 | 候选块 | RRF 判断 | 真正价值 |
|---|---|---|---|
| 1 | "A3 料筒超过 85℃ 触发高温告警" | 两路都靠前 | 主题相关,但没回答"能不能开" |
| 2 | "温度未降至 60℃ 以下时禁止打开防护罩" | 向量第 3、BM25 第 5 | 直接回答问题 |
| 3 | "加热圈单段电阻标准 18±2 欧姆" | 命中"温度/加热" | 弱相关 |
| 4 | "冷却水压力低于 0.25MPa 必须停机" | 同属超温处置章节 | 背景相关 |
| 5 | "防护罩每周检查一次联锁开关" | 命中"防护罩" | 字面相关,意图不同 |
如果只喂 Top-1,系统会回答"超过 85℃ 会告警",却没有回答能不能开。 如果喂 Top-3,模型看到一个直接答案、两个干扰项,仍可能把"告警阈值 85℃"和"开罩阈值 60℃"混在一起。 重排后的目标是:
重排前:告警阈值 → 禁止开罩 → 加热圈 → 冷却水 → 联锁检查
重排后:禁止开罩 → 告警阈值 → 联锁检查 → 冷却水 → 加热圈
▲
真正回答问题的片段,被顶到第 1
1.3 为什么不能一步到位
你可能会问:既然 Reranker 更准,为什么不直接拿它跟 10 万个 chunk 一一比较? 因为它太慢。
双塔检索:
文档向量提前算好并存库
在线只编码 1 次 query,再做 ANN
10 万条也可以毫秒级筛完
交叉编码精排:
每个候选都要和 query 拼成一对
10 万个 chunk = 10 万个 pair = 10 万次在线深度计算
延迟和成本都不可接受
| 直接方案 | 准确性 | 延迟 | 能否上线 |
|---|---|---|---|
| 只用向量检索取 Top-5 | 中 | 低 | 能,但排序容易不精 |
| 对全库做 Reranker | 高 | 极高 | ❌ 大库不可行 |
| 先召回 20~100,再精排 | 高 | 可控 | ✅ 工业标准做法 |
| 这就是两阶段检索存在的原因:快模型先缩小范围,准模型再做难判断。 |
⚠️ Reranker 不能把没召回的资料"变出来"。正确 chunk 不在 Top-20,精排再强也救不回来。所以 Day 67 的混合召回不是被替代,而是给今天打地基。
📖 二、双塔 vs 交叉编码器:为什么精排更准却更慢
2.1 双塔:先各看各的,再比向量
Day 62 用的 BGE-M3 稠密向量,是 Bi-Encoder(双编码器,简称双塔)路线。 "塔"不是两个不同模型,而是两条独立编码路径:query 走一次,document 走一次。两边通常共享同一套参数,但编码时互相看不见。
双塔 Bi-Encoder
query:"温度没降能开防护罩吗?" doc:"低于 60℃ 前禁止打开防护罩"
│ │
▼ ▼
BGE-M3 独立编码 BGE-M3 独立编码
│ │
▼ ▼
q = [0.12, -0.08, ...] d = [0.09, -0.11, ...]
└──────────────┬───────────────────────┘
▼
cosine(q, d) = 0.78
关键:query 编码时看不到 doc,doc 编码时也看不到 query。
双塔最大的工程优势是:文档向量可以离线预计算。
Day 65 的 kb/build_index.py 已经把所有 doc 向量算好,Day 66 又把它们迁进 Qdrant。在线提问时,只需执行一次 embed_one(question),然后在索引里找近邻。 代价也很明确:
-
"温度没降"和"低于 60℃"之间的关系,只能靠两个压缩后的向量间接表达。
-
query 里的"能不能开"无法逐 token 对齐 doc 里的"禁止打开"。
-
型号、否定词、数值条件这些细粒度信号,压成一个 1024 维向量后可能被稀释。
2.2 交叉编码:把两段话放在一张卷子上批
Reranker 通常是 Cross-Encoder(交叉编码器)。 它不先得到两个独立向量,而是把 query 和 document 拼成一对,一起送进 Transformer:
交叉编码 Cross-Encoder
[CLS] 温度没降能开防护罩吗? [SEP] 低于 60℃ 前禁止打开防护罩 [SEP]
│ query tokens │ doc tokens │
└─────────────────────────────────┴─────────────────────────────────┘
│
所有 token 互相 Attention
│
▼
相关性 logit = 8.642
这次,模型能直接看到:
-
query 的"能开吗"与 doc 的"禁止打开"是正面回答关系;
-
query 的"温度没降"与 doc 的"低于 60℃ 前"是条件对应;
-
"禁止"这个否定词不能丢;
-
另一段只说"85℃ 触发告警",虽然主题相近,却没有回答"能不能开"。
💡 双塔在比"整体像不像",交叉编码器在判"这段能不能回答这个问题"。 后者更像阅读理解,所以排序更准。
2.3 结构、计算与部署对照
| 维度 | 双塔 Bi-Encoder | 交叉编码 Cross-Encoder |
|---|---|---|
| 输入方式 | query、doc 分开编码 | [CLS] query [SEP] doc [SEP] 一起编码 |
| token 交互 | ❌ 编码阶段互不相见 | ✅ 每个 token 可互相 attention |
| 输出 | 向量,再算相似度 | 一个相关性分数 |
| doc 能否预计算 | ✅ 能,提前存 Qdrant | ❌ 不能,query 变了就要重算 |
| 在线模型前向 | query 只做 1 次 | m 个候选要做 m 个 pair |
| 全库检索 | ✅ 适合,ANN 很快 | ❌ 不适合 |
| 排序精度 | 中高 | 高 |
| 典型位置 | 第一阶段召回 | 第二阶段精排 |
| 今天的模型 | BAAI/bge-m3 |
BAAI/bge-reranker-v2-m3 |
2.4 复杂度与延迟账
设知识库有 N 个 chunk,召回后候选数为 m:
双塔在线成本:
1 次 query 编码 + 1 次 ANN 查询
文档编码成本已在 build_index 阶段付过
ANN 常见近似复杂度约 O(log N)
交叉编码在线成本:
构造 m 个 (query, doc) pair
对 m 对输入执行模型前向,计算量近似随 m 线性增长
每一对内部还有 Transformer Attention,长度越长越慢
下面是延迟量级,不是硬承诺。CPU、文本长度、线程数都会影响结果:
| 操作 | 候选数 | CPU 常见量级 | 备注 |
|---|---|---|---|
| Qdrant / Chroma 向量检索 | 全库 → 20 | 约 5~20ms | 不含首次模型加载 |
| BM25 + RRF | 两路各 20 | 约 3~15ms 增量 | 倒排检索很轻 |
| BGE-Reranker 精排 | 10 对 | 约 120~400ms | 取决于 chunk 长度 |
| BGE-Reranker 精排 | 20 对 | 约 200~800ms | 今天的默认配置 |
| BGE-Reranker 精排 | 50 对 | 约 500~2000ms | CPU 服务要慎用 |
⚠️ 首次请求还包含模型加载,可能是数秒到几十秒。线上服务启动时应预热,不能把冷启动时间算进正常请求 SLA。
2.5 今天为什么选 v2-m3
BAAI/bge-reranker-v2-m3 的关键事实:
-
568M 参数;
-
基于 BGE-M3,多语言;
-
最大长度 8192 tokens;
-
相比 LLM 系 Reranker 更轻,适合本地部署;
-
中文场景官方推荐
bge-reranker-v2-m3或bge-reranker-v2-minicpm-layerwise。 今天选它,不是因为它永远最强,而是因为它处在一个适合 FDE 现场交付的平衡点:中文强、API 简单、CPU 能跑、GPU 也能提速。
🖥️ 三、BGE-Reranker 本地跑起来
实操步骤 1:安装依赖
进入现有 fde-ai 项目并激活 Python 3.11+ 虚拟环境:
cd fde-ai
.\.venv\Scripts\Activate.ps1
python --version
pip install -U FlagEmbedding
输出示例:
Python 3.11.9
Successfully installed FlagEmbedding-1.x.x
📌
FlagEmbedding已经是 Day 62 的依赖。如果当前环境能正常导入BGEM3FlagModel,本次升级通常只会补齐 Reranker 相关能力。 ⚠️ 首次加载BAAI/bge-reranker-v2-m3会下载约 2.3GB 模型文件。客户现场若不能联网,应提前在可联网环境下载并带入模型缓存;不要等上线当天才发现机器出不了网。
实操步骤 2:单个 pair 打分
新建临时验证脚本 day68_reranker_hello.py:
"""Day68:BGE-Reranker 最小闭环------单 pair 打分"""
from FlagEmbedding import FlagReranker
MODEL_NAME = "BAAI/bge-reranker-v2-m3"
# CPU 必须 False;有 CUDA GPU 时才考虑 True
reranker = FlagReranker(MODEL_NAME, use_fp16=False)
query = "料筒温度没降下来,能不能打开防护罩?"
doc = "A3 注塑机料筒温度未降至 60℃ 以下时,禁止打开防护罩。"
score = reranker.compute_score([query, doc])
print(f"原始分数:{float(score):.4f}")
运行:
python day68_reranker_hello.py
输出示例:
原始分数:8.6421
这个 8.6421 是什么? 它是模型输出的原始 logit:
-
可以是正数,也可以是负数;
-
越大,表示模型认为 query 与 doc 越相关;
-
不是 0~1 概率;
-
不要拿
8.6理解成"86% 相关"。
实操步骤 3:批量打分
真正重排 Top-20 时,不要写 20 次 Python 循环分别调用模型。应把 pair 一次交给 compute_score 批量推理:
"""Day68:批量比较三个设备手册候选块"""
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=False)
query = "料筒温度没降下来,能不能打开防护罩?"
candidates = [
"A3 注塑机料筒超过 85℃ 时触发高温告警。",
"A3 注塑机料筒温度未降至 60℃ 以下时,禁止打开防护罩。",
"液压油每 500 小时更换一次,换油后检查油位。",
]
pairs = [[query, text] for text in candidates]
scores = reranker.compute_score(pairs)
for text, score in sorted(zip(candidates, scores), key=lambda x: -x[1]):
print(f"{score:8.4f} | {text}")
python day68_reranker_batch.py
输出示例:
8.6421 | A3 注塑机料筒温度未降至 60℃ 以下时,禁止打开防护罩。
2.1143 | A3 注塑机料筒超过 85℃ 时触发高温告警。
-6.2417 | 液压油每 500 小时更换一次,换油后检查油位。
看见区别了吗?
向量检索可能觉得前两条都在讲"料筒 + 温度",相似度只差一点;交叉编码器把问题一起读完后,直接回答"能不能打开"的第二条高出一大截。
💡 批量不等于少算。 20 个 pair 仍然要算 20 对,只是矩阵运算能并行,减少 Python 调用和设备搬运开销,所以比逐条调用快得多。
实操步骤 4:normalize=True 看 0~1 分数
对同一批 pairs 再传 normalize=True:
raw_scores = reranker.compute_score(pairs)
norm_scores = reranker.compute_score(pairs, normalize=True)
for raw, norm in zip(raw_scores, norm_scores):
print(f"raw={raw:8.4f} norm={norm:.6f}")
输出示例:
raw= 9.1336 norm=0.999892
raw= 3.0187 norm=0.953412
raw= -7.2041 norm=0.000742
它做的是 1 / (1 + exp(-raw_score)):只改变显示尺度,不改变排序。0.95 仍不是经过设备手册数据校准的"95% 正确率"。
实操步骤 5:CPU 与 GPU 配置别写反
from FlagEmbedding import FlagReranker
# 无 GPU:必须 False
cpu_reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=False)
# 有 CUDA GPU:可用 True 提速并省显存
# gpu_reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
| 环境 | use_fp16 |
结果 |
|---|---|---|
| CPU | False | ✅ 正常 |
| CPU | True | ❌ 可能报 LayerNormKernelImpl 等运行时错误 |
| CUDA GPU | False | ✅ 能跑,但较慢、显存更多 |
| CUDA GPU | True | ✅ 推荐,通常更快 |
⚠️ 今天最容易踩的坑就是 CPU 写成
use_fp16=True。 看到半精度、LayerNorm、not implemented for Half 一类错误,第一件事就是把它改回False。
🖥️ 四、搭出 rerank 管线(recall 20 → rerank → top 5)
现在把最小实验装进主线工程。 今天新增两个文件:
fde-ai/
├── kb/
│ ├── embedder.py # 已有:BGE-M3
│ ├── hybrid.py # Day67:hybrid_search(question, top_k)
│ ├── reranker.py # 🆕 模型单例 + 候选精排
│ ├── pipeline.py # 🆕 召回 20 → 精排 5
│ ├── answer.py # 第八节改造
│ └── api.py # 第八节改造
└── eval_rerank.py # 第五节:四组评测
实操步骤 6:写 kb/reranker.py
要求有四个:
-
模型只加载一次;
-
第一次真正需要时才加载;
-
候选批量打分;
-
保留重排前后名次与两种分数。
"""BGE-Reranker 封装:单例懒加载 + 批量精排。"""
import math
import threading
from typing import Anyfrom FlagEmbedding import FlagReranker
MODEL_NAME = "BAAI/bge-reranker-v2-m3"
USE_FP16 = False_model: FlagReranker | None = None
_model_lock = threading.Lock()def _get_model() -> FlagReranker:
"""进程内单例;并发首请求也只加载一次。"""
global _model
if _model is None:
with _model_lock:
if _model is None:
print(f"正在加载 Reranker:{MODEL_NAME}")
_model = FlagReranker(MODEL_NAME, use_fp16=USE_FP16)
return _modeldef _sigmoid(value: float) -> float:
"""稳定版 sigmoid,避免极大负数触发 exp 溢出。"""
if value >= 0:
return 1.0 / (1.0 + math.exp(-value))
exp_value = math.exp(value)
return exp_value / (1.0 + exp_value)class Reranker:
def init(self) -> None:
self.model_name = MODEL_NAMEdef rerank( self, question: str, candidates: list[dict[str, Any]], top_k: int = 5, ) -> list[dict[str, Any]]: """按相关性降序返回,并附加 raw/norm 分数与名次变化。""" if not candidates or top_k <= 0: return [] pairs = [[question, str(item["text"])] for item in candidates] raw = _get_model().compute_score(pairs) raw_scores = [float(raw)] if isinstance(raw, (int, float)) else [float(x) for x in raw] scored: list[dict[str, Any]] = [] for rank_before, (item, raw_score) in enumerate( zip(candidates, raw_scores), start=1 ): result = dict(item) result.update( raw_score=raw_score, norm_score=_sigmoid(raw_score), rank_before=rank_before, ) scored.append(result) scored.sort(key=lambda item: item["raw_score"], reverse=True) for rank_after, item in enumerate(scored, start=1): item["rank_after"] = rank_after return scored[: min(top_k, len(scored))]_default_reranker = Reranker()
def rerank(
question: str,
candidates: list[dict[str, Any]],
top_k: int = 5,
) -> list[dict[str, Any]]:
"""模块级便捷入口。"""
return _default_reranker.rerank(question, candidates, top_k)
这里没有调用两次模型来分别拿 raw 和 norm。 因为 sigmoid 不改变排序,我们只算一次 raw,再用 Python 计算 norm_score,直接省掉一半推理成本。
📌
dict(item)很重要:它复制 Day 67 候选里的id、text、rrf、ranks、source、chapter等字段。精排是加字段,不是把召回证据抹掉。
实操步骤 7:写 kb/pipeline.py
"""两阶段检索管线:宽召回 → 精排 → 窄输出。"""
from typing import Literal
from .hybrid import hybrid_search
from .reranker import rerank
from .retriever import retrieve
def retrieve_and_rerank(
question: str,
top_k: int = 5,
recall_k: int = 20,
device: str | None = None,
route: Literal["hybrid", "vector"] = "hybrid",
) -> list[dict]:
if not 1 <= top_k <= recall_k:
raise ValueError("必须满足 1 <= top_k <= recall_k")
if route == "hybrid":
candidates = hybrid_search(question, top_k=recall_k, device=device)
else:
candidates = retrieve(question, top_k=recall_k, device=device)
return rerank(question, candidates, top_k=top_k)
这段代码的关键不是长,而是边界清楚:
hybrid_search 只负责:找出 20 个候选
rerank 只负责:给已有候选重新排序
retrieve_and_rerank 负责:把两步串起来,对外提供一个入口
⚠️
recall_k必须大于等于top_k。如果只召回 5 条再精排 5 条,Reranker 只能调整这 5 条顺序;正确答案排在原始第 6 时,它救不回来。
实操步骤 8:打印"重排前 vs 重排后"
新建 day68_compare.py:
"""Day68:真实问题的重排前后对照。"""
from kb.hybrid import hybrid_search
from kb.pipeline import retrieve_and_rerank
QUESTION = "料筒温度没降下来能不能打开防护罩?"
before = hybrid_search(QUESTION, top_k=20)
after = retrieve_and_rerank(QUESTION, top_k=5, recall_k=20)
print(f"问题:{QUESTION}\n")
print("【重排前:RRF Top-5】")
for rank, hit in enumerate(before[:5], start=1):
print(f"{rank}. {hit['id']} | {hit['text'][:48].replace(chr(10), ' ')}")
print("\n【重排后:BGE-Reranker Top-5】")
for hit in after:
delta = hit["rank_before"] - hit["rank_after"]
print(
f"{hit['rank_after']}. 原#{hit['rank_before']:<2} "
f"变化={delta:+d} raw={hit['raw_score']:7.3f} "
f"norm={hit['norm_score']:.4f} | {hit['text'][:48].replace(chr(10), ' ')}"
)
运行:
python day68_compare.py
输出示例:
问题:料筒温度没降下来能不能打开防护罩?
【重排前:RRF Top-5】
1. manual_a3.clean.md-0005 | A3 注塑机料筒超过 85℃ 时触发高温告警......
2. manual_a3.clean.md-0010 | 温度未降至 60℃ 以下时禁止打开防护罩......
3. manual_a3.clean.md-0004 | 加热圈单段电阻标准 18±2 欧姆......
4. manual_a3.clean.md-0011 | 停机后关闭进水阀并挂牌上锁......
5. manual_a3.clean.md-0017 | 每周检查防护罩联锁开关......
【重排后:BGE-Reranker Top-5】
1. 原#2 变化=+1 raw= 8.642 norm=0.9998 | 温度未降至 60℃ 以下时禁止打开防护罩......
2. 原#1 变化=-1 raw= 2.114 norm=0.8923 | A3 注塑机料筒超过 85℃ 时触发高温告警......
3. 原#5 变化=+2 raw= -0.318 norm=0.4212 | 每周检查防护罩联锁开关......
4. 原#4 变化=+0 raw= -3.106 norm=0.0428 | 停机后关闭进水阀并挂牌上锁......
5. 原#3 变化=-2 raw= -5.773 norm=0.0031 | 加热圈单段电阻标准 18±2 欧姆......
这张表就是今天最直观的成果:
-
原第 2 的直接答案升到第 1;
-
原第 1 的背景资料退到第 2;
-
只因命中"加热"的弱相关块从第 3 掉到第 5;
-
每条都保留
rank_before,出了问题能追查是召回错还是重排错。
🖥️ 五、效果量化:用 Day 67 的评测集证明有效
"看起来第一名更准"不够。 Day 67 已经留下 20 条评测问题和 gold chunk。今天复用它,不重新挑题,避免为了新方案临时换一批"更好看"的问题。
5.1 三个排序指标先讲清楚
| 指标 | 回答的问题 | 越大越好 | 今天的用途 |
|---|---|---|---|
| Recall@5 | 前 5 里有没有至少一个正确块 | ✅ | 检查是否把 gold 留在可用范围 |
| Precision@3 | 前 3 里有多少比例是真正相关块 | ✅ | 检查喂给 LLM 的干扰项多不多 |
| NDCG@5 | 正确块是否排得靠前,且多个相关块的顺序是否合理 | ✅ | 综合衡量 Top-5 排序质量 |
| MRR | 第一个正确块排第几 | ✅ | 最直观地看 Top-1 能力 |
二值相关性下,NDCG 的直觉是:
正确块排第 1:收益最大
正确块排第 2:打折
正确块排第 5:再打折
正确块没进前 5:记 0
DCG@5 = Σ rel_i / log2(i + 1)
NDCG@5 = 实际 DCG / 理想 DCG
📌 Day 67 每题若只有 1 个 gold,Precision@3 的理论上限是
1/3 = 0.333,不是 1.0。想让这个指标更完整,应把同样能支撑答案的相邻 chunk 一起标成 gold。
实操步骤 10:新增 eval_rerank.py
下面复用 Day 67 的 load_qa()、_vector_route() 与 hybrid_search(),跑四组:纯向量、混合、混合 + rerank、纯向量 + rerank。
"""Day68:四组排序评测。
运行:python eval_rerank.py
"""
import math
import time
from eval_recall import load_qa
from kb.hybrid import _vector_route, hybrid_search
from kb.reranker import rerank
RECALL_K = 20
FINAL_K = 5
def ids_of(hits: list[dict]) -> list[str]:
return [str(hit["id"]) for hit in hits]
def recall_at_k(ranked: list[str], gold: list[str], k: int = 5) -> float:
return float(bool(set(ranked[:k]) & set(gold)))
def precision_at_k(ranked: list[str], gold: list[str], k: int = 3) -> float:
return len(set(ranked[:k]) & set(gold)) / k
def reciprocal_rank(ranked: list[str], gold: list[str], k: int = 5) -> float:
gold_set = set(gold)
for rank, chunk_id in enumerate(ranked[:k], start=1):
if chunk_id in gold_set:
return 1.0 / rank
return 0.0
def ndcg_at_k(ranked: list[str], gold: list[str], k: int = 5) -> float:
gold_set = set(gold)
dcg = sum(
1.0 / math.log2(rank + 1)
for rank, chunk_id in enumerate(ranked[:k], start=1)
if chunk_id in gold_set
)
ideal_hits = min(len(gold_set), k)
idcg = sum(1.0 / math.log2(rank + 1) for rank in range(1, ideal_hits + 1))
return dcg / idcg if idcg else 0.0
def vector_candidates(question: str, top_k: int) -> list[dict]:
return _vector_route(question, top_k=top_k)
def hybrid_candidates(question: str, top_k: int) -> list[dict]:
return hybrid_search(question, top_k=top_k)
def run_plain(question: str, route: str) -> list[dict]:
fn = hybrid_candidates if route == "hybrid" else vector_candidates
return fn(question, FINAL_K)
def run_reranked(question: str, route: str) -> list[dict]:
fn = hybrid_candidates if route == "hybrid" else vector_candidates
candidates = fn(question, RECALL_K)
return rerank(question, candidates, top_k=FINAL_K)
def mean(values: list[float]) -> float:
return sum(values) / len(values)
def main() -> None:
qa = load_qa()
systems = {
"纯向量": lambda q: run_plain(q, "vector"),
"混合": lambda q: run_plain(q, "hybrid"),
"混合+rerank": lambda q: run_reranked(q, "hybrid"),
"纯向量+rerank": lambda q: run_reranked(q, "vector"),
}
stats = {
name: {"recall": [], "p3": [], "ndcg": [], "mrr": [], "ms": []}
for name in systems
}
for item in qa:
for name, system in systems.items():
started = time.perf_counter()
ranked = ids_of(system(item["q"]))
elapsed_ms = (time.perf_counter() - started) * 1000
gold = item["gold"]
stats[name]["recall"].append(recall_at_k(ranked, gold, 5))
stats[name]["p3"].append(precision_at_k(ranked, gold, 3))
stats[name]["ndcg"].append(ndcg_at_k(ranked, gold, 5))
stats[name]["mrr"].append(reciprocal_rank(ranked, gold, 5))
stats[name]["ms"].append(elapsed_ms)
print(f"评测集:{len(qa)} 条;recall_k={RECALL_K};final_k={FINAL_K}\n")
print(f"{'方案':<16}{'Recall@5':>10}{'P@3':>8}{'NDCG@5':>10}{'MRR':>8}{'平均耗时':>12}")
print("-" * 64)
for name, values in stats.items():
print(
f"{name:<16}{mean(values['recall']):>10.3f}"
f"{mean(values['p3']):>8.3f}{mean(values['ndcg']):>10.3f}"
f"{mean(values['mrr']):>8.3f}{mean(values['ms']):>10.1f}ms"
)
if __name__ == "__main__":
main()
⚠️ 如果 Day 67 把评测文件放在
kb/eval_recall.py,只需把导入改为from kb.eval_recall import load_qa。评测问题与 gold 不变,不能为迎合今天的结果重新标注。
实操步骤 11:跑四组对比
python eval_rerank.py
本课示例环境:42 个 chunk、20 条问题、CPU、候选 Top-20。输出示例:
评测集:20 条;recall_k=20;final_k=5
方案 Recall@5 P@3 NDCG@5 MRR 平均耗时
----------------------------------------------------------------
纯向量 0.700 0.217 0.602 0.570 18.4ms
混合 0.950 0.300 0.862 0.836 23.9ms
混合+rerank 0.950 0.317 0.948 0.925 486.2ms
纯向量+rerank 0.850 0.283 0.879 0.842 472.5ms
你的数字会不同,但应该读出下面五件事。 第一,混合 + rerank 的 Recall@5 仍是 0.950。 这次没涨,不代表 Reranker 没用。Day 67 已经把大多数 gold 放进前 5,重排只调整顺序,没有创造新候选。 第二,MRR 从 0.836 涨到 0.925。 这说明第一个正确块明显更靠前。换成大白话:原来平均第 1.2 位左右,现在更接近稳定第 1。 第三,NDCG@5 从 0.862 涨到 0.948。
不只第一条,整个 Top-5 的相关块位置都更合理。 第四,Precision@3 从 0.300 涨到 0.317。
喂给 DeepSeek 的前三条里,弱相关干扰更少。单 gold 数据集上上限只有 0.333,所以 0.317 已接近上限。 第五,延迟从 23.9ms 涨到 486.2ms。
准确率不是免费的。今天后半段要做的,就是把这 460ms 花在值得的问题上,并通过候选数、批量与缓存控制成本。
5.2 一个必须说严谨的关键洞察
📌 Reranker 不改变"召回池",但会改变"截断后的 Top-K"。
分两种情况:
情况 A:召回 5 → 重排这 5 → 仍取 5
候选集合完全相同
Recall@5 必然不变
变化的是 MRR、NDCG@5、顺序
情况 B:召回 20 → 重排这 20 → 取 5
Recall@20 必然不变
Recall@5 可能提高:原第 8 的 gold 可被顶进前 5
也可能不变:gold 本来就在前 5
所以不能只盯 Recall@5。 重排序的主指标应是 MRR、NDCG@5、Precision@3;召回阶段的主指标应是 Recall@20。 两阶段各看自己的指标,才不会鸡同鸭讲。
💡 均值上涨后仍要逐条看失败案例:先核原文,再核 gold,最后才判断是不是 Reranker 排错。重排也会反过来暴露"gold 只标了一个、其实两段都相关"的标注问题。
📖 六、分数怎么用:阈值、拒答与常见误用
Day 67 遇到一个遗留问题:混合检索的 RRF 分数不是距离,不能再套 MAX_DISTANCE=0.55。 今天终于有新的相关性分数,但它也不能被滥用。
6.1 raw score 与 norm score 的正确理解
| 分数 | 范围 | 用途 | 不能做什么 |
|---|---|---|---|
raw_score |
可正可负,无固定上下界 | 排序、离线分析 | 不能当百分比 |
norm_score |
0~1,raw 做 sigmoid | 阈值表达更直观 | 仍不是校准概率 |
| 向量相似度 | 常见 0~1 | 召回阶段粗判断 | 不能替代 Reranker |
| RRF 分 | 正数,小数且受 k 影响 | 融合排序 | 不能跟余弦或 rerank 分比较 |
最重要的一条:Reranker 分数不要跨问题直接比较。
问题 A 的 Top-1:norm=0.91
问题 B 的 Top-1:norm=0.84
不能推出:A 的答案一定比 B 更可靠。
原因是不同问题的长度、表述、候选难度不同,模型输出分布会漂移。它最可靠的用法,是同一个问题内部比较候选顺序。
6.2 三档拒答策略
项目刚接入时,可以用下面这张表做起点;上线前必须用本地评测集校准。
| 档位 | 建议条件 | 系统动作 | 典型话术 |
|---|---|---|---|
| 高置信 | Top-1 norm_score >= 0.75,且召回侧有支持信号 |
正常回答 | 依据手册给出步骤与引用 |
| 中置信 | 0.45 <= norm_score < 0.75,或 Top-1/Top-2 太接近 |
谨慎回答或追问 | "请确认设备型号/故障码" |
| 低置信 | norm_score < 0.45,或没有候选 |
拒答 | "现有手册中未找到足够相关内容" |
| 这里的"召回侧支持信号"可以是: |
-
纯向量路:Chroma 距离不超过既有
MAX_DISTANCE=0.55; -
BM25 路:明确命中型号、故障码;
-
混合路:Top-1 同时出现在 dense 与 sparse 两路前列;
-
元数据:设备型号、手册版本与用户问题一致。
⚠️ 表里的
0.75 / 0.45是起始建议,不是宇宙真理。应在 20 条评测集上扫描阈值,再用真实拒答问题补一组负样本。
6.3 可直接运行的判定函数
"""Reranker 三档置信判定;阈值需用本地数据校准。"""
from typing import Literal
Decision = Literal["answer", "clarify", "reject"]
def decide(hits: list[dict]) -> Decision:
if not hits:
return "reject"
top = hits[0]
norm_score = float(top["norm_score"])
similarity = float(top.get("similarity", 0.0))
route_ranks = top.get("ranks", {})
bm25_rank = route_ranks.get("route2", 999)
recall_supported = similarity >= 0.45 or bm25_rank <= 3
if norm_score >= 0.75 and recall_supported:
return "answer"
if norm_score >= 0.45:
return "clarify"
return "reject"
from kb.pipeline import retrieve_and_rerank
from kb.confidence import decide
for question in [
"冷却水压力低于多少必须停机?",
"TK-108 的额定压力是多少?",
"这台设备的报废年限是多少?",
]:
hits = retrieve_and_rerank(question, top_k=5, recall_k=20)
print(question, "→", decide(hits), round(hits[0]["norm_score"], 4) if hits else None)
输出示例:
冷却水压力低于多少必须停机? → answer 0.9999
TK-108 的额定压力是多少? → answer 0.9987
这台设备的报废年限是多少? → reject 0.1132
6.4 Top-1 与 Top-2 的分差也有用
若 Top-1 和 Top-2 都很高,且分差极小,可能存在型号歧义:
TK-108 候选:0.982
TK-115 候选:0.978
分差只有 0.004
处理:不要自信选一个,追问"请确认设备型号是 TK-108 还是 TK-115"。
可增加一条中置信规则:
def top_gap(hits: list[dict]) -> float:
if len(hits) < 2:
return 1.0
return float(hits[0]["norm_score"]) - float(hits[1]["norm_score"])
if hits and hits[0]["norm_score"] >= 0.75 and top_gap(hits) < 0.02:
decision = "clarify"
但不要迷信固定的 0.02。仍要拿型号混淆题做校准。
6.5 五个常见误用
| ❌ 误用 | 为什么错 | ✅ 正确做法 |
|---|---|---|
| 把 raw=8.6 展示成"相关度 86%" | raw 是 logit,无百分比含义 | 内部调试保留 raw;展示时不露分数 |
normalize=True 后当真实概率 |
sigmoid 只压缩尺度,没有业务校准 | 用标注集画阈值混淆矩阵 |
| 跨问题比较 0.91 与 0.84 | 不同 query 的分布不可直接比 | 主要做同 query 内排序 |
| 只看 rerank 分,不看召回信号 | 模型也可能对无关候选给高分 | 与向量/BM25/metadata 联合判定 |
| 把分数直接展示给一线用户 | 用户会误解,且数值随版本漂移 | 对外只给答案、引用、是否找到依据 |
📌 用户需要的是"依据哪一页、哪一行",不是"模型给了 0.9237 分"。 可解释性来自引用证据,不来自一个看似精确的小数。
🖥️ 七、性能、成本与工程化
Reranker 的精度收益已经看见。现在算账。
实操步骤 13:逐阶段计时
新建 day68_benchmark.py:
"""Day68:拆分召回与精排耗时。"""
import statistics
import time
from kb.hybrid import hybrid_search
from kb.reranker import rerank
QUESTION = "料筒温度没降下来能不能打开防护罩?"
RUNS = 5
# 预热:模型加载、算子初始化不计入稳定延迟
warm_candidates = hybrid_search(QUESTION, top_k=20)
rerank(QUESTION, warm_candidates, top_k=5)
recall_times = []
rerank_times = []
total_times = []
for _ in range(RUNS):
total_start = time.perf_counter()
recall_start = time.perf_counter()
candidates = hybrid_search(QUESTION, top_k=20)
recall_ms = (time.perf_counter() - recall_start) * 1000
rerank_start = time.perf_counter()
hits = rerank(QUESTION, candidates, top_k=5)
rerank_ms = (time.perf_counter() - rerank_start) * 1000
total_ms = (time.perf_counter() - total_start) * 1000
recall_times.append(recall_ms)
rerank_times.append(rerank_ms)
total_times.append(total_ms)
assert len(hits) <= 5
print(f"召回平均:{statistics.mean(recall_times):.1f}ms")
print(f"精排平均:{statistics.mean(rerank_times):.1f}ms")
print(f"总计平均:{statistics.mean(total_times):.1f}ms")
python day68_benchmark.py
召回平均:24.8ms
精排平均:451.6ms
总计平均:476.5ms
结论很明显:95% 左右的新增延迟来自 Reranker。 优化 BM25 的 2ms 没意义,应该盯候选数、文本长度、批量和硬件。
实操步骤 14:测候选数 10 / 20 / 50
"""Day68:候选数与精排延迟实验。"""
import time
from kb.hybrid import hybrid_search
from kb.reranker import rerank
question = "冷却水压力低于多少必须停机?"
for recall_k in [10, 20, 50]:
candidates = hybrid_search(question, top_k=recall_k)
started = time.perf_counter()
hits = rerank(question, candidates, top_k=5)
elapsed_ms = (time.perf_counter() - started) * 1000
print(f"recall_k={recall_k:>2} 实际候选={len(candidates):>2} "
f"精排={elapsed_ms:>7.1f}ms Top-1={hits[0]['id']}")
输出示例:
recall_k=10 实际候选=10 精排= 238.7ms Top-1=manual_a3.clean.md-0000
recall_k=20 实际候选=20 精排= 452.3ms Top-1=manual_a3.clean.md-0000
recall_k=50 实际候选=42 精排= 967.4ms Top-1=manual_a3.clean.md-0000
| 候选数 | CPU 延迟量级 | 效果倾向 | 建议 |
|---|---|---|---|
| 10 | 约 120~400ms | 快,但可能救不回原第 11~20 的 gold | 小库、低延迟优先 |
| 20 | 约 200~800ms | 召回与成本平衡 | 今天默认 |
| 50 | 约 500~2000ms | Recall 上限更高,CPU 压力明显 | 只在评测证明有收益时用 |
| 100 | 常达秒级以上 | 边际收益通常很小 | GPU 或离线任务再考虑 |
💡 候选数不是越大越专业。 从 20 加到 50 若 NDCG 只涨 0.003、延迟却翻倍,就应该坚定地留在 20。
实操步骤 15:比较批量大小
在候选数固定为 20 时,只改 batch_size:
import time
from FlagEmbedding import FlagReranker
from kb.hybrid import hybrid_search
model = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=False)
q = "液压油多久更换一次?"
pairs = [[q, h["text"]] for h in hybrid_search(q, top_k=20)]
for size in [1, 4, 8, 16]:
start = time.perf_counter()
model.compute_score(pairs, batch_size=size, normalize=True)
print(size, f"{(time.perf_counter() - start) * 1000:.1f}ms")
1 812.4ms
4 536.8ms
8 447.1ms
16 433.5ms
CPU 常在 4~8 后收益变小;GPU 可继续加,但受显存限制。文本越长越吃内存,最大 8192 tokens 不等于应该把 chunk 塞满。
实操步骤 16:结果缓存要带文档版本
同一问题、同一版知识库可以缓存重排结果,但 key 不能只有 question:
import hashlib
import json
def rerank_cache_key(question: str, candidates: list[dict], doc_version: str) -> str:
body = {
"q": question.strip(),
"doc_version": doc_version,
"model": "BAAI/bge-reranker-v2-m3",
"ids": [item["id"] for item in candidates],
}
raw = json.dumps(body, ensure_ascii=False, sort_keys=True).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
print(rerank_cache_key("液压油多久换一次?", candidates, "manual-a3-v3")[:16])
9f4d2d36ce113fb8
单进程可用 lru_cache,多进程或多机用 Redis。无论放哪,key 都要包含问题、文档版本、候选 id、模型版本 ;手册更新时换 doc_version,旧排序自然失效。
7.1 CPU、GPU 与云端 API 怎么选
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
CPU 本地 v2-m3 |
无额外硬件;数据不出现场 | 20 对常见 200~800ms | 低 QPS、内网、PoC |
GPU 本地 v2-m3 |
延迟低;可 use_fp16=True |
GPU 成本与运维 | 中高 QPS、数据不能出域 |
| 云端 Rerank API | 不管模型和显卡,弹性好 | 按次付费、网络延迟、数据合规 | 公有云、快速上线 |
| 不启用 Rerank | 最快、最省 | 排序精度较低 | 评测证明无明显收益的简单 FAQ |
什么时候考虑 Cohere Rerank 一类云 API?
-
本地没有 GPU,但 QPS 已经让 CPU 顶不住;
-
客户允许把 query 与候选文本发到外部服务;
-
需要快速扩缩容,不想维护模型服务;
-
已经用本地评测集验证云端模型确实更好。 什么时候不要用?
-
手册、工单、故障记录禁止出内网;
-
网络不稳定,现场必须离线运行;
-
调用成本超过本地 GPU 的长期成本;
-
无法接受第三方模型升级带来的排序漂移。
7.2 模型服务化的三个工程动作
-
启动预热:服务启动后先跑一对短文本,别让首个用户承担冷启动。
-
并发限流:CPU 并发过高会互相抢核,用队列或信号量控制。
-
记录版本 :日志带
model_name、doc_version、recall_k、top_k与各阶段耗时,才能复现结果。
⚠️ 日志默认只记问题哈希、chunk id、版本和耗时,不记录完整问题与手册片段;需要原文时走受控采样。
🖥️ 八、接进 v0.2 的回答链路
最后一步:把 retrieve_and_rerank 接到已有 kb/answer.py 和 kb/api.py。
实操步骤 17:改造 kb/answer.py
下面给出完整版本。保留 DeepSeek 选型、.env 密钥和既有引用规则,只新增 use_rerank 与 recall_k。
"""知识库回答:召回/重排 → Prompt → DeepSeek → 引用。"""
import os
from dotenv import load_dotenv
from openai import OpenAI
from .pipeline import retrieve_and_rerank
from .retriever import retrieve
load_dotenv()
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
SYSTEM = """你是工厂设备运维助手,依据【参考资料】回答一线维修人员的问题。
【铁律】
1. 只使用【参考资料】中的信息,禁止使用资料外知识作答
2. 每个结论后用 [n] 标注来源
3. 资料中没有的内容,明确回答"资料中未提及"
4. 涉及断电、挂牌上锁、防护罩等安全要求时,必须原样保留
5. 回答简洁,优先使用编号步骤
"""
def _source_of(hit: dict, number: int) -> dict:
rank_before = int(hit.get("rank_before", number))
rank_after = int(hit.get("rank_after", number))
return {
"n": number,
"source": str(hit.get("source") or hit.get("id") or "unknown"),
"chapter": str(hit.get("chapter") or "未标注章节"),
"score": round(float(hit.get("similarity", hit.get("rrf", 0.0))), 4),
"rerank_score": (
round(float(hit["norm_score"]), 4)
if "norm_score" in hit
else None
),
"rank_change": rank_before - rank_after,
}
def answer(
question: str,
top_k: int = 5,
device: str | None = None,
use_rerank: bool = True,
recall_k: int = 20,
) -> dict:
if use_rerank:
hits = retrieve_and_rerank(
question,
top_k=top_k,
recall_k=recall_k,
device=device,
route="hybrid",
)
else:
hits = retrieve(question, top_k=top_k, device=device)
if not hits:
return {
"answer": "抱歉,现有手册中没有找到足够相关的内容。",
"sources": [],
"grounded": False,
}
context = "\n\n".join(
f"[{index}] 来源:{hit.get('source') or hit.get('id')} | "
f"章节:{hit.get('chapter', '未标注章节')}\n{hit['text']}"
for index, hit in enumerate(hits, start=1)
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": f"【参考资料】\n{context}\n\n【问题】{question}",
},
],
temperature=0,
max_tokens=800,
)
return {
"answer": response.choices[0].message.content,
"sources": [
_source_of(hit, index)
for index, hit in enumerate(hits, start=1)
],
"grounded": True,
"tokens": response.usage.total_tokens,
}
⚠️
.env里只放DEEPSEEK_API_KEY,代码只用os.getenv("DEEPSEEK_API_KEY")读取。禁止把真实 Key 写进教程、源码、日志或 curl 示例。sources现在多了两个调试字段: | 字段 | 含义 | 面向谁 | |------|------|-------| |rerank_score| sigmoid 后的相关性分数 | 内部调试 / 评测 | |rank_change|rank_before - rank_after;正数表示上升 | 内部调试 / 评测 |
例如 rank_change=+4 表示原第 5 被升到第 1;-2 表示原第 1 被降到第 3。
实操步骤 18:改造 kb/api.py
"""智能运维助手 v0.2:手册问答 API。"""
from fastapi import FastAPI
from pydantic import BaseModel, Field
from .answer import answer
app = FastAPI(title="智能运维助手 v0.2", version="0.2.0")
class AskRequest(BaseModel):
question: str = Field(
...,
min_length=2,
max_length=200,
examples=["A3 冷却水压力低于多少必须停机?"],
)
device: str | None = Field(None, description="限定设备号,如 A3")
top_k: int = Field(5, ge=1, le=10)
use_rerank: bool = Field(True, description="是否启用 BGE-Reranker 精排")
recall_k: int = Field(20, ge=5, le=100)
class Source(BaseModel):
n: int
source: str
chapter: str
score: float
rerank_score: float | None = None
rank_change: int = 0
class AskResponse(BaseModel):
question: str
answer: str
grounded: bool
sources: list[Source]
@app.post("/api/manual/ask", response_model=AskResponse, tags=["知识库问答"])
def ask_manual(req: AskRequest) -> AskResponse:
if req.use_rerank and req.recall_k < req.top_k:
raise ValueError("启用重排时 recall_k 必须大于等于 top_k")
result = answer(
req.question,
top_k=req.top_k,
device=req.device,
use_rerank=req.use_rerank,
recall_k=req.recall_k,
)
return AskResponse(
question=req.question,
answer=result["answer"],
grounded=result["grounded"],
sources=[Source(**item) for item in result["sources"]],
)
💡 更规范的做法是在 Pydantic 模型里做跨字段校验。今天先保持改动最小:业务边界在 API 入口拦住,
pipeline.py内部再拦一次。
实操步骤 19:启动并对比开关前后
启动:
.\.venv\Scripts\Activate.ps1
uvicorn kb.api:app --reload --port 8000
先关闭重排:
curl.exe -X POST "http://localhost:8000/api/manual/ask" `
-H "Content-Type: application/json" `
-d '{"question":"料筒温度没降下来能不能打开防护罩?","top_k":3,"use_rerank":false,"recall_k":20}'
再打开重排:
curl.exe -X POST "http://localhost:8000/api/manual/ask" `
-H "Content-Type: application/json" `
-d '{"question":"料筒温度没降下来能不能打开防护罩?","top_k":3,"use_rerank":true,"recall_k":20}'
对比输出时重点看 sources:关闭后"超温处置"排第 1;打开后"安全要求"升到第 1,rerank_score=0.9998、rank_change=+1。回答应变为:
不能打开。A3 注塑机料筒温度未降至 60℃ 以下时,禁止打开防护罩。[1]
最后回归四类问题:语义问法应命中超温处置;专名问法应让 TK-108 压过 TK-115;安全问法应把"禁止打开"排第 1;资料外问题即使返回候选,也必须被低置信策略拒答。
📊 重排序速查表
FlagReranker API 速查
| 操作 | 代码 |
|---|---|
| 导入 | from FlagEmbedding import FlagReranker |
| CPU 加载 | FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=False) |
| GPU 加载 | FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True) |
| 单 pair | compute_score(["查询", "候选文本"]) |
| 批量 pairs | compute_score([["查询", "候选1"], ["查询", "候选2"]]) |
| 0~1 输出 | compute_score(pairs, normalize=True) |
| 排序方向 | 越大越相关 |
| 原始输出 | logit,可正可负,不是概率 |
| 最大长度 | bge-reranker-v2-m3 最大 8192 tokens |
模型选型表
| 模型 | 类型 | 特点 | 适合场景 |
|---|---|---|---|
BAAI/bge-reranker-base |
Cross-Encoder | 较小、入门成本低 | 资源紧张、先做基线 |
BAAI/bge-reranker-large |
Cross-Encoder | 比 base 更大,精度通常更强 | 有更多算力的传统精排 |
BAAI/bge-reranker-v2-m3 |
Cross-Encoder | 568M、多语言、8192 tokens、轻量易部署 | 今天实操;中文本地部署首选 |
BAAI/bge-reranker-v2-gemma |
LLM Reranker | 用 FlagLLMReranker,能力强、成本高 |
GPU 充足、追求精度 |
BAAI/bge-reranker-v2-minicpm-layerwise |
Layerwise LLM Reranker | 用 LayerWiseFlagLLMReranker,可利用中间层 |
中文、高精度、可控计算预算 |
📌 LLM 系今天只做概念认识,不实操。先把
v2-m3的候选池、指标、阈值和服务链路做好,再换更大的模型做同一套 A/B 评测。
参数含义
| 参数 | 默认建议 | 含义 | 调整原则 |
|---|---|---|---|
use_fp16 |
CPU=False;GPU=True | 是否半精度推理 | CPU 绝不能开 True |
recall_k |
20 | 交给 Reranker 的候选数 | Recall@20 不足再加 |
top_k |
3~5 | 最终喂给 LLM 的条数 | Prompt 越短,干扰越少 |
normalize |
调试/阈值时 True | sigmoid 压到 0~1 | 排序本身不需要 |
batch_size |
CPU 先试 8 | 一批处理多少 pair | 用实测决定,受内存/显存限制 |
| chunk 长度 | 300~500 字起步 | 每个候选文本长度 | 越长越慢,且主题越杂 |
候选数与延迟对照
recall_k |
CPU 参考延迟 | 召回风险 | 推荐度 |
|---|---|---|---|
| 5 | 约 60~250ms | 原第 6 无法被救回 | 不推荐作为默认 |
| 10 | 约 120~400ms | 中 | 低延迟方案 |
| 20 | 约 200~800ms | 低 | 默认推荐 |
| 50 | 约 500~2000ms | 更低,但边际收益小 | 评测证明有效再用 |
| 100 | 常见秒级 | 很低 | GPU/离线任务 |
双塔与交叉编码一眼区分
| 问题 | 双塔 | 交叉编码 |
|---|---|---|
| query 和 doc 一起输入吗 | 否 | 是 |
| doc 可提前算吗 | 可以 | 不可以 |
| 适合全库搜索吗 | 适合 | 不适合 |
| 适合 Top-20 精排吗 | 一般 | 适合 |
| 速度 | 快 | 慢 |
| 精度 | 中高 | 高 |
常见翻车与解法
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
| CPU 报 Half / LayerNorm 错误 | use_fp16=True |
CPU 改为 False |
| 第一次请求特别慢 | 首次下载或冷启动 | 提前下载并启动预热 |
| 20 条用了 20 次循环调用 | 没批量组 pairs | 一次 compute_score(pairs) |
| raw 分数出现负数,以为模型坏了 | raw 是 logit | 越大越相关;需要直观尺度时 normalize=True |
| 把 0.98 当 98% 正确 | sigmoid 未做业务校准 | 用标注集校准阈值 |
| 重排后 Recall 没涨 | gold 本来就在 Top-5,或没进 Top-20 | 看 MRR/NDCG;同时查 Recall@20 |
| 正确答案完全救不回来 | 召回池里没有它 | 回 Day 67 增加召回深度/修 BM25 |
| 延迟超过 1 秒 | 候选过多、chunk 太长、CPU 慢 | 20→10、缩短 chunk、缓存或上 GPU |
| 手册更新后仍返回旧排序 | 缓存 key 没有文档版本 | key 加 doc_version 和候选 id |
| 用户纠结相关度小数 | 把内部模型分数展示出去了 | 对外展示引用,不展示 rerank 分数 |
| RRF 分与 rerank 分混用 | 两者量纲不同 | RRF 只融合;rerank 只精排与阈值 |
| Top-1/Top-2 都很高且接近 | 型号或条件有歧义 | 触发追问,不武断选择 |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| 两阶段职责 | 召回求全,精排求准;海选不能代替面试 |
| 为什么要 Top-20→Top-5 | Reranker 不能扫全库,也不能救回候选池外的资料 |
| 双塔 | query/doc 分开编码,文档可预计算,ANN 快,但交互信息丢失 |
| 交叉编码 | query/doc 拼成一对,token 互相 attention,更准但必须在线算 |
| 今日模型 | BAAI/bge-reranker-v2-m3:568M、多语言、8192 tokens |
| FlagReranker 输出 | 默认 raw logit,可正可负;越大越相关;不是概率 |
normalize=True |
sigmoid 到 0~1,顺序不变,仍未做业务校准 |
| CPU 铁律 | use_fp16=False,否则可能报 LayerNorm/Half 错误 |
| 工程入口 | hybrid_search(20) → rerank() → Top-3~5 |
| 关键指标 | Recall 看召回;MRR、NDCG@5、Precision@3 看排序 |
| 关键实验结论 | Recall@5 可不变,但 MRR/NDCG/Precision 应明显提升 |
| 拒答 | 结合 norm score、召回信号、Top-1/Top-2 分差,不单看一个数 |
| 性能 | CPU 精排 20 对常见 200~800ms,候选数与文本长度是主成本 |
| 缓存 | key 必须带 question、doc_version、候选 id、模型版本 |
| API 接入 | use_rerank 默认 True,保留关闭开关做回归与降级 |
| 🧠 核心认知 :RAG 的质量不只取决于"有没有找到",更取决于"把什么放在模型眼前"。双塔和混合检索把搜索范围从十万条缩到二十条,解决的是可达性 ;交叉编码器把二十条重新逐对审题,解决的是优先级 。真正稳定的生产链路不是押宝一个万能模型,而是让每一层只做自己最擅长的事:BGE-M3 懂语义,BM25 认专名,RRF 合并候选,BGE-Reranker 判断谁真正回答了问题,DeepSeek 最后负责组织答案。 |
📋 课后练习
练习 1:完成 20 条四组评测并分析失败案例(约 60 分钟)
-
跑
python eval_rerank.py,记录纯向量、混合、混合 + rerank、纯向量 + rerank 四组数据。 -
表中必须包含 Recall@5、Precision@3、NDCG@5、MRR、平均耗时。
-
对比 Day 67 的 MRR=0.836,写清自己机器上提升了多少。
-
跑"失败案例检查",至少人工复核 3 条重排后变差或没有改善的问题。
-
每条都判断根因属于:召回池缺失、gold 标注太窄、chunk 混杂、Reranker 判断错误。
-
禁止为了让数字变好修改 gold;只有核对原文确认标错时才能修,并留下变更记录。
练习 2:做候选数、batch 与阈值实验(约 50 分钟)
-
分别设置
recall_k ∈ {5, 10, 20, 50},记录 NDCG@5、MRR、平均精排耗时。 -
分别设置
batch_size ∈ {1, 4, 8, 16},在同一台机器、同一组 20 个 pair 上测量。 -
给评测集补 10 条"资料中没有答案"的负样本,如报废年限、未收录型号、未定义故障码。
-
扫描
norm_score阈值{0.30, 0.45, 0.60, 0.75, 0.90},统计误答与误拒。 -
选一个最终阈值,并用一句话说明你更不能接受"误答"还是"误拒"。设备安全场景通常优先减少误答。
练习 3:把重排稳定接入 v0.2 API(约 45 分钟)
-
按第八节改造
kb/answer.py和kb/api.py,保留use_rerank开关。 -
用同一个问题分别请求
use_rerank=false/true,保存 sources 顺序对比。 -
验证
rank_change:至少找到一条原第 3 以后升入 Top-1 的案例。 -
为 Reranker 加启动预热,并确保模型在进程内只加载一次。
-
给缓存 key 加
doc_version;修改版本后确认旧缓存不再命中。 -
回归四类问题:语义、专名、安全、资料外;资料外问题必须拒答。
🔭 下节预告
今天我们终于把"排序不精"治到了关键位置:Day 67 的混合检索负责把该找的带回来,Day 68 的 BGE-Reranker 负责把真正能回答问题的顶到 Top-3。 现在答案更准了,但用户仍会追问一句:
"你说温度必须降到 60℃,这句话到底在手册哪一页、哪一行?我能不能点开直接核对?" 目前
sources只有文件名和章节名。章节可能有十几页,现场工程师仍要手工翻;更重要的是,不同角色也不应该看到同一批资料------设备部能看维修 SOP,普通操作员未必能看内部故障分析。 明天进入 Day 69:引用溯源 + 权限过滤:
-
在解析与分块阶段保留页码、行号、chunk 边界;
-
让答案里的
[1]精确落到原 PDF 页码与原文位置; -
用 Qdrant payload filter 实现按角色、设备、文档密级过滤;
-
把"回答得准"升级成"每句话都能核、该看的人才能看"。 一句话:Day 68 决定哪段资料最该看,Day 69 证明这段资料从哪里来。
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
【FDE系列】阶段3:Day 63:文档解析 --- 把真实 PDF 手册变成可用文本-CSDN博客
【FDE系列】阶段3:Day 64:文本分块 --- 决定检索成败的那一步-CSDN博客
【FDE系列】阶段3:Day 65:向量数据库入门 --- Chroma 与本周收官-CSDN博客
【FDE系列】阶段3:Day 66:Qdrant 入门 --- 生产级向量库-CSDN博客
【FDE系列】阶段3:Day 67:混合检索 --- BM25 与 RRF 融合-CSDN博客
待完成教程:
Agent 框架与开发
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证