Skip to content

Commit be80ffc

Browse files
committed
Implement support for SQL Server VectorSearch()
Closes #36384 Closes #37535
1 parent 347ab47 commit be80ffc

22 files changed

+443
-90
lines changed

src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2918,9 +2918,6 @@ private Expression CreateGetValueExpression(
29182918
Type type,
29192919
IPropertyBase? property = null)
29202920
{
2921-
Check.DebugAssert(
2922-
property != null || type.IsNullableType(), "Must read nullable value from database if property is not specified.");
2923-
29242921
var getMethod = typeMapping.GetDataReaderMethod();
29252922

29262923
Expression indexExpression = Constant(index);

src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -627,21 +627,26 @@ protected override Expression VisitListInit(ListInitExpression listInitExpressio
627627
/// <inheritdoc />
628628
protected override Expression VisitMember(MemberExpression memberExpression)
629629
{
630-
// Fold member access into conditional, i.e. transform
631-
// (test ? expr1 : expr2).Member -> (test ? expr1.Member : expr2.Member)
632-
if (memberExpression.Expression is ConditionalExpression cond)
630+
var member = memberExpression.Member;
631+
632+
switch (memberExpression.Expression)
633633
{
634-
return Visit(
635-
Expression.Condition(
636-
cond.Test,
637-
Expression.MakeMemberAccess(cond.IfTrue, memberExpression.Member),
638-
Expression.MakeMemberAccess(cond.IfFalse, memberExpression.Member)));
634+
// Fold member access into conditional, i.e. transform
635+
// (test ? expr1 : expr2).Member -> (test ? expr1.Member : expr2.Member)
636+
case ConditionalExpression conditional:
637+
return Visit(
638+
Expression.Condition(
639+
conditional.Test,
640+
Expression.MakeMemberAccess(conditional.IfTrue, member),
641+
Expression.MakeMemberAccess(conditional.IfFalse, member)));
642+
643+
// Reduce member accesses on new expressions (new { A = 8 }.Select(x => x.A) => 8)
644+
case NewExpression @new when @new.Members?.IndexOf(member) is int index && index >= 0:
645+
return Visit(@new.Arguments[index]);
639646
}
640647

641648
var inner = Visit(memberExpression.Expression);
642649

643-
var member = memberExpression.Member;
644-
645650
// Try binding the member to a property on the structural type
646651
if (TryBindMember(inner, MemberIdentity.Create(member), out var expression))
647652
{

src/EFCore.Relational/Query/SqlExpressions/TableValuedFunctionExpression.cs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public class TableValuedFunctionExpression : TableExpressionBase, ITableBasedExp
2222
/// <param name="alias">An alias for the table.</param>
2323
/// <param name="storeFunction">The <see cref="IStoreFunction" /> associated this function.</param>
2424
/// <param name="arguments">The arguments of the function.</param>
25-
public TableValuedFunctionExpression(string alias, IStoreFunction storeFunction, IReadOnlyList<SqlExpression> arguments)
25+
public TableValuedFunctionExpression(string alias, IStoreFunction storeFunction, IReadOnlyList<Expression> arguments)
2626
: this(
2727
alias,
2828
storeFunction.Name,
@@ -41,7 +41,7 @@ public TableValuedFunctionExpression(string alias, IStoreFunction storeFunction,
4141
public TableValuedFunctionExpression(
4242
string alias,
4343
string name,
44-
IReadOnlyList<SqlExpression> arguments)
44+
IReadOnlyList<Expression> arguments)
4545
: this(alias, name, schema: null, builtIn: true, arguments)
4646
{
4747
}
@@ -60,7 +60,7 @@ protected TableValuedFunctionExpression(
6060
string name,
6161
string? schema,
6262
bool builtIn,
63-
IReadOnlyList<SqlExpression> arguments,
63+
IReadOnlyList<Expression> arguments,
6464
IReadOnlyDictionary<string, IAnnotation>? annotations = null)
6565
: base(alias, annotations)
6666
{
@@ -103,7 +103,7 @@ public override string Alias
103103
/// <summary>
104104
/// The list of arguments of this function.
105105
/// </summary>
106-
public virtual IReadOnlyList<SqlExpression> Arguments { get; }
106+
public virtual IReadOnlyList<Expression> Arguments { get; }
107107

108108
/// <inheritdoc />
109109
protected override Expression VisitChildren(ExpressionVisitor visitor)
@@ -117,18 +117,18 @@ protected override Expression VisitChildren(ExpressionVisitor visitor)
117117
/// </summary>
118118
/// <param name="arguments">The <see cref="Arguments" /> property of the result.</param>
119119
/// <returns>This expression if no children changed, or an expression with the updated children.</returns>
120-
public virtual TableValuedFunctionExpression Update(IReadOnlyList<SqlExpression> arguments)
120+
public virtual TableValuedFunctionExpression Update(IReadOnlyList<Expression> arguments)
121121
=> !arguments.SequenceEqual(Arguments, ReferenceEqualityComparer.Instance)
122122
? new TableValuedFunctionExpression(Alias, Name, Schema, IsBuiltIn, arguments, Annotations)
123123
: this;
124124

125125
/// <inheritdoc />
126126
public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor)
127127
{
128-
var newArguments = new SqlExpression[Arguments.Count];
128+
var newArguments = new Expression[Arguments.Count];
129129
for (var i = 0; i < newArguments.Length; i++)
130130
{
131-
newArguments[i] = (SqlExpression)cloningExpressionVisitor.Visit(Arguments[i]);
131+
newArguments[i] = cloningExpressionVisitor.Visit(Arguments[i]);
132132
}
133133

134134
var newTableValuedFunctionExpression = StoreFunction is null
@@ -153,19 +153,25 @@ public override TableValuedFunctionExpression WithAlias(string newAlias)
153153

154154
/// <inheritdoc />
155155
public override Expression Quote()
156-
=> StoreFunction is null
156+
{
157+
var arguments = NewArrayInit(
158+
typeof(Expression),
159+
Arguments.Select(a => ((IRelationalQuotableExpression)a).Quote()));
160+
161+
return StoreFunction is null
157162
? New(
158163
_quotingConstructor1 ??= typeof(TableValuedFunctionExpression).GetConstructor(
159-
[typeof(string), typeof(string), typeof(IReadOnlyList<SqlExpression>)])!,
164+
[typeof(string), typeof(string), typeof(IReadOnlyList<Expression>)])!,
160165
Constant(Alias, typeof(string)),
161166
Constant(Name, typeof(string)),
162-
NewArrayInit(typeof(SqlExpression), Arguments.Select(v => v.Quote())))
167+
arguments)
163168
: New(
164169
_quotingConstructor2 ??= typeof(TableValuedFunctionExpression).GetConstructor(
165-
[typeof(string), typeof(IStoreFunction), typeof(IReadOnlyList<SqlExpression>)])!,
170+
[typeof(string), typeof(IStoreFunction), typeof(IReadOnlyList<Expression>)])!,
166171
Constant(Alias, typeof(string)),
167172
RelationalExpressionQuotingUtilities.QuoteTableBase(StoreFunction),
168-
NewArrayInit(typeof(SqlExpression), Arguments.Select(v => v.Quote())));
173+
arguments);
174+
}
169175

170176
/// <inheritdoc />
171177
protected override void Print(ExpressionPrinter expressionPrinter)

src/EFCore.SqlServer/EFCore.SqlServer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<PackageTags>$(PackageTags);SQL Server</PackageTags>
1111
<ImplicitUsings>true</ImplicitUsings>
1212
<NoWarn>$(NoWarn);EF9100</NoWarn> <!-- Precompiled query is experimental -->
13+
<NoWarn>$(NoWarn);EF9105</NoWarn> <!-- VectorSearch is experimental -->
1314
</PropertyGroup>
1415

1516
<ItemGroup>

src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2471,8 +2471,7 @@ public static long PatIndex(
24712471
/// Vector distance is always exact and doesn't use any vector index, even if available.
24722472
/// </remarks>
24732473
/// <seealso href="https://learn.microsoft.com/sql/t-sql/functions/vector-distance-transact-sql">
2474-
/// SQL Server documentation for
2475-
/// <c>VECTOR_DISTANCE</c>.
2474+
/// SQL Server documentation for <c>VECTOR_DISTANCE()</c>.
24762475
/// </seealso>
24772476
/// <seealso href="https://learn.microsoft.com/sql/relational-databases/vectors/vectors-sql-server">Vectors in the SQL Database Engine.</seealso>
24782477
public static double VectorDistance<T>(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 System.Diagnostics.CodeAnalysis;
5+
using Microsoft.EntityFrameworkCore.Query.Internal;
6+
7+
#pragma warning disable IDE0130 // Namespace does not match folder structure
8+
9+
// ReSharper disable once CheckNamespace
10+
namespace Microsoft.EntityFrameworkCore;
11+
12+
/// <summary>
13+
/// SQL Server extension methods for LINQ queries.
14+
/// </summary>
15+
public static class SqlServerQueryableExtensions
16+
{
17+
/// <summary>
18+
/// Search for vectors similar to a given query vector using an approximate nearest neighbors vector search algorithm.
19+
/// </summary>
20+
/// <param name="source">The <see cref="DbSet{T}" /> representing the table containing the vector column to query.</param>
21+
/// <param name="vectorPropertySelector">A selector for the vector property on the entity.</param>
22+
/// <param name="similarTo">The vector used for search, either a parameter or another vector column.</param>
23+
/// <param name="metric">
24+
/// The distance metric used to calculate the distance between the query vector and the vectors in the specified column.
25+
/// An ANN (Approximate Nearest Neighbor) index is used only if a matching ANN index, with the same metric and on the same column,
26+
/// is found. If there are no compatible ANN indexes, a warning is raised and the KNN (k-Nearest Neighbor) algorithm is used.
27+
/// </param>
28+
/// <param name="topN">The maximum number of similar vectors that must be returned. It must be a positive integer.</param>
29+
/// <seealso href="https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql">
30+
/// SQL Server documentation for <c>VECTOR_SEARCH()</c>.
31+
/// </seealso>
32+
/// <seealso href="https://learn.microsoft.com/sql/relational-databases/vectors/vectors-sql-server">Vectors in the SQL Database Engine.</seealso>
33+
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
34+
public static IQueryable<VectorSearchResult<T>> VectorSearch<T, TVector>(
35+
this DbSet<T> source,
36+
Expression<Func<T, TVector>> vectorPropertySelector,
37+
TVector similarTo,
38+
[NotParameterized] string metric,
39+
int topN)
40+
where T : class
41+
where TVector : unmanaged
42+
{
43+
var queryableSource = (IQueryable)source;
44+
var root = (EntityQueryRootExpression)queryableSource.Expression;
45+
46+
return queryableSource.Provider is EntityQueryProvider
47+
? queryableSource.Provider.CreateQuery<VectorSearchResult<T>>(
48+
Expression.Call(
49+
// Note that the method used is the one below, accepting IQueryable<T>, not DbSet<T>
50+
method: new Func<IQueryable<T>, Expression<Func<T, TVector>>, TVector, string, int, IQueryable<VectorSearchResult<T>>>(VectorSearch).Method,
51+
root,
52+
Expression.Quote(vectorPropertySelector),
53+
Expression.Constant(similarTo),
54+
Expression.Constant(metric),
55+
Expression.Constant(topN)))
56+
: throw new InvalidOperationException(CoreStrings.FunctionOnNonEfLinqProvider(nameof(VectorSearch)));
57+
}
58+
59+
// A separate method stub is required since the public method accepts DbSet (to limit to direct usage on DbSets),
60+
// but the MethodCallExpression built above needs to accept an EntityQueryRootExpression (which is what we get for
61+
// a DbSet, but which isn't itself a DbSet).
62+
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
63+
private static IQueryable<VectorSearchResult<T>> VectorSearch<T, TVector>(
64+
this IQueryable<T> source,
65+
Expression<Func<T, TVector>> vectorPropertySelector,
66+
TVector similarTo,
67+
[NotParameterized] string metric,
68+
int topN)
69+
where T : class
70+
where TVector : unmanaged
71+
=> throw new UnreachableException();
72+
}
73+
74+
/// <summary>
75+
/// Represents the results from a call to
76+
/// <see cref="SqlServerQueryableExtensions.VectorSearch{T, TVector}(DbSet{T}, Expression{Func{T, TVector}}, TVector, string, int)" />.
77+
/// </summary>
78+
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
79+
public readonly struct VectorSearchResult<T>(T value, double distance)
80+
{
81+
/// <summary>
82+
/// The entity instance representing the row with a similar vector.
83+
/// </summary>
84+
public T Value { get; } = value;
85+
86+
/// <summary>
87+
/// The distance between the query vector and the similar vector.
88+
/// </summary>
89+
public double Distance { get; } = distance;
90+
}

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

Lines changed: 6 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,4 +380,7 @@
380380
<data name="VectorPropertiesNotSupportedInJson" xml:space="preserve">
381381
<value>Vector property '{propertyName}' is on '{structuralType}' which is mapped to JSON. Vector properties are not supported within JSON documents.</value>
382382
</data>
383+
<data name="VectorSearchRequiresColumn" xml:space="preserve">
384+
<value>VectorSearch() requires a valid vector column.</value>
385+
</data>
383386
</root>

src/EFCore.SqlServer/Query/Internal/SqlExpressions/SqlServerOpenJsonExpression.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ public class SqlServerOpenJsonExpression : TableValuedFunctionExpression
3131
/// any release. You should only use it directly in your code with extreme caution and knowing that
3232
/// doing so can result in application failures when updating to a new Entity Framework Core release.
3333
/// </summary>
34-
public virtual SqlExpression JsonExpression
35-
=> Arguments[0];
34+
public virtual SqlExpression Json { get; }
3635

3736
/// <summary>
3837
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -58,16 +57,17 @@ public virtual SqlExpression JsonExpression
5857
/// </summary>
5958
public SqlServerOpenJsonExpression(
6059
string alias,
61-
SqlExpression jsonExpression,
60+
SqlExpression json,
6261
IReadOnlyList<PathSegment>? path = null,
6362
IReadOnlyList<ColumnInfo>? columnInfos = null)
64-
: base(alias, "OPENJSON", schema: null, builtIn: true, [jsonExpression])
63+
: base(alias, "OPENJSON", schema: null, builtIn: true, arguments: [json])
6564
{
6665
if (columnInfos?.Count == 0)
6766
{
6867
columnInfos = null;
6968
}
7069

70+
Json = json;
7171
Path = path;
7272
ColumnInfos = columnInfos;
7373
}
@@ -80,7 +80,7 @@ public SqlServerOpenJsonExpression(
8080
/// </summary>
8181
protected override Expression VisitChildren(ExpressionVisitor visitor)
8282
{
83-
var visitedJsonExpression = (SqlExpression)visitor.Visit(JsonExpression);
83+
var visitedJsonExpression = (SqlExpression)visitor.Visit(Json);
8484

8585
PathSegment[]? visitedPath = null;
8686

@@ -144,7 +144,7 @@ public virtual SqlServerOpenJsonExpression Update(
144144
columnInfos = null;
145145
}
146146

147-
return jsonExpression == JsonExpression
147+
return jsonExpression == Json
148148
&& (ReferenceEquals(path, Path) || path is not null && Path is not null && path.SequenceEqual(Path))
149149
&& (ReferenceEquals(columnInfos, ColumnInfos)
150150
|| columnInfos is not null && ColumnInfos is not null && columnInfos.SequenceEqual(ColumnInfos))
@@ -169,7 +169,7 @@ public virtual SqlServerOpenJsonExpression Update(SqlExpression sqlExpression)
169169
/// </summary>
170170
public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor)
171171
{
172-
var newJsonExpression = (SqlExpression)cloningExpressionVisitor.Visit(JsonExpression);
172+
var newJsonExpression = (SqlExpression)cloningExpressionVisitor.Visit(Json);
173173
var clone = new SqlServerOpenJsonExpression(alias!, newJsonExpression, Path, ColumnInfos);
174174

175175
foreach (var annotation in GetAnnotations())
@@ -182,7 +182,7 @@ public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloni
182182

183183
/// <inheritdoc />
184184
public override SqlServerOpenJsonExpression WithAlias(string newAlias)
185-
=> new(newAlias, JsonExpression, Path, ColumnInfos);
185+
=> new(newAlias, Json, Path, ColumnInfos);
186186

187187
/// <inheritdoc />
188188
public override Expression Quote()
@@ -195,7 +195,7 @@ public override Expression Quote()
195195
typeof(IReadOnlyList<ColumnInfo>)
196196
])!,
197197
Constant(Alias, typeof(string)),
198-
JsonExpression.Quote(),
198+
Json.Quote(),
199199
Path is null
200200
? Constant(null, typeof(IReadOnlyList<PathSegment>))
201201
: NewArrayInit(typeof(PathSegment), Path.Select(s => s.Quote())),
@@ -227,7 +227,7 @@ protected override void Print(ExpressionPrinter expressionPrinter)
227227
{
228228
expressionPrinter.Append(Name);
229229
expressionPrinter.Append("(");
230-
expressionPrinter.Visit(JsonExpression);
230+
expressionPrinter.Visit(Json);
231231

232232
if (Path is not null)
233233
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public Expression Process(Expression expression)
8888
{
8989
// Remove the WITH clause from the OPENJSON expression
9090
var newOpenJsonExpression = openJsonExpression.Update(
91-
openJsonExpression.JsonExpression,
91+
openJsonExpression.Json,
9292
openJsonExpression.Path,
9393
columnInfos: null);
9494

0 commit comments

Comments
 (0)