首页 / 文章

RAG中的重新排序:交叉编码器与LLM评估器及其延迟对比

第二阶段重排序机制如何优化RAG中的候选项排序,跨编码器与LLM评估器的对比,延迟成本、故障模式以及相应的评估方法。

发布者:REACTAPP.TOP阅读约 14 分钟3111 词

来源: Reranking for RAG: Cross-Encoders, LLM Rerankers, and Latency Tradeoffs

结果中存在正确的内容片段,只是位置太靠下

想象一下,一个RAG系统的混合搜索功能终于开始正常工作了。向量存储已启动,关键词索引也能正常使用,融合逻辑也已编写完成,而包含答案的内容片段现在也出现在候选集的某个位置。即便如此,模型生成的回复依然很一般。

日志解释了原因。有开发者询问如何在测试环境中调试PAYMENTS_API_TIMEOUT错误。混合搜索返回了二十个候选项:一份关于认证的概述、一份去年的过时事件记录、一篇关于重试逻辑的通用页面,而在第八位则是包含确切解决方案的测试环境操作手册片段。但提示词只能容纳三个内容片段,正确的那个位于过靠下的位置,因此模型因为无法使用正确的材料而不得不用错误的材料来构建答案。

检索功能虽然能找到相关证据,但不会将其置于最前面。检索关注的是召回率,而重排则是在严格的资源限制下追求精确度。

仅有优质的候选集是不够的。模型实际读取的内容取决于该集合的顺序,而顺序正是制约因素。

重排阶段的作用

第一轮检索旨在快速覆盖大量数据。针对向量数据库或关键词索引的查询需要在毫秒级内处理数百万条记录,因此这些系统依赖近似算法和预计算索引,并有意检索超出实际需求的更多内容。

重新排序会增加一个第二阶段,该阶段会选取少量结果并对其进行更仔细的分析。这种方式速度较慢但精度更高,且不会触及数据库。第一轮检索得到的结果——通常是排名前二十或五十的结果——会由更强大、更精确的模型重新评分。

第一轮检索负责生成候选结果,而重新排序则决定哪些候选结果能进入有限的上下文窗口中。

为何第一轮检索无法完成工作

如果嵌入模型性能良好,最好的内容片段难道不应该直接排在最上面吗?其实并非如此,因为第一轮检索本质上就存在噪声。

像BM25这样的关键词评分方法依赖于术语的精确匹配程度。一个多次提及错误代码的冗长参考页面,往往能胜过仅提及两次的简短操作指南,因为该算法会将出现频率视为相关性。

向量检索还存在另一种噪声问题。嵌入模型通过将概念置于高维空间中来衡量语义相似度。对于故障排查类问题,它会呈现与所提及系统相关的文本片段。在该空间中,宏观概述往往位于具体操作步骤的附近。描述计费架构的文本片段可能会比用于重启计费服务进程的精确命令获得更高的得分。模型虽然知道这些内容都与计费相关,但却无法区分操作指令与理论描述。

混合检索方法将词汇列表和语义列表合并为一个包含所需证据的全面候选集。但这个集合中也包含重复内容、过时的文档、宏观总结以及仅部分相关的文本片段。

初次检索结果绝不能被视为最终的排序层。它的作用类似于漏斗,而重新排序则是位于漏斗底部的过滤环节。

两种重新排序的方法

一旦有了候选项,就需要对它们进行排序。目前有两种标准方法。一种使用交叉编码器,即专门训练用于评估文档与查询相关性的模型。另一种则使用大型语言模型,比如 Claude 或 GPT 这类模型,接收查询和文档后会被询问该文档的实用程度。

与初次排序相比,这两种方法都能显著提升排序质量。它们的区别在于延迟、成本、确定性以及所需的工程实现复杂度。

交叉编码器

交叉编码器也是一种变换器,但其工作方式与向量数据库中的嵌入模型不同。

标准的嵌入模型会将查询输入网络以获得一个向量,再将文档输入网络以获得另一个向量,最后通过余弦相似度比较这两个向量。这就是双编码器设计,它的速度很快,因为每个文档向量都可以提前计算出来。

