From f69d879ec98e6113c13d9ede7db1d23d93c5d428 Mon Sep 17 00:00:00 2001 From: Soby Chacko Date: Mon, 9 Dec 2024 19:48:51 -0500 Subject: [PATCH] Add builder pattern to RedisVectorStore and refactor package name Refactors RedisVectorStore to use the builder pattern for improved configuration and usability. The changes include: * Move classes to org.springframework.ai.vectorstore.redis package * Add RedisBuilder with comprehensive configuration options * Deprecate RedisVectorStoreConfig in favor of builder pattern * Enhance documentation with detailed usage examples * Improve error handling and parameter validation This change makes RedisVectorStore configuration more intuitive and consistent with other vector stores in the project. --- .../ROOT/pages/api/vectordbs/redis.adoc | 210 +++++----- .../RedisVectorStoreAutoConfiguration.java | 20 +- .../RedisFilterExpressionConverter.java | 4 +- .../{ => redis}/RedisVectorStore.java | 365 +++++++++++++++--- .../RedisFilterExpressionConverterTests.java | 38 +- .../{ => redis}/RedisVectorStoreIT.java | 21 +- .../RedisVectorStoreObservationIT.java | 25 +- 7 files changed, 459 insertions(+), 224 deletions(-) rename vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/{ => redis}/RedisFilterExpressionConverter.java (97%) rename vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/{ => redis}/RedisVectorStore.java (58%) rename vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/{ => redis}/RedisFilterExpressionConverterTests.java (71%) rename vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/{ => redis}/RedisVectorStoreIT.java (94%) rename vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/{ => redis}/RedisVectorStoreObservationIT.java (91%) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc index cd6337ba7..ca4b0c401 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc @@ -45,41 +45,15 @@ TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Man TIP: Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file. - The vector store implementation can initialize the requisite schema for you, but you must opt-in by specifying the `initializeSchema` boolean in the appropriate constructor or by setting `...initialize-schema=true` in the `application.properties` file. NOTE: this is a breaking change! In earlier versions of Spring AI, this schema initialization happened by default. +Please have a look at the list of <> for the vector store to learn about the default values and configuration options. Additionally, you will need a configured `EmbeddingModel` bean. Refer to the xref:api/embeddings.adoc#available-implementations[EmbeddingModel] section for more information. -Here is an example of the needed bean: - -[source,java] ----- -@Bean -public EmbeddingModel embeddingModel() { - // Can be any other EmbeddingModel implementation. - return new OpenAiEmbeddingModel(new OpenAiApi(System.getenv("SPRING_AI_OPENAI_API_KEY"))); -} ----- - -To connect to Redis you need to provide access details for your instance. -A simple configuration can either be provided via Spring Boot's _application.properties_, - -[source,properties] ----- -spring.ai.vectorstore.redis.uri= -spring.ai.vectorstore.redis.index= -spring.ai.vectorstore.redis.prefix= - -# API key if needed, e.g. OpenAI -spring.ai.openai.api.key= ----- - -Please have a look at the list of xref:#_configuration_properties[configuration parameters] for the vector store to learn about the default values and configuration options. - -Now you can Auto-wire the Redis Vector Store in your application and use it +Now you can auto-wire the `RedisVectorStore` as a vector store in your application. [source,java] ---- @@ -99,146 +73,144 @@ vectorStore.add(documents); List results = this.vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); ---- -=== Configuration properties +[[redisvector-properties]] +=== Configuration Properties -You can use the following properties in your Spring Boot configuration to customize the Redis vector store. +To connect to Redis and use the `RedisVectorStore`, you need to provide access details for your instance. +A simple configuration can be provided via Spring Boot's `application.yml`, -[stripes=even] +[source,yaml] +---- +spring: + data: + redis: + uri: + ai: + vectorstore: + redis: + initialize-schema: true + index-name: custom-index + prefix: custom-prefix + batching-strategy: TOKEN_COUNT # Optional: Controls how documents are batched for embedding +---- + +Properties starting with `spring.ai.vectorstore.redis.*` are used to configure the `RedisVectorStore`: + +[cols="2,5,1",stripes=even] |=== -|Property| Description | Default value - -|`spring.ai.vectorstore.redis.uri`| Server connection URI | `redis://localhost:6379` -|`spring.ai.vectorstore.redis.index`| Index name | `default-index` -|`spring.ai.vectorstore.redis.initialize-schema`| Whether to initialize the required schema | `false` -|`spring.ai.vectorstore.redis.prefix`| Prefix | `default:` +|Property | Description | Default Value +|`spring.ai.vectorstore.redis.initialize-schema`| Whether to initialize the required schema | `false` +|`spring.ai.vectorstore.redis.index-name` | The name of the index to store the vectors | `spring-ai-index` +|`spring.ai.vectorstore.redis.prefix` | The prefix for Redis keys | `embedding:` +|`spring.ai.vectorstore.redis.batching-strategy` | Strategy for batching documents when calculating embeddings. Options are `TOKEN_COUNT` or `FIXED_SIZE` | `TOKEN_COUNT` |=== -== Metadata filtering +== Metadata Filtering -You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with RedisVectorStore as well. +You can leverage the generic, portable xref:api/vectordbs.adoc#metadata-filters[metadata filters] with Redis as well. For example, you can use either the text expression language: [source,java] ---- -vectorStore.similaritySearch( - SearchRequest - .query("The World") - .withTopK(TOP_K) - .withSimilarityThreshold(SIMILARITY_THRESHOLD) - .withFilterExpression("country in ['UK', 'NL'] && year >= 2020")); +vectorStore.similaritySearch(SearchRequest.defaults() + .withQuery("The World") + .withTopK(TOP_K) + .withSimilarityThreshold(SIMILARITY_THRESHOLD) + .withFilterExpression("country in ['UK', 'NL'] && year >= 2020")); ---- -or programmatically using the expression DSL: +or programmatically using the `Filter.Expression` DSL: [source,java] ---- FilterExpressionBuilder b = new FilterExpressionBuilder(); -vectorStore.similaritySearch( - SearchRequest - .query("The World") - .withTopK(TOP_K) - .withSimilarityThreshold(SIMILARITY_THRESHOLD) - .withFilterExpression(b.and( - b.in("country", "UK", "NL"), - b.gte("year", 2020)).build())); +vectorStore.similaritySearch(SearchRequest.defaults() + .withQuery("The World") + .withTopK(TOP_K) + .withSimilarityThreshold(SIMILARITY_THRESHOLD) + .withFilterExpression(b.and( + b.in("country", "UK", "NL"), + b.gte("year", 2020)).build())); ---- -The portable filter expressions get automatically converted into link:https://redis.io/docs/interact/search-and-query/query/[Redis search queries]. -For example, the following portable filter expression: +NOTE: Those (portable) filter expressions get automatically converted into link:https://redis.io/docs/interact/search-and-query/query/[Redis search queries]. + +For example, this portable filter expression: [source,sql] ---- country in ['UK', 'NL'] && year >= 2020 ---- -is converted into Redis query: +is converted into the proprietary Redis filter format: -[source] +[source,text] ---- @country:{UK | NL} @year:[2020 inf] ---- -== Manual configuration +== Manual Configuration -If you prefer not to use the auto-configuration, you can manually configure the Redis Vector Store. -Add the Redis Vector Store and Jedis dependencies +Instead of using the Spring Boot auto-configuration, you can manually configure the Redis vector store. For this you need to add the `spring-ai-redis-store` to your project: [source,xml] ---- - org.springframework.ai - spring-ai-redis-store - - - - redis.clients - jedis - 5.1.0 + org.springframework.ai + spring-ai-redis-store ---- -TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. +or to your Gradle `build.gradle` build file. -Then, create a `RedisVectorStore` bean in your Spring configuration: +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-redis-store' +} +---- + +Create a `JedisPooled` bean: [source,java] ---- @Bean -public VectorStore vectorStore(EmbeddingModel embeddingModel) { - RedisVectorStoreConfig config = RedisVectorStoreConfig.builder() - .withURI("redis://localhost:6379") - // Define the metadata fields to be used - // in the similarity search filters. - .withMetadataFields( - MetadataField.tag("country"), - MetadataField.numeric("year")) - .build(); +public JedisPooled jedisPooled() { + return new JedisPooled("", 6379); +} +---- - return new RedisVectorStore(config, embeddingModel); +Then create the `RedisVectorStore` bean using the builder pattern: + +[source,java] +---- +@Bean +public VectorStore vectorStore(JedisPooled jedisPooled, EmbeddingModel embeddingModel) { + return RedisVectorStore.builder() + .jedis(jedisPooled) + .embeddingModel(embeddingModel) + .indexName("custom-index") // Optional: defaults to "spring-ai-index" + .prefix("custom-prefix") // Optional: defaults to "embedding:" + .metadataFields( // Optional: define metadata fields for filtering + MetadataField.tag("country"), + MetadataField.numeric("year")) + .initializeSchema(true) // Optional: defaults to false + .batchingStrategy(new TokenCountBatchingStrategy()) // Optional: defaults to TokenCountBatchingStrategy + .build(); +} + +// This can be any EmbeddingModel implementation +@Bean +public EmbeddingModel embeddingModel() { + return new OpenAiEmbeddingModel(new OpenAiApi(System.getenv("OPENAI_API_KEY"))); } ---- [NOTE] ==== -It is more convenient and preferred to create the `RedisVectorStore` as a Bean. -But if you decide to create it manually, then you must call the `RedisVectorStore#afterPropertiesSet()` after setting the properties and before using the client. +You must list explicitly all metadata field names and types (`TAG`, `TEXT`, or `NUMERIC`) for any metadata field used in filter expressions. +The `metadataFields` above registers filterable metadata fields: `country` of type `TAG`, `year` of type `NUMERIC`. ==== - -[NOTE] -==== -You must list explicitly all metadata field names and types (`TAG`, `TEXT`, or `NUMERIC`) for any metadata field used in filter expression. -The `withMetadataFields` above registers filterable metadata fields: `country` of type `TAG`, `year` of type `NUMERIC`. -==== - -Then in your main code, create some documents: - -[source,java] ----- -List documents = List.of( - new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("country", "UK", "year", 2020)), - new Document("The World is Big and Salvation Lurks Around the Corner", Map.of()), - new Document("You walk forward facing the past and you turn back toward the future.", Map.of("country", "NL", "year", 2023))); ----- - -Now add the documents to your vector store: - - -[source,java] ----- -vectorStore.add(documents); ----- - -And finally, retrieve documents similar to a query: - -[source,java] ----- -List results = vectorStore.similaritySearch( - SearchRequest - .query("Spring") - .withTopK(5)); ----- - -If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/redis/RedisVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/redis/RedisVectorStoreAutoConfiguration.java index 6a252bbcc..42516395c 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/redis/RedisVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/redis/RedisVectorStoreAutoConfiguration.java @@ -22,8 +22,7 @@ import redis.clients.jedis.JedisPooled; import org.springframework.ai.embedding.BatchingStrategy; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.TokenCountBatchingStrategy; -import org.springframework.ai.vectorstore.RedisVectorStore; -import org.springframework.ai.vectorstore.RedisVectorStore.RedisVectorStoreConfig; +import org.springframework.ai.vectorstore.redis.RedisVectorStore; import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -61,15 +60,16 @@ public class RedisVectorStoreAutoConfiguration { ObjectProvider customObservationConvention, BatchingStrategy batchingStrategy) { - var config = RedisVectorStoreConfig.builder() - .withIndexName(properties.getIndex()) - .withPrefix(properties.getPrefix()) + return RedisVectorStore.builder() + .jedis(new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort())) + .embeddingModel(embeddingModel) + .initializeSchema(properties.isInitializeSchema()) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .customObservationConvention(customObservationConvention.getIfAvailable(() -> null)) + .batchingStrategy(batchingStrategy) + .indexName(properties.getIndex()) + .prefix(properties.getPrefix()) .build(); - - return new RedisVectorStore(config, embeddingModel, - new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort()), - properties.isInitializeSchema(), observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP), - customObservationConvention.getIfAvailable(() -> null), batchingStrategy); } } diff --git a/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisFilterExpressionConverter.java similarity index 97% rename from vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java rename to vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisFilterExpressionConverter.java index 4536eda08..014d544f9 100644 --- a/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java +++ b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisFilterExpressionConverter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.vectorstore; +package org.springframework.ai.vectorstore.redis; import java.text.MessageFormat; import java.util.List; @@ -22,7 +22,7 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; -import org.springframework.ai.vectorstore.RedisVectorStore.MetadataField; +import org.springframework.ai.vectorstore.redis.RedisVectorStore.MetadataField; import org.springframework.ai.vectorstore.filter.Filter.Expression; import org.springframework.ai.vectorstore.filter.Filter.ExpressionType; import org.springframework.ai.vectorstore.filter.Filter.Group; diff --git a/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java similarity index 58% rename from vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java rename to vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java index 8415f8dfa..528500943 100644 --- a/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java +++ b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.vectorstore; +package org.springframework.ai.vectorstore.redis; import java.text.MessageFormat; import java.util.ArrayList; @@ -54,6 +54,9 @@ import org.springframework.ai.embedding.EmbeddingOptionsBuilder; import org.springframework.ai.embedding.TokenCountBatchingStrategy; import org.springframework.ai.observation.conventions.VectorStoreProvider; import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric; +import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore; import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext; @@ -61,21 +64,114 @@ import org.springframework.ai.vectorstore.observation.VectorStoreObservationConv import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; /** - * The RedisVectorStore is for managing and querying vector data in a Redis database. It - * offers functionalities like adding, deleting, and performing similarity searches on - * documents. + * Redis-based vector store implementation using Redis Stack with RediSearch and + * RedisJSON. * + *

