Skip to content
Go back

Microsoft.Extensions.AI 嵌入管道

IEmbeddingGenerator 给应用文本与检索用向量之间划了一条聚焦的边界。这条边界有价值,是因为嵌入生成出现在两个地方:语料进入系统时(摄取),以及用户提问时(查询)。两条路径应该共用同一个类型化契约,同时把缓存、追踪、限流和提供商专属配置留在清晰的接缝上。

Nick Cosentino(Dev Leader)这篇 2026 年 8 月的文章,基于 Microsoft.Extensions.AI.Abstractions 10.7.0 的稳定嵌入 API,讲的是产生和处理嵌入,不涉及选模型、也不实现向量库操作。配套的 RAG 工作流指南(Semantic Kernel 系列)是框架层面的补充读物。

适合:正在设计或维护 RAG 系统、想让嵌入步骤独立可理解、可替换的 .NET 开发者。读完你会得到:一个类型化嵌入边界、批量与单条生成的写法、缓存与遥测的组合方式,以及嵌入身份与兼容性怎么管理。

从一个类型化契约起步

嵌入是把输入表示成数值空间里的向量。检索系统嵌入文档块、嵌入问题,然后把向量交给检索层。嵌入生成器不决定哪些文档有效、块怎么存、答案怎么生成。

稳定的抽象是 IEmbeddingGenerator<TInput, TEmbedding>。文本管道里最常见的形态是 IEmbeddingGenerator<string, Embedding<float>>TInput 描述送去嵌入的值,Embedding<float> 通过 ReadOnlyMemory<float> Vector 暴露向量。

10.7.0 的契约要求并发使用、使用期间不得释放实例,并警告调用方不要在并发调用间共享可变的 EmbeddingGenerationOptions(除非实现保证不会修改它)。

把契约做成一个小型应用服务的依赖,会让摄取代码对自己真正需要什么保持诚实,也让查询路径用同一个抽象、不必知道具体是哪个客户端在推理:

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();
    }
}

顺序很重要。方法接收一组输入、为每个输入返回一个嵌入:在调用生成器之前保留源 chunk 的 ID,代码就能把返回的向量和产生它的那个 chunk 精确对应起来。不要把向量当成脱离输入与语料元数据漂移的匿名 float[]

抽象并不承诺「文档嵌入和查询嵌入因为都是 float 就兼容」——那是应用契约。把嵌入模型标识和向量维度与索引语料一起存储,再用配置为同一表示的查询生成器查询。不同的向量形状、或不同嵌入契约下生成的向量,需要显式的迁移与重新索引计划。

批量生成让摄取有节制

嵌入调用有边界成本:工作要离开应用、在执行方里跑、再返回结果。GenerateAsync 把批量显式化:摄取时一组 chunk 可以通过一次操作完成,应用同时保留它们的身份。

批量不是构建无界列表的许可。批次大小要有界,适配实现与它文档化的输入限制。嵌入抽象提供的是集合操作,不提供统一的批次大小、令牌策略或重试策略:

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;
    }
}

这个方法有一个重要局限:光有向量列表不足以上索引。生产摄取还需要文本或它的稳定引用、chunk 顺序、源身份、源修订、访问元数据和创建向量的嵌入契约。这个管道的边界设计,就是为了让嵌入步骤能独立被理解。

用户问题通常不需要批量。GenerateVectorAsync 直接表达单输入场景,返回原始向量值,检索实现收到向量即可,不必耦合任何提供商客户端:

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);
    }
}

摄取路径和问题路径应该在同一个兼容边界汇合,而不是各抄一段提供商专属代码。这个区别在语料需要重新嵌入时尤其有用:应用记录旧嵌入契约,通过同一个生成器边界构建新表示,验证检索行为,然后才退役旧表示。

在生成器之前处理文本与失败

生成器收到的文本,应该已经通过应用对来源的规则。这不意味着每条管道都需要一个复杂的规范化框架,而是你要决定这些规则住在哪里:例如摄取路径可以拒绝空 chunk、保留产生 chunk 的源修订、在任何向量创建前应用文档化的文本预处理策略。

这很重要,因为改变预处理就是改变被索引的表示。折叠空白、去掉样板文本、改变切分方式,都会改变送给生成器的输入。把预处理版本和模型标识一起记录,把两者的任何变更都当成新的嵌入契约——后面缓存键的例子也是这个原因。

查询路径同样需要清楚的策略。用户问题可能为空、超过应用定义的请求限制、或因为调用方断开被取消。这些是应用层结果,不该让向量库去解释。在调用生成器之前检查它们,把请求取消令牌传下去,嵌入无法进行时返回受控结果。

