☰
Spring AI 整合 ONNX 实现本地嵌入:从模型加载到向量检索全流程
2026/10/12 18:03:12 网站建设 项目流程

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

1.1 从一个真实的需求场景说起

去年年底我接手了一个内部知识库项目,需求很朴素:把团队积累的几百份技术文档、会议纪要、故障复盘做成一个能语义搜索的东西。一开始想得很简单,直接调云端嵌入接口不就行了?结果一算账就发现问题了——文档切片之后大概有十几万个 chunk,每次全量重建索引的 token 消耗量相当可观,而且后续每次增量更新都要走一遍网络请求。更麻烦的是,有些内部资料压根不适合往外发,数据出域这条红线直接卡死了云端方案。

这时候本地嵌入就成了唯一的选择。所谓本地嵌入,说白了就是在一个不依赖外部服务的环境里,把一段文本转换成一串固定长度的浮点数数组,也就是我们常说的向量或者坐标。这串数字代表了这段文本在语义空间里的位置,语义相近的文本,它们的坐标距离就近。检索的时候,把用户的问题也转成坐标,然后去找距离最近的几个文档坐标,就完成了语义搜索。

而ONNX在这里扮演的角色,是一个跨框架的模型推理格式。你可以把它理解成模型的“通用集装箱”——不管这个嵌入模型原本是用 PyTorch 还是 TensorFlow 训练的,导出成 ONNX 之后,就能用一个统一的运行时来加载和执行。对于 Java 技术栈来说,这一点尤其重要,因为 JVM 生态里原生跑深度学习模型的选择本来就不多,ONNX Runtime 提供了相当成熟的 Java binding,这就让 Spring 应用直接加载嵌入模型变成了现实。

1.2 本地 ONNX 嵌入到底解决了什么问题

我把本地 ONNX 嵌入的价值归纳成三条,都是实际踩过之后才体会到的。

第一是成本归零。云端嵌入按 token 计费,量小的时候感觉不到,量一大就是持续失血。本地模型跑在自己的 CPU 上,除了电费和机器折旧,边际成本几乎为零。对于知识库这种“建好之后还要反复重建、反复调参”的场景,成本优势非常明显。

第二是数据不出域。所有文本的向量化过程都在本地完成,原始文本和向量都不需要经过任何外部网络。对于有合规要求的内部系统,这一条基本是刚需。

第三是延迟可控。本地推理没有网络往返,虽然单次推理速度可能不如云端的大模型,但胜在稳定。尤其是批量建索引的时候,可以充分利用多核 CPU 并行,整体吞吐反而更可观。

当然,本地模型也有它的代价——模型体积、内存占用、推理速度都需要权衡。这就引出了下一个问题:模型怎么选。

1.3 模型选型的几个关键考量

选嵌入模型,我一般看四个维度:维度、体积、语言支持、推理速度。

维度决定了向量占用的存储空间和检索时的计算量。常见的嵌入维度有 384、512、768、1024 等。维度越高,表达能力通常越强,但存储和计算成本也线性上升。对于中文知识库,384 到 768 维基本够用。

体积直接关系到部署难度。一个几百 MB 的模型塞进容器镜像里,构建和分发都会变慢。我倾向于选择量化后的 ONNX 模型,体积能压到几十 MB 甚至更小。

语言支持是中文场景的命门。很多英文嵌入模型在中文上表现平平,必须选那些在中文语料上训练过的,或者至少是多语言的。

推理速度则和模型结构、量化方式有关。ONNX Runtime 支持多种图优化和量化,实际速度差异可能有好几倍。

综合下来,我最终选了一个多语言的小型嵌入模型,导出为 ONNX 并做了动态量化,维度 384,模型文件不到 50MB。这个选择在后续的实测中被证明是合理的——单条文本嵌入在普通笔记本 CPU 上大约 10 到 30 毫秒,批量处理时吞吐能到每秒几百条。

2. Spring AI 与 ONNX 的整合思路

2.1 Spring AI 的嵌入抽象层

Spring AI 提供了一套相对清晰的抽象,核心接口是EmbeddingModel。这个接口定义了两个主要方法:一个是把单条文本转成向量,一个是批量转换。围绕这个接口,Spring AI 还提供了向量存储(VectorStore)的抽象,负责向量的持久化和相似度检索。

理解这套抽象的意义在于:嵌入模型和向量存储是解耦的。你可以用本地的 ONNX 模型做嵌入,同时用内存向量库、关系型数据库或者专门的向量数据库做存储。这种解耦让技术选型变得灵活,也让我们可以专注于把本地嵌入这一环做扎实。

