はじめに
手書き文字や画像形式のPDFドキュメントから特定のデータを抽出する方法としてAzure AI Document IntelligenceのようなAI-OCRや、Microsoft FoundryでデプロイしたGPT-5.5のような生成AI-OCRといったアプローチがあると思います。
2026年6月に発表され、OCR性能においてGPT-5.5やGeminiを凌駕すると噂のOCR特化のAIモデル「Mistral OCR」。
この強力なモデルがMicrosoft Foundryから手軽に利用できるようになったらしいので、精度確認も兼ねて.NETのデスクトップアプリからセキュアに呼び出す方法を調べてみました。
ドキュメントデータ抽出は、実際の業務アプリにおいても、例えばPDFで届いた請求書から情報を抽出して、業務アプリのフォームに値を自動的に入力したり、バックグラウンドで自動処理するようなタスクで活用されていくと思います。
Mistal OCR4とは
- Mistral AI社が2026年6月に発表した最新の高性能な文書解析(ドキュメント・インテリジェンス)モデルで、OCR性能ではGPT-5.5やGeminiより高性能と話題です。
- 高度な構造化抽出: 単なる文字起こしにとどまらず、文書内の表、複雑な数式、署名、タイトルのほか、テキストの正確な位置(バウンディングボックス)まで識別してMarkdownやJSON形式で出力できます。
- 圧倒的な多言語対応と高精度: 日本語を含む170言語に対応しており、従来のOCRや他社の主要な文書解析システムを上回る高い読み取り精度と、信頼度スコアによる確認の効率化を実現しています。
- そしてAzure Foundryでも利用可能です。
やりたいこと
- Microsoft FoundryでデプロイしたAIモデルMistral OCR4を使ってOCRする
- 開発環境は業務アプリで使うため.NET デスクトップアプリとする
- 認証方法はEntraID認証としてユーザー自身のアカウントを利用する
- APIキーはパブリッククライアントアプリには埋め込めない
- 事前にMicrosoft FoundryでAI Userロールを付与しておく
- アプリの使い方としてはユーザーが添付したPDFに対してユーザーが自然言語で指示するとjson形式で抽出できるようにする。
- Mistral OCRの応答形式はjson_objectとして、スキーマを厳密に定義しなくともユーザーが自然言語で指定したフィールドをjson出力できるようにしたい
Mistral OCRのドキュメント
Azure上でのデプロイ
Microsoft Foundryの画面でモデルを探してデプロイするだけです。
追加で、FoundryリソースのIAMで、利用者に対してFoundry Userロールを付与しておきます。

