Skip to content

Commit b18635a

Browse files
committed
fix: NPE in GraphSearcher.search() with deleted vectors
Fixed issue ArcadeData#3715 When vectors are deleted from the index, ArcadePageVectorValues.getVector() returned null for deleted/missing ordinals. However, the HNSW graph may still reference these deleted ordinals during search traversal. JVector's GraphSearcher calls .length() on the returned vector, causing NPE when it's null.
1 parent e9bf7f1 commit b18635a

2 files changed

Lines changed: 231 additions & 9 deletions

File tree

engine/src/main/java/com/arcadedb/index/vector/ArcadePageVectorValues.java

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ public class ArcadePageVectorValues implements RandomAccessVectorValues {
5656
private final int[] ordinalToVectorId;
5757
private final LSMVectorIndex lsmIndex; // Used for reading quantized vectors
5858

59+
// Sentinel vector returned for deleted/missing ordinals to prevent NPE in JVector's GraphSearcher (issue #3715).
60+
// Uses Float.MIN_NORMAL to avoid division-by-zero with cosine similarity while giving very low similarity scores.
61+
private final VectorFloat<?> deletedSentinelVector;
62+
5963
// Cache for graph building - dramatically speeds up repeated vector access
6064
// Bounded LFU cache to prevent unbounded memory growth during graph construction
6165
private final MostUsedCache<Integer, VectorFloat<?>> vectorCache;
@@ -77,6 +81,7 @@ public ArcadePageVectorValues(final DatabaseInternal database, final int dimensi
7781
this.ordinalToVectorId = ordinalToVectorId;
7882
this.lsmIndex = lsmIndex;
7983
this.vectorCache = null; // No cache for live reads (search only reads each vector once)
84+
this.deletedSentinelVector = createDeletedSentinelVector(dimensions);
8085
}
8186

8287
// Constructor for graph building (uses immutable snapshot + cache for performance)
@@ -104,6 +109,7 @@ public ArcadePageVectorValues(final DatabaseInternal database, final int dimensi
104109
this.ordinalToVectorId = ordinalToVectorId;
105110
this.lsmIndex = lsmIndex;
106111
this.vectorCache = new MostUsedCache<>(cacheSize); // Bounded LFU cache for graph building
112+
this.deletedSentinelVector = createDeletedSentinelVector(dimensions);
107113
}
108114

109115
@Override
@@ -119,7 +125,7 @@ public int dimension() {
119125
@Override
120126
public VectorFloat<?> getVector(final int ordinal) {
121127
if (ordinal < 0 || ordinalToVectorId == null || ordinal >= ordinalToVectorId.length)
122-
return null;
128+
return deletedSentinelVector;
123129

124130
final int vectorId = ordinalToVectorId[ordinal];
125131

@@ -142,7 +148,10 @@ else if (vectorIndex != null)
142148
loc = null;
143149

144150
if (loc == null || loc.deleted)
145-
return null;
151+
// Return sentinel instead of null for deleted/missing entries (issue #3715).
152+
// JVector's GraphSearcher traverses deleted ordinals in the stale HNSW graph and
153+
// calls .length() on the vector, causing NPE if null. Results are filtered in post-processing.
154+
return deletedSentinelVector;
146155

147156
// Phase 2: Try reading from graph file first if vectors are stored inline
148157
// Only during search (vectorSnapshot == null), NOT during graph building (vectorSnapshot != null)
@@ -215,22 +224,22 @@ else if (vectorIndex != null)
215224
"Vector property '%s' not found in document %s (ordinal=%d). Available properties: %s",
216225
vectorPropertyName, loc.rid, ordinal, doc.getPropertyNames());
217226
}
218-
return null; // Property not found
227+
return deletedSentinelVector;
219228
}
220229

221230
final float[] vector = VectorUtils.convertToFloatArray(vectorObj);
222231
if (vector == null) {
223232
LogManager.instance().log(this, Level.WARNING,
224233
"Vector property '%s' is not float[] or List (type=%s, RID=%s)",
225234
vectorPropertyName, vectorObj.getClass().getName(), loc.rid);
226-
return null;
235+
return deletedSentinelVector;
227236
}
228237

229238
if (vector.length != dimensions) {
230239
LogManager.instance().log(this, Level.WARNING,
231240
"Vector dimension mismatch: expected %d, got %d (RID=%s)",
232241
dimensions, vector.length, loc.rid);
233-
return null;
242+
return deletedSentinelVector;
234243
}
235244

236245
// Safety check: Validate vector is not all zeros (would cause NaN in cosine similarity)
@@ -243,7 +252,7 @@ else if (vectorIndex != null)
243252
}
244253

245254
if (!hasNonZero)
246-
return null; // Zero vectors cause NaN in cosine similarity
255+
return deletedSentinelVector;
247256

248257
final VectorFloat<?> result = vts.createFloatVector(vector);
249258

@@ -261,12 +270,12 @@ else if (vectorIndex != null)
261270
return result;
262271

