主题
向量数据库
向量数据库是一类专门的数据库,在 AI 应用中扮演着至关重要的角色。
在向量数据库中,查询不同于传统关系型数据库。 它们不做精确匹配,而是执行相似度搜索。 当一个向量作为查询给出时,向量数据库返回与查询向量“similar”的向量。 关于这种相似度如何在高层被计算的更多细节,见向量相似度。
向量数据库用于把你的数据与 AI 模型集成起来。 使用它们的第一步是把数据加载进向量数据库。 然后,当要把一个用户查询发给 AI 模型时,会先检索出一组相似的文档。 这些文档随后充作用户问题的上下文,连同用户的查询一起被发给 AI 模型。 这一技术称为检索增强生成(RAG)。
以下各节描述用于多种向量库实现的 Spring AI 接口以及一些高层示例用法。
最后一节旨在揭示向量数据库中相似度搜索底层方法的神秘面纱。
API 概览
本节用作 Spring AI 框架内 VectorStore 接口及其相关类的指南。
Spring AI 通过 VectorStore 接口及其只读对应接口 VectorStoreRetriever 接口,提供一个与向量数据库交互的抽象 API。
VectorStoreRetriever 接口
Spring AI 提供一个名为 VectorStoreRetriever 的只读接口,它只暴露文档检索功能:
java
@FunctionalInterface
public interface VectorStoreRetriever {
List<Document> similaritySearch(SearchRequest request);
default List<Document> similaritySearch(String query) {
return this.similaritySearch(SearchRequest.builder().query(query).build());
}
}这个函数式接口专为那些只需从向量库检索文档、而不执行任何变更操作的用例设计。它遵循最小权限原则,只暴露文档检索所需的功能。
VectorStore 接口
VectorStore 接口继承 VectorStoreRetriever 并添加变更能力:
java
public interface VectorStore extends DocumentWriter, VectorStoreRetriever {
default String getName() {
return this.getClass().getSimpleName();
}
void add(List<Document> documents);
default void upsert(List<EmbeddedDocument> entries) { ... }
void delete(List<String> idList);
void delete(Filter.Expression filterExpression);
default void delete(String filterExpression) { ... }
default <T> Optional<T> getNativeClient() {
return Optional.empty();
}
}VectorStore 接口同时结合了读与写操作,让你能在向量数据库中添加、删除和搜索文档。 add 会为你计算嵌入,而 upsert 写入你自己提供的嵌入;参见 写入用户提供的嵌入。
SearchRequest 构建器
java
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[],称为向量嵌入。嵌入模型,如 Word2Vec、GLoVE 和 BERT,或 OpenAI 的 text-embedding-ada-002,用于把词、句或段落转换成这些向量嵌入。
向量数据库的角色是为这些嵌入存储并提供相似度搜索便利。它本身不生成嵌入。要创建向量嵌入,应使用 EmbeddingModel。
接口中的 similaritySearch 方法允许检索与给定查询字符串相似的文档。这些方法可以用以下参数来精调:
k:一个指定要返回的最大相似文档数的整数。这常被称为“前 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 时)把相应的 initialize-schema 属性在 application.properties 或 application.yml 中设为 true。 具体属性名请查阅你所用向量库的文档。
批处理策略
在处理向量库时,常常需要对大量文档做嵌入。 尽管一次性调用嵌入所有文档看似直接,这种方式却可能带来问题。 嵌入模型把文本当作令牌处理,并有最大令牌限制,常称为上下文窗口大小。 该限制约束了单次嵌入请求能处理的文本量。试图在一次调用中嵌入太多令牌会导致错误或截断的嵌入。
为应对这个令牌限制,Spring AI 实现了一种批处理策略。 这种方式把大批文档拆分成适合嵌入模型最大上下文窗口的更小批次。批处理不仅解决了令牌限制问题,还能带来性能提升和对 API 速率限制更高效的使用。
Spring AI 通过 BatchingStrategy 接口提供这一功能,它允许依据令牌数把文档分批处理。
核心的 BatchingStrategy 接口定义如下:
java
public interface BatchingStrategy {
List<List<Document>> batch(List<Document> documents);
}该接口定义了单个方法 batch,它接收一个文档列表并返回一个文档批次列表。
默认实现
Spring AI 提供一个名为 TokenCountBatchingStrategy 的默认实现。 该策略依据令牌数对文档分批,确保每个批次不超过一个计算得出的最大输入令牌数。
TokenCountBatchingStrategy 的主要特性:
- 使用 OpenAI 的最大输入令牌数(8191)作为默认上限。
- 纳入一个预留百分比(默认 10%),为潜在的开销提供缓冲。
- 实际最大输入令牌数按如下计算:
actualMaxInputTokenCount = originalMaxInputTokenCount * (1 - RESERVE_PERCENTAGE)
该策略为每个文档估计令牌数,把它们分组进不超过最大输入令牌数的批次,如果单个文档超过此限制则抛出异常。
你也能定制 TokenCountBatchingStrategy 以更好契合你的特定需求。这可以在一个 Spring Boot @Configuration 类中通过用自定义参数创建一个新实例来完成。
下面演示如何创建一个自定义 TokenCountBatchingStrategy bean:
java
@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
);
}
}在此配置中:
EncodingType.CL100K_BASE:指定用于分词的编码类型。JTokkitTokenCountEstimator用此编码类型来准确估计令牌数。8000:设置最大输入令牌数。该值应小于或等于你嵌入模型的最大上下文窗口大小。0.1:设置预留百分比。即从最大输入令牌数中预留的令牌百分比。它为处理期间令牌数潜在增长创建缓冲。
默认情况下,该构造器使用 Document.DEFAULT_CONTENT_FORMATTER 做内容格式化、MetadataMode.NONE 做元数据处理。若你需要定制这些参数,可以使用带额外参数的完整构造器。
一旦定义,这个自定义 TokenCountBatchingStrategy bean 就会被你应用中的 EmbeddingModel 实现自动使用,取代默认策略。
TokenCountBatchingStrategy 内部使用一个 TokenCountEstimator(具体是 JTokkitTokenCountEstimator)来计算令牌数以高效分批。这确保基于指定编码类型的准确令牌估计。
此外,TokenCountBatchingStrategy 通过允许你传入自己的 TokenCountEstimator 接口实现来提供灵活性。这一特性让你能使用量身定制的自定义令牌计数策略。例如:
java
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 特性。启用时,模型会静默截断超过最大尺寸的文本输入并继续处理;禁用时,它对过大的输入抛出明确错误。
在批处理策略配合自动截断使用时,你必须把你的批处理策略配置一个远高于模型实际最大值的最小输入令牌数。这能防止批处理策略对大文档抛出异常,让嵌入模型在内部处理截断。
自动截断的配置
启用自动截断时,把你的批处理策略最大输入令牌数设得远高于模型的实际限制。这能防止批处理策略对大文档抛出异常,让嵌入模型在内部处理截断。
下面是一个配置示例,在 PgVectorStore 中使用带自动截断与自定义 BatchingStrategy 的 Vertex AI,然后使用它们:
java
@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();
}
}在此配置中:
- 嵌入模型启用了自动截断,使它能优雅处理超大输入。
- 批处理策略使用一个人为的高令牌上限(132,900),远大于实际模型限制(20,000)。
- 向量库使用所配置的嵌入模型和自定义
BatchingStrategybean。
为何这可行
这种方式之所以可行,是因为:
TokenCountBatchingStrategy会检查是否有单个文档超过配置的最大值,若有则抛出IllegalArgumentException。- 通过在批处理策略中设一个非常高的限制,我们确保这一检查永不失败。
- 超过模型限制的文档或批次会被静默截断,并由嵌入模型的自动截断特性处理。
最佳实践
使用自动截断时:
- 把批处理策略的最大输入令牌数至少设为模型实际限制的 5-10 倍,以避免批处理策略过早抛异常。
- 留意日志中来自嵌入模型的截断警告(注意:并非所有模型都记录截断事件)。
- 考虑静默截断对嵌入质量的影响。
- 用示例文档测试,以确保截断后的嵌入仍满足你的要求。
- 为未来的维护者记录此配置,因为它非标准。
WARNING
尽管自动截断能防止错误,但它可能导致不完整的嵌入。长文档末尾的重要信息可能丢失。如果你的应用要求嵌入所有内容,请在嵌入前把文档拆分成更小的块。
Spring Boot 自动配置
如果你使用 Spring Boot 自动配置,你必须提供一个自定义 BatchingStrategy bean 来覆盖随 Spring AI 而来的默认那个:
java
@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,会自动替换所有向量库使用的默认批处理策略。
自定义实现
尽管 TokenCountBatchingStrategy 提供了一个稳健的默认实现,你仍可定制批处理策略以契合你的特定需求。 这可以通过 Spring Boot 的自动配置来完成。
要定制批处理策略,在你的 Spring Boot 应用中定义一个 BatchingStrategy bean:
java
@Configuration
public class EmbeddingConfig {
@Bean
public BatchingStrategy customBatchingStrategy() {
return new CustomBatchingStrategy();
}
}这个自定义 BatchingStrategy 随后会被你应用中的 EmbeddingModel 实现自动使用。
NOTE
Spring AI 支持的向量库被配置为使用默认的 TokenCountBatchingStrategy。
向量库实现
以下是 VectorStore 接口的可用实现:
- Azure Vector Search - Azure 向量库。
- Apache Cassandra - Apache Cassandra 向量库。
- Chroma Vector Store - Chroma 向量库。
- Elasticsearch Vector Store - Elasticsearch 向量库。
- GemFire Vector Store - GemFire 向量库。
- MariaDB Vector Store - MariaDB 向量库。
- Milvus Vector Store - Milvus 向量库。
- MongoDB Atlas Vector Store - MongoDB Atlas 向量库。
- Neo4j Vector Store - Neo4j 向量库。
- OpenSearch Vector Store - OpenSearch 向量库。
- Oracle Vector Store - Oracle Database 向量库。
- PgVector Store - PostgreSQL/PGVector 向量库。
- Pinecone Vector Store - Pinecone 向量库。
- Qdrant Vector Store - Qdrant 向量库。
- Redis Vector Store - Redis 向量库。
- Typesense Vector Store - Typesense 向量库。
- Weaviate Vector Store - Weaviate 向量库。
- S3 Vector Store - AWS S3 向量库。
- SimpleVectorStore - 一个向量存储的简单实现,仅适合测试用途。
NOTE
Azure Cosmos DB 向量库支持作为一个由 Azure Cosmos DB 团队维护的外部模块提供。
更多信息见 https://azurecosmosdb.github.io/spring-ai/docs/index.html。
DANGER
SimpleVectorStore 实现并非为生产使用而设计,只应用于测试或演示目的。
未来版本可能支持更多实现。
如果你有一个需要 Spring AI 支持的向量数据库,请在 GitHub 开一个 issue,或更好的是,提一个带实现的 pull request。
关于每个 VectorStore 实现的信息可在本章的各子节中找到。
用法示例
要为向量数据库计算嵌入,你需要挑选一个与所使用的高层 AI 模型相匹配的嵌入模型。
例如,对于 OpenAI 的 ChatGPT,我们使用 OpenAiEmbeddingModel 和一个名为 text-embedding-ada-002 的模型。
OpenAI 的 Spring Boot starter 自动配置会在 Spring 应用上下文中提供一个可依赖注入的 EmbeddingModel 实现。
写入向量库
把数据加载进向量库的一般用法,你会在一个类批处理的任务中完成:先把数据加载进 Spring AI 的 Document 类,然后在 VectorStore 接口上调用 add 方法。
给定一个指向源文件的 String 引用,该文件表示一个含我们要加载进向量数据库之数据的 JSON 文件,我们使用 Spring AI 的 JsonReader 加载 JSON 中的特定字段,把它们切分成小块,然后这些小块被传给向量库实现。VectorStore 实现计算嵌入,并把 JSON 连同嵌入一起存储到向量数据库:
java
@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);
}add 会为你计算嵌入。 如果你已有向量,就改用 upsert 原样写入;参见 写入用户提供的嵌入。
写入用户提供的嵌入
add 会在存储内部为每个文档计算一个嵌入,使用该存储的嵌入模型。 有时你已经有了向量并想原样写入:某服务商的批处理 API 半夜以半价算好了它们,另一个团队拥有嵌入流水线,或者你正从一个同时导出文本与向量的系统迁移。对于这些情况,VectorStore 接口提供了 upsert,它接收 EmbeddedDocument 中文档与其向量配对在一起,且自己绝不做任何嵌入:
java
public record EmbeddedDocument(Document document, float[] embedding) {
}顾名思义,upsert 按 id 替换:再次写入同一个 id 会覆盖既有行而非新增一个重复项。 这使得用稳定 id 重跑是安全的,对失败后可能重试的摄取任务很有用。
java
@Autowired
VectorStore vectorStore;
// a vector you computed elsewhere
float[] embedding = ...;
Document document = new Document("8f14e45f-ceea-467a-9a3e-5b1c2d6f7a90", "some text",
Map.of("source", "manual"));
this.vectorStore.upsert(List.of(new EmbeddedDocument(document, embedding)));NOTE
某些存储会对文档 id 施加约束。
Qdrant 要求一个 UUID,而 PgVectorStore 除非你配置 idType 否则把 id 存在一个 UUID 列中,所以一个任意字符串在那些存储上会失败。从你自己的某个键派生 UUID,例如 UUID.nameUUIDFromBytes(key.getBytes(UTF_8)),能让 id 在各次运行间保持稳定,这正是使重跑替换该行而非新增第二行的关键。
要通过 upsert 写纯文本,先嵌入它再交出结果:
java
@Autowired
EmbeddingModel embeddingModel;
float[] vector = this.embeddingModel.embed(document);
this.vectorStore.upsert(List.of(new EmbeddedDocument(document, vector)));upsert 是按存储选择性支持的:默认实现抛出 UnsupportedOperationException,因此一个存储只有在实现之后才支持它。 它在 pgvector、Redis、Elasticsearch 和 Qdrant 上可用,更多存储将陆续跟进。 EmbeddedDocument 会拒绝一个为空或含 NaN 或无穷大的向量,当向量来自你不控制的流水线时这一点值得了解:否则一个 NaN 会破坏距离运算而非使其失败。 当一个存储知道其索引维度时,会在写入任何内容之前把批次中每个向量与之比对,因此一个尺寸错误的向量会快速失败且不会部分写入。 在维度只能被猜测之处,校验留给数据库,它会用自己的错误拒绝该向量。
由于向量来自存储外部,把它们与索引对齐得由你自己搞定。 三件事必须一致,而只有第一件会替你检查:
宽度:: 你的嵌入模型决定宽度,而索引必须以同样的宽度创建,例如在存储构建器上 dimensions(1024),或一个映射写明 1024 的预创建索引。不匹配会在首次写入时失败。 向量空间:: 没有任何东西记录某行由哪个模型产出,所以由一个模型写入、用另一个模型搜索的向量会返回看似合理却毫无意义的结果,且任何地方都不报错。把模型名与版本写进元数据不费分毫,并告诉后来的读者某行实际装的是什么。 查询侧:: similaritySearch 仍会用存储自己的 EmbeddingModel 对查询做嵌入,因此那个模型必须产出与你所 upsert 的向量处于同一空间、同样宽度的向量。
索引非文本或超大内容
对于文本,存储把文档与其向量保持在同一行:搜索匹配向量并返回文本。多模态模型打破了这一假设。它给你一个可搜索的向量,用于图像、视频片段或音频剪辑,但内容本身位于 S3、CDN 或数据库中,且没有文本可放进该行。同样适用于对一行来说实在太大的文本,例如整篇文章而其块已被索引。
该行随后保留除内容以外的一切:id、元数据、向量,以及一个指向内容所在位置的指针。指针放在 DocumentMetadata.CONTENT_REF 下的元数据中,存储绝不解释它:它是一个 S3 URI、一个 CDN URL 或一个只有你的应用能解析的数据库键。这样的行用 upsert 写入,因为你从多模态模型自己提供向量:
java
byte[] frame = ...; // the image/video/audio bytes
float[] embedding = multimodalModel.embed(frame); // your multimodal model
String key = "video-42/frame-4711";
Document reference = Document.builder()
// Stable and UUID-shaped, so a re-run replaces this row
.id(UUID.nameUUIDFromBytes(key.getBytes(UTF_8)).toString())
.text("") // empty: the content lives elsewhere
.metadata(DocumentMetadata.CONTENT_REF.value(), "s3://media/video-42.mp4#t=4711")
.metadata("camera", "north-gate") // still filterable like any row
.build();
this.vectorStore.upsert(List.of(new EmbeddedDocument(reference, embedding)));NOTE
空文本才是让文档合法的东西;它不是内容。一个携带 CONTENT_REF 的文档尽管 isText() 报告为真,却是一个引用行。upsert 正因如此要求文本:没有存储能持久化一个不含文本的行,所以一个媒体文档会被拒绝而非半写入。
这个指针是一个普通的元数据条目。框架中没有任何东西读取或校验它,所以保持它非空得靠你自己。一个仍有其自身真实文本的行可以用 add 写入,这正是一个摘要或块保留回指整篇文章之指针的方式:
java
Document chunk = Document.builder()
.text(chunkText) // real text, so add can embed it
.metadata(DocumentMetadata.CONTENT_REF.value(), "articles/1042") // where the full article lives
.build();
this.vectorStore.add(List.of(chunk));只有空文本的行必须经由 upsert,因为 add 会对文本做嵌入而那时没有什么可嵌入。
搜索不变。similaritySearch 匹配向量并一如既往地返回 Document,对这些行,调用方跟随指针:
java
List<Document> results = this.vectorStore.similaritySearch(SearchRequest.builder()
.query("a truck at the north gate after dark") // embedded by the same multimodal model
.filterExpression("camera == 'north-gate'")
.build());
for (Document doc : results) {
String ref = (String) doc.getMetadata().get(DocumentMetadata.CONTENT_REF.value());
byte[] frame = mediaStorage.fetch(ref); // S3, CDN, wherever the pointer leads
}DANGER
这里有两个模型在起作用,而它们必须在同一空间、以同样宽度产出向量:你用来嵌入内容的那个,以及存储自己的 EmbeddingModel,它在 similaritySearch 内部对查询做嵌入。
Spring AI 的 VertexAiMultimodalEmbeddingModel 把文本和图像置于一个 1408 维的空间,但它实现的是 DocumentEmbeddingModel 而非 EmbeddingModel,所以在两侧都使用它意味着要为存储包装它。转而接入一个普通文本模型,你的查询会落到完全不同的地方:宽度不同时是错误,恰好匹配时则是悄然毫无意义的结果。
NOTE
Redis 只返回建库时声明的元数据字段,所以必须声明 content_ref,指针才能在一次读取后留存:.metadataFields(MetadataField.tag(DocumentMetadata.CONTENT_REF.value()))。
对你据以过滤的任何字段也一样,比如上面的 camera。向一个已有索引的存储添加字段意味着重建索引。
从向量库读取
稍后,当一个用户问题被传入 AI 模型时,会做一次相似度搜索来检索相似文档,这些文档随后被“填入”提示词,作为用户问题的上下文。
对于只读操作,你可以使用 VectorStore 接口或更专注的 VectorStoreRetriever 接口:
java
@Autowired
VectorStoreRetriever retriever; // Could also use VectorStore here
String question = "<question from user>";
List<Document> similarDocuments = retriever.similaritySearch(question);
// Or with more specific search parameters
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(5) // Return top 5 results
.similarityThreshold(0.7) // Only return results with similarity score >= 0.7
.build();
List<Document> filteredDocuments = retriever.similaritySearch(request);可以把额外选项传入 similaritySearch 方法,以定义要检索多少文档以及相似度搜索的阈值。
读写操作的分离
使用独立的接口让你能清楚界定哪些组件需要写访问、哪些只需读访问:
java
// Write operations in a service that needs full access
@Service
class DocumentIndexer {
private final VectorStore vectorStore;
DocumentIndexer(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void indexDocuments(List<Document> documents) {
vectorStore.add(documents);
}
}
// Read-only operations in a service that only needs retrieval
@Service
class DocumentRetriever {
private final VectorStoreRetriever retriever;
DocumentRetriever(VectorStoreRetriever retriever) {
this.retriever = retriever;
}
public List<Document> findSimilar(String query) {
return retriever.similaritySearch(query);
}
}这种关注点分离通过把变更操作的访问仅限于真正需要的组件,帮助构建更可维护、更安全的应用。
用 VectorStoreRetriever 做检索
VectorStoreRetriever 接口提供向量库的只读视图,只暴露相似度搜索功能。它遵循最小权限原则,在你只需检索文档而不修改底层数据的 RAG(检索增强生成)应用中尤其有用。
使用 VectorStoreRetriever 的好处
- 关注点分离:清楚地把读操作与写操作分离。
- 接口隔离:只需检索功能的客户端不会暴露于变更方法。
- 函数式接口:可用 lambda 表达式或方法引用实现,以应对简单用例。
- 更少的依赖:只需执行搜索的组件不必依赖完整的
VectorStore接口。
示例用法
当你只需执行相似度搜索时,可以直接使用 VectorStoreRetriever:
java
@Service
public class DocumentRetrievalService {
private final VectorStoreRetriever retriever;
public DocumentRetrievalService(VectorStoreRetriever retriever) {
this.retriever = retriever;
}
public List<Document> findSimilarDocuments(String query) {
return retriever.similaritySearch(query);
}
public List<Document> findSimilarDocumentsWithFilters(String query, String country) {
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(5)
.filterExpression("country == '" + country + "'")
.build();
return retriever.similaritySearch(request);
}
}在本例中,该服务只依赖 VectorStoreRetriever 接口,清楚地表明它只执行检索操作而不修改向量库。
与 RAG 应用集成
VectorStoreRetriever 接口在 RAG 应用中尤其有用,在那些应用里你需要检索相关文档来为 AI 模型提供上下文:
java
@Service
public class RagService {
private final VectorStoreRetriever retriever;
private final ChatModel chatModel;
public RagService(VectorStoreRetriever retriever, ChatModel chatModel) {
this.retriever = retriever;
this.chatModel = chatModel;
}
public String generateResponse(String userQuery) {
// Retrieve relevant documents
List<Document> relevantDocs = retriever.similaritySearch(userQuery);
// Extract content from documents to use as context
String context = relevantDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
// Generate response using the retrieved context
String prompt = "Context information:\n" + context + "\n\nUser query: " + userQuery;
return chatModel.generate(prompt);
}
}这一模式在 RAG 应用中允许检索组件与生成组件之间清晰分离。
元数据过滤器
本节描述你可以用于查询结果的各种过滤器。
过滤字符串
你可以把一个类 SQL 的过滤表达式作为 String 传给某个 similaritySearch 重载。
考虑以下示例:
"country == 'BG'""genre == 'drama' && year >= 2020""genre in ['comedy', 'documentary', 'drama']"
Filter.Expression
你可以用一个暴露流式 API 的 FilterExpressionBuilder 创建 Filter.Expression 的一个实例。一个简单的示例如下:
java
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' | '||';考虑以下示例:
java
Expression exp = b.and(b.eq("genre", "drama"), b.gte("year", 2020)).build();你还可以使用以下运算符:
IN: 'IN' | 'in';
NIN: 'NIN' | 'nin';
NOT: 'NOT' | 'not';考虑以下示例:
java
Expression exp = b.and(b.in("genre", "drama", "documentary"), b.not(b.lt("year", 2020))).build();你还可以使用以下运算符:
IS: 'IS' | 'is';
NULL: 'NULL' | 'null';
NOT NULL: 'NOT NULL' | 'not null';考虑以下示例:
java
Expression exp = b.and(b.isNull("year")).build();
Expression exp = b.and(b.isNotNull("year")).build();NOTE
IS NULL 和 IS NOT NULL 尚未在所有向量库中实现。
分区一个共享索引
一个向量库常常被许多文档逻辑组共享——例如一个索引容纳多个客户、项目或知识库的内容。VectorStore 在整个索引上操作,并依据相似度和你提供的过滤器返回或删除文档,因此一个过滤表达式就是选择你要处理的那个组的手段。
做法是:在写入时用标识其组的元数据标记每个文档,然后在搜索和删除上施加匹配的过滤器:
java
// When writing, record the group the document belongs to.
Document document = new Document(content, Map.of("group", groupId));
vectorStore.add(List.of(document));
// When searching, select documents from that group.
List<Document> results = vectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.filterExpression("group == '" + groupId + "'")
.build());
// When deleting by filter, target the same group.
Filter.Expression scoped = new FilterExpressionBuilder().eq("group", groupId).build();
vectorStore.delete(scoped);有几项实践能让各组干净地分离:
- 在写入时把标识性元数据添加到每个文档,使其以后总能用于过滤。
- 在搜索和删除上一致地施加相应的过滤器,使一个操作只影响预期的那个组。
从向量库删除文档
向量库接口提供了多个删除文档的方法,允许你按特定文档 ID 或使用过滤表达式来移除数据。
按文档 ID 删除
删除文档最简单的方式是提供一个文档 ID 列表:
java
void delete(List<String> idList);该方法移除 ID 与所提供列表中匹配的所有文档。 如果列表中有任何 ID 在存储中不存在,它会被忽略。
.示例用法
java
// 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()));按过滤表达式删除
对于更复杂的删除标准,你可以使用过滤表达式:
java
void delete(Filter.Expression filterExpression);该方法接受一个定义哪些文档应被删除标准的 Filter.Expression 对象。 当你需要依据文档的元数据属性删除文档时,它尤其有用。
.示例用法
java
// 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按字符串过滤表达式删除
为便捷起见,你也可以用基于字符串的过滤表达式删除文档:
java
void delete(String filterExpression);该方法在内部把所提供的字符串过滤器转换为一个 Filter.Expression 对象。 当你有字符串形式的过滤标准时它很有用。
.示例用法
java
// 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 块中:
.示例用法
java
try {
vectorStore.delete("country == 'Bulgaria'");
}
catch (Exception e) {
logger.error("Invalid filter expression", e);
}文档版本化用例
一个常见场景是管理文档版本:你需要上传一个新版本的文档同时移除旧版本。下面是用过滤表达式处理这种情况的方式:
.示例用法
java
// 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,
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你也可以用字符串过滤表达式达成同样目的:
.示例用法
java
// 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 来删除时,考虑使用过滤表达式。