八年 Java 干下来,我对「接入大模型」的分工是这样的:Chat 负责嘴碎,Embedding 负责记性好——当然这是玩笑话。认真讲:Embedding(嵌入) 就是把文字(以后还有图、视频)压成一串浮点数组(向量),让机器在语义空间里比「谁和谁更像」。
这篇不写玄学公式吓你,目标就三件:搞懂 EmbeddingModel 在 Spring AI 里怎么用、用 智谱(智谱 AI) 的 embedding-2 跑通、再用余弦相似度做一个最简版「相似句」。中间我塞几个当时我也愣过的问题,你对照团队里评审时也好开口。
基础篇(DeepSeek 对话上手):Spring_AI_快速上手_Deepseek实战。概念补课:Spring_AI_核心概念_Javaer入门.md。
这篇你能收获什么?
- Embedding 在业务里常干什么(检索、RAG、聚类、推荐、风控语义——不是替代 Chat)。
- Spring Boot 3.5 +
spring-ai-starter-model-zhipuai,把智谱 Embedding接进来。 - 一行
embed(String)/ 一批embed(List)的调用姿势。 - 余弦相似度是啥、代码咋写、为啥企业里最终往往还是向量库而不是内存里 for 循环。
安全提醒: API Key 只放环境变量,别贴进仓库;下文示例一律 ${ZHIPU_API_KEY}。
1. Embedding 是啥?Spring AI 为啥要搞一层接口?
Embedding(嵌入/向量):把文本(也可以扩展到图像、视频等)映射成固定维度的数字向量。语义相近的内容,在空间里距离更近——所以可以做相似度、近邻检索、聚类。
Spring AI 用 EmbeddingModel 统一各家实现(OpenAI、Titan、Azure、Ollama、智谱等)。好处和我们对接支付通道一样:换供应商尽量改配置,业务调用别跟着大改。
常见落地场景(对着需求聊不慌)
| 场景 | 人话 |
|---|---|
| 相似度 / 语义搜索 | 查询和文档都向量化,近邻搜最像的段落。 |
| 聚类 / 分类 | 向量当特征,后面接传统 ML 或规则。 |
| RAG | 先向量检索知识块,再交给 Chat 带着上下文生成。 |
| 推荐 | 内容、提问、用户行为语义近的凑一堆。 |
| 异常检测 | 离群向量=语义「不像正常话/正常工单」的可能异味。 |
探究:有了 Chat 还要 Embedding 干嘛?
Chat 是「生成一段话」;Embedding 是「把话变成坐标,用来比、搜、聚」。
例如 RAG:Embedding 负责找相关段落,Chat 负责组织语言回答——两件事都省事。
再直白点:关键词搜会漏掉类似「车厘子 vs 樱桃」这种同义变形;向量搜往往更扛说法不同但意思近——代价是多一套向量模型、存储和运维。
探究:DeepSeek 怎么不接 Embedding?
不少团队是 Chat 用 A 厂商、Embedding 用 B 厂商,很正常:谁有哪项能力就用谁。下文用智谱 embedding-3 演示;你项目里换成别的 EmbeddingModel 实现,Java 侧调用形状类似。
2. 智谱 AI 侧准备(注册、Key、文档)
智谱 AI(北京智谱华章科技有限公司,清华 KEG 相关成果转化)产品线包括 ChatGLM / GLM-4、多模态 GLM-4V、视频生成 CogVideoX 等——我们这篇只取 文本 Embedding-3。
常用入口(完整 URL,自行复制):
上面文档链接以官网为准;若路径微调,从开放平台首页进 开发文档 即可。
3. 动手:Spring Boot 项目 SpringAIEmbedding
3.1 约定目录
SpringAIEmbedding/
├── pom.xml
└── src/main/
├── java/com/example/springaiembedding/
│ ├── controller/EmbeddingController.java
│ └── service/EmbeddingService.java
└── resources/
└── application.properties
包名按你 Initializr 生成即可,下文用 com.example.springaiembedding 示意。
3.2 pom.xml
文件: pom.xml
要点:Spring Boot 3.5、Spring AI BOM、智谱 starter;需要文档切块工具时加 spring-ai-client-chat(内含 Document、TokenTextSplitter 等)。BOM 已管理版本时,优先不写死子依赖 version,避免和快照 BOM 打架——若你本地解析失败,再以官方示例为准锁版本。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>SpringAIEmbedding</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>SpringAIEmbedding</name>
<description>SpringAIEmbedding</description>
<properties>
<java.version>17</java.version>
</properties>
<!-- Spring AI BOM:统一版本;各 spring-ai-* 模块由 BOM 对齐 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-zhipuai</artifactId>
</dependency>
<!-- 文档切分、Document 等;版本建议交给 BOM,必要时再显式指定 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>central-portal-snapshots</id>
<name>Central Portal Snapshots</name>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</project>
3.3 application.properties
文件: src/main/resources/application.properties
# ---------- 应用 ----------
spring.application.name=SpringAIEmbedding
server.port=8080
# ---------- 智谱:Embedding(示例模型 embedding-2)----------
spring.ai.zhipuai.api-key=${ZHIPU_API_KEY}
spring.ai.zhipuai.base-url=https://open.bigmodel.cn/api/paas
spring.ai.zhipuai.embedding.options.model=embedding-3
# ---------- 智谱:Chat(可选,与本篇 Embedding 演示无强依赖)----------
spring.ai.zhipuai.chat.options.model=GLM-4-Flash
环境变量:
export ZHIPU_API_KEY="你的密钥"
4. 最小接口:把一句话打成向量
文件: src/main/java/com/example/springaiembedding/controller/EmbeddingController.java
package com.example.springaiembedding.controller;
import com.example.springaiembedding.service.EmbeddingService;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/ai")
public class EmbeddingController {
private final EmbeddingModel embeddingModel;
private final EmbeddingService embeddingService;
public EmbeddingController(EmbeddingModel embeddingModel, EmbeddingService embeddingService) {
this.embeddingModel = embeddingModel;
this.embeddingService = embeddingService;
}
/**
* 单条文本 → 向量(embedding-3 默认维度以官方为准,常见为 1024 维量级)。
*/
@GetMapping("/embedding")
public Map<String, Object> embed(
@RequestParam(value = "message", defaultValue = "给我讲个笑话") String message) {
float[] vector = embeddingModel.embed(message);
return Map.of(
"message", message,
"vector", vector
);
}
@GetMapping("/similarity")
public Map<String, String> similarity(@RequestParam("query") String query) {
String answer = embeddingService.queryBestMatch(query);
return Map.of("query", query, "answer", answer);
}
}
自测 URL:
http://localhost:8080/ai/embedding?message=今天要好好学习
返回里 vector 是一大串 float,别被长度吓到——那就是模型眼中的「这句话在 space 里的坐标」。
探究:embed 和 embedForResponse 一类 API 区别?
实践里先满足产品要的是「float[]」还是「带 metadata 的响应」;需要用量、模型名排查问题时,再走 EmbeddingResponse 更香。
5. 案例:本地三条知识,谁和用户问得最像?
思路很土但很清晰:
-
启动时把几条「知识」
embed(List)成向量缓存起来。 - 用户来问,把 query 向量化。
- 和每条知识向量算 余弦相似度,取最大的那条。
生产环境会换成 向量数据库(Vector Store)+ 索引,不会在百万文档上 Java for;教学阶段足够讲清链路。
5.1 余弦相似度(Cosine Similarity)是啥?
它解决什么问题?
把两段话都变成向量之后,你要回答:有多「像」?
余弦相似度是其中最常用的一种度量:看两个向量在空间里「指向」是否接近,而不太在意它们有多长(向量模长多大)。语义相近的句子,经同一套 Embedding 模型编码后,方向往往更接近,余弦值就偏大。
几何直觉(从二维想到高维)
余弦相似度是一种衡量两个向量方向相似程度的度量方法,通过计算它们夹角的余弦值来评估相似性,广泛应用于文本分析、数据挖掘等领域。
余弦相似度的数学本质是向量空间模型中夹角的余弦值,计算公式为两个向量的点积除以它们的模长乘积。对于n维向量A和B,公式可表示为:

