Skip to content

Commit 946657f

Browse files
authored
Add filter-based deletion to Milvus vector store (#2127)
Add string-based filter deletion alongside the Filter.Expression-based deletion for Milvus vector store, providing consistent deletion capabilities with other vector store implementations. Key changes: - Add delete(Filter.Expression) implementation for Milvus store - Leverage existing MilvusFilterExpressionConverter for filter translation - Use Milvus client's native delete API with filter expressions - Add comprehensive integration tests for filter deletion - Support both simple and complex filter expressions This maintains consistency with other vector store implementations while utilizing Milvus-specific APIs for efficient metadata-based deletion.
1 parent fc1e90c commit 946657f

File tree

2 files changed

+134
-2
lines changed

2 files changed

+134
-2
lines changed

vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/milvus/MilvusVectorStore.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.
@@ -61,6 +61,7 @@
6161
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
6262
import org.springframework.ai.vectorstore.SearchRequest;
6363
import org.springframework.ai.vectorstore.VectorStore;
64+
import org.springframework.ai.vectorstore.filter.Filter;
6465
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
6566
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
6667
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
@@ -291,6 +292,32 @@ public Optional<Boolean> doDelete(List<String> idList) {
291292
return Optional.of(status.getStatus() == Status.Success.getCode());
292293
}
293294

295+
@Override
296+
protected void doDelete(Filter.Expression filterExpression) {
297+
Assert.notNull(filterExpression, "Filter expression must not be null");
298+
299+
try {
300+
String nativeFilterExpression = this.filterExpressionConverter.convertExpression(filterExpression);
301+
302+
R<MutationResult> status = this.milvusClient.delete(DeleteParam.newBuilder()
303+
.withDatabaseName(this.databaseName)
304+
.withCollectionName(this.collectionName)
305+
.withExpr(nativeFilterExpression)
306+
.build());
307+
308+
if (status.getStatus() != Status.Success.getCode()) {
309+
throw new IllegalStateException("Failed to delete documents by filter: " + status.getMessage());
310+
}
311+
312+
long deleteCount = status.getData().getDeleteCnt();
313+
logger.debug("Deleted {} documents matching filter expression", deleteCount);
314+
}
315+
catch (Exception e) {
316+
logger.error("Failed to delete documents by filter: {}", e.getMessage(), e);
317+
throw new IllegalStateException("Failed to delete documents by filter", e);
318+
}
319+
}
320+
294321
@Override
295322
public List<Document> doSimilaritySearch(SearchRequest request) {
296323

vector-stores/spring-ai-milvus-store/src/test/java/org/springframework/ai/vectorstore/milvus/MilvusVectorStoreIT.java

Lines changed: 106 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.
@@ -22,11 +22,13 @@
2222
import java.util.List;
2323
import java.util.Map;
2424
import java.util.UUID;
25+
import java.util.stream.Collectors;
2526

2627
import io.milvus.client.MilvusServiceClient;
2728
import io.milvus.param.ConnectParam;
2829
import io.milvus.param.IndexType;
2930
import io.milvus.param.MetricType;
31+
import org.junit.jupiter.api.Test;
3032
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
3133
import org.junit.jupiter.params.ParameterizedTest;
3234
import org.junit.jupiter.params.provider.ValueSource;
@@ -42,6 +44,7 @@
4244
import org.springframework.ai.openai.api.OpenAiApi;
4345
import org.springframework.ai.vectorstore.SearchRequest;
4446
import org.springframework.ai.vectorstore.VectorStore;
47+
import org.springframework.ai.vectorstore.filter.Filter;
4548
import org.springframework.beans.factory.annotation.Value;
4649
import org.springframework.boot.SpringBootConfiguration;
4750
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -56,6 +59,7 @@
5659
* @author Christian Tzolov
5760
* @author Eddú Meléndez
5861
* @author Thomas Vitale
62+
* @author Soby Chacko
5963
*/
6064
@Testcontainers
6165
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
@@ -273,6 +277,107 @@ public void searchWithThreshold(String metricType) {
273277
});
274278
}
275279

