RAG 实战(四):Rerank 重排序

目录


一、粗排与精排的分工

上一篇结束时,两路检索已经融合成了一个还算靠谱的候选集。但候选集里仍然混着不少「相关但不解决问题」的东西。

先看一个具体的失败样本。用户问「入职 3 年能休多少天年假」,粗排交回 Top 5:

[1] "员工入职满一年后,可享受带薪年假10天。"          ✓ 直接回答
[2] "年假需提前3天申请,经直属领导批准。"              ✗ 相关,但不回答问题
[3] "入职满五年后,年假增至15天。"                    ✓ 部分回答
[4] "年假不可跨年累积,未使用的年假将清零。"           ✗ 相关,但不回答问题
[5] "病假需提供医院证明,3天以内由直属领导批准。"      ✗ 不相关

注意 [2] 和 [4]:它们确实是讲年假的,词面上和查询高度重叠(都出现了「年假」「3」),所以 BM25 给高分完全合理。问题在于「相关」和「能回答这个问题」是两件事。 检索器的目标函数是「这段文字和查询像不像」,而不是「这段文字能不能回答这个问题」——后者需要理解查询的意图,也就是「3 年」对应的是哪条规则。

这活儿得换个更贵的方式来做。招聘能类比得很贴切:

  • 粗排:HR 拿 1000 份简历按硬条件筛(有 Java、3 年经验、本科),几秒钟筛出 50 份。快,但粗糙。
  • 精排:HR 逐份细读这 50 份,看项目经历和技术栈是否真的匹配。慢,但能把最合适的 3 份挑出来。

没有人会一上来就细读 1000 份简历。 两阶段的真正意义在于:把昂贵但精准的判断,限制在一个足够小的候选集上。

graph LR
    Q["用户查询"] --> C["粗排<br/>BM25 + 向量 + RRF"]
    C -->|"Top 20<br/>快,保召回"| R["精排<br/>Cross-Encoder"]
    R -->|"Top 3<br/>慢,保准确"| F["最终结果"]

    style C fill:#e6f3ff
    style R fill:#e6ffe6

二、Bi-Encoder 与 Cross-Encoder

这是整篇最值得花时间理解的一节,因为它解释的不是「Rerank 好用」,而是为什么粗排阶段不能用更好的模型。

2.1 双塔(Bi-Encoder)

查询和文档各自过同一个编码器,得到两个向量,然后用余弦相似度比较:

Query → Encoder → Query 向量 ┐
                              ├→ 相似度 = cosine(Query 向量, Doc 向量)
Doc   → Encoder → Doc 向量   ┘

关键在于两者是分别编码的,编码时谁也不知道对方的存在。所以文档向量可以离线预先算好、存进向量库——这是它能在一百万篇文档上秒级检索的全部原因。

代价是:查询和文档之间的细粒度交互(哪个词对应哪个词、条件是否匹配)永远没有机会被模型看到。两个向量比完之后才知道结果,太晚了。

2.2 交叉编码器(Cross-Encoder)

把查询和文档拼成一个序列,一起送进模型,直接输出一个相关性分数:

[Query, Doc] → Encoder → 相关性分数

因为两者在同一个注意力计算里,模型能直接建模「这个查询的哪个部分」和「这个文档的哪个部分」之间的对应关系。前面那个「3 年」匹配不到「满一年」的问题,在这里就有机会被纠正——模型能同时看到两边,从而理解「满一年」是「3 年」的条件。

代价是致命的:分数无法预计算。 每一对(查询,文档)都要跑一次完整的前向计算。文档有 100 万篇,就得为这一次查询跑 100 万次模型推理。

一句话总结这个结构性差异:

Bi-EncoderCross-Encoder
编码方式查询、文档各自独立编码拼接后一起编码
能否预计算能,文档向量离线算好不能,必须查询时现算
单次查询代价1 次编码 + N 次向量点积N 次完整模型推理
精度较低明显更高
适合的位置粗排,全库检索精排,小候选集

这份表格也直接回答了一个常见追问:为什么不全用 Cross-Encoder? 因为复杂度从「N 次点积」变成了「N 次推理」,在 N 等于全库规模时不成立。粗排-精排不是效果上的妥协,而是代价结构决定的必然分工。


三、三种 Rerank 方案

方案原理优点缺点
托管 Rerank API调用 Cohere 等厂商的 rerank 接口无需 GPU,开箱即用,效果稳按调用量付费,数据出网
本地 Cross-Encoder自己部署 BGE-Reranker 等模型免费、数据不出内网、可定制微调需要 GPU 或接受 CPU 上的延迟
LLM Rerank用现成的大模型判断相关性无需额外模型,能理解复杂意图单次调用成本高,延迟大,大批量排序不划算