Spring AI 本身提供了一些云端嵌入模型的实现,但本地 ONNX 这一块需要我们自己动手。好在EmbeddingModel是个接口,实现起来并不复杂,核心工作就是加载 ONNX 模型、做分词、跑推理、归一化输出。

2.2 为什么是 ONNX 而不是其他格式

在 JVM 上跑模型,可选的路子其实有几条。一是用 DJL(Deep Java Library),它封装得比较好,但引入的依赖比较重;二是直接用 ONNX Runtime 的 Java API,轻量且可控;三是通过某种进程间通信调用 Python 服务,但这又引入了额外的部署复杂度。

我选 ONNX Runtime 直接集成,理由有三。第一,依赖干净,就一个onnxruntime的 jar 包加上它的原生库,没有一堆传递依赖。第二,控制力强,从输入张量的构造到输出张量的解析,每一步都在自己手里,出问题好排查。第三,性能可预期,ONNX Runtime 的图优化和线程管理都是成熟的,调优空间明确。

相比之下,DJL 虽然 API 更友好,但它对模型格式和分词器的封装有时候会带来“黑盒感”,遇到分词不一致或者输出异常时,排查起来反而更费劲。对于嵌入这种相对简单的模型,直接上 ONNX Runtime 是性价比最高的选择。

2.3 分词:本地嵌入最容易被忽视的一环

很多人以为加载了 ONNX 模型就万事大吉,结果跑出来的向量和预期对不上,问题十有八九出在分词上。

嵌入模型的输入不是原始文本,而是 token id 序列。不同的模型用不同的分词器,比如 BERT 系用 WordPiece,有些模型用 SentencePiece,还有些用 BPE。分词器必须和模型训练时用的完全一致,否则 token id 对不上,向量自然就是错的。

在 Python 生态里,分词器通常跟着模型一起提供,用transformers库加载就行。但在 Java 里,没有现成的等价物。我的做法是:在 Python 侧用官方分词器把一批文本的 token id 导出来,然后在 Java 侧实现一个等价的分词逻辑,用这批数据做回归测试。只要 Java 分词的结果和 Python 完全一致,就可以放心使用。

分词器本身通常是一个词表文件加一套规则。词表可以打包进资源目录,规则部分主要是文本规范化(比如转小写、去重音)、按子词切分、加特殊 token。这部分代码量不大,但必须仔细,一个字符的差异都可能导致结果偏移。

3. 从零搭建本地嵌入的完整实操

3.1 环境准备与依赖引入

先说依赖。核心就两个:ONNX Runtime 的 Java 包,以及 Spring AI 的嵌入抽象。Maven 里大概是这样:

<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.17.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> <version>1.0.0-M2</version> </dependency>

ONNX Runtime 的版本要和模型导出时的 opset 兼容,一般选比较新的稳定版就行。Spring AI 的版本迭代比较快,接口可能有变化,建议锁定一个版本,不要频繁升级。

模型文件、词表文件放到src/main/resources/models/下面。模型建议用动态量化版本,体积小、速度也快,精度损失在嵌入任务上通常可以接受。

3.2 模型加载与推理会话管理

ONNX Runtime 的推理入口是OrtSession。这个对象创建成本较高,而且不是线程安全的,所以正确的做法是:应用启动时创建一个OrtSession,然后用一个对象池或者ThreadLocal来管理并发访问。

我实测下来,单个OrtSession在多线程下直接共享会出问题,表现为结果错乱或者直接抛异常。用ThreadLocal<OrtSession>每个线程持有一个会话,内存开销可以接受,并发性能也稳定。如果并发量很大,可以考虑用对象池,但要注意会话的创建和销毁成本。

加载模型时,OrtSession.SessionOptions里可以配置线程数。对于嵌入这种计算密集但单次计算量不大的任务,把 intra-op 线程数设成 CPU 核心数,inter-op 线程数设成 1,通常是比较优的配置。这个参数需要根据实际机器压测调整,没有万能值。

3.3 分词器的 Java 实现要点

分词器的实现我拆成三步:规范化、预分词、子词切分。

规范化包括转小写、去除多余空白、处理全角半角。这一步看似简单,但中文文本里全角标点、零宽字符很常见,不处理干净会影响分词结果。

预分词是把文本切成初步的单元。英文按空格和标点切,中文按字切。这一步决定了后续子词切分的粒度。

子词切分是核心。以 WordPiece 为例,它采用贪心最长匹配:从当前位置开始,尝试匹配词表里最长的子词,匹配不到就回退一个字符,直到匹配成功或者只剩单字符。匹配不到的单字符用[UNK]代替。每个子词前面加##前缀表示它是词的延续部分。

