☰
Spring AI 集成 ONNX 本地嵌入模型:零成本构建 RAG 知识库
2026/10/12 18:08:16 网站建设 项目流程

1. 为什么要在本地跑嵌入模型

做过知识库问答的人都有一个共识:检索质量的上限,往往不是由大模型决定的,而是由嵌入模型决定的。你喂给向量库的文本被转成什么样的坐标,直接决定了后面能不能把正确的片段捞出来。很多人一上来就调云端嵌入接口,跑通 Demo 很快,但真到要处理几万份内部文档的时候,账单和延迟就开始咬人了。

我去年帮一个团队做内部知识库,文档量大概四万多个切片。一开始用的是某云端嵌入服务,按 token 计费,测试阶段没感觉,全量灌库那天晚上跑完一看消耗,负责预算的同事脸都绿了。更麻烦的是每次增量更新都要走网络,内网环境还得单独开通道,运维那边一堆审批。后来我们换成本地 ONNX 嵌入,一台普通的开发机,CPU 推理,灌库时间从原来的几个小时压到几十分钟,成本直接归零,而且整个链路完全离线,数据不出内网。

这就是标题里说的“零成本起步”的真实含义。它不是指硬件不要钱,而是指没有按量计费的边际成本,你跑一万次和跑一百万次,电费差别可以忽略。ONNX 这个格式的好处在于,它把训练框架和推理运行时解耦了。模型可以用 PyTorch 训练导出,也可以用现成的 sentence-transformers 转换,最后统一成.onnx文件,用 ONNX Runtime 加载。ONNX Runtime 对 CPU 的优化做得相当扎实,INT8 量化之后,一个 300MB 左右的嵌入模型在普通笔记本上也能跑到每秒几百个切片。

Spring AI 在这个链路里扮演的是胶水层的角色。它把文本切分、嵌入调用、向量库写入、相似度检索这几步串成一条流水线,你只需要配置好EmbeddingModel和VectorStore两个 Bean,剩下的编排它帮你做了。但 Spring AI 默认的嵌入实现大多指向云端 API,想用本地 ONNX 就得自己接一层。这个接入过程不难,但有几个坑点,比如 tokenizer 的对齐、池化方式的选择、归一化要不要做,这些细节如果搞错,检索结果会莫名其妙地差,而且很难排查。

这篇文章适合两类人看:一类是正在做 RAG 知识库、被云端嵌入成本或延迟卡住的开发者;另一类是想把 Spring AI 用起来、但不想依赖外部服务的后端工程师。我会从方案选型讲到具体接入,再到实操中踩过的坑,尽量把每一步的“为什么”说清楚,让你看完能直接抄作业。

2. 方案选型:ONNX 嵌入到底怎么选

2.1 嵌入模型的三条技术路线对比

在决定用 ONNX 之前,先搞清楚市面上嵌入模型的几种形态,不然容易选错方向。我把常见的路线整理成下面这张表,方便你对照自己的场景。

路线代表形态优点缺点适用场景
云端 API各类在线嵌入服务开箱即用,模型强按量计费,依赖网络,数据出境快速验证、小规模
本地 Python 服务sentence-transformers + FastAPI模型选择多,生态好需要维护 Python 环境,内存占用高已有 Python 栈的团队
本地 ONNX导出后的.onnx模型无 Python 依赖,CPU 友好,可量化需要自己处理 tokenizerJava/Go 等非 Python 后端

我选 ONNX 的核心理由是部署形态干净。你的 Spring Boot 应用本来就是一个 jar 包,如果为了嵌入再拖一个 Python 运行时进去,镜像体积、启动时间、依赖冲突都是问题。ONNX Runtime 有官方的 Java 绑定,直接作为 Maven 依赖引入,跟其他库没有冲突。而且 ONNX 模型可以量化,INT8 之后体积和内存占用都能砍掉一大半,这对容器化部署很关键。

2.2 模型尺寸与维度的取舍

