.Net: Update SQL Server vector search to latest VECTOR_SEARCH() syntax (#13863)

Updates the SQL Server vector store connector to support the latest
Azure SQL VECTOR_SEARCH() features, matching what was done in EF Core
(dotnet/efcore#38075).

## Changes

### VECTOR_SEARCH syntax update
- Replace deprecated `TOP_N` parameter with `SELECT TOP(N) WITH
APPROXIMATE`
- Enable iterative WHERE predicates are now applied during the vector
search process, not after retrievalfiltering
- Support `skip` via subquery wrapping (`TOP` and `OFFSET/FETCH` cannot
coexist in the same SELECT)

### Hybrid search
- Same VECTOR_SEARCH syntax changes in the semantic search CTE
- Filter support added in the vector search CTE (iterative filtering)

### Azure SQL runtime detection
- DiskAnn vector indexes and VECTOR_SEARCH require Azure SQL Database or
SQL database in Microsoft Fabric
- Added runtime check via `SERVERPROPERTY('EngineEdition')` at table
creation and query time
- Throws informative `NotSupportedException` when used against on-prem
SQL Server
- Result is cached per collection instance

### Test infrastructure
- Deleted `SqlServerDiskAnnVectorSearchTests.cs` (read-only table
workaround no longer tables with latest vector indexes now support full
DML)needed
- Created `SqlServerConnectionStringRequiredAttribute` to gate DiskAnn
tests on Azure SQL connection string availability
- Rewrote DiskAnn conformance test to handle 100-row minimum requirement
for vector index creation
- Updated all command builder unit tests for new SQL syntax

### Kept
- `PREVIEW_FEATURES` command in `CreateTable` (needed for future on-prem
SQL Server support)

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Shay Rojansky
2026-04-16 07:15:46 +00:00
committed by GitHub
co-authored by Copilot
parent 20c91a38d1
commit 7e216a0f5c
7 changed files with 367 additions and 228 deletions
+2
View File
@@ -502,3 +502,5 @@ swa-cli.config.json
# dapr extension files
**/dapr.yaml
*.lscache
@@ -42,6 +42,12 @@ public class SqlServerCollection<TKey, TRecord>
/// <summary>The database schema.</summary>
private readonly string? _schema;
/// <summary>Whether the model contains any DiskAnn vector properties, requiring Azure SQL.</summary>
private readonly bool _requiresAzureSql;
/// <summary>Cached result of the Azure SQL engine edition check (null = not yet checked).</summary>
private bool? _isAzureSql;
/// <summary>
/// Initializes a new instance of the <see cref="SqlServerCollection{TKey, TRecord}"/> class.
/// </summary>
@@ -78,6 +84,16 @@ public class SqlServerCollection<TKey, TRecord>
this._mapper = new SqlServerMapper<TRecord>(this._model);
// Check if any vector property uses DiskAnn, which requires Azure SQL.
foreach (var vp in this._model.VectorProperties)
{
if (vp.IndexKind == IndexKind.DiskAnn)
{
this._requiresAzureSql = true;
break;
}
}
var connectionStringBuilder = new SqlConnectionStringBuilder(connectionString);
this._collectionMetadata = new()
@@ -116,6 +132,12 @@ public class SqlServerCollection<TKey, TRecord>
private async Task CreateCollectionAsync(bool ifNotExists, CancellationToken cancellationToken)
{
using SqlConnection connection = new(this._connectionString);
if (this._requiresAzureSql)
{
await this.EnsureAzureSqlForDiskAnnAsync(connection, cancellationToken).ConfigureAwait(false);
}
List<SqlCommand> commands = SqlServerCommandBuilder.CreateTable(
connection,
this._schema,
@@ -604,6 +626,12 @@ public class SqlServerCollection<TKey, TRecord>
// Connection and command are going to be disposed by the ReadVectorSearchResultsAsync,
// when the user is done with the results.
SqlConnection connection = new(this._connectionString);
if (vectorProperty.IndexKind == IndexKind.DiskAnn)
{
await this.EnsureAzureSqlForDiskAnnAsync(connection, cancellationToken).ConfigureAwait(false);
}
SqlCommand command = SqlServerCommandBuilder.SelectVector(
connection,
this._schema,
@@ -664,6 +692,12 @@ public class SqlServerCollection<TKey, TRecord>
// Connection and command are going to be disposed by the ReadVectorSearchResultsAsync,
// when the user is done with the results.
SqlConnection connection = new(this._connectionString);
if (vectorProperty.IndexKind == IndexKind.DiskAnn)
{
await this.EnsureAzureSqlForDiskAnnAsync(connection, cancellationToken).ConfigureAwait(false);
}
SqlCommand command = SqlServerCommandBuilder.SelectHybrid(
connection,
this._schema,
@@ -807,4 +841,48 @@ public class SqlServerCollection<TKey, TRecord>
yield return this._mapper.MapFromStorageToDataModel(reader, options.IncludeVectors);
}
}
/// <summary>
/// Validates that the connection is to Azure SQL Database or SQL database in Microsoft Fabric,
/// which is required for DiskAnn vector indexes and the VECTOR_SEARCH function.
/// </summary>
private async Task EnsureAzureSqlForDiskAnnAsync(SqlConnection connection, CancellationToken cancellationToken)
{
if (this._isAzureSql is true)
{
return;
}
if (this._isAzureSql is false)
{
connection.Dispose();
throw new NotSupportedException(
"DiskAnn vector indexes and the VECTOR_SEARCH function require Azure SQL Database or SQL database in Microsoft Fabric. " +
"They are not supported on SQL Server. Use a Flat index kind with VECTOR_DISTANCE instead.");
}
if (connection.State != System.Data.ConnectionState.Open)
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
}
using var command = connection.CreateCommand();
command.CommandText = "SELECT SERVERPROPERTY('EngineEdition')";
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var engineEdition = Convert.ToInt32(result);
// 5 = Azure SQL Database, 11 = SQL database in Microsoft Fabric
this._isAzureSql = engineEdition is 5 or 11;
if (!this._isAzureSql.Value)
{
// Dispose the connection before throwing; in SearchAsync/HybridSearchAsync the connection
// is not in a using block (it's normally disposed by ReadVectorSearchResultsAsync).
connection.Dispose();
throw new NotSupportedException(
"DiskAnn vector indexes and the VECTOR_SEARCH function require Azure SQL Database or SQL database in Microsoft Fabric. " +
"They are not supported on SQL Server. Use a Flat index kind with VECTOR_DISTANCE instead.");
}
}
}
@@ -535,41 +535,67 @@ internal static class SqlServerCommandBuilder
string distanceMetric,
string sorting)
{
// VECTOR_SEARCH() currently only supports post-filtering (TOP_N candidates are returned first,
// then predicates are applied). Pre-filtering is not supported.
if (options.Filter is not null)
{
throw new NotSupportedException(
"Filtering is not supported with approximate vector search (VECTOR_SEARCH). " +
"Remove the filter or use IndexKind.Flat for exact search with VECTOR_DISTANCE.");
}
SqlCommand command = connection.CreateCommand();
command.Parameters.AddWithValue("@vector", vector);
StringBuilder sb = new(300);
// When skip > 0, we need a subquery since TOP and OFFSET/FETCH can't coexist in the same SELECT.
bool needsSubquery = options.Skip > 0;
if (needsSubquery)
{
sb.Append("SELECT * FROM (");
}
// VECTOR_SEARCH returns all columns from the table plus a 'distance' column.
// We select the needed columns from the table alias and alias 'distance' as 'score'.
sb.Append("SELECT ");
// The latest version vector indexes require SELECT TOP(N) WITH APPROXIMATE instead of the deprecated TOP_N parameter.
sb.Append("SELECT TOP(").Append(top + options.Skip).Append(") WITH APPROXIMATE ");
sb.AppendIdentifiers(model.Properties, prefix: "t.", includeVectors: options.IncludeVectors);
sb.AppendLine(",");
sb.AppendLine("s.[distance] AS [score]");
sb.Append("FROM VECTOR_SEARCH(TABLE = ");
sb.AppendTableName(schema, tableName);
sb.Append(" AS t, COLUMN = ").AppendIdentifier(vectorProperty.StorageName);
sb.Append(", SIMILAR_TO = @vector, METRIC = '").Append(distanceMetric).Append('\'');
sb.Append(", TOP_N = ").Append(top + options.Skip).AppendLine(") AS s");
sb.Append(", SIMILAR_TO = @vector, METRIC = '").Append(distanceMetric).AppendLine("') AS s");
// With latest version vector indexes, WHERE predicates are applied during the vector search process
// (iterative filtering), not after retrieval.
if (options.Filter is not null)
{
int startParamIndex = command.Parameters.Count;
SqlServerFilterTranslator translator = new(model, options.Filter, sb, startParamIndex: startParamIndex, tableAlias: "t");
translator.Translate(appendWhere: true);
List<object> parameters = translator.ParameterValues;
foreach (object parameter in parameters)
{
command.AddParameter(property: null, $"@_{startParamIndex++}", parameter);
}
sb.AppendLine();
}
if (options.ScoreThreshold is not null)
{
command.Parameters.AddWithValue("@scoreThreshold", options.ScoreThreshold!.Value);
sb.AppendLine("WHERE s.[distance] <= @scoreThreshold");
sb.Append(options.Filter is not null ? "AND " : "WHERE ");
sb.AppendLine("s.[distance] <= @scoreThreshold");
}
sb.AppendFormat("ORDER BY [score] {0}", sorting);
sb.AppendLine();
sb.AppendFormat("OFFSET {0} ROWS FETCH NEXT {1} ROWS ONLY;", options.Skip, top);
if (needsSubquery)
{
sb.AppendLine();
sb.Append(") AS [inner]");
sb.AppendLine();
sb.AppendFormat("ORDER BY [score] {0}", sorting);
sb.AppendLine();
sb.AppendFormat("OFFSET {0} ROWS FETCH NEXT {1} ROWS ONLY;", options.Skip, top);
}
command.CommandText = sb.ToString();
return command;
@@ -587,15 +613,6 @@ internal static class SqlServerCommandBuilder
{
bool useVectorSearch = UseVectorSearch(vectorProperty);
// VECTOR_SEARCH() currently only supports post-filtering (TOP_N candidates are returned first,
// then predicates are applied). Pre-filtering is not supported.
if (useVectorSearch && options.Filter is not null)
{
throw new NotSupportedException(
"Filtering is not supported with approximate vector search (VECTOR_SEARCH). " +
"Remove the filter or use IndexKind.Flat for exact search with VECTOR_DISTANCE.");
}
string distanceFunction = vectorProperty.DistanceFunction ?? DistanceFunction.CosineDistance;
(string distanceMetric, _) = MapDistanceFunction(distanceFunction);
@@ -652,16 +669,32 @@ internal static class SqlServerCommandBuilder
// CTE 2: Semantic/vector search
if (useVectorSearch)
{
// Use VECTOR_SEARCH() for approximate nearest neighbor search with a vector index
// Use VECTOR_SEARCH() for approximate nearest neighbor search with a vector index.
// The latest version vector indexes require SELECT TOP(N) WITH APPROXIMATE instead of the deprecated TOP_N parameter.
sb.AppendLine("semantic_search AS (");
sb.AppendLine(" SELECT TOP(@candidateCount)");
sb.AppendLine(" SELECT TOP(@candidateCount) WITH APPROXIMATE");
sb.Append(" t.").AppendIdentifier(model.KeyProperty.StorageName).AppendLine(",");
sb.AppendLine(" RANK() OVER (ORDER BY s.[distance]) AS [rank]");
sb.AppendLine(" FROM VECTOR_SEARCH(TABLE = ");
sb.Append(" ").AppendTableName(schema, tableName);
sb.Append(" AS t, COLUMN = ").AppendIdentifier(vectorProperty.StorageName);
sb.Append(", SIMILAR_TO = @vector, METRIC = '").Append(distanceMetric).Append('\'');
sb.Append(", TOP_N = @candidateCount").AppendLine(") AS s");
sb.Append(", SIMILAR_TO = @vector, METRIC = '").Append(distanceMetric).AppendLine("') AS s");
// With latest version vector indexes, WHERE predicates are applied during the vector search process
// (iterative filtering), not after retrieval.
if (options.Filter is not null)
{
int filterParamStart = command.Parameters.Count;
SqlServerFilterTranslator translator = new(model, options.Filter, sb, startParamIndex: filterParamStart, tableAlias: "t");
translator.Translate(appendWhere: true);
foreach (object parameter in translator.ParameterValues)
{
command.AddParameter(property: null, $"@_{filterParamStart++}", parameter);
}
sb.AppendLine();
}
sb.AppendLine(" ORDER BY s.[distance]");
sb.AppendLine("),");
}
else
@@ -262,11 +262,10 @@ public class SqlServerCommandBuilderTests
Assert.Equal(
"""
SELECT t.[id],t.[name],t.[embedding],
SELECT TOP(5) WITH APPROXIMATE t.[id],t.[name],t.[embedding],
s.[distance] AS [score]
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE', TOP_N = 5) AS s
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE') AS s
ORDER BY [score] ASC
OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY;
""", command.CommandText, ignoreLineEndingDifferences: true);
}
@@ -295,16 +294,18 @@ public class SqlServerCommandBuilderTests
Assert.Equal(
"""
SELECT t.[id],t.[name],
SELECT * FROM (SELECT TOP(8) WITH APPROXIMATE t.[id],t.[name],
s.[distance] AS [score]
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE', TOP_N = 8) AS s
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE') AS s
ORDER BY [score] ASC
) AS [inner]
ORDER BY [score] ASC
OFFSET 3 ROWS FETCH NEXT 5 ROWS ONLY;
""", command.CommandText, ignoreLineEndingDifferences: true);
}
[Fact]
public void SelectVector_WithDiskAnnIndex_WithFilter_Throws()
public void SelectVector_WithDiskAnnIndex_WithFilter()
{
var model = BuildModel(
[
@@ -324,12 +325,20 @@ public class SqlServerCommandBuilderTests
Filter = d => (string)d["name"]! == "test"
};
Assert.Throws<NotSupportedException>(() =>
SqlServerCommandBuilder.SelectVector(
connection, "schema", "table",
model.VectorProperties[0], model,
top: 5, options,
new SqlVector<float>(new float[] { 1f, 2f, 3f })));
using SqlCommand command = SqlServerCommandBuilder.SelectVector(
connection, "schema", "table",
model.VectorProperties[0], model,
top: 5, options,
new SqlVector<float>(new float[] { 1f, 2f, 3f }));
Assert.Equal(
"""
SELECT TOP(5) WITH APPROXIMATE t.[id],t.[name],
s.[distance] AS [score]
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE') AS s
WHERE (t.[name] = 'test')
ORDER BY [score] ASC
""", command.CommandText, ignoreLineEndingDifferences: true);
}
[Fact]
@@ -361,15 +370,86 @@ public class SqlServerCommandBuilderTests
Assert.Equal(
"""
SELECT t.[id],t.[name],t.[embedding],
SELECT TOP(5) WITH APPROXIMATE t.[id],t.[name],t.[embedding],
s.[distance] AS [score]
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE', TOP_N = 5) AS s
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE') AS s
WHERE s.[distance] <= @scoreThreshold
ORDER BY [score] ASC
OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY;
""", command.CommandText, ignoreLineEndingDifferences: true);
}
[Fact]
public void SelectVector_WithDiskAnnIndex_WithFilterAndScoreThreshold()
{
var model = BuildModel(
[
new VectorStoreKeyProperty("id", typeof(long)),
new VectorStoreDataProperty("name", typeof(string)),
new VectorStoreVectorProperty("embedding", typeof(ReadOnlyMemory<float>), 3)
{
IndexKind = IndexKind.DiskAnn,
DistanceFunction = DistanceFunction.CosineDistance
}
]);
using SqlConnection connection = CreateConnection();
var options = new VectorSearchOptions<Dictionary<string, object?>>
{
Filter = d => (string)d["name"]! == "test",
ScoreThreshold = 0.5f
};
using SqlCommand command = SqlServerCommandBuilder.SelectVector(
connection, "schema", "table",
model.VectorProperties[0], model,
top: 5, options,
new SqlVector<float>(new float[] { 1f, 2f, 3f }));
Assert.Equal(
"""
SELECT TOP(5) WITH APPROXIMATE t.[id],t.[name],
s.[distance] AS [score]
FROM VECTOR_SEARCH(TABLE = [schema].[table] AS t, COLUMN = [embedding], SIMILAR_TO = @vector, METRIC = 'COSINE') AS s
WHERE (t.[name] = 'test')
AND s.[distance] <= @scoreThreshold
ORDER BY [score] ASC
""", command.CommandText, ignoreLineEndingDifferences: true);
}
[Fact]
public void SelectHybrid_WithDiskAnnIndex_WithFilter()
{
var model = BuildModel(
[
new VectorStoreKeyProperty("id", typeof(long)),
new VectorStoreDataProperty("name", typeof(string)) { IsFullTextIndexed = true },
new VectorStoreVectorProperty("embedding", typeof(ReadOnlyMemory<float>), 3)
{
IndexKind = IndexKind.DiskAnn,
DistanceFunction = DistanceFunction.CosineDistance
}
]);
using SqlConnection connection = CreateConnection();
var options = new HybridSearchOptions<Dictionary<string, object?>>
{
Filter = d => (string)d["name"]! == "test"
};
using SqlCommand command = SqlServerCommandBuilder.SelectHybrid(
connection, "schema", "table",
model.VectorProperties[0], model.DataProperties.First(p => p.IsFullTextIndexed), model,
top: 5, options,
new SqlVector<float>(new float[] { 1f, 2f, 3f }),
"keyword");
Assert.Contains("SELECT TOP(@candidateCount) WITH APPROXIMATE", command.CommandText);
Assert.Contains("WHERE (t.[name] = 'test')", command.CommandText);
Assert.Contains("VECTOR_SEARCH(TABLE =", command.CommandText);
}
[Fact]
public void Upsert()
{
@@ -1,179 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// TEMPORARY: This test class exists to work around a current SQL Server 2025 limitation where tables with
// vector indexes are read-only. Once this limitation is lifted, these tests should be removed and the
// standard conformance tests in SqlServerIndexKindTests should be enabled instead (by removing the Skip on DiskAnn).
//
// The workaround is to:
// 1. Create the table without the vector index (using IndexKind.Flat).
// 2. Insert data while the table is still writable.
// 3. Create the DiskANN vector index via raw SQL.
// 4. Create a new collection instance with IndexKind.DiskAnn to route searches through VECTOR_SEARCH().
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.VectorData;
using SqlServer.ConformanceTests.Support;
using VectorData.ConformanceTests.Support;
using VectorData.ConformanceTests.Xunit;
using Xunit;
namespace SqlServer.ConformanceTests;
public class SqlServerDiskAnnVectorSearchTests(
SqlServerDiskAnnVectorSearchTests.Fixture fixture)
: IClassFixture<SqlServerDiskAnnVectorSearchTests.Fixture>
{
private const string CollectionName = "DiskAnnVectorSearchTests";
/// <summary>
/// Tests that approximate vector search via VECTOR_SEARCH() returns correct results
/// when a DiskANN index exists on the table.
/// </summary>
[ConditionalFact]
public async Task VectorSearch_WithDiskAnnIndex()
{
using var collection = this.CreateDiskAnnCollection();
var result = await collection.SearchAsync(
new ReadOnlyMemory<float>([10, 30, 50]), top: 1).SingleAsync();
Assert.NotNull(result);
Assert.Equal(2, result.Record.Int);
}
/// <summary>
/// Tests that VECTOR_SEARCH() correctly returns multiple results ordered by distance.
/// </summary>
[ConditionalFact]
public async Task VectorSearch_WithDiskAnnIndex_TopN()
{
using var collection = this.CreateDiskAnnCollection();
var results = await collection.SearchAsync(
new ReadOnlyMemory<float>([10, 30, 50]), top: 3).ToListAsync();
Assert.Equal(3, results.Count);
// The closest match should be the exact vector [10, 30, 50]
Assert.Equal(2, results[0].Record.Int);
}
/// <summary>
/// Tests that VECTOR_SEARCH() throws when a LINQ filter is specified,
/// since SQL Server's VECTOR_SEARCH only supports post-filtering.
/// </summary>
[ConditionalFact]
public async Task VectorSearch_WithDiskAnnIndex_WithFilter_Throws()
{
using var collection = this.CreateDiskAnnCollection();
var exception = await Assert.ThrowsAsync<NotSupportedException>(
async () => await collection.SearchAsync(
new ReadOnlyMemory<float>([10, 30, 50]),
top: 1,
new VectorSearchOptions<SearchRecord>
{
Filter = r => r.Int == 2
}).SingleAsync());
Assert.Contains("VECTOR_SEARCH", exception.Message);
}
private VectorStoreCollection<int, SearchRecord> CreateDiskAnnCollection()
{
VectorStoreCollectionDefinition definition = new()
{
Properties =
[
new VectorStoreKeyProperty(nameof(SearchRecord.Key), typeof(int)),
new VectorStoreDataProperty(nameof(SearchRecord.Int), typeof(int)),
new VectorStoreVectorProperty(nameof(SearchRecord.Vector), typeof(ReadOnlyMemory<float>), dimensions: 3)
{
IndexKind = IndexKind.DiskAnn,
DistanceFunction = DistanceFunction.CosineDistance
}
]
};
return fixture.TestStore.CreateCollection<int, SearchRecord>(CollectionName, definition);
}
public class SearchRecord
{
public int Key { get; set; }
public int Int { get; set; }
public ReadOnlyMemory<float> Vector { get; set; }
}
// TEMPORARY: This fixture works around the SQL Server 2025 read-only vector index limitation
// by creating the table without a vector index, inserting data, and then creating the index.
// See SqlServerIndexKindTests.DiskAnn for the standard (currently skipped) conformance test.
public class Fixture : VectorStoreFixture, IAsyncLifetime
{
public override TestStore TestStore => SqlServerTestStore.Instance;
public override async Task InitializeAsync()
{
await base.InitializeAsync();
var connectionString = SqlServerTestStore.Instance.ConnectionString;
// Step 1: Create a "flat" collection (no vector index) and insert data.
VectorStoreCollectionDefinition flatDefinition = new()
{
Properties =
[
new VectorStoreKeyProperty(nameof(SearchRecord.Key), typeof(int)),
new VectorStoreDataProperty(nameof(SearchRecord.Int), typeof(int)),
new VectorStoreVectorProperty(nameof(SearchRecord.Vector), typeof(ReadOnlyMemory<float>), dimensions: 3)
{
IndexKind = IndexKind.Flat,
DistanceFunction = DistanceFunction.CosineDistance
}
]
};
using var flatCollection = this.TestStore.CreateCollection<int, SearchRecord>(CollectionName, flatDefinition);
await flatCollection.EnsureCollectionDeletedAsync();
await flatCollection.EnsureCollectionExistsAsync();
SearchRecord[] records =
[
new() { Key = 1, Int = 1, Vector = new([1, 2, 3]) },
new() { Key = 2, Int = 2, Vector = new([10, 30, 50]) },
new() { Key = 3, Int = 3, Vector = new([100, 40, 70]) }
];
await flatCollection.UpsertAsync(records);
await this.TestStore.WaitForDataAsync(flatCollection, records.Length);
// Step 2: Create the DiskANN vector index now that data is already in the table.
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using (var enablePreview = new SqlCommand(
"ALTER DATABASE SCOPED CONFIGURATION SET PREVIEW_FEATURES = ON;", connection))
{
await enablePreview.ExecuteNonQueryAsync();
}
using (var createIndex = new SqlCommand(
$"CREATE VECTOR INDEX index_{CollectionName}_Vector ON [{CollectionName}]([Vector]) WITH (METRIC = 'COSINE', TYPE = 'DISKANN');",
connection))
{
await createIndex.ExecuteNonQueryAsync();
}
}
public override async Task DisposeAsync()
{
// Clean up the table
var connectionString = SqlServerTestStore.Instance.ConnectionString;
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using var command = new SqlCommand($"DROP TABLE IF EXISTS [{CollectionName}]", connection);
await command.ExecuteNonQueryAsync();
await base.DisposeAsync();
}
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.VectorData;
using SqlServer.ConformanceTests.Support;
using VectorData.ConformanceTests;
@@ -12,12 +13,95 @@ namespace SqlServer.ConformanceTests;
public class SqlServerIndexKindTests(SqlServerIndexKindTests.Fixture fixture)
: IndexKindTests<int>(fixture), IClassFixture<SqlServerIndexKindTests.Fixture>
{
// SQL Server 2025 currently makes tables with vector indexes read-only, so data must be inserted before
// the index is created. See SqlServerDiskAnnVectorSearchTests for a temporary workaround test that inserts
// data first and then creates the index. Remove the Skip and delete that class once this limitation is lifted.
[ConditionalFact(Skip = "SQL Server 2025 read-only vector index limitation; see SqlServerDiskAnnVectorSearchTests")]
public virtual Task DiskAnn()
=> this.Test(IndexKind.DiskAnn);
// Latest version vector indexes are only available in Azure SQL, not in on-prem SQL Server.
// They also require at least 100 rows before the vector index can be created,
// so we override the test to insert data first, then create the index.
[ConditionalFact]
[AzureSqlRequired]
public virtual async Task DiskAnn()
{
const string CollectionName = "IndexKindTests_DiskAnn";
// Step 1: Create the table using Flat index (no vector index) so we can insert data.
VectorStoreCollectionDefinition flatDefinition = new()
{
Properties =
[
new VectorStoreKeyProperty(nameof(SearchRecord.Key), typeof(int)),
new VectorStoreDataProperty(nameof(SearchRecord.Int), typeof(int)),
new VectorStoreVectorProperty(nameof(SearchRecord.Vector), typeof(ReadOnlyMemory<float>), dimensions: 3)
{
IndexKind = IndexKind.Flat,
DistanceFunction = DistanceFunction.CosineDistance
}
]
};
using var flatCollection = fixture.TestStore.CreateCollection<int, SearchRecord>(CollectionName, flatDefinition);
await flatCollection.EnsureCollectionDeletedAsync();
await flatCollection.EnsureCollectionExistsAsync();
try
{
// Step 2: Insert the 3 test rows + 97 filler rows to meet the 100-row minimum.
SearchRecord[] testRecords =
[
new() { Key = 1, Int = 1, Vector = new([1, 2, 3]) },
new() { Key = 2, Int = 2, Vector = new([10, 30, 50]) },
new() { Key = 3, Int = 3, Vector = new([100, 40, 70]) }
];
await flatCollection.UpsertAsync(testRecords);
var fillerRecords = Enumerable.Range(100, 97)
.Select(i => new SearchRecord
{
Key = i,
Int = i,
Vector = new([i * 0.1f, i * 0.2f, i * 0.3f])
})
.ToArray();
await flatCollection.UpsertAsync(fillerRecords);
// Step 3: Create the DiskANN vector index via raw SQL now that data is in the table.
using var connection = new SqlConnection(SqlServerTestStore.Instance.ConnectionString);
await connection.OpenAsync();
using (var createIndex = new SqlCommand(
$"CREATE VECTOR INDEX index_{CollectionName}_Vector ON [{CollectionName}]([Vector]) WITH (METRIC = 'COSINE', TYPE = 'DISKANN');",
connection))
{
await createIndex.ExecuteNonQueryAsync();
}
// Step 4: Create a new collection instance with DiskAnn to route searches through VECTOR_SEARCH().
VectorStoreCollectionDefinition diskAnnDefinition = new()
{
Properties =
[
new VectorStoreKeyProperty(nameof(SearchRecord.Key), typeof(int)),
new VectorStoreDataProperty(nameof(SearchRecord.Int), typeof(int)),
new VectorStoreVectorProperty(nameof(SearchRecord.Vector), typeof(ReadOnlyMemory<float>), dimensions: 3)
{
IndexKind = IndexKind.DiskAnn,
DistanceFunction = DistanceFunction.CosineDistance
}
]
};
using var diskAnnCollection = fixture.TestStore.CreateCollection<int, SearchRecord>(CollectionName, diskAnnDefinition);
var result = await diskAnnCollection.SearchAsync(new ReadOnlyMemory<float>([10, 30, 50]), top: 1).SingleAsync();
Assert.NotNull(result);
Assert.Equal(2, result.Record.Int);
}
finally
{
await flatCollection.EnsureCollectionDeletedAsync();
}
}
public new class Fixture() : IndexKindTests<int>.Fixture
{
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Data.SqlClient;
using VectorData.ConformanceTests.Xunit;
namespace SqlServer.ConformanceTests.Support;
/// <summary>
/// Skips the test(s) when the database is not Azure SQL Database or SQL database in Microsoft Fabric.
/// This is used for tests that require Azure SQL features not available in on-prem SQL Server (e.g. DiskAnn vector indexes).
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)]
public sealed class AzureSqlRequiredAttribute : Attribute, ITestCondition
{
private static bool? s_isAzureSql;
public async ValueTask<bool> IsMetAsync()
{
if (s_isAzureSql is not null)
{
return s_isAzureSql.Value;
}
var connectionString = SqlServerTestStore.Instance.ConnectionString;
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = "SELECT SERVERPROPERTY('EngineEdition')";
var result = await command.ExecuteScalarAsync();
var engineEdition = Convert.ToInt32(result);
// 5 = Azure SQL Database, 11 = SQL database in Microsoft Fabric
s_isAzureSql = engineEdition is 5 or 11;
return s_isAzureSql.Value;
}
public string SkipReason
=> "This test requires Azure SQL Database or SQL database in Microsoft Fabric.";
}