Microsoft.Extensions.AI embeddings .NET pipelines give me a focused boundary between application text and the vector values used for retrieval. That boundary is valuable because embedding generation appears in two places: when a corpus enters a system and when a user asks a question. I want both paths to use the same typed contract, while leaving caching, tracing, rate limits, and provider-specific setup at clear seams.
This article uses the stable embedding APIs documented for Microsoft.Extensions.AI.Abstractions 10.7.0. It is about producing and handling embeddings, not selecting a model or implementing vector-store operations. For the wider retrieval workflow, my RAG with Semantic Kernel guide remains useful framework-specific context.
Microsoft.Extensions.AI embeddings .NET start with one typed contract
An embedding is a vector that represents input in a numeric space. A retrieval system embeds document chunks, embeds a question, and gives those vectors to its retrieval layer. The embedding generator does not decide which documents are valid, how chunks are stored, or which answer is generated. For framework-specific vector-store implementation choices, see my Semantic Kernel vector-store guide.
The stable abstraction is IEmbeddingGenerator<TInput, TEmbedding>. In a text pipeline, the familiar shape is IEmbeddingGenerator<string, Embedding<float>>. TInput describes the values sent for embedding, and Embedding<float> exposes its vector through ReadOnlyMemory<float> Vector.
The 10.7.0 contract expects concurrent use and says instances must not be disposed while they are in use; it also warns callers not to share mutable EmbeddingGenerationOptions across concurrent calls unless they prevent mutation by the implementation (API reference).
I prefer to make that contract a dependency of a small application service. It keeps ingestion code honest about what it needs, and it lets a query path use the same abstraction without knowing which client performs inference.
using Microsoft.Extensions.AI;
public sealed record TextChunk(string Id, string Text);
public sealed record EmbeddedChunk(
string Id,
string Text,
ReadOnlyMemory<float> Vector);
public sealed class ChunkEmbedder(
IEmbeddingGenerator<string, Embedding<float>> generator)
{
public async Task<IReadOnlyList<EmbeddedChunk>> EmbedAsync(
IEnumerable<TextChunk> chunks,
CancellationToken cancellationToken)
{
var chunkList = chunks.ToArray();
GeneratedEmbeddings<Embedding<float>> embeddings =
await generator.GenerateAsync(
chunkList.Select(chunk => chunk.Text),
cancellationToken: cancellationToken);
return chunkList
.Zip(
embeddings,
(chunk, embedding) => new EmbeddedChunk(
chunk.Id,
chunk.Text,
embedding.Vector))
.ToArray();
}
}
The order matters. The method receives a collection of inputs and returns an embedding for each supplied value (GenerateAsync API reference). By retaining the source chunk IDs before calling the generator, the code can carry the returned vector forward with the exact chunk that produced it. I avoid treating vectors as anonymous float[] values that drift away from their input and corpus metadata.
The abstraction does not promise that document and query embeddings are compatible merely because they use float. That is an application contract. Store the embedding model identifier and the vector dimension with an indexed corpus, then use a query generator configured for that same representation. A different vector shape, or vectors generated under a different embedding contract, needs an explicit migration and re-indexing plan.
Batch generation keeps ingestion deliberate
Embedding calls have a boundary cost: work must leave the application, execute in an implementation, and return a result. GenerateAsync makes batching explicit, which is useful during ingestion because a set of chunks can travel through one operation while the application retains their identities.
Batching is not permission to build an unbounded list. I use a bounded batch size that is appropriate for the implementation and the input limits it documents. The embedding abstraction provides a collection operation. It does not publish one universal batch size, token policy, or retry policy.
Here is a compact batching helper. It avoids making a vector-store decision, but it gives an ingestion process a predictable sequence of calls and preserves cancellation.
using Microsoft.Extensions.AI;
public sealed class EmbeddingBatchProcessor(
IEmbeddingGenerator<string, Embedding<float>> generator)
{
public async Task<IReadOnlyList<ReadOnlyMemory<float>>> CreateVectorsAsync(
IEnumerable<string> texts,
int batchSize,
CancellationToken cancellationToken)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize);
var vectors = new List<ReadOnlyMemory<float>>();
foreach (string[] batch in texts.Chunk(batchSize))
{
GeneratedEmbeddings<Embedding<float>> generated =
await generator.GenerateAsync(
batch,
cancellationToken: cancellationToken);
vectors.AddRange(generated.Select(embedding => embedding.Vector));
}
return vectors;
}
}
This method has one important limitation: a list of vectors alone is not enough to index a corpus. Production ingestion also needs the text or a stable reference to it, chunk ordering, source identity, source revision, access metadata, and the embedding contract used to create the vector. The document Q&A application with Semantic Kernel shows an existing end-to-end tutorial; the pipeline boundary here is designed to make its embedding step independently understandable.
For a user question, a batch is usually unnecessary. GenerateVectorAsync expresses the one-input case directly and returns the raw vector value. The retrieval implementation can then receive a vector without being coupled to a provider client.
using Microsoft.Extensions.AI;
public sealed record EmbeddedQuery(
string Text,
ReadOnlyMemory<float> Vector);
public sealed class QueryEmbedder(
IEmbeddingGenerator<string, Embedding<float>> generator)
{
public async Task<EmbeddedQuery> EmbedAsync(
string question,
CancellationToken cancellationToken)
{
ReadOnlyMemory<float> vector = await generator.GenerateVectorAsync(
question,
cancellationToken: cancellationToken);
return new EmbeddedQuery(question, vector);
}
}
The ingestion path and question path should meet at the same compatibility boundary, not at a copied block of provider-specific code. That distinction becomes especially useful when a corpus must be re-embedded. The application can record the old embedding contract, build a new representation through the same generator boundary, validate retrieval behavior, and only then retire the old representation.
Prepare text and failures before the generator
The generator should receive text that has already passed the application rules for its source. That does not mean every pipeline needs an elaborate normalization framework. It means I decide where those rules live. For example, an ingestion path can reject an empty chunk, preserve the source revision that produced a chunk, and apply a documented text-preparation policy before any vector is created.
This matters because changing preprocessing changes the representation that is indexed. Collapsing whitespace, removing boilerplate, or changing how a document is split can all alter the input sent to the generator. I record a preparation version alongside the model identity, then treat a change to either one as a new embedding contract. The cache key example later in this article includes that version for the same reason.
The query path deserves an equally clear policy. A user question may be empty, exceed an application-defined request limit, or be cancelled because the caller disconnected. Those are application outcomes, not failures a vector store should have to interpret. Check them before calling the generator, pass the request cancellation token through, and return a controlled result when embedding cannot proceed.
I also avoid silently replacing a failed embedding with an arbitrary zero-filled vector. A placeholder vector looks valid to the next layer but does not represent the text that produced it. It can pollute an index during ingestion or generate irrelevant retrieval candidates during a query. Let the embedding operation fail with enough context for the caller to retry, record the failure, or keep that source revision out of the retrievable corpus.
For ingestion, that often means staging the derived work. Create chunks and vectors for a source revision, verify that every required embedding has returned, and only then let another component make the revision available for retrieval. The embedding abstraction does not define this transaction boundary, but its typed batch result makes the boundary straightforward to implement. A batch either gives the application a vector associated with every input or leaves the application free to keep the revision pending.
These practices also keep retries scoped. Retrying a transient generator failure should reuse the same text and embedding contract. Retrying after an operator changes preprocessing or model configuration is not the same operation. It creates a new representation that deserves new metadata and a deliberate evaluation pass.
Microsoft.Extensions.AI embeddings .NET pipelines add cross-cutting behavior
Embedding generation is a good place for cross-cutting behavior because every ingestion and query request passes through it. Microsoft.Extensions.AI supplies EmbeddingGeneratorBuilder<TInput, TEmbedding> for composing an implementation with decorators, including distributed caching and OpenTelemetry instrumentation.
The ordering of decorators has behavior. A cache wrapped outside tracing can leave a different trace footprint than tracing wrapped outside a cache. Neither ordering is universally right. What matters is deciding whether your telemetry should observe every request made by application code, only work that reaches the underlying generator, or both through separate signals.
The following factory receives the implementation and the cache as dependencies. It deliberately does not construct a provider client. That keeps credentials, endpoint configuration, and model naming in the composition root of the application.
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Caching.Distributed;
public static class EmbeddingPipeline
{
public static IEmbeddingGenerator<string, Embedding<float>> Create(
IEmbeddingGenerator<string, Embedding<float>> innerGenerator,
IDistributedCache cache,
EmbeddingContract contract,
string sourceName)
{
var cachedGenerator =
new DistributedCachingEmbeddingGenerator<string, Embedding<float>>(
innerGenerator,
cache)
{
CacheKeyAdditionalValues =
new object[]
{
contract.GeneratorName,
contract.ModelId,
contract.Dimensions,
contract.NormalizationVersion
}
};
return new EmbeddingGeneratorBuilder<string, Embedding<float>>(
cachedGenerator)
.UseOpenTelemetry(sourceName: sourceName)
.Build();
}
}
DistributedCachingEmbeddingGenerator uses CacheKeyAdditionalValues to augment its cache key, so this pipeline includes each embedding-contract field in every key (v10.7.0 source). It caches completed embeddings, not in-flight requests, so concurrent misses may still duplicate work; the decorator is concurrently safe only when its IDistributedCache is (v10.7.0 source).
OpenTelemetry helps answer a different question: what happened during a particular embedding request? The builder can emit telemetry around generator activity, while application code can add its own operation-level context such as corpus revision or a non-sensitive batch count (Microsoft Learn). In v10.7.0, OpenTelemetryEmbeddingGenerator.EnableSensitiveData defaults to false unless OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true; explicitly set and review it because enabling it records potentially sensitive additional properties (v10.7.0 source).
If a system needs rate limiting or another policy that is not supplied by the builder, DelegatingEmbeddingGenerator<TInput, TEmbedding> is the documented extension point. It forwards calls to an inner generator and lets a focused decorator enforce one concern. The official documentation's rate-limiting example is a useful reference because it shows the override point without baking a particular provider into the policy.
Caching and telemetry need an embedding identity
It is tempting to cache only by input text. That works until the same text can be embedded under more than one contract. An explicit identity makes the operational boundary visible.
public sealed record EmbeddingContract(
string GeneratorName,
string ModelId,
int Dimensions,
string NormalizationVersion);
The configured cache decorator uses this contract rather than an unused application hash. The dimensions are included because a retrieval index normally expects a fixed vector shape. The model ID and normalization version are included because semantic representation can change even when a dimension count does not.
When I trace this pipeline, I want enough information to connect a request with its behavior without exposing the input. Useful signals can include the embedding contract identifier, number of values requested, vector dimension, cache outcome, and elapsed time. For broader instrumentation patterns, see my Semantic Kernel semantic search article, while keeping in mind that its framework-specific embedding API is separate from the Microsoft.Extensions.AI surface used here.
Where provider adapters belong
IEmbeddingGenerator<string, Embedding<float>> is an abstraction, not an embedding model or a network protocol. A concrete application still registers or creates an implementation that performs inference. The stable Microsoft.Extensions.AI 10.7.0 release includes Microsoft.Extensions.AI.OpenAI support built against OpenAI 2.11.0, according to its official release notes. Separately, the OpenAI .NET 2.12.0 release is marked as a stable release in its official release record.
Those release details are version-sensitive. They explain why I keep adapter registration at the application edge rather than scattering concrete clients through indexing and query classes. The core code above only asks for an IEmbeddingGenerator, so provider registration can be independently versioned and tested.
This boundary also avoids turning a general embedding pipeline into a product-selection article. The generator's metadata and the application's embedding contract are the right places to record what was actually used. Retrieval behavior should then be evaluated against the corpus and questions that matter to the application, rather than inferred from a package name.
Existing Semantic Kernel material remains useful when you are maintaining a Semantic Kernel implementation. My text embeddings with Semantic Kernel guide covers its ITextEmbeddingGenerationService API. In this article, I use the current Microsoft.Extensions.AI abstraction as the primary embedding boundary. I am not labeling the older interface formally deprecated because the current source checked for this article does not establish that status.
Frequently asked questions
What does IEmbeddingGenerator do in a .NET pipeline?
IEmbeddingGenerator<TInput, TEmbedding> accepts input values and asynchronously returns their embeddings. For text retrieval, IEmbeddingGenerator<string, Embedding<float>> gives application code a typed place to generate vectors without depending on a concrete provider client.
Should I call GenerateAsync for one question?
You can, but GenerateVectorAsync is the documented convenience helper for one input. It returns ReadOnlyMemory<float>, which is convenient when the next boundary accepts one query vector.
Does batching guarantee a particular request size?
No. GenerateAsync accepts a collection, but provider limits and input constraints are outside the generic contract. Bound batches in application code and validate the limits of the concrete implementation you register.
Can an embedding cache key use only the input text?
It can, but that risks returning a vector created under an earlier embedding contract; DistributedCachingEmbeddingGenerator supports adding that identity through CacheKeyAdditionalValues (v10.7.0 source). Include the model or generator identity and any text-preparation version so a changed representation gets a different cache entry.
Does UseOpenTelemetry log my document text?
OpenTelemetryEmbeddingGenerator.EnableSensitiveData defaults to false, unless OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true; explicitly setting and reviewing it is safer than relying on an assumed deployment default, because enabled telemetry can record sensitive additional properties (v10.7.0 source).
Is ITextEmbeddingGenerationService formally deprecated?
I do not make that claim here. The checked current sources establish the Microsoft.Extensions.AI embedding abstraction used in this article, but they do not establish a formal deprecation attribute or declaration for the legacy Semantic Kernel interface.
Keep the boundary small and observable
Microsoft.Extensions.AI embeddings .NET pipelines are easier to operate when embedding generation is a small, typed boundary. Batch chunk inputs while preserving their identities. Generate one query vector through the same contract. Decorate the generator for caching and telemetry, then version those concerns with the embedding representation they serve.
That leaves the rest of the system free to focus on its own responsibility: chunking prepares source text, retrieval finds eligible evidence, and generation explains that evidence. The embedding pipeline does one job, but it makes the surrounding RAG system easier to trace and evolve.

