RAG 检索重排序完全指南:从一次"向量检索答案却总不对"看懂为什么 top-K 不能直接喂模型

2024 年我给一家公司做企业知识库的 RAG 问答把几千份内部文档喂进去让员工用大白话提问系统自动从文档里找答案第一版我做得很顺手把文档切成一段段的 chunk 每段算一个 embedding 存进向量库用户来一个问题把问题也算成 embedding 在向量库里检索出最接近的 5 段拼成上下文塞进 prompt 交给大模型回答本地我拿几个问题测了测答得像模像样我心里很笃定 RAG 嘛就是把问题和文档都变成向量向量检索一下返回的 top-K 就是按相关性从高到低排好序的取前几条喂给模型就行可等它一上线一串问题冒了出来第一种最先把我打懵答案常常不对不是大模型不会答而是我检索出的那 5 段资料里压根没有真正含答案的那一段第二种最难缠检索出的 top-5 里塞满了看着像其实没用的段落第三种最头疼我把检索的 K 从 5 调到 20 把更多内容塞给模型结果答案反而更差了第四种最莫名其妙同一个问题有时答对有时答错我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为向量检索一次就够了它返回的 top-K 已经是按相关性排好序的可这个认知是错的本文从头梳理为什么向量检索 top-K 直接喂模型答案不准召回与精排为什么必须分两步cross-encoder 重排凭什么比向量检索准重排之后还要做哪些截断召回阶段漏掉的内容为什么重排也救不回来以及一些把 RAG 重排序做扎实要避开的工程坑

2024 年我给一家公司做企业知识库的 RAG 问答——把几千份内部文档喂进去,让员工用大白话提问、系统自动从文档里找答案。第一版我做得很顺手:把文档切成一段段的 chunk,每段算一个 embedding 存进向量库;用户来一个问题,把问题也算成 embedding,在向量库里检索出最接近的 5 段,拼成上下文塞进 prompt,交给大模型回答。本地我拿几个问题测了测,答得像模像样,我心里很笃定:RAG 嘛,就是把问题和文档都变成向量,向量检索一下,返回的 top-K 就是按相关性从高到低排好序的,第一条最相关,取前几条喂给模型就行;要提升效果,无非是把 K 调大、把更多内容塞进去——这 RAG 稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:答案常常不对——不是大模型不会答,而是我检索出的那 5 段资料里,压根没有真正含答案的那一段,模型只能照着不相关的资料瞎编。第二种最难缠:检索出的 top-5 里,塞满了"看着像、其实没用"的段落——它们和问题有不少词重叠,可根本没回答问题。第三种最头疼:我想提升召回,把检索的 K 从 5 调到 20,把更多内容塞给模型,结果答案反而更差了。第四种最莫名其妙:同一个问题,我测了几次,有时答对、有时答错,结果很不稳定。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为向量检索一次就够了——它返回的 top-K,已经是按"和问题的相关性"从高到低排好序的,第一条就是最相关的那一段;我要做的,就是取前几条、原样喂给大模型;想让效果更好,就是把 K 调大、让更多文档进入上下文,检索出的东西越多,模型能用的料就越多。可这个认知是错的。向量检索用的是双塔模型(bi-encoder):它把问题和每段文档各自独立地编码成一个向量,再比较两个向量的距离。这种"各编各的、最后比距离"的方式,是为了能在亿级文档里飞快地粗筛而做的近似——它快,但它判断"相关性"的精度有限。它会把"语义氛围相近"的段落排到前面,可"语义氛围相近"根本不等于"真的回答了这个问题":一段大量出现"会员""开通""权益"的文字,和问题"怎么开通会员"在向量空间里挨得很近,但它讲的可能是"会员权益介绍",而不是"开通步骤"。所以正确的 RAG 检索,从来不是"向量检索一锤子买卖",而是两个阶段:先召回(recall)——用向量检索从海量文档里快速、宽松地粗筛出一大批候选;再精排(rerank)——用一个更慢但精准得多的模型,对这批候选逐一细评,挑出真正最相关的那几条。本文从头梳理:为什么"向量检索 top-K 直接喂模型"答案不准,召回与精排为什么必须分两步,cross-encoder 重排凭什么比向量检索准,重排之后还要做哪些截断,召回阶段漏了的内容为什么重排也救不回来,以及一些把 RAG 重排序做扎实要避开的工程坑。

问题背景

