# RAG重排序实战:硅基流动bge-reranker-v2-m3在线API vs 本地CrossEncoder,一篇讲透两种方案

RAG重排序实战:硅基流动bge-reranker-v2-m3在线API vs 本地CrossEncoder,一篇讲透两种方案

导读 :RAG 系统检索召回后,结果排序质量直接决定最终回答的准确性。向量检索 + BM25 混合召回能捞到候选集,但排序还得靠 Reranker。问题来了------用 Hugging Face 本地 CrossEncoder 还是调在线 Rerank API?文档教程清一色教你下载 sentence_transformers 跑本地模型,但生产环境你真的想每台机器都下 2GB 模型文件吗?本文从实际项目出发,对比本地 CrossEncoder 和硅基流动在线 Rerank API 两种方案的完整实现,讲清楚为什么在线 API 的分数归一化只需要 clamp 而 CrossEncoder 需要 sigmoid,附可直接复用的完整代码。

适合读者

  • 正在搭建 RAG 系统、需要引入重排序提升检索质量的开发者
  • 在本地 CrossEncoder 和在线 Rerank API 之间纠结选型的工程师
  • 使用硅基流动 API 但不清楚 rerank 接口怎么调的技术人员
  • 想理解 CrossEncoder logits 和 API relevance_score 区别的同学

阅读收益

  • 彻底搞懂本地 CrossEncoder 和在线 Rerank API 的核心差异
  • 掌握硅基流动 /rerank 接口的完整调用方式(含异常降级)
  • 理解为什么 CrossEncoder 需要 sigmoid 归一化而 API 只需要 clamp
  • 获得一个生产可用的重排序服务封装,含 RRF 融合 + rerank + 证据过滤
  • 学会在混合检索管线中正确接入重排序环节

