feat(vector-store) Add MariaDB Vector Store support to Spring AI

- Updated `org.springframework.boot.autoconfigure.AutoConfiguration.imports` to include MariaDB vector store auto-configuration
- Created MariaDB Vector Store autoconfiguration integration tests (`MariaDbStoreAutoConfigurationIT`)
- Added MariaDB store properties configuration and tests (`MariaDbStorePropertiesTests`)
- Introduced new Maven modules:
  - `spring-ai-mariadb-store`: Core MariaDB vector store implementation
  - `spring-ai-starter-mariadb-store`: Spring Boot starter for MariaDB vector store
- Added `MariaDBFilterExpressionConverter` to support JSON-based metadata filtering in MariaDB
- Implemented filter expression conversion for MariaDB vector store queries
- Added README.md with documentation link for MariaDB Vector Store
- Updated project dependencies to include MariaDB JDBC driver and test containers
- Configured integration testing with TestContainers for MariaDB
- Added observability support for MariaDB vector store operations
This commit is contained in:
diego
2024-11-26 12:34:17 +01:00
committed by Christian Tzolov
parent d0a0c878f8
commit 0b00e6f446
27 changed files with 2916 additions and 16 deletions

View File

@@ -25,7 +25,7 @@ You can find more details in the [Reference Documentation](https://docs.spring.i
- [Moderation](https://docs.spring.io/spring-ai/reference/api/index.html#api/moderation)
* Portable API support across AI providers for both synchronous and streaming API options are supported. Access to [model-specific features](https://docs.spring.io/spring-ai/reference/api/chatmodel.html#_chat_options) is also available.
* [Structured Outputs](https://docs.spring.io/spring-ai/reference/api/structured-output-converter.html) - Mapping of AI Model output to POJOs.
* Support for all major [Vector Database providers](https://docs.spring.io/spring-ai/reference/api/vectordbs.html) such as *Apache Cassandra, Azure Vector Search, Chroma, Milvus, MongoDB Atlas, Neo4j, Oracle, PostgreSQL/PGVector, PineCone, Qdrant, Redis, and Weaviate*.
* Support for all major [Vector Database providers](https://docs.spring.io/spring-ai/reference/api/vectordbs.html) such as *Apache Cassandra, Azure Vector Search, Chroma, Milvus, MongoDB Atlas, MariaDB, Neo4j, Oracle, PostgreSQL/PGVector, PineCone, Qdrant, Redis, and Weaviate*.
* Portable API across Vector Store providers, including a novel SQL-like [metadata filter API](https://docs.spring.io/spring-ai/reference/api/vectordbs.html#metadata-filters).
* [Tools/Function Calling](https://docs.spring.io/spring-ai/reference/api/functions.html) - permits the model to request the execution of client-side tools and functions, thereby accessing necessary real-time information as required.
* [Observability](https://docs.spring.io/spring-ai/reference/observability/index.html) - Provides insights into AI-related operations.

29
pom.xml
View File

@@ -52,6 +52,7 @@
<module>vector-stores/spring-ai-elasticsearch-store</module>
<module>vector-stores/spring-ai-gemfire-store</module>
<module>vector-stores/spring-ai-hanadb-store</module>
<module>vector-stores/spring-ai-mariadb-store</module>
<module>vector-stores/spring-ai-milvus-store</module>
<module>vector-stores/spring-ai-mongodb-atlas-store</module>
<module>vector-stores/spring-ai-neo4j-store</module>
@@ -73,6 +74,7 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-elasticsearch-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-gemfire-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-hanadb-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-mariadb-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-milvus-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-mongodb-atlas-store</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-neo4j-store</module>
@@ -212,6 +214,7 @@
<qdrant.version>1.9.1</qdrant.version>
<typesense.version>0.5.0</typesense.version>
<opensearch-client.version>2.10.1</opensearch-client.version>
<mariadb.version>3.5.1</mariadb.version>
<commonmark.version>0.22.0</commonmark.version>
<!-- also managed by boot bom -->
@@ -678,13 +681,13 @@
<exclude>org.springframework.ai.vectorstore**/Hana**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Hana**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Milvus**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Mongo**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/MariaDB**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Mongo**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Neo4j**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/OpenSearch**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Oracle**IT.java</exclude>
<!-- <exclude>org.springframework.ai.vectorstore**/PgVector**IT.java</exclude> -->
<exclude>org.springframework.ai.vectorstore**/Pinecone**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Pinecone**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore.qdrant/**/**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Qdrant**IT.java</exclude>
<exclude>org.springframework.ai.vectorstore**/Redis**IT.java</exclude>
@@ -692,14 +695,14 @@
<exclude>org.springframework.ai.vectorstore**/Weaviate**IT.java</exclude>
<!-- Auto-configurations-->
<!-- <exclude>org.springframework.ai.autoconfigure/**/**IT.java</exclude> -->
<exclude>org.springframework.ai.autoconfigure.anthropic/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.azure/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.bedrock/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.huggingface/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.chat/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.embedding/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.image/**/**IT.java</exclude>
@@ -713,24 +716,24 @@
<!-- <exclude>org.springframework.ai.autoconfigure.openai/**/**IT.java</exclude> -->
<exclude>org.springframework.ai.autoconfigure.postgresml/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.qianfan/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.retry/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.stabilityai/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.transformers/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.vectorstore/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.vectorstore/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.vertexai/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.watsonxai/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.zhipuai/**/**IT.java</exclude>
<exclude>org.springframework.ai.autoconfigure.zhipuai/**/**IT.java</exclude>
<!-- Test Containers -->
<!-- Test Containers -->
<exclude>org.springframework.ai.testcontainers/**/**IT.java</exclude>
<!-- Test Docker Compose -->
<!-- Test Docker Compose -->
<exclude>package org.springframework.ai.docker.compose/**/**IT.java</exclude>
<!-- AI Evaluation -->

View File

@@ -285,6 +285,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mariadb-store</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.springframework.ai</groupId>
@@ -455,6 +461,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mariadb-store-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-stability-ai-spring-boot-starter</artifactId>

View File

@@ -67,6 +67,11 @@ public enum VectorStoreProvider {
*/
HANA("hana"),
/**
* Vector store provided by MariaDB.
*/
MARIADB("mariadb"),
/**
* Vector store provided by Milvus.
*/

View File

@@ -80,6 +80,7 @@
** xref:api/vectordbs/chroma.adoc[]
** xref:api/vectordbs/elasticsearch.adoc[]
** xref:api/vectordbs/gemfire.adoc[GemFire]
** xref:api/vectordbs/mariadb.adoc[]
** xref:api/vectordbs/milvus.adoc[]
** xref:api/vectordbs/mongodb.adoc[]
** xref:api/vectordbs/neo4j.adoc[]

View File

@@ -207,6 +207,7 @@ These are the available implementations of the `VectorStore` interface:
* xref:api/vectordbs/chroma.adoc[Chroma Vector Store] - The https://www.trychroma.com/[Chroma] vector store.
* xref:api/vectordbs/elasticsearch.adoc[Elasticsearch Vector Store] - The https://www.elastic.co/[Elasticsearch] vector store.
* xref:api/vectordbs/gemfire.adoc[GemFire Vector Store] - The https://tanzu.vmware.com/content/blog/vmware-gemfire-vector-database-extension[GemFire] vector store.
* xref:api/vectordbs/mariadb.adoc[MariaDB Vector Store] - The https://mariadb.com/[MariaDB] vector store.
* xref:api/vectordbs/milvus.adoc[Milvus Vector Store] - The https://milvus.io/[Milvus] vector store.
* xref:api/vectordbs/mongodb.adoc[MongoDB Atlas Vector Store] - The https://www.mongodb.com/atlas/database[MongoDB Atlas] vector store.
* xref:api/vectordbs/neo4j.adoc[Neo4j Vector Store] - The https://neo4j.com/[Neo4j] vector store.

View File

@@ -0,0 +1,187 @@
= MariaDB Vector
This section walks you through setting up the MariaDB `VectorStore` to store document embeddings and perform similarity searches.
link:https://mariadb.org/projects/mariadb-vector/[MariaDB vector] is part of MariaDB 11.7 and enables storing and searching over machine learning-generated embeddings.
== Auto-Configuration
Add the MariaDBVectorStore boot starter dependency to your project:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mariadb-store-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-mariadb-store-spring-boot-starter'
}
----
The vector store implementation can initialize the required 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.
The Vector Store also requires an `EmbeddingModel` instance to calculate embeddings for the documents.
You can pick one of the available xref:api/embeddings.adoc#available-implementations[EmbeddingModel Implementations].
For example, to use the xref:api/embeddings/openai-embeddings.adoc[OpenAI EmbeddingModel], add the following dependency to your project:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file.
To connect to and configure the `MariaDBVectorStore`, you need to provide access details for your instance.
A simple configuration can be provided via Spring Boot's `application.yml`.
[yml]
----
spring:
datasource:
url: jdbc:mariadb://localhost/db
username: myUser
password: myPassword
ai:
vectorstore:
mariadbvector:
distance-type: COSINE
dimensions: 1536
----
TIP: If you run MariaDBvector as a Spring Boot dev service via link:https://docs.spring.io/spring-boot/reference/features/dev-services.html#features.dev-services.docker-compose[Docker Compose]
or link:https://docs.spring.io/spring-boot/reference/features/dev-services.html#features.dev-services.testcontainers[Testcontainers],
you don't need to configure URL, username and password since they are autoconfigured by Spring Boot.
TIP: Check the list of xref:#mariadbvector-properties[configuration parameters] to learn about the default values and configuration options.
Now you can auto-wire the `MariaDBVectorStore` in your application and use it
[source,java]
----
@Autowired VectorStore vectorStore;
// ...
List<Document> documents = List.of(
new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("meta1", "meta1")),
new Document("The World is Big and Salvation Lurks Around the Corner"),
new Document("You walk forward facing the past and you turn back toward the future.", Map.of("meta2", "meta2")));
// Add the documents to PGVector
vectorStore.add(documents);
// Retrieve documents similar to a query
List<Document> results = this.vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5));
----
[[mariadbvector-properties]]
=== Configuration properties
You can use the following properties in your Spring Boot configuration to customize the MariaDB vector store.
[cols="2,5,1",stripes=even]
|===
|Property| Description | Default value
|`spring.ai.vectorstore.mariadb.distance-type`| Search distance type. Defaults to `COSINE`. But if vectors are normalized to length 1, you can use `EUCLIDEAN` for best performance.| COSINE
|`spring.ai.vectorstore.mariadb.dimensions`| Embeddings dimension. If not specified explicitly the PgVectorStore will retrieve the dimensions form the provided `EmbeddingModel`. Dimensions are set to the embedding column the on table creation. If you change the dimensions your would have to re-create the vector_store table as well. | -
|`spring.ai.vectorstore.mariadb.remove-existing-vector-store-table` | Deletes the existing `vector_store` table on start up. | false
|`spring.ai.vectorstore.mariadb.initialize-schema` | Whether to initialize the required schema | false
|`spring.ai.vectorstore.mariadb.schema-name` | Vector store schema name | null
|`spring.ai.vectorstore.mariadb.table-name` | Vector store table name | `vector_store`
|`spring.ai.vectorstore.mariadb.schema-validation` | Enables schema and table name validation to ensure they are valid and existing objects. | false
|===
TIP: If you configure a custom schema and/or table name, consider enabling schema validation by setting `spring.ai.vectorstore.mariadb.schema-validation=true`.
This ensures the correctness of the names and reduces the risk of SQL injection attacks.
== Metadata filtering
You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with the MariaDB Vector store.
For example, you can use either the text expression language:
[source,java]
----
vectorStore.similaritySearch(
SearchRequest.defaults()
.withQuery("The World")
.withTopK(TOP_K)
.withSimilarityThreshold(SIMILARITY_THRESHOLD)
.withFilterExpression("author in ['john', 'jill'] && article_type == 'blog'"));
----
or programmatically using the `Filter.Expression` DSL:
[source,java]
----
FilterExpressionBuilder b = new FilterExpressionBuilder();
vectorStore.similaritySearch(SearchRequest.defaults()
.withQuery("The World")
.withTopK(TOP_K)
.withSimilarityThreshold(SIMILARITY_THRESHOLD)
.withFilterExpression(b.and(
b.in("author","john", "jill"),
b.eq("article_type", "blog")).build()));
----
NOTE: These filter expressions are converted into the equivalent PgVector filters.
== Manual Configuration
Instead of using the Spring Boot auto-configuration, you can manually configure the `MariaDBVectorStore`.
For this you need to add the MariaDB connector and `JdbcTemplate` auto-configuration dependencies to your project:
[source,xml]
----
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mariadb-store</artifactId>
</dependency>
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
To configure MariaDB Vector in your application, you can use the following setup:
[source,java]
----
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
return new MariaDBVectorStore(jdbcTemplate, embeddingModel);
}
----

View File

@@ -1,5 +1,4 @@
[[introduction]]
= Introduction
image::spring_ai_logo_with_text.svg[Integration Problem, width=300, align="left"]
@@ -26,7 +25,7 @@ Spring AI provides the following features:
** xref:api/audio/speech.adoc[Text to Speech]
** xref:api/moderation[Moderation]
* xref:api/structured-output-converter.adoc[Structured Outputs] - Mapping of AI Model output to POJOs.
* Support for all major xref:api/vectordbs.adoc[Vector Database providers] such as Apache Cassandra, Azure Cosmos DB, Azure Vector Search, Chroma, Elasticsearch, GemFire, Milvus, MongoDB Atlas, Neo4j, OpenSearch, Oracle, PostgreSQL/PGVector, PineCone, Qdrant, Redis, SAP Hana, Typesense and Weaviate.
* Support for all major xref:api/vectordbs.adoc[Vector Database providers] such as Apache Cassandra, Azure Cosmos DB, Azure Vector Search, Chroma, Elasticsearch, GemFire, MariaDB, Milvus, MongoDB Atlas, Neo4j, OpenSearch, Oracle, PostgreSQL/PGVector, PineCone, Qdrant, Redis, SAP Hana, Typesense and Weaviate.
* Portable API across Vector Store providers, including a novel SQL-like metadata filter API.
* xref:api/functions.adoc[Tools/Function Calling] - permits the model to request the execution of client-side tools and functions, thereby accessing necessary real-time information as required.
* xref:observability/index.adoc[Observability] - Provides insights into AI-related operations.

View File

@@ -178,6 +178,20 @@
<optional>true</optional>
</dependency>
<!-- MariaDB Vector Store-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mariadb-store</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>${mariadb.version}</version>
<optional>true</optional>
</dependency>
<!-- Chroma Vector Store -->
<dependency>
<groupId>org.springframework.ai</groupId>
@@ -457,6 +471,12 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.mariadb;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.embedding.BatchingStrategy;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.TokenCountBatchingStrategy;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
/**
* @author Diego Dupin
* @since 1.0.0
*/
@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class)
@ConditionalOnClass({ MariaDBVectorStore.class, DataSource.class, JdbcTemplate.class })
@EnableConfigurationProperties(MariaDbStoreProperties.class)
public class MariaDbStoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean(BatchingStrategy.class)
BatchingStrategy mariaDbStoreBatchingStrategy() {
return new TokenCountBatchingStrategy();
}
@Bean
@ConditionalOnMissingBean
public MariaDBVectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel,
MariaDbStoreProperties properties, ObjectProvider<ObservationRegistry> observationRegistry,
ObjectProvider<VectorStoreObservationConvention> customObservationConvention,
BatchingStrategy batchingStrategy) {
var initializeSchema = properties.isInitializeSchema();
return new MariaDBVectorStore.Builder(jdbcTemplate, embeddingModel).withSchemaName(properties.getSchemaName())
.withVectorTableName(properties.getTableName())
.withVectorTableValidationsEnabled(properties.isSchemaValidation())
.withDimensions(properties.getDimensions())
.withDistanceType(properties.getDistanceType())
.withContentFieldName(properties.getContentFieldName())
.withEmbeddingFieldName(properties.getEmbeddingFieldName())
.withIdFieldName(properties.getIdFieldName())
.withMetadataFieldName(properties.getMetadataFieldName())
.withRemoveExistingVectorStoreTable(properties.isRemoveExistingVectorStoreTable())
.withInitializeSchema(initializeSchema)
.withObservationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
.withSearchObservationConvention(customObservationConvention.getIfAvailable(() -> null))
.withBatchingStrategy(batchingStrategy)
.withMaxDocumentBatchSize(properties.getMaxDocumentBatchSize())
.build();
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.mariadb;
import org.springframework.ai.autoconfigure.vectorstore.CommonVectorStoreProperties;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore.MariaDBDistanceType;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Diego Dupin
*/
@ConfigurationProperties(MariaDbStoreProperties.CONFIG_PREFIX)
public class MariaDbStoreProperties extends CommonVectorStoreProperties {
public static final String CONFIG_PREFIX = "spring.ai.vectorstore.mariadb";
private int dimensions = MariaDBVectorStore.INVALID_EMBEDDING_DIMENSION;
private MariaDBDistanceType distanceType = MariaDBDistanceType.COSINE;
private boolean removeExistingVectorStoreTable = false;
private String tableName = MariaDBVectorStore.DEFAULT_TABLE_NAME;
private String schemaName = null;
private String embeddingFieldName = MariaDBVectorStore.DEFAULT_COLUMN_EMBEDDING;
private String idFieldName = MariaDBVectorStore.DEFAULT_COLUMN_ID;
private String metadataFieldName = MariaDBVectorStore.DEFAULT_COLUMN_METADATA;
private String contentFieldName = MariaDBVectorStore.DEFAULT_COLUMN_CONTENT;
private boolean schemaValidation = MariaDBVectorStore.DEFAULT_SCHEMA_VALIDATION;
private int maxDocumentBatchSize = MariaDBVectorStore.MAX_DOCUMENT_BATCH_SIZE;
public int getDimensions() {
return this.dimensions;
}
public void setDimensions(int dimensions) {
this.dimensions = dimensions;
}
public MariaDBVectorStore.MariaDBDistanceType getDistanceType() {
return this.distanceType;
}
public void setDistanceType(MariaDBDistanceType distanceType) {
this.distanceType = distanceType;
}
public boolean isRemoveExistingVectorStoreTable() {
return this.removeExistingVectorStoreTable;
}
public void setRemoveExistingVectorStoreTable(boolean removeExistingVectorStoreTable) {
this.removeExistingVectorStoreTable = removeExistingVectorStoreTable;
}
public String getTableName() {
return this.tableName;
}
public void setTableName(String vectorTableName) {
this.tableName = vectorTableName;
}
public String getSchemaName() {
return this.schemaName;
}
public void setSchemaName(String schemaName) {
this.schemaName = schemaName;
}
public boolean isSchemaValidation() {
return this.schemaValidation;
}
public void setSchemaValidation(boolean schemaValidation) {
this.schemaValidation = schemaValidation;
}
public int getMaxDocumentBatchSize() {
return this.maxDocumentBatchSize;
}
public void setMaxDocumentBatchSize(int maxDocumentBatchSize) {
this.maxDocumentBatchSize = maxDocumentBatchSize;
}
public String getEmbeddingFieldName() {
return embeddingFieldName;
}
public void setEmbeddingFieldName(String embeddingFieldName) {
this.embeddingFieldName = embeddingFieldName;
}
public String getIdFieldName() {
return idFieldName;
}
public void setIdFieldName(String idFieldName) {
this.idFieldName = idFieldName;
}
public String getMetadataFieldName() {
return metadataFieldName;
}
public void setMetadataFieldName(String metadataFieldName) {
this.metadataFieldName = metadataFieldName;
}
public String getContentFieldName() {
return contentFieldName;
}
public void setContentFieldName(String contentFieldName) {
this.contentFieldName = contentFieldName;
}
}

View File

@@ -47,6 +47,7 @@ org.springframework.ai.autoconfigure.vectorstore.neo4j.Neo4jVectorStoreAutoConfi
org.springframework.ai.autoconfigure.vectorstore.qdrant.QdrantVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.hanadb.HanaCloudVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.cosmosdb.CosmosDBVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.mariadb.MariaDbStoreAutoConfiguration
org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration
org.springframework.ai.autoconfigure.postgresml.PostgresMlAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.mongo.MongoDBAtlasVectorStoreAutoConfiguration

View File

@@ -0,0 +1,196 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.mariadb;
import io.micrometer.observation.tck.TestObservationRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.observation.conventions.VectorStoreProvider;
import org.springframework.ai.transformers.TransformersEmbeddingModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.autoconfigure.vectorstore.observation.ObservationTestUtil.assertObservationRegistry;
/**
* @author Diego Dupin
*/
@Testcontainers
public class MariaDbStoreAutoConfigurationIT {
public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("mariadb:11.7-rc");
@Container
@SuppressWarnings("resource")
static MariaDBContainer<?> mariadbContainer = new MariaDBContainer<>(DEFAULT_IMAGE);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MariaDbStoreAutoConfiguration.class,
JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class))
.withUserConfiguration(Config.class)
.withPropertyValues("spring.ai.vectorstore.mariadb.distanceType=COSINE",
"spring.ai.vectorstore.mariadb.initialize-schema=true",
// JdbcTemplate configuration
"spring.datasource.url=" + mariadbContainer.getJdbcUrl(),
"spring.datasource.username=" + mariadbContainer.getUsername(),
"spring.datasource.password=" + mariadbContainer.getPassword());
List<Document> documents = List.of(
new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")),
new Document(getText("classpath:/test/data/time.shelter.txt")),
new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("depression", "bad")));
public static String getText(String uri) {
var resource = new DefaultResourceLoader().getResource(uri);
try {
return resource.getContentAsString(StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private static boolean isFullyQualifiedTableExists(ApplicationContext context, String schemaName,
String tableName) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
if (schemaName == null) {
String sqlWithoutSchema = "SELECT EXISTS (SELECT * FROM information_schema.tables WHERE table_schema = SCHEMA() AND table_name = ?) as results";
return jdbcTemplate.queryForObject(sqlWithoutSchema, Boolean.class, tableName);
}
else {
String sqlWithSchema = "SELECT EXISTS (SELECT * FROM information_schema.tables WHERE table_schema = ? AND table_name = ?) as results";
return jdbcTemplate.queryForObject(sqlWithSchema, Boolean.class, schemaName, tableName);
}
}
@Test
public void addAndSearch() {
this.contextRunner.run(context -> {
MariaDBVectorStore vectorStore = context.getBean(MariaDBVectorStore.class);
TestObservationRegistry observationRegistry = context.getBean(TestObservationRegistry.class);
assertThat(isFullyQualifiedTableExists(context, null, MariaDBVectorStore.DEFAULT_TABLE_NAME)).isTrue();
vectorStore.add(this.documents);
assertObservationRegistry(observationRegistry, VectorStoreProvider.MARIADB,
VectorStoreObservationContext.Operation.ADD);
observationRegistry.clear();
List<Document> results = vectorStore
.similaritySearch(SearchRequest.query("What is Great Depression?").withTopK(1));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(this.documents.get(2).getId());
assertThat(resultDoc.getMetadata()).containsKeys("depression", "distance");
assertObservationRegistry(observationRegistry, VectorStoreProvider.MARIADB,
VectorStoreObservationContext.Operation.QUERY);
observationRegistry.clear();
// Remove all documents from the store
vectorStore.delete(this.documents.stream().map(doc -> doc.getId()).toList());
assertObservationRegistry(observationRegistry, VectorStoreProvider.MARIADB,
VectorStoreObservationContext.Operation.DELETE);
results = vectorStore.similaritySearch(SearchRequest.query("Great Depression").withTopK(1));
assertThat(results).hasSize(0);
observationRegistry.clear();
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "test:vector_store:id:metadata:embedding:content",
"test:my_table:my_id:my_metadata:my_embedding:my_content" })
public void customSchemaNames(String schemaTableName) {
String schemaName = schemaTableName.split(":")[0];
String tableName = schemaTableName.split(":")[1];
String idName = schemaTableName.split(":")[2];
String metaName = schemaTableName.split(":")[3];
String embeddingName = schemaTableName.split(":")[4];
String contentName = schemaTableName.split(":")[5];
this.contextRunner
.withPropertyValues("spring.ai.vectorstore.mariadb.schema-name=" + schemaName,
"spring.ai.vectorstore.mariadb.table-name=" + tableName,
"spring.ai.vectorstore.mariadb.id-field-name=" + idName,
"spring.ai.vectorstore.mariadb.metadata-field-name=" + metaName,
"spring.ai.vectorstore.mariadb.embedding-field-name=" + embeddingName,
"spring.ai.vectorstore.mariadb.content-field-name=" + contentName)
.run(context -> {
assertThat(isFullyQualifiedTableExists(context, schemaName, tableName)).isTrue();
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "test:vector_store", "test:my_table" })
public void disableSchemaInitialization(String schemaTableName) {
String schemaName = schemaTableName.split(":")[0];
String tableName = schemaTableName.split(":")[1];
this.contextRunner
.withPropertyValues("spring.ai.vectorstore.mariadb.schema-name=" + schemaName,
"spring.ai.vectorstore.mariadb.table-name=" + tableName,
"spring.ai.vectorstore.mariadb.initialize-schema=false")
.run(context -> {
assertThat(isFullyQualifiedTableExists(context, schemaName, tableName)).isFalse();
});
}
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean
public TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
@Bean
public EmbeddingModel embeddingModel() {
return new TransformersEmbeddingModel();
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.mariadb;
import org.junit.jupiter.api.Test;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore.MariaDBDistanceType;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Diego Dupin
*/
public class MariaDbStorePropertiesTests {
@Test
public void defaultValues() {
var props = new MariaDbStoreProperties();
assertThat(props.getDimensions()).isEqualTo(MariaDBVectorStore.INVALID_EMBEDDING_DIMENSION);
assertThat(props.getDistanceType()).isEqualTo(MariaDBDistanceType.COSINE);
assertThat(props.isRemoveExistingVectorStoreTable()).isFalse();
assertThat(props.isSchemaValidation()).isFalse();
assertThat(props.getSchemaName()).isNull();
assertThat(props.getTableName()).isEqualTo(MariaDBVectorStore.DEFAULT_TABLE_NAME);
}
@Test
public void customValues() {
var props = new MariaDbStoreProperties();
props.setDimensions(1536);
props.setDistanceType(MariaDBDistanceType.EUCLIDEAN);
props.setRemoveExistingVectorStoreTable(true);
props.setSchemaValidation(true);
props.setSchemaName("my_vector_schema");
props.setTableName("my_vector_table");
props.setIdFieldName("my_vector_id");
props.setMetadataFieldName("my_vector_meta");
props.setContentFieldName("my_vector_content");
props.setEmbeddingFieldName("my_vector_embedding");
props.setInitializeSchema(true);
assertThat(props.getDimensions()).isEqualTo(1536);
assertThat(props.getDistanceType()).isEqualTo(MariaDBDistanceType.EUCLIDEAN);
assertThat(props.isRemoveExistingVectorStoreTable()).isTrue();
assertThat(props.isSchemaValidation()).isTrue();
assertThat(props.getSchemaName()).isEqualTo("my_vector_schema");
assertThat(props.getTableName()).isEqualTo("my_vector_table");
assertThat(props.getIdFieldName()).isEqualTo("my_vector_id");
assertThat(props.getMetadataFieldName()).isEqualTo("my_vector_meta");
assertThat(props.getContentFieldName()).isEqualTo("my_vector_content");
assertThat(props.getEmbeddingFieldName()).isEqualTo("my_vector_embedding");
}
}

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2023-2024 the original author or authors.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-mariadb-store-spring-boot-starter</artifactId>
<packaging>jar</packaging>
<name>Spring AI Starter - MariaDB Vector Store</name>
<description>Spring AI MariaDB Vector Store Auto Configuration</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-spring-boot-autoconfigure</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mariadb-store</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1 @@
[MariaDB Vector Store Documentation](https://docs.spring.io/spring-ai/reference/api/vectordbs/mariadb.html)

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2023-2024 the original author or authors.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-mariadb-store</artifactId>
<packaging>jar</packaging>
<name>Spring AI Vector Store - MariaDB</name>
<description>Spring AI MariaDB Vector Store</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>${mariadb.version}</version>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
<configuration>
<skipITs>${skip.vectorstore.mariadb}</skipITs>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.Filter.Expression;
import org.springframework.ai.vectorstore.filter.Filter.Group;
import org.springframework.ai.vectorstore.filter.Filter.Key;
import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter;
/**
* Converts {@link Expression} into JSON metadata filter expression format.
* (https://mariadb.com/kb/en/json-functions/)
*
* @author Diego Dupin
*/
public class MariaDBFilterExpressionConverter extends AbstractFilterExpressionConverter {
private final String metadataFieldName;
public MariaDBFilterExpressionConverter(String metadataFieldName) {
this.metadataFieldName = metadataFieldName;
}
@Override
protected void doExpression(Expression expression, StringBuilder context) {
this.convertOperand(expression.left(), context);
context.append(getOperationSymbol(expression));
this.convertOperand(expression.right(), context);
}
@Override
protected void doSingleValue(Object value, StringBuilder context) {
if (value instanceof String) {
context.append(String.format("\'%s\'", value));
}
else {
context.append(value);
}
}
private String getOperationSymbol(Expression exp) {
return switch (exp.type()) {
case AND -> " AND ";
case OR -> " OR ";
case EQ -> " = ";
case NE -> " != ";
case LT -> " < ";
case LTE -> " <= ";
case GT -> " > ";
case GTE -> " >= ";
case IN -> " IN ";
case NOT, NIN -> " NOT IN ";
// you never know what the future might bring
default -> throw new RuntimeException("Not supported expression type: " + exp.type());
};
}
@Override
protected void doKey(Key key, StringBuilder context) {
context.append("JSON_VALUE(" + this.metadataFieldName + ", '$." + key.key() + "')");
}
protected void doStartValueRange(Filter.Value listValue, StringBuilder context) {
context.append("(");
}
protected void doEndValueRange(Filter.Value listValue, StringBuilder context) {
context.append(")");
}
@Override
protected void doStartGroup(Group group, StringBuilder context) {
context.append("(");
}
@Override
protected void doEndGroup(Group group, StringBuilder context) {
context.append(")");
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.mariadb.jdbc.Driver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* @author Diego Dupin
* @since 1.0.0
*/
public class MariaDBSchemaValidator {
private static final Logger logger = LoggerFactory.getLogger(MariaDBSchemaValidator.class);
private final JdbcTemplate jdbcTemplate;
public MariaDBSchemaValidator(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private boolean isTableExists(String schemaName, String tableName) {
// schema and table are expected to be escaped
String sql = String.format("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = ",
(schemaName == null) ? "SCHEMA()" : schemaName, tableName);
try {
// Query for a single integer value, if it exists, table exists
this.jdbcTemplate.queryForObject(sql, Integer.class);
return true;
}
catch (DataAccessException e) {
return false;
}
}
void validateTableSchema(String schemaName, String tableName, String idFieldName, String contentFieldName,
String metadataFieldName, String embeddingFieldName, int embeddingDimensions) {
if (!isTableExists(schemaName, tableName)) {
throw new IllegalStateException(
String.format("Table '%s' does not exist in schema '%s'", tableName, schemaName));
}
// ensure server support VECTORs
try {
// Query for a single integer value, if it exists, database support vector
this.jdbcTemplate.queryForObject("SELECT vec_distance_euclidean(x'0000803f', x'0000803f')", Integer.class,
schemaName, tableName);
}
catch (DataAccessException e) {
logger.error("Error while validating database vector support " + e.getMessage());
logger.error("Failed to validate that database supports VECTOR.\n" + "Run the following SQL commands:\n"
+ " SELECT @@version; \nAnd ensure that version is >= 11.7.1");
throw new IllegalStateException(e);
}
try {
logger.info("Validating MariaDBStore schema for table: {} in schema: {}", tableName, schemaName);
List<String> expectedColumns = new ArrayList<>();
expectedColumns.add(idFieldName);
expectedColumns.add(contentFieldName);
expectedColumns.add(metadataFieldName);
expectedColumns.add(embeddingFieldName);
// Query to check if the table exists with the required fields and types
// Include the schema name in the query to target the correct table
String query = "SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
+ "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?";
List<Map<String, Object>> columns = this.jdbcTemplate.queryForList(query, schemaName, tableName);
if (columns.isEmpty()) {
throw new IllegalStateException("Error while validating table schema, Table " + tableName
+ " does not exist in schema " + schemaName);
}
// Check each column against expected fields
List<String> availableColumns = new ArrayList<>();
for (Map<String, Object> column : columns) {
String columnName = validateAndEnquoteIdentifier((String) column.get("COLUMN_NAME"), false);
availableColumns.add(columnName);
}
// TODO ensure id is a primary key for batch update
expectedColumns.removeAll(availableColumns);
if (expectedColumns.isEmpty()) {
logger.info("MariaDB VectorStore schema validation successful");
}
else {
throw new IllegalStateException("Missing fields " + expectedColumns);
}
}
catch (DataAccessException | IllegalStateException e) {
logger.error("Error while validating table schema" + e.getMessage());
logger.error("Failed to operate with the specified table in the database. To resolve this issue,"
+ " please ensure the following steps are completed:\n"
+ "1. Verify that the table exists with the appropriate structure. If it does not"
+ " exist, create it using a SQL command similar to the following:\n"
+ String.format("""
CREATE TABLE IF NOT EXISTS %s (
%s UUID NOT NULL DEFAULT uuid() PRIMARY KEY,
%s TEXT,
%s JSON,
%s VECTOR(%d) NOT NULL,
VECTOR INDEX (%s)
) ENGINE=InnoDB""", schemaName == null ? tableName : schemaName + "." + tableName,
idFieldName, contentFieldName, metadataFieldName, embeddingFieldName, embeddingDimensions,
embeddingFieldName)
+ "\n" + "Please adjust these commands based on your specific configuration and the"
+ " capabilities of your vector database system.");
throw new IllegalStateException(e);
}
}
/**
* Escaped identifier according to MariaDB requirement.
* @param identifier identifier
* @param alwaysQuote indicate if identifier must be quoted even if not necessary.
* @return return escaped identifier, quoted when necessary or indicated with
* alwaysQuote.
* @see <a href="https://mariadb.com/kb/en/library/identifier-names/">mariadb
* identifier name</a>
*/
// @Override when not supporting java 8
public static String validateAndEnquoteIdentifier(String identifier, boolean alwaysQuote) {
try {
String quotedId = Driver.enquoteIdentifier(identifier, alwaysQuote);
// force use of simple table name
if (Pattern.compile("`?[\\p{Alnum}_]*`?").matcher(identifier).matches())
return quotedId;
throw new IllegalArgumentException(String
.format("Identifier '%s' should only contain alphanumeric characters and underscores", quotedId));
}
catch (SQLException e) {
throw new IllegalArgumentException(e);
}
}
}

View File

@@ -0,0 +1,559 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import io.micrometer.observation.ObservationRegistry;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.BatchingStrategy;
import org.springframework.ai.embedding.EmbeddingModel;
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.util.JacksonUtils;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;
/**
* Uses the "vector_store" table to store the Spring AI vector data. The table and the
* vector index will be auto-created if not available.
*
* @author Diego Dupin
* @since 1.0.0
*/
public class MariaDBVectorStore extends AbstractObservationVectorStore implements InitializingBean {
public static final int OPENAI_EMBEDDING_DIMENSION_SIZE = 1536;
public static final int INVALID_EMBEDDING_DIMENSION = -1;
public static final boolean DEFAULT_SCHEMA_VALIDATION = false;
public static final int MAX_DOCUMENT_BATCH_SIZE = 10_000;
private static final Logger logger = LoggerFactory.getLogger(MariaDBVectorStore.class);
public static final String DEFAULT_TABLE_NAME = "vector_store";
public static final String DEFAULT_COLUMN_EMBEDDING = "embedding";
public static final String DEFAULT_COLUMN_METADATA = "metadata";
public static final String DEFAULT_COLUMN_ID = "id";
public static final String DEFAULT_COLUMN_CONTENT = "content";
private static Map<MariaDBDistanceType, VectorStoreSimilarityMetric> SIMILARITY_TYPE_MAPPING = Map.of(
MariaDBDistanceType.COSINE, VectorStoreSimilarityMetric.COSINE, MariaDBDistanceType.EUCLIDEAN,
VectorStoreSimilarityMetric.EUCLIDEAN);
public final FilterExpressionConverter filterExpressionConverter;
private final String vectorTableName;
private final JdbcTemplate jdbcTemplate;
private final EmbeddingModel embeddingModel;
private final String schemaName;
private final boolean schemaValidation;
private final boolean initializeSchema;
private final int dimensions;
private final String contentFieldName;
private final String embeddingFieldName;
private final String idFieldName;
private final String metadataFieldName;
private final MariaDBDistanceType distanceType;
private final ObjectMapper objectMapper;
private final boolean removeExistingVectorStoreTable;
private final MariaDBSchemaValidator schemaValidator;
private final BatchingStrategy batchingStrategy;
private final int maxDocumentBatchSize;
public MariaDBVectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
this(jdbcTemplate, embeddingModel, INVALID_EMBEDDING_DIMENSION, MariaDBDistanceType.COSINE, false, false);
}
public MariaDBVectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel, int dimensions) {
this(jdbcTemplate, embeddingModel, dimensions, MariaDBDistanceType.COSINE, false, false);
}
public MariaDBVectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel, int dimensions,
MariaDBDistanceType distanceType, boolean removeExistingVectorStoreTable, boolean initializeSchema) {
this(DEFAULT_TABLE_NAME, jdbcTemplate, embeddingModel, dimensions, distanceType, removeExistingVectorStoreTable,
initializeSchema);
}
public MariaDBVectorStore(String vectorTableName, JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel,
int dimensions, MariaDBDistanceType distanceType, boolean removeExistingVectorStoreTable,
boolean initializeSchema) {
this(null, vectorTableName, DEFAULT_SCHEMA_VALIDATION, jdbcTemplate, embeddingModel, dimensions, distanceType,
removeExistingVectorStoreTable, initializeSchema);
}
private MariaDBVectorStore(String schemaName, String vectorTableName, boolean vectorTableValidationsEnabled,
JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel, int dimensions, MariaDBDistanceType distanceType,
boolean removeExistingVectorStoreTable, boolean initializeSchema) {
this(schemaName, vectorTableName, vectorTableValidationsEnabled, jdbcTemplate, embeddingModel, dimensions,
distanceType, removeExistingVectorStoreTable, initializeSchema, ObservationRegistry.NOOP, null,
new TokenCountBatchingStrategy(), MAX_DOCUMENT_BATCH_SIZE, DEFAULT_COLUMN_EMBEDDING,
DEFAULT_COLUMN_METADATA, DEFAULT_COLUMN_ID, DEFAULT_COLUMN_CONTENT);
}
private MariaDBVectorStore(String schemaName, String vectorTableName, boolean vectorTableValidationsEnabled,
JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel, int dimensions, MariaDBDistanceType distanceType,
boolean removeExistingVectorStoreTable, boolean initializeSchema, ObservationRegistry observationRegistry,
VectorStoreObservationConvention customObservationConvention, BatchingStrategy batchingStrategy,
int maxDocumentBatchSize, String contentFieldName, String embeddingFieldName, String idFieldName,
String metadataFieldName) {
super(observationRegistry, customObservationConvention);
this.objectMapper = JsonMapper.builder().addModules(JacksonUtils.instantiateAvailableModules()).build();
this.vectorTableName = (null == vectorTableName || vectorTableName.isEmpty()) ? DEFAULT_TABLE_NAME
: MariaDBSchemaValidator.validateAndEnquoteIdentifier(vectorTableName.trim(), false);
logger.info("Using the vector table name: {}. Is empty: {}", this.vectorTableName,
(vectorTableName == null || vectorTableName.isEmpty()));
this.schemaName = schemaName == null ? null
: MariaDBSchemaValidator.validateAndEnquoteIdentifier(schemaName, false);
this.schemaValidation = vectorTableValidationsEnabled;
this.jdbcTemplate = jdbcTemplate;
this.embeddingModel = embeddingModel;
this.dimensions = dimensions;
this.distanceType = distanceType;
this.removeExistingVectorStoreTable = removeExistingVectorStoreTable;
this.initializeSchema = initializeSchema;
this.schemaValidator = new MariaDBSchemaValidator(jdbcTemplate);
this.batchingStrategy = batchingStrategy;
this.maxDocumentBatchSize = maxDocumentBatchSize;
this.contentFieldName = MariaDBSchemaValidator.validateAndEnquoteIdentifier(contentFieldName, false);
this.embeddingFieldName = MariaDBSchemaValidator.validateAndEnquoteIdentifier(embeddingFieldName, false);
this.idFieldName = MariaDBSchemaValidator.validateAndEnquoteIdentifier(idFieldName, false);
this.metadataFieldName = MariaDBSchemaValidator.validateAndEnquoteIdentifier(metadataFieldName, false);
filterExpressionConverter = new MariaDBFilterExpressionConverter(this.metadataFieldName);
}
public MariaDBDistanceType getDistanceType() {
return this.distanceType;
}
@Override
public void doAdd(List<Document> documents) {
// Batch the documents based on the batching strategy
this.embeddingModel.embed(documents, EmbeddingOptionsBuilder.builder().build(), this.batchingStrategy);
List<List<Document>> batchedDocuments = batchDocuments(documents);
batchedDocuments.forEach(this::insertOrUpdateBatch);
}
private List<List<Document>> batchDocuments(List<Document> documents) {
List<List<Document>> batches = new ArrayList<>();
for (int i = 0; i < documents.size(); i += this.maxDocumentBatchSize) {
batches.add(documents.subList(i, Math.min(i + this.maxDocumentBatchSize, documents.size())));
}
return batches;
}
private void insertOrUpdateBatch(List<Document> batch) {
String sql = String.format(
"INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?) "
+ "ON DUPLICATE KEY UPDATE %s = VALUES(%s) , %s = VALUES(%s) , %s = VALUES(%s)",
getFullyQualifiedTableName(), this.idFieldName, this.contentFieldName, this.metadataFieldName,
this.embeddingFieldName, this.contentFieldName, this.contentFieldName, this.metadataFieldName,
this.metadataFieldName, this.embeddingFieldName, this.embeddingFieldName);
this.jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
var document = batch.get(i);
ps.setObject(1, document.getId());
ps.setString(2, document.getContent());
ps.setString(3, toJson(document.getMetadata()));
ps.setObject(4, document.getEmbedding());
}
@Override
public int getBatchSize() {
return batch.size();
}
});
}
private String toJson(Map<String, Object> map) {
try {
return this.objectMapper.writeValueAsString(map);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@Override
public Optional<Boolean> doDelete(List<String> idList) {
int updateCount = 0;
for (String id : idList) {
int count = this.jdbcTemplate.update(
String.format("DELETE FROM %s WHERE %s = ?", getFullyQualifiedTableName(), this.idFieldName), id);
updateCount = updateCount + count;
}
return Optional.of(updateCount == idList.size());
}
@Override
public List<Document> doSimilaritySearch(SearchRequest request) {
String nativeFilterExpression = (request.getFilterExpression() != null)
? this.filterExpressionConverter.convertExpression(request.getFilterExpression()) : "";
float[] embedding = this.embeddingModel.embed(request.getQuery());
String jsonPathFilter = "";
if (StringUtils.hasText(nativeFilterExpression)) {
jsonPathFilter = "and " + nativeFilterExpression + " ";
}
String distanceType = this.distanceType.name().toLowerCase(Locale.ROOT);
double distance = 1 - request.getSimilarityThreshold();
final String sql = String.format(
"SELECT * FROM (select %s, %s, %s, %s, vec_distance_%s(%s, ?) as distance "
+ "from %s) as t where distance < ? %sorder by distance asc LIMIT ?",
this.idFieldName, this.contentFieldName, this.metadataFieldName, this.embeddingFieldName, distanceType,
this.embeddingFieldName, getFullyQualifiedTableName(), jsonPathFilter);
logger.debug("SQL query: " + sql);
return this.jdbcTemplate.query(sql, new DocumentRowMapper(this.objectMapper), embedding, distance,
request.getTopK());
}
// ---------------------------------------------------------------------------------
// Initialize
// ---------------------------------------------------------------------------------
@Override
public void afterPropertiesSet() {
logger.info("Initializing MariaDBVectorStore schema for table: {} in schema: {}", this.vectorTableName,
this.schemaName);
logger.info("vectorTableValidationsEnabled {}", this.schemaValidation);
if (this.schemaValidation) {
this.schemaValidator.validateTableSchema(this.schemaName, this.vectorTableName, idFieldName,
contentFieldName, metadataFieldName, embeddingFieldName, this.embeddingDimensions());
}
if (!this.initializeSchema) {
logger.debug("Skipping the schema initialization for the table: {}", this.getFullyQualifiedTableName());
return;
}
if (this.schemaName != null)
this.jdbcTemplate.execute(String.format("CREATE SCHEMA IF NOT EXISTS %s", this.schemaName));
// Remove existing VectorStoreTable
if (this.removeExistingVectorStoreTable) {
this.jdbcTemplate.execute(String.format("DROP TABLE IF EXISTS %s", this.getFullyQualifiedTableName()));
}
this.jdbcTemplate.execute(String.format("""
CREATE TABLE IF NOT EXISTS %s (
%s UUID NOT NULL DEFAULT uuid() PRIMARY KEY,
%s TEXT,
%s JSON,
%s VECTOR(%d) NOT NULL,
VECTOR INDEX %s_idx (%s)
) ENGINE=InnoDB
""", this.getFullyQualifiedTableName(), idFieldName, contentFieldName, metadataFieldName,
embeddingFieldName, this.embeddingDimensions(),
(vectorTableName + "_" + embeddingFieldName).replaceAll("[^\\n\\r\\t\\p{Print}]", ""),
embeddingFieldName));
}
private String getFullyQualifiedTableName() {
if (this.schemaName != null)
return this.schemaName + "." + this.vectorTableName;
return this.vectorTableName;
}
int embeddingDimensions() {
// The manually set dimensions have precedence over the computed one.
if (this.dimensions > 0) {
return this.dimensions;
}
try {
int embeddingDimensions = this.embeddingModel.dimensions();
if (embeddingDimensions > 0) {
return embeddingDimensions;
}
}
catch (Exception e) {
logger.warn("Failed to obtain the embedding dimensions from the embedding model and fall backs to"
+ " default:" + OPENAI_EMBEDDING_DIMENSION_SIZE, e);
}
return OPENAI_EMBEDDING_DIMENSION_SIZE;
}
@Override
public VectorStoreObservationContext.Builder createObservationContextBuilder(String operationName) {
return VectorStoreObservationContext.builder(VectorStoreProvider.MARIADB.value(), operationName)
.withCollectionName(this.vectorTableName)
.withDimensions(this.embeddingDimensions())
.withNamespace(this.schemaName)
.withSimilarityMetric(getSimilarityMetric());
}
private String getSimilarityMetric() {
if (!SIMILARITY_TYPE_MAPPING.containsKey(this.getDistanceType())) {
return this.getDistanceType().name();
}
return SIMILARITY_TYPE_MAPPING.get(this.distanceType).value();
}
public enum MariaDBDistanceType {
EUCLIDEAN, COSINE;
}
private static class DocumentRowMapper implements RowMapper<Document> {
private final ObjectMapper objectMapper;
public DocumentRowMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Document mapRow(ResultSet rs, int rowNum) throws SQLException {
String id = rs.getString(1);
String content = rs.getString(2);
Map<String, Object> metadata = toMap(rs.getString(3));
float[] embedding = rs.getObject(4, float[].class);
float distance = rs.getFloat(5);
metadata.put("distance", distance);
Document document = new Document(id, content, metadata);
document.setEmbedding(embedding);
return document;
}
private Map<String, Object> toMap(String source) {
try {
return (Map<String, Object>) this.objectMapper.readValue(source, Map.class);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
public static class Builder {
private String contentFieldName = DEFAULT_COLUMN_CONTENT;
private String embeddingFieldName = DEFAULT_COLUMN_EMBEDDING;
private String idFieldName = DEFAULT_COLUMN_ID;
private String metadataFieldName = DEFAULT_COLUMN_METADATA;
private final JdbcTemplate jdbcTemplate;
private final EmbeddingModel embeddingModel;
private String schemaName = null;
private String vectorTableName;
private boolean vectorTableValidationsEnabled = MariaDBVectorStore.DEFAULT_SCHEMA_VALIDATION;
private int dimensions = MariaDBVectorStore.INVALID_EMBEDDING_DIMENSION;
private MariaDBDistanceType distanceType = MariaDBDistanceType.COSINE;
private boolean removeExistingVectorStoreTable = false;
private boolean initializeSchema;
private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
private BatchingStrategy batchingStrategy = new TokenCountBatchingStrategy();
private int maxDocumentBatchSize = MAX_DOCUMENT_BATCH_SIZE;
@Nullable
private VectorStoreObservationConvention searchObservationConvention;
// Builder constructor with mandatory parameters
public Builder(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
if (jdbcTemplate == null || embeddingModel == null) {
throw new IllegalArgumentException("JdbcTemplate and EmbeddingModel must not be null");
}
this.jdbcTemplate = jdbcTemplate;
this.embeddingModel = embeddingModel;
}
public Builder withSchemaName(String schemaName) {
this.schemaName = schemaName;
return this;
}
public Builder withVectorTableName(String vectorTableName) {
this.vectorTableName = vectorTableName;
return this;
}
public Builder withVectorTableValidationsEnabled(boolean vectorTableValidationsEnabled) {
this.vectorTableValidationsEnabled = vectorTableValidationsEnabled;
return this;
}
public Builder withDimensions(int dimensions) {
this.dimensions = dimensions;
return this;
}
public Builder withDistanceType(MariaDBDistanceType distanceType) {
this.distanceType = distanceType;
return this;
}
public Builder withRemoveExistingVectorStoreTable(boolean removeExistingVectorStoreTable) {
this.removeExistingVectorStoreTable = removeExistingVectorStoreTable;
return this;
}
public Builder withInitializeSchema(boolean initializeSchema) {
this.initializeSchema = initializeSchema;
return this;
}
public Builder withObservationRegistry(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
return this;
}
public Builder withSearchObservationConvention(VectorStoreObservationConvention customObservationConvention) {
this.searchObservationConvention = customObservationConvention;
return this;
}
public Builder withBatchingStrategy(BatchingStrategy batchingStrategy) {
this.batchingStrategy = batchingStrategy;
return this;
}
public Builder withMaxDocumentBatchSize(int maxDocumentBatchSize) {
this.maxDocumentBatchSize = maxDocumentBatchSize;
return this;
}
/**
* Configures the content field name to use.
* @param name the content field name to use
* @return this builder
*/
public Builder withContentFieldName(String name) {
this.contentFieldName = name;
return this;
}
/**
* Configures the embedding field name to use.
* @param name the embedding field name to use
* @return this builder
*/
public Builder withEmbeddingFieldName(String name) {
this.embeddingFieldName = name;
return this;
}
/**
* Configures the id field name to use.
* @param name the id field name to use
* @return this builder
*/
public Builder withIdFieldName(String name) {
this.idFieldName = name;
return this;
}
/**
* Configures the metadata field name to use.
* @param name the metadata field name to use
* @return this builder
*/
public Builder withMetadataFieldName(String name) {
this.metadataFieldName = name;
return this;
}
public MariaDBVectorStore build() {
return new MariaDBVectorStore(this.schemaName, this.vectorTableName, this.vectorTableValidationsEnabled,
this.jdbcTemplate, this.embeddingModel, this.dimensions, this.distanceType,
this.removeExistingVectorStoreTable, this.initializeSchema, this.observationRegistry,
this.searchObservationConvention, this.batchingStrategy, this.maxDocumentBatchSize,
this.contentFieldName, this.embeddingFieldName, this.idFieldName, this.metadataFieldName);
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.only;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* @author Diego Dupin
*/
@ExtendWith(MockitoExtension.class)
public class MariaDBEmbeddingDimensionsTests {
@Mock
private EmbeddingModel embeddingModel;
@Mock
private JdbcTemplate jdbcTemplate;
@Test
public void explicitlySetDimensions() {
final int explicitDimensions = 696;
var dim = new MariaDBVectorStore(this.jdbcTemplate, this.embeddingModel, explicitDimensions)
.embeddingDimensions();
assertThat(dim).isEqualTo(explicitDimensions);
verify(this.embeddingModel, never()).dimensions();
}
@Test
public void embeddingModelDimensions() {
when(this.embeddingModel.dimensions()).thenReturn(969);
var dim = new MariaDBVectorStore(this.jdbcTemplate, this.embeddingModel).embeddingDimensions();
assertThat(dim).isEqualTo(969);
verify(this.embeddingModel, only()).dimensions();
}
@Test
public void fallBackToDefaultDimensions() {
when(this.embeddingModel.dimensions()).thenThrow(new RuntimeException());
var dim = new MariaDBVectorStore(this.jdbcTemplate, this.embeddingModel).embeddingDimensions();
assertThat(dim).isEqualTo(MariaDBVectorStore.OPENAI_EMBEDDING_DIMENSION_SIZE);
verify(this.embeddingModel, only()).dimensions();
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.AND;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.EQ;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.GTE;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.IN;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.LTE;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NE;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NIN;
import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.OR;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.vectorstore.filter.Filter.Expression;
import org.springframework.ai.vectorstore.filter.Filter.Group;
import org.springframework.ai.vectorstore.filter.Filter.Key;
import org.springframework.ai.vectorstore.filter.Filter.Value;
import org.springframework.ai.vectorstore.mariadb.MariaDBFilterExpressionConverter;
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
/**
* @author Diego Dupin
*/
public class MariaDBFilterExpressionConverterTests {
FilterExpressionConverter converter = new MariaDBFilterExpressionConverter("metadata");
@Test
public void testEQ() {
// country == "BG"
String vectorExpr = this.converter.convertExpression(new Expression(EQ, new Key("country"), new Value("BG")));
assertThat(vectorExpr).isEqualTo("JSON_VALUE(metadata, '$.country') = 'BG'");
}
@Test
public void tesEqAndGte() {
// genre == "drama" AND year >= 2020
String vectorExpr = this.converter
.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("JSON_VALUE(metadata, '$.genre') = 'drama' AND JSON_VALUE(metadata, '$.year') >=" + " 2020");
}
@Test
public void tesIn() {
// genre in ["comedy", "documentary", "drama"]
String vectorExpr = this.converter.convertExpression(
new Expression(IN, new Key("genre"), new Value(List.of("comedy", "documentary", "drama"))));
assertThat(vectorExpr).isEqualTo("JSON_VALUE(metadata, '$.genre') IN ('comedy','documentary','drama')");
}
@Test
public void testNe() {
// year >= 2020 OR country == "BG" AND city != "Sofia"
String vectorExpr = this.converter
.convertExpression(new Expression(OR, new Expression(GTE, new Key("year"), new Value(2020)),
new Expression(AND, new Expression(EQ, new Key("country"), new Value("BG")),
new Expression(NE, new Key("city"), new Value("Sofia")))));
assertThat(vectorExpr)
.isEqualTo("JSON_VALUE(metadata, '$.year') >= 2020 OR JSON_VALUE(metadata, '$.country') = 'BG'"
+ " AND JSON_VALUE(metadata, '$.city') != 'Sofia'");
}
@Test
public void testGroup() {
// (year >= 2020 OR country == "BG") AND city NIN ["Sofia", "Plovdiv"]
String vectorExpr = this.converter.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")))),
new Expression(NIN, new Key("city"), new Value(List.of("Sofia", "Plovdiv")))));
assertThat(vectorExpr)
.isEqualTo("(JSON_VALUE(metadata, '$.year') >= 2020 OR JSON_VALUE(metadata, '$.country') ="
+ " 'BG') AND JSON_VALUE(metadata, '$.city') NOT IN ('Sofia','Plovdiv')");
}
@Test
public void testBoolean() {
// isOpen == true AND year >= 2020 AND country IN ["BG", "NL", "US"]
String vectorExpr = this.converter.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))),
new Expression(IN, new Key("country"), new Value(List.of("BG", "NL", "US")))));
assertThat(vectorExpr)
.isEqualTo("JSON_VALUE(metadata, '$.isOpen') = true AND JSON_VALUE(metadata, '$.year') >= 2020"
+ " AND JSON_VALUE(metadata, '$.country') IN ('BG','NL','US')");
}
@Test
public void testDecimal() {
// temperature >= -15.6 && temperature <= +20.13
String vectorExpr = this.converter
.convertExpression(new Expression(AND, new Expression(GTE, new Key("temperature"), new Value(-15.6)),
new Expression(LTE, new Key("temperature"), new Value(20.13))));
assertThat(vectorExpr).isEqualTo("JSON_VALUE(metadata, '$.temperature') >= -15.6 AND JSON_VALUE(metadata,"
+ " '$.temperature') <= 20.13");
}
@Test
public void testComplexIdentifiers() {
String vectorExpr = this.converter
.convertExpression(new Expression(EQ, new Key("\"country 1 2 3\""), new Value("BG")));
assertThat(vectorExpr).isEqualTo("JSON_VALUE(metadata, '$.\"country 1 2 3\"') = 'BG'");
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import org.testcontainers.utility.DockerImageName;
/**
* @author Diego Dupin
*/
public class MariaDBImage {
public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("mariadb:11.7-rc");
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* @author Diego Dupin
*/
@Testcontainers
public class MariaDBStoreCustomNamesIT {
private static String schemaName = "testdb";
@Container
@SuppressWarnings("resource")
static MariaDBContainer<?> mariadbContainer = new MariaDBContainer<>(MariaDBImage.DEFAULT_IMAGE)
.withUsername("mariadb")
.withPassword("mariadbpwd")
.withDatabaseName(schemaName);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=COSINE",
// JdbcTemplate configuration
"app.datasource.url=" + mariadbContainer.getJdbcUrl(), "app.datasource.username=mariadb",
"app.datasource.password=mariadbpwd", "app.datasource.type=com.zaxxer.hikari.HikariDataSource");
private static void dropTableByName(ApplicationContext context, String name) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
jdbcTemplate.execute("DROP TABLE IF EXISTS " + schemaName + "." + name);
}
private static boolean isIndexExists(ApplicationContext context, String schemaName, String tableName,
String indexName) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
String sql = "SELECT EXISTS (SELECT * FROM information_schema.statistics WHERE TABLE_SCHEMA=? AND"
+ " TABLE_NAME=? AND INDEX_NAME=? AND INDEX_TYPE='VECTOR')";
return jdbcTemplate.queryForObject(sql, Boolean.class, schemaName, tableName, indexName);
}
@SuppressWarnings("null")
private static boolean isTableExists(ApplicationContext context, String tableName) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
return jdbcTemplate.queryForObject(
"SELECT EXISTS (SELECT * FROM information_schema.tables WHERE table_schema= ? AND" + " table_name = ?)",
Boolean.class, schemaName, tableName);
}
private static boolean areColumnsExisting(ApplicationContext context, String tableName, String[] fieldNames) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
for (String field : fieldNames) {
boolean fieldsExists = jdbcTemplate
.queryForObject("SELECT EXISTS (SELECT * FROM information_schema.columns WHERE table_schema= ? AND"
+ " table_name = ? AND column_name = ?)", Boolean.class, schemaName, tableName, field);
if (!fieldsExists)
return false;
}
return true;
}
private static boolean isSchemaExists(ApplicationContext context, String schemaName) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
String sql = "SELECT EXISTS (SELECT * FROM information_schema.schemata WHERE schema_name = ?)";
return jdbcTemplate.queryForObject(sql, Boolean.class, schemaName);
}
@Test
public void shouldCreateDefaultTableAndIndexIfNotPresentInConfig() {
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.schemaValidation=false")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(isTableExists(context, "vector_store")).isTrue();
assertThat(isSchemaExists(context, schemaName)).isTrue();
dropTableByName(context, "vector_store");
});
}
@Test
public void shouldCreateTableAndIndexIfNotPresentInDatabase() {
String tableName = "new_vector_table";
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.vectorTableName=" + tableName)
.run(context -> {
assertThat(isTableExists(context, tableName)).isTrue();
assertThat(isIndexExists(context, schemaName, tableName, tableName + "_embedding_idx")).isTrue();
assertThat(isTableExists(context, "vector_store")).isFalse();
dropTableByName(context, tableName);
});
}
@Test
public void shouldCreateSpecificTableAndIndexIfNotPresentInDatabase() {
String tableName = "new_vector_table2";
String contentFieldName = "content2";
String embeddingFieldName = "embedding2";
String idFieldName = "id2";
String metadataFieldName = "metadata2";
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.vectorTableName=" + tableName)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.contentFieldName=" + contentFieldName)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.embeddingFieldName=" + embeddingFieldName)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.idFieldName=" + idFieldName)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.metadataFieldName=" + metadataFieldName)
.run(context -> {
assertThat(isTableExists(context, tableName)).isTrue();
assertThat(isIndexExists(context, schemaName, tableName, tableName + "_embedding_idx")).isTrue();
assertThat(isTableExists(context, "vector_store")).isFalse();
assertThat(areColumnsExisting(context, tableName,
new String[] { contentFieldName, embeddingFieldName, idFieldName, metadataFieldName }))
.isFalse();
dropTableByName(context, tableName);
});
}
@Test
public void shouldFailWhenCustomTableIsAbsentAndValidationEnabled() {
String tableName = "customvectortable";
this.contextRunner
.withPropertyValues("test.spring.ai.vectorstore.mariadb.vectorTableName=" + tableName,
"test.spring.ai.vectorstore.mariadb.schemaValidation=true")
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure()).hasCauseInstanceOf(IllegalStateException.class)
.hasMessageContaining("Table 'customvectortable' does not exist in schema 'testdb'");
});
}
@Test
public void shouldFailOnSQLInjectionAttemptInTableName() {
String tableName = "users; DROP TABLE users;";
this.contextRunner
.withPropertyValues("test.spring.ai.vectorstore.mariadb.vectorTableName=" + tableName,
"test.spring.ai.vectorstore.mariadb.schemaValidation=true")
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure().getCause()).hasCauseInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Identifier '`users; DROP TABLE users;`' should only contain alphanumeric"
+ " characters and underscores");
});
}
@Test
public void shouldFailOnSQLInjectionAttemptInSchemaName() {
String schemaName = "public; DROP TABLE users;";
String tableName = "customvectortable";
this.contextRunner
.withPropertyValues("test.spring.ai.vectorstore.mariadb.vectorTableName=" + tableName,
"test.spring.ai.vectorstore.mariadb.schemaName=" + schemaName,
"test.spring.ai.vectorstore.mariadb.schemaValidation=true")
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure().getCause()).hasCauseInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Identifier '`public; DROP TABLE users;`' should only contain alphanumeric"
+ " characters and underscores");
});
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
public static class TestApplication {
@Value("${test.spring.ai.vectorstore.mariadb.vectorTableName:}")
String vectorTableName;
@Value("${test.spring.ai.vectorstore.mariadb.schemaName:testdb}")
String schemaName;
@Value("${test.spring.ai.vectorstore.mariadb.schemaValidation:false}")
boolean schemaValidation;
int dimensions = 1536;
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
return new MariaDBVectorStore.Builder(jdbcTemplate, embeddingModel).withSchemaName(this.schemaName)
.withVectorTableName(this.vectorTableName)
.withVectorTableValidationsEnabled(this.schemaValidation)
.withDimensions(this.dimensions)
.withDistanceType(MariaDBVectorStore.MariaDBDistanceType.COSINE)
.withRemoveExistingVectorStoreTable(true)
.withInitializeSchema(true)
.build();
}
//
@Bean
public JdbcTemplate myJdbcTemplate(DataSource dataSource) {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
return jdbcTemplate;
}
@Bean
@Primary
@ConfigurationProperties("app.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public HikariDataSource dataSource(DataSourceProperties dataSourceProperties) {
return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
public EmbeddingModel embeddingModel() {
return new OpenAiEmbeddingModel(new OpenAiApi(System.getenv("OPENAI_API_KEY")));
}
}
}

