diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index 9f98878e2b7..16a60d690e9 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -2029,6 +2029,18 @@ public void SetLimit(SqlExpression sqlExpression) } } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + [EntityFrameworkInternal] + public void SetOffset(SqlExpression? sqlExpression) + { + Offset = sqlExpression; + } + /// /// Applies offset to the to skip the number of rows in the result set. /// diff --git a/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs index 432eb9e1f5c..74bb0229574 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs @@ -30,8 +30,8 @@ public static class SqlServerQueryableExtensions /// /// /// - /// Compose the returned query with OrderBy(r => r.Distance) and Take(...) to limit the results as required - /// for approximate vector search. + /// Results are implicitly ordered by distance ascending. Compose the returned query with Take(...) to limit the + /// number of results as required for approximate vector search. /// /// /// diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 2f5825011fe..d13bcc5be5b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs @@ -147,12 +147,18 @@ var metric var valueProjectionMember = new ProjectionMember().Append(resultType.GetProperty(nameof(VectorSearchResult<>.Value))!); var distanceProjectionMember = new ProjectionMember().Append(resultType.GetProperty(nameof(VectorSearchResult<>.Distance))!); + var distanceColumn = new ColumnExpression("Distance", vectorSearchFunction.Alias, typeof(double), _typeMappingSource.FindMapping(typeof(double)), nullable: false); + select.ReplaceProjection(new Dictionary { [valueProjectionMember] = entityProjection, - [distanceProjectionMember] = new ColumnExpression("Distance", vectorSearchFunction.Alias, typeof(double), _typeMappingSource.FindMapping(typeof(double)), nullable: false) + [distanceProjectionMember] = distanceColumn }); + // VECTOR_SEARCH() results are implicitly ordered by distance ascending; add this ordering so that + // users can compose with Take() without needing an explicit OrderBy(r => r.Distance). + select.AppendOrdering(new OrderingExpression(distanceColumn, ascending: true)); + var shaper = Expression.New( resultType.GetConstructors().Single(), arguments: @@ -795,6 +801,49 @@ bool TryTranslate( } } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression? TranslateTake(ShapedQueryExpression source, Expression count) + { + var selectExpression = (SelectExpression)source.QueryExpression; + + // When VECTOR_SEARCH() is present and an Offset has already been applied (i.e. Skip().Take()), we need to ensure that the + // generated SQL uses TOP WITH APPROXIMATE in a subquery, rather than the default OFFSET...FETCH which doesn't use the + // vector index. We push down a subquery with TOP(Skip + Take) WITH APPROXIMATE, and apply OFFSET...FETCH on the outer query. + if (selectExpression is { Offset: { } existingOffset } + && selectExpression.Tables.Any(t => t.UnwrapJoin() is TableValuedFunctionExpression { Name: "VECTOR_SEARCH" })) + { + var translation = TranslateExpression(count); + if (translation == null) + { + return null; + } + + var combinedLimit = _sqlExpressionFactory.Add(existingOffset, translation); + +#pragma warning disable EF1001 // Internal EF Core API usage. + // Clear the offset so the inner subquery uses TOP(M+N) instead of OFFSET...FETCH + selectExpression.SetOffset(null); + selectExpression.SetLimit(combinedLimit); + + // Push down: inner gets TOP(Skip+Take) WITH APPROXIMATE, outer is clean + selectExpression.PushdownIntoSubquery(); + + // Apply the original offset and take on the outer query as OFFSET...FETCH + selectExpression.ApplyOffset(existingOffset); + selectExpression.SetLimit(translation); +#pragma warning restore EF1001 // Internal EF Core API usage. + + return source; + } + + return base.TranslateTake(source, count); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -802,22 +851,42 @@ bool TryTranslate( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// protected override bool IsNaturallyOrdered(SelectExpression selectExpression) - => selectExpression is + => selectExpression switch { - Tables: [SqlServerOpenJsonExpression openJsonExpression, ..], - Orderings: - [ - { - Expression: SqlUnaryExpression + // OPENJSON rows are naturally ordered by their "key" column (array index), ascending. + // EF adds this ordering implicitly when expanding JSON arrays; treat it as natural to avoid spurious + // "Distinct after OrderBy without row limiting operator" warnings. + { + Tables: [SqlServerOpenJsonExpression openJsonExpression, ..], + Orderings: + [ { - OperatorType: ExpressionType.Convert, - Operand: ColumnExpression { Name: "key", TableAlias: var orderingTableAlias } - }, - IsAscending: true - } - ] - } - && orderingTableAlias == openJsonExpression.Alias; + Expression: SqlUnaryExpression + { + OperatorType: ExpressionType.Convert, + Operand: ColumnExpression { Name: "key", TableAlias: var openJsonOrderingTableAlias } + }, + IsAscending: true + } + ] + } when openJsonOrderingTableAlias == openJsonExpression.Alias => true, + + // VECTOR_SEARCH() results are naturally ordered by Distance ascending. + // EF adds this ordering implicitly during VectorSearch translation; treat it as natural to avoid spurious + // "Distinct after OrderBy without row limiting operator" warnings. + { + Tables: [TableValuedFunctionExpression { Name: "VECTOR_SEARCH" } vectorSearchFunction, ..], + Orderings: + [ + { + Expression: ColumnExpression { Name: "Distance", TableAlias: var vectorSearchOrderingTableAlias }, + IsAscending: true + } + ] + } when vectorSearchOrderingTableAlias == vectorSearchFunction.Alias => true, + + _ => false + }; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs index 62e012f04ae..d77b2c8c804 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs @@ -77,7 +77,6 @@ public async Task VectorSearch_project_entity_and_distance() var results = await ctx.VectorEntities .VectorSearch(e => e.Vector, similarTo: vector, "cosine") - .OrderBy(e => e.Distance) .Take(1) .ToListAsync(); @@ -103,7 +102,7 @@ ORDER BY [v0].[Distance] [ConditionalFact] [SqlServerCondition(SqlServerCondition.IsAzureSql)] [Experimental("EF9105")] - public async Task VectorSearch_project_entity_only_with_distance_filter_and_ordering() + public async Task VectorSearch_project_entity_only_with_distance_filter() { using var ctx = CreateContext(); @@ -112,7 +111,6 @@ public async Task VectorSearch_project_entity_only_with_distance_filter_and_orde var results = await ctx.VectorEntities .VectorSearch(e => e.Vector, similarTo: vector, "cosine") .Where(e => e.Distance < 0.01) - .OrderBy(e => e.Distance) .Select(e => e.Value) .Take(3) .ToListAsync(); @@ -151,7 +149,6 @@ public async Task VectorSearch_in_subquery() var results = await ctx.VectorEntities .VectorSearch(e => e.Vector, similarTo: vector, "cosine") - .OrderBy(e => e.Distance) .Take(3) .Select(e => new { e.Value.Id, e.Distance }) .Where(e => e.Distance < 0.01) @@ -183,6 +180,86 @@ ORDER BY [v1].[Distance] """); } + // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384). + [ConditionalFact] + [SqlServerCondition(SqlServerCondition.IsAzureSql)] + [Experimental("EF9105")] + public async Task VectorSearch_with_Skip_and_Take() + { + using var ctx = CreateContext(); + + var vector = new SqlVector(new float[] { 1, 2, 100 }); + + var results = await ctx.VectorEntities + .VectorSearch(e => e.Vector, similarTo: vector, "cosine") + .Skip(1) + .Take(2) + .ToListAsync(); + + Assert.Equal(2, results.Count); + + AssertSql( + """ +@p1='1' +@p2='2' +@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary) + +SELECT [v1].[Id], [v1].[Distance] +FROM ( + SELECT TOP(@p1 + @p2) WITH APPROXIMATE [v].[Id], [v0].[Distance] + FROM VECTOR_SEARCH( + TABLE = [VectorEntities] AS [v], + COLUMN = [Vector], + SIMILAR_TO = @p, + METRIC = 'cosine' + ) AS [v0] + ORDER BY [v0].[Distance] +) AS [v1] +ORDER BY [v1].[Distance] +OFFSET @p1 ROWS FETCH NEXT @p2 ROWS ONLY +"""); + } + + // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384). + [ConditionalFact] + [SqlServerCondition(SqlServerCondition.IsAzureSql)] + [Experimental("EF9105")] + public async Task VectorSearch_with_Take_and_Skip() + { + using var ctx = CreateContext(); + + var vector = new SqlVector(new float[] { 1, 2, 100 }); + + var results = await ctx.VectorEntities + .VectorSearch(e => e.Vector, similarTo: vector, "cosine") + .Take(3) + .Skip(1) + .ToListAsync(); + + Assert.Equal(2, results.Count); + + AssertSql( + """ +@p1='3' +@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary) +@p2='1' + +SELECT [v1].[Id], [v1].[Distance] +FROM ( + SELECT TOP(@p1) WITH APPROXIMATE [v].[Id], [v0].[Distance] + FROM VECTOR_SEARCH( + TABLE = [VectorEntities] AS [v], + COLUMN = [Vector], + SIMILAR_TO = @p, + METRIC = 'cosine' + ) AS [v0] + ORDER BY [v0].[Distance] +) AS [v1] +ORDER BY [v1].[Distance] +OFFSET @p2 ROWS +"""); + } + [ConditionalFact] public async Task Length() {