其值范围在-1到1之间,1表示完全相同,-1表示完全相反,0表示无关。
余弦相似度典型应用场景如下:
文本相似度计算:将文档转化为词频向量后,通过余弦相似度比较内容相似性。
聚类分析:衡量数据点在高维空间中的分布方向是否相近,常用于推荐系统或异常检测。
取值怎么读?
| 余弦值(约) | 含义(口语) |
|---|---|
| 接近 1 | 方向几乎一致,语义上通常更相似(还要看模型质量,不是 100% 真理)。 |
| 接近 0 | 近似正交,经常理解为「不怎么搭边」、关联弱。 |
| 接近 -1 | 反方向;文本语义里相对少见,但若出现说明向量在空间里「对着干」。 |
理论上取值区间是 ([-1, 1])。实际文本 Embedding 里分量有正有负,同类语义往往落在 ((0, 1]) 的偏多——所以 Demo 里用「谁最大谁最像」是合理策略;若你要严格阈值(例如 sim > 0.75 才返回),需要在业务语料上标一批样本再定,别拍脑袋。
为啥检索 / RAG 里爱用余弦?
- 文档长短不一时,向量模长可能差很多;余弦用夹角说话,减少「长文档天然占优势」的偏差。
- 很多向量库(Milvus、pgvector 等)/API 默认就提供 cosine / inner product(归一化后内积与余弦等价)一类距离,工程上成套。
当然:欧氏距离、点积(配合归一化) 也能做检索,团队里对齐一种度量 + 一套评测,比争论「哪个绝对最科学」更重要。
探究:为啥不用欧氏距离?
都可以。余弦对「向量长短」不敏感、文本场景常见;欧氏对绝对位置敏感。团队里先统一一种,RAG 评测时再对比。
5.2 EmbeddingService
文件: src/main/java/com/example/springaiembedding/service/EmbeddingService.java
package com.example.springaiembedding.service;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class EmbeddingService {
private final EmbeddingModel embeddingModel;
private final List<float[]> docVectors;
private final List<String> docs = List.of(
"美食非常美味,服务员也很友好。",
"这部电影既刺激又令人兴奋。",
"阅读书籍是扩展知识的好方法。"
);
public EmbeddingService(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
this.docVectors = this.embeddingModel.embed(docs);
}
/**
* 输入查询,返回余弦相似度最高的那条知识。
*/
public String queryBestMatch(String query) {
float[] queryVec = embeddingModel.embed(query);
int bestIdx = -1;
double bestSim = -1;
for (int i = 0; i < docVectors.size(); i++) {
double sim = cosineSimilarity(queryVec, docVectors.get(i));
if (sim > bestSim) {
bestSim = sim;
bestIdx = i;
}
}
return docs.get(bestIdx);
}
/**
* 余弦相似度,范围约 [-1, 1],越大越相似。
*/
private double cosineSimilarity(float[] a, float[] b) {
double dot = 0;
double na = 0;
double nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
double denom = Math.sqrt(na) * Math.sqrt(nb);
return denom == 0 ? 0 : dot / denom;
}
}
这段 cosineSimilarity 在算什么?(对着代码读一遍)
数学上就是上一节的公式:点积 /((|\mathbf{a}|\times|\mathbf{b}|))。实现里没单独写开方后再相乘以外的花样,就是朴素循环版,CPU 上对短向量够用。
| 变量 | 含义 |
|---|---|
dot |
点积(内积) (\sum_i a_i b_i)。同一维上两个分量相乘再累加:同号且绝对值都大的维度,对 dot 贡献大——两条向量在同一特征方向上同时「做强」时,点积更容易变大。 |
na |
(\sum_i a_i^2),向量 a 各分量平方和(还没开方)。 |
nb |
同理,向量 b 的平方和。 |
Math.sqrt(na) |
(|\mathbf{a}|),即向量 a 的欧氏长度(模长)。 |
denom |
(|\mathbf{a}||\mathbf{b}|),公式里的分母。 |
dot / denom |
即 (\dfrac{\mathbf{a}\cdot\mathbf{b}}{|\mathbf{a}||\mathbf{b}|}),也就是余弦相似度。 |
denom == 0 ? 0 : … 是干啥的?
若某一向量全 0(或数值下溢成 0),模长为 0,除法无意义;这里直接返回 0,避免 NaN。正规 Embedding 输出一般不会是全零向量,但防御式写法在工程里不丢人。
前提:a.length == b.length
同一模型、同一维度下,两条文本的向量长度一定相同;若你混了两个模型或手工拼向量,要先 assert 或短路,否则循环会越界或语义全错。
为啥用 double 累加?
float 分量相乘再累加到 double,比全程 float 稍抗一点舍入误差;结果和向量库里的 cosine 在浮点误差内应一致,线上要以评测集 + 供应商说明为准。
Controller 里注入 EmbeddingService 的 /similarity 已在 §3 合并写出;若你更喜欢两个 Controller 类,拆出去也行,别重复映射同一路径。
5.3 自测
http://localhost:8080/ai/similarity?query=美食
http://localhost:8080/ai/similarity?query=电影
http://localhost:8080/ai/similarity?query=书籍
预期(大意):
| query | answer(三条知识里最相近的一条) |
|---|---|
| 美食 | 美食非常美味,服务员也很友好。 |
| 电影 | 这部电影既刺激又令人兴奋。 |
| 书籍 | 阅读书籍是扩展知识的好方法。 |
6. 收尾:从 Demo 到上线还差啥?
八年经验里,Embedding 最容易翻车的不是公式,而是:
- 向量维度 / 模型版本换了,旧索引全废;
- 网关超时、限流没在压测里摸过;
- 成本:按 token 或按条计费,日志别打印整段向量;
-
真正规模必须上 Vector Store(Milvus、pgvector、Redis 向量模块等),内存
List<float[]>只适合讲清楚原理。
如果你下一篇想写 Spring AI + VectorStore + RAG 一条龙,可以按同一风格续上。
附录:参考链接
| 说明 | 地址 |
|---|---|
| Spring AI Reference | https://docs.spring.io/spring-ai/reference/ |
| 智谱开放平台 | https://open.bigmodel.cn/ |