12.向量数据库 (Vector Databases)

58 阅读16分钟

向量数据库 (Vector Databases)

前言

向量数据库是一种专门化的数据库,在 AI 应用程序中发挥着重要作用。

在向量数据库中,查询与传统关系数据库不同。它们不执行精确匹配,而是执行相似性搜索。当给定一个向量作为查询时,向量数据库返回与查询向量"相似"的向量。关于这种相似性在高级别上如何计算的更多详细信息,请参见 向量相似性

向量数据库用于将您的数据与 AI 模型集成。它们的使用的第一步是将您的数据加载到向量数据库中。然后,当用户查询要发送到 AI 模型时,首先检索一组相似的文档。这些文档随后用作用户问题的上下文,并与用户的查询一起发送到 AI 模型。这种技术被称为 检索增强生成(Retrieval Augmented Generation,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 构建器:

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 文档)的内容,并包含表示为字符串的文本。它还包含键值对形式的元数据,包括文件名等详细信息。

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

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

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

  • k:一个整数,指定要返回的相似文档的最大数量。这通常被称为"top K"搜索或"K 最近邻"(KNN)。
  • threshold:一个从 0 到 1 的双精度值,越接近 1 表示相似性越高。默认情况下,如果您设置阈值为 0.75,例如,只有相似度高于此值的文档才会被返回。
  • Filter.Expression:一个类,用于传递流畅的 DSL(域特定语言)表达式,其功能类似于 SQL 中的 "where" 子句,但它仅适用于 Document 的元数据键值对。
  • filterExpression:基于 ANTLR4 的外部 DSL,接受字符串形式的过滤表达式。例如,对于 country、year 和 isActive 等元数据键,您可以使用诸如:country == 'UK' && year >= 2020 && isActive == true 的表达式。

元数据过滤器 部分查找关于 Filter.Expression 的更多信息。

模式初始化

一些向量存储需要在后端使用前初始化其模式。默认情况下不会为您初始化。您必须选择加入,通过为适当的构造函数参数传递 boolean,或者如果使用 Spring Boot,在 application.propertiesapplication.yml 中将适当的 initialize-schema 属性设置为 true。请查看您使用的向量存储的文档以了解具体的属性名称。

批处理策略

在使用向量存储时,通常需要嵌入大量文档。虽然一次性调用嵌入所有文档看起来很简单,但这种方法可能会导致问题。嵌入模型将文本作为令牌(token)处理,并具有最大令牌限制,通常称为上下文窗口大小。此限制限制了可以在单个嵌入请求中处理的文本量。尝试在一次调用中嵌入太多令牌可能导致错误或截断的嵌入。

为了解决这个令牌限制问题,Spring AI 实现了批处理策略。这种方法将大量文档分解为适合嵌入模型最大上下文窗口的较小批次。批处理不仅解决了令牌限制问题,还可以带来性能提升和更有效的 API 速率限制使用。

Spring AI 通过 BatchingStrategy 接口提供此功能,该接口允许根据文档的令牌计数在子批次中处理文档。

核心 BatchingStrategy 接口定义如下:

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

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

默认实现

Spring AI 提供了一个名为 TokenCountBatchingStrategy 的默认实现。此策略根据文档的令牌计数对文档进行批处理,确保每个批次不超过计算的最大输入令牌计数。

TokenCountBatchingStrategy 的主要特点:

  1. 使用 OpenAI 的最大输入令牌计数(8191)作为默认上限。
  2. 融入一个保留百分比(默认 10%)以为潜在开销提供缓冲。
  3. 计算实际的最大输入令牌计数为:actualMaxInputTokenCount = originalMaxInputTokenCount * (1 - RESERVE_PERCENTAGE)

该策略估计每个文档的令牌计数,将它们分组为不超过最大输入令牌计数的批次,如果单个文档超过此限制则抛出异常。

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

以下是如何创建自定义 TokenCountBatchingStrategy bean 的示例:

@Configuration
public class EmbeddingConfig {
    @Bean
    public BatchingStrategy customTokenCountBatchingStrategy() {
        return new TokenCountBatchingStrategy(
            EncodingType.CL100K_BASE,  // 指定编码类型
            8000,                      // 设置最大输入令牌计数
            0.1                        // 设置保留百分比
        );
    }
}

