diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfiguration.java b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfiguration.java index fdca1aeaa2..abb14ff6d7 100644 --- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfiguration.java +++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfiguration.java @@ -42,6 +42,7 @@ * @author Eddú Meléndez * @author Christian Tzolov * @author Soby Chacko + * @author Anders Swanson */ @AutoConfiguration @ConditionalOnClass({ OracleVectorStore.class, DataSource.class, JdbcTemplate.class }) @@ -69,6 +70,11 @@ public OracleVectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel e .distanceType(properties.getDistanceType()) .dimensions(properties.getDimensions()) .searchAccuracy(properties.getSearchAccuracy()) + .hnswNeighbors(properties.getHnswNeighbors()) + .hnswEfConstruction(properties.getHnswEfConstruction()) + .ivfNeighborPartitions(properties.getIvfNeighborPartitions()) + .ivfSamplePerPartition(properties.getIvfSamplePerPartition()) + .ivfMinVectorsPerPartition(properties.getIvfMinVectorsPerPartition()) .initializeSchema(properties.isInitializeSchema()) .removeExistingVectorStoreTable(properties.isRemoveExistingVectorStoreTable()) .forcedNormalization(properties.isForcedNormalization()) diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreProperties.java b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreProperties.java index dec3a98b05..cf25da5591 100644 --- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreProperties.java +++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/main/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreProperties.java @@ -24,6 +24,7 @@ * Configuration properties for Oracle Vector Store. * * @author Loïc Lefèvre + * @author Anders Swanson */ @ConfigurationProperties(OracleVectorStoreProperties.CONFIG_PREFIX) public class OracleVectorStoreProperties extends CommonVectorStoreProperties { @@ -44,6 +45,16 @@ public class OracleVectorStoreProperties extends CommonVectorStoreProperties { private int searchAccuracy = OracleVectorStore.DEFAULT_SEARCH_ACCURACY; + private int hnswNeighbors = OracleVectorStore.DEFAULT_HNSW_NEIGHBORS; + + private int hnswEfConstruction = OracleVectorStore.DEFAULT_HNSW_EF_CONSTRUCTION; + + private int ivfNeighborPartitions = OracleVectorStore.DEFAULT_IVF_NEIGHBOR_PARTITIONS; + + private int ivfSamplePerPartition = OracleVectorStore.DEFAULT_IVF_SAMPLE_PER_PARTITION; + + private int ivfMinVectorsPerPartition = OracleVectorStore.DEFAULT_IVF_MIN_VECTORS_PER_PARTITION; + public String getTableName() { return this.tableName; } @@ -100,4 +111,94 @@ public void setSearchAccuracy(int searchAccuracy) { this.searchAccuracy = searchAccuracy; } + /** + * Returns the configured HNSW neighbors value. + * @return the configured HNSW neighbors value + * @since 2.0.0 + */ + public int getHnswNeighbors() { + return this.hnswNeighbors; + } + + /** + * Sets the HNSW neighbors value. + * @param hnswNeighbors the HNSW neighbors value + * @since 2.0.0 + */ + public void setHnswNeighbors(int hnswNeighbors) { + this.hnswNeighbors = hnswNeighbors; + } + + /** + * Returns the configured HNSW efConstruction value. + * @return the configured HNSW efConstruction value + * @since 2.0.0 + */ + public int getHnswEfConstruction() { + return this.hnswEfConstruction; + } + + /** + * Sets the HNSW efConstruction value. + * @param hnswEfConstruction the HNSW efConstruction value + * @since 2.0.0 + */ + public void setHnswEfConstruction(int hnswEfConstruction) { + this.hnswEfConstruction = hnswEfConstruction; + } + + /** + * Returns the configured IVF neighbor partitions value. + * @return the configured IVF neighbor partitions value + * @since 2.0.0 + */ + public int getIvfNeighborPartitions() { + return this.ivfNeighborPartitions; + } + + /** + * Sets the IVF neighbor partitions value. + * @param ivfNeighborPartitions the IVF neighbor partitions value + * @since 2.0.0 + */ + public void setIvfNeighborPartitions(int ivfNeighborPartitions) { + this.ivfNeighborPartitions = ivfNeighborPartitions; + } + + /** + * Returns the configured IVF sample per partition value. + * @return the configured IVF sample per partition value + * @since 2.0.0 + */ + public int getIvfSamplePerPartition() { + return this.ivfSamplePerPartition; + } + + /** + * Sets the IVF sample per partition value. + * @param ivfSamplePerPartition the IVF sample per partition value + * @since 2.0.0 + */ + public void setIvfSamplePerPartition(int ivfSamplePerPartition) { + this.ivfSamplePerPartition = ivfSamplePerPartition; + } + + /** + * Returns the configured IVF minimum vectors per partition value. + * @return the configured IVF minimum vectors per partition value + * @since 2.0.0 + */ + public int getIvfMinVectorsPerPartition() { + return this.ivfMinVectorsPerPartition; + } + + /** + * Sets the IVF minimum vectors per partition value. + * @param ivfMinVectorsPerPartition the IVF minimum vectors per partition value + * @since 2.0.0 + */ + public void setIvfMinVectorsPerPartition(int ivfMinVectorsPerPartition) { + this.ivfMinVectorsPerPartition = ivfMinVectorsPerPartition; + } + } diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfigurationIT.java b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfigurationIT.java index 946a156bbc..fb5e034f4b 100644 --- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfigurationIT.java +++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStoreAutoConfigurationIT.java @@ -51,14 +51,15 @@ * @author Christian Tzolov * @author Eddú Meléndez * @author Thomas Vitale + * @author Anders Swanson */ @Testcontainers public class OracleVectorStoreAutoConfigurationIT { @Container - static OracleContainer oracle23aiContainer = new OracleContainer("gvenzl/oracle-free:23-slim") - .withCopyFileToContainer(MountableFile.forClasspathResource("/oracle/initialize.sql"), - "/container-entrypoint-initdb.d/initialize.sql"); + static OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-free:23-slim").withCopyFileToContainer( + MountableFile.forClasspathResource("/oracle/initialize.sql"), + "/container-entrypoint-initdb.d/initialize.sql"); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OracleVectorStoreAutoConfiguration.class, @@ -68,9 +69,9 @@ public class OracleVectorStoreAutoConfigurationIT { "spring.ai.vectorstore.oracle.initialize-schema=true", "test.spring.ai.vectorstore.oracle.dimensions=384", // JdbcTemplate configuration - String.format("spring.datasource.url=%s", oracle23aiContainer.getJdbcUrl()), - String.format("spring.datasource.username=%s", oracle23aiContainer.getUsername()), - String.format("spring.datasource.password=%s", oracle23aiContainer.getPassword()), + String.format("spring.datasource.url=%s", oracleContainer.getJdbcUrl()), + String.format("spring.datasource.username=%s", oracleContainer.getUsername()), + String.format("spring.datasource.password=%s", oracleContainer.getPassword()), "spring.datasource.type=oracle.jdbc.pool.OracleDataSource"); List documents = List.of( @@ -152,6 +153,60 @@ public void autoConfigurationEnabledWhenTypeIsOracle() { }); } + @Test + public void customOracleIndexPropertiesAreApplied() { + this.contextRunner + .withPropertyValues("spring.ai.vectorstore.oracle.initialize-schema=false", + "spring.ai.vectorstore.oracle.index-type=HNSW", "spring.ai.vectorstore.oracle.hnsw-neighbors=64", + "spring.ai.vectorstore.oracle.hnsw-ef-construction=300", + "spring.ai.vectorstore.oracle.ivf-neighbor-partitions=32", + "spring.ai.vectorstore.oracle.ivf-sample-per-partition=16", + "spring.ai.vectorstore.oracle.ivf-min-vectors-per-partition=8") + .run(context -> { + OracleVectorStoreProperties properties = context.getBean(OracleVectorStoreProperties.class); + OracleVectorStore vectorStore = context.getBean(OracleVectorStore.class); + + assertThat(properties.getIndexType()).isEqualTo(OracleVectorStore.OracleVectorStoreIndexType.HNSW); + assertThat(properties.getHnswNeighbors()).isEqualTo(64); + assertThat(properties.getHnswEfConstruction()).isEqualTo(300); + assertThat(properties.getIvfNeighborPartitions()).isEqualTo(32); + assertThat(properties.getIvfSamplePerPartition()).isEqualTo(16); + assertThat(properties.getIvfMinVectorsPerPartition()).isEqualTo(8); + + assertThat(vectorStore.getIndexType()).isEqualTo(OracleVectorStore.OracleVectorStoreIndexType.HNSW); + assertThat(vectorStore.getHnswNeighbors()).isEqualTo(64); + assertThat(vectorStore.getHnswEfConstruction()).isEqualTo(300); + assertThat(vectorStore.getIvfNeighborPartitions()).isEqualTo(32); + assertThat(vectorStore.getIvfSamplePerPartition()).isEqualTo(16); + assertThat(vectorStore.getIvfMinVectorsPerPartition()).isEqualTo(8); + }); + } + + @Test + public void invalidHnswPropertiesFailAtStartup() { + this.contextRunner + .withPropertyValues("spring.ai.vectorstore.oracle.initialize-schema=false", + "spring.ai.vectorstore.oracle.index-type=HNSW", "spring.ai.vectorstore.oracle.hnsw-neighbors=0") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("HNSW neighbors must be greater than 0"); + }); + } + + @Test + public void invalidIvfPropertiesFailAtStartup() { + this.contextRunner + .withPropertyValues("spring.ai.vectorstore.oracle.initialize-schema=false", + "spring.ai.vectorstore.oracle.index-type=IVF", + "spring.ai.vectorstore.oracle.ivf-sample-per-partition=-2") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF sample per partition must be greater than 0"); + }); + } + @Configuration(proxyBeanMethods = false) static class Config { diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStorePropertiesTests.java b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStorePropertiesTests.java index 74dba0bab9..4b4927f29f 100644 --- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStorePropertiesTests.java +++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-oracle/src/test/java/org/springframework/ai/vectorstore/oracle/autoconfigure/OracleVectorStorePropertiesTests.java @@ -26,6 +26,7 @@ /** * @author Christian Tzolov + * @author Anders Swanson */ public class OracleVectorStorePropertiesTests { @@ -35,6 +36,12 @@ public void defaultValues() { assertThat(props.getDimensions()).isEqualTo(OracleVectorStore.DEFAULT_DIMENSIONS); assertThat(props.getDistanceType()).isEqualTo(OracleVectorStoreDistanceType.COSINE); assertThat(props.getIndexType()).isEqualTo(OracleVectorStoreIndexType.IVF); + assertThat(props.getHnswNeighbors()).isEqualTo(OracleVectorStore.DEFAULT_HNSW_NEIGHBORS); + assertThat(props.getHnswEfConstruction()).isEqualTo(OracleVectorStore.DEFAULT_HNSW_EF_CONSTRUCTION); + assertThat(props.getIvfNeighborPartitions()).isEqualTo(OracleVectorStore.DEFAULT_IVF_NEIGHBOR_PARTITIONS); + assertThat(props.getIvfSamplePerPartition()).isEqualTo(OracleVectorStore.DEFAULT_IVF_SAMPLE_PER_PARTITION); + assertThat(props.getIvfMinVectorsPerPartition()) + .isEqualTo(OracleVectorStore.DEFAULT_IVF_MIN_VECTORS_PER_PARTITION); assertThat(props.isRemoveExistingVectorStoreTable()).isFalse(); } @@ -44,12 +51,22 @@ public void customValues() { props.setDimensions(1536); props.setDistanceType(OracleVectorStoreDistanceType.EUCLIDEAN); - props.setIndexType(OracleVectorStoreIndexType.IVF); + props.setIndexType(OracleVectorStoreIndexType.HNSW); + props.setHnswNeighbors(64); + props.setHnswEfConstruction(300); + props.setIvfNeighborPartitions(20); + props.setIvfSamplePerPartition(16); + props.setIvfMinVectorsPerPartition(8); props.setRemoveExistingVectorStoreTable(true); assertThat(props.getDimensions()).isEqualTo(1536); assertThat(props.getDistanceType()).isEqualTo(OracleVectorStoreDistanceType.EUCLIDEAN); - assertThat(props.getIndexType()).isEqualTo(OracleVectorStoreIndexType.IVF); + assertThat(props.getIndexType()).isEqualTo(OracleVectorStoreIndexType.HNSW); + assertThat(props.getHnswNeighbors()).isEqualTo(64); + assertThat(props.getHnswEfConstruction()).isEqualTo(300); + assertThat(props.getIvfNeighborPartitions()).isEqualTo(20); + assertThat(props.getIvfSamplePerPartition()).isEqualTo(16); + assertThat(props.getIvfMinVectorsPerPartition()).isEqualTo(8); assertThat(props.isRemoveExistingVectorStoreTable()).isTrue(); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc index a1e87e4b20..a7abb5e1ad 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc @@ -130,7 +130,7 @@ Spring AI supports multiple relational databases via a dialect abstraction. The - MySQL / MariaDB - SQL Server - HSQLDB -- Oracle Database +- Oracle AI Database The correct dialect can be auto-detected from the JDBC URL when using `JdbcChatMemoryRepositoryDialect.from(DataSource)`. You can extend support for other databases by implementing the `JdbcChatMemoryRepositoryDialect` interface. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc index d45e66d312..2861217f4c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc @@ -389,7 +389,7 @@ These are the available implementations of the `VectorStore` interface: * 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. * xref:api/vectordbs/opensearch.adoc[OpenSearch Vector Store] - The https://opensearch.org/platform/search/vector-database.html[OpenSearch] vector store. -* xref:api/vectordbs/oracle.adoc[Oracle Vector Store] - The https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/overview-ai-vector-search.html[Oracle Database] vector store. +* xref:api/vectordbs/oracle.adoc[Oracle Vector Store] - The https://docs.oracle.com/en/database/oracle/oracle-database/26/vecse/overview-ai-vector-search.html[Oracle AI Database] vector store. * xref:api/vectordbs/pgvector.adoc[PgVector Store] - The https://github.com/pgvector/pgvector[PostgreSQL/PGVector] vector store. * xref:api/vectordbs/pinecone.adoc[Pinecone Vector Store] - https://www.pinecone.io/[Pinecone] vector store. * xref:api/vectordbs/qdrant.adoc[Qdrant Vector Store] - https://www.qdrant.tech/[Qdrant] vector store. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/oracle.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/oracle.adoc index b889209f78..ad27f94bd3 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/oracle.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/oracle.adoc @@ -1,8 +1,8 @@ -= Oracle Database 23ai - AI Vector Search += Oracle AI Database - AI Vector Search -The link:https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/overview-ai-vector-search.html[AI Vector Search] capabilities of the Oracle Database 23ai (23.4+) are available as a Spring AI `VectorStore` to help you to store document embeddings and perform similarity searches. Of course, all other features are also available. +The link:https://docs.oracle.com/en/database/oracle/oracle-database/26/vecse/overview-ai-vector-search.html[AI Vector Search] capabilities of Oracle AI Database are available as a Spring AI `VectorStore` to help you to store document embeddings and perform similarity searches. Of course, all other features are also available. -TIP: The <> appendix shows how to start a database with a lightweight Docker container. +TIP: The <> appendix shows how to start a database with a lightweight Docker container. == Auto-Configuration @@ -35,6 +35,8 @@ If you need this vector store to initialize the schema for you then you'll need NOTE: this is a breaking change! In earlier versions of Spring AI, this schema initialization happened by default. +NOTE: HNSW index creation requires an Oracle AI Database release that supports HNSW vector indexes. The local Testcontainers example below uses `gvenzl/oracle-free:23-slim`. + 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]. @@ -109,7 +111,7 @@ You can use the following properties in your Spring Boot configuration to custom |=== |Property| Description | Default value -|`spring.ai.vectorstore.oracle.index-type`| Nearest neighbor search index type. Options are `NONE` - exact nearest neighbor search, `IVF` - Inverted Flat File index. It has faster build times and uses less memory than HNSW, but has lower query performance (in terms of speed-recall tradeoff). `HNSW` - creates a multilayer graph. It has slower build times and uses more memory than IVF, but has better query performance (in terms of speed-recall tradeoff). | NONE +|`spring.ai.vectorstore.oracle.index-type`| Nearest neighbor search index type. Options are `NONE` - exact nearest neighbor search, `IVF` - Inverted Flat File index. It has faster build times and uses less memory than HNSW, but has lower query performance (in terms of speed-recall tradeoff). `HNSW` - creates a multilayer graph. It has slower build times and uses more memory than IVF, but has better query performance (in terms of speed-recall tradeoff). If Oracle rejects the configured index DDL at startup, initialization fails and the application must be reconfigured. | IVF |`spring.ai.vectorstore.oracle.distance-type`| Search distance type among `COSINE` (default), `DOT`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, and `MANHATTAN`. NOTE: If vectors are normalized, you can use `DOT` or `COSINE` for best performance.| COSINE @@ -122,6 +124,11 @@ NOTE: If vectors are normalized, you can use `DOT` or `COSINE` for best performa |`spring.ai.vectorstore.oracle.remove-existing-vector-store-table` | Drops the existing table on start up. | false |`spring.ai.vectorstore.oracle.initialize-schema` | Whether to initialize the required schema. | false |`spring.ai.vectorstore.oracle.search-accuracy` | Denote the requested accuracy target in the presence of index. Disabled by default. You need to provide an integer in the range [1,100] to override the default index accuracy (95). Using lower accuracy provides approximate similarity search trading off speed versus accuracy. | -1 (`DEFAULT_SEARCH_ACCURACY`) +|`spring.ai.vectorstore.oracle.hnsw-neighbors` | HNSW `NEIGHBORS` index build parameter. Used only when `index-type=HNSW`. | 40 +|`spring.ai.vectorstore.oracle.hnsw-ef-construction` | HNSW `EFCONSTRUCTION` index build parameter. Used only when `index-type=HNSW`. | 500 +|`spring.ai.vectorstore.oracle.ivf-neighbor-partitions` | IVF `NEIGHBOR PARTITIONS` index build parameter. Used only when `index-type=IVF`. | 10 +|`spring.ai.vectorstore.oracle.ivf-sample-per-partition` | Optional IVF `SAMPLE_PER_PARTITION` index build parameter. Omitted unless explicitly configured. | -1 +|`spring.ai.vectorstore.oracle.ivf-min-vectors-per-partition` | Optional IVF `MIN_VECTORS_PER_PARTITION` index build parameter. Omitted unless explicitly configured. | -1 |=== @@ -192,19 +199,34 @@ To configure the `OracleVectorStore` in your application, you can use the follow public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) { return OracleVectorStore.builder(jdbcTemplate, embeddingModel) .tableName("my_vectors") - .indexType(OracleVectorStoreIndexType.IVF) + .indexType(OracleVectorStoreIndexType.HNSW) .distanceType(OracleVectorStoreDistanceType.COSINE) .dimensions(1536) .searchAccuracy(95) + .hnswNeighbors(64) + .hnswEfConstruction(300) .initializeSchema(true) .build(); } ---- -== Run Oracle Database 23ai locally +To customize IVF index creation instead, configure the IVF-specific builder settings: +[source,java] ---- -docker run --rm --name oracle23ai -p 1521:1521 -e APP_USER=mlops -e APP_USER_PASSWORD=mlops -e ORACLE_PASSWORD=mlops gvenzl/oracle-free:23-slim +OracleVectorStore.builder(jdbcTemplate, embeddingModel) + .indexType(OracleVectorStoreIndexType.IVF) + .ivfNeighborPartitions(32) + .ivfSamplePerPartition(16) + .ivfMinVectorsPerPartition(8) + .initializeSchema(true) + .build(); +---- + +== Run Oracle AI Database locally + +---- +docker run --rm --name oracle-ai-db -p 1521:1521 -e APP_USER=mlops -e APP_USER_PASSWORD=mlops -e ORACLE_PASSWORD=mlops gvenzl/oracle-free:23-slim ---- You can then connect to the database using: @@ -221,10 +243,8 @@ The Oracle Vector Store implementation provides access to the underlying native ---- OracleVectorStore vectorStore = context.getBean(OracleVectorStore.class); Optional nativeClient = vectorStore.getNativeClient(); - if (nativeClient.isPresent()) { - OracleConnection connection = nativeClient.get(); - // Use the native client for Oracle-specific operations + OracleConnection connection = nativeClient.get(); // Use the native client for Oracle-specific operations } ---- diff --git a/vector-stores/spring-ai-oracle-store/README.md b/vector-stores/spring-ai-oracle-store/README.md index 0484b1911d..3b18d4114e 100644 --- a/vector-stores/spring-ai-oracle-store/README.md +++ b/vector-stores/spring-ai-oracle-store/README.md @@ -1 +1 @@ -[Oracle AI Vector Search Documentation](https://docs.oracle.com/en/database/oracle/oracle-database/23/nfcoa/ai_vector_search.html) \ No newline at end of file +[Oracle AI Vector Search Documentation](https://docs.oracle.com/en/database/oracle/oracle-database/26/nfcoa/ai_vector_search.html) \ No newline at end of file diff --git a/vector-stores/spring-ai-oracle-store/pom.xml b/vector-stores/spring-ai-oracle-store/pom.xml index a7fd58deaf..0574101851 100644 --- a/vector-stores/spring-ai-oracle-store/pom.xml +++ b/vector-stores/spring-ai-oracle-store/pom.xml @@ -27,7 +27,7 @@ spring-ai-oracle-store jar Spring AI Vector Store - Oracle - AI Vector Search from Oracle Database 23ai+ as a Spring AI Vector Store + AI Vector Search from Oracle AI Database as a Spring AI Vector Store https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/OracleVectorStore.java b/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/OracleVectorStore.java index 84025bce13..0c9ad02746 100644 --- a/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/OracleVectorStore.java +++ b/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/OracleVectorStore.java @@ -58,12 +58,11 @@ /** *

- * Integration of Oracle database 23ai as a Vector Store. + * Integration of Oracle AI Database as a Vector Store. *

*

- * With the release 23ai (23.4), the Oracle database provides numerous features useful for - * artificial intelligence such as Vectors, Similarity search, Vector indexes, ONNX - * models... + * Oracle AI Database provides numerous features useful for artificial intelligence such + * as Vectors, Similarity search, Vector indexes, ONNX models... *

*

* This Spring AI Vector store supports the following features: @@ -72,7 +71,7 @@ *

  • Distance type for similarity search (note that similarity threshold can be used * only with distance type COSINE and DOT when ingested vectors are normalized, see * forcedNormalization)
  • - *
  • Vector indexes (use IVF as of 23.4)
  • + *
  • Vector indexes (IVF and, where supported by the connected database, HNSW)
  • *
  • Exact and Approximate similarity search
  • *
  • Filter expression as SQL/JSON Path expression evaluation
  • * @@ -81,6 +80,7 @@ * @author Christian Tzolov * @author Soby Chacko * @author Thomas Vitale + * @author Anders Swanson */ public class OracleVectorStore extends AbstractObservationVectorStore implements InitializingBean { @@ -96,6 +96,16 @@ public class OracleVectorStore extends AbstractObservationVectorStore implements public static final int DEFAULT_SEARCH_ACCURACY = -1; + public static final int DEFAULT_HNSW_NEIGHBORS = 40; + + public static final int DEFAULT_HNSW_EF_CONSTRUCTION = 500; + + public static final int DEFAULT_IVF_NEIGHBOR_PARTITIONS = 10; + + public static final int DEFAULT_IVF_SAMPLE_PER_PARTITION = -1; + + public static final int DEFAULT_IVF_MIN_VECTORS_PER_PARTITION = -1; + private static final Logger logger = LoggerFactory.getLogger(OracleVectorStore.class); private static final Map SIMILARITY_TYPE_MAPPING = Map @@ -137,6 +147,16 @@ public class OracleVectorStore extends AbstractObservationVectorStore implements private final int searchAccuracy; + private final int hnswNeighbors; + + private final int hnswEfConstruction; + + private final int ivfNeighborPartitions; + + private final int ivfSamplePerPartition; + + private final int ivfMinVectorsPerPartition; + private final OracleJsonFactory osonFactory = new OracleJsonFactory(); private final ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -157,6 +177,11 @@ protected OracleVectorStore(Builder builder) { this.distanceType = builder.distanceType; this.dimensions = builder.dimensions; this.searchAccuracy = builder.searchAccuracy; + this.hnswNeighbors = builder.hnswNeighbors; + this.hnswEfConstruction = builder.hnswEfConstruction; + this.ivfNeighborPartitions = builder.ivfNeighborPartitions; + this.ivfSamplePerPartition = builder.ivfSamplePerPartition; + this.ivfMinVectorsPerPartition = builder.ivfMinVectorsPerPartition; this.initializeSchema = builder.initializeSchema; this.removeExistingVectorStoreTable = builder.removeExistingVectorStoreTable; this.forcedNormalization = builder.forcedNormalization; @@ -492,33 +517,112 @@ embedding vector(%s,FLOAT64) annotations(Distance '%s') switch (this.indexType) { case IVF: - this.jdbcTemplate.execute(String.format(""" - create vector index if not exists vector_index_%s on %s (embedding) - organization neighbor partitions - distance %s - with target accuracy %d - parameters (type IVF, neighbor partitions 10)""", this.tableName, this.tableName, - this.distanceType.name(), - this.searchAccuracy == DEFAULT_SEARCH_ACCURACY ? 95 : this.searchAccuracy)); + this.jdbcTemplate.execute(createIvfIndexSql()); + break; + case HNSW: + this.jdbcTemplate.execute(createHnswIndexSql()); + break; + case NONE: break; - - /* - * TODO: Enable for 23.5 case HNSW: - * this.jdbcTemplate.execute(String.format(""" create vector index if not - * exists vector_index_%s on %s (embedding) organization inmemory neighbor - * graph distance %s with target accuracy %d parameters (type HNSW, - * neighbors 40, efconstruction 500)""", tableName, tableName, - * distanceType.name(), searchAccuracy == DEFAULT_SEARCH_ACCURACY ? 95 : - * searchAccuracy)); break; - */ } } } + private String createIvfIndexSql() { + return String.format(""" + create vector index if not exists vector_index_%s on %s (embedding) + organization neighbor partitions + distance %s + with target accuracy %d + parameters (%s)""", this.tableName, this.tableName, this.distanceType.name(), + getTargetAccuracy(), getIvfIndexParameters()); + } + + private String getIvfIndexParameters() { + List parameters = new ArrayList<>(); + parameters.add("type IVF"); + parameters.add("neighbor partitions " + this.ivfNeighborPartitions); + if (this.ivfSamplePerPartition != DEFAULT_IVF_SAMPLE_PER_PARTITION) { + parameters.add("sample_per_partition " + this.ivfSamplePerPartition); + } + if (this.ivfMinVectorsPerPartition != DEFAULT_IVF_MIN_VECTORS_PER_PARTITION) { + parameters.add("min_vectors_per_partition " + this.ivfMinVectorsPerPartition); + } + return String.join(", ", parameters); + } + + private String createHnswIndexSql() { + return String.format(""" + create vector index if not exists vector_index_%s on %s (embedding) + organization inmemory neighbor graph + distance %s + with target accuracy %d + parameters (type HNSW, neighbors %d, efconstruction %d)""", this.tableName, this.tableName, + this.distanceType.name(), getTargetAccuracy(), this.hnswNeighbors, this.hnswEfConstruction); + } + + private int getTargetAccuracy() { + return this.searchAccuracy == DEFAULT_SEARCH_ACCURACY ? 95 : this.searchAccuracy; + } + public String getTableName() { return this.tableName; } + /** + * Returns the configured vector index type. + * @return the configured index type + * @since 2.0.0 + */ + public OracleVectorStoreIndexType getIndexType() { + return this.indexType; + } + + /** + * Returns the configured HNSW neighbors value. + * @return the configured HNSW neighbors value + * @since 2.0.0 + */ + public int getHnswNeighbors() { + return this.hnswNeighbors; + } + + /** + * Returns the configured HNSW efConstruction value. + * @return the configured HNSW efConstruction value + * @since 2.0.0 + */ + public int getHnswEfConstruction() { + return this.hnswEfConstruction; + } + + /** + * Returns the configured IVF neighbor partitions value. + * @return the configured IVF neighbor partitions value + * @since 2.0.0 + */ + public int getIvfNeighborPartitions() { + return this.ivfNeighborPartitions; + } + + /** + * Returns the configured IVF sample per partition value. + * @return the configured IVF sample per partition value + * @since 2.0.0 + */ + public int getIvfSamplePerPartition() { + return this.ivfSamplePerPartition; + } + + /** + * Returns the configured IVF minimum vectors per partition value. + * @return the configured IVF minimum vectors per partition value + * @since 2.0.0 + */ + public int getIvfMinVectorsPerPartition() { + return this.ivfMinVectorsPerPartition; + } + @Override public VectorStoreObservationContext.Builder createObservationContextBuilder(String operationName) { return VectorStoreObservationContext.builder(VectorStoreProvider.ORACLE.value(), operationName) @@ -567,7 +671,7 @@ public enum OracleVectorStoreIndexType { * * * @see Oracle + * "https://docs.oracle.com/en/database/oracle/oracle-database/26/vecse/hierarchical-navigable-small-world-index-syntax-and-parameters.html">Oracle * Database documentation */ HNSW, @@ -581,7 +685,7 @@ public enum OracleVectorStoreIndexType { *

    * * * @see Oracle + * "https://docs.oracle.com/en/database/oracle/oracle-database/26/vecse/understand-inverted-file-flat-vector-indexes.html">Oracle * Database documentation */ IVF @@ -686,6 +790,16 @@ public static class Builder extends AbstractVectorStoreBuilder { private int searchAccuracy = DEFAULT_SEARCH_ACCURACY; + private int hnswNeighbors = DEFAULT_HNSW_NEIGHBORS; + + private int hnswEfConstruction = DEFAULT_HNSW_EF_CONSTRUCTION; + + private int ivfNeighborPartitions = DEFAULT_IVF_NEIGHBOR_PARTITIONS; + + private int ivfSamplePerPartition = DEFAULT_IVF_SAMPLE_PER_PARTITION; + + private int ivfMinVectorsPerPartition = DEFAULT_IVF_MIN_VECTORS_PER_PARTITION; + private boolean initializeSchema = false; private boolean removeExistingVectorStoreTable = false; @@ -769,6 +883,72 @@ public Builder searchAccuracy(int searchAccuracy) { return this; } + /** + * Sets the NEIGHBORS parameter for HNSW indexes. + * @param hnswNeighbors the maximum number of neighbors per node + * @return the builder instance + * @since 2.0.0 + */ + public Builder hnswNeighbors(int hnswNeighbors) { + Assert.isTrue(hnswNeighbors > 0, "HNSW neighbors must be greater than 0"); + this.hnswNeighbors = hnswNeighbors; + return this; + } + + /** + * Sets the EFCONSTRUCTION parameter for HNSW indexes. + * @param hnswEfConstruction the size of the candidate list during construction + * @return the builder instance + * @since 2.0.0 + */ + public Builder hnswEfConstruction(int hnswEfConstruction) { + Assert.isTrue(hnswEfConstruction > 0, "HNSW efConstruction must be greater than 0"); + this.hnswEfConstruction = hnswEfConstruction; + return this; + } + + /** + * Sets the NEIGHBOR PARTITIONS parameter for IVF indexes. + * @param ivfNeighborPartitions the number of IVF partitions + * @return the builder instance + * @since 2.0.0 + */ + public Builder ivfNeighborPartitions(int ivfNeighborPartitions) { + Assert.isTrue(ivfNeighborPartitions > 0, "IVF neighbor partitions must be greater than 0"); + this.ivfNeighborPartitions = ivfNeighborPartitions; + return this; + } + + /** + * Sets the SAMPLE_PER_PARTITION parameter for IVF indexes. + * @param ivfSamplePerPartition the optional sample count per partition, or -1 to + * omit it from DDL + * @return the builder instance + * @since 2.0.0 + */ + public Builder ivfSamplePerPartition(int ivfSamplePerPartition) { + if (ivfSamplePerPartition != DEFAULT_IVF_SAMPLE_PER_PARTITION) { + Assert.isTrue(ivfSamplePerPartition > 0, "IVF sample per partition must be greater than 0"); + } + this.ivfSamplePerPartition = ivfSamplePerPartition; + return this; + } + + /** + * Sets the MIN_VECTORS_PER_PARTITION parameter for IVF indexes. + * @param ivfMinVectorsPerPartition the optional minimum vector count per + * partition, or -1 to omit it from DDL + * @return the builder instance + * @since 2.0.0 + */ + public Builder ivfMinVectorsPerPartition(int ivfMinVectorsPerPartition) { + if (ivfMinVectorsPerPartition != DEFAULT_IVF_MIN_VECTORS_PER_PARTITION) { + Assert.isTrue(ivfMinVectorsPerPartition > 0, "IVF min vectors per partition must be greater than 0"); + } + this.ivfMinVectorsPerPartition = ivfMinVectorsPerPartition; + return this; + } + /** * Sets whether to initialize the database schema. * @param initializeSchema true to initialize schema, false otherwise diff --git a/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/SqlJsonPathFilterExpressionConverter.java b/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/SqlJsonPathFilterExpressionConverter.java index 52175e0493..5f6e3887a3 100644 --- a/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/SqlJsonPathFilterExpressionConverter.java +++ b/vector-stores/spring-ai-oracle-store/src/main/java/org/springframework/ai/vectorstore/oracle/SqlJsonPathFilterExpressionConverter.java @@ -24,8 +24,9 @@ * Converts a {@link Filter} into a JSON Path expression. * * @author Loïc Lefèvre + * @author Anders Swanson * @see JSON + * "https://docs.oracle.com/en/database/oracle/oracle-database/26/adjsn/overview-sql-json-path-expressions.html">JSON * Path Documentation */ public class SqlJsonPathFilterExpressionConverter extends AbstractFilterExpressionConverter { diff --git a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleImage.java b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleImage.java index 2cdd78a3d8..00599e7849 100644 --- a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleImage.java +++ b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleImage.java @@ -20,9 +20,13 @@ /** * @author Thomas Vitale + * @author Anders Swanson */ public final class OracleImage { + /** + * The 23-slim tag resolves to the latest version of Oracle AI Database. + */ public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("gvenzl/oracle-free:23-slim"); private OracleImage() { diff --git a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreBuilderTests.java b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreBuilderTests.java new file mode 100644 index 0000000000..a28be8eaaf --- /dev/null +++ b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreBuilderTests.java @@ -0,0 +1,207 @@ +/* + * Copyright 2023-present 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.oracle; + +import java.lang.reflect.Method; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Anders Swanson + */ +class OracleVectorStoreBuilderTests { + + private final JdbcTemplate jdbcTemplate = new JdbcTemplate(); + + private final EmbeddingModel embeddingModel = new TestEmbeddingModel(); + + @Test + void shouldUseDefaultIndexConfiguration() { + OracleVectorStore vectorStore = OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel).build(); + + assertThat(vectorStore.getIndexType()).isEqualTo(OracleVectorStore.DEFAULT_INDEX_TYPE); + assertThat(vectorStore.getHnswNeighbors()).isEqualTo(OracleVectorStore.DEFAULT_HNSW_NEIGHBORS); + assertThat(vectorStore.getHnswEfConstruction()).isEqualTo(OracleVectorStore.DEFAULT_HNSW_EF_CONSTRUCTION); + assertThat(vectorStore.getIvfNeighborPartitions()).isEqualTo(OracleVectorStore.DEFAULT_IVF_NEIGHBOR_PARTITIONS); + assertThat(vectorStore.getIvfSamplePerPartition()) + .isEqualTo(OracleVectorStore.DEFAULT_IVF_SAMPLE_PER_PARTITION); + assertThat(vectorStore.getIvfMinVectorsPerPartition()) + .isEqualTo(OracleVectorStore.DEFAULT_IVF_MIN_VECTORS_PER_PARTITION); + } + + @Test + void shouldValidateHnswNeighbors() { + assertThatThrownBy( + () -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel).hnswNeighbors(0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("HNSW neighbors must be greater than 0"); + } + + @Test + void shouldValidateHnswEfConstruction() { + assertThatThrownBy( + () -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel).hnswEfConstruction(0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("HNSW efConstruction must be greater than 0"); + } + + @Test + void shouldValidateIvfNeighborPartitions() { + assertThatThrownBy(() -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .ivfNeighborPartitions(0) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF neighbor partitions must be greater than 0"); + } + + @Test + void shouldAllowIvfOptionalSentinelValues() { + OracleVectorStore vectorStore = OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .ivfSamplePerPartition(OracleVectorStore.DEFAULT_IVF_SAMPLE_PER_PARTITION) + .ivfMinVectorsPerPartition(OracleVectorStore.DEFAULT_IVF_MIN_VECTORS_PER_PARTITION) + .build(); + + assertThat(vectorStore.getIvfSamplePerPartition()) + .isEqualTo(OracleVectorStore.DEFAULT_IVF_SAMPLE_PER_PARTITION); + assertThat(vectorStore.getIvfMinVectorsPerPartition()) + .isEqualTo(OracleVectorStore.DEFAULT_IVF_MIN_VECTORS_PER_PARTITION); + } + + @Test + void shouldGenerateHnswIndexSql() { + OracleVectorStore vectorStore = OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .tableName("custom_vectors") + .indexType(OracleVectorStore.OracleVectorStoreIndexType.HNSW) + .distanceType(OracleVectorStore.OracleVectorStoreDistanceType.COSINE) + .hnswNeighbors(64) + .hnswEfConstruction(300) + .build(); + + String sql = invokePrivateSqlMethod(vectorStore, "createHnswIndexSql"); + + assertThat(sql).contains("type HNSW, neighbors 64, efconstruction 300"); + assertThat(sql).contains("with target accuracy 95"); + assertThat(sql).contains("organization inmemory neighbor graph"); + assertThat(sql).contains("vector_index_custom_vectors"); + } + + @Test + void shouldGenerateIvfIndexSqlWithOptionalParameters() { + OracleVectorStore vectorStore = OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .tableName("custom_vectors") + .indexType(OracleVectorStore.OracleVectorStoreIndexType.IVF) + .distanceType(OracleVectorStore.OracleVectorStoreDistanceType.COSINE) + .ivfNeighborPartitions(32) + .ivfSamplePerPartition(16) + .ivfMinVectorsPerPartition(8) + .build(); + + String sql = invokePrivateSqlMethod(vectorStore, "createIvfIndexSql"); + + assertThat(sql) + .contains("type IVF, neighbor partitions 32, sample_per_partition 16, min_vectors_per_partition 8"); + assertThat(sql).contains("with target accuracy 95"); + assertThat(sql).contains("organization neighbor partitions"); + } + + @Test + void shouldGenerateIvfIndexSqlWithoutOptionalParameters() { + OracleVectorStore vectorStore = OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .tableName("custom_vectors") + .indexType(OracleVectorStore.OracleVectorStoreIndexType.IVF) + .distanceType(OracleVectorStore.OracleVectorStoreDistanceType.COSINE) + .ivfNeighborPartitions(32) + .build(); + + String sql = invokePrivateSqlMethod(vectorStore, "createIvfIndexSql"); + + assertThat(sql).contains("type IVF, neighbor partitions 32"); + assertThat(sql).doesNotContain("sample_per_partition"); + assertThat(sql).doesNotContain("min_vectors_per_partition"); + } + + @Test + void shouldValidateIvfSamplePerPartition() { + assertThatThrownBy(() -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .ivfSamplePerPartition(0) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF sample per partition must be greater than 0"); + + assertThatThrownBy(() -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .ivfSamplePerPartition(-2) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF sample per partition must be greater than 0"); + } + + @Test + void shouldValidateIvfMinVectorsPerPartition() { + assertThatThrownBy(() -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .ivfMinVectorsPerPartition(0) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF min vectors per partition must be greater than 0"); + + assertThatThrownBy(() -> OracleVectorStore.builder(this.jdbcTemplate, this.embeddingModel) + .ivfMinVectorsPerPartition(-2) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF min vectors per partition must be greater than 0"); + } + + private static String invokePrivateSqlMethod(OracleVectorStore vectorStore, String methodName) { + try { + Method method = OracleVectorStore.class.getDeclaredMethod(methodName); + method.setAccessible(true); + return (String) method.invoke(vectorStore); + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private static final class TestEmbeddingModel implements EmbeddingModel { + + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + List embeddings = request.getInstructions() + .stream() + .map(text -> new Embedding(new float[] { 1.0f }, 0)) + .toList(); + return new EmbeddingResponse(embeddings); + } + + @Override + public float[] embed(Document document) { + return new float[] { 1.0f }; + } + + @Override + public int dimensions() { + return 1; + } + + } + +} diff --git a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreIT.java b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreIT.java index 4b3c491833..8fa9ae5cd4 100644 --- a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreIT.java +++ b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreIT.java @@ -68,7 +68,7 @@ public class OracleVectorStoreIT extends BaseVectorStoreTests { @Container - static OracleContainer oracle23aiContainer = new OracleContainer(OracleImage.DEFAULT_IMAGE) + static OracleContainer oracleContainer = new OracleContainer(OracleImage.DEFAULT_IMAGE) .withCopyFileToContainer(MountableFile.forClasspathResource("/initialize.sql"), "/container-entrypoint-initdb.d/initialize.sql") .withStartupTimeout(Duration.ofMinutes(5)) @@ -82,12 +82,19 @@ public class OracleVectorStoreIT extends BaseVectorStoreTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withUserConfiguration(TestClient.class) - .withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=COSINE", + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", "test.spring.ai.vectorstore.oracle.dimensions=384", + "test.spring.ai.vectorstore.oracle.hnswNeighbors=40", + "test.spring.ai.vectorstore.oracle.hnswEfConstruction=500", + "test.spring.ai.vectorstore.oracle.ivfNeighborPartitions=10", + "test.spring.ai.vectorstore.oracle.ivfSamplePerPartition=-1", + "test.spring.ai.vectorstore.oracle.ivfMinVectorsPerPartition=-1", + "test.spring.ai.vectorstore.oracle.initializeSchema=true", // JdbcTemplate configuration - String.format("app.datasource.url=%s", oracle23aiContainer.getJdbcUrl()), - String.format("app.datasource.username=%s", oracle23aiContainer.getUsername()), - String.format("app.datasource.password=%s", oracle23aiContainer.getPassword()), + String.format("app.datasource.url=%s", oracleContainer.getJdbcUrl()), + String.format("app.datasource.username=%s", oracleContainer.getUsername()), + String.format("app.datasource.password=%s", oracleContainer.getPassword()), "app.datasource.type=oracle.jdbc.pool.OracleDataSource"); public static String getText(final String uri) { @@ -104,6 +111,14 @@ private static void dropTable(ApplicationContext context, String tableName) { jdbcTemplate.execute("DROP TABLE IF EXISTS " + tableName + " PURGE"); } + private static boolean isIndexPresent(ApplicationContext context, String tableName) { + JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class); + String indexName = ("VECTOR_INDEX_" + tableName).toUpperCase(); + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM USER_INDEXES WHERE INDEX_NAME = ?", + Integer.class, indexName); + return count != null && count > 0; + } + private static boolean isSortedBySimilarity(final List documents) { final List scores = documents.stream().map(Document::getScore).toList(); @@ -127,7 +142,8 @@ private static boolean isSortedBySimilarity(final List documents) { @Override protected void executeTest(Consumer testFunction) { this.contextRunner - .withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=COSINE", + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY) .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -135,10 +151,14 @@ protected void executeTest(Consumer testFunction) { }); } - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "COSINE", "DOT", "EUCLIDEAN", "EUCLIDEAN_SQUARED", "MANHATTAN" }) - public void addAndSearch(String distanceType) { - this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) + @ParameterizedTest(name = "{0} / {1} : {displayName} ") + @CsvSource({ "NONE,COSINE", "NONE,DOT", "NONE,EUCLIDEAN", "NONE,EUCLIDEAN_SQUARED", "NONE,MANHATTAN", "IVF,COSINE", + "IVF,DOT", "IVF,EUCLIDEAN", "IVF,EUCLIDEAN_SQUARED", "IVF,MANHATTAN", "HNSW,COSINE", "HNSW,DOT", + "HNSW,EUCLIDEAN", "HNSW,EUCLIDEAN_SQUARED", "HNSW,MANHATTAN" }) + public void addAndSearch(String indexType, String distanceType) { + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=" + indexType, + "test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) .withPropertyValues( "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY) .run(context -> { @@ -170,7 +190,9 @@ public void addAndSearch(String distanceType) { @CsvSource({ "COSINE,-1", "DOT,-1", "EUCLIDEAN,-1", "EUCLIDEAN_SQUARED,-1", "MANHATTAN,-1", "COSINE,75", "DOT,80", "EUCLIDEAN,60", "EUCLIDEAN_SQUARED,30", "MANHATTAN,42" }) public void searchWithFilters(String distanceType, int searchAccuracy) { - this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) .withPropertyValues("test.spring.ai.vectorstore.oracle.searchAccuracy=" + searchAccuracy) .run(context -> { @@ -251,7 +273,9 @@ public void searchWithFilters(String distanceType, int searchAccuracy) { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "COSINE", "DOT", "EUCLIDEAN", "EUCLIDEAN_SQUARED", "MANHATTAN" }) public void documentUpdate(String distanceType) { - this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) .withPropertyValues( "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY) .run(context -> { @@ -292,7 +316,9 @@ public void documentUpdate(String distanceType) { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "COSINE", "DOT" }) public void searchWithThreshold(String distanceType) { - this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=" + distanceType) .withPropertyValues( "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY) .run(context -> { @@ -330,7 +356,8 @@ public void searchWithThreshold(String distanceType) { @Test void deleteWithComplexFilterExpression() { this.contextRunner - .withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=COSINE", + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY) .run(context -> { VectorStore vectorStore = context.getBean(VectorStore.class); @@ -369,7 +396,8 @@ void deleteWithComplexFilterExpression() { @Test void getNativeClientTest() { this.contextRunner - .withPropertyValues("test.spring.ai.vectorstore.oracle.distanceType=COSINE", + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY) .run(context -> { OracleVectorStore vectorStore = context.getBean(OracleVectorStore.class); @@ -378,6 +406,87 @@ void getNativeClientTest() { }); } + @Test + void customIvfParametersAreApplied() { + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", + "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY, + "test.spring.ai.vectorstore.oracle.initializeSchema=false", + "test.spring.ai.vectorstore.oracle.ivfNeighborPartitions=32", + "test.spring.ai.vectorstore.oracle.ivfSamplePerPartition=16", + "test.spring.ai.vectorstore.oracle.ivfMinVectorsPerPartition=8") + .run(context -> { + OracleVectorStore vectorStore = context.getBean(OracleVectorStore.class); + assertThat(vectorStore.getIndexType()).isEqualTo(OracleVectorStore.OracleVectorStoreIndexType.IVF); + assertThat(vectorStore.getIvfNeighborPartitions()).isEqualTo(32); + assertThat(vectorStore.getIvfSamplePerPartition()).isEqualTo(16); + assertThat(vectorStore.getIvfMinVectorsPerPartition()).isEqualTo(8); + }); + } + + @Test + void defaultIvfInitializationCreatesIndex() { + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", + "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY, + "test.spring.ai.vectorstore.oracle.initializeSchema=true") + .run(context -> { + OracleVectorStore vectorStore = context.getBean(OracleVectorStore.class); + assertThat(isIndexPresent(context, vectorStore.getTableName())).isTrue(); + dropTable(context, vectorStore.getTableName()); + }); + } + + @Test + void hnswInitializationPath() { + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=HNSW", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", + "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY, + "test.spring.ai.vectorstore.oracle.initializeSchema=true", + "test.spring.ai.vectorstore.oracle.hnswNeighbors=64", + "test.spring.ai.vectorstore.oracle.hnswEfConstruction=300") + .run(context -> { + assertThat(context.getStartupFailure()).isNull(); + OracleVectorStore vectorStore = context.getBean(OracleVectorStore.class); + assertThat(vectorStore.getIndexType()).isEqualTo(OracleVectorStore.OracleVectorStoreIndexType.HNSW); + assertThat(vectorStore.getHnswNeighbors()).isEqualTo(64); + assertThat(vectorStore.getHnswEfConstruction()).isEqualTo(300); + assertThat(isIndexPresent(context, vectorStore.getTableName())).isTrue(); + dropTable(context, vectorStore.getTableName()); + }); + } + + @Test + void invalidIvfSamplePerPartitionFailsAtStartup() { + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=IVF", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", + "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY, + "test.spring.ai.vectorstore.oracle.ivfSamplePerPartition=-2") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IVF sample per partition must be greater than 0"); + }); + } + + @Test + void invalidHnswNeighborsFailAtStartup() { + this.contextRunner + .withPropertyValues("test.spring.ai.vectorstore.oracle.indexType=HNSW", + "test.spring.ai.vectorstore.oracle.distanceType=COSINE", + "test.spring.ai.vectorstore.oracle.searchAccuracy=" + OracleVectorStore.DEFAULT_SEARCH_ACCURACY, + "test.spring.ai.vectorstore.oracle.hnswNeighbors=0") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("HNSW neighbors must be greater than 0"); + }); + } + @SpringBootConfiguration @EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class) public static class TestClient { @@ -385,18 +494,44 @@ public static class TestClient { @Value("${test.spring.ai.vectorstore.oracle.distanceType}") OracleVectorStore.OracleVectorStoreDistanceType distanceType; + @Value("${test.spring.ai.vectorstore.oracle.indexType}") + OracleVectorStore.OracleVectorStoreIndexType indexType; + @Value("${test.spring.ai.vectorstore.oracle.searchAccuracy}") int searchAccuracy; + @Value("${test.spring.ai.vectorstore.oracle.hnswNeighbors}") + int hnswNeighbors; + + @Value("${test.spring.ai.vectorstore.oracle.hnswEfConstruction}") + int hnswEfConstruction; + + @Value("${test.spring.ai.vectorstore.oracle.ivfNeighborPartitions}") + int ivfNeighborPartitions; + + @Value("${test.spring.ai.vectorstore.oracle.ivfSamplePerPartition}") + int ivfSamplePerPartition; + + @Value("${test.spring.ai.vectorstore.oracle.ivfMinVectorsPerPartition}") + int ivfMinVectorsPerPartition; + + @Value("${test.spring.ai.vectorstore.oracle.initializeSchema}") + boolean initializeSchema; + @Bean public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) { return OracleVectorStore.builder(jdbcTemplate, embeddingModel) .tableName(OracleVectorStore.DEFAULT_TABLE_NAME) - .indexType(OracleVectorStore.OracleVectorStoreIndexType.IVF) + .indexType(this.indexType) .distanceType(this.distanceType) .dimensions(384) .searchAccuracy(this.searchAccuracy) - .initializeSchema(true) + .hnswNeighbors(this.hnswNeighbors) + .hnswEfConstruction(this.hnswEfConstruction) + .ivfNeighborPartitions(this.ivfNeighborPartitions) + .ivfSamplePerPartition(this.ivfSamplePerPartition) + .ivfMinVectorsPerPartition(this.ivfMinVectorsPerPartition) + .initializeSchema(this.initializeSchema) .removeExistingVectorStoreTable(true) .forcedNormalization(true) .build(); @@ -410,9 +545,9 @@ public JdbcTemplate myJdbcTemplate(DataSource dataSource) { @Bean public DataSourceProperties dataSourceProperties() { DataSourceProperties properties = new DataSourceProperties(); - properties.setUrl(oracle23aiContainer.getJdbcUrl()); - properties.setUsername(oracle23aiContainer.getUsername()); - properties.setPassword(oracle23aiContainer.getPassword()); + properties.setUrl(oracleContainer.getJdbcUrl()); + properties.setUsername(oracleContainer.getUsername()); + properties.setPassword(oracleContainer.getPassword()); return properties; } diff --git a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreObservationIT.java b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreObservationIT.java index d16b56b33b..43274083cf 100644 --- a/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreObservationIT.java +++ b/vector-stores/spring-ai-oracle-store/src/test/java/org/springframework/ai/vectorstore/oracle/OracleVectorStoreObservationIT.java @@ -63,12 +63,13 @@ * @author Christian Tzolov * @author Thomas Vitale * @author Eddú Meléndez + * @author Anders Swanson */ @Testcontainers public class OracleVectorStoreObservationIT { @Container - static OracleContainer oracle23aiContainer = new OracleContainer(OracleImage.DEFAULT_IMAGE) + static OracleContainer oracleContainer = new OracleContainer(OracleImage.DEFAULT_IMAGE) .withCopyFileToContainer(MountableFile.forClasspathResource("/initialize.sql"), "/container-entrypoint-initdb.d/initialize.sql") .withStartupTimeout(Duration.ofMinutes(5)) @@ -210,9 +211,9 @@ public JdbcTemplate myJdbcTemplate(DataSource dataSource) { @Bean public DataSourceProperties dataSourceProperties() { DataSourceProperties properties = new DataSourceProperties(); - properties.setUrl(oracle23aiContainer.getJdbcUrl()); - properties.setUsername(oracle23aiContainer.getUsername()); - properties.setPassword(oracle23aiContainer.getPassword()); + properties.setUrl(oracleContainer.getJdbcUrl()); + properties.setUsername(oracleContainer.getUsername()); + properties.setPassword(oracleContainer.getPassword()); return properties; }