嵌入模型不是越大越好。我见过有人直接上 1024 维甚至 1536 维的大模型,结果向量库膨胀得飞快,检索反而变慢。维度和效果之间有个性价比拐点,需要根据你的文档特点来定。

一般来说,384 维的模型(比如 MiniLM 系列)在中文短文本检索上已经够用,模型文件大概 90MB 左右,量化后 20 多 MB。768 维的模型(比如 BGE-base 系列)效果更稳,适合文档片段较长、语义复杂的场景,模型文件 400MB 上下。再往上到 1024 维,提升就没那么明显了,但存储和计算成本是线性增长的。

我的建议是:先用 384 维跑通全流程,用真实 query 测一批召回率,如果 top-5 里经常漏掉正确片段,再考虑换 768 维。不要一上来就堆大模型,调优的空间反而被压缩了。

2.3 中文场景下的模型选择要点

中文嵌入和英文有个明显区别:分词粒度。英文按空格切词很自然,中文如果 tokenizer 处理不好,一个词被拆成单字,语义就散了。所以选模型的时候,一定要确认它的 tokenizer 是针对中文优化过的,或者至少是多语言版本。

另外要注意最大序列长度。很多模型默认 512 token,如果你的文档切片超过这个长度,会被截断,尾部信息直接丢失。切片策略要和模型长度对齐,这个后面会细讲。还有一个容易忽略的点是池化方式:有的模型用 CLS token 做句向量,有的用 mean pooling。导出 ONNX 的时候如果池化层没带上,你得在 Java 侧自己实现,这一步做错,向量方向就全偏了。

3. 核心细节:从模型文件到 Spring Bean

3.1 ONNX 模型的导出与量化

假设你已经选好了一个 sentence-transformers 格式的模型,第一步是把它转成 ONNX。这个过程在 Python 侧做一次就行,产出的.onnx文件可以跟着你的项目一起打包,或者放到对象存储里按需下载。

导出的时候有几个参数必须显式指定。opset版本建议用 14 以上,兼容性好;input_names和output_names要固定下来,Java 侧调用时靠这个名字找输入输出;最重要的是把池化和归一化一起导出,这样 Java 侧拿到的就是最终的句向量,不用再自己算。

