如何为你的 RAG 流水线加重排序

实践 · 约 10 分钟阅读 ·

改善平庸 RAG 答案的办法,很少是换更大的 LLM —— 通常是更好的检索排序。reranker 位于向量搜索和 LLM 调用之间,对候选重新打分,确保只有最相关的段落进入 prompt。

RAG 中的排序问题

典型的 RAG 系统会嵌入你的文档并存储向量。查询时它取出与查询嵌入最近的 k 个向量,把这些片段塞进 LLM 的 prompt。问题在于:独立嵌入之间的余弦相似度是个粗糙的相关性信号。正确的片段可能在前 20 个结果里,却排在第 14 位 —— 落在你真正送进模型的那 5 个之外。

这是“lost in the middle(迷失在中间)”问题的反面:正确答案一开始就没排在最前。重排序通过对检索器已经找到的短名单施加一个更昂贵、更准确的相关性模型来修复它。

检索—重排序—生成 范式

检索、重排序、生成流水线 查询依次经过三个阶段:先用向量检索召回 50–100 个候选,再用 cross-encoder 重排序、保留前 5–10 个,最后由 LLM 生成有依据的答案。 用户查询 1 检索 嵌入查询 → 向量检索 / BM25 召回前 50–100 个候选片段 快 · 召回 2 重排序 给每个(查询, 片段)对打分 —— cross-encoder 按分数排序 → 保留前 5–10 慢 · 精准 3 生成 重排后的前 k 个片段 + 查询 → LLM 综合出答案 综合 有依据的答案
reranker(第 2 阶段)是快速检索与 LLM 之间负责精度的一步。

检索器负责规模(毫秒级处理数百万文档)。reranker 负责质量(对几十个候选做精确排序)。LLM 负责综合。每个阶段只做自己擅长的事。

代码讲解

下面是一个使用本地 bge-reranker 的自包含 Python 示例。在生产中,如果你更喜欢托管 API,可以把 rank_documents 换成对 Cohere、Jina 或 Voyage 的调用。

使用本地 cross-encoder(sentence-transformers)

from sentence_transformers import CrossEncoder

# Load once at startup — reuse across requests
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

def rerank(query: str, candidates: list[str], top_n: int = 5) -> list[str]:
    """Return top_n candidates reranked by relevance to query."""
    pairs = [(query, doc) for doc in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(scores, candidates), reverse=True)
    return [doc for _, doc in ranked[:top_n]]

# --- In your RAG pipeline ---
raw_chunks = vector_db.search(query, top_k=50)   # retrieve wide
best_chunks = rerank(query, raw_chunks, top_n=5)  # rerank tight
answer = llm.complete(build_prompt(query, best_chunks))  # generate

使用 Cohere 托管 API

import cohere

co = cohere.ClientV2("YOUR_API_KEY")

def rerank(query: str, candidates: list[str], top_n: int = 5) -> list[str]:
    result = co.rerank(
        model="rerank-v4.0-pro",
        query=query,
        documents=candidates,
        top_n=top_n,
    )
    return [candidates[r.index] for r in result.results]

使用 Jina Reranker API

import requests

def rerank(query: str, candidates: list[str], top_n: int = 5) -> list[str]:
    resp = requests.post(
        "https://api.jina.ai/v1/rerank",
        headers={"Authorization": "Bearer YOUR_KEY"},
        json={
            "model": "jina-reranker-v3.5",
            "query": query,
            "documents": candidates,
            "top_n": top_n,
        },
    ).json()
    indices = [r["index"] for r in resp["results"]]
    return [candidates[i] for i in indices]

在框架内(LangChain · LlamaIndex · Haystack)

如果你已经在用某个 RAG 框架,重排序通常就是一个即插即用的节点,把你现有的检索器包起来。模式完全一样 —— 宽召回、重排序、保留 top-n —— 只是换成了框架的术语来表达。

# LangChain 1.x — pip install langchain-classic langchain-community
# ContextualCompressionRetriever wraps any base retriever
from langchain_classic.retrievers import ContextualCompressionRetriever
from langchain_classic.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")
compressor = CrossEncoderReranker(model=model, top_n=5)
retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 50}),
)
docs = retriever.invoke(query)   # retrieve 50 → rerank → top 5
# LlamaIndex — a node postprocessor on the query engine
from llama_index.core.postprocessor import SentenceTransformerRerank

reranker = SentenceTransformerRerank(model="BAAI/bge-reranker-v2-m3", top_n=5)
query_engine = index.as_query_engine(
    similarity_top_k=50,                 # retrieve wide
    node_postprocessors=[reranker],       # rerank to top 5
)
response = query_engine.query(query)
# Haystack 3.x — pip install haystack-ai sentence-transformers-haystack
from haystack import Pipeline
from haystack_integrations.components.rankers.sentence_transformers import (
    SentenceTransformersSimilarityRanker,
)

ranker = SentenceTransformersSimilarityRanker(model="BAAI/bge-reranker-v2-m3", top_k=5)
pipe = Pipeline()
pipe.add_component("retriever", retriever)   # returns ~50 docs
pipe.add_component("ranker", ranker)
pipe.connect("retriever.documents", "ranker.documents")

