Mert Özen Further With Every Line

Vector Search with EF Core (1): SQL Server 2025 vector Type, VectorDistance and EF Core 11 VectorSearch

Mert Özen Nov 8, 2026 10 min 2 views
Vector Search with EF Core (1): SQL Server 2025 vector Type, VectorDistance and EF Core 11 VectorSearch

In EF Core 10 a SQL Server 2025 vector column is mapped with a SqlVector<float> property and HasColumnType("vector(768)"), and search is done exactly with EF.Functions.VectorDistance. We build the code and inspect the real SQL EF Core generates: queries without a projection fetch the vector too, the preview vector index and VECTOR_SEARCH, and what EF Core 11 brings. SQL Server 2025 was not run for this post.

Version note: The code in this post was built with .NET 10 and EF (Entity Framework) Core 10.0.14 (Microsoft.EntityFrameworkCore.SqlServer, Microsoft.Data.SqlClient 6.1.7). The parts that connect to SQL (Structured Query Language) Server 2025 were not run for this post; the SQL shown is real output that EF Core generated without opening a connection.

Guess First

You keep Bookstore's chunks in a DocChunks table on SQL Server 2025; every row has a 768-dimension vector column. A shipping policy changed and you want to read that document's chunks with db.DocChunks.Where(c => c.DocId == "kargo-ve-teslimat") to look at their text. Does the SELECT generated by EF Core 10 include the Embedding column? If so, how many unnecessary bytes travel for every row?

The answer is in the middle of the post. First, this post's boundary: the SQL Server 2025 Docker image requires accepting a license (ACCEPT_EULA), so I do not run it in this series. The code compiles and EF Core's SQL generation is real, but I show no result of any query that goes to the database. The facts come from the SQL Server and EF Core documentation on Microsoft Learn. We do the same work against a running database, on PostgreSQL and pgvector, in the next post.

SQL Server 2025's vector Type

SQL Server 2025, Azure SQL Database and Azure SQL Managed Instance bring a built-in vector type. Values are stored in an optimized binary format but appear outside as a JSON (JavaScript Object Notation) array: '[0.1, 2, 30]'. The default base type is float32, which supports up to 1,998 dimensions. Half-precision float16 goes up to 3,996 dimensions but is still preview in SQL Server 2025 and requires turning on the PREVIEW_FEATURES database scoped configuration.

A few restrictions directly affect design. A vector column cannot be a primary key, foreign key or unique constraint; you cannot put a B-tree or columnstore index on it (it can only be an included column). There are no arithmetic or comparison operators; you compare two vectors only through functions. The dimension count is fixed by the column type: according to the documentation, writing a 256-element array into a 768-dimension column raises error 42204.

On the client side, the SqlVector<float> type that arrived with Microsoft.Data.SqlClient 6.1.0 carries the vector in binary over the TDS (Tabular Data Stream) protocol, with no JSON conversion cost and no precision loss. EF Core 10 maps this type directly. Older drivers keep seeing the vector as varchar(max) JSON.

Entity and Mapping

The model is the SQL Server counterpart of the doc_chunks table we build on pgvector in the seventh post. It also has Acl for the permission filter and EmbeddingModel to catch model switches (the "identity of a vector" from the second post):

using Microsoft.Data.SqlTypes;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Rag.SqlServer;

public class DocChunk
{
    public required string Id { get; set; }            // "kargo-ve-teslimat#0"
    public required string DocId { get; set; }
    public required string Section { get; set; }
    public required string Content { get; set; }
    public required string Acl { get; set; }
    public DateOnly UpdatedAt { get; set; }
    public required string EmbeddingModel { get; set; }
    public SqlVector<float> Embedding { get; set; }
}

public class RagDbContext(DbContextOptions<RagDbContext> options) : DbContext(options)
{
    public DbSet<DocChunk> DocChunks => Set<DocChunk>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DocChunk>(e =>
        {
            e.Property(c => c.Id).HasMaxLength(200);
            e.Property(c => c.DocId).HasMaxLength(100);
            e.Property(c => c.Acl).HasMaxLength(20);
            e.Property(c => c.EmbeddingModel).HasMaxLength(100);
            // The dimension comes from the embedding model: nomic-embed-text = 768
            e.Property(c => c.Embedding).HasColumnType("vector(768)");
            e.HasIndex(c => new { c.Acl, c.DocId });
        });
    }
}

