向量数据库

本站(springdoc.cn)中的内容来源于 spring.io ,原始版权归属于 spring.io。由 springdoc.cn 进行翻译,整理。可供个人学习、研究,未经许可,不得进行任何转载、商用或与之相关的行为。 商标声明:Spring 是 Pivotal Software, Inc. 在美国以及其他国家的商标。

向量数据库是一种专用数据库,在 AI 应用中扮演着至关重要的角色。

在向量数据库中,查询与传统关系型数据库不同。它们执行的是相似性搜索而非精确匹配。当给定一个向量作为查询时,向量数据库会返回与查询向量 “相似” 的向量。关于这种相似性如何在高层次上计算的更多细节,请参阅 “向量相似性”。

向量数据库用于将你的数据与 AI 模型集成。使用的第一步是将数据加载到向量数据库中。当需要向 AI 模型发送用户查询时,首先检索一组相似文档。这些文档随后作为用户问题的上下文,与用户查询一起发送给 AI 模型。这种技术被称为 检索增强生成(RAG)

以下部分描述了 Spring AI 接口,用于使用多种向量数据库实现和一些高级示例用法。

最后一部分旨在揭开向量数据库中相似性搜索的底层方法的神秘面纱。

API 概览

本节作为 Spring AI 框架中 VectorStore 接口及其相关类的使用指南。

Spring AI 通过 VectorStore 接口提供与向量数据库交互的抽象 API。

VectorStore 接口定义如下:

public interface VectorStore extends DocumentWriter {

    default String getName() {
		return this.getClass().getSimpleName();
	}

    void add(List<Document> documents);

    void delete(List<String> idList);

    void delete(Filter.Expression filterExpression);

    default void delete(String filterExpression) { ... };

    List<Document> similaritySearch(String query);

    List<Document> similaritySearch(SearchRequest request);

    default <T> Optional<T> getNativeClient() {
		return Optional.empty();
	}
}

以及相关的 SearchRequest Builder:

public class SearchRequest {

	public static final double SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0;

	public static final int DEFAULT_TOP_K = 4;

	private String query = "";

	private int topK = DEFAULT_TOP_K;

	private double similarityThreshold = SIMILARITY_THRESHOLD_ACCEPT_ALL;

	@Nullable
	private Filter.Expression filterExpression;

    public static Builder from(SearchRequest originalSearchRequest) {
		return builder().query(originalSearchRequest.getQuery())
			.topK(originalSearchRequest.getTopK())
			.similarityThreshold(originalSearchRequest.getSimilarityThreshold())
			.filterExpression(originalSearchRequest.getFilterExpression());
	}

	public static class Builder {

		private final SearchRequest searchRequest = new SearchRequest();

		public Builder query(String query) {
			Assert.notNull(query, "Query can not be null.");
			this.searchRequest.query = query;
			return this;
		}

		public Builder topK(int topK) {
			Assert.isTrue(topK >= 0, "TopK should be positive.");
			this.searchRequest.topK = topK;
			return this;
		}

		public Builder similarityThreshold(double threshold) {
			Assert.isTrue(threshold >= 0 && threshold <= 1, "Similarity threshold must be in [0,1] range.");
			this.searchRequest.similarityThreshold = threshold;
			return this;
		}

		public Builder similarityThresholdAll() {
			this.searchRequest.similarityThreshold = 0.0;
			return this;
		}

		public Builder filterExpression(@Nullable Filter.Expression expression) {
			this.searchRequest.filterExpression = expression;
			return this;
		}

		public Builder filterExpression(@Nullable String textExpression) {
			this.searchRequest.filterExpression = (textExpression != null)
					? new FilterExpressionTextParser().parse(textExpression) : null;
			return this;
		}

		public SearchRequest build() {
			return this.searchRequest;
		}

	}

	public String getQuery() {...}
	public int getTopK() {...}
	public double getSimilarityThreshold() {...}
	public Filter.Expression getFilterExpression() {...}
}

要将数据插入向量数据库,需将其封装在 Document 对象中。Document 类封装来自数据源(如 PDF 或 Word 文档)的内容,包含表示为字符串的文本。它还以键值对形式包含元数据,例如文件名等详细信息。

当插入向量数据库时,文本内容通过嵌入模型(如 Word2VecGLoVEBERT 或 OpenAI 的 text-embedding-ada-002)转换为数值数组(即 float[]),称为向量嵌入(Embedding)。这些模型用于将单词、句子或段落转换为向量嵌入。

向量数据库的作用是存储这些嵌入并促进其相似性搜索,它本身不生成嵌入。要创建向量嵌入,应使用 EmbeddingModel

