SpringAI实践系列写到第七篇,前面几篇把ChatClient、Prompt模板、结构化输出这些基础能力都过了一遍,整体跑通了一个“能聊天、能干活”的AI应用骨架。但真到做私域知识库问答、内部文档检索这类场景时,光靠对话模型是不够的——模型本身不知道你的业务文档里写了什么。你缺的是那条“让文本变成向量、让语义能进数据库”的通道,也就是今天要聊的本地向量嵌入模型集成。
这篇东西适合谁看?两类人。一类是已经在SpringAI项目里跑通了大模型对话、正准备往RAG方向走的后端工程师;另一类是对“本地化部署”有洁癖、不想把企业内部数据送到外部API去算embedding的架构师。如果你刚接触SpringAI,建议先翻翻这个系列的前几篇,把ChatClient和模型配置的底子打好,再来读这篇,会顺畅很多。
先给结论:在SpringAI里集成本地向量嵌入模型,官方支持链路已经相当成熟。你不需要自己搭模型服务,也不需要手写HTTP调用去拿向量,SpringAI的EmbeddingModel抽象把底层细节全挡住了。你要做的只有三件事:选一个本地方案、配好依赖和配置、然后用统一的接口去调用。这篇文章我会把选型账算清楚、把完整代码和配置贴出来,再把我实际踩过的坑一五一十列出来。
1. 为什么要在SpringAI里接本地嵌入模型:先把账算清楚
1.1 向量嵌入到底是什么,以及RAG为什么离不开它
很多人第一次接触“嵌入”这个概念时,容易把它想得过于玄乎。其实你可以把它理解成“把一句人话翻译成一组计算机能比较的数字列表”。比如“今天天气不错”这句话,经过嵌入模型处理后,会输出一个类似[0.012, -0.034, 0.117, ...]的浮点数数组,数组长度通常从几百到上千不等,这就是所谓的向量。
关键不在于数字本身,而在于它的性质:语义上相近的两句话,算出来的两个向量在空间里离得近。比如“今天天气不错”和“天气预报说今天晴朗”,向量距离会很小;而“今天天气不错”和“轮胎该换了”,向量距离会很大。这个性质被RAG(检索增强生成)拿来做关键一环:先把知识库里的文档全部切成小段、算好向量、存入向量数据库;用户提问时,把问题也转成向量,去数据库里搜语义最接近的几个文本片段;最后把这些片段拼进Prompt,交给大模型回答。
所以嵌入这一步的质量,直接决定了检索结果准不准。检索这一步失效的话,后面大模型拿到的东西就是无关的,回答自然也是错的。我在实际项目中见过一种典型翻车现场:用了云端通用嵌入接口,结果企业内部术语、产品代号完全被“曲解”,查出来的片段牛头不对马嘴。原因就是通用模型没针对你的业务语料做过适配,本质上是领域偏差问题。
1.2 本地部署和云端API的成本账与风险账
既然嵌入模型这么关键,那选本地还是云端,就得认真算账。我见过不少团队一开始图省事直接用云厂商的Embedding API,跑了一个月后开始难受,原因集中在三方面。
一是费用模型。嵌入接口按token计费,看起来单价很低,但知识库一入库就是几万几十万条文本,全量跑一遍成本还能忍,之后每天增量更新、每周全量重算,账单就不好看了。更麻烦的是,如果你做的是多租户系统,每个租户的知识库都要单独算向量,费用会线性膨胀。
二是数据合规和隐私。企业内部的知识库往往包含合同条款、客户资料、技术文档这些敏感内容。把数据发送到外部API去计算,先不说法律层面的合规要求,光是安全评审那一关就够你折腾几个月的。我在金融行业客户那里遇到过的情况是:明文规定客户数据不许出内网,那就只剩本地嵌入这一条路。
三是延迟和稳定性。外部Embedding API的网络往返通常要几百毫秒,如果一条文档切成30个片段,一次入库要等30次往返。本地模型基于ONNX或Ollama部署,同机调用延迟能压到几十毫秒甚至更低,而且没有限流和故障风险。
当然,本地部署也有它的成本:你得多管一个模型服务进程,CPU或内存资源会被吃掉一部分,模型版本升级也需要自己维护。但从整体账面上看,只要你的知识库规模上了一定量级、对数据安全有要求,本地嵌入几乎是必然选项。
1.3 SpringAI为Embedding做了什么抽象
SpringAI在这块做得很聪明。它没有把你锁定在某个具体的嵌入服务上,而是定义了一个统一的EmbeddingModel接口,接口方法是固定的,比如embed(String text)、embed(Document document)、embedForResponse(List<String> texts)。
你面向接口写业务代码,具体底层是Ollama、ONNX还是OpenAI,全靠配置切换。这意味着你今天用Ollama跑通全流程,明天想换成ONNX部署,业务代码一行都不用改,只改依赖和配置就行。我管这个叫“嵌入模型的可插拔设计”,它最大的价值不只是省事,而是让架构决策可以延后——你可以在项目早期先用最简单的Ollama验证业务逻辑,等真要上生产了再切换到资源占用更小的ONNX方案。
这个抽象层的存在,也意味着“本地向量嵌入模型集成”这件事,在SpringAI语境下更多是“找对包、配好参数”的问题,而不是“从零写一套模型调用代码”的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 本地嵌入模型选型:四个方案挨个过一遍
2.1 Ollama:最省事的入门选择
Ollama可能是目前跑本地嵌入模型门槛最低的方案。它本身是一个本地模型运行工具,支持拉取和运行多种开源模型,包括nomic-embed-text、bge-m3、snowflake-arctic-embed这些常用嵌入模型。
SpringAI专门为Ollama提供了独立模块spring-ai-ollama,你在pom里引入后,OllamaEmbeddingModel会自动装配成EmbeddingModel接口的实现。配置上也极其简单:spring.ai.ollama.base-url指向本地地址,spring.ai.ollama.embedding.model指定你要用的嵌入模型名即可。
Ollama的最大优势是上手没有门槛。下载安装、ollama pull nomic-embed-text、启动服务、配好SpringAI,十分钟就能拿到第一个向量。但它也有局限:Ollama作为一个通用模型运行服务,内存占用偏高,嵌入模型虽然比对话模型小,跑起来也要占个2到4GB内存;另外Ollama内部对请求做排队和并发控制,高吞吐场景下可能成为瓶颈。我个人的定位是:Ollama适合开发调试、技术验证、以及并发要求不高的内部工具。
2.2 ONNX Runtime:适合生产环境的轻量化方案
如果你追求内存占用小、启动快、可嵌入Java进程内运行,ONNX Runtime是更好的选择。所谓ONNX,你可以理解为一种“模型交换格式”,把各种框架训练好的模型统一转成一个文件,然后直接用onnxruntime这个推理引擎来跑。
之前火过一段时间的spring-ai-onnx-embedding模块,思路就是预先把嵌入模型转成ONNX格式,在SpringAI里通过ONNXEmbeddingModel加载指定路径的模型文件,纯Java进程内推理,不需要额外启动任何服务。这种方式对部署架构特别友好——模型文件放在classpath或本地磁盘上,应用启动时加载一次,之后每次调用都是内存内计算,延迟极低。
但这个方案的门槛在于:你需要一个能用的ONNX格式嵌入模型文件。社区里有现成的all-MiniLM-L6-v2等常见模型的ONNX版本,但如果你想要更新、更大的模型(比如bge-m3),可能得自己去HuggingFace下载后转换,这一步对不熟悉Python生态的人有点折腾。另外,ONNX方案对Java版本有要求(需要Java 17+),如果你的项目还在Java 8或11上,可能会卡在模块兼容性上,这点在选型时要先确认。
2.3 fastembed与HuggingFace Transformers方案的取舍
除了Ollama和ONNX,还有两个出现频率较高的选项:fastembed和HuggingFace Transformers。
fastembed是Qdrant团队推出的轻量级嵌入库,后端同样基于ONNX Runtime,但帮你把模型下载和加载封装得更友好。SpringAI社区早期有人通过fastembed库配合Java调用,但因为它的核心是Python库,Java生态接入比较别扭,后来SpringAI官方没有把fastembed作为一等公民支持,我不太推荐你在SpringAI项目里硬接它。
HuggingFace Transformers则是更底层的方案:通过deeplearning4j或Python服务中转来加载HuggingFace模型。问题在于,deeplearning4j的生态活跃度一般,而Python服务中转又违背了“Java进程内搞定一切”的初衷。我的建议是:非特殊原因不碰这条线,除非你已经有现成的Python模型服务,否则引入的维护成本和胶水代码会远超收益。
2.4 选型对比总表与我的建议
我把几个方案的关键特征列在下面,方便你对照自己的场景做决定。
| 方案 | 部署方式 | 内存占用 | 集成复杂度 | 推荐场景 |
|---|---|---|---|---|
| Ollama | 独立进程 | 较高(2-4GB) | 低 | 开发调试、内部工具、快速验证 |
| ONNX Runtime | 进程内 | 低(几百MB) | 中 | 生产环境、容器化部署、高并发 |
| fastembed | 进程内 | 低 | 高(Java生态不友好) | 不推荐在SpringAI中接入 |
| HuggingFace Transformers | 外部服务或JVM桥接 | 高 | 高 | 已有现成模型服务的团队 |
我的经验是:项目初期先用Ollama跑通业务逻辑,因为它的反馈回路最短;到了准备部署上线、要容器化的时候,再切换到ONNX方案,把模型文件打包进镜像,整个应用变成一个独立Java进程,运维模型会简单很多。
3. SpringAI集成本地嵌入模型完整实操
3.1 环境准备:Ollama安装与模型拉取
假设你现在打算用Ollama快速体验,那第一步就是装好Ollama本身。Ollama支持macOS、Windows和Linux,官网下载安装包即可,装完后在命令行验证一下:
bash复制ollama --version
然后拉取一个嵌入模型。我用的是nomic-embed-text,它是一个性能比较均衡的通用嵌入模型,输出维度768,对中文和英文都有不错的支持度:
bash复制ollama pull nomic-embed-text
拉取完成后,启动Ollama服务(通常安装后默认已启动),确认服务正常:
bash复制curl http://localhost:11434/api/tags
如果返回一个包含模型列表的JSON,说明服务正常。注意,Ollama默认监听11434端口,如果你改了配置,记得后面SpringAI的base-url也要跟着改。
3.2 pom.xml依赖引入与版本搭配
在SpringAI项目里加入Ollama模块,依赖如下:
xml复制<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
如果你是Spring Boot 3.2及以上版本,可以直接用1.0.0系列;如果还在用Spring Boot 3.1,建议锁到0.8.1版本,新版本对Boot版本有要求,硬升容易出现启动失败。这个版本匹配问题,是我在实操中看到最多的低级坑之一,务必先确认。
如果你走ONNX方案,则需要引入spring-ai-onnx-embedding-spring-boot-starter,同时准备一个ONNX模型文件,配置方式类似,但modelPath指向本地文件路径。
3.3 application.yml配置要点解析
Ollama方案的配置非常精简,核心就三项:
yaml复制spring:
ai:
ollama:
base-url: http://localhost:11434
embedding:
model: nomic-embed-text
这里我单独提醒一下model配置项:早期版本里,嵌入模型名被放在spring.ai.ollama.embedding.options.model下面,新版本调整到了spring.ai.ollama.embedding.model。你如果参考的是网上的旧文章,容易写错位置,然后启动时报错或者跑到默认模型上。遇到这种情况,先看一眼自己用的版本,别盲抄配置。
ONNX方案的配置则是这样的:
yaml复制spring:
ai:
onnx:
embedding:
model-path: classpath:onnx/all-MiniLM-L6-v2.onnx
metadata-path: classpath:onnx/all-MiniLM-L6-v2.metadata.json
model-path指向模型文件,metadata-path指向包含模型信息的JSON,如果没有也没关系,SpringAI会尝试从模型文件头读取维度信息。
3.4 核心代码:从EmbeddingModel调用到向量入库
配置好后,业务代码里直接注入EmbeddingModel接口即可,完全不用关心底层是Ollama还是ONNX:
java复制@Service
public class EmbeddingService {
private final EmbeddingModel embeddingModel;
public EmbeddingService(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
}
public float[] embedText(String text) {
return embeddingModel.embed(text);
}
public List<float[]> embedTexts(List<String> texts) {
return embeddingModel.embed(texts);
}
}
embed方法返回的是float数组,这就是你要存入向量数据库的向量。如果你需要拿到更完整的响应信息(比如token用量),可以用embedForResponse方法。
实际项目中,你通常不会只嵌入一两个文本,而是要把整个文档库批量切分、批量计算。这里我建议用List<String>批量传入,而不是在循环里逐个调用。原因有两个:一是批量调用能减少请求往返次数;二是Ollama这类服务对批量请求有内部优化,吞吐会明显高于单条循环。
下面是一个把文档切段、批量嵌入并写入PostgreSQL + PGVector的例子:
java复制public void indexDocument(String docId, String fullText) {
// 1. 按固定长度切段,保留少量重叠,避免语义断裂
List<String> chunks = splitText(fullText, 500, 50);
// 2. 批量算向量
List<float[]> vectors = embeddingModel.embed(chunks);
// 3. 写入向量数据库
for (int i = 0; i < chunks.size(); i++) {
jdbcTemplate.update(
"INSERT INTO documents(doc_id, chunk_index, content, embedding) VALUES (?, ?, ?, ?)",
docId, i, chunks.get(i), new PGvector(vectors.get(i))
);
}
}
splitText方法里,切段策略需要你结合实际文档类型调整。我见过两种常见策略:按固定字符数切、按语义边界切。固定字符数简单粗暴,但容易把一句话从中间截断;按句号换行切更自然,但段长度不齐。我的经验是:先用固定长度切,重叠区设50个字符左右,大部分场景够用。如果检索效果不理想,再考虑引入更精细的切分策略。
PGVector建表SQL如下:
sql复制CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
doc_id VARCHAR(64),
chunk_index INT,
content TEXT,
embedding VECTOR(768)
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
注意VECTOR(768)里的768是nomic-embed-text的输出维度。如果你换用了bge-m3,维度是1024,这里必须同步改。向量索引选vector_cosine_ops是因为我们在检索时会用余弦相似度,这个索引类型和距离函数要匹配,否则查询会走全表扫描。
3.5 向量检索与相似度计算示例
向量入库之后,检索就是反向操作:用户输入问题,把问题转成向量,去数据库里找最近的几个文本片段。
java复制public List<DocumentChunk> search(String query, int topK) {
float[] queryVector = embeddingModel.embed(query);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"""
SELECT doc_id, chunk_index, content, 1 - (embedding <=> ?::vector) AS similarity
FROM documents
ORDER BY embedding <=> ?::vector
LIMIT ?
""",
new PGvector(queryVector), new PGvector(queryVector), topK
);
// 映射成对象列表返回
return rows.stream()
.map(row -> new DocumentChunk(
(String) row.get("doc_id"),
(int) row.get("chunk_index"),
(String) row.get("content"),
(double) row.get("similarity")
))
.collect(Collectors.toList());
}
<=>是PGVector里的余弦距离运算符,1 - distance得到的就是相似度,数值越大表示越接近。这里我踩过一个困惑点:一开始用<->(欧氏距离)做排序,结果召回结果和预期不一致,后来发现嵌入模型训练时通常优化的是余弦相似度,换成<=>之后效果明显改善。
检索到TopK片段后,把它们拼接进Prompt,交给ChatModel生成答案,一条最小可用的RAG链路就闭环了。
4. 常见问题与排查技巧实录
4.1 Ollama连接不上、模型拉取失败的排查
这是出现频率最高的一类问题,具体表现为启动SpringAI应用时报connection refused,或者调用时提示ConnectException: Connection refused: localhost/127.0.0.1:11434。
排查思路按顺序来。第一,确认Ollama服务真的在跑,直接在命令行执行curl http://localhost:11434/api/tags,如果连不上,说明服务没起来,启动它。第二,确认端口对不对,Ollama默认11434,但你改了配置的话两边要一致。第三,确认SpringAI的base-url配置没写错,别在配置里多加斜杠或者写错协议。
还有一种容易忽略的情况:你在本机能访问Ollama,但应用跑在Docker容器里,这时候localhost指向容器自身,肯定连不上宿主机。需要把base-url改成宿主机IP,或者用Docker的host.docker.internal特殊域名,具体取决于你的容器网络模式。
4.2 响应超时与模型加载过慢
Ollama第一次调用嵌入接口时,往往要几秒钟甚至更久,因为模型需要从磁盘加载到内存。如果你的应用设置了较短的连接超时,第一次调用很可能直接超时失败。
排查方法是先把Ollama里的模型提前加载预热,用命令行手动调用一次:
bash复制curl http://localhost:11434/api/embed -d '{"model": "nomic-embed-text", "input": "hello"}'
让它把模型加载到内存里,之后应用再调就不会有加载延迟。生产环境如果是ONNX方案,模型是在应用启动时加载到堆外的,首次推理也会慢一点,但之后的调用延迟就非常稳定了。
4.3 向量维度不一致导致的存储报错
维度不一致这个问题,通常在替换模型后出现。比如你之前用nomic-embed-text,库表里VECTOR(768);后来换成了bge-m3,新向量变成1024维,插入时报expected 768 dimensions, not 1024。
这类错误定位不难,但有个细节容易被忽略:SpringAI的EmbeddingModel接口不会校验维度,底层向量库会校验。所以当你换模型时,要同步做三件事:改代码里的维度常量、改数据库表结构或迁移脚本、重建向量索引。
如果表里已经有大量数据,ALTER TABLE改维度虽然可行,但我建议直接重建一张新表,全量重新计算并写入向量,避免旧数据混着新数据造成检索结果混乱。
另外提醒一句:不同嵌入模型的相似度分布有很大差异,换模型后原来调的检索阈值(比如相似度大于0.7才返回)很可能是失效的,需要重新在验证集上校准,别偷懒直接用旧阈值。
4.4 补充排查:SpringAI连接DeepSeek不输出content的问题
这里专门讲一个和嵌入模型关系不大、但在这个系列实践里经常撞上的问题——SpringAI项目对接DeepSeek时,模型服务返回正常但解析出来的响应里content字段为空。
先说排查路径。DeepSeek的API设计是OpenAI兼容的,SpringAI官方没有单独为它封装模块,通常做法是用spring-ai-openai的starter,然后把base-url指向DeepSeek的接口地址。这种情况下content为空,多半是因为响应字段映射出了问题。
常规检查顺序如下:
-
确认模型名称正确。DeepSeek官方模型名通常是
deepseek-chat或deepseek-reasoner,不要想当然写成deepseek-v2之类的名字。模型名错误时API会直接报错,一般不会静默返回空content。 -
确认响应结构。DeepSeek的响应和OpenAI标准响应基本一致,但不同版本之间可能存在细微差异。如果你自己手写了HTTP调用去对接DeepSeek,解析JSON时务必确认
choices[0].message.content存在。SpringAI的OpenAI模块默认按这个路径取content,如果字段名对不上就会出现空值。 -
确认是否走了流式输出。用流式接口时,content其实是分多次推送的,最终会话里可能看起来是空的,但流式回调里是有内容的。检查一下你是用
stream()方法拿Subscriber还是用call()拿完整响应,两种模式处理方式完全不同。 -
确认返回的
finishReason。如果因为命中了上下文长度上限或者内容安全策略,模型可能在输出content之前就终止了,finishReason会返回length或content_filter。打开日志看这个字段,能帮你判断问题是出在模型侧还是解析侧。
我遇到过的真实案例是:接口返回里content字段名是正常的,但值为空字符串,同时reasoning_content里有内容。这种情况常见于DeepSeek的deepseek-reasoner模型,它在思考过程中把推理内容放在独立字段里,最终答案才放content。如果你选的模型本身就是推理型,它在某些场景下可能只返回推理过程、不返回最终答案,表现就是content为空。解决办法是检查你请求里的参数配置,或者干脆换用deepseek-chat这种普通对话模型。
5. 实操心得与后续扩展建议
5.1 我踩过的几个坑和最终落地方案
第一次在项目里落地本地嵌入模型时,我犯过一个比较典型的错误:把切段长度设成200个字符,结果一篇文章被切得稀碎,检索出来的片段上下文严重缺失,大模型基于这些碎片作答,答案质量惨不忍睹。后来我把切段长度调大到500、重叠区设50,效果立刻好转。这个参数没有绝对标准,取决于你的文档类型和模型窗口大小,建议用真实文档在验证集上多测几组参数再做决定。
另一个坑是批量嵌入的并发控制。早期我用parallelStream对几万条文档并发计算向量,结果Ollama服务直接打挂。后来改成固定线程池(8个线程)批量提交,稳定很多。如果你用的是ONNX方案,进程内推理本身就是并发的,但也要控制线程数,避免CPU争抢导致延迟飙升。
我最终的落地方案是:开发环境用Ollama + nomic-embed-text,生产环境切到ONNX + all-MiniLM-L6-v2,模型文件打包进Docker镜像,应用启动时加载,整体内存占用控制在几百MB。向量库用的是PostgreSQL + PGVector,直接挂在业务库旁边,少维护一个独立组件。
5.2 下一步还能怎么玩
基于这个基础链路,后续有很多值得扩展的方向。
一是重新排序(Rerank)阶段。语义检索召回Top20,再用一个轻量级的重排模型对候选片段精排,取Top5送入Prompt。这个组合能显著提升回答质量,尤其当知识库里内容相近的文档很多时,重排的收益非常明显。
二是混合检索。向量检索擅长语义匹配,但关键词精确匹配(比如产品型号、编号)是它的弱项。你可以把BM25关键词检索和向量检索的结果做融合,再送重排,这套组合在真实业务文档场景下效果最好。
三是面向业务语料做模型微调或适配。如果你发现通用嵌入模型对你的行业术语理解不理想,可以考虑在业务语料上对嵌入模型做二次训练。不过这个方向成本较高,一般团队建议先用通用模型加混合检索,效果不够再考虑微调。
我个人在这套方案上的体会是:本地向量嵌入的核心价值不是“省那点API费用”,而是把数据主权牢牢握在自己手里,同时获得稳定可控的延迟。SpringAI的抽象层让这件事的工程成本降到了很低——一次接入,随时可以在不同底层方案之间切换,这种灵活性在业务需求不断变化的今天尤为可贵。下一步我打算在这个基础上把重排和混合检索接进去,到时候再来分享实施效果。