Declaring the embedding as SqlVector<float> rather than float[] matters; that is the type EF Core maps to vector. The column type is set with HasColumnType("vector(768)"); you can do the same with the [Column(TypeName = "vector(768)")] attribute. Without opening a connection, I got the table's DDL (Data Definition Language) with GenerateCreateScript():

$ dotnet run -- schema
CREATE TABLE [DocChunks] (
    [Id] nvarchar(200) NOT NULL,
    [DocId] nvarchar(100) NOT NULL,
    [Section] nvarchar(max) NOT NULL,
    [Content] nvarchar(max) NOT NULL,
    [Acl] nvarchar(20) NOT NULL,
    [UpdatedAt] date NOT NULL,
    [EmbeddingModel] nvarchar(100) NOT NULL,
    [Embedding] vector(768) NOT NULL,
    CONSTRAINT [PK_DocChunks] PRIMARY KEY ([Id])
);
GO
CREATE INDEX [IX_DocChunks_Acl_DocId] ON [DocChunks] ([Acl], [DocId]);
GO

[Embedding] vector(768) NOT NULL as expected. The B-tree index on Acl and DocId is there for the SQL Server equivalent of the pre-filter plan from the fifth post: narrow the rows with this index on a selective filter, then compute distances only for them.

Generating and Saving Embeddings

Embeddings are generated outside the database, with IEmbeddingGenerator. The class below compiles but was not run for this post:

public sealed record ChunkInput(string Id, string DocId, string Section, string Content, string Acl, DateOnly UpdatedAt);

public sealed class SqlServerChunkIndexer(RagDbContext db, IEmbeddingGenerator<string, Embedding<float>> embedder)
{
    public async Task IndexAsync(IReadOnlyList<ChunkInput> chunks, CancellationToken ct = default)
    {
        // All chunks in one request: one batch instead of 53 separate calls to Ollama
        GeneratedEmbeddings<Embedding<float>> embeddings =
            await embedder.GenerateAsync(chunks.Select(c => "search_document: " + c.Content), cancellationToken: ct);

        for (int i = 0; i < chunks.Count; i++)
        {
            ChunkInput c = chunks[i];
            Embedding<float> e = embeddings[i];
            if (e.Vector.Length != 768)
            {
                throw new InvalidOperationException($"{c.Id}: expected 768 dimensions, got {e.Vector.Length}");
            }

            db.DocChunks.Add(new DocChunk
            {
                Id = c.Id,
                DocId = c.DocId,
                Section = c.Section,
                Content = c.Content,
                Acl = c.Acl,
                UpdatedAt = c.UpdatedAt,
                EmbeddingModel = e.ModelId ?? "unknown",
                Embedding = new SqlVector<float>(e.Vector)
            });
        }
        await db.SaveChangesAsync(ct);
    }
}

Three details. All chunks are embedded with a single GenerateAsync call; sending a separate request per chunk at ingestion is needless latency. The dimension check happens before saving: SQL Server rejects a vector with the wrong dimension anyway, but it is better to get the error in your own code, knowing which chunk caused it. And the EmbeddingModel column is filled from the ModelId the model returns; nobody types it by hand.

Exact Search with VectorDistance

In EF Core 10 the search uses EF.Functions.VectorDistance, translated to SQL Server's VECTOR_DISTANCE function. The metrics are "cosine", "euclidean" and "dot":

float[] embedding = args.Length > 1
    ? (await new OllamaApiClient(new Uri("http://localhost:11434"), "nomic-embed-text")
        .GenerateVectorAsync("search_query: " + args[1])).ToArray()
    : new float[768];
SqlVector<float> queryVector = new(embedding);

var query = db.DocChunks
    .Where(c => c.Acl == "public" && c.EmbeddingModel == "nomic-embed-text")
    .OrderBy(c => EF.Functions.VectorDistance("cosine", c.Embedding, queryVector))
    .Select(c => new
    {
        c.Id,
        c.Content,
        Distance = EF.Functions.VectorDistance("cosine", c.Embedding, queryVector)
    })
    .Take(5);