接口中的 similaritySearch 方法允许检索与给定查询字符串相似的文档。这些方法可通过以下参数进行微调:

  • k:一个整数,指定返回的相似文档的最大数量。这通常被称为 “top K” 搜索或 “K 最近邻”(KNN)。

  • threshold:一个介于 0 到 1 之间的双精度值,值越接近 1 表示相似度越高。例如,默认情况下,如果设置阈值为 0.75,则仅返回相似度高于此值的文档。

  • Filter.Expression:一个用于传递 Fluent DSL(领域特定语言)表达式的类,其功能类似于 SQL 中的 “where” 子句,但仅适用于 Document 的元数据键值对。

  • filterExpression:基于 ANTLR4 的外部 DSL,接受字符串形式的过滤表达式。例如,对于包含 countryyearisActive 等元数据键的情况,可以使用如下表达式:country == 'UK' && year >= 2020 && isActive == true

有关 Filter.Expression 的更多信息,请参阅 “元数据过滤器” 部分。

Schema 初始化

某些向量存储在使用前需要初始化其后端 Schema。默认情况下不会自动初始化。你必须通过传递适当的构造函数参数 boolean 值来选择启用,或者如果使用 Spring Boot,则在 application.propertiesapplication.yml 中将相应的 initialize-schema 属性设置为 true。请查阅你使用的向量存储的文档以获取具体的属性名称。

批处理策略

使用向量存储时,经常需要嵌入大量文档。虽然一次性嵌入所有文档看似简单,但这种方法可能导致问题。嵌入模型按标记(Token)处理文本,并有最大标记限制(通常称为上下文窗口大小)。该限制约束了单次嵌入请求可处理的文本量。尝试单次调用嵌入过多标记会导致错误或截断的嵌入。

为解决此标记(Token)限制,Spring AI 实现了批处理策略。该方法将大量文档分解为适合嵌入模型最大上下文窗口的小批次。批处理不仅解决了标记限制问题,还能提高性能并更高效地利用 API 速率限制。

Spring AI 通过 BatchingStrategy 接口提供此功能,允许根据文档的标记(Token)数进行子批次处理。

BatchingStrategy 接口定义如下:

public interface BatchingStrategy {
    List<List<Document>> batch(List<Document> documents);
}

该接口定义了单个方法 batch,它接收文档列表并返回文档批次列表。

默认实现

Spring AI 提供了名为 TokenCountBatchingStrategy 的默认实现。该策略根据文档的标记数进行批处理,确保每批次不超过计算得出的最大输入标记(Token)数。

TokenCountBatchingStrategy 的关键特性:

  1. 使用 OpenAI 的最大输入标记(Token)数(8191)作为默认上限。

  2. 包含保留百分比(默认为 10%)为潜在开销提供缓冲。

  3. 计算实际最大输入标记(Token)数为:actualMaxInputTokenCount = originalMaxInputTokenCount * (1 - RESERVE_PERCENTAGE)

该策略估算每个文档的标记(Token)数,将其分组为不超过最大输入标记(Token)数的批次,并在单个文档超过此限制时抛出异常。

你还可以自定义 TokenCountBatchingStrategy 以更好地满足特定需求。这可以通过在 Spring Boot 的 @Configuration 类中创建具有自定义参数的新实例来完成。

以下是创建自定义 TokenCountBatchingStrategy Bean 的示例:

@Configuration
public class EmbeddingConfig {
    @Bean
    public BatchingStrategy customTokenCountBatchingStrategy() {
        return new TokenCountBatchingStrategy(
            EncodingType.CL100K_BASE,  // Specify the encoding type
            8000,                      // Set the maximum input token count
            0.1                        // Set the reserve percentage
        );
    }
}

在此配置中:

  1. EncodingType.CL100K_BASE:指定用于标记化的编码类型。该编码类型由 JTokkitTokenCountEstimator 使用,以准确估算标记(Token)数。

  2. 8000:设置最大输入标记数(Token)。该值应小于或等于嵌入模型的最大上下文窗口大小。

  3. 0.1:设置保留百分比。从最大输入标记(Token)数中保留的标记百分比。这为处理过程中潜在的标记(Token)数增加提供了缓冲。

默认情况下,此构造函数使用 Document.DEFAULT_CONTENT_FORMATTER 进行内容格式化,并使用 MetadataMode.NONE 进行元数据处理。如果需要自定义这些参数,可以使用带有额外参数的完整构造函数。

一旦定义,此自定义 TokenCountBatchingStrategy bean 将自动被应用程序中的 EmbeddingModel 实现使用,替换默认策略。