这三个框架也都能通过各自的集成包接入托管重排序服务(Cohere、Jina、Voyage)—— 换掉组件,流水线不变。我们在 2026 年 10 月用 LangChain 1.4、LlamaIndex core 0.14 和 Haystack 3.3 实际跑过这三段代码。和多数教程里的版本相比,有两处变了:LangChain 1.0 把检索器相关的类从 langchain 移到了 langchain-classic;Haystack 3 的核心包里不再有 cross-encoder 排序组件,它在 sentence-transformers-haystack 集成包里。langchain-community 仍然能用,但现在会提示它即将停止维护、建议改用独立的集成包。

如何选 top-k 取值

你有两个 k 值要调:召回多少个,以及重排序后保留多少个。

参数常见范围说明
retrieval_k20–100越多 = 召回越好、reranker 越慢。50 是常见默认值。不要低于 20,否则可能彻底错过正确片段。
rerank_top_n3–10越少 = prompt 越便宜,但漏掉有用片段的风险越高。从 5 开始;根据你的上下文窗口和答案质量来调。

经验法则:召回的数量至少是你打算保留数量的 5 倍。想要最终 5 个片段,就至少召回 25–50 个。reranker 只能修正顺序,无法凭空变出根本没被召回的片段。

延迟取舍

重排序会给你的流水线增加一次模型调用。成本取决于具体方案:

方案大致延迟成本
Cohere / Jina / Voyage API80–200 ms按次或按 token 计费(每 1,000 个约 300 token 的片段约 $0.006–0.025)
bge-reranker(CPU,小模型)200–600 ms你的基础设施成本;按次免费
bge-reranker(GPU)15–60 msGPU 成本;按次免费
浏览器中的 jina-tiny30 段约 672 ms(实测)免费

只有浏览器那一行是实测的(在线 Demo,4 核 GitHub Actions 机器,2026 年 10 月)。其余都是粗略参考,不是测量结果:托管 API 的耗时主要花在网络上,取决于你所在的区域 —— Jina 的 FAQ 给出 100 篇 256 token 的文档约 150 ms;自建部署的耗时则完全取决于你的硬件和段落长度。

对大多数 RAG 应用来说,整条流水线 100–300 ms 的延迟完全可以接受,而质量提升是值得的。如果你的 SLA 非常紧,可以部署到 GPU、改用微型模型,或把 retrieval_k 从 50 降到 20–30。

积极使用缓存:如果同一查询反复出现(例如客服机器人),就按 (query, 语料版本) 的哈希缓存重排序结果。对重复查询而言,reranker 实际上变成了免费。

如何衡量它是否真的有帮助

不要凭感觉就加 reranker —— 要量化它。构建一个小型评测集,把查询与应当被检索到的片段配对,然后在同一集合上对比「仅检索」与「检索 + 重排序」。三个指标几乎覆盖所有场景:

Metric它衡量什么适用场景
Recall@k正确片段是否出现在前 k 个里?用来确定 retrieval_k —— reranker 无法找回检索器从未返回的片段。
MRR(平均倒数排名)平均而言,第一个相关片段排得有多高?单一答案的查找(FAQ、客服、「找出某条款」)。
nDCG@k相关片段是否排得靠前(按位置加权)?多段落答案:多个片段都重要,且顺序也要紧。

MRR 最直观:正确片段排第 1 得 1.0,排第 2 得 0.5,排第 4 得 0.25。在整个查询集上取平均,就得到一个数字,刻画「答案离顶部有多近?」—— 这正是 reranker 应当改善的东西。

# Minimal MRR + Recall@k over a labelled eval set.
# Each example: a query, the candidate texts, and the indices that are relevant.
def reciprocal_rank(ranked_ids, relevant_ids):
    for i, doc_id in enumerate(ranked_ids, start=1):
        if doc_id in relevant_ids:
            return 1.0 / i
    return 0.0

def evaluate(examples, rank_fn, k=5):
    mrr = recall = 0.0
    for ex in examples:
        ranked = rank_fn(ex["query"], ex["candidates"])  # returns ids, best first
        mrr += reciprocal_rank(ranked, ex["relevant"])
        recall += 1.0 if set(ranked[:k]) & set(ex["relevant"]) else 0.0
    n = len(examples)
    return {"MRR": mrr / n, f"Recall@{k}": recall / n}

base = evaluate(eval_set, retrieve_only)        # baseline
reranked = evaluate(eval_set, retrieve_then_rerank)  # with the reranker
print(base, reranked)   # expect MRR and nDCG to rise after reranking

哪怕只有 30–50 条标注查询,也足以看出信号。从日志里捞出真实查询,标注每条由哪个片段回答,你就有了一套回归测试 —— 每次改动检索器、分块或 reranker 都能重跑。若不想自己手写,ir-measures、ranx 以及 BEIR 的评测器都能替你算出这些指标。

常见坑:召回的候选太少(reranker 只能对拿到的东西重新排序);max_length 留得太短,长片段在打分前就被截断;把排序写成升序而非降序;以及以为不同模型的原始分数可以互相比较 —— 它们并未校准,重要的是排名顺序,而非绝对数值。

该选哪个 reranker

简短版:

查看完整模型对比 →

看看重排序的实际效果

粘贴你自己的查询和候选。一个 cross-encoder 在你的浏览器里为它们打分 —— 零 API 成本。

打开在线 Demo →

Keep reading