diff --git a/core/src/main/java/com/redis/vl/schema/VectorField.java b/core/src/main/java/com/redis/vl/schema/VectorField.java index aec8ea8..02c3d9f 100644 --- a/core/src/main/java/com/redis/vl/schema/VectorField.java +++ b/core/src/main/java/com/redis/vl/schema/VectorField.java @@ -50,6 +50,35 @@ public class VectorField extends BaseField { @JsonProperty("epsilon") private final Double epsilon; + // SVS-VAMANA algorithm parameters + /** Maximum edges per node in the VAMANA graph (default: 40) */ + @JsonProperty("graphMaxDegree") + private final Integer graphMaxDegree; + + /** Build-time candidate window size (default: 250) */ + @JsonProperty("constructionWindowSize") + private final Integer constructionWindowSize; + + /** Search-time candidate window size (default: 20) - primary tuning parameter */ + @JsonProperty("searchWindowSize") + private final Integer searchWindowSize; + + /** Range query boundary expansion factor for SVS (default: 0.01) */ + @JsonProperty("svsEpsilon") + private final Double svsEpsilon; + + /** Vector compression type (optional) */ + @JsonProperty("compression") + private final CompressionType compression; + + /** Dimensionality reduction for LeanVec compression (must be < dimensions) */ + @JsonProperty("reduce") + private final Integer reduce; + + /** Minimum vectors before compression training kicks in (default: 10,240) */ + @JsonProperty("trainingThreshold") + private final Integer trainingThreshold; + /** * Create a VectorField with name and dimensions (defaults to FLAT algorithm, COSINE distance) * @@ -71,6 +100,13 @@ public VectorField(String name, int dimensions) { this.hnswEfConstruction = null; this.hnswEfRuntime = null; this.epsilon = null; + this.graphMaxDegree = null; + this.constructionWindowSize = null; + this.searchWindowSize = null; + this.svsEpsilon = null; + this.compression = null; + this.reduce = null; + this.trainingThreshold = null; } /** Create a VectorField with all properties */ @@ -88,7 +124,15 @@ private VectorField( Integer hnswM, Integer hnswEfConstruction, Integer hnswEfRuntime, - Double epsilon) { + Double epsilon, + // SVS-VAMANA parameters + Integer graphMaxDegree, + Integer constructionWindowSize, + Integer searchWindowSize, + Double svsEpsilon, + CompressionType compression, + Integer reduce, + Integer trainingThreshold) { super(name, alias, indexed != null ? indexed : true, sortable != null ? sortable : false); if (dimensions <= 0) { throw new IllegalArgumentException("Dimensions must be positive"); @@ -97,12 +141,28 @@ private VectorField( this.algorithm = algorithm != null ? algorithm : VectorAlgorithm.FLAT; this.distanceMetric = distanceMetric != null ? distanceMetric : DistanceMetric.COSINE; this.dataType = dataType != null ? dataType : VectorDataType.FLOAT32; + + // FLAT parameters this.initialCapacity = initialCapacity; this.blockSize = blockSize; + + // HNSW parameters this.hnswM = hnswM; this.hnswEfConstruction = hnswEfConstruction; this.hnswEfRuntime = hnswEfRuntime; this.epsilon = epsilon; + + // SVS-VAMANA parameters + this.graphMaxDegree = graphMaxDegree; + this.constructionWindowSize = constructionWindowSize; + this.searchWindowSize = searchWindowSize; + this.svsEpsilon = svsEpsilon; + this.compression = compression; + this.reduce = reduce; + this.trainingThreshold = trainingThreshold; + + // Validate SVS-specific constraints + validateSVSConstraints(); } /** @@ -133,6 +193,8 @@ public static VectorFieldBuilder builder() { public Algorithm getAlgorithm() { if (algorithm == VectorAlgorithm.HNSW) { return Algorithm.HNSW; + } else if (algorithm == VectorAlgorithm.SVS_VAMANA) { + return Algorithm.SVS_VAMANA; } return Algorithm.FLAT; } @@ -160,6 +222,91 @@ public FieldType getFieldType() { return FieldType.VECTOR; } + /** + * Validate SVS-VAMANA specific constraints. + * + *
Validation rules: + * + *
Compression families: + * + *
Bit depths: + * + *
Controls the maximum number of edges per node in the VAMANA graph. Higher values improve + * recall but increase memory usage and build time. + * + * @param graphMaxDegree Max edges per node (recommended: 32-64, default: 40) + * @return This builder + */ + public VectorFieldBuilder graphMaxDegree(int graphMaxDegree) { + this.graphMaxDegree = graphMaxDegree; + return this; + } + + /** + * Set the construction window size for SVS-VAMANA algorithm. + * + *
Number of candidates considered during graph construction. Higher values improve index + * quality but increase build time. + * + * @param constructionWindowSize Build-time candidates (default: 250) + * @return This builder + */ + public VectorFieldBuilder constructionWindowSize(int constructionWindowSize) { + this.constructionWindowSize = constructionWindowSize; + return this; + } + + /** + * Set the search window size for SVS-VAMANA algorithm. + * + *
Number of candidates considered during search. This is the primary tuning parameter for + * accuracy vs performance trade-off. Higher values improve recall but increase query latency. + * + * @param searchWindowSize Search candidates (recommended: 20-50, default: 20) + * @return This builder + */ + public VectorFieldBuilder searchWindowSize(int searchWindowSize) { + this.searchWindowSize = searchWindowSize; + return this; + } + + /** + * Set the epsilon parameter for SVS-VAMANA range queries. + * + *
Boundary expansion factor for range queries. + * + * @param svsEpsilon Epsilon value (default: 0.01) + * @return This builder + */ + public VectorFieldBuilder svsEpsilon(double svsEpsilon) { + this.svsEpsilon = svsEpsilon; + return this; + } + + /** + * Set the compression type for SVS-VAMANA algorithm. + * + *
Available compression types: + * + *
Important: Only valid with LeanVec compression types. Must be less than the vector + * dimensions. + * + *
Recommended values: dimensions/2 or dimensions/4 + * + * @param reduce Target dimensions after reduction (must be < dimensions) + * @return This builder + * @throws IllegalArgumentException if used without LeanVec compression + */ + public VectorFieldBuilder reduce(int reduce) { + this.reduce = reduce; + return this; + } + + /** + * Set the training threshold for SVS-VAMANA compression. + * + *
Minimum number of vectors required before compression training begins. + * + * @param trainingThreshold Minimum vectors (default: 10,240) + * @return This builder + */ + public VectorFieldBuilder trainingThreshold(int trainingThreshold) { + this.trainingThreshold = trainingThreshold; + return this; + } + /** * Build the VectorField * @@ -528,7 +891,15 @@ public VectorField build() { hnswM, hnswEfConstruction, hnswEfRuntime, - epsilon); + epsilon, + // SVS-VAMANA parameters + graphMaxDegree, + constructionWindowSize, + searchWindowSize, + svsEpsilon, + compression, + reduce, + trainingThreshold); } } } diff --git a/core/src/test/java/com/redis/vl/BaseSVSIntegrationTest.java b/core/src/test/java/com/redis/vl/BaseSVSIntegrationTest.java new file mode 100644 index 0000000..073f9f4 --- /dev/null +++ b/core/src/test/java/com/redis/vl/BaseSVSIntegrationTest.java @@ -0,0 +1,77 @@ +package com.redis.vl; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import redis.clients.jedis.*; + +/** + * Base class for SVS-VAMANA integration tests requiring Redis ≥ 8.2.0 + * + *
Uses redis-stack:edge image which includes Redis 8.2.0+ with SVS-VAMANA support. + */ +public abstract class BaseSVSIntegrationTest { + + @SuppressFBWarnings( + value = {"MS_PKGPROTECT", "MS_CANNOT_BE_FINAL"}, + justification = "Test infrastructure fields intentionally mutable for test lifecycle") + protected static Jedis jedis; + + @SuppressFBWarnings( + value = {"MS_PKGPROTECT", "MS_CANNOT_BE_FINAL"}, + justification = "Test infrastructure fields intentionally mutable for test lifecycle") + protected static UnifiedJedis unifiedJedis; + + @SuppressFBWarnings( + value = {"MS_PKGPROTECT", "MS_CANNOT_BE_FINAL"}, + justification = "Test infrastructure fields intentionally mutable for test lifecycle") + protected static String redisUrl; + + private static GenericContainer> redisContainer; + private static JedisPool jedisPool; + + @BeforeAll + static void startContainer() { + // Start Redis 8.2 container with SVS-VAMANA support + redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:8.2")).withExposedPorts(6379); + redisContainer.start(); + + // Create Jedis connection pool + JedisPoolConfig poolConfig = new JedisPoolConfig(); + poolConfig.setMaxTotal(10); + poolConfig.setMaxIdle(5); + + String host = redisContainer.getHost(); + int port = redisContainer.getMappedPort(6379); + + // Build Redis URL for testing URL-based constructors + redisUrl = String.format("redis://%s:%d", host, port); + + jedisPool = new JedisPool(poolConfig, host, port); + + jedis = jedisPool.getResource(); + + // Create UnifiedJedis for RediSearch operations + HostAndPort hostAndPort = new HostAndPort(host, port); + unifiedJedis = new UnifiedJedis(hostAndPort); + } + + @AfterAll + static void stopContainer() { + if (jedis != null) { + jedis.close(); + } + if (unifiedJedis != null) { + unifiedJedis.close(); + } + if (jedisPool != null) { + jedisPool.close(); + } + if (redisContainer != null) { + redisContainer.stop(); + } + } +} diff --git a/core/src/test/java/com/redis/vl/schema/SVSVamanaFieldTest.java b/core/src/test/java/com/redis/vl/schema/SVSVamanaFieldTest.java new file mode 100644 index 0000000..3d8e72d --- /dev/null +++ b/core/src/test/java/com/redis/vl/schema/SVSVamanaFieldTest.java @@ -0,0 +1,372 @@ +package com.redis.vl.schema; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.search.schemafields.SchemaField; +import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm; + +/** + * Unit tests for SVS-VAMANA vector indexing algorithm support (#404). + * + *
Tests cover: + * + *
Python reference: tests/unit/test_schema.py (SVS-VAMANA tests) + */ +@DisplayName("SVS-VAMANA Field Unit Tests") +class SVSVamanaFieldTest { + + @Test + @DisplayName("CompressionType enum should have all 6 compression types") + void testCompressionTypeEnum() { + // Verify all 6 compression types exist + assertThat(VectorField.CompressionType.values()).hasSize(6); + + // LVQ types + assertThat(VectorField.CompressionType.LVQ4.getValue()).isEqualTo("LVQ4"); + assertThat(VectorField.CompressionType.LVQ4x4.getValue()).isEqualTo("LVQ4x4"); + assertThat(VectorField.CompressionType.LVQ4x8.getValue()).isEqualTo("LVQ4x8"); + assertThat(VectorField.CompressionType.LVQ8.getValue()).isEqualTo("LVQ8"); + + // LeanVec types + assertThat(VectorField.CompressionType.LeanVec4x8.getValue()).isEqualTo("LeanVec4x8"); + assertThat(VectorField.CompressionType.LeanVec8x8.getValue()).isEqualTo("LeanVec8x8"); + + // Test isLVQ() method + assertThat(VectorField.CompressionType.LVQ4.isLVQ()).isTrue(); + assertThat(VectorField.CompressionType.LVQ4x4.isLVQ()).isTrue(); + assertThat(VectorField.CompressionType.LVQ4x8.isLVQ()).isTrue(); + assertThat(VectorField.CompressionType.LVQ8.isLVQ()).isTrue(); + assertThat(VectorField.CompressionType.LeanVec4x8.isLVQ()).isFalse(); + assertThat(VectorField.CompressionType.LeanVec8x8.isLVQ()).isFalse(); + + // Test isLeanVec() method + assertThat(VectorField.CompressionType.LeanVec4x8.isLeanVec()).isTrue(); + assertThat(VectorField.CompressionType.LeanVec8x8.isLeanVec()).isTrue(); + assertThat(VectorField.CompressionType.LVQ4.isLeanVec()).isFalse(); + assertThat(VectorField.CompressionType.LVQ4x4.isLeanVec()).isFalse(); + assertThat(VectorField.CompressionType.LVQ4x8.isLeanVec()).isFalse(); + assertThat(VectorField.CompressionType.LVQ8.isLeanVec()).isFalse(); + } + + @Test + @DisplayName("VectorDataType enum should have all 6 data types") + void testVectorDataTypeExpansion() { + // Verify all 6 data types exist + assertThat(VectorField.VectorDataType.values()).hasSize(6); + + assertThat(VectorField.VectorDataType.BFLOAT16.getValue()).isEqualTo("BFLOAT16"); + assertThat(VectorField.VectorDataType.FLOAT16.getValue()).isEqualTo("FLOAT16"); + assertThat(VectorField.VectorDataType.FLOAT32.getValue()).isEqualTo("FLOAT32"); + assertThat(VectorField.VectorDataType.FLOAT64.getValue()).isEqualTo("FLOAT64"); + assertThat(VectorField.VectorDataType.INT8.getValue()).isEqualTo("INT8"); + assertThat(VectorField.VectorDataType.UINT8.getValue()).isEqualTo("UINT8"); + } + + @Test + @DisplayName("Algorithm enum should include SVS_VAMANA") + void testSVSVamanaAlgorithm() { + // Verify SVS_VAMANA enum exists + assertThat(VectorField.Algorithm.values()).hasSize(3); + assertThat(VectorField.Algorithm.SVS_VAMANA.getValue()).isEqualTo("SVS-VAMANA"); + } + + @Test + @DisplayName("Should create SVS field with minimal parameters") + void testSVSFieldCreation() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .build(); + + assertThat(field.getName()).isEqualTo("embedding"); + assertThat(field.getDimensions()).isEqualTo(768); + assertThat(field.getAlgorithm()).isEqualTo(VectorField.Algorithm.SVS_VAMANA); + assertThat(field.getDataType()).isEqualTo(VectorField.VectorDataType.FLOAT32); + } + + @Test + @DisplayName("Should create SVS field with LVQ4 compression") + void testSVSFieldWithCompression() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .compression(VectorField.CompressionType.LVQ4) + .searchWindowSize(30) + .build(); + + assertThat(field.getCompression()).isEqualTo(VectorField.CompressionType.LVQ4); + assertThat(field.getSearchWindowSize()).isEqualTo(30); + } + + @Test + @DisplayName("Should create SVS field with LeanVec and reduce") + void testSVSFieldWithLeanVecAndReduce() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(384) + .searchWindowSize(30) + .build(); + + assertThat(field.getCompression()).isEqualTo(VectorField.CompressionType.LeanVec4x8); + assertThat(field.getReduce()).isEqualTo(384); + assertThat(field.getDimensions()).isEqualTo(768); + } + + @Test + @DisplayName("SVS should accept FLOAT16 data type") + void testSVSAcceptsFloat16() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .build(); + + assertThat(field.getDataType()).isEqualTo(VectorField.VectorDataType.FLOAT16); + } + + @Test + @DisplayName("SVS should accept FLOAT32 data type") + void testSVSAcceptsFloat32() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .build(); + + assertThat(field.getDataType()).isEqualTo(VectorField.VectorDataType.FLOAT32); + } + + @Test + @DisplayName("SVS should reject FLOAT64 data type") + void testSVSRejectsFloat64() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT64) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SVS-VAMANA only supports FLOAT16 and FLOAT32") + .hasMessageContaining("FLOAT64"); + } + + @Test + @DisplayName("SVS should reject BFLOAT16 data type") + void testSVSRejectsBFloat16() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.BFLOAT16) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SVS-VAMANA only supports FLOAT16 and FLOAT32"); + } + + @Test + @DisplayName("SVS should reject INT8 data type") + void testSVSRejectsInt8() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.INT8) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SVS-VAMANA only supports FLOAT16 and FLOAT32"); + } + + @Test + @DisplayName("SVS should reject UINT8 data type") + void testSVSRejectsUInt8() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.UINT8) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SVS-VAMANA only supports FLOAT16 and FLOAT32"); + } + + @Test + @DisplayName("reduce >= dimensions should throw exception") + void testReduceGreaterThanDimensionsThrows() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(768) // equals dimensions - should fail + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reduce (768) must be less than dimensions (768)"); + } + + @Test + @DisplayName("reduce without compression should throw exception") + void testReduceWithoutCompressionThrows() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .reduce(384) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reduce parameter requires compression to be set"); + } + + @Test + @DisplayName("reduce with LVQ compression should throw exception") + void testReduceWithLVQThrows() { + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LVQ4) + .reduce(384) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reduce parameter is only supported with LeanVec compression types"); + } + + @Test + @DisplayName("reduce with LeanVec compression should succeed") + void testReduceWithLeanVecSucceeds() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(384) + .build(); + + assertThat(field.getReduce()).isEqualTo(384); + assertThat(field.getCompression()).isEqualTo(VectorField.CompressionType.LeanVec4x8); + } + + @Test + @DisplayName("LVQ compression without reduce should succeed") + void testLVQCompressionWithoutReduce() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .compression(VectorField.CompressionType.LVQ4) + .searchWindowSize(40) + .build(); + + assertThat(field.getCompression()).isEqualTo(VectorField.CompressionType.LVQ4); + assertThat(field.getReduce()).isNull(); + } + + @Test + @DisplayName("SVS field with all parameters should be created successfully") + void testAllSVSParameters() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(384) + .graphMaxDegree(50) + .constructionWindowSize(300) + .searchWindowSize(40) + .svsEpsilon(0.02) + .trainingThreshold(20000) + .build(); + + assertThat(field.getName()).isEqualTo("embedding"); + assertThat(field.getDimensions()).isEqualTo(768); + assertThat(field.getAlgorithm()).isEqualTo(VectorField.Algorithm.SVS_VAMANA); + assertThat(field.getDataType()).isEqualTo(VectorField.VectorDataType.FLOAT16); + assertThat(field.getCompression()).isEqualTo(VectorField.CompressionType.LeanVec4x8); + assertThat(field.getReduce()).isEqualTo(384); + assertThat(field.getGraphMaxDegree()).isEqualTo(50); + assertThat(field.getConstructionWindowSize()).isEqualTo(300); + assertThat(field.getSearchWindowSize()).isEqualTo(40); + assertThat(field.getSvsEpsilon()).isEqualTo(0.02); + assertThat(field.getTrainingThreshold()).isEqualTo(20000); + } + + @Test + @DisplayName("SVS field should convert to Jedis schema field with all attributes") + void testToJedisSchemaFieldSVS() { + VectorField field = + VectorField.builder() + .name("embedding") + .dimensions(768) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(384) + .graphMaxDegree(50) + .constructionWindowSize(300) + .searchWindowSize(40) + .svsEpsilon(0.02) + .trainingThreshold(20000) + .build(); + + SchemaField jedisField = field.toJedisSchemaField(); + + assertThat(jedisField).isNotNull(); + assertThat(jedisField).isInstanceOf(redis.clients.jedis.search.schemafields.VectorField.class); + + // Access attributes via reflection or ensure they're set correctly + redis.clients.jedis.search.schemafields.VectorField vectorField = + (redis.clients.jedis.search.schemafields.VectorField) jedisField; + + // Verify field name (convert to String to avoid type mismatch) + assertThat(vectorField.getFieldName().toString()).isEqualTo("embedding"); + } +} diff --git a/core/src/test/java/com/redis/vl/schema/SVSVamanaIntegrationTest.java b/core/src/test/java/com/redis/vl/schema/SVSVamanaIntegrationTest.java new file mode 100644 index 0000000..5c86166 --- /dev/null +++ b/core/src/test/java/com/redis/vl/schema/SVSVamanaIntegrationTest.java @@ -0,0 +1,270 @@ +package com.redis.vl.schema; + +import static org.assertj.core.api.Assertions.*; + +import com.redis.vl.BaseSVSIntegrationTest; +import com.redis.vl.index.SearchIndex; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.*; +import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm; + +/** + * Integration tests for SVS-VAMANA vector indexing algorithm (#404). + * + *
Tests cover: + * + *
Requirements: Redis ≥ 8.2.0, RediSearch ≥ 2.8.10 or SearchLight ≥ 2.8.10 + * + *
Uses redis-stack:latest container with Redis 8.2.0+ support + * + *
Python reference: tests/integration/test_svs_vamana.py + */ +@Tag("integration") +@Tag("svs") +@DisplayName("SVS-VAMANA Integration Tests") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SVSVamanaIntegrationTest extends BaseSVSIntegrationTest { + + private SearchIndex index; + private static final String INDEX_PREFIX = "svs_test"; + + @AfterEach + void tearDown() { + if (index != null) { + try { + index.delete(true); + } catch (Exception e) { + // Ignore cleanup errors + } + } + } + + @Test + @Order(1) + @DisplayName("Should create SVS index with minimal parameters") + void testCreateSVSIndexMinimal() { + VectorField vectorField = + VectorField.builder() + .name("embedding") + .dimensions(128) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .searchWindowSize(30) + .build(); + + IndexSchema schema = IndexSchema.builder().name("svs_minimal").prefix(INDEX_PREFIX).build(); + + schema.addField(vectorField); + + index = new SearchIndex(schema, unifiedJedis); + + // This will throw if Redis doesn't support SVS-VAMANA or if config is invalid + assertThatCode(() -> index.create(true)).doesNotThrowAnyException(); + + // Verify index exists + assertThat(index.exists()).isTrue(); + } + + @Test + @Order(2) + @DisplayName("Should create SVS index with LVQ4 compression") + void testCreateSVSIndexWithLVQ4() { + VectorField vectorField = + VectorField.builder() + .name("embedding") + .dimensions(128) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .compression(VectorField.CompressionType.LVQ4) + .searchWindowSize(40) + .graphMaxDegree(50) + .build(); + + IndexSchema schema = + IndexSchema.builder().name("svs_lvq4").prefix(INDEX_PREFIX + "_lvq4").build(); + + schema.addField(vectorField); + + index = new SearchIndex(schema, unifiedJedis); + + assertThatCode(() -> index.create(true)).doesNotThrowAnyException(); + assertThat(index.exists()).isTrue(); + } + + @Test + @Order(3) + @DisplayName("Should create SVS index with LeanVec and reduce") + void testCreateSVSIndexWithLeanVec() { + VectorField vectorField = + VectorField.builder() + .name("embedding") + .dimensions(256) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(128) // Reduce from 256 to 128 dimensions + .searchWindowSize(30) + .build(); + + IndexSchema schema = + IndexSchema.builder().name("svs_leanvec").prefix(INDEX_PREFIX + "_leanvec").build(); + + schema.addField(vectorField); + + index = new SearchIndex(schema, unifiedJedis); + + assertThatCode(() -> index.create(true)).doesNotThrowAnyException(); + assertThat(index.exists()).isTrue(); + } + + @Test + @Order(4) + @DisplayName("Should create SVS index with FLOAT16 data type") + void testSVSIndexWithFloat16() { + VectorField vectorField = + VectorField.builder() + .name("embedding") + .dimensions(128) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .searchWindowSize(30) + .build(); + + IndexSchema schema = + IndexSchema.builder().name("svs_float16").prefix(INDEX_PREFIX + "_float16").build(); + + schema.addField(vectorField); + + index = new SearchIndex(schema, unifiedJedis); + + assertThatCode(() -> index.create(true)).doesNotThrowAnyException(); + assertThat(index.exists()).isTrue(); + } + + @Test + @Order(5) + @DisplayName("Should create SVS index with all parameters") + void testSVSIndexWithAllParameters() { + VectorField vectorField = + VectorField.builder() + .name("embedding") + .dimensions(256) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT16) + .compression(VectorField.CompressionType.LeanVec4x8) + .reduce(128) + .graphMaxDegree(50) + .constructionWindowSize(300) + .searchWindowSize(40) + .svsEpsilon(0.02) + .trainingThreshold(20000) + .build(); + + IndexSchema schema = + IndexSchema.builder().name("svs_full_params").prefix(INDEX_PREFIX + "_full").build(); + + schema.addField(vectorField); + + index = new SearchIndex(schema, unifiedJedis); + + assertThatCode(() -> index.create(true)).doesNotThrowAnyException(); + assertThat(index.exists()).isTrue(); + } + + @Test + @Order(6) + @DisplayName("Should reject SVS index with invalid data type") + void testSVSIndexRejectsInvalidDatatype() { + // Attempt to create SVS index with FLOAT64 - should fail at build time + assertThatThrownBy( + () -> + VectorField.builder() + .name("embedding") + .dimensions(128) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT64) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SVS-VAMANA only supports FLOAT16 and FLOAT32"); + } + + @Test + @Order(7) + @DisplayName("Should load vectors and perform search on SVS index") + void testSVSIndexLoadAndSearch() { + // Create SVS index + VectorField vectorField = + VectorField.builder() + .name("embedding") + .dimensions(3) + .algorithm(VectorAlgorithm.SVS_VAMANA) + .dataType(VectorField.VectorDataType.FLOAT32) + .distanceMetric(VectorField.DistanceMetric.COSINE) + .searchWindowSize(30) + .build(); + + TextField titleField = TextField.builder().name("title").build(); + + IndexSchema schema = + IndexSchema.builder().name("svs_search_test").prefix(INDEX_PREFIX + "_search").build(); + + schema.addField(vectorField); + schema.addField(titleField); + + index = new SearchIndex(schema, unifiedJedis); + index.create(true); + + // Load sample vectors + List