目录

  1. 为什么需要重排序
  2. 两种方案概览
  3. 方案一:本地CrossEncoder实现
  4. [方案二:硅基流动在线Rerank API](#方案二:硅基流动在线Rerank API)
  5. [分数归一化:sigmoid vs clamp](#分数归一化:sigmoid vs clamp)
  6. 生产级封装:含异常降级
  7. 接入混合检索管线
  8. 证据过滤与最终截断
  9. 两种方案对比选型
  10. 踩坑清单:重排序中的7个关键问题
  11. 总结与延伸
  12. 文末互动

1. 为什么需要重排序

1.1 向量检索的短板

向量检索(Embedding 相似度搜索)擅长语义匹配------用户问"怎么办理社保卡",它能找到"社会保障卡申领流程"的文档。但它有明显的短板:

python 复制代码
# 向量检索结果(top 5)
# score=0.89  "社保卡申领需要身份证原件和照片"        ← 真正相关
# score=0.85  "社保卡补办流程如下"                    ← 也相关但不是问的
# score=0.82  "医疗保险报销范围"                      ← 语义相近但不相关
# score=0.80  "社保缴费基数调整通知"                  ← 关键词重叠但不相关
# score=0.78  "公积金提取条件"                        ← 完全不相关

向量分数高不代表真的相关。Embedding 模型把文本编码成 1024 维向量,用余弦相似度比较------这种"粗粒度"匹配捞得回候选,但排不准序。

1.2 BM25 也有盲区

BM25 关键词检索擅长精准匹配,但它不理解同义词:

python 复制代码
# 用户问题:"怎么办社保卡"
# BM25 结果:
# score=3.45  "社保卡办理流程"           ← 命中"社保卡"
# score=2.87  "社保卡补办"               ← 命中"社保卡"但不是问的
# score=0.00  "社会保障卡申领须知"        ← 没命中"社保卡"但语义相关

1.3 重排序的角色

Reranker 不是替代向量检索或 BM25,而是在它们之上做精排

复制代码
用户提问
  → 向量检索(粗召,top 30)    ← 快但粗
  → BM25 检索(粗召,top 10)   ← 精但漏
  → RRF 融合(合并两路结果)     ← 取长补短
  → Reranker 精排(重排序)      ← 关键一步
  → 截取 top 5 作为证据          ← 交给 LLM

Reranker 之所以能排得准,是因为它用的是 Cross-Encoder 架构------把 query 和 document 拼在一起送进模型,做完整的注意力计算,而不是各自编码后再比向量。精度高,但计算量大,所以只能对少量候选做精排。


2. 两种方案概览

维度 本地 CrossEncoder 硅基流动在线 API
模型 Qwen/Qwen3-RerankBAAI/bge-reranker-v2-m3 BAAI/bge-reranker-v2-m3
依赖 sentence_transformerstorch httpx(HTTP 客户端)
模型文件 首次运行下载 2GB+ 模型到本地 不需要,服务端推理
推理方式 reranker.predict(pairs) 本地 CPU/GPU client.post("/rerank") HTTP 调用
分数范围 原始 logits,任意实数 relevance_score,已在 0~1 之间
归一化 需要 sigmoid 只需 clamp
延迟 低(本地计算,无网络开销) 中(一次 HTTP 往返)
可用性 100%(本地运行) 依赖 API 服务可用性
部署成本 每台机器都要装依赖 + 下模型 只需一个 API Key
适合场景 数据量大、对延迟敏感、离线环境 快速开发、无需 GPU、跨平台

2.1 选型建议

  • 开发阶段 / 个人项目:用在线 API,省掉环境配置的麻烦
  • 生产环境 / 高并发:用本地 CrossEncoder,避免网络瓶颈和 API 限制
  • 离线 / 内网环境:只能用本地 CrossEncoder
  • Serverless / 容器化部署:在线 API 更友好,镜像体积小

本文重点讲在线 API 方案(因为这就是我项目里用的),同时给出本地 CrossEncoder 的等价实现做对比。


3. 方案一:本地CrossEncoder实现

3.1 安装依赖

bash 复制代码
pip install sentence-transformers torch

3.2 基本用法

python 复制代码
from sentence_transformers import CrossEncoder

# 首次运行会从 Hugging Face 下载模型(约 2GB)
# 模型路径格式:Hugging Face 仓库格式
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

# 构造 query-document 对
pairs = [
    ("怎么办社保卡", "社保卡申领需要身份证原件和照片"),
    ("怎么办社保卡", "医疗保险报销范围"),
    ("怎么办社保卡", "公积金提取条件"),
]

# predict 返回的是 logits(原始分数,可能是任意实数)
scores = reranker.predict(pairs)
print(scores)
# 输出: [6.234, -1.567, -3.891]
# 正数表示相关,负数表示不相关,但范围不固定

3.3 封装成服务

python 复制代码
from sentence_transformers import CrossEncoder
from functools import lru_cache

@lru_cache
def get_reranker():
    """单例模式加载 CrossEncoder 模型"""
    return CrossEncoder("BAAI/bge-reranker-v2-m3")


def rerank_local(
    query: str,
    documents: list[str],
    top_k: int = 5,
) -> list[dict]:
    """
    本地 CrossEncoder 重排序
    
    Args:
        query: 用户问题
        documents: 候选文档文本列表
        top_k: 返回前几条
    
    Returns:
        重排序后的结果列表,含 index 和 score
    """
    if not documents:
        return []
    
    reranker = get_reranker()
    
    # 构造 query-document 对
    pairs = [(query, doc) for doc in documents]
    
    # predict 返回 logits(原始分数)
    raw_scores = reranker.predict(pairs)
    
    # sigmoid 归一化:把 logits 压缩到 (0, 1) 区间
    import math
    results = []
    for idx, raw_score in enumerate(raw_scores):
        score = 1.0 / (1.0 + math.exp(-raw_score))  # sigmoid
        results.append({
            "index": idx,
            "raw_score": float(raw_score),
            "score": float(score),
        })
    
    # 按分数降序排序
    results.sort(key=lambda x: x["score"], reverse=True)
    
    return results[:top_k]

3.4 使用示例

python 复制代码
query = "怎么办社保卡"
documents = [
    "社保卡申领需要身份证原件和照片",
    "医疗保险报销范围",
    "公积金提取条件",
    "社保卡补办流程",
    "社保缴费基数调整",
]

results = rerank_local(query, documents, top_k=3)
for r in results:
    print(f"score={r['score']:.4f} | {documents[r['index']]}")

# 输出:
# score=0.9981 | 社保卡申领需要身份证原件和照片
# score=0.8765 | 社保卡补办流程
# score=0.0023 | 医疗保险报销范围

可以看到,CrossEncoder 精排后,真正相关的文档排到了前面,不相关的分数非常低。


4. 方案二:硅基流动在线Rerank API

4.1 为什么选在线 API

我项目里选在线 API 的原因很简单:

  • 开发机器没有 GPU,本地跑 CrossEncoder 一次要好几秒
  • 不想每台开发机都装 torch + sentence_transformers(加起来 5GB+)
  • 硅基流动 API 和我已有的 Embedding 服务共用一个 API Key,零额外配置
  • 重排序的候选量不大(几十条),一次 HTTP 调用延迟可控

4.2 硅基流动 Rerank API 规格

plain 复制代码
POST https://api.siliconflow.cn/v1/rerank
Authorization: Bearer <your_api_key>
Content-Type: application/json

请求体:
{
    "model": "BAAI/bge-reranker-v2-m3",
    "query": "用户问题",
    "documents": ["文档1", "文档2", "文档3"],
    "return_documents": false,
    "top_n": 3
}

响应体:
{
    "results": [
        {
            "index": 0,
            "relevance_score": 0.9876,
            "document": null
        },
        {
            "index": 2,
            "relevance_score": 0.5432,
            "document": null
        },
        {
            "index": 1,
            "relevance_score": 0.0123,
            "document": null
        }
    ]
}

关键点

  • results 已按 relevance_score 降序排列
  • index 指向原始 documents 数组的位置
  • relevance_score 已经是 0~1 之间的分数(不是 logits)
  • return_documents=false 时不返回原文,节省带宽
  • top_n 可以直接让 API 只返回前 N 条

4.3 配置

项目里的配置使用 Pydantic Settings 管理:

python 复制代码
# backend/app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pathlib import Path
from functools import lru_cache

ENV_FILE = Path(__file__).resolve().parents[1] / ".env"

class Settings(BaseSettings):
    # ... 其他配置 ...

    # Embedding 配置(使用硅基流动)
    embedding_api_key: str = ""                    # 从 .env 读取
    embedding_base_url: str = "https://api.siliconflow.cn/v1"
    embedding_model: str = "BAAI/bge-m3"

    # LLM 配置(硅基流动代理 DeepSeek)
    deepseek_api_key: str = ""
    deepseek_base_url: str = "https://api.siliconflow.cn/v1"
    deepseek_model: str = "deepseek-ai/DeepSeek-V4-Pro"

    # 重排序配置(使用硅基流动 rerank API)
    reranker_model: str = "BAAI/bge-reranker-v2-m3"
    enable_reranker: bool = True
    rerank_top_k: int = 5
    min_evidence_score: float = 0.3

    model_config = SettingsConfigDict(
        env_file=str(ENV_FILE),
        env_file_encoding="utf-8",
        extra="ignore",
        protected_namespaces=("settings_",),
    )

@lru_cache
def get_settings() -> Settings:
    return Settings()

.env 文件只需一行:

plain 复制代码
EMBEDDING_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx

注意:硅基流动的 Embedding、LLM 和 Rerank 共用同一个 API Key,不需要分别申请。

4.4 基础调用

python 复制代码
import httpx

def rerank_basic(
    query: str,
    documents: list[str],
    api_key: str,
    model: str = "BAAI/bge-reranker-v2-m3",
    top_n: int = 5,
) -> list[dict]:
    """硅基流动 rerank API 基础调用"""
    
    client = httpx.Client(
        base_url="https://api.siliconflow.cn/v1",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        timeout=30.0,
    )
    
    response = client.post(
        "/rerank",
        json={
            "model": model,
            "query": query,
            "documents": documents,
            "return_documents": False,
            "top_n": top_n,
        },
    )
    response.raise_for_status()
    data = response.json()
    
    # 解析结果
    results = []
    for item in data.get("results", []):
        results.append({
            "index": item["index"],
            "score": item.get("relevance_score", 0.0),
        })
    
    return results

4.5 使用示例

python 复制代码
api_key = "sk-xxxxxxxxxxxxxxxxxxxxxxxx"
query = "怎么办社保卡"
documents = [
    "社保卡申领需要身份证原件和照片",
    "医疗保险报销范围",
    "公积金提取条件",
    "社保卡补办流程",
    "社保缴费基数调整",
]

results = rerank_basic(query, documents, api_key, top_n=3)
for r in results:
    print(f"score={r['score']:.4f} | {documents[r['index']]}")

# 输出:
# score=0.9876 | 社保卡申领需要身份证原件和照片
# score=0.7654 | 社保卡补办流程
# score=0.0123 | 医疗保险报销范围

对比前面本地 CrossEncoder 的结果,排序完全一致,分数范围也接近------因为硅基流动底层跑的就是同一个模型 bge-reranker-v2-m3,区别只是推理位置和分数后处理不同。


5. 分数归一化:sigmoid vs clamp

这是两种方案最容易踩坑的区别。

5.1 CrossEncoder 输出的是 logits

python 复制代码
# CrossEncoder.predict() 返回的是原始 logits
# logits 可以是任意实数:-∞ 到 +∞

# 实际输出示例:
# scores = [6.234, -1.567, -3.891, 0.456, 12.789]
#
# logits 的含义:
#   正数 → 相关
#   负数 → 不相关
#   绝对值越大 → 判断越确信
#
# 但 logits 不在 [0, 1] 范围内,不能直接当概率/分数用

如果直接用 logits 做阈值过滤:

python 复制代码
# 错误做法:直接用 logits
if score > 0.5:  # 0.5 在 logits 空间没有意义
    # logit=0.5 其实表示"勉强相关"
    # logit=5.0 才是"非常相关"
    pass

# 错误做法:直接用 logits 做分数展示
# 用户看到 score=12.789 会懵------这是什么单位?

5.2 sigmoid 归一化

python 复制代码
import math

def sigmoid(x: float) -> float:
    """将 logits 压缩到 (0, 1) 区间"""
    return 1.0 / (1.0 + math.exp(-x))

# sigmoid 转换效果:
# logit=12.789 → sigmoid=0.999997  (非常相关)
# logit=6.234  → sigmoid=0.998056  (相关)
# logit=0.456  → sigmoid=0.612039  (勉强相关)
# logit=-1.567 → sigmoid=0.172689  (不太相关)
# logit=-3.891 → sigmoid=0.020057  (不相关)

sigmoid 的特性:

  • 输入 0 时输出 0.5(边界)
  • 正数 → 大于 0.5(相关)
  • 负数 → 小于 0.5(不相关)
  • 永远不等于 0 或 1(渐近线)

5.3 硅基流动 API 返回的已经是归一化分数

python 复制代码
# 硅基流动 /rerank API 的响应
# relevance_score 已经是 0~1 之间的浮点数
#
# {
#     "index": 0,
#     "relevance_score": 0.9876    ← 已经归一化了
# }
#
# 服务端在返回之前已经做了 sigmoid(或类似转换)
# 所以客户端不需要再做 sigmoid

5.4 正确的归一化方式

python 复制代码
# 本地 CrossEncoder:需要 sigmoid
def normalize_crossencoder_score(logit: float) -> float:
    import math
    return 1.0 / (1.0 + math.exp(-logit))


# 硅基流动 API:只需要 clamp(夹取到 [0, 1])
def normalize_api_score(raw_score: float) -> float:
    """
    分数归一化:把任意范围的分数压缩到 0~1 之间。

    硅基流动 rerank API 返回的 relevance_score 本身就是 0~1 之间,
    但为了安全起见,仍然做一次 clamp(夹取)处理。
    """
    if raw_score < 0:
        return 0.0
    if raw_score > 1:
        return 1.0
    return raw_score

5.5 为什么 API 方案用 clamp 就够了

python 复制代码
# 对比两种归一化策略

# 策略1:sigmoid(CrossEncoder 需要)
#   输入:logits ∈ (-∞, +∞)
#   输出:(0, 1)
#   特点:非线性映射,压缩极端值
#   适用:原始分数是 logits(无界)

# 策略2:clamp(硅基流动 API 需要)
#   输入:relevance_score ∈ [0, 1](理论上)
#   输出:[0, 1]
#   特点:线性截断,不做任何映射
#   适用:原始分数已经在目标范围内,只需防御性处理

# 为什么不对 API 返回的分数也做 sigmoid?
# 因为 API 服务端已经做过了!
# 如果对已经 sigmoid 过的分数再做一次 sigmoid:
score = 0.9876
double_sigmoid = 1.0 / (1.0 + math.exp(-(score * 12)))  # 假设放大12倍再sigmoid
# 这会扭曲分数分布,让中间值被压扁,极端值被拉满
# 原本 0.5 的分数会变成 0.997,失去区分度

5.6 归一化对比表

场景 原始分数范围 归一化方式 代码 原因
CrossEncoder logits (-∞, +∞) sigmoid 1/(1+e^{-x}) logits 无界,需要非线性压缩
硅基流动 API 0, 1 clamp max(0, min(1, x)) 已归一化,只需防御性截断
Milvus L2 [0, +∞) 指数衰减 e^{-x/scale} 距离越大越不相似
Milvus COSINE -1, 1 线性映射 (x+1)/2 余弦转相似度

6. 生产级封装:含异常降级

在线 API 最大的风险是网络不稳定。生产环境必须做异常降级------API 挂了不能让整个检索链路瘫痪。

6.1 完整封装

python 复制代码
"""
重排序服务:调用硅基流动 rerank API
含 HTTP 客户端复用、异常降级、分数归一化
"""

import logging
import httpx
from app.config import get_settings

logger = logging.getLogger(__name__)

# 全局 HTTP 客户端缓存(复用连接池)
_rerank_client = None


def _get_rerank_client():
    """创建并缓存 HTTP 客户端(复用硅基流动的 API Key)"""
    global _rerank_client
    if _rerank_client is None:
        settings = get_settings()
        _rerank_client = httpx.Client(
            base_url="https://api.siliconflow.cn/v1",
            headers={
                "Authorization": f"Bearer {settings.embedding_api_key or settings.deepseek_api_key}",
                "Content-Type": "application/json",
            },
            timeout=30.0,
        )
    return _rerank_client


def normalize_score(raw_score: float) -> float:
    """
    分数归一化:把任意范围的分数压缩到 0~1 之间。

    硅基流动 rerank API 返回的 relevance_score 本身就是 0~1 之间,
    但为了安全起见,仍然做一次 clamp(夹取)处理。
    """
    if raw_score < 0:
        return 0.0
    if raw_score > 1:
        return 1.0
    return raw_score


def rerank(
    question: str,
    candidates: list[dict],
    settings=None,
) -> list[dict]:
    """
    调用硅基流动 rerank API 对候选证据重排序。

    Args:
        question: 用户问题
        candidates: 候选证据列表(RRF 融合后的结果)
        settings: 配置对象(不传则自动获取)

    Returns:
        重排序后的证据列表,每条多了 rerank_score 字段
    """
    if not candidates:
        return []

    if settings is None:
        settings = get_settings()

    # 关闭重排序时直接返回,用 rrf_score 作为 score
    if not settings.enable_reranker:
        return candidates[:settings.rerank_top_k]

    # 准备文档列表(只取 content 给 API)
    documents = [c.get("content", "") for c in candidates]

    try:
        client = _get_rerank_client()
        response = client.post(
            "/rerank",
            json={
                "model": settings.reranker_model,
                "query": question,
                "documents": documents,
                "return_documents": False,          # 不需要返回原文,省带宽
                "top_n": len(documents),            # 全部重排,客户端自己截断
            },
        )
        response.raise_for_status()
        data = response.json()

        # 解析结果
        results = data.get("results", [])
        reranked = []
        for item in results:
            idx = item["index"]
            raw_score = item.get("relevance_score", 0.0)
            score = normalize_score(raw_score)

            # 从原始候选中取数据,附加重排序分数
            new_item = dict(candidates[idx])
            new_item["raw_score"] = raw_score
            new_item["rerank_score"] = score
            reranked.append(new_item)

        # 按 rerank_score 降序排序,截取 rerank_top_k 条
        reranked.sort(key=lambda x: x["rerank_score"], reverse=True)
        return reranked[:settings.rerank_top_k]

    except Exception as e:
        logger.warning(f"重排序失败,降级使用 RRF 分数: {e}")
        # 降级:用 rrf_score 排序,加 rerank_score 字段
        fallback = sorted(
            candidates,
            key=lambda c: c.get("rrf_score", c.get("score", 0.0)),
            reverse=True,
        )[:settings.rerank_top_k]
        for item in fallback:
            item["rerank_score"] = item.get("rrf_score", item.get("score", 0.0))
        return fallback

6.2 设计要点解析

HTTP 客户端复用

python 复制代码
# 全局单例,复用 TCP 连接池
_rerank_client = None

def _get_rerank_client():
    global _rerank_client
    if _rerank_client is None:
        _rerank_client = httpx.Client(...)
    return _rerank_client

每次 rerank 调用都 new httpx.Client() 会频繁建断 TCP 连接,浪费三次握手的时间。用全局单例复用连接池,后续请求走 keep-alive。

top_n 设为全部

python 复制代码
"top_n": len(documents),  # 全部重排,客户端自己截断

不直接让 API 截断,而是在客户端截取 rerank_top_k。这样如果后续想调整 top_k,不需要重新调 API。

异常降级

python 复制代码
except Exception as e:
    logger.warning(f"重排序失败,降级使用 RRF 分数: {e}")
    fallback = sorted(
        candidates,
        key=lambda c: c.get("rrf_score", c.get("score", 0.0)),
        reverse=True,
    )[:settings.rerank_top_k]
    for item in fallback:
        item["rerank_score"] = item.get("rrf_score", item.get("score", 0.0))
    return fallback

API 调用失败时(网络超时、服务不可用、限流等),降级用 RRF 分数排序,保证检索链路不中断。降级后的结果仍然有 rerank_score 字段(用 rrf_score 填充),下游代码无感知。

API Key 复用

python 复制代码
"Authorization": f"Bearer {settings.embedding_api_key or settings.deepseek_api_key}"

硅基流动的 Embedding、LLM 和 Rerank 共用同一个 API Key。优先用 embedding_api_key,如果没配就用 deepseek_api_key,灵活适配不同的 .env 配置习惯。


7. 接入混合检索管线

重排序不是孤立的一步,它接在 RRF 融合之后、证据过滤之前。

7.1 RRF 融合回顾

python 复制代码
"""
RRF 倒数排名融合:不直接比较分数,只看排名。
某结果在两路检索中排名越靠前,得分越高。
score = Σ 1 / (k + rank)
"""

def reciprocal_rank_fusion(
    vector_results: list[dict],
    bm25_results: list[dict],
    k: int = 60,
) -> list[dict]:
    """
    RRF 倒数排名融合。
    """
    scores: dict[str, float] = {}    # chunk_id -> rrf_score
    sources: dict[str, set] = {}     # chunk_id -> {source_names}
    all_items: dict[str, dict] = {}  # chunk_id -> 原始数据

    # 处理向量结果
    for rank, item in enumerate(vector_results):
        cid = item["chunk_id"]
        scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
        sources.setdefault(cid, set()).add("vector")
        if cid not in all_items:
            all_items[cid] = item

    # 处理 BM25 结果
    for rank, item in enumerate(bm25_results):
        cid = item["chunk_id"]
        scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
        sources.setdefault(cid, set()).add("bm25")
        if cid not in all_items:
            all_items[cid] = item

    # 组装融合结果
    ranked = []
    for cid, item in all_items.items():
        ranked.append({
            **item,
            "rrf_score": scores.get(cid, 0.0),
            "sources": list(sources.get(cid, set())),
        })

    # 按 RRF 分数降序排序
    ranked.sort(key=lambda x: x["rrf_score"], reverse=True)
    return ranked

7.2 混合检索 + 重排序管线

python 复制代码
import asyncio
from app.services.vector_store import VectorStoreService
from app.services.pipeline_utils import reciprocal_rank_fusion, normalize_score, filter_evidence
from app.config import get_settings

def retrieve_with_rerank(
    question: str,
    session,
    filters=None,
    top_k: int = 10,
    evidence_top_k: int = 5,
    min_score: float = 0.5,
) -> dict:
    """
    混合检索 + 重排序完整管线
    
    流程:向量检索 + BM25 检索 → RRF 融合 → Rerank 精排 → 证据过滤
    """

    # trace 初始化
    trace: dict = {
        "original_query": question,
        "steps": [],
    }

    # ------------------------------------------------------------------
    # 1. 向量 + BM25 并行检索
    # ------------------------------------------------------------------
    vector_store = VectorStoreService()

    async def _hybrid_search():
        """并行执行向量检索和 BM25 检索"""
        vector_task = asyncio.to_thread(
            vector_store.search,
            query=question,
            top_k=top_k * 3,  # 过检索 3 倍
            document_ids=filters.document_ids if filters else None,
        )
        bm25_task = asyncio.to_thread(
            _bm25_search,
            question=question,
            session=session,
            filters=filters,
            top_k=top_k,
        )
        return await asyncio.gather(vector_task, bm25_task)

    vector_candidates, bm25_candidates = asyncio.run(_hybrid_search())

    trace["steps"].append(f"[vector] 召回 {len(vector_candidates)} 条候选")
    trace["steps"].append(f"[bm25] 召回 {len(bm25_candidates)} 条候选")

    # ------------------------------------------------------------------
    # 2. RRF 融合
    # ------------------------------------------------------------------
    candidates = reciprocal_rank_fusion(vector_candidates, bm25_candidates)
    trace["steps"].append(f"[rrf] 融合后 {len(candidates)} 条候选")

    # ------------------------------------------------------------------
    # 3. 重排序(调用硅基流动 rerank API)
    # ------------------------------------------------------------------
    settings = get_settings()
    candidates = rerank(question, candidates, settings)
    trace["steps"].append(f"[rerank] 重排序后 {len(candidates)} 条候选")

    # ------------------------------------------------------------------
    # 4. 证据筛选
    # ------------------------------------------------------------------
    # 用 rerank_score 过滤
    scored = [c for c in candidates if c.get("rerank_score", 0.0) >= min_score]
    scored.sort(key=lambda c: c.get("rerank_score", 0.0), reverse=True)

    trace["steps"].append(
        f"[score] min_score={min_score} 过滤: {len(candidates)} -> {len(scored)}"
    )

    # 额外用 min_evidence_score 做证据过滤
    scored = filter_evidence(scored, settings.min_evidence_score, "rerank_score")
    trace["steps"].append(
        f"[evidence_filter] 证据过滤(阈值={settings.min_evidence_score}): -> {len(scored)} 条"
    )

    # 截取前 evidence_top_k 条
    evidence = scored[:evidence_top_k]

    # 统一 evidence_score 字段
    for ev in evidence:
        ev["evidence_score"] = ev.get("rerank_score", 0.0)

    trace["steps"].append(f"[evidence] 最终证据 {len(evidence)} 条")
    trace["final_evidence"] = evidence

    # 拒答判断
    refused = len(evidence) == 0
    if refused:
        trace["steps"].append("[refuse] 拒答: 未找到足够相关的证据")

    return {
        "query": question,
        "evidence": evidence,
        "refused": refused,
        "trace": trace,
    }

7.3 管线流程图

复制代码
用户问题
  │
  ├──→ 向量检索 (top_k × 3 = 30)     ──┐
  │                                     ├── 并行执行 (asyncio.gather)
  ├──→ BM25 检索 (top_k = 10)         ──┘
  │
  ▼
RRF 融合 (k=60)
  │  合并去重,按排名打分
  ▼
Rerank 精排 (硅基流动 API)
  │  Cross-Encoder 重排序
  │  返回 rerank_score ∈ [0, 1]
  ▼
证据过滤
  │  min_score 过滤 (如 0.5)
  │  min_evidence_score 过滤 (如 0.3)
  ▼
截取 evidence_top_k (如 5)
  │
  ▼
最终证据 → 交给 LLM 生成回答

8. 证据过滤与最终截断

8.1 两层过滤设计

python 复制代码
# 第一层:min_score(检索阶段过滤)
# 作用:过滤掉明显不相关的候选
# 阈值:0.5(较高,粗筛)
scored = [c for c in candidates if c.get("rerank_score", 0.0) >= min_score]

# 第二层:min_evidence_score(证据阶段过滤)
# 作用:过滤掉 rerank 分数不够高的证据
# 阈值:0.3(较低,精筛,防止过度过滤导致无证据可用)
scored = filter_evidence(scored, settings.min_evidence_score, "rerank_score")

8.2 为什么要两层过滤

python 复制代码
# 场景:rerank 返回 5 条结果
# rerank_score: [0.95, 0.72, 0.48, 0.35, 0.12]

# 只用一层 min_score=0.5:
# 过滤后: [0.95, 0.72, 0.48]  ← 3 条
# 问题:0.48 勉强通过,但质量不高

# 两层过滤 min_score=0.5 + min_evidence_score=0.3:
# 第一层: [0.95, 0.72, 0.48]  ← 过滤掉 0.35 和 0.12
# 第二层: [0.95, 0.72, 0.48]  ← 0.48 > 0.3,保留
# 结果:3 条证据,质量有保障

# 另一个场景:rerank 返回 5 条结果
# rerank_score: [0.95, 0.72, 0.45, 0.38, 0.31]
# 只用一层 min_score=0.5:
# 过滤后: [0.95, 0.72]  ← 只有 2 条
#
# 两层过滤 min_score=0.5 + min_evidence_score=0.3:
# 第一层: [0.95, 0.72]  ← 同样过滤
# 第二层: [0.95, 0.72]  ← 无变化
# 结果:2 条证据

两层过滤的价值在于:第一层用较高阈值粗筛,第二层用较低阈值兜底。如果后续调整 evidence_top_k 增大,第二层可以放更多结果进来。

8.3 filter_evidence 实现

python 复制代码
def filter_evidence(
    evidence: list[dict],
    min_score: float,
    score_field: str = "rerank_score",
):
    """
    证据过滤:只保留分数 >= min_score 的证据。

    Args:
        evidence: 待过滤的证据列表
        min_score: 最低分数阈值
        score_field: 从哪个字段读分数

    Returns:
        过滤后的证据列表
    """
    return [
        e for e in evidence
        if e.get(score_field, 0.0) >= min_score
    ]

8.4 统一 evidence_score

python 复制代码
# 不同检索模式用不同分数字段:
# - vector 模式:score(向量相似度)
# - hybrid 模式:rrf_score(RRF 融合分数)
# - hybrid_rerank / full 模式:rerank_score(重排序分数)

# 统一成 evidence_score,下游代码(如 prompt 拼接、日志展示)只需读一个字段
for ev in evidence:
    if "rerank_score" in ev:
        ev["evidence_score"] = ev["rerank_score"]
    elif "rrf_score" in ev:
        ev["evidence_score"] = ev["rrf_score"]
    else:
        ev["evidence_score"] = ev.get("score", 0.0)

9. 两种方案对比选型

9.1 性能对比

python 复制代码
# 测试环境:Python 3.13, 10 条候选文档

# 本地 CrossEncoder (CPU, Intel i7-12700K):
#   首次加载(模型下载): ~120s(一次性)
#   模型加载到内存: ~3s
#   单次 rerank: ~0.8s
#   内存占用: ~2.5GB

# 硅基流动 API:
#   首次调用: ~1.2s(含 TCP 连接建立)
#   后续调用: ~0.4s(连接复用)
#   内存占用: ~10MB(仅 HTTP 客户端)

9.2 完整对比表

维度 本地 CrossEncoder 硅基流动 API
安装复杂度 高(torch + 模型下载) 低(pip install httpx)
首次启动 慢(下载 2GB 模型) 快(无下载)
单次延迟 0.5-1s(CPU) 0.3-0.5s(网络)
吞吐量 受 CPU/GPU 限制 受 API 限流限制
内存占用 2.5GB+ ~10MB
磁盘占用 2GB+(模型文件) 0
离线可用
分数归一化 sigmoid clamp
异常处理 本地崩溃 网络超时/限流/服务不可用
降级方案 无(本地挂了就挂了) 降级用 RRF 分数
水平扩展 每台机器都要部署 共享 API 服务
成本 一次性硬件成本 按 API 调用计费

9.3 什么时候用哪个

python 复制代码
# 选本地 CrossEncoder:
# 1. 高并发生产环境(>100 QPS)
# 2. 离线 / 内网环境
# 3. 对延迟极度敏感(<100ms)
# 4. 有 GPU 服务器
# 5. 数据敏感不能发到第三方

# 选硅基流动 API:
# 1. 开发阶段 / 原型验证
# 2. 中小规模应用(<10 QPS)
# 3. 无 GPU 服务器
# 4. 容器化 / Serverless 部署
# 5. 快速迭代,不想管模型版本
# 6. 多端部署(Windows/Linux/Mac 统一用 API)

10. 踩坑清单:重排序中的7个关键问题

序号 问题 原因 解决方案
1 本地 CrossEncoder 分数异常 输出是 logits,范围不固定 sigmoid 归一化
2 API 分数重复 sigmoid 不知道 API 已归一化 只做 clamp
3 API 超时导致检索失败 没有异常降级 try-except 降级用 RRF
4 HTTP 连接频繁创建 每次调用 new Client 全局单例复用连接池
5 top_n 设太小 API 截断后无法调整 top_n=len(documents)
6 rerank 输入为空 候选全部被过滤 空列表直接返回
7 分数字段不统一 不同模式用不同字段 统一成 evidence_score

10.1 问题1详解:CrossEncoder logits

python 复制代码
# CrossEncoder.predict() 返回 logits
# logits 不是概率!不能直接当分数用

# 错误做法:
scores = reranker.predict(pairs)
for score in scores:
    if score > 0.5:  # 0.5 在 logits 空间无意义
        print("相关")

# 正确做法:
import math
scores = reranker.predict(pairs)
for score in scores:
    normalized = 1.0 / (1.0 + math.exp(-score))  # sigmoid
    if normalized > 0.5:  # sigmoid 后 0.5 是合理的阈值
        print("相关")

10.2 问题3详解:异常降级

python 复制代码
# 场景:硅基流动 API 突然超时
# 如果没有降级,整个检索链路中断

# 错误做法:
response = client.post("/rerank", json=payload)
response.raise_for_status()  # 直接抛异常,检索中断

# 正确做法:
try:
    response = client.post("/rerank", json=payload)
    response.raise_for_status()
    data = response.json()
    # ... 正常处理
except Exception as e:
    logger.warning(f"重排序失败,降级使用 RRF 分数: {e}")
    # 用 RRF 分数排序作为降级方案
    fallback = sorted(
        candidates,
        key=lambda c: c.get("rrf_score", c.get("score", 0.0)),
        reverse=True,
    )[:settings.rerank_top_k]
    for item in fallback:
        item["rerank_score"] = item.get("rrf_score", item.get("score", 0.0))
    return fallback

10.3 问题4详解:HTTP 连接复用

python 复制代码
# 错误做法:每次调用都创建新 Client
def rerank_wrong(query, documents):
    client = httpx.Client(...)  # 每次都 new
    response = client.post("/rerank", ...)
    client.close()  # 关闭连接
    # 问题:频繁 TCP 三次握手,浪费时间

# 正确做法:全局单例
_rerank_client = None

def _get_rerank_client():
    global _rerank_client
    if _rerank_client is None:
        _rerank_client = httpx.Client(...)  # 只创建一次
    return _rerank_client  # 后续复用 TCP 连接

10.4 问题7详解:分数字段统一

python 复制代码
# 不同模式产生不同分数字段:
# vector       → score
# hybrid       → rrf_score
# hybrid_rerank → rerank_score
# full          → rerank_score

# 下游代码(如 prompt 拼接)不应该关心当前是什么模式
# 统一成 evidence_score

for ev in evidence:
    if "rerank_score" in ev:
        ev["evidence_score"] = ev["rerank_score"]
    elif "rrf_score" in ev:
        ev["evidence_score"] = ev["rrf_score"]
    else:
        ev["evidence_score"] = ev.get("score", 0.0)

# 下游代码只需:
for ev in evidence:
    print(f"score={ev['evidence_score']:.4f}")

11. 总结与延伸

11.1 核心要点

要点 说明
两种方案都能用 本地 CrossEncoder 和在线 API 底层跑同一模型
分数归一化不同 CrossEncoder 用 sigmoid,API 用 clamp
在线 API 需降级 网络不稳定时降级用 RRF 分数
HTTP 连接要复用 全局单例 httpx.Client
两层证据过滤 min_score 粗筛 + min_evidence_score 精筛
分数字段统一 统一成 evidence_score,下游无感知
top_n 传全部 客户端截断比 API 截断更灵活

11.2 架构全景

复制代码
用户提问
  → 向量检索 (top_k × 3)     ──┐
  → BM25 检索 (top_k)         ──┤ 并行
  → RRF 融合                   ← 合并两路结果
  → Rerank 精排                ← 硅基流动 API / 本地 CrossEncoder
    ├── 分数归一化              ← sigmoid / clamp
    └── 异常降级                ← 降级用 RRF 分数
  → 证据过滤 (两层)
    ├── min_score              ← 粗筛
    └── min_evidence_score     ← 精筛
  → 截取 evidence_top_k
  → 统一 evidence_score
  → 交给 LLM 生成回答

11.3 延伸方向

  • 混合方案 :本地部署小模型(如 bge-reranker-base)做初筛,再调 API 大模型精排
  • 缓存层:对相同 query-documents 对缓存 rerank 结果,减少 API 调用
  • 批量优化:把多个用户的 rerank 请求合并成一个 batch 调用
  • 模型切换:硅基流动支持多个 rerank 模型,可按场景选择
  • A/B 测试:同时跑本地和 API 方案,对比排序质量和延迟

12.文末互动

思考题

  1. 如果你的 RAG 系统每天有 10 万次检索请求,每次 rerank 调用硅基流动 API 花费 0.001 元,你会选择继续用 API 还是切换到本地 CrossEncoder?考虑成本、延迟、运维复杂度三个维度。
  2. 降级方案中,我们用 RRF 分数代替 rerank 分数。但 RRF 分数范围通常在 0.01~0.05 之间,远小于 rerank 的 0~1。如果下游代码对分数范围有依赖(比如阈值 0.5),这个降级方案会不会出问题?怎么修复?
  3. 硅基流动 API 返回的 relevance_score 已经是 0~1,我们只做 clamp。但如果某天 API 升级后返回的分数范围变成了 0~100(忘了通知你),clamp 方案会怎样?应该怎么防御这种变化?

如果本文对你有帮助

  • 点赞支持,让更多 RAG 开发者看到这篇内容
  • 收藏备用,接入重排序时翻出来参考
  • 评论交流,说说你用的是本地还是在线方案,踩了哪些坑

作者的话:重排序是 RAG 检索管线中"花小钱办大事"的一步------用一次 API 调用或一次本地推理,就能显著提升最终回答质量。本文从实际项目出发,把本地 CrossEncoder 和硅基流动在线 API 两种方案掰开揉碎对比,重点讲清楚了 sigmoid vs clamp 这个最容易踩的归一化坑,以及异常降级这个生产环境必须有的兜底机制。选哪种方案取决于你的场景,但不管选哪种,分数归一化和异常降级都不能少。

相关推荐
yingyuecom1 小时前
Seedance 2.5正式发布:映悦AI迎来“更长、更可控、更极致”的视频生成时代
人工智能·gpt·chatgpt·prompt·aigc
MindUp1 小时前
告别排版焦虑:从 PPT 模板到 AI 生成工具的个人使用体验与效率对比
人工智能·powerpoint
张洛闻Eren1 小时前
MySQL 管理复制拓扑【MySQL第四课】
linux·运维·数据库·mysql
martindelophy1 小时前
Codex Chrome 插件 + Timeline Studio:构建可编辑的 AI 视频剪辑 Agent 工作流
前端·人工智能·chrome
AIkk861 小时前
大文件怎么压缩变小方便传输?本地压缩+云端方案对比测评
人工智能
薛定e的猫咪1 小时前
(ICLR2026)MORL‑FB:从无奖励强化学习视角重新审视多目标强化学习
人工智能·深度学习·机器学习
创世宇图1 小时前
【AI量化交易实战】第06讲:小雅再升级——Talib指标库与K线形态量化
算法
西安景驰电子1 小时前
《PTP精确时间协议系列》第二篇:工程部署、调试与性能优化
运维·服务器·网络·数据库·windows·性能优化
行业研究员2 小时前
腾讯云ADP:智能体平台封神榜
人工智能·microsoft·腾讯云·智能体·智能体平台·腾讯云adp