news 2026/10/15 6:44:57

Spring AI 本地 ONNX 嵌入模型实战:零成本构建知识库语义检索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 本地 ONNX 嵌入模型实战:零成本构建知识库语义检索

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

1.1 从一个真实需求说起

去年年底我接手了一个内部知识库项目,需求很朴素:把团队积累的几百份技术文档、会议纪要、故障复盘做成一个能语义搜索的小工具。一开始想得很简单,直接调云端嵌入接口就完事了,结果一算账傻眼了——文档切片之后大概有两万多条,每次全量重建索引的调用费用虽然不算离谱,但问题是这东西要频繁重建,而且文档里有些内容涉及内部架构细节,走外部接口总归让人心里不踏实。

于是我开始认真考虑本地嵌入这条路。核心诉求就三个:零调用成本、数据不出本机、能跟 Spring 生态无缝集成。Spring AI 正好提供了OnnxEmbeddingModel这个能力,配合本地导出的 ONNX 模型文件,整个嵌入过程完全在 JVM 进程内完成,不需要额外起 Python 服务,也不需要 GPU。

这篇文章就把我从零搭这套东西的完整过程拆开讲,包括模型怎么选、ONNX 怎么导出、Spring AI 怎么配、向量检索怎么接、踩了哪些坑。如果你也在做类似的知识库或者 RAG 应用,这套方案可以直接抄。

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

先说清楚本地嵌入和云端嵌入的本质区别。嵌入模型做的事情就是把一段文本映射成一个固定维度的浮点向量,语义相近的文本在向量空间里距离更近。云端方案是你把文本发过去,对方算完把向量返回来;本地方案是模型文件下载到本地,用推理引擎在你自己机器上算。

本地跑的好处很直接:

  • 成本归零:不管你有十万条还是一百万条切片,重建多少次索引,电费之外没有额外开销。
  • 数据闭环:文本从头到尾不离开你的机器,对于内部文档、客户资料这类敏感内容,这是硬需求。
  • 延迟可控:没有网络往返,单条嵌入的延迟基本就是纯计算时间,批量处理时吞吐更稳定。
  • 离线可用:内网环境、断网场景照样跑,不依赖任何外部服务。

代价也有:你需要自己管理模型文件,首次加载有冷启动开销,推理速度取决于你的 CPU。但对于知识库这种"写入一次、查询多次"的场景,这些代价完全可以接受。

1.3 技术选型的几个关键决策

在动手之前,有几个岔路口需要做选择,我把当时的思考过程列出来。

第一个决策:用 ONNX 还是用其他推理格式。主流的选择有 ONNX Runtime、PyTorch 原生、TensorFlow Lite 等。选 ONNX 的理由是它在 Java 生态里支持最成熟,ONNX Runtime 有官方 Java binding,Spring AI 也直接内置了对 ONNX 的支持,不需要自己写 JNI 胶水代码。而且 HuggingFace 上绝大多数嵌入模型都提供 ONNX 导出,选择面广。

第二个决策:模型选哪个。嵌入模型的选择要看你的语言场景。纯中文场景,BGE 系列(比如 bge-small-zh)是性价比很高的选择;中英混合场景,可以看 bge-m3 或者 gte 系列;如果追求极致轻量,all-MiniLM-L6-v2 只有 80MB 左右,英文效果不错但中文一般。我最后选了 bge-small-zh-v1.5,维度 512,模型文件 90MB 出头,CPU 上单条推理大概 10-20ms,对知识库场景够用了。

第三个决策:向量存哪里。Spring AI 支持多种 VectorStore,包括内存版 SimpleVectorStore、Redis、PGVector、Milvus 等。起步阶段我建议直接用 SimpleVectorStore,它把向量序列化到本地文件,零依赖,适合验证阶段。等数据量上来了再换 PGVector 或者 Milvus。

2. 环境准备与模型文件处理

2.1 依赖清单与版本对齐

Spring AI 的版本迭代比较快,不同版本 API 有差异,这里以 1.0.0 正式版为基准。Maven 依赖需要加这几块:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-transformers</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-simple</artifactId> </dependency>