交叉编码器不会生成独立的向量。它会将查询和文档合并成一条字符串,然后一次性将整个字符串输入网络。因此自注意力机制在每一层都能同时考虑查询词和文档词,从而了解这些词在上下文中的关联关系。其输出为一个单一的相关性分数。

这种设计在实现精细的相关性判断方面表现优异。其结果具有可预测性和稳定性,方差远小于通过提示语言模型得到的结果,并且它总能更倾向于选择具体的操作片段而非宽泛的总结。

这是一个基于托管式交叉编码器 API 构建的重新排序函数。

import cohere
import time

def rerank_cross_encoder(
    query: str,
    candidates: list[dict],
    top_n: int = 5,
    model: str = "rerank-v4.0-pro",
) -> list[dict]:
    """
    The practical default for second-stage ranking.
    Passes the query and candidate texts to a dedicated cross-encoder model.
    """
    co = cohere.ClientV2()

    # Extract just the text content for the API call
    documents = [c["content"] for c in candidates]

    resp = co.rerank(
        model=model,
        query=query,
        documents=documents,
        top_n=top_n,
    )

    # Reattach the original metadata and the new score
    reranked = []
    for r in resp.results:
        original_chunk = candidates[r.index]
        reranked.append({
            **original_chunk,
            "rerank_score": r.relevance_score
        })

    return reranked

在实际应用中,跨编码器适用于排名前十到五十的候选项。在工程师文档搜索、支持知识库以及内部辅助工具中,团队通常会优先使用它。

其主要缺点是速度。跨编码器无法扫描整个数据库,它需要先完成第一次扫描,通过API或本地模型获取候选项,然后再等待评分结果,这会增加一次网络传输和一次计算步骤。

LLM重排序模型

另一种方法是使用通用语言模型作为评估器。通过严格的提示词将用户的查询与一个候选项片段一起提交给模型,模型便会输出评分结果。

大语言模型重排工具能很好地处理细微差别。交叉编码器会寻找深层的语义关联,而大语言模型还能判断某个流程是否完整,对相互矛盾的证据进行推理,并将包含实际步骤的片段与仅含背景信息的片段区分开来。提示词可以要求其对模糊表述予以扣分,或优先考虑包含代码示例的片段。

这个评估提示词使用了Anthropic的客户端。

import anthropic

JUDGE_PROMPT = """\
You are a strict relevance judge. Given a user query and a candidate document chunk,
rate how well the chunk answers the query on a scale of 0 to 10.

Respond with ONLY a JSON object in this exact format:
{"score": <int>, "reason": "<one short sentence>"}

Query: {query}
Chunk: {chunk}"""

async def rerank_llm(
    query: str,
    candidates: list[dict],
    top_n: int = 5,
) -> list[dict]:
    """
    Expensive special forces. Uses an LLM to reason about nuance and completeness.
    """
    client = anthropic.AsyncAnthropic()
    scored = []

    for c in candidates:
        resp = await client.messages.create(
            model="claude-opus-4-6",
            max_tokens=128,
            messages=[{
                "role": "user",
                "content": JUDGE_PROMPT.format(query=query, chunk=c["content"]),
            }],
        )

        import json
        try:
            result = json.loads(resp.content[0].text)
            scored.append({
                **c,
                "rerank_score": result["score"],
                "reason": result.get("reason", "")
            })
        except (json.JSONDecodeError, KeyError):
            # Fallback if the model fails to follow JSON instructions
            scored.append({**c, "rerank_score": 0, "reason": "parse_error"})

    # Sort by the LLM-assigned score descending
    scored.sort(key=lambda x: x["rerank_score"], reverse=True)
    return scored[:top_n]

系统会要求模型在给出分数的同时说明理由。先撰写解释内容类似于思维链推理,有助于稳定最终得分。

这类模型在高价值查询、那些因答案准确性值得等待的低吞吐量系统,以及那些需要依据策略细节而非单纯主题匹配来确定相关性的复杂语料库中才能发挥作用。

其成本相当高昂。每个候选片段都需要进行一次完整的LLM调用,即便这些调用是并行执行的,最慢的响应也会决定最终结果。其成本远高于交叉编码器,且输出结果的确定性也更低。

重新排序对延迟的影响

