Skip to content

Commit bd52786

Browse files
authored
Add filter-based deletion and refactor tests for Pinecone vector store (#2142)
Add string-based filter deletion alongside the Filter.Expression-based deletion for Pinecone vector store and improve test organization through refactoring. Key changes: - Add delete(Filter.Expression) implementation using two-step process: * Search for documents matching filter * Delete matching documents by ID - Leverage existing search functionality to maintain filter consistency - Extract common test patterns into helper methods - Create reusable document factory methods for test data - Add comprehensive integration tests for filter deletion - Standardize test cleanup and verification patterns This maintains consistency with other vector store implementations while working within Pinecone's API limitations. The test refactoring improves maintainability and makes the test patterns clearer and more consistent. Signed-off-by: Soby Chacko <[email protected]>
1 parent 49f2320 commit bd52786

File tree

2 files changed

+180
-1
lines changed

2 files changed

+180
-1
lines changed

vector-stores/spring-ai-pinecone-store/src/main/java/org/springframework/ai/vectorstore/pinecone/PineconeVectorStore.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
package org.springframework.ai.vectorstore.pinecone;
1818

1919
import java.time.Duration;
20+
import java.util.HashMap;
2021
import java.util.List;
2122
import java.util.Map;
2223
import java.util.Optional;
24+
import java.util.stream.Collectors;
2325

2426
import com.fasterxml.jackson.core.type.TypeReference;
2527
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,10 +33,12 @@
3133
import io.pinecone.PineconeConnection;
3234
import io.pinecone.PineconeConnectionConfig;
3335
import io.pinecone.proto.DeleteRequest;
36+
import io.pinecone.proto.DeleteResponse;
3437
import io.pinecone.proto.QueryRequest;
3538
import io.pinecone.proto.QueryResponse;
3639
import io.pinecone.proto.UpsertRequest;
3740
import io.pinecone.proto.Vector;
41+
import org.apache.commons.logging.LogFactory;
3842

3943
import org.springframework.ai.document.Document;
4044
import org.springframework.ai.document.DocumentMetadata;
@@ -46,10 +50,12 @@
4650
import org.springframework.ai.observation.conventions.VectorStoreProvider;
4751
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
4852
import org.springframework.ai.vectorstore.SearchRequest;
53+
import org.springframework.ai.vectorstore.filter.Filter;
4954
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
5055
import org.springframework.ai.vectorstore.filter.converter.PineconeFilterExpressionConverter;
5156
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
5257
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
58+
import org.springframework.core.log.LogAccessor;
5359
import org.springframework.lang.Nullable;
5460
import org.springframework.util.Assert;
5561
import org.springframework.util.StringUtils;
@@ -82,6 +88,8 @@ public class PineconeVectorStore extends AbstractObservationVectorStore {
8288

8389
private final ObjectMapper objectMapper;
8490

91+
private static final LogAccessor logger = new LogAccessor(LogFactory.getLog(PineconeVectorStore.class));
92+
8593
/**
8694
* Creates a new PineconeVectorStore using the builder pattern.
8795
* @param builder The configured builder instance
@@ -248,6 +256,43 @@ public List<Document> similaritySearch(SearchRequest request, String namespace)
248256
.toList();
249257
}
250258

259+
@Override
260+
protected void doDelete(Filter.Expression filterExpression) {
261+
Assert.notNull(filterExpression, "Filter expression must not be null");
262+
263+
try {
264+
// Direct filter based deletion is not working in pinecone, so we are
265+
// retrieving the documents
266+
// by doing a similarity search with an empty query and then passing the ID's
267+
// of the documents to the delete(Id) API method.
268+
SearchRequest searchRequest = SearchRequest.builder()
269+
.query("") // empty query since we only want filter matches
270+
.filterExpression(filterExpression)
271+
.topK(10000) // large enough to get all matches
272+
.similarityThresholdAll()
273+
.build();
274+
275+
List<Document> matchingDocs = similaritySearch(searchRequest, this.pineconeNamespace);
276+
277+
if (!matchingDocs.isEmpty()) {
278+
// Then delete those documents by ID
279+
List<String> idsToDelete = matchingDocs.stream().map(Document::getId).collect(Collectors.toList());
280+
281+
Optional<Boolean> result = delete(idsToDelete, this.pineconeNamespace);
282+
283+
if (result.isPresent() && !result.get()) {
284+
throw new IllegalStateException("Failed to delete some documents");
285+
}
286+
287+
logger.debug(() -> "Deleted " + idsToDelete.size() + " documents matching filter expression");
288+
}
289+
}
290+
catch (Exception e) {
291+
logger.error(e, () -> "Failed to delete documents by filter");
292+
throw new IllegalStateException("Failed to delete documents by filter", e);
293+
}
294+
}
295+
251296
@Override
252297
public List<Document> doSimilaritySearch(SearchRequest request) {
253298
return similaritySearch(request, this.pineconeNamespace);

vector-stores/spring-ai-pinecone-store/src/test/java/org/springframework/ai/vectorstore/pinecone/PineconeVectorStoreIT.java

Lines changed: 135 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.
@@ -23,6 +23,7 @@
2323
import java.util.Map;
2424
import java.util.UUID;
2525
import java.util.concurrent.TimeUnit;
26+
import java.util.stream.Collectors;
2627

2728
import org.awaitility.Awaitility;
2829
import org.awaitility.Duration;
@@ -36,6 +37,7 @@
3637
import org.springframework.ai.transformers.TransformersEmbeddingModel;
3738
import org.springframework.ai.vectorstore.SearchRequest;
3839
import org.springframework.ai.vectorstore.VectorStore;
40+
import org.springframework.ai.vectorstore.filter.Filter;
3941
import org.springframework.boot.SpringBootConfiguration;
4042
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
4143
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -49,6 +51,7 @@
4951
/**
5052
* @author Christian Tzolov
5153
* @author Thomas Vitale
54+
* @author Soby Chacko
5255
*/
5356
@EnabledIfEnvironmentVariable(named = "PINECONE_API_KEY", matches = ".+")
5457
public class PineconeVectorStoreIT {
@@ -66,6 +69,8 @@ public class PineconeVectorStoreIT {
6669

6770
private static final String CUSTOM_CONTENT_FIELD_NAME = "article";
6871

72+
private static final int DEFAULT_TOP_K = 50;
73+
6974
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
7075
.withUserConfiguration(TestApplication.class);
7176

@@ -283,6 +288,135 @@ public void searchThresholdTest() {
283288
});
284289
}
285290

291+
@Test
292+
void deleteByFilter() {
293+
this.contextRunner.run(context -> {
294+
VectorStore vectorStore = context.getBean(VectorStore.class);
295+
296+
cleanupExistingDocuments(vectorStore, "The World");
297+
298+
var documents = createWorldDocuments();
299+
vectorStore.add(documents);
300+
301+
awaitDocumentsCount(vectorStore, "The World", 3);
302+
303+
Filter.Expression filterExpression = new Filter.Expression(Filter.ExpressionType.EQ,
304+
new Filter.Key("country"), new Filter.Value("BG"));
305+
306+
vectorStore.delete(filterExpression);
307+
308+
awaitDocumentsCount(vectorStore, "The World", 1);
309+
310+
List<Document> results = searchDocuments(vectorStore, "The World", 5);
311+
assertThat(results).hasSize(1);
312+
assertThat(results.get(0).getMetadata()).containsEntry("country", "NL");
313+
314+
vectorStore.delete(List.of(documents.get(1).getId())); // nlDocument
315+
awaitDocumentsCount(vectorStore, "The World", 0);
316+
});
317+
}
318+
319+
@Test
320+
void deleteWithStringFilterExpression() {
321+
this.contextRunner.run(context -> {
322+
VectorStore vectorStore = context.getBean(VectorStore.class);
323+
324+
cleanupExistingDocuments(vectorStore, "The World");
325+
326+
var documents = createWorldDocuments();
327+
vectorStore.add(documents);
328+
329+
awaitDocumentsCount(vectorStore, "The World", 3);
330+
331+
vectorStore.delete("country == 'BG'");
332+
333+
awaitDocumentsCount(vectorStore, "The World", 1);
334+
335+
List<Document> results = searchDocuments(vectorStore, "The World", 5);
336+
assertThat(results).hasSize(1);
337+
assertThat(results.get(0).getMetadata()).containsEntry("country", "NL");
338+
339+
vectorStore.delete(List.of(documents.get(1).getId())); // nlDocument
340+
awaitDocumentsCount(vectorStore, "The World", 0);
341+
});
342+
}
343+
344+
@Test
345+
void deleteWithComplexFilterExpression() {
346+
this.contextRunner.run(context -> {
347+
VectorStore vectorStore = context.getBean(VectorStore.class);
348+
349+
cleanupExistingDocuments(vectorStore, "Content");
350+
351+
var documents = createContentDocuments();
352+
vectorStore.add(documents);
353+
354+
awaitDocumentsCount(vectorStore, "Content", 3);
355+
356+
Filter.Expression complexFilter = createComplexFilter();
357+
vectorStore.delete(complexFilter);
358+
359+
awaitDocumentsCount(vectorStore, "Content", 2);
360+
361+
List<Document> results = searchDocuments(vectorStore, "Content", 5);
362+
assertThat(results).hasSize(2);
363+
assertComplexFilterResults(results);
364+
365+
vectorStore.delete(List.of(documents.get(0).getId(), documents.get(2).getId())); // doc1
366+
// and
367+
// doc3
368+
awaitDocumentsCount(vectorStore, "Content", 0);
369+
});
370+
}
371+
372+
private void cleanupExistingDocuments(VectorStore vectorStore, String query) {
373+
List<Document> existingDocs = searchDocuments(vectorStore, query, DEFAULT_TOP_K);
374+
if (!existingDocs.isEmpty()) {
375+
vectorStore.delete(existingDocs.stream().map(Document::getId).toList());
376+
}
377+
awaitDocumentsCount(vectorStore, query, 0);
378+
}
379+
380+
private List<Document> createWorldDocuments() {
381+
return List.of(
382+
new Document("The World is Big and Salvation Lurks Around the Corner",
383+
Map.of("country", "BG", "year", 2020)),
384+
new Document("The World is Big and Salvation Lurks Around the Corner", Map.of("country", "NL")),
385+
new Document("The World is Big and Salvation Lurks Around the Corner",
386+
Map.of("country", "BG", "year", 2023)));
387+
}
388+
389+
private List<Document> createContentDocuments() {
390+
return List.of(new Document("Content 1", Map.of("type", "A", "priority", 1)),
391+
new Document("Content 2", Map.of("type", "A", "priority", 2)),
392+
new Document("Content 3", Map.of("type", "B", "priority", 1)));
393+
}
394+
395+
private Filter.Expression createComplexFilter() {
396+
Filter.Expression priorityFilter = new Filter.Expression(Filter.ExpressionType.GT, new Filter.Key("priority"),
397+
new Filter.Value(1));
398+
Filter.Expression typeFilter = new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("type"),
399+
new Filter.Value("A"));
400+
return new Filter.Expression(Filter.ExpressionType.AND, typeFilter, priorityFilter);
401+
}
402+
403+
private void assertComplexFilterResults(List<Document> results) {
404+
assertThat(results.stream().map(doc -> doc.getMetadata().get("type")).collect(Collectors.toList()))
405+
.containsExactlyInAnyOrder("A", "B");
406+
assertThat(results.stream()
407+
.map(doc -> ((Number) doc.getMetadata().get("priority")).intValue())
408+
.collect(Collectors.toList())).containsExactlyInAnyOrder(1, 1);
409+
}
410+
411+
private List<Document> searchDocuments(VectorStore vectorStore, String query, int topK) {
412+
return vectorStore
413+
.similaritySearch(SearchRequest.builder().query(query).topK(topK).similarityThresholdAll().build());
414+
}
415+
416+
private void awaitDocumentsCount(VectorStore vectorStore, String query, int expectedCount) {
417+
Awaitility.await().until(() -> searchDocuments(vectorStore, query, DEFAULT_TOP_K), hasSize(expectedCount));
418+
}
419+
286420
@SpringBootConfiguration
287421
@EnableAutoConfiguration
288422
public static class TestApplication {

0 commit comments

Comments
 (0)