Skip to content

Commit d626bb9

Browse files
committed
Add filter-based deletion to VectorStore implementations
Add the ability to delete documents from vector stores using filter expressions. This allows for more flexible document deletion based on metadata criteria rather than just document IDs. Key changes: - Add delete(Filter.Expression) method to VectorStore interface - Implement filter-based deletion in ChromaVectorStore using where clauses - Implement filter-based deletion in ElasticsearchVectorStore using deleteByQuery - Implement filter-based deletion in PgVectorStore using jsonpath filters - Add integration tests for filter-based deletion across all implementations - Add default implementation in AbstractObservationVectorStore This extends the VectorStore API to support more sophisticated document management capabilities while maintaining consistent behavior across different backend implementations.
1 parent f5761de commit d626bb9

File tree

8 files changed

+336
-1
lines changed

8 files changed

+336
-1
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
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;
@@ -62,6 +63,14 @@ default void accept(List<Document> documents) {
6263
@Nullable
6364
Optional<Boolean> delete(List<String> idList);
6465

66+
/**
67+
* Deletes documents from the vector store based on filter criteria.
68+
* @param filterExpression Filter expression to identify documents to delete
69+
* @return Returns true if the documents were successfully deleted
70+
*/
71+
@Nullable
72+
Optional<Boolean> delete(Filter.Expression filterExpression);
73+
6574
/**
6675
* Retrieves documents by query embedding similarity and metadata filters to retrieve
6776
* 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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,19 @@ public Optional<Boolean> delete(List<String> deleteDocIds) {
99101
.observe(() -> this.doDelete(deleteDocIds));
100102
}
101103

104+
@Override
105+
@Nullable
106+
public Optional<Boolean> delete(Filter.Expression filterExpression) {
107+
VectorStoreObservationContext observationContext = this
108+
.createObservationContextBuilder(VectorStoreObservationContext.Operation.DELETE.value())
109+
.build();
110+
111+
return VectorStoreObservationDocumentation.AI_VECTOR_STORE
112+
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
113+
this.observationRegistry)
114+
.observe(() -> this.doDelete(filterExpression));
115+
}
116+
102117
@Override
103118
@Nullable
104119
public List<Document> similaritySearch(SearchRequest request) {
@@ -131,6 +146,18 @@ public List<Document> similaritySearch(SearchRequest request) {
131146
*/
132147
public abstract Optional<Boolean> doDelete(List<String> idList);
133148

149+
/**
150+
* Template method for concrete implementations to provide filter-based deletion logic.
151+
* @param filterExpression Filter expression to identify documents to delete
152+
* @return Returns true if the documents were successfully deleted
153+
*/
154+
@Nullable
155+
protected Optional<Boolean> doDelete(Filter.Expression filterExpression) {
156+
// this is temporary until we implement this method in all concrete vector stores, 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/ChromaVectorStore.java

Lines changed: 30 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,31 @@ public Optional<Boolean> doDelete(@NonNull List<String> idList) {
162167
}
163168

164169
@Override
165-
public @NonNull List<Document> doSimilaritySearch(@NonNull SearchRequest request) {
170+
protected Optional<Boolean> 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+
int status = this.chromaApi.deleteEmbeddings(this.collectionId, deleteRequest);
183+
184+
return Optional.of(status == 200);
185+
}
186+
catch (Exception e) {
187+
logger.error("Failed to delete documents by filter: {}", e.getMessage(), e);
188+
return Optional.of(false);
189+
}
190+
}
191+
192+
@Override
193+
@NonNull
194+
public List<Document> doSimilaritySearch(@NonNull SearchRequest request) {
166195

167196
String query = request.getQuery();
168197
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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
// Wait for indexing
188+
try {
189+
Thread.sleep(1000);
190+
}
191+
catch (InterruptedException e) {
192+
Thread.currentThread().interrupt();
193+
}
194+
195+
// Verify initial state
196+
var request = SearchRequest.builder().query("The World").topK(5).build();
197+
List<Document> results = vectorStore.similaritySearch(request);
198+
assertThat(results).hasSize(2);
199+
200+
// Delete document with country = Bulgaria
201+
Filter.Expression filterExpression = new Filter.Expression(
202+
Filter.ExpressionType.EQ,
203+
new Filter.Key("country"),
204+
new Filter.Value("Bulgaria")
205+
);
206+
207+
Optional<Boolean> deleteResult = vectorStore.delete(filterExpression);
208+
assertThat(deleteResult).isPresent().contains(true);
209+
210+
// Wait for deletion to be processed
211+
try {
212+
Thread.sleep(1000);
213+
}
214+
catch (InterruptedException e) {
215+
Thread.currentThread().interrupt();
216+
}
217+
218+
// Verify Bulgaria document was deleted
219+
results = vectorStore.similaritySearch(SearchRequest.from(request)
220+
.filterExpression("country == 'Bulgaria'")
221+
.build());
222+
assertThat(results).isEmpty();
223+
224+
// Verify Netherlands document still exists
225+
results = vectorStore.similaritySearch(SearchRequest.from(request)
226+
.filterExpression("country == 'Netherlands'")
227+
.build());
228+
assertThat(results).hasSize(1);
229+
assertThat(results.get(0).getMetadata().get("country")).isEqualTo("Netherlands");
230+
231+
// Clean up
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: 27 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,32 @@ public Optional<Boolean> doDelete(List<String> idList) {
221222
return Optional.of(bulkRequest(bulkRequestBuilder.build()).errors());
222223
}
223224

225+
@Override
226+
public Optional<Boolean> 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+
DeleteByQueryResponse response = this.elasticsearchClient.deleteByQuery(d -> d
235+
.index(this.options.getIndexName())
236+
.query(q -> q
237+
.queryString(qs -> qs
238+
.query(getElasticsearchQueryString(filterExpression))
239+
)
240+
)
241+
);
242+
243+
// Return true if any documents were deleted
244+
return Optional.of(!response.deleted().equals(0L));
245+
}
246+
catch (IOException e) {
247+
throw new RuntimeException(e);
248+
}
249+
}
250+
224251
private BulkResponse bulkRequest(BulkRequest bulkRequest) {
225252
try {
226253
return this.elasticsearchClient.bulk(bulkRequest);

vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStoreIT.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Date;
2525
import java.util.List;
2626
import java.util.Map;
27+
import java.util.Optional;
2728
import java.util.UUID;
2829
import java.util.concurrent.TimeUnit;
2930

@@ -53,6 +54,11 @@
5354
import org.springframework.ai.openai.OpenAiEmbeddingModel;
5455
import org.springframework.ai.openai.api.OpenAiApi;
5556
import org.springframework.ai.vectorstore.SearchRequest;
57+
import org.springframework.ai.vectorstore.filter.Filter;
58+
import org.springframework.ai.vectorstore.filter.Filter.Expression;
59+
import org.springframework.ai.vectorstore.filter.Filter.ExpressionType;
60+
import org.springframework.ai.vectorstore.filter.Filter.Key;
61+
import org.springframework.ai.vectorstore.filter.Filter.Value;
5662
import org.springframework.boot.SpringBootConfiguration;
5763
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
5864
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@@ -143,6 +149,66 @@ public void addAndDeleteDocumentsTest() {
143149
});
144150
}
145151