先把 RAG 检索这件事说清楚。RAG(检索增强生成)的核心,是在让大模型回答问题之前,先从一个知识库里检索出相关资料,把资料连同问题一起交给模型。检索得准不准,直接决定了答案对不对——模型再聪明,资料里没有答案,它也只能编。第一版的检索只有一步:向量检索(也叫语义检索)。它的做法是,事先用一个 embedding 模型把每段文档转成向量存好;查询时把问题也转成向量,在向量库里找距离最近的若干段。这一步快,但它有一个绕不开的局限,而第一版恰恰不知道这个局限的存在。

错误认知是:向量检索返回的 top-K 就是按相关性精确排好序的,取前几条直接喂模型即可,K 越大效果越好。真相是:向量检索是 bi-encoder 的近似粗筛,它擅长"快速从海量里捞出一批可能相关的",但不擅长"精确判断哪条最相关";要得到精确的相关性排序,必须在召回之后再加一道 cross-encoder 重排。把这一点摊开,第一版的几类问题就都能解释了:

  • 答案常常不对:向量检索的粗筛精度有限,真正含答案的那段没能排进 top-5,模型手里根本没有正确资料。
  • top-5 塞满"像而没用"的段落:向量检索把"词面氛围相近"误当成"相关",召回了一堆似是而非的内容。
  • K 调大反而更差:粗筛不准,K 越大,混进上下文的无关段落越多,真正有用的那段被噪声淹没。
  • 结果不稳定:几段相关性都说不清的候选,排序在边界上抖动,这次进 top-5、下次掉出去。

所以让 RAG 检索真正可靠,核心不是"把 K 调大、多塞资料",而是一整套工程:召回与精排分两阶段、用 cross-encoder 做重排、按相关性阈值截断、按上下文预算装填、把召回放宽到足够大。下面六节,就从第一版"向量检索一锤子买卖"的想当然讲起。

一、为什么"向量检索 top-K 直接喂模型"答案不准

第一版的检索,逻辑朴素到极致:问题转向量,向量库里取最近的 5 段,拼起来喂模型。

# 反面教材:第一版 RAG —— 向量检索取 top-5,直接喂给大模型

def answer_v1(question):
    # 把用户问题转成向量
    q_vec = embed(question)
    # 向量检索:在知识库里找向量距离最近的 5 段
    chunks = vector_db.search(q_vec, top_k=5)
    # 5 段原文拼成上下文,直接塞进 prompt
    context = "\n\n".join(c["text"] for c in chunks)
    return call_llm(
        f"根据以下资料回答问题。\n资料:{context}\n问题:{question}")

# 本地我拿几个问题测,答得像模像样,就上线了。
# 可线上很快发现:答案常常不对 —— 不是大模型不会答,
# 而是那 5 段资料里,压根没有真正含答案的那一段。

问题就藏在 vector_db.search 这一步。向量检索靠的是 bi-encoder:embedding 模型把问题编码成一个向量,又早早地把每段文档各自独立地编码成一个向量;检索时,只是在比较"问题向量"和"文档向量"之间的距离。注意这个"各自独立":文档向量是在你完全不知道用户会问什么的时候,就预先算好的。一段文档被压缩成一个固定长度的向量时,它必须"丢掉细节、只留大意"——否则没法预先算、没法快速比。于是向量检索能告诉你的,只是"这段文档的大意,和这个问题的大意,氛围像不像";它没有能力逐字逐句地审视"这段文档到底有没有正面回答这个问题"。

这一节要建立的认知是:向量检索不是一个"相关性排序器",它是一个"语义粗筛器"——它的设计目标,从一开始就是"在海量文档里飞快地把大概率无关的绝大多数挡掉",而不是"在剩下的候选里精确地分出第一名和第二名"。第一版最深的想当然,是把向量检索返回的那个有序列表,当成了一份精确的相关性排名:以为排第一的就是最相关的,排第五的就是第五相关的,这个顺序是可信的、可以照单全收的。可这个列表的"有序",只是按向量距离排的序,而向量距离衡量的是"两段文本被各自压缩成向量后,这两个向量有多近"。这中间隔着一层有损压缩:为了能预先算好、能在亿级规模里快速比对,每段文档都被压成了一个固定长度的向量,大量的细节信息在压缩中被丢掉了。用这种压缩后的向量比出来的距离,粗看大方向是对的——明显无关的文档,向量确实会离得远;但在"都沾点边"的那批候选之间,它分不清"真正回答了问题的"和"只是词面氛围像的"。这就是为什么第一版的 top-5 里,既可能混进似是而非的段落,又可能把真正的答案漏在第 8 名、第 20 名。承认这一点,你就会明白:向量检索这一步,不该是检索的终点,它只是第一道粗筛;粗筛之后,必须有一道更精细的工序。这道工序怎么和粗筛配合,就是下一节的两阶段检索。

