重排序 Rerank------粗召回之后,为什么还需要一次精排
前言
RAG 的检索不是一个动作,而是两段接力:
用户 query → 粗召回(快、宽)→ 精排 Rerank(准、严)→ 前几名喂给 LLM
第一段粗召回 负责"别漏",从几十万条文档里捞出 Top-50/100 个候选,主力是向量检索(也可以再加 BM25 混合);第二段精排 负责"别错",把候选重新排一遍,只留最相关的 Top-5/10,主力就是 Rerank(重排序)。
很多人只做了第一段:向量检索出 Top-K 就直接丢给 LLM。结果经常是"看起来相关,其实排在前面的不是最相关的"------这正是 Rerank 要解决的问题。
本文讲透:
- 为什么向量检索的 Top-K 不够准
- Bi-Encoder 和 Cross-Encoder 的本质区别
- 主流 Rerank 模型(BGE-Reranker、Cohere Rerank)和工业实践
- 用 Go 实现"粗召回 + Rerank 精排"两阶段检索
- 真实对比:加 Rerank 前后,命中率差多少
一、为什么需要 Rerank:两阶段检索范式
1.1 召回要"宽",排序要"准"
这是检索系统的两条互相矛盾的目标:
- 召回(Recall) :相关的文档尽量都捞出来。要求宽,宁滥勿缺。
- 精度(Precision) :捞出来的尽量都相关。要求准,宁缺勿滥。
向量检索擅长前者------它能用一次向量距离计算,把语义相近的文档批量捞出来。但它的"精"是有限的,因为向量检索只做了一次粗粒度的相似度比较。让它直接决定 Top-1,风险很大。
1.2 两阶段流程
┌─────────────────┐ ┌──────────────────┐ ┌─────────┐
│ 粗召回 Recall │ │ 精排 Rerank │ │ LLM │
│ 向量检索 Top-K │ ───► │ Cross-Encoder │ ───► │ 生成回答 │
│ (快,候选集大) │ │ 逐条打分 Top-N │ │ │
└─────────────────┘ └──────────────────┘ └─────────┘
- 粗召回:全库 → 候选 Top-K(K 通常取 50~200)
- 精排:对候选 Top-K 逐条做更精细的打分 → 取 Top-N(N 通常取 5~10)
1.3 向量检索 Top-K 的局限:独立编码,看不到交互
向量检索(Bi-Encoder 结构)的问题在于:query 和文档是分开编码的 ,各自变成一个向量,然后只算一个余弦距离。它看不到"query 里的某个词,在文档里到底有没有出现、出现在哪"这种词级交互。
我们用"中国载人登月"知识库(8 篇文档)做一个真实例子。用户问:
谁在月球表面做科学实验
纯向量粗召回的 Top3:
粗召回 Top3:工程意义 飞行数据 科研站与深空
正确答案"月面实验"(文档:航天员将在登月过程中进行科学实验,并测试月面通信和导航系统 )根本没进 Top3------它的语义相似度只有 0.289,排在全库第 4、5 名,差点被丢弃。
为什么?因为 query 里"月球"这个常见词太强,和"工程意义""科研站"这些也含"月球"的文档相似度高;而"科学实验"在 query 里只有一个词,向量上拉不动。
这种"词级信号被语义相似度淹没"的情况,在真实场景非常常见,尤其涉及专有名词、实体、精确词的时候。
二、Bi-Encoder vs Cross-Encoder
要理解 Rerank 为什么更准,必须先分清这两类模型。
2.1 Bi-Encoder:分开编码,适合召回
query ──► 编码器 ──► 向量 A
doc ──► 编码器 ──► 向量 B
相似度 = cosine(A, B)
- query 和 doc 各自独立过一遍编码器
- 因为互不干扰,doc 的向量可以离线预计算、存进向量数据库
- 查询时只需要算一次 query 向量,然后批量算距离 → 非常快,能扛百万级
- 代价:看不到 query 和 doc 的词级交互,精度天花板有限
代表作:BGE-M3、text-embedding-3、开源的 sentence-transformers 系列。
2.2 Cross-Encoder:拼接编码,适合精排
[CLS] query [SEP] doc [SEP] ──► 编码器 ──► 相关性分数
- query 和 doc 拼成一段文本一起过编码器
- 模型能看到 query 里的每个词和 doc 里的每个词如何互相影响(注意力机制)→ 打分更精细、更准
- 代价:每条 query-doc 组合都要单独过一遍模型 ,无法预计算 → 慢
- 如果全库 100 万条都跑 Cross-Encoder,根本跑不动
代表作:BGE-Reranker、Cohere Rerank、cross-encoder 系列。
2.3 对比
| 维度 | Bi-Encoder | Cross-Encoder |
|---|---|---|
| 编码方式 | query 和 doc 分开编码 | query 和 doc 拼接编码 |
| 词级交互 | 看不到 | 看得到(注意力) |
| 精度 | 中等 | 更高 |
| 速度 | 快(可预计算) | 慢(逐条打分) |
| 适合阶段 | 粗召回 | 精排 Rerank |
结论一句话:Bi-Encoder 负责"快和宽",Cross-Encoder 负责"准"。两个一起用,就是两阶段检索。
2.4 为什么 Cross-Encoder 能"救回"被漏掉的文档
回到 1.3 的例子。query"谁在月球表面做科学实验",正确答案"月面实验"文档的语义相似度只有 0.289(排在最后几名)。
但 Cross-Encoder 拼接后能直接看到:query 里的"科学实验"在文档里原样出现 、"月球"也在文档里出现------这种词级精确交互是非常强的相关信号,足以把"月面实验"顶到最前面。
这就是 Rerank 的核心价值:用更精细的交互信号,纠正粗召回在排序上的偏差。
三、主流 Rerank 模型与工程实践
3.1 BGE-Reranker(智源,最常用)
- 开源、中文效果好:
bge-reranker-base、bge-reranker-v2-m3 - 输出 query-doc 的相关性分数,可排序
- 支持通过 Ollama、FlagEmbedding、HuggingFace 部署
3.2 Cohere Rerank
- 商业 API,
POST /v1/rerank,输入query+documents列表,返回每个文档的relevance_score - 使用简单,但按量计费、数据出境需评估
3.3 工业实践基线
向量/混合检索 Top-100(粗召回,毫秒级)
│
Rerank 模型 对 100 个候选逐条打分(百毫秒级)
│
取 Top-5 ──► 拼 Prompt 喂给 LLM
K 和 N 的经验值:召回取 50~200,Rerank 后取 5~10。K 太小可能把正确答案漏掉,K 太大 Rerank 变慢。
3.4 Go 里怎么接入真实 Rerank 模型
真实生产不用自己写打分逻辑,直接调服务。以 HTTP API 为例(示意):
go
// 调用本机 Ollama 部署的 bge-reranker(示意,需真实服务)
func ollamaRerank(baseURL, query string, docs []string) ([]float64, error) {
body, _ := json.Marshal(map[string]any{
"model": "bge-reranker-v2-m3",
"query": query,
"documents": docs,
})
resp, err := http.Post(baseURL+"/api/rerank", "application/json",
strings.NewReader(string(body)))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out struct {
Results []struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
scores := make([]float64, len(docs))
for _, r := range out.Results {
scores[r.Index] = r.RelevanceScore
}
return scores, nil
}
说明:上面的代码需要真实部署的 Rerank 服务才能运行。接下来第五节的可运行实验,用 Go 自己实现一个"Cross-Encoder 风格"的精排打分器(语义 + 词级交互两个可解释信号),让你在本地就能完整跑通两阶段检索、亲眼看到 Rerank 的效果。真实场景中,把打分函数替换成
ollamaRerank这类调用即可。
四、Go 实现:两阶段检索
4.1 粗召回:向量检索器(Bi-Encoder 类比)
go
type VectorRetriever struct {
vecs []map[string]int
names []string
}
func NewVectorRetriever(docs, names []string) *VectorRetriever {
vr := &VectorRetriever{names: names}
for _, d := range docs {
vr.vecs = append(vr.vecs, tokenize(d))
}
return vr
}
func cosineSim(a, b map[string]int) float64 {
dot, na, nb := 0.0, 0.0, 0.0
for k, v := range a {
if bv, ok := b[k]; ok {
dot += float64(v * bv)
}
na += float64(v * v)
}
for _, v := range b {
nb += float64(v * v)
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// Retrieve 返回按相似度降序的全量排名(分数相同按 id 升序,保证可复现)
func (vr *VectorRetriever) Retrieve(query string) []int {
q := tokenize(expandQuery(query))
type item struct {
id int
sim float64
}
var items []item
for i, vec := range vr.vecs {
items = append(items, item{i, cosineSim(q, vec)})
}
sort.SliceStable(items, func(a, b int) bool {
if items[a].sim != items[b].sim {
return items[a].sim > items[b].sim
}
return items[a].id < items[b].id
})
res := make([]int, 0, len(items))
for _, it := range items {
res = append(res, it.id)
}
return res
}
4.2 精排:Cross-Encoder 风格 Reranker
go
type Reranker struct {
vecs []map[string]int
idf map[string]float64
N int
names []string
}
func NewReranker(docs, names []string) *Reranker {
r := &Reranker{idf: map[string]float64{}, N: len(docs), names: names}
df := map[string]int{}
for _, d := range docs {
for w := range tokenize(d) {
df[w]++
}
}
for w, n := range df {
r.idf[w] = math.Log((float64(r.N)-float64(n)+0.5)/(float64(n)+0.5) + 1)
}
for _, d := range docs {
r.vecs = append(r.vecs, tokenize(d))
}
return r
}
// lexicalHit:query 词在 doc 的 IDF 加权命中率 ∈ [0,1]
// 模拟 Cross-Encoder 能直接看到的"词级交互":稀有词/专有名词命中权重更大
func (r *Reranker) lexicalHit(q map[string]int, docID int) float64 {
d := r.vecs[docID]
hit, total := 0.0, 0.0
for w := range q {
idf := r.idf[w]
if idf == 0 {
continue
}
total += idf
if _, ok := d[w]; ok {
hit += idf
}
}
if total == 0 {
return 0
}
return hit / total
}
// Score:Cross-Encoder 风格交互打分 = 语义 + 词级精确交互
func (r *Reranker) Score(query string, docID int) float64 {
q := tokenize(expandQuery(query))
sem := cosineSim(q, r.vecs[docID])
lex := r.lexicalHit(q, docID)
return 0.5*sem + 0.5*lex
}
// Rerank 对候选集逐条打分,返回重排后的前 n(分数相同按 id 升序)
func (r *Reranker) Rerank(query string, candidates []int, n int) []int {
type item struct {
id int
score float64
}
var items []item
for _, id := range candidates {
items = append(items, item{id, r.Score(query, id)})
}
sort.SliceStable(items, func(a, b int) bool {
if items[a].score != items[b].score {
return items[a].score > items[b].score
}
return items[a].id < items[b].id
})
res := make([]int, 0, n)
for i := 0; i < n && i < len(items); i++ {
res = append(res, items[i].id)
}
return res
}
打分设计说明:真实 Cross-Encoder 用 BERT 类模型对
[query, doc]拼接文本做注意力交互,直接输出相关性分数。上面用两个可解释信号近似其行为:语义余弦 (粗粒度)+ IDF 加权词级命中(细粒度,稀有词/专有名词命中权重更大------这正是 Cross-Encoder 能看到、Bi-Encoder 看不到的部分)。0.5/0.5 是简单可调权重。
4.3 完整可运行代码
把词典分词、同义扩充(模拟 embedding 的语义泛化)、向量检索器、Reranker、main 入口拼在一起(go run main.go 直接运行):
go
package main
import (
"fmt"
"math"
"sort"
"strings"
)
// ==================== 词典分词(含数字/实体词元) ====================
var vocab = []string{
"载人登月", "月球", "任务", "航天员", "火箭", "飞船", "测试", "系统", "飞行",
"着陆器", "长征十号", "梦舟", "科研站", "通信", "导航", "科学实验", "深空", "首次",
"2030", "13天", "76万公里",
}
func tokenize(text string) map[string]int {
m := map[string]int{}
for _, w := range vocab {
if c := strings.Count(text, w); c > 0 {
m[w] = c
}
}
return m
}
// synonyms 模拟 embedding 能理解的近义/改写关系
var synonyms = map[string]string{
"宇航员": "航天员",
"登月舱": "着陆器",
"运载火箭": "火箭",
"返回舱": "飞船",
"探测": "测试",
}
func expandQuery(q string) string {
for src, dst := range synonyms {
q = strings.ReplaceAll(q, src, dst)
}
return q
}
// ==================== 向量检索器(Bi-Encoder 类比:粗召回) ====================
type VectorRetriever struct {
vecs []map[string]int
names []string
}
func NewVectorRetriever(docs, names []string) *VectorRetriever {
vr := &VectorRetriever{names: names}
for _, d := range docs {
vr.vecs = append(vr.vecs, tokenize(d))
}
return vr
}
func cosineSim(a, b map[string]int) float64 {
dot, na, nb := 0.0, 0.0, 0.0
for k, v := range a {
if bv, ok := b[k]; ok {
dot += float64(v * bv)
}
na += float64(v * v)
}
for _, v := range b {
nb += float64(v * v)
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
func (vr *VectorRetriever) Retrieve(query string) []int {
q := tokenize(expandQuery(query))
type item struct {
id int
sim float64
}
var items []item
for i, vec := range vr.vecs {
items = append(items, item{i, cosineSim(q, vec)})
}
sort.SliceStable(items, func(a, b int) bool {
if items[a].sim != items[b].sim {
return items[a].sim > items[b].sim
}
return items[a].id < items[b].id
})
res := make([]int, 0, len(items))
for _, it := range items {
res = append(res, it.id)
}
return res
}
// ==================== Reranker(Cross-Encoder 类比:精排) ====================
type Reranker struct {
vecs []map[string]int
idf map[string]float64
N int
names []string
}
func NewReranker(docs, names []string) *Reranker {
r := &Reranker{idf: map[string]float64{}, N: len(docs), names: names}
df := map[string]int{}
for _, d := range docs {
for w := range tokenize(d) {
df[w]++
}
}
for w, n := range df {
r.idf[w] = math.Log((float64(r.N)-float64(n)+0.5)/(float64(n)+0.5) + 1)
}
for _, d := range docs {
r.vecs = append(r.vecs, tokenize(d))
}
return r
}
func (r *Reranker) lexicalHit(q map[string]int, docID int) float64 {
d := r.vecs[docID]
hit, total := 0.0, 0.0
for w := range q {
idf := r.idf[w]
if idf == 0 {
continue
}
total += idf
if _, ok := d[w]; ok {
hit += idf
}
}
if total == 0 {
return 0
}
return hit / total
}
func (r *Reranker) Score(query string, docID int) float64 {
q := tokenize(expandQuery(query))
sem := cosineSim(q, r.vecs[docID])
lex := r.lexicalHit(q, docID)
return 0.5*sem + 0.5*lex
}
func (r *Reranker) Rerank(query string, candidates []int, n int) []int {
type item struct {
id int
score float64
}
var items []item
for _, id := range candidates {
items = append(items, item{id, r.Score(query, id)})
}
sort.SliceStable(items, func(a, b int) bool {
if items[a].score != items[b].score {
return items[a].score > items[b].score
}
return items[a].id < items[b].id
})
res := make([]int, 0, n)
for i := 0; i < n && i < len(items); i++ {
res = append(res, items[i].id)
}
return res
}
func contains(s []int, v int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
func fmtRank(ids []int, names []string, want int) string {
var sb strings.Builder
for _, id := range ids {
mark := ""
if id == want {
mark = "✓"
}
sb.WriteString(fmt.Sprintf("%s%s ", names[id], mark))
}
return sb.String()
}
func main() {
docs := []string{
"中国载人登月是中国载人航天工程的重要一步,标志着中国人将首次登陆月球",
"该任务将使用长征十号运载火箭和梦舟载人飞船,搭载航天员进行登月飞行",
"载人登月任务的主要目标是验证生命支持系统和航天员在月球表面的生存能力",
"载人登月任务计划在2030年前执行,将实现中国人首次登月",
"此次飞行将为后续的月球科研站建设提供关键数据和技术验证",
"航天员将在登月过程中进行科学实验,并测试月面通信和导航系统",
"任务将持续约13天,往返距离将超过76万公里",
"中国载人登月的成功将为建立月球科研站和未来深空探索奠定基础",
}
names := []string{
"工程意义", "运载与飞船", "生命支持", "执行时间", "飞行数据", "月面实验", "任务周期", "科研站与深空",
}
vr := NewVectorRetriever(docs, names)
rr := NewReranker(docs, names)
K := 5 // 粗召回候选数
fmt.Println("========== 两阶段检索对比(粗召回 → Rerank 精排) ==========")
cases := []struct {
q string
desc string
want int
}{
{"用长征十号火箭执行载人登月任务", "专有名词", 1},
{"宇航员在月球表面做什么", "近义改写", 2},
{"载人登月任务什么时候执行", "常见词", 3},
{"月球科研站有什么用", "组合词", 4},
{"任务持续多长时间", "常见词", 6},
{"使用什么载人飞船", "专有名词", 1},
{"如何验证生命支持系统", "常见词", 2},
{"此次飞行对后续有什么作用", "组合词", 4},
{"梦舟飞船由谁使用", "专有名词", 1},
{"谁在月球表面做科学实验", "组合词", 5},
{"2030年前执行什么任务", "数字", 3},
{"76万公里往返属于哪个任务", "数字", 6},
}
recallHit1, recallHit3 := 0, 0
rerankHit1, rerankHit3 := 0, 0
for _, c := range cases {
full := vr.Retrieve(c.q) // 粗召回:向量全排
cand := full[:K] // 取 Top-K 作为候选集
reranked := rr.Rerank(c.q, cand, 3) // 精排:只对候选集重排,取 Top-3
recallTop1, recallTop3 := full[0] == c.want, contains(full[:3], c.want)
rerankTop1, rerankTop3 := reranked[0] == c.want, contains(reranked, c.want)
fmt.Printf("\n查询:%s(%s)期望[%s]\n", c.q, c.desc, names[c.want])
fmt.Printf(" 粗召回 Top3:%s\n", fmtRank(full[:3], names, c.want))
fmt.Printf(" Rerank Top3:%s\n", fmtRank(reranked, names, c.want))
if recallTop1 {
recallHit1++
}
if recallTop3 {
recallHit3++
}
if rerankTop1 {
rerankHit1++
}
if rerankTop3 {
rerankHit3++
}
}
fmt.Println("\n========== 命中率汇总 ==========")
fmt.Printf(" 粗召回(向量Top3) Top1=%d/12 Top3=%d/12\n", recallHit1, recallHit3)
fmt.Printf(" Rerank精排后 Top1=%d/12 Top3=%d/12\n", rerankHit1, rerankHit3)
// 展示两个代表性查询的 Rerank 打分明细
fmt.Println("\n========== 打分明细 1(查询:用长征十号火箭执行载人登月任务) ==========")
q0 := "用长征十号火箭执行载人登月任务"
q0t := tokenize(expandQuery(q0))
for _, id := range vr.Retrieve(q0)[:5] {
fmt.Printf(" 候选[%-6s] 语义=%.3f 词法交互=%.3f 融合分=%.3f\n",
names[id], cosineSim(q0t, vr.vecs[id]), rr.lexicalHit(q0t, id), rr.Score(q0, id))
}
fmt.Println("\n========== 打分明细 2(查询:谁在月球表面做科学实验) ==========")
q1 := "谁在月球表面做科学实验"
q1t := tokenize(expandQuery(q1))
for _, id := range vr.Retrieve(q1)[:5] {
fmt.Printf(" 候选[%-6s] 语义=%.3f 词法交互=%.3f 融合分=%.3f\n",
names[id], cosineSim(q1t, vr.vecs[id]), rr.lexicalHit(q1t, id), rr.Score(q1, id))
}
}
运行 go run main.go,输出如下(节选关键部分):
========== 两阶段检索对比(粗召回 → Rerank 精排) ==========
查询:谁在月球表面做科学实验(组合词)期望[月面实验]
粗召回 Top3:工程意义 飞行数据 科研站与深空
Rerank Top3:月面实验✓ 工程意义 飞行数据
...(其余查询结果略)
========== 命中率汇总 ==========
粗召回(向量Top3) Top1=11/12 Top3=11/12
Rerank精排后 Top1=12/12 Top3=12/12
五、对比实验:加 Rerank 前后
5.1 实验设置
- 知识库:8 篇"中国载人登月"文档
- 查询:12 个,覆盖五类(专有名词、近义改写、常见词、组合词、数字)
- 粗召回:纯向量 Top-5 作为候选(模拟 Bi-Encoder 召回)
- 精排:Reranker 对候选逐条打分,取 Top-3(模拟 Cross-Encoder)
5.2 汇总结果
| 阶段 | Top1 命中 | Top3 命中 |
|---|---|---|
| 粗召回(向量 Top3) | 11/12 | 11/12 |
| Rerank 精排后 | 12/12 | 12/12 |
5.3 高光案例:正确答案从"被丢弃"到 Top1
查询"谁在月球表面做科学实验":
| 阶段 | Top1 | 说明 |
|---|---|---|
| 粗召回 | 工程意义 | 正确答案"月面实验"排在第 4/5 名,不在 Top3 |
| Rerank | 月面实验 | 词法交互把它顶回第 1 |
看打分明细,原因一目了然:
候选[月面实验] 语义=0.289 词法交互=0.721 融合分=0.505 ← 反超
候选[工程意义] 语义=0.408 词法交互=0.279 融合分=0.344
候选[飞行数据] 语义=0.408 词法交互=0.279 融合分=0.344
"月面实验"的语义相似度最低(0.289,所以粗召回排最后),但它和 query 的词级交互最强(0.721)------"科学实验""月球"这两个 query 词在文档里都原样命中。Rerank 融合后反超所有候选,这就是 Cross-Encoder 式的"交互信号"在起作用。
5.4 另一个细节:Rerank 保持正确,不引入新错误
再看专有名词查询"用长征十号火箭执行载人登月任务"的打分:
候选[运载与飞船] 语义=0.567 词法交互=0.861 融合分=0.714
候选[执行时间] 语义=0.500 词法交互=0.279 融合分=0.389
候选[生命支持] 语义=0.447 词法交互=0.279 融合分=0.363
粗召回本身排得对,Rerank 没有打乱它,反而让正确文档的领先优势更明显(0.714 vs 0.389)。好的 Rerank 不只是纠正错误,还要不破坏原本正确的结果。
5.5 三个结论
- Rerank 的核心价值是"救回":正确答案语义相似度不高时(0.289),粗召回会把它排在后面甚至丢弃,Rerank 靠词级交互把它顶回 Top1。
- Rerank 不引入新错误:对原本排对的查询(11/12),它保持正确并拉大优势。
- 诚实说明 :本文是小库(8 篇),粗召回本身已经很准(11/12),Rerank 的净提升只有 1 个案例。在真实大规模场景(粗召回 Top-100 噪声大、候选多)Rerank 的提升会显著得多------这正是一般 RAG 系统把 Rerank 作为标配的原因。代价是延迟:Cross-Encoder 要逐条打分,所以只对候选集做,K 取 50~200 是速度与精度的平衡点。
六、核心要点
- 两阶段检索:粗召回(Bi-Encoder,快、宽)→ Rerank 精排(Cross-Encoder,准、严)
- Bi-Encoder 分开编码、可预计算、适合召回;Cross-Encoder 拼接编码、能看到词级交互、适合精排
- Rerank 的价值:纠正粗召回在专有名词/实体/组合查询上的排序偏差,"救回"被漏到后面的正确答案
- 主流模型:BGE-Reranker(开源中文友好)、Cohere Rerank(商业 API);Go 里通过 HTTP 调用
- 参数经验值:召回 K 取 50~200,Rerank 后取 Top-5~10
- 代价:Cross-Encoder 逐条打分慢,只对候选集做;真实大库中提升显著,小库中多为边际修正