注意spring-ai-starter-model-transformers这个 starter 名字有点迷惑,它内部其实用的是 ONNX Runtime 做推理,不是 HuggingFace 的 Transformers 库。这个 starter 会自动把onnxruntime和tokenizers的依赖带进来,你不需要单独声明。

如果你用的是 Gradle,对应写法:

implementation 'org.springframework.ai:spring-ai-starter-model-transformers' implementation 'org.springframework.ai:spring-ai-starter-vector-store-simple'

版本管理建议用 Spring AI 的 BOM,避免各个模块版本打架:

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

提示:ONNX Runtime 的版本和模型导出时的 opset 版本有关联。如果加载模型时报 opset 不支持的错,优先检查 onnxruntime 版本是否太旧。

2.2 模型文件从哪来、怎么放

ONNX 嵌入模型文件通常包含两部分:模型本体(.onnx)和分词器文件(tokenizer.json、config.json等)。获取途径有两个:

一是直接从 HuggingFace 上找已经导出好的 ONNX 版本。很多热门模型都有社区贡献的 ONNX 分支,搜索时加上 "onnx" 关键词即可。下载后目录结构大概是这样:

models/bge-small-zh-v1.5/ ├── model.onnx ├── tokenizer.json ├── config.json ├── special_tokens_map.json └── tokenizer_config.json

二是自己用 Python 的optimum库导出。如果你手上只有 PyTorch 权重,可以这样转:

from optimum.onnxruntime import ORTModelForFeatureExtraction from transformers import AutoTokenizer model_id = "BAAI/bge-small-zh-v1.5" save_dir = "./bge-small-zh-onnx" model = ORTModelForFeatureExtraction.from_pretrained(model_id, export=True) tokenizer = AutoTokenizer.from_pretrained(model_id) model.save_pretrained(save_dir) tokenizer.save_pretrained(save_dir)

导出时有个细节要注意:嵌入模型导出后,输出通常是last_hidden_state,需要做 pooling 才能得到句向量。Spring AI 的OnnxEmbeddingModel内部会处理 mean pooling,但你要确认模型的输出格式和它预期的一致。如果发现嵌入结果明显不对(比如所有文本向量都差不多),大概率是 pooling 方式没对上。

2.3 目录结构与配置项

把模型文件放到项目的src/main/resources/models/下,或者放到项目外部的某个目录,通过配置指定绝对路径。我倾向于放外部目录,因为模型文件不该打进 jar 包,否则每次构建都拖着一个几十上百 MB 的产物。

application.yml配置大概长这样:

spring: ai: embedding: transformer: onnx: model-uri: file:/opt/models/bge-small-zh-v1.5/model.onnx tokenizer-uri: file:/opt/models/bge-small-zh-v1.5/tokenizer.json tokenizer: options: padding: true truncation: true max-length: 512

这里几个参数值得展开说。max-length设成 512 是 BGE 系列的上限,超过会被截断。padding和truncation都开,保证批量输入时长度对齐。如果你的文本切片普遍很短(比如 200 字以内),可以把 max-length 调小到 256,推理速度会快不少,因为注意力计算量跟序列长度是平方关系。

3. 核心代码实现与向量检索链路

3.1 嵌入模型的 Bean 配置

Spring AI 的自动配置会读取上面的 yaml 生成EmbeddingModelBean,但如果你想更精细地控制,也可以手动声明:

@Configuration public class EmbeddingConfig { @Bean public EmbeddingModel embeddingModel( @Value("${spring.ai.embedding.transformer.onnx.model-uri}") Resource modelResource, @Value("${spring.ai.embedding.transformer.onnx.tokenizer-uri}") Resource tokenizerResource) { var tokenizer = new OnnxTokenizer(tokenizerResource); return new OnnxEmbeddingModel(modelResource, tokenizer); } }

手动配置的好处是你能在构造前后加日志、做校验,比如启动时打印一下模型维度,确认加载成功。我习惯在启动类里加一段自检:

@Bean public ApplicationRunner embeddingSelfCheck(EmbeddingModel embeddingModel) { return args -> { float[] vec = embeddingModel.embed("这是一段测试文本"); System.out.println("嵌入维度: " + vec.length); }; }

如果这行打印出来的维度是 512(对应 bge-small-zh),说明模型加载正常。如果报错或者维度不对,就得回头查模型文件和配置。

