Add PostgresML support for EmbeddingClient

- Implement dimensions method
 - Add MetadataMode support. Defaults to EMBED
 - Drop the pgml extension between tests.
 - Disable the PostgresMlEmbeddingClientIT by default.

Resolves #33
This commit is contained in:
Toshiaki Maki
2023-09-28 15:13:15 +09:00
committed by Christian Tzolov
parent 7c3c8445f0
commit caa8cb2021
4 changed files with 393 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
<?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.7.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-postgresml-embedding-client</artifactId>
<packaging>jar</packaging>
<name>Spring AI Embedding Client - PostgresML</name>
<description>Spring AI PostgresML Embedding Client</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>
<dependencies>
<dependency>
<groupId>org.springframework.experimental.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${parent.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,181 @@
package org.springframework.ai.embedding;
import java.sql.Array;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* <a href="https://postgresml.org">PostgresML</a> EmbeddingClient
*
* @author Toshiaki Maki
*/
public class PostgresMlEmbeddingClient implements EmbeddingClient, InitializingBean {
private final JdbcTemplate jdbcTemplate;
private final String transformer;
private final VectorType vectorType;
private final String kwargs;
private final AtomicInteger embeddingDimensions = new AtomicInteger(-1);
private final MetadataMode metadataMode;
public enum VectorType {
PG_ARRAY("", null, (rs, i) -> {
Array embedding = rs.getArray("embedding");
return Arrays.stream((Float[]) embedding.getArray()).map(Float::doubleValue).toList();
}), PG_VECTOR("::vector", "vector", (rs, i) -> {
String embedding = rs.getString("embedding");
return Arrays.stream((embedding.substring(1, embedding.length() - 1)
/* remove leading '[' and trailing ']' */.split(","))).map(Double::parseDouble).toList();
});
private final String cast;
private final String extensionName;
private final RowMapper<List<Double>> rowMapper;
VectorType(String cast, String extensionName, RowMapper<List<Double>> rowMapper) {
this.cast = cast;
this.extensionName = extensionName;
this.rowMapper = rowMapper;
}
}
/**
* a constructor
* @param jdbcTemplate JdbcTemplate
*/
public PostgresMlEmbeddingClient(JdbcTemplate jdbcTemplate) {
this(jdbcTemplate, "distilbert-base-uncased");
}
/**
* a constructor
* @param jdbcTemplate JdbcTemplate
* @param transformer huggingface sentence-transformer name
*/
public PostgresMlEmbeddingClient(JdbcTemplate jdbcTemplate, String transformer) {
this(jdbcTemplate, transformer, VectorType.PG_ARRAY);
}
/**
* a constructor
* @param jdbcTemplate JdbcTemplate
* @param transformer huggingface sentence-transformer name
* @param vectorType vector type in PostgreSQL
*/
public PostgresMlEmbeddingClient(JdbcTemplate jdbcTemplate, String transformer, VectorType vectorType) {
this(jdbcTemplate, transformer, vectorType, Map.of(), MetadataMode.EMBED);
}
/**
* a constructor
* @param jdbcTemplate JdbcTemplate
* @param transformer huggingface sentence-transformer name
* @param vectorType vector type in PostgreSQL
* @param kwargs optional arguments
*/
public PostgresMlEmbeddingClient(JdbcTemplate jdbcTemplate, String transformer, VectorType vectorType,
Map<String, Object> kwargs, MetadataMode metadataMode) {
Assert.notNull(jdbcTemplate, "jdbc template must not be null.");
Assert.notNull(transformer, "transformer must not be null.");
Assert.notNull(vectorType, "vectorType must not be null.");
Assert.notNull(kwargs, "kwargs must not be null.");
Assert.notNull(metadataMode, "metadataMode must not be null.");
this.jdbcTemplate = jdbcTemplate;
this.transformer = transformer;
this.vectorType = vectorType;
this.metadataMode = metadataMode;
try {
this.kwargs = new ObjectMapper().writeValueAsString(kwargs);
}
catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
}
@Override
public List<Double> embed(String text) {
return this.jdbcTemplate.queryForObject(
"SELECT pgml.embed(?, ?, ?::JSONB)" + this.vectorType.cast + " AS embedding", this.vectorType.rowMapper,
this.transformer, text, this.kwargs);
}
@Override
public List<Double> embed(Document document) {
return this.embed(document.getFormattedContent(this.metadataMode));
}
@Override
public List<List<Double>> embed(List<String> texts) {
if (CollectionUtils.isEmpty(texts)) {
return List.of();
}
return this.jdbcTemplate.query(connection -> {
PreparedStatement preparedStatement = connection.prepareStatement("SELECT pgml.embed(?, text, ?::JSONB)"
+ vectorType.cast + " AS embedding FROM (SELECT unnest(?) AS text) AS texts");
preparedStatement.setString(1, transformer);
preparedStatement.setString(2, kwargs);
preparedStatement.setArray(3, connection.createArrayOf("TEXT", texts.toArray(Object[]::new)));
return preparedStatement;
}, rs -> {
List<List<Double>> result = new ArrayList<>();
while (rs.next()) {
result.add(vectorType.rowMapper.mapRow(rs, -1));
}
return result;
});
}
@Override
public EmbeddingResponse embedForResponse(List<String> texts) {
List<Embedding> data = new ArrayList<>();
List<List<Double>> embed = this.embed(texts);
for (int i = 0; i < embed.size(); i++) {
data.add(new Embedding(embed.get(i), i));
}
return new EmbeddingResponse(data,
Map.of("transformer", this.transformer, "vector-type", this.vectorType.name(), "kwargs", this.kwargs));
}
@Override
public int dimensions() {
if (this.embeddingDimensions.get() < 0) {
this.embeddingDimensions.set(EmbeddingUtil.dimensions(this, this.transformer));
}
return this.embeddingDimensions.get();
}
@Override
public void afterPropertiesSet() {
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS pgml");
if (StringUtils.hasText(this.vectorType.extensionName)) {
this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS " + this.vectorType.extensionName);
}
}
}

View File

@@ -0,0 +1,137 @@
package org.springframework.ai.embedding;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.PostgresMlEmbeddingClient.VectorType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.embedding.PostgresMlEmbeddingClient.VectorType.PG_ARRAY;
import static org.springframework.ai.embedding.PostgresMlEmbeddingClient.VectorType.PG_VECTOR;
/**
* @author Toshiaki Maki
*/
@JdbcTest(properties = "logging.level.sql=TRACE")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
@Disabled("Disabled from automatic execution, as it requires an excessive amount of memory (over 9GB)!")
class PostgresMlEmbeddingClientIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("ghcr.io/postgresml/postgresml:2.7.3").asCompatibleSubstituteFor("postgres"))
.withCommand("sleep", "infinity")
.withLabel("org.springframework.boot.service-connection", "postgres")
.withUsername("postgresml")
.withPassword("postgresml")
.withDatabaseName("postgresml")
.waitingFor(new LogMessageWaitStrategy().withRegEx(".*Starting dashboard.*\\s")
.withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)));
@Autowired
JdbcTemplate jdbcTemplate;
@AfterEach
void dropPgmlExtension() {
this.jdbcTemplate.execute("DROP EXTENSION IF EXISTS pgml");
}
@Test
void embed() {
PostgresMlEmbeddingClient embeddingClient = new PostgresMlEmbeddingClient(this.jdbcTemplate);
embeddingClient.afterPropertiesSet();
List<Double> embed = embeddingClient.embed("Hello World!");
assertThat(embed).hasSize(768);
// embeddingClient.dropPgmlExtension();
}
@Test
void embedWithPgVector() {
PostgresMlEmbeddingClient embeddingClient = new PostgresMlEmbeddingClient(this.jdbcTemplate,
"distilbert-base-uncased", PG_VECTOR);
embeddingClient.afterPropertiesSet();
List<Double> embed = embeddingClient.embed(new Document("Hello World!"));
assertThat(embed).hasSize(768);
// embeddingClient.dropPgmlExtension();
}
@Test
void embedWithDifferentModel() {
PostgresMlEmbeddingClient embeddingClient = new PostgresMlEmbeddingClient(this.jdbcTemplate,
"intfloat/e5-small");
embeddingClient.afterPropertiesSet();
List<Double> embed = embeddingClient.embed(new Document("Hello World!"));
assertThat(embed).hasSize(384);
// embeddingClient.dropPgmlExtension();
}
@Test
void embedWithKwargs() {
PostgresMlEmbeddingClient embeddingClient = new PostgresMlEmbeddingClient(this.jdbcTemplate,
"distilbert-base-uncased", PG_ARRAY, Map.of("device", "cpu"), MetadataMode.EMBED);
embeddingClient.afterPropertiesSet();
List<Double> embed = embeddingClient.embed(new Document("Hello World!"));
assertThat(embed).hasSize(768);
// embeddingClient.dropPgmlExtension();
}
@ParameterizedTest
@ValueSource(strings = { "PG_ARRAY", "PG_VECTOR" })
void embedForResponse(String vectorType) {
PostgresMlEmbeddingClient embeddingClient = new PostgresMlEmbeddingClient(this.jdbcTemplate,
"distilbert-base-uncased", VectorType.valueOf(vectorType));
embeddingClient.afterPropertiesSet();
EmbeddingResponse embeddingResponse = embeddingClient
.embedForResponse(List.of("Hello World!", "Spring AI!", "LLM!"));
assertThat(embeddingResponse).isNotNull();
assertThat(embeddingResponse.getData()).hasSize(3);
assertThat(embeddingResponse.getMetadata()).containsExactlyEntriesOf(
Map.of("transformer", "distilbert-base-uncased", "vector-type", vectorType, "kwargs", "{}"));
assertThat(embeddingResponse.getData().get(0).getIndex()).isEqualTo(0);
assertThat(embeddingResponse.getData().get(0).getEmbedding()).hasSize(768);
assertThat(embeddingResponse.getData().get(1).getIndex()).isEqualTo(1);
assertThat(embeddingResponse.getData().get(1).getEmbedding()).hasSize(768);
assertThat(embeddingResponse.getData().get(2).getIndex()).isEqualTo(2);
assertThat(embeddingResponse.getData().get(2).getEmbedding()).hasSize(768);
// embeddingClient.dropPgmlExtension();
}
@Test
void dimensions() {
PostgresMlEmbeddingClient embeddingClient = new PostgresMlEmbeddingClient(this.jdbcTemplate);
embeddingClient.afterPropertiesSet();
assertThat(embeddingClient.dimensions()).isEqualTo(768);
// cached
assertThat(embeddingClient.dimensions()).isEqualTo(768);
}
@SpringBootApplication
public static class TestApplication {
}
}

View File

@@ -22,6 +22,7 @@
<module>vector-stores/spring-ai-pgvector-store</module>
<module>vector-stores/spring-ai-milvus-store</module>
<module>vector-stores/spring-ai-neo4j-store</module>
<module>embedding-clients/spring-ai-postgresml-embedding-client</module>
</modules>
<organization>