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: []);