成本量级值得心里有数:托管 API 按「每千次查询」计费,本地模型是一次性硬件投入换固定延迟,LLM Rerank 则是每个候选文档一次大模型调用——用它排 20 个候选,等于把整个 RAG 的大模型开销乘以 20。所以 LLM Rerank 的合理位置是:候选很少(比如 5 条以内),或者需要判断「文档能否回答问题」这类 Cross-Encoder 也不擅长的语义任务。

这一篇先落地托管 API(Cohere),代码里同时保留 LLM Rerank 作为可切换的备选分支。


四、完整代码

4.1 添加依赖

pom.xml

<!-- OkHttp(用于调用 Cohere API) -->
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.12.0</version>
</dependency>

4.2 RerankService

RerankService.java

package com.rag.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.rag.model.Document;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

/**
 * Rerank 重排序服务
 *
 * 职责:对粗排结果进行精排,提高最终结果的相关性
 *
 * 类比:论文审稿人的"二次评审"
 * - 第一轮评审(粗排):快速筛选出可能相关的论文
 * - 第二轮评审(精排):仔细阅读每篇论文,给出精确评分
 */
@Slf4j
@Service
public class RerankService {

    @Value("${rag.rerank.enabled:false}")
    private boolean rerankEnabled;

    @Value("${rag.rerank.provider:cohere}")
    private String rerankProvider;

    @Value("${rag.rerank.cohere.api-key:}")
    private String cohereApiKey;

    @Value("${rag.rerank.cohere.model:rerank-v3.5}")
    private String cohereModel;

    @Value("${rag.rerank.top-n:3}")
    private int rerankTopN;

    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 对文档列表进行重排序
     *
     * @param query   用户查询
     * @param documents 粗排结果
     * @return 精排后的文档列表
     */
    public List<Document> rerank(String query, List<Document> documents) {
        if (!rerankEnabled) {
            log.info("Rerank 未启用,返回原始结果");
            return documents.subList(0, Math.min(rerankTopN, documents.size()));
        }

        if (documents.isEmpty()) {
            return documents;
        }

        log.info("开始 Rerank,查询: {}, 文档数量: {}", query, documents.size());

        switch (rerankProvider.toLowerCase()) {
            case "cohere":
                return rerankWithCohere(query, documents);
            case "llm":
                return rerankWithLlm(query, documents);
            default:
                log.warn("未知的 Rerank 提供商: {}, 使用默认排序", rerankProvider);
                return documents.subList(0, Math.min(rerankTopN, documents.size()));
        }
    }

    /**
     * 使用 Cohere API 进行重排序
     *
     * Cohere Rerank API 文档:
     * https://docs.cohere.com/docs/reranking
     */
    private List<Document> rerankWithCohere(String query, List<Document> documents) {
        try {
            // 构建请求体
            Map<String, Object> requestBody = new HashMap<>();
            requestBody.put("model", cohereModel);
            requestBody.put("query", query);
            requestBody.put("documents", documents.stream()
                    .map(Document::getContent)
                    .collect(Collectors.toList()));
            requestBody.put("top_n", rerankTopN);

            // 调用 Cohere API
            String responseJson = callCohereApi(requestBody);

            // 解析响应
            CohereResponse response = objectMapper.readValue(responseJson, CohereResponse.class);

            // 根据 Rerank 分数重新排序文档
            List<Document> rerankedDocs = new ArrayList<>();
            for (CohereResult result : response.getResults()) {
                int index = result.getIndex();
                if (index >= 0 && index < documents.size()) {
                    Document doc = documents.get(index);
                    // 可以把 Rerank 分数存到 metadata 里
                    doc.getMetadata().put("rerank_score", result.getRelevanceScore());
                    rerankedDocs.add(doc);
                }
            }

            log.info("Cohere Rerank 完成,返回 {} 个文档", rerankedDocs.size());
            return rerankedDocs;

        } catch (Exception e) {
            log.error("Cohere Rerank 失败", e);
            // 失败时返回原始结果
            return documents.subList(0, Math.min(rerankTopN, documents.size()));
        }
    }

