Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration!How!?

Vector / ANN Search

Vector queries let you find documents by embedding similarity — the nearest k documents to a query vector under a configurable distance metric. The library exposes a single NearestVectors API that runs on top of each provider’s native ANN engine: pgvector on PostgreSQL, the VECTOR type on SQL Server 2025, the native VECTOR type on Oracle 23ai, DiskANN on CosmosDB, Atlas $vectorSearch on MongoDB, the vss extension on DuckDB, and sqlite-vec on SQLite. LiteDB, IndexedDB, and MySQL throw NotSupportedException because they have no comparable engine.

The shape mirrors the spatial API: register a property with MapVectorProperty<T>(...), then query with store.Query<T>().NearestVectors(queryEmbedding, k: 10).

The vector lives on the document as a ReadOnlyMemory<float> — the same shape Microsoft.Extensions.AI.Embedding<float>.Vector returns. System.Text.Json round-trips it as a JSON array out of the box.

public class Document
{
public Guid Id { get; set; }
public string Content { get; set; } = "";
public ReadOnlyMemory<float> Embedding { get; set; }
}

The supported distance / similarity metrics.

public enum VectorDistance
{
Cosine, // distance in [0, 2]; lower = closer
Euclidean, // L2 distance; lower = closer
DotProduct, // raw inner product; higher = closer
Hamming // bit-vector distance (pgvector only)
}

The ANN index strategy the provider should provision.

public enum VectorIndexKind
{
None, // flat scan
Flat, // explicit flat index where the provider distinguishes
Hnsw, // pgvector, DuckDB vss, Atlas, Oracle 23ai — default
Ivf, // pgvector ivfflat, Oracle 23ai
DiskAnn, // SQL Server 2025, CosmosDB
QuantizedFlat // CosmosDB
}

Wraps a document with its computed similarity score.

public class VectorResult<T> where T : class
{
public required T Document { get; init; }
public float Score { get; init; }
}

Score semantics are stable across providers:

Metric Surfaced as Direction
Cosine Distance in [0, 2] Lower = closer
Euclidean L2 distance Lower = closer
DotProduct Raw inner product Higher = closer (negated internally on engines that don’t sort that way, so ORDER BY score ASC always means “nearest first”)
Hamming Bit count Lower = closer

Register the embedding property with MapVectorProperty<T>. The mapping captures the dimension, metric, index kind, and tuning options.

var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
{
EnableVectorExtension = true // load sqlite-vec on every connection
}
}.MapVectorProperty<Document>(
d => d.Embedding,
dimensions: 1536,
metric: VectorDistance.Cosine,
indexKind: VectorIndexKind.Hnsw));

You can map multiple types — each gets its own sidecar storage and index parameters:

opts.MapVectorProperty<Memo>(m => m.Embedding, dimensions: 1536)
.MapVectorProperty<ProductDescription>(p => p.Embedding,
dimensions: 384,
metric: VectorDistance.DotProduct);

Provider-specific options (CosmosDbDocumentStoreOptions, MongoDbDocumentStoreOptions)

Section titled “Provider-specific options (CosmosDbDocumentStoreOptions, MongoDbDocumentStoreOptions)”

Cosmos and Mongo carry their own option classes (no shared base with DocumentStoreOptions). Each exposes the same MapVectorProperty<T> overloads with the provider-appropriate default for indexKind:

var cosmos = new CosmosDbDocumentStore(new CosmosDbDocumentStoreOptions
{
ConnectionString = "AccountEndpoint=...;AccountKey=...",
DatabaseName = "mydb"
}.MapVectorProperty<Document>(d => d.Embedding,
dimensions: 1536,
indexKind: VectorIndexKind.DiskAnn));
if (store.SupportsVector)
{
var hits = await store.NearestVectors<Document>(queryEmbedding, k: 10);
}
Provider SupportsVector
PostgreSQL (pgvector) true
SQL Server 2025 true
Oracle 23ai (native VECTOR) true
DuckDB (vss extension) true
CosmosDB true (when vector properties are mapped)
MongoDB (Atlas Vector Search) true (when vector properties are mapped)
SQLite (sqlite-vec) true (with the Shiny.DocumentDb.Sqlite.VectorSupport package, or EnableVectorExtension / VectorExtensionPreloaded)
SQLCipher true (same as SQLite — the VectorSupport package registers vec0 against the e_sqlcipher engine)
LiteDB false
IndexedDB false
MySQL false

The preferred entry point. Where(...) predicates pre-filter where the provider supports it; OrderBy / Paginate / GroupBy are ignored (use k to bound the result count).

