云计算百科
云计算领域专业知识百科平台

RAG 重排候选取多少条?K 从 10 加到 800,precision@10 由 0.29–0.38 升到 0.97–0.99(3 轮实测)

摘要

3 万条合成向量、100 条查询、3 个种子:不重排时 precision@10 只有 0.293–0.381;重排深度 K=100 时 0.775–0.820,K=200 时 0.879–0.909,K=800 才到 0.972–0.990。每翻一倍 K 的增量从 +0.17–0.19 掉到 +0.03–0.05,而候选重算耗时从 0.006–0.013 ms 涨到 0.044–0.046 ms,拐点在 100–200。

一、K 决定的是"重排能看到什么"

RAG 的检索一般是两段:向量索引或召回模型先给出 K 条候选,重排模型(cross-encoder,或带业务约束的打分器)再从这 K 条里挑出最终 top-N。K 取多少,网上常见 50、100、200 三种说法,但很少给出可复现的曲线。

K 有两个方向的错。取小了,相关文档根本没进候选,重排再强也变不出来;取大了,每个候选都要过一次重排前向,成本按 K 线性涨。所以 K 的合理值不是拍出来的,而是被两个量夹出来的:一阶段候选覆盖率的上限,以及你愿意为每条候选付多少钱。

这篇文章把"候选深度"单独隔离出来,量了一条从 K=10 到 K=800 的曲线,并给出定 K 的步骤。

二、实验设置:只动 K 这一个变量

合成数据:3 万条 64 维文档向量,200 个主题。文档向量 = 主题中心 + 每维 0.09 的噪声 + 0.35 × aspect × 公共方向。每个文档带一个二元属性 aspect,查询也带目标 aspect,相关文档定义为"同主题且 aspect 相同",每个查询平均 74.1–76.0 条。

三个阶段:

  • 一阶段:把 64 维随机投影到 16 维,模拟索引里只存压缩或低维向量,按该分数取 top-K;
  • 二阶段(重排替身):先精确匹配 aspect,再按 64 维全精度余弦排序,取前 10。它不是 cross-encoder,只是"重排器能读懂结构化约束 + 全精度打分"的最小替身;
  • 对照:不做重排,直接取一阶段 top-10。

为什么用合成数据而不是现成语料?因为要单独动 K,就必须让"相关性"有确定答案。真实语料里的人工标注会把标注噪声一起带进来,K 从 10 加到 800 的差异可能被噪声吃掉。这里的 aspect 属性就是一个人工可控的"结构化约束",它同时出现在查询和文档里,一阶段的压缩向量只能部分感知,二阶段能精确读取——这个差异正是重排价值的来源。

3 个随机种子各跑一轮,每轮独立进程,表中给区间。环境是 Python 3.11.5 + numpy 2.4.6,Windows 下需要 PYTHONIOENCODING=utf-8 才能正常输出中文。

先说边界:这些数字证明的是排序机制的走向,不代表任何具体 embedding 模型或向量库的效果;毫秒数只代表本机 numpy 的算力。你自己业务的 K,必须用你的数据重跑。

三、精度:100 是明显的拐点

表 1:重排深度与 precision@10(3 轮区间)

Kprecision@10候选里的相关文档数(均值)
不重排 0.293–0.381 2.9–3.8
10 0.293–0.381 2.9–3.8
20 0.475–0.571 5.5–7.2
50 0.663–0.739 12.4–15.3
100 0.775–0.820 20.1–24.4
200 0.879–0.909 29.3–34.3
400 0.926–0.958 40.1–44.1
800 0.972–0.990 50.6–53.6

