Skip to content
Go back

.NET RAG 稳定架构:边界先于实现

RAG 和 embeddings 在 .NET 里最容易理解错的方式,是把它们当成一个聊天功能。它们实际上是一个以生成文本结尾的数据系统。一个有用的回答,依赖四件事依次成立:来源变成可信的 chunk、查询变成兼容的向量、检索尊重访问规则、最终回答展示它的证据。任何一条边界出错,系统都可以照样自信地输出——但内容可能是错的、过时的,或者越权的。

Nick Cosentino(Dev Leader)在 2026 年 8 月的这篇指南只讲架构层:刻意不选模型、不选向量数据库、不选编排框架。那些是实现决策,应该在契约、数据所有权和失败处理都清楚之后再做。如果你要框架级实现,他已有的 Semantic Kernel RAG 指南是配套读物;这篇要建立的是任何实现都必须保留下来的系统骨架。

这篇文章适合:正在设计或维护 RAG 系统、担心「换向量库就得重写」的 .NET 开发者。读完你会得到四条稳定边界、一份可编译的概念性 C# 契约,以及一套事故时可以问的问题。

两条路径:摄取与回答

RAG 的实际流程有两条完全不同的路径:

生成放在最后是有原因的:它不应该拥有身份、来源准入、文档生命周期或检索策略。应用应该能换掉模型客户端而不重写删除工作流,换掉索引而不丢失溯源。

这个分离也解释了为什么简单原型看起来「能工作」:硬编码的样本语料没有权限变化、没有畸形上传、没有索引滞后、没有过期缓存,也不需要解释为什么检索到某个结果。生产数据全都有。

摄取是经过审计的转换

摄取把源文档变成派生数据,而派生数据并不比源数据低一等——chunk、embedding、索引、响应缓存都能影响用户看到什么。

第一步是在分块之前分配持久的文档身份:保留源 URI 或仓库身份、修订版本、摄取时间戳、内容哈希、准入主体。然后把文档身份和授权元数据携带到每个 chunk 上——一个没有来源和访问范围的 chunk,是「等待变成安全 bug 的孤儿」。

OWASP RAG Security Cheat Sheet 明确要求:摄取时哈希文档、保留溯源、扫描内容、只准入批准来源。这些不是 embedding 调用旁边的装饰,而是让后续调查和删除成为可能的控制。

原文给出了一份 provider-neutral 的领域模型,所有例子合成一个可编译的 C# 10+ 概念文件。第一块是共享 using 和必须活过分块的溯源记录:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace RagArchitecture;

public sealed record SourceDocument(
    string Id,
    Uri SourceUri,
    string Title,
    string Version,
    DateTimeOffset IngestedAt,
    string IngestedBy,
    string Sha256,
    IReadOnlySet<string> AllowedPrincipals);

public sealed record DocumentChunk(
    string Id,
    string DocumentId,
    int Ordinal,
    string Text,
    SourceDocument Source);

public static class DocumentIntegrity
{
    public static string CreateSha256(string content)
    {
        return Convert.ToHexString(
            SHA256.HashData(Encoding.UTF8.GetBytes(content)));
    }
}

分块是检索设计决策,不是 SDK 复选框:chunk 要保留足够多的局部语义来回答问题,又要足够小,让检索集不至于挤掉答案。边界策略、重叠策略、提取器版本都应该是显式摄取配置。架构层面的要点更简单:改分块策略等于产生一个新的派生语料,推广之前需要评估。

摄取 worker 应该先暂存(stage)文档:验证类型和来源、规范化文本、计算哈希、分配元数据,然后只有整个文档成功后才让它的 chunk 可检索。这避免了「半本新手册和半本旧手册并存可搜索」的尴尬状态,也给删除一个稳定键:源文档 ID。

Provider-neutral 的嵌入边界

嵌入生成器接收文本、产生向量。向量搜索拿查询向量和索引向量比较,所以文档与查询的表示必须和索引配置兼容(Azure AI Search 文档明确这一点)。如果系统要改这个契约,迁移必须是刻意的:版本化嵌入模型 → 重新嵌入受影响的 chunk → 重新评估检索 → 旧表示安全后才退役