+ * The store uses Redis JSON documents to persist vector embeddings along with their + * associated document content and metadata. It leverages RediSearch for creating and + * querying vector similarity indexes. The RedisVectorStore manages and queries vector + * data, offering functionalities like adding, deleting, and performing similarity + * searches on documents. + *

+ * + *

* The store utilizes RedisJSON and RedisSearch to handle JSON documents and to index and - * search vector data. It supports various vector algorithms (e.g., FLAT, HSNW) for + * search vector data. It supports various vector algorithms (e.g., FLAT, HNSW) for * efficient similarity searches. Additionally, it allows for custom metadata fields in * the documents to be stored alongside the vector and content data. + *

* - * This class requires a RedisVectorStoreConfig configuration object for initialization, - * which includes settings like Redis URI, index name, field names, and vector algorithms. - * It also requires an EmbeddingModel to convert documents into embeddings before storing - * them. + *

+ * Features: + *

+ *
    + *
  • Automatic schema initialization with configurable index creation
  • + *
  • Support for HNSW and FLAT vector indexing algorithms
  • + *
  • Cosine similarity metric for vector comparisons
  • + *
  • Flexible metadata field types (TEXT, TAG, NUMERIC) for advanced filtering
  • + *
  • Configurable similarity thresholds for search results
  • + *
  • Batch processing support with configurable batching strategies
  • + *
