Add PGVector VectorStore implementation

* Enable PGVector indexing support
* Add same document ID vector update support
* Add documentation for VectorStores
This commit is contained in:
Christian Tzolov
2023-09-15 22:39:01 +02:00
committed by Mark Pollack
parent 0218069613
commit 5a63cd840c
18 changed files with 829 additions and 79 deletions

3
.gitignore vendored
View File

@@ -26,10 +26,11 @@ out
/.gradletasknamecache
**/*.flattened-pom.xml
vscode
vscode
settings.json
node
node_modules
package-lock.json
package.json
.vscode

View File

@@ -149,10 +149,10 @@ To build with only unit tests
```
To build including integration tests.
Set API key environment variables for OpenAI and Azure OpenAI before running.
Set API key environment variables for OpenAI and Azure OpenAI before running.
```shell
./mvnw clean package -Pintegration-tests
./mvnw clean verify -Pintegration-tests
```
To build the docs

11
pom.xml
View File

@@ -19,6 +19,7 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-openai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-azure-openai</module>
<module>spring-ai-docs</module>
<module>vector-stores/spring-ai-pgvector-store</module>
</modules>
<organization>
@@ -61,7 +62,7 @@
<java.version>17</java.version>
<!-- prodution dependencies -->
<!-- production dependencies -->
<spring-boot.version>3.1.2</spring-boot.version>
<stringtemplate.version>4.0.2</stringtemplate.version>
<open-ai-client.version>0.12.0</open-ai-client.version>
@@ -127,9 +128,6 @@
<version>${maven-surefire-plugin.version}</version>
<configuration>
<argLine>${surefireArgLine}</argLine>
<excludes>
<exclude>**/*IntegrationTests.java</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
@@ -247,11 +245,6 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
<configuration>
<includes>
<include>**/*IntegrationTests.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<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.experimental.ai</groupId>
@@ -18,6 +19,10 @@
<developerConnection>git@github.com:spring-projects-experimental/spring-ai.git</developerConnection>
</scm>
<properties>
<jsonschema.version>4.31.1</jsonschema.version>
</properties>
<dependencies>
<!-- production dependencies -->
@@ -46,21 +51,15 @@
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>4.31.1</version>
<version>${jsonschema.version}</version>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
<version>4.31.1</version>
<version>${jsonschema.version}</version>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.kjetland</groupId>-->
<!-- <artifactId>mbknor-jackson-jsonschema_2.13</artifactId>-->
<!-- <version>1.0.39</version>-->
<!-- </dependency>-->
<!-- test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -6,14 +6,10 @@ import java.util.*;
public class Document {
private static String DEFAULT_TEXT_TEMPLATE = "{metadata_string}\n\n{text}";
private static String DEFAULT_METADATA_TEMPLATE = "{key}: {value}";
/**
* Unique ID
*/
private String id = UUID.randomUUID().toString();
private final String id;
private List<Double> embedding = new ArrayList<>();
@@ -25,28 +21,24 @@ public class Document {
// Type; introduce when support images, now only text.
// TODO: Rename to `content` instead.
private String text;
private MetadataMode metadataMode = MetadataMode.NONE;
private List<String> excludedMetadataKeysForEmbedding;
private List<String> excludedMetadataKeysForLlm;
private List<String> relatedIds;
private String textTemplate = DEFAULT_TEXT_TEMPLATE;
private String metadataTemplate = DEFAULT_METADATA_TEMPLATE;
private String metadataSeparator = "\n";
public Document(String text) {
this(UUID.randomUUID().toString(), text);
}
public Document(String id, String text) {
this.id = id;
this.text = text;
this.metadata = metadata;
}
public Document(String text, Map<String, Object> metadata) {
this(UUID.randomUUID().toString(), text, metadata);
}
public Document(String id, String text, Map<String, Object> metadata) {
this.id = id;
this.text = text;
this.metadata = metadata;
}
@@ -59,6 +51,43 @@ public class Document {
return this.text;
}
public Map<String, Object> getMetadata() {
return metadata;
}
public List<Double> getEmbedding() {
return embedding;
}
public void setEmbedding(List<Double> embedding) {
this.embedding = embedding;
}
@Override
public String toString() {
return "Document{" + "id='" + id + '\'' + ", metadata=" + metadata + ", text='" + text + '\'' + '}';
}
// TODO: Consider moving the following methods & fields in a seprarate
// dedicated class. (e.g. DocumentService, DocumentUtil or alike)¬
// private List<String> excludedMetadataKeysForEmbedding;
// private List<String> relatedIds;
private static String DEFAULT_TEXT_TEMPLATE = "{metadata_string}\n\n{text}";
private static String DEFAULT_METADATA_TEMPLATE = "{key}: {value}";
private final String textTemplate = DEFAULT_TEXT_TEMPLATE;
private final String metadataTemplate = DEFAULT_METADATA_TEMPLATE;
private final String metadataSeparator = "\n";
private MetadataMode metadataMode = MetadataMode.NONE;
private List<String> excludedMetadataKeysForLlm;
public String getContent() {
return getContent(MetadataMode.ALL);
}
@@ -103,45 +132,28 @@ public class Document {
return String.join(getMetadataSeparator(), metadataStringList);
}
public String getTextTemplate() {
private String getTextTemplate() {
return textTemplate;
}
public String getMetadataTemplate() {
private String getMetadataTemplate() {
return metadataTemplate;
}
public String getMetadataSeparator() {
private String getMetadataSeparator() {
return metadataSeparator;
}
public Map<String, Object> getMetadata() {
return metadata;
}
// public void setTextTemplate(String textTemplate) {
// this.textTemplate = textTemplate;
// }
public List<Double> getEmbedding() {
return embedding;
}
// public void setMetadataTemplate(String metadataTemplate) {
// this.metadataTemplate = metadataTemplate;
// }
public void setEmbedding(List<Double> embedding) {
this.embedding = embedding;
}
@Override
public String toString() {
return "Document{" + "id='" + id + '\'' + ", metadata=" + metadata + ", text='" + text + '\'' + '}';
}
public void setTextTemplate(String textTemplate) {
this.textTemplate = textTemplate;
}
public void setMetadataTemplate(String metadataTemplate) {
this.metadataTemplate = metadataTemplate;
}
public void setMetadataSeparator(String metadataSeparator) {
this.metadataSeparator = metadataSeparator;
}
// public void setMetadataSeparator(String metadataSeparator) {
// this.metadataSeparator = metadataSeparator;
// }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1 +1,202 @@
= Vector Databases
= Vector Databases
== Introduction
Vector Databases are a specialized type of database that plays an essential role in AI applications.
In Vector Databases, queries differ from traditional relational databases.
Instead of exact matches, they perform similarity searches.
When given a vector as a query, a Vector Database returns vectors that are "similar" to the query vector.
Further details on how this similarity is calculated at a high level in provided in a later section.
Vector Databases are used to integrate your data with AI Models.
The first step in their usage is to load your data into a Vector Database.
Then, when a user query is to be sent to the AI model, a set of similar documents is retrieved first.
These documents then serve as the context for the user's question and are sent to the AI model along with the user's query.
This technique is known as Retrieval Augmented Generation.
In the following sections, we will describe the Spring AI interface for using multiple Vector Database implementations and some high-level sample usage.
The last section attempts to demystify the underlying approach of similarity search of Vector Databases.
== Spring AI Vector Database Support
Spring AI provides an abstract API over Vector Databases using the interface `VectorStore`.
The API of the Vector store is shown below
```java
public interface VectorStore {
void add(List<Document> documents);
Optional<Boolean> delete(List<String> idList);
List<Document> similaritySearch(String query);
List<Document> similaritySearch(String query, int k);
List<Document> similaritySearch(String query, int k, double threshold);
```
The Spring AI library uses the `Document` class to model the `String` you want to store in the Vector Database along with the vector representing that `String`.
The vector that representing the `String` is of the type `List<Double>` and is often called the string's embedding.
The `List<Double> is computed from the `String` using an implementation of the class `EmbeddingClient`.
An Embedding Model is an example of an AI model, that transforms from `String` to a `List<Double>`.
The embeddings are used internally by other AI Model implementations, such as models that convert `text` to `text` like ChatGPT.
`EmbeddingClient` that computes the embedding is passed as a constructor argument to `VectorStore` implementation.
The `VectorStore` implementations supported by Spring AI are:
* InMemoryVectorStore
* SimplePersistentVectorStore
* PgVector - A Vector Store build on PostgreSQL.
More are implementations are coming, with Pinecone being the next implementation.
If you have a Vector Database that needs to be supported by Spring AI, please open an issue on GitHub or, even better, submit a Pull Request with an implementation.
== Usage
To compute the embeddings for a Vector Database, you need to pick an Embedding Model that matches the higher-level AI model being used.
For example, with OpenAI's ChatGPT, we use the `OpenAiEmbeddingClient` and the model name `text-embedding-ada-002`.
The Spring Boot Starter's autoconfiguation for OpenAI makes an implementation of `EmbeddingClient` available in the Spring Application Context for Dependency Injection.
The general usage of loading data into a vector store is something you do as a batch-type job, first loading data into Spring AI's `Document` class and then calling the `save` method.
Given a String `sourceFile` that represents a JSON file with data we want to load into the Vector Database, we use Spring AI's `JsonLoader` to load specific fields in the JSON file, which splits them up into small pieces and then passes those small pieces to the vector store implementation.
The `VectorStore` implementation computes the embeddings and stores the JSON and the embedding in the Vector Database.
```java
@Autowired
VectorStore vectorStore;
void load(String sourceFile) {}
JsonLoader jsonLoader = new JsonLoader(new FileSystemResource(sourceFile),
"price", "name", "shortDescription", "description", "tags");
List<Document> documents = jsonLoader.load();
this.vectorStore.add(documents);
}
```
Later, when a user question is to be passed into the AI Model, a similarity search is done to retrieve similar documents, which are then 'stuffed' into the prompt as context for the user's question.
```java
String question = <question from user>
List<Document> similarDocuments = store.similaritySearch(question);
```
There are additional options to be passed into the `similaritySearch` method that defines how many documents to retrieve and a threshold of the similarity search.
== Understanding Vectors
Vectors have dimensionality and a direction.
For example, a picture of a two-dimensional vector stem:[\vec{a}] in the cartesian coordinate system pictured as an arrow.
image::vector_2d_coordinates.png[]
The head of the vector stem:[\vec{a}] is at the point stem:[(a_1, a_2)]
The *x* coordinate value is stem:[a_1] and the *y* coordinate value is stem:[a_2] and are also referred to as the components of the vector.
== Similarity
Several mathematical formulas can be used to determine if two vectors are similar.
One of the most intuitive to visualize and understand is cosine similarity.
Look at the following pictures that show three sets of graphs.
image::vector_similarity.png[]
The vectors stem:[\vec{A}] and stem:[\vec{B}] are considered similar, when they are pointing close to each other, as in the first diagram.
The vectors are considered unrelated when pointing perpendicular to each other and opposite when they point away from each other.
The angle between them, stem:[\theta], is a good measure of their similarity.
How can the angle stem:[\theta] be computed?
We are all familiar with the https://en.wikipedia.org/wiki/Pythagorean_theorem#History[Pythagorean Theorem]
image:pythagorean-triangle.png[]
What about when the angle between *a* and *b* is not 90 degrees?
Enter the https://en.wikipedia.org/wiki/Law_of_cosines[Law of cosines]
.Law of Cosines
****
stem:[a^2 + b^2 - 2ab\cos\theta = c^2]
****
Showing this as a vector diagram
image:lawofcosines.png[]
The magnitude of this vector is defined in terms of its components as:
.Magnitude
****
stem:[\vec{A} * \vec{A} = ||\vec{A}||^2 = A_1^2 + A_2^2 ]
****
and the dot product between two vectors stem:[\vec{A}] and stem:[\vec{B}] is defined in terms of its components as:
.Dot Product
****
stem:[\vec{A} * \vec{B} = A_1B_1 + A_2B_2]
****
Rewriting the Law of Cosines with vector magnitudes and dot products gives:
.Law of Cosines in Vector form
****
stem:[||\vec{A}||^2 + ||\vec{B}||^2 - 2||\vec{A}||||\vec{B}||\cos\theta = ||\vec{C}||^2]
****
Replacing stem:[||\vec{C}||^2] with stem:[||\vec{B} - \vec{A}||^2] gives:
.Law of Cosines in Vector form only in terms of stem:[\vec{A}] and stem:[\vec{B}]
****
stem:[||\vec{A}||^2 + ||\vec{B}||^2 - 2||\vec{A}||||\vec{B}||\cos\theta = ||\vec{B} - \vec{A}||^2]
****
https://towardsdatascience.com/cosine-similarity-how-does-it-measure-the-similarity-maths-behind-and-usage-in-python-50ad30aad7db[Expanding this out] gives us the formula for https://en.wikipedia.org/wiki/Cosine_similarity[Cosine Similarity].
.Cosine Similarity
****
stem:[similarity(vec{A},vec{B}) = \cos(\theta) = \frac{\vec{A}\cdot\vec{B}}{||\vec{A}\||\cdot||\vec{B}||]
****
This formula works for dimensions higher than 2 or 3, though it is hard to visualize, https://projector.tensorflow.org/[but can be done to some extent].
It is common for vectors in AI/ML applications to have hundreds or a thousand dimensions.
The similarity function in higher dimensions using the components of the vector is shown below.
It expands the two-dimensional definitions of Magnitude and Dot Product given previously to *N* dimensions using the https://en.wikipedia.org/wiki/Summation[Summation mathematical syntax].
.Cosine Similarity with vector components
****
stem:[similarity(vec{A},vec{B}) = \cos(\theta) = \frac{ \sum_{i=1}^{n} {A_i B_i} }{ \sqrt{\sum_{i=1}^{n}{A_i^2} \cdot \sum_{i=1}^{n}{B_i^2}}]
****
This is the key formula used in the simple implementation of a Vector Store and can be found in the `InMemoryVectorStore` implementation.

View File

@@ -8,7 +8,7 @@ import org.springframework.ai.client.AiResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.loader.impl.JsonLoader;
import org.springframework.ai.openai.embedding.OpenAiEmbeddingClient;
import org.springframework.ai.openai.testutils.AbstractIntegrationTest;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.ai.prompt.Prompt;
import org.springframework.ai.prompt.SystemPromptTemplate;
import org.springframework.ai.prompt.messages.Message;
@@ -28,9 +28,9 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
public class AcmeIntegrationTest extends AbstractIntegrationTest {
public class AcmeIT extends AbstractIT {
private static final Logger logger = LoggerFactory.getLogger(AcmeIntegrationTest.class);
private static final Logger logger = LoggerFactory.getLogger(AcmeIT.class);
@Value("classpath:/data/acme/bikes.json")
private Resource bikesResource;

View File

@@ -3,7 +3,7 @@ package org.springframework.ai.openai.client;
import org.junit.jupiter.api.Test;
import org.springframework.ai.client.AiResponse;
import org.springframework.ai.client.Generation;
import org.springframework.ai.openai.testutils.AbstractIntegrationTest;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.ai.parser.BeanOutputParser;
import org.springframework.ai.parser.ListOutputParser;
import org.springframework.ai.parser.MapOutputParser;
@@ -24,7 +24,7 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class ClientIntegrationTests extends AbstractIntegrationTest {
class ClientIT extends AbstractIT {
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;

View File

@@ -10,7 +10,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class EmbeddingIntegrationTest {
class EmbeddingIT {
@Autowired
private OpenAiEmbeddingClient embeddingClient;

View File

@@ -18,9 +18,9 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
public class AbstractIntegrationTest {
public abstract class AbstractIT {
private static final Logger logger = LoggerFactory.getLogger(AbstractIntegrationTest.class);
private static final Logger logger = LoggerFactory.getLogger(AbstractIT.class);
@Autowired
protected OpenAiClient openAiClient;

View File

@@ -0,0 +1,16 @@
# PGvector VectorStore
Pgvector is an open-source extension for PostgreSQL that enables storing and searching over machine learning-generated embeddings. It provides different capabilities that let users identify both exact and approximate nearest neighbors. It is designed to work seamlessly with other PostgreSQL features, including indexing and querying.
## Start Postgres+PGVecgor DB:
```
docker run -it --rm --name postgres -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres ankane/pgvector
```
You can connect to this server like this:
```
psql -U postgres -h localhost -p 5432
```

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.experimental.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>0.2.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-pgvector-store</artifactId>
<packaging>jar</packaging>
<name>Spring AI Vector Store - pgvector</name>
<description>Spring AI PGVector Vector Store</description>
<url>https://github.com/spring-projects-experimental/spring-ai</url>
<scm>
<url>https://github.com/spring-projects-experimental/spring-ai</url>
<connection>git://github.com/spring-projects-experimental/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects-experimental/spring-ai.git</developerConnection>
</scm>
<properties>
<pgvector.version>0.1.3</pgvector.version>
<postgresql.version>42.6.0</postgresql.version>
<spring-ai.version>0.2.0-SNAPSHOT</spring-ai.version>
<!-- testing -->
<testcontainers.version>1.19.0</testcontainers.version>
<hikari-cp.version>4.0.3</hikari-cp.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.experimental.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>com.pgvector</groupId>
<artifactId>pgvector</artifactId>
<version>${pgvector.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.experimental.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.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>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikari-cp.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,261 @@
/*
* Copyright 2023-2023 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;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pgvector.PGvector;
import org.postgresql.util.PGobject;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
/**
* @author Christian Tzolov
*/
public class PgVectorStore implements VectorStore {
public static final int OPENAI_EMBEDDING_DIMENSION_SIZE = 1536;
private final JdbcTemplate jdbcTemplate;
private final EmbeddingClient embeddingClient;
private int dimensions;
private PgDistanceType distanceType;
private ObjectMapper objectMapper = new ObjectMapper();
/**
* By default, pgvector performs exact nearest neighbor search, which provides perfect
* recall. You can add an index to use approximate nearest neighbor search, which
* trades some recall for speed. Unlike typical indexes, you will see different
* results for queries after adding an approximate index.
*/
public enum PgIndexType {
/**
* Performs exact nearest neighbor search, which provides perfect recall.
*/
NONE,
/**
* An IVFFlat index divides vectors into lists, and then searches a subset of
* those lists that are closest to the query vector. It has faster build times and
* uses less memory than HNSW, but has lower query performance (in terms of
* speed-recall tradeoff).
*/
IVFFLAT,
/**
* An HNSW index creates a multilayer graph. It has slower build times and uses
* more memory than IVFFlat, but has better query performance (in terms of
* speed-recall tradeoff). Theres no training step like IVFFlat, so the index can
* be created without any data in the table.
*/
HNSW;
}
public enum PgDistanceType {
EuclideanDistance("<->", "vector_l2_ops"),
NegativeInnerProduct("<#>", "vector_ip_ops"),
CosineDistance("<=>", "vector_cosine_ops");
public final String operator;
public final String index;
PgDistanceType(String operator, String index) {
this.operator = operator;
this.index = index;
}
}
private static class DocumentRowMapper implements RowMapper<Document> {
private static final String COLUMN_EMBEDDING = "embedding";
private static final String COLUMN_METADATA = "metadata";
private static final String COLUMN_ID = "id";
private static final String COLUMN_CONTENT = "content";
private ObjectMapper objectMapper;
public DocumentRowMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Document mapRow(ResultSet rs, int rowNum) throws SQLException {
String id = rs.getString(COLUMN_ID);
String content = rs.getString(COLUMN_CONTENT);
PGobject metadata = rs.getObject(COLUMN_METADATA, PGobject.class);
PGobject embedding = rs.getObject(COLUMN_EMBEDDING, PGobject.class);
Document document = new Document(id, content, toMap(metadata));
document.setEmbedding(toDoubleList(embedding));
return document;
}
private List<Double> toDoubleList(PGobject embedding) throws SQLException {
float[] floatArray = new PGvector(embedding.getValue()).toArray();
List<Double> doubleEmbedding = IntStream.range(0, floatArray.length)
.mapToDouble(i -> floatArray[i])
.boxed()
.collect(Collectors.toList());
return doubleEmbedding;
}
private Map<String, Object> toMap(PGobject pgObject) {
String source = pgObject.getValue();
try {
return (Map<String, Object>) objectMapper.readValue(source, Map.class);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
public PgVectorStore(JdbcTemplate jdbcTemplate, EmbeddingClient embeddingClient) {
this(jdbcTemplate, embeddingClient, OPENAI_EMBEDDING_DIMENSION_SIZE,
PgVectorStore.PgDistanceType.CosineDistance, false, PgIndexType.NONE);
}
public PgVectorStore(JdbcTemplate jdbcTemplate, EmbeddingClient embeddingClient, int dimensions,
PgDistanceType distanceType, boolean removeExistingVectorStoreTable, PgIndexType createIndexMethod) {
this.jdbcTemplate = jdbcTemplate;
this.embeddingClient = embeddingClient;
this.dimensions = dimensions;
this.distanceType = distanceType;
// Add PGVector support.
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector");
// Add JSONB support.
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS hstore");
// Add UUID support.
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"");
// Remove existing VectorStoreTable
if (removeExistingVectorStoreTable) {
this.jdbcTemplate.execute("DROP TABLE IF EXISTS vector_store");
}
// TODO: we create id of type UUID, while the Document's id is String!!!
// TODO: remove injection!
this.jdbcTemplate
.execute("CREATE TABLE IF NOT EXISTS vector_store ( " + "id uuid DEFAULT uuid_generate_v4 () PRIMARY KEY, "
+ "content text, " + "metadata json, " + "embedding vector(" + this.dimensions + "))");
if (createIndexMethod != PgIndexType.NONE) {
this.jdbcTemplate.execute("CREATE INDEX ON vector_store USING " + createIndexMethod.name() + " (embedding "
+ distanceType.index + ")");
}
this.jdbcTemplate
.execute("CREATE TABLE IF NOT EXISTS vector_store ( " + "id uuid DEFAULT uuid_generate_v4 () PRIMARY KEY, "
+ "content text, " + "metadata json, " + "embedding vector(" + this.dimensions + "))");
}
@Override
public void add(List<Document> documents) {
for (Document document : documents) {
List<Double> embedding = this.embeddingClient.embed(document);
document.setEmbedding(embedding);
UUID id = UUID.fromString(document.getId());
String content = document.getText(); // TODO: shall we use the text of text +
// metadata?
Map<String, Object> metadata = document.getMetadata();
PGvector pgEmbedding = new PGvector(toFloatArray(embedding));
jdbcTemplate.update(
"INSERT INTO vector_store (id, content, metadata, embedding) VALUES (?, ?, ?::jsonb, ?) "
+ "ON CONFLICT (id) DO " + "UPDATE SET content = ? , metadata = ?::jsonb , embedding = ? ",
id, content, metadata, pgEmbedding, content, metadata, pgEmbedding);
}
}
private float[] toFloatArray(List<Double> embeddingDouble) {
float[] embeddingFloat = new float[embeddingDouble.size()];
int i = 0;
for (Double d : embeddingDouble) {
embeddingFloat[i++] = d.floatValue();
}
return embeddingFloat;
}
@Override
public Optional<Boolean> delete(List<String> idList) {
int updateCount = 0;
for (String id : idList) {
int count = jdbcTemplate.update("DELETE FROM vector_store WHERE id = ?", UUID.fromString(id));
updateCount = updateCount + count;
}
return Optional.of(updateCount == idList.size());
}
@Override
public List<Document> similaritySearch(String query) {
return this.similaritySearch(query, 4);
}
@Override
public List<Document> similaritySearch(String query, int k) {
PGvector queryEmbedding = getQueryEmbedding(query);
return this.jdbcTemplate.query(
"SELECT * FROM vector_store ORDER BY embedding " + this.distanceType.operator + " ? LIMIT ?",
new DocumentRowMapper(this.objectMapper), queryEmbedding, k);
}
@Override
public List<Document> similaritySearch(String query, int k, double threshold) {
PGvector queryEmbedding = getQueryEmbedding(query);
return this.jdbcTemplate.query(
"SELECT * FROM vector_store ORDER BY embedding " + this.distanceType.operator + " ? < ? LIMIT ? ",
new DocumentRowMapper(this.objectMapper), queryEmbedding, threshold, k);
}
private PGvector getQueryEmbedding(String query) {
List<Double> embedding = this.embeddingClient.embed(query);
return new PGvector(toFloatArray(embedding));
}
}

View File

@@ -0,0 +1,173 @@
/*
* Copyright 2023-2023 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;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.sql.DataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.vectorstore.PgVectorStore.PgIndexType;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
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.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
*/
@Testcontainers
public class PgVectorStoreIT {
@Container
static GenericContainer<?> postgresContainer = new GenericContainer<>("ankane/pgvector")
.withEnv("POSTGRES_USER", "postgres")
.withEnv("POSTGRES_PASSWORD", "postgres")
.withExposedPorts(5432);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues("spring.datasource.type=com.zaxxer.hikari.HikariDataSource",
"spring.ai.openai.apiKey=" + System.getenv("SPRING_AI_OPENAI_API_KEY"),
// JdbcTemplate configuration
String.format("app.datasource.url=jdbc:postgresql://localhost:%d/%s",
postgresContainer.getMappedPort(5432), "postgres"),
"app.datasource.username=postgres", "app.datasource.password=postgres",
"app.datasource.type=com.zaxxer.hikari.HikariDataSource");
@Test
public void vectorStoreTest() {
contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);
List<Document> documents = List.of(
new Document(
"Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!",
Collections.singletonMap("meta1", "meta1")),
new Document("Hello World Hello World Hello World Hello World Hello World Hello World Hello World"),
new Document(
"Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression",
Collections.singletonMap("meta2", "meta2")));
vectorStore.add(documents);
List<Document> results = vectorStore.similaritySearch("Great", 1);
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(documents.get(2).getId());
assertThat(resultDoc.getText()).isEqualTo(
"Great Depression Great Depression Great Depression Great Depression Great Depression Great Depression");
assertThat(resultDoc.getMetadata()).isEqualTo(Collections.singletonMap("meta2", "meta2"));
// Remove all documents from the store
vectorStore.delete(documents.stream().map(doc -> doc.getId()).collect(Collectors.toList()));
List<Document> results2 = vectorStore.similaritySearch("Great", 1);
assertThat(results2).hasSize(0);
});
}
@Test
public void documentUpdateTest() {
contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)).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("Spring", 5);
assertThat(results).hasSize(1);
Document resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(document.getId());
assertThat(resultDoc.getText()).isEqualTo("Spring AI rocks!!");
assertThat(resultDoc.getMetadata()).isEqualTo(Collections.singletonMap("meta1", "meta1"));
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("FooBar", 5);
assertThat(results).hasSize(1);
resultDoc = results.get(0);
assertThat(resultDoc.getId()).isEqualTo(document.getId());
assertThat(resultDoc.getText()).isEqualTo("The World is Big and Salvation Lurks Around the Corner");
assertThat(resultDoc.getMetadata()).isEqualTo(Collections.singletonMap("meta2", "meta2"));
});
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
public static class TestApplication {
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingClient embeddingClient) {
return new PgVectorStore(jdbcTemplate, embeddingClient, 1536, PgVectorStore.PgDistanceType.CosineDistance,
true, PgIndexType.HNSW);
}
@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();
}
}
}