View File

@@ -0,0 +1,377 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariDataSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import javax.sql.DataSource;
import org.junit.Assert;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser.FilterExpressionParseException;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.util.CollectionUtils;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* @author Diego Dupin
*/
@Testcontainers
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class MariaDBStoreIT {
private static String schemaName = "testdb";
@Container
@SuppressWarnings("resource")
static MariaDBContainer<?> mariadbContainer = new MariaDBContainer<>(MariaDBImage.DEFAULT_IMAGE)
.withUsername("mariadb")
.withPassword("mariadbpwd")
.withDatabaseName(schemaName);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=COSINE",
// JdbcTemplate configuration
String.format("app.datasource.url=jdbc:mariadb://%s:%d/%s?maxQuerySizeToLog=50000",
mariadbContainer.getHost(), mariadbContainer.getMappedPort(3306), schemaName),
"app.datasource.username=mariadb", "app.datasource.password=mariadbpwd",
"app.datasource.type=com.zaxxer.hikari.HikariDataSource");
List<Document> documents = List.of(
new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")),
new Document(getText("classpath:/test/data/time.shelter.txt")),
new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2")));
public static String getText(String uri) {
var resource = new DefaultResourceLoader().getResource(uri);
try {
return resource.getContentAsString(StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private static void dropTable(ApplicationContext context) {
JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class);
jdbcTemplate.execute("DROP TABLE IF EXISTS vector_store");
}
static Stream<Arguments> provideFilters() {
return Stream.of(Arguments.of("country in ['BG','NL']", 3), // String Filters In
Arguments.of("year in [2020]", 1), // Numeric Filters In
Arguments.of("country not in ['BG']", 1), // String Filter Not In
Arguments.of("year not in [2020]", 1) // Numeric Filter Not In
);
}
private static boolean isSortedByDistance(List<Document> docs) {
List<Float> distances = docs.stream().map(doc -> (Float) doc.getMetadata().get("distance")).toList();
if (CollectionUtils.isEmpty(distances) || distances.size() == 1) {
return true;
}
Iterator<Float> iter = distances.iterator();
Float current, previous = iter.next();
while (iter.hasNext()) {
current = iter.next();
if (previous > current) {
return false;
}
previous = current;
}
return true;
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "COSINE", "EUCLIDEAN" })
public void addAndSearch(String distanceType) {
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=" + distanceType)
.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
vectorStore.add(this.documents);
List<Document> results = vectorStore
.similaritySearch(SearchRequest.query("What is Great Depression").withTopK(1));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(this.documents.get(2).getId());
assertThat(resultDoc.getMetadata()).containsKeys("meta2", "distance");
// Remove all documents from the store
vectorStore.delete(this.documents.stream().map(doc -> doc.getId()).toList());
List<Document> results2 = vectorStore
.similaritySearch(SearchRequest.query("Great Depression").withTopK(1));
assertThat(results2).hasSize(0);
dropTable(context);
});
}
@ParameterizedTest(name = "Filter expression {0} should return {1} records ")
@MethodSource("provideFilters")
public void searchWithInFilter(String expression, Integer expectedRecords) {
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=COSINE").run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "BG", "year", 2020, "foo bar 1", "bar.foo"));
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "NL"));
var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "BG", "year", 2023));
vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2));
SearchRequest searchRequest = SearchRequest.query("The World")
.withFilterExpression(expression)
.withTopK(5)
.withSimilarityThresholdAll();
List<Document> results = vectorStore.similaritySearch(searchRequest);
assertThat(results).hasSize(expectedRecords);
// Remove all documents from the store
dropTable(context);
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "COSINE", "EUCLIDEAN" })
public void searchWithFilters(String distanceType) {
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=" + distanceType)
.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "BG", "year", 2020, "foo bar 1", "bar.foo"));
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "NL"));
var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "BG", "year", 2023));
vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2));
SearchRequest searchRequest = SearchRequest.query("The World").withTopK(5).withSimilarityThresholdAll();
List<Document> results = vectorStore.similaritySearch(searchRequest);
assertThat(results).hasSize(3);
results = vectorStore.similaritySearch(searchRequest.withFilterExpression("country == 'NL'"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(nlDocument.getId());
results = vectorStore.similaritySearch(searchRequest.withFilterExpression("country == 'BG'"));
assertThat(results).hasSize(2);
assertThat(results.get(0).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
assertThat(results.get(1).getId()).isIn(bgDocument.getId(), bgDocument2.getId());
results = vectorStore
.similaritySearch(searchRequest.withFilterExpression("country == 'BG' && year == 2020"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId());
results = vectorStore.similaritySearch(
searchRequest.withFilterExpression("(country == 'BG' && year == 2020) || (country == 'NL')"));
assertThat(results).hasSize(2);
assertThat(results.get(0).getId()).isIn(bgDocument.getId(), nlDocument.getId());
assertThat(results.get(1).getId()).isIn(bgDocument.getId(), nlDocument.getId());
results = vectorStore.similaritySearch(searchRequest
.withFilterExpression("NOT((country == 'BG' && year == 2020) || (country == 'NL'))"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(bgDocument2.getId());
results = vectorStore.similaritySearch(SearchRequest.query("The World")
.withTopK(5)
.withSimilarityThresholdAll()
.withFilterExpression("\"foo bar 1\" == 'bar.foo'"));
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(bgDocument.getId());
try {
vectorStore.similaritySearch(searchRequest.withFilterExpression("country == NL"));
Assert.fail("Invalid filter expression should have been cached!");
}
catch (FilterExpressionParseException e) {
assertThat(e.getMessage()).contains("Line: 1:17, Error: no viable alternative at input 'NL'");
}
// Remove all documents from the store
dropTable(context);
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "COSINE", "EUCLIDEAN" })
public void documentUpdate(String distanceType) {
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=" + distanceType)
.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
Document document = new Document(UUID.randomUUID().toString(), "Spring AI rocks!!",
Collections.singletonMap("meta1", "meta1"));
vectorStore.add(List.of(document));
List<Document> results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(document.getId());
assertThat(resultDoc.getContent()).isEqualTo("Spring AI rocks!!");
assertThat(resultDoc.getMetadata()).containsKeys("meta1", "distance");
Document sameIdDocument = new Document(document.getId(),
"The World is Big and Salvation Lurks Around the Corner",
Collections.singletonMap("meta2", "meta2"));
vectorStore.add(List.of(sameIdDocument));
results = vectorStore.similaritySearch(SearchRequest.query("FooBar").withTopK(5));
assertThat(results).hasSize(1);
resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(document.getId());
assertThat(resultDoc.getContent()).isEqualTo("The World is Big and Salvation Lurks Around the Corner");
assertThat(resultDoc.getMetadata()).containsKeys("meta2", "distance");
dropTable(context);
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "COSINE", "EUCLIDEAN" })
public void searchWithThreshold(String distanceType) {
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=" + distanceType)
.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
vectorStore.add(this.documents);
List<Document> fullResult = vectorStore
.similaritySearch(SearchRequest.query("Time Shelter").withTopK(5).withSimilarityThresholdAll());
assertThat(fullResult).hasSize(3);
assertThat(isSortedByDistance(fullResult)).isTrue();
List<Float> distances = fullResult.stream()
.map(doc -> (Float) doc.getMetadata().get("distance"))
.toList();
float threshold = (distances.get(0) + distances.get(1)) / 2;
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query("Time Shelter").withTopK(5).withSimilarityThreshold(1 - threshold));
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(this.documents.get(1).getId());
dropTable(context);
});
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
public static class TestApplication {
@Value("${test.spring.ai.vectorstore.mariadb.distanceType}")
MariaDBVectorStore.MariaDBDistanceType distanceType;
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
return new MariaDBVectorStore(jdbcTemplate, embeddingModel, MariaDBVectorStore.INVALID_EMBEDDING_DIMENSION,
this.distanceType, true, true);
}
@Bean
public JdbcTemplate myJdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
@Primary
@ConfigurationProperties("app.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public HikariDataSource dataSource(DataSourceProperties dataSourceProperties) {
return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
public EmbeddingModel embeddingModel() {
return new OpenAiEmbeddingModel(new OpenAiApi(System.getenv("OPENAI_API_KEY")));
}
}
}

