Skip to content

Commit 06e7ec7

Browse files
rojiCopilot
andcommitted
Update SQL Server vector search for latest VECTOR_SEARCH() syntax
- Remove deprecated topN parameter from VectorSearch API - Add WithApproximate() LINQ operator for opt-in approximate search - Implicit ORDER BY Distance for VectorSearch results - Update SQL generation: GenerateTop checks WithApproximateExpression - Handle nullability processing for WithApproximateExpression - Remove SetOffset from SelectExpression (no longer needed) - Add comprehensive tests including exact kNN, approximate, subquery, joins, skip/take patterns - Refresh API baselines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c073a69 commit 06e7ec7

9 files changed

Lines changed: 410 additions & 12 deletions

File tree

src/EFCore.SqlServer/EFCore.SqlServer.baseline.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2662,6 +2662,10 @@
26622662
{
26632663
"Member": "static System.Linq.IQueryable<Microsoft.EntityFrameworkCore.VectorSearchResult<T>> VectorSearch<T, TVector>(this Microsoft.EntityFrameworkCore.DbSet<T> source, System.Linq.Expressions.Expression<System.Func<T, TVector>> vectorPropertySelector, TVector similarTo, string metric);",
26642664
"Stage": "Experimental"
2665+
},
2666+
{
2667+
"Member": "static System.Linq.IQueryable<T> WithApproximate<T>(this System.Linq.IQueryable<T> source);",
2668+
"Stage": "Experimental"
26652669
}
26662670
]
26672671
},

src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public static class SqlServerQueryableExtensions
1818
#region VectorSearch
1919