3.2 文档切分策略

嵌入之前要先切分文档。切分粒度直接决定检索质量,切太粗会导致一个向量里混了多个主题,检索时召回不准;切太细会丢失上下文,语义不完整。

Spring AI 提供了TokenTextSplitter,按 token 数切分:

TokenTextSplitter splitter = new TokenTextSplitter(300, 100, 5, 10000, true); List<Document> chunks = splitter.apply(documents);

参数含义依次是:目标 chunk 大小、相邻 chunk 重叠 token 数、最小 chunk 字符数、最大 chunk 字符数、是否保留分隔符。我一般把 chunk 设成 300-500 token,重叠 50-100 token。重叠的作用是防止一句话被从中间切断后两边都读不通,检索时至少有一个 chunk 能包含完整语义。

对于 Markdown 文档,更好的做法是先按标题层级切,再对超长的段落做二次切分。这样每个 chunk 天然带有一个主题边界,检索质量比纯按长度切高不少。

3.3 写入向量库

切分完就是嵌入加存储。SimpleVectorStore 的用法很直接:

@Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel) .build(); return store; }

写入时:

vectorStore.add(chunks);

SimpleVectorStore 默认把数据放在内存里,进程重启就没了。要持久化的话,可以调用store.save(file)把向量序列化到磁盘,启动时再store.load(file)读回来。文件格式是 JSON,两万条 512 维向量的文件大概几十 MB,加载速度可以接受。

注意:SimpleVectorStore 的相似度计算是暴力遍历,数据量超过十万条之后查询会明显变慢。这个阶段就该考虑换 PGVector 或者 Milvus 了。

3.4 检索与相似度阈值

检索的核心 API 是similaritySearch:

SearchRequest request = SearchRequest.builder() .query("如何排查连接池泄漏") .topK(5) .similarityThreshold(0.6) .build(); List<Document> results = vectorStore.similaritySearch(request);

topK控制返回条数,similarityThreshold控制最低相似度。这两个参数需要根据实际数据调。阈值设太高会漏召回,设太低会引入噪声。我的经验是先用 0.5 跑一批测试查询,看看返回结果的相关性,再往上调到 0.6-0.7。

相似度算法默认是余弦相似度。BGE 系列模型官方建议在计算余弦之前先对向量做归一化,Spring AI 内部已经处理了这一步,你不需要额外操作。但如果你自己换了别的模型,要确认一下归一化逻辑是否匹配,否则相似度排序会出问题。

4. 性能调优与常见问题排查

4.1 批量嵌入与吞吐优化

单条嵌入在 CPU 上大概 10-20ms,两万条切片如果一条条来,光嵌入就要好几分钟。批量处理能显著提升吞吐,因为 ONNX Runtime 对 batch 输入有优化。

Spring AI 的embed(List<String>)方法支持批量,但要注意 batch size 不能太大,否则内存会爆。我实测下来 batch size 设 32 比较稳,再大收益递减而且容易 OOM。批量嵌入的代码:

List<String> texts = chunks.stream().map(Document::getText).toList(); List<float[]> vectors = embeddingModel.embed(texts);

如果文档量特别大,建议分批次处理,每批几百条,处理完一批写一批,避免所有向量同时驻留内存。

4.2 常见报错与解决对照表

报错信息可能原因解决方向
Unsupported model IR versiononnxruntime 版本低于模型 opset升级 onnxruntime 到 1.16+
Tokenizer file not foundtokenizer.json 路径不对检查配置路径,确认文件存在
嵌入维度与预期不符模型输出层配置错误确认模型是嵌入模型而非分类模型
检索结果全部相似度接近pooling 方式不匹配检查模型是否需 mean pooling
启动时 OOM模型文件过大或堆内存不足调大-Xmx,或换更小的模型
中文检索效果差用了英文模型换 bge-small-zh 等中文模型

4.3 几个我踩过的坑

坑一:模型文件路径用了 classpath 前缀。一开始我把模型放在 resources 下,配置写classpath:models/model.onnx,结果加载时报找不到文件。原因是 ONNX Runtime 需要的是文件系统路径,不是 classpath 资源。后来改成file:前缀指向外部目录才正常。如果你非要把模型打进 jar,得先把它解压到临时目录再加载。