View File

@@ -0,0 +1,209 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariDataSource;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
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.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.ai.vectorstore.observation.DefaultVectorStoreObservationConvention;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.HighCardinalityKeyNames;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.LowCardinalityKeyNames;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* @author Diego Dupin
*/
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
@Testcontainers
public class MariaDBStoreObservationIT {
private static String schemaName = "testdb";
@Container
@SuppressWarnings("resource")
static MariaDBContainer<?> mariadbContainer = new MariaDBContainer<>(MariaDBImage.DEFAULT_IMAGE)
.withUsername("mariadb")
.withPassword("mariadbpwd")
.withDatabaseName(schemaName);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(Config.class)
.withPropertyValues("test.spring.ai.vectorstore.mariadb.distanceType=COSINE",
// JdbcTemplate configuration
"app.datasource.url=" + mariadbContainer.getJdbcUrl(), "app.datasource.username=mariadb",
"app.datasource.password=mariadbpwd", "app.datasource.type=com.zaxxer.hikari.HikariDataSource");
List<Document> documents = List.of(
new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")),
new Document(getText("classpath:/test/data/time.shelter.txt")),
new Document(getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2")));
public static String getText(String uri) {
var resource = new DefaultResourceLoader().getResource(uri);
try {
return resource.getContentAsString(StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
void observationVectorStoreAddAndQueryOperations() {
this.contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
TestObservationRegistry observationRegistry = context.getBean(TestObservationRegistry.class);
vectorStore.add(this.documents);
TestObservationRegistryAssert.assertThat(observationRegistry)
.doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo(DefaultVectorStoreObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("%s add".formatted(VectorStoreProvider.MARIADB.value()))
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.DB_OPERATION_NAME.asString(), "add")
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.DB_SYSTEM.asString(),
VectorStoreProvider.MARIADB.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.SPRING_AI_KIND.asString(),
SpringAiKind.VECTOR_STORE.value())
.doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.DB_VECTOR_QUERY_CONTENT.asString())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_VECTOR_DIMENSION_COUNT.asString(), "1536")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_COLLECTION_NAME.asString(),
MariaDBVectorStore.DEFAULT_TABLE_NAME)
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_NAMESPACE.asString(), schemaName)
.doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.DB_VECTOR_FIELD_NAME.asString())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_SEARCH_SIMILARITY_METRIC.asString(),
VectorStoreSimilarityMetric.COSINE.value())
.doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.DB_VECTOR_QUERY_TOP_K.asString())
.doesNotHaveHighCardinalityKeyValueWithKey(
HighCardinalityKeyNames.DB_VECTOR_QUERY_SIMILARITY_THRESHOLD.asString())
.hasBeenStarted()
.hasBeenStopped();
observationRegistry.clear();
List<Document> results = vectorStore
.similaritySearch(SearchRequest.query("What is Great Depression").withTopK(1));
assertThat(results).isNotEmpty();
TestObservationRegistryAssert.assertThat(observationRegistry)
.doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo(DefaultVectorStoreObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("%s query".formatted(VectorStoreProvider.MARIADB.value()))
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.DB_OPERATION_NAME.asString(), "query")
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.DB_SYSTEM.asString(),
VectorStoreProvider.MARIADB.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.SPRING_AI_KIND.asString(),
SpringAiKind.VECTOR_STORE.value())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_VECTOR_QUERY_CONTENT.asString(),
"What is Great Depression")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_VECTOR_DIMENSION_COUNT.asString(), "1536")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_COLLECTION_NAME.asString(),
MariaDBVectorStore.DEFAULT_TABLE_NAME)
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_NAMESPACE.asString(), schemaName)
.doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.DB_VECTOR_FIELD_NAME.asString())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_SEARCH_SIMILARITY_METRIC.asString(),
VectorStoreSimilarityMetric.COSINE.value())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_VECTOR_QUERY_TOP_K.asString(), "1")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_VECTOR_QUERY_SIMILARITY_THRESHOLD.asString(),
"0.0")
.hasBeenStarted()
.hasBeenStopped();
});
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
static class Config {
@Bean
public TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel,
ObservationRegistry observationRegistry) {
return new MariaDBVectorStore.Builder(jdbcTemplate, embeddingModel).withSchemaName(schemaName)
.withDistanceType(MariaDBVectorStore.MariaDBDistanceType.COSINE)
.withObservationRegistry(observationRegistry)
.withInitializeSchema(true)
.build();
}
@Bean
public JdbcTemplate myJdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
@Primary
@ConfigurationProperties("app.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public HikariDataSource dataSource(DataSourceProperties dataSourceProperties) {
return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
public EmbeddingModel embeddingModel() {
return new OpenAiEmbeddingModel(new OpenAiApi(System.getenv("OPENAI_API_KEY")));
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.mariadb;
import java.util.Collections;
import org.junit.Assert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.mariadb.MariaDBSchemaValidator;
import org.springframework.ai.vectorstore.mariadb.MariaDBVectorStore;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.only;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* @author Diego Dupin
*/
public class MariaDBStoreTests {
@ParameterizedTest(name = "{0} - enquote identifier validation")
@CsvSource({
// Standard valid cases
"customvectorstore, true, `customvectorstore`", "user_data, true, `user_data`", "test123, true, `test123`",
"valid_table_name, true, `valid_table_name`", "customvectorstore, false, customvectorstore",
"user_data, false, user_data", "test123, false, test123", "valid_table_name, false, valid_table_name",
"1234567890123456789012345678901234567890123456789012345678901234, false, `1234567890123456789012345678901234567890123456789012345678901234`" })
void enquoteIdentifier(String tableName, boolean alwaysQuote, String expected) {
assertThat(MariaDBSchemaValidator.validateAndEnquoteIdentifier(tableName, alwaysQuote));
}
@ParameterizedTest(name = "{0} - error identifier validation")
@CsvSource({ "12345678901234567890123456789012345678901234567890123456789012345, false",
"12345678901234567890123456789012345678901234567890123456789012345, true",
"customvectorstore;drop table users;, false", "some\u0000notpossibleValue, true" })
void enquoteIdentifierThrow(String tableName, boolean alwaysQuote) {
Assert.assertThrows(IllegalArgumentException.class,
() -> MariaDBSchemaValidator.validateAndEnquoteIdentifier(tableName, alwaysQuote));
}
@Test
void shouldAddDocumentsInBatchesAndEmbedOnce() {
// Given
var jdbcTemplate = mock(JdbcTemplate.class);
var embeddingModel = mock(EmbeddingModel.class);
var mariadbVectorStore = new MariaDBVectorStore.Builder(jdbcTemplate, embeddingModel)
.withMaxDocumentBatchSize(1000)
.build();
// Testing with 9989 documents
var documents = Collections.nCopies(9989, new Document("foo"));
// When
mariadbVectorStore.doAdd(documents);
// Then
verify(embeddingModel, only()).embed(eq(documents), any(), any());
var batchUpdateCaptor = ArgumentCaptor.forClass(BatchPreparedStatementSetter.class);
verify(jdbcTemplate, times(10)).batchUpdate(anyString(), batchUpdateCaptor.capture());
assertThat(batchUpdateCaptor.getAllValues()).hasSize(10)
.allSatisfy(BatchPreparedStatementSetter::getBatchSize)
.satisfies(batches -> {
for (int i = 0; i < 9; i++) {
assertThat(batches.get(i).getBatchSize()).as("Batch at index %d should have size 10", i)
.isEqualTo(1000);
}
assertThat(batches.get(9).getBatchSize()).as("Last batch should have size 989").isEqualTo(989);
});
}
}