摘要
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 轮区间)
| 不重排 | 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 |
三点值得注意:
表 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 轮区间)
| 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 的可执行步骤
另外,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。改动参数(噪声、投影维度、相关文档比例)后建议先小规模跑一轮,确认曲线形状再上全量。
网硕互联帮助中心





评论前必须登录!
注册