Spring AI 源码解析(四):Embedding 与 VectorStore 实现原理

做 AI 应用绕不开 RAG,做 RAG 绕不开 EmbeddingVectorStore。这两者是 RAG 的数据基石——Embedding 负责"理解"语义,VectorStore 负责"记忆"内容。

EmbeddingModel 接口

三个重载方法层层递进:从单文本到单文档到批量请求。实际项目中大部分时候用第三个,批量效率高。

public interface EmbeddingModel {
    List<Double> embed(String text);
    default List<Double> embed(Document document) {
        return embed(document.getContent());
    }
    EmbeddingResponse embed(EmbeddingRequest request);
}

OpenAiEmbeddingModel 核心实现

public class OpenAiEmbeddingModel implements EmbeddingModel {
    private final OpenAiApi openAiApi;
    private final String model;
    private final int batchSize;

    public OpenAiEmbeddingModel(OpenAiApi openAiApi, OpenAiEmbeddingOptions options) {
        this.openAiApi = openAiApi;
        this.model = options.getModel() != null
            ? options.getModel() : "text-embedding-3-small";
        this.batchSize = options.getBatchSize() != null
            ? options.getBatchSize() : DEFAULT_BATCH_SIZE;
    }

    @Override
    public EmbeddingResponse embed(EmbeddingRequest request) {
        List<List<String>> batches = batchTexts(request.getTexts(), this.batchSize);
        List<Embedding> allEmbeddings = new ArrayList<>();
        for (List<String> batch : batches) {
            EmbeddingResponse response = doEmbedBatch(batch);
            allEmbeddings.addAll(response.getEmbeddings());
        }
        return new EmbeddingResponse(allEmbeddings);
    }
}

关键参数 batchSize——控制每次 API 调用发送多少文本。

batchSize 每次耗时 1000 段文本总耗时 失败率
1 ~300ms ~5min <0.1%
10 ~500ms ~50s 0.5%
20 ~800ms ~40s 1%
50 ~2s ~40s 5%
100 ~4s ~40s 15%

默认值是 20 左右,从稳定性角度这个值选得挺合理。如果你在批处理大量文档,可以适当调大到 30-50,但要做好重试的兜底,因为 batch 越大越容易超时。

模型默认用的是 text-embedding-3-small。这个模型性价比很高,1536 维向量足够大部分场景用。如果对精度要求特别高,可以切到 text-embedding-3-large(3072 维),但向量存储和检索的成本也会翻倍。

VectorStore 接口

public interface VectorStore {
    void add(List<Document> documents);
    void delete(List<String> idList);
    List<Document> similaritySearch(SearchRequest searchRequest);
    default List<Document> similaritySearch(String query) {
        return similaritySearch(SearchRequest.query(query));
    }
}
}

四个核心方法:增、删、查、快捷查。Spring AI 提供了多种实现:

- **PgvectorStore**PostgreSQL + pgvector 扩展)
- **RedisVectorStore**Redis + RedisSearch 模块)
- **ElasticsearchVectorStore**Elasticsearch- **ChromaVectorStore**Chroma- **MilvusVectorStore**Milvus- **SimpleVectorStore**(本地文件,测试用)

我三个主流的都用过:

| 实现 | 优势 | 劣势 | 适用场景 |
|------|------|------|---------|
| **PgvectorStore** | 数据不丢失,事务支持,与业务数据同库 | 查询性能受限于 PostgreSQL | 中小规模生产环境 |
| **RedisVectorStore** | 查询速度极快 | 数据可能丢失,需要额外持久化方案 | 缓存、高频检索 |
| **SimpleVectorStore** | 零依赖、测试方便 | 重启后数据丢失 | 开发测试 |

如果你的项目已经在用 PostgreSQL**无脑选 PgvectorStore**。少维护一套中间件,省心很多。

## PgvectorStore 源码分析

### 文档入库

