Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 4 章

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 里敲出来即可。

教学目标
理解向量化与相似检索,能搭向量索引
教学重点
EmbeddingModel API、VectorStore 读写、元数据过滤
教学难点
维度一致性铁律、相似度阈值调参、幂等灌库
建议学时
3 学时

教学设计说明(教师参考):本章最大掉队点是「维度不一致」和「阈值设太高导致检索为空」。建议课堂节奏:先用 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(嵌入)就是把这种「人知道但机器不知道」的语义关系,变成机器能算的数字关系:

"怎么把钱退回来?" ──▶ [0.021, -0.153, 0.887, ... ] ← 1536 个浮点数 "退款申请流程说明" ──▶ [0.019, -0.148, 0.891, ... ] ← 与上面非常接近! "如何配置数据库连接" ──▶ [-0.611, 0.327, -0.045, ... ] ← 与上面差很远 语义相似度 = 两个向量之间的距离(越近越相似)
向量的每一维代表什么?没有任何一维能被单独解释成「情绪」「主题」。它们是模型在大量语料上训练出来的抽象特征空间。你只需要知道两件事:① 维度必须和模型严格一致(换模型就得重新灌数据);② 同一个模型生成的向量才能互相比较(用 A 模型灌库、B 模型查询,结果全是噪音)。

为什么这决定了 RAG 的上限

第 5 章的 RAG 效果好不好,80% 取决于本节这一步。检索错了,后面的大模型再强也只能基于错误的资料胡说八道。所以本章不是「预备知识」,而是整个 RAG 的地基。

一个记忆小技巧:把 Embedding 想象成「把一句话压缩成一张指纹」。两句意思相近的话,指纹也相近;意思不同的话,指纹差很远。检索就是「拿查询指纹去库里找最像的几张指纹」——所以叫相似检索,而不是关键词检索。
一句话记忆:Embedding 就是把「意思」变成「坐标」;坐标近 = 意思近,检索就是找最近邻,而不是找相同字。

4.2向量模型选型与维度对照

嵌入模型(Embedding Model)是什么(是什么 → 为什么需要 → 没有它会怎样 → 类比):

  • 是什么:一个专门把文本编码成向量的神经网络模型(如 OpenAI text-embedding 系列、通义 text-embedding-v3、Ollama 的 nomic-embed-text)。
  • 为什么需要:只有模型才能产出「语义相近→向量相近」的那串数字;自己手写的哈希/分词做不到语义对齐。
  • 没有它会怎样:你拿不到可比较的向量,整个相似检索链路断在第一环。注意 DeepSeek 不提供 embedding 接口,用 DeepSeek 做 chat 可以,做向量化必须换供应商。
  • 类比:嵌入模型就像一个「翻译官」,负责把人类语言翻译成机器能比大小的「数字语言」;翻译官不同(模型不同),翻译出的数字体系也不同,不能混着比。
模型维度部署方式特点与建议
text-embedding-3-small1536OpenAI API便宜、够用(需网络可达)
text-embedding-3-large3072OpenAI API效果更好,成本和存储翻倍,中文场景提升有限
text-embedding-v3(通义)1024DashScope API国内可用性好,中文效果不错,推荐国内同学使用
embedding-3(智谱)
课程实测主用
256 / 512 / 1024 / 2048
(可自定义,课程锁 1024)
API(OpenAI 兼容)国内直连稳定,0.5 元/百万 tokens;全程手把手配置见 4.5 方案 C
nomic-embed-text768Ollama 本地完全离线、免费、中文尚可,教学/内网首选
BAAI/bge-large-zh-v1.51024本地(ONNX/Transformers)中文效果优秀,需要本地推理环境
BAAI/bge-m31024本地 / 服务化多语言 + 长文本,检索质量高,资源占用大
三个必须记住的铁律:
  1. 灌库和查询必须用同一个模型。换了模型,旧向量全部作废,必须整库重灌。这是 RAG 项目最常见的「换模型后检索全废」事故。
  2. 建表时的维度必须等于模型输出维度。用 1536 建表却灌 1024 维向量,直接插入报错。
  3. 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_DISTANCEdistance-type: NEGATIVE_INNER_PRODUCT
