本地 266MB 模型,追平大模型 API,还快 0.66 秒

RAG 的召回环节给什么,模型就吃什么。召回顺序错了,后面全错。

重排(rerank)是修正顺序的那一步。我把三种重排方案在同一个知识库上跑了一遍,结论和预期不同:本地跑一个 266MB 的量化模型,精度追平了调大模型 API,而且快 0.66 秒。

先给结论:

  1. 难集 10 条上,不重排和关键词重排都是 90% hit@1;大模型重排和本地交叉编码器都是 100% 。
  2. 大模型重排 1.87 s ,本地交叉编码器 1.21 s,差 0.66 秒。
  3. 差在不用走网络------大模型重排每次检索多一次完整模型调用,本地重排只有几毫秒。
  4. 代价是 690MB RSS ,而且这部分内存分配在堆外 ,jvm.memory.used 看不到。
  5. 一个容易翻车的细节:重排的输入窗口不足会让 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

三组几乎一样。原因有两个:

  1. 4 篇文档太短,600 字接近「一篇一块」,切块变量没发力
  2. 指标按文件名判定,不看小节

第 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 下载,不代表模型加载性能。

九、三个工程坑

  1. 不要假设 Spring AI 缓存返回的文件类型。 ResourceCacheService 返回 FileUrlResource,用 instanceof FileSystemResource 判断会落空,导致 90MB 的模型被复制成临时文件。改成直接 Resource.getFile()。这个 bug 是靠加载日志里的文件名发现的。
  2. 懒加载必须配超时和退避。 下载没有连接/总时长超时,失败后每次请求都重试,请求线程会被拖死。
  3. 测试里要关掉 embedding。 spring.ai.model.embedding=none,否则跑单测会下载 90MB 模型。

十、局限

  1. 知识库只有 4 篇文档、6 个块。 这个规模下召回本身不是瓶颈,结论不能外推到大规模语料。
  2. 评测集各 10 条。 只能说方向,谈不上显著。
  3. 指标按文件名判定,粒度太粗,切块收益不可见。
  4. 平均耗时含生成步骤,所以「cross 快 0.66 秒」衡量的是整条链路,不是重排本身。
  5. 单一模型、单一硬件(macOS / Apple Silicon),ONNX 在其他平台的推理耗时未测。
  6. 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 篇看更连贯。

相关推荐
Dumbness10 分钟前
不传 day 参数,座位图显示全空:用 Python 标准库手写 Web 框架,请求到业务代码前的 5 个细节
后端·python
Swot11 分钟前
SvelteKit 踩坑记:+page.js 里忘了 ...data,服务端数据全没了
前端·后端·svelte
Csvn12 分钟前
Nginx 配置语法与上下文:一次搞懂继承、变量与用 map 替掉 if
后端·nginx
Arone聊架构16 分钟前
把 WeKnora v0.9 + 多站点客服工单塞进 1.9G 小鸡:248MB 内存压测、纯 Go 解析桥与 SQLite 写锁踩坑实录
后端
泡海椒23 分钟前
jquick-pdf 饼图(JPie)实战:Java 生成贷款五级分类资产质量报告
后端
学长毕设1 小时前
基于SpringBoot的景点管理系统-附源码
java·spring boot·后端
ZhengEnCi2 小时前
Trae 的 .trae 目录到底怎么用:rules、commands、skills 三层机制与一棵典型目录树的落地拆解
ai编程
ZhengEnCi2 小时前
Agent 查不出代码 Bug 怎么办:一份带时间戳的调试日志与读日志排查流程
python·ai编程
生锈的键盘3 小时前
从实战搞懂 Docker:一个 Python 项目的开发环境容器化笔记
后端