增加的排序阶段会改变系统的运行方式,它需要更多的模型调用、额外的文本数据序列化处理以及更多的网络传输时间。

用户并不关心检索方式如何安排,也不关注召回率指标。他们关注的是答案的质量和响应速度。如果某个理论上出色的重排序算法导致助手响应过慢而不可靠,那它就毫无用处。

这个工具用于测量重排序函数的执行时间。

def rerank_with_timing(
    rerank_fn: callable,
    query: str,
    candidates: list[dict],
    top_n: int = 5,
) -> tuple[list[dict], float]:
    """
    Measure the exact cost of the reranking stage.
    """
    t0 = time.perf_counter()

    results = rerank_fn(query, candidates, top_n)

    latency_ms = (time.perf_counter() - t0) * 1000
    return results, latency_ms

将20个候选项传递给快速交叉编码器API可能会增加150到300毫秒的延迟,通常这是为获得更好上下文所付出的合理代价。而将50个候选项传递给大型语言模型评估器则可能需要2到4秒的时间。再加上生成内容所需的3秒,用户总共需要等待7秒,这已经超出了聊天界面所能接受的延迟范围。

重新排序的候选项数量需要调整。前10个候选项虽然处理速度快,但在第一次检索效果不佳时可能会遗漏合适的内容;前50个候选项几乎能涵盖所有内容,但会耗尽所有的延迟预算。在实际应用中,对于交叉编码器而言,前20个候选项通常是最佳选择。

何时重新排序能带来好处

当第一次检索结果杂乱但通常仍能找到相关证据时,可以加入重新排序模块。

如果追踪结果显示某些证据片段经常出现在候选结果中,但排名仅在第十或第十五位,那么重新排序就能立即修复系统问题。当泛化的文档通常能胜过精确的流程,且答案质量在很大程度上取决于提示词中是否包含最优质的三个片段时,精确性就比单纯的召回率更为重要,此时进行重新排序是合理的。

操作手册、内部政策辅助工具以及故障排除工具几乎总是需要重新排序功能。选择正确的指令而非相近的指令,往往能决定一个问题能否得到解决,还是会导致服务中断。

何时重新排序属于过度处理

当语料库规模极小时,无需使用重新排序功能。一个仅有五十页的知识库完全可以被完整嵌入,并通过向量搜索来提供查询服务。

当第一轮处理已经近乎完美时,再做处理也是毫无意义的。在那些依赖大量查询匹配的系统里,BM25总能将正确的API引用排在首位,而交叉编码器只会拖慢速度,却无法改变结果。

对于那些对延迟要求极为严格的场景,比如语音助手或内联代码补全,根本没有空间再增加处理步骤。应当优化第一轮处理,并接受一定程度的误差。而且当正确的内容通常已经排在第一或第二位时,排名问题便不再会成为阻碍。

需注意的故障模式

重新排序工具虽然能调整顺序,但也会带来新的问题。部署后需留意这些情况。

宽泛总结偏差。某些交叉编码器是在以全面答案为奖励标准的数据上训练的。由于这类总结与查询的词汇重合度更高,因此一个通用的概述往往能比一段简短精确的代码片段获得更高的评分。

重复内容过多。如果语料库中存在同一份部署指南的三个版本,第一次检索很可能会把这三个版本全部获取过来。重排器会认为这些版本都能很好地回答查询,因此给它们的评分都很高。这样一来,上下文窗口中的前三个位置就会显示相同的信息,从而挤掉了其他不同的证据。像这样的工具可以去除近乎重复的内容。

def dedupe_candidates(
    candidates: list[dict],
    similarity_threshold: float = 0.85,
) -> list[dict]:

    seen_tokens: list[set[str]] = []
    deduped = []

    for c in candidates:
        tokens = set(c["content"].lower().split())
        is_dup = False

        for s in seen_tokens:
            # Calculate simple Jaccard similarity
            overlap = len(tokens & s) / max(len(tokens | s), 1)
            if overlap >= similarity_threshold:
                is_dup = True
                break

        if not is_dup:
            deduped.append(c)
            seen_tokens.append(tokens)

    return deduped

