From d4f803e5150d6274590486cce7afc031c3fd140b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 10 Nov 2025 17:06:03 +0000 Subject: [PATCH 01/21] in-progress hybrid search --- src/NRedisStack/NRedisStack.csproj | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 13 - .../Search/HybridSearchQuery.Combiner.cs | 117 +++++ .../Search/HybridSearchQuery.Command.cs | 229 ++++++++++ .../Search/HybridSearchQuery.QueryConfig.cs | 52 +++ .../HybridSearchQuery.VectorSearchConfig.cs | 105 +++++ .../HybridSearchQuery.VectorSearchMethod.cs | 146 ++++++ src/NRedisStack/Search/HybridSearchQuery.cs | 205 +++++++++ src/NRedisStack/Search/Reducer.cs | 1 + src/NRedisStack/Search/Scorer.cs | 92 ++++ .../Search/HybridSearchIntegrationTests.cs | 9 + .../Search/HybridSearchUnitTests.cs | 430 ++++++++++++++++++ 12 files changed, 1387 insertions(+), 13 deletions(-) create mode 100644 src/NRedisStack/Search/HybridSearchQuery.Combiner.cs create mode 100644 src/NRedisStack/Search/HybridSearchQuery.Command.cs create mode 100644 src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs create mode 100644 src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs create mode 100644 src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs create mode 100644 src/NRedisStack/Search/HybridSearchQuery.cs create mode 100644 src/NRedisStack/Search/Scorer.cs create mode 100644 tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs create mode 100644 tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs 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/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 00dcb3fb..e69de29b 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,13 +0,0 @@ -#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! diff --git a/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs b/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs new file mode 100644 index 00000000..ee791ef6 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs @@ -0,0 +1,117 @@ +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); + + 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 = 2; + if (_window is not null) count += 2; + if (_constant is not null) count += 2; + return count; + } + + internal override void AddOwnArgs(List args) + { + args.Add(Method); + int tokens = 0; + if (_window is not null) tokens += 2; + if (_constant is not null) tokens += 2; + args.Add(tokens); + if (_window is not null) + { + args.Add("WINDOW"); + args.Add(_window); + } + + 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() => IsDefault ? 2 : 6; + + private bool IsDefault => ReferenceEquals(this, s_Default); + + internal override void AddOwnArgs(List args) + { + args.Add(Method); + if (IsDefault) + { + args.Add(0); + } + else + { + args.Add(4); + args.Add("ALPHA"); + args.Add(_alpha); + args.Add("BETA"); + args.Add(_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..90821c4c --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -0,0 +1,229 @@ +using System.Buffers; +using System.Diagnostics; +using System.Runtime.InteropServices; +using NRedisStack.Search.Aggregation; +using StackExchange.Redis; + +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + internal string Command => "FT.HYBRID"; + + internal ICollection GetArgs(in RedisKey index, Dictionary? parameters) + { + var count = GetOwnArgsCount(); + var args = new List(count + 1); + args.Add(index); + AddOwnArgs(args); + Debug.Assert(args.Count == count + 1, + $"Arg count mismatch; check {nameof(GetOwnArgsCount)} ({count}) vs {nameof(AddOwnArgs)} ({args.Count - 1})"); + return args; + } + + internal int GetOwnArgsCount() + { + int count = 0; // note index is not included here + if (_query is not null) + { + count += 2 + (_queryConfig?.GetOwnArgsCount() ?? 0); + } + + if (_vectorField is not null) + { + count += 3 + (_vectorConfig?.GetOwnArgsCount() ?? 0); + } + + if (_combiner is not null) + { + count += 1 + _combiner.GetOwnArgsCount(); + if (_combineScoreAlias != null) count += 2; + } + + if (_loadFields is not null) + { + count += 2 + _loadFields.Length; + } + + if (_groupByFieldOrFields is not null) + { + count += 2; + if (_groupByFieldOrFields is string[] fields) + { + count += fields.Length; + } + else + { + count += 1; // single string + } + + if (_groupByReducer is not null) + { + count += 3 + _groupByReducer.ArgCount(); + } + } + + if (_applyExpression is not null) + { + count += 4; + } + + if (_sortByFieldOrFields is not null) + { + count += 2; + switch (_sortByFieldOrFields) + { + case string: + count += 1; + break; + case string[] strings: + count += strings.Length; + break; + case SortedField field when field.Order == SortedField.SortOrder.ASC: + count += 1; + break; + case SortedField field: + count += 2; + break; + case SortedField[] fields: + foreach (var field in fields) + { + if (field.Order == SortedField.SortOrder.DESC) count++; + } + + count += fields.Length; + break; + } + } + + return count; + } + + internal void AddOwnArgs(List args) + { + if (_query is not null) + { + args.Add("SEARCH"); + args.Add(_query); + _queryConfig?.AddOwnArgs(args); + } + + if (_vectorField is not null) + { + args.Add("VSIM"); + args.Add(_vectorField); +#if NET || NETSTANDARD2_1_OR_GREATER + args.Add(Convert.ToBase64String(_vectorData.Span)); +#else + if (MemoryMarshal.TryGetArray(_vectorData, out ArraySegment segment)) + { + args.Add(Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count)); + } + else + { + var span = _vectorData.Span; + var oversized = ArrayPool.Shared.Rent(span.Length); + span.CopyTo(oversized); + args.Add(Convert.ToBase64String(oversized, 0, span.Length)); + ArrayPool.Shared.Return(oversized); + } +#endif + + _vectorConfig?.AddOwnArgs(args); + } + + if (_combiner is not null) + { + args.Add("COMBINE"); + _combiner.AddOwnArgs(args); + + if (_combineScoreAlias != null) + { + args.Add("YIELD_SCORE_AS"); + args.Add(_combineScoreAlias); + } + } + + if (_loadFields is not null) + { + args.Add("LOAD"); + args.Add(_loadFields.Length); + args.AddRange(_loadFields); + } + + 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"); + } + + if (_groupByReducer is not null) + { + args.Add("REDUCE"); + args.Add(_groupByReducer.Name); + _groupByReducer.SerializeRedisArgs(args); // includes the count + } + } + + if (_applyExpression is not null) + { + args.Add("APPLY"); + args.Add(_applyExpression); + args.Add("AS"); + args.Add(_applyAlias!); + } + + if (_sortByFieldOrFields is not null) + { + 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 field when field.Order == SortedField.SortOrder.ASC: + 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"); + } + } + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs new file mode 100644 index 00000000..979d8db2 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs @@ -0,0 +1,52 @@ +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + public sealed class QueryConfig + { + private Scorer? _scorer; + + /// + /// Scoring algorithm for the query. + /// + public QueryConfig Scorer(Scorer scorer) + { + _scorer = scorer; + return this; + } + + private string? _scoreAlias; + + /// + /// Include the score in the query results. + /// + public QueryConfig ScoreAlias(string scoreAlias) + { + _scoreAlias = scoreAlias; + return this; + } + + internal int GetOwnArgsCount() + { + int count = 0; + if (_scorer != null) count += 1 + _scorer.GetOwnArgsCount(); + if (_scoreAlias != null) count += 2; + return count; + } + + internal void AddOwnArgs(List args) + { + 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..40a0a2bc --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -0,0 +1,105 @@ +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + public sealed class VectorSearchConfig + { + private string? _filter; + private VectorFilterPolicy? _filterPolicy; + private int? _filterBatchSize; + + /// + /// Pre-filter for VECTOR results + /// + public VectorSearchConfig Filter(string filter, VectorFilterPolicy? policy = null, int? batchSize = null) + { + _filter = filter; + _filterPolicy = policy; + _filterBatchSize = batchSize; + return this; + } + + /// + /// The filter policy to apply + /// + public enum VectorFilterPolicy + { + AdHoc, + Batches, + Acorn, + } + + private string? _scoreAlias; + + /// + /// Include the score in the query results. + /// + public VectorSearchConfig ScoreAlias(string scoreAlias) + { + _scoreAlias = scoreAlias; + return this; + } + + + private VectorSearchMethod? _method; + + /// + /// The method to use for vector search. + /// + public VectorSearchConfig Method(VectorSearchMethod method) + { + _method = method; + return this; + } + + internal int GetOwnArgsCount() + { + int count = 0; + if (_method != null) count += _method.GetOwnArgsCount(); + if (_filter != null) + { + count += 2; + if (_filterPolicy != null) + { + count += 2; + if (_filterBatchSize != null) count += 2; + } + } + + if (_scoreAlias != null) count += 2; + return count; + } + + internal void AddOwnArgs(List args) + { + _method?.AddOwnArgs(args); + if (_filter != null) + { + args.Add("FILTER"); + args.Add(_filter); + if (_filterPolicy != null) + { + args.Add("POLICY"); + args.Add(_filterPolicy switch + { + VectorFilterPolicy.AdHoc => "ADHOC", + VectorFilterPolicy.Batches => "BATCHES", + VectorFilterPolicy.Acorn => "ACORN", + _ => _filterPolicy.ToString()!, + }); + if (_filterBatchSize != null) + { + args.Add("BATCH_SIZE"); + args.Add(_filterBatchSize); + } + } + } + + if (_scoreAlias != null) + { + args.Add("YIELD_SCORE_AS"); + args.Add(_scoreAlias); + } + } + } +} \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs new file mode 100644 index 00000000..c622aa8a --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs @@ -0,0 +1,146 @@ +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + 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/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs new file mode 100644 index 00000000..97d34294 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -0,0 +1,205 @@ +using NRedisStack.Search.Aggregation; + +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + private string? _query; + QueryConfig? _queryConfig; + + /// + /// Specify the textual search portion of the query. + /// + public HybridSearchQuery Search(string query, QueryConfig? config = null) + { + _query = query; + _queryConfig = config; + return this; + } + + private string? _vectorField; + private ReadOnlyMemory _vectorData; + private VectorSearchConfig? _vectorConfig; + + /// + /// Specify the vector search portion of the query. + /// + public HybridSearchQuery VectorSimilaritySearch(string field, ReadOnlyMemory data, VectorSearchConfig? config = null) + { + _vectorField = field; + _vectorData = data; + _vectorConfig = 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, string? scoreAlias = null) + { + _combiner = combiner; + _combineScoreAlias = scoreAlias; + return this; + } + + private string[]? _loadFields; + + /// + /// Add the list of fields to return in the results. + /// + public HybridSearchQuery Load(params string[] fields) + { + _loadFields = fields; + return this; + } + + private int _rerankTop; + private string? _rerankExpression; + + /// + /// Specify the re-rank configuration. + /// + public HybridSearchQuery ReRank(int top, string expression) + { + _rerankTop = top; + _rerankExpression = expression; + return this; + } + + private object? _groupByFieldOrFields; + private Reducer? _groupByReducer; + + /// + /// Perform a group by operation on the results. + /// + public HybridSearchQuery GroupBy(string field, Reducer? reducer = null) + { + _groupByFieldOrFields = field; + _groupByReducer = reducer; + return this; + } + + /// + /// Perform a group by operation on the results. + /// + public HybridSearchQuery GroupBy(string[] fields, Reducer? reducer = null) + { + _groupByFieldOrFields = fields; + _groupByReducer = reducer; + return this; + } + + private string? _applyExpression, _applyAlias; + + /// + /// Apply a field transformation expression to the results. + /// + public HybridSearchQuery Apply(string expression, string alias) + { + _applyExpression = expression; + _applyAlias = alias; + return this; + } + + private object? _sortByFieldOrFields; + + /// + /// Sort the final results by the specified fields. + /// + public HybridSearchQuery SortBy(params SortedField[] fields) + { + _sortByFieldOrFields = fields; + return this; + } + + /// + /// Sort the final results by the specified fields. + /// + public HybridSearchQuery SortBy(params string[] fields) + { + _sortByFieldOrFields = fields; + return this; + } + + /// + /// Sort the final results by the specified field. + /// + public HybridSearchQuery SortBy(SortedField field) + { + _sortByFieldOrFields = field; + return this; + } + /// + /// Sort the final results by the specified field. + /// + public HybridSearchQuery SortBy(string field) + { + _sortByFieldOrFields = field; + return this; + } + + private string? _filter; + + /// + /// Final result filtering + /// + public HybridSearchQuery Filter(string expression) + { + _filter = expression; + return this; + } + + private int _pagingOffset = -1, _pagingCount = -1; + public HybridSearchQuery Limit(int offset, int count) + { + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); + _pagingOffset = offset; + _pagingCount = count; + return this; + } + + private string? _language; + /// + /// The language to use for search queries. + /// + public HybridSearchQuery Language(string language) + { + _language = language; + return this; + } + + private bool _explainScore; + + /// + /// Include score explanations + /// + public HybridSearchQuery ExplainScore(bool explainScore = true) + { + _explainScore = explainScore; + return this; + } + + private bool _timeout; + /// + /// Apply the global timeout setting. + /// + public HybridSearchQuery Timeout(bool timeout = true) + { + _timeout = timeout; + return this; + } + + private int _cursorCount; + private TimeSpan _cursorMaxIdle; + public HybridSearchQuery WithCursor(int count, TimeSpan maxIdle = default) + { + if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count)); + _cursorCount = count; + _cursorMaxIdle = maxIdle; + return this; + } +} \ No newline at end of file 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/Scorer.cs b/src/NRedisStack/Search/Scorer.cs new file mode 100644 index 00000000..b8044ab0 --- /dev/null +++ b/src/NRedisStack/Search/Scorer.cs @@ -0,0 +1,92 @@ +namespace NRedisStack.Search; + +/// +/// See https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/scoring/ for more details +/// +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. + /// + 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 normalised by the linear function tanh(x). + /// + /// used to smooth the function and the score values. + public static Scorer BM25StdTanh(int y = Bm25StdTanh.DEFAULT_Y) => Bm25StdTanh.Create(y); + + /// + /// 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/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs new file mode 100644 index 00000000..09e61430 --- /dev/null +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -0,0 +1,9 @@ +using Xunit.Abstractions; + +namespace NRedisStack.Tests.Search; + +public class HybridSearchIntegrationTests(EndpointsFixture endpointsFixture, ITestOutputHelper log) + : AbstractNRedisStackTest(endpointsFixture, log), IDisposable +{ + +} \ 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..ae3a812d --- /dev/null +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -0,0 +1,430 @@ +using System.Reflection; +using System.Text; +using NRedisStack.Search; +using NRedisStack.Search.Aggregation; +using StackExchange.Redis; +using Xunit; +using Xunit.Abstractions; + +namespace NRedisStack.Tests.Search; + +public class HybridSearchUnitTests(ITestOutputHelper log) +{ + private readonly RedisKey _index = "myindex"; + private ref readonly RedisKey Index => ref _index; + + private ICollection GetArgs(HybridSearchQuery query, Dictionary? 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.QueryConfig queryConfig = new(); + if (withAlias) queryConfig.ScoreAlias("score_alias"); + query.Search("foo", 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.QueryConfig queryConfig = new(); + queryConfig.Scorer(Scorer.TfIdf); + if (withAlias) queryConfig.ScoreAlias("score_alias"); + query.Search("foo", 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.QueryConfig queryConfig = new(); + queryConfig.Scorer(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("foo", queryConfig); + + object[] expected = [Index, "SEARCH", "foo", "SCORER", scenario]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void BasicSearch_WithBM25StdTanh() + { + HybridSearchQuery query = new(); + query.Search("foo", new HybridSearchQuery.QueryConfig().Scorer(Scorer.BM25StdTanh(5))); + + object[] expected = [Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void BasicZeroLengthVectorSearch(bool withConfig) + { + HybridSearchQuery query = new(); + if (withConfig) + { + HybridSearchQuery.VectorSearchConfig config = new(); + query.VectorSimilaritySearch("vfield", Array.Empty(), config); + } + else + { + query.VectorSimilaritySearch("vfield", Array.Empty()); + } + + object[] expected = [Index, "VSIM", "vfield", ""]; + Assert.Equivalent(expected, GetArgs(query)); + } + + private static readonly ReadOnlyMemory SomeRandomDataHere = Encoding.UTF8.GetBytes("some random data here!"); + + [Fact] + public void BasicNonZeroLengthVectorSearch() + { + HybridSearchQuery query = new(); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere); + + object[] expected = [Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ=="]; + 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(); + if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); + searchConfig.Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + + object[] expected = + [Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "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(); + if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); + searchConfig.Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( + 16, + maxTopCandidates: 100, + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "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(); + if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); + searchConfig.Method(HybridSearchQuery.VectorSearchMethod.Range(4.2, + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "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(); + var searchConfig = new HybridSearchQuery.VectorSearchConfig(); + if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); + searchConfig.Method(HybridSearchQuery.VectorSearchMethod.Range(4.2, + epsilon: 0.06, + distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "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(); + var searchConfig = new HybridSearchQuery.VectorSearchConfig(); + searchConfig.Filter("@foo:bar"); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", @"FILTER", "@foo:bar" + ]; + + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.AdHoc)] + [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches)] + [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches, 100)] + [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Acorn)] + public void BasicVectorSearch_WithFilter_WithPolicy(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy policy, int? batchSize = null) + { + HybridSearchQuery query = new(); + var searchConfig = new HybridSearchQuery.VectorSearchConfig(); + searchConfig.Filter("@foo:bar", policy, batchSize); + query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + + object[] expected = + [ + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", @"FILTER", "@foo:bar", "POLICY", policy.ToString().ToUpper() + ]; + if (batchSize != null) + { + expected = [..expected, "BATCH_SIZE", batchSize]; + } + 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 LoadFields() + { + HybridSearchQuery query = new(); + query.Load("field1", "field2"); + object[] expected = [Index, "LOAD", 2, "field1", "field2"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Fact] + public void LoadEmptyFields() + { + HybridSearchQuery query = new(); + query.Load([]); + object[] expected = [Index, "LOAD", 0]; + 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() + { + HybridSearchQuery query = new(); + query.GroupBy("field1", Reducers.Count()); + object[] expected = [Index, "GROUPBY", 1, "field1", "REDUCE", "COUNT", 0]; + 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"], 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("@field1 + @field2", "sum"); + object[] expected = [Index, "APPLY", "@field1 + @field2", "AS", "sum"]; + Assert.Equivalent(expected, GetArgs(query)); + } +} \ No newline at end of file From 4e66b5ae59e450de85fdd24e7a65a43cb00dff01 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 11 Nov 2025 12:21:15 +0000 Subject: [PATCH 02/21] preliminary API and final unit tests (no integration tests yet) --- Directory.Build.props | 2 +- NRedisStack.sln | 6 + src/NRedisStack/Experiments.cs | 40 +++ .../PublicAPI/PublicAPI.Shipped.txt | 12 + .../PublicAPI/PublicAPI.Unshipped.txt | 52 ++++ .../Search/HybridSearchQuery.Command.cs | 86 ++++++- src/NRedisStack/Search/HybridSearchQuery.cs | 9 +- src/NRedisStack/Search/Scorer.cs | 3 + src/NRedisStack/Search/SortedField.cs | 23 +- .../Search/HybridSearchUnitTests.cs | 228 +++++++++++++++++- 10 files changed, 421 insertions(+), 40 deletions(-) create mode 100644 src/NRedisStack/Experiments.cs 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..a7395d62 100644 --- a/NRedisStack.sln +++ b/NRedisStack.sln @@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{84D6210F version.json = version.json 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 +40,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/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/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 e69de29b..94ea8aca 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,52 @@ +[NRS001]NRedisStack.Search.Scorer +[NRS001]override NRedisStack.Search.Scorer.ToString() -> string! +[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.BM25StdTanh(int y = 4) -> 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]NRedisStack.Search.HybridSearchQuery +[NRS001]NRedisStack.Search.HybridSearchQuery.Apply(string! expression, string! alias) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Combine(NRedisStack.Search.HybridSearchQuery.Combiner! combiner, string? scoreAlias = null) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Combiner +[NRS001]NRedisStack.Search.HybridSearchQuery.Combiner.Combiner() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.ExplainScore(bool explainScore = true) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Filter(string! expression) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string! field, NRedisStack.Search.Aggregation.Reducer? reducer = null) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string![]! fields, NRedisStack.Search.Aggregation.Reducer? reducer = null) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.HybridSearchQuery() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.Language(string! language) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Limit(int offset, int count) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Load(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.QueryConfig() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.ScoreAlias(string! scoreAlias) -> NRedisStack.Search.HybridSearchQuery.QueryConfig! +[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.Scorer(NRedisStack.Search.Scorer! scorer) -> NRedisStack.Search.HybridSearchQuery.QueryConfig! +[NRS001]NRedisStack.Search.HybridSearchQuery.ReRank(int top, string! expression) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.Search(string! query, NRedisStack.Search.HybridSearchQuery.QueryConfig? config = null) -> NRedisStack.Search.HybridSearchQuery! +[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(bool timeout = true) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.Filter(string! filter, NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy? policy = null, int? batchSize = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.Method(NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! method) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.ScoreAlias(string! scoreAlias) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Acorn = 2 -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.AdHoc = 0 -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches = 1 -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorSearchConfig() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchMethod +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSimilaritySearch(string! field, System.ReadOnlyMemory data, NRedisStack.Search.HybridSearchQuery.VectorSearchConfig? config = null) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.WithCursor(int count = 0, System.TimeSpan maxIdle = default(System.TimeSpan)) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]override NRedisStack.Search.HybridSearchQuery.Combiner.ToString() -> string! +[NRS001]override NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.ToString() -> string! +[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.VectorSearchMethod.NearestNeighbour(int count = 10, int? maxTopCandidates = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! +[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.Range(double radius, double? epsilon = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 90821c4c..042e76da 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -1,27 +1,29 @@ using System.Buffers; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using NRedisStack.Search.Aggregation; using StackExchange.Redis; namespace NRedisStack.Search; +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] public sealed partial class HybridSearchQuery { internal string Command => "FT.HYBRID"; - internal ICollection GetArgs(in RedisKey index, Dictionary? parameters) + internal ICollection GetArgs(in RedisKey index, IDictionary? parameters) { - var count = GetOwnArgsCount(); + var count = GetOwnArgsCount(parameters); var args = new List(count + 1); args.Add(index); - AddOwnArgs(args); + 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() + internal int GetOwnArgsCount(IDictionary? parameters) { int count = 0; // note index is not included here if (_query is not null) @@ -79,7 +81,7 @@ internal int GetOwnArgsCount() case string[] strings: count += strings.Length; break; - case SortedField field when field.Order == SortedField.SortOrder.ASC: + case SortedField { Order: SortedField.SortOrder.ASC }: count += 1; break; case SortedField field: @@ -96,10 +98,26 @@ internal int GetOwnArgsCount() } } + 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) count++; + + if (_cursorCount >= 0) + { + count++; + if (_cursorCount != 0) count += 2; + if (_cursorMaxIdle > TimeSpan.Zero) count += 2; + } + return count; } - internal void AddOwnArgs(List args) + internal void AddOwnArgs(List args, IDictionary? parameters) { if (_query is not null) { @@ -197,7 +215,7 @@ internal void AddOwnArgs(List args) args.Add(fields.Length); args.AddRange(fields); break; - case SortedField field when field.Order == SortedField.SortOrder.ASC: + case SortedField { Order: SortedField.SortOrder.ASC } field: args.Add(1); args.Add(field.FieldName); break; @@ -225,5 +243,59 @@ internal void AddOwnArgs(List args) 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); + } + } + else + { + foreach (var entry in parameters) + { + args.Add(entry.Key); + args.Add(entry.Value); + } + } + } + + if (_explainScore) args.Add("EXPLAINSCORE"); + if (_timeout) args.Add("TIMEOUT"); + + 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); + } + } } } \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 97d34294..2fdac73f 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -193,11 +193,14 @@ public HybridSearchQuery Timeout(bool timeout = true) return this; } - private int _cursorCount; + private int _cursorCount = -1; // -1: no cursor; 0: default count private TimeSpan _cursorMaxIdle; - public HybridSearchQuery WithCursor(int count, TimeSpan maxIdle = default) + + /// + /// Use a cursor for result iteration. + /// + public HybridSearchQuery WithCursor(int count = 0, TimeSpan maxIdle = default) { - if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count)); _cursorCount = count; _cursorMaxIdle = maxIdle; return this; diff --git a/src/NRedisStack/Search/Scorer.cs b/src/NRedisStack/Search/Scorer.cs index b8044ab0..8805b438 100644 --- a/src/NRedisStack/Search/Scorer.cs +++ b/src/NRedisStack/Search/Scorer.cs @@ -1,8 +1,11 @@ +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() 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/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index ae3a812d..7b801a5d 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -13,7 +13,7 @@ public class HybridSearchUnitTests(ITestOutputHelper log) private readonly RedisKey _index = "myindex"; private ref readonly RedisKey Index => ref _index; - private ICollection GetArgs(HybridSearchQuery query, Dictionary? parameters = null) + private ICollection GetArgs(HybridSearchQuery query, IDictionary? parameters = null) { Assert.Equal("FT.HYBRID", query.Command); var args = query.GetArgs(Index, parameters); @@ -292,13 +292,14 @@ public void BasicVectorSearch_WithFilter_NoPolicy() Assert.Equivalent(expected, GetArgs(query)); } - + [Theory] [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.AdHoc)] [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches)] [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches, 100)] [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Acorn)] - public void BasicVectorSearch_WithFilter_WithPolicy(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy policy, int? batchSize = null) + public void BasicVectorSearch_WithFilter_WithPolicy(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy policy, + int? batchSize = null) { HybridSearchQuery query = new(); var searchConfig = new HybridSearchQuery.VectorSearchConfig(); @@ -307,12 +308,14 @@ public void BasicVectorSearch_WithFilter_WithPolicy(HybridSearchQuery.VectorSear object[] expected = [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", @"FILTER", "@foo:bar", "POLICY", policy.ToString().ToUpper() + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", @"FILTER", "@foo:bar", "POLICY", + policy.ToString().ToUpper() ]; if (batchSize != null) { expected = [..expected, "BATCH_SIZE", batchSize]; } + Assert.Equivalent(expected, GetArgs(query)); } @@ -324,16 +327,17 @@ public void Combine_DefaultLinear() 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"]; + 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() { @@ -362,6 +366,7 @@ public void Combine_NonDefaultRrf(int? window, double? constant) { expected = [..expected, "CONSTANT", constant]; } + Assert.Equivalent(expected, GetArgs(query)); } @@ -373,7 +378,7 @@ public void LoadFields() object[] expected = [Index, "LOAD", 2, "field1", "field2"]; Assert.Equivalent(expected, GetArgs(query)); } - + [Fact] public void LoadEmptyFields() { @@ -391,7 +396,7 @@ public void GroupBy_SingleField() object[] expected = [Index, "GROUPBY", 1, "field1"]; Assert.Equivalent(expected, GetArgs(query)); } - + [Fact] public void GroupBy_SingleField_WithReducer() { @@ -400,7 +405,7 @@ public void GroupBy_SingleField_WithReducer() object[] expected = [Index, "GROUPBY", 1, "field1", "REDUCE", "COUNT", 0]; Assert.Equivalent(expected, GetArgs(query)); } - + [Fact] public void GroupBy_MultipleFields() { @@ -409,7 +414,7 @@ public void GroupBy_MultipleFields() object[] expected = [Index, "GROUPBY", 2, "field1", "field2"]; Assert.Equivalent(expected, GetArgs(query)); } - + [Fact] public void GroupBy_MultipleFields_WithReducer() { @@ -427,4 +432,205 @@ public void Apply() object[] expected = [Index, "APPLY", "@field1 + @field2", "AS", "sum"]; 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 TimeoutImplicit() + { + HybridSearchQuery query = new(); + query.Timeout(); + object[] expected = [Index, "TIMEOUT"]; + Assert.Equivalent(expected, GetArgs(query)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TimeoutExplicit(bool enabled) + { + HybridSearchQuery query = new(); + query.Timeout(enabled); + object[] expected = enabled ? [Index, "TIMEOUT"] : [Index]; + 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 MakeMeOneWithEverything() + { + HybridSearchQuery query = new(); + query.Search("foo", + new HybridSearchQuery.QueryConfig().Scorer(Scorer.BM25StdTanh(5)).ScoreAlias("text_score_alias")) + .VectorSimilaritySearch("bar", new byte[] {1, 2, 3}, + new HybridSearchQuery.VectorSearchConfig() + .Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias")) + .Filter("@foo:bar").ScoreAlias("vector_score_alias")) + .Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(10, 0.5), "my_combined_alias") + .Load("field1", "field2") + .GroupBy("field1", Reducers.Quantile("@field3", 0.5)) + .Apply("@field1 + @field2", "apply_alias") + .SortBy(SortedField.Asc("field1"), SortedField.Desc("field2")) + .Filter("@field1:bar") + .Limit(12, 54) + .ExplainScore() + .Timeout() + .WithCursor(10, TimeSpan.FromSeconds(10)); + + var args = new Dictionary + { + ["x"] = 42, + ["y"] = "abc" + }; + object[] expected = [ + Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5, "YIELD_SCORE_AS", "text_score_alias", "VSIM", "bar", + "AQID", "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, "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)); + } } \ No newline at end of file From 6b531df8d6bfe8a67eeee3804c73140b4a69b983 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 11 Nov 2025 12:25:20 +0000 Subject: [PATCH 03/21] push docs --- docs/docs.csproj | 6 +++++ docs/exp/NRS001.md | 22 +++++++++++++++++++ .../Search/HybridSearchQuery.Command.cs | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 docs/docs.csproj create mode 100644 docs/exp/NRS001.md diff --git a/docs/docs.csproj b/docs/docs.csproj new file mode 100644 index 00000000..977e065b --- /dev/null +++ b/docs/docs.csproj @@ -0,0 +1,6 @@ + + + + netstandard2.0 + + 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/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 042e76da..1d73b594 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -84,7 +84,7 @@ internal int GetOwnArgsCount(IDictionary? parameters) case SortedField { Order: SortedField.SortOrder.ASC }: count += 1; break; - case SortedField field: + case SortedField: count += 2; break; case SortedField[] fields: From 9128f9f3160ee030b6e4394f78ba6f1d6a9929f6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 11 Nov 2025 15:00:01 +0000 Subject: [PATCH 04/21] start integration tests --- NRedisStack.sln | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 28 ++-- .../Search/HybridSearchQuery.Command.cs | 40 ++++-- src/NRedisStack/Search/HybridSearchQuery.cs | 17 ++- src/NRedisStack/Search/HybridSearchResult.cs | 123 ++++++++++++++++++ src/NRedisStack/Search/ISearchCommands.cs | 12 ++ .../Search/ISearchCommandsAsync.cs | 5 + src/NRedisStack/Search/Scorer.cs | 2 + src/NRedisStack/Search/SearchCommands.cs | 11 ++ src/NRedisStack/Search/SearchCommandsAsync.cs | 10 ++ .../Search/HybridSearchIntegrationTests.cs | 54 +++++++- .../Search/HybridSearchUnitTests.cs | 17 ++- tests/dockers/docker-compose.yml | 4 +- 13 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 src/NRedisStack/Search/HybridSearchResult.cs diff --git a/NRedisStack.sln b/NRedisStack.sln index a7395d62..a03db832 100644 --- a/NRedisStack.sln +++ b/NRedisStack.sln @@ -15,6 +15,7 @@ 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}" diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 94ea8aca..d3d2e83b 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,13 +1,9 @@ +[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.HybridSearchQuery.ReturnFields(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.ReturnFields(string! field) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchResult [NRS001]NRedisStack.Search.Scorer -[NRS001]override NRedisStack.Search.Scorer.ToString() -> string! -[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.BM25StdTanh(int y = 4) -> 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]NRedisStack.Search.HybridSearchQuery [NRS001]NRedisStack.Search.HybridSearchQuery.Apply(string! expression, string! alias) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Combine(NRedisStack.Search.HybridSearchQuery.Combiner! combiner, string? scoreAlias = null) -> NRedisStack.Search.HybridSearchQuery! @@ -20,7 +16,6 @@ [NRS001]NRedisStack.Search.HybridSearchQuery.HybridSearchQuery() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.Language(string! language) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Limit(int offset, int count) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.Load(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig [NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.QueryConfig() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.ScoreAlias(string! scoreAlias) -> NRedisStack.Search.HybridSearchQuery.QueryConfig! @@ -44,9 +39,20 @@ [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchMethod [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSimilaritySearch(string! field, System.ReadOnlyMemory data, NRedisStack.Search.HybridSearchQuery.VectorSearchConfig? config = null) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.WithCursor(int count = 0, System.TimeSpan maxIdle = default(System.TimeSpan)) -> NRedisStack.Search.HybridSearchQuery! +[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.HybridSearchQuery.Combiner.ToString() -> string! [NRS001]override NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.ToString() -> string! +[NRS001]override NRedisStack.Search.Scorer.ToString() -> string! [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.VectorSearchMethod.NearestNeighbour(int count = 10, int? maxTopCandidates = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! -[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.Range(double radius, double? epsilon = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! \ No newline at end of file +[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.Range(double radius, double? epsilon = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! +[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.BM25StdTanh(int y = 4) -> 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! \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 1d73b594..1b4d6d81 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -1,18 +1,16 @@ using System.Buffers; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using NRedisStack.Search.Aggregation; using StackExchange.Redis; namespace NRedisStack.Search; -[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] public sealed partial class HybridSearchQuery { internal string Command => "FT.HYBRID"; - internal ICollection GetArgs(in RedisKey index, IDictionary? parameters) + internal ICollection GetArgs(in RedisKey index, IReadOnlyDictionary? parameters) { var count = GetOwnArgsCount(parameters); var args = new List(count + 1); @@ -23,7 +21,7 @@ internal ICollection GetArgs(in RedisKey index, IDictionary? parameters) + internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) { int count = 0; // note index is not included here if (_query is not null) @@ -42,9 +40,14 @@ internal int GetOwnArgsCount(IDictionary? parameters) if (_combineScoreAlias != null) count += 2; } - if (_loadFields is not null) + switch (_loadFieldOrFields) { - count += 2 + _loadFields.Length; + case string: + count += 3; + break; + case string[] fields: + count += 2 + fields.Length; + break; } if (_groupByFieldOrFields is not null) @@ -117,7 +120,7 @@ internal int GetOwnArgsCount(IDictionary? parameters) return count; } - internal void AddOwnArgs(List args, IDictionary? parameters) + internal void AddOwnArgs(List args, IReadOnlyDictionary? parameters) { if (_query is not null) { @@ -162,11 +165,18 @@ internal void AddOwnArgs(List args, IDictionary? paramet } } - if (_loadFields is not null) + switch (_loadFieldOrFields) { - args.Add("LOAD"); - args.Add(_loadFields.Length); - args.AddRange(_loadFields); + 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) @@ -298,4 +308,12 @@ internal void AddOwnArgs(List args, IDictionary? paramet } } } + + internal void Validate() + { + if (_query is null | _vectorField is null) + { + throw new InvalidOperationException($"Both the query ({nameof(Query)}(...)) and vector search ({nameof(VectorSimilaritySearch)}(...))) details must be set."); + } + } } \ No newline at end of file diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 2fdac73f..b5e09a80 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using NRedisStack.Search.Aggregation; namespace NRedisStack.Search; +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] public sealed partial class HybridSearchQuery { private string? _query; @@ -45,14 +47,23 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) return this; } - private string[]? _loadFields; + private object? _loadFieldOrFields; /// /// Add the list of fields to return in the results. /// - public HybridSearchQuery Load(params string[] fields) + public HybridSearchQuery ReturnFields(params string[] fields) // naming for consistency with SearchQuery { - _loadFields = fields; + _loadFieldOrFields = fields; + return this; + } + + /// + /// Add the list of fields to return in the results. + /// + public HybridSearchQuery ReturnFields(string field) // naming for consistency with SearchQuery + { + _loadFieldOrFields = field; return this; } diff --git a/src/NRedisStack/Search/HybridSearchResult.cs b/src/NRedisStack/Search/HybridSearchResult.cs new file mode 100644 index 00000000..455a881b --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchResult.cs @@ -0,0 +1,123 @@ +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; + 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: + var rows = new IReadOnlyDictionary[value.Length]; + for (int j = 0; j < value.Length; j++) + { + rows[j] = ParseRow(value[j]); + } + + obj.Results = rows; + 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, + } + + public long TotalResults { get; private set; } = -1; // initialize to -1 to indicate not set + + public TimeSpan ExecutionTime { get; private set; } + + public string[] Warnings { get; private set; } = []; + + public IReadOnlyDictionary[] Results { get; private set; } = []; +} \ No newline at end of file diff --git a/src/NRedisStack/Search/ISearchCommands.cs b/src/NRedisStack/Search/ISearchCommands.cs index a3c4c387..58c5da94 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 parameters to pass to the query, if any. + /// 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/Scorer.cs b/src/NRedisStack/Search/Scorer.cs index 8805b438..9b9e6d75 100644 --- a/src/NRedisStack/Search/Scorer.cs +++ b/src/NRedisStack/Search/Scorer.cs @@ -30,6 +30,7 @@ private protected Scorer() /// /// A variation on the basic TFIDF scorer. /// + // ReSharper disable InconsistentNaming public static Scorer BM25Std { get; } = new SimpleScorer("BM25STD"); /// @@ -42,6 +43,7 @@ private protected Scorer() /// /// used to smooth the function and the score values. public static Scorer BM25StdTanh(int y = Bm25StdTanh.DEFAULT_Y) => Bm25StdTanh.Create(y); + // 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. diff --git a/src/NRedisStack/Search/SearchCommands.cs b/src/NRedisStack/Search/SearchCommands.cs index d7112de2..c5999771 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(); + + + /// + [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)); + } } \ No newline at end of file diff --git a/src/NRedisStack/Search/SearchCommandsAsync.cs b/src/NRedisStack/Search/SearchCommandsAsync.cs index b26979ec..5df28d46 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< HybridSearchResult> 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/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index 09e61430..f05dec79 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -1,3 +1,7 @@ +using System.Runtime.CompilerServices; +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; +using Xunit; using Xunit.Abstractions; namespace NRedisStack.Tests.Search; @@ -5,5 +9,53 @@ namespace NRedisStack.Tests.Search; public class HybridSearchIntegrationTests(EndpointsFixture endpointsFixture, ITestOutputHelper log) : AbstractNRedisStackTest(endpointsFixture, log), IDisposable { - + private readonly struct Api(SearchCommands ft, string index) + { + public string Index { get; } = index; + public SearchCommands FT { get; } = ft; + } + + private Api CreateIndex(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"] = "FLOAT32", + ["DIM"] = "2", + ["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) + .AddGeoField("geo1", missingIndex: true) + .AddGeoShapeField("geoshape1", Schema.GeoShapeField.CoordinateSystem.FLAT, missingIndex: true) + .AddVectorField("vector1", Schema.VectorField.VectorAlgo.FLAT, vectorAttrs, missingIndex: true); + + var ftCreateParams = FTCreateParams.CreateParams(); + Assert.True(ft.Create(index, ftCreateParams, sc)); + + return new(ft, index); + } + + [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] + [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] + public void TestSetup(string endpointId) + { + var api = CreateIndex(endpointId, populate: false); + Dictionary args = new() { ["x"] = "abc" }; + var query = new HybridSearchQuery() + .Search("*") + .VectorSimilaritySearch("@vector1", new byte[] {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); + } } \ No newline at end of file diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index 7b801a5d..f9a72e57 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -13,7 +13,7 @@ public class HybridSearchUnitTests(ITestOutputHelper log) private readonly RedisKey _index = "myindex"; private ref readonly RedisKey Index => ref _index; - private ICollection GetArgs(HybridSearchQuery query, IDictionary? parameters = null) + private ICollection GetArgs(HybridSearchQuery query, IReadOnlyDictionary? parameters = null) { Assert.Equal("FT.HYBRID", query.Command); var args = query.GetArgs(Index, parameters); @@ -370,11 +370,20 @@ public void Combine_NonDefaultRrf(int? window, double? 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.Load("field1", "field2"); + query.ReturnFields("field1", "field2"); object[] expected = [Index, "LOAD", 2, "field1", "field2"]; Assert.Equivalent(expected, GetArgs(query)); } @@ -383,7 +392,7 @@ public void LoadFields() public void LoadEmptyFields() { HybridSearchQuery query = new(); - query.Load([]); + query.ReturnFields([]); object[] expected = [Index, "LOAD", 0]; Assert.Equivalent(expected, GetArgs(query)); } @@ -603,7 +612,7 @@ public void MakeMeOneWithEverything() .Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias")) .Filter("@foo:bar").ScoreAlias("vector_score_alias")) .Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(10, 0.5), "my_combined_alias") - .Load("field1", "field2") + .ReturnFields("field1", "field2") .GroupBy("field1", Reducers.Quantile("@field3", 0.5)) .Apply("@field1 + @field2", "apply_alias") .SortBy(SortedField.Asc("field1"), SortedField.Desc("field2")) diff --git a/tests/dockers/docker-compose.yml b/tests/dockers/docker-compose.yml index bd6b7963..f59cde9a 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-RC1-pre.2} 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-RC1-pre.2} container_name: redis-cluster environment: - REDIS_CLUSTER=yes From 6141c16636868b43b266c6a6463fcb639fd46773 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 11 Nov 2025 16:58:43 +0000 Subject: [PATCH 05/21] tweaking API --- .../OverloadResolutionPriorityAttribute.cs | 10 +++ .../PublicAPI/PublicAPI.Unshipped.txt | 22 ++++- src/NRedisStack/Search/ApplyExpression.cs | 20 +++++ .../Search/HybridSearchQuery.Command.cs | 87 ++++++++++++++++--- src/NRedisStack/Search/HybridSearchQuery.cs | 61 ++++++++++--- .../Search/HybridSearchUnitTests.cs | 62 ++++++++++--- 6 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 src/NRedisStack/OverloadResolutionPriorityAttribute.cs create mode 100644 src/NRedisStack/Search/ApplyExpression.cs 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.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index d3d2e83b..fb81db21 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,18 +1,34 @@ +NRedisStack.Search.ApplyExpression +NRedisStack.Search.ApplyExpression.Alias.get -> string? +NRedisStack.Search.ApplyExpression.ApplyExpression() -> void +NRedisStack.Search.ApplyExpression.ApplyExpression(string! expression, string? alias = null) -> void +NRedisStack.Search.ApplyExpression.Expression.get -> string! +override NRedisStack.Search.ApplyExpression.Equals(object? obj) -> bool +override NRedisStack.Search.ApplyExpression.GetHashCode() -> int +override NRedisStack.Search.ApplyExpression.ToString() -> string! +static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.ApplyExpression(string! expression) -> NRedisStack.Search.ApplyExpression [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.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.GroupBy(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string! field) -> 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.HybridSearchResult +[NRS001]NRedisStack.Search.HybridSearchResult.ExecutionTime.get -> System.TimeSpan +[NRS001]NRedisStack.Search.HybridSearchResult.Results.get -> System.Collections.Generic.IReadOnlyDictionary![]! +[NRS001]NRedisStack.Search.HybridSearchResult.TotalResults.get -> long +[NRS001]NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! [NRS001]NRedisStack.Search.Scorer [NRS001]NRedisStack.Search.HybridSearchQuery -[NRS001]NRedisStack.Search.HybridSearchQuery.Apply(string! expression, string! alias) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Combine(NRedisStack.Search.HybridSearchQuery.Combiner! combiner, string? scoreAlias = null) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Combiner [NRS001]NRedisStack.Search.HybridSearchQuery.Combiner.Combiner() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.ExplainScore(bool explainScore = true) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Filter(string! expression) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string! field, NRedisStack.Search.Aggregation.Reducer? reducer = null) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string![]! fields, NRedisStack.Search.Aggregation.Reducer? reducer = null) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.HybridSearchQuery() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.Language(string! language) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Limit(int offset, int count) -> NRedisStack.Search.HybridSearchQuery! diff --git a/src/NRedisStack/Search/ApplyExpression.cs b/src/NRedisStack/Search/ApplyExpression.cs new file mode 100644 index 00000000..bf7a5c49 --- /dev/null +++ b/src/NRedisStack/Search/ApplyExpression.cs @@ -0,0 +1,20 @@ +namespace NRedisStack.Search; + +/// +/// Represents an APPLY expression in an aggregation query. +/// +/// The expression to apply. +/// The alias for the expression in the results. +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/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 1b4d6d81..6a7d31f7 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -62,17 +62,40 @@ internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) count += 1; // single string } - if (_groupByReducer is not null) + switch (_reducerOrReducers) { - count += 3 + _groupByReducer.ArgCount(); + 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); } - if (_applyExpression is not null) + switch (_applyExpression) { - count += 4; + 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) { count += 2; @@ -196,20 +219,60 @@ internal void AddOwnArgs(List args, IReadOnlyDictionary? throw new ArgumentException("Invalid group by field or fields"); } - if (_groupByReducer is not null) + 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(_groupByReducer.Name); - _groupByReducer.SerializeRedisArgs(args); // includes the count + args.Add(reducer.Name); + reducer.SerializeRedisArgs(args); + if (reducer.Alias is not null) + { + args.Add("AS"); + args.Add(reducer.Alias); + } } } - if (_applyExpression is not null) + switch (_applyExpression) { - args.Add("APPLY"); - args.Add(_applyExpression); - args.Add("AS"); - args.Add(_applyAlias!); + 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) diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index b5e09a80..2a0d2d62 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using NRedisStack.Search.Aggregation; namespace NRedisStack.Search; @@ -54,7 +55,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) /// public HybridSearchQuery ReturnFields(params string[] fields) // naming for consistency with SearchQuery { - _loadFieldOrFields = fields; + _loadFieldOrFields = NullIfEmpty(fields); return this; } @@ -81,37 +82,71 @@ public HybridSearchQuery ReRank(int top, string expression) } private object? _groupByFieldOrFields; - private Reducer? _groupByReducer; + private object? _reducerOrReducers; /// /// Perform a group by operation on the results. /// - public HybridSearchQuery GroupBy(string field, Reducer? reducer = null) + public HybridSearchQuery GroupBy(string field) { _groupByFieldOrFields = field; - _groupByReducer = reducer; return this; } /// /// Perform a group by operation on the results. /// - public HybridSearchQuery GroupBy(string[] fields, Reducer? reducer = null) + public HybridSearchQuery GroupBy(params string[] fields) { - _groupByFieldOrFields = fields; - _groupByReducer = reducer; + _groupByFieldOrFields = NullIfEmpty(fields); return this; } - private string? _applyExpression, _applyAlias; + /// + /// Perform a reduce operation on the results, after grouping. + /// + public HybridSearchQuery Reduce(Reducer reducer) + { + _reducerOrReducers = reducer; + return this; + } + + /// + /// Perform a reduce operation on the results, after grouping. + /// + public HybridSearchQuery Reduce(params Reducer[] reducers) + { + _reducerOrReducers = NullIfEmpty(reducers); + return this; + } + + private static T[]? NullIfEmpty(T[]? array) => array?.Length > 0 ? array : null; + + private object? _applyExpression; /// /// Apply a field transformation expression to the results. /// - public HybridSearchQuery Apply(string expression, string alias) + [OverloadResolutionPriority(1)] // allow Apply(new("expr", "alias")) to resolve correctly + public HybridSearchQuery Apply(ApplyExpression applyExpression) + { + if (applyExpression.Alias is null) + { + _applyExpression = applyExpression.Expression; + } + else + { + _applyExpression = applyExpression; // pay for the box + } + return this; + } + + /// + /// Apply field transformation expressions to the results. + /// + public HybridSearchQuery Apply(params ApplyExpression[] applyExpression) { - _applyExpression = expression; - _applyAlias = alias; + _applyExpression = NullIfEmpty(applyExpression); return this; } @@ -122,7 +157,7 @@ public HybridSearchQuery Apply(string expression, string alias) /// public HybridSearchQuery SortBy(params SortedField[] fields) { - _sortByFieldOrFields = fields; + _sortByFieldOrFields = NullIfEmpty(fields); return this; } @@ -131,7 +166,7 @@ public HybridSearchQuery SortBy(params SortedField[] fields) /// public HybridSearchQuery SortBy(params string[] fields) { - _sortByFieldOrFields = fields; + _sortByFieldOrFields = NullIfEmpty(fields); return this; } diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index f9a72e57..3e8327dd 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -393,7 +393,7 @@ public void LoadEmptyFields() { HybridSearchQuery query = new(); query.ReturnFields([]); - object[] expected = [Index, "LOAD", 0]; + object[] expected = [Index]; Assert.Equivalent(expected, GetArgs(query)); } @@ -407,14 +407,38 @@ public void GroupBy_SingleField() } [Fact] - public void GroupBy_SingleField_WithReducer() + public void GroupBy_SingleField_WithReducer_NoAlias() { HybridSearchQuery query = new(); - query.GroupBy("field1", Reducers.Count()); + 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() { @@ -428,7 +452,7 @@ public void GroupBy_MultipleFields() public void GroupBy_MultipleFields_WithReducer() { HybridSearchQuery query = new(); - query.GroupBy(["field1", "field2"], Reducers.Quantile("@field3", 0.5)); + 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)); } @@ -437,11 +461,20 @@ public void GroupBy_MultipleFields_WithReducer() public void Apply() { HybridSearchQuery query = new(); - query.Apply("@field1 + @field2", "sum"); + 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_SingleString() { @@ -605,16 +638,16 @@ public void CursorWithCountAndMaxIdle() public void MakeMeOneWithEverything() { HybridSearchQuery query = new(); - query.Search("foo", + query.Search("foo", new HybridSearchQuery.QueryConfig().Scorer(Scorer.BM25StdTanh(5)).ScoreAlias("text_score_alias")) - .VectorSimilaritySearch("bar", new byte[] {1, 2, 3}, + .VectorSimilaritySearch("bar", new byte[] { 1, 2, 3 }, new HybridSearchQuery.VectorSearchConfig() .Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias")) .Filter("@foo:bar").ScoreAlias("vector_score_alias")) .Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(10, 0.5), "my_combined_alias") .ReturnFields("field1", "field2") - .GroupBy("field1", Reducers.Quantile("@field3", 0.5)) - .Apply("@field1 + @field2", "apply_alias") + .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) @@ -627,16 +660,19 @@ public void MakeMeOneWithEverything() ["x"] = 42, ["y"] = "abc" }; - object[] expected = [ - Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5, "YIELD_SCORE_AS", "text_score_alias", "VSIM", "bar", + object[] expected = + [ + Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5, "YIELD_SCORE_AS", + "text_score_alias", "VSIM", "bar", "AQID", "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, "APPLY", "@field1 + @field2", "AS", "apply_alias", + "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]; + "WITHCURSOR", "COUNT", 10, "MAXIDLE", 10000 + ]; log.WriteLine(query.Command + " " + string.Join(" ", expected)); log.WriteLine("vs"); From c736263694fc2f74e8e4020ffc5f54941fabf45e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 11:29:19 +0000 Subject: [PATCH 06/21] refactor API and deal with missing server features --- .../PublicAPI/PublicAPI.Unshipped.txt | 69 ++++--- .../Search/HybridSearchQuery.Command.cs | 54 +----- .../Search/HybridSearchQuery.QueryConfig.cs | 52 ----- .../Search/HybridSearchQuery.SearchConfig.cs | 95 +++++++++ .../HybridSearchQuery.VectorSearchConfig.cs | 180 ++++++++++++------ src/NRedisStack/Search/HybridSearchQuery.cs | 66 +++---- src/NRedisStack/Search/Reducers.cs | 5 +- .../Search/HybridSearchIntegrationTests.cs | 2 +- .../Search/HybridSearchUnitTests.cs | 138 +++++--------- 9 files changed, 341 insertions(+), 320 deletions(-) delete mode 100644 src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs create mode 100644 src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index fb81db21..c0bd50ab 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -9,52 +9,62 @@ override NRedisStack.Search.ApplyExpression.ToString() -> string! static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.ApplyExpression(string! expression) -> NRedisStack.Search.ApplyExpression [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.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.GroupBy(params string![]! fields) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.GroupBy(string! field) -> 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.HybridSearchResult -[NRS001]NRedisStack.Search.HybridSearchResult.ExecutionTime.get -> System.TimeSpan -[NRS001]NRedisStack.Search.HybridSearchResult.Results.get -> System.Collections.Generic.IReadOnlyDictionary![]! -[NRS001]NRedisStack.Search.HybridSearchResult.TotalResults.get -> long -[NRS001]NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! -[NRS001]NRedisStack.Search.Scorer -[NRS001]NRedisStack.Search.HybridSearchQuery [NRS001]NRedisStack.Search.HybridSearchQuery.Combine(NRedisStack.Search.HybridSearchQuery.Combiner! combiner, string? scoreAlias = null) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Combiner [NRS001]NRedisStack.Search.HybridSearchQuery.Combiner.Combiner() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.ExplainScore(bool explainScore = true) -> NRedisStack.Search.HybridSearchQuery! [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.Language(string! language) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Limit(int offset, int count) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig -[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.QueryConfig() -> void -[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.ScoreAlias(string! scoreAlias) -> NRedisStack.Search.HybridSearchQuery.QueryConfig! -[NRS001]NRedisStack.Search.HybridSearchQuery.QueryConfig.Scorer(NRedisStack.Search.Scorer! scorer) -> NRedisStack.Search.HybridSearchQuery.QueryConfig! -[NRS001]NRedisStack.Search.HybridSearchQuery.ReRank(int top, string! expression) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.Search(string! query, NRedisStack.Search.HybridSearchQuery.QueryConfig? config = null) -> 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(bool timeout = true) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorData +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorData.VectorData() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearch(NRedisStack.Search.HybridSearchQuery.VectorSearchConfig config) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearch(string! fieldName, NRedisStack.Search.HybridSearchQuery.VectorData vectorData) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.Filter(string! filter, NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy? policy = null, int? batchSize = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig! -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.Method(NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! method) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig! -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.ScoreAlias(string! scoreAlias) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig! -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Acorn = 2 -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.AdHoc = 0 -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches = 1 -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy +[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.HybridSearchQuery.VectorSearchMethod? +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.ScoreAlias.get -> string? +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorData.get -> NRedisStack.Search.HybridSearchQuery.VectorData [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorSearchConfig() -> void +[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.VectorSearchConfig(string! fieldName, NRedisStack.Search.HybridSearchQuery.VectorData vectorData, NRedisStack.Search.HybridSearchQuery.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.HybridSearchQuery.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.HybridSearchQuery.VectorData vectorData) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchMethod -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSimilaritySearch(string! field, System.ReadOnlyMemory data, NRedisStack.Search.HybridSearchQuery.VectorSearchConfig? config = null) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.WithCursor(int count = 0, System.TimeSpan maxIdle = default(System.TimeSpan)) -> NRedisStack.Search.HybridSearchQuery! +[NRS001]NRedisStack.Search.HybridSearchResult +[NRS001]NRedisStack.Search.HybridSearchResult.ExecutionTime.get -> System.TimeSpan +[NRS001]NRedisStack.Search.HybridSearchResult.Results.get -> System.Collections.Generic.IReadOnlyDictionary![]! +[NRS001]NRedisStack.Search.HybridSearchResult.TotalResults.get -> long +[NRS001]NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! +[NRS001]NRedisStack.Search.Scorer [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.HybridSearchQuery.Combiner.ToString() -> string! @@ -62,6 +72,9 @@ static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.A [NRS001]override NRedisStack.Search.Scorer.ToString() -> string! [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.HybridSearchQuery.VectorData.implicit operator NRedisStack.Search.HybridSearchQuery.VectorData(byte[]! data) -> NRedisStack.Search.HybridSearchQuery.VectorData +[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorData.implicit operator NRedisStack.Search.HybridSearchQuery.VectorData(System.ReadOnlyMemory data) -> NRedisStack.Search.HybridSearchQuery.VectorData [NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.NearestNeighbour(int count = 10, int? maxTopCandidates = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! [NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.Range(double radius, double? epsilon = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! [NRS001]static NRedisStack.Search.Scorer.BM25Std.get -> NRedisStack.Search.Scorer! diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 6a7d31f7..f24678c7 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -10,7 +10,7 @@ public sealed partial class HybridSearchQuery { internal string Command => "FT.HYBRID"; - internal ICollection GetArgs(in RedisKey index, IReadOnlyDictionary? parameters) + internal ICollection GetArgs(string index, IReadOnlyDictionary? parameters) { var count = GetOwnArgsCount(parameters); var args = new List(count + 1); @@ -23,16 +23,8 @@ internal ICollection GetArgs(in RedisKey index, IReadOnlyDictionary? parameters) { - int count = 0; // note index is not included here - if (_query is not null) - { - count += 2 + (_queryConfig?.GetOwnArgsCount() ?? 0); - } - - if (_vectorField is not null) - { - count += 3 + (_vectorConfig?.GetOwnArgsCount() ?? 0); - } + int count = _search.GetOwnArgsCount() + _vsim.GetOwnArgsCount(); // note index is not included here + if (_combiner is not null) { @@ -77,7 +69,7 @@ internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) static int CountReducer(Reducer reducer) => 3 + reducer.ArgCount() + (reducer.Alias is null ? 0 : 2); } - switch (_applyExpression) + switch (_applyExpressionOrExpressions) { case string expression: count += CountApply(new ApplyExpression(expression)); @@ -145,36 +137,8 @@ internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) internal void AddOwnArgs(List args, IReadOnlyDictionary? parameters) { - if (_query is not null) - { - args.Add("SEARCH"); - args.Add(_query); - _queryConfig?.AddOwnArgs(args); - } - - if (_vectorField is not null) - { - args.Add("VSIM"); - args.Add(_vectorField); -#if NET || NETSTANDARD2_1_OR_GREATER - args.Add(Convert.ToBase64String(_vectorData.Span)); -#else - if (MemoryMarshal.TryGetArray(_vectorData, out ArraySegment segment)) - { - args.Add(Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count)); - } - else - { - var span = _vectorData.Span; - var oversized = ArrayPool.Shared.Rent(span.Length); - span.CopyTo(oversized); - args.Add(Convert.ToBase64String(oversized, 0, span.Length)); - ArrayPool.Shared.Return(oversized); - } -#endif - - _vectorConfig?.AddOwnArgs(args); - } + _search.AddOwnArgs(args); + _vsim.AddOwnArgs(args); if (_combiner is not null) { @@ -244,7 +208,7 @@ static void AddReducer(Reducer reducer, List args) } } - switch (_applyExpression) + switch (_applyExpressionOrExpressions) { case string expression: AddApply(new ApplyExpression(expression), args); @@ -374,9 +338,9 @@ static void AddApply(in ApplyExpression expr, List args) internal void Validate() { - if (_query is null | _vectorField is null) + if (!(_search.HasValue & _vsim.HasValue)) { - throw new InvalidOperationException($"Both the query ({nameof(Query)}(...)) and vector search ({nameof(VectorSimilaritySearch)}(...))) details must be set."); + 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.QueryConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs deleted file mode 100644 index 979d8db2..00000000 --- a/src/NRedisStack/Search/HybridSearchQuery.QueryConfig.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace NRedisStack.Search; - -public sealed partial class HybridSearchQuery -{ - public sealed class QueryConfig - { - private Scorer? _scorer; - - /// - /// Scoring algorithm for the query. - /// - public QueryConfig Scorer(Scorer scorer) - { - _scorer = scorer; - return this; - } - - private string? _scoreAlias; - - /// - /// Include the score in the query results. - /// - public QueryConfig ScoreAlias(string scoreAlias) - { - _scoreAlias = scoreAlias; - return this; - } - - internal int GetOwnArgsCount() - { - int count = 0; - if (_scorer != null) count += 1 + _scorer.GetOwnArgsCount(); - if (_scoreAlias != null) count += 2; - return count; - } - - internal void AddOwnArgs(List args) - { - 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.SearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs new file mode 100644 index 00000000..c98f1d94 --- /dev/null +++ b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs @@ -0,0 +1,95 @@ +using System.Runtime.CompilerServices; + +namespace NRedisStack.Search; + +public sealed partial class HybridSearchQuery +{ + 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 index 40a0a2bc..2f0fcea0 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -1,104 +1,164 @@ +using System.Buffers; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + namespace NRedisStack.Search; public sealed partial class HybridSearchQuery { - public sealed class VectorSearchConfig + public readonly struct VectorData { - private string? _filter; - private VectorFilterPolicy? _filterPolicy; - private int? _filterBatchSize; + // intended to allow future flexibility in how we express vectors + private readonly ReadOnlyMemory _data; + private VectorData(ReadOnlyMemory data) + { + _data = data; + } + + public static implicit operator VectorData(byte[] data) => new(data); + public static implicit operator VectorData(ReadOnlyMemory data) => new(data); + internal void AddOwnArgs(List args) + { +#if NET || NETSTANDARD2_1_OR_GREATER + args.Add(Convert.ToBase64String(_data.Span)); +#else + if (MemoryMarshal.TryGetArray(_data, out ArraySegment segment)) + { + args.Add(Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count)); + } + else + { + var span = _data.Span; + var oversized = ArrayPool.Shared.Rent(span.Length); + span.CopyTo(oversized); + args.Add(Convert.ToBase64String(oversized, 0, span.Length)); + ArrayPool.Shared.Return(oversized); + } +#endif + } + internal int GetOwnArgsCount() => 1; + internal bool HasValue => _data.Length > 0; + } + public readonly struct VectorSearchConfig(string fieldName, VectorData vectorData, VectorSearchMethod? method = null, string? filter = null, string? scoreAlias = null) + { + internal bool HasValue => _vectorData.HasValue & _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; /// - /// Pre-filter for VECTOR results + /// Vector search method configuration. /// - public VectorSearchConfig Filter(string filter, VectorFilterPolicy? policy = null, int? batchSize = null) + 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) { - _filter = filter; - _filterPolicy = policy; - _filterBatchSize = batchSize; - return this; + var copy = this; + Unsafe.AsRef(in copy._vectorData) = vectorData; + return copy; } /// - /// The filter policy to apply + /// Specify the vector search method. /// - public enum VectorFilterPolicy + public VectorSearchConfig WithMethod(VectorSearchMethod? method) { - AdHoc, - Batches, - Acorn, + var copy = this; + Unsafe.AsRef(in copy._method) = method; + return copy; } - private string? _scoreAlias; - /// - /// Include the score in the query results. + /// Specify the field name for vector search. /// - public VectorSearchConfig ScoreAlias(string scoreAlias) + public VectorSearchConfig WithFieldName(string fieldName) { - _scoreAlias = scoreAlias; - return this; + var copy = this; + Unsafe.AsRef(in copy._fieldName) = fieldName; + return copy; } - - private VectorSearchMethod? _method; + /// + /// Specify the filter expression. + /// + public VectorSearchConfig WithFilter(string? filter) + { + var copy = this; + Unsafe.AsRef(in copy._filter) = filter; + return copy; + } /// - /// The method to use for vector search. + /// Specify the score alias. /// - public VectorSearchConfig Method(VectorSearchMethod method) + public VectorSearchConfig WithScoreAlias(string? scoreAlias) { - _method = method; - return this; + var copy = this; + Unsafe.AsRef(in copy._scoreAlias) = scoreAlias; + return copy; } internal int GetOwnArgsCount() { int count = 0; - if (_method != null) count += _method.GetOwnArgsCount(); - if (_filter != null) + if (HasValue) { - count += 2; - if (_filterPolicy != null) - { - count += 2; - if (_filterBatchSize != null) count += 2; - } - } + count += 2 + _vectorData.GetOwnArgsCount(); + if (_method != null) count += _method.GetOwnArgsCount(); + if (_filter != null) count += 2; - if (_scoreAlias != null) count += 2; + if (_scoreAlias != null) count += 2; + } return count; } internal void AddOwnArgs(List args) { - _method?.AddOwnArgs(args); - if (_filter != null) + if (HasValue) { - args.Add("FILTER"); - args.Add(_filter); - if (_filterPolicy != null) + args.Add("VSIM"); + args.Add(_fieldName); + _vectorData.AddOwnArgs(args); + + _method?.AddOwnArgs(args); + if (_filter != null) { - args.Add("POLICY"); - args.Add(_filterPolicy switch - { - VectorFilterPolicy.AdHoc => "ADHOC", - VectorFilterPolicy.Batches => "BATCHES", - VectorFilterPolicy.Acorn => "ACORN", - _ => _filterPolicy.ToString()!, - }); - if (_filterBatchSize != null) - { - args.Add("BATCH_SIZE"); - args.Add(_filterBatchSize); - } + args.Add("FILTER"); + args.Add(_filter); } - } - if (_scoreAlias != null) - { - args.Add("YIELD_SCORE_AS"); - args.Add(_scoreAlias); + if (_scoreAlias != null) + { + args.Add("YIELD_SCORE_AS"); + args.Add(_scoreAlias); + } } } } diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 2a0d2d62..46dec960 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -7,31 +7,30 @@ namespace NRedisStack.Search; [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] public sealed partial class HybridSearchQuery { - private string? _query; - QueryConfig? _queryConfig; + private SearchConfig _search; + private VectorSearchConfig _vsim; /// /// Specify the textual search portion of the query. /// - public HybridSearchQuery Search(string query, QueryConfig? config = null) + public HybridSearchQuery Search(SearchConfig query) { - _query = query; - _queryConfig = config; + _search = query; return this; } - private string? _vectorField; - private ReadOnlyMemory _vectorData; - private VectorSearchConfig? _vectorConfig; + /// + /// 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 VectorSimilaritySearch(string field, ReadOnlyMemory data, VectorSearchConfig? config = null) + public HybridSearchQuery VectorSearch(VectorSearchConfig config) { - _vectorField = field; - _vectorData = data; - _vectorConfig = config; + _vsim = config; return this; } @@ -58,7 +57,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) _loadFieldOrFields = NullIfEmpty(fields); return this; } - + /// /// Add the list of fields to return in the results. /// @@ -68,19 +67,6 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) return this; } - private int _rerankTop; - private string? _rerankExpression; - - /// - /// Specify the re-rank configuration. - /// - public HybridSearchQuery ReRank(int top, string expression) - { - _rerankTop = top; - _rerankExpression = expression; - return this; - } - private object? _groupByFieldOrFields; private object? _reducerOrReducers; @@ -122,7 +108,7 @@ public HybridSearchQuery Reduce(params Reducer[] reducers) private static T[]? NullIfEmpty(T[]? array) => array?.Length > 0 ? array : null; - private object? _applyExpression; + private object? _applyExpressionOrExpressions; /// /// Apply a field transformation expression to the results. @@ -132,12 +118,13 @@ public HybridSearchQuery Apply(ApplyExpression applyExpression) { if (applyExpression.Alias is null) { - _applyExpression = applyExpression.Expression; + _applyExpressionOrExpressions = applyExpression.Expression; } else { - _applyExpression = applyExpression; // pay for the box + _applyExpressionOrExpressions = applyExpression; // pay for the box } + return this; } @@ -146,7 +133,7 @@ public HybridSearchQuery Apply(ApplyExpression applyExpression) /// public HybridSearchQuery Apply(params ApplyExpression[] applyExpression) { - _applyExpression = NullIfEmpty(applyExpression); + _applyExpressionOrExpressions = NullIfEmpty(applyExpression); return this; } @@ -160,7 +147,7 @@ public HybridSearchQuery SortBy(params SortedField[] fields) _sortByFieldOrFields = NullIfEmpty(fields); return this; } - + /// /// Sort the final results by the specified fields. /// @@ -178,6 +165,7 @@ public HybridSearchQuery SortBy(SortedField field) _sortByFieldOrFields = field; return this; } + /// /// Sort the final results by the specified field. /// @@ -199,6 +187,7 @@ public HybridSearchQuery Filter(string expression) } private int _pagingOffset = -1, _pagingCount = -1; + public HybridSearchQuery Limit(int offset, int count) { if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); @@ -208,18 +197,8 @@ public HybridSearchQuery Limit(int offset, int count) return this; } - private string? _language; - /// - /// The language to use for search queries. - /// - public HybridSearchQuery Language(string language) - { - _language = language; - return this; - } - private bool _explainScore; - + /// /// Include score explanations /// @@ -230,6 +209,7 @@ public HybridSearchQuery ExplainScore(bool explainScore = true) } private bool _timeout; + /// /// Apply the global timeout setting. /// @@ -238,7 +218,7 @@ public HybridSearchQuery Timeout(bool timeout = true) _timeout = timeout; return this; } - + private int _cursorCount = -1; // -1: no cursor; 0: default count private TimeSpan _cursorMaxIdle; 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/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index f05dec79..32198a75 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -50,7 +50,7 @@ public void TestSetup(string endpointId) Dictionary args = new() { ["x"] = "abc" }; var query = new HybridSearchQuery() .Search("*") - .VectorSimilaritySearch("@vector1", new byte[] {1,2,3,4}) + .VectorSearch("@vector1", new byte[] {1,2,3,4}) .ReturnFields("@text1"); var result = api.FT.HybridSearch(api.Index, query, args); Assert.Equal(0, result.TotalResults); diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index 3e8327dd..1c9dbcfd 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -1,8 +1,6 @@ -using System.Reflection; using System.Text; using NRedisStack.Search; using NRedisStack.Search.Aggregation; -using StackExchange.Redis; using Xunit; using Xunit.Abstractions; @@ -10,9 +8,8 @@ namespace NRedisStack.Tests.Search; public class HybridSearchUnitTests(ITestOutputHelper log) { - private readonly RedisKey _index = "myindex"; - private ref readonly RedisKey Index => ref _index; - + private string Index { get; } = "myindex"; + private ICollection GetArgs(HybridSearchQuery query, IReadOnlyDictionary? parameters = null) { Assert.Equal("FT.HYBRID", query.Command); @@ -45,9 +42,9 @@ public void BasicSearch() public void BasicSearch_WithNullScorer(bool withAlias) // test: no SCORER added { HybridSearchQuery query = new(); - HybridSearchQuery.QueryConfig queryConfig = new(); - if (withAlias) queryConfig.ScoreAlias("score_alias"); - query.Search("foo", queryConfig); + HybridSearchQuery.SearchConfig queryConfig = "foo"; + if (withAlias) queryConfig = queryConfig.WithScoreAlias("score_alias"); + query.Search(queryConfig); object[] expected = [Index, "SEARCH", "foo"]; if (withAlias) @@ -64,10 +61,10 @@ public void BasicSearch_WithNullScorer(bool withAlias) // test: no SCORER added public void BasicSearch_WithSimpleScorer(bool withAlias) { HybridSearchQuery query = new(); - HybridSearchQuery.QueryConfig queryConfig = new(); - queryConfig.Scorer(Scorer.TfIdf); - if (withAlias) queryConfig.ScoreAlias("score_alias"); - query.Search("foo", queryConfig); + 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) @@ -89,8 +86,8 @@ public void BasicSearch_WithSimpleScorer(bool withAlias) public void BasicSearch_WithKnownSimpleScorers(string scenario) { HybridSearchQuery query = new(); - HybridSearchQuery.QueryConfig queryConfig = new(); - queryConfig.Scorer(scenario switch + HybridSearchQuery.SearchConfig queryConfig = "foo"; + queryConfig = queryConfig.WithScorer(scenario switch { "TFIDF" => Scorer.TfIdf, "TFIDF.DOCNORM" => Scorer.TfIdfDocNorm, @@ -101,7 +98,7 @@ public void BasicSearch_WithKnownSimpleScorers(string scenario) "HAMMING" => Scorer.Hamming, _ => throw new NotImplementedException(), }); - query.Search("foo", queryConfig); + query.Search(queryConfig); object[] expected = [Index, "SEARCH", "foo", "SCORER", scenario]; Assert.Equivalent(expected, GetArgs(query)); @@ -111,29 +108,20 @@ public void BasicSearch_WithKnownSimpleScorers(string scenario) public void BasicSearch_WithBM25StdTanh() { HybridSearchQuery query = new(); - query.Search("foo", new HybridSearchQuery.QueryConfig().Scorer(Scorer.BM25StdTanh(5))); + 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)); } - [Theory] - [InlineData(true)] - [InlineData(false)] - public void BasicZeroLengthVectorSearch(bool withConfig) + [Fact] + public void BasicVectorSearch() { HybridSearchQuery query = new(); - if (withConfig) - { - HybridSearchQuery.VectorSearchConfig config = new(); - query.VectorSimilaritySearch("vfield", Array.Empty(), config); - } - else - { - query.VectorSimilaritySearch("vfield", Array.Empty()); - } - - object[] expected = [Index, "VSIM", "vfield", ""]; + byte[] data = [1, 2, 3]; + query.VectorSearch("vfield", data); + + object[] expected = [Index, "VSIM", "vfield", "AQID"]; Assert.Equivalent(expected, GetArgs(query)); } @@ -143,7 +131,7 @@ public void BasicZeroLengthVectorSearch(bool withConfig) public void BasicNonZeroLengthVectorSearch() { HybridSearchQuery query = new(); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere); + query.VectorSearch("vfield", SomeRandomDataHere); object[] expected = [Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ=="]; Assert.Equivalent(expected, GetArgs(query)); @@ -157,14 +145,14 @@ public void BasicNonZeroLengthVectorSearch() public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlias) { HybridSearchQuery query = new(); - var searchConfig = new HybridSearchQuery.VectorSearchConfig(); - if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); - searchConfig.Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( + var searchConfig = new HybridSearchQuery.VectorSearchConfig("vField", SomeRandomDataHere); + if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); + searchConfig = searchConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + query.VectorSearch(searchConfig); object[] expected = - [Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "KNN", withDistanceAlias ? 4 : 2, "K", 10]; + [Index, "VSIM", "vField", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "KNN", withDistanceAlias ? 4 : 2, "K", 10]; if (withDistanceAlias) { expected = [..expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; @@ -186,13 +174,13 @@ public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlia public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDistanceAlias) { HybridSearchQuery query = new(); - var searchConfig = new HybridSearchQuery.VectorSearchConfig(); - if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); - searchConfig.Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( + var searchConfig = new HybridSearchQuery.VectorSearchConfig("vfield", SomeRandomDataHere); + if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); + searchConfig = searchConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( 16, maxTopCandidates: 100, distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + query.VectorSearch(searchConfig); object[] expected = [ @@ -220,11 +208,11 @@ public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDista public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAlias) { HybridSearchQuery query = new(); - var searchConfig = new HybridSearchQuery.VectorSearchConfig(); - if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); - searchConfig.Method(HybridSearchQuery.VectorSearchMethod.Range(4.2, + var searchConfig = new HybridSearchQuery.VectorSearchConfig("vfield", SomeRandomDataHere); + if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); + searchConfig = searchConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.Range(4.2, distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + query.VectorSearch(searchConfig); object[] expected = [ @@ -252,12 +240,12 @@ public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAl public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool withDistanceAlias) { HybridSearchQuery query = new(); - var searchConfig = new HybridSearchQuery.VectorSearchConfig(); - if (withScoreAlias) searchConfig.ScoreAlias("my_score_alias"); - searchConfig.Method(HybridSearchQuery.VectorSearchMethod.Range(4.2, + HybridSearchQuery.VectorSearchConfig vsimConfig = new("vfield", SomeRandomDataHere); + if (withScoreAlias) vsimConfig = vsimConfig.WithScoreAlias("my_score_alias"); + vsimConfig = vsimConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.Range(4.2, epsilon: 0.06, distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + query.VectorSearch(vsimConfig); object[] expected = [ @@ -281,40 +269,12 @@ public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool wi public void BasicVectorSearch_WithFilter_NoPolicy() { HybridSearchQuery query = new(); - var searchConfig = new HybridSearchQuery.VectorSearchConfig(); - searchConfig.Filter("@foo:bar"); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); - - object[] expected = - [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", @"FILTER", "@foo:bar" - ]; - - Assert.Equivalent(expected, GetArgs(query)); - } - - [Theory] - [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.AdHoc)] - [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches)] - [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Batches, 100)] - [InlineData(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy.Acorn)] - public void BasicVectorSearch_WithFilter_WithPolicy(HybridSearchQuery.VectorSearchConfig.VectorFilterPolicy policy, - int? batchSize = null) - { - HybridSearchQuery query = new(); - var searchConfig = new HybridSearchQuery.VectorSearchConfig(); - searchConfig.Filter("@foo:bar", policy, batchSize); - query.VectorSimilaritySearch("vfield", SomeRandomDataHere, searchConfig); + query.VectorSearch(new("vfield", SomeRandomDataHere, filter: "@foo:bar")); object[] expected = [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", @"FILTER", "@foo:bar", "POLICY", - policy.ToString().ToUpper() + Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "FILTER", "@foo:bar" ]; - if (batchSize != null) - { - expected = [..expected, "BATCH_SIZE", batchSize]; - } Assert.Equivalent(expected, GetArgs(query)); } @@ -410,7 +370,8 @@ public void GroupBy_SingleField() 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 + 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)); } @@ -432,10 +393,13 @@ public void GroupBy_SingleField_WithReducers_WithAlias() Reducers.Min("@field2").As("min"), Reducers.Max("@field2").As("max"), Reducers.Count().As("qty")); - object[] expected = [Index, "GROUPBY", 1, "field1", + object[] expected = + [ + Index, "GROUPBY", 1, "field1", "REDUCE", "MIN", 1, "@field2", "AS", "min", "REDUCE", "MAX", 1, "@field2", "AS", "max", - "REDUCE", "COUNT", 0, "AS", "qty"]; + "REDUCE", "COUNT", 0, "AS", "qty" + ]; Assert.Equivalent(expected, GetArgs(query)); } @@ -638,12 +602,10 @@ public void CursorWithCountAndMaxIdle() public void MakeMeOneWithEverything() { HybridSearchQuery query = new(); - query.Search("foo", - new HybridSearchQuery.QueryConfig().Scorer(Scorer.BM25StdTanh(5)).ScoreAlias("text_score_alias")) - .VectorSimilaritySearch("bar", new byte[] { 1, 2, 3 }, - new HybridSearchQuery.VectorSearchConfig() - .Method(HybridSearchQuery.VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias")) - .Filter("@foo:bar").ScoreAlias("vector_score_alias")) + query.Search(new("foo", Scorer.BM25StdTanh(5), "text_score_alias")) + .VectorSearch(new HybridSearchQuery.VectorSearchConfig("bar", new byte[] { 1, 2, 3 }, + HybridSearchQuery.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")) From 4f32325c7e5a8ab7316cfc12f41fe3d77e53dc8c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 11:49:01 +0000 Subject: [PATCH 07/21] mask cursor API, unclear --- src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt | 1 - src/NRedisStack/Search/HybridSearchQuery.cs | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index c0bd50ab..2db2a4c1 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -58,7 +58,6 @@ static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.A [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithScoreAlias(string? scoreAlias) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchConfig.WithVectorData(NRedisStack.Search.HybridSearchQuery.VectorData vectorData) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchMethod -[NRS001]NRedisStack.Search.HybridSearchQuery.WithCursor(int count = 0, System.TimeSpan maxIdle = default(System.TimeSpan)) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchResult [NRS001]NRedisStack.Search.HybridSearchResult.ExecutionTime.get -> System.TimeSpan [NRS001]NRedisStack.Search.HybridSearchResult.Results.get -> System.Collections.Generic.IReadOnlyDictionary![]! diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 46dec960..900a4e44 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -225,8 +225,9 @@ public HybridSearchQuery Timeout(bool timeout = true) /// /// Use a cursor for result iteration. /// - public HybridSearchQuery WithCursor(int count = 0, TimeSpan maxIdle = default) + internal HybridSearchQuery WithCursor(int count = 0, TimeSpan maxIdle = default) { + // not currently exposed, while I figure out the API _cursorCount = count; _cursorMaxIdle = maxIdle; return this; From 9548d539ff72ac370c7c0706e8e9c53b8492f1b3 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 14:49:41 +0000 Subject: [PATCH 08/21] normalize API --- .../PublicAPI/PublicAPI.Unshipped.txt | 57 +++---- src/NRedisStack/Search/ApplyExpression.cs | 3 + .../Search/HybridSearchQuery.Command.cs | 20 +-- .../HybridSearchQuery.VectorSearchConfig.cs | 33 ---- .../HybridSearchQuery.VectorSearchMethod.cs | 146 ----------------- src/NRedisStack/Search/HybridSearchQuery.cs | 11 ++ src/NRedisStack/Search/ISearchCommands.cs | 3 +- .../Search/ISearchCommandsAsync.cs | 2 +- src/NRedisStack/Search/SearchCommands.cs | 6 +- src/NRedisStack/Search/SearchCommandsAsync.cs | 4 +- src/NRedisStack/Search/VectorData.cs | 40 +++++ src/NRedisStack/Search/VectorSearchMethod.cs | 155 ++++++++++++++++++ .../Search/HybridSearchIntegrationTests.cs | 3 +- .../Search/HybridSearchUnitTests.cs | 28 ++-- 14 files changed, 271 insertions(+), 240 deletions(-) delete mode 100644 src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs create mode 100644 src/NRedisStack/Search/VectorData.cs create mode 100644 src/NRedisStack/Search/VectorSearchMethod.cs diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 2db2a4c1..ea03db97 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,14 +1,10 @@ -NRedisStack.Search.ApplyExpression -NRedisStack.Search.ApplyExpression.Alias.get -> string? -NRedisStack.Search.ApplyExpression.ApplyExpression() -> void -NRedisStack.Search.ApplyExpression.ApplyExpression(string! expression, string? alias = null) -> void -NRedisStack.Search.ApplyExpression.Expression.get -> string! -override NRedisStack.Search.ApplyExpression.Equals(object? obj) -> bool -override NRedisStack.Search.ApplyExpression.GetHashCode() -> int -override NRedisStack.Search.ApplyExpression.ToString() -> string! -static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.ApplyExpression(string! expression) -> NRedisStack.Search.ApplyExpression -[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.ISearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> NRedisStack.Search.HybridSearchResult! +[NRS001]NRedisStack.ISearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> 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.Apply(NRedisStack.Search.ApplyExpression applyExpression) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Apply(params NRedisStack.Search.ApplyExpression[]! applyExpression) -> NRedisStack.Search.HybridSearchQuery! @@ -21,6 +17,7 @@ static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.A [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.Parameters(System.Collections.Generic.IReadOnlyDictionary! parameters) -> 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! @@ -40,42 +37,42 @@ static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.A [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(bool timeout = true) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorData -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorData.VectorData() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearch(NRedisStack.Search.HybridSearchQuery.VectorSearchConfig config) -> NRedisStack.Search.HybridSearchQuery! -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearch(string! fieldName, NRedisStack.Search.HybridSearchQuery.VectorData vectorData) -> 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.HybridSearchQuery.VectorSearchMethod? +[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.HybridSearchQuery.VectorData +[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.HybridSearchQuery.VectorData vectorData, NRedisStack.Search.HybridSearchQuery.VectorSearchMethod? method = null, string? filter = null, string? scoreAlias = null) -> 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.HybridSearchQuery.VectorSearchMethod? method) -> 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.HybridSearchQuery.VectorData vectorData) -> NRedisStack.Search.HybridSearchQuery.VectorSearchConfig -[NRS001]NRedisStack.Search.HybridSearchQuery.VectorSearchMethod +[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 -> System.Collections.Generic.IReadOnlyDictionary![]! [NRS001]NRedisStack.Search.HybridSearchResult.TotalResults.get -> long [NRS001]NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! [NRS001]NRedisStack.Search.Scorer -[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]NRedisStack.Search.VectorData +[NRS001]NRedisStack.Search.VectorData.VectorData() -> void +[NRS001]NRedisStack.Search.VectorSearchMethod +[NRS001]NRedisStack.SearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> NRedisStack.Search.HybridSearchResult! +[NRS001]NRedisStack.SearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> 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.HybridSearchQuery.VectorSearchMethod.ToString() -> string! [NRS001]override NRedisStack.Search.Scorer.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.HybridSearchQuery.VectorData.implicit operator NRedisStack.Search.HybridSearchQuery.VectorData(byte[]! data) -> NRedisStack.Search.HybridSearchQuery.VectorData -[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorData.implicit operator NRedisStack.Search.HybridSearchQuery.VectorData(System.ReadOnlyMemory data) -> NRedisStack.Search.HybridSearchQuery.VectorData -[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.NearestNeighbour(int count = 10, int? maxTopCandidates = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! -[NRS001]static NRedisStack.Search.HybridSearchQuery.VectorSearchMethod.Range(double radius, double? epsilon = null, string? distanceAlias = null) -> NRedisStack.Search.HybridSearchQuery.VectorSearchMethod! [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.BM25StdTanh(int y = 4) -> NRedisStack.Search.Scorer! @@ -83,4 +80,8 @@ static NRedisStack.Search.ApplyExpression.implicit operator NRedisStack.Search.A [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! \ No newline at end of file +[NRS001]static NRedisStack.Search.Scorer.TfIdfDocNorm.get -> NRedisStack.Search.Scorer! +[NRS001]static NRedisStack.Search.VectorData.implicit operator NRedisStack.Search.VectorData(byte[]! data) -> NRedisStack.Search.VectorData +[NRS001]static NRedisStack.Search.VectorData.implicit operator NRedisStack.Search.VectorData(System.ReadOnlyMemory data) -> 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/ApplyExpression.cs b/src/NRedisStack/Search/ApplyExpression.cs index bf7a5c49..d4e413ac 100644 --- a/src/NRedisStack/Search/ApplyExpression.cs +++ b/src/NRedisStack/Search/ApplyExpression.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace NRedisStack.Search; /// @@ -5,6 +7,7 @@ namespace NRedisStack.Search; /// /// 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; diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index f24678c7..84aad2f7 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -10,18 +10,18 @@ public sealed partial class HybridSearchQuery { internal string Command => "FT.HYBRID"; - internal ICollection GetArgs(string index, IReadOnlyDictionary? parameters) + internal ICollection GetArgs(string index) { - var count = GetOwnArgsCount(parameters); + var count = GetOwnArgsCount(); var args = new List(count + 1); args.Add(index); - AddOwnArgs(args, parameters); + AddOwnArgs(args); 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) + internal int GetOwnArgsCount() { int count = _search.GetOwnArgsCount() + _vsim.GetOwnArgsCount(); // note index is not included here @@ -120,7 +120,7 @@ internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) if (_pagingOffset >= 0) count += 3; - if (parameters is not null) count += (parameters.Count + 1) * 2; + if (_parameters is not null) count += (_parameters.Count + 1) * 2; if (_explainScore) count++; if (_timeout) count++; @@ -135,7 +135,7 @@ internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) return count; } - internal void AddOwnArgs(List args, IReadOnlyDictionary? parameters) + internal void AddOwnArgs(List args) { _search.AddOwnArgs(args); _vsim.AddOwnArgs(args); @@ -294,11 +294,11 @@ static void AddApply(in ApplyExpression expr, List args) args.Add(_pagingCount); } - if (parameters is not null) + if (_parameters is not null) { args.Add("PARAMS"); - args.Add(parameters.Count * 2); - if (parameters is Dictionary typed) + args.Add(_parameters.Count * 2); + if (_parameters is Dictionary typed) { foreach (var entry in typed) // avoid allocating enumerator { @@ -308,7 +308,7 @@ static void AddApply(in ApplyExpression expr, List args) } else { - foreach (var entry in parameters) + foreach (var entry in _parameters) { args.Add(entry.Key); args.Add(entry.Value); diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs index 2f0fcea0..3827ee3d 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -7,39 +7,6 @@ namespace NRedisStack.Search; public sealed partial class HybridSearchQuery { - public readonly struct VectorData - { - // intended to allow future flexibility in how we express vectors - private readonly ReadOnlyMemory _data; - private VectorData(ReadOnlyMemory data) - { - _data = data; - } - - public static implicit operator VectorData(byte[] data) => new(data); - public static implicit operator VectorData(ReadOnlyMemory data) => new(data); - internal void AddOwnArgs(List args) - { -#if NET || NETSTANDARD2_1_OR_GREATER - args.Add(Convert.ToBase64String(_data.Span)); -#else - if (MemoryMarshal.TryGetArray(_data, out ArraySegment segment)) - { - args.Add(Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count)); - } - else - { - var span = _data.Span; - var oversized = ArrayPool.Shared.Rent(span.Length); - span.CopyTo(oversized); - args.Add(Convert.ToBase64String(oversized, 0, span.Length)); - ArrayPool.Shared.Return(oversized); - } -#endif - } - internal int GetOwnArgsCount() => 1; - internal bool HasValue => _data.Length > 0; - } public readonly struct VectorSearchConfig(string fieldName, VectorData vectorData, VectorSearchMethod? method = null, string? filter = null, string? scoreAlias = null) { internal bool HasValue => _vectorData.HasValue & _fieldName is not null; diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs deleted file mode 100644 index c622aa8a..00000000 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchMethod.cs +++ /dev/null @@ -1,146 +0,0 @@ -namespace NRedisStack.Search; - -public sealed partial class HybridSearchQuery -{ - 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/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 900a4e44..0760a4d2 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -232,4 +232,15 @@ internal HybridSearchQuery WithCursor(int count = 0, TimeSpan maxIdle = default) _cursorMaxIdle = maxIdle; return this; } + + private IReadOnlyDictionary? _parameters; + + /// + /// Supply parameters for the query. + /// + public HybridSearchQuery Parameters(IReadOnlyDictionary parameters) + { + _parameters = parameters is { Count: 0 } ? null : parameters; // ignore empty + return this; + } } \ No newline at end of file diff --git a/src/NRedisStack/Search/ISearchCommands.cs b/src/NRedisStack/Search/ISearchCommands.cs index 58c5da94..934ee7a2 100644 --- a/src/NRedisStack/Search/ISearchCommands.cs +++ b/src/NRedisStack/Search/ISearchCommands.cs @@ -351,9 +351,8 @@ public interface ISearchCommands /// /// The index name. /// The query to execute. - /// The parameters to pass to the query, if any. /// List of TAG field values /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null); + HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query); } \ No newline at end of file diff --git a/src/NRedisStack/Search/ISearchCommandsAsync.cs b/src/NRedisStack/Search/ISearchCommandsAsync.cs index b0b516c0..7365d77d 100644 --- a/src/NRedisStack/Search/ISearchCommandsAsync.cs +++ b/src/NRedisStack/Search/ISearchCommandsAsync.cs @@ -350,5 +350,5 @@ public interface ISearchCommandsAsync /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - Task HybridSearchAsync(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null); + Task HybridSearchAsync(string indexName, HybridSearchQuery query); } \ No newline at end of file diff --git a/src/NRedisStack/Search/SearchCommands.cs b/src/NRedisStack/Search/SearchCommands.cs index c5999771..7ce5237e 100644 --- a/src/NRedisStack/Search/SearchCommands.cs +++ b/src/NRedisStack/Search/SearchCommands.cs @@ -318,10 +318,10 @@ public bool SynUpdate(string indexName, string synonymGroupId, bool skipInitialS /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - public HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + public HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query) { query.Validate(); - var args = query.GetArgs(indexName, parameters); + var args = query.GetArgs(indexName); return HybridSearchResult.Parse(db.Execute(query.Command, args)); } -} \ No newline at end of file +} diff --git a/src/NRedisStack/Search/SearchCommandsAsync.cs b/src/NRedisStack/Search/SearchCommandsAsync.cs index 5df28d46..3c5fefc7 100644 --- a/src/NRedisStack/Search/SearchCommandsAsync.cs +++ b/src/NRedisStack/Search/SearchCommandsAsync.cs @@ -356,10 +356,10 @@ public async Task SynUpdateAsync(string indexName, string synonymGroupId, /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - public async Task< HybridSearchResult> HybridSearchAsync(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + public async Task< HybridSearchResult> HybridSearchAsync(string indexName, HybridSearchQuery query) { query.Validate(); - var args = query.GetArgs(indexName, parameters); + var args = query.GetArgs(indexName); return HybridSearchResult.Parse(await _db.ExecuteAsync(query.Command, args)); } } \ 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..314efb68 --- /dev/null +++ b/src/NRedisStack/Search/VectorData.cs @@ -0,0 +1,40 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace NRedisStack.Search; + +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +public readonly struct VectorData +{ + // intended to allow future flexibility in how we express vectors + private readonly ReadOnlyMemory _data; + private VectorData(ReadOnlyMemory data) + { + _data = data; + } + + public static implicit operator VectorData(byte[] data) => new(data); + public static implicit operator VectorData(ReadOnlyMemory data) => new(data); + internal void AddOwnArgs(List args) + { +#if NET || NETSTANDARD2_1_OR_GREATER + args.Add(Convert.ToBase64String(_data.Span)); +#else + if (MemoryMarshal.TryGetArray(_data, out ArraySegment segment)) + { + args.Add(Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count)); + } + else + { + var span = _data.Span; + var oversized = ArrayPool.Shared.Rent(span.Length); + span.CopyTo(oversized); + args.Add(Convert.ToBase64String(oversized, 0, span.Length)); + ArrayPool.Shared.Return(oversized); + } +#endif + } + internal int GetOwnArgsCount() => 1; + internal bool HasValue => _data.Length > 0; +} \ 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 index 32198a75..ee27d2da 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -51,8 +51,9 @@ public void TestSetup(string endpointId) var query = new HybridSearchQuery() .Search("*") .VectorSearch("@vector1", new byte[] {1,2,3,4}) + .Parameters(args) .ReturnFields("@text1"); - var result = api.FT.HybridSearch(api.Index, query, args); + var result = api.FT.HybridSearch(api.Index, query); Assert.Equal(0, result.TotalResults); Assert.NotEqual(TimeSpan.Zero, result.ExecutionTime); Assert.Empty(result.Warnings); diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index 1c9dbcfd..abfbc65a 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -10,10 +10,10 @@ public class HybridSearchUnitTests(ITestOutputHelper log) { private string Index { get; } = "myindex"; - private ICollection GetArgs(HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + private ICollection GetArgs(HybridSearchQuery query) { Assert.Equal("FT.HYBRID", query.Command); - var args = query.GetArgs(Index, parameters); + var args = query.GetArgs(Index); log.WriteLine(query.Command + " " + string.Join(" ", args)); return args; } @@ -147,7 +147,7 @@ public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlia HybridSearchQuery query = new(); var searchConfig = new HybridSearchQuery.VectorSearchConfig("vField", SomeRandomDataHere); if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); - searchConfig = searchConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( + searchConfig = searchConfig.WithMethod(VectorSearchMethod.NearestNeighbour( distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); query.VectorSearch(searchConfig); @@ -176,7 +176,7 @@ public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDista HybridSearchQuery query = new(); var searchConfig = new HybridSearchQuery.VectorSearchConfig("vfield", SomeRandomDataHere); if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); - searchConfig = searchConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.NearestNeighbour( + searchConfig = searchConfig.WithMethod(VectorSearchMethod.NearestNeighbour( 16, maxTopCandidates: 100, distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); @@ -210,7 +210,7 @@ public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAl HybridSearchQuery query = new(); var searchConfig = new HybridSearchQuery.VectorSearchConfig("vfield", SomeRandomDataHere); if (withScoreAlias) searchConfig = searchConfig.WithScoreAlias("my_score_alias"); - searchConfig = searchConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.Range(4.2, + searchConfig = searchConfig.WithMethod(VectorSearchMethod.Range(4.2, distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); query.VectorSearch(searchConfig); @@ -242,7 +242,7 @@ public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool wi HybridSearchQuery query = new(); HybridSearchQuery.VectorSearchConfig vsimConfig = new("vfield", SomeRandomDataHere); if (withScoreAlias) vsimConfig = vsimConfig.WithScoreAlias("my_score_alias"); - vsimConfig = vsimConfig.WithMethod(HybridSearchQuery.VectorSearchMethod.Range(4.2, + vsimConfig = vsimConfig.WithMethod(VectorSearchMethod.Range(4.2, epsilon: 0.06, distanceAlias: withDistanceAlias ? "my_distance_alias" : null)); query.VectorSearch(vsimConfig); @@ -602,9 +602,14 @@ public void CursorWithCountAndMaxIdle() 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 byte[] { 1, 2, 3 }, - HybridSearchQuery.VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias")) + 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") @@ -615,13 +620,8 @@ public void MakeMeOneWithEverything() .Limit(12, 54) .ExplainScore() .Timeout() + .Parameters(args) .WithCursor(10, TimeSpan.FromSeconds(10)); - - var args = new Dictionary - { - ["x"] = 42, - ["y"] = "abc" - }; object[] expected = [ Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5, "YIELD_SCORE_AS", @@ -638,6 +638,6 @@ public void MakeMeOneWithEverything() log.WriteLine(query.Command + " " + string.Join(" ", expected)); log.WriteLine("vs"); - Assert.Equivalent(expected, GetArgs(query, args)); + Assert.Equivalent(expected, GetArgs(query)); } } \ No newline at end of file From 8cc5ff2cbf680b34e3656b5583e3e6342f616f39 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 15:09:06 +0000 Subject: [PATCH 09/21] rev local docker and mark docs as non-packable --- docs/docs.csproj | 1 + tests/dockers/docker-compose.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs.csproj b/docs/docs.csproj index 977e065b..b6848c2d 100644 --- a/docs/docs.csproj +++ b/docs/docs.csproj @@ -2,5 +2,6 @@ netstandard2.0 + false diff --git a/tests/dockers/docker-compose.yml b/tests/dockers/docker-compose.yml index f59cde9a..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.4-RC1-pre.2} + 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.4-RC1-pre.2} + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre} container_name: redis-cluster environment: - REDIS_CLUSTER=yes From 752c65f73d03e4928ae30e19f0851e2412f1af5d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 15:46:56 +0000 Subject: [PATCH 10/21] dotnet format --- src/NRedisStack/CoreCommands/Enums/SetInfoAttr.cs | 1 + src/NRedisStack/Search/AggregationRequest.cs | 1 + src/NRedisStack/Search/ApplyExpression.cs | 2 +- src/NRedisStack/Search/HybridSearchQuery.Command.cs | 2 +- src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs | 4 ++-- .../Search/HybridSearchQuery.VectorSearchConfig.cs | 2 +- src/NRedisStack/Search/HybridSearchResult.cs | 6 +++--- src/NRedisStack/Search/Scorer.cs | 4 ++-- src/NRedisStack/Search/SearchCommandsAsync.cs | 2 +- 9 files changed, 13 insertions(+), 11 deletions(-) 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/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 index d4e413ac..a2fd7361 100644 --- a/src/NRedisStack/Search/ApplyExpression.cs +++ b/src/NRedisStack/Search/ApplyExpression.cs @@ -18,6 +18,6 @@ public readonly struct ApplyExpression(string expression, string? alias = null) 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/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 84aad2f7..008c3244 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -24,7 +24,7 @@ internal ICollection GetArgs(string index) internal int GetOwnArgsCount() { int count = _search.GetOwnArgsCount() + _vsim.GetOwnArgsCount(); // note index is not included here - + if (_combiner is not null) { diff --git a/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs index c98f1d94..ce570c18 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs @@ -38,7 +38,7 @@ public SearchConfig WithScorer(Scorer? scorer) Unsafe.AsRef(in copy._scorer) = scorer; return copy; } - + /// /// Specify the scorer to use for the query. /// @@ -48,7 +48,7 @@ public SearchConfig WithQuery(string query) Unsafe.AsRef(in copy._query) = query; return copy; } - + /// /// Specify the scorer to use for the query. /// diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs index 3827ee3d..bbc0e1ba 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -36,7 +36,7 @@ public readonly struct VectorSearchConfig(string fieldName, VectorData vectorDat /// Include the score in the query results. /// public string? ScoreAlias => _scoreAlias; - + /// /// The vector data to search for. /// diff --git a/src/NRedisStack/Search/HybridSearchResult.cs b/src/NRedisStack/Search/HybridSearchResult.cs index 455a881b..97b2cf4f 100644 --- a/src/NRedisStack/Search/HybridSearchResult.cs +++ b/src/NRedisStack/Search/HybridSearchResult.cs @@ -15,7 +15,7 @@ internal static HybridSearchResult Parse(RedisResult? result) if (len > 0) { int index = 0; - for (int i = 0 ; i < len; i++) + for (int i = 0; i < len; i++) { var key = ParseKey(result[index++]); if (key is not ResultKey.Unknown) @@ -56,7 +56,7 @@ internal static HybridSearchResult Parse(RedisResult? result) } } return obj; - + static ResultKey ParseKey(RedisResult key) { if (!key.IsNull && key.Resp2Type is ResultType.BulkString or ResultType.SimpleString) @@ -114,7 +114,7 @@ private enum ResultKey } public long TotalResults { get; private set; } = -1; // initialize to -1 to indicate not set - + public TimeSpan ExecutionTime { get; private set; } public string[] Warnings { get; private set; } = []; diff --git a/src/NRedisStack/Search/Scorer.cs b/src/NRedisStack/Search/Scorer.cs index 9b9e6d75..41d3ae43 100644 --- a/src/NRedisStack/Search/Scorer.cs +++ b/src/NRedisStack/Search/Scorer.cs @@ -21,12 +21,12 @@ private protected Scorer() /// 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. /// diff --git a/src/NRedisStack/Search/SearchCommandsAsync.cs b/src/NRedisStack/Search/SearchCommandsAsync.cs index 3c5fefc7..b7c8121a 100644 --- a/src/NRedisStack/Search/SearchCommandsAsync.cs +++ b/src/NRedisStack/Search/SearchCommandsAsync.cs @@ -356,7 +356,7 @@ public async Task SynUpdateAsync(string indexName, string synonymGroupId, /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - public async Task< HybridSearchResult> HybridSearchAsync(string indexName, HybridSearchQuery query) + public async Task HybridSearchAsync(string indexName, HybridSearchQuery query) { query.Validate(); var args = query.GetArgs(indexName); From 98fab2b94dea68ca14f60c48f81ac41fd295b5ff Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 16:37:28 +0000 Subject: [PATCH 11/21] better support for vector data --- .../PublicAPI/PublicAPI.Unshipped.txt | 15 ++-- .../HybridSearchQuery.VectorSearchConfig.cs | 8 +-- src/NRedisStack/Search/VectorData.cs | 68 ++++++++++++------- .../Search/HybridSearchIntegrationTests.cs | 2 +- .../Search/HybridSearchUnitTests.cs | 25 +++---- 5 files changed, 71 insertions(+), 47 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index ea03db97..055c9c74 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -38,20 +38,20 @@ [NRS001]NRedisStack.Search.HybridSearchQuery.SortBy(string! field) -> NRedisStack.Search.HybridSearchQuery! [NRS001]NRedisStack.Search.HybridSearchQuery.Timeout(bool timeout = true) -> 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.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.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.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.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 -> System.Collections.Generic.IReadOnlyDictionary![]! @@ -59,7 +59,6 @@ [NRS001]NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! [NRS001]NRedisStack.Search.Scorer [NRS001]NRedisStack.Search.VectorData -[NRS001]NRedisStack.Search.VectorData.VectorData() -> void [NRS001]NRedisStack.Search.VectorSearchMethod [NRS001]NRedisStack.SearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> NRedisStack.Search.HybridSearchResult! [NRS001]NRedisStack.SearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> System.Threading.Tasks.Task! @@ -81,7 +80,9 @@ [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.implicit operator NRedisStack.Search.VectorData(byte[]! data) -> NRedisStack.Search.VectorData -[NRS001]static NRedisStack.Search.VectorData.implicit operator NRedisStack.Search.VectorData(System.ReadOnlyMemory data) -> NRedisStack.Search.VectorData +[NRS001]static NRedisStack.Search.VectorData.Create(System.ReadOnlyMemory vector) -> NRedisStack.Search.VectorData! +[NRS001]static NRedisStack.Search.VectorData.FromBase64(string! base64) -> 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!(System.ReadOnlyMemory vector) -> 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/HybridSearchQuery.VectorSearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs index bbc0e1ba..69774f22 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -9,7 +9,7 @@ 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.HasValue & _fieldName is not null; + internal bool HasValue => _vectorData is not null & _fieldName is not null; private readonly string _fieldName = fieldName; private readonly VectorData _vectorData = vectorData; @@ -40,7 +40,7 @@ public readonly struct VectorSearchConfig(string fieldName, VectorData vectorDat /// /// The vector data to search for. /// - public VectorData VectorData => _vectorData; + public VectorData? VectorData => _vectorData; /// /// Specify the vector search method. @@ -97,7 +97,7 @@ internal int GetOwnArgsCount() int count = 0; if (HasValue) { - count += 2 + _vectorData.GetOwnArgsCount(); + count += 2 + _vectorData.ArgsCount(); if (_method != null) count += _method.GetOwnArgsCount(); if (_filter != null) count += 2; @@ -112,7 +112,7 @@ internal void AddOwnArgs(List args) { args.Add("VSIM"); args.Add(_fieldName); - _vectorData.AddOwnArgs(args); + _vectorData.AddArgs(args); _method?.AddOwnArgs(args); if (_filter != null) diff --git a/src/NRedisStack/Search/VectorData.cs b/src/NRedisStack/Search/VectorData.cs index 314efb68..5e5b3437 100644 --- a/src/NRedisStack/Search/VectorData.cs +++ b/src/NRedisStack/Search/VectorData.cs @@ -5,36 +5,58 @@ namespace NRedisStack.Search; [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] -public readonly struct VectorData +public abstract class VectorData { - // intended to allow future flexibility in how we express vectors - private readonly ReadOnlyMemory _data; - private VectorData(ReadOnlyMemory data) + private protected VectorData() { - _data = data; } - public static implicit operator VectorData(byte[] data) => new(data); - public static implicit operator VectorData(ReadOnlyMemory data) => new(data); - internal void AddOwnArgs(List args) + /// + /// A vector of entries. + /// + public static VectorData Create(ReadOnlyMemory vector) => new VectorDataSingle(vector); + + /// + /// A pre-formatted base-64 value. + /// + public static VectorData FromBase64(string base64) => new VectorDataBase64(base64); + + /// + /// A vector of entries. + /// + public static implicit operator VectorData(float[] data) => new VectorDataSingle(data); + + /// + /// A vector of entries. + /// + public static implicit operator VectorData(ReadOnlyMemory vector) => new VectorDataSingle(vector); + + internal virtual void AddArgs(List args) => args.Add(ToString() ?? ""); + internal virtual int ArgsCount() => 1; + + private sealed class VectorDataSingle(ReadOnlyMemory vector) : VectorData { + public override string ToString() + { + 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 - args.Add(Convert.ToBase64String(_data.Span)); + return Convert.ToBase64String(bytes); #else - if (MemoryMarshal.TryGetArray(_data, out ArraySegment segment)) - { - args.Add(Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count)); - } - else - { - var span = _data.Span; - var oversized = ArrayPool.Shared.Rent(span.Length); - span.CopyTo(oversized); - args.Add(Convert.ToBase64String(oversized, 0, span.Length)); - ArrayPool.Shared.Return(oversized); - } + 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 + } } - internal int GetOwnArgsCount() => 1; - internal bool HasValue => _data.Length > 0; + + private sealed class VectorDataBase64(string vector) : VectorData + { + public override string ToString() => vector; + } + + 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/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index ee27d2da..7f88f57e 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -50,7 +50,7 @@ public void TestSetup(string endpointId) Dictionary args = new() { ["x"] = "abc" }; var query = new HybridSearchQuery() .Search("*") - .VectorSearch("@vector1", new byte[] {1,2,3,4}) + .VectorSearch("@vector1", new float[] {1,2,3,4}) .Parameters(args) .ReturnFields("@text1"); var result = api.FT.HybridSearch(api.Index, query); diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index abfbc65a..fb9d1867 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -118,14 +118,15 @@ public void BasicSearch_WithBM25StdTanh() public void BasicVectorSearch() { HybridSearchQuery query = new(); - byte[] data = [1, 2, 3]; - query.VectorSearch("vfield", data); + query.VectorSearch("vfield", Array.Empty()); - object[] expected = [Index, "VSIM", "vfield", "AQID"]; + object[] expected = [Index, "VSIM", "vfield", ""]; Assert.Equivalent(expected, GetArgs(query)); } - private static readonly ReadOnlyMemory SomeRandomDataHere = Encoding.UTF8.GetBytes("some random data here!"); + private static readonly ReadOnlyMemory SomeRandomDataHere = new float[] {1, 2, 3, 4}; + + private const string SomeRandomBase64 = "AACAPwAAAEAAAEBAAACAQA=="; [Fact] public void BasicNonZeroLengthVectorSearch() @@ -133,7 +134,7 @@ public void BasicNonZeroLengthVectorSearch() HybridSearchQuery query = new(); query.VectorSearch("vfield", SomeRandomDataHere); - object[] expected = [Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ=="]; + object[] expected = [Index, "VSIM", "vfield", SomeRandomBase64]; Assert.Equivalent(expected, GetArgs(query)); } @@ -152,7 +153,7 @@ public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlia query.VectorSearch(searchConfig); object[] expected = - [Index, "VSIM", "vField", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "KNN", withDistanceAlias ? 4 : 2, "K", 10]; + [Index, "VSIM", "vField", SomeRandomBase64, "KNN", withDistanceAlias ? 4 : 2, "K", 10]; if (withDistanceAlias) { expected = [..expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; @@ -184,7 +185,7 @@ public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDista object[] expected = [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "KNN", withDistanceAlias ? 6 : 4, "K", 16, + Index, "VSIM", "vfield", SomeRandomBase64, "KNN", withDistanceAlias ? 6 : 4, "K", 16, "EF_RUNTIME", 100 ]; if (withDistanceAlias) @@ -216,7 +217,7 @@ public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAl object[] expected = [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "RANGE", withDistanceAlias ? 4 : 2, "RADIUS", + Index, "VSIM", "vfield", SomeRandomBase64, "RANGE", withDistanceAlias ? 4 : 2, "RADIUS", 4.2 ]; if (withDistanceAlias) @@ -249,7 +250,7 @@ public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool wi object[] expected = [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "RANGE", withDistanceAlias ? 6 : 4, "RADIUS", + Index, "VSIM", "vfield", SomeRandomBase64, "RANGE", withDistanceAlias ? 6 : 4, "RADIUS", 4.2, "EPSILON", 0.06 ]; if (withDistanceAlias) @@ -273,7 +274,7 @@ public void BasicVectorSearch_WithFilter_NoPolicy() object[] expected = [ - Index, "VSIM", "vfield", "c29tZSByYW5kb20gZGF0YSBoZXJlIQ==", "FILTER", "@foo:bar" + Index, "VSIM", "vfield", SomeRandomBase64, "FILTER", "@foo:bar" ]; Assert.Equivalent(expected, GetArgs(query)); @@ -608,7 +609,7 @@ public void MakeMeOneWithEverything() ["y"] = "abc" }; query.Search(new("foo", Scorer.BM25StdTanh(5), "text_score_alias")) - .VectorSearch(new HybridSearchQuery.VectorSearchConfig("bar", new byte[] { 1, 2, 3 }, + .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") @@ -626,7 +627,7 @@ public void MakeMeOneWithEverything() [ Index, "SEARCH", "foo", "SCORER", "BM25STD.TANH", "BM25STD_TANH_FACTOR", 5, "YIELD_SCORE_AS", "text_score_alias", "VSIM", "bar", - "AQID", "KNN", 6, "K", 10, "EF_RUNTIME", 100, "YIELD_DISTANCE_AS", "vector_distance_alias", "FILTER", + "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", From 5d795b6ce9138f76176bad966ab658bed526b7a2 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 16:39:19 +0000 Subject: [PATCH 12/21] dotnet format --- .../Search/HybridSearchIntegrationTests.cs | 2 +- .../Search/HybridSearchUnitTests.cs | 30 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index 7f88f57e..b703fdd2 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -50,7 +50,7 @@ public void TestSetup(string endpointId) Dictionary args = new() { ["x"] = "abc" }; var query = new HybridSearchQuery() .Search("*") - .VectorSearch("@vector1", new float[] {1,2,3,4}) + .VectorSearch("@vector1", new float[] { 1, 2, 3, 4 }) .Parameters(args) .ReturnFields("@text1"); var result = api.FT.HybridSearch(api.Index, query); diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index fb9d1867..643b2dff 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -9,7 +9,7 @@ namespace NRedisStack.Tests.Search; public class HybridSearchUnitTests(ITestOutputHelper log) { private string Index { get; } = "myindex"; - + private ICollection GetArgs(HybridSearchQuery query) { Assert.Equal("FT.HYBRID", query.Command); @@ -49,7 +49,7 @@ public void BasicSearch_WithNullScorer(bool withAlias) // test: no SCORER added object[] expected = [Index, "SEARCH", "foo"]; if (withAlias) { - expected = [..expected, "YIELD_SCORE_AS", "score_alias"]; + expected = [.. expected, "YIELD_SCORE_AS", "score_alias"]; } Assert.Equivalent(expected, GetArgs(query)); @@ -69,7 +69,7 @@ public void BasicSearch_WithSimpleScorer(bool withAlias) object[] expected = [Index, "SEARCH", "foo", "SCORER", "TFIDF"]; if (withAlias) { - expected = [..expected, "YIELD_SCORE_AS", "score_alias"]; + expected = [.. expected, "YIELD_SCORE_AS", "score_alias"]; } Assert.Equivalent(expected, GetArgs(query)); @@ -119,12 +119,12 @@ 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 static readonly ReadOnlyMemory SomeRandomDataHere = new float[] { 1, 2, 3, 4 }; private const string SomeRandomBase64 = "AACAPwAAAEAAAEBAAACAQA=="; @@ -156,12 +156,12 @@ public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlia [Index, "VSIM", "vField", SomeRandomBase64, "KNN", withDistanceAlias ? 4 : 2, "K", 10]; if (withDistanceAlias) { - expected = [..expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; } if (withScoreAlias) { - expected = [..expected, "YIELD_SCORE_AS", "my_score_alias"]; + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; } Assert.Equivalent(expected, GetArgs(query)); @@ -190,12 +190,12 @@ public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDista ]; if (withDistanceAlias) { - expected = [..expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; } if (withScoreAlias) { - expected = [..expected, "YIELD_SCORE_AS", "my_score_alias"]; + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; } Assert.Equivalent(expected, GetArgs(query)); @@ -222,12 +222,12 @@ public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAl ]; if (withDistanceAlias) { - expected = [..expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; } if (withScoreAlias) { - expected = [..expected, "YIELD_SCORE_AS", "my_score_alias"]; + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; } Assert.Equivalent(expected, GetArgs(query)); @@ -255,12 +255,12 @@ public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool wi ]; if (withDistanceAlias) { - expected = [..expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; + expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; } if (withScoreAlias) { - expected = [..expected, "YIELD_SCORE_AS", "my_score_alias"]; + expected = [.. expected, "YIELD_SCORE_AS", "my_score_alias"]; } Assert.Equivalent(expected, GetArgs(query)); @@ -320,12 +320,12 @@ public void Combine_NonDefaultRrf(int? window, double? 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]; + expected = [.. expected, "WINDOW", window]; } if (constant is not null) { - expected = [..expected, "CONSTANT", constant]; + expected = [.. expected, "CONSTANT", constant]; } Assert.Equivalent(expected, GetArgs(query)); From 83a254799b798aa069df9fe7a84c0565c64eaa23 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 12 Nov 2025 16:55:28 +0000 Subject: [PATCH 13/21] naming is hard --- src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt | 1 + .../Search/HybridSearchQuery.VectorSearchConfig.cs | 4 ++-- src/NRedisStack/Search/VectorData.cs | 12 ++++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 055c9c74..688a9ef8 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -67,6 +67,7 @@ [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! diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs index 69774f22..eec6dc1e 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -97,7 +97,7 @@ internal int GetOwnArgsCount() int count = 0; if (HasValue) { - count += 2 + _vectorData.ArgsCount(); + count += 2 + _vectorData.Base64ArgsCount(); if (_method != null) count += _method.GetOwnArgsCount(); if (_filter != null) count += 2; @@ -112,7 +112,7 @@ internal void AddOwnArgs(List args) { args.Add("VSIM"); args.Add(_fieldName); - _vectorData.AddArgs(args); + _vectorData.AddBase64Args(args); _method?.AddOwnArgs(args); if (_filter != null) diff --git a/src/NRedisStack/Search/VectorData.cs b/src/NRedisStack/Search/VectorData.cs index 5e5b3437..08b328ba 100644 --- a/src/NRedisStack/Search/VectorData.cs +++ b/src/NRedisStack/Search/VectorData.cs @@ -31,12 +31,16 @@ private protected VectorData() /// public static implicit operator VectorData(ReadOnlyMemory vector) => new VectorDataSingle(vector); - internal virtual void AddArgs(List args) => args.Add(ToString() ?? ""); - internal virtual int ArgsCount() => 1; + internal void AddBase64Args(List args) => args.Add(ToBase64()); + internal int Base64ArgsCount() => 1; + private protected abstract string ToBase64(); + + /// + public override string ToString() => ToBase64(); private sealed class VectorDataSingle(ReadOnlyMemory vector) : VectorData { - public override string ToString() + private protected override string ToBase64() { if (!BitConverter.IsLittleEndian) ThrowBigEndian(); // we could loop and reverse each, but...how to test? var bytes = MemoryMarshal.AsBytes(vector.Span); @@ -54,7 +58,7 @@ public override string ToString() private sealed class VectorDataBase64(string vector) : VectorData { - public override string ToString() => vector; + private protected override string ToBase64() => vector; } private protected static void ThrowBigEndian() => From f262659bf63062dbf61258f6e11b31bd5ba72f60 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 13 Nov 2025 10:41:29 +0000 Subject: [PATCH 14/21] implement NOSORT --- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../Search/HybridSearchQuery.Command.cs | 128 ++++++++++-------- src/NRedisStack/Search/HybridSearchQuery.cs | 12 ++ .../Search/HybridSearchUnitTests.cs | 29 ++++ 4 files changed, 113 insertions(+), 57 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 688a9ef8..2cfff867 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -17,6 +17,7 @@ [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.Parameters(System.Collections.Generic.IReadOnlyDictionary! parameters) -> 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! diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 008c3244..32c1ae50 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -90,29 +90,36 @@ internal int GetOwnArgsCount() if (_sortByFieldOrFields is not null) { - count += 2; - switch (_sortByFieldOrFields) + if (ReferenceEquals(_sortByFieldOrFields, s_NoSortSentinel)) { - 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; + 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; + } } } @@ -241,43 +248,50 @@ static void AddApply(in ApplyExpression expr, List args) if (_sortByFieldOrFields is not null) { - args.Add("SORTBY"); - switch (_sortByFieldOrFields) + if (ReferenceEquals(_sortByFieldOrFields, s_NoSortSentinel)) { - 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("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); - if (field.Order == SortedField.SortOrder.DESC) args.Add("DESC"); - } - - break; - default: - throw new ArgumentException("Invalid sort by field or fields"); + 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"); + } } } diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 0760a4d2..0893cf3d 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -142,12 +142,24 @@ public HybridSearchQuery Apply(params ApplyExpression[] applyExpression) /// /// 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) { _sortByFieldOrFields = NullIfEmpty(fields); return this; } + /// + /// Do not sort the final results. This disables the default sort by score. + /// + public HybridSearchQuery NoSort() + { + _sortByFieldOrFields = s_NoSortSentinel; + return this; + } + + private static readonly object s_NoSortSentinel = new(); + /// /// Sort the final results by the specified fields. /// diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index 643b2dff..200bb48f 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -440,6 +440,35 @@ public void Apply_Multi() 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() { From 7c7d7fd451e6ee149bb0fe4c61349d9f4686775a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 13 Nov 2025 13:00:47 +0000 Subject: [PATCH 15/21] move parameters back to to method, not the builder --- .../PublicAPI/PublicAPI.Unshipped.txt | 15 +-- src/NRedisStack/Search/ISearchCommands.cs | 3 +- .../Search/ISearchCommandsAsync.cs | 2 +- src/NRedisStack/Search/Parameters.cs | 98 +++++++++++++++++++ src/NRedisStack/Search/SearchCommands.cs | 4 +- src/NRedisStack/Search/SearchCommandsAsync.cs | 4 +- src/NRedisStack/Search/VectorData.cs | 36 ++++--- .../Search/HybridSearchUnitTests.cs | 62 +++++++++--- 8 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 src/NRedisStack/Search/Parameters.cs diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 2cfff867..b961a41e 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,11 +1,14 @@ -[NRS001]NRedisStack.ISearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> NRedisStack.Search.HybridSearchResult! -[NRS001]NRedisStack.ISearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> System.Threading.Tasks.Task! +NRedisStack.Search.Parameters +static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generic.IReadOnlyDictionary! +[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, string? scoreAlias = null) -> NRedisStack.Search.HybridSearchQuery! @@ -18,7 +21,6 @@ [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.Parameters(System.Collections.Generic.IReadOnlyDictionary! parameters) -> 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! @@ -61,8 +63,8 @@ [NRS001]NRedisStack.Search.Scorer [NRS001]NRedisStack.Search.VectorData [NRS001]NRedisStack.Search.VectorSearchMethod -[NRS001]NRedisStack.SearchCommands.HybridSearch(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> NRedisStack.Search.HybridSearchResult! -[NRS001]NRedisStack.SearchCommandsAsync.HybridSearchAsync(string! indexName, NRedisStack.Search.HybridSearchQuery! query) -> System.Threading.Tasks.Task! +[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! @@ -83,8 +85,9 @@ [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.FromBase64(string! base64) -> 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.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/ISearchCommands.cs b/src/NRedisStack/Search/ISearchCommands.cs index 934ee7a2..07670cda 100644 --- a/src/NRedisStack/Search/ISearchCommands.cs +++ b/src/NRedisStack/Search/ISearchCommands.cs @@ -351,8 +351,9 @@ public interface ISearchCommands /// /// 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); + 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 7365d77d..b0b516c0 100644 --- a/src/NRedisStack/Search/ISearchCommandsAsync.cs +++ b/src/NRedisStack/Search/ISearchCommandsAsync.cs @@ -350,5 +350,5 @@ public interface ISearchCommandsAsync /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - Task HybridSearchAsync(string indexName, HybridSearchQuery query); + 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/SearchCommands.cs b/src/NRedisStack/Search/SearchCommands.cs index 7ce5237e..6c0b5ca0 100644 --- a/src/NRedisStack/Search/SearchCommands.cs +++ b/src/NRedisStack/Search/SearchCommands.cs @@ -318,10 +318,10 @@ public bool SynUpdate(string indexName, string synonymGroupId, bool skipInitialS /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - public HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query) + public HybridSearchResult HybridSearch(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) { query.Validate(); - var args = query.GetArgs(indexName); + 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 b7c8121a..ab557252 100644 --- a/src/NRedisStack/Search/SearchCommandsAsync.cs +++ b/src/NRedisStack/Search/SearchCommandsAsync.cs @@ -356,10 +356,10 @@ public async Task SynUpdateAsync(string indexName, string synonymGroupId, /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - public async Task HybridSearchAsync(string indexName, HybridSearchQuery query) + public async Task HybridSearchAsync(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) { query.Validate(); - var args = query.GetArgs(indexName); + 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/VectorData.cs b/src/NRedisStack/Search/VectorData.cs index 08b328ba..a6e4bfcc 100644 --- a/src/NRedisStack/Search/VectorData.cs +++ b/src/NRedisStack/Search/VectorData.cs @@ -17,30 +17,32 @@ private protected VectorData() public static VectorData Create(ReadOnlyMemory vector) => new VectorDataSingle(vector); /// - /// A pre-formatted base-64 value. + /// Represent a vector as a parameter to be supplied later. /// - public static VectorData FromBase64(string base64) => new VectorDataBase64(base64); + public static VectorData Parameter(string name) => new VectorParameter(name); /// /// A vector of entries. /// public static implicit operator VectorData(float[] data) => new VectorDataSingle(data); - /// - /// A vector of entries. - /// + /// public static implicit operator VectorData(ReadOnlyMemory vector) => new VectorDataSingle(vector); - internal void AddBase64Args(List args) => args.Add(ToBase64()); - internal int Base64ArgsCount() => 1; - private protected abstract string ToBase64(); + /// + public static implicit operator VectorData(string name) => new VectorParameter(name); + + internal abstract object GetSingleArg(); /// - public override string ToString() => ToBase64(); + public override string ToString() => GetType().Name; private sealed class VectorDataSingle(ReadOnlyMemory vector) : VectorData { - private protected override string ToBase64() + 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); @@ -56,9 +58,19 @@ private protected override string ToBase64() } } - private sealed class VectorDataBase64(string vector) : VectorData + private sealed class VectorParameter : VectorData { - private protected override string ToBase64() => vector; + 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() => diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index 200bb48f..2872146c 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -10,10 +10,10 @@ public class HybridSearchUnitTests(ITestOutputHelper log) { private string Index { get; } = "myindex"; - private ICollection GetArgs(HybridSearchQuery query) + private ICollection GetArgs(HybridSearchQuery query, IReadOnlyDictionary? parameters = null) { Assert.Equal("FT.HYBRID", query.Command); - var args = query.GetArgs(Index); + var args = query.GetArgs(Index, parameters); log.WriteLine(query.Command + " " + string.Join(" ", args)); return args; } @@ -126,7 +126,7 @@ public void BasicVectorSearch() private static readonly ReadOnlyMemory SomeRandomDataHere = new float[] { 1, 2, 3, 4 }; - private const string SomeRandomBase64 = "AACAPwAAAEAAAEBAAACAQA=="; + private const string SomeRandomVectorValue = "AACAPwAAAEAAAEBAAACAQA=="; [Fact] public void BasicNonZeroLengthVectorSearch() @@ -134,7 +134,7 @@ public void BasicNonZeroLengthVectorSearch() HybridSearchQuery query = new(); query.VectorSearch("vfield", SomeRandomDataHere); - object[] expected = [Index, "VSIM", "vfield", SomeRandomBase64]; + object[] expected = [Index, "VSIM", "vfield", SomeRandomVectorValue]; Assert.Equivalent(expected, GetArgs(query)); } @@ -153,7 +153,7 @@ public void BasicVectorSearch_WithKNN(bool withScoreAlias, bool withDistanceAlia query.VectorSearch(searchConfig); object[] expected = - [Index, "VSIM", "vField", SomeRandomBase64, "KNN", withDistanceAlias ? 4 : 2, "K", 10]; + [Index, "VSIM", "vField", SomeRandomVectorValue, "KNN", withDistanceAlias ? 4 : 2, "K", 10]; if (withDistanceAlias) { expected = [.. expected, "YIELD_DISTANCE_AS", "my_distance_alias"]; @@ -185,7 +185,7 @@ public void BasicVectorSearch_WithKNN_WithEF(bool withScoreAlias, bool withDista object[] expected = [ - Index, "VSIM", "vfield", SomeRandomBase64, "KNN", withDistanceAlias ? 6 : 4, "K", 16, + Index, "VSIM", "vfield", SomeRandomVectorValue, "KNN", withDistanceAlias ? 6 : 4, "K", 16, "EF_RUNTIME", 100 ]; if (withDistanceAlias) @@ -217,7 +217,7 @@ public void BasicVectorSearch_WithRange(bool withScoreAlias, bool withDistanceAl object[] expected = [ - Index, "VSIM", "vfield", SomeRandomBase64, "RANGE", withDistanceAlias ? 4 : 2, "RADIUS", + Index, "VSIM", "vfield", SomeRandomVectorValue, "RANGE", withDistanceAlias ? 4 : 2, "RADIUS", 4.2 ]; if (withDistanceAlias) @@ -250,7 +250,7 @@ public void BasicVectorSearch_WithRange_WithEpsilon(bool withScoreAlias, bool wi object[] expected = [ - Index, "VSIM", "vfield", SomeRandomBase64, "RANGE", withDistanceAlias ? 6 : 4, "RADIUS", + Index, "VSIM", "vfield", SomeRandomVectorValue, "RANGE", withDistanceAlias ? 6 : 4, "RADIUS", 4.2, "EPSILON", 0.06 ]; if (withDistanceAlias) @@ -274,7 +274,7 @@ public void BasicVectorSearch_WithFilter_NoPolicy() object[] expected = [ - Index, "VSIM", "vfield", SomeRandomBase64, "FILTER", "@foo:bar" + Index, "VSIM", "vfield", SomeRandomVectorValue, "FILTER", "@foo:bar" ]; Assert.Equivalent(expected, GetArgs(query)); @@ -449,7 +449,7 @@ public void SortBy_EmptyStrings() object[] expected = [Index]; Assert.Equivalent(expected, GetArgs(query)); } - + [Fact] public void SortBy_EmptySortedFields() { @@ -459,7 +459,7 @@ public void SortBy_EmptySortedFields() object[] expected = [Index]; Assert.Equivalent(expected, GetArgs(query)); } - + [Fact] public void NoSort() { @@ -628,6 +628,28 @@ public void CursorWithCountAndMaxIdle() 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() { @@ -650,7 +672,6 @@ public void MakeMeOneWithEverything() .Limit(12, 54) .ExplainScore() .Timeout() - .Parameters(args) .WithCursor(10, TimeSpan.FromSeconds(10)); object[] expected = [ @@ -668,6 +689,21 @@ public void MakeMeOneWithEverything() log.WriteLine(query.Command + " " + string.Join(" ", expected)); log.WriteLine("vs"); - Assert.Equivalent(expected, GetArgs(query)); + 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 From c992d839b27fbefadee7ced7a6336039c0b7d531 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 13 Nov 2025 13:01:07 +0000 Subject: [PATCH 16/21] push all the changes --- .../Search/HybridSearchQuery.Command.cs | 35 +++++++------- .../Search/HybridSearchQuery.SearchConfig.cs | 3 ++ .../HybridSearchQuery.VectorSearchConfig.cs | 7 +-- src/NRedisStack/Search/HybridSearchQuery.cs | 48 +++++++++++++++++-- .../Search/HybridSearchIntegrationTests.cs | 3 +- 5 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 32c1ae50..32e86150 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -1,8 +1,5 @@ -using System.Buffers; using System.Diagnostics; -using System.Runtime.InteropServices; using NRedisStack.Search.Aggregation; -using StackExchange.Redis; namespace NRedisStack.Search; @@ -10,18 +7,19 @@ public sealed partial class HybridSearchQuery { internal string Command => "FT.HYBRID"; - internal ICollection GetArgs(string index) + internal ICollection GetArgs(string index, IReadOnlyDictionary? parameters) { - var count = GetOwnArgsCount(); + _frozen = true; + var count = GetOwnArgsCount(parameters); var args = new List(count + 1); args.Add(index); - AddOwnArgs(args); + 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() + internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) { int count = _search.GetOwnArgsCount() + _vsim.GetOwnArgsCount(); // note index is not included here @@ -119,7 +117,7 @@ internal int GetOwnArgsCount() count += fields.Length; break; - } + } } } @@ -127,7 +125,10 @@ internal int GetOwnArgsCount() if (_pagingOffset >= 0) count += 3; - if (_parameters is not null) count += (_parameters.Count + 1) * 2; + if (parameters is not null) + { + count += (parameters.Count + 1) * 2; + } if (_explainScore) count++; if (_timeout) count++; @@ -142,7 +143,7 @@ internal int GetOwnArgsCount() return count; } - internal void AddOwnArgs(List args) + internal void AddOwnArgs(List args, IReadOnlyDictionary? parameters) { _search.AddOwnArgs(args); _vsim.AddOwnArgs(args); @@ -291,7 +292,7 @@ static void AddApply(in ApplyExpression expr, List args) break; default: throw new ArgumentException("Invalid sort by field or fields"); - } + } } } @@ -308,24 +309,24 @@ static void AddApply(in ApplyExpression expr, List args) args.Add(_pagingCount); } - if (_parameters is not null) + if (parameters is not null) { args.Add("PARAMS"); - args.Add(_parameters.Count * 2); - if (_parameters is Dictionary typed) + 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); + args.Add(entry.Value is VectorData vec ? vec.GetSingleArg() : entry.Value); } } else { - foreach (var entry in _parameters) + foreach (var entry in parameters) { args.Add(entry.Key); - args.Add(entry.Value); + args.Add(entry.Value is VectorData vec ? vec.GetSingleArg() : entry.Value); } } } diff --git a/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs index ce570c18..a2bd26c8 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.SearchConfig.cs @@ -4,6 +4,9 @@ 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; diff --git a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs index eec6dc1e..972c8eed 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.VectorSearchConfig.cs @@ -1,7 +1,4 @@ -using System.Buffers; -using System.Numerics; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; namespace NRedisStack.Search; @@ -97,7 +94,7 @@ internal int GetOwnArgsCount() int count = 0; if (HasValue) { - count += 2 + _vectorData.Base64ArgsCount(); + count += 3; if (_method != null) count += _method.GetOwnArgsCount(); if (_filter != null) count += 2; @@ -112,7 +109,7 @@ internal void AddOwnArgs(List args) { args.Add("VSIM"); args.Add(_fieldName); - _vectorData.AddBase64Args(args); + args.Add(_vectorData.GetSingleArg()); _method?.AddOwnArgs(args); if (_filter != null) diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 0893cf3d..6721d146 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -4,17 +4,34 @@ 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 { + 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; } @@ -30,6 +47,7 @@ public HybridSearchQuery VectorSearch(string fieldName, VectorData vectorData) /// public HybridSearchQuery VectorSearch(VectorSearchConfig config) { + ThrowIfFrozen(); _vsim = config; return this; } @@ -42,6 +60,7 @@ public HybridSearchQuery VectorSearch(VectorSearchConfig config) /// public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) { + ThrowIfFrozen(); _combiner = combiner; _combineScoreAlias = scoreAlias; return this; @@ -54,6 +73,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) /// public HybridSearchQuery ReturnFields(params string[] fields) // naming for consistency with SearchQuery { + ThrowIfFrozen(); _loadFieldOrFields = NullIfEmpty(fields); return this; } @@ -63,6 +83,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) /// public HybridSearchQuery ReturnFields(string field) // naming for consistency with SearchQuery { + ThrowIfFrozen(); _loadFieldOrFields = field; return this; } @@ -75,6 +96,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) /// public HybridSearchQuery GroupBy(string field) { + ThrowIfFrozen(); _groupByFieldOrFields = field; return this; } @@ -84,6 +106,7 @@ public HybridSearchQuery GroupBy(string field) /// public HybridSearchQuery GroupBy(params string[] fields) { + ThrowIfFrozen(); _groupByFieldOrFields = NullIfEmpty(fields); return this; } @@ -93,6 +116,7 @@ public HybridSearchQuery GroupBy(params string[] fields) /// public HybridSearchQuery Reduce(Reducer reducer) { + ThrowIfFrozen(); _reducerOrReducers = reducer; return this; } @@ -102,6 +126,7 @@ public HybridSearchQuery Reduce(Reducer reducer) /// public HybridSearchQuery Reduce(params Reducer[] reducers) { + ThrowIfFrozen(); _reducerOrReducers = NullIfEmpty(reducers); return this; } @@ -116,6 +141,7 @@ public HybridSearchQuery Reduce(params Reducer[] reducers) [OverloadResolutionPriority(1)] // allow Apply(new("expr", "alias")) to resolve correctly public HybridSearchQuery Apply(ApplyExpression applyExpression) { + ThrowIfFrozen(); if (applyExpression.Alias is null) { _applyExpressionOrExpressions = applyExpression.Expression; @@ -133,6 +159,7 @@ public HybridSearchQuery Apply(ApplyExpression applyExpression) /// public HybridSearchQuery Apply(params ApplyExpression[] applyExpression) { + ThrowIfFrozen(); _applyExpressionOrExpressions = NullIfEmpty(applyExpression); return this; } @@ -145,6 +172,7 @@ public HybridSearchQuery Apply(params ApplyExpression[] applyExpression) /// The default sort order is by score, unless overridden or disabled. public HybridSearchQuery SortBy(params SortedField[] fields) { + ThrowIfFrozen(); _sortByFieldOrFields = NullIfEmpty(fields); return this; } @@ -154,6 +182,7 @@ public HybridSearchQuery SortBy(params SortedField[] fields) /// public HybridSearchQuery NoSort() { + ThrowIfFrozen(); _sortByFieldOrFields = s_NoSortSentinel; return this; } @@ -165,6 +194,7 @@ public HybridSearchQuery NoSort() /// public HybridSearchQuery SortBy(params string[] fields) { + ThrowIfFrozen(); _sortByFieldOrFields = NullIfEmpty(fields); return this; } @@ -174,6 +204,7 @@ public HybridSearchQuery SortBy(params string[] fields) /// public HybridSearchQuery SortBy(SortedField field) { + ThrowIfFrozen(); _sortByFieldOrFields = field; return this; } @@ -183,6 +214,7 @@ public HybridSearchQuery SortBy(SortedField field) /// public HybridSearchQuery SortBy(string field) { + ThrowIfFrozen(); _sortByFieldOrFields = field; return this; } @@ -194,6 +226,7 @@ public HybridSearchQuery SortBy(string field) /// public HybridSearchQuery Filter(string expression) { + ThrowIfFrozen(); _filter = expression; return this; } @@ -202,6 +235,7 @@ public HybridSearchQuery Filter(string expression) 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; @@ -216,6 +250,7 @@ public HybridSearchQuery Limit(int offset, int count) /// public HybridSearchQuery ExplainScore(bool explainScore = true) { + ThrowIfFrozen(); _explainScore = explainScore; return this; } @@ -227,6 +262,7 @@ public HybridSearchQuery ExplainScore(bool explainScore = true) /// public HybridSearchQuery Timeout(bool timeout = true) { + ThrowIfFrozen(); _timeout = timeout; return this; } @@ -239,20 +275,22 @@ public HybridSearchQuery Timeout(bool timeout = true) /// 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; } - private IReadOnlyDictionary? _parameters; - /// - /// Supply parameters for the query. + /// 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 Parameters(IReadOnlyDictionary parameters) + /// + public HybridSearchQuery AllowModification() { - _parameters = parameters is { Count: 0 } ? null : parameters; // ignore empty + _frozen = false; return this; } } \ No newline at end of file diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index b703fdd2..3c43a992 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -51,9 +51,8 @@ public void TestSetup(string endpointId) var query = new HybridSearchQuery() .Search("*") .VectorSearch("@vector1", new float[] { 1, 2, 3, 4 }) - .Parameters(args) .ReturnFields("@text1"); - var result = api.FT.HybridSearch(api.Index, query); + var result = api.FT.HybridSearch(api.Index, query, args); Assert.Equal(0, result.TotalResults); Assert.NotEqual(TimeSpan.Zero, result.ExecutionTime); Assert.Empty(result.Warnings); From 7ebee4f2a800de8b748ef7d054475108dbd3b6f0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 13 Nov 2025 16:11:07 +0000 Subject: [PATCH 17/21] more working-esque integration tests --- .../PublicAPI/PublicAPI.Unshipped.txt | 4 + src/NRedisStack/Search/HybridSearchQuery.cs | 22 ++- src/NRedisStack/Search/VectorData.cs | 11 ++ .../Search/HybridSearchIntegrationTests.cs | 140 ++++++++++++++++-- 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index b961a41e..425c2df7 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,5 +1,7 @@ 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 @@ -15,6 +17,7 @@ static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generi [NRS001]NRedisStack.Search.HybridSearchQuery.Combiner [NRS001]NRedisStack.Search.HybridSearchQuery.Combiner.Combiner() -> void [NRS001]NRedisStack.Search.HybridSearchQuery.ExplainScore(bool explainScore = true) -> NRedisStack.Search.HybridSearchQuery! +[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! @@ -89,5 +92,6 @@ static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generi [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/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 6721d146..2da75d9a 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -11,6 +11,24 @@ namespace NRedisStack.Search; [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; @@ -69,7 +87,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) private object? _loadFieldOrFields; /// - /// Add the list of fields to return in the results. + /// 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 { @@ -79,7 +97,7 @@ public HybridSearchQuery Combine(Combiner combiner, string? scoreAlias = null) } /// - /// Add the list of fields to return in the results. + /// 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 { diff --git a/src/NRedisStack/Search/VectorData.cs b/src/NRedisStack/Search/VectorData.cs index a6e4bfcc..00f43d17 100644 --- a/src/NRedisStack/Search/VectorData.cs +++ b/src/NRedisStack/Search/VectorData.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using StackExchange.Redis; namespace NRedisStack.Search; @@ -16,6 +17,11 @@ private protected VectorData() /// 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. /// @@ -58,6 +64,11 @@ private string ToBase64() } } + private sealed class VectorDataRaw(ReadOnlyMemory bytes) : VectorData + { + internal override object GetSingleArg() => (RedisValue)bytes; + } + private sealed class VectorParameter : VectorData { private readonly string name; diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index 3c43a992..5c03fdb1 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -1,6 +1,11 @@ +using System.Buffers; +using System.Numerics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; using NRedisStack.RedisStackCommands; using NRedisStack.Search; +using StackExchange.Redis; using Xunit; using Xunit.Abstractions; @@ -9,22 +14,26 @@ namespace NRedisStack.Tests.Search; public class HybridSearchIntegrationTests(EndpointsFixture endpointsFixture, ITestOutputHelper log) : AbstractNRedisStackTest(endpointsFixture, log), IDisposable { - private readonly struct Api(SearchCommands ft, string index) + 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 Api CreateIndex(string endpointId, [CallerMemberName] string caller = "", bool populate = true) + 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"] = "FLOAT32", - ["DIM"] = "2", + ["TYPE"] = "FLOAT16", + ["DIM"] = V1DIM, ["DISTANCE_METRIC"] = "L2", }; Schema sc = new Schema() @@ -32,21 +41,46 @@ private Api CreateIndex(string endpointId, [CallerMemberName] string caller = "" .AddTextField("text1", 1.0, missingIndex: true) .AddTagField("tag1", missingIndex: true) .AddNumericField("numeric1", missingIndex: true) - .AddGeoField("geo1", missingIndex: true) - .AddGeoShapeField("geoshape1", Schema.GeoShapeField.CoordinateSystem.FLAT, missingIndex: true) .AddVectorField("vector1", Schema.VectorField.VectorAlgo.FLAT, vectorAttrs, missingIndex: true); var ftCreateParams = FTCreateParams.CreateParams(); - Assert.True(ft.Create(index, ftCreateParams, sc)); + 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); + return new(ft, index, db); } [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] - public void TestSetup(string endpointId) + public async Task TestSetup(string endpointId) { - var api = CreateIndex(endpointId, populate: false); + var api = await CreateIndexAsync(endpointId, populate: false); Dictionary args = new() { ["x"] = "abc" }; var query = new HybridSearchQuery() .Search("*") @@ -58,4 +92,90 @@ public void TestSetup(string endpointId) Assert.Empty(result.Warnings); Assert.Empty(result.Results); } + + [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] + [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), 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.Equal(10, result.Results.Length); + } + + 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.WriteLine(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 From 6bc9d5514bbf02f31d9cf59ef53a10838d2eb41d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 14 Nov 2025 14:08:37 +0000 Subject: [PATCH 18/21] parse search results correctly --- .../PublicAPI/PublicAPI.Unshipped.txt | 3 +- src/NRedisStack/Search/Document.cs | 48 +++++++++++++++++++ src/NRedisStack/Search/HybridSearchQuery.cs | 1 + src/NRedisStack/Search/HybridSearchResult.cs | 40 ++++++++++++---- .../Search/HybridSearchIntegrationTests.cs | 13 ++++- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 425c2df7..812698c2 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -60,9 +60,8 @@ static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generi [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 -> System.Collections.Generic.IReadOnlyDictionary![]! +[NRS001]NRedisStack.Search.HybridSearchResult.Results.get -> NRedisStack.Search.Document![]! [NRS001]NRedisStack.Search.HybridSearchResult.TotalResults.get -> long -[NRS001]NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! [NRS001]NRedisStack.Search.Scorer [NRS001]NRedisStack.Search.VectorData [NRS001]NRedisStack.Search.VectorSearchMethod 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.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index 2da75d9a..da1c4e08 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -27,6 +27,7 @@ public static class Fields /// The score from the query. /// public const string Score = "@__score"; + // ReSharper restore InconsistentNaming } private bool _frozen; diff --git a/src/NRedisStack/Search/HybridSearchResult.cs b/src/NRedisStack/Search/HybridSearchResult.cs index 97b2cf4f..d99db3f4 100644 --- a/src/NRedisStack/Search/HybridSearchResult.cs +++ b/src/NRedisStack/Search/HybridSearchResult.cs @@ -31,6 +31,7 @@ internal static HybridSearchResult Parse(RedisResult? result) 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++) @@ -39,14 +40,9 @@ internal static HybridSearchResult Parse(RedisResult? result) } obj.Warnings = warnings; break; + */ case ResultKey.Results when value.Length > 0: - var rows = new IReadOnlyDictionary[value.Length]; - for (int j = 0; j < value.Length; j++) - { - rows[j] = ParseRow(value[j]); - } - - obj.Results = rows; + obj._rawResults = value.ToArray(); break; } } @@ -113,11 +109,37 @@ private enum ResultKey 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; } - public string[] Warnings { 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(); - public IReadOnlyDictionary[] Results { get; private set; } = []; + 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/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index 5c03fdb1..e6f8d070 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Data; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -113,7 +114,17 @@ public async Task TestSearch(string endpointId) 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}"); + } } private void WriteArgs(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) @@ -146,7 +157,7 @@ private void WriteArgs(string indexName, HybridSearchQuery query, IReadOnlyDicti sb.Append(arg); } } - log.WriteLine(sb.ToString()); + Log(sb.ToString()); ArrayPool.Shared.Return(scratch); From ef57889bcbfa08b4dfce23d8a7c3acd41a5136ab Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 14 Nov 2025 15:59:20 +0000 Subject: [PATCH 19/21] integration tests --- .../PublicAPI/PublicAPI.Unshipped.txt | 6 +- .../Search/HybridSearchQuery.Combiner.cs | 43 ++--- .../Search/HybridSearchQuery.Command.cs | 10 +- src/NRedisStack/Search/HybridSearchQuery.cs | 13 +- src/NRedisStack/Search/Scorer.cs | 4 +- .../Search/HybridSearchIntegrationTests.cs | 161 +++++++++++++++++- .../Search/HybridSearchUnitTests.cs | 19 +-- 7 files changed, 198 insertions(+), 58 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 812698c2..59589c8c 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -13,10 +13,9 @@ static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generi [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, string? scoreAlias = null) -> 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.ExplainScore(bool explainScore = true) -> NRedisStack.Search.HybridSearchQuery! [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! @@ -42,7 +41,7 @@ static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generi [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(bool timeout = true) -> 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 @@ -80,7 +79,6 @@ static NRedisStack.Search.Parameters.From(T obj) -> System.Collections.Generi [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.BM25StdTanh(int y = 4) -> 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! diff --git a/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs b/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs index ee791ef6..6e7994b9 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Combiner.cs @@ -16,7 +16,7 @@ public static Combiner Linear(double alpha = LinearCombiner.DEFAULT_ALPHA, doubl => LinearCombiner.Create(alpha, beta); internal abstract int GetOwnArgsCount(); - internal abstract void AddOwnArgs(List args); + internal abstract void AddOwnArgs(List args, int limit); private sealed class ReciprocalRankFusionCombiner : Combiner { @@ -41,24 +41,21 @@ internal static ReciprocalRankFusionCombiner Create(int? window, double? constan internal override int GetOwnArgsCount() { - int count = 2; - if (_window is not null) count += 2; + int count = 4; if (_constant is not null) count += 2; return count; } - internal override void AddOwnArgs(List args) + private static readonly object BoxedDefaultWindow = 20; + + internal override void AddOwnArgs(List args, int limit) { args.Add(Method); - int tokens = 0; - if (_window is not null) tokens += 2; + int tokens = 2; if (_constant is not null) tokens += 2; args.Add(tokens); - if (_window is not null) - { - args.Add("WINDOW"); - args.Add(_window); - } + args.Add("WINDOW"); + args.Add(_window ?? (limit > 0 ? limit : BoxedDefaultWindow)); if (_constant is not null) { @@ -92,25 +89,21 @@ internal static LinearCombiner Create(double alpha, double beta) public override string ToString() => $"{Method} {_alpha} {_beta}"; - internal override int GetOwnArgsCount() => IsDefault ? 2 : 6; + internal override int GetOwnArgsCount() => 6; private bool IsDefault => ReferenceEquals(this, s_Default); - internal override void AddOwnArgs(List args) + private static readonly object BoxedDefaultAlpha = DEFAULT_ALPHA, BoxedDefaultBeta = DEFAULT_BETA; + + internal override void AddOwnArgs(List args, int limit) { args.Add(Method); - if (IsDefault) - { - args.Add(0); - } - else - { - args.Add(4); - args.Add("ALPHA"); - args.Add(_alpha); - args.Add("BETA"); - args.Add(_beta); - } + 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); } } } diff --git a/src/NRedisStack/Search/HybridSearchQuery.Command.cs b/src/NRedisStack/Search/HybridSearchQuery.Command.cs index 32e86150..0d834bf9 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.Command.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.Command.cs @@ -131,7 +131,7 @@ internal int GetOwnArgsCount(IReadOnlyDictionary? parameters) } if (_explainScore) count++; - if (_timeout) count++; + if (_timeout > TimeSpan.Zero) count+= 2; if (_cursorCount >= 0) { @@ -151,7 +151,7 @@ internal void AddOwnArgs(List args, IReadOnlyDictionary? if (_combiner is not null) { args.Add("COMBINE"); - _combiner.AddOwnArgs(args); + _combiner.AddOwnArgs(args, _pagingCount); if (_combineScoreAlias != null) { @@ -332,7 +332,11 @@ static void AddApply(in ApplyExpression expr, List args) } if (_explainScore) args.Add("EXPLAINSCORE"); - if (_timeout) args.Add("TIMEOUT"); + if (_timeout > TimeSpan.Zero) + { + args.Add("TIMEOUT"); + args.Add((long)_timeout.TotalMilliseconds); + } if (_cursorCount >= 0) { diff --git a/src/NRedisStack/Search/HybridSearchQuery.cs b/src/NRedisStack/Search/HybridSearchQuery.cs index da1c4e08..85d38247 100644 --- a/src/NRedisStack/Search/HybridSearchQuery.cs +++ b/src/NRedisStack/Search/HybridSearchQuery.cs @@ -77,7 +77,12 @@ public HybridSearchQuery VectorSearch(VectorSearchConfig config) /// /// 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, string? scoreAlias = null) + 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; @@ -267,19 +272,19 @@ public HybridSearchQuery Limit(int offset, int count) /// /// Include score explanations /// - public HybridSearchQuery ExplainScore(bool explainScore = true) + internal HybridSearchQuery ExplainScore(bool explainScore = true) // not yet implemented { ThrowIfFrozen(); _explainScore = explainScore; return this; } - private bool _timeout; + private TimeSpan _timeout; /// /// Apply the global timeout setting. /// - public HybridSearchQuery Timeout(bool timeout = true) + public HybridSearchQuery Timeout(TimeSpan timeout) { ThrowIfFrozen(); _timeout = timeout; diff --git a/src/NRedisStack/Search/Scorer.cs b/src/NRedisStack/Search/Scorer.cs index 41d3ae43..3855a669 100644 --- a/src/NRedisStack/Search/Scorer.cs +++ b/src/NRedisStack/Search/Scorer.cs @@ -39,10 +39,10 @@ private protected Scorer() public static Scorer BM25StdNorm { get; } = new SimpleScorer("BM25STD.NORM"); /// - /// A variation of BM25STD.NORM, where the scores are normalised by the linear function tanh(x). + /// 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. - public static Scorer BM25StdTanh(int y = Bm25StdTanh.DEFAULT_Y) => Bm25StdTanh.Create(y); + internal static Scorer BM25StdTanh(int y = Bm25StdTanh.DEFAULT_Y) => Bm25StdTanh.Create(y); // doesn't yet work with FT.HYBRID // ReSharper restore InconsistentNaming /// diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index e6f8d070..1fcdc6b9 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -1,11 +1,13 @@ 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; @@ -24,7 +26,8 @@ private readonly struct Api(SearchCommands ft, string index, IDatabase db) private const int V1DIM = 5; - private async Task CreateIndexAsync(string endpointId, [CallerMemberName] string caller = "", bool populate = true) + private async Task CreateIndexAsync(string endpointId, [CallerMemberName] string caller = "", + bool populate = true) { var index = $"ix_{caller}"; var db = GetCleanDatabase(endpointId); @@ -78,7 +81,7 @@ private async Task CreateIndexAsync(string endpointId, [CallerMemberName] s } [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] - [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] public async Task TestSetup(string endpointId) { var api = await CreateIndexAsync(endpointId, populate: false); @@ -95,7 +98,7 @@ public async Task TestSetup(string endpointId) } [SkipIfRedisTheory(Comparison.LessThan, "8.3.224")] - [MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] public async Task TestSearch(string endpointId) { var api = await CreateIndexAsync(endpointId, populate: true); @@ -127,7 +130,153 @@ public async Task TestSearch(string endpointId) } } - private void WriteArgs(string indexName, HybridSearchQuery query, IReadOnlyDictionary? parameters = null) + public enum Scenario + { + Simple, + NoSort, + [Obsolete] ExplainScore, + Apply, + LinearNoScore, + [Obsolete]LinearWithScore, + RrfNoScore, + [Obsolete]RrfWithScore, + [Obsolete]FilterByTag, + FilterByNumber, + LimitFirstPage, + LimitSecondPage, + LimitEmptyPage, + SortBySingle, + SortByMultiple, + Timeout, + ReduceSingleSimpleWithAlias, + ReduceSingleSimpleWithoutAlias, + ReduceSingleComplexWithAlias, + ReduceMulti, + GroupByNoReduce, + SearchWithAlias, + SearchWithSimpleScorer, + [Obsolete]SearchWithComplexScorer, + [Obsolete]VectorWithAlias, + VectorWithRange, + [Obsolete]VectorWithRangeAndDistanceAlias, + [Obsolete]VectorWithRangeAndEpsilon, + VectorWithTagFilter, + VectorWithNumericFilter, + VectorWithNearest, + VectorWithNearestCount, + [Obsolete]VectorWithNearestDistAlias, + [Obsolete]VectorWithNearestMaxCandidates, + } + + private static class EnumCache + { + public static IEnumerable Values { get; } = ( + from field in typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static) + where !Attribute.IsDefined(field, typeof(ObsoleteAttribute)) + 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.FilterByTag => query.Filter("@tag1:{foo}"), + Scenario.FilterByNumber => 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")), + _ => throw new ArgumentOutOfRangeException(scenario.ToString()), + }; +#pragma warning restore CS0612 + WriteArgs(api.Index, query); + + var result = api.FT.HybridSearch(api.Index, query); + 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 = []; @@ -138,7 +287,7 @@ private void WriteArgs(string indexName, HybridSearchQuery query, IReadOnlyDicti sb.Append(' '); if (arg is string s) { - sb.Append('"').Append(s.Replace("\"","\\\"")).Append('"'); + sb.Append('"').Append(s.Replace("\"", "\\\"")).Append('"'); } else if (arg is RedisValue v) { @@ -157,6 +306,7 @@ private void WriteArgs(string indexName, HybridSearchQuery query, IReadOnlyDicti sb.Append(arg); } } + Log(sb.ToString()); ArrayPool.Shared.Return(scratch); @@ -186,6 +336,7 @@ static void WriteEscaped(ReadOnlySpan span, StringBuilder sb) sb.Append((char)b); } } + sb.Append('"'); } } diff --git a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs index 2872146c..f987a10c 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchUnitTests.cs @@ -571,22 +571,11 @@ public void ExplainScoreExplicit(bool enabled) } [Fact] - public void TimeoutImplicit() + public void Timeout() { HybridSearchQuery query = new(); - query.Timeout(); - object[] expected = [Index, "TIMEOUT"]; - Assert.Equivalent(expected, GetArgs(query)); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void TimeoutExplicit(bool enabled) - { - HybridSearchQuery query = new(); - query.Timeout(enabled); - object[] expected = enabled ? [Index, "TIMEOUT"] : [Index]; + query.Timeout(TimeSpan.FromSeconds(1)); + object[] expected = [Index, "TIMEOUT", 1000]; Assert.Equivalent(expected, GetArgs(query)); } @@ -671,7 +660,7 @@ public void MakeMeOneWithEverything() .Filter("@field1:bar") .Limit(12, 54) .ExplainScore() - .Timeout() + .Timeout(TimeSpan.FromSeconds(1)) .WithCursor(10, TimeSpan.FromSeconds(10)); object[] expected = [ From 876adfb312855736d1e31544bece4ada149fa4ec Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 14 Nov 2025 16:14:54 +0000 Subject: [PATCH 20/21] pre-vs-post-filter --- .../Search/HybridSearchIntegrationTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index 1fcdc6b9..ae9c53b2 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -140,8 +140,8 @@ public enum Scenario [Obsolete]LinearWithScore, RrfNoScore, [Obsolete]RrfWithScore, - [Obsolete]FilterByTag, - FilterByNumber, + [Obsolete]PostFilterByTag, + PostFilterByNumber, LimitFirstPage, LimitSecondPage, LimitEmptyPage, @@ -166,6 +166,8 @@ public enum Scenario VectorWithNearestCount, [Obsolete]VectorWithNearestDistAlias, [Obsolete]VectorWithNearestMaxCandidates, + PreFilterByTag, + PreFilterByNumeric } private static class EnumCache @@ -233,8 +235,10 @@ public async Task TestSearchScenarios(string endpointId, Scenario scenario) 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.FilterByTag => query.Filter("@tag1:{foo}"), - Scenario.FilterByNumber => query.ReturnFields([..fields, "@numeric1"]).Filter("@numeric1!=0"), + 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), From b89e9904a2d548fa4384cfdafdd37503686c5fd7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 14 Nov 2025 17:09:12 +0000 Subject: [PATCH 21/21] attr --- .../Search/HybridSearchIntegrationTests.cs | 105 +++++++++++++----- 1 file changed, 75 insertions(+), 30 deletions(-) diff --git a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs index ae9c53b2..fe0ef9d8 100644 --- a/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs +++ b/tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs @@ -64,13 +64,17 @@ private async Task CreateIndexAsync(string endpointId, [CallerMemberName] s { halves[j] = (Half)rand.NextDouble(); } - HashEntry[] entry = [ + + HashEntry[] entry = + [ new("text1", $"Search entry {i}"), new("tag1", tags[rand.Next(tags.Length)]), new("numeric1", rand.Next(0, 32)), - new("vector1", vec)]; + new("vector1", vec) + ]; last = db.HashSetAsync($"{index}_entry{i}", entry); } + await last; #else throw new PlatformNotSupportedException("FP16"); @@ -134,13 +138,13 @@ public enum Scenario { Simple, NoSort, - [Obsolete] ExplainScore, + [Broken] ExplainScore, Apply, LinearNoScore, - [Obsolete]LinearWithScore, + LinearWithScore, RrfNoScore, - [Obsolete]RrfWithScore, - [Obsolete]PostFilterByTag, + RrfWithScore, + [Broken] PostFilterByTag, PostFilterByNumber, LimitFirstPage, LimitSecondPage, @@ -155,26 +159,36 @@ public enum Scenario GroupByNoReduce, SearchWithAlias, SearchWithSimpleScorer, - [Obsolete]SearchWithComplexScorer, - [Obsolete]VectorWithAlias, + [Broken] SearchWithComplexScorer, + VectorWithAlias, VectorWithRange, - [Obsolete]VectorWithRangeAndDistanceAlias, - [Obsolete]VectorWithRangeAndEpsilon, + [Broken] VectorWithRangeAndDistanceAlias, + VectorWithRangeAndEpsilon, VectorWithTagFilter, VectorWithNumericFilter, VectorWithNearest, VectorWithNearestCount, - [Obsolete]VectorWithNearestDistAlias, - [Obsolete]VectorWithNearestMaxCandidates, + [Broken] VectorWithNearestDistAlias, + VectorWithNearestMaxCandidates, PreFilterByTag, - PreFilterByNumeric + 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(ObsoleteAttribute)) + where !Attribute.IsDefined(field, typeof(BrokenAttribute)) let val = field.GetRawConstantValue() where val is not null select (T)val).ToArray(); @@ -217,16 +231,26 @@ public async Task TestSearchScenarios(string endpointId, Scenario scenario) 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.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"]) @@ -236,7 +260,8 @@ public async Task TestSearchScenarios(string endpointId, Scenario scenario) 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.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), @@ -248,14 +273,34 @@ public async Task TestSearchScenarios(string endpointId, Scenario scenario) 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.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 - WriteArgs(api.Index, query); + 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); + var result = api.FT.HybridSearch(api.Index, query, args); Assert.True(result.TotalResults > 0); Assert.NotEqual(TimeSpan.Zero, result.ExecutionTime); Assert.Empty(result.Warnings); @@ -265,7 +310,7 @@ public async Task TestSearchScenarios(string endpointId, Scenario scenario) { Log($"{row.Id}, {row.Score}"); if (!(scenario is Scenario.ReduceSingleSimpleWithAlias or Scenario.ReduceSingleComplexWithAlias - or Scenario.ReduceMulti or Scenario.ReduceSingleSimpleWithoutAlias or Scenario.GroupByNoReduce)) + or Scenario.ReduceMulti or Scenario.ReduceSingleSimpleWithoutAlias or Scenario.GroupByNoReduce)) { Assert.NotNull(row.Id); Assert.NotEqual("", row.Id);