diff --git a/src/NRedisStack/Search/FieldName.cs b/src/NRedisStack/Search/FieldName.cs index eee3ca7e..9361facd 100644 --- a/src/NRedisStack/Search/FieldName.cs +++ b/src/NRedisStack/Search/FieldName.cs @@ -36,5 +36,7 @@ public FieldName As(string attribute) this.Alias = attribute; return this; } + + public static implicit operator FieldName(string name) => Of(name); } } \ No newline at end of file diff --git a/src/NRedisStack/Search/Schema.cs b/src/NRedisStack/Search/Schema.cs index 96bb920b..a5555948 100644 --- a/src/NRedisStack/Search/Schema.cs +++ b/src/NRedisStack/Search/Schema.cs @@ -1,4 +1,5 @@ -using NRedisStack.Search.Literals; +using System.Diagnostics; +using NRedisStack.Search.Literals; using static NRedisStack.Search.Schema.GeoShapeField; using static NRedisStack.Search.Schema.VectorField; @@ -260,10 +261,12 @@ public class VectorField : Field public enum VectorAlgo { FLAT, - HNSW + HNSW, + SVS_VAMANA, } public VectorAlgo Algorithm { get; } + public Dictionary? Attributes { get; } public bool MissingIndex { get; } @@ -275,25 +278,340 @@ public VectorField(FieldName name, VectorAlgo algorithm, Dictionary? attributes, bool missingIndex) + : this(name, algorithm, attributes, missingIndex) + { + Type = type; + Dimensions = dimensions; + DistanceMetric = distanceMetric; + } + public VectorField(string name, VectorAlgo algorithm, Dictionary? attributes = null, bool missingIndex = false) : this(FieldName.Of(name), algorithm, attributes, missingIndex) { } internal override void AddFieldTypeArgs(List args) { - args.Add(Algorithm.ToString()); - if (Attributes != null) + args.Add(Algorithm switch { - args.Add(Attributes.Count() * 2); - - foreach (var attribute in Attributes) + VectorAlgo.FLAT => "FLAT", + VectorAlgo.HNSW => "HNSW", + VectorAlgo.SVS_VAMANA => "SVS-VAMANA", + _ => Algorithm.ToString(), // fallback + }); + var attribs = Attributes; + var count = DirectAttributeCount + (attribs?.Count ?? 0); + args.Add(count * 2); +#if DEBUG + int before = args.Count; +#endif + AddDirectAttributes(args); + if (attribs != null) + { + foreach (var attribute in attribs) { args.Add(attribute.Key); args.Add(attribute.Value); } } +#if DEBUG + Debug.Assert(args.Count == (before + (count * 2)), "Arg count mismatch; check " + nameof(AddDirectAttributes) + " vs " + nameof(DirectAttributeCount)); +#endif if (MissingIndex) args.Add(FieldOptions.INDEXMISSING); } + + // attributes handled inside the type-system (rather than via Attributes) + + /// + /// The width, or number of dimensions, of the vector embeddings stored in this field. In other words, the number of floating point elements comprising the vector. DIM must be a positive integer. The vector used to query this field must have the exact dimensions as the field itself. + /// + public int Dimensions { get; set; } = 0; + public new VectorType Type { get; set; } = VectorType.NotSpecified; + public VectorDistanceMetric DistanceMetric { get; set; } = VectorDistanceMetric.NotSpecified; + + internal virtual int DirectAttributeCount + => (Dimensions == 0 ? 0 : 1) + + (Type == VectorType.NotSpecified ? 0 : 1) + + (DistanceMetric == VectorDistanceMetric.NotSpecified ? 0 : 1); + + internal virtual void AddDirectAttributes(List args) + { + if (Dimensions != 0) + { + args.Add("DIM"); + args.Add(Dimensions); + } + if (Type != VectorType.NotSpecified) + { + args.Add("TYPE"); + args.Add(Type switch + { + VectorType.FLOAT32 => "FLOAT32", + VectorType.FLOAT64 => "FLOAT64", + VectorType.BFLOAT16 => "BFLOAT16", + VectorType.FLOAT16 => "FLOAT16", + _ => Type.ToString(), // fallback + }); + } + + if (DistanceMetric != VectorDistanceMetric.NotSpecified) + { + args.Add("DISTANCE_METRIC"); + args.Add(DistanceMetric switch + { + VectorDistanceMetric.EuclideanDistance => "L2", + VectorDistanceMetric.InnerProduct => "IP", + VectorDistanceMetric.CosineDistance => "COSINE", + _ => DistanceMetric.ToString(), // fallback + }); + } + } + + /// + /// The storage type for a vector field. + /// + public enum VectorType + { + /// + /// Not specified, or specified via . + /// + NotSpecified = 0, + /// + /// 32-bit floating point. + /// + FLOAT32 = 1, + /// + /// 64-bit floating point. + /// + FLOAT64 = 2, + /// + /// 16-bit "brain" floating point + /// + BFLOAT16 = 3, // requires v2.10 or later. + /// + /// 16-bit floating point. + /// + FLOAT16 = 4, // requires v2.10 or later. + } + + /// + /// The distance metric to use for vector similarity search. + /// + public enum VectorDistanceMetric + { + /// + /// Not specified, or specified via . + /// + NotSpecified = 0, + /// + /// Euclidean distance between two vectors - this corresponds to the L2 option in Redis. + /// + EuclideanDistance = 1, + /// + /// Inner product of two vectors - this corresponds to the IP option in Redis. + /// + InnerProduct = 2, + /// + /// Cosine distance of two vectors - this corresponds to the COSINE option in Redis. + /// + CosineDistance = 3, + } + + /// + /// The distance metric to use for vector similarity search. + /// + public enum VectorCompressionAlgorithm + { + /// + /// Not specified, or specified via . + /// + NotSpecified = 0, + LVQ8 = 1, + LVQ4 = 2, + LVQ4x4 = 3, + LVQ4x8 = 4, + LeanVec4x8 = 5, + LeanVec8x8 = 6, + } + } + + + /// + /// A that uses the algorithm. + /// + public class FlatVectorField : VectorField + { + public FlatVectorField(FieldName name, + VectorType type, int dimensions, VectorDistanceMetric distanceMetric, + Dictionary? attributes = null, bool missingIndex = false) + : base(name, VectorAlgo.FLAT, type, dimensions, distanceMetric, attributes, missingIndex) + { + } + } + + /// + /// A that uses the algorithm. + /// + public class HnswVectorField : VectorField + { + public HnswVectorField(FieldName name, + VectorType type, int dimensions, VectorDistanceMetric distanceMetric, + Dictionary? attributes = null, bool missingIndex = false) + : base(name, VectorAlgo.HNSW, type, dimensions, distanceMetric, attributes, missingIndex) + { + } + + // optional attributes + /// + /// "M"; Max number of outgoing edges (connections) for each node in a graph layer. On layer zero, the max number of connections will be 2 * M. Higher values increase accuracy, but also increase memory usage and index build time. The default is 16. + /// + public int MaxOutgoingConnections { get; set; } = DEFAULT_M; + + /// + /// "EF_CONSTRUCTION"; Max number of connected neighbors to consider during graph building. Higher values increase accuracy, but also increase index build time. The default is 200. + /// + public int MaxConnectedNeighbors { get; set; } = DEFAULT_EF_CONSTRUCTION; + + /// + /// "EF_RUNTIME"; Max top candidates during KNN search. Higher values increase accuracy, but also increase search latency. The default is 10. + /// + public int MaxTopCandidates { get; set; } = DEFAULT_EF_RUNTIME; + + /// + /// "EPSILON"; Relative factor that sets the boundaries in which a range query may search for candidates. That is, vector candidates whose distance from the query vector is radius * (1 + EPSILON) are potentially scanned, allowing more extensive search and more accurate results, at the expense of run time. The default is 0.01. + /// + public double BoundaryFactor { get; set; } = DEFAULT_EPSILON; + + internal const int DEFAULT_M = 16, DEFAULT_EF_CONSTRUCTION = 200, DEFAULT_EF_RUNTIME = 10; + internal const double DEFAULT_EPSILON = 0.01; + internal override int DirectAttributeCount + => base.DirectAttributeCount + + (MaxOutgoingConnections == DEFAULT_M ? 0 : 1) + + (MaxConnectedNeighbors == DEFAULT_EF_CONSTRUCTION ? 0 : 1) + + (MaxTopCandidates == DEFAULT_EF_RUNTIME ? 0 : 1) + // ReSharper disable once CompareOfFloatsByEqualityOperator + + (BoundaryFactor == DEFAULT_EPSILON ? 0 : 1); + + internal override void AddDirectAttributes(List args) + { + base.AddDirectAttributes(args); + if (MaxOutgoingConnections != DEFAULT_M) + { + args.Add("M"); + args.Add(MaxOutgoingConnections); + } + if (MaxConnectedNeighbors != DEFAULT_EF_CONSTRUCTION) + { + args.Add("EF_CONSTRUCTION"); + args.Add(MaxConnectedNeighbors); + } + if (MaxTopCandidates != DEFAULT_EF_RUNTIME) + { + args.Add("EF_RUNTIME"); + args.Add(MaxTopCandidates); + } + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (BoundaryFactor != DEFAULT_EPSILON) + { + args.Add("EPSILON"); + args.Add(BoundaryFactor); + } + } } + + /// + /// A that uses the algorithm. + /// + public class SvsVanamaVectorField : VectorField + { + public SvsVanamaVectorField(FieldName name, + VectorType type, int dimensions, VectorDistanceMetric distanceMetric, + Dictionary? attributes = null, bool missingIndex = false) + : base(name, VectorAlgo.SVS_VAMANA, type, dimensions, distanceMetric, attributes, missingIndex) + { + } + + public VectorCompressionAlgorithm CompressionAlgorithm { get; set; } = VectorCompressionAlgorithm.NotSpecified; + + internal override int DirectAttributeCount + => (CompressionAlgorithm == VectorCompressionAlgorithm.NotSpecified ? 0 : 1) + + base.DirectAttributeCount + + (ConstructionWindowSize == DEFAULT_CONSTRUCTION_WINDOW_SIZE ? 0 : 1) + + (GraphMaxDegree == DEFAULT_GRAPH_MAX_DEGREE ? 0 : 1) + + (SearchWindowSize == DEFAULT_SEARCH_WINDOW_SIZE ? 0 : 1) + // ReSharper disable once CompareOfFloatsByEqualityOperator + + (RangeSearchApproximationFactor == DEFAULT_EPSILON ? 0 : 1) + + (TrainingThreshold == 0 ? 0 : 1) + + (ReducedDimensions == 0 ? 0 : 1); + + internal override void AddDirectAttributes(List args) + { + if (CompressionAlgorithm != VectorCompressionAlgorithm.NotSpecified) + { + args.Add("COMPRESSION"); + args.Add(CompressionAlgorithm switch + { + VectorCompressionAlgorithm.LVQ8 => "LVQ8", + VectorCompressionAlgorithm.LVQ4 => "LVQ4", + VectorCompressionAlgorithm.LVQ4x4 => "LVQ4x4", + VectorCompressionAlgorithm.LVQ4x8 => "LVQ4x8", + VectorCompressionAlgorithm.LeanVec4x8 => "LeanVec4x8", + VectorCompressionAlgorithm.LeanVec8x8 => "LeanVec8x8", + _ => CompressionAlgorithm.ToString(), // fallback + }); + } + base.AddDirectAttributes(args); + if (ConstructionWindowSize != DEFAULT_CONSTRUCTION_WINDOW_SIZE) + { + args.Add("CONSTRUCTION_WINDOW_SIZE"); + args.Add(ConstructionWindowSize); + } + if (GraphMaxDegree != DEFAULT_GRAPH_MAX_DEGREE) + { + args.Add("GRAPH_MAX_DEGREE"); + args.Add(GraphMaxDegree); + } + if (SearchWindowSize != DEFAULT_SEARCH_WINDOW_SIZE) + { + args.Add("SEARCH_WINDOW_SIZE"); + args.Add(SearchWindowSize); + } + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (RangeSearchApproximationFactor != DEFAULT_EPSILON) + { + args.Add("EPSILON"); + args.Add(RangeSearchApproximationFactor); + } + if (TrainingThreshold != 0) + { + args.Add("TRAINING_THRESHOLD"); + args.Add(TrainingThreshold); + } + if (ReducedDimensions != 0) + { + args.Add("REDUCE"); + args.Add(ReducedDimensions); + } + } + + public int ConstructionWindowSize { get; set; } = DEFAULT_CONSTRUCTION_WINDOW_SIZE; + public int GraphMaxDegree { get; set; } = DEFAULT_GRAPH_MAX_DEGREE; + public int SearchWindowSize { get; set; } = DEFAULT_SEARCH_WINDOW_SIZE; + public double RangeSearchApproximationFactor { get; set; } = DEFAULT_EPSILON; + /// + /// Number of vectors after which training is triggered; defaults to 10 * DEFAULT_BLOCK_SIZE (or the provided value, limited to 100 * DEFAULT_BLOCK_SIZE) where DEFAULT_BLOCK_SIZE = 1024 + /// + public int TrainingThreshold { get; set; } + /// + /// The dimension used when using LeanVec compression for dimensionality reduction; defaults to dim/2 (applicable only with compression of type LeanVec, should always be < dim) + /// + public int ReducedDimensions { get; set; } + + internal const int DEFAULT_CONSTRUCTION_WINDOW_SIZE = 200, DEFAULT_GRAPH_MAX_DEGREE = 32, DEFAULT_SEARCH_WINDOW_SIZE = 10; + internal const double DEFAULT_EPSILON = 0.01; + } + public List Fields { get; } = new List(); /// @@ -528,5 +846,61 @@ public Schema AddVectorField(string name, VectorAlgo algorithm, Dictionary + /// Add a vector to the schema. + /// + public Schema AddFlatVectorField(FieldName name, VectorType type, int dimensions, VectorDistanceMetric distanceMetric, Dictionary? attributes = null, bool missingIndex = false) + { + Fields.Add(new FlatVectorField(name, type, dimensions, distanceMetric, attributes, missingIndex)); + return this; + } + + /// + /// Add a vector to the schema. + /// + public Schema AddHnswVectorField(FieldName name, VectorType type, int dimensions, VectorDistanceMetric distanceMetric, + int maxOutgoingConnections = HnswVectorField.DEFAULT_M, + int maxConnectedNeighbors = HnswVectorField.DEFAULT_EF_CONSTRUCTION, + int maxTopCandidates = HnswVectorField.DEFAULT_EF_RUNTIME, + double boundaryFactor = HnswVectorField.DEFAULT_EPSILON, + Dictionary? attributes = null, bool missingIndex = false) + { + Fields.Add(new HnswVectorField(name, type, dimensions, distanceMetric, attributes, missingIndex) + { + MaxOutgoingConnections = maxOutgoingConnections, + MaxConnectedNeighbors = maxConnectedNeighbors, + MaxTopCandidates = maxTopCandidates, + BoundaryFactor = boundaryFactor, + }); + return this; + } + + /// + /// Add a vector to the schema. + /// + /// Note that is only applicable when using LeanVec compression. + public Schema AddSvsVanamaVectorField(FieldName name, VectorType type, int dimensions, VectorDistanceMetric distanceMetric, + VectorCompressionAlgorithm compressionAlgorithm = VectorCompressionAlgorithm.NotSpecified, + int constructionWindowSize = SvsVanamaVectorField.DEFAULT_CONSTRUCTION_WINDOW_SIZE, + int graphMaxDegree = SvsVanamaVectorField.DEFAULT_GRAPH_MAX_DEGREE, + int searchWindowSize = SvsVanamaVectorField.DEFAULT_SEARCH_WINDOW_SIZE, + double rangeSearchApproximationFactor = SvsVanamaVectorField.DEFAULT_EPSILON, + int trainingThreshold = 0, + int reducedDimensions = 0, + Dictionary? attributes = null, bool missingIndex = false) + { + Fields.Add(new SvsVanamaVectorField(name, type, dimensions, distanceMetric, attributes, missingIndex) + { + CompressionAlgorithm = compressionAlgorithm, + ConstructionWindowSize = constructionWindowSize, + GraphMaxDegree = graphMaxDegree, + SearchWindowSize = searchWindowSize, + RangeSearchApproximationFactor = rangeSearchApproximationFactor, + TrainingThreshold = trainingThreshold, + ReducedDimensions = reducedDimensions, + }); + return this; + } } } diff --git a/src/NRedisStack/SerializedCommand.cs b/src/NRedisStack/SerializedCommand.cs index 3d7cb404..34cb8ac7 100644 --- a/src/NRedisStack/SerializedCommand.cs +++ b/src/NRedisStack/SerializedCommand.cs @@ -16,5 +16,10 @@ public SerializedCommand(string command, ICollection args) Command = command; Args = args.ToArray(); } + + /// + public override string ToString() => Args is { Length: > 0 } + ? (Command + " " + string.Join(" ", Args)) + : Command; } } \ No newline at end of file diff --git a/tests/NRedisStack.Tests/AbstractNRedisStackTest.cs b/tests/NRedisStack.Tests/AbstractNRedisStackTest.cs index 77ec122c..054fe9ff 100644 --- a/tests/NRedisStack.Tests/AbstractNRedisStackTest.cs +++ b/tests/NRedisStack.Tests/AbstractNRedisStackTest.cs @@ -3,11 +3,16 @@ using System.Runtime.CompilerServices; using StackExchange.Redis; using Xunit; +using Xunit.Abstractions; + namespace NRedisStack.Tests; public abstract class AbstractNRedisStackTest : IClassFixture, IAsyncLifetime { protected internal EndpointsFixture EndpointsFixture; + private readonly ITestOutputHelper? log; + + protected void Log(string message) => log?.WriteLine(message); // TODO: DISH overload in net9? net10? protected readonly ConfigurationOptions DefaultConnectionConfig = new() { @@ -16,9 +21,10 @@ public abstract class AbstractNRedisStackTest : IClassFixture, AllowAdmin = true, }; - protected internal AbstractNRedisStackTest(EndpointsFixture endpointsFixture) + protected internal AbstractNRedisStackTest(EndpointsFixture endpointsFixture, ITestOutputHelper? log = null) { this.EndpointsFixture = endpointsFixture; + this.log = log; } protected ConnectionMultiplexer GetConnection(string endpointId = EndpointsFixture.Env.Standalone) => EndpointsFixture.GetConnectionById(this.DefaultConnectionConfig, endpointId); diff --git a/tests/NRedisStack.Tests/Search/IndexCreationTests.cs b/tests/NRedisStack.Tests/Search/IndexCreationTests.cs index 789bc778..a2c16468 100644 --- a/tests/NRedisStack.Tests/Search/IndexCreationTests.cs +++ b/tests/NRedisStack.Tests/Search/IndexCreationTests.cs @@ -1,8 +1,10 @@ +using System.Runtime.InteropServices; using StackExchange.Redis; using NRedisStack.Search; using NRedisStack.RedisStackCommands; using Xunit; using NetTopologySuite.Geometries; +using Xunit.Abstractions; namespace NRedisStack.Tests.Search; @@ -10,10 +12,11 @@ public class IndexCreationTests : AbstractNRedisStackTest, IDisposable { private readonly string index = "MISSING_EMPTY_INDEX"; - public IndexCreationTests(EndpointsFixture endpointsFixture) : base(endpointsFixture) + public IndexCreationTests(EndpointsFixture endpointsFixture, ITestOutputHelper log) : base(endpointsFixture, log) { } + private readonly ITestOutputHelper log; private static readonly string INDEXMISSING = "INDEXMISSING"; private static readonly string INDEXEMPTY = "INDEXEMPTY"; private static readonly string SORTABLE = "SORTABLE"; @@ -38,7 +41,7 @@ public void TestMissingEmptyFieldCommandArgs() "numeric1","NUMERIC", INDEXMISSING, "geo1","GEO", INDEXMISSING, "geoshape1","GEOSHAPE", "FLAT", INDEXMISSING, - "vector1","VECTOR","FLAT", INDEXMISSING}; + "vector1","VECTOR","FLAT", 0, INDEXMISSING}; Assert.Equal(expectedArgs, cmd.Args); } @@ -144,6 +147,7 @@ public void TestCreateFloat16VectorField(string endpointId) ["DIM"] = "4", ["DISTANCE_METRIC"] = "L2", }); + Assert.True(ft.Create("idx", new FTCreateParams(), schema)); short[] vec1 = new short[] { 2, 1, 2, 2, 2 }; @@ -168,6 +172,74 @@ public void TestCreateFloat16VectorField(string endpointId) Assert.Equal(2, res.TotalResults); } + [SkipIfRedis(Comparison.LessThan, "8.1.240")] + [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] + public void TestCreate_Float16_Int32_VectorField_Svs(string endpointId) + { + IDatabase db = GetCleanDatabase(endpointId); + var ft = db.FT(2); + var schema = new Schema().AddSvsVanamaVectorField("v", Schema.VectorField.VectorType.FLOAT16, 5, + Schema.VectorField.VectorDistanceMetric.EuclideanDistance) + .AddSvsVanamaVectorField("v2", Schema.VectorField.VectorType.FLOAT32, 4, + Schema.VectorField.VectorDistanceMetric.EuclideanDistance); + + var cmd = SearchCommandBuilder.Create("idx", FTCreateParams.CreateParams(), schema).ToString(); + Log(cmd); + + Assert.True(ft.Create("idx", new FTCreateParams(), schema)); + + byte[] vec1ToBytes = MemoryMarshal.AsBytes(stackalloc short[] { 2, 1, 2, 2, 2 }).ToArray(); + byte[] vec2ToBytes = MemoryMarshal.AsBytes(stackalloc int[] { 1, 2, 2, 2 }).ToArray(); + + var entries = new HashEntry[] { new HashEntry("v", vec1ToBytes), new HashEntry("v2", vec2ToBytes) }; + db.HashSet("a", entries); + db.HashSet("b", entries); + db.HashSet("c", entries); + + var q = new Query("*=>[KNN 2 @v $vec]").ReturnFields("__v_score"); + var res = ft.Search("idx", q.AddParam("vec", vec1ToBytes)); + Assert.Equal(2, res.TotalResults); + + q = new Query("*=>[KNN 2 @v2 $vec]").ReturnFields("__v_score"); + res = ft.Search("idx", q.AddParam("vec", vec2ToBytes)); + Assert.Equal(2, res.TotalResults); + } + + [SkipIfRedis(Comparison.LessThan, "8.1.240")] + [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] + public void TestCreate_Float16_Int32_VectorField_Svs_WithCompression(string endpointId) + { + IDatabase db = GetCleanDatabase(endpointId); + var ft = db.FT(2); + var schema = new Schema().AddSvsVanamaVectorField("v", Schema.VectorField.VectorType.FLOAT16, 5, + Schema.VectorField.VectorDistanceMetric.EuclideanDistance, + reducedDimensions: 2, compressionAlgorithm: Schema.VectorField.VectorCompressionAlgorithm.LeanVec4x8) + .AddSvsVanamaVectorField("v2", Schema.VectorField.VectorType.FLOAT32, 4, + Schema.VectorField.VectorDistanceMetric.EuclideanDistance, + compressionAlgorithm: Schema.VectorField.VectorCompressionAlgorithm.LVQ4); + + var cmd = SearchCommandBuilder.Create("idx", FTCreateParams.CreateParams(), schema).ToString(); + Log(cmd); + + Assert.True(ft.Create("idx", new FTCreateParams(), schema)); + + byte[] vec1ToBytes = MemoryMarshal.AsBytes(stackalloc short[] { 2, 1, 2, 2, 2 }).ToArray(); + byte[] vec2ToBytes = MemoryMarshal.AsBytes(stackalloc int[] { 1, 2, 2, 2 }).ToArray(); + + var entries = new HashEntry[] { new HashEntry("v", vec1ToBytes), new HashEntry("v2", vec2ToBytes) }; + db.HashSet("a", entries); + db.HashSet("b", entries); + db.HashSet("c", entries); + + var q = new Query("*=>[KNN 2 @v $vec]").ReturnFields("__v_score"); + var res = ft.Search("idx", q.AddParam("vec", vec1ToBytes)); + Assert.Equal(2, res.TotalResults); + + q = new Query("*=>[KNN 2 @v2 $vec]").ReturnFields("__v_score"); + res = ft.Search("idx", q.AddParam("vec", vec2ToBytes)); + Assert.Equal(2, res.TotalResults); + } + [SkipIfRedis(Comparison.LessThan, "7.9.0")] [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] public void TestCreateInt8VectorField(string endpointId) @@ -282,5 +354,64 @@ public void TestCombiningMissingEmptySortableFields(string endpointId) Assert.Equal("hashWithMissingFields", result.Documents[0].Id); } + [Fact] + public void TestIndexingCreation_Default() + { + Schema sc = new Schema() + .AddFlatVectorField("vector1", Schema.VectorField.VectorType.FLOAT32, 2, + Schema.VectorField.VectorDistanceMetric.EuclideanDistance, missingIndex: true) + .AddHnswVectorField("vector2", Schema.VectorField.VectorType.FLOAT64, 3, + Schema.VectorField.VectorDistanceMetric.CosineDistance, missingIndex: false) + .AddSvsVanamaVectorField("vector3", Schema.VectorField.VectorType.FLOAT16, 4, + Schema.VectorField.VectorDistanceMetric.InnerProduct, missingIndex: true); + + var ftCreateParams = FTCreateParams.CreateParams(); + var cmd = SearchCommandBuilder.Create("IDX_NAME", ftCreateParams, sc).ToString(); + + Assert.Equal("FT.CREATE IDX_NAME SCHEMA vector1 VECTOR FLAT 6 DIM 2 TYPE FLOAT32 DISTANCE_METRIC L2 INDEXMISSING vector2 VECTOR HNSW 6 DIM 3 TYPE FLOAT64 DISTANCE_METRIC COSINE vector3 VECTOR SVS-VAMANA 6 DIM 4 TYPE FLOAT16 DISTANCE_METRIC IP INDEXMISSING", cmd); + } + + [Fact] + public void TestIndexingCreation_WithAttribs() + { + Schema sc = new Schema() + .AddFlatVectorField("vector1", Schema.VectorField.VectorType.NotSpecified, 0, + Schema.VectorField.VectorDistanceMetric.NotSpecified, missingIndex: true, attributes: new Dictionary() + { + ["TYPE"] = "FUT1", // some values not representable in the old API + ["DIM"] = "FUT2", + ["DISTANCE_METRIC"] = "FUT3", + ["NEW_FIELD"] = "NEW_VALUE", + }); + + var ftCreateParams = FTCreateParams.CreateParams(); + var cmd = SearchCommandBuilder.Create("IDX_NAME", ftCreateParams, sc).ToString(); + Assert.Equal("FT.CREATE IDX_NAME SCHEMA vector1 VECTOR FLAT 8 TYPE FUT1 DIM FUT2 DISTANCE_METRIC FUT3 NEW_FIELD NEW_VALUE INDEXMISSING", cmd); + } + + [Fact] + public void TestIndexingCreation_Custom_Everything() + { + Schema sc = new Schema() + .AddFlatVectorField("vector1", Schema.VectorField.VectorType.FLOAT32, 2, + Schema.VectorField.VectorDistanceMetric.EuclideanDistance, missingIndex: true) + .AddHnswVectorField("vector2", Schema.VectorField.VectorType.FLOAT64, 3, + Schema.VectorField.VectorDistanceMetric.CosineDistance, + maxOutgoingConnections: 10, maxConnectedNeighbors: 20, maxTopCandidates: 30, boundaryFactor: 0.7, + missingIndex: false) + .AddSvsVanamaVectorField("vector3", Schema.VectorField.VectorType.FLOAT16, 4, + Schema.VectorField.VectorDistanceMetric.InnerProduct, + compressionAlgorithm: Schema.VectorField.VectorCompressionAlgorithm.LeanVec4x8, + constructionWindowSize: 35, graphMaxDegree: 17, searchWindowSize: 30, rangeSearchApproximationFactor: 0.5, + trainingThreshold: 100, reducedDimensions: 50, + missingIndex: true); + + var ftCreateParams = FTCreateParams.CreateParams(); + var cmd = SearchCommandBuilder.Create("IDX_NAME", ftCreateParams, sc).ToString(); + + Assert.Equal("FT.CREATE IDX_NAME SCHEMA vector1 VECTOR FLAT 6 DIM 2 TYPE FLOAT32 DISTANCE_METRIC L2 INDEXMISSING " + + "vector2 VECTOR HNSW 14 DIM 3 TYPE FLOAT64 DISTANCE_METRIC COSINE M 10 EF_CONSTRUCTION 20 EF_RUNTIME 30 EPSILON 0.7 " + + "vector3 VECTOR SVS-VAMANA 20 COMPRESSION LeanVec4x8 DIM 4 TYPE FLOAT16 DISTANCE_METRIC IP CONSTRUCTION_WINDOW_SIZE 35 GRAPH_MAX_DEGREE 17 SEARCH_WINDOW_SIZE 30 EPSILON 0.5 TRAINING_THRESHOLD 100 REDUCE 50 INDEXMISSING", cmd); + } } \ No newline at end of file