在此配置中:

  1. EncodingType.CL100K_BASE:指定用于令牌化的编码类型。此编码类型由 JTokkitTokenCountEstimator 用于准确估计令牌计数。
  2. 8000:设置最大输入令牌计数。此值应小于或等于嵌入模型的最大上下文窗口大小。
  3. 0.1:设置保留百分比。从最大输入令牌计数中保留的令牌百分比。这为处理期间潜在的令牌计数增加创建缓冲。

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

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

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

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

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 功能。启用时,模型会静默截断超过最大大小的文本输入并继续处理;禁用时,它会为过大的输入抛出明确的错误。

将自动截断与批处理策略一起使用时,您必须配置批处理策略的输入令牌计数远高于模型的实际最大值。这可以防止批处理策略为大文档引发异常,允许嵌入模型在内部处理截断。

自动截断的配置

启用自动截断时,将批处理策略的最大输入令牌计数设置得远高于模型的实际限制。这可以防止批处理策略为大文档引发异常,允许嵌入模型在内部处理截断。

以下是将 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)  // 启用自动截断
                .build();

        return new VertexAiTextEmbeddingModel(connectionDetails, options);
    }

    @Bean
    public BatchingStrategy batchingStrategy() {
        // 只有在嵌入模型中启用自动截断时才使用高令牌限制。
        // 设置比模型实际支持的高得多的令牌计数
        // (例如,当 Vertex AI 仅支持高达 20,000 时设置为 132,900)
        return new TokenCountBatchingStrategy(
                EncodingType.CL100K_BASE,
                132900,  // 人为高限制
                0.1      // 10% 保留
        );
    }

    @Bean
    public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel, BatchingStrategy batchingStrategy) {
        return PgVectorStore.builder(jdbcTemplate, embeddingModel)
            // 此处省略其他属性
            .build();
    }
}

在此配置中:

  1. 嵌入模型启用了自动截断,允许它优雅地处理超大输入。
  2. 批处理策略使用人为的高令牌限制(132,900),远大于实际模型限制(20,000)。
  3. 向量存储使用配置的嵌入模型和自定义 BatchingStrategy bean。
为什么这有效

这种方法有效的原因是:

  1. TokenCountBatchingStrategy 检查是否有任何单个文档超过配置的最大值,如果超过则抛出 IllegalArgumentException
  2. 通过在批处理策略中设置非常高的限制,我们确保此检查永远不会失败。
  3. 超过模型限制的文档或批次通过嵌入模型的自动截断功能被静默截断和处理。
最佳实践

使用自动截断时:

  • 将批处理策略的最大输入令牌计数设置为模型实际限制的至少 5-10 倍,以避免批处理策略的过早异常。
  • 监控嵌入模型的截断警告日志(注意:并非所有模型都会记录截断事件)。
  • 考虑静默截断对嵌入质量的影响。
  • 使用示例文档进行测试,以确保截断的嵌入仍然满足您的要求。
  • 为未来的维护者记录此配置,因为它是不标准的。

注意

虽然自动截断可以防止错误,但可能导致不完整的嵌入。长文档末尾的重要信息可能会丢失。如果您的应用程序需要嵌入所有内容,请在嵌入前将文档分解为更小的块。

Spring Boot 自动配置

如果您使用 Spring Boot 自动配置,必须提供自定义 BatchingStrategy bean 来覆盖 Spring AI 附带的默认 bean:

@Bean
public BatchingStrategy customBatchingStrategy() {
    // 这个 bean 将覆盖默认的 BatchingStrategy
    return new TokenCountBatchingStrategy(
            EncodingType.CL100K_BASE,
            132900,  // 远高于模型的实际限制
            0.1
    );
}

应用程序上下文中此 bean 的存在将自动替换所有向量存储使用的默认批处理策略。

自定义实现

虽然 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 上提交问题,或者更好的,提交带有实现的拉取请求。

每个 VectorStore 实现的信息都可以在本章的小节中找到。

示例用法

要为向量数据库计算嵌入,您需要选择与使用的高级 AI 模型匹配的嵌入模型。

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

OpenAI 的 Spring Boot 启动器的自动配置使 EmbeddingModel 的实现可用于 Spring 应用程序上下文进行依赖注入。

