用 C# 串起生产级 RAG 的关键环节
教程覆盖 .NET RAG 从文档解析、OCR、分块到混合检索、重排序、上下文压缩、缓存与评测的完整链路。为各项技术提供 C# 示例,并讨论 GraphRAG、纠错式 RAG 等方案何时值得投入。
AI 深度解读
RAG 做到「切块、生成向量、检索、交给模型回答」之后,为什么还是会答错?作者把问题拆到了更靠前的位置:文档有没有解析对,索引有没有及时更新,权限有没有跟着变化。高级 RAG 的起点,是查清失败发生在哪一层。
原文列出了混合检索、重排、GraphRAG 等完整路线,但提供的内容在 HTML 解析代码开头截断。这里能展开判断的,是文档摄取与解析;后续技术只有路线说明,没有足够实现和评测证据。
先修哪一层,要由失败样本决定
基础 RAG 的流程很短:提取文本、切块、生成 embedding、做 top-k 向量检索,再把结果放进提示词并验证回答。面对干净文档和简单问题,它已经可能够用。
问题出在真实资料上。扫描 PDF 没有文本层,表格的含义依赖行列关系,网页混着导航和广告,用户的问题又可能模糊、多段、依赖对话历史。此时换一个生成模型,未必能修好证据缺失。
作者给出的扩展路线包含查询改写与扩展、关键词加向量的混合检索、Reciprocal Rank Fusion(RRF)、cross-encoder 重排、父子块检索、上下文压缩,以及证据不足时的纠正与重试。每项技术都对应某类故障,也会增加延迟、token、摄取费用或维护复杂度。
先诊断,再增加技术,是这篇最有价值的原则。
原文把第 19 节 benchmarking 放在决策中心:先建基线,测出失败,再加一种技术并重测。不过,当前材料没有给出评测集、对照结果或收益数字。它是一份工程方法指南,不能据此认定某种组合效果最好。
知识库入库,为什么需要一条数据流水线
作者把摄取流程拆成来源检测、原始文件暂存、解析、清洗与补充元数据、切块、生成向量、写入索引和登记状态。重点是让知识库能够持续维护。
例如,同一文件反复提交,索引状态应该一致;文件内容没变,就不该再次消耗 OCR 和 embedding 费用;源文档删除后,旧块也必须删除,否则系统还会引用已经下线的资料。
为此,登记表需要记录内容 hash、解析器版本、切块器版本、embedding 模型、状态和错误。原始文件与中间结果也应保留,便于重新切块时复用,避免重复下载和 OCR。
示例通过统一数据模型隔离文件格式,让下游切块只依赖 ParsedDocument:
public enum ElementType { Title, Heading, Paragraph, ListItem, Table, Code, Image, Header, Footer }
public record DocumentElement(
ElementType Type, string Text, int Level = 0, int? Page = null,
IReadOnlyDictionary<string, string>? Attributes = null);
public record ParsedDocument(
string DocId, string Source, string Title, string Language,
IReadOnlyList<DocumentElement> Elements, IReadOnlyDictionary<string, string> Metadata);
public record DocumentContext(string DocId, string Source, string FileName, string ContentType, int TenantId, IReadOnlyList<string> AclGroups);
public interface IDocumentParser
{
bool CanParse(string contentType, string fileName);
Task<ParsedDocument> ParseAsync(Stream content, DocumentContext context, CancellationToken ct);
}
public record Chunk(
string Id, string DocId, int Index, string Text, string SectionPath, int? Page,
string? ParentId = null, string? PrevId = null, string? NextId = null);
public interface IChunker { IReadOnlyList<Chunk> Chunk(ParsedDocument document); }
关键看 IChunker 的入参:它接收统一文档,而不是 PDF 或 Word 文件流。新增格式主要改解析器,调整切块策略也不用重写文件读取逻辑。页码、章节路径、父子关系和相邻块关系,则为引用定位及后续扩展上下文留了位置。
编排示例使用 Microsoft.Extensions.AI 的 IEmbeddingGenerator,模型为 text-embedding-3-small,流水线版本为 parse-3/chunk-5,每批 BatchSize = 64。它把 SectionPath 和正文一起送入 GenerateAsync,让短段落获得章节语境。这些都是示例配置,没有证据表明适合所有语料。
运行层面,作者要求限制并发,避免压垮 OCR 和 embedding 服务;单份坏文件单独标记失败;摄取放在后台队列中执行。权限变更则更新已有块的 ACL 字段,无需重新生成向量。
文档能读出来,不代表适合检索
解析质量决定了检索能拿到什么证据。
PDF 的难点在于,它保存的是页面上字形的位置,不天然具备段落语义。PdfPig 能辅助提取文本,但多栏阅读顺序、重复页眉页脚和表格仍需处理。原文建议复杂版式考虑 Azure AI Document Intelligence 的 layout 模型;扫描件则需要 OCR。当前材料没有展开 OCR 实现。
Word 有更明确的结构,DocumentFormat.OpenXml 可以读取标题、段落、列表和表格。但标题样式不能只靠 Heading1 判断,本地化模板和自定义样式可能不同,还需解析样式显示名,并用大纲级别兜底。修订、批注、脚注是否保留,也要先制定规则,尤其要避免把内部批注直接送入知识库。
Excel 需要先判断用途。政策表、目录、价格表适合按记录检索;「按地区汇总收入」属于计算任务,作者建议使用 SQL 或受治理的数据查询工具。
表格进 RAG 时,每行都应带上列名和工作表名。示例使用 ClosedXML,通过 GetFormattedString() 读取显示值,并把一行改写成带字段名的文本。它假定已用区域的首行就是表头,实际还需处理多行表头、合并单元格、隐藏行以及公式缓存。示例中的价格 499、生效日期 2025-01-01,只是说明记录格式,不是业务结论。
HTML 则要优先提取 <main>、<article>,清除导航、页脚、广告等模板内容,同时保留标题层级、代码块和表格。否则每页重复的导航文字也会进入向量,干扰召回。
安全问题贯穿这些格式:白底白字、隐藏工作表、HTML 隐藏元素都可能夹带提示注入。作者要求按文件字节识别格式,限制文件大小、页数和解压体积,并把提取内容视为不可信数据。清理隐藏文字只是其中一步。
我的判断
我赞同作者把重点放在数据质量和索引维护上。对于团队文档问答,权限过期、源文件删除后仍可检索、表格丢失列名,都是具体且可检查的问题,比直接增加一层 agent 更容易确定改动是否有效。
但代码应当作为结构示意。作者也明确说明,示例省略了生产所需的错误处理、重试和遥测,相关库与服务 API 要按安装版本验证。
还有一个实现细节需要补齐:示例用内容 hash 配合 parse-3/chunk-5 判断是否重处理,清理旧块时却只按 keepContentHash 保留当前版本。同一内容只更换切块器时,hash 不变;如果新旧块并存,这个条件不足以识别旧块。实际设计应把流水线版本或索引代次纳入清理规则。
「先写新块,再删旧块」能减少文档短暂搜不到的窗口,但两次独立操作不能保证真正的原子切换。原文提出的蓝绿索引加别名切换,更适合模型或 schema 的整体迁移。
至于自建还是托管,作者给出了 Azure AI Search indexers、integrated vectorization 等替代方案。普通需求可以用托管能力减少运维;需要特殊结构切块、自定义补充信息或多存储索引时,自建才更有理由。原文没有成本和质量对照,这仍是经验判断。
能拿来做什么
- 为现有文档问答整理一组真实失败问题,逐条检查是解析遗漏、切块断句、检索漏召回,还是生成阶段忽略证据,再决定改哪一层。
- 在 Node 或 .NET 的摄取服务中保留标题、页码、表头和来源位置,同时记录内容 hash 与处理版本,让资料能够重建、追溯和迁移。
- 给团队知识库补上删除同步与权限更新机制,并验证旧文档撤回后不能继续被引用,权限变更能够传递到已有索引块。
- 遇到 Excel 问答时,先区分查记录和做统计;记录检索保留字段名,需要聚合计算的请求交给受控查询工具。