实现的时候有个坑:词表的加载顺序和查找方式会影响性能。词表可能有几万个条目,用HashMap查找是 O(1),但最长匹配需要多次查找。我的做法是把词表按长度分组,先试长词再试短词,减少查找次数。

3.4 输入张量的构造与输出解析

嵌入模型的输入通常有三个张量:input_ids、attention_mask、token_type_ids。input_ids是 token 的 id 序列,attention_mask标记哪些位置是真实 token(1)哪些是填充(0),token_type_ids在单句任务里通常全 0。

张量的形状是[batch_size, sequence_length]。sequence_length要固定,通常是 128 或 256。短于这个长度的用 0 填充,长于这个长度的要截断。截断策略有从头截、从尾截、头尾各截一半,一般选头尾各截一半,保留首尾信息。

输出通常是一个[batch_size, sequence_length, hidden_size]的张量,或者已经池化好的[batch_size, hidden_size]。如果是前者,需要自己做池化——常见的是取[CLS]位置的向量,或者对所有 token 的向量做平均池化。具体用哪种,取决于模型训练时的设定,这个信息一般在模型的文档或者配置里能找到。

拿到向量之后,还有一步归一化。把向量除以其 L2 范数,使其长度为 1。归一化之后,余弦相似度就等价于点积,检索时计算更快。这一步很多教程会漏掉,但不做归一化,相似度排序可能会出问题。

4. 向量存储与检索的落地细节

4.1 向量存储的选型

向量存哪里,取决于数据量和检索要求。数据量小(几万条以内),直接用内存里的一个List<float[]>加暴力检索就够了,简单可靠。数据量大,就得上专门的向量索引。

我试过几种方案。内存暴力检索在十万条以内,单次查询几毫秒到几十毫秒,完全可用。再往上,暴力检索的延迟就线性增长了,这时候需要近似最近邻(ANN)索引。常见的 ANN 算法有 HNSW、IVF 等,各有取舍:HNSW 查询快、内存占用高;IVF 内存省、但需要训练且召回率略低。

对于 Spring AI 项目,如果已经在用某个向量数据库的 starter,直接用它提供的VectorStore实现就行。如果没有,自己基于内存实现一个也不难,核心就是余弦相似度排序。

4.2 相似度计算与 Top-K 检索

相似度计算用余弦相似度。因为向量已经归一化,余弦相似度就是点积:

float dot(float[] a, float[] b) { float sum = 0; for (int i = 0; i < a.length; i++) { sum += a[i] * b[i]; } return sum; }

Top-K 检索就是遍历所有向量,算相似度,维护一个大小为 K 的最小堆。K 一般取 3 到 10,看具体场景。检索出来的结果还要带上原始文本和元数据,方便展示和溯源。

这里有个优化点:批量查询。如果一次要查多个问题,可以把查询向量组成矩阵,和文档矩阵做矩阵乘法,比逐条算快得多。ONNX Runtime 本身也支持批量推理,建索引的时候把文本分批送进去,吞吐能提升好几倍。

4.3 索引构建与增量更新

建索引是个批处理任务。流程是:读文档、切片、批量嵌入、写入向量存储。切片策略很关键,切得太碎会丢失上下文,切得太大会稀释语义。我一般按段落切,每段控制在 200 到 500 字,段间可以留一点重叠,避免边界信息丢失。

增量更新要处理两个问题:新文档的加入和旧文档的删除。加入好办,嵌入之后追加就行。删除需要维护一个 id 到向量位置的映射,删除时把对应位置标记为无效,定期做一次压缩整理。如果用的是支持删除的向量数据库,这部分就交给数据库处理。

重建索引的时候,建议先建到临时索引,建完再原子替换,避免重建期间检索不可用。

5. 常见问题与排查技巧实录

5.1 向量结果和 Python 对不上怎么办

这是最常见的问题,排查顺序如下。

先查分词。把同一段文本在 Python 和 Java 里分别分词,打印 token id 序列,逐位对比。只要有一位不同,就顺着分词逻辑找差异。常见差异点:大小写处理、全角半角、特殊字符、截断长度。

再查张量构造。确认attention_mask是否正确,填充位置是否真的填了 0,token_type_ids是否全 0。这些细节错了,输出会整体偏移。

最后查池化和归一化。确认用的是[CLS]还是平均池化,归一化是否做了。这几步任何一步不一致,向量就会有差异。

5.2 推理速度慢的优化方向

速度慢,先看是不是每次推理都重新创建了OrtSession。如果是,改成复用,速度能提升一个数量级。