280+
@Test
281+
public void deleteByFilter() {
282+
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=COSINE").run(context -> {
283+
VectorStore vectorStore = context.getBean(VectorStore.class);
284+
285+
resetCollection(vectorStore);
286+
287+
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
288+
Map.of("country", "BG", "year", 2020));
289+
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
290+
Map.of("country", "NL", "year", 2021));
291+
var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner",
292+
Map.of("country", "BG", "year", 2023));
293+
294+
vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2));
295+
296+
SearchRequest searchRequest = SearchRequest.builder()
297+
.query("The World")
298+
.topK(5)
299+
.similarityThresholdAll()
300+
.build();
301+
302+
List<Document> results = vectorStore.similaritySearch(searchRequest);
303+
assertThat(results).hasSize(3);
304+
305+
Filter.Expression filterExpression = new Filter.Expression(Filter.ExpressionType.EQ,
306+
new Filter.Key("country"), new Filter.Value("BG"));
307+
308+
vectorStore.delete(filterExpression);
309+
310+
// Verify deletion - should only have NL document remaining
311+
results = vectorStore.similaritySearch(searchRequest);
312+
assertThat(results).hasSize(1);
313+
assertThat(results.get(0).getMetadata()).containsEntry("country", "NL");
314+
});
315+
}
316+
317+
@Test
318+
public void deleteWithStringFilterExpression() {
319+
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=COSINE").run(context -> {
320+
VectorStore vectorStore = context.getBean(VectorStore.class);
321+
322+
resetCollection(vectorStore);
323+
324+
var bgDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
325+
Map.of("country", "BG", "year", 2020));
326+
var nlDocument = new Document("The World is Big and Salvation Lurks Around the Corner",
327+
Map.of("country", "NL", "year", 2021));
328+
var bgDocument2 = new Document("The World is Big and Salvation Lurks Around the Corner",
329+
Map.of("country", "BG", "year", 2023));
330+
331+
vectorStore.add(List.of(bgDocument, nlDocument, bgDocument2));
332+
333+
var searchRequest = SearchRequest.builder().query("The World").topK(5).similarityThresholdAll().build();
334+
335+
List<Document> results = vectorStore.similaritySearch(searchRequest);
336+
assertThat(results).hasSize(3);
337+
338+
// Delete using string filter expression
339+
vectorStore.delete("country == 'BG'");
340+
341+
results = vectorStore.similaritySearch(searchRequest);
342+
assertThat(results).hasSize(1);
343+
assertThat(results.get(0).getMetadata()).containsEntry("country", "NL");
344+
});
345+
}
346+
347+
@Test
348+
public void deleteWithComplexFilterExpression() {
349+
this.contextRunner.withPropertyValues("test.spring.ai.vectorstore.milvus.metricType=COSINE").run(context -> {
350+
VectorStore vectorStore = context.getBean(VectorStore.class);
351+
352+
resetCollection(vectorStore);
353+
354+
var doc1 = new Document("Content 1", Map.of("type", "A", "priority", 1));
355+
var doc2 = new Document("Content 2", Map.of("type", "A", "priority", 2));
356+
var doc3 = new Document("Content 3", Map.of("type", "B", "priority", 1));
357+
358+
vectorStore.add(List.of(doc1, doc2, doc3));
359+
360+
// Complex filter expression: (type == 'A' AND priority > 1)
361+
Filter.Expression priorityFilter = new Filter.Expression(Filter.ExpressionType.GT,
362+
new Filter.Key("priority"), new Filter.Value(1));
363+
Filter.Expression typeFilter = new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("type"),
364+
new Filter.Value("A"));
365+
Filter.Expression complexFilter = new Filter.Expression(Filter.ExpressionType.AND, typeFilter,
366+
priorityFilter);
367+
368+
vectorStore.delete(complexFilter);
369+
370+
var results = vectorStore
371+
.similaritySearch(SearchRequest.builder().query("Content").topK(5).similarityThresholdAll().build());
372+
373+
assertThat(results).hasSize(2);
374+
assertThat(results.stream().map(doc -> doc.getMetadata().get("type")).collect(Collectors.toList()))
375+
.containsExactlyInAnyOrder("A", "B");
376+
assertThat(results.stream().map(doc -> doc.getMetadata().get("priority")).collect(Collectors.toList()))
377+
.containsExactlyInAnyOrder(1, 1);
378+
});
379+
}
380+
276381
@SpringBootConfiguration
277382
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
278383
public static class TestApplication {

0 commit comments

Comments
 (0)