Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
Expand All @@ -24,9 +24,11 @@
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentWriter;
import org.springframework.ai.embedding.BatchingStrategy;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.observation.DefaultVectorStoreObservationConvention;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

/**
* The {@code VectorStore} interface defines the operations for managing and querying
Expand Down Expand Up @@ -62,6 +64,28 @@ default void accept(List<Document> documents) {
@Nullable
Optional<Boolean> delete(List<String> idList);

/**
* Deletes documents from the vector store based on filter criteria.
* @param filterExpression Filter expression to identify documents to delete
* @throws IllegalStateException if the underlying delete causes an exception
*/
void delete(Filter.Expression filterExpression);

/**
* Deletes documents from the vector store using a string filter expression. Converts
* the string filter to an Expression object and delegates to
* {@link #delete(Filter.Expression)}.
* @param filterExpression String representation of the filter criteria
* @throws IllegalArgumentException if the filter expression is null
* @throws IllegalStateException if the underlying delete causes an exception
*/
default void delete(String filterExpression) {
SearchRequest searchRequest = SearchRequest.builder().filterExpression(filterExpression).build();
Filter.Expression textExpression = searchRequest.getFilterExpression();
Assert.notNull(textExpression, "Filter expression must not be null");
this.delete(textExpression);
}

/**
* Retrieves documents by query embedding similarity and metadata filters to retrieve
* exactly the number of nearest-neighbor results that match the request criteria.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
Expand All @@ -18,6 +18,7 @@

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import io.micrometer.observation.ObservationRegistry;

Expand All @@ -27,6 +28,7 @@
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.lang.Nullable;

/**
Expand Down Expand Up @@ -99,6 +101,18 @@ public Optional<Boolean> delete(List<String> deleteDocIds) {
.observe(() -> this.doDelete(deleteDocIds));
}

@Override
public void delete(Filter.Expression filterExpression) {
VectorStoreObservationContext observationContext = this
.createObservationContextBuilder(VectorStoreObservationContext.Operation.DELETE.value())
.build();

VectorStoreObservationDocumentation.AI_VECTOR_STORE
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> this.doDelete(filterExpression));
}

@Override
@Nullable
public List<Document> similaritySearch(SearchRequest request) {
Expand Down Expand Up @@ -131,6 +145,19 @@ public List<Document> similaritySearch(SearchRequest request) {
*/
public abstract Optional<Boolean> doDelete(List<String> idList);

/**
* Template method for concrete implementations to provide filter-based deletion
* logic.
* @param filterExpression Filter expression to identify documents to delete
*/
@Nullable
protected void doDelete(Filter.Expression filterExpression) {
// this is temporary until we implement this method in all concrete vector stores,
// at which point
// this method will become an abstract method.
throw new UnsupportedOperationException();
}

/**
* Perform the actual similarity search operation.
* @param request the search request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ public AddEmbeddingsRequest(String id, float[] embedding, Map<String, Object> me
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record DeleteEmbeddingsRequest(// @formatter:off
@JsonProperty("ids") List<String> ids,
@Nullable @JsonProperty("ids") List<String> ids,
@Nullable @JsonProperty("where") Map<String, Object> where) { // @formatter:on

public DeleteEmbeddingsRequest(List<String> ids) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.ai.chroma.vectorstore.ChromaApi.AddEmbeddingsRequest;
import org.springframework.ai.chroma.vectorstore.ChromaApi.DeleteEmbeddingsRequest;
Expand All @@ -40,6 +42,7 @@
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
Expand Down Expand Up @@ -81,6 +84,8 @@ public class ChromaVectorStore extends AbstractObservationVectorStore implements

private boolean initialized = false;

private static final Logger logger = LoggerFactory.getLogger(ChromaVectorStore.class);

/**
* @param builder {@link VectorStore.Builder} for chroma vector store
*/
Expand Down Expand Up @@ -162,7 +167,29 @@ public Optional<Boolean> doDelete(@NonNull List<String> idList) {
}