    /**
     * 调用 Cohere API
     */
    private String callCohereApi(Map<String, Object> requestBody) throws Exception {
        okhttp3.OkHttpClient client = new okhttp3.OkHttpClient();

        String json = objectMapper.writeValueAsString(requestBody);

        okhttp3.Request request = new okhttp3.Request.Builder()
                .url("https://api.cohere.ai/v1/rerank")
                .addHeader("Authorization", "Bearer " + cohereApiKey)
                .addHeader("Content-Type", "application/json")
                .post(okhttp3.RequestBody.create(json, okhttp3.MediaType.parse("application/json")))
                .build();

        try (okhttp3.Response response = client.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new RuntimeException("Cohere API 调用失败: " + response.code());
            }
            return response.body().string();
        }
    }

    /**
     * 使用 LLM 进行重排序(备选方案)
     *
     * 思路:让 LLM 判断每个文档和查询的相关性,给出分数
     *
     * 优点:不需要额外的 API
     * 缺点:成本高,速度慢
     */
    private List<Document> rerankWithLlm(String query, List<Document> documents) {
        // 这里简化实现,实际可以调用 LLM
        // 提示:可以让 LLM 对每个文档打分(1-10分),然后按分数排序

        log.info("LLM Rerank 暂未实现,返回原始结果");
        return documents.subList(0, Math.min(rerankTopN, documents.size()));
    }

    // ========== Cohere API 响应类 ==========

    @Data
    public static class CohereResponse {
        private List<CohereResult> results;
    }

    @Data
    public static class CohereResult {
        private int index;
        private double relevanceScore;
    }
}

几个设计上值得留意的地方:

降级是显式设计的,不是碰巧。 整条链路被 try-catch 包住,任何异常(API 超时、Key 失效、额度耗尽)都会退回到「取粗排的前 N 条」。这意味着 Rerank 挂掉时系统不会整个瘫掉,只是退化成上一篇的效果。 对外的检索服务都应该有这种降级路径——外部依赖的失败是常态,不是意外。

备选分支的切换靠配置。 provider 决定走 Cohere 还是走 LLM,两者产出同样的数据结构,上层无感知。

OkHttpClient 是在方法内部 new 出来的。 这是个应该修的问题:OkHttp 的客户端持有连接池和线程池,设计上就是单例复用的。每次调用都新建一个,等于每次请求都丢弃一个连接池——高频调用下会表现为连接无法复用、线程数上涨、偶发的资源泄漏。正确做法是把它声明为 Bean 注入进来,全局一个。这个坑放在误区里细说。

4.3 配置

application.yml

rag:
  chunk-size: 500
  chunk-overlap: 50
  top-k: 10  # 粗排返回更多文档,给 Rerank 留余量
  rerank:
    enabled: true  # 启用 Rerank
    provider: cohere  # 使用 Cohere
    top-n: 3  # Rerank 后返回 3 个文档
    cohere:
      api-key: ${COHERE_API_KEY:}  # Cohere API Key
      model: rerank-v3.5  # Cohere Rerank 模型

注意 top-k: 10 和 top-n: 3 是两个不同的旋钮:

  • top-k 是粗排交回多少条给精排。这个值决定了精排的候选池,也直接决定了 Rerank 的成本和延迟(候选越多,要算的 pair 越多)。
  • top-n 是精排之后留多少条塞进 Prompt。这个值影响回答质量和 token 成本。

把它们调成同一个值是常见的错误——那样就白做精排了:粗排留 3 条,精排只是把这 3 条重新排个序,一个都没换掉。精排的价值恰恰在于「从 10 条里淘汰掉 7 条」,所以 top-k 必须显著大于 top-n。

4.4 接入 RagService

在 RagService 中注入 RerankService

// 在 RagService 中注入 RerankService
private final RerankService rerankService;

// 修改 ask 方法
public String ask(String question) {
    log.info("收到用户问题: {}", question);

    // 第一步:混合检索(粗排)
    log.info("步骤 1: 混合检索(粗排)...");
    List<Document> coarseResults = hybridRetrievalService.search(question, topK * 2);

    if (coarseResults.isEmpty()) {
        log.warn("未找到相关文档");
        return "抱歉,知识库中没有找到相关信息。";
    }

    // 第二步:Rerank(精排)
    log.info("步骤 2: Rerank(精排)...");
    List<Document> fineResults = rerankService.rerank(question, coarseResults);

    log.info("找到 {} 个相关文档", fineResults.size());

    // 第三步:组装 Prompt
    log.info("步骤 3: 组装 Prompt...");
    String prompt = buildPrompt(question, fineResults);

    // 第四步:调用大模型
    log.info("步骤 4: 调用大模型生成回答...");
    return callLlm(prompt);
}

4.5 新增接口

在 RagController 中添加 Rerank 接口

/**
 * Rerank 测试接口
 */
@PostMapping("/rerank-test")
public ResponseEntity<Map<String, Object>> rerankTest(@RequestBody AskRequest request) {
    log.info("Rerank 测试: {}", request.getQuestion());

    try {
        // 粗排
        List<Document> coarseResults = hybridRetrievalService.search(request.getQuestion(), 10);

        // 精排
        List<Document> fineResults = rerankService.rerank(request.getQuestion(), coarseResults);

        return ResponseEntity.ok(Map.of(
                "success", true,
                "coarseResults", coarseResults.size(),
                "fineResults", fineResults.stream().map(doc -> Map.of(
                        "id", doc.getId(),
                        "content", doc.getContent(),
                        "rerank_score", doc.getMetadata().getOrDefault("rerank_score", "N/A")
                )).collect(Collectors.toList())
        ));
    } catch (Exception e) {
        log.error("Rerank 测试失败", e);
        return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", "测试失败: " + e.getMessage()
        ));
    }
}