过时文档的偏好问题。交叉编码器仅能读取文本,而无法知晓当前是哪一年。即便那份较早编制的文档早已过时,经过精心完善的2022年版操作手册仍可能比仓促编写的2026年版得分更高。重新排序后应用的元数据优化机制会根据文档的新鲜度与状态来调整其得分。

from datetime import datetime, timezone

def apply_metadata_boost(
    candidates: list[dict],
    freshness_halflife_days: int = 90,
) -> list[dict]:

    now = datetime.now(timezone.utc)
    boosted = []

    for c in candidates:
        score = c.get("rerank_score", 0.0)

        # Hard penalty for superseded documentation
        if c.get("status") == "superseded":
            score *= 0.4

        # Gradual decay for older documents
        updated = c.get("updated_at")
        if updated:
            age_days = (now - updated).days
            decay_factor = max(0.5, 1 - age_days / (freshness_halflife_days * 2))
            score *= decay_factor

        boosted.append({**c, "rerank_score": score})

    # Sort again based on the adjusted scores
    boosted.sort(key=lambda x: x["rerank_score"], reverse=True)
    return boosted

延迟激增问题。系统部署后性能有所提升,但偶尔仍会出现五秒左右的停滞。这种情况通常发生在候选项数量骤增或外部重排序API性能下降时。当第二阶段处理时间过长时,应始终设置超时机制,跳过重排序步骤并直接返回初次检索的结果。

正确评估重排序器

许多团队会用错误的标准来衡量,即检查最相关的内容是否出现在前二十名中。其实初次检索已经能够满足这一需求。

更重要的问题是,重排序器是否将真正重要的内容提升到了足够靠前的位置。这就需要衡量前1名和前3名的相关性、答案的完整性以及重复内容的过滤效果,同时还要关注延迟开销和每次查询的成本。

构建一个评估工具,使用完全相同的候选集,比较普通的第一遍基准方法与交叉编码器以及LLM重排序器的效果。

from dataclasses import dataclass

@dataclass
class RerankEvalCase:
    query: str
    expected_substring: str
    category: str  # e.g., "identifier", "procedure", "troubleshooting"

def eval_reranking(
    cases: list[RerankEvalCase],
    retrieve_fn: callable,
    rerank_fns: dict[str, callable | None],
    top_n: int = 3,
) -> dict:
    """
    Compare multiple reranking strategies against a baseline.
    Measures hit rate at top-N and tracks latency overhead.
    """
    results = {}

    for name, rerank_fn in rerank_fns.items():
        hits = 0
        total_latency = 0.0
        by_type: dict[str, dict] = {}

        for case in cases:
            # Get the exact same starting candidates for every strategy
            candidates = retrieve_fn(case.query)

            if rerank_fn is not None:
                reranked, lat = rerank_with_timing(
                    rerank_fn, case.query, candidates, top_n
                )
                total_latency += lat
            else:
                # Baseline: just take the top-N from first-pass retrieval
                reranked = candidates[:top_n]

            # Check if the expected evidence made it into the final prompt window
            top_contents = [r["content"] for r in reranked]
            found = any(case.expected_substring in c for c in top_contents)
            hits += int(found)

            # Track metrics by query category
            by_type.setdefault(case.category, {"hit": 0, "total": 0})
            by_type[case.category]["total"] += 1
            by_type[case.category]["hit"] += int(found)

        total = len(cases)
        results[name] = {
            "hit_rate": hits / total if total else 0,
            "avg_latency_ms": total_latency / total if total else 0,
            "by_type": {
                t: {**v, "rate": v["hit"] / v["total"]}
                for t, v in by_type.items()
            },
        }

    return results

测试集应涵盖多种查询类型:以精确字符串匹配为主的类型、以步骤顺序为关键的因素型类型,以及通过症状定位根本原因的故障排查型类型。

这些数据能够准确反映重排器带来的提升效果。如果交叉编码器能在200毫秒内将前3名的命中率从60%提升到85%,那就应该采用它;而如果大语言模型重排器能在3秒内将命中率提升到88%,那么交叉编码器可能仍更合适。重排技术的有效性必须通过在合理延迟限制下获得更好的最终结果来证明。

合理的默认配置组合

