CH 04嵌入模型与向量数据库
这一章是整门课唯一涉及数学概念的章节,但请放心——你不需要会推导公式,只需要建立一个直觉:把文本丢进一个函数,出来一串数字;语义相近的文本,出来的数字串也相近。向量的本质就是「把意思变成坐标」。本章所有代码基于 Spring AI 2.0.1 + Spring Boot 4.1.x + Java 17;chat 默认走 DeepSeek(密钥 ${DEEPSEEK_API_KEY:}),但 DeepSeek 不提供 embedding 能力,向量化需用 OpenAI / 通义 / 智谱 / 本地 Ollama 等兼容供应商(详见 4.5)。本章配套 lab 没有现成代码,所有类都按「自建」口径从零新建,跟着每一节的 ol.steps 一步步在 IDEA 里敲出来即可。
教学设计说明(教师参考):本章最大掉队点是「维度不一致」和「阈值设太高导致检索为空」。建议课堂节奏:先用 4.1 的「退款」例子在黑板上画向量,建立直觉;再让学生亲手跑 4.4 的 compare() 实验,亲眼看到语义相似分数最高;最后用 4.8 的三个 curl 走完灌库→检索闭环,其中第 ③ 步「钱什么时候能到账」命中退款条款是必查项。检查方式:抽查学生的 pgvector 表维度与 embedding 模型是否一致,并让他演示一次「加分类过滤后结果变化」。
本章新增易掉队点(写进教案,逐条盯):
- embedding 没有 .options 层级:学生常照 chat 的写法写
spring.ai.openai.embedding.options.model,2.0 直接不生效/报错。正确是扁平的spring.ai.openai.embedding.model(仅 chat 保留.options)。 - DeepSeek 没有 embedding:把 embedding 也配成 deepseek key 是本章头号隐性 bug。embedding 必须换 OpenAI 兼容供应商(通义/智谱/SiliconFlow)或本地 Ollama,只改 yml 的 base-url / api-key / model。
- 维度必须和模型输出一致:换模型(如 1536→1024)必须同步改
dimensions并重建表,否则插入直接报错。 - 元数据过滤字符串必须单引号:双引号会解析失败,且字段类型要稳定(数字别有时写 3 有时写 "3")。
- 多向量库 bean 歧义:同时引入 pgvector + milvus 时按类型注入
VectorStore会冲突,要用@Qualifier或关闭其中一个自动装配。 - 批量上限与分片:循环单条灌库必被限流,必须用
add(List)批量且每批 10~50 条自己分片。 - 旧名 EmbeddingClient 已移除:1.x 教程里的
EmbeddingClient在 2.0 已更名移除,复制过来编译报找不到类,必须用EmbeddingModel。
4.1Embedding 到底在做什么:从「关键词匹配」到「语义匹配」
先看一个经典问题。用户问:「怎么把钱退回来?」 而你的知识库里写的是:「退款申请流程说明」。这两句话没有一个字相同,用传统的关键词检索(MySQL 的 LIKE '%退款%' 或 Elasticsearch 的倒排索引)都匹配不到。但人一眼就知道它们讲的是同一件事。
Embedding 是什么(是什么 → 为什么需要 → 没有它会怎样 → 类比):
- 是什么:Embedding(嵌入)是一个把「文本」映射成「一串浮点数(向量)」的函数。输入一句话,输出一个固定长度的数组,例如 1536 个 float。
- 为什么需要:计算机不懂「意思」,只认数字。要让机器比较两段文本的语义远近,必须先把它变成可计算的数字。
- 没有它会怎样:只能做关键词匹配——字对字比对。遇到同义改写(「退钱」vs「退款」)、上下位词(「手机」vs「电子设备」)全部失效,RAG 检索阶段就漏掉了正确资料。
- 类比:把 Embedding 想象成「把一句话压缩成一张指纹」。意思相近的话,指纹也相近;意思不同的话,指纹差很远。检索就是「拿查询指纹去库里找最像的几张指纹」。
| 对比维度 | 关键词匹配(旧) | 语义匹配(Embedding,新) |
|---|---|---|
| 比较的单位 | 字符 / 词面 | 整句语义向量 |
| 能处理同义改写吗 | ❌ 字不同就匹配不到 | ✅ 意思近,向量就近 |
| 典型实现 | SQL LIKE / ES 倒排索引 | Embedding + 向量距离计算 |
| 缺点 | 召回低、漏检多 | 需要先向量化、要存向量、要算距离 |
Embedding(嵌入)就是把这种「人知道但机器不知道」的语义关系,变成机器能算的数字关系:
为什么这决定了 RAG 的上限
第 5 章的 RAG 效果好不好,80% 取决于本节这一步。检索错了,后面的大模型再强也只能基于错误的资料胡说八道。所以本章不是「预备知识」,而是整个 RAG 的地基。
4.2向量模型选型与维度对照
嵌入模型(Embedding Model)是什么(是什么 → 为什么需要 → 没有它会怎样 → 类比):
- 是什么:一个专门把文本编码成向量的神经网络模型(如 OpenAI text-embedding 系列、通义 text-embedding-v3、Ollama 的 nomic-embed-text)。
- 为什么需要:只有模型才能产出「语义相近→向量相近」的那串数字;自己手写的哈希/分词做不到语义对齐。
- 没有它会怎样:你拿不到可比较的向量,整个相似检索链路断在第一环。注意 DeepSeek 不提供 embedding 接口,用 DeepSeek 做 chat 可以,做向量化必须换供应商。
- 类比:嵌入模型就像一个「翻译官」,负责把人类语言翻译成机器能比大小的「数字语言」;翻译官不同(模型不同),翻译出的数字体系也不同,不能混着比。
| 模型 | 维度 | 部署方式 | 特点与建议 |
|---|---|---|---|
text-embedding-3-small | 1536 | OpenAI API | 便宜、够用(需网络可达) |
text-embedding-3-large | 3072 | OpenAI API | 效果更好,成本和存储翻倍,中文场景提升有限 |
text-embedding-v3(通义) | 1024 | DashScope API | 国内可用性好,中文效果不错,推荐国内同学使用 |
embedding-3(智谱)课程实测主用 | 256 / 512 / 1024 / 2048 (可自定义,课程锁 1024) | API(OpenAI 兼容) | 国内直连稳定,0.5 元/百万 tokens;全程手把手配置见 4.5 方案 C |
nomic-embed-text | 768 | Ollama 本地 | 完全离线、免费、中文尚可,教学/内网首选 |
BAAI/bge-large-zh-v1.5 | 1024 | 本地(ONNX/Transformers) | 中文效果优秀,需要本地推理环境 |
BAAI/bge-m3 | 1024 | 本地 / 服务化 | 多语言 + 长文本,检索质量高,资源占用大 |
- 灌库和查询必须用同一个模型。换了模型,旧向量全部作废,必须整库重灌。这是 RAG 项目最常见的「换模型后检索全废」事故。
- 建表时的维度必须等于模型输出维度。用 1536 建表却灌 1024 维向量,直接插入报错。
- dimensions 参数若被模型支持(如 text-embedding-3 系列可截断到 512/1024),可以省存储、提速,但会略微损失精度。降维后同样要统一。
embedding-3(云 API,课程 lab 实测主用)——OpenAI 兼容接口国内直连,1024 维中文效果好,成本约 0.5 元/百万 tokens,整章实验花费不到 1 元;② Ollama 本地 nomic-embed-text(768 维)——完全离线零密钥,机房/内网环境首选。无论选哪个,记住:全课程只锁定一个模型,不要中途更换。
4.3相似度算法与索引类型
三种距离度量
| 算法 | Spring AI 枚举 | 直觉理解 | 适用 |
|---|---|---|---|
| 余弦距离 | COSINE_DISTANCE |
只看方向,忽略长度。文本检索的标准选择。 | ✅ 默认用这个 |
| 欧氏距离 | EUCLIDEAN_DISTANCE |
看绝对位置的远近。受向量长度影响大。 | 图像/数值向量 |
| 内积 | NEGATIVE_INNER_PRODUCT |
方向 + 长度。适合「长度本身有含义」的场景。 | 推荐系统 |
| 对比维度 | 余弦相似度(Cosine) | 点积(Dot Product / 内积) |
|---|---|---|
| 计算方式 | 点积 ÷(两向量模长乘积) | 对应位直接相乘再求和,Σ aᵢ·bᵢ |
| 是否受向量长度影响 | ❌ 已归一化,只看方向 | ✅ 向量越长值越大 |
| 文本检索里的问题 | 无:长文本不会天然更远 | 长文本分数虚高,偏差大 |
| Spring AI 里怎么写 | distance-type: COSINE_DISTANCE | distance-type: NEGATIVE_INNER_PRODUCT |
| 结论 | ✅ 文本检索默认选它 | 仅当「长度本身有语义」时(如推荐打分)才用 |
索引类型:为什么需要它
假设库里有 100 万条向量。用户查询一次,要对 100 万条逐一算余弦距离——这叫暴力检索(Flat / 精确检索),准确率 100% 但慢得无法接受。
近似最近邻(ANN)索引通过牺牲一点点召回率,把复杂度从 O(n) 降到接近 O(log n):
| 索引类型 | 原理(一句话) | 适用规模 | 调参要点 |
|---|---|---|---|
HNSW |
构建多层「高速公路 + 小路」的图,查询时从高速逐层下降 | 10 万 ~ 千万级 | m(每节点连边数,默认 16)、ef_construction(构建精度,默认 64)。值越大越准越慢越占内存 |
IVF_FLAT |
先把向量聚类成 N 个桶,查询时只搜最近的几个桶 | 百万级以上 | nlist(桶数);查询时用 nprobe 控制搜几个桶 |
Flat / 暴力 |
全量精确计算,不做任何近似 | < 1 万条 | 无参数,教学和小数据量下推荐用(结果可复现,便于调试) |
4.4EmbeddingModel:把文本变成数字的 API
上一节讲的是「为什么」,这一节动手写出第一段真正可调用的嵌入代码。Spring AI 把各家嵌入模型统一抽象成 EmbeddingModel 接口——你写一遍代码,换供应商只改 yml,不用改 Java。
| 对比维度 | EmbeddingModel(嵌入模型) | ChatModel(对话模型) |
|---|---|---|
| 输入 | 一段/一批文本 | 多轮对话消息 |
| 输出 | float[] / EmbeddingResponse(向量) | 回答文本 / ChatResponse |
| 典型用途 | 灌库、相似检索前的中间步骤 | 直接和用户对话、RAG 生成答案 |
| yml 配置层级 | spring.ai.openai.embedding.model(扁平,无 .options) | spring.ai.openai.chat.options.model(有 .options) |
| DeepSeek 是否支持 | ❌ 不支持,须换供应商 | ✅ 支持(OpenAI 兼容) |
EmbeddingClient 在 2.0 已更名并移除,统一改为 EmbeddingModel。你在网上看到的旧教程若写 EmbeddingClient,在新版本里已不存在,复制过来编译会报「找不到符号」。看到旧名直接全局替换成 EmbeddingModel 即可。
① 新建包与类(IDEA 手把手)
-
在 IDEA 左侧 Project 视图,展开
src/main/java→com.example.springai。在springai包上右键 → New → Package,输入ch04,回车(得到com.example.springai.ch04)。 -
在
ch04包上右键 → New → Java Class,名字填EmbeddingDemoService,回车。 -
把下面这段「标准答案」整段粘贴进
EmbeddingDemoService.java,Ctrl + S 保存(下面的代码语义不要改,它就是本章的标准答案):import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingResponse; @Service public class EmbeddingDemoService { private final EmbeddingModel embeddingModel; public EmbeddingDemoService(EmbeddingModel embeddingModel) { this.embeddingModel = embeddingModel; } /** ① 单条文本 → float[] */ public float[] embedOne(String text) { float[] vector = embeddingModel.embed(text); System.out.println("维度 = " + vector.length); // 1536 / 1024 / 768 … return vector; } /** ② 批量 → 一次网络往返处理多条,比循环单条快得多(务必用这个) */ public EmbeddingResponse embedBatch(List<String> texts) { // 注意:多数厂商单次批量上限为 10~100 条,超了要自己分片 return embeddingModel.embedForResponse(texts); } /** * ③ 手写一个"语义相似度比较器",用来验证向量到底有没有用。 * 这是本章最重要的一个实验,强烈建议自己跑一遍。 */ public void compare() { String query = "怎么把钱退回来"; List<String> candidates = List.of( "退款申请流程说明", // 语义相似,应排第 1 "售后服务中心电话", // 弱相关 "如何配置数据库连接池" // 完全不相关 ); float[] q = embeddingModel.embed(query); for (String c : candidates) { float[] v = embeddingModel.embed(c); // 余弦相似度 = 点积 / (模长乘积),取值 [-1, 1],越接近 1 越相似 double cos = cosine(q, v); System.out.printf("%.4f %s%n", cos, c); } // 期望看到:退款申请流程说明 的分数明显最高(通常 0.75 以上) } private double cosine(float[] a, float[] b) { double dot = 0, na = 0, 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]; } return dot / (Math.sqrt(na) * Math.sqrt(nb)); } }
embedForResponse。循环调用单条接口灌 1 万篇文档,会被限流且慢 10 倍以上。
② 注意各厂商的批量上限。OpenAI 单次最多 2048 条但受 Token 总量限制;通义 text-embedding-v3 单次上限较小。灌库时务必自己分片,例如每 10 条一批。
② 让 compare() 真的跑起来(加一个启动器)
上面的 EmbeddingDemoService 只是个 @Service,需要有人调用它。我们加一个 CommandLineRunner,让应用一启动就打印相似度对比,方便你「亲眼验证向量有用」。
- 在
ch04包上右键 → New → Java Class,名字填EmbeddingDemoRunner。 - 粘贴下面代码并保存(这是驱动标准答案跑起来的「脚手架」,不属于标准答案本身,可随时删):
package com.example.springai.ch04;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class EmbeddingDemoRunner implements CommandLineRunner {
private final EmbeddingDemoService demo;
public EmbeddingDemoRunner(EmbeddingDemoService demo) {
this.demo = demo;
}
@Override
public void run(String... args) {
System.out.println("===== 语义相似度实验 start =====");
demo.compare();
System.out.println("===== 语义相似度实验 end =====");
}
}
③ 运行验证
先确认 yml 里已经配好了某个可用的 embedding 供应商(见 4.5,推荐先用 Ollama 本地 nomic-embed-text,零密钥)。然后:
步骤 A:先编译,确保代码写得对(不需要联网、不需要密钥):
# 在项目根目录(pom.xml 所在目录)执行
mvn -q compile
看到什么算通过:命令结束没有任何 BUILD FAILURE,最后一行是 BUILD SUCCESS(-q 会静默掉下载日志,只留结果)。如果这里报错,通常是包名/import/泛型没敲对,先修编译再往下。
步骤 B:启动应用,触发 compare():
mvn spring-boot:run
预期输出(控制台):应用启动后,会打印类似下面的三行(分数随模型不同略有浮动,但顺序一定不变):
===== 语义相似度实验 start =====
0.7588 退款申请流程说明 ← 课程 lab 实测(智谱 embedding-3,1024 维)
0.30xx 售后服务中心电话
0.10xx 如何配置数据库连接池
===== 语义相似度实验 end =====
看到什么算通过:「退款申请流程说明」的分数(实测 0.7588,用智谱 embedding-3 / 1024 维跑出)明显高于另外两条,且和「如何配置数据库连接池」接近 0.1。这说明:两句没有相同字的话,语义向量确实很近——你对 RAG 的信任从「老师说能行」变成「我亲眼见过」。分数绝对值随模型不同会浮动,但排序不变。
- 控制台报
No qualifying bean of type 'EmbeddingModel':说明 embedding 供应商没配或 starter 没加。回去检查 4.5 的依赖与 yml,确认spring.ai.ollama.embedding.model(或对应供应商)已配置且本地 Ollama 已启动(curl http://localhost:11434/api/tags能看到nomic-embed-text)。 - 报 401 / 403:embedding 用了某个云侧 key,但 key 没读到或无效。确认环境变量已设置且 IDEA/终端已重开(见 0.6 节「进程继承」)。
- 打印了但三条分数都接近 0.5~0.6、区分度差:可能是中英文不匹配(库里中文、查询英文),或模型中文能力弱,换
text-embedding-v3/nomic-embed-text再试。
compare() 实验值得手跑一遍?因为很多学生「听说过向量有用」但没亲眼验证过。当你看到「怎么把钱退回来」和「退款申请流程说明」的余弦分高达 0.7588,而和「数据库连接池」只有 0.1,你对 RAG 的信任就从「老师说能行」变成了「我亲眼见过」。这一节的输出截图,建议作为本章必交作业之一。
EmbeddingModel.embed(text) 一句话把文本变向量;单条用 embed、批量用 embedForResponse;换供应商只改 yml,Java 不动。4.5向量数据库选型与配置
向量本身只是一串数字,要能「存得下、查得快、能过滤」,需要一个向量数据库。它和关系库的区别是:除了存字段,还能直接对向量列建索引并算最近邻。
| 方案 | 优势 | 劣势 | 课程定位 |
|---|---|---|---|
| pgvector 推荐入门 |
复用已有 PostgreSQL,运维成本极低;支持 SQL + 向量混合查询;支持元数据过滤与事务 | 千万级以上性能不如专用向量库 | 第 4、5、11 章的主用方案 |
| Milvus | 专为向量设计,十亿级规模,索引类型丰富,分布式可扩展 | 组件多(etcd + minio),资源占用大,运维复杂 | 用于理解「专用向量库」的差异 |
| Redis(RedisSearch) | 已有 Redis 就能用,速度快,适合做缓存层 | 持久化与大规模能力弱于前两者 | 延伸阅读 |
| Elasticsearch / OpenSearch | 可与 BM25 关键词检索做混合检索(Hybrid Search) | 向量能力相对弱,集群重 | 混合检索章节提及 |
| SimpleVectorStore(内存) | 零依赖,写到内存里,启动即用 | 重启即丢,无法扩展 | 写单元测试时非常好用 |
第 0 步:先把 PostgreSQL 与 pgvector 装好(Windows 手把手,约 20 分钟)
pgvector 不是一款独立数据库,它是 PostgreSQL 的一个扩展。所以顺序永远是:① 装 PostgreSQL → ② 把 pgvector 扩展文件放进去 → ③ 建库、建扩展、建表。这一步不先做完,后面遇到的 relation "vector_store" does not exist、extension "vector" is not available、Connection to localhost:5432 refused 全都无从查起——因为你连"数据库在不在"都说不清。
-
下载并解压 PostgreSQL 17 的 Windows 二进制包(EDB 官网「Binaries」区的
.zip,不要下 Installer)。解压到D:\pgsql。路径纪律:不要放在含中文或空格的目录(如「桌面」「Program Files」),否则后面
initdb和pg_ctl会因为路径解析失败,报的错还看不出跟路径有关。解压后自检:确认这三个目录都在——
D:\pgsql\bin、D:\pgsql\lib、D:\pgsql\share。 -
把 pgvector 扩展文件放进去(下载 pgvector 的 Windows 预编译包,解压后按下表放)。放错目录是后面「扩展不可用」的唯一原因:
pgvector 包里拿到的文件 放到哪里 vector.dllD:\pgsql\lib\vector.controlD:\pgsql\share\extension\全部 vector--*.sql(通常 40 多个)D:\pgsql\share\extension\放完立刻验证(一定要做):用记事本打开
D:\pgsql\share\extension\vector.control,里面应当有default_version = '0.8.6'。看到这行,说明扩展文件位置对了;看不到,说明你放错了或没解压完整。 -
初始化数据目录(只做一次)。打开一个新的 PowerShell 或 CMD 窗口,执行:
:: 把数据目录建到 D:\pgdata,超级用户 postgres,UTF-8 编码 D:\pgsql\bin\initdb.exe -D D:\pgdata -U postgres -E UTF8 --locale=C预期输出结尾:
Success. You can now start the database server using: ... pg_ctl -D "D:\pgdata" -l logfile start看到什么算通过:出现
Success.,且D:\pgdata下生成了PG_VERSION(内容为17)与postgresql.conf。若报initdb: error: directory "D:\pgdata" exists but is not empty,说明目录里已有东西——换一个空目录,或确认那是你要复用的旧库。 -
改两处配置。用记事本打开
D:\pgdata\postgresql.conf,把下面两行前面的#去掉并改成:port = 5432 listen_addresses = 'localhost'再打开
D:\pgdata\pg_hba.conf,在文件最下方追加两行(让本机免密连上,便于排错;生产环境请改用scram-sha-256):host all all 127.0.0.1/32 trust host all all ::1/128 trust为什么两行都要写:yml 里写的是
localhost,Windows 上它可能被解析成 IPv6 的::1。只写127.0.0.1那一行时,应用会报no pg_hba.conf entry for host "::1"——这个错看起来像"没权限",实际只是少了一行 IPv6 规则。 -
启动数据库。继续用刚才那个终端:
D:\pgsql\bin\pg_ctl.exe -D D:\pgdata -l D:\pgdata\pglog.txt start预期输出:
waiting for server to start.... done与server started。验证它在监听 5432:
D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -c "SELECT version();"预期输出:形如
PostgreSQL 17.x on x86_64-windows, compiled by ...的一行。看到它就说明数据库层已经通了。 -
建业务库、业务账号,并安装 vector 扩展。三条命令,逐条执行:
:: ① 建库(名字与 application.yml 的 url 一致) D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -c "CREATE DATABASE vectordb;" :: ② 建应用账号(与 yml 的 username / password 一致) D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -c "CREATE USER ai WITH PASSWORD 'ai123456';" D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -c "GRANT ALL PRIVILEGES ON DATABASE vectordb TO ai;" :: ③ 装扩展(必须用超级用户 postgres 执行,普通用户没权限) D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -d vectordb -c "CREATE EXTENSION vector;"最终验证(这一步是整个第 0 步的验收动作):
D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -d vectordb -c "SELECT extname, extversion FROM pg_extension WHERE extname='vector';"预期输出:
extname | extversion ---------+------------ vector | 0.8.6 (1 row)看到什么算通过:必须出现
vector | 0.8.6这一行。看不到 extversion 就是扩展没装成,回去看第 2 步的文件位置。到这里,"数据库层"才算准备完毕——接下来配 yml 才有意义。
D:\pgsql\bin 加入用户环境变量 Path,然后重开终端,之后就可以直接写 psql -U postgres -h 127.0.0.1。注意:改了环境变量不重开终端不生效(与第 0 章讲的是同一个坑)。
CREATE EXTENSION vector 报 ERROR: extension "vector" is not available。
根因:PostgreSQL 在 share/extension 里找不到 vector.control,即第 2 步的文件没放对。
排查顺序:① 确认 D:\pgsql\share\extension\vector.control 文件存在;② 确认 D:\pgsql\lib\vector.dll 存在(缺 dll 会在下一步报「could not load library」);③ 确认 PostgreSQL 版本与 pgvector 包的版本匹配(PG 17 要配支持 17 的 pgvector 包,版本不对即使文件放对了也加载失败);④ 三条都对还报错,就重启数据库再试(pg_ctl -D D:\pgdata restart)——扩展目录是启动时扫描的。
postmaster.pid。
根因:用 Start-Process、& 或 nohup 这类方式把 postgres.exe 挂到了终端进程树下,终端一关或命令一返回,数据库进程就被连带回收,但 postmaster.pid 还留在数据目录里——于是下次启动报 lock file "postmaster.pid" already exists,看起来像"端口被占",实际是"进程已死、文件没清"。
正确做法:Windows 上一律用 pg_ctl start(它会把进程正确托管),或用 pg_ctl register 注册成系统服务;不要直接双击 postgres.exe,也不要靠终端后台挂起。
已残留怎么救:先确认真的没有 postgres 进程(任务管理器搜 postgres,有就先结束),再删 D:\pgdata\postmaster.pid,然后重新 pg_ctl start。进程还在时删这个文件会导致数据目录损坏,务必先确认进程已退。
Failed to obtain JDBC Connection / Connection to localhost:5432 refused。
三步定位:① psql -U postgres -h 127.0.0.1 -c "SELECT 1;" 能不能连上——连不上就是数据库没起来(回第 5 步);② 能连上但应用报错,就看 yml 的 url 里库名、username/password 与第 6 步建的账号是否一致;③ 报 password authentication failed 而 pg_hba.conf 写的是 trust,说明改完没重启数据库。注意第 5 步的 listen_addresses 只写了 'localhost'——如果你是从别的机器访问,这里要放开,但本地练习保持 localhost 更安全。
:: 停止
D:\pgsql\bin\pg_ctl.exe -D D:\pgdata stop
:: 重启(改完 postgresql.conf / pg_hba.conf 后必须重启才生效)
D:\pgsql\bin\pg_ctl.exe -D D:\pgdata restart
:: 看状态
D:\pgsql\bin\pg_ctl.exe -D D:\pgdata status
不建议用 taskkill 强杀——它不做 clean shutdown,下次启动可能要走崩溃恢复(日志出现 database system was not properly shut down; automatic recovery in progress)。真要用强杀,杀完记得确认 postmaster.pid 已消失。
pgvector 依赖与配置(手把手)
-
打开项目根目录的
pom.xml,在<dependencies>里加入 pgvector starter(它会传递引入 postgresql 驱动)。在 IDEA 里直接粘贴到已有依赖后面:<!-- pom.xml:pgvector 的 Spring AI starter(注意它会传递引入 postgresql 驱动) --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> </dependency> -
Ctrl + S 保存,等待右下角 Maven 同步完成(首次会下载依赖)。
.options 层级。旧教程里常见的 spring.ai.openai.embedding.options.model: xxx 在 2.0 是错的,正确写法是 spring.ai.openai.embedding.model: xxx(扁平)。只有 chat 层保留 spring.ai.openai.chat.options 这一层。两者不要搞混。
-
打开
src/main/resources/application.yml,按下面「扁平、无 .options、维度匹配」的写法配置。注意:chat 走 DeepSeek,embedding 必须换供应商——下面给两套 embedding 方案,二选一(推荐方案 A 本地 Ollama,零密钥)。# application.yml —— pgvector 完整配置(每一项都有注释) spring: datasource: # ★ VectorStore 用的就是这个数据源 url: jdbc:postgresql://localhost:5432/vectordb username: ai password: ai123456 driver-class-name: org.postgresql.Driver ai: # ---- chat 模型:课程默认 DeepSeek(OpenAI 兼容),密钥走环境变量占位符 ---- openai: api-key: ${DEEPSEEK_API_KEY:} base-url: https://api.deepseek.com chat: options: model: deepseek-chat # ---- embedding 模型:★ DeepSeek 不提供 embedding,必须换供应商 ---- # 方案 A(推荐入门,零密钥):本地 Ollama 的 nomic-embed-text,输出 768 维 ollama: base-url: http://localhost:11434 embedding: model: nomic-embed-text # ★ 扁平,无 .options;输出 768 维 # 方案 B(云侧,国内推荐):通义 text-embedding-v3,输出 1024 维 # 启用方案 B 时,把上面的 ollama 整段注释掉,取消下面注释,并改 dimensions 为 1024 # dashscope: # api-key: ${DASHSCOPE_API_KEY:} # embedding: # model: text-embedding-v3 # 方案 C(★ 课程 lab 实测主用):智谱 embedding-3,输出 1024 维 # 关键技巧:embedding 在 openai 下单独覆盖 base-url / api-key,chat 仍走上面的 DeepSeek # 启用时:把 ollama 整段注释掉,取消下面注释,并把 pgvector 的 dimensions 改为 1024 # openai: # embedding: # base-url: https://open.bigmodel.cn/api/paas/v4 # api-key: ${ZHIPU_API_KEY:} # model: embedding-3 # dimensions: 1024 vectorstore: pgvector: # 是否自动建表 / 建扩展。首次启动 true,后续可设 false 加快启动 initialize-schema: true # 表名。默认为 vector_store,改这里要同步改 table-name: vector_store # 索引类型:HNSW(推荐)/ IVFFlat / NONE(精确检索) index-type: HNSW # 距离类型,必须与灌库时一致 distance-type: COSINE_DISTANCE # ★★★ 维度必须等于 embedding 模型的输出维度,写错必报错 # 用 nomic-embed-text → 768;用 text-embedding-v3 → 1024;用 text-embedding-3-small → 1536 dimensions: 768 # 单次批量写入上限,文档多时自动分批 max-document-batch-size: 10000 -
Ctrl + S 保存 yml。记住一条:只要改了 embedding 模型(维度变了),就必须把
dimensions改成对应值,并且把 pgvector 表删掉重建(drop table if exists vector_store;后重启让initialize-schema: true重新建表)。
nomic-embed-text(768 维),yml 用上面的「方案 A」即可。启动前确认 Ollama 已运行且模型已拉取:
# 1) 启动 Ollama(默认监听 11434)
# 2) 拉取嵌入模型(只需一次)
ollama pull nomic-embed-text
# 3) 验证模型在本地已就绪
curl http://localhost:11434/api/tags
选哪个都行,但一旦选定,全课程不要换(否则维度不一致,旧库全废)。
方案 C 全程手把手:接入智谱 embedding-3(课程 lab 实测,推荐)
这套配置在课程 lab 里真实跑通过:中文语义检索效果好(「钱什么时候能到账」能命中「3 个工作日内退回原支付账户」),国内直连不需要任何网络工具,花费按 token 计费(0.5 元/百万 tokens,跑完本章全部实验通常 不到 1 元)。跟着下面 8 步做,每一步都有「看到什么算通过」。
-
注册并生成 API Key。打开
https://open.bigmodel.cn(智谱开放平台),用手机号注册并完成实名认证。进入控制台 → 「API Keys」→ 新建一个 Key,它会形如a5081a84....zJW75k(id.secret两段式,整串复制)。这一步只需要做一次。 -
确认账户有可用额度。embedding-3 按量计费:0.5 元 / 百万 Tokens。如果调用时收到
HTTP 429且错误码1113、提示「余额不足或无可用资源包」,去控制台「财务中心」充值最低档(10 元够整个学期)或在「资源包」页领免费额度。 -
把 Key 配置成环境变量(不要写进代码)。以 PowerShell 为例(永久生效,写入用户级):
# 永久写入用户环境变量(推荐) [Environment]::SetEnvironmentVariable("ZHIPU_API_KEY", "你的key", "User") # 验证:必须新开一个终端/重启 IDEA 后执行 echo $env:ZHIPU_API_KEY看到什么算通过:新开的终端里
echo能打印出你的 key。已经开着的 IDEA 读不到新环境变量,必须整个重启 IDEA(不是重启运行窗口)——这是环境变量「进程继承」规则,没重启就配好了也白配。 -
改 yml:embedding 在 openai 下单独覆盖 base-url / api-key。核心认知:智谱的 API 是 OpenAI 兼容的,所以不用换 starter,只要在
spring.ai.openai.embedding下覆盖三个值,chat 层完全不受影响(chat 继续走 DeepSeek):# application.yml —— 在 spring.ai 下,把原来 ollama 的 embedding 整段注释/删除,改为: spring: ai: openai: api-key: ${DEEPSEEK_API_KEY:} # chat 用,保持不动 base-url: https://api.deepseek.com # chat 用,保持不动 chat: options: model: deepseek-chat # chat 用,保持不动 # ★ embedding 三件套:只影响向量化,chat 不受影响。扁平结构,没有 .options embedding: base-url: https://open.bigmodel.cn/api/paas/v4 api-key: ${ZHIPU_API_KEY:} model: embedding-3 dimensions: 1024 # 主动声明 1024 维(省一半存储) vectorstore: pgvector: dimensions: 1024 # ★ 必须与上面一致 # 其余 initialize-schema / index-type / distance-type 保持原样两处易错:①
embedding下没有.options层级,写embedding.options.model是 1.x 的错误写法,2.0 不生效;②base-url必须带/api/paas/v4,少了版本段会 404。 -
改了模型就要删表重建。如果你之前用 Ollama/通义灌过数据,向量维度是旧的 768/1536,必须先清库:
-- 在 psql(或 IDEA Database 面板)里对 vectordb 执行 drop table if exists vector_store;然后保持
initialize-schema: true重启应用,它会按新的 1024 维重建表。建库后 dimensions 就锁死了——这是 pgvector 列类型vector(1024)决定的,改维度只能删表重建。 -
启动前先确认 PostgreSQL 在跑。本章最常见的启动失败就是下面这个(JDBC 连不上):
PgVectorStoreAutoConfiguration ... Failed to obtain JDBC Connection Caused by: PSQLException: Connection to localhost:5432 refused.这不是 Spring AI 的错,是 postgres 进程根本没在跑。先把数据库拉起来(本项目 lab 用绿色版:前台运行
postgres.exe -D 数据目录的那个终端窗口别关),看到日志出现database system is ready to accept connections再启动应用。 -
验证 ①:跑 4.4 的 compare() 实验。启动应用,控制台应打印三行分数,其中「退款申请流程说明」明显最高(实测 0.7588,智谱 embedding-3 / 1024 维)。
-
验证 ②:确认表结构真的是 1024 维。psql 连上
vectordb:\d vector_store -- 应看到:embedding | vector(1024),以及 hnsw (embedding vector_cosine_ops) 索引看到什么算通过:维度是 1024、索引是 HNSW cosine。到这里,智谱链路(注册 → 充值 → 环境变量 → yml 覆盖 → 建表 → 语义实验)全部打通。
| 报错 | 原因与解法 |
|---|---|
401 / 错误码 1000「身份验证失败」 | key 抄错、只复制了半段、或 key 已被删除。回控制台重新复制整串(含中间的点) |
429 / 错误码 1113「余额不足或无可用资源包」 | 账户没充值/没领资源包。去财务中心充值或领免费额度 |
429 / 错误码 1302 并发超限 | 免费档并发只有 5。灌库批量别太大(BATCH_SIZE 20 以内),或加退避重试 |
404 / 接口不存在 | base-url 少了 /api/paas/v4 版本段 |
报 expected 768 dimensions, not 1024(或相反) | 换了模型没删表重建。按第 5 步 drop 表重启 |
| 环境变量读不到(yml 占位符原样出现在报错里) | IDEA/终端是在设置环境变量之前开的,整个重启 |
spring.ai.openai.embedding 下覆盖 base-url/api-key/model 即可,chat 不动;维度主动声明 1024 并与 pgvector 一致;报 429/1113 就是没充值。运行验证:确认表真的建出来了
# 编译
mvn -q compile
# 启动(确保 PostgreSQL/pgvector 已运行、datasource 可达)
mvn spring-boot:run
看到什么算通过:启动日志里出现 pgvector 建表/建扩展相关 INFO,且没有 expected N dimensions 或 extension "vector" is not available 之类报错。再用 psql / IDEA Database 工具连上 vectordb,执行:
:: 用 psql 连上业务库(psql 已加 PATH 就直接写 psql,否则用全路径)
D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -d vectordb
-- 连上后执行:能看到这张表,说明自动建表成功
\d vector_store
-- 应看到一列类型为 vector(N),N = 你配置的 dimensions(如 768)
-- 以及一条 hnsw (embedding vector_cosine_ops) 索引
ERROR: expected 1536 dimensions, not 1024(pgvector)或
Collection schema dimension mismatch(Milvus)。
看到这个错误,不要改代码,先检查三处是否一致:建表维度(yml 的 dimensions)、模型的输出维度(embedding 配置)、以及两者是否同一个模型。三者任一不一致都会触发这个错。若你换了模型,记得删表重建(见上面第 4 步)。
Milvus 配置(做对比实验时用)
<!-- 依赖:与 pgvector 可以同时存在,但要靠 @Qualifier 或 spring.ai.vectorstore.type 指定主用 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
</dependency>
spring:
ai:
vectorstore:
milvus:
client:
host: localhost
port: 19530
# 用户名密码(未开启鉴权可留空)
username: root
password: milvus
database-name: default
collection-name: vector_store
# ★ 同样必须与 embedding 维度一致
embedding-dimension: 768
# 索引类型:IVF_FLAT / HNSW / FLAT
index-type: IVF_FLAT
# 距离度量:COSINE / L2 / IP
metric-type: COSINE
# 是否自动创建 collection
initialize-schema: true
VectorStore bean,按类型注入会歧义。解决办法:① 在需要的地方加 @Qualifier("pgVectorStore");② 或者在 yml 里关闭自动装配(spring.ai.vectorstore.<type>.enabled: false),再在配置类里手动 new。第 11 章会给出「主库 + 备库」的完整写法。
${XXX_API_KEY:} 占位符。4.6VectorStore:写入、删除、检索
VectorStore 是 Spring AI 对「向量库读写」的统一抽象。你不用手写 SQL 或向量距离计算,add() 时它内部自动调用 EmbeddingModel 把文本变成向量再入库,similaritySearch() 时自动把查询文本向量化再去比。
Document 与 metadata(先认识数据单位)
import org.springframework.ai.document.Document;
// Document = 文本内容 + 元数据 + 向量(向量由 VectorStore 自动生成,你不用管)
Document doc = new Document(
"退款申请需在订单完成后 7 天内提交,超期需联系客服人工审核。", // ① content:会被向量化的文本
Map.of( // ② metadata:不会向量化,但可用来过滤
"source", "refund-policy.pdf",
"page", 3,
"category", "售后",
"version", "v2"
)
);
// 也可以自己生成文档 ID(默认是随机 UUID)
Document withId = new Document(
"doc-001", // 显式指定 id,便于幂等更新
"退款申请需在订单完成后 7 天内提交。",
Map.of("category", "售后"));
// 读取
doc.getText(); // 正文
doc.getMetadata(); // 元数据 Map
doc.getId(); // id
doc.getScore(); // ★ 检索返回的文档会带相似度分数,写入时为 null
写入与删除(手把手建类)
- 在
ch04包上右键 → New → Java Class,名字填VectorStoreBasics。 - 粘贴下面「标准答案」并保存(语义不要改):
@Service
public class VectorStoreBasics {
private final VectorStore vectorStore;
public VectorStoreBasics(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/** 写入:内部会自动调用 EmbeddingModel 生成向量,再批量入库 */
public void add(List<Document> docs) {
vectorStore.add(docs);
// 注意:add 是"只增不改"。同一个 id 再 add 一次会怎样?
// 取决于实现:pgvector 用主键冲突会更新(upsert 行为),Milvus 可能抛错。
// 生产上建议:更新前先 delete(ids),再 add,语义更清晰。
}
/** 按 id 删除 */
public void deleteByIds(List<String> ids) {
vectorStore.delete(ids);
}
/** 按条件删除:例如删除某个文档来源的全部向量(重建索引时用) */
public void deleteBySource(String source) {
vectorStore.delete("source == '" + source + "'");
}
/** 向量库的名字,多库共存时用于区分 */
public String name() {
return vectorStore.getName();
}
}
检索:SearchRequest 详解(本章最重要的一段)
- 继续在
ch04包新建类VectorSearchDemo(或把下面代码并入你已有的检索服务),粘贴标准答案并保存:
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
// 最简写法:默认 topK,无阈值过滤
List<Document> simple = vectorStore.similaritySearch("退款要几天");
// 完整写法:所有参数都显式控制(推荐)
SearchRequest request = SearchRequest.builder()
.query("退款要几天") // 查询文本,会被向量化后去比对
.topK(5) // 返回最相似的 5 条
.similarityThreshold(0.5) // ★ 相似度低于 0.5 的直接丢弃(余弦下 0.5 约等于"弱相关")
.filterExpression("category == '售后'") // 元数据过滤,见 4.7
.build();
List<Document> docs = vectorStore.similaritySearch(request);
for (Document d : docs) {
System.out.printf("分数=%.4f 来源=%s 内容=%s%n",
d.getScore(), d.getMetadata().get("source"), d.getText());
}
| 参数 | 默认 | 怎么调 |
|---|---|---|
topK | 实现相关(常为 4~10) | 太小会漏(检索不到关键资料),太大会引入噪音并浪费 Token。中文知识库建议 4~8 起步,用第 10 章的评测集调优 |
similarityThreshold | 0.0(不过滤) | 强烈建议设 0.5~0.7。不设阈值的后果是:无论问什么,都会返回 topK 条「最像的」,哪怕它们完全不相关,模型就会基于垃圾资料胡说 |
filterExpression | 无 | 用元数据缩小检索范围,能显著提升准确率(见 4.7) |
VectorStore.add(docs) 自动向量化入库;similaritySearch(request) 自动向量化查询并比距离;阈值和 topK 必须显式设,否则会返回一堆不相关的垃圾。4.7元数据过滤:把检索范围先缩小,再算相似度
这是提升 RAG 准确率性价比最高的一招。举例:公司有 5 个部门的知识库,用户是财务部的,问「报销标准是多少」。如果不加过滤,向量检索会在全公司 10 万条文档里找,很可能把销售部的差旅标准捞出来——语义上极其相似,但答案是错的。
// ---------- 支持的运算符(Spring AI 的过滤表达式 DSL)----------
// 比较: == != > >= < <=
// 逻辑: and or ( )
// 集合: in
// 精确匹配
"category == '售后'"
// 数值比较(注意:数值型元数据不要加引号)
"page >= 10"
// 组合条件
"category == '售后' and version == 'v2'"
// 集合包含
"source in ['refund-policy.pdf', 'aftersale.pdf']"
// 实际用法
SearchRequest req = SearchRequest.builder()
.query("报销标准是多少")
.topK(5)
.similarityThreshold(0.5)
.filterExpression("dept == '财务部' and docType == '制度'")
.build();
source(文件路径)、page(页码,用于引用溯源)、docType(制度/手册/FAQ)、
dept 或 tenantId(多租户隔离,强烈建议)、updatedAt(用于时效性过滤)、
version(知识库版本)。一旦灌了几十万条数据再想补字段,就要整库重灌——这个坑本课程的学员踩过太多次了。
- 字符串必须用单引号,双引号会解析失败。
- 元数据字段值的类型要稳定。同一个字段有时写数字 3、有时写字符串 "3",过滤时会漏结果。
- 过滤条件的字段如果是
null或缺省,==不会匹配到它(不是 SQL 的三值逻辑,各实现略有差异,建议灌库时给所有文档补齐元数据)。
4.8完整示例:文档向量索引服务
目标:一个能「灌库 + 检索 + 统计」的最小可用服务。本节的代码第 5 章会直接复用。注意:本课程不使用 Lombok,日志用显式 LoggerFactory。下面 4 个文件请逐一按步骤新建,每个文件都是标准答案,语义不要改。
① 灌库服务
- 在
ch04包上右键 → New → Package,输入dto,回车。 - 在
ch04包(不是 dto)上右键 → New → Java Class,名字填DocumentIndexService,粘贴下面标准答案并保存:
package com.example.springai.ch04;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class DocumentIndexService {
private static final Logger log = LoggerFactory.getLogger(DocumentIndexService.class);
/** 单批写入条数。太大易触发厂商限流,太小效率低。10~50 是常见区间 */
private static final int BATCH_SIZE = 20;
private final VectorStore vectorStore;
public DocumentIndexService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/**
* 把一批文本(已切分好的片段)灌入向量库。
* @param source 来源标识(如文件路径),用于后续按来源删除/过滤
* @param chunks 已切分的文本片段,每个片段会成为一条向量
*/
public int index(String source, String category, List<String> chunks) {
// ★ 幂等:先删掉这个来源的旧数据,避免重复灌导致结果重复
// 这是"重建索引"最常见的做法
deleteBySource(source);
int seq = 0;
int total = 0;
for (int i = 0; i < chunks.size(); i += BATCH_SIZE) {
List<String> batch = chunks.subList(i, Math.min(i + BATCH_SIZE, chunks.size()));
List<Document> docs = batch.stream()
.map(text -> new Document(text, buildMetadata(source, category, chunks.indexOf(text))))
.toList();
vectorStore.add(docs); // 内部自动批量向量化 + 入库
total += docs.size();
log.info("已灌入 {}/{} 条", total, chunks.size());
}
return total;
}
/** 元数据必须在灌库时一次性写全,后面补字段要整库重灌 */
private Map<String, Object> buildMetadata(String source, String category, int chunkIndex) {
return Map.of(
"source", source,
"category", category,
"chunkIndex", chunkIndex,
// 注意:pgvector 的 JSON 元数据对 LocalDateTime 支持不一,
// 统一转成字符串更稳妥
"indexedAt", LocalDateTime.now().toString()
);
}
/** 按来源删除:重建索引时先调它 */
public void deleteBySource(String source) {
try {
vectorStore.delete("source == '" + source + "'");
} catch (Exception e) {
// 表为空或字段不存在时可能抛异常,降级为打日志,不阻断主流程
log.debug("按来源删除失败(可能是首次灌库):{}", e.getMessage());
}
}
}
② 检索服务
- 在
ch04包上右键 → New → Java Class,名字填VectorSearchService,粘贴标准答案并保存:
@Service
public class VectorSearchService {
private static final Logger log = LoggerFactory.getLogger(VectorSearchService.class);
private final VectorStore vectorStore;
public VectorSearchService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/**
* 语义检索。
* @param query 用户问题
* @param topK 返回条数
* @param threshold 相似度阈值,低于它的结果丢弃
* @param category 分类过滤,可为 null
*/
public List<SearchResult> search(String query, int topK,
double threshold, String category) {
// 过滤表达式按需拼接。注意:字符串值必须单引号
String filter = (category == null || category.isBlank())
? null
: "category == '" + category + "'";
var builder = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(threshold);
if (filter != null) {
builder.filterExpression(filter);
}
List<Document> docs = vectorStore.similaritySearch(builder.build());
log.info("检索「{}」命中 {} 条", query, docs.size());
// 转成对外的 DTO,不要把 Document 直接暴露给前端(会带上内部元数据)
return docs.stream()
.map(d -> new SearchResult(
d.getText(),
d.getScore(),
String.valueOf(d.getMetadata().get("source")),
String.valueOf(d.getMetadata().get("category"))))
.toList();
}
}
③ HTTP 接口与验证
- 在
ch04.dto包上右键 → New → Java Class,名字填IndexRequest,粘贴下面两个 record(可放同一文件或各建一个)并保存:
package com.example.springai.ch04.dto;
// 灌库入参
public record IndexRequest(String source, String category, List<String> chunks) {}
// 检索出参
public record SearchResult(String content, Double score, String source, String category) {}
- 在
ch04包上右键 → New → Java Class,名字填VectorApiController,粘贴下面标准答案并保存:
@RestController
@RequestMapping("/api/v1/vector")
public class VectorApiController {
private final DocumentIndexService indexService;
private final VectorSearchService searchService;
public VectorApiController(DocumentIndexService i, VectorSearchService s) {
this.indexService = i;
this.searchService = s;
}
/** 灌库:先手工传几段文本,验证链路 */
@PostMapping("/index")
public Map<String, Object> index(@RequestBody IndexRequest req) {
int n = indexService.index(req.source(), req.category(), req.chunks());
return Map.of("indexed", n, "source", req.source());
}
/** 检索 */
@GetMapping("/search")
public List<SearchResult> search(@RequestParam String q,
@RequestParam(defaultValue = "5") int topK,
@RequestParam(defaultValue = "0.5") double threshold,
@RequestParam(required = false) String category) {
return searchService.search(q, topK, threshold, category);
}
}
④ 运行验证:三条命令走完整个闭环
先编译并启动(确保 embedding 供应商可达、pgvector 表已建):
mvn -q compile
mvn spring-boot:run
看到什么算通过:BUILD SUCCESS 且应用监听 8080,日志无维度/扩展报错。然后依次发下面四条请求。
-
① 灌库:先灌三条售后知识。
curl -X POST http://localhost:8080/api/v1/vector/index \ -H "Content-Type: application/json" \ -d '{ "source":"refund-policy.md", "category":"售后", "chunks":[ "退款申请需在订单完成后 7 天内提交,超期需联系客服人工审核。", "退款将在审核通过后 3 个工作日内退回原支付账户。", "生鲜类商品一经签收不支持无理由退款。" ]}'预期输出:
{"indexed":3,"source":"refund-policy.md"},日志出现「已灌入 3/3 条」。 -
② 再灌三条技术文档(用来验证「不串味」)。
curl -X POST http://localhost:8080/api/v1/vector/index \ -H "Content-Type: application/json" \ -d '{ "source":"db-guide.md", "category":"技术", "chunks":[ "PostgreSQL 连接池建议 max_connections 不超过 200。", "pgvector 的 HNSW 索引构建参数 m 默认为 16。" ]}'预期输出:
{"indexed":2,"source":"db-guide.md"}。 -
③ 语义检索:故意用「没有一个字相同」的问法。
curl "http://localhost:8080/api/v1/vector/search?q=钱什么时候能到账&topK=3&threshold=0.4"预期输出:第一条命中「退款将在审核通过后 3 个工作日内退回原支付账户」——说明语义检索生效(关键词匹配绝对做不到)。
-
④ 加分类过滤,验证元数据过滤生效。
curl "http://localhost:8080/api/v1/vector/search?q=钱什么时候能到账&topK=3&category=技术"预期输出:结果全部来自
category=技术(很可能为空),证明过滤条件真的缩小了检索范围。
- 返回为空:阈值 0.4 仍偏高,或查询/库语言不一致。先把
threshold设为0看分数分布,再定阈值(见 4.6 参数表)。 - 命中了「技术」类而非「售后」类:模型中文能力不足,换成
text-embedding-v3或nomic-embed-text再试;或检查 4.5 的dimensions是否与模型一致(不一致会静默维度错配)。 - 报 500 / 向量维度错:说明你中途换了 embedding 模型却没删表重建,回去按 4.5 第 4 步删表。
text-embedding-v3 或 nomic-embed-text 再试),或者你的阈值设得太高。这一步跑通,第 5 章的 RAG 就是水到渠成。
来自课程 lab 真实复盘的三条实测判据(可写进实验报告):
- 语义命中判据:查询「钱什么时候能到账」,正确命中「退款将在审核通过后 3 个工作日内退回原支付账户」,余弦分 0.474——注意原文里没有「到账」两个字,关键词匹配做不到,这就是语义检索的证明。
- 过滤价值判据:不加过滤时,销售部差旅标准以 0.5240 混进「报销标准」检索结果的第 2 名(语义极像、答案全错);加上
dept == '财务部'一刀排除。这就是 4.7 过滤存在的意义,不是锦上添花。 - 幂等判据:同一 source 重复调 3 次
/index,库中该 source 的条数不变(先删后灌);去掉幂等删除再试,条数翻 3 倍。
buildMetadata里用chunks.indexOf(text)算 chunkIndex 有 bug:重复文本拿到同一下标且 O(n²),应改循环下标。- 过滤表达式
"category == '" + category + "'"有注入风险:category 含单引号时表达式直接坏掉,应转义或用结构化Filter.Expression构建。 - 接口无参数校验:topK 无上界(传 10000 能拖垮库)、threshold 未约束 0~1,按第 3 章的校验规范补上。
- embedding 调用无失败隔离:ZHIPU_API_KEY 未配置或智谱超时,灌库直接 500;至少加超时与明确报错提示。
- 演示 Runner 每次启动都真实调用智谱 API(花钱 + 断网起不来),可用
@ConditionalOnProperty加开关或仅 dev profile 启用。
deleteBySource 做幂等;检索用 SearchRequest 显式定 topK + 阈值 + 过滤;第 ③ 步「钱什么时候能到账」能命中退款条款,就证明语义检索真的通了。4.9常见坑排错
| 现象 | 原因 | 解决 |
|---|---|---|
expected 1536 dimensions, not 1024 | 建表维度与模型输出维度不一致 | 改 yml 的 dimensions 与模型对齐;或换模型 |
relation "vector_store" does not exist | 建表脚本没执行 / initialize-schema 为 false | 设 initialize-schema: true 并重启;或手工执行 init SQL |
extension "vector" is not available | pgvector 扩展没装:本地装的 PostgreSQL 缺扩展文件,或容器用的不是带 pgvector 的镜像 | 本地(Windows ZIP 版):见 4.5「第 0 步」第 2 步——vector.control 与 vector--*.sql 必须放进 D:\pgsql\share\extension\,vector.dll 放进 D:\pgsql\lib\,然后重启数据库;容器:镜像改用 pgvector/pgvector:pg17;两种方式最后都要用超级用户执行 CREATE EXTENSION vector; |
数据库「一会儿就断」,重启报 lock file "postmaster.pid" already exists | 用 Start-Process / & 把 postgres.exe 挂在终端进程树下,终端一关进程被回收,pid 文件残留 | 改用 pg_ctl start;救急:确认无 postgres 进程后删 postmaster.pid 再启动(详见 4.5「第 0 步」坑 B) |
no pg_hba.conf entry for host "::1" | yml 写 localhost 被解析成 IPv6,但 pg_hba.conf 只放行了 127.0.0.1/32 | 在 pg_hba.conf 追加 host all all ::1/128 trust,重启数据库 |
| 检索结果永远为空 | 阈值太高,或查询语言与库内容语言不匹配 | 先把 threshold 设 0 看分数分布,再定阈值;确认中英文 |
| 检索结果重复很多条 | 重复灌库(没做幂等删除) | 灌库前先 deleteBySource;或给 Document 指定稳定 id 并 upsert |
| 灌库很慢 / 被限流 429 | 逐条调用 embedding,或批量太大 | 用 add(List) 批量接口,每批 10~50 条,必要时加退避重试 |
| 元数据过滤报语法错 | 字符串没用单引号,或字段类型不一致 | 字符串值用单引号;统一元数据类型 |
No qualifying bean of type VectorStore | 没加 starter,或 datasource 配错 | 检查依赖与 spring.datasource;注意 pgvector 走的是标准 datasource |
| Milvus 连不上 / 一直重连 | etcd、minio 没起来,或内存不足 | docker compose logs milvus 看日志;先只跑 pgvector |
No qualifying bean of type EmbeddingModel | embedding 供应商没配 / starter 没加 / DeepSeek 被误用作 embedding | 确认加了对应 starter 且 yml 里 embedding 走 Ollama/通义/智谱等支持 embedding 的供应商,而非 DeepSeek |
旧教程复制的 EmbeddingClient 编译报错 | 1.x 类名在 2.0 已更名移除 | 全局替换为 EmbeddingModel;不要写 embedding.options 层级 |
启动即失败:Failed to obtain JDBC Connection → Connection to localhost:5432 refused | PostgreSQL 进程根本没在跑(绿色版窗口被关 / 终端进程树被回收),不是 Spring AI 配置错 | 先把 postgres 拉起来:前台运行 postgres.exe -D 数据目录 并保持窗口不关,日志出现 ready to accept connections 再启动应用;若仍起不来,删掉数据目录里残留的 postmaster.pid 再试 |
入库抛 Invalid UUID string: doc-001,应用起不来 | pgvector 的 id 列是 uuid 类型,Spring AI 强制 UUID.fromString() 转换 | 自定义 id 必须是合法 UUID(可用固定前缀如 10000000-0000-0000-0000-000000000001 保幂等);换 Milvus 等库无此限制 |
CommandLineRunner 里的 add() 抛异常 → 整个应用启动失败 | Runner 在启动最后阶段执行,任何异常都会终止 Spring 上下文 | 演示数据用「先按 source 清理再灌」的幂等写法;生产灌库走 HTTP 接口,不塞 Runner |
curl 报 415 Unsupported Media Type | 终端下 -H "Content-Type: application/json" 头未正确到达服务端 | 改用 curl ≥7.82 的 --json "@test.json"(同时设置 Content-Type 与 body);中文报文一律写成 UTF-8 JSON 文件再引用 |
| GET 检索带中文参数乱码或报错 | 终端编码 + URL 未编码 | 用 curl -G "http://localhost:8080/api/v1/vector/search" --data-urlencode "q=钱什么时候能到账" |
| 换 embedding 模型后检索分数全部异常,甚至不报错但结果全是垃圾 | 旧向量是旧模型的坐标系,与新模型查询向量静默错配——不报错比报错更危险 | 固定模型后不再换;确要换,先 drop table(或 TRUNCATE)再以新维度重建重灌 |
4.10自测题
Q1. 为什么换了 Embedding 模型之后,整个向量库必须重灌?
因为不同模型构建的向量空间完全不同。同一个词在模型 A 和模型 B 里的坐标毫无可比性,用 B 的向量去和 A 灌的数据算距离,得到的是噪音而非相似度。另外维度可能不同,schema 层面也直接冲突。
Q2. similarityThreshold 不设置会有什么后果?
无论用户问什么,都会返回「最相似的 topK 条」——即使这些内容完全不相关。RAG 场景下模型会基于这些垃圾资料强行组织答案,产生幻觉。这是「明明库里没这个知识,模型还是瞎编」的最主要原因之一。建议设 0.5~0.7 起步。
Q3. 元数据(metadata)为什么不能等灌完库再补?
因为元数据是随文档一起写入向量库的,不是独立字段。想给已存在的向量补一个字段,只能读出全部数据、补字段、重新写入——代价等同于整库重灌。所以元数据 schema 必须在第一次灌库前设计好。
Q4. 数据量只有 5000 条时,为什么建议先用 Flat(精确)检索?
5000 条的全量计算在现代 CPU 上是毫秒级,完全可接受。用精确检索能保证「检索结果 100% 是真实的最近邻」,从而把调试焦点放在切分质量、查询表述、阈值上,而不是被 ANN 的近似误差干扰。等数据量上万、或延迟不达标时,再切换到 HNSW 并做召回率对比。
Q5.【动手题】写一段代码,把 1000 篇文章标题批量向量化并入库,要求:每批 20 条、失败自动重试 3 次、记录总耗时与成功条数。
要点:用 Lists.partition(list, 20) 分片;每片包 try/catch,失败时 Thread.sleep(backoff * attempt) 做指数退避;用 System.currentTimeMillis() 或 StopWatch 计时;失败的分片收集起来最后统一重试,避免单条失败导致整批回滚。
4.11实操作业与本章验收
课堂练习(课上完成,约 40 分钟):
- 跑通 4.4 的
compare()实验,把「怎么把钱退回来」与三条候选的余弦分截图,确认语义相似项分数最高(>0.7)。 - 照 4.8 跑完四个 curl:先灌售后+技术两类文档,再用「钱什么时候能到账」做语义检索(确认命中退款条款),最后加
category=技术过滤(确认结果被收紧)。 - 把
similarityThreshold从 0.4 改成 0.95,重发检索,观察结果是否变空——直观感受阈值的作用。
课后作业:
- 给
DocumentIndexService的元数据补上tenantId字段,并在检索接口加一个tenantId查询参数做多租户隔离;写 200 字说明「为什么多租户 RAG 必须用元数据过滤而非分库」。 - 用同一个 pgvector 实例,故意把 yml 的
dimensions改错,重启触发维度报错,把报错信息和你的排查过程写成 150 字笔记。
- 能用自己的话说清 Embedding 在做什么,并解释「为什么关键词匹配做不到、语义匹配可以」(4.1 实验截图为证)。
- 能说出维度、距离算法、索引类型三个配置项的作用和相互约束,并确认自己库的维度与模型一致。
- 三个 curl 实验全部通过,特别是「钱什么时候能到账」能命中退款条款。
- 能设计一份合格的元数据 schema(含 source / page / category / tenantId),并知道「换模型必须重灌库」「阈值必须调」两条铁律。
4.12动手清单与自查表(照着核对)
本章是第一次引入外部依赖(数据库),失败面比前三章大得多:一半的报错来自"数据库没弄好",另一半来自"维度/阈值配错"。下面把三类事情分开列清楚。
① 前置:数据库层必须先过关(不过关别往下走)
| 检查项 | 命令 | 预期 / 判据 |
|---|---|---|
| PostgreSQL 在跑 | D:\pgsql\bin\pg_ctl.exe -D D:\pgdata status |
输出 server is running |
| 能连上、版本对 | D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -c "SELECT version();" |
出现 PostgreSQL 17.x |
| pgvector 扩展已装 | ... -d vectordb -c "SELECT extversion FROM pg_extension WHERE extname='vector';" |
必须返回 0.8.6(空结果=扩展没装,回 4.5 节「第 0 步」第 2 步) |
| 业务库与账号存在 | ... -h 127.0.0.1 -d vectordb -c "\du" |
能看到 ai 这个角色 |
② 你要建的文件(建一个,勾一个)
| 文件(相对工程根) | 对应小节 | 怎么确认建对了 |
|---|---|---|
.../ch04/dto/IndexRequest.java | 4.8 | record:source / category / chunks |
.../ch04/dto/SearchResult.java | 4.8 | record:content / score / source / category(不外暴露 Document) |
.../ch04/EmbeddingDemoService.java | 4.4 | 有单条 embed()、批量 embedForResponse()、手写余弦相似度三个方法 |
.../ch04/VectorStoreBasics.java | 4.6 | 封装 add / delete / similaritySearch,底层操作只在这里出现一次 |
.../ch04/VectorSearchDemo.java | 4.6 + 4.7 | 含幂等 seed()、demoSearchRequest()、demoFilter() |
.../ch04/Ch04DemoRunner.java | 4.4 ~ 4.7 | 按 4.4→seed→4.6→4.7 顺序跑;脚手架,可随时删 |
.../ch04/DocumentIndexService.java | 4.8 | 灌库前先 deleteBySource(幂等),再按 BATCH_SIZE 分批 add |
.../ch04/VectorSearchService.java | 4.8 | 检索参数(topK / 阈值 / 过滤)显式传入,结果转 SearchResult |
.../ch04/VectorApiController.java | 4.8 | 两个端点:POST /api/v1/vector/index、GET /api/v1/vector/search |
src/main/resources/application.yml(改动) | 4.5 | datasource 指向 vectordb;embedding 段是扁平的(无 .options);dimensions 与模型一致 |
仓库根 test-index-refund.json / test-index-db.json | 4.8 | UTF-8 保存的中文报文(终端直传中文会乱码) |
id 必须是合法 UUID。pgvector 的 id 列类型是 uuid,塞 "doc-001" 会在启动时直接抛 Invalid UUID string 并让整个应用起不来。想固定 id 就用 10000000-0000-0000-0000-000000000001 这类合法值。铁律二:演示数据不要在
CommandLineRunner 里灌。Runner 在启动最后阶段执行,add() 一旦抛异常就终止 Spring 上下文,应用直接起不来。演示用幂等 seed(先清再灌),生产灌库走 HTTP 接口。
③ 分步验收:从零到跑通(每步都有预期输出)
-
编译并启动。启动日志里应有 pgvector 建表/建扩展的 INFO,且没有
expected N dimensions、extension "vector" is not available、Connection refused。 -
确认表真的建出来了(自动建表成功的唯一证据):
D:\pgsql\bin\psql.exe -U postgres -h 127.0.0.1 -d vectordb -c "\d vector_store"预期:看到
id uuid/content text/metadata json/embedding vector(N),以及一条hnsw (embedding vector_cosine_ops)索引。看到什么算通过:
vector(N)里的 N 必须等于你 yml 里配的dimensions(用 nomic-embed-text 就是 768,用智谱 embedding-3 就是 1024)。N 不对,后面灌库必报错。 -
先跑 4.4 的相似度实验(验证"向量真的有用",这一步不需要数据库):运行
EmbeddingDemoService的compare()(或Ch04DemoRunner的日志输出)。预期:「怎么把钱退回来」与「退款申请流程说明」的余弦分明显最高(0.7 以上量级),与无关句子的分数明显更低。
看到什么算通过:语义相近的那条排第一。这一条是整章的直觉基础——它证明了"语义匹配"这件事真的成立。
-
灌库(先清后灌,幂等):
curl -s -X POST http://localhost:8080/api/v1/vector/index \ -H "Content-Type: application/json" \ --data-binary "@test-index-refund.json"预期:返回写入条数(如
{"indexed":8}之类)。重复执行一次,条数应不变——这是幂等生效的证据。条数翻倍说明没做
deleteBySource,先修再往下。 -
语义检索(第 ③ 项验收动作,也是本章最漂亮的一次实验):用一句不含原文关键词的口语去查:
:: 中文参数必须 URL 编码,否则乱码 curl -s -G http://localhost:8080/api/v1/vector/search \ --data-urlencode "q=钱什么时候能到账" \ --data-urlencode "topK=5"预期:命中
refund-policy.md里的退款到账条款,score 在 0.5 以上。看到什么算通过(可精确断言):结果里必须出现「3 个工作日内退回原支付账户」这类内容——注意原文里根本没有"到账"两个字,能命中就说明走的是语义而非关键词。这一条过不了,后面第 5 章整章都会塌。
-
加元数据过滤,看结果被收紧:
curl -s -G http://localhost:8080/api/v1/vector/search \ --data-urlencode "q=报销标准是多少" \ --data-urlencode "category=财务"预期:结果里不再出现"销售部差旅标准"之类归属错误的文档。
对照实验(推荐做):把
category参数去掉再查一次,你会看到它混进第 2 名——一加一减,元数据过滤的价值立刻可见。 -
做一个"阈值"反例:把
similarityThreshold从 0.4 改成0.95,重发检索。预期:结果几乎变空。再改回 0.5 左右,恢复正常。
为什么要做:让"阈值过低会进噪声、过高会检索为空"这两件事变成身体记忆——这是后面调 RAG 效果时最常动的两个旋钮之一。
④ 卡住了去哪查(症状 → 定位)
| 症状 | 先去哪一节 | 一句话提示 |
|---|---|---|
extension "vector" is not available | 4.5 节「第 0 步」坑 A + 4.9 | 扩展文件没放对目录,重启数据库再试 |
数据库"一会儿就断",postmaster.pid 残留 | 4.5 节「第 0 步」坑 B | 不要用 Start-Process/& 挂起,用 pg_ctl start |
Failed to obtain JDBC Connection / Connection refused | 4.5 节「第 0 步」坑 C + 4.9 | 先 psql 自测;再对 yml 的库名/账号 |
no pg_hba.conf entry for host "::1" | 4.5 节第 4 步 | 补 host all all ::1/128 trust 后重启 |
expected 1536 dimensions, not 1024 | 4.5 节「运行验证」+ 4.9 | 建表维度 ≠ 模型输出维度;换模型要删表重建 |
Invalid UUID string: doc-001,应用起不来 | 本节 pit + 4.9 | 自定义 id 必须是合法 UUID |
Runner 里 add() 抛异常 → 启动失败 | 本节 pit + 4.9 | 演示数据先清再灌;生产走 HTTP 接口 |
| 检索结果永远为空 | 4.9 排错表 | 先把阈值设 0 看分数分布,再定阈值 |
| 检索结果重复很多条 | 4.9 排错表 | 没做幂等删除,同一来源被灌了多次 |
curl 报 415 Unsupported Media Type | 4.9 排错表 | 改用 --json "@文件"(同时设 Content-Type 与 body) |
| GET 带中文参数乱码 / 报错 | 4.9 排错表 | -G + --data-urlencode |
No qualifying bean of type VectorStore | 4.9 排错表 | 检查 starter 与 datasource;pgvector 走标准 datasource |
旧教程里的 EmbeddingClient 编译不过 | 4.4 节 | 2.0 已更名 EmbeddingModel |
\d vector_store 里的 vector(N) 与模型维度一致;② 三次检索实验里有两次是"反直觉但必须出现"的结果——不含"到账"二字却命中退款条款、去掉过滤就混进错误部门的文档;③ 能说清一个灌库闭环的三件套:幂等删除 → 分批写入 → 元数据一次写全。