【FDE系列】阶段3:Day 68:重排序 — 用 BGE-Reranker 把真正相关的顶上来

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段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

要求有四个:

  1. 模型只加载一次;

  2. 第一次真正需要时才加载;

  3. 候选批量打分;

  4. 保留重排前后名次与两种分数。

    """BGE-Reranker 封装:单例懒加载 + 批量精排。"""
    import math
    import threading
    from typing import Any

    from 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 _model

    def _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_NAME

    复制代码
     def 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 模型服务化的三个工程动作

  1. 启动预热:服务启动后先跑一对短文本,别让首个用户承担冷启动。

  2. 并发限流:CPU 并发过高会互相抢核,用队列或信号量控制。

  3. 记录版本 :日志带 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 分钟)

  1. 跑 python eval_rerank.py,记录纯向量、混合、混合 + rerank、纯向量 + rerank 四组数据。

  2. 表中必须包含 Recall@5、Precision@3、NDCG@5、MRR、平均耗时。

  3. 对比 Day 67 的 MRR=0.836,写清自己机器上提升了多少。

  4. 跑"失败案例检查",至少人工复核 3 条重排后变差或没有改善的问题。

  5. 每条都判断根因属于:召回池缺失、gold 标注太窄、chunk 混杂、Reranker 判断错误。

  6. 禁止为了让数字变好修改 gold;只有核对原文确认标错时才能修,并留下变更记录。

练习 2:做候选数、batch 与阈值实验(约 50 分钟)

  1. 分别设置 recall_k ∈ {5, 10, 20, 50},记录 NDCG@5、MRR、平均精排耗时。

  2. 分别设置 batch_size ∈ {1, 4, 8, 16},在同一台机器、同一组 20 个 pair 上测量。

  3. 给评测集补 10 条"资料中没有答案"的负样本,如报废年限、未收录型号、未定义故障码。

  4. 扫描 norm_score 阈值 {0.30, 0.45, 0.60, 0.75, 0.90},统计误答与误拒。

  5. 选一个最终阈值,并用一句话说明你更不能接受"误答"还是"误拒"。设备安全场景通常优先减少误答。

练习 3:把重排稳定接入 v0.2 API(约 45 分钟)

  1. 按第八节改造 kb/answer.py 和 kb/api.py,保留 use_rerank 开关。

  2. 用同一个问题分别请求 use_rerank=false/true,保存 sources 顺序对比。

  3. 验证 rank_change:至少找到一条原第 3 以后升入 Top-1 的案例。

  4. 为 Reranker 加启动预热,并确保模型在进程内只加载一次。

  5. 给缓存 key 加 doc_version;修改版本后确认旧缓存不再命中。

  6. 回归四类问题:语义、专名、安全、资料外;资料外问题必须拒答。


🔭 下节预告

今天我们终于把"排序不精"治到了关键位置:Day 67 的混合检索负责把该找的带回来,Day 68 的 BGE-Reranker 负责把真正能回答问题的顶到 Top-3。 现在答案更准了,但用户仍会追问一句:

"你说温度必须降到 60℃,这句话到底在手册哪一页、哪一行?我能不能点开直接核对?" 目前 sources 只有文件名和章节名。章节可能有十几页,现场工程师仍要手工翻;更重要的是,不同角色也不应该看到同一批资料------设备部能看维修 SOP,普通操作员未必能看内部故障分析。 明天进入 Day 69:引用溯源 + 权限过滤:

  1. 在解析与分块阶段保留页码、行号、chunk 边界;

  2. 让答案里的 [1] 精确落到原 PDF 页码与原文位置;

  3. 用 Qdrant payload filter 实现按角色、设备、文档密级过滤;

  4. 把"回答得准"升级成"每句话都能核、该看的人才能看"。 一句话: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 治理)

阶段五:行业实战与认证

相关推荐
Henry-SAP40 分钟前
SAP MMBE跨工厂库存层级展示解析
人工智能·云原生·sap·erp
海宇大数据1 小时前
零信任架构实战:基于海宇身份证OCR构建自动化跨境清关核验网关
人工智能·架构·c#·自动化
强壮的CAT1 小时前
VINS-Fusion 移植 ROS2 Jazzy 踩坑(一):环境与编译
人工智能·算法·机器人·自动驾驶
Omics Pro1 小时前
计算虚拟扰动:网络毒理+虚拟敲除
数据库·人工智能·算法·机器学习·自然语言处理
正经教主1 小时前
【FDE系列】阶段3:Day 63:文档解析 — 把真实 PDF 手册变成可用文本
人工智能·pdf·fde
AI人工智能+3 小时前
行驶证识别技术融合计算机视觉与自然语言处理,实现对模糊、倾斜、反光等复杂场景下行驶证的高精度结构化数据提取
人工智能·深度学习·自然语言处理·ocr·行驶证识别
weixin_750330236 小时前
AI获客工具选型:从技术架构看中小企业效率提升方案
大数据·人工智能·架构·ai获客
mpp0077 小时前
36氪《2026 中国 AI Agent 行业发展报告》:当 Agent 进入「交付」主战场
大数据·人工智能
吃饱了得干活9 小时前
Agent 的决策与规划:ReAct、Plan-and-Execute、Reflexion 与 Tree of Thoughts
人工智能·llm·agent