var hits = await store.Query<Document>()
.Where(d => d.Tenant == tenantId)
.NearestVectors(queryEmbedding, k: 10);
foreach (var hit in hits)
Console.WriteLine($"{hit.Score:F4} {hit.Document.Content}");

NearestVectors (low-level on IDocumentStore / IDocumentSession)

Section titled “NearestVectors (low-level on IDocumentStore / IDocumentSession)”

Same call without the fluent builder — useful when the filter is null or you’re not in a query chain. It lives on both the long-lived IDocumentStore and a short-lived IDocumentSession, so a unit of work can run vector search alongside its buffered writes.

var hits = await store.NearestVectors<Document>(queryEmbedding, k: 10);
// With an optional filter
var scopedHits = await store.NearestVectors<Document>(
queryEmbedding, k: 10,
filter: d => d.Status == "Active");

Through a session it delegates to the same engine — and inside an explicit transaction it reads that transaction’s consistent snapshot, so a read-modify-write over vector results sees one stable view:

await using var session = store.OpenSession();
if (session.Store.SupportsVector) // capability is a store property; reach it via session.Store
{
await using var tx = await session.BeginTransaction(); // relational providers
var hits = await session.NearestVectors<Document>(queryEmbedding, k: 10);
// …mutate + session.Add(...); await session.SaveChanges(); all in one transaction
await tx.Commit();
}

The contract is “matches the Where predicates AND is in the top-k nearest”. The order of operations is provider-dependent and documented per backend below.

  • CosmosDB, pgvector, SQL Server, Oracle, Atlas, DuckDB — pre-filter inside the ANN search. Result count == k unless fewer documents match.
  • SQLite (sqlite-vec) — ANN candidates are produced first, then the predicate is applied. The library pulls k * sqlite.postFilterMultiplier candidates so the post-filter doesn’t starve the result set (default multiplier 4).

Wire Microsoft.Extensions.AI.IEmbeddingGenerator<string, Embedding<float>> so the vector is populated automatically when a text property is set. Auto-embed is a write interceptor, so it runs on every provider (relational and the document-native stores — CosmosDB, MongoDB, Redis, …), inside the write’s transaction, on Insert, BatchInsert, and Upsert, before the document is serialized.

There are two overloads:

DI overload (recommended) — resolves the generator per write from the caller’s scope (ctx.Services), so a scoped session yields the caller’s own generator (per-tenant / per-scope model selection just works). Register the generator in DI:

using Shiny.DocumentDb.Extensions.AI;
services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(/* e.g. AddOpenAIClient(...).AsEmbeddingGenerator() */);
services.AddDocumentStore(opts =>
{
opts.MapVectorProperty<Document>(d => d.Embedding, dimensions: 1536)
.AutoEmbedOnInsert<Document>(
sourceSelector: d => d.Content,
targetSetter: (d, vec) => d.Embedding = vec,
targetGetter: d => d.Embedding); // optional — skip when vector already set
});

Explicit-generator overload — pass a fixed instance for the container-free new DocumentStore(options) path (or when you always want one specific model):

opts.MapVectorProperty<Document>(d => d.Embedding, dimensions: 1536)
.AutoEmbedOnInsert<Document>(
generator, // fixed IEmbeddingGenerator instance
sourceSelector: d => d.Content,
targetSetter: (d, vec) => d.Embedding = vec,
targetGetter: d => d.Embedding);

Skip rules:

  • If sourceSelector returns null or "", it is a no-op (the vector stays at default).
  • If targetGetter is provided and the existing vector is non-empty, it is a no-op — explicit writes win.

With the DI overload, if no IEmbeddingGenerator can be resolved from the write’s scope, the write throws InvalidOperationException with a clear message (register one in DI, or use the explicit overload) rather than silently writing an empty vector.

VectorIndexOptions exposes the common ANN knobs plus a ProviderHints dictionary for the long tail.

opts.MapVectorProperty<Document>(
d => d.Embedding,
dimensions: 1536,
metric: VectorDistance.Cosine,
indexKind: VectorIndexKind.Hnsw,
configureIndex: i =>
{
i.HnswM = 16;
i.HnswEfConstruction = 64;
i.HnswEfSearch = 40;
i.IvfLists = 100;
i.ProviderHints["sqlite.postFilterMultiplier"] = 8;
i.ProviderHints["atlas.indexName"] = "my-vec-index";
i.ProviderHints["atlas.numCandidates"] = 200;
});

Recognized hints:

Hint Type Provider Default
sqlite.postFilterMultiplier int SQLite 4
atlas.indexName string MongoDB Atlas vector_index_{type}
atlas.numCandidates int MongoDB Atlas 10 * k

Unknown keys are silently ignored per provider.