结论✅ 文本检索默认选它仅当「长度本身有语义」时(如推荐打分)才用
为什么文本检索几乎都用余弦?因为文本向量经过归一化,长度不携带语义信息,用余弦可以避免「长文本天然距离更远」的偏差。选 COSINE 是最省心的默认值。

索引类型:为什么需要它

假设库里有 100 万条向量。用户查询一次,要对 100 万条逐一算余弦距离——这叫暴力检索(Flat / 精确检索),准确率 100% 但慢得无法接受。

近似最近邻(ANN)索引通过牺牲一点点召回率,把复杂度从 O(n) 降到接近 O(log n):

索引类型原理(一句话)适用规模调参要点
HNSW 构建多层「高速公路 + 小路」的图,查询时从高速逐层下降 10 万 ~ 千万级 m(每节点连边数,默认 16)、ef_construction(构建精度,默认 64)。值越大越准越慢越占内存
IVF_FLAT 先把向量聚类成 N 个桶,查询时只搜最近的几个桶 百万级以上 nlist(桶数);查询时用 nprobe 控制搜几个桶
Flat / 暴力 全量精确计算,不做任何近似 < 1 万条 无参数,教学和小数据量下推荐用(结果可复现,便于调试)
课程建议:数据量小于 1 万条时,用 Flat 精确检索。这样你调试 RAG 时看到的「检索不到」问题,一定是「数据/切分/查询」本身的问题,而不是被 ANN 的近似性干扰。等数据量上来,再切到 HNSW,并做一次「精确检索 vs ANN 检索」的召回率对比实验。
一句话记忆:余弦看方向、点积看长度,文本用余弦;数据少就用 Flat 精确检索,数据多了再上 HNSW 近似索引。

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 兼容)
关于旧名(避坑):Spring AI 1.x 中的 EmbeddingClient 在 2.0 已更名并移除,统一改为 EmbeddingModel。你在网上看到的旧教程若写 EmbeddingClient,在新版本里已不存在,复制过来编译会报「找不到符号」。看到旧名直接全局替换成 EmbeddingModel 即可。

① 新建包与类(IDEA 手把手)

  1. 在 IDEA 左侧 Project 视图,展开 src/main/java → com.example.springai。在 springai 包上右键 → New → Package,输入 ch04,回车(得到 com.example.springai.ch04)。

  2. 在 ch04 包上右键 → New → Java Class,名字填 EmbeddingDemoService,回车。

  3. 把下面这段「标准答案」整段粘贴进 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,让应用一启动就打印相似度对比,方便你「亲眼验证向量有用」。

  1. 在 ch04 包上右键 → New → Java Class,名字填 EmbeddingDemoRunner。
  2. 粘贴下面代码并保存(这是驱动标准答案跑起来的「脚手架」,不属于标准答案本身,可随时删):
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 的信任从「老师说能行」变成「我亲眼见过」。分数绝对值随模型不同会浮动,但排序不变。