@Override
public @NonNull List<Document> doSimilaritySearch(@NonNull SearchRequest request) {
protected void doDelete(Filter.Expression expression) {
Assert.notNull(expression, "Filter expression must not be null");

try {
ChromaFilterExpressionConverter converter = new ChromaFilterExpressionConverter();
String whereClauseStr = converter.convertExpression(expression);

Map<String, Object> whereClause = this.chromaApi.where(whereClauseStr);

logger.debug("Deleting with where clause: {}", whereClause);

DeleteEmbeddingsRequest deleteRequest = new DeleteEmbeddingsRequest(null, whereClause);
this.chromaApi.deleteEmbeddings(this.collectionId, deleteRequest);
}
catch (Exception e) {
logger.error("Failed to delete documents by filter: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to delete documents by filter", e);
}
}

@Override
@NonNull
public List<Document> doSimilaritySearch(@NonNull SearchRequest request) {

String query = request.getQuery();
Assert.notNull(query, "Query string must not be null");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
Expand Down Expand Up @@ -36,6 +36,7 @@
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
Expand Down Expand Up @@ -169,6 +170,69 @@ public void addAndSearchWithFilters() {
});
}

@Test
public void deleteWithFilterExpression() {
this.contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);

// Create test documents with different metadata
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "Bulgaria"));
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("country", "Netherlands"));

// Add documents to the store
vectorStore.add(List.of(bgDocument, nlDocument));

// Verify initial state
var request = SearchRequest.builder().query("The World").topK(5).build();
List<Document> results = vectorStore.similaritySearch(request);
assertThat(results).hasSize(2);

// Delete document with country = Bulgaria
Filter.Expression filterExpression = new Filter.Expression(Filter.ExpressionType.EQ,
new Filter.Key("country"), new Filter.Value("Bulgaria"));

vectorStore.delete(filterExpression);

// Verify Bulgaria document was deleted
results = vectorStore
.similaritySearch(SearchRequest.from(request).filterExpression("country == 'Bulgaria'").build());
assertThat(results).isEmpty();

// Verify Netherlands document still exists
results = vectorStore
.similaritySearch(SearchRequest.from(request).filterExpression("country == 'Netherlands'").build());
assertThat(results).hasSize(1);
assertThat(results.get(0).getMetadata().get("country")).isEqualTo("Netherlands");

// Clean up
vectorStore.delete(List.of(nlDocument.getId()));
});
}

@Test
public void deleteWithStringFilterExpression() {
this.contextRunner.run(context -> {
VectorStore vectorStore = context.getBean(VectorStore.class);

var bgDocument = new Document("The World is Big", Map.of("country", "Bulgaria"));
var nlDocument = new Document("The World is Big", Map.of("country", "Netherlands"));
vectorStore.add(List.of(bgDocument, nlDocument));

var request = SearchRequest.builder().query("World").topK(5).build();
assertThat(vectorStore.similaritySearch(request)).hasSize(2);

vectorStore.delete("country == 'Bulgaria'");

var results = vectorStore.similaritySearch(request);
assertThat(results).hasSize(1);
assertThat(results.get(0).getMetadata().get("country")).isEqualTo("Netherlands");

vectorStore.delete(List.of(nlDocument.getId()));
});
}

@Test
public void documentUpdateTest() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.elasticsearch.core.search.Hit;
Expand Down Expand Up @@ -221,6 +222,23 @@ public Optional<Boolean> doDelete(List<String> idList) {
return Optional.of(bulkRequest(bulkRequestBuilder.build()).errors());
}

@Override
public void doDelete(Filter.Expression filterExpression) {
// For the index to be present, either it must be pre-created or set the
// initializeSchema to true.
if (!indexExists()) {
throw new IllegalArgumentException("Index not found");
}

try {
this.elasticsearchClient.deleteByQuery(d -> d.index(this.options.getIndexName())
.query(q -> q.queryString(qs -> qs.query(getElasticsearchQueryString(filterExpression)))));
}
catch (Exception e) {
throw new IllegalStateException("Failed to delete documents by filter", e);
}
}

private BulkResponse bulkRequest(BulkRequest bulkRequest) {
try {
return this.elasticsearchClient.bulk(bulkRequest);
Expand Down
Loading
Loading