263272
} catch (final RecordNotFoundException e) {
264-
// DELETED RECORD
265-
return null;
273+
// DELETED RECORD — return sentinel to avoid NPE in JVector (issue #3715)
274+
return deletedSentinelVector;
266275
} catch (final Exception e) {
267276
LogManager.instance().log(this, Level.WARNING,
268277
"Error reading vector from document (ordinal=%d, RID=%s): %s", ordinal, loc.rid, e.getMessage());
269-
return null;
278+
return deletedSentinelVector;
270279
}
271280
}
272281

@@ -295,4 +304,15 @@ public RandomAccessVectorValues copy() {
295304
// This implementation is thread-safe for reads (PageManager handles concurrency)
296305
return this;
297306
}
307+
308+
/**
309+
* Creates a sentinel vector for deleted/missing ordinals with small non-zero values.
310+
* Uses Float.MIN_NORMAL to avoid division-by-zero in cosine similarity while producing
311+
* very low similarity scores that effectively push deleted nodes to the bottom of results.
312+
*/
313+
private static VectorFloat<?> createDeletedSentinelVector(final int dimensions) {
314+
final float[] sentinel = new float[dimensions];
315+
Arrays.fill(sentinel, Float.MIN_NORMAL);
316+
return vts.createFloatVector(sentinel);
317+
}
298318
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
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+
* http://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+
* SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
17+
* SPDX-License-Identifier: Apache-2.0
18+
*/
19+
package com.arcadedb.index.vector;
20+
21+
import com.arcadedb.GlobalConfiguration;
22+
import com.arcadedb.TestHelper;
23+
import com.arcadedb.database.RID;
24+
import com.arcadedb.query.sql.executor.Result;
25+
import com.arcadedb.query.sql.executor.ResultSet;
26+
import com.arcadedb.schema.Type;
27+
import com.arcadedb.schema.VertexType;
28+
import org.junit.jupiter.api.Test;
29+
30+
import java.util.ArrayList;
31+
import java.util.Arrays;
32+
import java.util.List;
33+
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
36+
/**
37+
* Test to reproduce issue #3715: NullPointerException in GraphSearcher.search() when
38+
* searching a vector index that contains deleted entries.
39+
* <p>
40+
* The HNSW graph still references ordinals of deleted vectors. When JVector traverses the
41+
* graph during search, it calls getVector() on deleted ordinals, gets null, and throws NPE.
42+
* <p>
43+
* The bug manifests with large graphs (>=1000 vectors) where the rebuild is async and may not
44+
* happen before the next search. With few deletions (below the mutations threshold), no rebuild
45+
* is triggered at all, and the stale graph is used directly.
46+
* <p>
47+
* <a href="https://github.com/ArcadeData/arcadedb/issues/3715">GitHub Issue #3715</a>
48+
*
49+
* @author Luca Garulli (l.garulli@arcadedata.com)
50+
*/
51+
class Issue3715VectorSearchAfterDeleteTest extends TestHelper {
52+
53+
private static final int DIMENSIONS = 64;
54+
// Must be >= 1000 to trigger async rebuild path (ASYNC_REBUILD_MIN_GRAPH_SIZE)
55+
private static final int TOTAL_VECTORS = 1500;
56+
// Delete a large portion to maximize chance of search traversing through deleted ordinals.
57+
// Even if this exceeds the mutation threshold, the async rebuild won't complete before search.
58+
private static final int VECTORS_TO_DELETE = 500;
59+
60+
@Test
61+
void vectorSearchAfterDeleteShouldNotThrowNPE() {
62+
// Set very high mutation threshold so the graph is NOT rebuilt after deletions.
63+
// This forces the search to use the stale graph with edges to deleted ordinals.
64+
database.getConfiguration().setValue(GlobalConfiguration.VECTOR_INDEX_MUTATIONS_BEFORE_REBUILD, 100_000);
65+
66+
// Phase 1: Create schema with vector index
67+
database.transaction(() -> {
68+
final VertexType type = database.getSchema().createVertexType("VectorDoc");
69+
type.createProperty("name", Type.STRING);
70+
type.createProperty("embedding", Type.ARRAY_OF_FLOATS);
71+
72+
database.getSchema().buildTypeIndex("VectorDoc", new String[] { "embedding" })
73+
.withLSMVectorType()
74+
.withDimensions(DIMENSIONS)
75+
.withSimilarity("COSINE")
76+
.withMaxConnections(16)
77+
.withBeamWidth(100)
78+
.create();
79+
});
80+
81+
// Phase 2: Insert enough vectors to exceed ASYNC_REBUILD_MIN_GRAPH_SIZE (1000)
82+
final List<RID> insertedRIDs = new ArrayList<>();
83+
database.transaction(() -> {
84+
for (int i = 0; i < TOTAL_VECTORS; i++) {
85+
final var vertex = database.newVertex("VectorDoc");
86+
vertex.set("name", "doc" + i);
87+
final float[] vector = new float[DIMENSIONS];
88+
for (int j = 0; j < DIMENSIONS; j++)
89+
vector[j] = (float) Math.random();
90+
vertex.set("embedding", vector);
91+
vertex.save();
92+
insertedRIDs.add(vertex.getIdentity());
93+
}
94+
});
95+
96+
// Phase 3: Force graph build by doing a search first
97+
database.transaction(() -> {
98+
final float[] queryVector = new float[DIMENSIONS];
99+
Arrays.fill(queryVector, 0.5f);
100+
final ResultSet rs = database.query("sql",
101+
"SELECT vectorNeighbors('VectorDoc[embedding]', ?, 10) AS neighbors",
102+
queryVector);
103+
assertThat(rs.hasNext()).isTrue();
104+
rs.close();
105+
});
106+
107+
// Phase 4: Delete vectors (below the mutation threshold so NO rebuild is triggered)
108+
// This leaves the HNSW graph with stale edges to deleted ordinals
109+
database.transaction(() -> {
110+
for (int i = 0; i < VECTORS_TO_DELETE; i++)
111+
insertedRIDs.get(i).asDocument().delete();
112+
});
113+
114+
// Phase 5: Search multiple times with different query vectors.
115+
// The HNSW graph still has edges to deleted ordinals. Before the fix,
116+
// getVector() returned null for deleted ordinals, causing NPE in JVector.
117+
database.transaction(() -> {
118+
for (int s = 0; s < 20; s++) {
119+
final float[] queryVector = new float[DIMENSIONS];
120+
for (int j = 0; j < DIMENSIONS; j++)
121+
queryVector[j] = (float) Math.random();
122+
final ResultSet rs = database.query("sql",
123+
"SELECT vectorNeighbors('VectorDoc[embedding]', ?, 10) AS neighbors",
124+
queryVector);
125+
assertThat(rs.hasNext()).isTrue();
126+
final Result result = rs.next();
127+
final List<?> neighbors = result.getProperty("neighbors");
128+
assertThat(neighbors).isNotNull();
129+
assertThat(neighbors.size()).isGreaterThan(0);
130+
assertThat(neighbors.size()).isLessThanOrEqualTo(10);
131+
rs.close();
132+
}
133+
});
134+
}
135+
136+
@Test
137+
void vectorSearchAfterDeleteWithReopenShouldNotThrowNPE() {
138+
// Same scenario but with database reopen between delete and search
139+
database.transaction(() -> {
140+
final VertexType type = database.getSchema().createVertexType("VectorDoc2");
141+
type.createProperty("name", Type.STRING);
142+
type.createProperty("embedding", Type.ARRAY_OF_FLOATS);
143+
144+
database.getSchema().buildTypeIndex("VectorDoc2", new String[] { "embedding" })
145+
.withLSMVectorType()
146+
.withDimensions(DIMENSIONS)
147+
.withSimilarity("COSINE")
148+
.withMaxConnections(16)
149+
.withBeamWidth(100)
150+
.create();
151+
});
152+
153+
final List<RID> insertedRIDs = new ArrayList<>();
154+
database.transaction(() -> {
155+
for (int i = 0; i < TOTAL_VECTORS; i++) {
156+
final var vertex = database.newVertex("VectorDoc2");
157+
vertex.set("name", "doc" + i);
158+
final float[] vector = new float[DIMENSIONS];
159+
for (int j = 0; j < DIMENSIONS; j++)
160+
vector[j] = (float) Math.random();
161+
vertex.set("embedding", vector);
162+
vertex.save();
163+
insertedRIDs.add(vertex.getIdentity());
164+
}
165+
});
166+
167+
// Force graph build
168+
database.transaction(() -> {
169+
final float[] queryVector = new float[DIMENSIONS];
170+
Arrays.fill(queryVector, 0.5f);
171+
final ResultSet rs = database.query("sql",
172+
"SELECT vectorNeighbors('VectorDoc2[embedding]', ?, 10) AS neighbors",
173+
queryVector);
174+
assertThat(rs.hasNext()).isTrue();
175+
rs.close();
176+
});
177+
178+
// Delete vectors
179+
database.transaction(() -> {
180+
for (int i = 0; i < VECTORS_TO_DELETE; i++)
181+
insertedRIDs.get(i).asDocument().delete();
182+
});
183+
184+
// Reopen database (simulates the user's scenario where deletions persist on disk)
185+
reopenDatabase();
186+
187+
// Search after reopen — should NOT throw NPE
188+
database.transaction(() -> {
189+
final float[] queryVector = new float[DIMENSIONS];
190+
Arrays.fill(queryVector, 0.5f);
191+
final ResultSet rs = database.query("sql",
192+
"SELECT vectorNeighbors('VectorDoc2[embedding]', ?, 10) AS neighbors",
193+
queryVector);
194+
assertThat(rs.hasNext()).isTrue();
195+
final Result result = rs.next();
196+
final List<?> neighbors = result.getProperty("neighbors");
197+
assertThat(neighbors).isNotNull();
198+
assertThat(neighbors.size()).isGreaterThan(0);
199+
rs.close();
200+
});
201+
}
202+
}

0 commit comments

Comments
 (0)