排查:启动后 compare() 没打印,或直接报错。
  1. 控制台报 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)。
  2. 报 401 / 403:embedding 用了某个云侧 key,但 key 没读到或无效。确认环境变量已设置且 IDEA/终端已重开(见 0.6 节「进程继承」)。
  3. 打印了但三条分数都接近 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 ZIP 免安装版)+ pgvector 0.8.6。 ZIP 版不写注册表、不装系统服务、删目录即卸载,最适合学生机;缺点是要自己敲几条命令,下面全部给出。 如果你已经装了 PostgreSQL 安装版(含 pgAdmin),可以跳过第 1、3、4 步,直接从第 2 步(放 pgvector 扩展文件)开始。
  1. 下载并解压 PostgreSQL 17 的 Windows 二进制包(EDB 官网「Binaries」区的 .zip,不要下 Installer)。解压到 D:\pgsql。

    路径纪律:不要放在含中文或空格的目录(如「桌面」「Program Files」),否则后面 initdb 和 pg_ctl 会因为路径解析失败,报的错还看不出跟路径有关。

    解压后自检:确认这三个目录都在——D:\pgsql\bin、D:\pgsql\lib、D:\pgsql\share。

  2. 把 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'。看到这行,说明扩展文件位置对了;看不到,说明你放错了或没解压完整。

  3. 初始化数据目录(只做一次)。打开一个新的 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,说明目录里已有东西——换一个空目录,或确认那是你要复用的旧库。

  4. 改两处配置。用记事本打开 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 规则。

  5. 启动数据库。继续用刚才那个终端:

    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 ... 的一行。看到它就说明数据库层已经通了。

  6. 建业务库、业务账号,并安装 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 才有意义。

把 psql 加进 PATH(可省掉每个命令前那一长串路径):把 D:\pgsql\bin 加入用户环境变量 Path,然后重开终端,之后就可以直接写 psql -U postgres -h 127.0.0.1。注意:改了环境变量不重开终端不生效(与第 0 章讲的是同一个坑)。
坑 A: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)——扩展目录是启动时扫描的。
坑 B:数据库「刚才还好好的,一会儿就连不上」,而且日志目录留下 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。进程还在时删这个文件会导致数据目录损坏,务必先确认进程已退。
坑 C:应用报 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 依赖与配置(手把手)

  1. 打开项目根目录的 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>
  2. Ctrl + S 保存,等待右下角 Maven 同步完成(首次会下载依赖)。

2.0 配置铁律(务必让学生记住):embedding 模型配置没有 .options 层级。旧教程里常见的 spring.ai.openai.embedding.options.model: xxx 在 2.0 是错的,正确写法是 spring.ai.openai.embedding.model: xxx(扁平)。只有 chat 层保留 spring.ai.openai.chat.options 这一层。两者不要搞混。
  1. 打开 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
  2. Ctrl + S 保存 yml。记住一条:只要改了 embedding 模型(维度变了),就必须把 dimensions 改成对应值,并且把 pgvector 表删掉重建(drop table if exists vector_store; 后重启让 initialize-schema: true 重新建表)。

本地免 key 方案(机房/内网首选):不想依赖任何云 key,装 Ollama 并拉取 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 步做,每一步都有「看到什么算通过」。

  1. 注册并生成 API Key。打开 https://open.bigmodel.cn(智谱开放平台),用手机号注册并完成实名认证。进入控制台 → 「API Keys」→ 新建一个 Key,它会形如 a5081a84....zJW75k(id.secret 两段式,整串复制)。这一步只需要做一次。

  2. 确认账户有可用额度。embedding-3 按量计费:0.5 元 / 百万 Tokens。如果调用时收到 HTTP 429 且错误码 1113、提示「余额不足或无可用资源包」,去控制台「财务中心」充值最低档(10 元够整个学期)或在「资源包」页领免费额度。

  3. 把 Key 配置成环境变量(不要写进代码)。以 PowerShell 为例(永久生效,写入用户级):

    # 永久写入用户环境变量(推荐)
    [Environment]::SetEnvironmentVariable("ZHIPU_API_KEY", "你的key", "User")
    
    # 验证:必须新开一个终端/重启 IDEA 后执行
    echo $env:ZHIPU_API_KEY

    看到什么算通过:新开的终端里 echo 能打印出你的 key。已经开着的 IDEA 读不到新环境变量,必须整个重启 IDEA(不是重启运行窗口)——这是环境变量「进程继承」规则,没重启就配好了也白配。

  4. 改 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。

  5. 改了模型就要删表重建。如果你之前用 Ollama/通义灌过数据,向量维度是旧的 768/1536,必须先清库:

    -- 在 psql(或 IDEA Database 面板)里对 vectordb 执行
    drop table if exists vector_store;

    然后保持 initialize-schema: true 重启应用,它会按新的 1024 维重建表。建库后 dimensions 就锁死了——这是 pgvector 列类型 vector(1024) 决定的,改维度只能删表重建。

  6. 启动前先确认 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 再启动应用。

  7. 验证 ①:跑 4.4 的 compare() 实验。启动应用,控制台应打印三行分数,其中「退款申请流程说明」明显最高(实测 0.7588,智谱 embedding-3 / 1024 维)。

  8. 验证 ②:确认表结构真的是 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/终端是在设置环境变量之前开的,整个重启
