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()
{