このバージョンはまだ開発中であり、まだ安定しているとは見なされていません。最新の安定バージョンについては、Spring AI 2.0.1 を使用してください! |
ベクトルデータベース
ベクトルデータベースは、AI アプリケーションで重要なロールを果たす特殊な型のデータベースです。
ベクトルデータベースでは、クエリが従来のリレーショナルデータベースとは異なります。完全一致の代わりに、類似性検索が実行されます。クエリとしてベクトルが与えられると、ベクトルデータベースはクエリベクトルに「類似した」ベクトルを返します。この類似性が高レベルでどのように計算されるかについての詳細は、ベクトルの類似性で説明されています。
ベクトルデータベースは、データを AI モデルと統合するために使用されます。使用の最初のステップは、データをベクトルデータベースにロードすることです。次に、ユーザーのクエリが AI モデルに送信されると、最初に類似したドキュメントのセットが取得されます。これらのドキュメントはユーザーの質問のコンテキストとして機能し、ユーザーのクエリとともに AI モデルに送信されます。この手法は検索拡張生成 (RAG) として知られています。
以下のセクションでは、複数のベクトルデータベース実装を使用するための Spring AI インターフェースと、いくつかの高レベルな使用例について説明します。
最後のセクションは、ベクトルデータベースでの類似性検索の基礎となるアプローチをわかりやすく説明することを目的としています。
API の概要
このセクションでは、Spring AI フレームワーク内の VectorStore インターフェースおよび関連クラスについて解説します。
Spring AI は、VectorStore インターフェースとその読み取り専用版である VectorStoreRetriever インターフェースを介してベクトルデータベースとやり取りするための抽象化された API を提供します。
VectorStoreRetriever インターフェース
Spring AI は、ドキュメント取得機能のみを公開する VectorStoreRetriever と呼ばれる読み取り専用インターフェースを提供します。
@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 を継承し、変異機能を追加します。
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();
}
}The VectorStore interface combines both read and write operations, allowing you to add, delete, and search for documents in a vector database. While add computes the embeddings for you, upsert writes ones you supply yourself; see Writing User-Supplied Embeddings.
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[] に変換されます。Word2Vec [Wikipedia] (英語) 、GLoVE [Wikipedia] (英語) 、BERT [Wikipedia] (英語) などの埋め込みモデル、または OpenAI の text-embedding-ada-002 は、単語、文、または段落をこれらのベクトル埋め込みに変換するために使用されます。
ベクトルデータベースのロールは、これらの埋め込みの類似性検索を保存し、容易にすることです。埋め込み自体は生成しません。ベクトル埋め込みを作成するには、EmbeddingModel を利用する必要があります。
インターフェースの similaritySearch メソッドを使用すると、指定されたクエリ文字列に類似したドキュメントを取得できます。これらのメソッドは、次のパラメーターを使用して微調整できます。
k: 返される類似ドキュメントの最大数を指定する整数。これは、「上位 K」検索、または「K 最近傍」(KNN) と呼ばれることがよくあります。threshold: 0 ~ 1 の範囲の double 値。1 に近い値ほど類似性が高いことを示します。デフォルトでは、たとえば、しきい値を 0.75 に設定すると、この値を超える類似性を持つドキュメントのみが返されます。Filter.Expression: SQL の 'where' 句と同様に機能する流れるような DSL (ドメイン固有言語) 式を渡すために使用されるクラスですが、Documentのメタデータのキーと値のペアにのみ適用されます。filterExpression: フィルター式を文字列として受け入れる ANTLR4 に基づく外部 DSL。例: 国、年、isActiveなどのメタデータキーでは、次のような式を使用できます。:country == 'UK' && year >= 2020 && isActive == true.
Filter.Expression の詳細については、"メタデータフィルター" セクションを参照してください。
スキーマの初期化
一部のベクトルストアでは、使用前にバックエンドスキーマを初期化する必要があります。デフォルトでは初期化されません。適切なコンストラクター引数に boolean を渡すか、Spring Boot を使用する場合は、適切な initialize-schema プロパティを application.properties または application.yml の true に設定して、オプトインする必要があります。特定のプロパティ名については、使用しているベクトルストアのドキュメントを確認してください。
バッチ戦略
ベクトルストアを使用する場合、大量のドキュメントを埋め込む必要があることがよくあります。1 回の呼び出しですべてのドキュメントを一度に埋め込むのは簡単なように思えるかもしれませんが、このアプローチは課題を引き起こす可能性があります。埋め込みモデルはテキストをトークンとして処理し、最大トークン制限 (コンテキストウィンドウサイズと呼ばれることが多い) があります。この制限により、1 回の埋め込みリクエストで処理できるテキストの量が制限されます。1 回の呼び出しで埋め込もうとするトークンが多すぎると、エラーが発生したり、埋め込みが切り捨てられたりする可能性があります。
このトークン制限に対処するため、Spring AI はバッチ処理戦略を採用しています。この手法では、大量のドキュメントを、埋め込みモデルの最大コンテキストウィンドウ内に収まる小さなバッチに分割します。バッチ処理はトークン制限の課題を解決するだけでなく、パフォーマンスの向上や API レート制限のより効率的な利用にもつながります。
Spring AI は、トークン数に基づいてドキュメントをサブバッチ処理できる BatchingStrategy インターフェースを介してこの機能を提供します。
コア BatchingStrategy インターフェースは次のように定義されます。
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 を作成する方法の例を次に示します。
@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 インターフェースの独自の実装を渡すことができるため、柔軟性が高まります。この機能により、特定のニーズに合わせたカスタムトークンカウント戦略を使用できます。例:
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) // 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();
}
}この構成では、次のようになります。
埋め込みモデルでは自動切り捨てが有効になっているため、サイズが大きすぎる入力を適切に処理できます。
バッチ処理戦略では、実際のモデル制限 (20,000) よりもはるかに大きい、人為的に高いトークン制限 (132,900) を使用します。
ベクトルストアは、構成された埋め込みモデルとカスタム
BatchingStrategyBean を使用します。
なぜこれが機能するのか
このアプローチが機能する理由は次のとおりです。
TokenCountBatchingStrategyは、単一のドキュメントが設定された最大値を超えているかどうかを確認し、超過している場合はIllegalArgumentExceptionをスローします。バッチ処理戦略で非常に高い制限を設定することにより、このチェックが失敗しないことが保証されます。
モデルの制限を超えるドキュメントまたはバッチは、埋め込みモデルの自動切り捨て機能によって自動的に切り捨てられ、処理されます。
ベストプラクティス
自動切り捨てを使用する場合:
バッチ戦略による早期の例外を回避するには、バッチ戦略の最大入力トークン数をモデルの実際の制限より少なくとも 5-10x 大きく設定します。
埋め込みモデルからの切り捨て警告がないかログを監視します (注: すべてのモデルが切り捨てイベントをログに記録するわけではありません)。
サイレントトランケーションが埋め込み品質に与える影響を考慮してください。
切り捨てられた埋め込みが要件を満たしていることを確認するために、サンプルドキュメントでテストします。
この設定は非標準なので、将来のメンテナーのためにドキュメント化してください。
| 自動切り捨てはエラーを防ぎますが、埋め込みが不完全になる可能性があります。長いドキュメントの末尾にある重要な情報が失われる可能性があります。アプリケーションですべてのコンテンツを埋め込む必要がある場合は、埋め込む前にドキュメントを小さなチャンクに分割してください。 |
Spring Boot 自動構成
Spring Boot の自動構成を使用する場合は、Spring AI に付属するデフォルトの BatchingStrategy Bean を上書きするために、カスタムの BatchingStrategy Bean を指定する必要があります。
@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 を定義します。
@Configuration
public class EmbeddingConfig {
@Bean
public BatchingStrategy customBatchingStrategy() {
return new CustomBatchingStrategy();
}
} このカスタム BatchingStrategy は、アプリケーション内の EmbeddingModel 実装によって自動的に使用されます。
Spring AI でサポートされているベクトルストアは、デフォルトの TokenCountBatchingStrategy を使用するように構成されています。 |
VectorStore の実装
VectorStore インターフェースの利用可能な実装は次のとおりです。
Azure ベクトル検索 - Azure (英語) ベクトルストア。
Apache Cassandra - Apache Cassandra (英語) ベクトルストア。
Chroma ベクトルストア - Chroma (英語) ベクトルストア。
Elasticsearch ベクトルストア - Elasticsearch (英語) ベクトルストア。
GemFire ベクトルストア - GemFire (英語) ベクトルストア。
MariaDB ベクトルストア - MariaDB (英語) ベクトルストア。
Milvus ベクトルストア - Milvus (英語) ベクトルストア。
MongoDB Atlas ベクトルストア - MongoDB Atlas (英語) ベクトルストア。
Neo4j ベクトルストア - Neo4j (英語) ベクトルストア。
OpenSearch ベクトルストア - OpenSearch (英語) ベクトルストア。
Oracle ベクトルストア - Oracle データベース (英語) ベクトルストア。
PgVector ストア - PostgreSQL/PGVector [GitHub] (英語) ベクトルストア。
Pinecone ベクトルストア - Pinecone (英語) ベクトルストア。
Qdrant ベクトルストア - Qdrant (英語) ベクトルストア。
Redis ベクトルストア - Redis (英語) ベクトルストア。
Typesense ベクトルストア - Typesense (英語) ベクトルストア。
Weaviate ベクトルストア - Weaviate (英語) ベクトルストア。
S3 ベクトルストア - AWS S3 [Amazon] ベクトルストア。
SimpleVectorStore [GitHub] (英語) - ベクトルストレージのシンプルな実装であり、テスト目的のみに適しています。
| Azure Cosmos DB ベクトルストアのサポートは、Azure Cosmos DB チームによって保守されている外部モジュールとして利用可能です。詳細については、azurecosmosdb.github.io/spring-ai/docs/index.html (英語) を参照してください。 |
SimpleVectorStore の実装は実運用向けに設計されておらず、テストまたはデモンストレーション目的でのみ使用してください。 |
将来のリリースでは、さらに多くの実装がサポートされる可能性があります。
Spring AI でサポートする必要のあるベクトルデータベースがある場合は、GitHub に課題を提起するか、さらに良い方法として、実装を含むプルリクエストを送信してください。
VectorStore の各実装に関する情報は、この章のサブセクションに記載されています。
使用例
ベクトルデータベースの埋め込みを計算するには、使用されている高レベルの AI モデルと一致する埋め込みモデルを選択する必要があります。
例: OpenAI の ChatGPT では、OpenAiEmbeddingModel と text-embedding-ada-002 というモデルを使用します。
Spring Boot スターターの OpenAI 用の自動構成により、依存性注入のために Spring アプリケーションコンテキストで EmbeddingModel の実装が利用できるようになります。
ベクトルストアへの書き込み
ベクトルストアにデータをロードする一般的な方法は、バッチ処理のようなジョブで行うもので、まず Spring AI の Document クラスにデータをロードし、次に VectorStore インターフェースの add メソッドを呼び出します。
ベクトルデータベースにロードしたいデータを含む JSON ファイルを表すソースファイルへの String 参照が与えられた場合、Spring AI の JsonReader を使用して JSON 内の特定のフィールドをロードします。これにより、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);
}add computes the embeddings for you. If you already have the vectors, write them as they are with upsert instead; see Writing User-Supplied Embeddings.
Writing User-Supplied Embeddings
add computes an embedding for every document inside the store, using the store’s embedding model. Sometimes you already have the vectors and want to write them as-is: a provider’s batch API computed them overnight at half price, another team owns the embedding pipeline, or you are migrating from a system that exports text and vectors together. For those cases the VectorStore interface offers upsert, which takes the document and its vector paired together in an EmbeddedDocument and never embeds anything itself:
public record EmbeddedDocument(Document document, float[] embedding) {
}As the name says, upsert replaces by id: writing the same id again overwrites the existing row rather than adding a duplicate. That makes a re-run with stable ids safe, which is useful for an ingestion job that may be retried after a failure.
@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)));Some stores constrain the document id. Qdrant requires a UUID, and PgVectorStore stores the id in a UUID column unless you configure idType, so an arbitrary string fails on those. Deriving the UUID from a key of your own, for example UUID.nameUUIDFromBytes(key.getBytes(UTF_8)), keeps the id stable across runs, which is what makes a re-run replace the row rather than add a second one. |
To write plain text through upsert, embed it first and hand over the result:
@Autowired
EmbeddingModel embeddingModel;
float[] vector = this.embeddingModel.embed(document);
this.vectorStore.upsert(List.of(new EmbeddedDocument(document, vector)));upsert is opt-in per store: the default implementation throws UnsupportedOperationException, so a store supports it only once it has been implemented. It is available on pgvector, Redis, Elasticsearch, and Qdrant, with more stores to follow. EmbeddedDocument rejects a vector that is empty or holds NaN or an infinity, which is worth knowing when the vectors come from a pipeline you do not control: a NaN would otherwise corrupt distance maths rather than fail. When a store knows its index dimension, it checks every vector in the batch against it before writing anything, so a wrong-sized vector fails fast and cannot partially write. Where that dimension can only be guessed, the check is left to the database, which rejects the vector with its own error.
Because the vectors come from outside the store, lining them up with the index is yours to get right. Three things have to agree, and only the first is checked for you:
- 幅
Your embedding model decides the width, and the index has to be created with that same width, for example
dimensions(1024)on the store builder or a pre-created index whose mapping says 1024. A mismatch fails on the first write.- Vector space
Nothing records which model produced a row, so vectors written by one model and searched with another come back plausible but meaningless, with no error anywhere. Writing the model name and version into metadata costs nothing and tells a later reader what a row actually holds.
- The query side
similaritySearchstill embeds the query with the store’s ownEmbeddingModel, so that model has to produce vectors in the same space and of the same width as the ones you upserted.
Indexing Non-Text or Oversized Content
With text, the store keeps the document and its vector together in one row: search matches on the vector and hands back the text. A multimodal model breaks that assumption. It gives you a searchable vector for an image, a video segment, or an audio clip, but the content itself lives in S3, a CDN, or a database, and there is no text to put in the row. The same applies to text that is simply too large for the row, such as a full article whose chunks were indexed.
The row then keeps everything except the content: id, metadata, vector, and a pointer to where the content lives. The pointer goes in metadata under DocumentMetadata.CONTENT_REF, and the store never interprets it: it is an S3 URI, a CDN URL, or a database key that only your application resolves. Such a row is written with upsert, because you supply the vector from the multimodal model yourself:
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)));The empty text is what makes the document valid; it is not content. A document carrying CONTENT_REF is a reference row even though isText() reports true. upsert requires text for this reason: no store can persist a row that carries none, so a media document is rejected rather than half-written. |
The pointer is an ordinary metadata entry. Nothing in the framework reads or validates it, so keeping it non-empty is up to you. A row that still has real text of its own can be written with add, which is how a summary or a chunk keeps a pointer back to the full article:
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));Only a row with empty text has to go through upsert, because add embeds the text and there would be nothing to embed.
Search is unchanged. similaritySearch matches on vectors and returns Document s as always, and for these rows the caller follows the pointer:
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
}Two models are in play here and they have to produce vectors in the same space and of the same width: the one you embed the content with, and the store’s own EmbeddingModel, which embeds the query inside similaritySearch. Spring AI’s VertexAiMultimodalEmbeddingModel puts text and images in one 1408-dimension space, but it implements DocumentEmbeddingModel rather than EmbeddingModel, so using it on both sides means wrapping it for the store. Wire an ordinary text model instead and your queries land somewhere else entirely: an error if the widths differ, and quietly meaningless results if they happen to match. |
Redis returns only the metadata fields declared when the store is built, so content_ref has to be declared for the pointer to survive a read: .metadataFields(MetadataField.tag(DocumentMetadata.CONTENT_REF.value())). The same goes for any field you filter on, such as camera above. Adding a field to a store that already has an index means recreating the index. |
ベクトルストアからの読み取り
その後、ユーザーの質問が AI モデルに渡されると、類似性検索が実行され、類似のドキュメントが取得され、ユーザーの質問のコンテキストとしてプロンプトに「詰め込まれ」ます。
読み取り専用操作の場合は、VectorStore インターフェースまたはより重点を置いた VectorStoreRetriever インターフェースのいずれかを使用できます。
@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 メソッドに渡して、取得するドキュメントの数と類似性検索のしきい値を定義できます。
読み取り操作と書き込み操作の分離
個別のインターフェースを使用すると、書き込みアクセスが必要なコンポーネントと読み取りアクセスのみが必要なコンポーネントを明確に定義できます。
// 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(Retrieval-Augmented Generation)アプリケーションで役立ちます。
VectorStoreRetriever を使用するメリット
関心の分離 : 読み取り操作と書き込み操作を明確に区別します。
インターフェース分離 : 取得機能のみを必要とするクライアントは、変更メソッドに公開されません。
関数インターフェース : 単純なユースケースでは、ラムダ式またはメソッド参照を使用して実装できます。
依存度の減少 : 検索を実行するだけでよいコンポーネントは、完全な
VectorStoreインターフェースに依存する必要はありません。
使用例
類似性検索のみを実行する必要がある場合は、VectorStoreRetriever を直接使用できます。
@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 インターフェースは、AI モデルのコンテキストを提供するために関連ドキュメントを取得する必要がある RAG アプリケーションで特に役立ちます。
@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 オーバーロードの 1 つに渡すことができます。
次の例を考えてみましょう。
"country == 'BG'""genre == 'drama' && year >= 2020""genre in ['comedy', 'documentary', 'drama']"
Filter.Expression
スムーズな API を公開する FilterExpressionBuilder を使用して Filter.Expression のインスタンスを作成できます。簡単な例は次のとおりです。
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();次の演算子も使用できます。
IS: 'IS' | 'is';
NULL: 'NULL' | 'null';
NOT NULL: 'NOT NULL' | 'not null';次の例を考えてみましょう。
Expression exp = b.and(b.isNull("year")).build();
Expression exp = b.and(b.isNotNull("year")).build();IS NULL と IS NOT NULL はまだすべてのベクトルストアに実装されていません。 |
共有インデックスのパーティショニング
単一のベクトルストアは、多くの場合、複数の論理的なドキュメントグループで共有されます。たとえば、1 つのインデックスに複数の顧客、プロジェクト、ナレッジベースのコンテンツが格納されている場合などです。VectorStore はインデックス全体に対して動作し、類似性および指定したフィルターに基づいてドキュメントを返すか削除します。つまり、フィルター式によって操作対象のグループを選択することになります。
このアプローチは、ドキュメント作成時に各ドキュメントにそのグループを識別するメタデータを付加し、その後、検索と削除に一致するフィルターを適用するというものです。
// 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 のリストを提供することです。
void delete(List<String> idList);このメソッドは、指定されたリスト内の ID と一致するすべてのドキュメントを削除します。リスト内の ID がストアに存在しない場合は無視されます。
// 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 オブジェクトを受け入れます。これは、メタデータプロパティに基づいてドキュメントを削除する必要がある場合に特に便利です。
// 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 オブジェクトに変換します。文字列形式のフィルター条件がある場合に便利です。
// 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 ブロックでラップすることです。
try {
vectorStore.delete("country == 'Bulgaria'");
}
catch (Exception e) {
logger.error("Invalid filter expression", e);
}ドキュメントのバージョン管理のユースケース
一般的なシナリオは、ドキュメントのバージョン管理で、古いバージョンを削除しながら新しいバージョンのドキュメントをアップロードする必要がある場合です。フィルター式を使用してこれを処理する方法は次のとおりです。
// 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文字列フィルター式を使用して同じことを実現することもできます。
// Delete old version using string filter
vectorStore.delete("docId == 'AIML-001' AND version == '1.0'");
// Add new version
vectorStore.add(List.of(documentV2));