三点值得注意:

  • K=10 的重排和不重排完全一样(都是 0.293–0.381)。因为候选集合就是原来那 10 条,重排只能换顺序,换不出新文档。K 必须大于最终条数才有意义。
  • 每翻一倍 K 的增量:20→50 是 +0.17–0.19,50→100 是 +0.08–0.14,100→200 是 +0.06–0.11,200→400 是 +0.05–0.06,400→800 只剩 +0.03–0.05。拐点在 100–200 之间。
  • 800 条候选也到不了 1.0,因为一阶段候选本身就不全。继续加 K 是在逼近一阶段的天花板,不是解决重排的问题。
  • 表 1 最后一列可以直接解释第 1 点:K=10 时候选里平均只有 2.9–3.8 条相关文档,所以 precision@10 最多也就是 0.29–0.38;K=100 时这个数是 20.1–24.4,已经够填满 10 个位置,精度就由重排本身决定。换句话说,精度 = min(1, 候选里的相关数 / 10) × 重排排序质量,K 只影响前一项。这也意味着,当候选里的相关数已经超过 10 之后,再加深 K 的收益会明显变缓。

    四、延迟:重算耗时随 K 线性涨

    表 2:每查询耗时(毫秒,3 轮区间)

    K重排耗时总耗时(含一阶段扫描)
    10 0.006–0.013 0.020–0.028
    100 0.009–0.010 0.023–0.026
    400 0.023–0.025 0.037–0.041
    800 0.044–0.046 0.058–0.062

    一阶段扫描固定在 0.014–0.016 ms。这里必须强调:我测到的只是 numpy 的候选重算开销。生产里的 cross-encoder 是每个候选一次模型前向,单价可能是这里的几百上千倍,所以"K 从 100 加到 400"的真实代价要用你的重排单价去乘,不能拿这张表当成本结论。这张表能说明的只有一件事:重排是 O(K) 的,K 翻 8 倍,重排耗时也从 0.006–0.013 ms 涨到 0.044–0.046 ms。

    #mermaid-svg-QiHU3kahHuUdlQGO{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QiHU3kahHuUdlQGO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QiHU3kahHuUdlQGO .error-icon{fill:#552222;}#mermaid-svg-QiHU3kahHuUdlQGO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QiHU3kahHuUdlQGO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QiHU3kahHuUdlQGO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QiHU3kahHuUdlQGO .marker.cross{stroke:#333333;}#mermaid-svg-QiHU3kahHuUdlQGO svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QiHU3kahHuUdlQGO p{margin:0;}#mermaid-svg-QiHU3kahHuUdlQGO .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-QiHU3kahHuUdlQGO .cluster-label text{fill:#333;}#mermaid-svg-QiHU3kahHuUdlQGO .cluster-label span{color:#333;}#mermaid-svg-QiHU3kahHuUdlQGO .cluster-label span p{background-color:transparent;}#mermaid-svg-QiHU3kahHuUdlQGO .label text,#mermaid-svg-QiHU3kahHuUdlQGO span{fill:#333;color:#333;}#mermaid-svg-QiHU3kahHuUdlQGO .node rect,#mermaid-svg-QiHU3kahHuUdlQGO .node circle,#mermaid-svg-QiHU3kahHuUdlQGO .node ellipse,#mermaid-svg-QiHU3kahHuUdlQGO .node polygon,#mermaid-svg-QiHU3kahHuUdlQGO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QiHU3kahHuUdlQGO .rough-node .label text,#mermaid-svg-QiHU3kahHuUdlQGO .node .label text,#mermaid-svg-QiHU3kahHuUdlQGO .image-shape .label,#mermaid-svg-QiHU3kahHuUdlQGO .icon-shape .label{text-anchor:middle;}#mermaid-svg-QiHU3kahHuUdlQGO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QiHU3kahHuUdlQGO .rough-node .label,#mermaid-svg-QiHU3kahHuUdlQGO .node .label,#mermaid-svg-QiHU3kahHuUdlQGO .image-shape .label,#mermaid-svg-QiHU3kahHuUdlQGO .icon-shape .label{text-align:center;}#mermaid-svg-QiHU3kahHuUdlQGO .node.clickable{cursor:pointer;}#mermaid-svg-QiHU3kahHuUdlQGO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QiHU3kahHuUdlQGO .arrowheadPath{fill:#333333;}#mermaid-svg-QiHU3kahHuUdlQGO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QiHU3kahHuUdlQGO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QiHU3kahHuUdlQGO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QiHU3kahHuUdlQGO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QiHU3kahHuUdlQGO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QiHU3kahHuUdlQGO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QiHU3kahHuUdlQGO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QiHU3kahHuUdlQGO .cluster text{fill:#333;}#mermaid-svg-QiHU3kahHuUdlQGO .cluster span{color:#333;}#mermaid-svg-QiHU3kahHuUdlQGO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QiHU3kahHuUdlQGO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QiHU3kahHuUdlQGO rect.text{fill:none;stroke-width:0;}#mermaid-svg-QiHU3kahHuUdlQGO .icon-shape,#mermaid-svg-QiHU3kahHuUdlQGO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QiHU3kahHuUdlQGO .icon-shape p,#mermaid-svg-QiHU3kahHuUdlQGO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QiHU3kahHuUdlQGO .icon-shape .label rect,#mermaid-svg-QiHU3kahHuUdlQGO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QiHU3kahHuUdlQGO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QiHU3kahHuUdlQGO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QiHU3kahHuUdlQGO :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    太小

    合适

    过大

    查询

    一阶段:压缩向量取 top-K

    K 取多大

    相关文档没进候选重排无法补救

    二阶段:约束匹配 + 全精度重排

    最终 top-10

    每个候选一次前向成本线性上涨

    五、定 K 的可执行步骤

  • 先量一阶段的上限:用标注集算"真相关文档落在 top-K 里的比例",这就是重排能达到的极限。K 加到这个比例不再涨,再加就是纯浪费。
  • 定精度目标,再选最小 K:本实验里要到 0.90 左右,K 取 200(0.879–0.909);要到 0.80,K=100 就够(0.775–0.820)。
  • 乘重排单价做预算:成本约等于 查询量 × K × 单候选前向成本。先给一个延迟上限(比如 P99 只许增加 30 ms),再反推 K。
  • K 已经很大精度还低,先去查一阶段:本实验 800 条候选也只有 0.972–0.990,剩下的缺口来自召回,不来自重排。
  • 线上用"重排后 top-10 与人工标注的重合数"做回归,不要只看离线 recall——相关集大时 recall 天然很低,本实验每条查询约 75 条相关文档,recall@10 的理论上限只有 10/75。
  • 另外,K 的取值建议跟着查询类型走,而不是全局固定。闲聊类查询的相关文档多,K=50 就够;带约束的查询(时间、环境、权限)相关文档少,一阶段更容易漏,K 反而要加大到 200 以上。本实验之所以在 100–200 出现拐点,就是因为在这个噪声和维度下,候选里的相关数大约在这个深度才稳定超过 10。

    六、核心代码

    取候选与重排的关键逻辑(完整脚本见文末说明):

    def topk_idx(scores, k):
    """取分数最高的 k 个下标,按分数降序。"""
    if k >= scores.shape[0]:
    return np.argsort(–scores)
    part = np.argpartition(–scores, k)[:k]
    return part[np.argsort(–scores[part])]

    for i in range(N_QUERIES):
    cand = topk_idx(stage1[:, i], k)
    cand_gold += len(set(cand.tolist()) & set(gold[i].tolist()))

    t1 = time.perf_counter()
    sc = exact[cand, i]
    matched = (aspect[cand] == q_aspect[i])
    # 排序主键:aspect 是否匹配;次键:全精度余弦
    order = np.lexsort((–sc, –matched.astype(np.int8)))
    picked = set(cand[order[:TOP_N]].tolist())
    rerank_times.append((time.perf_counter() – t1) * 1000)
    hits += len(picked & set(gold[i].tolist()))

    np.lexsort 的最后一个键是主键,所以 (-sc, -matched) 表示"先按 aspect 匹配与否分组,组内再按余弦降序",不用写两层排序。

    七、避坑指南

    • 别拿"K=10 的重排"当重排收益:候选集合没变,precision@10 一模一样,看起来像"重排没用"。
    • 别用 recall 评估这条链路:相关文档多的时候,recall@10 的上限就是 10 除以相关总数,看着像效果很差,其实 precision 已经很高。
    • 别照抄网上的阈值:本实验的曲线由噪声、维度、簇数共同决定,换成真实 embedding,拐点会移动。
    • 候选先去重:同一文档的多个 chunk 会挤占 K 的名额,实际有效候选远小于 K。
    • 延迟要端到端测:只测打分会把网络、批处理、tokenize 的成本全漏掉。
    • 别把 K 和最终条数设成相等:那是"只换序",不如不加重排。
    • 别忽略候选的顺序信息:重排拿到的候选最好带上一阶段的分数,两者做特征融合通常比只用重排分更稳。
    • 别在压测里复用同一个查询:缓存和预热的命中会让 K 的差异看不出来,评估集要覆盖长尾。

    八、结论

    在这套合成数据上,重排深度从 10 加到 800,precision@10 从 0.29–0.38 升到 0.97–0.99,但 100 之后每翻一倍的收益只剩 0.03–0.11。先把一阶段覆盖率和重排单价量出来,再取满足精度目标的最小 K(本实验是 100–200),比记一个"行业推荐值"可靠得多。

    实验脚本:demos/rerank_depth_lab.py,输出留档:demos/rerank_depth_lab.transcript.txt。改动参数(噪声、投影维度、相关文档比例)后建议先小规模跑一轮,确认曲线形状再上全量。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » RAG 重排候选取多少条?K 从 10 加到 800,precision@10 由 0.29–0.38 升到 0.97–0.99(3 轮实测)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!