资讯动态

本地ONNX嵌入模型集成Spring AI:零成本构建知识库向量化服务

发布时间:2026/10/14 10:23:35 来源:尧图企业网站定制
1. 为什么我把向量化服务从在线API换成了本地ONNX在做知识库相关项目时第一步就是把非结构化文本转换成向量。我最早和其他人一样图省事直接接了在线向量化API注册账号、拿密钥、按token付费一套流程走下来确实不用自己操心模型。但等项目真正上线跑起来问题就接踵而至。1.1 在线API的三个隐性成本第一个是成本曲线完全不可控。测试阶段一个月几块钱显得很便宜但生产环境一旦接入持续的数据同步文档量从几百篇涨到几万篇每次全量重算一次嵌入的费用就是肉眼可见的账单。更难受的是增量更新每来一批新文档就得调一次接口积少成多之后这笔费用会稳定成为每月的固定支出。第二个是隐私和数据边界问题。很多知识库场景处理的是内部文档、业务资料这些文本内容发给第三方API从合规角度很难解释清楚。哪怕服务商承诺数据不留存文档内容和上下文的敏感性也决定了这种做法不是长久之计。第三个是网络依赖和延迟抖动。在线API一次调用少说几百毫秒如果要做大批量文档的离线处理还会遇到并发限制、超时重试、限流报错这些问题。我遇到过半夜批量任务跑到一半被限流的情况整个数据管道直接卡住排查起来相当费劲。1.2 本地方案能带来什么后来我把向量化服务全部迁移到了本地ONNX推理这件事带来的改变是根本性的。首先本地跑嵌入模型完全不产生token费用硬件要求也不高一个普通的CPU就能跑得很舒服——这也是标题里零成本的真正含义。其次文本完全不离开服务器数据安全边界清晰了不用再纠结外部API的合规问题。最后批量处理的速度反而更快了本地推理没有网络开销一次批量调用可以将几百条文本打包送进模型吞吐量远超在线接口。这里我需要澄清一个概念标题里说的本地ONNX嵌入指的是把开源嵌入模型转换成ONNX格式然后用ONNX Runtime在本地完成推理把一段文本映射成一个浮点数组。整个流程不依赖外部服务也不依赖GPU。1.3 文本秒变坐标到底是什么意思先把这个最核心的概念讲清楚否则后面代码看不懂。所谓文本秒变坐标本质上是把一段自然语言转成一个高维空间里的坐标点。这个坐标点通常是一个浮点数组比如384维或768维。向量检索的逻辑就是基于这些坐标的几何关系语义相似的文本它们的坐标在空间里距离很近语义无关的文本坐标就会离得很远。举个例子在向量空间里公司今年的营收增长了20%和本年度企业收入提升了两成这两句话的坐标距离会非常近而今天天气不错这个句子的坐标会离它们很远。传统的关键词检索靠字面匹配遇到同义改写基本就哑火但向量检索靠的是语义距离这也是它能在知识库、RAG、语义搜索这些场景里站稳脚跟的原因。Spring AI作为Java生态里对接AI能力的框架统一了EmbeddingModel这个抽象接口。我们要做的事情就是让Spring AI用本地ONNX模型来完成文本 - 坐标这一步转换而不是去配置在线API的密钥。2. 模型选型与ONNX转换零成本起步的第一块拼图本地化方案的第一步是选一个合适的嵌入模型。我见过不少人一上来就追求大模型什么几百MB甚至上GB的模型都敢往项目里塞结果推理速度慢得离谱效果提升却微乎其微。做知识库起步阶段模型选择要综合考量三个指标体积、速度和语言适配度。2.1 嵌入模型怎么挑既要效果又要体积行业里有一个公认的规律嵌入模型不是越大越好。对于中文知识库场景一个参数量在两亿左右、输出维度在384到512的模型已经足够覆盖绝大多数场景。这类模型转成ONNX之后体积一般在100MB上下加载到内存里占用不到200MB在普通服务器CPU上跑单条文本的向量化延迟可以控制在几十毫秒以内。选型时要注意几个关键点。首先是中文语料的覆盖度同一个模型对不同语言的语义理解能力差异很大建议选择在中文语料上有针对训练过的开源模型。其次是最大序列长度知识库里的文档往往比较长如果模型只支持最多128个token的输入那么超长文本就只能被截断语义信息会损失严重。我一般选择最大长度至少256的模型实际使用时再配合文本切分策略来适配。还有一个容易被忽视的点模型的输出维度。维度越高理论上表示语义的能力越强但存储和检索的成本也随之上涨。对零成本起步这个目标来说384维是个甜点值——既能保证足够的语义区分度又不会让向量数据库占用太多内存和磁盘。2.2 从PyTorch权重到ONNX文件的手动转换选定模型之后接下来的关键步骤是把原始权重转成ONNX格式。现在开源模型社区里很多嵌入模型直接用ONNX格式分发省去了转换步骤。但如果你的模型只有PyTorch版本的权重就需要自己手动转一次。转换过程的核心思路是加载原始模型用句子嵌入模式导出计算图保留隐藏状态输出然后序列化成一个.onnx文件。比如在Python环境里用简单的脚本就可以完成from transformers import AutoTokenizer, AutoModel import torch model_id 你挑选的开源嵌入模型路径 tokenizer AutoTokenizer.from_pretrained(model_id) model AutoModel.from_pretrained(model_id) # 设置模型为推理模式 model.eval() # 构造一个示例输入用于固定ONNX图的输入形状 example_input tokenizer(测试文本, return_tensorspt) # 导出ONNX torch.onnx.export( model, ({key: value for key, value in example_input.items()},), embedding_model.onnx, input_names[input_ids, attention_mask, token_type_ids], output_names[sentence_embedding], dynamic_axes{ input_ids: {0: batch_size, 1: sequence_length}, attention_mask: {0: batch_size, 1: sequence_length}, token_type_ids: {0: batch_size, 1: sequence_length}, sentence_embedding: {0: batch_size, 1: sequence_length} }, opset_version14 )这段脚本的重点有两个。第一dynamic_axes必须配置好因为实际推理时文本长度是不固定的如果不给模型加上动态轴后期只要遇到超过固定长度的输入就会直接报错。第二opset_version建议至少在14以上太低的算子集版本会导致后续在Java端推理时出现不支持某些算子的问题。2.3 导出过程中的验证环节很多人在这一步栽过跟头ONNX文件导出来了模型也能加载但推理出的向量质量明显有问题相似度计算结果完全不符合直觉。原因多半是转换时忘了把模型切到evaluation模式或者忘了在导出前对tokenizer的padding策略做对齐。正确的验证方法是在导出之前和导出之后各跑一遍推理对比结果。具体做法是准备几组语义相近和语义无关的句子对分别用原始PyTorch模型和导出后的ONNX模型计算相似度观察两组结果是否一致。只要数值接近就说明导出过程没有破坏模型的核心计算逻辑。导出之后的文件还有一步重要处理检查是不是真的只需要sentence_embedding这一个输出。有些模型导出后会连带输出last_hidden_state等额外信息会在推理时增加不必要的计算量。用工具精简掉这些冗余输出可以让最终ONNX文件更小推理速度更快。3. Spring AI集成写一个基于ONNX Runtime的EmbeddingModel模型文件准备就绪之后剩下的工作都发生在Java工程里。Spring AI的核心设计是抽象了EmbeddingModel接口实现这个接口就可以让上层服务统一调用开发者在业务代码里完全不需要关心底层到底用的是本地模型还是远程API。3.1 项目依赖与基础配置先看Maven依赖的添加方式。核心依赖有两个一个是Spring AI的starter一个是ONNX Runtime的Java绑定。如果你的项目里打算用Spring AI的向量存储和RAG相关功能还需要引入对应的模块。dependencies !-- Spring AI 基础依赖 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId version你的版本号/version /dependency !-- ONNX Runtime Java 绑定 -- dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.17.1/version /dependency /dependencies这里插一句可能让你意外的话ONNX Runtime在Java端的API设计非常清爽核心对象只有两个——OrtEnvironment和OrtSession。前者是运行时环境整个JVM只需要创建一次后者是加载好的模型会话负责接收输入、执行推理、返回输出。把这两个对象管理好集成基本就完成了一半。3.2 核心实现加载模型、分词、推理、归一化下面是EmbeddingModel实现的核心代码。我把它拆解成几个部分每个部分都有值得关注的细节。import ai.onnxruntime.*; import org.springframework.ai.embedding.Embedding; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.stereotype.Component; import java.nio.FloatBuffer; import java.util.*; Component public class OnnxEmbeddingModel implements EmbeddingModel { private final OrtEnvironment environment; private final OrtSession session; private final OnnxTokenizer tokenizer; private final int dimensions; public OnnxEmbeddingModel() throws OrtException { // 1. 初始化runtime环境全局共享 this.environment OrtEnvironment.getEnvironment(); // 2. 加载ONNX模型文件 this.session environment.createSession(embedding_model.onnx, new OrtSession.SessionOptions()); // 3. 从模型输出shape中读取向量维度 this.dimensions 384; // 4. 加载tokenizer使用转换时同款的词表 this.tokenizer new OnnxTokenizer(vocab.txt); } Override public EmbeddingResponse call(EmbeddingRequest request) { Listfloat[] embeddings new ArrayList(); // 批量处理减少推理调用次数 for (String text : request.getInstructions()) { float[] vector embedText(text); embeddings.add(vector); } ListEmbedding data embeddings.stream() .map(vector - new Embedding(vector, 0)) .toList(); return new EmbeddingResponse(data); } private float[] embedText(String text) { try { // 分词文本 - token IDs mask token type ids OnnxTokenizer.TokenizationResult tokenization tokenizer.tokenize(text, 256); // 构造ONNX Runtime的输入Tensor OnnxTensor inputIds OnnxTensor.createTensor( environment, tokenization.inputIds(), new long[]{1, tokenization.inputIds().length}); OnnxTensor attentionMask OnnxTensor.createTensor( environment, tokenization.attentionMask(), new long[]{1, tokenization.attentionMask().length}); OnnxTensor tokenTypeIds OnnxTensor.createTensor( environment, tokenization.tokenTypeIds(), new long[]{1, tokenization.tokenTypeIds().length}); // 执行推理 MapString, OnnxTensor inputs Map.of( input_ids, inputIds, attention_mask, attentionMask, token_type_ids, tokenTypeIds ); try (OrtSession.Result results session.run(inputs)) { // 拿到句子向量batch1, dim384 OptionalOnnxTensor output results.get(sentence_embedding); if (output.isEmpty()) { throw new RuntimeException(模型输出缺失: sentence_embedding); } float[] rawVector output.get().getFloatBuffer().array(); // 关键一步L2归一化直接把向量变换成单位向量 return l2Normalize(rawVector); } } catch (OrtException e) { throw new RuntimeException(ONNX推理失败, e); } } private float[] l2Normalize(float[] vector) { float sumSquares 0.0f; for (float v : vector) { sumSquares v * v; } float norm (float) Math.sqrt(sumSquares); float[] result new float[vector.length]; for (int i 0; i vector.length; i) { result[i] vector[i] / norm; } return result; } Override public int dimensions() { return dimensions; } }这里有一个非常关键的细节值得展开讲为什么我做了L2归一化。原始模型输出的向量长度是不固定的不同文本转换出的向量模长差异很大。如果不做归一化向量检索用余弦相似度计算时其实是在同时比较方向和长度而文本语义通常只体现在方向上。统一归一化成单位向量之后余弦相似度等价于点积运算计算更快效果也更稳定。另一个细节是dimensions()方法。Spring AI的很多下游功能比如向量存储构建、元数据拼接都会调用这个方法读取模型输出的向量维度。如果你返回的维度值和实际推理结果不一致后面存向量时会遇到维度报错。我在项目里遇到过这种情况排查了半天才发现是这个方法返回值写错了。3.3 把自定义实现注册进Spring上下文实现EmbeddingModel接口之后把它交给Spring管理就行了。我用的是Component注解注册这样在任何业务代码里都可以通过Autowired直接注入。Service public class KnowledgeSearchService { private final EmbeddingModel embeddingModel; public KnowledgeSearchService(EmbeddingModel embeddingModel) { this.embeddingModel embeddingModel; } public void indexDocument(String content) { EmbeddingResponse response embeddingModel.call( new EmbeddingRequest(List.of(content), EmbeddingOptionsBuilder.builder().build())); ListEmbedding embeddings response.getEmbeddings(); // 拿到float[]向量后存入向量存储 } }Spring AI的EmbeddingModel接口设计得足够薄以至于你可以在不修改任何业务代码的前提下把本地ONNX实现替换成OpenAI、或者任何其他在线实现。这也是框架设计的价值所在嵌入式实现的切换成本被降到了最低。不过有一点要提醒我上面的OnnxTokenizer类还没有实现这是一个容易被忽略的环节。如果你的项目里不想手工维护分词逻辑可以在转换模型时把tokenizer的vocab.txt一并复制到资源目录然后自己写一个极简的分词器。但如果追求精确的tokenization效果建议使用开源Java分词库来加载与模型配套的词表。4. 向量检索与知识库最小闭环嵌入模型跑通之后知识库就有了文本到坐标的能力接下来要把坐标存起来并且支持检索。这一步决定了知识库的可用程度。4.1 存储与检索从零实现到Spring AI内置方案零成本起步阶段可以完全不引入外部向量数据库。你只需要一个支持高维数组运算的内存存储结构配合暴力检索就可以满足早期需求。对于起步阶段几千条文档的规模暴力检索完全够用——计算一次余弦相似度只需要做几百次浮点乘法现代CPU微秒级别就能完成。Spring AI里有一个内置的SimpleVectorStore底层基于内存维护向量和文档的映射支持相似度检索。它的API简洁到让人愉快Configuration public class VectorStoreConfig { Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return new SimpleVectorStore(embeddingModel); } }写入文档时调用vectorStore.add(...)检索时调用vectorStore.similaritySearch(...)。如果不满足于内存存储Spring AI还提供各种向量数据库的适配器你可以把SimpleVectorStore替换成任意一种专业向量数据库实现而上层业务代码几乎不需要改动。这里我给出一个更实用的建议不要盲目追求外部向量数据库。如果只是个人项目、内部工具或者知识库起步阶段内存向量存储的性价比最高。等到数据量确实超出内存能承载的范围再迁移到专业向量数据库也不迟。4.2 文档切分策略向量检索效果的隐藏变量很多人以为向量检索全靠模型效果实际影响最大的往往是上游的文档切分策略。一段长文本直接整段向量化一旦超过模型的序列长度限制就会被强行截断后半部分的关键信息全部丢失。反过来如果切分得太碎每一片文本携带的上下文太少向量化时也可能因为语义不完整而产生偏差。我的切分策略是分两步走。第一步按段落边界切分保证每片文本至少是一个语义完整的段落。第二步如果段落仍然过长按句号、问号等标点继续拆配合滑动窗口让相邻文本片重叠一定量。这样既能控制单次向量化的输入长度又能保持切片之间的语义连贯性。实际工程里切片长度设置成多少合适取决于模型的max sequence length。我一般把单个文本片控制在200到300个token以内再配合重叠逻辑实测检索效果比较平衡。4.3 端到端RAG问答接口的实现向量存储就位后一个最小的知识库问答流程就可以串起来了接收用户问题 - 问题向量化 - 在向量存储中检索最相似的文本片 - 把文本片和问题组装成提示词 - 调用大模型生成答案。下面是一段完整的Controller实现它演示了从检索到生成的完整链路RestController RequestMapping(/api/knowledge) public class KnowledgeController { private final VectorStore vectorStore; private final ChatClient chatClient; public KnowledgeController(VectorStore vectorStore, ChatClient chatClient) { this.vectorStore vectorStore; this.chatClient chatClient; } PostMapping(/search) public SearchResult search(RequestBody String question) { // 1. 问题向量化 相似度检索返回TopK文本片 ListDocument documents vectorStore.similaritySearch( SearchRequest.builder() .query(question) .topK(5) .similarityThreshold(0.6) .build() ); // 2. 组装检索到的文本 String context documents.stream() .map(Document::getContent) .reduce(, (a, b) - a \n b); // 3. 传递给大模型生成回答 String answer chatClient.prompt() .user(请根据以下资料回答问题\n context \n问题 question) .call() .content(); return new SearchResult(answer, documents); } }这个接口已经具备一个知识库问答系统的最小骨架。你可以在此基础上加历史对话记忆、改写用户问题、结果排序等功能但核心链路的复杂度并没有想象中那么高。5. 实测效果、性能数字与踩坑记录前几章讲的都是思路和代码这一章我把自己实际跑过的性能数据和踩过的坑整理出来希望对你有更直接的参考价值。5.1 CPU推理的实际表现我的测试环境是一台普通的开发笔记本CPU是六核十二线程内存16GB没有独立显卡。使用的模型输出维度384最大序列长度256ONNX Runtime采用默认CPU执行配置。测试数据是一批中文文档单条文本平均长度150个token左右。实测单条文本向量化耗时在30到50毫秒之间性能可以接受。但真正体现本地推理优势的是批量处理把100条文本打包成一个批次执行推理单条平均耗时能压到5毫秒以内。原因是ONNX Runtime在CPU上会调用底层的高性能数学库批量推理时矩阵运算的SIMD加速效果非常明显。所以如果你的知识库需要定期全量重建索引务必使用批量方式而不是一条一条地调用嵌入接口。我见过有人写了逐条循环的代码处理一万条文档耗时整整十分钟改成批量后同样工作量只要几十秒。5.2 我踩过的四个坑第一个坑是tokenizer与模型不匹配。我最初为了省事用了一个通用分词库加载了错误的词表文件结果模型输出的向量和PyTorch推理结果完全不匹配。同一个苹果在不同实现里被切分成完全不同的token语义向量自然对不上号。后来换成与模型原生的词表配套的Java分词实现问题消失。这里提醒一句ONNX转换时务必同时保存原始tokenizer的vocab文件这两者是强绑定的。第二个坑是L2归一化的位置。向量归一化这个操作放在哪一步执行对检索效果影响很大。我遇到的情况是模型输出直接存库检索时现场归一化这在单机内部问题不大。但如果你在多台机器部署某些节点做了归一化、某些节点没有检索结果就会出现随机漂移。最佳实践是写入向量库之前统一归一化存储的永远是单位向量检索时直接点积即可。第三个坑是ONNX Runtime的线程数设置。默认情况下ONNX Runtime会让线程池占满所有CPU核心。如果你的服务器上还跑着其他业务服务推理语句就会疯狂抢占CPU导致整个应用响应变慢。解决办法是创建Session时显式指定线程数控制在核心数的一半左右。OrtSession.SessionOptions options new OrtSession.SessionOptions(); options.setIntraOpNumThreads(4);第四个坑是批量推理容易踩进输入长度不一致的陷阱。ONNX Runtime要求同一个批次内所有输入的形状一致所以对长度不等的文本做batch时必须padding到同一长度并配合attention_mask标记填充位。Java端做padding比Python端麻烦代码量不小但如果漏了mask处理模型的输出会包含大量填充位的噪声向量严重影响检索精度。5.3 后续可以扩展的方向整套方案跑起来之后我陆续做过一些扩展。如果你有同样需求可以从这几个方向入手。第一个是持久化方案升级。内存向量存储在重启后数据会丢失起步阶段可以用文件序列化兜底比如把向量数组和文档元数据以二进制格式落盘。数据量进一步增长后考虑切换到外部向量数据库Spring AI的接口抽象让这种迁移成本很低。第二个是混合检索。向量检索擅长语义匹配但对精确数值、专有名词的检索反而弱于关键词匹配。实际项目里我把向量检索和倒排索引做了一次融合先分别取得两路结果再做Reciprocal Rank Fusion合并排序检索效果比单独使用任何一路都好。第三个是增量索引策略。知识库是持续增长的每次新增文档不需要全量重建向量库。我的做法是对每个文本片计算一个哈希值写入前先检查哈希是否已存在存在则跳过。这套去重处理让增量更新的成本降到最低。说实话整个方案落地之后回头看零成本起步的本质并不是不花钱而是把成本结构的决定权掌握在自己手里。在线API用一天就要付一天的钱而且这个钱会随着数据量增长持续滚雪球。本地ONNX方案的主要成本就是前期配置环境的那几小时之后每一次向量化都是固定的CPU开销不存在边际成本的指数增长。如果你也在规划知识库或者语义检索系统我建议你直接把本地嵌入模型纳入选型——它可能不酷但真的能让你睡得安稳。

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

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

免费获取报价 →
↑