1. 你将学到什么 #
重排是检索流水线里的「临门一脚」。前面的召回负责从海量文档里尽快捞出一堆可能相关的候选;重排负责在这堆候选里再认真读一遍,把真正能回答问题的排到最前面。
本文的学习顺序是:
- 先建立直觉:为什么「只召回」常常不够
- 再补前置:嵌入、双塔、交叉编码器分别是什么
- 然后看机制:重排模型到底怎么给一对「问题 + 文档」打分
- 接着动手:先写简版,再用真实 API,最后接到 LangChain
- 最后记坑:哪些做法看起来合理,实际会踩雷
2. 环境准备 #
下面这些东西准备好,后面的代码就能直接跑。
2.1. 需要安装的包 #
本文用到的核心库如下。在仓库根目录或任意干净虚拟环境里执行:
# 安装通义 SDK(直接调 TextReRank API 用)
pip install dashscope
# 安装环境变量加载工具(从 .env 读 API Key)
pip install python-dotenv
# 安装 LangChain 相关包(接到检索器时用)
pip install langchain-core langchain-community langchain-classic
# 安装 BM25 与中文分词(本文用 BM25 做粗召回,避免依赖向量库)
pip install rank-bm25 jieba说明:
dashscope:阿里云通义的官方 SDK,用来调用重排 APIlangchain-community:里面有DashScopeRerank封装langchain-classic:里面有ContextualCompressionRetriever(把「粗召回 + 重排」串起来)rank-bm25+jieba:用来做关键词粗召回;中文必须先分词,BM25 才有意义
2.2. 需要配置的 API Key #
重排模型要花钱(或走免费额度),需要通义百炼的 Key。
在项目根目录的 .env 文件里写上:
DASHSCOPE_API_KEY=你的通义API密钥申请入口见阿里云百炼控制台。没有 Key 时,第 5 节的简易重排器仍然可以完整运行;从第 6 节开始才需要 Key。
3. 为什么需要重排 #
只做召回、不做重排,系统经常会「找到了正确答案,却把它埋在第 3、第 4 名」。
3.1. 一个会失败的真实场景 #
假设用户问:
多少天内可以退货?
知识库里有这些片段(简化版):
| 编号 | 内容 | 是否正确答案 |
|---|---|---|
| A | 签收 7 日内可无理由退货,商品需保持完好。 | ✅ 是 |
| B | 退款将在收到退货并检验合格后的 3 个工作日内原路退回。 | ❌ 否(讲退款时效) |
| C | 质量问题 15 日内可换货,需提供照片凭证。 | ❌ 否(讲换货) |
| D | 预售商品以商品页标注的发货时间为准。 | ❌ 否(讲发货) |
向量检索或混合检索常常会把 B、C、D 也捞进来,因为它们都带「退」「日」「货」这类相近语义。若最终只把 top-1 交给大模型,模型就可能根据「退款 3 个工作日」胡乱答成「3 天」,而真正的「7 日内」还躺在第 3 名。
召回的目标是「尽量别漏」;重排的目标是「把对的顶上去」。 两件事不能互相替代。
3.2. 通俗类比:海选与决赛 #
把整个检索想成招聘:
- 召回 = HR 用关键词海选
条件写「Python、3 年经验」,机器很快筛出 100 份简历。速度快,但分不清「真·3 年」和「实习也算 3 年」。 - 重排 = 技术专家精读这 100 份
专家一边对照岗位要求,一边读简历细节,最后排出前 5 名进入面试。
RAG 里也是一样:
全库(可能有几万、几十万条)
│
│ 召回:快、粗、求「不漏」
▼
候选集(通常几十条)
│
│ 重排:慢、细、求「排对」
▼
精排结果(通常 3~5 条)
│
▼
送给大模型生成答案3.3. 快与准为什么不能一步做完 #
如果对全库每一条都用「精读」模型打分,精度会很高,但速度和费用都会炸掉:10 万条文档就要跑 10 万次重排模型。
所以工业界几乎都用两段式:
| 阶段 | 典型技术 | 特点 | 目标 |
|---|---|---|---|
| 召回 | BM25、向量检索、混合检索 | 快,可预计算,能扫全库 | 把正确答案捞进候选 |
| 重排 | 交叉编码器(本文主角) | 慢,必须「问题+文档」现算 | 把正确答案排到最前 |
一句话记住:
重排不是搜索引擎,它是搜索结果的精修器。
候选里没有正确答案时,重排再强也救不回来。
4. 前置知识 #
下面这些词在重排文档里经常出现。不懂它们,后面读代码会很吃力,所以单独拆开讲。
4.1. 什么是「相关性分数」 #
相关性分数就是模型给出的一个数字,表示「这条文档和这个问题有多搭」。
- 分数越高,通常表示越相关
- 不同模型的分数量纲不同:有的像 0~1 的概率,有的是任意实数
- 绝对不要拿「向量检索的相似度」和「重排分数」直接比大小,它们不是同一种尺子
开始最容易犯的错:
向量检索得分 0.82 > 重排得分 0.31
→ 就以为向量结果更好这是错的。0.82 和 0.31 来自两套打分体系,没有可比性。线上系统在重排之后,应完全信任重排后的名次,丢掉第一阶段的分数。
4.2. 什么是文本嵌入(Embedding) #
嵌入就是把一段文字变成一串数字(向量),让计算机能用「距离远近」表达「意思远近」。
直觉:
"我喜欢编程" → [0.12, -0.05, 0.88, ...]
"我爱写代码" → [0.11, -0.04, 0.85, ...] ← 两个向量很近
"今天天气很好" → [0.70, 0.22, -0.31, ...] ← 和上面两个很远召回阶段大量使用嵌入:文档可以提前算好向量存进库,查询时只算问题向量,再找最近邻。这就是它快的原因。
4.3. 什么是双塔模型(Bi-Encoder) #
双塔模型 = 两个塔分别编码,再比向量。
问题 ──► 编码器 A ──► 问题向量 ──┐
├─► 余弦相似度 / 点积 ──► 分数
文档 ──► 编码器 B ──► 文档向量 ──┘特点:
- 文档向量可以提前算好,检索时几乎只做向量比对
- 问题与文档在编码时互不看见对方
- 适合海量召回,精度有上限
类比:先各自写一份摘要,再拿两份摘要比对。摘要写得再好,也可能丢掉「否定词」「具体数字」这类细节。
4.4. 什么是交叉编码器(Cross-Encoder) #
交叉编码器 = 把问题和文档拼成一段,一起送进模型。
[问题] + [分隔符] + [文档]
│
▼
同一个 Transformer
(内部做全文注意力交互)
│
▼
输出一个相关性分数特点:
- 问题里的每个词都能「看见」文档里的每个词,细节捕捉更强
- 没法预计算:换一个问题,就要重新跑一遍
- 适合对少量候选精排,不适合扫全库
类比:把岗位要求和简历摊在一张桌上,逐句对照,而不是先各自写摘要再比对。
4.5. 双塔 vs 交叉:一张对照表 #
| 对比项 | 双塔 Bi-Encoder | 交叉 Cross-Encoder |
|---|---|---|
| 输入方式 | 问题、文档各自编码 | 问题 + 文档拼在一起 |
| 能否预计算文档 | 能 | 不能 |
| 速度 | 快 | 慢 |
| 精度 | 一般 | 更高 |
| 典型用途 | 召回 | 重排 |
RAG 里常见组合:双塔(或 BM25)负责召回,交叉编码器负责重排。
4.6. 什么是「文档压缩器」(Compressor) #
在 LangChain 里,重排组件常被归类为 Document Compressor(文档压缩器)。
名字有点怪,含义其实很朴素:
输入一批文档,输出更少(或更精炼)的文档。
重排就是一种压缩:
- 输入:粗召回的 8 条
- 输出:按相关性重排后的 top_n=3 条
同一位置还可以放「用 LLM 裁剪无关句子」之类的组件。本文只关心重排这一种。
4.7. 什么是 Pointwise 打分 #
重排最主流的打分方式叫 Pointwise(点式):
- 每次只看一对
(问题, 某一篇文档) - 模型输出这一对的分数
- 全部打完后,按分数从高到低排序
还有 Listwise(一次看整个列表再排序)等更复杂玩法。开始阶段掌握 Pointwise 就够了,也是通义 gte-rerank-v2 实际在做的事。
5. 重排模型到底怎么工作 #
有了前置概念,可以把重排的内部步骤说清楚。这一节偏「理解」,下一节开始写代码。
5.1. 三步流水线 #
交叉编码器重排通常分三步:
- 拼接输入
把问题和文档拼成一条序列,中间用特殊分隔符隔开,例如:[CLS] 多少天内可以退货? [SEP] 签收 7 日内可无理由退货…… [SEP] - 深度交互
Transformer 的注意力机制让问题中的每个词都能关注文档中的每个词。于是模型能发现:「问题里的『多少天』」对应「文档里的『7 日内』」。 - 输出分数
模型把交互后的表示送进一个小分类头,吐出一个相关性分数;对所有候选重复后排序,再截断为top_n。
5.2. 为什么比向量检索更准 #
向量检索编码文档时,还不知道用户会问什么,只能把整句压成一个「通用坐标」。细节(否定词、具体天数、退货 vs 退款)容易糊在一起。
重排是拿着问题去读文档,所以能区分:
- 「7 日内可退货」→ 直接回答「多少天」
- 「3 个工作日内退款」→ 也有「日」,但答的不是同一个问题
5.3. 为什么必须先召回再重排 #
因为第 2 步要对每一个候选跑一遍模型:
- 候选 10 条 → 成本可接受
- 候选 10 万条 → 延迟和费用都不可接受
所以正确姿势永远是:
全库 ──召回──► 少量候选 ──重排──► top_n6. 先写一个「简易重排器」 #
正式调 API 之前,先用纯 Python 模拟一遍「粗召回 → 打分 → 排序 → 截断」。这样你能看清重排的流程,不被 SDK 细节干扰。
6.1. 这一节在演示什么 #
简易版用「问题和文档的公共字数」当假分数。它当然不如神经网络聪明,但流程和真重排一模一样:
- 准备问题与候选文档
- 对每个候选打一个分数
- 按分数降序排序
- 只保留前
top_n条
这一节不需要 API Key,适合验证环境与思路。
6.2. 完整可运行代码 #
把下面整段保存为 toy_rerank.py,直接运行:
# -*- coding: utf-8 -*-
# 上面这行告诉 Python:本文件按 UTF-8 编码读取,避免中文注释乱码
# 定义用户问题:后面所有候选都围绕这个问题打分
query = "多少天内可以退货?"
# 定义候选文档列表:模拟「粗召回」已经捞出来的几条
# 每条是一个字典,含编号 id 和正文 text,方便打印时对照
candidates = [
# 正确答案:明确写了「7 日内可无理由退货」
{"id": "A", "text": "签收 7 日内可无理由退货,商品需保持完好。"},
# 干扰项:也有「退」「日」,但讲的是退款时效,不是退货期限
{"id": "B", "text": "退款将在收到退货并检验合格后的 3 个工作日内原路退回。"},
# 干扰项:讲换货,不是退货
{"id": "C", "text": "质量问题 15 日内可换货,需提供照片凭证。"},
# 干扰项:讲发货,和退货几乎无关
{"id": "D", "text": "预售商品以商品页标注的发货时间为准。"},
]
# 定义一个极其简单的打分函数:公共字符越多,分数越高
def toy_score(question: str, document: str) -> float:
# 把问题转成字符集合,去掉空白,避免空格干扰
q_chars = set(question.replace(" ", ""))
# 把文档转成字符集合,同样去掉空白
d_chars = set(document.replace(" ", ""))
# 若问题为空,直接返回 0,避免除零
if not q_chars:
# 没有有效字符时无法计算重合度
return 0.0
# 计算公共字符个数,再除以问题字符数,得到 0~1 的粗略相关性
return len(q_chars & d_chars) / len(q_chars)
# 定义重排主函数:打分 → 排序 → 截断
def toy_rerank(question: str, docs: list[dict], top_n: int = 3) -> list[dict]:
# 准备一个空列表,用来收集带分数的结果
scored = []
# 遍历每一个候选文档
for doc in docs:
# 取出正文
text = doc["text"]
# 调用简易打分函数,得到一个浮点分数
score = toy_score(question, text)
# 把原文档和分数打包成新字典,后面排序用
scored.append({"id": doc["id"], "text": text, "score": score})
# 按 score 从高到低排序;reverse=True 表示降序
scored.sort(key=lambda item: item["score"], reverse=True)
# 只保留前 top_n 条,模拟真实重排的截断行为
return scored[:top_n]
# 调用简易重排,保留前 3 名
ranked = toy_rerank(query, candidates, top_n=3)
# 打印标题,方便在终端里辨认这一段输出
print("问题:", query)
# 打印结果说明
print("重排结果(分数从高到低):")
# 用 enumerate 从 1 开始编号,打印名次、编号、分数、正文
for i, item in enumerate(ranked, start=1):
# 分数保留 4 位小数,和后面真实 API 的展示风格保持一致
print(f" {i}. score={item['score']:.4f} [{item['id']}] {item['text']}")6.3. 你应该看到什么 #
输出大致如下(具体分数取决于字符重合,重点看流程是否跑通):
问题: 多少天内可以退货?
重排结果(分数从高到低):
1. score=0.xxxx [A] 签收 7 日内可无理由退货,商品需保持完好。
2. score=0.xxxx [B] 退款将在收到退货并检验合格后的 3 个工作日内原路退回。
3. score=0.xxxx [C] 质量问题 15 日内可换货,需提供照片凭证。简易版可能不够聪明,甚至偶尔把干扰项排得很靠前——这没关系。你要带走的是流程:
打分函数可以换,流水线不变。
下一节只是把toy_score换成真正的神经网络 API。
7. 用通义 API 做真实重排 #
这一节调用阿里云通义的 TextReRank,模型用当前可用的 gte-rerank-v2。
7.1. API 在做什么 #
你把「一个问题 + 一批文档」发给服务端,它返回每条文档的:
index:这条结果对应你传入列表里的第几篇(从 0 开始)relevance_score:相关性分数
然后你按分数排序(SDK 通常已经排好),取前 top_n 条即可。
7.2. 完整可运行代码 #
把下面整段保存为 dashscope_rerank_demo.py。确保项目根目录 .env 里已有 DASHSCOPE_API_KEY。
# -*- coding: utf-8 -*-
# 指定源文件编码,保证中文注释正常
# 导入标准库:用于判断 HTTP 状态是否成功
from http import HTTPStatus
# 从 .env 文件加载环境变量(包括 DASHSCOPE_API_KEY)
from dotenv import load_dotenv
# 导入通义重排接口
from dashscope import TextReRank
# 执行加载:把 .env 中的键值写进操作系统环境变量
load_dotenv()
# 定义用户问题
query = "多少天内可以退货?"
# 定义候选文档:顺序就是 index=0,1,2,3
documents = [
# index=0:正确答案
"签收 7 日内可无理由退货,商品需保持完好,包装与配件齐全。",
# index=1:退款时效干扰项
"退款将在收到退货并检验合格后的 3 个工作日内原路退回。",
# index=2:换货干扰项
"质量问题 15 日内可换货,需提供照片凭证并联系客服登记。",
# index=3:发货干扰项
"预售商品以商品页标注的发货时间为准,如遇缺货会主动联系并可全额退款。",
]
# 调用通义重排 API
# model:必须用仍在服务的 gte-rerank-v2(旧名 gte-rerank 会 403)
# query:用户问题
# documents:候选正文列表
# top_n:最多返回几条;这里取 3
# return_documents:False 表示结果里不回传正文,我们本地用 index 回查即可
response = TextReRank.call(
model="gte-rerank-v2",
query=query,
documents=documents,
top_n=3,
return_documents=False,
)
# 检查 HTTP 状态码,不是 200 就打印错误信息并退出
if response.status_code != HTTPStatus.OK:
# 打印状态码与错误消息,方便排查 Key、权限、模型名问题
print("重排失败:", response.status_code, getattr(response, "message", response))
# 失败时直接结束脚本,避免后面访问空结果
raise SystemExit(1)
# 从响应里取出 results 列表
results = response.output.results
# 打印问题,作为输出标题
print("问题:", query)
# 打印结果说明
print("真实重排结果:")
# 遍历返回的每一条精排结果
for rank, item in enumerate(results, start=1):
# 取出该结果对应的原始下标
index = int(item.index)
# 取出相关性分数
score = float(item.relevance_score)
# 用下标回到本地 documents 列表,取出正文
text = documents[index]
# 按名次打印:分数、原始下标、正文
print(f" {rank}. score={score:.4f} [index={index}] {text}")7.3. 预期结果长什么样 #
正确时,第 1 名应是「签收 7 日内可无理由退货…」,分数明显高于干扰项。示意:
问题: 多少天内可以退货?
真实重排结果:
1. score=0.32xx [index=0] 签收 7 日内可无理由退货……
2. score=0.19xx [index=1] 退款将在收到退货并检验合格后……
3. score=0.17xx [index=?] ……注意两点:
- 分数小数末位可能轻微跳动(比如 0.3256 / 0.3252),排名通常稳定。不要写
if score == 0.3256。 - 若报错里出现
403或results为空,优先检查:Key 是否有效、模型名是否写成了已停用的gte-rerank。
7.4. 和简易版对照着看 #
| 步骤 | 简易版 | 真实 API |
|---|---|---|
| 打分 | toy_score 数公共字符 |
TextReRank.call 神经网络打分 |
| 排序 | list.sort |
服务端已按分数排好 |
| 截断 | [:top_n] |
请求参数 top_n |
| 输出字段 | 自定义 score |
relevance_score + index |
流程相同,只是打分器从「数字符」换成了「交叉编码器」。
8. 接到 LangChain:粗召回 + 重排 #
真实项目里,候选文档通常来自检索器,而不是手写列表。这一节用:
- BM25Retriever:关键词粗召回(不需要向量库)
- DashScopeRerank:交叉编码器精排
- ContextualCompressionRetriever:把两者串成一个对外普通的
retriever
8.1. 流水线长什么样 #
用户问题
│
▼
BM25 粗召回(例如取 5 条)
│
▼
DashScopeRerank(精排后保留 top_n=3)
│
▼
最终 3 条 Document(metadata 里带 relevance_score)对外你只调用一次 retriever.invoke(问题),内部自动走完两段。
8.2. 一个必须绕开的坑:模型名被覆盖 #
LangChain 的 DashScopeRerank 在构造时有个校验器,会把你传入的模型名强行改回旧的 gte-rerank。而旧模型现在会返回 403,表现成很难懂的:
AttributeError: 'NoneType' object has no attribute 'results'正确写法:
- 先
DashScopeRerank(top_n=3)构造出来 - 再执行
reranker.model = "gte-rerank-v2"
原因:校验器只在创建对象时跑一遍;创建后再赋值,走普通属性路径,不会被改回去。
8.3. 完整可运行代码 #
保存为 langchain_rerank_demo.py 后运行:
# -*- coding: utf-8 -*-
# 指定 UTF-8,避免中文注释出问题
# 导入中文分词库:BM25 处理中文前必须先切词
import jieba
# 从 .env 加载 DASHSCOPE_API_KEY
from dotenv import load_dotenv
# 导入「压缩检索器」:负责把粗召回和重排串起来
from langchain_classic.retrievers import ContextualCompressionRetriever
# 导入通义重排的 LangChain 封装(本质是文档压缩器)
from langchain_community.document_compressors import DashScopeRerank
# 导入 BM25 检索器,用作粗召回
from langchain_community.retrievers import BM25Retriever
# 导入 Document 数据结构:page_content 存正文,metadata 存附加信息
from langchain_core.documents import Document
# 加载环境变量
load_dotenv()
# 准备一份迷你知识库:每条都带 chunk_id,方便对照名次
chunks = [
# 正确答案
Document(
page_content="签收 7 日内可无理由退货,商品需保持完好,包装与配件齐全。",
metadata={"chunk_id": "as-0001"},
),
# 换货干扰项
Document(
page_content="质量问题 15 日内可换货,需提供照片凭证并联系客服登记。",
metadata={"chunk_id": "as-0002"},
),
# 跨境退货运费干扰项
Document(
page_content="跨境订单以页面公示为准,退回运费由买家承担。",
metadata={"chunk_id": "as-0003"},
),
# 退款时效干扰项(最强干扰之一)
Document(
page_content="退款将在收到退货并检验合格后的 3 个工作日内原路退回。",
metadata={"chunk_id": "as-0004"},
),
# 发货时效
Document(
page_content="一般订单 48 小时内发出,节假日顺延。",
metadata={"chunk_id": "as-0005"},
),
# 预售发货干扰项
Document(
page_content="预售商品以商品页标注的发货时间为准,如遇缺货会主动联系并可全额退款。",
metadata={"chunk_id": "as-0006"},
),
]
# 定义中文分词函数:去掉空白 token,避免空字符串进入 BM25
def cn_tokenize(text: str) -> list[str]:
# jieba.lcut 把句子切成词列表
words = jieba.lcut(text)
# 用列表推导式过滤纯空白
return [w for w in words if w.strip()]
# 用全部语料构建 BM25 检索器
# k=5 表示粗召回最多返回 5 条
# preprocess_func 必须传入中文分词,否则默认按空格切,中文会整句当一个词
bm25 = BM25Retriever.from_documents(
chunks,
k=5,
preprocess_func=cn_tokenize,
)
# 构造重排器:top_n=3 表示精排后只留 3 条给下游
reranker = DashScopeRerank(top_n=3)
# 打印构造后的默认模型名,你会看到它被改成了旧的 gte-rerank
print("构造后的模型名:", reranker.model)
# 构造完成后再改模型名,绕开校验器的强制覆盖
reranker.model = "gte-rerank-v2"
# 再打印一次,确认已经改成可用模型
print("修正后的模型名:", reranker.model)
# 把「BM25 粗召回」和「重排压缩器」包装成一个检索器
retriever = ContextualCompressionRetriever(
# base_compressor:负责精排/压缩
base_compressor=reranker,
# base_retriever:负责先捞候选
base_retriever=bm25,
)
# 定义要测试的问题
question = "多少天内可以退货?"
# 先看「不做重排」时 BM25 的原始顺序,便于对比
print("\n【仅 BM25 粗召回】")
# 直接调用 BM25,得到未精排的候选
bm25_docs = bm25.invoke(question)
# 逐条打印 chunk_id 与正文
for i, doc in enumerate(bm25_docs, start=1):
# metadata 里的 chunk_id 是我们建语料时写入的
print(f" {i}. [{doc.metadata['chunk_id']}] {doc.page_content}")
# 再看「粗召回 + 重排」后的结果
print("\n【BM25 + 重排】")
# invoke 一次即可走完两段流水线
rerank_docs = retriever.invoke(question)
# 逐条打印重排分数、编号、正文
for i, doc in enumerate(rerank_docs, start=1):
# relevance_score 是重排器写入 metadata 的字段;普通 BM25 结果没有它
score = doc.metadata["relevance_score"]
# chunk_id 用于人工核对是不是正确答案 as-0001
chunk_id = doc.metadata["chunk_id"]
# 打印名次、分数、编号、正文
print(f" {i}. {score:.4f} [{chunk_id}] {doc.page_content}")8.4. 如何读输出 #
重点对比两段:
- 仅 BM25:正确答案
as-0001可能不在第 1(甚至较靠后) - BM25 + 重排:
as-0001应升到第 1,且relevance_score明显高于第 2、第 3
示意:
构造后的模型名: gte-rerank
修正后的模型名: gte-rerank-v2
【仅 BM25 粗召回】
1. [as-0004] 退款将在……
2. [as-0002] 质量问题 15 日内……
3. [as-0001] 签收 7 日内……
【BM25 + 重排】
1. 0.32xx [as-0001] 签收 7 日内可无理由退货……
2. 0.19xx [as-0004] 退款将在……
3. 0.17xx [as-0006] 预售商品……这就是重排的价值:同样的候选集合,换一种「会读内容」的打分方式,名次被纠正了。
8.5. 边界行为 #
| 情况 | 实际行为 |
|---|---|
top_n 大于候选条数 |
有多少返回多少,不报错、不补齐 |
| 粗召回得到 0 条 | 直接返回空列表,不会调用重排 API(省钱) |
| 同一问题连问多次 | 排名通常稳定,分数小数末位可能微抖 |
9. 重排之后,分数还能做什么 #
很多人第一反应是:既然有了 relevance_score,那就设个阈值,低于 0.2 就拒答。
9.1. 为什么不建议用重排分做拒答阈值 #
重排分数回答的是:
在当前这批候选里,谁相对更相关?
它不保证跨问题可比。常见现象:
- 库里没有答案的问题,top-1 分数也可能不低
- 库里有答案的问题,top-1 分数也可能偏低
- 不同问题的「高分」不在同一水平线上
所以:
- 排序:信任重排名次 ✅
- 拒答:交给生成模型根据资料判断,或另做专门策略 ✅
- 用固定阈值卡
relevance_score:开始阶段不建议 ❌
9.2. 重排分适合干什么 #
更合适的用途:
- 展示给开发者看:调试时打印分数,观察区分度
- 截断条数:已经通过
top_n完成 - 人工抽检:分数接近时,人工看是不是同族干扰项太多
记住口诀:
重排分用来排序,不用来判生死。
10. 开始常见坑 #
这一节集中列出自学时最容易踩的雷,建议收藏。
10.1. 坑:指望重排拯救糟糕的召回 #
如果正确答案根本没进候选集,重排只能把「一堆错的」排得整整齐齐。
检查顺序应是:
- 正确答案是否在粗召回结果里?
- 在的话,重排有没有把它顶到前面?
- 都不对,再查语料切分、同义词、权限过滤等问题
10.2. 坑:把召回分和重排分直接比较 #
两套分数量纲不同。重排完成后,下游只看重排后的顺序和 relevance_score。
10.3. 坑:模型名写成旧的 gte-rerank #
旧模型会 403。用 LangChain 封装时务必:
# 先构造重排器,只关心 top_n
reranker = DashScopeRerank(top_n=3)
# 再强制改成仍在服务的模型名
reranker.model = "gte-rerank-v2"10.4. 坑:对中文 BM25 忘记分词 #
BM25 默认常按空格切词。中文句子没有空格时,整句会被当成一个词,所有文档得分可能变成 0,而且常常不报错。
本文里用 jieba.lcut 就是为了避开这个静默失败。
10.5. 坑:把重排分数当成精确常量 #
不要写:
# 错误示范:把某一次调用的分数当成永恒真理
if score == 0.3256:
pass小数末位有噪声。展示时保留 2~4 位即可。
10.6. 坑:候选太多导致又慢又贵 #
交叉编码器要对每个候选跑一遍。实践里常见配置:
- 粗召回:10~50 条(本文演示用 5 条)
- 重排保留:3~5 条送给大模型
开始先从「召回 5~10、重排留 3」开始,够用了。
11. 小结 #
把整篇压成几句:
- 召回求不漏,重排求排对。
- 双塔适合海选,交叉编码器适合决赛。
- 重排不能扫全库,只能精修候选。
- 通义侧优先用
gte-rerank-v2;LangChain 封装要构造后再改model。 - 重排分用来排序,不适合单独当拒答阈值。
- 候选里没有正确答案时,先修召回,再谈重排。
推荐的开始默认组合:
BM25(或 向量 / 混合)粗召回
+
gte-rerank-v2 精排 top_n=3
+
大模型基于这 3 条生成答案