二、召回与精排:RAG 检索为什么必须分两阶段

既然向量检索只是粗筛,那它就不该独自承担"选出最终答案资料"的重任。正确的结构是把检索拆成两个阶段,各司其职:第一阶段叫召回(recall),用向量检索,目标是"全"——从海量文档里快速捞出一大批候选,只要正确答案大概率在这批里就行,不追求排序精确;第二阶段叫精排(rerank),用一个更准的模型,目标是"准"——对这批候选逐一细评,排出真正可信的顺序。先看召回阶段的改动:它不再只取 5 条,而是取一大批。

# 召回阶段:向量检索不再只取 5 条,而是粗筛出一大批候选

def recall(question, recall_k=100):
    q_vec = embed(question)
    # 召回的目标不是"准",而是"全" —— 取 top-100,
    # 只要正确答案大概率落在这 100 条里就行,顺序无所谓
    candidates = vector_db.search(q_vec, top_k=recall_k)
    return candidates

# 向量检索很快,从亿级文档里取 top-100 也就几十毫秒。
# 它干的就是"粗筛"的活:把明显无关的绝大多数挡在门外,
# 留下一批"可能相关"的候选,交给下一阶段去精挑细选。

把召回 K 放大,看似只是改了一个数字,意义却很根本:它把"向量检索精度不够"这个问题,从"无法弥补"变成了"可以弥补"。top-5 时,正确答案排在第 8 名就彻底没救了;top-100 时,它排在第 8 名、第 50 名都还在候选池里,只要后面的精排能把它捞上来就行。召回阶段于是有了一个明确而朴素的职责:别漏。

这一节的认知是:两阶段检索的本质,是用两个各有所长、各有所短的工具做接力——让"快但粗"的向量检索去解决"从海量里收敛到几百条"这个规模问题,让"准但慢"的重排模型去解决"从几百条里选出最相关的几条"这个精度问题;任何一个工具想独自包揽全程,都会出问题。第一版的错,是让一个工具干了两份它干不好的活。它让向量检索既负责"从海量收敛"、又负责"精确选出 top-5"。前一份活向量检索干得很好——它快,亿级文档里粗筛眨眼就完;可后一份活,它干不了,因为它的精度不够。反过来,你也不能让精排模型去干全程:精排模型(下一节会讲的 cross-encoder)精度高,但它慢得多,你不可能让它去逐一审视知识库里的每一段文档——亿级文档逐条精排,一个查询要算到天荒地老。所以这两个工具谁也替代不了谁,正确的用法是接力:向量检索先把"亿"这个量级,飞快地收敛到"百"这个量级;再由精排模型,在这"百"条里慢慢地、精确地排出最相关的几条。理解了这个分工,你看召回阶段就清楚了:它的 K 必须设得足够大(几百条),因为它唯一的 KPI 是"别把正确答案漏在候选池外";它的排序精不精确,完全不重要,那是下一阶段的事。而下一阶段那个"准但慢"的工具到底是什么、它凭什么比向量检索准,就是下一节。

三、Cross-encoder 重排序:它凭什么比向量检索准

精排阶段用的模型,叫 cross-encoder。它和向量检索的 bi-encoder,差别就在一个"cross"上。bi-encoder 是把问题和文档各自独立编码,最后比向量;cross-encoder 是把问题和一段文档拼在一起,作为一个整体输入模型,让模型从头到尾通读这一对,直接输出一个相关性分数。

# 精排阶段:用 cross-encoder 给每条候选打一个真正的相关性分

from sentence_transformers import CrossEncoder

# cross-encoder:把"问题"和"一段文档"拼在一起整体阅读,
# 直接吐出一个相关性分数 —— 比向量检索的距离精准得多
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def rerank(question, candidates):
    # 每条候选都和问题组成一个 pair
    pairs = [(question, c["text"]) for c in candidates]
    # cross-encoder 对每个 pair 算分,分数越高越相关
    scores = reranker.predict(pairs)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    # 按重排分数从高到低重新排序
    return sorted(candidates, key=lambda c: c["rerank_score"],
                  reverse=True)

