Skip to content

Commit ac939b8

Browse files
lvcatae898
authored andcommitted
fix: ensures that the fallback document scan during * vector graph rebuild is scoped to the specific bucket associated with the vector index, not the entire type
Based on PR ArcadeData#3775 by @lekmaneb. Fixes issue ArcadeData#3722
1 parent 77a3fb7 commit ac939b8

2 files changed

Lines changed: 141 additions & 5 deletions

File tree

‎engine/src/main/java/com/arcadedb/index/vector/LSMVectorIndex.java‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,21 +1136,21 @@ private void buildGraphFromScratchWithRetry(final GraphBuildCallback graphCallba
11361136
// If pages have corrupted entries (e.g., old-format tombstones), the parser may miss many vectors.
11371137
// In that case, fall back to scanning documents directly to rebuild the vector list.
11381138
boolean documentScanPerformed = false;
1139-
final String typeName = getTypeName();
1140-
if (typeName != null && !ridToLatestVector.isEmpty()) {
1139+
if (metadata.associatedBucketId != -1) {
11411140
try {
1142-
final long docCount = database.countType(typeName, false);
1141+
final com.arcadedb.engine.Bucket bucket = database.getSchema().getBucketById(metadata.associatedBucketId);
1142+
final long docCount = database.countBucket(bucket.getName());
11431143
if (ridToLatestVector.size() < docCount * 8 / 10) {
11441144
LogManager.instance().log(this, Level.WARNING,
11451145
"Page-parsed vectors (%d) significantly less than document count (%d) for index %s. "
11461146
+ "Falling back to document scan to recover missing vectors.",
11471147
ridToLatestVector.size(), docCount, indexName);
11481148

1149-
// Scan all documents to find vectors missing from the page-parsed set
1149+
// Scan all documents in the associated bucket to find vectors missing from the page-parsed set
11501150
final String vectorProp =
11511151
metadata.propertyNames != null && !metadata.propertyNames.isEmpty() ? metadata.propertyNames.getFirst() :
11521152
"vector";
1153-
database.scanType(typeName, false, record -> {
1153+
database.scanBucket(bucket.getName(), record -> {
11541154
final Document doc = (Document) record;
11551155
final RID rid = doc.getIdentity();
11561156
if (!ridToLatestVector.containsKey(rid)) {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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.TestHelper;
22+
import com.arcadedb.engine.Bucket;
23+
import com.arcadedb.index.TypeIndex;
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.Arrays;
31+
import java.util.List;
32+
import java.util.Map;
33+
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
36+
/**
37+
* Test for PR #3775 / issue #3722: ensures that the fallback document scan during
38+
* vector graph rebuild is scoped to the specific bucket associated with the vector
39+
* index, not the entire type. In a multi-bucket scenario, scanning all buckets
40+
* would inflate the document count and incorrectly trigger the fallback, potentially
41+
* adding documents from unrelated buckets into the vector index.
42+
*
43+
* @author lekmaneb (contributor)
44+
* @author Luca Garulli (l.garulli@arcadedata.com)
45+
*/
46+
class Issue3722VectorFallbackBucketScopeTest extends TestHelper {
47+
48+
private static final int DIMENSIONS = 32;
49+
private static final int VECTORS_PER_BUCKET = 50;
50+
51+
@Test
52+
void fallbackScanShouldBeScopedToAssociatedBucket() {
53+
// Create type with two buckets so we can verify bucket-scoped behavior
54+
database.transaction(() -> {
55+
final VertexType type = database.getSchema().createVertexType("MultiBucketVector");
56+
type.createProperty("name", Type.STRING);
57+
type.createProperty("vector", Type.ARRAY_OF_FLOATS);
58+
59+
// Add a second bucket to the type
60+
final Bucket extraBucket = database.getSchema().createBucket("MultiBucketVector_extra");
61+
type.addBucket(extraBucket);
62+
63+
// Create vector index (will create one sub-index per bucket)
64+
database.getSchema().buildTypeIndex("MultiBucketVector", new String[] { "vector" })
65+
.withLSMVectorType()
66+
.withDimensions(DIMENSIONS)
67+
.withSimilarity("COSINE")
68+
.withMaxConnections(16)
69+
.withBeamWidth(100)
70+
.create();
71+
});
72+
73+
// Insert vectors - distribute across both buckets
74+
database.transaction(() -> {
75+
final List<Bucket> buckets = database.getSchema().getType("MultiBucketVector").getBuckets(false);
76+
assertThat(buckets.size()).isGreaterThanOrEqualTo(2);
77+
78+
for (int i = 0; i < VECTORS_PER_BUCKET * 2; i++) {
79+
final var vertex = database.newVertex("MultiBucketVector");
80+
vertex.set("name", "vec" + i);
81+
vertex.set("vector", createDeterministicVector(i));
82+
vertex.save();
83+
}
84+
});
85+
86+
// Build vector graph
87+
final TypeIndex typeIndex = (TypeIndex) database.getSchema().getIndexByName("MultiBucketVector[vector]");
88+
assertThat(typeIndex.getSubIndexes().size()).isGreaterThanOrEqualTo(2);
89+
90+
// Force build on each sub-index
91+
for (final var subIndex : typeIndex.getSubIndexes()) {
92+
final LSMVectorIndex vectorIndex = (LSMVectorIndex) subIndex;
93+
vectorIndex.buildVectorGraphNow();
94+
95+
// Verify associated bucket ID is set
96+
assertThat(vectorIndex.getAssociatedBucketId()).isNotEqualTo(-1);
97+
98+
final Map<String, Long> stats = vectorIndex.getStats();
99+
final long totalVectors = stats.get("totalVectors");
100+
101+
// Each sub-index should only have vectors from its associated bucket,
102+
// not from all buckets of the type. With the old code using countType/scanType,
103+
// the document count would be inflated and vectors from wrong buckets could leak in.
104+
final Bucket associatedBucket = database.getSchema().getBucketById(vectorIndex.getAssociatedBucketId());
105+
final long bucketDocCount = database.countBucket(associatedBucket.getName());
106+
assertThat(totalVectors)
107+
.as("Sub-index for bucket '%s' should have vectors only from that bucket", associatedBucket.getName())
108+
.isLessThanOrEqualTo(bucketDocCount);
109+
}
110+
111+
// Reopen and verify search works correctly
112+
reopenDatabase();
113+
114+
database.transaction(() -> {
115+
final float[] queryVector = new float[DIMENSIONS];
116+
Arrays.fill(queryVector, 0.5f);
117+
final ResultSet rs = database.query("sql",
118+
"SELECT vectorNeighbors('MultiBucketVector[vector]', ?, ?) AS neighbors",
119+
queryVector, 10);
120+
121+
assertThat(rs.hasNext()).isTrue();
122+
final Result result = rs.next();
123+
final List<?> neighbors = result.getProperty("neighbors");
124+
assertThat(neighbors).isNotNull();
125+
assertThat(neighbors.size()).isGreaterThan(0);
126+
rs.close();
127+
});
128+
}
129+
130+
private float[] createDeterministicVector(final int index) {
131+
final float[] vector = new float[DIMENSIONS];
132+
for (int j = 0; j < DIMENSIONS; j++)
133+
vector[j] = (float) Math.sin(index * 0.1 + j * 0.3) * 0.5f + 0.5f;
134+
return vector;
135+
}
136+
}

0 commit comments

Comments
 (0)