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 + 证据过滤
- 学会在混合检索管线中正确接入重排序环节
目录
- 为什么需要重排序
- 两种方案概览
- 方案一:本地CrossEncoder实现
- [方案二:硅基流动在线Rerank API](#方案二:硅基流动在线Rerank API)
- [分数归一化:sigmoid vs clamp](#分数归一化:sigmoid vs clamp)
- 生产级封装:含异常降级
- 接入混合检索管线
- 证据过滤与最终截断
- 两种方案对比选型
- 踩坑清单:重排序中的7个关键问题
- 总结与延伸
- 文末互动
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-Rerank 或 BAAI/bge-reranker-v2-m3 |
BAAI/bge-reranker-v2-m3 |
| 依赖 | sentence_transformers、torch |
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.文末互动
思考题:
- 如果你的 RAG 系统每天有 10 万次检索请求,每次 rerank 调用硅基流动 API 花费 0.001 元,你会选择继续用 API 还是切换到本地 CrossEncoder?考虑成本、延迟、运维复杂度三个维度。
- 降级方案中,我们用 RRF 分数代替 rerank 分数。但 RRF 分数范围通常在 0.01~0.05 之间,远小于 rerank 的 0~1。如果下游代码对分数范围有依赖(比如阈值 0.5),这个降级方案会不会出问题?怎么修复?
- 硅基流动 API 返回的
relevance_score已经是 0~1,我们只做 clamp。但如果某天 API 升级后返回的分数范围变成了 0~100(忘了通知你),clamp 方案会怎样?应该怎么防御这种变化?
如果本文对你有帮助:
- 点赞支持,让更多 RAG 开发者看到这篇内容
- 收藏备用,接入重排序时翻出来参考
- 评论交流,说说你用的是本地还是在线方案,踩了哪些坑
作者的话:重排序是 RAG 检索管线中"花小钱办大事"的一步------用一次 API 调用或一次本地推理,就能显著提升最终回答质量。本文从实际项目出发,把本地 CrossEncoder 和硅基流动在线 API 两种方案掰开揉碎对比,重点讲清楚了 sigmoid vs clamp 这个最容易踩的归一化坑,以及异常降级这个生产环境必须有的兜底机制。选哪种方案取决于你的场景,但不管选哪种,分数归一化和异常降级都不能少。