Skip to content

Commit 7304a75

Browse files
committed
Add filter-based deletion and string filter API to VectorStore
Add string-based filter deletion alongside the Filter.Expression-based deletion for vector stores. This provides a more convenient API for simple filter cases while maintaining the full flexibility of expression-based filtering. Key changes: - Add delete(Filter.Expression) and delete(String) methods to VectorStore - Add default string filter implementation in VectorStore interface - Implement filter deletion in Chroma/Elasticsearch/PgVector stores - Add integration tests for both filter APIs across implementations This extends vector store deletion capabilities while maintaining consistent behavior across implementations.
1 parent f5761de commit 7304a75

File tree

9 files changed

+391
-7
lines changed

9 files changed

+391
-7
lines changed

spring-ai-core/src/main/java/org/springframework/ai/vectorstore/VectorStore.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,9 +24,11 @@
2424
import org.springframework.ai.document.Document;
2525
import org.springframework.ai.document.DocumentWriter;
2626
import org.springframework.ai.embedding.BatchingStrategy;
27+
import org.springframework.ai.vectorstore.filter.Filter;
2728
import org.springframework.ai.vectorstore.observation.DefaultVectorStoreObservationConvention;
2829
import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention;
2930
import org.springframework.lang.Nullable;
31+
import org.springframework.util.Assert;
3032

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

67+
/**
68+
* Deletes documents from the vector store based on filter criteria.
69+
* @param filterExpression Filter expression to identify documents to delete
70+
* @throws IllegalStateException if the underlying delete causes an exception
71+
*/
72+
void delete(Filter.Expression filterExpression);
73+
74+
/**
75+
* Deletes documents from the vector store using a string filter expression. Converts
76+
* the string filter to an Expression object and delegates to
77+
* {@link #delete(Filter.Expression)}.
78+
* @param filterExpression String representation of the filter criteria
79+
* @throws IllegalArgumentException if the filter expression is null
80+
* @throws IllegalStateException if the underlying delete causes an exception
81+
*/
82+
default void delete(String filterExpression) {
83+
SearchRequest searchRequest = SearchRequest.builder().filterExpression(filterExpression).build();
84+
Filter.Expression textExpression = searchRequest.getFilterExpression();
85+
Assert.notNull(textExpression, "Filter expression must not be null");
86+
this.delete(textExpression);
87+
}
88+
6589
/**
6690
* Retrieves documents by query embedding similarity and metadata filters to retrieve
6791
* exactly the number of nearest-neighbor results that match the request criteria.

spring-ai-core/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
1818

1919
import java.util.List;
2020
import java.util.Optional;
21+
import java.util.stream.Collectors;
2122

2223
import io.micrometer.observation.ObservationRegistry;
2324

@@ -27,6 +28,7 @@
2728
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
2829
import org.springframework.ai.vectorstore.SearchRequest;
2930
import org.springframework.ai.vectorstore.VectorStore;
31+
import org.springframework.ai.vectorstore.filter.Filter;
3032
import org.springframework.lang.Nullable;
3133

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

104+
@Override
105+
public void delete(Filter.Expression filterExpression) {
106+
VectorStoreObservationContext observationContext = this
107+
.createObservationContextBuilder(VectorStoreObservationContext.Operation.DELETE.value())
108+
.build();
109+
110+
VectorStoreObservationDocumentation.AI_VECTOR_STORE
111+
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
112+
this.observationRegistry)
113+
.observe(() -> this.doDelete(filterExpression));
114+
}
115+
102116
@Override
103117
@Nullable
104118
public List<Document> similaritySearch(SearchRequest request) {
@@ -131,6 +145,19 @@ public List<Document> similaritySearch(SearchRequest request) {
131145
*/
132146
public abstract Optional<Boolean> doDelete(List<String> idList);
133147

