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

实践 · 约 10 分钟阅读 ·

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

RAG 中的排序问题

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

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

检索—重排序—生成 范式

Retrieve, rerank, generate pipeline A query flows through three stages: retrieve 50–100 candidates with vector search, rerank them with a cross-encoder to keep the top 5–10, then generate a grounded answer with the LLM. User query 1 Retrieve Embed query → vector search / BM25 top 50–100 candidate chunks fast · recall 2 Rerank Score each (query, chunk) pair — cross-encoder sort by score → keep top 5–10 slow · precise 3 Generate Top-k reranked chunks + query → LLM synthesise the answer synthesis Grounded answer
The reranker (stage 2) is the precision step between fast retrieval and the 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-base", 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.Client("YOUR_API_KEY")

def rerank(query: str, candidates: list[str], top_n: int = 5) -> list[str]:
    result = co.rerank(
        model="rerank-v3.5",
        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-v2-base-multilingual",
            "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 — ContextualCompressionRetriever wraps any base retriever
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base")
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-base", 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 2.x — a Ranker component in the pipeline
from haystack import Pipeline
from haystack.components.rankers import TransformersSimilarityRanker

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

这三者也都能通过各自的集成包接入托管 reranker(Cohere、Jina、Voyage)—— 换掉组件,流水线照旧。

如何选 top-k 取值

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

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

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

延迟取舍

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

方案P50 延迟(50 篇文档)成本
Cohere / Jina / Voyage API80–200 ms按次计费(约 $0.0002–0.002 / 千片段)
bge-reranker on CPU (small)200–600 ms你的基础设施成本;按次免费
bge-reranker on GPU15–60 msGPU 成本;按次免费
Local tiny model (e.g. jina-tiny)30–120 ms CPU免费

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

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

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

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

Metric它衡量什么适用场景
Recall@k正确片段是否出现在前 k 个里?用来确定 retrieval_k —— reranker 无法找回检索器从未返回的片段。
MRR (Mean Reciprocal Rank)平均而言,第一个相关片段排得有多高?单一答案的查找(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-measuresranx 以及 BEIR 的评测器都能替你算出这些指标。

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

该选哪个 reranker

简短版:

查看完整模型对比 →

看看重排序的实际效果

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

打开在线 Demo →

Keep reading