把召回和精排接起来,第二版的检索就完整了:向量检索召回一大批,cross-encoder 精排,取精排后的前几条。

# 两阶段拼起来:recall 召回一大批,rerank 精排取前几条

def answer_v2(question):
    # 阶段一:向量检索召回 100 条候选(要全,不漏)
    candidates = recall(question, recall_k=100)
    # 阶段二:cross-encoder 精排,排出真正可信的顺序
    ranked = rerank(question, candidates)
    # 精排之后,取前 5 条才是真正最相关的 5 段
    top = ranked[:5]
    context = "\n\n".join(c["text"] for c in top)
    return call_llm(
        f"根据以下资料回答问题。\n资料:{context}\n问题:{question}")

# 同样是"取 5 段",answer_v1 取的是向量检索的前 5(粗筛结果),
# answer_v2 取的是 100 条经 cross-encoder 精排后的前 5 —— 天差地别。

如果不想自己部署 cross-encoder 模型,各家也提供现成的重排 API,接法类似。

# 不想自己部署 cross-encoder,也可以调现成的重排 API

curl -s https://api.rerank-provider.com/v1/rerank \
  -H "Authorization: Bearer $RERANK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "model": "rerank-v1",
        "query": "怎么开通会员",
        "documents": ["开通会员的操作步骤如下",
                       "会员到期后如何续费",
                       "会员积分规则说明"]
      }'

# 返回里每条 document 带一个 relevance_score,按它排序即可。
# RERANK_API_KEY 要从环境变量读,绝不要把密钥写进代码。

这一节的认知是:cross-encoder 比向量检索准,不是因为它"模型更大、更聪明",而是因为它把判断的时机改对了——它是在"已经看到了具体这个问题"之后,才去阅读这段文档的;而向量检索,是在"还不知道用户会问什么"的时候,就被迫把文档压缩成了向量。这是两种检索方式最根本的差别。向量检索的 bi-encoder,为了能预先算好文档向量、为了能在亿级规模里快速比对,它必须在"离线、不知道问题"的状态下,把每段文档独立地压缩成一个固定向量——这个压缩一旦完成,文档里的细节就丢掉了,之后无论来什么问题,都只能拿这个压缩过的向量去比。cross-encoder 则完全不预先压缩:它要等到查询真的来了,把"这个具体的问题"和"这段具体的文档"拼成一对,整体地、逐字地读一遍,在阅读过程中让问题里的每个词和文档里的每个词充分交互,然后才给出分数。同样是问"怎么开通会员",面对一段讲"会员权益介绍"的文档,bi-encoder 只看到两个氛围相近的向量、给了个不低的距离分;cross-encoder 通读之后会发现"这段全程在讲权益、根本没提开通步骤",于是给一个很低的分。代价是,cross-encoder 慢得多——它没法预先算,每个"问题-文档"对都得现场跑一遍模型,所以它绝不能用在亿级文档的全量检索上,只能用在召回之后那几百条候选的精排上。这正好和上一节的分工对上了:向量检索用"预先压缩"换来了"快",于是去做粗筛;cross-encoder 用"现场细读"换来了"准",于是去做精排。而精排给出了可信的分数之后,事情还没完——这些分数还要被用来做截断,那是下一节。

四、重排之后:相关性阈值与上下文预算

有了 cross-encoder 排好序、还带着分数的候选,你可能觉得"取前 5 条"就收工了。但这里还藏着第一版第三个问题——"K 调大反而更差"——的根子。重排之后,有两件事必须做。第一件:按相关性分数做截断。不是"排在前 5 就要",而是"分数够高才要"。

# 重排之后第一件事:按相关性分数截断,分不够的不要

def select_by_threshold(ranked, score_threshold=0.3, max_n=5):
    picked = []
    for c in ranked:
        # 重排分低于阈值,说明它其实不相关,直接停
        if c["rerank_score"] < score_threshold:
            break          # 已按分数降序,后面的只会更低
        picked.append(c)
        if len(picked) >= max_n:
            break
    return picked

# 关键:哪怕重排把候选排好了序,也不是"取前 5 条"就完事。
# 如果这次检索的知识库里本就没有相关内容,前 5 条的分数
# 会全都很低 —— 这时宁可一条都不给模型、让它回答"资料里没有",
# 也好过硬塞 5 段不相关的、逼着模型编一个答案出来。