152+
@Test
153+
public void deleteDocumentsByFilterExpressionTest() {
154+
getContextRunner().run(context -> {
155+
ElasticsearchVectorStore vectorStore = context.getBean("vectorStore_cosine",
156+
ElasticsearchVectorStore.class);
157+
ElasticsearchClient elasticsearchClient = context.getBean(ElasticsearchClient.class);
158+
159+
IndicesStats stats = elasticsearchClient.indices()
160+
.stats(s -> s.index("spring-ai-document-index"))
161+
.indices()
162+
.get("spring-ai-document-index");
163+
164+
assertThat(stats.total().docs().count()).isEqualTo(0L);
165+
166+
// Add documents with metadata
167+
List<Document> documents = List.of(
168+
new Document("1", getText("classpath:/test/data/spring.ai.txt"), Map.of("meta1", "meta1")),
169+
new Document("2", getText("classpath:/test/data/time.shelter.txt"), Map.of()),
170+
new Document("3", getText("classpath:/test/data/great.depression.txt"), Map.of("meta2", "meta2"))
171+
);
172+
173+
vectorStore.add(documents);
174+
elasticsearchClient.indices().refresh();
175+
176+
stats = elasticsearchClient.indices()
177+
.stats(s -> s.index("spring-ai-document-index"))
178+
.indices()
179+
.get("spring-ai-document-index");
180+
assertThat(stats.total().docs().count()).isEqualTo(3L);
181+
182+
// Delete documents with meta1 using filter expression
183+
Expression filterExpression = new Expression(
184+
ExpressionType.EQ,
185+
new Key("meta1"),
186+
new Value("meta1")
187+
);
188+
189+
Optional<Boolean> deleteResult = vectorStore.delete(filterExpression);
190+
elasticsearchClient.indices().refresh();
191+
192+
assertThat(deleteResult).isPresent().contains(true);
193+
194+
stats = elasticsearchClient.indices()
195+
.stats(s -> s.index("spring-ai-document-index"))
196+
.indices()
197+
.get("spring-ai-document-index");
198+
assertThat(stats.total().docs().count()).isEqualTo(2L);
199+
200+
// Clean up remaining documents
201+
vectorStore.delete(List.of("2", "3"));
202+
elasticsearchClient.indices().refresh();
203+
204+
stats = elasticsearchClient.indices()
205+
.stats(s -> s.index("spring-ai-document-index"))
206+
.indices()
207+
.get("spring-ai-document-index");
208+
assertThat(stats.total().docs().count()).isEqualTo(0L);
209+
});
210+
}
211+
146212
@ParameterizedTest(name = "{0} : {displayName} ")
147213
@ValueSource(strings = { "cosine", "l2_norm", "dot_product" })
148214
public void addAndSearchTest(String similarityFunction) {

vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
4646
import org.springframework.ai.vectorstore.SearchRequest;
4747
import org.springframework.ai.vectorstore.VectorStore;
48+
import org.springframework.ai.vectorstore.filter.Filter;
4849
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
4950
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
5051
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
@@ -315,6 +316,23 @@ public Optional<Boolean> doDelete(List<String> idList) {
315316
return Optional.of(updateCount == idList.size());
316317
}
317318

319+
@Override
320+
protected Optional<Boolean> doDelete(Filter.Expression filterExpression) {
321+
String nativeFilterExpression = this.filterExpressionConverter.convertExpression(filterExpression);
322+
323+
String sql = "DELETE FROM " +
324+
getFullyQualifiedTableName() +
325+
" WHERE metadata::jsonb @@ '" +
326+
nativeFilterExpression +
327+
"'::jsonpath";
328+
329+
// Execute the delete
330+
int updateCount = this.jdbcTemplate.update(sql);
331+
332+
// Return true if any records were deleted
333+
return Optional.of(updateCount > 0);
334+
}
335+
318336
@Override
319337
public List<Document> doSimilaritySearch(SearchRequest request) {
320338

0 commit comments

Comments
 (0)