Skip to content

Commit 2a7321f

Browse files
jinlee1703spring-builds
authored andcommitted
Fix: Make metadata mutable in AzureVectorStore (Azure AI Search) (#4131)
* fix(azure): parse metadata JSON into mutable Map before injecting distance Avoid UnsupportedOperationException by ensuring metadata is always a LinkedHashMap. Add `parseMetadataToMutable` helper and use it in similarity search path. * test(azure): add unit tests for mutable metadata parsing and distance Add `AzureVectorStoreMetadataParsingTests` to verify: - valid JSON → mutable LinkedHashMap copy - blank/invalid JSON → empty LinkedHashMap - can inject `distance` without throwing UnsupportedOperationException These tests fail on the previous implementation and pass with the fix, guarding against regressions. * refactor(azure): replace LinkedHashMap with HashMap in parseMetadataToMutable We don’t rely on insertion order for metadata, so a regular HashMap is sufficient. This change addresses reviewer feedback while still ensuring the returned map is always mutable by creating a defensive copy or returning an empty instance. Fixes #4117 Signed-off-by: Jinwoo Lee <[email protected]> (cherry picked from commit f3962a6)
1 parent f71f9de commit 2a7321f

File tree

2 files changed

+73
-4
lines changed

2 files changed

+73
-4
lines changed

vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.util.ArrayList;
2020
import java.util.Collections;
21+
import java.util.HashMap;
2122
import java.util.List;
2223
import java.util.Map;
2324
import java.util.Optional;
@@ -75,6 +76,7 @@
7576
* @author Josh Long
7677
* @author Thomas Vitale
7778
* @author Soby Chacko
79+
* @author Jinwoo Lee
7880
*/
7981
public class AzureVectorStore extends AbstractObservationVectorStore implements InitializingBean {
8082

@@ -239,10 +241,7 @@ public List<Document> doSimilaritySearch(SearchRequest request) {
239241

240242
final AzureSearchDocument entry = result.getDocument(AzureSearchDocument.class);
241243

242-
Map<String, Object> metadata = (StringUtils.hasText(entry.metadata()))
243-
? JSONObject.parseObject(entry.metadata(), new TypeReference<>() {
244-
245-
}) : Map.of();
244+
Map<String, Object> metadata = parseMetadataToMutable(entry.metadata());
246245

247246
metadata.put(DocumentMetadata.DISTANCE.value(), 1.0 - result.getScore());
248247

@@ -325,6 +324,21 @@ public <T> Optional<T> getNativeClient() {
325324
return Optional.of(client);
326325
}
327326

327+
static Map<String, Object> parseMetadataToMutable(@Nullable String metadataJson) {
328+
if (!StringUtils.hasText(metadataJson)) {
329+
return new HashMap<>();
330+
}
331+
try {
332+
Map<String, Object> parsed = JSONObject.parseObject(metadataJson, new TypeReference<Map<String, Object>>() {
333+
});
334+
return (parsed == null) ? new HashMap<>() : new HashMap<>(parsed);
335+
}
336+
catch (Exception ex) {
337+
logger.warn("Failed to parse metadata JSON. Using empty metadata. json={}", metadataJson, ex);
338+
return new HashMap<>();
339+
}
340+
}
341+
328342
public record MetadataField(String name, SearchFieldDataType fieldType) {
329343

330344
public static MetadataField text(String name) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.vectorstore.azure;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import java.util.Map;
22+
23+
import org.junit.jupiter.api.Test;
24+
25+
/**
26+
* Unit tests for {@link AzureVectorStore#parseMetadataToMutable(String)}.
27+
*
28+
* @author Jinwoo Lee
29+
*/
30+
class AzureVectorStoreMetadataTests {
31+
32+
@Test
33+
void returnsMutableMapForBlankOrNull() {
34+
Map<String, Object> m1 = AzureVectorStore.parseMetadataToMutable(null);
35+
m1.put("distance", 0.1);
36+
assertThat(m1).containsEntry("distance", 0.1);
37+
38+
Map<String, Object> m2 = AzureVectorStore.parseMetadataToMutable("");
39+
m2.put("distance", 0.2);
40+
assertThat(m2).containsEntry("distance", 0.2);
41+
42+
Map<String, Object> m3 = AzureVectorStore.parseMetadataToMutable(" ");
43+
m3.put("distance", 0.3);
44+
assertThat(m3).containsEntry("distance", 0.3);
45+
}
46+
47+
@Test
48+
void wrapsParsedJsonInLinkedHashMapSoItIsMutable() {
49+
Map<String, Object> map = AzureVectorStore.parseMetadataToMutable("{\"k\":\"v\"}");
50+
assertThat(map).containsEntry("k", "v");
51+
map.put("distance", 0.4);
52+
assertThat(map).containsEntry("distance", 0.4);
53+
}
54+
55+
}

0 commit comments

Comments
 (0)