```java
@Override
public void add(List<Document> documents) {
    List<String> texts = documents.stream()
        .map(Document::getContent)
        .collect(Collectors.toList());
    EmbeddingResponse embeddingResponse =
        embeddingModel.embed(new EmbeddingRequest(texts, ""));
    List<Embedding> embeddings = embeddingResponse.getEmbeddings();

    String sql = """
        INSERT INTO vector_store (id, content, metadata, embedding)
        VALUES (?, ?, ?::jsonb, ?::vector)
        ON CONFLICT (id) DO UPDATE SET
            content = EXCLUDED.content,
            metadata = EXCLUDED.metadata,
            embedding = EXCLUDED.embedding
        """;
    // batchUpdate ...
}

add() 方法做了两件事:先调 Embedding 生成向量,再写入数据库。你传入的 Document 列表,内部被批量 Embedding 后,每条对应一行记录。

注意 ON CONFLICT 的 upsert 语义——如果 Document 的 id 已存在,就覆盖。这个设计我在做增量更新时用到过:从数据库里读一批数据做 Embedding,如果文档内容变了,覆盖旧的向量就行,不需要先删再插。

相似度检索

@Override
public List<Document> similaritySearch(SearchRequest searchRequest) {
    List<Double> queryVector = embeddingModel.embed(searchRequest.getQuery());
    String sql = """
        SELECT id, content, metadata,
               1 - (embedding <=> ?::vector) AS similarity
        FROM vector_store
        WHERE 1 - (embedding <=> ?::vector) >= ?
        ORDER BY similarity DESC
        LIMIT ?
        """;
    return jdbcTemplate.query(sql, new DocumentRowMapper(),
        vectorStr, vectorStr, threshold, topK);
}

<=> 是 pgvector 的余弦距离运算符。1 - 距离 转成相似度,值范围 [0, 1],越接近 1 表示越相似。

实际使用中调参经验:

topK(返回条数):默认通常是 4,但真正有效的往往只有前 1-2 条。如果对召回率要求高,设到 10-20 都行。

similarityThreshold(相似度阈值):这个参数很敏感。设太低了(如 0.5)会返回一堆不相关的文档;设太高了(如 0.9)可能一个都搜不到。我的经验:

场景 推荐阈值
技术文档问答(代码、API) 0.75 - 0.85
开放域问答(百科类) 0.65 - 0.75
闲聊、泛对话 0.5 - 0.6
刚开始调试不确定 先设 0.0 看全部结果,再逐步调高

索引策略:HNSW vs IVFFlat

Pgvector 支持两种索引类型,Spring AI 通过配置暴露:

public class PgvectorStoreProperties {
    private IndexType indexType = IndexType.HNSW;
    private int m = 16;         // HNSW 的最大连接数
    private int efConstruction = 64;  // HNSW 的动态列表大小
    private int listCount = 100;      // IVFFlat 的列表数
}

HNSW 默认。选 HNSW 的理由:

维度 HNSW IVFFlat
查询速度 更快 较慢
建索引时间 较慢
内存占用 较高
精度 受 listCount 影响

我的建议:生产环境用 HNSW,开发环境用 IVFFlat。HNSW 的查询速度和精度都更好,虽然建索引慢一点,但建完之后优势明显。如果你的数据量很小(几千条),甚至可以不建索引,全表扫描就够快了。

数据类型:Document

public class Document {
    private String id;
    private String content;
    private Map<String, Object> metadata;
}
}

这个类看起来简单,但用好了 metadata 字段能省很多事。我常用的 metadata 使用模式:

**给文档打标签,检索时过滤**:
```java
Document doc = new Document("Spring AI 支持多种模型",
    Map.of("category", "技术文档", "lang", "zh", "version", "2.0"));

然后在检索时可以加上过滤条件,但注意——PgvectorStore 默认的 SQL 里没有 metadata 过滤。如果你需要按 metadata 字段过滤,需要自己扩展 SQL。Spring AI 的新版本已经支持了 Filter 表达式,可以通过 SearchRequest 传入过滤条件。

Embedding 与 VectorStore 的协作

整个流程:

入库:文本 → EmbeddingModel.embed() → 向量 + 原文 → VectorStore.add()
检索:问题 → EmbeddingModel.embed() → 向量 → VectorStore.similaritySearch() → 相关文档

两边的流程是对称的——入和查都要先过 Embedding。这意味着 Embedding 模型的稳定性直接影响检索效果。如果某天你换了Embedding 模型(比如从 text-embedding-3-small 切到 large),以前入库的向量和新生成的向量不兼容,需要重新入库。
Embedding 模型一旦确定,别轻易换。换了就全量重新入库。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