第二件:按上下文预算装填。重排选出的几条,长度不一,不能无脑全拼进 prompt——要按一个 token 预算来装,装满即止。

# 重排之后第二件事:按 token 预算装填上下文,装满即止

def build_context(picked, token_budget=2000):
    parts, used = [], 0
    for c in picked:
        t = count_tokens(c["text"])
        # 加上这条会超预算,就停 —— 别让上下文无限膨胀
        if used + t > token_budget:
            break
        parts.append(c["text"])
        used += t
    return "\n\n".join(parts)

# 上下文不是越多越好:塞太多,既烧 token 又增加延迟,
# 更糟的是,真正有用的那一两段会被淹没在一堆次相关内容里,
# 大模型反而抓不住重点 —— 这就是"K 调大反而更差"的真相。

这一节的认知是:检索的终点,不是"给模型尽量多的资料",而是"给模型恰好够用、且尽量干净的资料"——上下文里每多一段次相关的内容,都不是"多一份参考",而是"多一份噪声"。第一版有一个很自然、却很错误的直觉:模型答不好,是不是因为给它的资料太少?于是把 K 从 5 调到 20,想着"资料多了,总有一段是对的"。可这个直觉把大模型读资料的方式想错了。模型读上下文,不是"从一堆资料里精准挑出有用的那段、无视其余"——它会受到上下文里所有内容的影响。你塞进去 20 段,其中 2 段真正相关、18 段似是而非,那 18 段不会乖乖待着不动,它们会稀释模型的注意力、会把模型往错误的方向带,甚至会让模型把某段次相关内容里的细节,错当成答案的一部分。所以"K 调大反而更差"一点都不奇怪:你以为在增加信息,其实在增加噪声。正确的做法,是让检索这一步对"进上下文的资格"严格把关:第一道关是相关性阈值——cross-encoder 给的分数低于阈值,说明它根本不相关,哪怕它排在第一名也不要;一次检索如果所有候选分数都低,那就该坦然地告诉模型"没找到资料",让它回答"我不知道",而不是硬凑。第二道关是上下文预算——就算都过了阈值,也按一个 token 预算来装,优先装最相关的,装满就停。把检索的目标从"多"扭正成"准而干净",你才能让重排选出的好东西,不被次相关的内容拖累。而这一切的前提,是召回阶段真的把正确答案放进了候选池——下一节就说这件事。

五、召回漏掉的,重排也救不回来

前面把重排讲得很重要,但有一个残酷的事实必须说清楚:重排序再准,它也只是个"排序器"——它只能在召回阶段交给它的那批候选里挑挑拣拣,它无法凭空变出一条召回没召回到的内容。所以如果召回阶段就把正确答案漏在了候选池之外,后面的重排再精密,也是回天乏术。这意味着召回 K 的大小是一条生命线。

# 召回 K 设多大?太小,会把正确答案直接漏在门外

# 反面:召回只取 top-10
#   -> 重排序再准,也只能从这 10 条里挑;
#      如果正确答案在向量检索里排第 30 名,它根本进不了候选池
candidates = vector_db.search(q_vec, top_k=10)        # 太小,危险

# 正解:召回取 top-100 ~ 200
#   -> 给重排序一个足够大的候选池;正确答案哪怕被
#      向量检索粗筛排到第 50 名,也还在池子里,有机会被重排捞起
candidates = vector_db.search(q_vec, top_k=150)       # 够宽

# 记住:重排序是"从候选里精挑",它无法凭空变出
# 召回阶段没召回到的内容。召回漏了,就是真的漏了。

除了把 K 放大,还有一招能进一步堵住召回的漏洞:把用户的一个问题,改写、扩展成多个不同说法的查询,各自召回再合并——同一件事,用户的问法和文档的写法常常对不上,多换几种说法去召回,能捞回不少。

# 召回扩大的另一招:把用户问题改写 / 扩展成多个查询

def expand_query(question):
    # 让一个小模型把问题改写成几个意思相同、说法不同的查询
    rewrites = call_small_llm(
        f"把下面的问题改写成 3 个意思相同但说法不同的查询,"
        f"每行一个,不要解释:\n{question}").strip().split("\n")
    return [question] + [r.strip() for r in rewrites if r.strip()]