成本账(打消「不敢调 API」的顾虑):本章全部实验(compare 4 条 + 灌库 5 条 + 检索若干次)合计不到 1 万 tokens,花费 约 0.005 元。按 10 元充值额度,够整个班级跑完本章。别因为「怕花钱」去用盗版中转站——那是期末项目翻车的常见起点。
一句话记忆:智谱 embedding-3 走 OpenAI 兼容,在 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
同时配置两个向量库怎么办?Spring AI 会注册多个 VectorStore bean,按类型注入会歧义。解决办法:① 在需要的地方加 @Qualifier("pgVectorStore");② 或者在 yml 里关闭自动装配(spring.ai.vectorstore.<type>.enabled: false),再在配置类里手动 new。第 11 章会给出「主库 + 备库」的完整写法。
一句话记忆:embedding 没有 .options 层级、DeepSeek 不能做 embedding;维度必须和模型输出一致,换模型 = 改 dimensions + 删表重建;密钥永远用 ${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

写入与删除(手把手建类)

  1. 在 ch04 包上右键 → New → Java Class,名字填 VectorStoreBasics。
  2. 粘贴下面「标准答案」并保存(语义不要改):
@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 详解(本章最重要的一段)

  1. 继续在 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 章的评测集调优
similarityThreshold0.0(不过滤)强烈建议设 0.5~0.7。不设阈值的后果是:无论问什么,都会返回 topK 条「最像的」,哪怕它们完全不相关,模型就会基于垃圾资料胡说
filterExpression无用元数据缩小检索范围,能显著提升准确率(见 4.7)
别迷信分数绝对值。不同模型、不同距离算法算出的分数不可直接比较。正确做法是:在你自己的数据上跑几十个查询,观察「正确文档的分数分布」,再决定阈值。这个动作第 10 章会系统化。
一句话记忆: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(知识库版本)。
一旦灌了几十万条数据再想补字段,就要整库重灌——这个坑本课程的学员踩过太多次了。
过滤语法易错点:
  1. 字符串必须用单引号,双引号会解析失败。
  2. 元数据字段值的类型要稳定。同一个字段有时写数字 3、有时写字符串 "3",过滤时会漏结果。
  3. 过滤条件的字段如果是 null 或缺省,== 不会匹配到它(不是 SQL 的三值逻辑,各实现略有差异,建议灌库时给所有文档补齐元数据)。
一句话记忆:元数据是「不向量化但能过滤」的字段;字符串过滤用单引号、数值别加引号,且类型要稳定;设计要在灌库前定好,事后补 = 全库重灌。

4.8完整示例:文档向量索引服务

目标:一个能「灌库 + 检索 + 统计」的最小可用服务。本节的代码第 5 章会直接复用。注意:本课程不使用 Lombok,日志用显式 LoggerFactory。下面 4 个文件请逐一按步骤新建,每个文件都是标准答案,语义不要改。

src/main/java/com/example/springai/ch04/ ├── dto/ │ ├── IndexRequest.java # 入参:文档标题、正文、来源、分类 │ └── SearchResult.java # 出参:内容 + 分数 + 来源 ├── DocumentIndexService.java # 灌库 + 删除 + 重建 ├── VectorSearchService.java # 检索(含阈值、过滤、格式化) └── VectorApiController.java # HTTP 接口

① 灌库服务

  1. 在 ch04 包上右键 → New → Package,输入 dto,回车。
  2. 在 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());
        }
    }
}