五、对比测试

5.1 获取 Cohere API Key

  1. 访问 https://cohere.com/
  2. 注册账号
  3. 创建 API Key(免费额度够做对比实验)

5.2 测试 Rerank

# 设置环境变量
export COHERE_API_KEY=xxx

# 测试 Rerank
curl -X POST http://localhost:8080/api/rag/rerank-test \
  -H "Content-Type: application/json" \
  -d '{
    "question": "入职3年能休多少天年假"
  }'

5.3 对比效果

# 不启用 Rerank(在配置中设置 enabled: false)
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "入职3年能休多少天年假"}'

# 启用 Rerank
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "入职3年能休多少天年假"}'

对比实验的做法是:同一批查询,跑两遍(enabled: false 和 enabled: true),然后逐条对比 Top 3 的差异。

这里必须说一句:不要用「感觉变好了」来下结论。 逐个查询肉眼比对能帮你发现具体案例(比如那个「3 年 vs 满一年」的例子是否被纠正),但没法回答「整体上好了多少」。这个问题只有评估集能回答——也就是下一篇。


六、常见误区

❌「加了 Rerank 就一定更准」 Rerank 只会对粗排给的候选池重排序,它无法把粗排没召回的东西捞回来。如果正确文档压根不在 Top 10 里,加多少个 Rerank 都没用。这就是为什么召回率要由粗排负责——精排的能力上限是粗排给的候选集。两者是串联的,短板在任何一端都会立刻显形。

❌「每次请求 new 一个 OkHttpClient 没关系」 有关系,而且是典型的慢性病。OkHttp 的 OkHttpClient 内部持有连接池(默认 5 个空闲连接、5 分钟保活)和任务分发用的线程池——它的设计意图就是全局一个实例,所有请求共享。每次调用新建一个,连接复用彻底失效(每次都要重新 TCP + TLS 握手),请求量上来之后线程和 socket 数量都会持续膨胀。这类问题在压测时才会暴露,单机手测完全看不出来。

❌「Rerank 的延迟可以忽略」 一次跨公网的 Rerank API 调用,延迟在几十到几百毫秒量级,具体取决于候选数量和网络路径——这个数字应该由你自己测,而不是照抄任何文章里的值。它叠加在原本的「检索 + 生成」之上,如果整个问答的延迟预算是 2 秒,Rerank 占了 300ms 就是个必须正视的决策。控制手段是调小 top-k,或者对延迟敏感的场景只对 Top 5 做精排。

❌「Cross-Encoder 一定比 LLM 判断得准」 两者擅长的事不同。Cross-Encoder 是在「相关性排序」这个具体任务上专门训练的,对「这段和这句像不像」的判断又快又稳。但让它判断「这段材料能不能回答这个问题」,它并不比一个大模型强。选型看任务,不看谁更「高级」。

❌「Rerank 分数据可以拿来当阈值过滤」 Cohere 等 API 返回的相关性分数是模型内部的 logit 或归一化值,不同模型之间不可比,同一个模型跨批次也未必稳定。把它当排序依据是合理的,当「低于 0.3 就丢弃」这类硬阈值用则很容易踩坑——阈值该在评估集上测出来,而不是凭数值直觉定。


七、自检清单

  • 能说清 Bi-Encoder 和 Cross-Encoder 在编码阶段的差别
  • 说得清为什么 Cross-Encoder 的分数无法预计算
  • 知道 Cross-Encoder 的复杂度从「N 次点积」变成了「N 次推理」
  • 说得清粗排-精排为什么是串联关系,短板在哪一端都会显形
  • 知道 top-k 和 top-n 各自控制什么,以及为什么不能相等
  • 知道 Rerank 服务失败时的降级路径会退化成什么
  • 说得清 OkHttpClient 为什么要全局复用
  • 知道 Rerank 分数不能当硬阈值用,以及原因

附:延伸阅读

相关资料

资源说明
Cohere: Reranking接口语义、top_n 与返回分数的含义
Passage Re-ranking with BERT (Nogueira & Cho, 2019)monoBERT,Cross-Encoder 做精排的开山之作
sentence-transformers: Cross-Encoders本地 Cross-Encoder 的实际用法与性能特征
FlagEmbedding / BGE-Reranker中文场景常用的开源 Reranker
MS MARCO Passage Ranking精排模型的经典评测集,理解指标口径
OkHttp: Recipes客户端复用与超时配置的官方说明

关联笔记


最后更新:2026-09-27