def multi_recall(question, recall_k=100):
    seen, pool = set(), []
    # 每个改写后的查询各召回一批,按文档 id 合并去重
    for q in expand_query(question):
        for c in vector_db.search(embed(q), top_k=recall_k):
            if c["id"] not in seen:
                seen.add(c["id"])
                pool.append(c)
    return pool

# 用户问"这功能怎么开",知识库里写的是"启用该选项的步骤"——
# 原问题可能召不到,但改写出的"如何启用这个功能"就召到了。

这一节的认知是:一个两阶段检索系统的能力上限,是由召回阶段"封顶"的——重排阶段只能决定你能不能逼近这个上限,却永远不可能突破它;所以召回的职责,不是"找得准",而是"绝不漏"。这是一个很容易被忽略、却决定成败的先后关系。第一版升级到两阶段之后,人的注意力很容易全被重排吸走——因为重排看起来更"高级"、更"智能",调它的效果立竿见影。于是召回阶段被随手对待,K 设个 10、20 就算了。可这恰恰是把生命线给掐了。你要时刻记得这两个阶段的因果方向:召回先发生,它划定了一个候选池;重排后发生,它只在这个池子里工作。重排能做的最好的事,就是把池子里那条最相关的捞到第一名;但如果那条正确答案,在召回那一步就没进池子,重排连见都见不到它,自然也排不出它。这就是为什么"召回宁可宽,不可漏"是一条铁律:召回多放一些次相关的进来,顶多是给重排增加一点工作量(多算几十个 pair),重排有能力把它们识别出来、压到后面去——这个代价很小;可召回一旦漏掉了正确答案,这个损失是后面任何环节都补不回来的。所以经营召回阶段,要往两个方向使劲:一是把 K 放到足够大(几百条),给重排一个宽裕的候选池;二是当心"问法和写法对不上"——用户口语化的提问,和文档里书面化的表述,词面常常差很远,把问题改写成多种说法分别召回,能把这类因为"说法不同"而漏掉的内容捞回来。把召回做"全"、把重排做"准",两者配合,RAG 检索才算真正立住了。而要让这套两阶段检索在生产里稳定跑起来,还有几个工程坑——那是下一节。

把一个用户问题进来后,完整地走一遍两阶段检索的流程画出来,就是下面这张图:

[mermaid]
flowchart TD
A[用户问题进来] --> B[问题改写扩展成多个查询]
B --> C[向量检索召回 取 top-100 候选]
C --> D[cross-encoder 逐条精排打分]
D --> E{重排分数高过阈值吗}
E -->|全部都低| F[告诉模型没找到资料]
E -->|有合格的| G[按 token 预算装填上下文]
G --> H[资料连同问题一起喂给大模型]

六、把 RAG 重排序做扎实,要避开的工程坑

前面五节讲清了 RAG 重排序的核心:两阶段分工、cross-encoder 精排、阈值与预算截断、召回放宽。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是上线后最先被骂的:重排序慢,逐条调用会把延迟放大到难以接受。

# 坑一:重排序逐条调用,延迟会很难看 —— 必须批量推理

# 反面:100 条候选,一条一条喂给 cross-encoder
for c in candidates:
    # 每次 predict 都是一次独立的模型前向,100 条就是 100 次
    c["score"] = reranker.predict([(question, c["text"])])[0]

# 正解:把 100 个 pair 一次性批量喂进去,
#       cross-encoder 在一批前向里并行地把它们算完
pairs = [(question, c["text"]) for c in candidates]
scores = reranker.predict(pairs, batch_size=32)

# 重排序本来就比向量检索慢,逐条调用会把这份慢放大几十倍;
# 批量推理是把重排延迟压回可接受范围的第一手段。

第二个坑,是模型加载。cross-encoder 模型不小,加载一次要好几秒,绝不能每个请求都重新加载。

# 坑二:cross-encoder 模型别每次请求都重新加载

# 反面:每来一个请求,函数里都 new 一个 CrossEncoder ——
#       光加载模型权重就要好几秒,每个请求都白白卡这么久
def rerank_bad(question, candidates):
    reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")   # 致命:每次都加载
    pairs = [(question, c["text"]) for c in candidates]
    return reranker.predict(pairs)

# 正解:进程启动时加载一次,常驻内存,所有请求复用同一个实例
RERANKER = CrossEncoder("BAAI/bge-reranker-v2-m3")       # 模块级,只加载一次

