Skip to content

Conversation

@mgravell
Copy link
Collaborator

@mgravell mgravell commented Nov 12, 2025

tasks:

primary API is new HybridSearchResult ISearchCommands.HybridSearch((string indexName, HybridSearchQuery query, IReadOnlyDictionary<string, object>? parameters) API (and Async twin).

Note that because FT.HYBRID supports parameterization in both the queries and the filters, I have deviated from the pattern for FT.SEARCH, in that the parameters are specified separately to the query. This means that a single query instance can be created and stored, then reused repeatedly with different values. To avoid concurrency concerns, "popsicle immutability" is used in the query object, becoming automatically frozen when issued. For convenience, a secondary type-based API is presented to allow parameters from, for example, anonymous types.

HybridSearchQuery acts as a builder, so:

var query = new HybridSearchQuery().Search("text part").VectorSearch("@vectorField", vectorData);
var result = ft.HybridSearch("myIndex", query);

However, a full API for the supported server features is available, for example:

var query = new HybridSearchQuery()
            .Search(new("foo", Scorer.BM25StdTanh(5), "text_score_alias"))
            .VectorSearch(new HybridSearchQuery.VectorSearchConfig("bar", new float[] { 1, 2, 3 },
                    VectorSearchMethod.NearestNeighbour(10, 100, "vector_distance_alias"))
                .WithFilter("@foo:bar").WithScoreAlias("vector_score_alias"))
            .Combine(HybridSearchQuery.Combiner.ReciprocalRankFusion(10, 0.5), "my_combined_alias")
            .ReturnFields("field1", "field2")
            .GroupBy("field1").Reduce(Reducers.Quantile("@field3", 0.5).As("reducer_alias"))
            .Apply(new("@field1 + @field2", "apply_alias"))
            .SortBy(SortedField.Asc("field1"), SortedField.Desc("field2"))
            .Filter("@field1:bar")
            .Limit(12, 54)
            .ExplainScore()
            .Timeout()

var args = Parameters.From(new { x = 12, y = "abc" });
var results = ft.HybridSearch(query, args);

To complement this, we introduce some additional additional types; the types specific to hybrid-search are mostly inside NRedisStack.Search.HybridSearchQuery; the types that are also conceptually wider to all of search are in NRedisStack.Search.

  • NRedisStack.Search.ApplyExpression - immutable readonly struct tuple of string expression and string? alias - NRedisStack.Search.Scorer - immutable class, private subtypes for TFIDF, BM25STD, etc from https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/scoring/
  • NRedisStack.Search.VectorData - abstracts over ways of representing vector data; readonly struct
  • NRedisStack.Search.HybridSearchQuery - the new builder API
  • NRedisStack.Search.HybridSearchQuery - the new result API
  • NRedisStack.Search.HybridSearchQuery.Combiner - immutable class, private subtypes for RRD and linear combiners
  • NRedisStack.Search.HybridSearchQuery.SearchConfig - immutable readonly struct describing a text search with scorer and alias
  • NRedisStack.Search.HybridSearchQuery.VectorSearchConfig - immutable readonly struct describing a vector search with method, filter, alias
  • NRedisStack.Search.VectorSearchMethod - immutable class, private subtypes for "nearest neighbour", "range", etc
  • NRedisStack.Search.Parameters - allows objects to be used as parameter sources

At the moment VectorData only supports ROM<float> (float[]), in line with SE.Redis VSIM support, but we can extend this as necessary.


To make working with vector data more convenient, a new VectorData API is added, with use-cases:

  • var vec = VectorData.Raw(blob); - raw handling, owner handles lifetime
  • var vec = VectorData.Parameter(name); - vector deferred to the parameters collection
  • using var vec = VectorData.Lease<Half>(20); - lease a vector backed by raw bytes, interpreted as Half, with .Span a convenience accessor
  • using var vec = VectorData.LeaseWithValues(...) - like Lease, but inferring type and dimension from the supplied payload, which becomes the initial payload

There is no quantization etc; the caller is required to understand their vector type and send the values appropriately, but this is hopefully a little easier than making the consumer responsible for the byte shunt.

Note that in all cases, the vector is passed in PARAMS to prevent $ ambiguity, and to pre-empt future server API changes here.


The new APIs are marked [Experimental], pointing people to (when merged) a portal with the contents from https://github.com/redis/NRedisStack/blob/9548d539ff72ac370c7c0706e8e9c53b8492f1b3/docs/exp/NRS001.md


API note: the significance of With* in the VectorSearchConfig and SearchConfig is that these are "withers", i.e. while it is a fluent API, the underlying type is immutable, so this returns a different instance (in this case, of a value-type). This contrasts with the HybridSearchQuery which pairs with SearchQuery in exposing a mutable fluent API, where each method ends return this.


Note that this also includes a fix for #453

@mgravell mgravell marked this pull request as draft November 12, 2025 14:33
Copy link
Collaborator

@atakavci atakavci left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other than a couple of questions and small details, looks pretty good to me.

@@ -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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

multiple indexes

needs small change with wording.

}
}

private sealed class LinearCombiner : Combiner
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WINDOW is missing with LinearCombiner.
https://redis.io/docs/latest/commands/ft.hybrid/


public sealed partial class HybridSearchQuery
{
public abstract class Combiner
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why YIELD_SCORE_AS is left out?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the server doesn't implement this yet

/// <summary>
/// Configure the score fusion method (optional). If not provided, Reciprocal Rank Fusion (RRF) is used with server-side default parameters.
/// </summary>
internal HybridSearchQuery Combine(Combiner combiner, string scoreAlias) // YIELD_SCORE_AS not yet implemented
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit confused, the comment on YIELD_SCORE_AS is a left over?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the server doesn't implement this yet

/// <summary>
/// Add the list of fields to return in the results. Well-known fields are available via <see cref="Fields"/>.
/// </summary>
public HybridSearchQuery ReturnFields(string field) // naming for consistency with SearchQuery
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just to avoid array allocation for single item?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, basically


#if !NET9_0_OR_GREATER
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
internal sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no use of this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will remove; there was at some point

}
break;
}
static int CountReducer(Reducer reducer) => 3 + reducer.ArgCount() + (reducer.Alias is null ? 0 : 2);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to make these calculations easier to maintain (or self explaning)?
feels like these expressions could get confusing quickly in between command syntax changes in time.

/// common queries, by passing the search operands as named parameters.
/// </summary>
[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)]
public sealed partial class HybridSearchQuery
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets put it explicitly that class is not thread-safe and not ready to use by multiple threads with as is condition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants