Spring AI 2.0 学习指南企业级智能应用开发实践 环境准备 速查 ⌂ 全部教程
首页 / Spring AI 指南 / 第 4 章

CH 04嵌入模型与向量数据库

这一章是整门课唯一涉及数学概念的章节,但请放心——你不需要会推导公式,只需要建立一个直觉: 把文本丢进一个函数,出来一串数字;语义相近的文本,出来的数字串也相近。向量的本质就是「把意思变成坐标」。

学习目标
理解向量化与相似检索
前置要求
第 1 章 + 已启动 pgvector
本章产出
文档向量索引(可检索)
建议学时
4–5 小时

4.1Embedding 到底在做什么:从「关键词匹配」到「语义匹配」

先看一个经典问题。用户问:「怎么把钱退回来?」 而你的知识库里写的是:「退款申请流程说明」。这两句话没有一个字相同,用传统的关键词检索(MySQL 的 LIKE '%退款%' 或 Elasticsearch 的倒排索引)都匹配不到。但人一眼就知道它们讲的是同一件事。

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 的地基。

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

模型维度部署方式特点与建议
text-embedding-3-small1536OpenAI API便宜、够用,课程默认(但需网络可达)
text-embedding-3-large3072OpenAI API效果更好,成本和存储翻倍,中文场景提升有限
text-embedding-v3(通义)1024DashScope API国内可用性好,中文效果不错,推荐国内同学使用
embedding-3(智谱)2048API国内可选,维度可自定义
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),可以省存储、提速,但会略微损失精度。降维后同样要统一。

4.3相似度算法与索引类型

三种距离度量

算法Spring AI 枚举直觉理解适用
余弦距离 COSINE_DISTANCE 只看方向,忽略长度。文本检索的标准选择。 ✅ 默认用这个
欧氏距离 EUCLIDEAN_DISTANCE 看绝对位置的远近。受向量长度影响大。 图像/数值向量
内积 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 检索」的召回率对比实验。

4.4EmbeddingModel:把文本变成数字的 API

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 条一批。

4.5向量数据库选型与配置

方案优势劣势课程定位
pgvector
推荐入门
复用已有 PostgreSQL,运维成本极低;支持 SQL + 向量混合查询;支持元数据过滤与事务 千万级以上性能不如专用向量库 第 4、5、11 章的主用方案
Milvus 专为向量设计,十亿级规模,索引类型丰富,分布式可扩展 组件多(etcd + minio),资源占用大,运维复杂 用于理解「专用向量库」的差异
Redis(RedisSearch) 已有 Redis 就能用,速度快,适合做缓存层 持久化与大规模能力弱于前两者 延伸阅读
Elasticsearch / OpenSearch 可与 BM25 关键词检索做混合检索(Hybrid Search) 向量能力相对弱,集群重 混合检索章节提及
SimpleVectorStore(内存) 零依赖,写到内存里,启动即用 重启即丢,无法扩展 写单元测试时非常好用

pgvector 依赖与配置

<!-- pom.xml:pgvector 的 Spring AI starter(注意它会传递引入 postgresql 驱动) -->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
# application.yml —— pgvector 完整配置(每一项都有注释)
spring:
  datasource:                              # ★ VectorStore 用的就是这个数据源
    url: jdbc:postgresql://localhost:5432/vectordb
    username: ai
    password: ai123456
    driver-class-name: org.postgresql.Driver

  ai:
    vectorstore:
      pgvector:
        # 是否自动建表 / 建扩展。首次启动 true,后续可设 false 加快启动
        initialize-schema: true
        # 表名。默认为 vector_store,改这里要同步改 
        table-name: vector_store
        # 索引类型:HNSW(推荐)/ IVFFlat / NONE(精确检索)
        index-type: HNSW
        # 距离类型,必须与灌库时一致
        distance-type: COSINE_DISTANCE
        # ★★★ 维度必须等于 embedding 模型的输出维度,写错必报错
        dimensions: 1536
        # 单次批量写入上限,文档多时自动分批
        max-document-batch-size: 10000
        # 搜索时默认返回条数(可被 SearchRequest 覆盖)
        # 若使用外部已建好的库,可关掉自动初始化:
        # schema-validation: true
      # 同时启用了 OpenAI embedding 时,会自动用 OpenAI 生成向量
    openai:
      api-key: ${OPENAI_API_KEY:}
      base-url: https://api.openai.com         # embedding 建议用 OpenAI 或通义,DeepSeek 无此能力
      embedding:
        options:
          model: text-embedding-3-small        # 输出 1536 维,与上面 dimensions 对应
维度不匹配的报错长这样: ERROR: expected 1536 dimensions, not 1024(pgvector)或 Collection schema dimension mismatch(Milvus)。 看到这个错误,不要改代码,先检查三处是否一致:建表维度、模型的输出维度、yml 里的 dimensions。

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: 1536
        # 索引类型: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..enabled: false), 再在配置类里手动 new。第 11 章会给出「主库 + 备库」的完整写法。

4.6VectorStore:写入、删除、检索

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

写入与删除

@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 详解(本章最重要的一段)

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 章会系统化。

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 章会直接复用。

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

① 灌库服务

package com.example.springai.ch04;

import lombok.extern.slf4j.Slf4j;
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;

@Slf4j
@Service
public class DocumentIndexService {

    /** 单批写入条数。太大易触发厂商限流,太小效率低。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());
        }
    }
}

② 检索服务

@Slf4j
@Service
public class VectorSearchService {

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

@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);
    }

    public record IndexRequest(String source, String category, List<String> chunks) {}
    public record SearchResult(String content, Double score, String source, String category) {}
}

验证步骤(三条命令走完整个闭环):

# ① 灌库:三条售后知识
curl -X POST http://localhost:8080/api/v1/vector/index \
  -H "Content-Type: application/json" \
  -d '{
    "source":"refund-policy.md",
    "category":"售后",
    "chunks":[
      "退款申请需在订单完成后 7 天内提交,超期需联系客服人工审核。",
      "退款将在审核通过后 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。"
    ]}'

# ③ 语义检索:故意用"没有一个字相同"的问法
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=技术(很可能为空),
#       证明过滤条件真的缩小了检索范围
第 ③ 步是这个实验的灵魂。如果它没命中,说明向量模型的中文能力不足(换成 text-embedding-v3 或 nomic-embed-text 再试), 或者你的阈值设得太高。这一步跑通,第 5 章的 RAG 就是水到渠成。

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 扩展没装(用了普通 postgres 镜像)镜像必须用 pgvector/pgvector:pg16,并执行 CREATE EXTENSION vector
检索结果永远为空阈值太高,或查询语言与库内容语言不匹配先把 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

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 计时;失败的分片收集起来最后统一重试,避免单条失败导致整批回滚。

本章验收标准:
  • 能用自己的话说清 Embedding 在做什么,并解释「为什么关键词匹配做不到、语义匹配可以」。
  • 能说出维度、距离算法、索引类型三个配置项的作用和相互约束。
  • 三个 curl 实验全部通过,特别是「钱什么时候能到账」能命中退款条款。
  • 能设计一份合格的元数据 schema(含 source / page / category / tenantId)。
  • 知道「换模型必须重灌库」以及「阈值必须调」这两条铁律。