【検証】画像PDFを検索可能化してRAGデータソースに活用する

## この記事について
RAGの精度向上には、元データの品質改善が欠かせません。本記事では、画像PDFのようにそのままでは検索できないドキュメントに対して、検索可能なPDFへ変換する検証を行った内容を紹介します。

## 問題意識
画像PDFは、単純にアップロードするだけ(何も前処理を行わない)ではKendraやBedrock Knowledge Baseなどの検索サービスに同期できません。
そのため、これまではAmazon Bedrockなどを経由させ、基盤モデルを利用してOCRをかけ、テキストファイルに変換したうえで登録する、というプロセスを踏んでいました。

ただこの方法だと、以下のような課題があると感じるようになりました。
- ①元々あったPDFのページ数が失われてしまう
- ②構造的なOCRがなかなか難しい
- ③どこをどう読み取ったのか、という詳細の推論過程はブラックボックスになっている


そこで今回は、次の2点を意識して検証を進めることにしました。
①「画像PDF」→「検索可能なPDF」に蘇らせる（テキスト情報を埋め込む）
② より高精度なOCRエンジンを使用する



## 検証スタート
::: note info 
前提条件
RAGの仕組みの構築は、Amazon BedrockのKnowledge Base（以下KBと記載）を使用し、
ベクトルDBには、Pineconeを採用しました。
※ 検証を通して気づいたのですが、Pineconeの[Serverless index](https://docs.pinecone.io/guides/indexes/create-an-index)を用いれば、かなり費用を抑えられると思いました。（私はAWS Marketplace 経由で利用していたので Standardプラン）
検証を行う中で、まあまあ使っていましたが、0.003ドルくらいにしか費用がかさまなかったです。従量課金なのがメリットですね。
![スクリーンショット 2025-04-29 15.40.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/986b1de5-137b-4b48-a74e-e69ed5fa6d9d.png)

![スクリーンショット 2025-04-29 15.36.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/6a498d49-9083-4fa9-bfb5-c34f97f77381.png)


:::

### 検証で確かめること
- ページ数が正しく保持されること
    - OCR→PDFの過程で、正しくページ数を保持できているのか
        - 検索を行った際に、「何ページ目から抽出できたのか」という情報が正しく抽出できているかで確認
- OCR精度はいかほどか（期待する情報がある程度取れてくるか？）
    - 回答の正確性

### 検証に使用するドキュメント
今回は、OCRを実現するアプローチとして2つの策を考え、検証してみました。
検証に共通して、適当な画像をスキャンし、PDFにしたものをS3に配置し、データソースとして指定します。（`test-input.pdf`と名付けます）
![スクリーンショット 2025-04-27 12.16.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/9aa13def-e727-40e0-8edf-95d73cbd5109.png)

当然、このPDFは、検索ができる状況ではありません。
試しに、Amazon BedrockのKnowledge Baseを作成し、S3に配置した`test-input.pdf`と同期してみると、以下のように同期に失敗する旨のエラーが表示されています。
![スクリーンショット 2025-04-26 22.58.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/ca91c4cf-6de3-4819-9884-bc008f430746.png)

### 検証において使用したpythonスクリプト
:::note info
今回の検証で使用した全体のpythonスクリプトを以下に添付します。
[GitHub](https://github.com/y-mae-dev/convert-searcheble-pdf-sample)にも公開したので、自己責任の上ご利用ください。
各手順の部分で、かいつまんで説明します。
なお、処理の全体像の図を記事最下部（[処理イメージ](#処理イメージ))の項に記載しましたので、気になる方は参考になれば幸いです。

<details><summary>検証で使用したスクリプト</summary>

```python
import os
import sys
from pathlib import Path
from typing import List

import fitz
from google.cloud import documentai_v1 as documentai
from google.cloud.documentai_toolbox import document
from ocrmypdf import exceptions, hocrtransform, ocr

# サービスアカウントキーのパスを環境変数に設定
# NOTE 事前にスクリプトを実行するファイルと同じ階層に配置
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "XXXXXXX.json"

# プロジェクトID・ロケーション・プロセッサIDを設定
project_id = "your-project-id"
location = "us" #またはeu（プロセッサを作成したリージョン）
processor_id = "your-processor-id" #（作成したプロセッサID）


def process_pdf_by_ocrmypdf(input_pdf: str, output_pdf: str) -> None:
    """
    OCRmyPDFを使ってPDFに透明テキストレイヤーを付与する

    Args:
        input_pdf (str): 入力PDFファイルパス
        output_pdf (str): 出力PDFファイルパス
    """
    try:
        ocr(
            input_file=input_pdf,
            output_file=output_pdf,
            language="jpn",
            deskew=True,
            clean=True,
            progress_bar=True,
        )
        print(f"[INFO] OCRが正常に完了: {output_pdf}")
    except exceptions.ExitStatusException as e:
        print(f"[ERROR] OCRmyPDFエラー: {e}")


def split_pdf_page_by_page(input_path: str) -> List[str]:
    """
    PDFファイルを1ページずつ分割し、個別ファイルとして保存する

    Args:
        input_path (str): 入力PDFファイルパス

    Returns:
        List[str]: 分割後の各ページPDFファイルパスリスト
    """
    pdf = fitz.open(input_path)
    chunk_files = []

    base_name = os.path.splitext(input_path)[0]

    for i in range(len(pdf)):
        chunk_path = f"{base_name}_page{i + 1}.pdf"
        chunk_pdf = fitz.open()
        chunk_pdf.insert_pdf(pdf, from_page=i, to_page=i)
        chunk_pdf.save(chunk_path)
        chunk_pdf.close()
        chunk_files.append(chunk_path)

    pdf.close()
    return chunk_files


def convert_hocr_to_pdf(
    hocr_path: str, background_pdf_path: str, output_pdf_path: str, dpi: int = 300
) -> None:
    """
    hOCRファイルから透明テキストレイヤーを作成し、背景PDFと合成する

    Args:
        hocr_path (str): hOCRファイルパス
        background_pdf_path (str): 背景PDFファイルパス
        output_pdf_path (str): 出力PDFファイルパス
        dpi (int, optional): 解像度（デフォルト300）
    """
    ocr_only_pdf_path = str(Path(output_pdf_path).with_suffix(".ocr_only.pdf"))

    transformer = hocrtransform.HocrTransform(hocr_filename=Path(hocr_path), dpi=dpi)
    transformer.to_pdf(out_filename=Path(ocr_only_pdf_path))
    print(f"[INFO] 透明テキストPDF生成完了: {ocr_only_pdf_path}")

    merge_background_and_ocr(background_pdf_path, ocr_only_pdf_path, output_pdf_path)
    print(f"[INFO] 背景と透明テキストを合成完了: {output_pdf_path}")

    Path(ocr_only_pdf_path).unlink(missing_ok=True)


def merge_background_and_ocr(
    background_pdf_path: str, ocr_text_pdf_path: str, output_pdf_path: str
) -> None:
    """
    背景PDFと透明テキストレイヤーPDFを合成する

    Args:
        background_pdf_path (str): 背景PDFパス
        ocr_text_pdf_path (str): 透明テキストPDFパス
        output_pdf_path (str): 出力PDFパス
    """
    bg_doc = fitz.open(background_pdf_path)
    ocr_doc = fitz.open(ocr_text_pdf_path)

    for page_num in range(len(bg_doc)):
        bg_page = bg_doc[page_num]
        bg_page.show_pdf_page(bg_page.rect, ocr_doc, page_num)

    bg_doc.save(output_pdf_path, garbage=4, deflate=True)
    bg_doc.close()
    ocr_doc.close()


def process_document_with_docai(file_path: str) -> documentai.Document:
    """
    Document AIでPDFをOCR処理する

    Args:
        file_path (str): 入力PDFパス

    Returns:
        documentai.Document: OCR処理結果
    """
    client = documentai.DocumentProcessorServiceClient()
    name = f"projects/{project_id}/locations/{location}/processors/{processor_id}"

    with open(file_path, "rb") as f:
        content = f.read()

    request = documentai.ProcessRequest(
        name=name,
        raw_document=documentai.RawDocument(
            content=content, mime_type="application/pdf"
        ),
    )
    result = client.process_document(request=request)
    return result.document


def save_docai_response_to_json(
    document_obj: documentai.Document, output_path: str
) -> None:
    """
    Document AIレスポンスをJSONファイルとして保存する

    Args:
        document_obj (Document): Documentオブジェクト
        output_path (str): 出力JSONファイルパス
    """
    json_obj = documentai.Document.to_json(document_obj)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write(json_obj)


def convert_docai_response_to_hocr(
    docai_document: documentai.Document, title: str, document_path: str
) -> str:
    """
    Document AI JSONからhOCR形式に変換する

    Args:
        docai_document (Document): OCRドキュメント
        title (str): hOCRファイルタイトル
        document_path (str): Document AI JSONパス

    Returns:
        str: hOCRテキスト
    """
    wrapped_doc = document.Document.from_document_path(document_path=document_path)
    return wrapped_doc.export_hocr_str(title=title)


def merge_pdfs_with_pymupdf(pdf_files: List[str], output_path: str) -> None:
    """
    複数PDFファイルを結合する

    Args:
        pdf_files (List[str]): PDFファイルリスト
        output_path (str): 出力ファイルパス
    """
    merger = fitz.open()
    for pdf_file in pdf_files:
        merger.insert_pdf(fitz.open(pdf_file))
    merger.save(output_path)
    merger.close()


def make_searchable_pdf(input_path: str, output_path: str, use_docai: bool = True) -> None:
    """
    PDFをOCR処理して検索可能なPDFに変換する

    Args:
        input_path (str): 入力PDFパス
        output_path (str): 出力PDFパス
        use_docai (bool, optional): Document AIを使用するか（デフォルトTrue）
    """
    print(f"[INFO] 分割処理中: {input_path}")
    chunk_files = split_pdf_page_by_page(input_path)
    processed_chunks = []
    temp_files = []

    for i, chunk_file in enumerate(chunk_files):
        print(f"[INFO] ページ {i + 1}/{len(chunk_files)} 処理中...")

        base = os.path.splitext(chunk_file)[0]
        hocr_path = f"{base}.hocr.xml"
        json_path = f"{base}.json"
        output_chunk = f"{base}_processed.pdf"

        try:
            if use_docai:
                docai_document = process_document_with_docai(chunk_file)
                save_docai_response_to_json(docai_document, json_path)
                hocr_content = convert_docai_response_to_hocr(
                    docai_document, f"Chunk {i + 1}", json_path
                )

                with open(hocr_path, "w", encoding="utf-8") as f:
                    f.write(hocr_content)

                convert_hocr_to_pdf(hocr_path, chunk_file, output_chunk)

            else:
                process_pdf_by_ocrmypdf(chunk_file, output_chunk)

            processed_chunks.append(output_chunk)
            temp_files += [chunk_file]
            if use_docai:
                temp_files += [hocr_path, json_path]

        except Exception as e:
            print(f"[ERROR] ページ {i + 1} 処理失敗: {e}")

    print("[INFO] 各ページを結合中...")
    merge_pdfs_with_pymupdf(processed_chunks, output_path)

    for path in temp_files + processed_chunks:
        try:
            os.remove(path)
        except Exception as e:
            print(f"[WARN] 一時ファイル削除失敗: {path} -> {e}")

    print(f"[DONE] 完了: {output_path}")


# 実行部分
if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("使用方法: python script.py 入力PDF 出力PDF [use_docai]")
        sys.exit(1)

    input_pdf = sys.argv[1]
    output_pdf = sys.argv[2]
    use_docai = sys.argv[3].lower() == "true" if len(sys.argv) > 3 else True

    if use_docai:
        if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
            print("[WARN] 認証情報が設定されていません。")

    make_searchable_pdf(input_pdf, output_pdf, use_docai)
```
</details>
:::

#### ① pythonライブラリ「OCRmyPDF」を使ってOCR
##### OCRmyPDFとは
https://github.com/ocrmypdf/OCRmyPDF

>OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched or copy-pasted.（GitHubリポジトリ説明から引用）

OCRmyPDFは、
スキャンされたPDFファイルにテキストを埋め込むことができる役割を担っています。
オープンソースのOCRエンジンである、[「Tesseract OCR」](https://github.com/tesseract-ocr/tesseract)が内部的に使われています。
Tesseract OCRは、日本語を含めた多言語にも対応しています。

##### 手順1 ocrmypdfライブラリをインストール

:::note info
uvを使って検証を行いました。
検証時点でのバージョンは`uv 0.6.12`です。
```zsh
$ uv version
uv 0.6.12 (e4e03833f 2025-04-02)
```
:::

`uv init`で任意のプロジェクトを作成したのちに、下記を実行します。これでプログラムからocrmypdfライブラリを呼び出す準備が整いました。
```zsh
$ uv add ocrmypdf
```
```zsh
$ uv sync
```

##### 手順2 プログラムから呼び出す
OCR処理は[ocrmypdf.ocr](https://ocrmypdf.readthedocs.io/en/v11.2.1/api.html#ocrmypdf.ocr)が担っているので、これを使います。
`input_file`にはOCRをかけたいファイルのパスを、
`output_file`にはOCR結果の出力先のパスを指定します。

`--language`オプションをつけることで、もとドキュメントの言語を指定することができます。（多くの場合は`jpn`）を指定することになるかと思います。


```python
from ocrmypdf import ocr, exceptions
def process_pdf_by_ocrmypdf(input_pdf: str, output_pdf: str) -> None:
    """
    OCRmyPDFを使ってPDFに透明テキストレイヤーを付与する

    Args:
        input_pdf (str): 入力PDFファイルのパス
        output_pdf (str): 出力PDFファイルのパス

    Returns:
        None
    """
    try:
        ocr(
            input_file=input_pdf,
            output_file=output_pdf,
            language="jpn",     
            deskew=True,        # 傾きを補正するオプション
            clean=True,         # ノイズ除去をするオプション
            progress_bar=True,  # 処理状況をターミナルで確認可能にする
        )
        print(f"[INFO] OCRが正常に完了: {output_pdf}")
    except exceptions.ExitCodeException as e:
        print(f"[ERROR] OCR実行中にエラー: {e}")
```

`progress_bar=True`をつけると、OCR処理の進捗状況をターミナル上で確認することができます。
出力結果は以下のようになります。

<details><summary>プログレスバー出力イメージ</summary>

```zsh
$ uv run convert_to_searchable_pdf_v2.py test-input.pdf test-output.pdf false
[INFO] 分割処理中: test-input.pdf
[INFO] チャンク 1/3 処理中...
Scanning contents     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
[tesseract] lots of diacritics - possibly poor OCR
OCR                   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
PDF/A conversion      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
Linearizing           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 100/100 0:00:00
Recompressing JPEGs   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0/0 -:--:--
Deflating JPEGs       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
JBIG2                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0/0 -:--:--
[INFO] OCRが正常に完了: test-input_page1_processed.pdf
[INFO] チャンク 2/3 処理中...
Scanning contents     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
OCR                   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
PDF/A conversion      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
Linearizing           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 100/100 0:00:00
Recompressing JPEGs   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0/0 -:--:--
Deflating JPEGs       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
JBIG2                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0/0 -:--:--
[INFO] OCRが正常に完了: test-input_page2_processed.pdf
[INFO] チャンク 3/3 処理中...
Scanning contents     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
OCR                   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
PDF/A conversion      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
Linearizing           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 100/100 0:00:00
Recompressing JPEGs   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0/0 -:--:--
Deflating JPEGs       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 1/1 0:00:00
JBIG2                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0/0 -:--:--
[INFO] OCRが正常に完了: test-input_page3_processed.pdf
[INFO] チャンク結合中...
[DONE] 完了: test-output.pdf
```
</details>


#### ② Google CloudのOCRサービス「Document AI」を使ってOCRを行う
##### Document AIとは
Google CloudのOCRサービスです。
料金体系は以下のようになっています。
Enterprise Document OCR プロセッサを使う場合では、1000ページごとに1.5$ということで、少量のデータであれば比較的安価にOCRをすることができます。

料金体系は以下のページに記載がありますので、詳細はこちらをご覧ください。

https://cloud.google.com/document-ai/pricing?hl=ja


![スクリーンショット 2025-04-29 12.27.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/e585592d-ae94-44a1-bf78-fddb0d07d942.png)
上記のようにGoogle Cloud コンソール上から簡単にテストすることができますが、


今回はGoogle Cloud SDK経由から呼び出すことを考えます。

利用に際しては
サービスアカウントの認証情報作成、プロセッサの作成など事前準備が必要となります。
こちらのチュートリアル（1-5）が参考になります。

https://codelabs.developers.google.com/codelabs/docai-ocr-python?hl=ja#4

##### 手順1.Document AI ToolBoxを用いて、画像PDFをhocr形式に変換する
https://cloud.google.com/document-ai/docs/samples/documentai-toolbox-document-to-hocr?hl=ja

Document AIには、[Toolbox](https://cloud.google.com/document-ai/docs/toolbox?hl=ja)と呼ばれる、OCR結果を2次利用しやすい形式に変換するためのクライアントライブラリが用意されています。

:::note info
インストール方法は[クライアントライブラリをインストールする](https://cloud.google.com/document-ai/docs/libraries?hl=ja#install)の項を参照してください。
:::

```python
def convert_docai_response_to_hocr(
    docai_document: documentai.Document, title: str, document_path: str
) -> str:
    """
    JSON（Document AIのレスポンスが入っている）をhOCR形式に変換する

    Args:
        docai_document (Document): OCRドキュメント
        title (str): hOCRファイルタイトル
        document_path (str): Document AI JSONパス

    Returns:
        str: hOCRテキスト
    """
    wrapped_doc = document.Document.from_document_path(document_path=document_path)
    return wrapped_doc.export_hocr_str(title=title)
```
その中に、Document AIによるOCR結果のレスポンス（JSON形式）をhocr形式に変換するツールがあったため、今回はこれを使用します。
変換された結果が以下です。（掲載しているのは一部です）
`bbox`という記述が多くありますが、これはOCRによって検出された物体(画像やテキストなど)の領域のことを指すようです。
つまり出力されたhocr形式のファイルは「どこに、何が書いてあるかを詳細にまとめたXML形式のファイル」と言えそうです。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="unknown" lang="unknown">
<head>
<title>Chunk 2</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<meta name="ocr-system" content="Document AI OCR" />
<meta name="ocr-langs" content="unknown" />
<meta name="ocr-scripts" content="unknown" />
<meta name="ocr-number-of-pages" content="1" />
<meta name="ocr-capabilities" content="ocrp_lang ocr_page ocr_carea ocr_par ocr_line ocrx_word" />
</head>
<body>
<div class='ocr_page' lang='unknown' title='bbox 0 0 1626 2459'><span class='ocr_carea' id='block_1_0' title='bbox 400 54 797 61'><p class='ocr_par' id='par_1_0_0' title='bbox 400 54 797 61'><span class='ocr_line' id='line_1_0_0_0' title='bbox 400 54 797 61'>かわさきの上下水道 No.59 令和7年3月
<span class='ocrx_word' id='word_1_0_0_0_0' title='bbox 400 54 475 71'>かわさき</span><span class='ocrx_word' id='word_1_0_0_0_1' title='bbox 479 52 498 69'>の</span><span class='ocrx_word' id='word_1_0_0_0_2' title='bbox 498 51 580 68'>上下水道 </span><span class='ocrx_word' id='word_1_0_0_0_3' title='bbox 600 48 654 65'>No.59 </span><span class='ocrx_word' id='word_1_0_0_0_4' title='bbox 676 46 715 64'>令和</span><span class='ocrx_word' id='word_1_0_0_0_5' title='bbox 720 45 732 63'>7</span><span class='ocrx_word' id='word_1_0_0_0_6' title='bbox 738 44 757 61'>年</span><span class='ocrx_word' id='word_1_0_0_0_7' title='bbox 763 44 774 62'>3</span><span class='ocrx_word' id='word_1_0_0_0_8' title='bbox 781 43 798 61'>月
....中略
</body>
</html>
```


##### 手順2.hocr形式から、PDFに変換する
OCRmyPDFのライブラリで、hocr形式からPDFに変換を行うことのできる[`hocrtransform`](https://ocrmypdf.readthedocs.io/en/latest/apiref.html#module-ocrmypdf.hocrtransform)メソッドがあるため、これを利用します。
```python
def convert_hocr_to_pdf(hocr_path, background_pdf_path, output_pdf_path, dpi=300):
    """hOCRファイルから透明テキストレイヤーを作成するサンプルコード"""
    ocr_only_pdf_path = str(Path(output_pdf_path).with_suffix(".ocr_only.pdf"))

    transformer = hocrtransform.HocrTransform(hocr_filename=Path(hocr_path), dpi=dpi)
    transformer.to_pdf(out_filename=Path(ocr_only_pdf_path))
    print(f"[INFO] 透明テキストPDF生成完了: {ocr_only_pdf_path}")
```
変換結果は以下のようになりした。
コード内にも記載していますが、あくまでもここでは検出されたテキストがPDF内に埋め込まれるだけです。なので文字列検索には引っかかりますが、こちら側からは確認ができません。
この問題を手順3で解決します。


![スクリーンショット 2025-04-29 12.50.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/ba01fdae-1de6-44a5-b76b-da5a1428b7b8.png)


##### 手順3.透明テキストが含まれるPDFと元PDFを重ね合わせる
手順2で生じた問題を解決するため、元の画像PDFに、テキストを重ね合わせる処理を追加します。

```diff_python
def convert_hocr_to_pdf(hocr_path, background_pdf_path, output_pdf_path, dpi=300):
+   """hOCRファイルから透明テキストレイヤーを作成し、背景PDFと合成する"""
    ocr_only_pdf_path = str(Path(output_pdf_path).with_suffix(".ocr_only.pdf"))

    transformer = hocrtransform.HocrTransform(hocr_filename=Path(hocr_path), dpi=dpi)
    transformer.to_pdf(out_filename=Path(ocr_only_pdf_path))
    print(f"[INFO] 透明テキストPDF生成完了: {ocr_only_pdf_path}")

+    merge_background_and_ocr(background_pdf_path, ocr_only_pdf_path, output_pdf_path)
+    print(f"[INFO] 背景と透明テキストを合成完了: {output_pdf_path}")
```

新たに、関数`merge_background_and_ocr`を作成します。
```python
def merge_background_and_ocr(background_pdf_path, ocr_text_pdf_path, output_pdf_path):
    """背景PDFと透明テキストレイヤーPDFを合成"""
    bg_doc = fitz.open(background_pdf_path)
    ocr_doc = fitz.open(ocr_text_pdf_path)

    for page_num in range(len(bg_doc)):
        bg_page = bg_doc[page_num]
        bg_page.show_pdf_page(bg_page.rect, ocr_doc, page_num)

    bg_doc.save(output_pdf_path, garbage=4, deflate=True)
    bg_doc.close()
    ocr_doc.close()
```
今回は`pymupdf`ライブラリの[`show_pdf_page`](https://pymupdf.readthedocs.io/ja/latest/page.html#Page.show_pdf_page)メソッドを使用しました。
このメソッドはあるPDFの特定のページの内容を、別のPDFに描画できるメソッドで、
処理内では、画像PDFの上に、透明テキストが含まれるOCR済みのPDFを重ね合わせています。

```python
# 補足
# bg.rect→配置場所（「どこに重ね合わせるか」）
# ocr_doc→OCR済みの、テキスト情報だけが存在するPDF（「何を重ね合わせるか」）
# page_num→重ね合わせる対象のページ数
bg_page.show_pdf_page(bg_page.rect, ocr_doc, page_num)

# saveメソッドについては以下を参照
# https://pymupdf.readthedocs.io/ja/latest/document.html#Document.save
## garbage=4にすることで不要なコンテンツを削除してくれる。
## deflate=Trueにすることでファイルを圧縮してくれる。

```
手順1-3の処理を以て、画像PDFからテキスト情報を埋め込んだPDFへの変換を行うことができます。


## RAGの結果を見てみる
### 簡単な質問をしてみる
KBを使って、RAGをしてみます。

#### ① OCRmypdfでOCRを行った場合
元々の画像PDFの2ページ目にあった、「工事をしないとどうなるの？」の項に関する質問をしてみます。
![スクリーンショット 2025-04-29 11.41.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/bf1222b3-a25a-46d2-beca-85f1b76032f7.png)

「耐用年数を経過した水道管はどのくらいありますか？」という質問を投げてみました。

710kmと返してくれるのが期待値ですが、元ドキュメントの通り、710kmと返却してくれました。OCRの読み取り精度は悪くなさそうです。
また、`x-amz-bedrock-kb-document-page-number`で、抽出元のページも相違なく取れてきています。

![スクリーンショット 2025-04-29 13.36.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/7e4dbbf6-e7ea-44d7-a327-7e58e6e1a932.png)


#### ② Document AIでOCRを行った場合
こちらについても、同様問題なく結果が取れてきそうでした。①とはそこまで差異がないですが、ソースチャンクを見る限り、こちらの方が文字をより正確に読み取ってくれているような気がしました。

![スクリーンショット 2025-04-29 11.39.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/4f9d6406-3a59-4bfe-a2d9-472a519f6aaf.png)

:::note info  
おまけ
Kendraでindexを立てて検索を行ってみても、同じように検索結果からページ情報が得られました。
![スクリーンショット 2025-04-26 22.15.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623937/4ffcfc58-4d79-47ed-9ffd-532cff9f42bb.png)
:::


## まとめ
ここまで読んでいただき、ありがとうございました。
今回は、画像PDFに対してOCRの処理を行い、検索可能なPDFに変換して、RAGのデータソースとして機能するかの検証を行いました。
比較的読み取りのしやすいドキュメントでテストを行ったのもありますが、問題なくデータの前処理を行うことができました。
冒頭の問題意識の項で記載した、ページ数の抽出の課題は、
ページごとにOCRをかける→PDFに書き戻す、というアプローチでクリアできそうな気がしました。
また、Document AIを使えば、どこをどう読み取っているのかの情報が、レスポンスからある程度把握できるので、こちらも便利な気がしました。

今後は、
- 表組みが多いPDF（帳票、レポート）

- 手書き文字が混在するPDF

- フォントが特殊な文書

など、より難易度の高いケースでも本手法が使えるか検証していきたいと思います。


#### おまけ
##### 処理イメージ
処理の全体像をmermaid記法で書いてみました。
```mermaid
flowchart TD
    A[画像PDF（入力）] --> B{ページごとに分割}

    B --> C1[ページ1]
    B --> C2[ページ2]

    C1 --> D1{Document AIを使うか？}
    C2 --> D2{Document AIを使うか？}

    D1 -- Yes --> E1[Document AIでOCR実行、<br>レスポンスをJSONファイルに保存]
    D1 -- No --> F1[OCRmyPDFでOCR]

    D2 -- Yes --> E2[Document AIでOCR実行、<br>レスポンスをJSONファイルに保存]
    D2 -- No --> F2[OCRmyPDFでOCR]

    E1 --> G1[JSONからhOCRを生成 → <br>透明テキストPDF作成]
    E2 --> G2[JSONからhOCRを生成 → <br>透明テキストPDF作成]

    G1 --> H1[背景PDFと<br>透明テキスト合成]
    G2 --> H2[背景PDFと<br>透明テキスト合成]

    F1 --> I1[テキストが埋め込まれたPDF]
    F2 --> I2[テキストが埋め込まれたPDF]

    H1 --> M[各ページの結果を統合]
    H2 --> M
    I1 --> M
    I2 --> M

    M --> Z[検索可能なPDF（出力）]

```