Console.WriteLine(query.ToQueryString());
$ dotnet run -- query
DECLARE @p int = 5;
DECLARE @queryVector sql_variant = Microsoft.Data.SqlTypes.SqlVector`1[System.Single];
SELECT TOP(@p) [d].[Id], [d].[Content], VECTOR_DISTANCE('cosine', [d].[Embedding], @queryVector) AS [Distance]
FROM [DocChunks] AS [d]
WHERE [d].[Acl] = N'public' AND [d].[EmbeddingModel] = N'nomic-embed-text'
ORDER BY VECTOR_DISTANCE('cosine', [d].[Embedding], @queryVector)

The SQL is as expected: the filter in WHERE, ordering by VECTOR_DISTANCE, 5 rows via TOP(@p). Thanks to the projection the Embedding column does not enter the SELECT, and the distance is computed and returned separately. Do not be misled by ToQueryString() showing sql_variant in the parameter line; as noted in the EF Core series post "What's New in EF Core 10: Named Query Filters, LeftJoin and Complex Types in JSON", that is only how ToQueryString() displays it. The SQL Server documentation states plainly that vector cannot be assigned to sql_variant; the real parameter is sent by SqlClient with the vector type.

This is an exact kNN search: SQL Server computes the distance for every row that passes the filter. If the filter is selective (a few thousand chunks of one tenant) that is perfectly reasonable; it is the same pre-filter plan we saw on pgvector in the fifth post. At hundreds of thousands of rows you need approximate search, that is, a vector index.

Fetching the Vector Column for Nothing

Back to the opening question. The query that reads entities without a projection:

$ dotnet run -- load-all
SELECT [d].[Id], [d].[Acl], [d].[Content], [d].[DocId], [d].[Embedding], [d].[EmbeddingModel], [d].[Section], [d].[UpdatedAt]
FROM [DocChunks] AS [d]
WHERE [d].[DocId] = N'kargo-ve-teslimat'

Yes, [d].[Embedding] is inside the SELECT. A 768-dimension float32 vector is 3,072 bytes of raw data; every chunk you read just to look at its text brings it along, and EF Core keeps it in the change tracker. For a 100-chunk admin screen that is 300 KB, for an ingestion job working over a million chunks 3 GB of unnecessary traffic. The fix is simple: use projections in listing and admin queries and touch the vector only where you really need it. EF Core 11 changes this behavior; more in the next section.

VECTOR_SEARCH, Vector Indexes and EF Core 11

In SQL Server, approximate search uses CREATE VECTOR INDEX and the VECTOR_SEARCH table-valued function. The index type is DiskANN; supported metrics are cosine, euclidean and dot. These features are generally available in Azure SQL Database, while in SQL Server 2025 they are preview and require PREVIEW_FEATURES. Following the syntax on Microsoft Learn, the T-SQL (Transact-SQL) for our table would look like this; this block was not run:

ALTER DATABASE SCOPED CONFIGURATION SET PREVIEW_FEATURES = ON;

CREATE VECTOR INDEX IX_DocChunks_Embedding
    ON DocChunks (Embedding)
    WITH (METRIC = 'cosine', TYPE = 'diskann');

SELECT TOP (5) WITH APPROXIMATE d.Id, d.Content, s.distance
FROM VECTOR_SEARCH(
        TABLE = DocChunks AS d,
        COLUMN = Embedding,
        SIMILAR_TO = @queryVector,
        METRIC = 'cosine'
     ) AS s
WHERE d.Acl = N'public'
ORDER BY s.distance;

Three notes from the Learn documentation affect your design. First, a vector index can only be created on a table with at least 100 rows that have vectors; with fewer, error 42266 is returned. Second, the current index version applies the filter during the search (iterative filtering) and keeps the table open to DML (Data Manipulation Language); the earlier version applied the filter after the search and made the indexed table read-only. So you need to follow the release notes and the index version in sys.vector_indexes. Third, when you regenerate almost all embeddings with a new model (the model switch from the second post), dropping and recreating the index is recommended; otherwise search keeps working but recall may drop.

On the EF Core side, the counterpart of this query comes with EF Core 11. According to the EF Core vector search page on Microsoft Learn, HasVectorIndex(b => b.Embedding, "cosine") defines a vector index in the model, the VectorSearch(...) extension method translates to VECTOR_SEARCH and returns the entity together with its Distance through VectorSearchResult<T>, and WithApproximate(), called after Take(k), adds WITH APPROXIMATE to the TOP. The same page says that starting with EF Core 11, vector properties are not loaded by default in entity queries; the load-all query above will not fetch Embedding on EF Core 11. According to the documentation these APIs (Application Programming Interfaces) and their SQL Server counterparts are experimental and subject to change. EF Core 11 ships with .NET 11 on 10 November 2026; at the time of writing the latest version on NuGet was 11.0.0-rc.3, and because it only targets net11.0 it cannot be built with the .NET 10 SDK (Software Development Kit). So I do not give code for these APIs here.

Production Notes

Scan your dependencies. When I first built this project, NuGet reported this:

$ dotnet list package --vulnerable --include-transitive
Project `EfSqlLab` has the following vulnerable packages
   [net10.0]:
   Transitive Package                       Resolved   Severity   Advisory URL
   > System.Security.Cryptography.Pkcs      9.0.11     High       https://github.com/advisories/GHSA-846f-f764-76mx
                                                       Moderate   https://github.com/advisories/GHSA-2fmv-rv2c-59x7

The package comes through SqlClient's dependency chain. Adding a direct reference to System.Security.Cryptography.Pkcs 10.0.14 removed the warning, and the same command reported "has no vulnerable packages". Updating EF Core does not always update transitive dependencies; run dotnet list package --vulnerable --include-transitive in CI (Continuous Integration).

Before depending on preview features in production, split the picture in two: the vector type and VECTOR_DISTANCE are generally available in SQL Server 2025, vector indexes and VECTOR_SEARCH are preview. Exact search with a selective B-tree filter is often enough for a medium-sized RAG (Retrieval-Augmented Generation) system; show by measurement that you need approximate search, then move to the preview.

If you used the EFCore.SqlServer.VectorSearch package before, remove it from the project when moving to EF Core 10; according to Learn, the built-in support in EF Core 10 replaces that extension. If you stored vectors as varbinary or JSON strings, moving them to a vector column is a data migration; the "new column, backfill, switch" sequence from the second post applies here too.

Common Pitfalls

First, trying it on SQL Server 2022. The vector type and VECTOR_DISTANCE come with SQL Server 2025; the EF Core 10 documentation also says vector support only works on SQL Server 2025 and above (and on Azure SQL). On an older server the error appears at migration time or on the first query.

Second, defining a dimension that differs from the model. With vector(1536) copied from a sample, every insert of a 768-dimension nomic-embed-text vector fails. Take the dimension from the embedding model's output and check it in code.

Third, forgetting the float32 limit with large models. A model producing 3,072 dimensions (such as text-embedding-3-large) exceeds the 1,998 limit of float32; you need to move to float16 (preview in SQL Server 2025) or, if the model supports it, a shorter dimension.

Fourth, fetching the whole vector on every query. In EF Core 10 every entity query without a projection also brings Embedding. Use projections on listing screens and for "did it change?" checks in ingestion.

Fifth, trusting ToQueryString() and thinking the parameter type is the problem. The sql_variant line is only display; verify real behavior with SQL Profiler or Extended Events.

A Small Experiment

★☆☆ Rewrite the load-all query with a projection (Id, Section, Content) and confirm in the ToQueryString() output that Embedding disappears.

★★☆ Add a Tenant property to DocChunk, define an index on (Tenant, Acl) and inspect the GenerateCreateScript() output. Extend the query's filter by tenant and look at the generated SQL.

★★★ If you can run SQL Server 2025 in an environment where you can accept its license terms, load the 53 chunks with SqlServerChunkIndexer, add synthetic rows to pass the 100-row minimum and create the vector index with the T-SQL above. Compare the exact VECTOR_DISTANCE and VECTOR_SEARCH results for the same query vector.

On the SQL Server side we built the model and the query but could not run them. In the next post we run the same model for real on PostgreSQL and pgvector: mapping with Pgvector.EntityFrameworkCore, an HNSW (Hierarchical Navigable Small World) index, checking index use with EXPLAIN, halfvec, and the same query written with Dapper.