+ * + *

+ * Basic usage example: + *

+ *
{@code
+ * RedisVectorStore vectorStore = RedisVectorStore.builder()
+ *     .jedis(jedisPooled)
+ *     .embeddingModel(embeddingModel)
+ *     .indexName("custom-index")     // Optional: defaults to "spring-ai-index"
+ *     .prefix("custom-prefix")       // Optional: defaults to "embedding:"
+ *     .vectorAlgorithm(Algorithm.HNSW)
+ *     .build();
+ *
+ * // Add documents
+ * vectorStore.add(List.of(
+ *     new Document("content1", Map.of("meta1", "value1")),
+ *     new Document("content2", Map.of("meta2", "value2"))
+ * ));
+ *
+ * // Search with filters
+ * List results = vectorStore.similaritySearch(
+ *     SearchRequest.query("search text")
+ *         .withTopK(5)
+ *         .withSimilarityThreshold(0.7)
+ *         .withFilterExpression("meta1 == 'value1'")
+ * );
+ * }
+ * + *

+ * Advanced configuration example: + *

+ *
{@code
+ * RedisVectorStore vectorStore = RedisVectorStore.builder()
+ *     .jedis(jedisPooled)
+ *     .embeddingModel(embeddingModel)
+ *     .indexName("custom-index")
+ *     .prefix("custom-prefix")
+ *     .contentFieldName("custom_content")
+ *     .embeddingFieldName("custom_embedding")
+ *     .vectorAlgorithm(Algorithm.FLAT)
+ *     .metadataFields(
+ *         MetadataField.tag("category"),
+ *         MetadataField.numeric("year"),
+ *         MetadataField.text("description"))
+ *     .initializeSchema(true)
+ *     .batchingStrategy(new TokenCountBatchingStrategy())
+ *     .build();
+ * }
+ * + *