TokenCountBatchingStrategy 内部使用 TokenCountEstimator(特别是 JTokkitTokenCountEstimator)来计算标记(Token)数以进行高效批处理。这确保了基于指定编码类型的准确标记估算。

此外,TokenCountBatchingStrategy 通过允许传入自定义的 TokenCountEstimator 接口实现提供灵活性。此功能使你能够使用针对特定需求定制的标记(Token)计数策略。例如:

TokenCountEstimator customEstimator = new YourCustomTokenCountEstimator();
TokenCountBatchingStrategy strategy = new TokenCountBatchingStrategy(
		this.customEstimator,
    8000,  // maxInputTokenCount
    0.1,   // reservePercentage
    Document.DEFAULT_CONTENT_FORMATTER,
    MetadataMode.NONE
);

使用自动截断功能

一些嵌入模型(如 Vertex AI 文本嵌入)支持 auto_truncate 功能。启用时,模型会静默截断超过最大大小的文本输入并继续处理;禁用时,对于过大的输入会抛出明确错误。

使用自动截断与批处理策略时,必须将批处理策略配置为比模型实际最大值高得多的输入标记(Token)数。这防止批处理策略对大文档抛出异常,允许嵌入模型内部处理截断。

自动截断配置

启用自动截断时,请将批处理策略的最大输入标记(Token)数设置为远高于模型实际限制的值。这样可避免批处理策略对大文档抛出异常,由嵌入模型内部处理截断操作。

以下是配置 Vertex AI 使用自动截断和自定义 BatchingStrategy,并在 PgVectorStore 中应用的示例:

@Configuration
public class AutoTruncationEmbeddingConfig {

    @Bean
    public VertexAiTextEmbeddingModel vertexAiEmbeddingModel(
            VertexAiEmbeddingConnectionDetails connectionDetails) {

        VertexAiTextEmbeddingOptions options = VertexAiTextEmbeddingOptions.builder()
                .model(VertexAiTextEmbeddingOptions.DEFAULT_MODEL_NAME)
                .autoTruncate(true)  // Enable auto-truncation
                .build();

        return new VertexAiTextEmbeddingModel(connectionDetails, options);
    }

    @Bean
    public BatchingStrategy batchingStrategy() {
        // Only use a high token limit if auto-truncation is enabled in your embedding model.
        // Set a much higher token count than the model actually supports
        // (e.g., 132,900 when Vertex AI supports only up to 20,000)
        return new TokenCountBatchingStrategy(
                EncodingType.CL100K_BASE,
                132900,  // Artificially high limit
                0.1      // 10% reserve
        );
    }

    @Bean
    public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel, BatchingStrategy batchingStrategy) {
        return PgVectorStore.builder(jdbcTemplate, embeddingModel)
            // other properties omitted here
            .build();
    }
}

在配置中:

  1. 该嵌入模型已启用自动截断功能,可优雅处理超长输入。

  2. 批处理策略采用人为设定的高标记(Token)限制(132,900),远高于模型实际限制(20,000)。

  3. 向量存储使用配置的嵌入模型和自定义 BatchingStrategy Bean。

实现原理

此方案有效的原因是:

  1. TokenCountBatchingStrategy 会检查单个文档是否超过配置的最大值,若超过则抛出 IllegalArgumentException

  2. 通过在批处理策略中设置极高的限制值,可确保此检查永远不会失败。

  3. 超出模型限制的文档或批次会被嵌入模型的自动截断功能静默截断并处理。

最佳实践

使用自动截断时:

  • 将批处理策略的最大输入标记数至少设置为模型实际限制的 5-10 倍,以避免批处理策略过早出现异常。

  • 监控日志,查看嵌入模型是否发出截断警告(注意:并非所有模型都会记录截断事件)。

  • 评估静默截断对嵌入质量的影响。

  • 使用示例文档进行测试,确保截断后的嵌入仍符合需求。

  • 将此配置记录下来,供未来的维护者使用,因为它是非标准配置。

虽然自动截断能避免错误,但可能导致嵌入不完整。长文档末尾的重要信息可能会丢失。若应用需要嵌入全部内容,请在嵌入前将文档分割为更小的块。

Spring Boot 自动配置

若使用 Spring Boot 自动配置,必须提供自定义的 BatchingStrategy Bean 以覆盖 Spring AI 的默认实现:

@Bean
public BatchingStrategy customBatchingStrategy() {
    // This bean will override the default BatchingStrategy
    return new TokenCountBatchingStrategy(
            EncodingType.CL100K_BASE,
            132900,  // Much higher than model's actual limit
            0.1
    );
}

该 Bean 存在于 Application Context(应用上下文)时,将自动替换所有向量存储使用的默认批处理策略。

