Spring AI 集成 ONNX 本地嵌入模型:零成本构建 RAG 知识库

发布时间:2026/10/12 18:08:24
Spring AI 集成 ONNX 本地嵌入模型:零成本构建 RAG 知识库
1. 为什么要在本地跑嵌入模型做过知识库问答的人都有一个共识检索质量的上限往往不是由大模型决定的而是由嵌入模型决定的。你喂给向量库的文本被转成什么样的坐标直接决定了后面能不能把正确的片段捞出来。很多人一上来就调云端嵌入接口跑通 Demo 很快但真到要处理几万份内部文档的时候账单和延迟就开始咬人了。我去年帮一个团队做内部知识库文档量大概四万多个切片。一开始用的是某云端嵌入服务按 token 计费测试阶段没感觉全量灌库那天晚上跑完一看消耗负责预算的同事脸都绿了。更麻烦的是每次增量更新都要走网络内网环境还得单独开通道运维那边一堆审批。后来我们换成本地 ONNX 嵌入一台普通的开发机CPU 推理灌库时间从原来的几个小时压到几十分钟成本直接归零而且整个链路完全离线数据不出内网。这就是标题里说的“零成本起步”的真实含义。它不是指硬件不要钱而是指没有按量计费的边际成本你跑一万次和跑一百万次电费差别可以忽略。ONNX 这个格式的好处在于它把训练框架和推理运行时解耦了。模型可以用 PyTorch 训练导出也可以用现成的 sentence-transformers 转换最后统一成.onnx文件用 ONNX Runtime 加载。ONNX Runtime 对 CPU 的优化做得相当扎实INT8 量化之后一个 300MB 左右的嵌入模型在普通笔记本上也能跑到每秒几百个切片。Spring AI 在这个链路里扮演的是胶水层的角色。它把文本切分、嵌入调用、向量库写入、相似度检索这几步串成一条流水线你只需要配置好EmbeddingModel和VectorStore两个 Bean剩下的编排它帮你做了。但 Spring AI 默认的嵌入实现大多指向云端 API想用本地 ONNX 就得自己接一层。这个接入过程不难但有几个坑点比如 tokenizer 的对齐、池化方式的选择、归一化要不要做这些细节如果搞错检索结果会莫名其妙地差而且很难排查。这篇文章适合两类人看一类是正在做 RAG 知识库、被云端嵌入成本或延迟卡住的开发者另一类是想把 Spring AI 用起来、但不想依赖外部服务的后端工程师。我会从方案选型讲到具体接入再到实操中踩过的坑尽量把每一步的“为什么”说清楚让你看完能直接抄作业。2. 方案选型ONNX 嵌入到底怎么选2.1 嵌入模型的三条技术路线对比在决定用 ONNX 之前先搞清楚市面上嵌入模型的几种形态不然容易选错方向。我把常见的路线整理成下面这张表方便你对照自己的场景。路线代表形态优点缺点适用场景云端 API各类在线嵌入服务开箱即用模型强按量计费依赖网络数据出境快速验证、小规模本地 Python 服务sentence-transformers FastAPI模型选择多生态好需要维护 Python 环境内存占用高已有 Python 栈的团队本地 ONNX导出后的.onnx模型无 Python 依赖CPU 友好可量化需要自己处理 tokenizerJava/Go 等非 Python 后端我选 ONNX 的核心理由是部署形态干净。你的 Spring Boot 应用本来就是一个 jar 包如果为了嵌入再拖一个 Python 运行时进去镜像体积、启动时间、依赖冲突都是问题。ONNX Runtime 有官方的 Java 绑定直接作为 Maven 依赖引入跟其他库没有冲突。而且 ONNX 模型可以量化INT8 之后体积和内存占用都能砍掉一大半这对容器化部署很关键。2.2 模型尺寸与维度的取舍嵌入模型不是越大越好。我见过有人直接上 1024 维甚至 1536 维的大模型结果向量库膨胀得飞快检索反而变慢。维度和效果之间有个性价比拐点需要根据你的文档特点来定。一般来说384 维的模型比如 MiniLM 系列在中文短文本检索上已经够用模型文件大概 90MB 左右量化后 20 多 MB。768 维的模型比如 BGE-base 系列效果更稳适合文档片段较长、语义复杂的场景模型文件 400MB 上下。再往上到 1024 维提升就没那么明显了但存储和计算成本是线性增长的。我的建议是先用 384 维跑通全流程用真实 query 测一批召回率如果 top-5 里经常漏掉正确片段再考虑换 768 维。不要一上来就堆大模型调优的空间反而被压缩了。2.3 中文场景下的模型选择要点中文嵌入和英文有个明显区别分词粒度。英文按空格切词很自然中文如果 tokenizer 处理不好一个词被拆成单字语义就散了。所以选模型的时候一定要确认它的 tokenizer 是针对中文优化过的或者至少是多语言版本。另外要注意最大序列长度。很多模型默认 512 token如果你的文档切片超过这个长度会被截断尾部信息直接丢失。切片策略要和模型长度对齐这个后面会细讲。还有一个容易忽略的点是池化方式有的模型用 CLS token 做句向量有的用 mean pooling。导出 ONNX 的时候如果池化层没带上你得在 Java 侧自己实现这一步做错向量方向就全偏了。3. 核心细节从模型文件到 Spring Bean3.1 ONNX 模型的导出与量化假设你已经选好了一个 sentence-transformers 格式的模型第一步是把它转成 ONNX。这个过程在 Python 侧做一次就行产出的.onnx文件可以跟着你的项目一起打包或者放到对象存储里按需下载。导出的时候有几个参数必须显式指定。opset版本建议用 14 以上兼容性好input_names和output_names要固定下来Java 侧调用时靠这个名字找输入输出最重要的是把池化和归一化一起导出这样 Java 侧拿到的就是最终的句向量不用再自己算。from transformers import AutoTokenizer, AutoModel import torch model_name your-local-model-path tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModel.from_pretrained(model_name) dummy tokenizer(测试文本, return_tensorspt, paddingTrue, truncationTrue, max_length512) torch.onnx.export( model, (dummy[input_ids], dummy[attention_mask]), embedding.onnx, input_names[input_ids, attention_mask], output_names[last_hidden_state], dynamic_axes{ input_ids: {0: batch, 1: sequence}, attention_mask: {0: batch, 1: sequence}, }, opset_version14, )导出之后建议做一次 INT8 量化体积能压到原来的四分之一左右CPU 推理速度提升明显。量化会带来一点点精度损失但在检索任务上通常感知不到。量化用 ONNX Runtime 自带的工具就行一条命令的事。注意量化后的模型在 Java 侧加载时要确保 ONNX Runtime 版本和量化工具版本匹配否则可能报算子不支持。我踩过一次量化用的是 1.16 的工具运行时是 1.14加载直接失败。3.2 Tokenizer 的 Java 侧对齐这是整个接入过程中最容易翻车的地方。Python 侧用 HuggingFace tokenizer 把文本转成 id 序列Java 侧必须用完全一致的逻辑还原这个过程否则同一个词两边切出来的 id 不一样向量自然对不上。我的做法是直接用 HuggingFace 的tokenizers库导出一个tokenizer.json然后在 Java 侧用ai.djl.huggingface或者com.robrua.nlp这类库加载。DJL 的 tokenizer 实现和 HuggingFace 对齐得比较好支持 BPE 和 WordPiece中文也能正确处理。加载之后要验证一遍拿几句典型的中文文本Python 侧和 Java 侧分别 encode对比 id 序列是否完全一致。这一步花十分钟能省掉后面几小时的排查。我当时的验证用例包括纯中文、中英混合、带标点、超长截断这几种情况全部对齐之后才继续往下走。3.3 实现自定义 EmbeddingModelSpring AI 的EmbeddingModel接口很简洁核心就是embed(String text)和embed(ListString texts)两个方法。你要做的是把 ONNX Runtime 的推理封装进去。public class OnnxEmbeddingModel implements EmbeddingModel { private final OrtSession session; private final HuggingFaceTokenizer tokenizer; Override public float[] embed(String text) { long[] ids tokenizer.encode(text); long[] mask new long[ids.length]; Arrays.fill(mask, 1L); // 构造 OnnxTensor执行推理取 mean pooling 结果 // 最后做 L2 归一化 return normalize(pooling(output)); } }几个关键点batch 推理要支持一次传多个文本比循环单条快很多归一化一定要做余弦相似度依赖单位向量异常处理要细tokenizer 遇到空字符串或者超长文本要有兜底逻辑。4. 实操全流程从零搭一个本地检索链路4.1 项目依赖与目录结构先把 Maven 依赖理清楚。核心是三个Spring AI 的 starter、ONNX Runtime 的 Java 包、以及 tokenizer 库。dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId /dependency dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.16.3/version /dependency dependency groupIdai.djl.huggingface/groupId artifactIdtokenizers/artifactId version0.27.0/version /dependency目录结构上我把模型文件和 tokenizer 文件放在src/main/resources/model/下面打包进 jar。如果模型太大不想进 jar就放外部目录用配置项指定路径。我倾向于后者因为模型更新的时候不用重新打包。4.2 文本切分的参数计算切分策略直接决定检索效果。切太碎语义不完整切太大噪声多而且可能超过模型长度。我的经验值是每片 300 到 500 个中文字符重叠 50 到 80 个字符。为什么要有重叠因为句子边界不一定落在切片边界上。比如一个关键结论横跨两个切片没有重叠的话两个切片各拿一半检索时都匹配不上。重叠部分相当于给边界处上了保险。计算切片数量的时候要考虑 token 和字符的比例。中文大致是 1 个 token 对应 1.5 到 2 个字符所以 500 字符大概 250 到 330 个 token留足余量给 512 的上限。如果你的文档里有大量英文或代码这个比例会变需要单独测一下。4.3 灌库与检索的完整代码灌库的流程是读文档 → 切分 → 批量嵌入 → 写入 VectorStore。Spring AI 的VectorStore.add()接收的是Document列表每个 Document 带内容和元数据。ListDocument docs splitter.split(rawText); ListDocument embedded embeddingModel.embed(docs); vectorStore.add(embedded);检索的时候把用户 query 用同一个嵌入模型转成向量然后调vectorStore.similaritySearch()。这里有个细节query 和文档必须用同一个模型不能一个用本地 ONNX 一个用云端维度对不上直接报错。SearchRequest request SearchRequest.query(userQuery).withTopK(5); ListDocument results vectorStore.similaritySearch(request);topK 的选择也有讲究。太小容易漏太大引入噪声。我一般先用 5 到 10 测召回如果正确片段经常排在 3 名开外说明嵌入质量有问题得回去调模型或切分策略而不是盲目加大 topK。4.4 性能实测数据在普通开发机8 核 CPU16G 内存上我测了一组数据供参考。384 维模型INT8 量化batch size 设为 16。环节耗时说明模型加载约 1.2 秒启动时一次性单条嵌入约 8 毫秒短文本批量嵌入16 条约 45 毫秒平均每条 2.8 毫秒万级切片灌库约 3 分钟含切分和写入单次检索约 15 毫秒topK5这个性能对绝大多数内部知识库场景都够用了。如果你的文档量到百万级瓶颈会在向量库的索引上那时候要考虑用支持 HNSW 的向量库而不是纠结嵌入速度。5. 常见问题与排查技巧5.1 检索结果差先查这三个地方检索效果不好很多人第一反应是换模型其实大部分问题出在链路细节上。我整理了一个排查顺序按这个走能省不少时间。现象可能原因排查方法完全不相关tokenizer 不对齐对比 Python 和 Java 的 id 序列部分相关池化方式错误检查是否用了 mean pooling排序混乱未做归一化确认向量模长是否为 1长文本漏召回切片超长被截断统计切片 token 分布tokenizer 不对齐是最隐蔽的因为程序不报错只是结果差。我建议在启动时加一个自检用固定的测试文本跑一遍嵌入和预存的基准向量对比余弦相似度低于 0.99 就告警。5.2 内存与并发问题ONNX Runtime 的 session 是线程安全的可以多线程共享。但要注意OrtSession的创建比较重不要每次请求都新建。我的做法是用 Spring 的单例 Bean 持有 session全局一个实例。内存方面384 维模型加载后大概占 200MB 左右768 维的会到 800MB。如果容器内存紧张可以调 ONNX Runtime 的intra_op_num_threads参数限制线程数减少内存开销。另外批量嵌入的时候 batch size 不要设太大16 到 32 比较稳太大反而会因为内存拷贝拖慢速度。5.3 模型更新的平滑切换模型换了之后旧向量和新向量不在同一个空间必须全量重灌。这个操作要设计成可回滚的新模型灌到一个新的 collection验证检索效果没问题之后再切流量过去。Spring AI 的 VectorStore 支持指定 collection 名称切换的时候改配置就行不用动代码。提示重灌期间旧 collection 保持可读避免服务中断。灌完做一轮 A/B 对比用同一批 query 看召回率变化确认提升再切。6. 几个我踩过的坑和对应解法第一个坑是量化模型的算子兼容性。前面提过量化工具和运行时版本要匹配。除此之外有些模型导出时用了动态量化Java 侧加载会报NOT_IMPLEMENTED。解决办法是导出时用静态量化或者干脆不量化用 FP32 跑性能差距在可接受范围内。第二个坑是中文标点的影响。有些 tokenizer 会把中文标点单独切出来导致句子被切得很碎。我在预处理阶段做了一步标点归一化把全角标点转半角连续标点合并嵌入质量有肉眼可见的提升。第三个坑是空文本和超短文本。切片过程中难免产生空字符串或者只有一两个字的片段这些片段嵌入出来是噪声会污染检索结果。我在灌库前加了一层过滤长度小于 10 个字符的直接丢弃。第四个坑是向量库的维度配置。换模型的时候忘了改 VectorStore 的维度配置写入直接报错。这个错误信息还算明确但如果你用的是自动建表的向量库可能表已经建好了改维度要删表重建数据就没了。所以换模型前一定要确认维度配置同步更新。7. 后续可以怎么扩展跑通基础链路之后有几个方向可以继续优化。一是混合检索把向量检索和关键词检索的结果做融合对专有名词和代码片段的召回提升明显。二是重排序在向量召回 top-20 之后用一个小的交叉编码器做精排取 top-5 给大模型效果比单纯加大 topK 好得多。三是增量更新文档变动时只重灌变化的切片而不是全量重跑这个需要给每个切片维护一个内容哈希。重排序模型同样可以走 ONNX 路线只是它需要 query 和文档成对输入推理成本比嵌入高适合放在召回之后的小候选集上。我实测下来加一层重排序top-5 的准确率能提升十几个百分点代价是每次检索多几十毫秒这个 trade-off 在大多数场景下是划算的。最后分享一个小技巧嵌入模型的输出向量建议存一份到本地文件做备份。向量库如果因为配置问题需要重建有备份的话可以直接导入不用重新跑一遍嵌入。这个备份在调试阶段特别有用你可以快速切换不同的向量库实现做对比而不用每次都等灌库。