资讯动态

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

发布时间:2026/10/15 6:45:15 来源:尧图企业网站定制
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 bindingSpring 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-transformers/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/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, exportTrue) 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); ListDocument 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 是similaritySearchSearchRequest request SearchRequest.builder() .query(如何排查连接池泄漏) .topK(5) .similarityThreshold(0.6) .build(); ListDocument 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(ListString)方法支持批量但要注意 batch size 不能太大否则内存会爆。我实测下来 batch size 设 32 比较稳再大收益递减而且容易 OOM。批量嵌入的代码ListString texts chunks.stream().map(Document::getText).toList(); Listfloat[] vectors embeddingModel.embed(texts);如果文档量特别大建议分批次处理每批几百条处理完一批写一批避免所有向量同时驻留内存。4.2 常见报错与解决对照表报错信息可能原因解决方向Unsupported model IR versiononnxruntime 版本低于模型 opset升级 onnxruntime 到 1.16Tokenizer 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 过程中被 killJSON 文件会写一半下次 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 本身对混合检索的支持还在演进中实践中可以自己实现分别跑向量检索和数据库全文检索然后用 RRFReciprocal 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 的来源文件名、章节标题、原始位置这些元数据一起存进去。检索返回结果时带上这些元数据用户看到的不只是一段文本而是来自某文档第几节的完整上下文体验会好很多。这个改动很小但用起来差别很大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