还要避免用全零向量悄悄替换失败的嵌入。占位向量在下一层看起来有效,但它不代表产生它的文本:摄取时污染索引,查询时生成无关候选。让嵌入操作带着足够上下文失败,调用方可以重试、记录失败、或让该源修订不进可检索语料。

摄取时通常意味着暂存派生工作:为一个源修订创建 chunk 和向量,确认每个所需嵌入都已返回,然后才让其他组件把修订提供给检索。嵌入抽象不定义这个事务边界,但它的类型化批量结果让边界实现起来很直接——一个批次要么给应用每个输入对应的向量,要么让应用把修订留在待定状态。

重试的范围也因此受限:重试瞬时生成器失败应该复用同一文本与嵌入契约;操作者改了预处理或模型配置后的重试不是同一操作——它产生新表示,需要新元数据和一次有意的评估。

横切行为:缓存与遥测

嵌入生成是放横切行为的好地方,因为每个摄取与查询请求都经过它。Microsoft.Extensions.AI 提供 EmbeddingGeneratorBuilder<TInput, TEmbedding> 组合实现与装饰器,包括分布式缓存和 OpenTelemetry 插桩。

装饰器的顺序有行为:缓存包在追踪外面,和追踪包在缓存外面,留下的追踪足迹不同。没有哪个顺序普遍正确,关键是决定你的遥测要观察应用代码发出的每个请求、只观察真正到达底层生成器的工作、还是通过独立信号两者都要。

下面的工厂把实现与缓存作为依赖收进来,刻意不构造提供商客户端——凭据、端点配置和模型命名留在应用的组合根:

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();
    }
}

DistributedCachingEmbeddingGeneratorCacheKeyAdditionalValues 扩充缓存键,所以这条管道把嵌入契约的每个字段都放进每个键。它缓存的是已完成的嵌入、不是进行中的请求,所以并发的缓存未命中仍可能重复工作;装饰器的并发安全取决于它的 IDistributedCache

OpenTelemetry 回答的是另一个问题:一次嵌入请求期间发生了什么。10.7.0 里 OpenTelemetryEmbeddingGenerator.EnableSensitiveData 默认 false,除非设置 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true。要显式设置并审查它——开启后可能记录敏感的附加属性。

需要限流或其他 builder 没提供的策略时,DelegatingEmbeddingGenerator<TInput, TEmbedding> 是文档化的扩展点:它把调用转发给内层生成器,让一个专注的装饰器强制一个关注点。

缓存与遥测需要嵌入身份

只按输入文本缓存很诱人,但同一段文本可能在多个契约下嵌入。显式身份让这个操作边界可见:

public sealed record EmbeddingContract(
    string GeneratorName,
    string ModelId,
    int Dimensions,
    string NormalizationVersion);

维度包含在内,因为检索索引通常期望固定向量形状;模型 ID 与规范化版本包含在内,因为即使维度数不变,语义表示也可能变化。

追踪时,作者希望有足够信息把请求和它的行为连起来,而不暴露输入:有用的信号包括嵌入契约标识、请求的值的数量、向量维度、缓存结果与耗时。

提供商适配器放在哪里

IEmbeddingGenerator<string, Embedding<float>> 是抽象,不是嵌入模型或网络协议。具体应用仍然要注册或创建做推理的实现。稳定版 10.7.0 包含基于 OpenAI 2.11.0 构建的 Microsoft.Extensions.AI.OpenAI;而 OpenAI .NET 2.12.0 已标记为稳定版。

这些版本细节是易变的,也正是把适配器注册放在应用边缘、而不是把具体客户端散落在索引与查询类里的原因。上面的核心代码只要求 IEmbeddingGenerator,提供商注册可以独立版本化与测试。

这条边界也避免把通用嵌入管道变成选型文章:生成器的元数据与应用定义的嵌入契约,才是记录「实际用了什么」的正确位置。检索行为应该针对应用真正关心的语料与问题来评估,而不是从包名推断。

常见问题

保持边界小而可观测

嵌入管道在操作上更容易,当嵌入生成是一条小而类型化的边界:批量处理 chunk 同时保留身份;用同一个契约生成单个查询向量;用缓存与遥测装饰生成器;把这些关注点和它们服务的嵌入表示一起版本化。

剩下的系统可以专注自己的职责:切分准备源文本、检索找合格证据、生成解释证据。嵌入管道只做一件事,但它让周围的 RAG 系统更容易追踪和演化。

Aide Hub 持续分享 AI 助手、开发工具与软件工程实践。想把这套边界用起来,可以从 ChunkEmbedder + QueryEmbedder 两个小服务开始,再按需要加 EmbeddingContract 与缓存装饰。

参考


Tags


Next

Codex 多代理编排:给 Sol 一个团队