Spring AI 结合本地 ONNX 嵌入模型:零成本搭建 Java 知识库检索

发布时间:2026/10/13 15:44:59
Spring AI 结合本地 ONNX 嵌入模型:零成本搭建 Java 知识库检索 前阵子一个朋友找到我说他手头有几千份内部文档想做个知识库检索。他第一反应是去注册云端的文本向量接口我帮他算了一笔账立刻劝住了。按量计费看着便宜数据量一大每个月就是一笔固定支出更要命的是合同、报价单这些内容根本不适合传到外部服务。最后我帮他换了方案用 Spring AI 搭服务框架嵌入模型用本地 ONNX 格式跑所有文本在本地变成坐标知识库零成本起步。这篇文章就是完整复盘从模型选型、格式转换、代码集成到上线踩坑一次说清。适合刚接触向量检索、RAG或者正在为向量化成本发愁的Java开发者。1. 为什么要把嵌入模型搬回本地这笔账其实很好算1.1 云端口向量接口的“隐形账单”先别急着谈技术先说钱。一段500字的文本传到云端向量接口按token计费。量小的时候无所谓文档库涨到一万份、两万份每次全量重建入库都是一次成本再加上增量更新、测试调参、多环境测试、预发、生产各跑一遍费用就滚起来了。我见过一个朋友的项目一个月光向量化就花了将近四位数问题是他那知识库总共才三千份文档。更隐蔽的成本在运维侧。远程接口有QPS限制批量入库时要限速、重试、处理超时接口一升级参数可能变代码要跟着改。这些看不见的维护成本不算进账单但都压在开发者身上。还有数据链路的问题。企业内部检索往往涉及敏感的运营数据法务和合规部门不会同意把明文文本送到外部。文档只要上了外网无论合同怎么写都算一次数据出境。与其天天提心吊胆不如在一开始就把向量化放在内网。1.2 本地ONNX嵌入的收益与代价换成本地ONNX嵌入模型之后情况完全不一样。模型文件一百多MB放在服务器上每天跑一万次、一亿次成本都是零。单条文本在普通CPU上嵌入一次大约10到30毫秒批量处理还能快不少本地推理没有并发限制没有网络抖动断电断网都不影响“文本变坐标”这件事。代价当然也有。轻量级模型的语义精度比不上那些云端的大模型尤其在非常专业的垂直领域召回可能略差。但这里要区分需求绝大多数内部知识库做的是FAQ、规章制度、产品文档这类检索语义相似度是很直接的通用小型模型足够胜任。从“成本为零”和“数据不出内网”两个角度看这笔账怎么算都划算。1.3 边界这套方案适合谁不适合谁我自己的经验是本地ONNX嵌入最适合下面几类场景文档量在百万级以内、语义检索要求中等即可、存在数据出网限制、想快速跑通RAG Demo并且以后不想被API账单卡住。如果你要处理的是跨语言大规模搜索引擎级别的问题或者对召回率要求极度苛刻那还是老老实实上更强的方案。但“零成本起步”的价值恰恰在这里——先用这套方案把流程跑通等后续确实需要更强语义能力了再平滑换掉EmbeddingModel实现即可业务代码基本不用动。2. 模型选型与ONNX格式转换先把“坐标生成器”准备妥当2.1 嵌入模型是怎么把文本变成坐标的理解嵌入模型不需要太深的数学背景。你可以把它想成一个“坐标生成器”一段文字输入进去出来一串浮点数比如128个、384个或者1024个浮点数。这些数字构成一个向量文字的语义越接近向量在空间里的夹角就越小。具体到实现上这类模型通常是一个多层Transformer编码器先算出每个词或子词的表示再通过池化把整句话压缩成一个向量。常用的池化是取所有token向量按注意力掩码加权平均得到句向量后再做L2归一化。归一化有个好处两个向量的余弦相似度可以直接用点积算后面检索时省一步。2.2 选型三要素维度、体积、语言覆盖挑嵌入模型我一般只看三个数字。第一是输出维度。维度太低语义信息装不下维度太高存储和计算成本直线上升。个人经验5000份以下文档384维完全够用十万级文档512到768维更稳再往上追求极致精度就得接收更大的内存和更慢的推理。第二是模型体积和推理速度。同一个能力水平下优先选量化版本体积能缩小到原来的四分之一速度也明显提升。对CPU推理来说INT8量化几乎是必选项。第三是语言覆盖。这是最容易被忽略的一条。如果你处理的是中文文档一定要选在中文语料上预训练过的模型否则英文模型会把中文词切得乱七八糟向量质量一塌糊涂。我下面做了一个简单的选型参考表。场景建议维度模型体积知识库规模参考备注英文通用FAQ384约40-80MB万份以内推理最快Demo首选中文通用文档512约100-200MB十万份以内必须选中文预训练词表多语言/垂直领域768以上200MB较大规模需要更强算力支撑这个表不是死的。如果你能接受略慢的检索速度当然可以选更大的模型。我的建议是先拿一台测试机跑一遍真实语料对比几个候选模型在同样查询下的召回效果再决定。2.3 把模型转成ONNX的实操脚本PyTorch模型没法直接在Java服务里跑所以要先导出成ONNX。转换过程中我最常犯的错误是把池化和归一化漏掉。如果只导出原始Transformer输出Java端拿到的是每个token的向量还得自己拼句向量烦得很。正确的做法是在导出前包一个子类把池化和归一化一起做进去。下面是一份可直接调整的导出脚本。我用的是固定序列长度动态batch这样Java端处理起来最省事。import torch from transformers import AutoModel, AutoTokenizer model_path ./models/your_embedding_model tokenizer AutoTokenizer.from_pretrained(model_path) base_model AutoModel.from_pretrained(model_path) base_model.eval() class SentenceEmbedder(torch.nn.Module): def __init__(self, bert): super().__init__() self.bert bert def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) token_embeddings outputs.last_hidden_state mask attention_mask.unsqueeze(-1).float() summed (token_embeddings * mask).sum(dim1) counts mask.sum(dim1).clamp(min1e-9) mean_pooled summed / counts return torch.nn.functional.normalize(mean_pooled, p2, dim1) wrapper SentenceEmbedder(base_model) wrapper.eval() MAX_SEQ 256 dummy tokenizer([用于导出的示例句子], max_lengthMAX_SEQ, paddingmax_length, truncationTrue, return_tensorspt) torch.onnx.export( wrapper, (dummy[input_ids], dummy[attention_mask]), model.onnx, input_names[input_ids, attention_mask], output_names[sentence_embedding], dynamic_axes{ input_ids: {0: batch}, attention_mask: {0: batch}, sentence_embedding: {0: batch} }, opset_version14 )注意为什么序列长度要固定因为动态序列长度意味着Java端每次推理都要按实际长度做padding少了容易编解码错误多了白白浪费计算固定256以后超长文本截断短文本补零逻辑稳定。导出完成后在本地加载ONNX再跑一次把输出和PyTorch原模型的输出比对一下误差应该在1e-4以内。2.4 模型文件怎么放我习惯把模型和分词器放到resources目录下面按功能分文件夹。一个小型知识库工程通常是这个结构src/main/resources/models/embedding/ ├── model.onnx ├── config.json └── vocab.txt分词器词表是Java端做WordPiece切词的关键一定要跟模型一起拷贝。我不建议把模型打成fat jar时直接塞进去如果模型有100M构建产物会膨胀不少启动也会变慢。更推荐把模型放在服务器上的固定路径通过配置项传入开发环境则用classpath文件。路径通过application.yml统一管理后面换模型时只改配置不动代码。3. Spring AI接入ONNX嵌入模型核心逻辑与代码实战3.1 Spring AI为什么值得关注统一Embedding抽象Spring AI在Java生态里最大的价值是把“文本转向量”这件事抽象成了一个统一接口。你写业务代码时不关心底层的向量化到底来自哪家也不关心返回格式长什么样只需要调EmbeddingModel的几个方法。这个抽象带来的直接好处是以后想从本地ONNX切换到更强能力的云端接口只需换一个Bean实现检索、入库这些代码完全不用动。我是从RAG项目开始接触这个框架的。刚开始也觉得多一层抽象是负担后来发现做知识库最大的痛苦恰恰是“方案绑定”——文档切块、入库、检索一套代码写完换一家向量化服务就得重来一遍。有了统一抽象这套成本被基本抹平了。3.2 自定义EmbeddingModel加载ONNX与分词器Spring AI本身没有把任意ONNX模型都封装好的现成实现我更愿意自己实现EmbeddingModel接口。这样逻辑透明也方便调试。核心类大概是这个结构public class LocalOnnxEmbeddingModel implements EmbeddingModel { private final OrtEnvironment environment; private final OrtSession session; private final BertTokenizer tokenizer; private final int maxSeqLen; public LocalOnnxEmbeddingModel(String modelPath, String vocabPath, int maxSeqLen) throws Exception { this.environment OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options new OrtSession.SessionOptions(); // 可在这里开启CPU优化、线程数配置等 this.session environment.createSession(modelPath, options); this.tokenizer new BertTokenizer(vocabPath); this.maxSeqLen maxSeqLen; } Override public float[] embed(String text) { return doEmbed(text); } private float[] doEmbed(String text) { long[] inputIds tokenizer.encode(text, maxSeqLen); long[] attentionMask new long[inputIds.length]; for (int i 0; i inputIds.length; i) { attentionMask[i] inputIds[i] 0L ? 0L : 1L; } long[] shape new long[]{1L, maxSeqLen}; try (OnnxTensor idTensor OnnxTensor.createTensor(environment, new long[][]{inputIds}, shape); OnnxTensor maskTensor OnnxTensor.createTensor(environment, new long[][]{attentionMask}, shape); OrtSession.Result result session.run(java.util.Map.of( input_ids, idTensor, attention_mask, maskTensor))) { float[][] embedding (float[][]) result.get(0).get().getValue(); return embedding[0]; } catch (Exception e) { throw new RuntimeException(本地向量化失败, e); } } }这里的BertTokenizer我建议直接参考开源示例里的实现几十行代码读vocab.txt做最基础的WordPiece切词加上[CLS]和[SEP]不足部分补零。如果你的模型用的是SentencePiece也可以换成对应的分词方式。分词器必须跟模型配套这是很多人忽视的坑模型是中文词表你却用了英文分词器结果自然一塌糊涂。接口里还有call(EmbeddingRequest)、embed(Document)等方法需要实现逻辑上都是调用doEmbed之后再包装结果。确切的继承方法和返回值和你引入的Spring AI版本强相关记得打开IDE看Javadoc。3.3 配置Bean与调用示例把自定义模型注册成Bean之后Spring AI的其余组件就能统一使用了。配置类很简单Configuration public class EmbeddingConfig { Bean public EmbeddingModel localEmbeddingModel( Value(${embedding.model.path}) String modelPath, Value(${embedding.vocab.path}) String vocabPath, Value(${embedding.max-seq-len:256}) int maxSeqLen) throws Exception { return new LocalOnnxEmbeddingModel(modelPath, vocabPath, maxSeqLen); } }对应的配置项embedding: model: path: ./models/embedding/model.onnx vocab: path: ./models/embedding/vocab.txt max-seq-len: 256一切就绪后调用方式非常清爽EmbeddingModel embeddingModel; // 注入 float[] vector embeddingModel.embed(如何申请退款);这个方法返回的就是这条文本在语义空间里的坐标了。你不需要关心它是怎么算出来的后面无论是入库还是检索都基于这一串float。3.4 批量嵌入与并发注意点入库的时候一条一条调用embed实在太慢。Spring AI的embed(ListString)支持批量底层循环内建但我在实际项目里还是建议自己控制批次大小。原因很简单本地模型推理是CPU密集操作批次太大会把服务器CPU瞬间打满其他接口全部变慢。我自己习惯把批次控制在16到32条之间同时给嵌入组件做一个固定大小的线程池。入库是离线任务用两三个线程慢慢跑在线检索是单条查询走单线程避免并发竞争。别小看这个设计线上抖动很多不是模型问题是并发模型没控制好。提示如果你用容器部署记得给CPU设置配额否则ONNX Runtime默认行为会把宿主机所有核心吃满旁边跑的其他服务会遭殃。4. 向量存储与检索知识库的“坐标仓库”怎么搭4.1 向量存储选型内存、文件还是独立服务向量存哪里需要先想清楚。我见过一上来就搭独立向量数据库的人三千条文档的非结构化知识库搞了一整套集群运维成本高得离谱。零成本起步的阶段千万别过度设计。Spring AI提供了一组VectorStore抽象实现选型分三档。第一档是纯内存实现适合Demo和单元测试进程一停数据就没了第二档是带持久化的本地实现启动时从文件加载向量数据变更后落盘适合几万条以内的知识库第三档才是独立数据库服务适合百万级向量、需要分布式和高级过滤的场景。我的建议是直接用第二档起步对应到Spring AI里就是SimpleVectorStore。Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return new SimpleVectorStore(embeddingModel); }4.2 入库流水线加载、切块、向量化知识库不是直接把整篇文档丢进去。一篇几千字的制度文件如果整篇变成一个向量查询时很难精准命中某个条款。正确的做法是先把文档切成小块每一块再变成向量。我自己用的切块策略是按token数切成256左右的小块相邻块之间重叠20个token。为什么要有重叠因为理想切块边界经常不是整齐的一段语义刚好横跨两个块时重叠部分能降低信息被切碎的概率。Spring AI里的入库代码串联起来大概是这样的// 1. 加载文本 TextReader reader new TextReader(new FileSystemResource(./docs/faq.txt)); ListDocument docs reader.read(); // 2. 切块 TokenTextSplitter splitter new TokenTextSplitter(); ListDocument chunks splitter.apply(docs); // 3. 向量化并入库VectorStore实现内部会自动调用EmbeddingModel vectorStore.add(chunks);写到这里必须提醒一句加Document的时候一定要把来源、页码、章节这些元数据塞进metadata里否则检索命中的结果无法追溯到原始位置知识库就白做了。4.3 相似度检索在算什么余弦相似度与TopK检索接口拿到一个查询文本后会先把它变成向量再跟库里所有向量做相似度比较。因为我们导出模型前做了L2归一化所以相似度直接用点积算就行数值越接近1语义越接近。TopK的意思很简单返回最相近的K条。K太小可能漏召回K太大会把不相关结果带进来个人经验是5到8比较均衡。Spring AI查询代码长这样SearchRequest request SearchRequest.builder() .query(怎么重置密码) .topK(5) .similarityThreshold(0.72) .build(); ListDocument hits vectorStore.similaritySearch(request);4.4 阈值怎么调similarityThreshold是一个容易踩坑的参数。阈值太高相关片段被过滤掉返回为空阈值太低无关内容混进来。我的调法是拿两三百条真实query跑一遍把正确命中片段的相似度分布打出来在最低命中值附近留一点余量。比如我某个知识库里正确召回的最低相似度是0.75那我就把阈值设到0.72。宁可让几条弱相关内容进来也别把该命中的过滤掉——因为后面还有一轮排序或者对话模型会帮忙筛选。5. 从零搭一个本地知识库问答完整项目串起来5.1 工程结构与依赖清单一个最小可运行的工程大概是这样的src/main/java/com/example/kb/ ├── config/EmbeddingConfig.java ├── embedding/LocalOnnxEmbeddingModel.java ├── embedding/BertTokenizer.java ├── service/DocumentIngestService.java ├── service/SearchService.java └── web/SearchController.java src/main/resources/ ├── application.yml └── models/embedding/ ├── model.onnx └── vocab.txt依赖方面除了Spring Boot基础依赖我用到了Spring AI的Embedding模块和向量存储模块另外加ONNX Runtime的Java绑定和分词器工具类。版本建议通过Spring AI的BOM统一管理这样各模块之间不会出现依赖打架。5.2 核心代码走读入库与查询入库服务一次性跑完逻辑是顺序的扫描文档目录、逐文件加载、统一切块、塞进VectorStore。Service public class DocumentIngestService { private final VectorStore vectorStore; public DocumentIngestService(VectorStore vectorStore) { this.vectorStore vectorStore; } public void ingest(String dirPath) { File dir new File(dirPath); File[] files dir.listFiles(); if (files null) return; for (File file : files) { if (!file.getName().endsWith(.txt)) continue; TextReader reader new TextReader(new FileSystemResource(file)); ListDocument docs reader.read(); ListDocument chunks new TokenTextSplitter().apply(docs); vectorStore.add(chunks); System.out.println(已入库: file.getName() - chunks.size() 个分块); } } }检索服务的代码更短但我会把阈值、TopK都做成可配置项方便接口联调的时候快速改。Service public class SearchService { private final VectorStore vectorStore; public SearchService(VectorStore vectorStore) { this.vectorStore vectorStore; } public ListString search(String query, int topK, double threshold) { SearchRequest request SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(threshold) .build(); return vectorStore.similaritySearch(request).stream() .map(doc - 【 doc.getMetadata().get(source) 】 doc.getText()) .toList(); } }Controller只需要一个查询接口和一个触发入库的接口方便验证。5.3 实测效果一个FAQ知识库的查询结果我用一份模拟FAQ文档库做的测试里面大概六十个问题覆盖账号、支付、售后、物流。入库之后跑了几个查询结果如下查询命中的分块相似度忘记密码怎么办“如何重置登录密码”0.86付款后多久发货“下单支付成功后的发货时效”0.81退款需要多长时间“退款处理时长”0.78怎么联系人工客服“客服热线与在线客服入口”0.74有没有优惠券“新人优惠券领取方式”0.69低于阈值被过滤有意思的是查询表述和原文完全不同但向量检索还是能命中比如“忘记密码怎么办”命中了“如何重置登录密码”这就是语义检索相对关键词检索的核心差异。最后一条“有没有优惠券”阈值设0.72结果被过滤了实际库里确实只有一条弱相关内容这个行为符合预期。5.4 从检索到问答下一步怎么接生成模型检索跑通之后距离一个完整的问答机器人只差一步把命中的文本片段拼进Prompt交给生成模型作答。这一步和向量化是解耦的你可以先用云端对话模型也可以再本地部署一个小型模型。我的建议是检索部分先稳下来生成部分可以往后放。原因很实际检索阶段的召回质量决定了整个RAG的上限你后面换再聪明的模型都救不回已经被过滤掉的正确答案。6. 踩坑复盘维度不一致、中文效果差、性能抖动怎么办6.1 维度不一致最典型的启动报错我自己的第一个报错发生在换模型之后持久化的向量文件里存的是384维新的模型输出512维启动加载时直接抛异常。这个问题看起来低级但特别容易埋进配置里。排查链路其实很清楚。先用一段打印日志把模型输出的向量维度打出来再检查VectorStore持久化文件里的维度。如果是对不上就清空重灌。我的建议是在持久化文件名里带上维度信息比如faq-store-384.dat而且把维度写进配置文件的metadata字段每次启动时校验。6.2 中文效果差模型、分词、切块三处排查中文检索效果差我第一反应不是换模型而是先怀疑分词器。英文模型的分词器对中文是按单字切的语义信息全丢。如果你用的是多语言模型但效果还是不对那就按三处依次排查。第一处是模型词表确认模型是否在中文语料训练过最简单的方法是看分词器词表里中文字符的数量。第二处是分词器不同模型的切词策略不同WordPiece和SentencePiece不能互换。第三处才是模型本身大小如果前两处都没问题再考虑换成更大尺寸的中文模型。我在一个项目中换掉分词器的实现后召回率直接提升了一截模型本身根本没动。6.3 CPU推理性能调优预热、并发与量化本地嵌入面临的最大问题不是准不准而是快不快。我踩过的第一个性能坑是“首调用慢”服务刚启动时第一次embed花了将近300毫秒后面才降到20毫秒。原因是JVM和ONNX Runtime还在初始化模型还没加载到内存热区。解法是在启动阶段跑一次空文本嵌入做预热把该加载的资源都加载起来。并发控制刚才提过还要补一条ONNX Runtime默认会用满CPU的所有核心。如果你在一台共享小机器上跑记得限制线程数别把其他服务挤死。另外模型量化对CPU推理速度的提升非常明显INT8量化后的模型体积是原来的四分之一推理速度几乎翻倍刚才选型表里建议优先选量化版本就是这个原因。6.4 ONNX推理的内存脚印ONNX Runtime在JVM里分配的一部分内存是堆外内存不在JVM堆限制范围内。如果只盯着Xmx调整很容易忽略这个大头。我用一个约200MB的模型时堆外内存峰值能有600MB。所以在容器部署时要给足内存配额同时可以开启环境变量控制线程池和内存复用。小知识库机器配置的参考单核2GB机器勉强能跑但检索并发一高就扛不住我实际用的是双核4GB稳定跑384维量化模型检索接口P95在50毫秒以内。如果你的文档量再大一点内存和CPU就按这个基准往上加。最后分享一个我自己的体会。做本地向量检索真正决定效果的不是模型选多大而是文档切块切得合不合理、阈值调得稳不稳。模型只负责把文本映射成坐标而切块和元数据决定了这个坐标能不能在关键时候被找回来。这套Spring AI加本地ONNX嵌入的思路我目前用在一个内部知识库上API账单是零单次检索在几十毫秒日常使用非常稳定。你先跑通再慢慢优化不用一开始就追求大而全的系统。所有代码都能在几分钟内搭起来剩下的时间留在调语料上比折腾框架值多了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询