Skip to content

Commit d1e1e6c

Browse files
committed
Polish S3 vector store filter deletion
Add tests for NOT, ISNULL, and ISNOTNULL filter expressions and the null-metadata vector case in S3VectorStoreTests. Fix incorrect filter format example in the S3 Vector Store reference documentation and add a section documenting filter-based deletion. Signed-off-by: Soby Chacko <soby.chacko@broadcom.com>
1 parent ec86fa6 commit d1e1e6c

3 files changed

Lines changed: 90 additions & 5 deletions

File tree

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/s3-vector-store.adoc

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,37 @@ For example, this portable filter expression:
122122
country in ['UK', 'NL'] && year >= 2020
123123
----
124124

125-
is converted into the proprietary S3 Vector Store filter format:
125+
is converted into the S3 Vector Store filter format:
126126

127-
[source,text]
127+
[source,json]
128128
----
129-
@country:{UK | NL} @year:[2020 inf]
129+
{"$and": [{"country": {"$in": ["UK", "NL"]}}, {"year": {"$gte": 2020}}]}
130130
----
131131

132+
=== Filter-based Deletion
133+
134+
You can also delete documents by filter expression:
135+
136+
[source,java]
137+
----
138+
vectorStore.delete("country in ['UK', 'NL'] && year >= 2020");
139+
----
140+
141+
or programmatically:
142+
143+
[source,java]
144+
----
145+
FilterExpressionBuilder b = new FilterExpressionBuilder();
146+
147+
vectorStore.delete(b.and(
148+
b.in("country", "UK", "NL"),
149+
b.gte("year", 2020)).build());
150+
----
151+
152+
NOTE: S3 Vectors does not provide a server-side filter-delete API.
153+
Filter-based deletion lists all vectors in the index (paginated, 500 per page) and evaluates the filter expression locally.
154+
For large indexes this operation is proportional to the total number of stored vectors.
155+
132156
== Manual Configuration
133157

134158
Instead of using the Spring Boot auto-configuration, you can manually configure the S3 Vector Store. For this you need to add the `spring-ai-s3-vector-store` to your project:

vector-stores/spring-ai-s3-vector-store/src/main/java/org/springframework/ai/vectorstore/s3/S3VectorStoreFilterExpressionEvaluator.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ private Object filterValue(Filter.Operand operand) {
116116
throw new IllegalArgumentException("Expected a Value operand but got: " + operand.getClass().getName());
117117
}
118118

