RAG 的召回环节给什么,模型就吃什么。召回顺序错了,后面全错。
重排(rerank)是修正顺序的那一步。我把三种重排方案在同一个知识库上跑了一遍,结论和预期不同:本地跑一个 266MB 的量化模型,精度追平了调大模型 API,而且快 0.66 秒。
先给结论:
- 难集 10 条上,不重排和关键词重排都是 90% hit@1;大模型重排和本地交叉编码器都是 100% 。
- 大模型重排 1.87 s ,本地交叉编码器 1.21 s,差 0.66 秒。
- 差在不用走网络------大模型重排每次检索多一次完整模型调用,本地重排只有几毫秒。
- 代价是 690MB RSS ,而且这部分内存分配在堆外 ,
jvm.memory.used看不到。 - 一个容易翻车的细节:重排的输入窗口不足会让 hit@1 从 100% 掉到 90%。
一、实验条件
| 项 | 值 |
|---|---|
| JDK | 17 |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| 生成模型 | deepseek-chat(兼容别名,实际路由 deepseek-flash),temperature 0.2 |
| Embedding | BGE-small-zh-v1.5(Xenova ONNX,512 维,mean pooling) |
| 向量库 | SimpleVectorStore(内存,暴力检索) |
| 知识库 | 4 篇 Markdown(退货政策 / 配送时效 / Spring AI 基础 / RAG 流程),chunk-size 300 切完 6 块 |
| 检索参数 | candidate-k = 5,top-k = 3 |
| 评测集 | 简单集 10 条 / 难集 10 条(同义改写、口语化、跨概念) |
| 日期 | 2026-10-21 |
知识库只有 4 篇文档、6 个块------这个规模在后文会成为一个解释。
二、四组结果
难集 10 条
| 模式 | hit@1 | hit@3 | MRR | 平均耗时 |
|---|---|---|---|---|
| none(不重排) | 90% | 100% | 0.933 | 1.07 s |
| lexical(关键词) | 90% | 100% | 0.950 | 1.07 s |
| llm(调大模型) | 100% | 100% | 1.000 | 1.87 s |
| cross(本地交叉编码器) | 100% | 100% | 1.000 | 1.21 s |
简单集 10 条
| 模式 | hit@1 | 平均耗时 |
|---|---|---|
| lexical | 100% | 1.07 s |
| llm | 100% | 1.87 s |
| cross | 100% | 1.09 s |
两张表要一起看。 lexical 在简单集是 100%,到难集掉到 90%------它修好了 h-spring-2(「模型怎么决定调用哪个函数」,问题里没有「工具调用」这类关键词),却把 h-shipping-1 挤到第 2 名。
关键词重排不是单调改进,bigram 覆盖率会误匹配。
平均耗时包含什么
1.07s / 1.21s / 1.87s 都是召回 + 重排 + 生成三步的总和,不是重排本身的耗时。
这一点决定了第三节的结论:cross 的本地推理只占几毫秒,真正的时间在生成。
三、为什么本地更快
大模型重排 :把问题和 5 个候选块拼成一个 prompt,发给模型让它排序。每检索一次,就多一次完整的模型调用。
本地交叉编码器:把 query 和每个 doc 拼起来过一次模型,输出一个 0~1 的相关性分数。模型在本地,266MB 量化版,跑 5 次几毫秒。
两者做的事情一样(都是 query+doc 联合打分),区别只在模型在哪跑。
所以 0.66 秒的差距不是「本地模型更强」,是「不用走网络」。
顺带一个判断依据:如果你们的重排是「调 API 给候选打分」,那它本质上是在为每次检索付一次模型调用。按题目量算成本时,这一项容易被漏掉。
四、交叉编码器实现
Spring AI 2.0.1 里没有现成的 RerankModel 接口,需要自己写。
src/main/java/.../service/rag/CrossEncoderReranker.java
java
public List<Document> rerank(String question, List<Document> candidates, int topK) {
if (candidates.size() <= topK) {
return candidates;
}
try {
CrossEncoderScorer active = scorer();
List<ScoredDocument> scored = candidates.stream()
.map(document -> new ScoredDocument(document, active.score(question, document.getText())))
.sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
.toList();
log.info("cross rerank: query={}, top={}", question, describe(scored, topK));
return scored.stream().limit(topK).map(ScoredDocument::document).toList();
} catch (RuntimeException ex) {
log.warn("cross rerank 失败,回退向量顺序: {}", ex.getMessage());
return candidates.stream().limit(topK).toList();
}
}
两个设计点:
一、候选数不超过 topK 时直接返回。 只有 5 个候选、要 3 个,也得排;但如果候选本来就不多于 topK,排序没有意义,省一次模型加载。
二、打分异常时回退向量顺序。 这一条很重要:模型下载失败、ONNX 加载出错、内存不足,任何一种情况都不该让问答链路挂掉。回退到原来的向量顺序,效果变差但服务还在。
懒加载与失败退避:
java
private CrossEncoderScorer scorer() {
CrossEncoderScorer existing = scorer;
if (existing != null) {
return existing;
}
if (failedAtNanos != 0 && System.nanoTime() - failedAtNanos < RETRY_BACKOFF_NANOS) {
throw new IllegalStateException("reranker 加载失败,退避期内不再重试");
}
synchronized (this) {
if (scorer == null) {
try {
scorer = scorerFactory.get();
} catch (RuntimeException | LinkageError ex) {
failedAtNanos = System.nanoTime();
throw ex instanceof RuntimeException runtimeException
? runtimeException
: new IllegalStateException("reranker 原生库加载失败", ex);
}
}
return scorer;
}
}
RETRY_BACKOFF_NANOS 是 5 分钟。没有这个退避,模型下载失败时每个请求都会重试一次 266MB 的下载,请求线程直接被拖死。
模型本身:
| 项 | 值 |
|---|---|
| 模型 | Xenova/bge-reranker-base 的 model_quantized.onnx |
| 大小 | 279,301,077 字节(约 266 MB) |
| 分词器 | 17,098,079 字节 |
| 输入 | input_ids / attention_mask,int64 [1, seq],截断到 512 |
| 输出 | float[1,1] logit,sigmoid 后得到 0,1 |
| 缓存命中后加载 | 619 -- 803 ms |
五、分数的两个坑
5.1 区分度比向量高得多
问题「拆封之后还能不能退?」的三个候选:
| 文档 | cross 分数 |
|---|---|
| refund-policy.md(正确) | 0.869 |
| shipping.md | 0.001 |
| spring-ai.md | 0.000 |
对比向量检索:分数通常挤在 0.45 -- 0.65,排序判断很勉强。
交叉编码器因为是 query+doc 联合编码,分数拉开得非常彻底。
5.2 但绝对分数不能跨查询比较
同一个 refund-policy.md,两条不同的问题分别打到 0.222 和 0.869。
所以不要用固定阈值判断「资料是否充足」。 0.222 不代表文档无关,只代表这条问法下它排得低。
如果你的系统里写了 if (score < 0.5) return "资料不足",这条规则是错的。
六、截断导致翻车
大模型重排的第一版,我只把候选的前 200 字喂给模型。
shipping.md 的「订单号」小节在文档末尾,200 字内看不到,shipping-3 被 spring-ai.md 抢到 top-1。
| 候选窗口 | hit@1 | hit@3 | MRR |
|---|---|---|---|
| 前 200 字 | 90% | 100% | 0.950 |
| 前 600 字 | 100% | 100% | 1.000 |
对应配置项 app.rag.rerank-snippet-length。
教训不是「窗口要开大」,是「换模型之前先检查喂进去的内容是否完整」------我差点拿一个被截断的输入去评判模型能力,得出「大模型重排不行」的结论。
七、切块实验:指标测不出来
| chunk-size | hit@1 | hit@3 | MRR |
|---|---|---|---|
| 150 | 90% | 100% | 0.933 |
| 300 | 90% | 100% | 0.950 |
| 600 | 90% | 100% | 0.950 |
三组几乎一样。原因有两个:
- 4 篇文档太短,600 字接近「一篇一块」,切块变量没发力
- 指标按文件名判定,不看小节
第 2 点尤其值得说:chunk-size 150 时,refund 三题的 top-3 全部来自 refund-policy.md 的不同小节,不再混进数码、生鲜的内容------块级精度确实提升了,但「文件名对不对」这个指标看不到。
如果你的 RAG 评测只看「有没有召回正确的文档」,那么切块、重排带来的精度提升基本不可见。 要么把标签细化到小节/段落,要么直接做答案级评测。
八、内存:堆外分配
| 阶段 | RSS |
|---|---|
| Spring Boot 启动完成(含 embedding 模型) | 621 -- 698 MB |
| cross 首次加载 reranker 之后 | 1311 MB |
增量约 690 MB。
同期堆内:heapUsedBytes 403 MB。
ONNX Runtime 的模型内存和计算工作区分配在堆外 ,jvm.memory.used / Runtime.totalMemory() 都看不到。
如果你用堆内存做容量规划,会严重低估------实际占用是 RSS 的 1311MB,不是堆的 403MB。
GET /api/rag/stats 的实测快照:
json
{
"rerankMode": "cross",
"chunkCount": 6,
"models": [
{"role": "embedding", "fileSizeBytes": 94851877, "loaded": true},
{"role": "reranker", "fileSizeBytes": 279301077, "loaded": true, "loadMillis": 124316}
],
"memory": {"heapUsedBytes": 403357072, "rssBytes": 1311588352}
}
首次 loadMillis 124316 ms 里绝大部分是 266MB 下载,不代表模型加载性能。
九、三个工程坑
- 不要假设 Spring AI 缓存返回的文件类型。
ResourceCacheService返回FileUrlResource,用instanceof FileSystemResource判断会落空,导致 90MB 的模型被复制成临时文件。改成直接Resource.getFile()。这个 bug 是靠加载日志里的文件名发现的。 - 懒加载必须配超时和退避。 下载没有连接/总时长超时,失败后每次请求都重试,请求线程会被拖死。
- 测试里要关掉 embedding。
spring.ai.model.embedding=none,否则跑单测会下载 90MB 模型。
十、局限
- 知识库只有 4 篇文档、6 个块。 这个规模下召回本身不是瓶颈,结论不能外推到大规模语料。
- 评测集各 10 条。 只能说方向,谈不上显著。
- 指标按文件名判定,粒度太粗,切块收益不可见。
- 平均耗时含生成步骤,所以「cross 快 0.66 秒」衡量的是整条链路,不是重排本身。
- 单一模型、单一硬件(macOS / Apple Silicon),ONNX 在其他平台的推理耗时未测。
- 690MB 是 RSS 增量,不同量化版本和硬件可能不同。
十一、怎么选
| 场景 | 选择 | 理由 |
|---|---|---|
| 刚跑通,不追求准确 | 不重排 | 零成本 |
| 问题偏简单 | lexical | 零成本,简单集 100% |
| 问题难、内存给得起 | cross | 100%,比调 API 快 0.66s |
| 没有本地模型 / 临时验证 | llm | 慢但不用下载模型 |
配置方式:
yml
app:
rag:
rerank: ${RAG_RERANK:none} # none / lexical / llm / cross
candidate-k: 5
rerank-snippet-length: 600 # 别设成 200
arduino
RAG_RERANK=cross ./mvnw spring-boot:run
curl -s http://127.0.0.1:8080/api/rag/stats
默认仍然是 none。 690MB 不是所有部署环境都给得起,所以 cross 做成显式开关,而不是默认路径。
如果你们也在做 RAG 重排,欢迎说说用的什么方案。我这边只在中文小语料上测过, 大规模语料下本地推理的相对优势可能会变------那个场景我没数据。
这是「入职日志」系列的第 6 篇。完整连载在同名公众号,从第 1 篇看更连贯。