坑二:分词器配置和模型不匹配。我试过拿 A 模型的分词器配 B 模型,结果嵌入出来的向量完全没有语义区分度。分词器必须和模型配套,从同一个仓库下载。

坑三:max-length 设太大导致长文本推理慢。有些文档切片超过 512 token,如果 max-length 设成 1024,推理时间会翻好几倍。正确做法是在切分阶段就控制好 chunk 大小,让嵌入阶段的输入不超过模型上限。

坑四:SimpleVectorStore 持久化文件损坏。如果进程在 save 过程中被 kill,JSON 文件会写一半,下次 load 直接报解析错误。建议 save 时先写临时文件再原子重命名,或者定期备份。

4.4 检索质量调优的实操心得

检索质量差的时候,不要急着换模型,先按这个顺序排查:

第一步,看切分。随便挑几个 chunk 打印出来,看看是不是语义完整。如果 chunk 里全是半截句子,先调切分参数。

第二步,看查询和文档的表述差异。嵌入模型对"问句"和"陈述句"的匹配有时会有偏差。BGE 系列建议在查询前加一个指令前缀,比如为这个句子生成表示以用于检索相关文章:,能提升检索效果。Spring AI 里可以通过自定义EmbeddingModel包装类来实现查询侧的指令注入。

第三步,看阈值。把 similarityThreshold 设成 0,打印所有结果的相似度分数,观察相关结果和不相关结果的分数分布,找一个能分开两者的阈值。

第四步,才考虑换模型。如果前面都调过了还是不行,可能是模型本身对你的领域不适用,这时候换一个在相似领域上表现更好的模型。

5. 从验证到生产的扩展路径

5.1 数据量增长后的存储切换

SimpleVectorStore 适合验证阶段,数据量上万之后就该考虑专业向量库。切换成本其实不高,因为 Spring AI 的VectorStore接口是统一的,你只需要换一个实现类,业务代码基本不用动。

PGVector 是最平滑的选择,如果你已经在用 PostgreSQL,加个扩展就行,向量和业务数据在同一个库里,事务一致性也好保证。Milvus 适合数据量特别大(百万级以上)的场景,但运维复杂度高一些。选哪个取决于你的数据规模和团队运维能力。

5.2 混合检索的引入

纯向量检索有个天然短板:对精确关键词的匹配不如传统全文检索。比如用户搜一个具体的错误码 "ERR_5023",向量检索可能召回一堆语义相近但错误码不同的内容。解决办法是混合检索,把向量检索和 BM25 全文检索的结果做融合排序。

Spring AI 本身对混合检索的支持还在演进中,实践中可以自己实现:分别跑向量检索和数据库全文检索,然后用 RRF(Reciprocal Rank Fusion)算法合并两路结果。RRF 的公式很简单,对每个文档,把它在各路结果中的排名取倒数相加,按总分排序。这个方案实现起来不复杂,效果提升明显。

5.3 嵌入模型的更新与重建

模型不是一劳永逸的。当你换了嵌入模型,所有已存的向量都必须重建,因为不同模型的向量空间不兼容。重建流程要设计成可回滚的:新模型写入新的 collection 或表,验证检索质量没问题后再切换,旧数据保留一段时间作为回退。

重建期间如果服务不能停,可以做双写:新文档同时用新旧两个模型嵌入,查询时走新模型,等全量重建完成再下线旧模型。这套流程听起来麻烦,但真到换模型的时候你会庆幸提前设计了。

5.4 一个容易被忽略的细节:向量归一化

前面提过 BGE 系列需要归一化,这里再展开说一下为什么。余弦相似度的定义是两向量点积除以模长乘积。如果向量已经归一化(模长为 1),余弦相似度就退化成点积,计算更快。更重要的是,如果查询向量和文档向量的归一化方式不一致,相似度排序会完全乱掉。

Spring AI 的 OnnxEmbeddingModel 默认会对输出做 L2 归一化,但如果你自己包装了 EmbeddingModel,或者用了自定义的 pooling,一定要确认归一化这一步没丢。验证方法很简单:随便嵌入一段文本,算一下向量的模长,接近 1 就说明归一化生效了。