生产系统可靠的基准流程遵循特定的顺序:通过具备结构感知能力的切分方式对文档进行分割,再利用混合检索方法收集二十到三十个候选项,去除几乎完全相同的文本以实现去重,随后使用交叉编码器对剩余内容进行语义相关性评分,同时对过时的文档施加元数据惩罚。最后从排序后的列表中选取排名前三到五的片段来构建提示词。

def two_stage_retrieve(
    query: str,
    retrieve_fn: callable,
    top_k: int = 20,
    top_n: int = 5,
) -> tuple[list[dict], dict]:
    """
    The complete production pipeline for second-stage ranking.
    """
    t0 = time.perf_counter()

    # Stage 1: Fast hybrid retrieval
    candidates = retrieve_fn(query)[:top_k]

    # Clean up the candidate pool
    candidates = dedupe_candidates(candidates)

    # Stage 2: Cross-encoder rerank
    # We score slightly more than top_n to allow metadata boosts to reorder the edges
    score_limit = min(top_n * 2, len(candidates))
    reranked = rerank_cross_encoder(query, candidates, top_n=score_limit)

    # Apply business logic for freshness and status
    final = apply_metadata_boost(reranked)[:top_n]

    latency = (time.perf_counter() - t0) * 1000

    trace = {
        "query": query,
        "first_pass_count": len(candidates),
        "post_rerank_count": len(reranked),
        "final_count": len(final),
        "latency_ms": latency,
    }

    return final, trace

默认方案之所以有效,是因为每种工具都能完成其擅长的任务。检索模块负责候选项的召回,重排模块负责候选项的排序,而交叉编码器则为第二阶段提供最佳的性价比。

需要注意一点:重排模块不应被用来临时修补有问题的数据。如果数据分割不当,它只会准确地对错误的分块进行排序;而当元数据缺失时,它也无法判断哪份文档已过时。应先解决数据导入和提取的问题。

重排模块仍无法解决的难题

重排模块能确保检索结果中最具说服力的证据传递给模型,但它无法告诉你向量搜索是否真的是正确的选择。比如,对于“某个地区有多少活跃用户”这样的问题,将其指向文档索引是错误的,因为相关数据存储在SQL数据库中。

检索质量具有多个层次。第一层负责定位文本,第二层负责对文本进行排序,但在这两步之前还需先决定在何处进行搜索。根据用户意图,在向量搜索、SQL查询和实时网络搜索之间路由请求,是上述所有操作之前的前置环节。

相关阅读

  • 仅用Python实现RAG:分块、TF-IDF、余弦相似度与提示工程 —— 仅借助数学模块构建检索增强生成系统:滑动窗口分块、TF-IDF向量、余弦相似度排序、实用提示及其局限性。
  • 单独复制可行,组合使用则失败:通过孤立复制测试的标记在上下文中的脆弱性 —— 对八种开放权重模型中的标记进行筛选,这些标记单独复制时正确,但在上下文中会被丢弃或改变,同时检测其嵌入几何特性、任务保持能力及工具使用情况。
  • 用印度民间故事训练小型GPT:分词器、掩码与损失函数 —— 从零构建的900万参数GPT:为何GPT-2的分词器无法处理民间故事中的词汇,BPE与因果掩码的运作原理,以及为何一个批次能产生4,096个下一个标记的损失值。
  • 按语义搜索视频与音频:基于Gemini Embedding 2的多模态RAG系统构建 —— 如何通过为文本、音频、视频和PDF创建统一的嵌入空间来替代转录流程,以及分块处理、ChromaDB、查询扩展、重排序和阈值设置的作用。
  • 神经网络中的叠加现象:简化模型、自编码器及特征融合 — 网络如何存储比维度更多的特征,干扰会带来什么代价,以及为何稀疏自编码器能够融合数据无法区分的特征。
  • 无需查询的片段选择:在RAG无查询可做时使用K均值算法 — 当没有用户查询时,RAG无法选择相关上下文。将K设为输出数量,对片段嵌入进行聚类,选取质心片段,并保留覆盖范围及来源引用。
  • RAG中的重排序:跨编码器真正发挥作用的狭窄区间 — 为何跨编码器仅能在召回率下限与K值上限之间提升RAG性能,什么是幻影匹配,以及如何在一个下午内自行测量查询数据在这两个边界上的表现。