尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

PDF到JSON:基于.NET Core与Semantic Kernel的智能抽取方案

发布时间:2026/9/29 15:51:07

资讯中心
01
ARTICLE

PDF到JSON:基于.NET Core与Semantic Kernel的智能抽取方案

PDF到JSON:基于.NET Core与Semantic Kernel的智能抽取方案
1. 项目概述为什么偏偏是这三样东西凑在一起先聊个我自己的经历。上个月接了个需求客户给了一堆几百页的PDF合同要我把里面的关键字段全部抽出来填进业务系统的表单里。用正则写PDF的文字层时好时坏有的扫描件直接让你抓瞎。用现成的解析库解析结果是纯文本离“结构化业务字段”还差着十万八千里。最后我选的技术路线就是标题这组组合.NET Core 做服务底座PDF解析库搞定内容抽取Semantic Kernel 接大模型做语义理解和字段映射。整套方案跑通之后原本需要人工录一天的合同现在几分钟出结果准确率还比我预想的高不少。为什么说这是“终极方案”核心在于它同时解决了两个问题PDF解析的格式混乱问题和非结构化文本到结构化数据的语义鸿沟问题。传统方式里前者靠正则和模板后者靠人工写规则两样都是又脆又死。而 Semantic Kernel 作为微软推出的AI编排框架把大模型接入到.NET应用里变得非常自然不需要你另起炉灶搞Python服务整个链路能在同一个技术栈里闭环。这篇文章适合谁我假设你是.NET 背景、想在大模型落地上车但还没找到下手点的开发者。你会在这篇文章里看到一条完整的、可落地的技术路径包括我的踩坑记录和最终能跑的代码片段。提示整篇文章基于我实际项目中的方案演进部分代码做了脱敏和简化但核心思路完全一致。扫描件PDF会涉及OCR我会在第5节单独说明先把最常见、性价比最高的文本型PDF主链路讲透。2. 方案选型PDF解析层与AI编排层的搭配逻辑2.1 PDF解析库怎么选PdfPig、iText7 还是 DocnetPDF解析是整个链路的地基。地基不牢后面AI再聪明也会被垃圾输入带偏。我的选择标准有三条跨平台、好拿文本、能容忍不完美的PDF。库跨平台社区活跃度文本抽取质量备注PdfPig是纯.NET中等好自带字符定位轻量适合无依赖场景iText7是高很好但商业许可严格企业商用要注意AGPL条款Docnet是中等依赖Native库部署略复杂基于PDFium渲染能力强我最终选了PdfPig。原因很实际它是纯托管代码不依赖任何本机DLL容器化部署的时候少掉一批头发。虽然它在处理带复杂绘图元素的PDF时会丢掉一些视觉信息但对于“抽取正文和表格文字”这个需求绰绰有余。另一个备选是Docnet如果你后面要拼OCR或者要渲染PDF成图片做多模态输入可以考虑它代价是部署镜像时要多塞几个原生库。2.2 Semantic Kernel 在.NET生态里的定位很多.NET开发者一听“大模型应用”就往Python那边想这其实是被惯性带偏了。Semantic Kernel 就是微软官方给.NET生态准备的AI编排层它把“模型对话”“函数调用”“记忆管理”“计划执行”这些概念都封装成了熟悉的.NET组件。你可以把它理解成大模型是一匹烈马SK是缰绳而你的.NET程序是骑手。具体到我们这个项目SK解决三个问题统一封装不同模型通道不管是OpenAI还是Azure OpenAI甚至是本地部署的模型通过OpenAI兼容接口它都用差不多的方式调用切换供应商时不用改业务代码。结构化输出的天然支持新版SK对response_format这类参数有封装配合严格的System Prompt能极大提高模型返回合法JSON的概率。Function Calling的托管如果解析过程中需要动态决定下一步动作比如“这段文本太乱要重新用OCR”SK可以帮你把决策逻辑和工具调用串起来。2.3 架构思路管道式处理别做成大泥球整个方案我把它拆成了四段管道每一段只负责一件事PDF文件 → 文本抽取(PdfPig) → 语义结构化(Semantic Kernel) → JSON校验与后处理这个分层的价值在于每段都可以独立测试、独立替换。比如某天你发现PdfPig抽文本效果不好可以单独换掉这一层改动范围被锁死在接口边界内。如果你觉得GPT-4o成本太高可以只改SK那一段的模型配置其他代码纹丝不动。这是我在几个项目里反复吃亏后才总结出来的纪律——AI应用尤其容易写着写着变成一坨浆糊因为大模型的非确定性会让你的调试范围无限扩大如果模块边界不清晰你根本查不动问题。3. 核心细节解析提示词设计与结构化输出约束3.1 提示词不要直接问“请提取信息”要给它一张填表模板我见过太多人在这一步犯懒System Prompt就写一句话“请把PDF内容转成JSON”。大模型确实能给你吐一个JSON但字段名、嵌套结构、值的格式全都随缘。今天给你contract_no明天给你contractNumber后天给你合同编号下游系统分分钟被炸崩。正确的姿势是在System Prompt里给出目标JSON的完整模板并配上字段级说明。我在实战中用的Prompt结构一般长这样你是文档结构化专家。请从用户提供的文档文本中提取以下字段严格输出合法JSON。 目标JSON结构 { contract_no: string, 合同编号如果没有则为空字符串, contract_date: string, ISO8601格式日期如2025-01-15缺省为空, party_a: {name: string, credit_code: string}, party_b: {name: string, credit_code: string}, amount: number, 合同金额元如未知则为0, items: [{name: string, quantity: number, unit_price: number}] } 要求 1. 只输出JSON不输出任何解释文字。 2. 如果某个字段在文档中不存在按默认值处理不要编造。 3. 金额字段统一为数字类型千分位逗号必须去除。别小看这个模板它做了三件事定义字段名统一下游契约、定义默认值处理缺省、定义格式要求把钱转成数字。有了这三条你后端的反序列化才敢真正放心。3.2 温度、TopP 与 JSON Mode模型不是算命先生大模型生成文本天生有随机性但在结构化抽取场景里你恰恰要压制这种随机性。参数上我固定为Temperature 0让输出尽可能确定。实测下来温度调到0.2以上时偶尔会在数字上多出逗号、日期写法漂移0最稳。TopP 1或者跟随默认如果已经设了低温度TopP的作用就被稀释了不必并存过低。response_format json_objectOpenAI系模型专门为JSON输出设计的模式配合提示词里的“只输出JSON”能达到接近100%的JSON合法性。注意开启json_object模式时提示词里必须出现“json”字样否则可能直接报错。如果你用的是Azure OpenAI对应参数是response_format的扩展SK里一般通过OpenAIHttpClientHandler或请求模板注入。我见过不少人栽在“感觉输出偶尔不是纯JSON”这个坑上多数情况就是忘了开response_format。3.3 JSON Schema 校验AI也会一本正经地胡说八道模型就算输出了合法JSON不代表字段内容就一定是对的。比如合同日期可能被它格式化成2025年1月1日金额可能给成1,234,567.89。所以我在SK处理完输出之后永远跟一道硬校验。这里我推荐用JsonSerializer 手写校验逻辑的组合。先反序列化成强类型DTO再逐个字段做格式校正。光是依赖模型自觉不够因为你没法在提示词里穷举所有格式边界但代码可以。var json semanticResult.ToString(); var contract JsonSerializer.DeserializeContractDto(json, jsonOptions); if (!DateTime.TryParse(contract.ContractDate, out var dt)) { contract.ContractDate NormalizeDate(contract.ContractDate); // 自定义清洗逻辑 }这道后处理是把“AI输出”变成“业务可用数据”的最后一公里千万不能省。4. 实操过程从PDF到JSON的完整代码链路4.1 环境准备与项目初始化先建一个ASP.NET Core Web API项目或者一个控制台程序都行看你的宿主环境。我一般喜欢用Web API因为后续要接文件上传和异步任务接口。dotnet new webapi -n PdfToJsonService cd PdfToJsonService dotnet add package UglyToad.PdfPig dotnet add package Microsoft.SemanticKernel依赖就这两个非常干净。如果你走Azure OpenAI通道还要加上Microsoft.SemanticKernel.Connectors.AzureOpenAI或类似包具体名字跟随SK版本走。注意Semantic Kernel 的API更新很勤包版本之间的命名空间和扩展方法可能有差异。我的示例代码基于 SK 1.x 系列的写法你用更高版本时如果发现编译过不去大概率是包名或扩展方法变了优先去官方迁移文档查。4.2 第一步用 PdfPig 抽取PDF纯文本PdfPig的API非常直白。打开文档、逐页读词、拼装文本核心代码如下using UglyToad.PdfPig; using UglyToad.PdfPig.Content; public static string ExtractTextFromPdf(Stream pdfStream) { var sb new StringBuilder(); using (var document PdfDocument.Open(pdfStream)) { foreach (var page in document.GetPages()) { // 方式1按词拼接 foreach (var word in page.GetWords()) { sb.Append(word.Text).Append( ); } // 方式2保留换行信息如果版式影响语义分段 sb.AppendLine(); } } return sb.ToString(); }这里有个细节page.GetWords()返回的每个Word还带着位置信息和字体信息word.GlyphRectangle能拿到坐标。如果你做的是表格类PDF可以按坐标聚类重建行与列效果远好于纯文本流。但这个玩法会显著增加代码量前期建议先用简单拼接跑通发现问题再上坐标增强。还有一个容易踩的坑有些PDF的文本层虽然存在但是顺序是乱的尤其是多栏排版、页眉页脚混排那种。PdfPig是按内容流存储顺序读取的不一定跟人眼阅读顺序一致。遇到这种情况我的临时方案是直接裸文本丢给大模型让模型自己去理解语义顺序。大模型对乱序文本的容忍度比你想象中高得多尤其是GPT-4级别的模型只要不是太离谱它基本能还原出来。如果效果不理想再回到坐标聚类的路线。4.3 第二步配置 Semantic Kernel 对话客户端SK的核心对象是Kernel。初始化时把模型通道注册进去后续所有调用都走这个入口using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; var builder Kernel.CreateBuilder(); builder.AddOpenAIChatCompletion( modelId: gpt-4o-mini, apiKey: 你的API_KEY, orgId: null ); var kernel builder.Build(); var chat kernel.GetRequiredServiceIChatCompletionService();如果是Azure OpenAI通道写法类似把AddAzureOpenAIChatCompletion接上endpoint、deploymentName、apiKey三个参数即可。密钥千万别硬编码在代码里走环境变量或配置文件这是基本素养但我在不少朋友的项目里看到过把key写死在cs文件里的情况都是隐形炸弹。由于前端代码在最终部署时会直接暴露这里的你的API_KEY仅用于本地调试正式环境务必通过AddEnvironmentVariables()或用户机密注入。4.4 第三步构造Prompt并调用模型前面第3节我给出了Prompt的结构示例。在代码里我把System Prompt抽成独立的模板文件方便调整Prompt时不用动代码。这是我这套方案里很关键的一个小设计——Prompt是会被频繁迭代的塞在代码字符串里每次改都要发版太痛苦了。var systemPrompt File.ReadAllText(Prompts/contract_extract_system.txt); var chatHistory new ChatHistory(systemPrompt); chatHistory.AddUserMessage($以下是PDF文档抽取出的原始文本\n\n{extractedText}); var result await chat.GetChatMessageContentAsync(chatHistory, executionSettings: new OpenAIPromptExecutionSettings { Temperature 0, ResponseFormat json_object }); var jsonText result.Content;这里ResponseFormat json_object是OpenAI系的关键帧。SK 1.x 中它通过OpenAIPromptExecutionSettings暴露你在其他模型比如走Ollama本地模型时这个参数可能要换成别的方式需要注意。4.5 第四步JSON解析与清洗模型返回的result.Content在json_object模式下通常已经是合法JSON但仍建议加一道防御——用C#的正则或字符串判断截掉可能出现的Markdown代码块围栏。为什么因为即便开了json_object模式模型在极少数情况下可能返回带围栏的伪JSON某些代理层还会往响应里加调试信息。var cleanedJson TrimJsonEnclosure(jsonText); // 自己实现截取第一个{到最后一个}然后做反序列化 字段清洗var options new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive true }; var contract JsonSerializer.DeserializeContractDto(cleanedJson, options);ContractDto就是我下游业务要用的强类型。这里把弱类型JSON转换成强类型DTO是关键一步之后业务的每个字段都有了编译期检查不用到处写魔法字符串。4.6 完整接口示例文件上传 异步任务处理实际业务中PDF不会只来一个所以我给接口加了异步任务队列的思路。接口先接收文件落盘到临时目录写入任务表后台Worker去消费。这一步用的是IHostedServiceChannelT零外部依赖跑单机完全够用。[HttpPost(upload)] public async TaskIActionResult Upload(IFormFile file) { var tempPath Path.Combine(_env.WebRootPath, temp, ${Guid.NewGuid()}.pdf); await using (var fs File.Create(tempPath)) { await file.CopyToAsync(fs); } var taskId await _jobService.EnqueueAsync(tempPath); return Accepted(new { taskId }); } [HttpGet(result/{taskId})] public async TaskIActionResult GetResult(string taskId) { var (status, json) await _jobService.GetAsync(taskId); return status switch { JobStatus.Done Ok(json), JobStatus.Running StatusCode(202), _ NotFound() }; }EnqueueAsync内部就是往Channel里写一条消息Worker从Channel读出来逐条处理。用Channel做内存队列的好处是实现简单、不用引入Redis之类的中间件。缺点也明显进程重启后队列丢任务。如果要求不丢任务还是要落到数据库表里配合轮询或者消息队列。我这边是内部工具容忍重启丢任务所以才用这么简的方案。5. 扫描件与图片型PDF加一层OCR兜底前面主链路要求PDF本身有文本层。但现实中总有扫描件混进来你在PdfPig里抽出来的文本可能是空字符串或者搜遍全文一个字都get不到。这个时候必须上OCR。我的方案是先用文本抽取结果判断PDF是否可读如果文本量低于某个阈值自动切换OCR通道。判断阈值我用的是“每页字数 10 即判定为扫描件”。这个数不是拍脑袋来的我拿测试集试过低于10个字的页面基本可以确定没有有效文本层。OCR的实现我选了PaddleOCR走Python微服务或者Tesseract走Native封装。在纯.NET环境下Tesseract 有Charon或Tesseract的.NET绑定但部署要打语言包。我的妥协方案是把OCR做成独立的旁路服务用HTTP调用主服务保持.NET纯净。理由还是那个能不开新坑就不开。OCR之后的文本直接复用第4节的那条SK链路根本不用改后续代码。这一点也是管道式设计的好处——OCR只是替换了“抽取层”后面语义结构化的逻辑完全透明。提醒OCR抽出来的文本质量再烂也尽量保持原始字符顺序。因为大模型的分词和语义理解对顺序敏感尤其是扫描件里表格混排的情况如果OCR层自作聪明地排序反而会打乱模型对结构的判断。6. 常见问题与排查技巧实录6.1 模型返回的JSON总带Markdown代码块现象result.Content开头多了json结尾多了。直接JsonSerializer.Deserialize会抛异常错误提示还特别像JSON本身坏了容易让人误判。排查先打印result.Content原文肉眼看看。如果是围栏问题写个简单的清理函数去掉首尾非JSON字符private static string TrimJsonEnclosure(string raw) { var start raw.IndexOf({); var end raw.LastIndexOf(}); return start 0 end start ? raw.Substring(start, end - start 1) : raw; }根因分析多数情况是某些代理网关私自往响应里加内容或者模型虽然开了json_object但历史消息里有Recap信息干扰。清理函数治标如果频繁出现去检查网关配置是否改写了响应。6.2 抽取到的日期五花八门模型可能把2024年3月5日、2024-03-05、2024.3.5全给你来一遍。我的做法是加一道标准化函数private static string NormalizeDate(string raw) { raw raw.Trim(); var patterns new[] { (\d{4})年(\d{1,2})月(\d{1,2})日, (\d{4})[/-](\d{1,2})[/-](\d{1,2}), (\d{4})\.(\d{1,2})\.(\d{1,2}) }; foreach (var p in patterns) { var m Regex.Match(raw, p); if (m.Success) { return ${m.Groups[1].Value}-{int.Parse(m.Groups[2].Value):00}-{int.Parse(m.Groups[3].Value):00}; } } throw new FormatException($无法识别日期格式: {raw}); }教训不要指望提示词能覆盖所有日期格式。模型是按“语义”理解日期的不是按“格式”解析的。后处理层做格式标准化才是稳定路径。6.3 金额被模型加了千分位逗号1,234,567.89这种字符串直接反序列化成decimal会失败。在DTO里给字段加[JsonConverter(typeof(DecimalJsonConverter))]或者在JsonSerializerOptions里配置自定义Converter。我的优先方案是后处理凡是数字字段先把逗号去掉再进行解析。public static decimal ParseAmount(string raw) { var cleaned raw.Replace(,, ).Replace(元, ).Trim(); return decimal.TryParse(cleaned, out var val) ? val : 0m; }6.4 PDF文本顺序混乱导致模型理解错误现象模型返回的party_a.name和party_b.name对调了或者表格里的数量与单价错位。排查打开PDF原文对比抽取文本。如果确认是文本流顺序问题最简单的绕路是——给模型提供带坐标信息的文本让模型根据坐标组装表格。我在实践中会输出类似word.Text [ x , y ]的增强文本模型对坐标的利用能力虽然不如专用表格解析但在多数布局简单的场景下够用。6.5 大文件PDF超时如果PDF有几百页裸文本可能十几万字符一次请求塞给GPT-4o-mini容易触发上下文超限或费用爆炸。我的策略是按页分批处理、最后合并var pageTexts ExtractTextByPage(pdfStream); // 返回 Liststring foreach (var pageText in pageTexts.TakeWhile(...)) { var result await ExtractPage(pageText); allResults.Add(result); }合并天然存在字段归属问题——同一份合同的甲方可能在第一页出现乙方在第二页。我的妥协做法热身页前3页用一个全局抽取Prompt后续页面只做增量补充最后用一个合并Prompt收口。这个方案整体准确率会略降但换来的是对超大文件的可用性工程上完全值得。6.6 Semantic Kernel 版本升级后API不兼容SK 1.x 里面Kernel.CreateBuilder()已经取代了早期版本的IKernel而很多老教程还在用旧写法。如果你看到SemanticFunction这种类型说明你看的教程至少是1.0之前的版本。处理方式只有一个升级依赖以你项目实际引用的包版本为准官方仓库的MigrationGuide是优先参考。我自己的体会是SK 这东西API变动太快不要背API要背架构概念。知道Kernel是编排入口、知道有ChatCompletionService负责对话、知道可以注入ExecutionSettings控制输出就够了具体方法名直接查NuGet源码或XML注释。7. 个人经验总结这套方案还能怎么扩展在几个项目里滚过一圈之后我对这套“PDF → AI → JSON”管道的评价是它真正解决的是“最后一公里”的语义理解问题而不是简单的文本转换。很多业务方一开始以为他们要的是工具其实他们要的是“一个能把人从重复读文档中解放出来的系统”。后续如果要扩展我建议优先考虑三件事把它包装成团队可以自助使用的内部平台。像我们定制了字段模板后业务侧可以在界面上新增一种文档类型、拖拽定义字段后台动态生成Prompt。这让方案的复用价值翻了倍而不再是一堆一次性脚本。引入模板确认机制。对高频、结构固定的文档比如某银行的制式合同把模型输出的结果跟人工确认后的模板存起来下次同类型文档直接套模板成本和准确率都更可控。考虑多模型容灾。SK支持切换模型供应商我在生产环境留了GPT-4o-mini做主力、本地的Qwen2.5-VL做备用的切换开关。大模型服务不可用的时候至少系统还有兜底能力不至于全链路瘫痪。再提醒一句结构化抽取的质量上限很大程度取决于提示词设计和后处理规则模型选型反而没那么关键。我在同一条链路上从GPT-3.5切到GPT-4o准确率提升很明显但切换到Qwen系列时只要把提示词细节调一调差距也没那么大。所以别迷信“换个更贵的模型一切就好了”把管道各层的脏活处理好才是这个方案真正值钱的地方。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。