② 检索服务

  1. 在 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 接口与验证

  1. 在 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) {}
  1. 在 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,日志无维度/扩展报错。然后依次发下面四条请求。

  1. ① 灌库:先灌三条售后知识。

    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 条」。

  2. ② 再灌三条技术文档(用来验证「不串味」)。

    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"}。

  3. ③ 语义检索:故意用「没有一个字相同」的问法。

    curl "http://localhost:8080/api/v1/vector/search?q=钱什么时候能到账&topK=3&threshold=0.4"

    预期输出:第一条命中「退款将在审核通过后 3 个工作日内退回原支付账户」——说明语义检索生效(关键词匹配绝对做不到)。

  4. ④ 加分类过滤,验证元数据过滤生效。

    curl "http://localhost:8080/api/v1/vector/search?q=钱什么时候能到账&topK=3&category=技术"

    预期输出:结果全部来自 category=技术(很可能为空),证明过滤条件真的缩小了检索范围。

第 ③ 步没命中退款条款?按顺序排查:
  1. 返回为空:阈值 0.4 仍偏高,或查询/库语言不一致。先把 threshold 设为 0 看分数分布,再定阈值(见 4.6 参数表)。
  2. 命中了「技术」类而非「售后」类:模型中文能力不足,换成 text-embedding-v3 或 nomic-embed-text 再试;或检查 4.5 的 dimensions 是否与模型一致(不一致会静默维度错配)。
  3. 报 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 倍。
工程改进清单(实验室复盘沉淀,作为本章选做进阶):
  1. buildMetadata 里用 chunks.indexOf(text) 算 chunkIndex 有 bug:重复文本拿到同一下标且 O(n²),应改循环下标。
  2. 过滤表达式 "category == '" + category + "'" 有注入风险:category 含单引号时表达式直接坏掉,应转义或用结构化 Filter.Expression 构建。
  3. 接口无参数校验:topK 无上界(传 10000 能拖垮库)、threshold 未约束 0~1,按第 3 章的校验规范补上。
  4. embedding 调用无失败隔离:ZHIPU_API_KEY 未配置或智谱超时,灌库直接 500;至少加超时与明确报错提示。
  5. 演示 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 availablepgvector 扩展没装:本地装的 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 EmbeddingModelembedding 供应商没配 / 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 refusedPostgreSQL 进程根本没在跑(绿色版窗口被关 / 终端进程树被回收),不是 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 字笔记。

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.java4.8record:source / category / chunks
.../ch04/dto/SearchResult.java4.8record:content / score / source / category(不外暴露 Document)
.../ch04/EmbeddingDemoService.java4.4有单条 embed()、批量 embedForResponse()、手写余弦相似度三个方法
.../ch04/VectorStoreBasics.java4.6封装 add / delete / similaritySearch,底层操作只在这里出现一次
.../ch04/VectorSearchDemo.java4.6 + 4.7含幂等 seed()、demoSearchRequest()、demoFilter()
.../ch04/Ch04DemoRunner.java4.4 ~ 4.7按 4.4→seed→4.6→4.7 顺序跑;脚手架,可随时删
.../ch04/DocumentIndexService.java4.8灌库前先 deleteBySource(幂等),再按 BATCH_SIZE 分批 add
.../ch04/VectorSearchService.java4.8检索参数(topK / 阈值 / 过滤)显式传入,结果转 SearchResult
.../ch04/VectorApiController.java4.8两个端点:POST /api/v1/vector/index、GET /api/v1/vector/search
src/main/resources/application.yml(改动)4.5datasource 指向 vectordb;embedding 段是扁平的(无 .options);dimensions 与模型一致
仓库根 test-index-refund.json / test-index-db.json4.8UTF-8 保存的中文报文(终端直传中文会乱码)
铁律一:自定义 id 必须是合法 UUID。pgvector 的 id 列类型是 uuid,塞 "doc-001" 会在启动时直接抛 Invalid UUID string 并让整个应用起不来。想固定 id 就用 10000000-0000-0000-0000-000000000001 这类合法值。
铁律二:演示数据不要在 CommandLineRunner 里灌。Runner 在启动最后阶段执行,add() 一旦抛异常就终止 Spring 上下文,应用直接起不来。演示用幂等 seed(先清再灌),生产灌库走 HTTP 接口。