再看线程配置。intra-op线程数设成 CPU 核心数,inter-op设成 1,通常是最优的。如果设反了,性能会明显下降。

还可以考虑量化。动态量化把权重从 float32 压到 int8,速度能提升 2 到 4 倍,精度损失在嵌入任务上一般小于 1%。如果对精度要求极高,可以用静态量化,但需要校准数据集。

批量推理也是提速的关键。单条推理的固定开销占比高,批量处理能摊薄这个开销。实测批量大小设成 16 或 32,吞吐提升明显。

5.3 内存占用过高的处理

内存占用主要来自三块:模型本身、OrtSession、向量数据。

模型和会话的内存是固定的,量化能显著降低。向量数据的内存和数量、维度成正比,十万条 384 维的 float 向量大约 150MB,可以接受。如果维度更高或者数量更大,可以考虑用 float16 存储,内存减半,精度损失很小。

还有一个容易被忽视的点:JVM 堆外内存。ONNX Runtime 的原生内存不在 JVM 堆里,-Xmx管不到它。如果发现进程内存远超-Xmx设定,大概率是原生内存占用。可以通过SessionOptions限制内存 arena 的大小,或者定期重建会话释放内存。

5.4 常见问题速查表

现象可能原因排查方向
向量结果与 Python 不一致分词差异、张量构造错误、池化方式不同逐位对比 token id,检查 mask 和池化
推理速度慢会话未复用、线程配置不当、未量化复用会话,调整线程数,启用量化
内存占用高模型未量化、向量用 float32、原生内存泄漏量化模型,float16 存储,限制 arena
检索结果不相关未归一化、切片过大、模型不适配中文检查归一化,调整切片,换中文模型
并发下结果错乱OrtSession共享用 ThreadLocal 或对象池
启动报 opset 不兼容ONNX Runtime 版本与模型 opset 不匹配升级运行时或重新导出模型

6. 一些实操心得与扩展思路

6.1 我踩过的几个坑

第一个坑是分词器的特殊 token。有些模型在句首加[CLS],句尾加[SEP],有些只加[CLS]。加错了,向量会偏。这个必须对着模型文档确认,不能想当然。

第二个坑是截断长度。我一开始设了 512,结果发现大部分文本根本用不到这么长,反而拖慢了推理。后来改成 256,速度提升明显,检索质量几乎没变化。截断长度要根据实际文本长度分布来定,不是越长越好。

第三个坑是归一化的时机。我一开始在池化之后忘了归一化,检索结果排序乱七八糟。后来加上归一化,排序立刻正常了。这个细节很容易漏,但影响很大。

第四个坑是并发下的会话共享。我一开始图省事,全局共享一个OrtSession,单线程测试没问题,一上并发就出各种诡异结果。改成ThreadLocal之后彻底解决。

6.2 后续可以怎么扩展

本地嵌入搭好之后,扩展方向其实不少。

一是混合检索。向量检索擅长语义匹配,但对精确关键词的匹配不如传统倒排索引。把两者结合,用加权或者重排序的方式融合结果,检索质量能再上一个台阶。

二是重排序。先用向量检索召回一批候选,再用一个更精细的交叉编码器模型对候选做重排序。交叉编码器精度高但速度慢,只用在少量候选上,整体性价比很高。

三是多模态扩展。同样的 ONNX 思路,可以加载图像嵌入模型,把图片也转成向量,实现图文混合检索。技术路径是一样的,只是输入预处理不同。

四是模型热更新。把模型文件放在外部目录,应用启动时加载,需要更新时替换文件并触发重新加载。这样换模型不用重新打包部署,运维上方便很多。

6.3 给准备上手的朋友几句实在话

如果你正准备在 Spring 项目里搞本地嵌入,我的建议是:先把分词这一环做扎实。整个链路里,分词是最容易出错、也最难排查的部分。花时间把 Java 分词和 Python 分词对齐,后面会省很多事。

模型选型不要贪大。嵌入任务和生成任务不一样,小模型往往就够用,而且速度快、部署简单。先用小模型跑通全流程,有需要再换大的。

向量存储先别急着上向量数据库。内存暴力检索在中小规模下完全够用,等数据量真的上来了再迁移,迁移成本也不高。

最后,建索引和检索要分开压测。这两个环节的性能瓶颈不一样,混在一起测容易互相干扰,定位不到真正的问题。

这套方案我在几个内部项目里都用过,从几百份文档到几万份文档,整体表现稳定。本地 ONNX 嵌入这条路,对于有数据合规要求、又想控制成本的团队来说,确实是个值得投入的方向。

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

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

立即咨询