2020
/// <summary>
21-
/// Search for vectors similar to a given query vector using an approximate nearest neighbors vector search algorithm.
21+
/// Search for vectors similar to a given query vector using the SQL Server <c>VECTOR_SEARCH()</c> function.
2222
/// </summary>
2323
/// <param name="source">The <see cref="DbSet{T}" /> representing the table containing the vector column to query.</param>
2424
/// <param name="vectorPropertySelector">A selector for the vector property on the entity.</param>
@@ -30,8 +30,9 @@ public static class SqlServerQueryableExtensions
3030
/// </param>
3131
/// <remarks>
3232
/// <para>
33-
/// Compose the returned query with <c>OrderBy(r => r.Distance)</c> and <c>Take(...)</c> to limit the results as required
34-
/// for approximate vector search.
33+
/// Use <see cref="WithApproximate{T}" /> after <c>Take(...)</c> to enable approximate nearest neighbor (ANN)
34+
/// search, which uses the vector index for better performance. Without <c>WithApproximate</c>, an exact k-nearest
35+
/// neighbor (kNN) search is performed. Add an explicit <c>OrderBy</c> to control result ordering.
3536
/// </para>
3637
/// </remarks>
3738
/// <seealso href="https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql">
@@ -75,6 +76,32 @@ private static IQueryable<VectorSearchResult<T>> VectorSearch<T, TVector>(
7576
where TVector : unmanaged
7677
=> throw new UnreachableException();
7778

79+
/// <summary>
80+
/// Enables approximate nearest neighbor (ANN) search for a vector search query. This causes <c>WITH APPROXIMATE</c> to
81+
/// be added to the SQL <c>TOP</c> clause, instructing SQL Server to use the vector index for better performance.
82+
/// </summary>
83+
/// <remarks>
84+
/// <para>
85+
/// This method must be called after <c>Take(...)</c> to specify the number of results. Without <c>WithApproximate</c>,
86+
/// vector search performs an exact k-nearest neighbor (kNN) search without using the vector index.
87+
/// </para>
88+
/// </remarks>
89+
/// <seealso href="https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql">
90+
/// SQL Server documentation for <c>VECTOR_SEARCH()</c>.
91+
/// </seealso>
92+
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
93+
public static IQueryable<T> WithApproximate<T>(this IQueryable<T> source)
94+
{
95+
var queryableSource = (IQueryable)source;
96+
97+
return queryableSource.Provider is EntityQueryProvider
98+
? queryableSource.Provider.CreateQuery<T>(
99+
Expression.Call(
100+
method: new Func<IQueryable<T>, IQueryable<T>>(WithApproximate).Method,
101+
queryableSource.Expression))
102+
: throw new InvalidOperationException(CoreStrings.FunctionOnNonEfLinqProvider(nameof(WithApproximate)));
103+
}
104+
78105
#endregion VectorSearch
79106

80107
#region Full-text search TVFs

src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/EFCore.SqlServer/Properties/SqlServerStrings.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,4 +426,10 @@
426426
<data name="VectorSearchRequiresColumn" xml:space="preserve">
427427
<value>VectorSearch() requires a valid vector column.</value>
428428
</data>
429+
<data name="WithApproximateRequiresTake" xml:space="preserve">
430+
<value>WithApproximate() must be called after Take() to specify the number of results.</value>
431+
</data>
432+
<data name="WithApproximateNotSupportedWithSkipAndTake" xml:space="preserve">
433+
<value>WithApproximate() after Skip().Take() is not supported. Use Take().WithApproximate().Skip() instead, or remove Skip().</value>
434+
</data>
429435
</root>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
5+
6+
// ReSharper disable once CheckNamespace
7+
namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
8+
9+
// WITH APPROXIMATE can only be specified on SELECT's TOP clause, so it's better to model it as a flag on SelectExpression
10+
// rather than as an expression node. But EF doesn't currently support provider-specific subclasses of SelectExpression.
11+
12+
/// <summary>
13+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
14+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
15+
/// any release. You should only use it directly in your code with extreme caution and knowing that
16+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
17+
/// </summary>
18+
/// <remarks>
19+
/// Wraps a <see cref="SqlExpression" /> (the limit value) so that <c>GenerateTop</c> can render
20+
/// <c>TOP(N) WITH APPROXIMATE</c> instead of just <c>TOP(N)</c>.
21+
/// </remarks>
22+
public class WithApproximateExpression : SqlExpression
23+
{
24+
private static ConstructorInfo? _quotingConstructor;
25+
26+
/// <summary>
27+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
28+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
29+
/// any release. You should only use it directly in your code with extreme caution and knowing that
30+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
31+
/// </summary>
32+
public WithApproximateExpression(SqlExpression operand)
33+
: base(operand.Type, operand.TypeMapping)
34+
{
35+
Operand = operand;
36+
}
37+
38+
/// <summary>
39+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
40+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
41+
/// any release. You should only use it directly in your code with extreme caution and knowing that
42+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
43+
/// </summary>
44+
public virtual SqlExpression Operand { get; }
45+
46+
/// <inheritdoc />
47+
protected override Expression VisitChildren(ExpressionVisitor visitor)
48+
=> Update((SqlExpression)visitor.Visit(Operand));
49+
50+
/// <summary>
51+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
52+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
53+
/// any release. You should only use it directly in your code with extreme caution and knowing that
54+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
55+
/// </summary>
56+
public virtual WithApproximateExpression Update(SqlExpression operand)
57+
=> operand != Operand
58+
? new WithApproximateExpression(operand)
59+
: this;
60+
61+
/// <inheritdoc />
62+
public override Expression Quote()
63+
=> New(
64+
_quotingConstructor ??= typeof(WithApproximateExpression).GetConstructor(
65+
[typeof(SqlExpression)])!,
66+
Operand.Quote());
67+
68+
/// <inheritdoc />
69+
protected override void Print(ExpressionPrinter expressionPrinter)
70+
{
71+
expressionPrinter.Visit(Operand);
72+
expressionPrinter.Append(" WITH APPROXIMATE");
73+
}
74+
75+
/// <inheritdoc />
76+
public override bool Equals(object? obj)
77+
=> obj != null
78+
&& (ReferenceEquals(this, obj)
79+
|| obj is WithApproximateExpression withApproximate
80+
&& Equals(withApproximate));
81+
82+
private bool Equals(WithApproximateExpression other)
83+
=> base.Equals(other)
84+
&& Operand.Equals(other.Operand);
85+
86+
/// <inheritdoc />
87+
public override int GetHashCode()
88+
=> HashCode.Combine(base.GetHashCode(), Operand);
89+
}

src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -548,16 +548,12 @@ protected override void GenerateTop(SelectExpression selectExpression)
548548
if (selectExpression is { Limit: not null, Offset: null })
549549
{
550550
Sql.Append("TOP(");
551-
552551
Visit(selectExpression.Limit);
553552

554-
Sql.Append(") ");
555-
556-
// When performing approximate vector search with VECTOR_SEARCH(), SQL Server requires adding
557-
// WITH APPROXIMATE: https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql
558-
if (selectExpression.Tables.Any(t => t.UnwrapJoin() is TableValuedFunctionExpression { Name: "VECTOR_SEARCH" }))
553+
// WithApproximateExpression renders its own closing ") WITH APPROXIMATE " via VisitExtension
554+
if (selectExpression.Limit is not WithApproximateExpression)
559555
{
560-
Sql.Append("WITH APPROXIMATE ");
556+
Sql.Append(") ");
561557
}
562558
}
563559

@@ -755,6 +751,13 @@ when tableExpression.FindAnnotation(SqlServerAnnotationNames.TemporalOperationTy
755751

756752
case SqlServerOpenJsonExpression openJsonExpression:
757753
return VisitOpenJsonExpression(openJsonExpression);
754+
755+
// WithApproximateExpression wraps the Limit in the SelectExpression; it renders the operand value
756+
// followed by ") WITH APPROXIMATE " (closing the TOP( opened by GenerateTop).
757+
case WithApproximateExpression withApproximate:
758+
Visit(withApproximate.Operand);
759+
Sql.Append(") WITH APPROXIMATE ");
760+
return withApproximate;
758761
}
759762

760763
return base.VisitExtension(extensionExpression);

src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ var metric
171171
#pragma warning restore EF9105 // VectorSearch is experimental
172172
}
173173