③ 分步验收:从零到跑通(每步都有预期输出)

  1. 编译并启动。启动日志里应有 pgvector 建表/建扩展的 INFO,且没有 expected N dimensions、extension "vector" is not available、Connection refused。

  2. 确认表真的建出来了(自动建表成功的唯一证据):

    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 不对,后面灌库必报错。

  3. 先跑 4.4 的相似度实验(验证"向量真的有用",这一步不需要数据库):运行 EmbeddingDemoService 的 compare()(或 Ch04DemoRunner 的日志输出)。

    预期:「怎么把钱退回来」与「退款申请流程说明」的余弦分明显最高(0.7 以上量级),与无关句子的分数明显更低。

    看到什么算通过:语义相近的那条排第一。这一条是整章的直觉基础——它证明了"语义匹配"这件事真的成立。

  4. 灌库(先清后灌,幂等):

    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,先修再往下。

  5. 语义检索(第 ③ 项验收动作,也是本章最漂亮的一次实验):用一句不含原文关键词的口语去查:

    :: 中文参数必须 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 章整章都会塌。

  6. 加元数据过滤,看结果被收紧:

    curl -s -G http://localhost:8080/api/v1/vector/search \
      --data-urlencode "q=报销标准是多少" \
      --data-urlencode "category=财务"

    预期:结果里不再出现"销售部差旅标准"之类归属错误的文档。

    对照实验(推荐做):把 category 参数去掉再查一次,你会看到它混进第 2 名——一加一减,元数据过滤的价值立刻可见。

  7. 做一个"阈值"反例:把 similarityThreshold 从 0.4 改成 0.95,重发检索。

    预期:结果几乎变空。再改回 0.5 左右,恢复正常。

    为什么要做:让"阈值过低会进噪声、过高会检索为空"这两件事变成身体记忆——这是后面调 RAG 效果时最常动的两个旋钮之一。

④ 卡住了去哪查(症状 → 定位)

症状先去哪一节一句话提示
extension "vector" is not available4.5 节「第 0 步」坑 A + 4.9扩展文件没放对目录,重启数据库再试
数据库"一会儿就断",postmaster.pid 残留4.5 节「第 0 步」坑 B不要用 Start-Process/& 挂起,用 pg_ctl start
Failed to obtain JDBC Connection / Connection refused4.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 10244.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 Type4.9 排错表改用 --json "@文件"(同时设 Content-Type 与 body)
GET 带中文参数乱码 / 报错4.9 排错表-G + --data-urlencode
No qualifying bean of type VectorStore4.9 排错表检查 starter 与 datasource;pgvector 走标准 datasource
旧教程里的 EmbeddingClient 编译不过4.4 节2.0 已更名 EmbeddingModel
本章「做完」的判据(三条同时成立):① 四个数据库检查项全绿、\d vector_store 里的 vector(N) 与模型维度一致;② 三次检索实验里有两次是"反直觉但必须出现"的结果——不含"到账"二字却命中退款条款、去掉过滤就混进错误部门的文档;③ 能说清一个灌库闭环的三件套:幂等删除 → 分批写入 → 元数据一次写全。