From 07fb69cd3f1496c2b53126858a55d6621e934c8c Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 11 Apr 2026 09:38:16 +0200 Subject: [PATCH 1/4] Add implicit ORDER BY Distance for VectorSearch translation VECTOR_SEARCH() results are inherently ordered by distance ascending. Add this ordering implicitly during translation so users can compose with Take() without needing an explicit OrderBy(r => r.Distance). Also remove the unnecessary forDml guard from GenerateTop() since VectorSearch() requires DbSet and cannot appear in DELETE/UPDATE table lists through LINQ. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/SqlServerQueryableExtensions.cs | 4 ++-- ...qlServerQueryableMethodTranslatingExpressionVisitor.cs | 8 +++++++- .../Query/Translations/VectorTranslationsSqlServerTest.cs | 3 --- 3 files changed, 9 insertions(+), 6 deletions(-) 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..4c0d3d07624 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: diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs index 62e012f04ae..8bb5f468811 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(); @@ -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) From e8af99c0954e0f5d64e43094414603a1ddf240a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:50:21 +0000 Subject: [PATCH 2/4] Address reviewer feedback: rename test and extend IsNaturallyOrdered for VECTOR_SEARCH Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/e4de5685-2516-4055-9e2a-b8f8216313a9 Co-authored-by: roji <1862641+roji@users.noreply.github.com> --- ...yableMethodTranslatingExpressionVisitor.cs | 42 ++++++++++++------- .../VectorTranslationsSqlServerTest.cs | 2 +- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 4c0d3d07624..76e108ed34c 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs @@ -808,22 +808,34 @@ 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 - { - Tables: [SqlServerOpenJsonExpression openJsonExpression, ..], - Orderings: - [ - { - Expression: SqlUnaryExpression + => (selectExpression is + { + 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 + } + ] + } + && openJsonOrderingTableAlias == openJsonExpression.Alias) + || (selectExpression is + { + Tables: [TableValuedFunctionExpression { Name: "VECTOR_SEARCH" } vectorSearchFunction, ..], + Orderings: + [ + { + Expression: ColumnExpression { Name: "Distance", TableAlias: var vectorSearchOrderingTableAlias }, + IsAscending: true + } + ] + } + && vectorSearchOrderingTableAlias == vectorSearchFunction.Alias); /// /// 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 8bb5f468811..121bc87025b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs @@ -102,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(); From a1c2e5d6ffda4192f13bb51089eb8d1fe68b66d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:43:23 +0000 Subject: [PATCH 3/4] Refactor IsNaturallyOrdered to switch expression with case comments Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/89b8b8d6-f330-4dee-b9c9-66fdff8f250b Co-authored-by: roji <1862641+roji@users.noreply.github.com> --- ...yableMethodTranslatingExpressionVisitor.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 76e108ed34c..2666697095b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs @@ -808,7 +808,11 @@ 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 + { + // 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: @@ -822,9 +826,11 @@ protected override bool IsNaturallyOrdered(SelectExpression selectExpression) IsAscending: true } ] - } - && openJsonOrderingTableAlias == openJsonExpression.Alias) - || (selectExpression is + } 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: @@ -834,8 +840,10 @@ protected override bool IsNaturallyOrdered(SelectExpression selectExpression) IsAscending: true } ] - } - && vectorSearchOrderingTableAlias == vectorSearchFunction.Alias); + } when vectorSearchOrderingTableAlias == vectorSearchFunction.Alias => true, + + _ => false + }; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to From 3d949f281aa864e2746382fcbfb42555b80ad1f2 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 13 Apr 2026 22:14:27 +0200 Subject: [PATCH 4/4] Handle Skip/Take correctly, preserving approximate search --- .../Query/SqlExpressions/SelectExpression.cs | 12 +++ ...yableMethodTranslatingExpressionVisitor.cs | 43 ++++++++++ .../VectorTranslationsSqlServerTest.cs | 80 +++++++++++++++++++ 3 files changed, 135 insertions(+) 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/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 2666697095b..d13bcc5be5b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs @@ -801,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 diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs index 121bc87025b..d77b2c8c804 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs @@ -180,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() {