1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

昨今、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;
    }
}


1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?