119-
@SuppressWarnings("unchecked")
120119
private int compare(@Nullable Object metaVal, @Nullable Object filterVal) {
121120
if (metaVal == null && filterVal == null) {
122121
return 0;
@@ -135,7 +134,9 @@ private int compare(@Nullable Object metaVal, @Nullable Object filterVal) {
135134
}
136135
if (metaVal instanceof Comparable comparable && filterVal instanceof Comparable) {
137136
try {
138-
return comparable.compareTo(filterVal);
137+
@SuppressWarnings("unchecked")
138+
int result = comparable.compareTo(filterVal);
139+
return result;
139140
}
140141
catch (ClassCastException ex) {
141142
throw new IllegalArgumentException("Cannot compare values of incompatible types %s and %s"

vector-stores/spring-ai-s3-vector-store/src/test/java/S3VectorStoreTests.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,62 @@ void filterDeleteSkipsDeleteWhenNoVectorsMatch() {
153153
verify(s3VectorsClient, never()).queryVectors(any(QueryVectorsRequest.class));
154154
}
155155

156+
@Test
157+
void filterDeleteHandlesNotExpression() {
158+
S3VectorsClient s3VectorsClient = mock(S3VectorsClient.class);
159+
FilterExpressionBuilder b = new FilterExpressionBuilder();
160+
when(s3VectorsClient.listVectors(any(ListVectorsRequest.class))).thenReturn(ListVectorsResponse.builder()
161+
.vectors(vector("chunk-1", Map.of("fileId", "file-1")), vector("chunk-2", Map.of("fileId", "file-2")),
162+
vector("chunk-3", Map.of("fileId", "file-1")))
163+
.build());
164+
165+
S3VectorStore vectorStore = vectorStore(s3VectorsClient);
166+
167+
vectorStore.delete(b.not(b.eq("fileId", "file-2")).build());
168+
169+
ArgumentCaptor<DeleteVectorsRequest> deleteRequestCaptor = ArgumentCaptor.forClass(DeleteVectorsRequest.class);
170+
verify(s3VectorsClient).deleteVectors(deleteRequestCaptor.capture());
171+
assertThat(deleteRequestCaptor.getValue().keys()).containsExactly("chunk-1", "chunk-3");
172+
}
173+
174+
@Test
175+
void filterDeleteHandlesIsNullExpression() {
176+
S3VectorsClient s3VectorsClient = mock(S3VectorsClient.class);
177+
FilterExpressionBuilder b = new FilterExpressionBuilder();
178+
when(s3VectorsClient.listVectors(any(ListVectorsRequest.class))).thenReturn(ListVectorsResponse.builder()
179+
.vectors(vector("chunk-1", Map.of("fileId", "file-1")), vectorWithNullMetadata("chunk-2"),
180+
vector("chunk-3", Map.of("tag", "x")))
181+
.build());
182+
183+
S3VectorStore vectorStore = vectorStore(s3VectorsClient);
184+
185+
// Expect: chunk-2 (null metadata map) and chunk-3 (fileId key absent) both match
186+
vectorStore.delete(b.isNull("fileId").build());
187+
188+
ArgumentCaptor<DeleteVectorsRequest> deleteRequestCaptor = ArgumentCaptor.forClass(DeleteVectorsRequest.class);
189+
verify(s3VectorsClient).deleteVectors(deleteRequestCaptor.capture());
190+
assertThat(deleteRequestCaptor.getValue().keys()).containsExactly("chunk-2", "chunk-3");
191+
}
192+
193+
@Test
194+
void filterDeleteHandlesIsNotNullExpression() {
195+
S3VectorsClient s3VectorsClient = mock(S3VectorsClient.class);
196+
FilterExpressionBuilder b = new FilterExpressionBuilder();
197+
when(s3VectorsClient.listVectors(any(ListVectorsRequest.class))).thenReturn(ListVectorsResponse.builder()
198+
.vectors(vector("chunk-1", Map.of("fileId", "file-1")), vectorWithNullMetadata("chunk-2"),
199+
vector("chunk-3", Map.of("tag", "x")))
200+
.build());
201+
202+
S3VectorStore vectorStore = vectorStore(s3VectorsClient);
203+
204+
// Expect: only chunk-1 has a non-null fileId value
205+
vectorStore.delete(b.isNotNull("fileId").build());
206+
207+
ArgumentCaptor<DeleteVectorsRequest> deleteRequestCaptor = ArgumentCaptor.forClass(DeleteVectorsRequest.class);
208+
verify(s3VectorsClient).deleteVectors(deleteRequestCaptor.capture());
209+
assertThat(deleteRequestCaptor.getValue().keys()).containsExactly("chunk-1");
210+
}
211+
156212
private static S3VectorStore vectorStore(S3VectorsClient s3VectorsClient) {
157213
return new S3VectorStore.Builder(s3VectorsClient, mock(EmbeddingModel.class))
158214
.vectorBucketName("test-vector-bucket")
@@ -164,6 +220,10 @@ private static ListOutputVector vector(String key, Map<String, Object> metadata)
164220
return ListOutputVector.builder().key(key).metadata(metadata(metadata)).build();
165221
}
166222

223+
private static ListOutputVector vectorWithNullMetadata(String key) {
224+
return ListOutputVector.builder().key(key).build();
225+
}
226+
167227
private static Document metadata(Map<String, Object> metadata) {
168228
Map<String, Document> values = new LinkedHashMap<>(metadata.size());
169229
metadata.forEach((key, value) -> values.put(key, DocumentUtils.toDocument(value)));

0 commit comments

Comments
 (0)