はじめに
昨今、LLMやRAGの構築が盛んですが、実務で必ずぶち当たるのが「社内のドキュメントが画像形式のPDFやスキャンデータばかりで、テキストがうまく抽出できない」という問題です。
また、LLMに直接画像を読ませてデータ抽出するようなケースでは、ドキュメントが画像だとトークン消費が大きかったり、安価なモデルだと細かい文字の誤認識も少なくありません。
そこで見直したいのがAI-OCRです。
あまり知られていないですが、AI-OCRサービスであるAzure AI Document Intelligenceでは、出力オプション(AnalyzeOutputOption.Pdf)を使うと、OCR処理の後に画像形式のPDFや画像ファイル(JPEG/PNG等)を、テキストレイヤーが結合された「検索可能なPDF」へ簡単に変換できます。
これを前処理として挟むことで、以下のような絶大なメリットが得られます。
- RAGにおいては精度が劇的に向上する
- データ抽出のようなケースでは、検索可能PDFにしてからLLMに渡すことで、LLM-OCR(構造化データ抽出)のトークン節約と精度向上を両立できる
AI-OCRもまだまだ捨てたもんじゃないですね。
今回は、C# (.NET) でローカルのファイルをAI-OCRにかけて検索可能なPDFを生成するコードを紹介します。
主な機能と特徴
- 画像ファイルにも対応: PDFだけでなく、スキャンされた .jpg, .png, .tiff などの画像からでもテキストレイヤー付きのPDFを生成可能。
- 高精度なレイアウト認識: 独自のPDF再構築ロジック不要。Azure側が文字の位置情報を完璧にマッピングしたPDFを返してくれます。
- OCR+LLMの組み合わせにおいて、OCRテキストと座標情報をLLMに送るよりも、PDFに文字を埋め込んだファイルを送信する方がトークン消費の節約にもなります。
利用するサービス
- Azure AI Document Intelligence
- OCRが高速なことで知られている
prebuilt-readモデルを使用します- 現在のところreadモデルしかPDF出力に対応していないようです。
実装コード
- 以下が、画像やPDFをテキスト検索可能なPDF(BinaryData)に変換するサービスクラスのコードです
- 従来の
FormRecognizerに代わってAzure.AI.DocumentIntelligenceパッケージを使用します。 - 公式サンプルにありがちな Uri 起点ではなく、実務を想定してStream/BinaryData処理を行っています
- デスクトップアプリを想定するため、ユーザーのEntraIDを利用して認証します。
internal class AzureOCRService
{
DocumentIntelligenceClient _client;
public AzureOCRService(string endpoint, nint hwnd)
{
// WAMによるブローカー認証を使用します。
var credential = AzureIdentityHelper.GetCredential(hwnd);
_client = new DocumentIntelligenceClient(
new Uri(endpoint),
credential
);
}
// AnalyzeDocumentAsyncのOutputをPDFとしてSearchable PDFに変換するサンプルコード
public async Task<ServiceResult<BinaryData>> ConvertToSearchablePdfAsync(string filePath, string modelId = "prebuilt-read")
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"File not found: {filePath}");
// サンプルはURIだったが、Streamでも実行可能
BinaryData? pdfBinary;
try {
// ファイルがロックされている可能性があるため、共有モードで開いてBinaryDataに変換する
BinaryData binaryData = await LoadLockedPdfAsync(filePath);
// AnalyzeDocumentOptionsを作成
AnalyzeDocumentOptions options = new(modelId, binaryData);
// PDFに変換するためのオプションを追加
options.Output.Add(AnalyzeOutputOption.Pdf);
// ドキュメント分析を実行
var operation = await AnalyzeDocumentCoreAsync(options);
if(operation == null || !operation.HasValue) {
return ServiceResult<BinaryData>.Failure("Document analysis failed: Operation could not be started.");
}
// clientからPDFを取得するメソッドを実行
pdfBinary = await _client.GetAnalyzeResultPdfAsync(modelId, operation.Id);
}
catch (RequestFailedException ex) {
return ServiceResult<BinaryData>.Failure($"Request failed: {ex.Message} (Status: {ex.Status})");
}
catch (Exception ex) {
return ServiceResult<BinaryData>.Failure($"An error occurred: {ex.Message}");
}
return ServiceResult<BinaryData>.Success(pdfBinary);
}
/// <summary>
/// ファイルパスからOCRを実行(prebuilt-read)
/// </summary>
/// <param name="filePath">ローカルファイルパス</param>
/// <returns>抽出テキスト</returns>
public async Task<ServiceResult<AnalyzeResult>> AnalyzeDocumentAsync(string filePath, string modelId = "prebuilt-read")
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"File not found: {filePath}");
// サンプルはURIだったが、Streamでも実行可能
AnalyzeResult? result = null;
try {
// ファイルがロックされている可能性があるため、共有モードで開いてBinaryDataに変換する
BinaryData binaryData = await LoadLockedPdfAsync(filePath);
// AnalyzeDocumentOptionsを作成
AnalyzeDocumentOptions options = new(modelId, binaryData);
// ドキュメント分析を実行
var operation = await AnalyzeDocumentCoreAsync(options);
if(operation == null || !operation.HasValue) {
return ServiceResult<AnalyzeResult>.Failure("Document analysis failed: Operation could not be started.");
}
result = operation.Value;
}
catch (RequestFailedException ex) {
return ServiceResult<AnalyzeResult>.Failure($"Request failed: {ex.Message} (Status: {ex.Status})");
}
catch (Exception ex) {
return ServiceResult<AnalyzeResult>.Failure($"An error occurred: {ex.Message}");
}
return ServiceResult<AnalyzeResult>.Success(result);
}
public async Task<Operation<AnalyzeResult>?> AnalyzeDocumentCoreAsync(AnalyzeDocumentOptions analyzeDocumentOptions)
{
Operation<AnalyzeResult> operation;
try {
operation = await _client.AnalyzeDocumentAsync(WaitUntil.Completed, analyzeDocumentOptions);
}
catch (RequestFailedException ex) {
throw new Exception($"Request failed: {ex.Message} (Status: {ex.Status})", ex);
}
catch (Exception ex) {
throw;
}
return operation;
}
private async Task<BinaryData> LoadLockedPdfAsync(string pdfPath)
{
// 既に開かれているファイルと競合しないように共有モード(FileShare)を設定する
using var fileStream = new FileStream(
pdfPath,
FileMode.Open,
FileAccess.Read, // 自分自身は読み取り専用で開く
FileShare.ReadWrite, // 他のプロセスが「読み取り」「書き込み」中でも開くことを許可する
bufferSize: 4096,
useAsync: true);
// BinaryDataに非同期でロード
return await BinaryData.FromStreamAsync(fileStream);
}
}
AzureIdentityHelper.cs
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;
}
}