Spring AI + ONNX 本地嵌入:零成本搭建知识库检索链路

📅 发布时间:2026/10/13 2:14:03
Spring AI + ONNX 本地嵌入:零成本搭建知识库检索链路
做本地知识库第一步永远是向量化。无论你是要给内部文档加语义搜索还是想搭一套给大模型喂上下文的 RAG 链路都得先把文本变成一串可计算的数字数组。这个动作在 Spring AI 生态里叫 Embedding在很多云端服务里则被包装成“按量计费的 API”。不少团队一上来就选云端接口等账单和网络问题冒出来以后才回头算成本账。我的建议很直接如果你刚开始做知识库或者只是想验证一个检索原型本地 ONNX 嵌入完全够用而且能真正做到零成本起步。这篇文章要聊的就是这样一套方案用 Spring AI 作为嵌入和检索的抽象层用 ONNX 格式的本地模型把文本变成高维坐标再用内存数据结构搭一个最简知识库。整个过程不需要联网、不需要申请密钥、不需要为每次调用掏钱所有环节都跑在本地。适合正在用 Java 做 RAG、做文档问答、做语义搜索的开发者参考尤其是那些被云端服务成本、数据合规和网络延迟折腾过的团队。我实际操作下来从依赖引入到完成一个可检索的接口大概花了一个下午。中间踩了几个不大不小的坑比如模型文件路径配错、tokenizer 缺失、中文输入效果波动等。下面把完整过程拆开讲碰到的问题也一并整理在最后方便你直接对照排查。1. 为什么“文本秒变坐标”这么关键1.1 从字符串匹配到语义距离传统搜索用的是关键词匹配一个句子拆成词然后看查询词有没有出现在文档里。这种方式对精确术语有效但遇到同义改写、语序调整、口语化表达就基本失效。比如用户搜“怎么调低数据库连接池上限”文档里写的是“减小连接池最大连接数”关键词搜索很难把这两句话关联起来但人一眼就知道这是同一个问题。向量化就是为了解决这种语义鸿沟。它的核心思路是把每一段文本映射成一个高维向量这个向量在空间中指向的位置代表这段文本的语义。语义相近的两句话向量在空间里的距离也近语义无关的文本向量方向就会差得很远。你可以把它想象成给句子发了一张“语义地图上的经纬度坐标”有了坐标之后我们比较的不再是字面而是位置。在常识里坐标是一组数字文本也是一样。所谓“一到三百八十四维实数数组”说穿了就是把一句话变成 384 个小数的排列。后续无论是做相似度匹配、聚类还是去重都能在数学层面完成不需要模型重新参与推理。1.2 本地 ONNX 相比云端接口的三点优势ONNX 是一种开放的模型交换格式把训练好的模型统一包装成标准文件再借助本地推理引擎执行。嵌入模型本身不算大很多轻量模型压缩之后只有几十到一两百 MB完全可以在 CPU 上跑出可用性能。正是因为模型足够轻本地部署才变得现实。把 ONNX 嵌入模型跑在本地和调用云端嵌入接口的区别非常明显。我列过一张表方便你直观对比对比维度本地 ONNX 嵌入云端嵌入接口调用费用无额外费用本机执行按调用次数或 token 用量计费数据安全文本不离开本机需要考虑数据出域与脱敏合规单次延迟通常几十毫秒不受网络影响受网络波动、服务端并发影响离线可用完全支持一般需要联网启动成本下载模型文件即可需要注册账号、获取密钥、接入 SDK这不是说云端接口永远不好大规模生产环境里云端托管有运维省心、可用性高的好处。但知识库项目的特点是早期数据量小、迭代频繁、需要反复调试分块策略和检索参数。如果每一步都在付费试错成本会变得很高。本地模型把“实验成本”压到几乎为零这才是准备阶段的最佳选择。1.3 Spring AI 在这里扮演什么角色Spring AI 给 Java 开发者提供了一套统一接口其中EmbeddingModel负责文本嵌入VectorStore负责向量存取。这意味着我可以在代码里面向抽象编程而不必关心底层实现到底来自本地模型、数据库扩展还是远端服务。这个设计很像 Java 生态里的 JDBC。你写业务代码时用的是DataSource和Connection换数据库厂商只需要改配置和驱动业务代码可以保持不变。Spring AI 也是一样今天用本地 ONNX明天想切到生产级的向量数据库或云端嵌入服务通过接口替换就能平滑迁移。先本地验证再逐步升级这也符合我在项目里一贯的渐进式演进思路。2. 事前准备依赖、模型与工程目录2.1 基础环境版本动手之前先确认基础环境。我用的组合是 JDK 17、Spring Boot 3.2、Maven 3.9Spring AI 的版本为 1.0.x。这套组合在大多数新项目里都很常见如果你用的是 JDK 11最好先升级到 17因为 Spring AI 的很多新特性基于更高版本的字节码旧 JDK 会遇到兼容问题。组件推荐版本说明JDK17Spring AI 1.0 依赖较新的字节码Spring Boot3.2配合 Spring AI 的自动配置Maven3.9常规构建工具即可Spring AI1.0.x包含 ONNX embedding 支持这里建议尽量选稳定版不要追刚发布的里程碑版本。我折腾过一次按最新 SNAPSHOT 版本升级结果某个 API 签名变了花了不少时间改代码。追求稳定就把小版本锁死。2.2 Maven 依赖引入在pom.xml里引入 Spring AI 的 ONNX embedding 模块dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-onnx-embedding/artifactId version1.0.0/version /dependency这个模块会传递引入 ONNX runtime 相关的依赖正常情况下不需要手动再去添加底层推理引擎。引入依赖后最好先跑一次mvn dependency:tree确认版本没有冲突。我遇到过传递依赖的推理库版本被另一个依赖覆盖的情况最典型的症状是运行时抛出类找不到的异常排查起来比较费劲。2.3 模型文件下载与目录安排ONNX 嵌入模型常用的是 MiniLM 这类轻量句子模型输出 384 维向量。需要的文件是两个model.onnx和tokenizer.json。前者是推理权重后者是切词器词汇表。有些模型包还会带tokenizer_config.json、special_tokens_map.json等附属文件建议一并保留不要只拿主文件。存放位置有两种选择开发调试阶段放在src/main/resources/models/下打包后文件在 classpath 中可以访问。正式发布阶段放在应用运行目录的外部models/文件夹下通过绝对路径或相对路径指定避免 fat jar 体积过于臃肿。我实际推荐第二种方式因为嵌入模型文件动辄几十 MB 以上打进 jar 会显著拖慢启动和部署效率。而且模型可能在业务迭代中被替换外部挂载更容易维护。3. 让 Spring AI 跑通本地 ONNX 嵌入3.1 最小化配置在application.yml中指定模型文件的 classpath 路径或文件路径spring: ai: embedding: onnx: model-path: classpath:models/model.onnx tokenizer-path: classpath:models/tokenizer.json threads: 4如果你把模型放在外部目录那么把classpath:前缀去掉直接写文件系统路径例如spring: ai: embedding: onnx: model-path: ./models/model.onnx tokenizer-path: ./models/tokenizer.jsonthreads这个参数控制推理线程数默认值未必适合你的机器。双核四线程的老机器设 4 问题不大但如果是高并发场景我个人不建议盲目调大因为 CPU 推理本身是瓶颈线程数量提升到一定程度后收益会骤降。3.2 第一行向量化代码Spring AI 自动配置会读取上面的配置注入一个EmbeddingModel实例。在 Controller 里直接使用RestController RequestMapping(/embed) public class EmbedController { private final EmbeddingModel embeddingModel; public EmbedController(EmbeddingModel embeddingModel) { this.embeddingModel embeddingModel; } PostMapping public float[] embed(RequestBody String text) { return embeddingModel.embed(text); } }embed(String)会返回一个float[]数组长度为 384。这就是文本对应的坐标。批量处理时可以一次传入多条文本让底层做批量推理ListString texts List.of(Spring AI 是什么, 如何构建本地知识库, 向量检索原理); Listfloat[] vectors embeddingModel.embed(texts);批量调用比循环单条调用更高效原因是 embedding 模型对 batch 输入的处理使用了并行计算。知识库构建阶段动辄成百上千条文本用批量接口能把整体耗时压到可接受范围。3.3 “文本秒变坐标”的现场验证我第一次跑通时把输出直接打印出来看效果前几个数值大概是这样的text Java 开发中使用向量检索 vector [0.0132, -0.0178, 0.0255, ..., 0.0021]单独看小数没有意义必须结合相似度计算才有价值。于是我顺手写了两个测试句子一个跟原句语义接近一个完全无关然后计算它们与新句子的余弦相似度相似句子“在 Java 项目里实现语义搜索” → 相似度约 0.87无关句子“今天中午吃什么” → 相似度约 0.12这个结果非常直观语义相近的文本在向量空间里靠得很近无关内容聚不拢。看到这样的效果就可以放下心来继续搭知识库了。4. 用内存向量库搭一个最简知识库4.1 文档分块向量检索的隐藏门槛把一段文本直接转成向量然后整段拿去检索这在小型文档上可行但对长文档效果很差。原因在于 embedding 模型有输入长度上限MiniLM 类模型通常只支持几百个 token超出部分会被截断截断后语义信息就会丢失。正确的做法是先分块再嵌入。我的经验是按段落或按语义完整性分块单块长度控制在 200 到 300 个字符左右相邻分块可以保留少量重叠避免切断语义分块不仅是给 embedding 模型限长也是对检索精度负责。用户一个问题通常只需要文档中的某一段来回答而不是整篇文档。块越小检索结果越精准但块过小又会丢失上下文。200 到 300 个字符是我试下来比较稳的区间。4.2 核心检索代码定义文档块的结构public record DocumentChunk(String content, float[] vector) { }然后写一个简单的内存存储与检索组件Component public class LocalVectorStore { private final EmbeddingModel embeddingModel; private final ListDocumentChunk chunks new CopyOnWriteArrayList(); public LocalVectorStore(EmbeddingModel embeddingModel) { this.embeddingModel embeddingModel; } public void addChunk(String content) { float[] vector embeddingModel.embed(content); chunks.add(new DocumentChunk(content, vector)); } public ListDocumentChunk search(String query, int topK) { float[] queryVector embeddingModel.embed(query); return chunks.stream() .map(chunk - new ScoredCandidate(chunk, cosineSimilarity(queryVector, chunk.vector()))) .sorted(Comparator.comparingDouble(ScoredCandidate::score).reversed()) .limit(topK) .map(ScoredCandidate::chunk) .toList(); } public double cosineSimilarity(float[] v1, float[] v2) { double dot 0.0; double norm1 0.0; double norm2 0.0; for (int i 0; i v1.length; i) { dot v1[i] * v2[i]; norm1 v1[i] * v1[i]; norm2 v2[i] * v2[i]; } return dot / (Math.sqrt(norm1) * Math.sqrt(norm2) 1e-9); } private record ScoredCandidate(DocumentChunk chunk, double score) { } }这段代码基本就是知识库检索的最小核心把新文本向量化后存起来查询时把问题向量化再逐条计算余弦相似度按分数取排名靠前的分块返回。模型推理之外没有任何中间环节逻辑非常透明。4.3 为什么用余弦相似度向量相似度的计算方式有很多常见的有欧氏距离、点积、余弦相似度。我的选择是余弦相似度原因很直接它只关心方向不关心模长适合文本语义比较的场景向量归一化之后余弦相似度和点积效果等价但实现清楚、不易出错对数值波动鲁棒直接排序结果稳定你可以把两个向量看成从原点出发的两条线余弦相似度计算的是两条线之间的夹角。夹角越小方向越接近语义就越相似。另外一个细节是分母加了1e-9这是为了防零向量的除零异常。空文本或全零向量在实际调用中偶尔会出现这一行保护能避免整个检索链路崩溃。5. 把检索结果接进 RAG 链路5.1 完整链路设计RAG检索增强生成的基本流程是用户提问 → 向量化问题 → 从知识库检索相关片段 → 把片段拼接进提示词 → 交给后续生成模型 → 得到回答。嵌入和检索只是前半段但也是决定回答质量的关键。如果检索回来的片段根本不是用户想问的内容后面生成模型再强也答不出正确答案。所以很多人说 RAG 的瓶颈不在生成而在检索我是非常认同的。Spring AI 的优点在这里体现得很明显EmbeddingModel只负责向量化你的业务代码不必关心具体实现VectorStore如果换到生产级方案改造范围也受控。这意味着前期在内存库上验证的检索逻辑后续可以平滑替换。5.2 检索接口示例写一个对外接口把问题和返回条数传进来RestController RequestMapping(/search) public class SearchController { private final LocalVectorStore localVectorStore; public SearchController(LocalVectorStore localVectorStore) { this.localVectorStore localVectorStore; } PostMapping public ListString search(RequestBody SearchRequest request) { return localVectorStore.search(request.query(), request.topK()) .stream() .map(DocumentChunk::content) .toList(); } public record SearchRequest(String query, int topK) { } }这样一个最小可用的语义搜索接口就出现了。调用方把问题发过来服务端返回最相关的几段文本。你可以先用 curl 或接口测试工具简单验证再把结果拼到前端做展示。5.3 拼接提示词时要注意什么把检索片段交给生成模型时需要注意几点控制上下文长度。topK 取值一般 3 到 6 比较合适太多片段会让输入过长生成模型反而抓不住重点。给片段加上来源标记。哪怕是内存库也建议在结果中保留文档标识方便后续追踪哪段内容影响了回答。提示词里明确指令让模型只基于给定的片段回答避免模型自由发挥。看起来这些是细节但实际运行效果差异非常大。我曾经在 topK 调到 20 的时候测试过生成结果反而变得混乱因为无关片段干扰了模型判断。从 5 开始调试观察效果再微调是比较稳妥的做法。6. 实测数据与性能观察6.1 向量化与检索的耗时量级以我本机的测试环境为参考CPU 是普通桌面级处理器内存 16 GB。分块后的知识库一共构建了 1000 条向量耗时和检索性能如下操作类型耗时量级备注单条文本向量化首次约 200 至 500 ms包含模型加载与线程初始化单条文本向量化预热后约 20 至 60 ms稳定后的正常水平1000 条分块批量嵌入约 20 至 40 秒与机器 CPU 性能强相关单次 topK 检索1000 条小于 10 ms线性扫描可接受全量内存占用约 10 至 20 MB1000 条 384 维向量体积不大这里要说明一点不同机器、不同模型量化版本的差异会很明显上面数据仅供参考但量级可以给你一个预期。模型第一次加载到内存会比较慢所以做性能测试前最好先预热一次。6.2 为什么这个量级已经够用1000 条文档块的检索耗时不到 10 毫秒对大多数内部知识库场景来说已经完全感知不到延迟。即使数据量涨到 10 万条线性扫描的耗时也就在几十到几百毫秒之间仍然可以接受。一旦数据量继续膨胀比如百万级向量线性扫描就会出现明显瓶颈。这时候需要引入专门的向量索引数据集的选择就交给PGVector或更专业的向量数据库。但这是后话起步阶段完全不必为“未来规模”提前买单。先跑通业务闭环再在增长点位上做升级这是我认为最务实的路径。7. 常见问题与避坑实录7.1 模型文件加载失败提示找不到 tokenizer这个坑我刚开始就踩过。只下载了模型权重文件没有把同目录下的 tokenizer 文件一起放进去启动时直接报错。正确的做法是确保model.onnx和tokenizer.json都放在指定路径下并且路径配置完全一致。如果模型包里有多个 tokenizer 相关文件也一起保留有些版本在读取时会引用附加配置文件。7.2 中文输入效果不如英文MiniLM 这一类模型主要面向英文语料训练中文语义支持相对弱。如果你要建的库以中文为主建议换成中文或双语预训练的嵌入模型同样找到 ONNX 格式版本再接入。测试时可以拿几组中英文同义句分别算相似度观察差距。如果中文相似度分数普遍偏低直接换模型比调参更有效。7.3 第一次启动特别慢接口超时原因通常是模型文件从 classpath 或磁盘加载进入内存需要时间。如果接口在容器健康检查阶段就被调用很可能触发超时。解决办法有两个应用启动完成后主动预热一次调用任意文本的 embed把模型加载流程跑完。把模型文件放在外部路径而不是 classpath 下减少 jar 读取和复制开销。预热之后在线推理的耗时会变得非常稳定。7.4 依赖传递版本冲突导致运行时异常Spring AI 底层依赖的推理库可能与其他组件发生版本冲突表现是启动时或调用时抛出NoClassDefFoundError。遇到这类问题优先检查 Maven 依赖树mvn dependency:tree找到冲突项后在pom.xml中显式声明统一版本。不要随意升级底层推理库ONNX 模型文件往往对运行时版本敏感升级可能反过来导致模型无法加载。7.5 向量检索结果看起来“不像那么回事”如果你的检索结果明显不准先不要急着怀疑模型。大部分情况下是分块策略的问题比如块太长导致语义被稀释或者块太短导致上下文缺失。建议做一组对照试验同一份文档分别用 100、200、300、500 字符分块记录相同查询的 topK 命中情况。这一步能帮你找到适合当前文档结构的分块长度。最后再分享一点个人体会。做知识库项目最难的不是把向量化跑通而是把“语义检索”和“实际业务”真正结合起来。本地 ONNX Spring AI 这个组合帮我快速验证了整套链路零调用成本、数据不落地、调试自由。你完全可以用这套方案先把最小闭环搭起来后面再决定是否引入专业向量数据库或云端服务反正 Spring AI 的接口预留了替换空间。如果你也在做类似的事从这套本地方案起步不会错。哪怕只是先跑通一个 demo你对向量检索的理解都会比只看文档要深得多。过程不复杂值得亲自试一遍。