自定义实现

虽然 TokenCountBatchingStrategy 提供了健壮的默认实现,但你可以通过 Spring Boot 自动配置定制批处理策略以满足特定需求。

要定制批处理策略,请在 Spring Boot 应用中定义 BatchingStrategy Bean:

@Configuration
public class EmbeddingConfig {
    @Bean
    public BatchingStrategy customBatchingStrategy() {
        return new CustomBatchingStrategy();
    }
}

该自定义 BatchingStrategy 随后将被应用中所有 EmbeddingModel 实现自动采用。

Spring AI 支持的向量存储默认配置使用 TokenCountBatchingStrategy。 目前 SAP Hana 向量存储尚未配置批处理功能。

VectorStore 实现

以下是 VectorStore 接口的可用实现:

未来版本可能会支持更多实现。

如果你需要 Spring AI 支持某个向量数据库,请在 GitHub 上提交 Issue,或者更好的是,提交一个实现该功能的拉取请求(PR)。

VectorStore 实现的详细信息可在本章后续小节中查阅。

使用示例

为向量数据库计算嵌入时,需选择与所使用的高层 AI 模型相匹配的嵌入模型。

例如,使用 OpenAI 的 ChatGPT 时,我们采用 OpenAiEmbeddingModel 和名为 text-embedding-ada-002 的模型。

Spring Boot starter 对 OpenAI 的自动配置会在 Spring 应用上下文中提供 EmbeddingModel 实现,便于依赖注入。

通常以批处理作业方式将数据加载至向量存储:先将数据载入 Spring AI 的 Document 类,再调用 save 方法。

给定一个指向 JSON 源文件的 String 引用(包含需加载至向量数据库的数据),我们使用 Spring AI 的 JsonReader 加载 JSON 中的特定字段,将其分割为小块后传递给向量存储实现。VectorStore 实现会计算嵌入向量,并将 JSON 数据与嵌入向量存储至向量数据库:

  @Autowired
  VectorStore vectorStore;

  void load(String sourceFile) {
            JsonReader jsonReader = new JsonReader(new FileSystemResource(sourceFile),
                    "price", "name", "shortDescription", "description", "tags");
            List<Document> documents = jsonReader.get();
            this.vectorStore.add(documents);
  }

随后,当用户问题传入 AI 模型时,会执行相似性搜索以检索相关文档,这些文档将作为上下文 “填充” 到提示中,与用户问题一并处理。

   String question = <question from user>
   List<Document> similarDocuments = store.similaritySearch(this.question);

可通过 similaritySearch 方法传入额外参数,定义检索文档的数量和相似性搜索的阈值。

元数据过滤器

本节介绍可用于查询结果的各种过滤器。

过滤字符串

你可以将类似 SQL 的过滤表达式作为 String 传递给 similaritySearch 方法的重载之一。

考虑如下示例:

  • "country == 'BG'"

  • "genre == 'drama' && year >= 2020"

  • "genre in ['comedy', 'documentary', 'drama']"

Filter.Expression

你可以使用 FilterExpressionBuilder 创建一个 Filter.Expression 实例,该 Builder 提供了 Fluent 式 API。简单示例如下:

FilterExpressionBuilder b = new FilterExpressionBuilder();
Expression expression = this.b.eq("country", "BG").build();

你可以使用以下运算符构建复杂的表达式:

EQUALS: '=='
MINUS : '-'
PLUS: '+'
GT: '>'
GE: '>='
LT: '<'
LE: '<='
NE: '!='

你可以使用以下运算符组合表达式:

AND: 'AND' | 'and' | '&&';
OR: 'OR' | 'or' | '||';

考虑如下示例:

Expression exp = b.and(b.eq("genre", "drama"), b.gte("year", 2020)).build();

你也可以使用如下操作符:

IN: 'IN' | 'in';
NIN: 'NIN' | 'nin';
NOT: 'NOT' | 'not';

考虑如下示例:

Expression exp = b.and(b.in("genre", "drama", "documentary"), b.not(b.lt("year", 2020))).build();

从向量存储删除文档

向量存储接口提供多种文档删除方法,支持通过特定文档 ID 或过滤表达式移除数据。

根据文档 ID 删除

删除文档的最简单方法是提供文档 ID 列表:

void delete(List<String> idList);

此方法删除 ID 与所提供列表中的 ID 一致的所有文档。如果列表中的任何 ID 在存储中不存在,它将被忽略。

Example usage
// Create and add document
Document document = new Document("The World is Big",
    Map.of("country", "Netherlands"));
vectorStore.add(List.of(document));

// Delete document by ID
vectorStore.delete(List.of(document.getId()));