148+
/**
149+
* Template method for concrete implementations to provide filter-based deletion
150+
* logic.
151+
* @param filterExpression Filter expression to identify documents to delete
152+
*/
153+
@Nullable
154+
protected void doDelete(Filter.Expression filterExpression) {
155+
// this is temporary until we implement this method in all concrete vector stores,
156+
// at which point
157+
// this method will become an abstract method.
158+
throw new UnsupportedOperationException();
159+
}
160+
134161
/**
135162
* Perform the actual similarity search operation.
136163
* @param request the search request

vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/vectorstore/ChromaApi.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ public AddEmbeddingsRequest(String id, float[] embedding, Map<String, Object> me
338338
*/
339339
@JsonInclude(JsonInclude.Include.NON_NULL)
340340
public record DeleteEmbeddingsRequest(// @formatter:off
341-
@JsonProperty("ids") List<String> ids,
341+
@Nullable @JsonProperty("ids") List<String> ids,
342342
@Nullable @JsonProperty("where") Map<String, Object> where) { // @formatter:on
343343

344344
public DeleteEmbeddingsRequest(List<String> ids) {

vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/vectorstore/ChromaVectorStore.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import com.fasterxml.jackson.core.JsonProcessingException;
2626
import com.fasterxml.jackson.databind.ObjectMapper;
2727
import com.fasterxml.jackson.databind.json.JsonMapper;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
2830

2931
import org.springframework.ai.chroma.vectorstore.ChromaApi.AddEmbeddingsRequest;
3032
import org.springframework.ai.chroma.vectorstore.ChromaApi.DeleteEmbeddingsRequest;
@@ -40,6 +42,7 @@
4042
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
4143
import org.springframework.ai.vectorstore.SearchRequest;
4244
import org.springframework.ai.vectorstore.VectorStore;
45+
import org.springframework.ai.vectorstore.filter.Filter;
4346
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
4447
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
4548
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
@@ -81,6 +84,8 @@ public class ChromaVectorStore extends AbstractObservationVectorStore implements
8184

8285
private boolean initialized = false;
8386

87+
private static final Logger logger = LoggerFactory.getLogger(ChromaVectorStore.class);
88+
8489
/**
8590
* @param builder {@link VectorStore.Builder} for chroma vector store
8691
*/
@@ -162,7 +167,29 @@ public Optional<Boolean> doDelete(@NonNull List<String> idList) {
162167
}
163168

164169
@Override
165-
public @NonNull List<Document> doSimilaritySearch(@NonNull SearchRequest request) {
170+
protected void doDelete(Filter.Expression expression) {
171+
Assert.notNull(expression, "Filter expression must not be null");
172+
173+
try {
174+
ChromaFilterExpressionConverter converter = new ChromaFilterExpressionConverter();
175+
String whereClauseStr = converter.convertExpression(expression);
176+
177+
Map<String, Object> whereClause = this.chromaApi.where(whereClauseStr);
178+
179+
logger.debug("Deleting with where clause: {}", whereClause);
180+
181+
DeleteEmbeddingsRequest deleteRequest = new DeleteEmbeddingsRequest(null, whereClause);
182+
this.chromaApi.deleteEmbeddings(this.collectionId, deleteRequest);
183+
}
184+
catch (Exception e) {
185+
logger.error("Failed to delete documents by filter: {}", e.getMessage(), e);
186+
throw new IllegalStateException("Failed to delete documents by filter", e);
187+
}
188+
}
189+
190+
@Override
191+
@NonNull
192+
public List<Document> doSimilaritySearch(@NonNull SearchRequest request) {
166193

167194
String query = request.getQuery();
168195
Assert.notNull(query, "Query string must not be null");

vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/vectorstore/ChromaVectorStoreIT.java

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -36,6 +36,7 @@
3636
import org.springframework.ai.openai.api.OpenAiApi;
3737
import org.springframework.ai.vectorstore.SearchRequest;
3838
import org.springframework.ai.vectorstore.VectorStore;
39+
import org.springframework.ai.vectorstore.filter.Filter;
3940
import org.springframework.boot.SpringBootConfiguration;
4041
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
4142
import org.springframework.context.annotation.Bean;
@@ -169,6 +170,69 @@ public void addAndSearchWithFilters() {
169170
});
170171
}
171172

173+
@Test
174+
public void deleteWithFilterExpression() {
175+
this.contextRunner.run(context -> {
176+
VectorStore vectorStore = context.getBean(VectorStore.class);
177+
178+
// Create test documents with different metadata
179+
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
180+
Map.of("country", "Bulgaria"));
181+
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
182+
Map.of("country", "Netherlands"));
183+
184+
// Add documents to the store
185+
vectorStore.add(List.of(bgDocument, nlDocument));
186+
187+
// Verify initial state
188+
var request = SearchRequest.builder().query("The World").topK(5).build();
189+
List<Document> results = vectorStore.similaritySearch(request);
190+
assertThat(results).hasSize(2);
191+
192+
// Delete document with country = Bulgaria
193+
Filter.Expression filterExpression = new Filter.Expression(Filter.ExpressionType.EQ,
194+
new Filter.Key("country"), new Filter.Value("Bulgaria"));
195+
196+
vectorStore.delete(filterExpression);
197+
198+
// Verify Bulgaria document was deleted
199+
results = vectorStore
200+
.similaritySearch(SearchRequest.from(request).filterExpression("country == 'Bulgaria'").build());
201+
assertThat(results).isEmpty();
202+
203+
// Verify Netherlands document still exists
204+
results = vectorStore
205+
.similaritySearch(SearchRequest.from(request).filterExpression("country == 'Netherlands'").build());
206+
assertThat(results).hasSize(1);
207+
assertThat(results.get(0).getMetadata().get("country")).isEqualTo("Netherlands");
208+
209+
// Clean up
210+
vectorStore.delete(List.of(nlDocument.getId()));
211+
});
212+
}
213+
214+
@Test
215+
public void deleteWithStringFilterExpression() {
216+
this.contextRunner.run(context -> {
217+
VectorStore vectorStore = context.getBean(VectorStore.class);
218+
219+
var bgDocument = new Document("The World is Big", Map.of("country", "Bulgaria"));
220+
var nlDocument = new Document("The World is Big", Map.of("country", "Netherlands"));
221+
vectorStore.add(List.of(bgDocument, nlDocument));
222+
223+
var request = SearchRequest.builder().query("World").topK(5).build();
224+
assertThat(vectorStore.similaritySearch(request)).hasSize(2);
225+
226+
vectorStore.delete("country == 'Bulgaria'");
227+
228+
var results = vectorStore.similaritySearch(request);
229+
assertThat(results).hasSize(1);
230+
assertThat(results.get(0).getMetadata().get("country")).isEqualTo("Netherlands");
231+
232+
vectorStore.delete(List.of(nlDocument.getId()));
233+
});
234+
}
235+
172236
@Test
173237
public void documentUpdateTest() {
174238

vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStore.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import co.elastic.clients.elasticsearch.ElasticsearchClient;
2727
import co.elastic.clients.elasticsearch.core.BulkRequest;
2828
import co.elastic.clients.elasticsearch.core.BulkResponse;
29+
import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse;
2930
import co.elastic.clients.elasticsearch.core.SearchResponse;
3031
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
3132
import co.elastic.clients.elasticsearch.core.search.Hit;
@@ -221,6 +222,23 @@ public Optional<Boolean> doDelete(List<String> idList) {
221222
return Optional.of(bulkRequest(bulkRequestBuilder.build()).errors());
222223
}
223224

225+
@Override
226+
public void doDelete(Filter.Expression filterExpression) {
227+
// For the index to be present, either it must be pre-created or set the
228+
// initializeSchema to true.
229+
if (!indexExists()) {
230+
throw new IllegalArgumentException("Index not found");
231+
}
232+
233+
try {
234+
this.elasticsearchClient.deleteByQuery(d -> d.index(this.options.getIndexName())
235+
.query(q -> q.queryString(qs -> qs.query(getElasticsearchQueryString(filterExpression)))));
236+
}
237+
catch (Exception e) {
238+
throw new IllegalStateException("Failed to delete documents by filter", e);
239+
}
240+
}
241+
224242
private BulkResponse bulkRequest(BulkRequest bulkRequest) {
225243
try {
226244
return this.elasticsearchClient.bulk(bulkRequest);

0 commit comments

Comments
 (0)