6. 写在最后的一些个人体会

这套本地 ONNX 嵌入方案我从验证到上线跑了小半年,最大的感受是:本地嵌入的门槛比想象中低,但细节比想象中多。模型加载、分词器匹配、pooling 方式、归一化、切分策略,每一个环节出问题都会导致检索效果打折,而且症状往往很隐蔽——不是报错,而是"结果看起来差不多但就是不准"。

排查这类问题没有捷径,就是打印中间结果,一层层看。嵌入向量打印出来看看数值分布,检索结果打印出来看看相似度分数,切分后的 chunk 打印出来读一读。大部分问题在这三步之内都能定位。

另外一点体会是关于模型选择的。不要一上来就追求大模型,bge-small 这种小模型在中文知识库场景下效果已经相当能打,而且推理快、内存占用低。等你的检索 pipeline 调顺了,再考虑换更大的模型提升效果,这时候换模型的收益才看得清楚。

最后分享一个实用小技巧:建索引的时候,把每个 chunk 的来源文件名、章节标题、原始位置这些元数据一起存进去。检索返回结果时带上这些元数据,用户看到的不只是一段文本,而是"来自某文档第几节"的完整上下文,体验会好很多。这个改动很小,但用起来差别很大。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 6:44:13

网络工程师面试真题解析:从协议原理到故障排查实战

1. 这不是背题手册&#xff0c;而是网络工程师面试现场的“压力测试还原”“网络工程师常见面试问题”——看到这九个字&#xff0c;很多人第一反应是翻出某宝上卖得最火的《网络面试100问》&#xff0c;或者打开某乎搜“思科华为面试题汇总”&#xff0c;然后开始逐条默写OSPF…

作者头像 李华
网站建设 2026/10/15 6:43:16

TurboID邻近标记全流程实操指南:从质粒构建到质谱数据分析

1. TurboID邻近标记&#xff1a;为什么它是研究蛋白互作的新宠从事蛋白互作研究这些年&#xff0c;邻近标记技术几乎成了我日常工作中绕不开的核心工具。所谓邻近标记&#xff0c;核心思路是把一种改造过的生物素连接酶——最典型的就是TurboID或miniTurbo——融合到你研究的那…

作者头像 李华
网站建设 2026/10/15 6:43:16

C语言经典100例第37题:选择排序与冒泡排序实战详解

做到C经典100例第37题时&#xff0c;我停下的时间比预想中长很多。前面35道题大多是单点知识点&#xff0c;这道“对10个数进行排序”看起来人畜无害&#xff0c;却把数组、循环、输入输出、变量交换全部串在一起&#xff0c;题面只有一句话&#xff0c;程序分析也只给了“选择…

作者头像 李华
网站建设 2026/10/15 6:42:45

TensorFlow 2.x实战指南:从张量计算图到模型部署的完整路径

1. 从零开始理解TensorFlow&#xff1a;它到底在算什么很多人第一次接触TensorFlow&#xff0c;脑子里冒出来的第一个问题不是“怎么用”&#xff0c;而是“这东西到底在算什么”。我刚开始学的时候也有这个困惑&#xff0c;文档里满屏的“张量”“计算图”“会话”&#xff0c…

作者头像 李华
网站建设 2026/10/15 6:42:45

TensorFlow深度学习实战:从核心概念到工程化部署的完整指南

1. 为什么“tensorflow”这个标题值得单独拎出来聊如果你在技术社区混过一段时间&#xff0c;大概率见过这样的场景&#xff1a;有人发帖问“我想入门深度学习&#xff0c;该从哪个框架开始”&#xff0c;底下回复五花八门&#xff0c;但出现频率最高的那个名字&#xff0c;往往…

作者头像 李华
网站建设 2026/10/15 6:42:36

RAG检索增强生成实战:从最小可用系统到生产级优化

1. RAG检索增强到底在解决什么问题1.1 从一个尴尬场景说起大语言模型有个让人又爱又恨的特点&#xff1a;它什么都懂一点&#xff0c;但问到具体细节就容易一本正经地胡说八道。你问它某个内部技术文档里的接口参数&#xff0c;它能给你编出一套看起来煞有介事的答案&#xff0…

作者头像 李华