所以 embeddings 放在一个本地边界后面:应用拥有文本准备、模型版本记录、重试策略和是否索引的决策;provider 实现只拥有远程或本地推理调用。Microsoft 的 IEmbeddingGenerator<TInput, TEmbedding> 是一个有用的集成缝,但应用契约不应依赖框架特定的类型。

public sealed record EmbeddingVector(
    string ModelId,
    ReadOnlyMemory<float> Values)
{
    public int Dimensions => Values.Length;
}

public interface IEmbeddingGenerator
{
    Task<EmbeddingVector> GenerateAsync(
        string text,
        CancellationToken cancellationToken);
}

public sealed record AccessScope(IReadOnlySet<string> PrincipalIds);

public sealed record RetrievalRequest(
    EmbeddingVector QueryVector,
    AccessScope Access,
    int MaximumResults);

注意这里缺了什么:provider 客户端、API key、用户提供的 tenant filter。这些关注点真实存在,但属于各自的边界。访问范围必须由服务端从已认证的调用者推导,而不是信任浏览器在 JSON 里带的 tenant ID。

向量只是检索信号之一。精确标识符、产品代码、日期和名字通常需要词法匹配。混合检索(hybrid)组合词法与向量候选生成,重排序(reranking)是在候选集上的后期精度步骤——它们解决不同的问题,不要因为某个存储产品暴露了一个 SearchAsync 方法就把它们压成一个概念。架构应该能分别表示:词法检索、稠密检索、融合、元数据过滤、可选二阶段 reranker。

围绕检索结果和引用构建

面向回答生成的 RAG 系统,检索应该产出带证据的结果而不是纯文本:选中了哪个 chunk、来自哪个来源修订、在哪个访问范围下、分数多少。生成器可以消费文本,但应用必须在 prompt 之外保留这些证据。

这对调试至关重要:坏回答可能来自没有相关 chunk、排序太弱、过滤过宽、来源修订缺失、或者综合得不好。没有类型化的检索结果,每一种失败看起来都像「模型编造了答案」。

public sealed record RetrievedChunk(DocumentChunk Chunk, double Score);

public interface IChunkIndex
{
    Task ReplaceDocumentAsync(
        SourceDocument document,
        IReadOnlyList<IndexedChunk> chunks,
        CancellationToken cancellationToken);

    Task<IReadOnlyList<RetrievedChunk>> SearchAsync(
        RetrievalRequest request,
        CancellationToken cancellationToken);

    Task DeleteByDocumentIdAsync(
        string documentId,
        CancellationToken cancellationToken);
}

public sealed record IndexedChunk(
    DocumentChunk Chunk,
    EmbeddingVector Vector);

public sealed record Citation(
    string DocumentId,
    string ChunkId,
    Uri SourceUri,
    string Title,
    string Version,
    string Sha256);

引用是契约,不是渲染细节:它让 UI 能显示来源链接,让运维能回放一次响应,让审查者能确认回答引用的修订确实是实际检索到的那份。这比要求模型在散文里编造脚注强得多。

Microsoft 的安全多租户 RAG 架构指南把访问边界说得很明确:身份流过请求路径,只有授权的 grounding 数据能到达模型;存储前面放 API 层,让授权和过滤不散落在应用各处。这正好映射到 AccessScopeIChunkIndex:索引实现返回候选之前先强制作用域。

回答组装契约保持显式

回答组装边界保留「用户问题 → 选中的 chunk → 生成文本 → 返回引用」之间的链接。它不决定检索到的 chunk 是否可信,也不授予授权——那些决策发生在来源准入和检索阶段。

public sealed record UserQuestion(string Text);

public sealed record GeneratedAnswer(
    string Text,
    IReadOnlyList<string> SupportingChunkIds);

public interface IAnswerGenerator
{
    Task<GeneratedAnswer> GenerateAsync(
        UserQuestion question,
        IReadOnlyList<RetrievedChunk> evidence,
        CancellationToken cancellationToken);
}

public sealed record RagAnswer(
    string Text,
    IReadOnlyList<Citation> Citations);

public interface IRagAnswerResult;

