Skip to content

Commit bf13d30

Browse files
committed
perf: massive improvement with vector indexes
Fixed issue ArcadeData#3721
1 parent a403193 commit bf13d30

12 files changed

Lines changed: 1841 additions & 191 deletions

File tree

engine/src/main/java/com/arcadedb/function/sql/vector/SQLFunctionVectorNeighbors.java

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public SQLFunctionVectorNeighbors() {
4949

5050
public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params,
5151
final CommandContext context) {
52-
if (params == null || params.length != 3)
52+
if (params == null || params.length < 3 || params.length > 4)
5353
throw new CommandSQLParsingException(getSyntax());
5454

5555
final String indexSpec = params[0].toString();
@@ -63,6 +63,10 @@ public Object execute(final Object self, final Identifiable currentRecord, final
6363

6464
final int limit = params[2] instanceof Number n ? n.intValue() : Integer.parseInt(params[2].toString());
6565

66+
// Optional 4th parameter: efSearch (search beam width for recall tuning)
67+
final int efSearch = params.length >= 4 && params[3] != null ?
68+
(params[3] instanceof Number n ? n.intValue() : Integer.parseInt(params[3].toString())) : -1;
69+
6670
// Parse the index specification: TYPE[property] or just index name
6771
final String specifiedTypeName;
6872
final String propertyName;
@@ -74,7 +78,7 @@ public Object execute(final Object self, final Identifiable currentRecord, final
7478
// Assume it's just an index name
7579
final Index directIndex = context.getDatabase().getSchema().getIndexByName(indexSpec);
7680
if (directIndex instanceof TypeIndex typeIndex) {
77-
return executeWithTypeIndex(typeIndex, null, key, limit, context);
81+
return executeWithTypeIndex(typeIndex, null, key, limit, efSearch, context);
7882
}
7983
throw new CommandSQLParsingException(
8084
"Index '" + indexSpec + "' is not a vector index (found: " + (directIndex != null ? directIndex.getClass().getSimpleName() : "null") + ")");
@@ -97,11 +101,11 @@ public Object execute(final Object self, final Identifiable currentRecord, final
97101
allowedBucketIds.add(bucket.getFileId());
98102
}
99103

100-
return executeWithTypeIndex(typeIndex, allowedBucketIds, key, limit, context);
104+
return executeWithTypeIndex(typeIndex, allowedBucketIds, key, limit, efSearch, context);
101105
}
102106

103107
private Object executeWithTypeIndex(final TypeIndex typeIndex, final Set<Integer> allowedBucketIds, final Object key,
104-
final int limit, final CommandContext context) {
108+
final int limit, final int efSearch, final CommandContext context) {
105109
final var bucketIndexes = typeIndex.getIndexesOnBuckets();
106110
if (bucketIndexes == null || bucketIndexes.length == 0) {
107111
throw new CommandSQLParsingException("Index '" + typeIndex.getName() + "' has no bucket indexes");
@@ -127,15 +131,15 @@ private Object executeWithTypeIndex(final TypeIndex typeIndex, final Set<Integer
127131
}
128132

129133
// Search across all matching vector indexes and merge results
130-
return executeWithLSMVectorIndexes(vectorIndexes, key, limit, context);
134+
return executeWithLSMVectorIndexes(vectorIndexes, key, limit, efSearch, context);
131135
}
132136

133137
/**
134138
* Search across multiple vector indexes and merge the results.
135139
* This is used when searching within a specific type that may have multiple buckets.
136140
*/
137141
private Object executeWithLSMVectorIndexes(final List<LSMVectorIndex> vectorIndexes, final Object key, final int limit,
138-
final CommandContext context) {
142+
final int efSearch, final CommandContext context) {
139143
// Get the query vector
140144
final float[] queryVector = extractQueryVector(key, vectorIndexes.getFirst(), context);
141145

@@ -144,7 +148,7 @@ private Object executeWithLSMVectorIndexes(final List<LSMVectorIndex> vectorInde
144148

145149
for (final LSMVectorIndex lsmIndex : vectorIndexes) {
146150
// Request more results from each index to ensure we have enough after merging
147-
final List<Pair<RID, Float>> neighbors = lsmIndex.findNeighborsFromVector(queryVector, limit);
151+
final List<Pair<RID, Float>> neighbors = lsmIndex.findNeighborsFromVector(queryVector, limit, efSearch);
148152
allNeighbors.addAll(neighbors);
149153
}
150154

@@ -226,6 +230,6 @@ private float[] extractQueryVector(final Object key, final LSMVectorIndex lsmInd
226230
}
227231

228232
public String getSyntax() {
229-
return NAME + "(<index-name>, <key-or-vector>, <k>)";
233+
return NAME + "(<index-name>, <key-or-vector>, <k>[, <efSearch>])";
230234
}
231235
}

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

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
import com.arcadedb.database.Record;
2424
import com.arcadedb.exception.RecordNotFoundException;
2525
import com.arcadedb.log.LogManager;
26-
import com.arcadedb.utility.MostUsedCache;
27-
2826
import io.github.jbellis.jvector.graph.ImmutableGraphIndex;
2927
import io.github.jbellis.jvector.graph.RandomAccessVectorValues;
3028
import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex;
@@ -33,6 +31,7 @@
3331
import io.github.jbellis.jvector.vector.types.VectorTypeSupport;
3432

3533
import java.util.*;
34+
import java.util.concurrent.ConcurrentHashMap;
3635
import java.util.logging.*;
3736

3837
/**
@@ -61,8 +60,9 @@ public class ArcadePageVectorValues implements RandomAccessVectorValues {
6160
private final VectorFloat<?> deletedSentinelVector;
6261

6362
// Cache for graph building - dramatically speeds up repeated vector access
64-
// Bounded LFU cache to prevent unbounded memory growth during graph construction
65-
private final MostUsedCache<Integer, VectorFloat<?>> vectorCache;
63+
// Lock-free concurrent map to avoid mutex contention during parallel JVector graph build
64+
private final ConcurrentHashMap<Integer, VectorFloat<?>> vectorCache;
65+
private final int maxCacheSize;
6666

6767
// Constructor for live reads (uses shared vectorIndex, no cache needed)
6868
public ArcadePageVectorValues(final DatabaseInternal database, final int dimensions, final String vectorPropertyName,
@@ -71,6 +71,7 @@ public ArcadePageVectorValues(final DatabaseInternal database, final int dimensi
7171
}
7272

7373
// Constructor for live reads with LSM index reference (for quantization support)
74+
// Includes a small bounded cache for search — JVector revisits nodes during beam search
7475
public ArcadePageVectorValues(final DatabaseInternal database, final int dimensions, final String vectorPropertyName,
7576
final VectorLocationIndex vectorIndex, final int[] ordinalToVectorId, final LSMVectorIndex lsmIndex) {
7677
this.database = database;
@@ -80,7 +81,9 @@ public ArcadePageVectorValues(final DatabaseInternal database, final int dimensi
8081
this.vectorSnapshot = null;
8182
this.ordinalToVectorId = ordinalToVectorId;
8283
this.lsmIndex = lsmIndex;
83-
this.vectorCache = null; // No cache for live reads (search only reads each vector once)
84+
// Small search-time cache: beam search revisits nodes during graph traversal
85+
this.vectorCache = new ConcurrentHashMap<>(512);
86+
this.maxCacheSize = 1024;
8487
this.deletedSentinelVector = createDeletedSentinelVector(dimensions);
8588
}
8689

@@ -108,7 +111,9 @@ public ArcadePageVectorValues(final DatabaseInternal database, final int dimensi
108111
this.vectorSnapshot = vectorSnapshot;
109112
this.ordinalToVectorId = ordinalToVectorId;
110113
this.lsmIndex = lsmIndex;
111-
this.vectorCache = new MostUsedCache<>(cacheSize); // Bounded LFU cache for graph building
114+
final int effectiveCacheSize = cacheSize <= 0 ? DEFAULT_CACHE_SIZE : cacheSize;
115+
this.vectorCache = new ConcurrentHashMap<>(Math.min(effectiveCacheSize, 1_000_000)); // Lock-free cache for parallel graph building
116+
this.maxCacheSize = effectiveCacheSize;
112117
this.deletedSentinelVector = createDeletedSentinelVector(dimensions);
113118
}
114119

@@ -131,11 +136,9 @@ public VectorFloat<?> getVector(final int ordinal) {
131136

132137
// Check cache first (for graph building - dramatically speeds up repeated access)
133138
if (vectorCache != null) {
134-
synchronized (vectorCache) {
135-
final VectorFloat<?> cached = vectorCache.get(vectorId);
136-
if (cached != null)
137-
return cached;
138-
}
139+
final VectorFloat<?> cached = vectorCache.get(vectorId);
140+
if (cached != null)
141+
return cached;
139142
}
140143

141144
// Use snapshot if available (during graph building), otherwise use live vectorIndex
@@ -167,11 +170,8 @@ else if (vectorIndex != null)
167170
// Track fetch source for metrics
168171
lsmIndex.metrics.incrementVectorFetchFromGraph();
169172

170-
if (vectorCache != null) {
171-
synchronized (vectorCache) {
172-
vectorCache.put(vectorId, vector);
173-
}
174-
}
173+
if (vectorCache != null && vectorCache.size() < maxCacheSize)
174+
vectorCache.put(vectorId, vector);
175175

176176
return vector;
177177
}
@@ -242,16 +242,7 @@ else if (vectorIndex != null)
242242
return deletedSentinelVector;
243243
}
244244

245-
// Safety check: Validate vector is not all zeros (would cause NaN in cosine similarity)
246-
boolean hasNonZero = false;
247-
for (float v : vector) {
248-
if (v != 0.0f) {
249-
hasNonZero = true;
250-
break;
251-
}
252-
}
253-
254-
if (!hasNonZero)
245+
if (VectorUtils.isZeroVector(vector))
255246
return deletedSentinelVector;
256247

257248
final VectorFloat<?> result = vts.createFloatVector(vector);
@@ -261,11 +252,8 @@ else if (vectorIndex != null)
261252
lsmIndex.metrics.incrementVectorFetchFromDocuments();
262253

263254
// Cache the result if caching is enabled (for graph building performance)
264-
if (vectorCache != null) {
265-
synchronized (vectorCache) {
266-
vectorCache.put(vectorId, result);
267-
}
268-
}
255+
if (vectorCache != null && vectorCache.size() < maxCacheSize)
256+
vectorCache.put(vectorId, result);
269257

270258
return result;
271259

@@ -286,11 +274,8 @@ else if (vectorIndex != null)
286274
* without needing their own database context for lookupByRID.
287275
*/
288276
public void putInCache(final int vectorId, final VectorFloat<?> vector) {
289-
if (vectorCache != null && vector != null) {
290-
synchronized (vectorCache) {
291-
vectorCache.put(vectorId, vector);
292-
}
293-
}
277+
if (vectorCache != null && vector != null)
278+
vectorCache.put(vectorId, vector);
294279
}
295280

296281
@Override
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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.database.DatabaseInternal;
22+
import com.arcadedb.database.Document;
23+
import com.arcadedb.log.LogManager;
24+
25+
import io.github.jbellis.jvector.graph.RandomAccessVectorValues;
26+
import io.github.jbellis.jvector.vector.VectorizationProvider;
27+
import io.github.jbellis.jvector.vector.types.VectorFloat;
28+
import io.github.jbellis.jvector.vector.types.VectorTypeSupport;
29+
30+
import java.util.concurrent.ConcurrentHashMap;
31+
import java.util.concurrent.atomic.AtomicInteger;
32+
import java.util.logging.Level;
33+
34+
/**
35+
* A growable RandomAccessVectorValues with lazy disk fallback.
36+
* <p>
37+
* New vectors inserted via {@link #addVector} are cached in memory (ConcurrentHashMap).
38+
* Existing vectors not in the cache are loaded lazily from ArcadeDB pages/documents
39+
* on first access and then cached. This avoids pre-loading all vectors at startup
40+
* while keeping frequently-accessed vectors fast.
41+
* <p>
42+
* Thread-safe for concurrent reads and writes.
43+
*
44+
* @author Luca Garulli (l.garulli@arcadedata.com)
45+
*/
46+
class GrowableVectorValues implements RandomAccessVectorValues {
47+
private static final VectorTypeSupport vts = VectorizationProvider.getInstance().getVectorTypeSupport();
48+
49+
private final int dimensions;
50+
private final ConcurrentHashMap<Integer, VectorFloat<?>> vectors;
51+
private final AtomicInteger count = new AtomicInteger(0);
52+
53+
// Lazy-load support: when a vector is not in the map, read from disk
54+
private final VectorLocationIndex vectorIndex;
55+
private final LSMVectorIndex lsmIndex;
56+
private final DatabaseInternal database;
57+
private final String vectorPropertyName;
58+
59+
/**
60+
* Simple mode: no disk fallback (used in tests and when all vectors are in memory).
61+
*/
62+
GrowableVectorValues(final int dimensions) {
63+
this(dimensions, 1024, null, null, null, null);
64+
}
65+
66+
/**
67+
* Simple mode with initial capacity.
68+
*/
69+
GrowableVectorValues(final int dimensions, final int initialCapacity) {
70+
this(dimensions, initialCapacity, null, null, null, null);
71+
}
72+
73+
/**
74+
* Full mode with lazy disk fallback for existing vectors.
75+
*/
76+
GrowableVectorValues(final int dimensions, final int initialCapacity,
77+
final VectorLocationIndex vectorIndex, final LSMVectorIndex lsmIndex,
78+
final DatabaseInternal database, final String vectorPropertyName) {
79+
this.dimensions = dimensions;
80+
this.vectors = new ConcurrentHashMap<>(Math.max(16, initialCapacity));
81+
this.vectorIndex = vectorIndex;
82+
this.lsmIndex = lsmIndex;
83+
this.database = database;
84+
this.vectorPropertyName = vectorPropertyName;
85+
}
86+
87+
void addVector(final int ordinal, final VectorFloat<?> vector) {
88+
vectors.put(ordinal, vector);
89+
int current;
90+
while ((current = count.get()) <= ordinal)
91+
count.compareAndSet(current, ordinal + 1);
92+
}
93+
94+
void removeVector(final int ordinal) {
95+
vectors.remove(ordinal);
96+
}
97+
98+
@Override
99+
public int size() {
100+
return count.get();
101+
}
102+
103+
@Override
104+
public int dimension() {
105+
return dimensions;
106+
}
107+
108+
@Override
109+
public VectorFloat<?> getVector(final int ordinal) {
110+
// Fast path: check in-memory cache
111+
final VectorFloat<?> cached = vectors.get(ordinal);
112+
if (cached != null)
113+
return cached;
114+
115+
// Slow path: lazy-load from disk and cache
116+
if (vectorIndex == null || database == null)
117+
return null;
118+
119+
final VectorLocationIndex.VectorLocation loc = vectorIndex.getLocation(ordinal);
120+
if (loc == null || loc.deleted)
121+
return null;
122+
123+
try {
124+
float[] vector = null;
125+
126+
// Try quantized pages first (INT8/BINARY)
127+
if (lsmIndex != null)
128+
vector = lsmIndex.readVectorFromOffset(loc.absoluteFileOffset, loc.isCompacted);
129+
130+
// Fall back to document lookup
131+
if (vector == null && vectorPropertyName != null) {
132+
final var record = database.lookupByRID(loc.rid, false);
133+
final Document doc = (Document) record;
134+
vector = VectorUtils.convertToFloatArray(doc.get(vectorPropertyName));
135+
}
136+
137+
if (vector != null && vector.length == dimensions && !VectorUtils.isZeroVector(vector)) {
138+
final VectorFloat<?> vf = vts.createFloatVector(vector);
139+
vectors.put(ordinal, vf); // Cache for next access
140+
return vf;
141+
}
142+
} catch (final Exception e) {
143+
LogManager.instance().log(this, Level.FINE,
144+
"Could not lazy-load vector ordinal=%d: %s", ordinal, e.getMessage());
145+
}
146+
147+
return null;
148+
}
149+
150+
@Override
151+
public boolean isValueShared() {
152+
return false;
153+
}
154+
155+
@Override
156+
public RandomAccessVectorValues copy() {
157+
return this;
158+
}
159+
160+
int vectorCount() {
161+
return vectors.size();
162+
}
163+
}

0 commit comments

Comments
 (0)