174+
case nameof(SqlServerQueryableExtensions.WithApproximate):
175+
{
176+
return TranslateWithApproximate(source);
177+
}
178+
174179
case nameof(SqlServerQueryableExtensions.FreeTextTable) or nameof(SqlServerQueryableExtensions.ContainsTable)
175180
when source is
176181
{
@@ -795,6 +800,30 @@ bool TryTranslate(
795800
}
796801
}
797802

803+
private ShapedQueryExpression TranslateWithApproximate(ShapedQueryExpression source)
804+
{
805+
var selectExpression = (SelectExpression)source.QueryExpression;
806+
807+
switch (selectExpression)
808+
{
809+
// WithApproximate() after Skip().Take() — not yet supported; SQL Server will add native OFFSET+FETCH
810+
// WITH APPROXIMATE support in the future.
811+
case { Limit: not null, Offset: not null }:
812+
throw new InvalidOperationException(SqlServerStrings.WithApproximateNotSupportedWithSkipAndTake);
813+
814+
// Normal case: WithApproximate() after Take() — wrap the Limit with WithApproximateExpression
815+
case { Limit: { } limit }:
816+
#pragma warning disable EF1001 // Internal EF Core API usage.
817+
selectExpression.SetLimit(new WithApproximateExpression(limit));
818+
#pragma warning restore EF1001 // Internal EF Core API usage.
819+
return source;
820+
821+
// WithApproximate() without Take()
822+
default:
823+
throw new InvalidOperationException(SqlServerStrings.WithApproximateRequiresTake);
824+
}
825+
}
826+
798827
/// <summary>
799828
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
800829
/// the same compatibility standards as public APIs. It may be changed or removed without notice in

src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Diagnostics.CodeAnalysis;
66
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
77
using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal;
8+
using Microsoft.EntityFrameworkCore.SqlServer.Query.Internal.SqlExpressions;
89

910
namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
1011

@@ -73,6 +74,9 @@ protected override SqlExpression VisitCustomSqlExpression(
7374
SqlServerAggregateFunctionExpression aggregateFunctionExpression
7475
=> VisitSqlServerAggregateFunction(aggregateFunctionExpression, allowOptimizedExpansion, out nullable),
7576

77+
WithApproximateExpression withApproximate
78+
=> withApproximate.Update(Visit(withApproximate.Operand, allowOptimizedExpansion, out nullable)),
79+
7680
_ => base.VisitCustomSqlExpression(sqlExpression, allowOptimizedExpansion, out nullable)
7781
};
7882

0 commit comments

Comments
 (0)