public sealed record GroundedRagAnswer(RagAnswer Answer) : IRagAnswerResult;

public sealed record AnswerWithheld(string Reason) : IRagAnswerResult;

public interface IAnswerComposer
{
    Task<IRagAnswerResult> ComposeAsync(
        GeneratedAnswer generated,
        IReadOnlyList<RetrievedChunk> evidence,
        CancellationToken cancellationToken);
}

两个契约细节值得强调:

授权、安全与删除是检索需求

授权必须在 prompt 构造之前完成。每个 chunk 都需要访问控制元数据,检索边界用服务端推导的作用域强制执行。文本进入 LLM 上下文之后再过滤已经太晚——OWASP 要求访问控制在内容到达模型之前完成。模型不能是「调用者能不能看这个 chunk」的权威。

检索到的文本是数据,不是指令。OWASP 建议:清晰分隔检索内容、限制 chunk 的数量和总大小、扫描可疑内容、验证输出、保留完整管道痕迹。这些控制降低风险,但不会让 prompt injection 变得不可能——所以来源准入、最小权限和输出控制要协同工作。

embeddings 值得和源文本同样的对待。embeddings 不是匿名数据(OWASP 明确提醒),需要与来源等效的访问控制。向量索引是安全敏感的派生存储:写入只给摄取服务,查询只能穿过检索边界。

删除闭环:当来源被移除、取代或失去权限时,删除它的 chunk、embeddings 和缓存响应,并保留审计事件。IChunkIndex 的删除操作很小,但它代表一条触及所有派生工件的工作流——一个缺失的源文档,不能因为它的 embedding 被遗忘而仍然可检索

让质量与运维在用户报告之前可见

评估可观测性分开:

评估要分开做:检索可能漏掉需要的 chunk,可能返回太多无关上下文,也可能证据没问题但生成器写出了无依据的回答。用一小批人工评审的问题(带预期文档或 chunk ID),比一个宽泛的基准分数更早暴露这些失败。评估数据也有生命周期:语料修订会让预期的 chunk ID 失效,即使应用代码没变——每个测试用例都要记录语料修订。

追踪 ID 应该连接摄取、检索、生成和删除,但默认不要把原始敏感 prompt 或文档放进遥测。目标是可回放的证据,而不是第二个无治理的语料副本

这套架构在事故时给你一组可回答的问题:这个 chunk 被批准了吗?哪个来源修订产生了它?检索发生时调用者有访问权吗?回答限于检索到的证据吗?缓存是否跨越了权限变化?这些问题之所以可回答,是因为系统保留了需要的契约。

常见问题

RAG 和 embeddings 的最小架构是什么? 至少要有:来源溯源、chunk 身份、嵌入边界、带服务端强制访问范围的检索、回答边界、引用。在语料变得难以推理之前,加上显式删除和遥测路径。

embeddings 能取代关键词搜索吗? 不能。embeddings 检索概念相似性,关键词检索保留精确词法信号。它们是独立的候选生成器,必要时再加融合策略。

每个回答都要存引用吗? 存储构造引用所需的证据(来源、chunk、修订、哈希)。是否展示给用户是产品决策,但保留证据让验证和运维成为可能。

可以在模型看到文本之后再强制授权吗? 不能作为保护机密性的控制。授权要在检索和 prompt 构造之前完成,生成后控制只是次要防线。

源文档变更或删除怎么办? 用源文档 ID 找到所有派生 chunk 和向量,删除或重建,使权限作用域的缓存失效,记录结果。把这条工作流当作摄取设计的一部分,而不是事后清理。

有引用就自动是事实吗? 不是。引用只显示检索到了哪些证据,不证明检索完整、来源最新、或生成措辞忠实于来源。评估和来源治理仍然必要。

稳定的心智模型

RAG 里持久的部分不是一个包调用,而是一条责任链

批准来源 → 版本化 chunk → 兼容 embedding → 授权检索 → 检索证据 → 引用回答 → 来源变更 → 完整删除

先建这些边界。然后模型客户端、向量存储、混合策略、reranker 或框架都可以演化,而不至于把整个系统变成一次重写。

参考


Tags


Next

用 Claude 重构遗留 .NET:不是重写是工作流