必要なパラメータ
エンドポイント
- APIエンドポイントは以下になります
https://your-resource.services.ai.azure.com/providers/mistral/azure/ocr - これを見つけるまでに時間がかかりました。Foundry画面は
.services.ai.azure.comまでしか書いてないし、Mistral公式の/v1/ocrも違います。他のmistralモデルの画面に正しいエンドポイントが書いてありました。
モデル名
-
mistral-ocr-4-0を指定 - Azure環境では
mistral-ocr-latestという指定は使えないようです。
リクエストボディ
- OCR対象のドキュメントをファイルから送信するにはdocument_urlとしてdatauriで指定するようです。
- 出力形式はdocument_annotation_formatで指定し、既定だとtext、オプションでjson_object、json_schemaに対応しているようです。
- 今回は
json_objectで回答させます。- json_objectを指定する場合は
document_annotation_promptパラメータにプロンプトを記述してjsonを出力するよう指示が必要です。 - json_objectを指定する例が見つからず苦労しましたがAPIからのエラーメッセージを見て何とか解決しました。 json_schemaパラメータは空でもいいので必ず指定が必要みたいです。
- json_objectを指定する場合は
var requestBody = new
{
model = "mistral-ocr-4-0",
document = new
{
type = "document_url",
document_url = $"data:{mimeType},{base64Image}",
document_name = fileName
},
document_annotation_prompt = prompt,
document_annotation_format = new
{
type = "json_object",
json_schema = new
{
name = "empty_schema",
strict = false,
schema = new { } // 空のスキーマを指定
}
}
};
プロンプト
こちらのOCRサービスの「1. 見積明細」のサンプルで試します。
https://aiocr.jp/example/table/
プロンプトは、ドキュメントの表部分を抽出するため以下のように記述します。
この書き方だとjson部分は変更しなくていいので一般ユーザーでも手軽に記述できます。
ドキュメントからデータを抽出して。
# 抽出ルール
- 値が見つからないフィールドはnullを格納
- 表データは全ての行を抽出すること
# 抽出するフィールド
## 番号
- 見つからない行はnullを格納すること
## 名称
- 見つからない行はnullを格納すること
## 数量
## 金額
# 出力形式
- 以下のjsonで出力して
- 列は抽出するフィールドに合わせて追加して
{
"value": [
{
"列1": "",
"列2": "",
}
]
}
コード
- .NETからMistral OCRを簡単に呼び出すSDKはまだないようなので自前で実装します。
- 他のAzureサービスSDKのように呼び出す場合はAzure.CoreのHttpPipelineを活用すると楽に実装できるようです。Copilotさんから教えてもらいました。
AzureMistralServiceクラス
- コンストラクタでは、エンドポイントとTokenCredential取得用に親ウィンドウのウィンドウハンドルを渡します。
- Webアプリの場合は引数を消して通常のInteractiveBrowserCredentialに変更してください。
- ExecuteOcrWithPromptメソッドでは、解析するファイルパスとプロンプトを指定します。
- 具体的なプロンプトは上記です。
internal class AzureMistralService
{
private readonly string _endpoint;
private readonly TokenCredential _credential;
private readonly HttpPipeline _pipeline;
private const string _tokenEndpoint = "https://ai.azure.com/.default";
private static readonly string[] scopes = new[] { "https://cognitiveservices.azure.com/.default" };
private JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions()
{
// JSONの snake_case を C#の PascalCase に自動マッピングする設定
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public AzureMistralService(string endpoint,nint parentWindowHandle)
{
_endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
// TokenCredentialの取得方法は後述。Azure.Identity.Brokerを使ってWAMによる認証を行うためウィンドウハンドルが必要です。
_credential = AzureIdentityHelper.GetCredential(parentWindowHandle);
// SDK標準のオプション(リトライやトランスポート、ログの設定)
var options = new MistralOcrClientOptions();
// HttpPipelineの構築
// 引数にcredentialとスコープを渡すことで、Bearerトークンを自動付与するポリシーが組み込まれます
_pipeline = HttpPipelineBuilder.Build(
options,
perRetryPolicies: Array.Empty<HttpPipelinePolicy>(),
perCallPolicies: new HttpPipelinePolicy[] { new BearerTokenAuthenticationPolicy(_credential, scopes) },//
responseClassifier: new ResponseClassifier()
);
}
/// <summary>
/// Mistral OCRを実行し、プロンプトに基づいてドキュメントのデータ抽出結果を取得します
/// MistralResult型で返します
/// </summary>
/// <param name="filePath"></param>
/// <param name="prompt"></param>
/// <returns></returns>
public async Task<ServiceResult<MistralResult>> ExecuteOcrWithPrompt(string filePath,string prompt)
{
// バリデーション
if (string.IsNullOrWhiteSpace(filePath))
{
return ServiceResult<MistralResult>.Failure("File path is null or empty.");
}
if (string.IsNullOrWhiteSpace(prompt))
{
return ServiceResult<MistralResult>.Failure("prompt is null or empty.");
}
if (!File.Exists(filePath))
{
return ServiceResult<MistralResult>.Failure($"File not found: {filePath}");
}
string extension = Path.GetExtension(filePath).ToLowerInvariant();
string? mimeType = extension switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
_ => null
};
if (mimeType == null)
{
return ServiceResult<MistralResult>.Failure($"Unsupported file type: {extension}. Only PDF, PNG, and JPG are supported.");
}
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
// ファイルの読み込み
using var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
bufferSize: 4096,
useAsync: true);
var binaryData = await BinaryData.FromStreamAsync(fileStream, cts.Token).ConfigureAwait(false);
var fileName = Path.GetFileName(filePath);
// リクエストの作成
using var request = _pipeline.CreateRequest();
// リクエストライン
request.Method = RequestMethod.Post;
var uriBuilder = new RequestUriBuilder();
uriBuilder.Reset(new Uri(_endpoint));
request.Uri = uriBuilder;
// リクエストヘッダー
request.Headers.Add(HttpHeader.Names.ContentType, "application/json");
request.Headers.Add(HttpHeader.Names.Accept, "application/json");
request.ClientRequestId = Guid.NewGuid().ToString();
// リクエストボディ
var base64Image = Convert.ToBase64String(binaryData);
var requestBody = new
{
model = "mistral-ocr-4-0",
document = new
{
type = "document_url",
document_url = $"data:{mimeType};base64,{base64Image}",
document_name = fileName
},
document_annotation_prompt = prompt,
document_annotation_format = new
{
type = "json_object",
json_schema = new
{
name = "empty_schema",
strict = false,
schema = new {} // 空のスキーマを指定
}
}
};
request.Content = RequestContent.Create(requestBody);
// リクエストの送信
var response = await _pipeline.SendRequestAsync(request, cts.Token).ConfigureAwait(false);
if(response == null)
{
return ServiceResult<MistralResult>.Failure("No response received from the OCR service.");
}
if (response.IsError)
{
return ServiceResult<MistralResult>.Failure($"{response.ReasonPhrase}\n{response.Content}");
}
using var reader = new StreamReader(response.ContentStream!);
var contentString = await reader.ReadToEndAsync().ConfigureAwait(false);
// レスポンスContentをDTOに変換
var mistralResult = JsonSerializer.Deserialize<MistralResult>(contentString,_jsonSerializerOptions);
if(mistralResult == null)
{
return ServiceResult<MistralResult>.Failure("Failed to deserialize the response content.");
}
// DTOに変換して返す
return ServiceResult<MistralResult>.Success(mistralResult);
}
/// <summary>
/// 参考:GetTokenAsyncを事前に実行してアクセス権があるか調べるためのメソッド
/// AccessTokenのJWTをパースすることで誰のアカウントでログインしているかも返します。
/// </summary>
/// <returns></returns>
public async Task<EntraAccount?> PreAuthorize(string tokenEndpoint = _tokenEndpoint)
{
// _credentialを使って事前に認証を行いTokenを取得する
TokenRequestContext tokenRequestContext = new(
new[]{
tokenEndpoint
},
new Guid().ToString()
);
CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
AccessToken token;
try {
token = await _credential.GetTokenAsync(tokenRequestContext,cts.Token);
}
catch (AuthenticationFailedException ex)
{
Console.Error.WriteLine($"Authentication failed: {ex.Message}");
return null;
}
catch (RequestFailedException ex)
{
Console.Error.WriteLine($"Key Vault request failed: {ex.Message}");
return null;
}catch (ClientResultException ex)
{
Console.Error.WriteLine($"Key Vault request failed: {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Unexpected error: {ex.Message}");
return null;
}
// decode jws in token
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token.Token);
return EntraAccount.FromJWT(jwt);
}
}
// ClientOptions を継承した独自のオプションクラスを用意する
public class MistralOcrClientOptions : ClientOptions
{
public MistralOcrClientOptions(ServiceVersion version = ServiceVersion.V1)
{
Version = version;
}
public enum ServiceVersion
{
V1 = 1
}
public ServiceVersion Version { get; }
}
AzureIdentityHelper
-
GetCredentialを呼び出すとユーザーのEntraIdを使ってTokenCredentialを作成します。
-
基本はこちらの記事と同じですがSharedTokenCacheCredentialが廃止されました
-
Azure.Identity及びAzure.Identity.Brokerのnugetパッケージが必要です。
-
WAM(Windows Account Manager)画面を表に出す場合があるので親ウィンドウのウィンドウハンドルが必要です。
- デスクトップアプリは公開クライアントです。機密クライアントのようにAPIキーやクライアントシークレットは使用できません。
- Azure.Identity内部のAzureCLIのアプリケーションIDとScopeを提示してEntraID認証を行います。
-
Azure.Identityの以前のバージョンはMSALのディスクキャッシュファイル内にIDTokenが保存されていましたが、現在のバージョンはセキュリティ対策のためかIDTokenは保存されなくなりました。
public static class AzureIdentityHelper
{
private static TokenCachePersistenceOptions _tokenCachePersistenceOptions = new();
/// <summary>
/// TokenCredential for interactive authentication with Azure.Identity library.
/// </summary>
public static TokenCredential GetCredential(nint handle)
{
// ChainedTokenCredentialを使用して複数の認証方法を試す
TokenCredential credential = new ChainedTokenCredential(
// WAMのデフォルトアカウントで認証する
new InteractiveBrowserCredential(
new InteractiveBrowserCredentialBrokerOptions(handle)
{
TokenCachePersistenceOptions = _tokenCachePersistenceOptions,
UseDefaultBrokerAccount = true,
}
),
new InteractiveBrowserCredential(
// デフォルトアカウントでダメな時は他のアカウントで認証する
new InteractiveBrowserCredentialBrokerOptions(handle)
{
TokenCachePersistenceOptions = _tokenCachePersistenceOptions,
}
)
);
return credential;
}
}
MistralResult型
- レスポンスのJSONから自動生成したコンテナクラスです。JSONからマッピングします。
- 型が不明な部分はコメントアウトしています。
internal class MistralResult
{
public List<Page> Pages { get; set; } = new List<Page>();
public string Model { get; set; } = string.Empty;
public string DocumentAnnotation { get; set; } = string.Empty;
public UsageInfo? UsageInfo { get; set; }
}
public class UsageInfo
{
public int PagesProcessed { get; set; }
public int DocSizeBytes { get; set; }
}
public class Page
{
public int Index { get; set; }
public string Markdown { get; set; } = string.Empty;
//public object[] images { get; set; }
//public object[] tables { get; set; }
//public object[] hyperlinks { get; set; }
//public object header { get; set; }
//public object footer { get; set; }
public Dimensions? Dimensions { get; set; }
//public object confidence_scores { get; set; }
//public object blocks { get; set; }
}
public class Dimensions
{
public int Dpi { get; set; }
public int Height { get; set; }
public int Width { get; set; }
}
ServiceResult
- Serviceクラスの応答をResultパターンで返すためのクラス
public class ServiceResult<T>
{
public bool HasError { get; }
public string ErrorMessage { get; }
public T? Result { get; }
private ServiceResult(bool hasError, string errorMessage = "", T? entity = default)
{
HasError = hasError;
ErrorMessage = errorMessage;
Result = entity;
}
// Factory methods for creating success and failure results
public static ServiceResult<T> Success(T entity) => new ServiceResult<T>(false, "", entity);
public static ServiceResult<T> Failure(string errorMessage) => new ServiceResult<T>(true, errorMessage);
}
呼び出し方
初期化
private AzureMistralService _mistralService;
public MainWindowViewModel()
{
_mistralService = new AzureMistralService("https://{your-resource}.services.ai.azure.com/providers/mistral/azure/ocr", MainWindowHandle);
}
Mistral OCR4の実行
private async Task ExecuteMistral()
{
// ViewModelプロパティのバリデーション
//
// OCR実行
ServiceResult<MistralResult> serviceResult = await _mistralService.ExecuteOcrWithPrompt(ImageFilePath!,UserPrompt!);
if (serviceResult.HasError) {
_dialogService.Show(serviceResult.ErrorMessage, "OCRエラー", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
MistralResult mistralResult = serviceResult.Result!;
// 画面上に表示
ResponseText = ParseResponseJson(mistralResult.DocumentAnnotation);
}
MistralOCRのレスポンス例
{
"pages": [
{
"index": 0,
"markdown": "# 工 事 明 細 書\n\n| 番号 | 名称 | 仕様 | 数量 | 単位 | 単価 | 金額 |\n| --- | --- | --- | --- | --- | --- | --- |\n| 株式会社○×△新築工事 | | | | | | |\n| | 直接工事 | | | | | |\n| A | 建築工事 | | 1.00 | 式 | | 123,456,789 |\n| B | 電気工事 | | 1.00 | 式 | | 18,250,000 |\n| C | 機械設備工事 | | 1.00 | 式 | | 38,000,000 |\n| | 直接工事費計 | | | | | 179,706,789 |\n| D | 共通仮設工事 | 3.5か月 | 1.00 | 式 | | 2,500,000 |\n| | 純工事費計 | | | | | 182,206,789 |\n| E | 現場管理費 | 3.5か月 | 1.00 | 式 | | 3,456,700 |\n| | 工事原価 | | | | | 185,663,489 |\n| | 一般管理費 | | 1.00 | 式 | | 5,036,511 |\n| | 工事価格 | | | | | 190,700,000 |\n\n株式会社ASAHI ROBOT 建設",
"images": [],
"tables": [],
"hyperlinks": [],
"header": null,
"footer": null,
"dimensions": {
"dpi": 87,
"height": 719,
"width": 1018
},
"confidence_scores": null,
"blocks": null
}
],
"model": "mistral-ocr-4-0",
"document_annotation": "{\n \"value\": [\n {\n \"番号\": \"A\",\n \"名称\": \"建築工事\",\n \"数量\": \"1.00\",\n \"金額\": \"123,456,789\"\n },\n {\n \"番号\": \"B\",\n \"名称\": \"電気工事\",\n \"数量\": \"1.00\",\n \"金額\": \"18,250,000\"\n },\n {\n \"番号\": \"C\",\n \"名称\": \"機械設備工事\",\n \"数量\": \"1.00\",\n \"金額\": \"38,000,000\"\n },\n {\n \"番号\": \"D\",\n \"名称\": \"共通仮設工事\",\n \"数量\": \"1.00\",\n \"金額\": \"2,500,000\"\n },\n {\n \"番号\": \"E\",\n \"名称\": \"現場管理費\",\n \"数量\": \"1.00\",\n \"金額\": \"3,456,700\"\n },\n {\n \"番号\": null,\n \"名称\": \"直接工事\",\n \"数量\": null,\n \"金額\": null\n },\n {\n \"番号\": null,\n \"名称\": \"直接工事費計\",\n \"数量\": null,\n \"金額\": \"179,706,789\"\n },\n {\n \"番号\": null,\n \"名称\": \"純工事費計\",\n \"数量\": null,\n \"金額\": \"182,206,789\"\n },\n {\n \"番号\": null,\n \"名称\": \"工事原価\",\n \"数量\": null,\n \"金額\": \"185,663,489\"\n },\n {\n \"番号\": null,\n \"名称\": \"一般管理費\",\n \"数量\": \"1.00\",\n \"金額\": \"5,036,511\"\n },\n {\n \"番号\": null,\n \"名称\": \"工事価格\",\n \"数量\": null,\n \"金額\": \"190,700,000\"\n }\n ]\n}",
"usage_info": {
"pages_processed": 1,
"doc_size_bytes": 117608
}
}
おわりに
凶悪な手書き文字はネット上にサンプルがなく、日本語のOCR精度は試せなかったですが
職場では沢山ありそうなので試していきたいです。
追記
業務で試してみたのでメモ
手書き文字
汚い日本語の手書き文字ついては、下記の記事の通りGeminiが圧勝だと思います。
https://qiita.com/y_nakahira_prompt-x/items/6358b9389b8c020bcb68
実際に試した感覚でも、Gemini>GPT5.5>Mistral OCR4でした。
学習量の差でしょうか。Mistralは欧州産モデルですし、手書きには強くなかったですね。
活字
感覚ですが、複雑な帳票から自然言語でデータを抽出するタスクではMistral OCR4が強かったです。
忠実に指示を守ってデータを抽出してくれました。
GPT5.5レベルでもドキュメントを画像のまま見せると抽出できない場合があり、事前にOCRをかけたりしていましたが、Mistralは画像のままでも十分です。
ページ課金なのもいいところですね。