CREATE EXTENSION IF NOT EXISTS vector runs idempotently on every connection. Each (documents-table, document-type) pair gets its own sidecar table with a vector(n) column and an HNSW or IVF index when configured.

  • Operators used per metric: <=> (cosine), <-> (L2), <#> (negative inner product), <+> (Hamming).
  • Pre-filter via JOIN against the documents table — your Where(...) clause is translated to SQL and runs at the planner level alongside the ANN ordering.
  • Per-query SET LOCAL hnsw.ef_search is emitted when VectorIndexOptions.HnswEfSearch is set.

Sidecar table with a VECTOR(n) column. VECTOR_DISTANCE('cosine' | 'euclidean' | 'dot', col, @v) powers the ranking, and CREATE VECTOR INDEX ... WITH (METRIC = ..., TYPE = DISKANN) is attempted under a TRY/CATCH so older SQL Server versions degrade to sequential scan rather than failing table init. Hamming throws.

Sidecar table with a native VECTOR(n, FLOAT32) column. VECTOR_DISTANCE(col, TO_VECTOR(@v), COSINE | EUCLIDEAN | DOT) powers the ranking — Hamming throws (it is for binary vectors). The query vector is bound as a JSON-array string and parsed by TO_VECTOR; long embeddings cross the VARCHAR2 bind limit and are sent as CLOB, which TO_VECTOR also accepts.

  • VectorIndexKind.HnswCREATE VECTOR INDEX ... ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE <metric> PARAMETERS (TYPE HNSW, NEIGHBORS …, EFCONSTRUCTION …).
  • VectorIndexKind.Ivf... ORGANIZATION NEIGHBOR PARTITIONS DISTANCE <metric> PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS …).
  • Index creation is wrapped in a PL/SQL exception handler — Oracle’s vector indexes require the vector_memory_size pool, so where it isn’t configured the index is silently skipped and queries fall back to an exact sequential scan.
  • FETCH APPROX FIRST k ROWS ONLY is emitted only when an index kind is requested (so Oracle can use the index); VectorIndexKind.None uses an exact FETCH FIRST k ROWS ONLY. Pre-filter via the JOIN back to the documents table. See Oracle › Vector.

CosmosDB — embedding policy + VectorDistance()

Section titled “CosmosDB — embedding policy + VectorDistance()”

The container’s VectorEmbeddingPolicy and IndexingPolicy.VectorIndexes are configured on first touch from the mapped properties. The query is a standard Cosmos SQL SELECT TOP @k ... ORDER BY VectorDistance(...) with the score returned as a projection. Pre-filter via your Where(...) predicate translated by the existing Cosmos expression visitor.

MongoDB — $vectorSearch aggregation (Atlas only)

Section titled “MongoDB — $vectorSearch aggregation (Atlas only)”

The query is built as a two-stage aggregation: $vectorSearch with path, queryVector, numCandidates, limit, and a filter clause for the type-name match, then $project to surface the document body and the vectorSearchScore meta.

Atlas requires a pre-existing vector search index. Use atlas.indexName to point at an existing index (default convention is vector_index_{type}). On-prem MongoDB throws NotSupportedException with a clear message — $vectorSearch is an Atlas feature.

The vss extension is auto-loaded on every connection alongside json. The sidecar table stores FLOAT[N] arrays, optionally indexed with HNSW. Distance functions: array_cosine_distance, array_distance (L2), array_inner_product (negated for ORDER BY ASC semantics). HNSW persistence on file-backed DBs uses the hnsw_enable_experimental_persistence flag.

Two sidecar tables per type — a vec0 virtual table and an integer-rowid map (vec0 indexes only integer rowids, mirroring the R*Tree spatial pattern). The sqlite-vec extension is loaded on every connection via SqliteConnection.LoadExtension(VectorExtensionPath).

  • Extension loading is opt-in via SqliteDatabaseProvider.EnableVectorExtension = true. The user must ship the native binary; the loader searches the standard OS paths and the app directory.
  • vec0 has no HNSW — searches are flat-scan. When a Where(...) filter is in play, the library asks vec0 for k * postFilterMultiplier candidates and then filters in the JOIN.
Operation Vector sync
Insert / Update / Upsert Extracts the mapped vector and upserts into the sidecar
BatchInsert Vector upserts run inside the same transaction so a vector write failure rolls the batch back
Remove Deletes the sidecar row
Clear Truncates the sidecar for the type
IDocumentSession Vector queries inside a unit throw NotSupportedException — run them against the store

A vector field that’s left at default (zero-length ReadOnlyMemory<float>) is skipped on the write path — the document is stored without an embedding. Dimension mismatches throw ArgumentException with the document Id and expected/actual dimension so the failure is easy to diagnose.