def rerank_good(question, candidates):
    pairs = [(question, c["text"]) for c in candidates]
    return RERANKER.predict(pairs, batch_size=32)

还有几个坑值得点一下。其一,重排序的相关性阈值不能拍脑袋定,要拿一批人工标注的"问题-文档-该不该相关"样本去校准,在不同阈值下看准确率,挑一个合适的——不同的 reranker 模型、不同的知识库,合适的阈值都不一样。其二,文档切块(chunk)的大小会直接影响重排:块切得太大,一块里混了相关和不相关的内容,cross-encoder 也难给出干净的分数;切得太碎,又容易把一个完整答案拆散。其三,重排和召回最好用同一种语言/领域适配的模型——拿一个英文 reranker 去重排中文知识库,精度会大打折扣。下面把 RAG 两阶段检索的分工集中对照一下:

RAG 两阶段检索的分工对照

  阶段          用什么            目标          速度     K 的量级
  --------------------------------------------------------------
  召回 recall   向量检索 双塔     要全 不漏      很快     100 ~ 200
  精排 rerank   cross-encoder    要准 排序      较慢     召回的全部
  阈值截断      重排分数阈值      去掉不相关     极快     阈值以上
  预算装填      token 预算       控噪声 控成本   极快     3 ~ 5 条

  原则:召回宁可宽 不可漏,精排宁可慢 不可粗;
        最终进 prompt 的,只留真正相关的那几条

这一节这几个坑,串起来是同一个意思:RAG 检索不是"调一个 API、拿回几段文字"这么一个轻飘飘的动作,而是一条有召回、有精排、有截断、有装填的流水线——这条流水线上的每一环,都有自己的性能开销、自己的参数、自己的失败方式,需要你逐环去调、去测、去监控。第一版把检索当成了一个黑箱:输入问题,输出几段资料,中间发生了什么、慢在哪里、为什么有时不准,它一概不关心。可一旦你把检索升级成两阶段,你就实实在在地接手了一条流水线。这条流水线上,cross-encoder 是个"慢工序",你不做批量推理、不让模型常驻,它就会成为整个问答的延迟瓶颈;相关性阈值是个"需要校准的参数",你拍脑袋定,它要么放进太多噪声、要么误杀正确答案;chunk 大小是个"上游决定下游"的设置,它切得不好,再精的重排也救不回来;模型的语言领域匹配,是个"用错了精度就崩"的前提。这些都不是"接个 API"能自动搞定的,它们是你作为这条流水线的负责人,必须一环一环去拧的螺丝。但反过来说,正因为它是一条你能看清、能拆解的流水线,而不是一个黑箱,你才有办法把它做好:哪一环慢,你测得出来;哪一环漏,你定位得到;哪个参数不对,你调得动。把 RAG 检索当成一条需要逐环经营的流水线来对待,而不是一个一调就灵的黑箱,你才能真正用稳它。

关键概念速查

概念 说明
RAG 检索增强生成,先从知识库检索资料,再连同问题一起交给大模型
向量检索 把问题和文档各自编码成向量,按向量距离检索,快但精度有限
bi-encoder 双塔 问题与文档独立编码,文档向量可预先算好,所以快
cross-encoder 问题与文档拼在一起整体阅读直接打分,慢但精准
召回 recall 第一阶段,用向量检索从海量文档粗筛出一大批候选,求全
精排 rerank 第二阶段,用 cross-encoder 对候选精排,选出真正最相关的
召回 K 召回阶段取多少条候选,太小会把正确答案漏在候选池外
相关性阈值 重排分数低于它的候选判为不相关,直接丢弃不进上下文
上下文预算 按 token 上限装填资料,避免次相关内容淹没真正有用的段落
查询改写 把一个问题扩展成多个说法分别召回,弥补问法与文档写法的差异

避坑清单

  1. 不要把向量检索的 top-K 直接喂模型:它只是粗筛,不是精确的相关性排序。
  2. 不要靠调大 K 来提效果:粗筛不准时,K 越大混进的噪声越多。
  3. 不要省掉重排序:召回之后必须用 cross-encoder 精排一遍。
  4. 不要把召回 K 设太小:正确答案漏出候选池,重排也救不回来。
  5. 不要重排后无脑取前几条:要按相关性阈值截断,分不够的不要。
  6. 不要把检索到的资料全塞进上下文:按 token 预算装填,控制噪声。
  7. 不要逐条调用 cross-encoder:用批量推理,否则延迟会被放大几十倍。
  8. 不要每个请求都重新加载重排模型:进程启动时加载一次,常驻复用。
  9. 不要拍脑袋定相关性阈值:用人工标注样本校准出来。
  10. 不要忽视 chunk 大小和模型语言匹配:它们从上游决定重排的精度。

