In this post, let's have a look at how we can classify a document that
contains multiple document types, and route each part to a different
analyzer, using Azure AI Content Understanding and the .NET SDK.
Say you receive a single PDF that contains an invoice, a bank statement and
a loan application, all combined together, something like this:
Now you want each part identified, and you want different
fields extracted from each. Content Understanding supports exactly
this: a classifier splits the file into segments, and each category
can optionally point at its own analyzer that does the field
extraction.
|
|
| Combined Document |
Let's have a look on how to achieve this.
We are going to create two custom analyzers, one for
invoices and one for loan applications. Then we are going to create a
classifier with three categories, where the third one
(Bank_Statement) is deliberately left without an analyzer. That last
bit turns out to be the most interesting part of the whole exercise.
First, the packages (versions are the latest as of today, they will change).
dotnet add package Azure.AI.ContentUnderstanding dotnet add package Azure.Identity
You will need a Microsoft Foundry resource in a supported region,
with the required models deployed and set as defaults, and the
Cognitive Services User role assigned to yourself. That role is
needed even if you own the resource.
using Azure; using Azure.AI.ContentUnderstanding; using Azure.Identity; const string Endpoint = "https://<your-foundry-resource>.services.ai.azure.com"; const string DocumentUrl = "https://github.com/Azure-Samples/azure-ai-content-understanding-python/raw/refs/heads/main/data/mixed_financial_docs.pdf"; var credential = new DefaultAzureCredential(); ContentUnderstandingClient client = new(new Uri(Endpoint), credential); var suffix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var loanApplicationAnalyzerId = $"loan_application_analyzer_{suffix}"; var invoiceAnalyzerId = $"invoice_analyzer_{suffix}"; var classifierId = $"classifier_{suffix}"; var completionModel = "gpt-5.2";
Now the first custom analyzer for Invoice. Nothing fancy here, just a field schema
describing what we want out of an invoice.
// Invoice analyzer. ContentAnalyzer invoiceAnalyzer = new() { BaseAnalyzerId = "prebuilt-document", Description = "Extracts key fields from invoices", Models = { ["completion"] = completionModel }, FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition> { ["InvoiceNumber"] = new() { Type = ContentFieldType.String, Method = GenerationMethod.Extract, Description = "Invoice identifier." }, ["VendorName"] = new() { Type = ContentFieldType.String, Method = GenerationMethod.Extract, Description = "Name of the vendor issuing the invoice." }, ["TotalAmount"] = new() { Type = ContentFieldType.Number, Method = GenerationMethod.Extract, Description = "Invoice total including tax." }, }) }; await CreateAnalyzer(client, invoiceAnalyzerId, invoiceAnalyzer);
Now the second analyzer for Loan Application, same like before, just different fields.
// Loan application analyzer. ContentAnalyzer loanApplicationAnalyzer = new() { BaseAnalyzerId = "prebuilt-document", Description = "Extracts key fields from loan applications", Models = { ["completion"] = completionModel }, FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition> { ["ApplicantName"] = new() { Type = ContentFieldType.String, Method = GenerationMethod.Extract, Description = "Full name of the loan applicant." }, ["LoanAmountRequested"] = new() { Type = ContentFieldType.Number, Method = GenerationMethod.Extract, Description = "Total loan amount requested." }, ["LoanPurpose"] = new() { Type = ContentFieldType.String, Method = GenerationMethod.Generate, Description = "Stated purpose of the loan." }, }), }; await CreateAnalyzer(client, loanApplicationAnalyzerId, loanApplicationAnalyzer);
Next, the classifier. This is where the routing happens. Note
EnableSegment, which is what makes the service split a multi-document
file rather than treating it as one document, and AnalyzerId on each
category, which is what points a category at an analyzer.
// Classifier: Invoice and Loan application route to their analyzers, Bank statement is classified only. ContentAnalyzer classifier = new() { BaseAnalyzerId = "prebuilt-document", Description = "Splits a multi-type document and routes each part to its analyzer", Models = { ["completion"] = completionModel }, Config = new ContentAnalyzerConfig { EnableSegment = true, ContentCategories = { ["Invoice"] = new ContentCategoryDefinition { Description = "Billing documents requesting payment for goods or services, with line items, taxes and totals.", AnalyzerId = invoiceAnalyzerId, }, ["Loan_Application"] = new ContentCategoryDefinition { Description = "Requests for funding, including applicant details, financial history, loan amount and purpose.", AnalyzerId = loanApplicationAnalyzerId, }, ["Bank_Statement"] = new ContentCategoryDefinition { Description = "Statements summarizing account activity over a period, including deposits, withdrawals and balances.", // No AnalyzerId: this category is classified but not routed to an analyzer. } } } }; await CreateAnalyzer(client, classifierId, classifier);
And then we analyze. The sample PDF above conveniently contains all three
document types across four pages.
Operation<AnalysisResult> operation = await client.AnalyzeAsync( WaitUntil.Completed, classifierId, inputs: [new AnalysisInput { Uri = new Uri(DocumentUrl) }]); AnalysisResult result = operation.Value; // The first content is the whole file. Its segments show how the classifier split it, // including categories that were not routed to an analyzer. var wholeDocument = (DocumentContent)result.Contents![0]; Console.WriteLine($"Split into {wholeDocument.Segments?.Count ?? 0} segment(s):"); foreach (DocumentContentSegment segment in wholeDocument.Segments ?? []) { Console.WriteLine($" {segment.Category,-18} pages {segment.StartPageNumber}-{segment.EndPageNumber}"); } // Only categories with an AnalyzerId get their own content entry, with extracted fields. foreach (AnalysisContent content in result.Contents.Where(c => c.Category is not null)) { var document = (DocumentContent)content; Console.WriteLine($"\n{document.Category} (pages {document.StartPageNumber}-{document.EndPageNumber}) via {document.AnalyzerId}"); foreach ((string name, ContentField field) in document.Fields) { Console.WriteLine($" {name}: {field.Value ?? "(null)"}"); } }
And the output.
Split into 3 segment(s): Invoice pages 1-1 Bank_Statement pages 2-3 Loan_Application pages 4-4 Invoice (pages 1-1) via invoice_analyzer_1784529162 InvoiceNumber: INV-100 VendorName: CONTOSO LTD. TotalAmount: 110 Loan_Application (pages 4-4) via loan_application_analyzer_1784529162 ApplicantName: John Smith LoanAmountRequested: 25000 LoanPurpose: Debt Consolidation
Now here is the part that is easy to get wrong. Bank_Statement shows
up in the segment list, but it never appears in the second loop. That is not
a bug, it is how the response is shaped, and it is much clearer if we look
at the raw JSON.
{
// omitted: id, status
"result": {
"analyzerId": "classifier_1784529162",
"apiVersion": "2025-11-01",
// omitted: createdAt, stringEncoding, warnings
"contents": [
{
"path": "input1",
"markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
"startPageNumber": 1,
"endPageNumber": 4,
"unit": "inch",
"pages": [
// omitted: pageNumber, angle, width, height for each of the 4 pages
],
"segments": [
{
"segmentId": "segment1",
"startPageNumber": 1,
"endPageNumber": 1,
"category": "Invoice"
},
{
"segmentId": "segment2",
"startPageNumber": 2,
"endPageNumber": 3,
"category": "Bank_Statement"
},
{
"segmentId": "segment3",
"startPageNumber": 4,
"endPageNumber": 4,
"category": "Loan_Application"
}
],
"analyzerId": "classifier_1784529162",
"mimeType": "application/pdf"
},
{
"path": "input1/segment1",
"category": "Invoice",
"markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
"fields": {
"InvoiceNumber": {
"type": "string",
"valueString": "INV-100",
"spans": [
{
"offset": 90,
"length": 7
}
],
"confidence": 0.738,
"source": "D(1,7.4772,1.3993,8.0103,1.3987,8.0105,1.5459,7.4774,1.5465)"
},
"VendorName": {
"type": "string",
"valueString": "CONTOSO LTD.",
"confidence": 0.939
// omitted: spans, source
},
"TotalAmount": {
"type": "number",
"valueNumber": 110,
"confidence": 0.9
// omitted: spans, source
}
},
"startPageNumber": 1,
"endPageNumber": 1,
// omitted: kind, unit, pages, segments
"analyzerId": "invoice_analyzer_1784529162"
},
{
"path": "input1/segment3",
"category": "Loan_Application",
"markdown": "# Contoso Bank Loan Application Form\n...",
"fields": {
"ApplicantName": {
"type": "string",
"valueString": "John Smith",
"confidence": 0.981
// omitted: spans, source
},
"LoanAmountRequested": {
"type": "number",
"valueNumber": 25000,
"confidence": 0.745
// omitted: spans, source
},
"LoanPurpose": {
"type": "string",
"valueString": "Debt Consolidation",
"confidence": 0.675
// omitted: spans, source
}
},
"startPageNumber": 4,
"endPageNumber": 4,
// omitted: kind, unit, pages, segments
"analyzerId": "loan_application_analyzer_1784529162"
}
]
},
"usage": {
"documentPagesStandard": 4,
"contextualizationTokens": 4000,
"tokens": {
"gpt-5.2-input": 7710,
"gpt-5.2-output": 222
}
}
}
Look at the path values.
We have input1, input1/segment1 and input1/segment3.
There is no input1/segment2. The service numbered all three segments
in the parent content, then only emitted content entries for the two that
had an analyzer attached. The gap in the numbering is the giveaway.
I could not find this spelled out in the docs, but from what I can see the response has two layers, and you need both:
- Classification lives in Contents[0].Segments, and contains every category the classifier found, routed or not.
- Extraction lives in Contents[1..], and contains only the categories that had an AnalyzerId, each with its Fields.
One last practical note: analyzers are resources that live on your Foundry
resource until you delete them, so remember to clean them up.
// Classifier first: it references the two analyzers. foreach (var analyzerId in new[] { classifierId, invoiceAnalyzerId, loanApplicationAnalyzerId }) { try { await client.DeleteAnalyzerAsync(analyzerId); } catch (RequestFailedException ex) when (ex.Status == 404) { // Creation failed earlier, so let that exception surface instead of this one. } }
More read:
Hope this helps.
Happy Coding.
Regards,
Jaliya