from transformers import AutoTokenizer, AutoModel import torch model_name = "your-local-model-path" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) dummy = tokenizer("测试文本", return_tensors="pt", padding=True, truncation=True, max_length=512) torch.onnx.export( model, (dummy["input_ids"], dummy["attention_mask"]), "embedding.onnx", input_names=["input_ids", "attention_mask"], output_names=["last_hidden_state"], dynamic_axes={ "input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, }, opset_version=14, )

导出之后建议做一次 INT8 量化,体积能压到原来的四分之一左右,CPU 推理速度提升明显。量化会带来一点点精度损失,但在检索任务上通常感知不到。量化用 ONNX Runtime 自带的工具就行,一条命令的事。

注意:量化后的模型在 Java 侧加载时,要确保 ONNX Runtime 版本和量化工具版本匹配,否则可能报算子不支持。我踩过一次,量化用的是 1.16 的工具,运行时是 1.14,加载直接失败。

3.2 Tokenizer 的 Java 侧对齐

这是整个接入过程中最容易翻车的地方。Python 侧用 HuggingFace tokenizer 把文本转成 id 序列,Java 侧必须用完全一致的逻辑还原这个过程,否则同一个词两边切出来的 id 不一样,向量自然对不上。

我的做法是直接用 HuggingFace 的tokenizers库导出一个tokenizer.json,然后在 Java 侧用ai.djl.huggingface或者com.robrua.nlp这类库加载。DJL 的 tokenizer 实现和 HuggingFace 对齐得比较好,支持 BPE 和 WordPiece,中文也能正确处理。

加载之后要验证一遍:拿几句典型的中文文本,Python 侧和 Java 侧分别 encode,对比 id 序列是否完全一致。这一步花十分钟,能省掉后面几小时的排查。我当时的验证用例包括纯中文、中英混合、带标点、超长截断这几种情况,全部对齐之后才继续往下走。

3.3 实现自定义 EmbeddingModel

Spring AI 的EmbeddingModel接口很简洁,核心就是embed(String text)和embed(List<String> texts)两个方法。你要做的是把 ONNX Runtime 的推理封装进去。

public class OnnxEmbeddingModel implements EmbeddingModel { private final OrtSession session; private final HuggingFaceTokenizer tokenizer; @Override public float[] embed(String text) { long[] ids = tokenizer.encode(text); long[] mask = new long[ids.length]; Arrays.fill(mask, 1L); // 构造 OnnxTensor,执行推理,取 mean pooling 结果 // 最后做 L2 归一化 return normalize(pooling(output)); } }

几个关键点:batch 推理要支持,一次传多个文本比循环单条快很多;归一化一定要做,余弦相似度依赖单位向量;异常处理要细,tokenizer 遇到空字符串或者超长文本要有兜底逻辑。

4. 实操全流程:从零搭一个本地检索链路

4.1 项目依赖与目录结构

先把 Maven 依赖理清楚。核心是三个:Spring AI 的 starter、ONNX Runtime 的 Java 包、以及 tokenizer 库。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> </dependency> <dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.16.3</version> </dependency> <dependency> <groupId>ai.djl.huggingface</groupId> <artifactId>tokenizers</artifactId> <version>0.27.0</version> </dependency>

目录结构上,我把模型文件和 tokenizer 文件放在src/main/resources/model/下面,打包进 jar。如果模型太大不想进 jar,就放外部目录,用配置项指定路径。我倾向于后者,因为模型更新的时候不用重新打包。

4.2 文本切分的参数计算

切分策略直接决定检索效果。切太碎,语义不完整;切太大,噪声多,而且可能超过模型长度。我的经验值是每片 300 到 500 个中文字符,重叠 50 到 80 个字符。

为什么要有重叠?因为句子边界不一定落在切片边界上。比如一个关键结论横跨两个切片,没有重叠的话,两个切片各拿一半,检索时都匹配不上。重叠部分相当于给边界处上了保险。

计算切片数量的时候要考虑 token 和字符的比例。中文大致是 1 个 token 对应 1.5 到 2 个字符,所以 500 字符大概 250 到 330 个 token,留足余量给 512 的上限。如果你的文档里有大量英文或代码,这个比例会变,需要单独测一下。

4.3 灌库与检索的完整代码

灌库的流程是:读文档 → 切分 → 批量嵌入 → 写入 VectorStore。Spring AI 的VectorStore.add()接收的是Document列表,每个 Document 带内容和元数据。

List<Document> docs = splitter.split(rawText); List<Document> embedded = embeddingModel.embed(docs); vectorStore.add(embedded);

检索的时候,把用户 query 用同一个嵌入模型转成向量,然后调vectorStore.similaritySearch()。这里有个细节:query 和文档必须用同一个模型,不能一个用本地 ONNX 一个用云端,维度对不上直接报错。

SearchRequest request = SearchRequest.query(userQuery).withTopK(5); List<Document> results = vectorStore.similaritySearch(request);

topK 的选择也有讲究。太小容易漏,太大引入噪声。我一般先用 5 到 10 测召回,如果正确片段经常排在 3 名开外,说明嵌入质量有问题,得回去调模型或切分策略,而不是盲目加大 topK。

4.4 性能实测数据

在普通开发机(8 核 CPU,16G 内存)上,我测了一组数据供参考。384 维模型,INT8 量化,batch size 设为 16。

环节耗时说明
模型加载约 1.2 秒启动时一次性
单条嵌入约 8 毫秒短文本
批量嵌入(16 条)约 45 毫秒平均每条 2.8 毫秒
万级切片灌库约 3 分钟含切分和写入
单次检索约 15 毫秒topK=5

这个性能对绝大多数内部知识库场景都够用了。如果你的文档量到百万级,瓶颈会在向量库的索引上,那时候要考虑用支持 HNSW 的向量库,而不是纠结嵌入速度。

5. 常见问题与排查技巧

5.1 检索结果差,先查这三个地方

检索效果不好,很多人第一反应是换模型,其实大部分问题出在链路细节上。我整理了一个排查顺序,按这个走能省不少时间。

现象可能原因排查方法
完全不相关tokenizer 不对齐对比 Python 和 Java 的 id 序列
部分相关池化方式错误检查是否用了 mean pooling
排序混乱未做归一化确认向量模长是否为 1
长文本漏召回切片超长被截断统计切片 token 分布

tokenizer 不对齐是最隐蔽的,因为程序不报错,只是结果差。我建议在启动时加一个自检:用固定的测试文本跑一遍嵌入,和预存的基准向量对比余弦相似度,低于 0.99 就告警。

5.2 内存与并发问题

ONNX Runtime 的 session 是线程安全的,可以多线程共享。但要注意OrtSession的创建比较重,不要每次请求都新建。我的做法是用 Spring 的单例 Bean 持有 session,全局一个实例。

内存方面,384 维模型加载后大概占 200MB 左右,768 维的会到 800MB。如果容器内存紧张,可以调 ONNX Runtime 的intra_op_num_threads参数限制线程数,减少内存开销。另外,批量嵌入的时候 batch size 不要设太大,16 到 32 比较稳,太大反而会因为内存拷贝拖慢速度。

5.3 模型更新的平滑切换

模型换了之后,旧向量和新向量不在同一个空间,必须全量重灌。这个操作要设计成可回滚的:新模型灌到一个新的 collection,验证检索效果没问题之后,再切流量过去。Spring AI 的 VectorStore 支持指定 collection 名称,切换的时候改配置就行,不用动代码。

提示:重灌期间旧 collection 保持可读,避免服务中断。灌完做一轮 A/B 对比,用同一批 query 看召回率变化,确认提升再切。

6. 几个我踩过的坑和对应解法

第一个坑是量化模型的算子兼容性。前面提过,量化工具和运行时版本要匹配。除此之外,有些模型导出时用了动态量化,Java 侧加载会报NOT_IMPLEMENTED。解决办法是导出时用静态量化,或者干脆不量化,用 FP32 跑,性能差距在可接受范围内。

第二个坑是中文标点的影响。有些 tokenizer 会把中文标点单独切出来,导致句子被切得很碎。我在预处理阶段做了一步标点归一化,把全角标点转半角,连续标点合并,嵌入质量有肉眼可见的提升。

第三个坑是空文本和超短文本。切片过程中难免产生空字符串或者只有一两个字的片段,这些片段嵌入出来是噪声,会污染检索结果。我在灌库前加了一层过滤,长度小于 10 个字符的直接丢弃。

第四个坑是向量库的维度配置。换模型的时候忘了改 VectorStore 的维度配置,写入直接报错。这个错误信息还算明确,但如果你用的是自动建表的向量库,可能表已经建好了,改维度要删表重建,数据就没了。所以换模型前一定要确认维度配置同步更新。

7. 后续可以怎么扩展

跑通基础链路之后,有几个方向可以继续优化。一是混合检索,把向量检索和关键词检索的结果做融合,对专有名词和代码片段的召回提升明显。二是重排序,在向量召回 top-20 之后,用一个小的交叉编码器做精排,取 top-5 给大模型,效果比单纯加大 topK 好得多。三是增量更新,文档变动时只重灌变化的切片,而不是全量重跑,这个需要给每个切片维护一个内容哈希。

重排序模型同样可以走 ONNX 路线,只是它需要 query 和文档成对输入,推理成本比嵌入高,适合放在召回之后的小候选集上。我实测下来,加一层重排序,top-5 的准确率能提升十几个百分点,代价是每次检索多几十毫秒,这个 trade-off 在大多数场景下是划算的。

最后分享一个小技巧:嵌入模型的输出向量建议存一份到本地文件做备份。向量库如果因为配置问题需要重建,有备份的话可以直接导入,不用重新跑一遍嵌入。这个备份在调试阶段特别有用,你可以快速切换不同的向量库实现做对比,而不用每次都等灌库。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询