diff --git a/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs
index 129080855fc..d6244c596a6 100644
--- a/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs
+++ b/src/EFCore.SqlServer/Design/Internal/SqlServerCSharpRuntimeAnnotationCodeGenerator.cs
@@ -105,8 +105,6 @@ public override void Generate(IIndex index, CSharpRuntimeAnnotationCodeGenerator
annotations.Remove(SqlServerAnnotationNames.FillFactor);
annotations.Remove(SqlServerAnnotationNames.SortInTempDb);
annotations.Remove(SqlServerAnnotationNames.DataCompression);
- annotations.Remove(SqlServerAnnotationNames.VectorIndexMetric);
- annotations.Remove(SqlServerAnnotationNames.VectorIndexType);
annotations.Remove(SqlServerAnnotationNames.FullTextIndex);
annotations.Remove(SqlServerAnnotationNames.FullTextCatalog);
annotations.Remove(SqlServerAnnotationNames.FullTextChangeTracking);
@@ -128,8 +126,6 @@ public override void Generate(ITableIndex index, CSharpRuntimeAnnotationCodeGene
annotations.Remove(SqlServerAnnotationNames.FillFactor);
annotations.Remove(SqlServerAnnotationNames.SortInTempDb);
annotations.Remove(SqlServerAnnotationNames.DataCompression);
- annotations.Remove(SqlServerAnnotationNames.VectorIndexMetric);
- annotations.Remove(SqlServerAnnotationNames.VectorIndexType);
annotations.Remove(SqlServerAnnotationNames.FullTextIndex);
annotations.Remove(SqlServerAnnotationNames.FullTextCatalog);
annotations.Remove(SqlServerAnnotationNames.FullTextChangeTracking);
diff --git a/src/EFCore.SqlServer/Diagnostics/Internal/SqlServerLoggingDefinitions.cs b/src/EFCore.SqlServer/Diagnostics/Internal/SqlServerLoggingDefinitions.cs
index e077de65e5c..752d15d9498 100644
--- a/src/EFCore.SqlServer/Diagnostics/Internal/SqlServerLoggingDefinitions.cs
+++ b/src/EFCore.SqlServer/Diagnostics/Internal/SqlServerLoggingDefinitions.cs
@@ -202,4 +202,12 @@ public class SqlServerLoggingDefinitions : RelationalLoggingDefinitions
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public EventDefinitionBase? LogMissingViewDefinitionRights;
+
+ ///
+ /// 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.
+ ///
+ public EventDefinitionBase? LogVectorSearchWithoutApproximate;
}
diff --git a/src/EFCore.SqlServer/Diagnostics/SqlServerEventId.cs b/src/EFCore.SqlServer/Diagnostics/SqlServerEventId.cs
index 2dc4dce065f..bd905528320 100644
--- a/src/EFCore.SqlServer/Diagnostics/SqlServerEventId.cs
+++ b/src/EFCore.SqlServer/Diagnostics/SqlServerEventId.cs
@@ -33,6 +33,7 @@ private enum Id
DecimalTypeKeyWarning,
SavepointsDisabledBecauseOfMARS,
JsonTypeExperimental, // No longer used
+ VectorSearchWithoutApproximateWarning,
// Scaffolding events
ColumnFound = CoreEventId.ProviderDesignBaseId,
@@ -145,6 +146,20 @@ private static EventId MakeTransactionId(Id id)
///
public static readonly EventId SavepointsDisabledBecauseOfMARS = MakeTransactionId(Id.SavepointsDisabledBecauseOfMARS);
+ private static readonly string QueryPrefix = DbLoggerCategory.Query.Name + ".";
+
+ private static EventId MakeQueryId(Id id)
+ => new((int)id, QueryPrefix + id);
+
+ ///
+ /// A VectorSearch query was translated without WithApproximate().
+ /// Without WithApproximate(), the query performs an exact brute-force search instead of using any vector index.
+ ///
+ ///
+ /// This event is in the category.
+ ///
+ public static readonly EventId VectorSearchWithoutApproximateWarning = MakeQueryId(Id.VectorSearchWithoutApproximateWarning);
+
private static readonly string ScaffoldingPrefix = DbLoggerCategory.Scaffolding.Name + ".";
private static EventId MakeScaffoldingId(Id id)
diff --git a/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json b/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json
index ad86a0f6459..0d47a856daa 100644
--- a/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json
+++ b/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json
@@ -1300,6 +1300,9 @@
},
{
"Member": "static readonly Microsoft.Extensions.Logging.EventId UniqueConstraintFound"
+ },
+ {
+ "Member": "static readonly Microsoft.Extensions.Logging.EventId VectorSearchWithoutApproximateWarning"
}
]
},
@@ -2662,6 +2665,10 @@
{
"Member": "static System.Linq.IQueryable> VectorSearch(this Microsoft.EntityFrameworkCore.DbSet source, System.Linq.Expressions.Expression> vectorPropertySelector, TVector similarTo, string metric);",
"Stage": "Experimental"
+ },
+ {
+ "Member": "static System.Linq.IQueryable WithApproximate(this System.Linq.IQueryable source);",
+ "Stage": "Experimental"
}
]
},
diff --git a/src/EFCore.SqlServer/Extensions/Internal/SqlServerLoggerExtensions.cs b/src/EFCore.SqlServer/Extensions/Internal/SqlServerLoggerExtensions.cs
index 4111ef6f3fa..32fcdfa2984 100644
--- a/src/EFCore.SqlServer/Extensions/Internal/SqlServerLoggerExtensions.cs
+++ b/src/EFCore.SqlServer/Extensions/Internal/SqlServerLoggerExtensions.cs
@@ -637,4 +637,32 @@ public static void MissingViewDefinitionRightsWarning(
// No DiagnosticsSource events because these are purely design-time messages
}
+
+ ///
+ /// 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.
+ ///
+ public static void VectorSearchWithoutApproximateWarning(
+ this IDiagnosticsLogger diagnostics,
+ string propertyName,
+ string entityTypeName)
+ {
+ var definition = SqlServerResources.LogVectorSearchWithoutApproximate(diagnostics);
+
+ if (diagnostics.ShouldLog(definition))
+ {
+ definition.Log(diagnostics, propertyName, entityTypeName);
+ }
+
+ if (diagnostics.NeedsEventData(definition, out var diagnosticSourceEnabled, out var simpleLogEnabled))
+ {
+ var eventData = new EventData(
+ definition,
+ (d, _) => ((EventDefinition)d).GenerateMessage(propertyName, entityTypeName));
+
+ diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled);
+ }
+ }
}
diff --git a/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs
index d77063eaae8..b704dab5cec 100644
--- a/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs
+++ b/src/EFCore.SqlServer/Extensions/SqlServerIndexExtensions.cs
@@ -466,9 +466,7 @@ public static void SetDataCompression(this IMutableIndex index, DataCompressionT
/// Whether the index is a vector index.
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
public static bool IsVectorIndex(this IReadOnlyIndex index)
- => index is RuntimeIndex
- ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData)
- : index.FindAnnotation(SqlServerAnnotationNames.VectorIndexMetric) is not null;
+ => index.FindAnnotation(SqlServerAnnotationNames.VectorIndexMetric) is not null;
///
/// Returns the similarity metric for the vector index.
@@ -477,9 +475,7 @@ public static bool IsVectorIndex(this IReadOnlyIndex index)
/// The similarity metric for the vector index.
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
public static string? GetVectorMetric(this IReadOnlyIndex index)
- => index is RuntimeIndex
- ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData)
- : (string?)index[SqlServerAnnotationNames.VectorIndexMetric];
+ => (string?)index[SqlServerAnnotationNames.VectorIndexMetric];
///
/// Returns the similarity metric for the vector index.
@@ -490,11 +486,6 @@ public static bool IsVectorIndex(this IReadOnlyIndex index)
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
public static string? GetVectorMetric(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject)
{
- if (index is RuntimeIndex)
- {
- throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData);
- }
-
var annotation = index.FindAnnotation(SqlServerAnnotationNames.VectorIndexMetric);
if (annotation != null)
{
@@ -551,9 +542,7 @@ public static void SetVectorMetric(this IMutableIndex index, string? metric)
/// The type of the vector index.
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
public static string? GetVectorIndexType(this IReadOnlyIndex index)
- => (index is RuntimeIndex)
- ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData)
- : (string?)index[SqlServerAnnotationNames.VectorIndexType];
+ => (string?)index[SqlServerAnnotationNames.VectorIndexType];
///
/// Returns the type of the vector index.
@@ -564,11 +553,6 @@ public static void SetVectorMetric(this IMutableIndex index, string? metric)
[Experimental(EFDiagnostics.SqlServerVectorSearch)]
public static string? GetVectorIndexType(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject)
{
- if (index is RuntimeIndex)
- {
- throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData);
- }
-
var annotation = index.FindAnnotation(SqlServerAnnotationNames.VectorIndexType);
if (annotation != null)
{
diff --git a/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs
index 432eb9e1f5c..639c1854db9 100644
--- a/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs
+++ b/src/EFCore.SqlServer/Extensions/SqlServerQueryableExtensions.cs
@@ -18,7 +18,7 @@ public static class SqlServerQueryableExtensions
#region VectorSearch
///
- /// Search for vectors similar to a given query vector using an approximate nearest neighbors vector search algorithm.
+ /// Search for vectors similar to a given query vector using the SQL Server VECTOR_SEARCH() function.
///
/// The representing the table containing the vector column to query.
/// A selector for the vector property on the entity.
@@ -30,8 +30,9 @@ 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.
+ /// Use after Take(...) to enable approximate nearest neighbor (ANN)
+ /// search, which uses the vector index for better performance. Without WithApproximate, an exact k-nearest
+ /// neighbor (kNN) search is performed. Add an explicit OrderBy to control result ordering.
///
///
///
@@ -75,6 +76,32 @@ private static IQueryable> VectorSearch(
where TVector : unmanaged
=> throw new UnreachableException();
+ ///
+ /// Enables approximate nearest neighbor (ANN) search for a vector search query. This causes WITH APPROXIMATE to
+ /// be added to the SQL TOP clause, instructing SQL Server to use the vector index for better performance.
+ ///
+ ///
+ ///
+ /// This method must be called after Take(...) to specify the number of results. Without WithApproximate,
+ /// vector search performs an exact k-nearest neighbor (kNN) search without using the vector index.
+ ///
+ ///
+ ///
+ /// SQL Server documentation for VECTOR_SEARCH().
+ ///
+ [Experimental(EFDiagnostics.SqlServerVectorSearch)]
+ public static IQueryable WithApproximate(this IQueryable source)
+ {
+ var queryableSource = (IQueryable)source;
+
+ return queryableSource.Provider is EntityQueryProvider
+ ? queryableSource.Provider.CreateQuery(
+ Expression.Call(
+ method: new Func, IQueryable>(WithApproximate).Method,
+ queryableSource.Expression))
+ : throw new InvalidOperationException(CoreStrings.FunctionOnNonEfLinqProvider(nameof(WithApproximate)));
+ }
+
#endregion VectorSearch
#region Full-text search TVFs
diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs
index e332f427feb..59cb22aabed 100644
--- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs
+++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs
@@ -557,6 +557,18 @@ public static string VectorPropertiesNotSupportedInJson(object? propertyName, ob
public static string VectorSearchRequiresColumn
=> GetString("VectorSearchRequiresColumn");
+ ///
+ /// WithApproximate() must be called after Take() to specify the number of results.
+ ///
+ public static string WithApproximateRequiresTake
+ => GetString("WithApproximateRequiresTake");
+
+ ///
+ /// WithApproximate() after Skip().Take() is not supported. Use Take().WithApproximate().Skip() instead, or remove Skip().
+ ///
+ public static string WithApproximateNotSupportedWithSkipAndTake
+ => GetString("WithApproximateNotSupportedWithSkipAndTake");
+
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name)!;
@@ -1176,5 +1188,30 @@ public static EventDefinition LogSavepointsDisabledBecauseOfMARS(IDiagnosticsLog
return (EventDefinition)definition;
}
+
+ ///
+ /// The query uses 'VectorSearch' on property '{property}' of entity type '{entityType}', but 'WithApproximate()' was not specified. The query will perform an exact brute-force search instead of using a vector index. Call 'WithApproximate()' after 'Take()' to use the vector index for better performance. To identify the code which triggers this warning, call 'ConfigureWarnings(w => w.Throw(SqlServerEventId.VectorSearchWithoutApproximateWarning))'.
+ ///
+ public static EventDefinition LogVectorSearchWithoutApproximate(IDiagnosticsLogger logger)
+ {
+ var definition = ((Diagnostics.Internal.SqlServerLoggingDefinitions)logger.Definitions).LogVectorSearchWithoutApproximate;
+ if (definition == null)
+ {
+ definition = NonCapturingLazyInitializer.EnsureInitialized(
+ ref ((Diagnostics.Internal.SqlServerLoggingDefinitions)logger.Definitions).LogVectorSearchWithoutApproximate,
+ logger,
+ static logger => new EventDefinition(
+ logger.Options,
+ SqlServerEventId.VectorSearchWithoutApproximateWarning,
+ LogLevel.Warning,
+ "SqlServerEventId.VectorSearchWithoutApproximateWarning",
+ level => LoggerMessage.Define(
+ level,
+ SqlServerEventId.VectorSearchWithoutApproximateWarning,
+ _resourceManager.GetString("LogVectorSearchWithoutApproximate")!)));
+ }
+
+ return (EventDefinition)definition;
+ }
}
}
diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx
index 7d8deaf14f3..e2b012113a1 100644
--- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx
+++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx
@@ -327,6 +327,10 @@
Savepoints are disabled because Multiple Active Result Sets (MARS) is enabled. If 'SaveChanges' fails, then the transaction cannot be automatically rolled back to a known clean state. Instead, the transaction should be rolled back by the application before retrying 'SaveChanges'. See https://go.microsoft.com/fwlink/?linkid=2149338 for more information and examples. To identify the code which triggers this warning, call 'ConfigureWarnings(w => w.Throw(SqlServerEventId.SavepointsDisabledBecauseOfMARS))'.
Warning SqlServerEventId.SavepointsDisabledBecauseOfMARS
+
+ The query uses 'VectorSearch' on property '{property}' of entity type '{entityType}', but 'WithApproximate()' was not specified. The query will perform an exact brute-force search instead of using a vector index. Call 'WithApproximate()' after 'Take()' to use the vector index for better performance. To identify the code which triggers this warning, call 'ConfigureWarnings(w => w.Throw(SqlServerEventId.VectorSearchWithoutApproximateWarning))'.
+ Warning SqlServerEventId.VectorSearchWithoutApproximateWarning string string
+
The properties {properties} are configured to use 'Identity' value generation and are mapped to the same table '{table}', but only one column per table can be configured as 'Identity'. Call 'ValueGeneratedNever' in 'OnModelCreating' for properties that should not use 'Identity'.
@@ -426,4 +430,10 @@
VectorSearch() requires a valid vector column.
+
+ WithApproximate() must be called after Take() to specify the number of results.
+
+
+ WithApproximate() after Skip().Take() is not supported. Use Take().WithApproximate().Skip() instead, or remove Skip().
+
\ No newline at end of file
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlExpressions/WithApproximateExpression.cs b/src/EFCore.SqlServer/Query/Internal/SqlExpressions/WithApproximateExpression.cs
new file mode 100644
index 00000000000..9c5c1412584
--- /dev/null
+++ b/src/EFCore.SqlServer/Query/Internal/SqlExpressions/WithApproximateExpression.cs
@@ -0,0 +1,89 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
+
+// ReSharper disable once CheckNamespace
+namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
+
+// WITH APPROXIMATE can only be specified on SELECT's TOP clause, so it's better to model it as a flag on SelectExpression
+// rather than as an expression node. But EF doesn't currently support provider-specific subclasses of SelectExpression.
+
+///
+/// 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.
+///
+///
+/// Wraps a (the limit value) so that GenerateTop can render
+/// TOP(N) WITH APPROXIMATE instead of just TOP(N).
+///
+public class WithApproximateExpression : SqlExpression
+{
+ private static ConstructorInfo? _quotingConstructor;
+
+ ///
+ /// 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.
+ ///
+ public WithApproximateExpression(SqlExpression operand)
+ : base(operand.Type, operand.TypeMapping)
+ {
+ Operand = operand;
+ }
+
+ ///
+ /// 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.
+ ///
+ public virtual SqlExpression Operand { get; }
+
+ ///
+ protected override Expression VisitChildren(ExpressionVisitor visitor)
+ => Update((SqlExpression)visitor.Visit(Operand));
+
+ ///
+ /// 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.
+ ///
+ public virtual WithApproximateExpression Update(SqlExpression operand)
+ => operand != Operand
+ ? new WithApproximateExpression(operand)
+ : this;
+
+ ///
+ public override Expression Quote()
+ => New(
+ _quotingConstructor ??= typeof(WithApproximateExpression).GetConstructor(
+ [typeof(SqlExpression)])!,
+ Operand.Quote());
+
+ ///
+ protected override void Print(ExpressionPrinter expressionPrinter)
+ {
+ expressionPrinter.Visit(Operand);
+ expressionPrinter.Append(" WITH APPROXIMATE");
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ => obj != null
+ && (ReferenceEquals(this, obj)
+ || obj is WithApproximateExpression withApproximate
+ && Equals(withApproximate));
+
+ private bool Equals(WithApproximateExpression other)
+ => base.Equals(other)
+ && Operand.Equals(other.Operand);
+
+ ///
+ public override int GetHashCode()
+ => HashCode.Combine(base.GetHashCode(), Operand);
+}
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs
index 51e5957f9eb..358c8a517d8 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs
@@ -548,16 +548,12 @@ protected override void GenerateTop(SelectExpression selectExpression)
if (selectExpression is { Limit: not null, Offset: null })
{
Sql.Append("TOP(");
-
Visit(selectExpression.Limit);
- Sql.Append(") ");
-
- // When performing approximate vector search with VECTOR_SEARCH(), SQL Server requires adding
- // WITH APPROXIMATE: https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql
- if (selectExpression.Tables.Any(t => t.UnwrapJoin() is TableValuedFunctionExpression { Name: "VECTOR_SEARCH" }))
+ // WithApproximateExpression renders its own closing ") WITH APPROXIMATE " via VisitExtension
+ if (selectExpression.Limit is not WithApproximateExpression)
{
- Sql.Append("WITH APPROXIMATE ");
+ Sql.Append(") ");
}
}
@@ -755,6 +751,13 @@ when tableExpression.FindAnnotation(SqlServerAnnotationNames.TemporalOperationTy
case SqlServerOpenJsonExpression openJsonExpression:
return VisitOpenJsonExpression(openJsonExpression);
+
+ // WithApproximateExpression wraps the Limit in the SelectExpression; it renders the operand value
+ // followed by ") WITH APPROXIMATE " (closing the TOP( opened by GenerateTop).
+ case WithApproximateExpression withApproximate:
+ Visit(withApproximate.Operand);
+ Sql.Append(") WITH APPROXIMATE ");
+ return withApproximate;
}
return base.VisitExtension(extensionExpression);
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs
index a575aa0307c..1c058b1ccae 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
@@ -36,6 +36,8 @@ public override Expression Process(Expression query)
var query2 = _jsonPostprocessor.Process(query1);
var query3 = _aggregatePostprocessor.Visit(query2);
+ new SqlServerVectorSearchWithoutApproximateDetector(queryCompilationContext).Visit(query3);
+
return query3;
}
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
index 2f5825011fe..f7e2368ff57 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
@@ -171,6 +171,11 @@ var metric
#pragma warning restore EF9105 // VectorSearch is experimental
}
+ case nameof(SqlServerQueryableExtensions.WithApproximate):
+ {
+ return TranslateWithApproximate(source);
+ }
+
case nameof(SqlServerQueryableExtensions.FreeTextTable) or nameof(SqlServerQueryableExtensions.ContainsTable)
when source is
{
@@ -795,6 +800,34 @@ bool TryTranslate(
}
}
+ private ShapedQueryExpression TranslateWithApproximate(ShapedQueryExpression source)
+ {
+ var selectExpression = (SelectExpression)source.QueryExpression;
+
+ switch (selectExpression)
+ {
+ // WithApproximate() after Skip().Take() — not yet supported; SQL Server will add native OFFSET+FETCH
+ // WITH APPROXIMATE support in the future.
+ case { Limit: not null, Offset: not null }:
+ throw new InvalidOperationException(SqlServerStrings.WithApproximateNotSupportedWithSkipAndTake);
+
+ // Already wrapped — calling WithApproximate() twice is a no-op.
+ case { Limit: WithApproximateExpression }:
+ return source;
+
+ // Normal case: WithApproximate() after Take() — wrap the Limit with WithApproximateExpression
+ case { Limit: { } limit }:
+#pragma warning disable EF1001 // Internal EF Core API usage.
+ selectExpression.SetLimit(new WithApproximateExpression(limit));
+#pragma warning restore EF1001 // Internal EF Core API usage.
+ return source;
+
+ // WithApproximate() without Take()
+ default:
+ throw new InvalidOperationException(SqlServerStrings.WithApproximateRequiresTake);
+ }
+ }
+
///
/// 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/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs
index d364e55a0fb..b663763c70f 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs
@@ -73,6 +73,9 @@ protected override SqlExpression VisitCustomSqlExpression(
SqlServerAggregateFunctionExpression aggregateFunctionExpression
=> VisitSqlServerAggregateFunction(aggregateFunctionExpression, allowOptimizedExpansion, out nullable),
+ WithApproximateExpression withApproximate
+ => withApproximate.Update(Visit(withApproximate.Operand, allowOptimizedExpansion, out nullable)),
+
_ => base.VisitCustomSqlExpression(sqlExpression, allowOptimizedExpansion, out nullable)
};
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerVectorSearchWithoutApproximateDetector.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerVectorSearchWithoutApproximateDetector.cs
new file mode 100644
index 00000000000..67fb7801de1
--- /dev/null
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerVectorSearchWithoutApproximateDetector.cs
@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
+using Microsoft.EntityFrameworkCore.SqlServer.Extensions.Internal;
+using Microsoft.EntityFrameworkCore.SqlServer.Query.Internal.SqlExpressions;
+
+namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
+
+///
+/// Walks the expression tree looking for SelectExpressions that contain a VECTOR_SEARCH TVF without
+/// a as their Limit, and emits a warning for each.
+///
+///
+/// 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.
+///
+public class SqlServerVectorSearchWithoutApproximateDetector(SqlServerQueryCompilationContext queryCompilationContext)
+ : ExpressionVisitor
+{
+ ///
+ /// 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 Expression VisitExtension(Expression node)
+ {
+ switch (node)
+ {
+ case ShapedQueryExpression shapedQuery:
+ Visit(shapedQuery.QueryExpression);
+ return node;
+
+ case SelectExpression { Limit: not WithApproximateExpression } select:
+ var relationalModel = queryCompilationContext.Model.GetRelationalModel();
+
+ foreach (var table in select.Tables)
+ {
+#pragma warning disable EF9105 // IsVectorIndex is experimental
+ if (table is TableValuedFunctionExpression
+ {
+ Name: "VECTOR_SEARCH",
+ Arguments: [TableExpression tableExpr, ColumnExpression columnExpr, ..]
+ }
+ && relationalModel.FindTable(tableExpr.Name, tableExpr.Schema) is { } relationalTable
+ && relationalTable.Indexes.Any(
+ i => i.Columns is [{ Name: var c }] && c == columnExpr.Name
+ && i.MappedIndexes.Any(mi => mi.IsVectorIndex())))
+ {
+ var entityType = relationalTable.EntityTypeMappings.FirstOrDefault()?.TypeBase;
+
+ queryCompilationContext.Logger.VectorSearchWithoutApproximateWarning(
+ columnExpr.Name,
+ entityType?.DisplayName() ?? tableExpr.Name);
+ }
+#pragma warning restore EF9105
+ }
+
+ return base.VisitExtension(node);
+
+ default:
+ return base.VisitExtension(node);
+ }
+ }
+}
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs
index 62e012f04ae..48244c25abb 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs
@@ -1,9 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#pragma warning disable EF9105 // Vector search is experimental
+
using System.ComponentModel.DataAnnotations.Schema;
-using System.Diagnostics.CodeAnalysis;
+using Microsoft.Data.SqlClient;
using Microsoft.Data.SqlTypes;
+using Microsoft.EntityFrameworkCore.SqlServer.Internal;
+using Microsoft.Extensions.Logging;
namespace Microsoft.EntityFrameworkCore.Query.Translations;
@@ -68,7 +72,6 @@ ORDER BY VECTOR_DISTANCE('cosine', [v].[Vector], CAST('[1,2,100]' AS VECTOR(3)))
// 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_project_entity_and_distance()
{
using var ctx = CreateContext();
@@ -77,8 +80,9 @@ public async Task VectorSearch_project_entity_and_distance()
var results = await ctx.VectorEntities
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
- .OrderBy(e => e.Distance)
+ .OrderBy(r => r.Distance)
.Take(1)
+ .WithApproximate()
.ToListAsync();
Assert.Equal(2, results.Single().Value.Id);
@@ -102,8 +106,40 @@ ORDER BY [v0].[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_project_entity_only_with_distance_filter_and_ordering()
+ public async Task VectorSearch_exact_knn()
+ {
+ 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")
+ .OrderBy(r => r.Distance)
+ .Take(1)
+ .ToListAsync();
+
+ Assert.Equal(2, results.Single().Value.Id);
+
+ AssertSql(
+ """
+@p1='1'
+@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
+
+SELECT TOP(@p1) [v].[Id], [v0].[Distance]
+FROM VECTOR_SEARCH(
+ TABLE = [VectorEntities] AS [v],
+ COLUMN = [Vector],
+ SIMILAR_TO = @p,
+ METRIC = 'cosine'
+) AS [v0]
+ORDER BY [v0].[Distance]
+""");
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task VectorSearch_project_entity_only_with_distance_filter()
{
using var ctx = CreateContext();
@@ -115,6 +151,7 @@ public async Task VectorSearch_project_entity_only_with_distance_filter_and_orde
.OrderBy(e => e.Distance)
.Select(e => e.Value)
.Take(3)
+ .WithApproximate()
.ToListAsync();
Assert.Collection(
@@ -142,7 +179,6 @@ ORDER BY [v0].[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_in_subquery()
{
using var ctx = CreateContext();
@@ -153,6 +189,7 @@ public async Task VectorSearch_in_subquery()
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
.OrderBy(e => e.Distance)
.Take(3)
+ .WithApproximate()
.Select(e => new { e.Value.Id, e.Distance })
.Where(e => e.Distance < 0.01)
.ToListAsync();
@@ -183,6 +220,231 @@ ORDER BY [v1].[Distance]
""");
}
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task VectorSearch_with_Where_before_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")
+ .Where(e => e.Value.Id > 5)
+ .OrderBy(e => e.Distance)
+ .Take(3)
+ .WithApproximate()
+ .ToListAsync();
+
+ Assert.Equal(3, results.Count);
+ Assert.All(results, r => Assert.True(r.Value.Id > 5));
+
+ AssertSql(
+ """
+@p1='3'
+@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
+
+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]
+WHERE [v].[Id] > 5
+ORDER BY [v0].[Distance]
+""");
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task VectorSearch_with_Join_before_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")
+ .Join(
+ ctx.VectorEntities,
+ r => r.Value.Id,
+ e => e.Id,
+ (r, e) => new { e.Id, r.Distance })
+ .OrderBy(r => r.Distance)
+ .Take(3)
+ .WithApproximate()
+ .ToListAsync();
+
+ Assert.Equal(3, results.Count);
+
+ AssertSql(
+ """
+@p1='3'
+@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
+
+SELECT TOP(@p1) WITH APPROXIMATE [v1].[Id], [v0].[Distance]
+FROM VECTOR_SEARCH(
+ TABLE = [VectorEntities] AS [v],
+ COLUMN = [Vector],
+ SIMILAR_TO = @p,
+ METRIC = 'cosine'
+) AS [v0]
+INNER JOIN [VectorEntities] AS [v1] ON [v].[Id] = [v1].[Id]
+ORDER BY [v0].[Distance]
+""");
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ 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")
+ .OrderBy(r => r.Distance)
+ .Take(3)
+ .WithApproximate()
+ .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
+""");
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task VectorSearch_reranking()
+ {
+ using var ctx = CreateContext();
+
+ var vector = new SqlVector(new float[] { 1, 2, 100 });
+
+ // Inner: approximate broad retrieval
+ var candidates = ctx.VectorEntities
+ .VectorSearch(e => e.Vector, similarTo: vector, "cosine")
+ .OrderBy(r => r.Distance)
+ .Take(10)
+ .WithApproximate();
+
+ // Outer: exact re-ranking by a different criterion (no WithApproximate — exact top 3)
+ var results = await candidates
+ .OrderBy(r => r.Value.Id)
+ .Take(3)
+ .ToListAsync();
+
+ Assert.Equal(3, results.Count);
+
+ AssertSql(
+ """
+@p2='3'
+@p1='10'
+@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
+
+SELECT TOP(@p2) [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].[Id]
+""");
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task WithApproximate_without_Take_throws()
+ {
+ using var ctx = CreateContext();
+
+ var vector = new SqlVector(new float[] { 1, 2, 100 });
+
+ var exception = await Assert.ThrowsAsync(
+ () => ctx.VectorEntities
+ .VectorSearch(e => e.Vector, similarTo: vector, "cosine")
+ .WithApproximate()
+ .ToListAsync());
+
+ Assert.Equal(SqlServerStrings.WithApproximateRequiresTake, exception.Message);
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task WithApproximate_with_Skip_and_Take_throws()
+ {
+ using var ctx = CreateContext();
+
+ var vector = new SqlVector(new float[] { 1, 2, 100 });
+
+ var exception = await Assert.ThrowsAsync(
+ () => ctx.VectorEntities
+ .VectorSearch(e => e.Vector, similarTo: vector, "cosine")
+ .OrderBy(r => r.Distance)
+ .Skip(1)
+ .Take(3)
+ .WithApproximate()
+ .ToListAsync());
+
+ Assert.Equal(SqlServerStrings.WithApproximateNotSupportedWithSkipAndTake, exception.Message);
+ }
+
+ // The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
+ [ConditionalFact]
+ [SqlServerCondition(SqlServerCondition.IsAzureSql)]
+ public async Task VectorSearch_without_WithApproximate_logs_warning()
+ {
+ using var ctx = CreateContext();
+
+ var vector = new SqlVector(new float[] { 1, 2, 100 });
+
+ // Use a query structurally distinct from other tests to avoid compiled query cache hits
+ _ = await ctx.VectorEntities
+ .VectorSearch(e => e.IndexedVector, similarTo: vector, "cosine")
+ .OrderBy(r => r.Distance)
+ .Select(r => r.Value.Id)
+ .Take(1)
+ .ToListAsync();
+
+ var warning = Assert.Single(Fixture.TestSqlLoggerFactory.Log, l => l.Id == SqlServerEventId.VectorSearchWithoutApproximateWarning);
+ Assert.Equal(LogLevel.Warning, warning.Level);
+ Assert.Contains("IndexedVector", warning.Message);
+ Assert.Contains("VectorEntity", warning.Message);
+ }
+
[ConditionalFact]
public async Task Length()
{
@@ -215,6 +477,9 @@ public class VectorQueryContext(DbContextOptions options) : PoolableDbContext(op
{
public DbSet VectorEntities { get; set; } = null!;
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.Entity().HasVectorIndex(e => e.IndexedVector).HasMetric("cosine");
+
public static async Task SeedAsync(VectorQueryContext context)
{
// SQL Server vector indexes require at least 100 rows.
@@ -222,13 +487,29 @@ public static async Task SeedAsync(VectorQueryContext context)
i => new VectorEntity
{
Id = i,
- Vector = new SqlVector(new float[] { i * 0.01f, i * 0.02f, i * 0.03f })
+ Vector = new SqlVector(new float[] { i * 0.01f, i * 0.02f, i * 0.03f }),
+ IndexedVector = new SqlVector(new float[] { i * 0.01f, i * 0.02f, i * 0.03f })
}).ToList();
// Override specific rows we use in test assertions
- vectorEntities[0] = new VectorEntity { Id = 1, Vector = new SqlVector(new float[] { 1, 2, 3 }) };
- vectorEntities[1] = new VectorEntity { Id = 2, Vector = new SqlVector(new float[] { 1, 2, 100 }) };
- vectorEntities[2] = new VectorEntity { Id = 3, Vector = new SqlVector(new float[] { 1, 2, 1000 }) };
+ vectorEntities[0] = new VectorEntity
+ {
+ Id = 1,
+ Vector = new SqlVector(new float[] { 1, 2, 3 }),
+ IndexedVector = new SqlVector(new float[] { 1, 2, 3 })
+ };
+ vectorEntities[1] = new VectorEntity
+ {
+ Id = 2,
+ Vector = new SqlVector(new float[] { 1, 2, 100 }),
+ IndexedVector = new SqlVector(new float[] { 1, 2, 100 })
+ };
+ vectorEntities[2] = new VectorEntity
+ {
+ Id = 3,
+ Vector = new SqlVector(new float[] { 1, 2, 1000 }),
+ IndexedVector = new SqlVector(new float[] { 1, 2, 1000 })
+ };
context.VectorEntities.AddRange(vectorEntities);
await context.SaveChangesAsync();
@@ -236,7 +517,7 @@ public static async Task SeedAsync(VectorQueryContext context)
await context.Database.ExecuteSqlAsync($"ALTER DATABASE SCOPED CONFIGURATION SET PREVIEW_FEATURES = ON");
await context.Database.ExecuteSqlAsync($"""
-CREATE VECTOR INDEX vec_idx ON VectorEntities(Vector)
+CREATE VECTOR INDEX vec_idx ON VectorEntities(IndexedVector)
WITH (METRIC = 'Cosine', TYPE = 'DiskANN');
""");
}
@@ -249,6 +530,9 @@ public class VectorEntity
[Column(TypeName = "vector(3)")]
public SqlVector Vector { get; set; }
+
+ [Column(TypeName = "vector(3)")]
+ public SqlVector IndexedVector { get; set; }
}
public class VectorQueryFixture : SharedStoreFixtureBase
@@ -256,13 +540,76 @@ public class VectorQueryFixture : SharedStoreFixtureBase
protected override string StoreName
=> "VectorTranslationsTest";
+ // Vector indexes require ≥100 rows with non-NULL vectors, so the standard EnsureClean
+ // (which drops + recreates tables including the vector index before seeding) fails.
+ // VectorSearchTestStoreFactory creates a store that drops/creates tables without the
+ // vector index; SeedAsync then inserts data and creates the vector index via raw SQL.
protected override ITestStoreFactory TestStoreFactory
- => SqlServerTestStoreFactory.Instance;
+ => VectorSearchTestStoreFactory.Instance;
public TestSqlLoggerFactory TestSqlLoggerFactory
=> (TestSqlLoggerFactory)ListLoggerFactory;
+ protected override bool ShouldLogCategory(string logCategory)
+ => logCategory == DbLoggerCategory.Query.Name;
+
+ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
+ => base.AddOptions(builder)
+ .ConfigureWarnings(w => w.Log(SqlServerEventId.VectorSearchWithoutApproximateWarning));
+
protected override Task SeedAsync(VectorQueryContext context)
=> VectorQueryContext.SeedAsync(context);
+
+ private class VectorSearchTestStoreFactory : SqlServerTestStoreFactory
+ {
+ public static new VectorSearchTestStoreFactory Instance { get; } = new();
+
+ public override TestStore GetOrCreate(string storeName)
+ => new VectorSearchTestStore(storeName);
+ }
+
+ private class VectorSearchTestStore(string name) : SqlServerTestStore(name)
+ {
+ // Vector indexes require ≥100 rows, so we can't use the standard EnsureClean
+ // (which drops + recreates all tables including vector indexes before data exists).
+ // Instead we drop the table and recreate it without the vector index;
+ // it gets created by SeedAsync after data is inserted.
+ protected override async Task InitializeAsync(
+ Func createContext,
+ Func? seed,
+ Func? clean)
+ {
+ await using var context = createContext();
+
+ // Ensure the database itself exists (EnsureCreated would also create
+ // the vector index, which fails on empty tables).
+ await using (var master = new SqlConnection(CreateConnectionString("master", multipleActiveResultSets: false)))
+ {
+ await master.OpenAsync();
+ await using var command = master.CreateCommand();
+ command.CommandText = $"""
+ IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = N'{Name}')
+ CREATE DATABASE [{Name}];
+ """;
+ await command.ExecuteNonQueryAsync();
+ }
+
+ await context.Database.ExecuteSqlRawAsync(
+ """
+ DROP TABLE IF EXISTS [VectorEntities];
+ CREATE TABLE [VectorEntities] (
+ [Id] int NOT NULL,
+ [IndexedVector] vector(3) NOT NULL,
+ [Vector] vector(3) NOT NULL,
+ CONSTRAINT [PK_VectorEntities] PRIMARY KEY ([Id])
+ );
+ """);
+
+ if (seed != null)
+ {
+ await seed(context);
+ }
+ }
+ }
}
}
diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/DbContextModelBuilder.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/DbContextModelBuilder.cs
index e22b160e496..56eb2af0e7c 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/DbContextModelBuilder.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/DbContextModelBuilder.cs
@@ -84,6 +84,8 @@ private IRelationalModel CreateRelationalModel()
var iX_VectorIndexEntity_Vector = new TableIndex(
"IX_VectorIndexEntity_Vector", vectorIndexEntityTable, new[] { vectorColumn }, false);
iX_VectorIndexEntity_Vector.SetRowIndexValueFactory(new SimpleRowIndexValueFactory>(iX_VectorIndexEntity_Vector));
+ iX_VectorIndexEntity_Vector.AddAnnotation("SqlServer:VectorIndexMetric", "cosine");
+ iX_VectorIndexEntity_Vector.AddAnnotation("SqlServer:VectorIndexType", "DiskANN");
var iX_VectorIndexEntity_VectorIx = RelationalModel.GetIndex(this,
"Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelSqlServerTest+VectorIndexEntity",
new[] { "Vector" });
diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs
index 892e0b4329f..43651e367dc 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs
@@ -140,6 +140,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas
var index = runtimeEntityType.AddIndex(
new[] { vector });
+ index.AddAnnotation("SqlServer:VectorIndexMetric", "cosine");
+ index.AddAnnotation("SqlServer:VectorIndexType", "DiskANN");
return runtimeEntityType;
}
diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs
index 1d06c6c428c..d798f379d93 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs
@@ -214,9 +214,8 @@ public virtual Task Vector_index()
Assert.False(vectorProperty.IsAutoLoaded);
var index = entityType.GetIndexes().Single();
- // Vector index annotations are not used at runtime, so they are not included in the compiled model
- Assert.Null(index[SqlServerAnnotationNames.VectorIndexMetric]);
- Assert.Null(index[SqlServerAnnotationNames.VectorIndexType]);
+ Assert.Equal("cosine", index[SqlServerAnnotationNames.VectorIndexMetric]);
+ Assert.Equal("DiskANN", index[SqlServerAnnotationNames.VectorIndexType]);
},
useContext: null,
additionalSourceFiles: []);