diff --git a/Directory.Build.props b/Directory.Build.props index 80453fb9..b446cc4f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -23,7 +23,7 @@ true true true - $(NoWarn);CS1591 + $(NoWarn);CS1591;NRS001 $([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::get_Windows()))) diff --git a/NRedisStack.sln b/NRedisStack.sln index 045e7015..a03db832 100644 --- a/NRedisStack.sln +++ b/NRedisStack.sln @@ -15,8 +15,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{84D6210F Directory.Packages.props = Directory.Packages.props global.json = global.json version.json = version.json + tests\dockers\docker-compose.yml = tests\dockers\docker-compose.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs", "docs\docs.csproj", "{4A35FC3C-69BC-4BDB-A4B2-6BFD0DF215AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,5 +41,9 @@ Global {F14F6342-14A0-4DDD-AB05-C425B1AD8001}.Debug|Any CPU.Build.0 = Debug|Any CPU {F14F6342-14A0-4DDD-AB05-C425B1AD8001}.Release|Any CPU.ActiveCfg = Release|Any CPU {F14F6342-14A0-4DDD-AB05-C425B1AD8001}.Release|Any CPU.Build.0 = Release|Any CPU + {4A35FC3C-69BC-4BDB-A4B2-6BFD0DF215AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A35FC3C-69BC-4BDB-A4B2-6BFD0DF215AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A35FC3C-69BC-4BDB-A4B2-6BFD0DF215AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A35FC3C-69BC-4BDB-A4B2-6BFD0DF215AD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/docs/docs.csproj b/docs/docs.csproj new file mode 100644 index 00000000..b6848c2d --- /dev/null +++ b/docs/docs.csproj @@ -0,0 +1,7 @@ + + + + netstandard2.0 + false + + diff --git a/docs/exp/NRS001.md b/docs/exp/NRS001.md new file mode 100644 index 00000000..57dfba50 --- /dev/null +++ b/docs/exp/NRS001.md @@ -0,0 +1,22 @@ +Redis 8.4 is currently in preview and may be subject to change. + +*Hybrid Search* is a new feature in Redis 8.4 that allows you to search across multiple indexes and data types. + +The corresponding library feature must also be considered subject to change: + +1. Existing bindings may cease working correctly if the underlying server API changes. +2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time + or run-time breaks. + +While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress +this warning by adding the following to your `csproj` file: + +```xml +$(NoWarn);NRS001 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable NRS001 +``` diff --git a/src/NRedisStack/CoreCommands/Enums/SetInfoAttr.cs b/src/NRedisStack/CoreCommands/Enums/SetInfoAttr.cs index 16f10a9a..84e3c64d 100644 --- a/src/NRedisStack/CoreCommands/Enums/SetInfoAttr.cs +++ b/src/NRedisStack/CoreCommands/Enums/SetInfoAttr.cs @@ -1,4 +1,5 @@ namespace NRedisStack.Core; + public enum SetInfoAttr { /// diff --git a/src/NRedisStack/Experiments.cs b/src/NRedisStack/Experiments.cs new file mode 100644 index 00000000..38845d05 --- /dev/null +++ b/src/NRedisStack/Experiments.cs @@ -0,0 +1,40 @@ +namespace NRedisStack +{ + // [Experimental(Experiments.SomeFeature, UrlFormat = Experiments.UrlFormat)] + // where SomeFeature has the next label, for example "NRS042", and /docs/exp/NRS042.md exists + internal static class Experiments + { + public const string UrlFormat = "https://redis.github.io/NRedisStack/exp/"; + + // ReSharper disable once InconsistentNaming + public const string Server_8_4 = "NRS001"; + } +} + +#if !NET8_0_OR_GREATER +#pragma warning disable SA1403 +namespace System.Diagnostics.CodeAnalysis +#pragma warning restore SA1403 +{ + [AttributeUsage( + AttributeTargets.Assembly | + AttributeTargets.Module | + AttributeTargets.Class | + AttributeTargets.Struct | + AttributeTargets.Enum | + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Property | + AttributeTargets.Field | + AttributeTargets.Event | + AttributeTargets.Interface | + AttributeTargets.Delegate, + Inherited = false)] + internal sealed class ExperimentalAttribute(string diagnosticId) : Attribute + { + public string DiagnosticId { get; } = diagnosticId; + public string? UrlFormat { get; set; } + public string? Message { get; set; } + } +} +#endif \ No newline at end of file diff --git a/src/NRedisStack/NRedisStack.csproj b/src/NRedisStack/NRedisStack.csproj index 15da0637..4cab0a8f 100644 --- a/src/NRedisStack/NRedisStack.csproj +++ b/src/NRedisStack/NRedisStack.csproj @@ -15,6 +15,7 @@ + diff --git a/src/NRedisStack/OverloadResolutionPriorityAttribute.cs b/src/NRedisStack/OverloadResolutionPriorityAttribute.cs new file mode 100644 index 00000000..1b4d6c0a --- /dev/null +++ b/src/NRedisStack/OverloadResolutionPriorityAttribute.cs @@ -0,0 +1,10 @@ +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; + +#if !NET9_0_OR_GREATER +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +internal sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute +{ + public int Priority { get; } = priority; +} +#endif \ No newline at end of file diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Shipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Shipped.txt index e3cae9d9..a53931b5 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Shipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Shipped.txt @@ -1414,3 +1414,15 @@ static NRedisStack.Search.FieldName.implicit operator NRedisStack.Search.FieldNa NRedisStack.DataTypes.TimeStamp.Equals(NRedisStack.DataTypes.TimeStamp other) -> bool static NRedisStack.DataTypes.TimeStamp.operator ==(NRedisStack.DataTypes.TimeStamp left, NRedisStack.DataTypes.TimeStamp right) -> bool static NRedisStack.DataTypes.TimeStamp.operator !=(NRedisStack.DataTypes.TimeStamp left, NRedisStack.DataTypes.TimeStamp right) -> bool +NRedisStack.ISearchCommands.AggregateEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IEnumerable! +NRedisStack.ISearchCommands.CursorDel(NRedisStack.Search.AggregationResult! result) -> bool +NRedisStack.ISearchCommands.CursorRead(NRedisStack.Search.AggregationResult! result, int? count = null) -> NRedisStack.Search.AggregationResult! +NRedisStack.ISearchCommandsAsync.AggregateAsyncEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IAsyncEnumerable! +NRedisStack.ISearchCommandsAsync.CursorDelAsync(NRedisStack.Search.AggregationResult! result) -> System.Threading.Tasks.Task! +NRedisStack.ISearchCommandsAsync.CursorReadAsync(NRedisStack.Search.AggregationResult! result, int? count = null) -> System.Threading.Tasks.Task! +NRedisStack.SearchCommands.AggregateEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IEnumerable! +NRedisStack.SearchCommands.CursorDel(NRedisStack.Search.AggregationResult! result) -> bool +NRedisStack.SearchCommands.CursorRead(NRedisStack.Search.AggregationResult! result, int? count = null) -> NRedisStack.Search.AggregationResult! +NRedisStack.SearchCommandsAsync.AggregateAsyncEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IAsyncEnumerable! +NRedisStack.SearchCommandsAsync.CursorDelAsync(NRedisStack.Search.AggregationResult! result) -> System.Threading.Tasks.Task! +NRedisStack.SearchCommandsAsync.CursorReadAsync(NRedisStack.Search.AggregationResult! result, int? count = null) -> System.Threading.Tasks.Task! diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 00dcb3fb..59589c8c 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,13 +1,94 @@ -#nullable enable -NRedisStack.ISearchCommands.AggregateEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IEnumerable! -NRedisStack.ISearchCommands.CursorDel(NRedisStack.Search.AggregationResult! result) -> bool -NRedisStack.ISearchCommands.CursorRead(NRedisStack.Search.AggregationResult! result, int? count = null) -> NRedisStack.Search.AggregationResult! -NRedisStack.ISearchCommandsAsync.AggregateAsyncEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IAsyncEnumerable! -NRedisStack.ISearchCommandsAsync.CursorDelAsync(NRedisStack.Search.AggregationResult! result) -> System.Threading.Tasks.Task! -NRedisStack.ISearchCommandsAsync.CursorReadAsync(NRedisStack.Search.AggregationResult! result, int? count = null) -> System.Threading.Tasks.Task! -NRedisStack.SearchCommands.AggregateEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IEnumerable! -NRedisStack.SearchCommands.CursorDel(NRedisStack.Search.AggregationResult! result) -> bool -NRedisStack.SearchCommands.CursorRead(NRedisStack.Search.AggregationResult! result, int? count = null) -> NRedisStack.Search.AggregationResult! -NRedisStack.SearchCommandsAsync.AggregateAsyncEnumerable(string! index, NRedisStack.Search.AggregationRequest! query) -> System.Collections.Generic.IAsyncEnumerable! -NRedisStack.SearchCommandsAsync.CursorDelAsync(NRedisStack.Search.AggregationResult! result) -> System.Threading.Tasks.Task! -NRedisStack.SearchCommandsAsync.CursorReadAsync(NRedisStack.Search.AggregationResult! result, int? count = null) -> System.Threading.Tasks.Task! +NRedisStack.Search.Parameters +static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generic.IReadOnlyDictionary! +[NRS001]const NRedisStack.Search.HybridSearchQuery.Fields.Key = "@__key" -> string! +[NRS001]const NRedisStack.Search.HybridSearchQuery.Fields.Score = "@__score" -> string! +[NRS001]NRedisStack.ISearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query, System.Collections.Generic.IReadOnlyDictionary? parameters = null) -> NRedisStack.Search.HybridSearchResult! +[NRS001]NRedisStack.ISearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query, System.Collections.Generic.IReadOnlyDictionary? parameters = null) -> System.Threading.Tasks.Task! +[NRS001]NRedisStack.Search.ApplyExpression +[NRS001]NRedisStack.Search.ApplyExpression.Alias.get -> string? +[NRS001]NRedisStack.Search.ApplyExpression.ApplyExpression() -> void +[NRS001]NRedisStack.Search.ApplyExpression.ApplyExpression(string! expression, string? alias = null) -> void +[NRS001]NRedisStack.Search.ApplyExpression.Expression.get -> string! +[NRS001]NRedisStack.Search.HybridSearchQuery +[NRS001]NRedisStack.Search.HybridSearchQuery.AllowModification() -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Apply(NRedisStack.Search.ApplyExpression applyExpression) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Apply(params NRedisStack.Search.ApplyExpression[]! applyExpression) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Combine(NRedisStack.Search.HybridSearchQuery.Combiner! combiner) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Combiner +[NRS001]NRedisStack.Search.HybridSearchQuery.Combiner.Combiner() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.Fields +[NRS001]NRedisStack.Search.HybridSearchQuery.Filter(string! expression) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string! field) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.HybridSearchQuery() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.Limit(int offset, int count) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.NoSort() -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Reduce(NRedisStack.Search.Aggregation.Reducer! reducer) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Reduce(params NRedisStack.Search.Aggregation.Reducer![]! reducers) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.ReturnFields(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.ReturnFields(string! field) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Search(NRedisStack.Search.HybridSearchQuery.SearchConfig query) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.Query.get -> string! +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.ScoreAlias.get -> string? +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.Scorer.get -> NRedisStack.Search.Scorer? +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.SearchConfig() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.SearchConfig(string! query, NRedisStack.Search.Scorer? scorer = null, string? scoreAlias = null) -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.WithQuery(string! query) -> NRedisStack.Search.HybridSearchQuery.SearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.WithScoreAlias(string? alias) -> NRedisStack.Search.HybridSearchQuery.SearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.SearchConfig.WithScorer(NRedisStack.Search.Scorer? scorer) -> NRedisStack.Search.HybridSearchQuery.SearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.SortBy(NRedisStack.Search.Aggregation.SortedField! field) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.SortBy(params NRedisStack.Search.Aggregation.SortedField![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.SortBy(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.SortBy(string! field) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Timeout(System.TimeSpan timeout) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearch(NRedisStack.Search.HybridSearchQuery.VectorSearchConfig config) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearch(string! fieldName, NRedisStack.Search.VectorData! vectorData) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.FieldName.get -> string! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.Filter.get -> string? +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.Method.get -> NRedisStack.Search.VectorSearchMethod? +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.ScoreAlias.get -> string? +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorData.get -> NRedisStack.Search.VectorData? +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorSearchConfig() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorSearchConfig(string! fieldName, NRedisStack.Search.VectorData! vectorData, NRedisStack.Search.VectorSearchMethod? method = null, string? filter = null, string? scoreAlias = null) -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithFieldName(string! fieldName) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithFilter(string? filter) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithMethod(NRedisStack.Search.VectorSearchMethod? method) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithScoreAlias(string? scoreAlias) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithVectorData(NRedisStack.Search.VectorData! vectorData) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchResult +[NRS001]NRedisStack.Search.HybridSearchResult.ExecutionTime.get -> System.TimeSpan +[NRS001]NRedisStack.Search.HybridSearchResult.Results.get -> NRedisStack.Search.Document![]! +[NRS001]NRedisStack.Search.HybridSearchResult.TotalResults.get -> long +[NRS001]NRedisStack.Search.Scorer +[NRS001]NRedisStack.Search.VectorData +[NRS001]NRedisStack.Search.VectorSearchMethod +[NRS001]NRedisStack.SearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query, System.Collections.Generic.IReadOnlyDictionary? parameters = null) -> NRedisStack.Search.HybridSearchResult! +[NRS001]NRedisStack.SearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query, System.Collections.Generic.IReadOnlyDictionary? parameters = null) -> System.Threading.Tasks.Task! +[NRS001]override NRedisStack.Search.ApplyExpression.Equals(object? obj) -> bool +[NRS001]override NRedisStack.Search.ApplyExpression.GetHashCode() -> int +[NRS001]override NRedisStack.Search.ApplyExpression.ToString() -> string! +[NRS001]override NRedisStack.Search.HybridSearchQuery.Combiner.ToString() -> string! +[NRS001]override NRedisStack.Search.Scorer.ToString() -> string! +[NRS001]override NRedisStack.Search.VectorData.ToString() -> string! +[NRS001]override NRedisStack.Search.VectorSearchMethod.ToString() -> string! +[NRS001]static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.ApplyExpression(string! expression) -> NRedisStack.Search.ApplyExpression +[NRS001]static NRedisStack.Search.HybridSearchQuery.Combiner.Linear(double alpha = 0.3, double beta = 0.7) -> NRedisStack.Search.HybridSearchQuery.Combiner! +[NRS001]static NRedisStack.Search.HybridSearchQuery.Combiner.ReciprocalRankFusion(int? window = null, double? constant = null) -> NRedisStack.Search.HybridSearchQuery.Combiner! +[NRS001]static NRedisStack.Search.HybridSearchQuery.SearchConfig.implicit operator NRedisStack.Search.HybridSearchQuery.SearchConfig(string! query) -> NRedisStack.Search.HybridSearchQuery.SearchConfig +[NRS001]static NRedisStack.Search.Scorer.BM25Std.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.Scorer.BM25StdNorm.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.Scorer.DisMax.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.Scorer.DocScore.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.Scorer.Hamming.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.Scorer.TfIdf.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.Scorer.TfIdfDocNorm.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.VectorData.Create(System.ReadOnlyMemory vector) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorData.implicit operator NRedisStack.Search.VectorData!(float[]! data) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorData.implicit operator NRedisStack.Search.VectorData!(string! name) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorData.implicit operator NRedisStack.Search.VectorData!(System.ReadOnlyMemory vector) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorData.Parameter(string! name) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorData.Raw(System.ReadOnlyMemory bytes) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorSearchMethod.NearestNeighbour(int count = 10, int? maxTopCandidates = null, string? distanceAlias = null) -> NRedisStack.Search.VectorSearchMethod! +[NRS001]static NRedisStack.Search.VectorSearchMethod.Range(double radius, double? epsilon = null, string? distanceAlias = null) -> NRedisStack.Search.VectorSearchMethod! \ No newline at end of file diff --git a/src/NRedisStack/Search/AggregationRequest.cs b/src/NRedisStack/Search/AggregationRequest.cs index 8cac4f77..46b771cb 100644 --- a/src/NRedisStack/Search/AggregationRequest.cs +++ b/src/NRedisStack/Search/AggregationRequest.cs @@ -2,6 +2,7 @@ using NRedisStack.Search.Literals; namespace NRedisStack.Search; + public class AggregationRequest : IDialectAwareParam { private readonly List args = []; // Check if Readonly diff --git a/src/NRedisStack/Search/ApplyExpression.cs b/src/NRedisStack/Search/ApplyExpression.cs new file mode 100644 index 00000000..a2fd7361 --- /dev/null +++ b/src/NRedisStack/Search/ApplyExpression.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NRedisStack.Search; + +/// +/// Represents an APPLY expression in an aggregation query. +/// +/// The expression to apply. +/// The alias for the expression in the results. +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public readonly struct ApplyExpression(string expression, string? alias = null) +{ + public string Expression { get; } = expression; + public string? Alias { get; } = alias; + public override string ToString() => Expression; + public override int GetHashCode() => (Expression?.GetHashCode() ?? 0) ^ (Alias?.GetHashCode() ?? 0); + + public override bool Equals(object? obj) => obj is ApplyExpression other && + (Expression == other.Expression && + Alias == other.Alias); + + public static implicit operator ApplyExpression(string expression) => new(expression); +} \ No newline at end of file diff --git a/src/NRedisStack/Search/Document.cs b/src/NRedisStack/Search/Document.cs index 80f6da46..a20c1a97 100644 --- a/src/NRedisStack/Search/Document.cs +++ b/src/NRedisStack/Search/Document.cs @@ -50,6 +50,54 @@ public static Document Load(string id, double score, byte[]? payload, RedisValue return ret; } + internal static Document Load(RedisResult src) // used from HybridSearch + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (src is null || src.IsNull || src.Length < 0) return null!; + + var fields = src.ToArray(); + string id = ""; + double score = double.NaN; + var fieldCount = fields.Length / 2; + for (int i = 0; i < fieldCount; i++) + { + var key = fields[2 * i]; + if (key.Resp2Type == ResultType.BulkString && !key.IsNull) + { + var blob = (byte[])key!; + switch (blob.Length) + { + case 5 when "__key"u8.SequenceEqual(blob): + id = fields[(2 * i) + 1].ToString(); + break; + case 7 when "__score"u8.SequenceEqual(blob): + score = (double)fields[(2 * i) + 1]; + break; + } + } + } + Document doc = new(id, score, null); + for (int i = 0; i < fieldCount; i++) + { + var key = fields[2 * i]; + if (key.Resp2Type == ResultType.BulkString && !key.IsNull) + { + var blob = (byte[])key!; + switch (blob.Length) + { + case 5 when "__key"u8.SequenceEqual(blob): + case 7 when "__score"u8.SequenceEqual(blob): + break; // skip, already parsed + default: + doc[key.ToString()] = (RedisValue)fields[(2 * i) + 1]; + break; + } + } + } + + return doc; + } + public static Document Load(string id, double score, byte[]? payload, RedisValue[]? fields, string[]? scoreExplained) { Document ret = Load(id, score, payload, fields); diff --git a/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs b/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs new file mode 100644 index 00000000..6e7994b9 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs @@ -0,0 +1,110 @@ +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + public abstract class Combiner + { + internal abstract string Method { get; } + + /// + public override string ToString() => Method; + + public static Combiner ReciprocalRankFusion(int? window = null, double? constant = null) + => ReciprocalRankFusionCombiner.Create(window, constant); + + public static Combiner Linear(double alpha = LinearCombiner.DEFAULT_ALPHA, double beta = LinearCombiner.DEFAULT_BETA) + => LinearCombiner.Create(alpha, beta); + + internal abstract int GetOwnArgsCount(); + internal abstract void AddOwnArgs(List args, int limit); + + private sealed class ReciprocalRankFusionCombiner : Combiner + { + private readonly int? _window; + private readonly double? _constant; + + private ReciprocalRankFusionCombiner(int? window, double? constant) + { + _window = window; + _constant = constant; + } + + internal static ReciprocalRankFusionCombiner? s_Default; + + internal static ReciprocalRankFusionCombiner Create(int? window, double? constant) + => window is null & constant is null + ? (s_Default ??= new ReciprocalRankFusionCombiner(null, null)) + : new(window, constant); + + internal override string Method => "RRF"; + public override string ToString() => $"{Method} {_window} {_constant}"; + + internal override int GetOwnArgsCount() + { + int count = 4; + if (_constant is not null) count += 2; + return count; + } + + private static readonly object BoxedDefaultWindow = 20; + + internal override void AddOwnArgs(List args, int limit) + { + args.Add(Method); + int tokens = 2; + if (_constant is not null) tokens += 2; + args.Add(tokens); + args.Add("WINDOW"); + args.Add(_window ?? (limit > 0 ? limit : BoxedDefaultWindow)); + + if (_constant is not null) + { + args.Add("CONSTANT"); + args.Add(_constant); + } + } + } + + private sealed class LinearCombiner : Combiner + { + private readonly double _alpha, _beta; + + private LinearCombiner(double alpha, double beta) + { + _alpha = alpha; + _beta = beta; + } + + internal static LinearCombiner? s_Default; + + internal static LinearCombiner Create(double alpha, double beta) + // ReSharper disable CompareOfFloatsByEqualityOperator + => alpha == DEFAULT_ALPHA & beta == DEFAULT_BETA + // ReSharper restore CompareOfFloatsByEqualityOperator + ? (s_Default ??= new LinearCombiner(DEFAULT_ALPHA, DEFAULT_BETA)) + : new(alpha, beta); + + internal const double DEFAULT_ALPHA = 0.3, DEFAULT_BETA = 0.7; + internal override string Method => "LINEAR"; + + public override string ToString() => $"{Method} {_alpha} {_beta}"; + + internal override int GetOwnArgsCount() => 6; + + private bool IsDefault => ReferenceEquals(this, s_Default); + + private static readonly object BoxedDefaultAlpha = DEFAULT_ALPHA, BoxedDefaultBeta = DEFAULT_BETA; + + internal override void AddOwnArgs(List args, int limit) + { + args.Add(Method); + args.Add(4); + bool isDefault = ReferenceEquals(this, s_Default); + args.Add("ALPHA"); + args.Add(isDefault ? BoxedDefaultAlpha : _alpha); + args.Add("BETA"); + args.Add(isDefault ? BoxedDefaultBeta : _beta); + } + } + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs new file mode 100644 index 00000000..0d834bf9 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -0,0 +1,365 @@ +using System.Diagnostics; +using NRedisStack.Search.Aggregation; + +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + internal string Command => "FT.HYBRID"; + + internal ICollection GetArgs(string index, IReadOnlyDictionary? parameters) + { + _frozen = true; + var count = GetOwnArgsCount(parameters); + var args = new List(count + 1); + args.Add(index); + AddOwnArgs(args, parameters); + Debug.Assert(args.Count == count + 1, + $"Arg count mismatch; check {nameof(GetOwnArgsCount)} ({count}) vs {nameof(AddOwnArgs)} ({args.Count - 1})"); + return args; + } + + internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) + { + int count = _search.GetOwnArgsCount() + _vsim.GetOwnArgsCount(); // note index is not included here + + + if (_combiner is not null) + { + count += 1 + _combiner.GetOwnArgsCount(); + if (_combineScoreAlias != null) count += 2; + } + + switch (_loadFieldOrFields) + { + case string: + count += 3; + break; + case string[] fields: + count += 2 + fields.Length; + break; + } + + if (_groupByFieldOrFields is not null) + { + count += 2; + if (_groupByFieldOrFields is string[] fields) + { + count += fields.Length; + } + else + { + count += 1; // single string + } + + switch (_reducerOrReducers) + { + case Reducer reducer: + count += CountReducer(reducer); + break; + case Reducer[] reducers: + foreach (var reducer in reducers) + { + count += CountReducer(reducer); + } + break; + } + static int CountReducer(Reducer reducer) => 3 + reducer.ArgCount() + (reducer.Alias is null ? 0 : 2); + } + + switch (_applyExpressionOrExpressions) + { + case string expression: + count += CountApply(new ApplyExpression(expression)); + break; + case ApplyExpression applyExpression: + count += CountApply(applyExpression); + break; + case ApplyExpression[] applyExpressions: + foreach (var applyExpression in applyExpressions) + { + count += CountApply(applyExpression); + } + break; + } + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + static int CountApply(in ApplyExpression expr) => expr.Expression is null ? 0 : (expr.Alias is null ? 2 : 4); + + if (_sortByFieldOrFields is not null) + { + if (ReferenceEquals(_sortByFieldOrFields, s_NoSortSentinel)) + { + count++; + } + else + { + count += 2; + switch (_sortByFieldOrFields) + { + case string: + count += 1; + break; + case string[] strings: + count += strings.Length; + break; + case SortedField { Order: SortedField.SortOrder.ASC }: + count += 1; + break; + case SortedField: + count += 2; + break; + case SortedField[] fields: + foreach (var field in fields) + { + if (field.Order == SortedField.SortOrder.DESC) count++; + } + + count += fields.Length; + break; + } + } + } + + if (_filter is not null) count += 2; + + if (_pagingOffset >= 0) count += 3; + + if (parameters is not null) + { + count += (parameters.Count + 1) * 2; + } + + if (_explainScore) count++; + if (_timeout > TimeSpan.Zero) count+= 2; + + if (_cursorCount >= 0) + { + count++; + if (_cursorCount != 0) count += 2; + if (_cursorMaxIdle > TimeSpan.Zero) count += 2; + } + + return count; + } + + internal void AddOwnArgs(List args, IReadOnlyDictionary? parameters) + { + _search.AddOwnArgs(args); + _vsim.AddOwnArgs(args); + + if (_combiner is not null) + { + args.Add("COMBINE"); + _combiner.AddOwnArgs(args, _pagingCount); + + if (_combineScoreAlias != null) + { + args.Add("YIELD_SCORE_AS"); + args.Add(_combineScoreAlias); + } + } + + switch (_loadFieldOrFields) + { + case string field: + args.Add("LOAD"); + args.Add(1); + args.Add(field); + break; + case string[] fields: + args.Add("LOAD"); + args.Add(fields.Length); + args.AddRange(fields); + break; + } + + if (_groupByFieldOrFields is not null) + { + args.Add("GROUPBY"); + switch (_groupByFieldOrFields) + { + case string field: + args.Add(1); + args.Add(field); + break; + case string[] fields: + args.Add(fields.Length); + args.AddRange(fields); + break; + default: + throw new ArgumentException("Invalid group by field or fields"); + } + + switch (_reducerOrReducers) + { + case Reducer reducer: + AddReducer(reducer, args); + break; + case Reducer[] reducers: + foreach (var reducer in reducers) + { + AddReducer(reducer, args); + } + break; + } + static void AddReducer(Reducer reducer, List args) + { + args.Add("REDUCE"); + args.Add(reducer.Name); + reducer.SerializeRedisArgs(args); + if (reducer.Alias is not null) + { + args.Add("AS"); + args.Add(reducer.Alias); + } + } + } + + switch (_applyExpressionOrExpressions) + { + case string expression: + AddApply(new ApplyExpression(expression), args); + break; + case ApplyExpression applyExpression: + AddApply(in applyExpression, args); + break; + case ApplyExpression[] applyExpressions: + foreach (var applyExpression in applyExpressions) + { + AddApply(applyExpression, args); + } + break; + } + + static void AddApply(in ApplyExpression expr, List args) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (expr.Expression is not null) + { + args.Add("APPLY"); + args.Add(expr.Expression); + if (expr.Alias is not null) + { + args.Add("AS"); + args.Add(expr.Alias); + } + } + } + + if (_sortByFieldOrFields is not null) + { + if (ReferenceEquals(_sortByFieldOrFields, s_NoSortSentinel)) + { + args.Add("NOSORT"); + } + else + { + args.Add("SORTBY"); + switch (_sortByFieldOrFields) + { + case string field: + args.Add(1); + args.Add(field); + break; + case string[] fields: + args.Add(fields.Length); + args.AddRange(fields); + break; + case SortedField { Order: SortedField.SortOrder.ASC } field: + args.Add(1); + args.Add(field.FieldName); + break; + case SortedField field: + args.Add(2); + args.Add(field.FieldName); + args.Add("DESC"); + break; + case SortedField[] fields: + var descCount = 0; + foreach (var field in fields) + { + if (field.Order == SortedField.SortOrder.DESC) descCount++; + } + + args.Add(fields.Length + descCount); + foreach (var field in fields) + { + args.Add(field.FieldName); + if (field.Order == SortedField.SortOrder.DESC) args.Add("DESC"); + } + + break; + default: + throw new ArgumentException("Invalid sort by field or fields"); + } + } + } + + if (_filter is not null) + { + args.Add("FILTER"); + args.Add(_filter); + } + + if (_pagingOffset >= 0) + { + args.Add("LIMIT"); + args.Add(_pagingOffset); + args.Add(_pagingCount); + } + + if (parameters is not null) + { + args.Add("PARAMS"); + args.Add(parameters.Count * 2); + if (parameters is Dictionary typed) + { + foreach (var entry in typed) // avoid allocating enumerator + { + args.Add(entry.Key); + args.Add(entry.Value is VectorData vec ? vec.GetSingleArg() : entry.Value); + } + } + else + { + foreach (var entry in parameters) + { + args.Add(entry.Key); + args.Add(entry.Value is VectorData vec ? vec.GetSingleArg() : entry.Value); + } + } + } + + if (_explainScore) args.Add("EXPLAINSCORE"); + if (_timeout > TimeSpan.Zero) + { + args.Add("TIMEOUT"); + args.Add((long)_timeout.TotalMilliseconds); + } + + if (_cursorCount >= 0) + { + args.Add("WITHCURSOR"); + if (_cursorCount != 0) + { + args.Add("COUNT"); + args.Add(_cursorCount); + } + + if (_cursorMaxIdle > TimeSpan.Zero) + { + args.Add("MAXIDLE"); + args.Add((long)_cursorMaxIdle.TotalMilliseconds); + } + } + } + + internal void Validate() + { + if (!(_search.HasValue & _vsim.HasValue)) + { + throw new InvalidOperationException($"Both the query ({nameof(Query)}(...)) and vector search ({nameof(VectorSearch)}(...))) details must be set."); + } + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs new file mode 100644 index 00000000..a2bd26c8 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs @@ -0,0 +1,98 @@ +using System.Runtime.CompilerServices; + +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + /// + /// Represents a search query. For a parameterized query, a search like "$key" will search using the parameter named key. + /// + public readonly struct SearchConfig(string query, Scorer? scorer = null, string? scoreAlias = null) + { + private readonly string _query = query; + private readonly Scorer? _scorer = scorer; + private readonly string? _scoreAlias = scoreAlias; + + public static implicit operator SearchConfig(string query) => new(query); + + internal bool HasValue => _query is not null; + + /// + /// The query string. + /// + public string Query => _query; + + /// + /// Scoring algorithm for the query. + /// + public Scorer? Scorer => _scorer; + + /// + /// Include the score in the query results. + /// + public string? ScoreAlias => _scoreAlias; + + /// + /// Specify the scorer to use for the query. + /// + public SearchConfig WithScorer(Scorer? scorer) + { + var copy = this; + Unsafe.AsRef(in copy._scorer) = scorer; + return copy; + } + + /// + /// Specify the scorer to use for the query. + /// + public SearchConfig WithQuery(string query) + { + var copy = this; + Unsafe.AsRef(in copy._query) = query; + return copy; + } + + /// + /// Specify the scorer to use for the query. + /// + public SearchConfig WithScoreAlias(string? alias) + { + var copy = this; + Unsafe.AsRef(in copy._scoreAlias) = alias; + return copy; + } + + internal int GetOwnArgsCount() + { + int count = 0; + if (HasValue) + { + count += 2; + if (Scorer != null) count += 1 + Scorer.GetOwnArgsCount(); + if (ScoreAlias != null) count += 2; + } + + return count; + } + + internal void AddOwnArgs(List args) + { + if (HasValue) + { + args.Add("SEARCH"); + args.Add(Query); + if (Scorer != null) + { + args.Add("SCORER"); + Scorer.AddOwnArgs(args); + } + + if (ScoreAlias != null) + { + args.Add("YIELD_SCORE_AS"); + args.Add(ScoreAlias); + } + } + } + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs new file mode 100644 index 00000000..972c8eed --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -0,0 +1,129 @@ +using System.Runtime.CompilerServices; + +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + public readonly struct VectorSearchConfig(string fieldName, VectorData vectorData, VectorSearchMethod? method = null, string? filter = null, string? scoreAlias = null) + { + internal bool HasValue => _vectorData is not null & _fieldName is not null; + + private readonly string _fieldName = fieldName; + private readonly VectorData _vectorData = vectorData; + private readonly VectorSearchMethod? _method = method; + private readonly string? _filter = filter; + private readonly string? _scoreAlias = scoreAlias; + + /// + /// The field name for vector search. + /// + public string FieldName => _fieldName; + + /// + /// Vector search method configuration. + /// + public VectorSearchMethod? Method => _method; + + /// + /// Filter expression for vector search. + /// + public string? Filter => _filter; + + /// + /// Include the score in the query results. + /// + public string? ScoreAlias => _scoreAlias; + + /// + /// The vector data to search for. + /// + public VectorData? VectorData => _vectorData; + + /// + /// Specify the vector search method. + /// + public VectorSearchConfig WithVectorData(VectorData vectorData) + { + var copy = this; + Unsafe.AsRef(in copy._vectorData) = vectorData; + return copy; + } + + /// + /// Specify the vector search method. + /// + public VectorSearchConfig WithMethod(VectorSearchMethod? method) + { + var copy = this; + Unsafe.AsRef(in copy._method) = method; + return copy; + } + + /// + /// Specify the field name for vector search. + /// + public VectorSearchConfig WithFieldName(string fieldName) + { + var copy = this; + Unsafe.AsRef(in copy._fieldName) = fieldName; + return copy; + } + + /// + /// Specify the filter expression. + /// + public VectorSearchConfig WithFilter(string? filter) + { + var copy = this; + Unsafe.AsRef(in copy._filter) = filter; + return copy; + } + + /// + /// Specify the score alias. + /// + public VectorSearchConfig WithScoreAlias(string? scoreAlias) + { + var copy = this; + Unsafe.AsRef(in copy._scoreAlias) = scoreAlias; + return copy; + } + + internal int GetOwnArgsCount() + { + int count = 0; + if (HasValue) + { + count += 3; + if (_method != null) count += _method.GetOwnArgsCount(); + if (_filter != null) count += 2; + + if (_scoreAlias != null) count += 2; + } + return count; + } + + internal void AddOwnArgs(List args) + { + if (HasValue) + { + args.Add("VSIM"); + args.Add(_fieldName); + args.Add(_vectorData.GetSingleArg()); + + _method?.AddOwnArgs(args); + if (_filter != null) + { + args.Add("FILTER"); + args.Add(_filter); + } + + if (_scoreAlias != null) + { + args.Add("YIELD_SCORE_AS"); + args.Add(_scoreAlias); + } + } + } + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs new file mode 100644 index 00000000..85d38247 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -0,0 +1,320 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using NRedisStack.Search.Aggregation; + +namespace NRedisStack.Search; + +/// +/// Represents a hybrid search (FT.HYBRID) operation. Note that instances can be reused for +/// common queries, by passing the search operands as named parameters. +/// +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public sealed partial class HybridSearchQuery +{ + /// + /// Well-known fields for use with + /// + public static class Fields + { + // ReSharper disable InconsistentNaming + + /// + /// The key of the indexed item in the database. + /// + public const string Key = "@__key"; + + /// + /// The score from the query. + /// + public const string Score = "@__score"; + + // ReSharper restore InconsistentNaming + } + private bool _frozen; + private SearchConfig _search; + private VectorSearchConfig _vsim; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private HybridSearchQuery ThrowIfFrozen() // GetArgs freezes + { + if (_frozen) Throw(); + return this; + + [MethodImpl(MethodImplOptions.NoInlining)] + static void Throw() => throw new InvalidOperationException( + "By default, the query cannot be mutated after being issued (to allow safe parameterized reuse from concurrent callers). If you are using the query sequentially rather than concurrently, you can use " + nameof(AllowModification) + " to re-enable changes."); + } + /// + /// Specify the textual search portion of the query. + /// For a parameterized query, a search like "$key" will search using the parameter named key. + /// + public HybridSearchQuery Search(SearchConfig query) + { + ThrowIfFrozen(); + _search = query; + return this; + } + + /// + /// Specify the vector search portion of the query. + /// + public HybridSearchQuery VectorSearch(string fieldName, VectorData vectorData) + => VectorSearch(new VectorSearchConfig(fieldName, vectorData)); + + /// + /// Specify the vector search portion of the query. + /// + public HybridSearchQuery VectorSearch(VectorSearchConfig config) + { + ThrowIfFrozen(); + _vsim = config; + return this; + } + + private Combiner? _combiner; + private string? _combineScoreAlias; + + /// + /// Configure the score fusion method (optional). If not provided, Reciprocal Rank Fusion (RRF) is used with server-side default parameters. + /// + public HybridSearchQuery Combine(Combiner combiner) => Combine(combiner, null!); + + /// + /// Configure the score fusion method (optional). If not provided, Reciprocal Rank Fusion (RRF) is used with server-side default parameters. + /// + internal HybridSearchQuery Combine(Combiner combiner, string scoreAlias) // YIELD_SCORE_AS not yet implemented + { + ThrowIfFrozen(); + _combiner = combiner; + _combineScoreAlias = scoreAlias; + return this; + } + + private object? _loadFieldOrFields; + + /// + /// Add the list of fields to return in the results. Well-known fields are available via . + /// + public HybridSearchQuery ReturnFields(params string[] fields) // naming for consistency with SearchQuery + { + ThrowIfFrozen(); + _loadFieldOrFields = NullIfEmpty(fields); + return this; + } + + /// + /// Add the list of fields to return in the results. Well-known fields are available via . + /// + public HybridSearchQuery ReturnFields(string field) // naming for consistency with SearchQuery + { + ThrowIfFrozen(); + _loadFieldOrFields = field; + return this; + } + + private object? _groupByFieldOrFields; + private object? _reducerOrReducers; + + /// + /// Perform a group by operation on the results. + /// + public HybridSearchQuery GroupBy(string field) + { + ThrowIfFrozen(); + _groupByFieldOrFields = field; + return this; + } + + /// + /// Perform a group by operation on the results. + /// + public HybridSearchQuery GroupBy(params string[] fields) + { + ThrowIfFrozen(); + _groupByFieldOrFields = NullIfEmpty(fields); + return this; + } + + /// + /// Perform a reduce operation on the results, after grouping. + /// + public HybridSearchQuery Reduce(Reducer reducer) + { + ThrowIfFrozen(); + _reducerOrReducers = reducer; + return this; + } + + /// + /// Perform a reduce operation on the results, after grouping. + /// + public HybridSearchQuery Reduce(params Reducer[] reducers) + { + ThrowIfFrozen(); + _reducerOrReducers = NullIfEmpty(reducers); + return this; + } + + private static T[]? NullIfEmpty(T[]? array) => array?.Length > 0 ? array : null; + + private object? _applyExpressionOrExpressions; + + /// + /// Apply a field transformation expression to the results. + /// + [OverloadResolutionPriority(1)] // allow Apply(new("expr", "alias")) to resolve correctly + public HybridSearchQuery Apply(ApplyExpression applyExpression) + { + ThrowIfFrozen(); + if (applyExpression.Alias is null) + { + _applyExpressionOrExpressions = applyExpression.Expression; + } + else + { + _applyExpressionOrExpressions = applyExpression; // pay for the box + } + + return this; + } + + /// + /// Apply field transformation expressions to the results. + /// + public HybridSearchQuery Apply(params ApplyExpression[] applyExpression) + { + ThrowIfFrozen(); + _applyExpressionOrExpressions = NullIfEmpty(applyExpression); + return this; + } + + private object? _sortByFieldOrFields; + + /// + /// Sort the final results by the specified fields. + /// + /// The default sort order is by score, unless overridden or disabled. + public HybridSearchQuery SortBy(params SortedField[] fields) + { + ThrowIfFrozen(); + _sortByFieldOrFields = NullIfEmpty(fields); + return this; + } + + /// + /// Do not sort the final results. This disables the default sort by score. + /// + public HybridSearchQuery NoSort() + { + ThrowIfFrozen(); + _sortByFieldOrFields = s_NoSortSentinel; + return this; + } + + private static readonly object s_NoSortSentinel = new(); + + /// + /// Sort the final results by the specified fields. + /// + public HybridSearchQuery SortBy(params string[] fields) + { + ThrowIfFrozen(); + _sortByFieldOrFields = NullIfEmpty(fields); + return this; + } + + /// + /// Sort the final results by the specified field. + /// + public HybridSearchQuery SortBy(SortedField field) + { + ThrowIfFrozen(); + _sortByFieldOrFields = field; + return this; + } + + /// + /// Sort the final results by the specified field. + /// + public HybridSearchQuery SortBy(string field) + { + ThrowIfFrozen(); + _sortByFieldOrFields = field; + return this; + } + + private string? _filter; + + /// + /// Final result filtering + /// + public HybridSearchQuery Filter(string expression) + { + ThrowIfFrozen(); + _filter = expression; + return this; + } + + private int _pagingOffset = -1, _pagingCount = -1; + + public HybridSearchQuery Limit(int offset, int count) + { + ThrowIfFrozen(); + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); + _pagingOffset = offset; + _pagingCount = count; + return this; + } + + private bool _explainScore; + + /// + /// Include score explanations + /// + internal HybridSearchQuery ExplainScore(bool explainScore = true) // not yet implemented + { + ThrowIfFrozen(); + _explainScore = explainScore; + return this; + } + + private TimeSpan _timeout; + + /// + /// Apply the global timeout setting. + /// + public HybridSearchQuery Timeout(TimeSpan timeout) + { + ThrowIfFrozen(); + _timeout = timeout; + return this; + } + + private int _cursorCount = -1; // -1: no cursor; 0: default count + private TimeSpan _cursorMaxIdle; + + /// + /// Use a cursor for result iteration. + /// + internal HybridSearchQuery WithCursor(int count = 0, TimeSpan maxIdle = default) + { + ThrowIfFrozen(); + // not currently exposed, while I figure out the API + _cursorCount = count; + _cursorMaxIdle = maxIdle; + return this; + } + + /// + /// By default, queries are frozen when issued, to allow safe re-use of prepared queries from different callers. + /// If you instead want to make sequential use of a query in a single caller, you can use this method + /// to re-enable modification after issuing each query. + /// + /// + public HybridSearchQuery AllowModification() + { + _frozen = false; + return this; + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchResult.cs b/src/NRedisStack/Search/HybridSearchResult.cs new file mode 100644 index 00000000..d99db3f4 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchResult.cs @@ -0,0 +1,145 @@ +using System.Diagnostics.CodeAnalysis; +using StackExchange.Redis; + +namespace NRedisStack.Search; + +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public sealed class HybridSearchResult +{ + private HybridSearchResult() { } + internal static HybridSearchResult Parse(RedisResult? result) + { + if (result is null || result.IsNull) return null!; + var obj = new HybridSearchResult(); + var len = result.Length / 2; + if (len > 0) + { + int index = 0; + for (int i = 0; i < len; i++) + { + var key = ParseKey(result[index++]); + if (key is not ResultKey.Unknown) + { + var value = result[index]; + if (!value.IsNull) + { + switch (key) + { + case ResultKey.TotalResults: + obj.TotalResults = (long)value; + break; + case ResultKey.ExecutionTime: + obj.ExecutionTime = TimeSpan.FromSeconds((double)value); + break; + /* // defer Warnings until we've seen examples + case ResultKey.Warnings when value.Length > 0: + var warnings = new string[value.Length]; + for (int j = 0; j < value.Length; j++) + { + warnings[j] = value[j].ToString(); + } + obj.Warnings = warnings; + break; + */ + case ResultKey.Results when value.Length > 0: + obj._rawResults = value.ToArray(); + break; + } + } + } + + index++; // move past value + } + } + return obj; + + static ResultKey ParseKey(RedisResult key) + { + if (!key.IsNull && key.Resp2Type is ResultType.BulkString or ResultType.SimpleString) + { + return key.ToString().ToLowerInvariant() switch + { + "total_results" => ResultKey.TotalResults, + "execution_time" => ResultKey.ExecutionTime, + "warnings" => ResultKey.Warnings, + "results" => ResultKey.Results, + _ => ResultKey.Unknown + }; + } + + return ResultKey.Unknown; + } + } + + private static IReadOnlyDictionary ParseRow(RedisResult value) + { + var arr = (RedisResult[])value!; + var row = new Dictionary(arr.Length / 2); + for (int i = 0; i < arr.Length; i += 2) + { + var key = arr[i].ToString(); + var parsed = ParseValue(arr[i + 1]); + row.Add(key, parsed); + } + + return row; + } + + private static object ParseValue(RedisResult? value) + { + if (value is null || value.IsNull) return null!; + switch (value.Resp2Type) // for now, only use RESP2 types, to avoid unexpected changes + { + case ResultType.BulkString: + case ResultType.SimpleString: + return value.ToString(); + case ResultType.Integer: + return (long)value; + default: + return value; + } + } + + private enum ResultKey + { + Unknown, + TotalResults, + ExecutionTime, + Warnings, + Results, + } + + /// + /// The number of records matched. + /// + public long TotalResults { get; private set; } = -1; // initialize to -1 to indicate not set + + /// + /// The time taken to execute this query. + /// + public TimeSpan ExecutionTime { get; private set; } + + // not exposing this until I've seen it being used + internal string[] Warnings { get; private set; } = []; + + private RedisResult[] _rawResults = []; + private Document[]? _docResults; + + /// + /// Obtain the results as entries. + /// + public Document[] Results => _docResults ??= ParseDocResults(); + + private Document[] ParseDocResults() + { + var raw = _rawResults; + if (raw.Length == 0) return []; + Document[] docs = new Document[raw.Length]; + for (int i = 0 ; i < raw.Length ; i ++) + { + docs[i] = Document.Load(raw[i]); + } + + return docs; + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/ISearchCommands.cs b/src/NRedisStack/Search/ISearchCommands.cs index a3c4c387..07670cda 100644 --- a/src/NRedisStack/Search/ISearchCommands.cs +++ b/src/NRedisStack/Search/ISearchCommands.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using NRedisStack.Search; using NRedisStack.Search.Aggregation; using NRedisStack.Search.DataTypes; @@ -344,4 +345,15 @@ public interface ISearchCommands /// List of TAG field values /// RedisResult[] TagVals(string indexName, string fieldName); + + /// + /// Perform a hybrid search query. + /// + /// The index name. + /// The query to execute. + /// The optional parameters used in this query. + /// List of TAG field values + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null); } \ No newline at end of file diff --git a/src/NRedisStack/Search/ISearchCommandsAsync.cs b/src/NRedisStack/Search/ISearchCommandsAsync.cs index 9ecad397..b0b516c0 100644 --- a/src/NRedisStack/Search/ISearchCommandsAsync.cs +++ b/src/NRedisStack/Search/ISearchCommandsAsync.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using NRedisStack.Search; using NRedisStack.Search.Aggregation; using NRedisStack.Search.DataTypes; @@ -346,4 +347,8 @@ public interface ISearchCommandsAsync /// List of TAG field values /// Task TagValsAsync(string indexName, string fieldName); + + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + Task HybridSearchAsync(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null); } \ No newline at end of file diff --git a/src/NRedisStack/Search/Parameters.cs b/src/NRedisStack/Search/Parameters.cs new file mode 100644 index 00000000..42bc6bfa --- /dev/null +++ b/src/NRedisStack/Search/Parameters.cs @@ -0,0 +1,98 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace NRedisStack.Search; + +/// +/// Create query parameters from an object template. +/// +public static class Parameters +{ + /// + /// Create parameters from an object template. + /// + public static IReadOnlyDictionary From(T obj) + => new TypedParameters(obj); + + private sealed class TypedParameters(T obj) : IReadOnlyDictionary + { + // ReSharper disable once InconsistentNaming + private static readonly PropertyInfo[] s_properties = + typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead + && p.GetGetMethod() is not null + && !p.PropertyType.IsByRef +#if NET || NETSTANDARD2_1_OR_GREATER + && !p.PropertyType.IsByRefLike +#else + && !p.PropertyType.GetCustomAttributes().Any(x => x.GetType().FullName == "System.Runtime.CompilerServices.IsByRefLikeAttribute") +#endif + ).ToArray(); + + public IEnumerator> GetEnumerator() + { + foreach (var prop in s_properties) + { + var value = prop.GetValue(obj); + if (value is not null) + { + yield return new KeyValuePair(prop.Name, value); + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => s_properties.Length; + public bool ContainsKey(string key) => TryGetValue(key, out _); // because we need the null-check + + public bool TryGetValue(string key, out object value) + { + foreach (var prop in s_properties) + { + if (prop.Name == key) + { + value = prop.GetValue(obj)!; + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + return value is not null; + } + } + + value = null!; + return false; + } + + public object this[string key] => TryGetValue(key, out var value) ? value : throw new KeyNotFoundException(); + + public IEnumerable Keys + { + get + { + foreach (var prop in s_properties) + { + var value = prop.GetValue(obj); + if (value is not null) + { + yield return prop.Name; + } + } + } + } + + public IEnumerable Values + { + get + { + foreach (var prop in s_properties) + { + var value = prop.GetValue(obj); + if (value is not null) + { + yield return value; + } + } + } + } + } +} diff --git a/src/NRedisStack/Search/Reducer.cs b/src/NRedisStack/Search/Reducer.cs index c4717b5f..19950cbb 100644 --- a/src/NRedisStack/Search/Reducer.cs +++ b/src/NRedisStack/Search/Reducer.cs @@ -22,6 +22,7 @@ protected Reducer(string? field) //protected Reducer() : this(field: null) { } protected virtual int GetOwnArgsCount() => _field == null ? 0 : 1; + internal int ArgCount() => GetOwnArgsCount(); protected virtual void AddOwnArgs(List args) { if (_field != null) args.Add(_field); diff --git a/src/NRedisStack/Search/Reducers.cs b/src/NRedisStack/Search/Reducers.cs index df5bf098..c595b8e5 100644 --- a/src/NRedisStack/Search/Reducers.cs +++ b/src/NRedisStack/Search/Reducers.cs @@ -2,11 +2,10 @@ public static class Reducers { - public static Reducer Count() => CountReducer.Instance; + public static Reducer Count() => new CountReducer(); // don't memoize; see https://github.com/redis/NRedisStack/issues/453 private sealed class CountReducer : Reducer { - internal static readonly Reducer Instance = new CountReducer(); - private CountReducer() : base(null) { } + internal CountReducer() : base(null) { } public override string Name => "COUNT"; } diff --git a/src/NRedisStack/Search/Scorer.cs b/src/NRedisStack/Search/Scorer.cs new file mode 100644 index 00000000..3855a669 --- /dev/null +++ b/src/NRedisStack/Search/Scorer.cs @@ -0,0 +1,97 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NRedisStack.Search; + +/// +/// See https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/scoring/ for more details +/// +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public abstract class Scorer +{ + private protected Scorer() + { + } + + /// + public override string ToString() => Method; + + internal abstract string Method { get; } + + /// + /// Basic TF-IDF scoring with a few extra features, + /// + public static Scorer TfIdf { get; } = new SimpleScorer("TFIDF"); + + /// + /// Identical to the default TFIDF scorer, with one important distinction: Term frequencies are normalized by the length of the document, expressed as the total number of terms. + /// + public static Scorer TfIdfDocNorm { get; } = new SimpleScorer("TFIDF.DOCNORM"); + + /// + /// A variation on the basic TFIDF scorer. + /// + // ReSharper disable InconsistentNaming + public static Scorer BM25Std { get; } = new SimpleScorer("BM25STD"); + + /// + /// A variation of BM25STD, where the scores are normalized by the minimum and maximum scores. + /// + public static Scorer BM25StdNorm { get; } = new SimpleScorer("BM25STD.NORM"); + + /// + /// A variation of BM25STD.NORM, where the scores are normalized by the linear function tanh(x). + /// + /// used to smooth the function and the score values. + internal static Scorer BM25StdTanh(int y = Bm25StdTanh.DEFAULT_Y) => Bm25StdTanh.Create(y); // doesn't yet work with FT.HYBRID + // ReSharper restore InconsistentNaming + + /// + /// A simple scorer that sums up the frequencies of matched terms. In the case of union clauses, it will give the maximum value of those matches. No other penalties or factors are applied. + /// + public static Scorer DisMax { get; } = new SimpleScorer("DISMAX"); + + /// + /// A scoring function that just returns the presumptive score of the document without applying any calculations to it. Since document scores can be updated, this can be useful if you'd like to use an external score and nothing further. + /// + public static Scorer DocScore { get; } = new SimpleScorer("DOCSCORE"); + + /// + /// Scoring by the inverse Hamming distance between the document's payload and the query payload is performed. + /// + public static Scorer Hamming { get; } = new SimpleScorer("HAMMING"); + + private sealed class Bm25StdTanh : Scorer + { + private readonly int _y; + + private Bm25StdTanh(int y) => _y = y; + + private static Bm25StdTanh? s_Default; + internal const int DEFAULT_Y = 4; + internal static Bm25StdTanh Create(int y) => y == DEFAULT_Y + ? (s_Default ??= new Bm25StdTanh(DEFAULT_Y)) : new(y); + internal override string Method => "BM25STD.TANH"; + + /// + public override string ToString() => $"{Method} BM25STD_TANH_FACTOR {_y}"; + + internal override int GetOwnArgsCount() => 3; + internal override void AddOwnArgs(List args) + { + args.Add(Method); + args.Add("BM25STD_TANH_FACTOR"); + args.Add(_y); + } + } + + private sealed class SimpleScorer(string method) : Scorer // no args + { + internal override string Method => method; + internal override int GetOwnArgsCount() => 1; + internal override void AddOwnArgs(List args) => args.Add(method); + } + + internal abstract int GetOwnArgsCount(); + + internal abstract void AddOwnArgs(List args); +} \ No newline at end of file diff --git a/src/NRedisStack/Search/SearchCommands.cs b/src/NRedisStack/Search/SearchCommands.cs index d7112de2..6c0b5ca0 100644 --- a/src/NRedisStack/Search/SearchCommands.cs +++ b/src/NRedisStack/Search/SearchCommands.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using NRedisStack.Search; using NRedisStack.Search.Aggregation; using NRedisStack.Search.DataTypes; @@ -313,4 +314,14 @@ public bool SynUpdate(string indexName, string synonymGroupId, bool skipInitialS /// public RedisResult[] TagVals(string indexName, string fieldName) => //TODO: consider return Set db.Execute(SearchCommandBuilder.TagVals(indexName, fieldName)).ToArray(); -} \ No newline at end of file + + + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + public HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + { + query.Validate(); + var args = query.GetArgs(indexName, parameters); + return HybridSearchResult.Parse(db.Execute(query.Command, args)); + } +} diff --git a/src/NRedisStack/Search/SearchCommandsAsync.cs b/src/NRedisStack/Search/SearchCommandsAsync.cs index b26979ec..ab557252 100644 --- a/src/NRedisStack/Search/SearchCommandsAsync.cs +++ b/src/NRedisStack/Search/SearchCommandsAsync.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using NRedisStack.Search; using NRedisStack.Search.Aggregation; using NRedisStack.Search.DataTypes; @@ -352,4 +353,13 @@ public async Task SynUpdateAsync(string indexName, string synonymGroupId, /// public async Task TagValsAsync(string indexName, string fieldName) => //TODO: consider return Set (await _db.ExecuteAsync(SearchCommandBuilder.TagVals(indexName, fieldName))).ToArray(); + + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + public async Task HybridSearchAsync(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + { + query.Validate(); + var args = query.GetArgs(indexName, parameters); + return HybridSearchResult.Parse(await _db.ExecuteAsync(query.Command, args)); + } } \ No newline at end of file diff --git a/src/NRedisStack/Search/SortedField.cs b/src/NRedisStack/Search/SortedField.cs index 2054b20e..4e9f626e 100644 --- a/src/NRedisStack/Search/SortedField.cs +++ b/src/NRedisStack/Search/SortedField.cs @@ -1,29 +1,16 @@ namespace NRedisStack.Search.Aggregation; -public class SortedField +public class SortedField(string fieldName, SortedField.SortOrder order = SortedField.SortOrder.ASC) { - public enum SortOrder { ASC, DESC } - public string FieldName { get; } - public SortOrder Order { get; } - - public SortedField(String fieldName, SortOrder order = SortOrder.ASC) - { - FieldName = fieldName; - Order = order; - } + public string FieldName { get; } = fieldName; + public SortOrder Order { get; } = order; - public static SortedField Asc(String field) - { - return new(field, SortOrder.ASC); - } + public static SortedField Asc(string field) => new(field, SortOrder.ASC); - public static SortedField Desc(String field) - { - return new(field, SortOrder.DESC); - } + public static SortedField Desc(string field) => new(field, SortOrder.DESC); } \ No newline at end of file diff --git a/src/NRedisStack/Search/VectorData.cs b/src/NRedisStack/Search/VectorData.cs new file mode 100644 index 00000000..00f43d17 --- /dev/null +++ b/src/NRedisStack/Search/VectorData.cs @@ -0,0 +1,89 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using StackExchange.Redis; + +namespace NRedisStack.Search; + +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public abstract class VectorData +{ + private protected VectorData() + { + } + + /// + /// A vector of entries. + /// + public static VectorData Create(ReadOnlyMemory vector) => new VectorDataSingle(vector); + + /// + /// A raw vector payload. + /// + public static VectorData Raw(ReadOnlyMemory bytes) => new VectorDataRaw(bytes); + + /// + /// Represent a vector as a parameter to be supplied later. + /// + public static VectorData Parameter(string name) => new VectorParameter(name); + + /// + /// A vector of entries. + /// + public static implicit operator VectorData(float[] data) => new VectorDataSingle(data); + + /// + public static implicit operator VectorData(ReadOnlyMemory vector) => new VectorDataSingle(vector); + + /// + public static implicit operator VectorData(string name) => new VectorParameter(name); + + internal abstract object GetSingleArg(); + + /// + public override string ToString() => GetType().Name; + + private sealed class VectorDataSingle(ReadOnlyMemory vector) : VectorData + { + internal override object GetSingleArg() => ToBase64(); + public override string ToString() => ToBase64(); + + private string ToBase64() + { + if (!BitConverter.IsLittleEndian) ThrowBigEndian(); // we could loop and reverse each, but...how to test? + var bytes = MemoryMarshal.AsBytes(vector.Span); +#if NET || NETSTANDARD2_1_OR_GREATER + return Convert.ToBase64String(bytes); +#else + var oversized = ArrayPool.Shared.Rent(bytes.Length); + bytes.CopyTo(oversized); + var result = Convert.ToBase64String(oversized, 0, bytes.Length); + ArrayPool.Shared.Return(oversized); + return result; +#endif + } + } + + private sealed class VectorDataRaw(ReadOnlyMemory bytes) : VectorData + { + internal override object GetSingleArg() => (RedisValue)bytes; + } + + private sealed class VectorParameter : VectorData + { + private readonly string name; + + public VectorParameter(string name) + { + if (string.IsNullOrEmpty(name) || name[0] != '$') Throw(); + this.name = name; + static void Throw() => throw new ArgumentException("Parameter tokens must start with the character '$'."); + } + + public override string ToString() => name; + internal override object GetSingleArg() => name; + } + + private protected static void ThrowBigEndian() => + throw new PlatformNotSupportedException("Big-endian CPUs are not currently supported for this operation"); +} \ No newline at end of file diff --git a/src/NRedisStack/Search/VectorSearchMethod.cs b/src/NRedisStack/Search/VectorSearchMethod.cs new file mode 100644 index 00000000..4efb0411 --- /dev/null +++ b/src/NRedisStack/Search/VectorSearchMethod.cs @@ -0,0 +1,155 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NRedisStack.Search; + +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public abstract class VectorSearchMethod +{ + private protected VectorSearchMethod() + { + } + + private protected abstract string Method { get; } + + internal abstract int GetOwnArgsCount(); + internal abstract void AddOwnArgs(List args); + + /// + public override string ToString() => Method; + + public static VectorSearchMethod Range(double radius, double? epsilon = null, string? distanceAlias = null) + => RangeVectorSearchMethod.Create(radius, epsilon, distanceAlias); + + public static VectorSearchMethod NearestNeighbour( + int count = NearestNeighbourVectorSearchMethod.DEFAULT_NEAREST_NEIGHBOUR_COUNT, int? maxTopCandidates = null, + string? distanceAlias = null) + => NearestNeighbourVectorSearchMethod.Create(count, maxTopCandidates, distanceAlias); + + private sealed class NearestNeighbourVectorSearchMethod : VectorSearchMethod + { + private static NearestNeighbourVectorSearchMethod? s_Default; + + internal static NearestNeighbourVectorSearchMethod Create(int count, int? maxTopCandidates, + string? distanceAlias) + => count == DEFAULT_NEAREST_NEIGHBOUR_COUNT & maxTopCandidates == null & distanceAlias == null + ? (s_Default ??= new NearestNeighbourVectorSearchMethod(DEFAULT_NEAREST_NEIGHBOUR_COUNT, null, null)) + : new(count, maxTopCandidates, distanceAlias); + + private NearestNeighbourVectorSearchMethod(int nearestNeighbourCount, int? maxTopCandidates, + string? distanceAlias) + { + NearestNeighbourCount = nearestNeighbourCount; + MaxTopCandidates = maxTopCandidates; + DistanceAlias = distanceAlias; + } + + internal const int DEFAULT_NEAREST_NEIGHBOUR_COUNT = 10; + private protected override string Method => "KNN"; + + /// + /// The number of nearest neighbors to find. This is the K in KNN. + /// + public int NearestNeighbourCount { get; } + + /// + /// Max top candidates during KNN search. Higher values increase accuracy, but also increase search latency. + /// This corresponds to the HNSW "EF_RUNTIME" parameter. + /// + public int? MaxTopCandidates { get; } + + /// + /// Include the distance from the query vector in the results. + /// + public string? DistanceAlias { get; } + + internal override int GetOwnArgsCount() + { + int count = 4; + if (MaxTopCandidates != null) count += 2; + if (DistanceAlias != null) count += 2; + return count; + } + + internal override void AddOwnArgs(List args) + { + args.Add(Method); + int tokens = 2; + if (MaxTopCandidates != null) tokens += 2; + if (DistanceAlias != null) tokens += 2; + args.Add(tokens); + args.Add("K"); + args.Add(NearestNeighbourCount); + if (MaxTopCandidates != null) + { + args.Add("EF_RUNTIME"); + args.Add(MaxTopCandidates); + } + + if (DistanceAlias != null) + { + args.Add("YIELD_DISTANCE_AS"); + args.Add(DistanceAlias); + } + } + } + + private sealed class RangeVectorSearchMethod : VectorSearchMethod + { + internal static RangeVectorSearchMethod Create(double radius, double? epsilon, string? distanceAlias) + => new(radius, epsilon, distanceAlias); + + private RangeVectorSearchMethod(double radius, double? epsilon, string? distanceAlias) + { + Radius = radius; + Epsilon = epsilon; + DistanceAlias = distanceAlias; + } + + private protected override string Method => "RANGE"; + + /// + /// The search radius/threshold. Finds all vectors within this distance. + /// + public double Radius { get; } + + /// + /// 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. + /// + public double? Epsilon { get; } + + /// + /// Include the distance from the query vector in the results. + /// + public string? DistanceAlias { get; } + + internal override int GetOwnArgsCount() + { + int count = 4; + if (Epsilon != null) count += 2; + if (DistanceAlias != null) count += 2; + return count; + } + + internal override void AddOwnArgs(List args) + { + args.Add(Method); + int tokens = 2; + if (Epsilon != null) tokens += 2; + if (DistanceAlias != null) tokens += 2; + args.Add(tokens); + args.Add("RADIUS"); + args.Add(Radius); + if (Epsilon != null) + { + args.Add("EPSILON"); + args.Add(Epsilon); + } + + if (DistanceAlias != null) + { + args.Add("YIELD_DISTANCE_AS"); + args.Add(DistanceAlias); + } + } + } +} \ No newline at end of file diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs new file mode 100644 index 00000000..fe0ef9d8 --- /dev/null +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -0,0 +1,392 @@ +using System.Buffers; +using System.Data; +using System.Numerics; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; +using NRedisStack.Search.Aggregation; +using StackExchange.Redis; +using Xunit; +using Xunit.Abstractions; + +namespace NRedisStack.Tests.Search; + +public class HybridSearchIntegrationTests(EndpointsFixture endpointsFixture, ITestOutputHelper log) + : AbstractNRedisStackTest(endpointsFixture, log), IDisposable +{ + private readonly struct Api(SearchCommands ft, string index, IDatabase db) + { + public string Index { get; } = index; + public SearchCommands FT { get; } = ft; + public IDatabase DB { get; } = db; + } + + private const int V1DIM = 5; + + private async Task CreateIndexAsync(string endpointId, [CallerMemberName] string caller = "", + bool populate = true) + { + var index = $"ix_{caller}"; + var db = GetCleanDatabase(endpointId); + // ReSharper disable once RedundantArgumentDefaultValue + var ft = db.FT(2); + + var vectorAttrs = new Dictionary() + { + ["TYPE"] = "FLOAT16", + ["DIM"] = V1DIM, + ["DISTANCE_METRIC"] = "L2", + }; + Schema sc = new Schema() + // ReSharper disable once RedundantArgumentDefaultValue + .AddTextField("text1", 1.0, missingIndex: true) + .AddTagField("tag1", missingIndex: true) + .AddNumericField("numeric1", missingIndex: true) + .AddVectorField("vector1", Schema.VectorField.VectorAlgo.FLAT, vectorAttrs, missingIndex: true); + + var ftCreateParams = FTCreateParams.CreateParams(); + Assert.True(await ft.CreateAsync(index, ftCreateParams, sc)); + + if (populate) + { +#if NET + Task last = Task.CompletedTask; + var rand = new Random(12345); + string[] tags = ["foo", "bar", "blap"]; + for (int i = 0; i < 16; i++) + { + byte[] vec = new byte[V1DIM * sizeof(ushort)]; + var halves = MemoryMarshal.Cast(vec); + for (int j = 1; j < V1DIM; j++) + { + halves[j] = (Half)rand.NextDouble(); + } + + HashEntry[] entry = + [ + new("text1", $"Search entry {i}"), + new("tag1", tags[rand.Next(tags.Length)]), + new("numeric1", rand.Next(0, 32)), + new("vector1", vec) + ]; + last = db.HashSetAsync($"{index}_entry{i}", entry); + } + + await last; +#else + throw new PlatformNotSupportedException("FP16"); +#endif + } + + return new(ft, index, db); + } + + [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] + public async Task TestSetup(string endpointId) + { + var api = await CreateIndexAsync(endpointId, populate: false); + Dictionary args = new() { ["x"] = "abc" }; + var query = new HybridSearchQuery() + .Search("*") + .VectorSearch("@vector1", new float[] { 1, 2, 3, 4 }) + .ReturnFields("@text1"); + var result = api.FT.HybridSearch(api.Index, query, args); + Assert.Equal(0, result.TotalResults); + Assert.NotEqual(TimeSpan.Zero, result.ExecutionTime); + Assert.Empty(result.Warnings); + Assert.Empty(result.Results); + } + + [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] + public async Task TestSearch(string endpointId) + { + var api = await CreateIndexAsync(endpointId, populate: true); + + var hash = (await api.DB.HashGetAllAsync($"{api.Index}_entry2")).ToDictionary(k => k.Name, v => v.Value); + var vec = (byte[])hash["vector1"]!; + var text = (string)hash["text1"]!; + var query = new HybridSearchQuery() + .Search(text) + .VectorSearch("@vector1", VectorData.Raw(vec)) + .ReturnFields("@text1", HybridSearchQuery.Fields.Key, HybridSearchQuery.Fields.Score); + + WriteArgs(api.Index, query); + + var result = api.FT.HybridSearch(api.Index, query); + Assert.Equal(10, result.TotalResults); + Assert.NotEqual(TimeSpan.Zero, result.ExecutionTime); + Assert.Empty(result.Warnings); + Assert.Same(result.Results, result.Results); // check this is not allocating each time + Assert.Equal(10, result.Results.Length); + foreach (var row in result.Results) + { + Assert.NotNull(row.Id); + Assert.NotEqual("", row.Id); + Assert.False(double.IsNaN(row.Score)); + var text1 = (string)row["text1"]!; + Assert.False(string.IsNullOrWhiteSpace(text1)); + Log($"{row.Id}, {row.Score}, {text1}"); + } + } + + public enum Scenario + { + Simple, + NoSort, + [Broken] ExplainScore, + Apply, + LinearNoScore, + LinearWithScore, + RrfNoScore, + RrfWithScore, + [Broken] PostFilterByTag, + PostFilterByNumber, + LimitFirstPage, + LimitSecondPage, + LimitEmptyPage, + SortBySingle, + SortByMultiple, + Timeout, + ReduceSingleSimpleWithAlias, + ReduceSingleSimpleWithoutAlias, + ReduceSingleComplexWithAlias, + ReduceMulti, + GroupByNoReduce, + SearchWithAlias, + SearchWithSimpleScorer, + [Broken] SearchWithComplexScorer, + VectorWithAlias, + VectorWithRange, + [Broken] VectorWithRangeAndDistanceAlias, + VectorWithRangeAndEpsilon, + VectorWithTagFilter, + VectorWithNumericFilter, + VectorWithNearest, + VectorWithNearestCount, + [Broken] VectorWithNearestDistAlias, + VectorWithNearestMaxCandidates, + PreFilterByTag, + PreFilterByNumeric, + [Broken] ParamPostFilter, + ParamSearch, + ParamVsim, + [Broken] ParamMultiPostFilter, + ParamPreFilter, + ParamMultiPreFilter + } + + private sealed class BrokenAttribute : Attribute + { + } + + private static class EnumCache + { + public static IEnumerable Values { get; } = ( + from field in typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static) + where !Attribute.IsDefined(field, typeof(BrokenAttribute)) + let val = field.GetRawConstantValue() + where val is not null + select (T)val).ToArray(); + } + + private static IEnumerable CrossJoin(Func> environments) + where T : unmanaged, Enum + { + foreach (var arr in environments()) + { + foreach (T scenario in EnumCache.Values) + { + yield return [..arr, scenario]; + } + } + } + + public static IEnumerable AllEnvironments_Scenarios() => + CrossJoin(EndpointsFixture.Env.AllEnvironments); + + [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] + [MemberData(nameof(AllEnvironments_Scenarios))] + public async Task TestSearchScenarios(string endpointId, Scenario scenario) + { + var api = await CreateIndexAsync(endpointId, populate: true); + + var hash = (await api.DB.HashGetAllAsync($"{api.Index}_entry2")).ToDictionary(k => k.Name, v => v.Value); + var vec = (byte[])hash["vector1"]!; + var text = (string)hash["text1"]!; + string[] fields = ["@text1", HybridSearchQuery.Fields.Key, HybridSearchQuery.Fields.Score]; + var query = new HybridSearchQuery() + .Search(text) + .VectorSearch("@vector1", VectorData.Raw(vec)) + .ReturnFields(fields); + +#pragma warning disable CS0612 + query = scenario switch + { + Scenario.Simple => query, + Scenario.SearchWithAlias => query.Search(new(text, scoreAlias: "score_alias")), + Scenario.SearchWithSimpleScorer => query.Search(new(text, scorer: Scorer.TfIdf)), + Scenario.SearchWithComplexScorer => query.Search(new(text, scorer: Scorer.BM25StdTanh(7))), + Scenario.VectorWithAlias => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + scoreAlias: "score_alias")), + Scenario.VectorWithRange => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.Range(42))), + Scenario.VectorWithRangeAndDistanceAlias => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.Range(42, distanceAlias: "dist_alias"))), + Scenario.VectorWithRangeAndEpsilon => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.Range(42, epsilon: 0.1))), + Scenario.VectorWithNearest => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.NearestNeighbour())), + Scenario.VectorWithNearestCount => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.NearestNeighbour(20))), + Scenario.VectorWithNearestDistAlias => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.NearestNeighbour(distanceAlias: "dist_alias"))), + Scenario.VectorWithNearestMaxCandidates => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + method: VectorSearchMethod.NearestNeighbour(maxTopCandidates: 10))), + Scenario.VectorWithTagFilter => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + filter: "@tag1:{foo}")), + Scenario.VectorWithNumericFilter => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + filter: "@numeric1!=0")), + Scenario.NoSort => query.NoSort(), + Scenario.ExplainScore => query.ExplainScore(), + Scenario.Apply => query.ReturnFields([..fields, "@numeric1"]) + .Apply(new("@numeric1 * 2", "x2"), new("@x2 * 3")), // non-aliased, comes back as the expression + Scenario.LinearNoScore => query.Combine(HybridSearchQuery.Combiner.Linear(0.4, 0.6)), + Scenario.LinearWithScore => query.Combine(HybridSearchQuery.Combiner.Linear(), "lin_score"), + Scenario.RrfNoScore => query.Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(10, 1.2)), + Scenario.RrfWithScore => query.Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(), "rrf_score"), + Scenario.PreFilterByTag => query.VectorSearch(new("@vector1", VectorData.Raw(vec), filter: "@tag1:{foo}")), + Scenario.PreFilterByNumeric => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + filter: "@numeric1!=0")), + Scenario.PostFilterByTag => query.Filter("@tag1:{foo}"), + Scenario.PostFilterByNumber => query.ReturnFields([..fields, "@numeric1"]).Filter("@numeric1!=0"), + Scenario.LimitFirstPage => query.Limit(0, 2), + Scenario.LimitSecondPage => query.Limit(2, 2), + Scenario.LimitEmptyPage => query.Limit(0, 0), + Scenario.SortBySingle => query.SortBy("@numeric1"), + Scenario.SortByMultiple => query.SortBy("@text1", "@numeric1", HybridSearchQuery.Fields.Score), + Scenario.Timeout => query.Timeout(TimeSpan.FromSeconds(1)), + Scenario.GroupByNoReduce => query.GroupBy("@tag1"), + Scenario.ReduceSingleSimpleWithAlias => query.GroupBy("@tag1").Reduce(Reducers.Avg("@numeric1").As("avg")), + Scenario.ReduceSingleSimpleWithoutAlias => query.GroupBy("@tag1").Reduce(Reducers.Sum("@numeric1")), + Scenario.ReduceSingleComplexWithAlias => query.GroupBy("@tag1") + .Reduce(Reducers.Quantile("@numeric1", 0.5).As("qt")), + Scenario.ReduceMulti => query.GroupBy("@tag1").Reduce(Reducers.Count().As("count"), + Reducers.Min("@numeric1").As("min"), Reducers.Max("@numeric1").As("max")), + Scenario.ParamVsim => query.VectorSearch("@vector1", VectorData.Parameter("$v")), + Scenario.ParamSearch => query.Search("$q"), + Scenario.ParamPreFilter => + query.VectorSearch(new("@vector1", VectorData.Raw(vec), filter: "@numeric1!=$n")), + Scenario.ParamPostFilter => query.ReturnFields([..fields, "@numeric1"]).Filter("@numeric1!=$n"), + Scenario.ParamMultiPreFilter => query.VectorSearch(new("@vector1", VectorData.Raw(vec), + filter: "@numeric1!=$n | @tag1:{$t}")), + Scenario.ParamMultiPostFilter => query.ReturnFields([..fields, "@numeric1"]) + .Filter("@numeric1!=$n | @tag1:{$t}"), + _ => throw new ArgumentOutOfRangeException(scenario.ToString()), + }; +#pragma warning restore CS0612 + Dictionary? args = scenario switch + { + Scenario.ParamPostFilter or Scenario.ParamPreFilter => new Dictionary() { ["n"] = 42 }, + Scenario.ParamMultiPostFilter or Scenario.ParamMultiPreFilter => new Dictionary() + { ["n"] = 42, ["t"] = "foo" }, + Scenario.ParamSearch => new Dictionary() { ["q"] = text }, + Scenario.ParamVsim => new Dictionary() { ["v"] = VectorData.Raw(vec) }, + _ => null, + }; + WriteArgs(api.Index, query, args); + + var result = api.FT.HybridSearch(api.Index, query, args); + Assert.True(result.TotalResults > 0); + Assert.NotEqual(TimeSpan.Zero, result.ExecutionTime); + Assert.Empty(result.Warnings); + Assert.Same(result.Results, result.Results); // check this is not allocating each time + Assert.True(scenario == Scenario.LimitEmptyPage | result.Results.Length > 0); + foreach (var row in result.Results) + { + Log($"{row.Id}, {row.Score}"); + if (!(scenario is Scenario.ReduceSingleSimpleWithAlias or Scenario.ReduceSingleComplexWithAlias + or Scenario.ReduceMulti or Scenario.ReduceSingleSimpleWithoutAlias or Scenario.GroupByNoReduce)) + { + Assert.NotNull(row.Id); + Assert.NotEqual("", row.Id); + Assert.False(double.IsNaN(row.Score)); + } + + foreach (var prop in row._properties) + { + Log($"{prop.Key}={prop.Value}"); + } + } + } + + private void WriteArgs(string indexName, HybridSearchQuery query, + IReadOnlyDictionary? parameters = null) + { + byte[] scratch = []; + + var sb = new StringBuilder(query.Command).Append(' '); + var args = query.GetArgs(indexName, parameters); + foreach (var arg in args) + { + sb.Append(' '); + if (arg is string s) + { + sb.Append('"').Append(s.Replace("\"", "\\\"")).Append('"'); + } + else if (arg is RedisValue v) + { + var len = v.GetByteCount(); + if (len > scratch.Length) + { + ArrayPool.Shared.Return(scratch); + scratch = ArrayPool.Shared.Rent(len); + } + + v.CopyTo(scratch); + WriteEscaped(scratch.AsSpan(0, len), sb); + } + else + { + sb.Append(arg); + } + } + + Log(sb.ToString()); + + ArrayPool.Shared.Return(scratch); + + static void WriteEscaped(ReadOnlySpan span, StringBuilder sb) + { + // write resp-cli style + sb.Append("\""); + foreach (var b in span) + { + if (b < ' ' | b >= 127 | b == '"' | b == '\\') + { + switch (b) + { + case (byte)'\\': sb.Append("\\\\"); break; + case (byte)'"': sb.Append("\\\""); break; + case (byte)'\n': sb.Append("\\n"); break; + case (byte)'\r': sb.Append("\\r"); break; + case (byte)'\t': sb.Append("\\t"); break; + case (byte)'\b': sb.Append("\\b"); break; + case (byte)'\a': sb.Append("\\a"); break; + default: sb.Append("\\x").Append(b.ToString("X2")); break; + } + } + else + { + sb.Append((char)b); + } + } + + sb.Append('"'); + } + } +} \ No newline at end of file diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs new file mode 100644 index 00000000..f987a10c --- /dev/null +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -0,0 +1,698 @@ +using System.Text; +using NRedisStack.Search; +using NRedisStack.Search.Aggregation; +using Xunit; +using Xunit.Abstractions; + +namespace NRedisStack.Tests.Search; + +public class HybridSearchUnitTests(ITestOutputHelper log) +{ + private string Index { get; } = "myindex"; + + private ICollection GetArgs(HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + { + Assert.Equal("FT.HYBRID", query.Command); + var args = query.GetArgs(Index, parameters); + log.WriteLine(query.Command + " " + string.Join(" ", args)); + return args; + } + + [Fact] + public void EmptySearch() + { + HybridSearchQuery query = new(); + object[] expected = [Index]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void BasicSearch() + { + HybridSearchQuery query = new(); + query.Search("foo"); + + object[] expected = [Index, "SEARCH", "foo"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void BasicSearch_WithNullScorer(bool withAlias) // test: no SCORER added + { + HybridSearchQuery query = new(); + HybridSearchQuery.SearchConfig queryConfig = "foo"; + if (withAlias) queryConfig = queryConfig.WithScoreAlias("score_alias"); + query.Search(queryConfig); + + object[] expected = [Index, "SEARCH", "foo"]; + if (withAlias) + { + expected = [.. expected, "YIELD_SCORE_AS", "score_alias"]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void BasicSearch_WithSimpleScorer(bool withAlias) + { + HybridSearchQuery query = new(); + HybridSearchQuery.SearchConfig queryConfig = "foo"; + queryConfig = queryConfig.WithScorer(Scorer.TfIdf); + if (withAlias) queryConfig = queryConfig.WithScoreAlias("score_alias"); + query.Search(queryConfig); + + object[] expected = [Index, "SEARCH", "foo", "SCORER", "TFIDF"]; + if (withAlias) + { + expected = [.. expected, "YIELD_SCORE_AS", "score_alias"]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData("TFIDF")] + [InlineData("TFIDF.DOCNORM")] + [InlineData("BM25STD")] + [InlineData("BM25STD.NORM")] + [InlineData("DISMAX")] + [InlineData("DOCSCORE")] + [InlineData("HAMMING")] + public void BasicSearch_WithKnownSimpleScorers(string scenario) + { + HybridSearchQuery query = new(); + HybridSearchQuery.SearchConfig queryConfig = "foo"; + queryConfig = queryConfig.WithScorer(scenario switch + { + "TFIDF" => Scorer.TfIdf, + "TFIDF.DOCNORM" => Scorer.TfIdfDocNorm, + "BM25STD" => Scorer.BM25Std, + "BM25STD.NORM" => Scorer.BM25StdNorm, + "DISMAX" => Scorer.DisMax, + "DOCSCORE" => Scorer.DocScore, + "HAMMING" => Scorer.Hamming, + _ => throw new NotImplementedException(), + }); + query.Search(queryConfig); + + object[] expected = [Index, "SEARCH", "foo", "SCORER", scenario]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void BasicSearch_WithBM25StdTanh() + { + HybridSearchQuery query = new(); + query.Search(new("foo", scorer: Scorer.BM25StdTanh(5))); + + object[] expected = [Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void BasicVectorSearch() + { + HybridSearchQuery query = new(); + query.VectorSearch("vfield", Array.Empty()); + + object[] expected = [Index, "VSIM", "vfield", ""]; + Assert.Equivalent(expected, GetArgs(query)); + } + + private static readonly ReadOnlyMemory SomeRandomDataHere = new float[] { 1, 2, 3, 4 }; + + private const string SomeRandomVectorValue = "AACAPwAAAEAAAEBAAACAQA=="; + + [Fact] + public void BasicNonZeroLengthVectorSearch() + { + HybridSearchQuery query = new(); + query.VectorSearch("vfield", SomeRandomDataHere); + + object[] expected = [Index, "VSIM", "vfield", SomeRandomVectorValue]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlias) + { + HybridSearchQuery query = new(); + var searchConfig = new HybridSearchQuery.VectorSearchConfig("vField", SomeRandomDataHere); + if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); + searchConfig = searchConfig.WithMethod(VectorSearchMethod.NearestNeighbour( + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSearch(searchConfig); + + object[] expected = + [Index, "VSIM", "vField", SomeRandomVectorValue, "KNN", withDistanceAlias ? 4 : 2, "K", 10]; + if (withDistanceAlias) + { + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + } + + if (withScoreAlias) + { + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDistanceAlias) + { + HybridSearchQuery query = new(); + var searchConfig = new HybridSearchQuery.VectorSearchConfig("vfield", SomeRandomDataHere); + if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); + searchConfig = searchConfig.WithMethod(VectorSearchMethod.NearestNeighbour( + 16, + maxTopCandidates: 100, + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSearch(searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", SomeRandomVectorValue, "KNN", withDistanceAlias ? 6 : 4, "K", 16, + "EF_RUNTIME", 100 + ]; + if (withDistanceAlias) + { + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + } + + if (withScoreAlias) + { + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAlias) + { + HybridSearchQuery query = new(); + var searchConfig = new HybridSearchQuery.VectorSearchConfig("vfield", SomeRandomDataHere); + if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); + searchConfig = searchConfig.WithMethod(VectorSearchMethod.Range(4.2, + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSearch(searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", SomeRandomVectorValue, "RANGE", withDistanceAlias ? 4 : 2, "RADIUS", + 4.2 + ]; + if (withDistanceAlias) + { + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + } + + if (withScoreAlias) + { + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool withDistanceAlias) + { + HybridSearchQuery query = new(); + HybridSearchQuery.VectorSearchConfig vsimConfig = new("vfield", SomeRandomDataHere); + if (withScoreAlias) vsimConfig = vsimConfig.WithScoreAlias("my_score_alias"); + vsimConfig = vsimConfig.WithMethod(VectorSearchMethod.Range(4.2, + epsilon: 0.06, + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSearch(vsimConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", SomeRandomVectorValue, "RANGE", withDistanceAlias ? 6 : 4, "RADIUS", + 4.2, "EPSILON", 0.06 + ]; + if (withDistanceAlias) + { + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + } + + if (withScoreAlias) + { + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void BasicVectorSearch_WithFilter_NoPolicy() + { + HybridSearchQuery query = new(); + query.VectorSearch(new("vfield", SomeRandomDataHere, filter: "@foo:bar")); + + object[] expected = + [ + Index, "VSIM", "vfield", SomeRandomVectorValue, "FILTER", "@foo:bar" + ]; + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Combine_DefaultLinear() + { + HybridSearchQuery query = new(); + query.Combine(HybridSearchQuery.Combiner.Linear()); + object[] expected = [Index, "COMBINE", "LINEAR", 0]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Combine_Linear_EqualSplit_WithAlias() + { + HybridSearchQuery query = new(); + query.Combine(HybridSearchQuery.Combiner.Linear(0.5, 0.5), "my_combined_alias"); + object[] expected = + [Index, "COMBINE", "LINEAR", 4, "ALPHA", 0.5, "BETA", 0.5, "YIELD_SCORE_AS", "my_combined_alias"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Combine_DefaultRrf_WithAlias() + { + HybridSearchQuery query = new(); + query.Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(), "my_combined_alias"); + object[] expected = [Index, "COMBINE", "RRF", 0, "YIELD_SCORE_AS", "my_combined_alias"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(null, null)] + [InlineData(42, null)] + [InlineData(null, 12.1)] + [InlineData(42, 12.1)] + public void Combine_NonDefaultRrf(int? window, double? constant) + { + HybridSearchQuery query = new(); + query.Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(window, constant)); + object[] expected = [Index, "COMBINE", "RRF", (window is not null ? 2 : 0) + (constant is not null ? 2 : 0)]; + if (window is not null) + { + expected = [.. expected, "WINDOW", window]; + } + + if (constant is not null) + { + expected = [.. expected, "CONSTANT", constant]; + } + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void LoadField() + { + HybridSearchQuery query = new(); + query.ReturnFields("field1"); + object[] expected = [Index, "LOAD", 1, "field1"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void LoadFields() + { + HybridSearchQuery query = new(); + query.ReturnFields("field1", "field2"); + object[] expected = [Index, "LOAD", 2, "field1", "field2"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void LoadEmptyFields() + { + HybridSearchQuery query = new(); + query.ReturnFields([]); + object[] expected = [Index]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void GroupBy_SingleField() + { + HybridSearchQuery query = new(); + query.GroupBy("field1"); + object[] expected = [Index, "GROUPBY", 1, "field1"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void GroupBy_SingleField_WithReducer_NoAlias() + { + HybridSearchQuery query = new(); + query.GroupBy("field1") + .Reduce(Reducers.Count().As(null!)); // workaround https://github.com/redis/NRedisStack/issues/453 + object[] expected = [Index, "GROUPBY", 1, "field1", "REDUCE", "COUNT", 0]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void GroupBy_SingleField_WithReducer_WithAlias() + { + HybridSearchQuery query = new(); + query.GroupBy("field1").Reduce(Reducers.Count().As("qty")); + object[] expected = [Index, "GROUPBY", 1, "field1", "REDUCE", "COUNT", 0, "AS", "qty"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void GroupBy_SingleField_WithReducers_WithAlias() + { + HybridSearchQuery query = new(); + query.GroupBy("field1").Reduce( + Reducers.Min("@field2").As("min"), + Reducers.Max("@field2").As("max"), + Reducers.Count().As("qty")); + object[] expected = + [ + Index, "GROUPBY", 1, "field1", + "REDUCE", "MIN", 1, "@field2", "AS", "min", + "REDUCE", "MAX", 1, "@field2", "AS", "max", + "REDUCE", "COUNT", 0, "AS", "qty" + ]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void GroupBy_MultipleFields() + { + HybridSearchQuery query = new(); + query.GroupBy(["field1", "field2"]); + object[] expected = [Index, "GROUPBY", 2, "field1", "field2"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void GroupBy_MultipleFields_WithReducer() + { + HybridSearchQuery query = new(); + query.GroupBy(["field1", "field2"]).Reduce(Reducers.Quantile("@field3", 0.5)); + object[] expected = [Index, "GROUPBY", 2, "field1", "field2", "REDUCE", "QUANTILE", 2, "@field3", 0.5]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Apply() + { + HybridSearchQuery query = new(); + query.Apply(new("@field1 + @field2", "sum")); + object[] expected = [Index, "APPLY", "@field1 + @field2", "AS", "sum"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Apply_Multi() + { + HybridSearchQuery query = new(); + query.Apply(new("@field1 + @field2", "sum"), "@field3 + @field4"); + object[] expected = [Index, "APPLY", "@field1 + @field2", "AS", "sum", "APPLY", "@field3 + @field4"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_EmptyStrings() + { + HybridSearchQuery query = new(); + string[] sortBy = []; + query.SortBy(sortBy); + object[] expected = [Index]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_EmptySortedFields() + { + HybridSearchQuery query = new(); + SortedField[] sortBy = []; + query.SortBy(sortBy); + object[] expected = [Index]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void NoSort() + { + HybridSearchQuery query = new(); + query.NoSort(); + object[] expected = [Index, "NOSORT"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_SingleString() + { + HybridSearchQuery query = new(); + query.SortBy("field1"); + object[] expected = [Index, "SORTBY", 1, "field1"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_SingleSortedFieldAsc() + { + HybridSearchQuery query = new(); + query.SortBy(SortedField.Asc("field1")); + object[] expected = [Index, "SORTBY", 1, "field1"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_SingleSortedFieldDesc() + { + HybridSearchQuery query = new(); + query.SortBy(SortedField.Desc("field1")); + object[] expected = [Index, "SORTBY", 2, "field1", "DESC"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_MultiString() + { + HybridSearchQuery query = new(); + query.SortBy("field1", "field2"); + object[] expected = [Index, "SORTBY", 2, "field1", "field2"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_MultiSortedFieldAsc() + { + HybridSearchQuery query = new(); + query.SortBy(SortedField.Asc("field1"), SortedField.Asc("field2")); + object[] expected = [Index, "SORTBY", 2, "field1", "field2"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_MultiSortedFieldDesc() + { + HybridSearchQuery query = new(); + query.SortBy(SortedField.Desc("field1"), SortedField.Desc("field2")); + object[] expected = [Index, "SORTBY", 4, "field1", "DESC", "field2", "DESC"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SortBy_MultiSortedFieldMixed() + { + HybridSearchQuery query = new(); + query.SortBy(SortedField.Desc("field1"), SortedField.Asc("field2")); + object[] expected = [Index, "SORTBY", 3, "field1", "DESC", "field2"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Filter() + { + HybridSearchQuery query = new(); + query.Filter("@field1:bar"); + object[] expected = [Index, "FILTER", "@field1:bar"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Limit() + { + HybridSearchQuery query = new(); + query.Limit(12, 54); + object[] expected = [Index, "LIMIT", 12, 54]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void ExplainScoreImplicit() + { + HybridSearchQuery query = new(); + query.ExplainScore(); + object[] expected = [Index, "EXPLAINSCORE"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExplainScoreExplicit(bool enabled) + { + HybridSearchQuery query = new(); + query.ExplainScore(enabled); + object[] expected = enabled ? [Index, "EXPLAINSCORE"] : [Index]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void Timeout() + { + HybridSearchQuery query = new(); + query.Timeout(TimeSpan.FromSeconds(1)); + object[] expected = [Index, "TIMEOUT", 1000]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void SimpleCursor() + { + HybridSearchQuery query = new(); + query.WithCursor(10); + object[] expected = [Index, "WITHCURSOR"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + + [Fact] + public void CusorWithCount() + { + HybridSearchQuery query = new(); + query.WithCursor(15); + object[] expected = [Index, "WITHCURSOR", "COUNT", 15]; + Assert.Equivalent(expected, GetArgs(query)); + } + + + [Fact] + public void CursorWithMaxIdle() + { + HybridSearchQuery query = new(); + query.WithCursor(maxIdle: TimeSpan.FromSeconds(10)); + object[] expected = [Index, "WITHCURSOR", "MAXIDLE", 10000]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void CursorWithCountAndMaxIdle() + { + HybridSearchQuery query = new(); + query.WithCursor(15, maxIdle: TimeSpan.FromSeconds(10)); + object[] expected = [Index, "WITHCURSOR", "COUNT", 15, "MAXIDLE", 10000]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void ParameterizedQuery() + { + HybridSearchQuery query = new(); + query.Search("$s").VectorSearch("@field", "$v"); + + // issue that query, with parameter values from a dictionary + IReadOnlyDictionary args = new Dictionary + { + { "s", "abc"}, + {"v", VectorData.Create(SomeRandomDataHere) } + }; + object[] expected = [Index, "SEARCH", "$s", "VSIM", "@field", "$v", "PARAMS", 4, "s", "abc", "v", SomeRandomVectorValue]; + Assert.Equivalent(expected, GetArgs(query, args)); + + // issue a second query against the same "query" instance, with different parameter values, this time from an object + args = Parameters.From(new { s = "def", v = SomeRandomDataHere }); + expected[8] = "def"; // update our expectations + expected[10] = SomeRandomVectorValue; + Assert.Equivalent(expected, GetArgs(query, args)); + } + + [Fact] + public void MakeMeOneWithEverything() + { + HybridSearchQuery query = new(); + var args = new Dictionary + { + ["x"] = 42, + ["y"] = "abc" + }; + query.Search(new("foo", Scorer.BM25StdTanh(5), "text_score_alias")) + .VectorSearch(new HybridSearchQuery.VectorSearchConfig("bar", new float[] { 1, 2, 3 }, + VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias")) + .WithFilter("@foo:bar").WithScoreAlias("vector_score_alias")) + .Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(10, 0.5), "my_combined_alias") + .ReturnFields("field1", "field2") + .GroupBy("field1").Reduce(Reducers.Quantile("@field3", 0.5).As("reducer_alias")) + .Apply(new("@field1 + @field2", "apply_alias")) + .SortBy(SortedField.Asc("field1"), SortedField.Desc("field2")) + .Filter("@field1:bar") + .Limit(12, 54) + .ExplainScore() + .Timeout(TimeSpan.FromSeconds(1)) + .WithCursor(10, TimeSpan.FromSeconds(10)); + object[] expected = + [ + Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5, "YIELD_SCORE_AS", + "text_score_alias", "VSIM", "bar", + "AACAPwAAAEAAAEBA", "KNN", 6, "K", 10, "EF_RUNTIME", 100, "YIELD_DISTANCE_AS", "vector_distance_alias", "FILTER", + "@foo:bar", "YIELD_SCORE_AS", "vector_score_alias", "COMBINE", "RRF", 4, "WINDOW", 10, "CONSTANT", 0.5, + "YIELD_SCORE_AS", "my_combined_alias", "LOAD", 2, "field1", "field2", "GROUPBY", 1, "field1", "REDUCE", + "QUANTILE", 2, "@field3", 0.5, "AS", "reducer_alias", "APPLY", "@field1 + @field2", "AS", "apply_alias", + "SORTBY", 3, "field1", "field2", "DESC", "FILTER", "@field1:bar", "LIMIT", 12, 54, + "PARAMS", 4, "x", 42, "y", "abc", + "EXPLAINSCORE", "TIMEOUT", + "WITHCURSOR", "COUNT", 10, "MAXIDLE", 10000 + ]; + + log.WriteLine(query.Command + " " + string.Join(" ", expected)); + log.WriteLine("vs"); + Assert.Equivalent(expected, GetArgs(query, args)); + } + + [Fact] + public void Freezing() + { + HybridSearchQuery query = new(); + query.Limit(3, 4); // fine + query.Limit(5, 6); // fine + + query.GetArgs("abc", null); // indirect freeze + Assert.Throws(() => query.Limit(7, 8)); + + query.AllowModification(); + query.Limit(9, 10); // fine + query.Limit(11, 12); // fine + } +} \ No newline at end of file diff --git a/tests/dockers/docker-compose.yml b/tests/dockers/docker-compose.yml index bd6b7963..2bc3da44 100644 --- a/tests/dockers/docker-compose.yml +++ b/tests/dockers/docker-compose.yml @@ -3,7 +3,7 @@ services: redis: - image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.2.1} + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre} container_name: redis-standalone environment: - TLS_ENABLED=yes @@ -21,7 +21,7 @@ services: - all cluster: - image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.2.1} + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre} container_name: redis-cluster environment: - REDIS_CLUSTER=yes