根据过滤表达式删除

对于更复杂的删除条件,可以使用过滤表达式:

void delete(Filter.Expression filterExpression);

该方法接受一个 Filter.Expression 对象,该对象定义了删除文档的标准。当需要根据元数据属性删除文档时,该方法尤其有用。

Example usage
// Create test documents with different metadata
Document bgDocument = new Document("The World is Big",
    Map.of("country", "Bulgaria"));
Document nlDocument = new Document("The World is Big",
    Map.of("country", "Netherlands"));

// Add documents to the store
vectorStore.add(List.of(bgDocument, nlDocument));

// Delete documents from Bulgaria using filter expression
Filter.Expression filterExpression = new Filter.Expression(
    Filter.ExpressionType.EQ,
    new Filter.Key("country"),
    new Filter.Value("Bulgaria")
);
vectorStore.delete(filterExpression);

// Verify deletion with search
SearchRequest request = SearchRequest.builder()
    .query("World")
    .filterExpression("country == 'Bulgaria'")
    .build();
List<Document> results = vectorStore.similaritySearch(request);
// results will be empty as Bulgarian document was deleted

根据字符串过滤表达式删除

为方便起见,还可以使用基于字符串的过滤表达式删除文档:

void delete(String filterExpression);

该方法在内部将提供的字符串过滤器转换为 Filter.Expression 对象。该方法在使用字符串格式的筛选条件时非常有用。

Example usage
// Create and add documents
Document bgDocument = new Document("The World is Big",
    Map.of("country", "Bulgaria"));
Document nlDocument = new Document("The World is Big",
    Map.of("country", "Netherlands"));
vectorStore.add(List.of(bgDocument, nlDocument));

// Delete Bulgarian documents using string filter
vectorStore.delete("country == 'Bulgaria'");

// Verify remaining documents
SearchRequest request = SearchRequest.builder()
    .query("World")
    .topK(5)
    .build();
List<Document> results = vectorStore.similaritySearch(request);
// results will only contain the Netherlands document

调用删除 API 时的错误处理

所有删除方法在出错时都会抛出异常。

最佳做法是将删除操作封装在 try-catch 块中:

Example usage
try {
    vectorStore.delete("country == 'Bulgaria'");
}
catch (Exception  e) {
    logger.error("Invalid filter expression", e);
}

文档版本控制用例

一个常见的场景是管理文档版本,即在上传文档新版本的同时需要移除旧版本。以下是使用筛选表达式来处理此情况的方法:

Example usage
// Create initial document (v1) with version metadata
Document documentV1 = new Document(
    "AI and Machine Learning Best Practices",
    Map.of(
        "docId", "AIML-001",
        "version", "1.0",
        "lastUpdated", "2024-01-01"
    )
);

// Add v1 to the vector store
vectorStore.add(List.of(documentV1));

// Create updated version (v2) of the same document
Document documentV2 = new Document(
    "AI and Machine Learning Best Practices - Updated",
    Map.of(
        "docId", "AIML-001",
        "version", "2.0",
        "lastUpdated", "2024-02-01"
    )
);

// First, delete the old version using filter expression
Filter.Expression deleteOldVersion = new Filter.Expression(
    Filter.ExpressionType.AND,
    Arrays.asList(
        new Filter.Expression(
            Filter.ExpressionType.EQ,
            new Filter.Key("docId"),
            new Filter.Value("AIML-001")
        ),
        new Filter.Expression(
            Filter.ExpressionType.EQ,
            new Filter.Key("version"),
            new Filter.Value("1.0")
        )
    )
);
vectorStore.delete(deleteOldVersion);

// Add the new version
vectorStore.add(List.of(documentV2));

// Verify only v2 exists
SearchRequest request = SearchRequest.builder()
    .query("AI and Machine Learning")
    .filterExpression("docId == 'AIML-001'")
    .build();
List<Document> results = vectorStore.similaritySearch(request);
// results will contain only v2 of the document

你也可以使用字符串筛选表达式来完成相同的操作:

Example usage
// Delete old version using string filter
vectorStore.delete("docId == 'AIML-001' AND version == '1.0'");

// Add new version
vectorStore.add(List.of(documentV2));

删除文档时的性能考量

  • 当您确切知道需要删除哪些文档时,按 ID 列表删除通常速度更快。

  • 基于筛选的删除可能需要扫描索引以找到匹配的文档;但是,这取决于具体的向量存储实现。

  • 大规模删除操作应分批进行,以避免给系统带来过大压力。

  • 在根据文档属性进行删除时,建议直接使用筛选表达式,而不是先收集 ID。

理解向量