+ * Database Requirements: + *

+ *
    + *
  • Redis Stack with RediSearch and RedisJSON modules
  • + *
  • Redis version 7.0 or higher
  • + *
  • Sufficient memory for storing vectors and indexes
  • + *
+ * + *

+ * Vector Algorithms: + *

+ *
    + *
  • HNSW: Default algorithm, provides better search performance with slightly higher + * memory usage
  • + *
  • FLAT: Brute force algorithm, provides exact results but slower for large + * datasets
  • + *
+ * + *

+ * Metadata Field Types: + *

+ *
    + *
  • TAG: For exact match filtering on categorical data
  • + *
  • TEXT: For full-text search capabilities
  • + *
  • NUMERIC: For range queries on numerical data
  • + *
* * @author Julien Ruaux * @author Christian Tzolov @@ -86,6 +182,7 @@ import org.springframework.util.CollectionUtils; * @see VectorStore * @see RedisVectorStoreConfig * @see EmbeddingModel + * @since 1.0.0 */ public class RedisVectorStore extends AbstractObservationVectorStore implements InitializingBean { @@ -119,40 +216,67 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements private static final String DEFAULT_DISTANCE_METRIC = "COSINE"; - private final boolean initializeSchema; - private final JedisPooled jedis; - private final EmbeddingModel embeddingModel; + private final boolean initializeSchema; - private final RedisVectorStoreConfig config; + private final String indexName; + + private final String prefix; + + private final String contentFieldName; + + private final String embeddingFieldName; + + private final Algorithm vectorAlgorithm; + + private final List metadataFields; private final BatchingStrategy batchingStrategy; - private FilterExpressionConverter filterExpressionConverter; + private final FilterExpressionConverter filterExpressionConverter; + @Deprecated(since = "1.0.0-M5", forRemoval = true) public RedisVectorStore(RedisVectorStoreConfig config, EmbeddingModel embeddingModel, JedisPooled jedis, boolean initializeSchema) { - this(config, embeddingModel, jedis, initializeSchema, ObservationRegistry.NOOP, null, new TokenCountBatchingStrategy()); } + @Deprecated(since = "1.0.0-M5", forRemoval = true) public RedisVectorStore(RedisVectorStoreConfig config, EmbeddingModel embeddingModel, JedisPooled jedis, boolean initializeSchema, ObservationRegistry observationRegistry, VectorStoreObservationConvention customObservationConvention, BatchingStrategy batchingStrategy) { - super(observationRegistry, customObservationConvention); + this(builder().jedis(jedis) + .embeddingModel(embeddingModel) + .indexName(config.indexName) + .prefix(config.prefix) + .contentFieldName(config.contentFieldName) + .embeddingFieldName(config.embeddingFieldName) + .vectorAlgorithm(config.vectorAlgorithm) + .metadataFields(config.metadataFields) + .initializeSchema(initializeSchema) + .observationRegistry(observationRegistry) + .customObservationConvention(customObservationConvention) + .batchingStrategy(batchingStrategy)); + } - Assert.notNull(config, "Config must not be null"); - Assert.notNull(embeddingModel, "Embedding model must not be null"); - this.initializeSchema = initializeSchema; + protected RedisVectorStore(RedisBuilder builder) { + super(builder); - this.jedis = jedis; - this.embeddingModel = embeddingModel; - this.config = config; - this.filterExpressionConverter = new RedisFilterExpressionConverter(this.config.metadataFields); - this.batchingStrategy = batchingStrategy; + Assert.notNull(builder.jedis, "JedisPooled must not be null"); + + this.jedis = builder.jedis; + this.indexName = builder.indexName; + this.prefix = builder.prefix; + this.contentFieldName = builder.contentFieldName; + this.embeddingFieldName = builder.embeddingFieldName; + this.vectorAlgorithm = builder.vectorAlgorithm; + this.metadataFields = builder.metadataFields; + this.initializeSchema = builder.initializeSchema; + this.batchingStrategy = builder.batchingStrategy; + this.filterExpressionConverter = new RedisFilterExpressionConverter(this.metadataFields); } public JedisPooled getJedis() { @@ -168,8 +292,8 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements for (Document document : documents) { var fields = new HashMap(); - fields.put(this.config.embeddingFieldName, embeddings.get(documents.indexOf(document))); - fields.put(this.config.contentFieldName, document.getContent()); + fields.put(this.embeddingFieldName, embeddings.get(documents.indexOf(document))); + fields.put(this.contentFieldName, document.getContent()); fields.putAll(document.getMetadata()); pipeline.jsonSetWithEscape(key(document.getId()), JSON_SET_PATH, fields); } @@ -186,7 +310,7 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements } private String key(String id) { - return this.config.prefix + id; + return this.prefix + id; } @Override @@ -216,13 +340,13 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements String filter = nativeExpressionFilter(request); - String queryString = String.format(QUERY_FORMAT, filter, request.getTopK(), this.config.embeddingFieldName, + String queryString = String.format(QUERY_FORMAT, filter, request.getTopK(), this.embeddingFieldName, EMBEDDING_PARAM_NAME, DISTANCE_FIELD_NAME); List returnFields = new ArrayList<>(); - this.config.metadataFields.stream().map(MetadataField::name).forEach(returnFields::add); - returnFields.add(this.config.embeddingFieldName); - returnFields.add(this.config.contentFieldName); + this.metadataFields.stream().map(MetadataField::name).forEach(returnFields::add); + returnFields.add(this.embeddingFieldName); + returnFields.add(this.contentFieldName); returnFields.add(DISTANCE_FIELD_NAME); var embedding = this.embeddingModel.embed(request.getQuery()); Query query = new Query(queryString).addParam(EMBEDDING_PARAM_NAME, RediSearchUtil.toByteArray(embedding)) @@ -231,7 +355,7 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements .limit(0, request.getTopK()) .dialect(2); - SearchResult result = this.jedis.ftSearch(this.config.indexName, query); + SearchResult result = this.jedis.ftSearch(this.indexName, query); return result.getDocuments() .stream() .filter(d -> similarityScore(d) >= request.getSimilarityThreshold()) @@ -240,14 +364,12 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements } private Document toDocument(redis.clients.jedis.search.Document doc) { - var id = doc.getId().substring(this.config.prefix.length()); - var content = doc.hasProperty(this.config.contentFieldName) ? doc.getString(this.config.contentFieldName) : ""; - Map metadata = this.config.metadataFields.stream() + var id = doc.getId().substring(this.prefix.length()); + var content = doc.hasProperty(this.contentFieldName) ? doc.getString(this.contentFieldName) : ""; + Map metadata = this.metadataFields.stream() .map(MetadataField::name) .filter(doc::hasProperty) .collect(Collectors.toMap(Function.identity(), doc::getString)); - // TODO: this seems wrong. The key is named "vector_store", but the value is the - // distance. Can we remove this after standardizing the metadata? metadata.put(DISTANCE_FIELD_NAME, 1 - similarityScore(doc)); metadata.put(DocumentMetadata.DISTANCE.value(), 1 - similarityScore(doc)); return Document.builder().id(id).text(content).metadata(metadata).score((double) similarityScore(doc)).build(); @@ -272,12 +394,12 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements } // If index already exists don't do anything - if (this.jedis.ftList().contains(this.config.indexName)) { + if (this.jedis.ftList().contains(this.indexName)) { return; } - String response = this.jedis.ftCreate(this.config.indexName, - FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.config.prefix), schemaFields()); + String response = this.jedis.ftCreate(this.indexName, + FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.prefix), schemaFields()); if (!RESPONSE_OK.test(response)) { String message = MessageFormat.format("Could not create index: {0}", response); throw new RuntimeException(message); @@ -290,16 +412,16 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements vectorAttrs.put("DISTANCE_METRIC", DEFAULT_DISTANCE_METRIC); vectorAttrs.put("TYPE", VECTOR_TYPE_FLOAT32); List fields = new ArrayList<>(); - fields.add(TextField.of(jsonPath(this.config.contentFieldName)).as(this.config.contentFieldName).weight(1.0)); + fields.add(TextField.of(jsonPath(this.contentFieldName)).as(this.contentFieldName).weight(1.0)); fields.add(VectorField.builder() - .fieldName(jsonPath(this.config.embeddingFieldName)) + .fieldName(jsonPath(this.embeddingFieldName)) .algorithm(vectorAlgorithm()) .attributes(vectorAttrs) - .as(this.config.embeddingFieldName) + .as(this.embeddingFieldName) .build()); - if (!CollectionUtils.isEmpty(this.config.metadataFields)) { - for (MetadataField field : this.config.metadataFields) { + if (!CollectionUtils.isEmpty(this.metadataFields)) { + for (MetadataField field : this.metadataFields) { fields.add(schemaField(field)); } } @@ -318,7 +440,7 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements } private VectorAlgorithm vectorAlgorithm() { - if (this.config.vectorAlgorithm == Algorithm.HSNW) { + if (this.vectorAlgorithm == Algorithm.HSNW) { return VectorAlgorithm.HNSW; } return VectorAlgorithm.FLAT; @@ -332,9 +454,9 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements public VectorStoreObservationContext.Builder createObservationContextBuilder(String operationName) { return VectorStoreObservationContext.builder(VectorStoreProvider.REDIS.value(), operationName) - .withCollectionName(this.config.indexName) + .withCollectionName(this.indexName) .withDimensions(this.embeddingModel.dimensions()) - .withFieldName(this.config.embeddingFieldName) + .withFieldName(this.embeddingFieldName) .withSimilarityMetric(VectorStoreSimilarityMetric.COSINE.value()); } @@ -361,9 +483,151 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements } + public static RedisBuilder builder() { + return new RedisBuilder(); + } + + public static class RedisBuilder extends AbstractVectorStoreBuilder { + + private JedisPooled jedis; + + private String indexName = DEFAULT_INDEX_NAME; + + private String prefix = DEFAULT_PREFIX; + + private String contentFieldName = DEFAULT_CONTENT_FIELD_NAME; + + private String embeddingFieldName = DEFAULT_EMBEDDING_FIELD_NAME; + + private Algorithm vectorAlgorithm = DEFAULT_VECTOR_ALGORITHM; + + private List metadataFields = new ArrayList<>(); + + private boolean initializeSchema = false; + + private BatchingStrategy batchingStrategy = new TokenCountBatchingStrategy(); + + public RedisBuilder jedis(JedisPooled jedis) { + Assert.notNull(jedis, "JedisPooled must not be null"); + this.jedis = jedis; + return this; + } + + /** + * Sets the Redis index name. + * @param indexName the index name to use + * @return the builder instance + */ + public RedisBuilder indexName(String indexName) { + if (StringUtils.hasText(indexName)) { + this.indexName = indexName; + } + return this; + } + + /** + * Sets the Redis key prefix (default: "embedding:"). + * @param prefix the prefix to use + * @return the builder instance + */ + public RedisBuilder prefix(String prefix) { + if (StringUtils.hasText(prefix)) { + this.prefix = prefix; + } + return this; + } + + /** + * Sets the Redis content field name. + * @param fieldName the content field name to use + * @return the builder instance + */ + public RedisBuilder contentFieldName(String fieldName) { + if (StringUtils.hasText(fieldName)) { + this.contentFieldName = fieldName; + } + return this; + } + + /** + * Sets the Redis embedding field name. + * @param fieldName the embedding field name to use + * @return the builder instance + */ + public RedisBuilder embeddingFieldName(String fieldName) { + if (StringUtils.hasText(fieldName)) { + this.embeddingFieldName = fieldName; + } + return this; + } + + /** + * Sets the Redis vector algorithm. + * @param algorithm the vector algorithm to use + * @return the builder instance + */ + public RedisBuilder vectorAlgorithm(Algorithm algorithm) { + if (algorithm != null) { + this.vectorAlgorithm = algorithm; + } + return this; + } + + /** + * Sets the metadata fields. + * @param fields the metadata fields to include + * @return the builder instance + */ + public RedisBuilder metadataFields(MetadataField... fields) { + return metadataFields(Arrays.asList(fields)); + } + + /** + * Sets the metadata fields. + * @param fields the list of metadata fields to include + * @return the builder instance + */ + public RedisBuilder metadataFields(List fields) { + if (fields != null && !fields.isEmpty()) { + this.metadataFields = new ArrayList<>(fields); + } + return this; + } + + /** + * Sets whether to initialize the schema. + * @param initializeSchema true to initialize schema, false otherwise + * @return the builder instance + */ + public RedisBuilder initializeSchema(boolean initializeSchema) { + this.initializeSchema = initializeSchema; + return this; + } + + /** + * Sets the batching strategy. + * @param batchingStrategy the strategy to use + * @return the builder instance + * @throws IllegalArgumentException if batchingStrategy is null + */ + public RedisBuilder batchingStrategy(BatchingStrategy batchingStrategy) { + Assert.notNull(batchingStrategy, "BatchingStrategy must not be null"); + this.batchingStrategy = batchingStrategy; + return this; + } + + @Override + public RedisVectorStore build() { + validate(); + return new RedisVectorStore(this); + } + + } + /** * Configuration for the Redis vector store. */ + @Deprecated(since = "1.0.0-M5", forRemoval = true) public static final class RedisVectorStoreConfig { private final String indexName; @@ -395,19 +659,20 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements * Start building a new configuration. * @return The entry point for creating a new configuration. */ + @Deprecated(since = "1.0.0-M5", forRemoval = true) public static Builder builder() { - return new Builder(); } /** * {@return the default config} */ + @Deprecated(since = "1.0.0-M5", forRemoval = true) public static RedisVectorStoreConfig defaultConfig() { - return builder().build(); } + @Deprecated(since = "1.0.0-M5", forRemoval = true) public static final class Builder { private String indexName = DEFAULT_INDEX_NAME; diff --git a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisFilterExpressionConverterTests.java similarity index 71% rename from vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java rename to vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisFilterExpressionConverterTests.java index 07e8ef0e9..28680c05d 100644 --- a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java +++ b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisFilterExpressionConverterTests.java @@ -14,14 +14,14 @@ * limitations under the License. */ -package org.springframework.ai.vectorstore; +package org.springframework.ai.vectorstore.redis; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; -import org.springframework.ai.vectorstore.RedisVectorStore.MetadataField; +import org.springframework.ai.vectorstore.redis.RedisVectorStore.MetadataField; import org.springframework.ai.vectorstore.filter.Filter.Expression; import org.springframework.ai.vectorstore.filter.Filter.Group; import org.springframework.ai.vectorstore.filter.Filter.Key; @@ -49,7 +49,7 @@ class RedisFilterExpressionConverterTests { @Test void testEQ() { // country == "BG" - String vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("country")) + String vectorExpr = converter(RedisVectorStore.MetadataField.tag("country")) .convertExpression(new Expression(EQ, new Key("country"), new Value("BG"))); assertThat(vectorExpr).isEqualTo("@country:{BG}"); } @@ -57,8 +57,8 @@ class RedisFilterExpressionConverterTests { @Test void tesEqAndGte() { // genre == "drama" AND year >= 2020 - String vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("genre"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.numeric("year")) + String vectorExpr = converter(RedisVectorStore.MetadataField.tag("genre"), + RedisVectorStore.MetadataField.numeric("year")) .convertExpression(new Expression(AND, new Expression(EQ, new Key("genre"), new Value("drama")), new Expression(GTE, new Key("year"), new Value(2020)))); assertThat(vectorExpr).isEqualTo("@genre:{drama} @year:[2020 inf]"); @@ -67,18 +67,16 @@ class RedisFilterExpressionConverterTests { @Test void tesIn() { // genre in ["comedy", "documentary", "drama"] - String vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("genre")) - .convertExpression( - new Expression(IN, new Key("genre"), new Value(List.of("comedy", "documentary", "drama")))); + String vectorExpr = converter(RedisVectorStore.MetadataField.tag("genre")).convertExpression( + new Expression(IN, new Key("genre"), new Value(List.of("comedy", "documentary", "drama")))); assertThat(vectorExpr).isEqualTo("@genre:{comedy | documentary | drama}"); } @Test void testNe() { // year >= 2020 OR country == "BG" AND city != "Sofia" - String vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.numeric("year"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("country"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("city")) + String vectorExpr = converter(RedisVectorStore.MetadataField.numeric("year"), + RedisVectorStore.MetadataField.tag("country"), RedisVectorStore.MetadataField.tag("city")) .convertExpression(new Expression(OR, new Expression(GTE, new Key("year"), new Value(2020)), new Group(new Expression(AND, new Expression(EQ, new Key("country"), new Value("BG")), new Expression(NE, new Key("city"), new Value("Sofia")))))); @@ -88,9 +86,8 @@ class RedisFilterExpressionConverterTests { @Test void testGroup() { // (year >= 2020 OR country == "BG") AND city NIN ["Sofia", "Plovdiv"] - String vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.numeric("year"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("country"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("city")) + String vectorExpr = converter(RedisVectorStore.MetadataField.numeric("year"), + RedisVectorStore.MetadataField.tag("country"), RedisVectorStore.MetadataField.tag("city")) .convertExpression(new Expression(AND, new Group(new Expression(OR, new Expression(GTE, new Key("year"), new Value(2020)), new Expression(EQ, new Key("country"), new Value("BG")))), @@ -101,9 +98,8 @@ class RedisFilterExpressionConverterTests { @Test void tesBoolean() { // isOpen == true AND year >= 2020 AND country IN ["BG", "NL", "US"] - String vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.numeric("year"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("country"), - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("isOpen")) + String vectorExpr = converter(RedisVectorStore.MetadataField.numeric("year"), + RedisVectorStore.MetadataField.tag("country"), RedisVectorStore.MetadataField.tag("isOpen")) .convertExpression(new Expression(AND, new Expression(AND, new Expression(EQ, new Key("isOpen"), new Value(true)), new Expression(GTE, new Key("year"), new Value(2020))), @@ -115,8 +111,7 @@ class RedisFilterExpressionConverterTests { @Test void testDecimal() { // temperature >= -15.6 && temperature <= +20.13 - String vectorExpr = converter( - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.numeric("temperature")) + String vectorExpr = converter(RedisVectorStore.MetadataField.numeric("temperature")) .convertExpression(new Expression(AND, new Expression(GTE, new Key("temperature"), new Value(-15.6)), new Expression(LTE, new Key("temperature"), new Value(20.13)))); @@ -125,12 +120,11 @@ class RedisFilterExpressionConverterTests { @Test void testComplexIdentifiers() { - String vectorExpr = converter( - org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("country 1 2 3")) + String vectorExpr = converter(RedisVectorStore.MetadataField.tag("country 1 2 3")) .convertExpression(new Expression(EQ, new Key("\"country 1 2 3\""), new Value("BG"))); assertThat(vectorExpr).isEqualTo("@\"country 1 2 3\":{BG}"); - vectorExpr = converter(org.springframework.ai.vectorstore.RedisVectorStore.MetadataField.tag("country 1 2 3")) + vectorExpr = converter(RedisVectorStore.MetadataField.tag("country 1 2 3")) .convertExpression(new Expression(EQ, new Key("'country 1 2 3'"), new Value("BG"))); assertThat(vectorExpr).isEqualTo("@'country 1 2 3':{BG}"); } diff --git a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreIT.java similarity index 94% rename from vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java rename to vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreIT.java index 6c389b672..250f07b25 100644 --- a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java +++ b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreIT.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.vectorstore; +package org.springframework.ai.vectorstore.redis; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -34,8 +34,9 @@ import redis.clients.jedis.JedisPooled; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.transformers.TransformersEmbeddingModel; -import org.springframework.ai.vectorstore.RedisVectorStore.MetadataField; -import org.springframework.ai.vectorstore.RedisVectorStore.RedisVectorStoreConfig; +import org.springframework.ai.vectorstore.redis.RedisVectorStore.MetadataField; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -255,13 +256,13 @@ class RedisVectorStoreIT { @Bean public RedisVectorStore vectorStore(EmbeddingModel embeddingModel, JedisConnectionFactory jedisConnectionFactory) { - return new RedisVectorStore( - RedisVectorStoreConfig.builder() - .withMetadataFields(MetadataField.tag("meta1"), MetadataField.tag("meta2"), - MetadataField.tag("country"), MetadataField.numeric("year")) - .build(), - embeddingModel, - new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort()), true); + return RedisVectorStore.builder() + .jedis(new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort())) + .embeddingModel(embeddingModel) + .metadataFields(MetadataField.tag("meta1"), MetadataField.tag("meta2"), MetadataField.tag("country"), + MetadataField.numeric("year")) + .initializeSchema(true) + .build(); } @Bean diff --git a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreObservationIT.java b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreObservationIT.java similarity index 91% rename from vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreObservationIT.java rename to vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreObservationIT.java index 2d2ed538c..b88928f04 100644 --- a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreObservationIT.java +++ b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreObservationIT.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.vectorstore; +package org.springframework.ai.vectorstore.redis; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -38,8 +38,9 @@ import org.springframework.ai.observation.conventions.SpringAiKind; import org.springframework.ai.observation.conventions.VectorStoreProvider; import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric; import org.springframework.ai.transformers.TransformersEmbeddingModel; -import org.springframework.ai.vectorstore.RedisVectorStore.MetadataField; -import org.springframework.ai.vectorstore.RedisVectorStore.RedisVectorStoreConfig; +import org.springframework.ai.vectorstore.redis.RedisVectorStore.MetadataField; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.observation.DefaultVectorStoreObservationConvention; import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.HighCardinalityKeyNames; import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.LowCardinalityKeyNames; @@ -175,14 +176,16 @@ public class RedisVectorStoreObservationIT { @Bean public RedisVectorStore vectorStore(EmbeddingModel embeddingModel, JedisConnectionFactory jedisConnectionFactory, ObservationRegistry observationRegistry) { - return new RedisVectorStore( - RedisVectorStoreConfig.builder() - .withMetadataFields(MetadataField.tag("meta1"), MetadataField.tag("meta2"), - MetadataField.tag("country"), MetadataField.numeric("year")) - .build(), - embeddingModel, - new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort()), true, - observationRegistry, null, new TokenCountBatchingStrategy()); + return RedisVectorStore.builder() + .jedis(new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort())) + .embeddingModel(embeddingModel) + .observationRegistry(observationRegistry) + .customObservationConvention(null) + .initializeSchema(true) + .batchingStrategy(new TokenCountBatchingStrategy()) + .metadataFields(MetadataField.tag("meta1"), MetadataField.tag("meta2"), MetadataField.tag("country"), + MetadataField.numeric("year")) + .build(); } @Bean