Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
作者:鱼宵 | Spring AI 实战精通营 · 第 7 篇
有个朋友想让 AI 回答他公司内部的 FAQ,第一反应是"把文档全塞进提示词"——塞了两份手册,模型一本正经地编答案。问题在哪?大模型的知识训练完就冻结了,它根本不知道你公司的文档;而你要是拿数据库 LIKE '%年假%' 去搜,搜"我想请假出去玩"一个"年假"字都不沾边,照样搜不到。
这就是 RAG(检索增强生成)要解决的事:用户提问时,先从你的资料里"语义相关"地捞出片段,再拼进提示词让模型答。这篇文章只讲 RAG 的前一半——怎么把文本变成可搜索的坐标。代码全在仓库的 lesson-07/ 目录,clone 下来照着跑,你会亲眼看到一个有点魔法的现象:搜"喉咙堵",命中的是"咽不下去"那句,俩话一个相同的实义字都没有。而且这节课全程本机跑,零 API Key、零外网调用、零成本。
一、核心原理:文本怎么变成"坐标"
1. Embedding:给每句话在语义地图上定位
**Embedding(嵌入)**就是一个函数:输入一段文本,输出一个固定长度的数字数组(本课是 384 维,即 384 个浮点数)。
类比:把每段话都投到一张 384 维的"语义地图"上,意思相近的话落在地图相邻的位置。"我心情不好吃不下饭"和"咽不下去"虽然用字不同,但意思相近,地图上就挨得近。为什么需要它?——计算机不会直接比较两句话"像不像",但很会比较两组数字"近不近"。嵌入就是把"语义相似度"翻译成"向量距离"。
2. distance:地图上的直线距离
两句话的向量拿到后算一个距离(本课 SimpleVectorStore 默认余弦距离,范围 0~2):
- distance 越小 = 两个向量方向越一致 = 语义越像;
- 0 表示几乎完全同向,2 表示方向相反。
类比地图测距:你搜"附近的咖啡店",手机按直线距离排序——distance 就是这个直线距离,越近排越前。初写检索的人常把它搞反,记住"越小越像"。
3. VectorStore:统一的"国标插座"
VectorStore 是 Spring AI 定义的统一接口,核心就两个方法:add(文档列表) 入库、similaritySearch(查询) 检索。
类比国标插座:不管你买内存实现、Redis、PgVector 还是 Milvus,插头形状统一(同一个 VectorStore 接口),插上去就能用。本课用 SimpleVectorStore(纯内存,关机即丢)教学;生产换成 Redis 向量库,业务代码一行不动。
4. 本地 ONNX:绿色软件式跑模型
ONNX(Open Neural Network Exchange)是模型的"通用集装箱格式":PyTorch 训练好的模型导出成 .onnx 文件,任何装了 ONNX Runtime 的机器都能跑,不依赖 Python、不依赖 GPU。类比把一个要装一堆依赖的程序压成单文件绿色版,拷过来双击就跑。
本课用 all-MiniLM-L6-v2:输出 384 维向量,模型文件 86MB,CPU 上毫秒级出向量,文件已经随工程打包在 resources/models/ 下,开箱即用。
5. 错误写法 vs 正确写法
错误:拿数据库 LIKE '%喉咙%' 去搜
→ "咽不下去"里没有"喉咙"两个字,直接漏检——关键字匹配不懂语义
正确:把查询和语料都嵌成向量,按 distance 排序
→ 字面不沾边但意思相近的句子,照样排到最前
二、动手:这节课连 Key 都不用配
环境:Windows + JDK 17 + Maven 3.9+。不需要任何 API Key——嵌入模型跑在本机 ONNX Runtime 上。
第 1 步:检查环境(注意,没有 Key 那一步)。
java -version # 期望 True
Get-NetTCPConnection -LocalPort 8099 -State Listen -ErrorAction SilentlyContinue # 无输出=空闲
第 2 步:编译 + 启动。
cd spring-ai-journey\lesson-07
$env:JAVA_HOME="C:\Program Files\Java\jdk-17" # Maven 跑在 JDK 17 上
mvn clean install -DskipTests
mvn spring-boot:run
# 看到 Tomcat started on port 8099,还有一行"语料入库完成:共 5 段"即成功(本地模型 2~3 秒启动)
第 3 步:调两个接口。
# ① 向量维度自检:看嵌入模型到底吐出多少维
Invoke-RestMethod "http://localhost:8099/dim"
# ② 语义检索(中文要 URL 编码)
$q = [uri]::EscapeDataString('我今天心情不好不想吃饭')
Invoke-RestMethod "http://localhost:8099/search?q=$q"
三、关键代码:四个类,串起建库到检索
第一段:application.yml——模型指向本机文件,不联网。
server:
port: 8099 # 端口按课程分配表:lesson-07 = 8099
spring:
ai:
embedding:
transformer:
# 本地模型随工程打包在 classpath:models/(tokenizer.json + model.onnx,all-MiniLM-L6-v2)。
# starter 默认会从 GitHub 远程下载,国内直连常超时;
# 显式指向 classpath 后启动不联网、秒开。本地模型没有 Key 的概念。
onnx:
model-uri: classpath:models/model.onnx
tokenizer:
uri: classpath:models/tokenizer.json
第二段:VectorStoreConfig.java——手工 new 一个内存向量库。
package com.springai.lesson07;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 向量库配置:把"嵌入模型 + 内存向量库"组装成一个 VectorStore Bean。
* SimpleVectorStore 是教学/测试用的内存实现,starter 不自动装配,所以这里手工声明 @Bean。
* 换成 Redis 向量库时,这个 @Bean 整个删掉,改引 starter 自动装配即可,业务代码不动。
*/
@Configuration
public class VectorStoreConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
// builder 传入嵌入模型:入库和查询时都要用它把文本变成向量
return SimpleVectorStore.builder(embeddingModel).build();
}
}
第三段:DataLoader.java——启动时把 5 段语料嵌成向量存进去。
package com.springai.lesson07;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 语料入库器:启动完成后自动把 5 段中文语料写进向量库(对应 RAG 的"建库"阶段)。
* ApplicationRunner:Spring Boot 启动完自动跑一次 run(),很适合初始化数据。
*/
@Component
public class DataLoader implements ApplicationRunner {
private final VectorStore vectorStore;
public DataLoader(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@Override
public void run(ApplicationArguments args) {
// 5 段语料故意设计成两组:字面不相关但语义相关、字面相关但语义相反——
// 用来检验嵌入模型是不是真懂语义,而不是按关键字匹配。
List<Document> documents = List.of(
// 句1:情绪低落 + 没胃口
new Document("我今天心情不好,完全没胃口,什么都吃不下",
Map.of("source", "句1", "tag", "情绪低落/没胃口")),
// 句2:美食(带"饭"字,但语义是"好吃",和"不想吃饭"相反)
new Document("这家店招牌红烧肉肥而不腻,特别下饭,我连吃了三碗",
Map.of("source", "句2", "tag", "美食/下饭")),
// 句3:咽不下去(和句1语义相近,但字面一个"饭"字都没有)
new Document("总觉得喉咙里堵着东西,咽不下去,勉强吃一点就想吐",
Map.of("source", "句3", "tag", "身体不适/吞咽困难")),
// 句4:户外风景(和吃饭情绪无关)
new Document("周末去郊外爬山,山顶视野开阔,风景特别好",
Map.of("source", "句4", "tag", "户外/风景")),
// 句5:情绪调节(带"心情不好",但语义是"解决办法")
new Document("心情不好的时候听听歌、散散步,很快就能平静下来",
Map.of("source", "句5", "tag", "情绪调节"))
);
// add() 内部做两件事:调嵌入模型把每段文本变成向量,然后存进内存库
vectorStore.add(documents);
System.out.println("========== 语料入库完成:共 " + documents.size() + " 段 ==========");
}
}
第四段:VectorSearchController.java——两个检索接口。
package com.springai.lesson07;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 语义检索接口:本课核心演示入口。
* GET /dim —— 返回嵌入模型输出的向量维度(眼见为实 all-MiniLM 是 384 维)
* GET /search?q=... —— 把查询嵌入成向量,找距离最近的 topK 段文本返回
*/
@RestController
public class VectorSearchController {
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
public VectorSearchController(VectorStore vectorStore, EmbeddingModel embeddingModel) {
this.vectorStore = vectorStore;
this.embeddingModel = embeddingModel;
}
/** 向量维度自检:embed() 返回 float[],length 就是维度 */
@GetMapping("/dim")
public Map<String, Object> dim() {
float[] vector = embeddingModel.embed("维度自检文本");
Map<String, Object> result = new LinkedHashMap<>();
result.put("embeddingModel", embeddingModel.getClass().getSimpleName());
result.put("dimension", vector.length);
result.put("firstFiveNumbers", List.of(vector[0], vector[1], vector[2], vector[3], vector[4]));
return result;
}
/**
* 语义检索:similaritySearch 内部把 q 也嵌成向量,和库中每条向量算距离排序取 topK。
* distance 越小越相似(SimpleVectorStore 默认余弦距离,0=完全同向,2=反向)。
*/
@GetMapping("/search")
public Map<String, Object> search(@RequestParam("q") String q,
@RequestParam(value = "topK", defaultValue = "3") int topK) {
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder()
.query(q) // 查询语句(会被嵌入成向量)
.topK(topK) // 返回最相似的前 K 条
.build()
);
List<Map<String, Object>> items = hits.stream().map(doc -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("content", doc.getText()); // 命中的原文
item.put("metadata", doc.getMetadata()); // 入库时带的 source/tag
item.put("distance", doc.getMetadata().get("distance")); // 距离:越小越相似
return item;
}).toList();
Map<String, Object> result = new LinkedHashMap<>();
result.put("query", q);
result.put("hitCount", items.size());
result.put("hits", items);
return result;
}
}
第五段:Lesson07Application.java——标准启动类。
package com.springai.lesson07;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring AI 实战精通营 · 第 7 课:RAG 上(向量库与嵌入)。
* 启动时自动把 5 段中文语料嵌成向量存入内存库;访问 /search 做语义检索。
* 本课零 API 成本:嵌入模型 all-MiniLM-L6-v2 跑在本机 ONNX Runtime。
*/
@SpringBootApplication
public class Lesson07Application {
public static void main(String[] args) {
SpringApplication.run(Lesson07Application.class, args);
}
}
四、实测输出:搜"喉咙堵",命中"咽不下去"
以下是 2026-10-05 本机真实运行。先看 /dim,眼见为实 384 维:
{
"embeddingModel": "TransformersEmbeddingModel",
"dimension": 384,
"firstFiveNumbers": [0.14497744, 0.5271977, 0.4065016, -0.093232825, -0.029930325]
}
dimension=384 证实 all-MiniLM-L6-v2 输出 384 维向量;后面那串数字就是向量的前五个分量——每句话都会被表示成这样一串数字。
再看 /search,第一个查询:"我今天心情不好不想吃饭":
命中 top3(distance 越小越相似):
[1] distance=0.1097 tag=情绪低落/没胃口 ← 句1,几乎一样的话,最近
[2] distance=0.2840 tag=情绪调节 ← 句5,带"心情不好",次之
[3] distance=0.3484 tag=美食/下饭 ← 句2,含"饭"字但语义相反,排最后
注意句2:它带"饭"字,字面上和查询最像,可因为语义是"好吃",反而排第三——关键字匹配会把它排第一,语义检索没干这事。
第二个查询更绝:"喉咙里堵得慌,咽东西难受":
命中 top3:
[1] distance=0.0654 tag=身体不适/吞咽困难 ← 句3"咽不下去",排第一!
[2] distance=0.3923 tag=户外/风景
[3] distance=0.3991 tag=美食/下饭
这就是 RAG 的核心魔法:查询和句3 没有一个相同的实义字("堵/难受" vs "堵着东西/咽不下去"),但嵌入模型把它们的语义拉到了相邻位置,distance 只有 0.0654。它懂意思,不是按关键字匹配。 你拿 LIKE '%喉咙%' 去搜,句3 根本不会命中。
排查提示:启动报 "Failed to cache the resource",是模型 URI 没指向 classpath 还想联网下,确认 yml 里
classpath:models/model.onnx;注入 EmbeddingModel 报 NoSuchBean,是 pom 漏了spring-ai-starter-model-transformers。
五、挑战题:改一改,看看会怎样
- ⭐ 调 topK:同一个查询,分别请求
/search?q=...&topK=1和/search?q=...&topK=5,对比返回条数。想一个问题:为什么 RAG 不能把全部片段都塞给模型?答案在VectorSearchController的topK参数和第二段语料设计里。 - ⭐⭐ 加一段语料:在
DataLoader里加第 6 段文本(比如一句你自己的 FAQ),重启后查一句相关的话,验证"新词入库即可搜"——想想生产里文档更新了要怎么办。 - ⭐⭐⭐ 对比关键字搜索:用 SQL
LIKE或 Javacontains实现一遍同样的查询,搜"喉咙堵"为什么关键字搜不到句3?把 RAG 语义检索和关键字搜索的差别写 200 字总结——这就是 RAG 的本质。
六、生产环境进阶:三个加分项
1. SimpleVectorStore 关机即丢,生产要换真库。 它是内存 Map,重启数据全没。生产换成 spring-ai-starter-vector-store-redis(或 PgVector、Milvus),把 VectorStoreConfig 里那个 @Bean 删掉改用 starter 自动装配,Controller 一行不动——这就是"国标插座"的兑现。
2. 中文场景换中文嵌入模型。 all-MiniLM 是英文优化、中文教学演示够用;生产中文知识库换 bge-small-zh 等中文模型,VectorStore 接口不变,只换模型文件。
3. 本地 ONNX 模型别让它联网下载。 starter 默认 URI 指向 GitHub raw,国内直连常超时,而且大文件走 LFS 直接 Invoke-WebRequest 只拿到 133 字节的指针文件。提前把真模型放 resources/models/,yml 显式配 classpath:。
七、面试回答模板
面试官:什么是 Embedding?为什么"语义相近"的两句话,向量也相近?
一句话:嵌入把一段文本映射成固定长度的数字向量,语义相近则向量在空间里相邻。展开:它把"语义相似度"翻译成"向量距离",计算机才好比较——计算机不会读句子,但很会算两组数字的远近。类比给每句话在语义地图上定坐标,意思差不多的话落在相邻位置。(指向本课第一节 / lesson-07 的 DataLoader 语料)
追问:Spring AI 的 VectorStore 抽象是什么?为什么换 Redis/PgVector 业务代码不改?
一句话:VectorStore 是统一接口,核心就 add() 和 similaritySearch() 两个方法。展开:不管背后是内存、Redis 还是 Milvus,Controller 只认这个接口;换实现只改 pom 依赖和配置,业务代码一行不动——像国标插座,插头牌子随便换。(指向本课第三节 VectorStoreConfig)
追问:distance 是越小越相似吗?
对。SimpleVectorStore 用余弦距离,范围 0~2,0 是完全同向(几乎同义)、2 是方向相反;检索结果按距离升序排,越靠前越像。别搞反成"越大越像"。(指向本课第一、四节)
追问:为什么 RAG 用本地 ONNX 嵌入,而不是调在线 embedding 接口?
本地嵌入三件好处:零 API 成本、数据不出本机、离线可用;代价是模型小、效果弱于在线大 embedding。教学和敏感数据场景用本地,追求效果再换在线模型——VectorStore 接口不变。(指向本课第一节和第六节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| 模型默认从 GitHub 下载 | 启动 SocketException 超时 | 模型放 resources,yml 配 classpath:models/ |
| GitHub raw 下大文件 | 只拿到 133 字节 LFS 指针 | 从 LFS 仓库/镜像下真 model.onnx(86MB) |
| embed() 返回 float[] | 写成 double[] 编译报错 | Spring AI 1.0.x 嵌入返回 float[] |
| SimpleVectorStore 忘声明 @Bean | 注入 VectorStore 失败 | VectorStoreConfig 里手工 @Bean |
| distance 搞反 | 以为越大越像 | 余弦距离 0=同向,越小越像 |
| 关键字 LIKE 检索 | 搜"喉咙堵"漏检"咽不下去" | 用语义向量检索,不懂字面要懂意思 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 7 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…
- 本文对应源码位置:
lesson-07/(Web 工程,内含VectorStoreConfig组装向量库 +DataLoader启动入库 +VectorSearchController检索双接口,本地 ONNX 模型已打包)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话》——本课你已经能"按语义捞出片段",下一课把它和大模型串起来:从 pdf/md 自动加载切分,用 QuestionAnswerAdvisor 自动完成"检索→把片段塞进提示词→生成带引用的回答"。你会做出一个真正能用的 FAQ 客服助手,还能问文档外的问题看它怎么兜底。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:SpringAI、向量检索、Embedding 摘要建议(≤256 字):搜"我不想吃饭",关键字 LIKE 搜不到"咽不下去"——因为它俩一个相同字都没有,但意思很近。这就是向量检索要解决的事。本文用 Spring AI 的本地 ONNX 嵌入模型 all-MiniLM-L6-v2,把文本变成 384 维坐标,存进内存向量库,实测搜"喉咙堵"命中"咽不下去"。全程零 API Key 零成本,逐行拆解建库与检索四个类,附 distance 排序、VectorStore 抽象和面试回答模板,源码已开源 lesson-07 可 clone 直接跑。