总结

回头看第一版那个"向量检索 top-K 直接喂模型"的 RAG,它的失败很典型。它不在某一行代码,而在一个对向量检索的根本误解:以为它返回的有序列表,就是一份精确可信的相关性排名。真相是,向量检索是 bi-encoder 的近似粗筛——它为了能在亿级文档里飞快地比对,把每段文档预先压缩成了一个有损的向量,这种压缩让它擅长"快速挡掉绝大多数无关的",却不擅长"在沾边的候选里精确分出高下"。第一版让这个粗筛器独自承担了"选出最终答案资料"的重任,于是真正含答案的段落被漏掉、似是而非的段落被选中,答案自然常常不对。

而把 RAG 检索做对,工程量并不小。它不是"调大 K、多塞资料"那么简单,而是要把检索拆成召回与精排两个阶段、要用 cross-encoder 对召回的候选做精排、要按相关性阈值把不合格的候选挡在上下文外、要按 token 预算克制地装填、要把召回 K 放到足够大并用查询改写堵住召回漏洞、还要做批量推理压延迟、让重排模型常驻内存、校准阈值、匹配好 chunk 大小和模型语言。一套真正可靠的 RAG 检索,是这些环节一个不少地拼起来的。

这件事其实很像一家公司招人。第一版的做法,像是 HR 用关键词在简历库里搜一下,搜出来排在最前面的 5 份,直接就发了 offer——可关键词匹配只能筛掉明显不沾边的,它分不清"简历里堆了一堆热门词"和"这个人真的能干这活"。聪明的招法是分两步:第一步海选,HR 用关键词、用基本条件,从一万份简历里飞快地筛出两百份,这一步要的是"别把好苗子漏掉",粗一点没关系;第二步面试,面试官把每个候选人请来,结合这个具体岗位,一对一地深谈,这一步慢,但能真正看出谁合适——这就是召回与精排的分工。面试完还有两件事:一是定线,达不到这条线的,哪怕是面过的人里最好的,也宁可不招,空着岗也好过招个不合适的(相关性阈值);二是名额有限,岗位就两三个,不能因为面了二十个就硬塞二十个进来(上下文预算)。而最关键、最容易被忘记的一条是:面试官再厉害,也只能从进了面试的那两百人里挑——海选那一步漏掉的人,面试官这辈子都见不到他。所以海选宁可放宽,招到好人才,靠的从来不是"面试官一个人神通广大",而是海选够全、面试够准,两步配合到位。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测 RAG,来来回回就拿那么几个问题,而且这几个问题,往往是你照着知识库里某段文档的写法去问的——问法和文档高度贴合,向量检索这种粗筛器也能轻松命中,你会觉得"top-K 直接喂模型,挺准的嘛"。真正会把问题撑爆的,是上线后的真实用户:他们用五花八门的、口语化的、和文档写法对不上的说法来提问,你那个只靠向量检索的粗筛,会大面积地把真正的答案漏在 top-K 之外;他们会问那些答案藏在某段不起眼文档里的刁钻问题,把你"K 调大就行"的幻觉打碎;海量真实问答里,似是而非的段落会反复污染上下文,让答案飘忽不定。这些场景,你本地几个贴合的测试问题,一个都模拟不到。所以如果你正在搭一个 RAG 系统,别等用户开始抱怨"它答得驴唇不对马嘴",才回头怀疑你的检索方式。在写下第一行检索代码时就想清楚:向量检索只是粗筛,我有没有一道 cross-encoder 精排、我的召回 K 够不够大不会漏、我有没有按相关性阈值和上下文预算去截断——把"检索到一些资料"和"检索到真正能回答这个问题的那几段资料"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

CDN 缓存命中率完全指南:从一次"加了 CDN 命中率却几乎为零"看懂为什么边缘节点是空的

2026-5-22 22:50:13

技术教程

Java 线程池配置完全指南:从一次"线程池把服务拖到 OOM"看懂为什么不能用 Executors

2026-5-22 23:06:32

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索