将数据加载到向量存储的一般用法是您会在批处理作业中执行的操作,首先将数据加载到 Spring AI 的 Document 类中,然后调用 save 方法。

给定一个指向源文件的 String 引用,该文件表示包含要加载到向量数据库的数据的 JSON 文件,我们使用 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 = <来自用户的问题>
   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 的实例,该构建器暴露了流畅的 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();

从向量存储中删除文档

Vector Store 接口提供了多种删除文档的方法,允许您通过特定的文档 ID 或使用过滤表达式来删除数据。

通过文档 ID 删除

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

void delete(List<String> idList);

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

示例用法

// 创建并添加文档
Document document = new Document("The World is Big",
    Map.of("country", "Netherlands"));
vectorStore.add(List.of(document));

// 通过 ID 删除文档
vectorStore.delete(List.of(document.getId()));

通过过滤表达式删除

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

void delete(Filter.Expression filterExpression);

此方法接受 Filter.Expression 对象,该对象定义了应该删除哪些文档的条件。当您需要根据文档的元数据属性删除文档时,这特别有用。

示例用法

// 创建具有不同元数据的测试文档
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));

// 使用过滤表达式删除来自保加利亚的文档
Filter.Expression filterExpression = new Filter.Expression(
    Filter.ExpressionType.EQ,
    new Filter.Key("country"),
    new Filter.Value("Bulgaria")
);
vectorStore.delete(filterExpression);

// 使用搜索验证删除
SearchRequest request = SearchRequest.builder()
    .query("World")
    .filterExpression("country == 'Bulgaria'")
    .build();
List<Document> results = vectorStore.similaritySearch(request);
// 结果将为空,因为保加利亚文档已被删除

通过字符串过滤表达式删除

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

void delete(String filterExpression);

此方法在内部将提供的字符串过滤器转换为 Filter.Expression 对象。当您有字符串格式的过滤条件时,这很有用。

示例用法

// 创建并添加文档
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));

// 使用字符串过滤器删除保加利亚文档
vectorStore.delete("country == 'Bulgaria'");

// 验证剩余文档
SearchRequest request = SearchRequest.builder()
    .query("World")
    .topK(5)
    .build();
List<Document> results = vectorStore.similaritySearch(request);
// 结果将只包含荷兰文档

调用删除 API 时的错误处理

所有删除方法在发生错误时都可能抛出异常:

最佳实践是将删除操作包装在 try-catch 块中:

示例用法

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

文档版本控制用例

一个常见的场景是管理文档版本,您需要在删除旧版本的同时上传文档的新版本。以下是使用过滤表达式处理此问题的方法:

示例用法

// 创建具有版本元数据的初始文档(v1)
Document documentV1 = new Document(
    "AI and Machine Learning Best Practices",
    Map.of(
        "docId", "AIML-001",
        "version", "1.0",
        "lastUpdated", "2024-01-01"
    )
);

// 将 v1 添加到向量存储
vectorStore.add(List.of(documentV1));

// 创建同一文档的更新版本(v2)
Document documentV2 = new Document(
    "AI and Machine Learning Best Practices - Updated",
    Map.of(
        "docId", "AIML-001",
        "version", "2.0",
        "lastUpdated", "2024-02-01"
    )
);

// 首先,使用过滤表达式删除旧版本
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);

// 添加新版本
vectorStore.add(List.of(documentV2));

// 验证只有 v2 存在
SearchRequest request = SearchRequest.builder()
    .query("AI and Machine Learning")
    .filterExpression("docId == 'AIML-001'")
    .build();
List<Document> results = vectorStore.similaritySearch(request);
// 结果将只包含文档的 v2

您也可以使用字符串过滤表达式完成相同的操作:

示例用法

// 使用字符串过滤器删除旧版本
vectorStore.delete("docId == 'AIML-001' AND version == '1.0'");

// 添加新版本
vectorStore.add(List.of(documentV2));

删除文档时的性能考虑

  • 当您确切知道要删除哪些文档时,通过 ID 列表删除通常更快。
  • 基于过滤器的删除可能需要扫描索引以查找匹配的文档;然而,这取决于向量存储的实现。
  • 大型删除操作应该进行批处理以避免系统过载。
  • 当基于文档属性而不是首先收集 ID 删除时,考虑使用过滤表达式。

理解向量

理解向量