Generative AIを用いてPDFから抽出した文章を要約してみた

記事タイトルとURLをコピーする

G-gen 又吉です。当記事では、Cloud Vision API を用いて PDF ファイルからテキストを抽出し、Google Cloud の Generative AI モデルが利用できる Vertex AI PaLM API を呼び出して抽出したテキストの要約をやってみたので解説します。

Cloud Vision API × Vertex AI PaLM API

前提知識

Generative AI Support on Vertex AI

先日 Vertex AI でも Generative AI がサポートされました。Generative AI モデル (基盤モデル) の裏側は PaLM 2 が利用されており、多言語、推論、コーディング機能が強化された最先端の大規模言語モデル (LLM) です。

Vertex AI で Generative AI がサポートされたことで、Vertex AI のエンドポイントから基盤モデルを呼び出せるようになりました。

Vertex AI の Generative AI サポートについての詳細は以下の記事をご参照下さい。

blog.g-gen.co.jp

2023 年 7 月現在、Vertex AI PaLM API は日本語未対応ですが、筆者の環境は Trusted Testers プログラムに参加中のため日本語にも対応しております。

Cloud Vision API

Cloud Vision API とは、事前トレーニング済み Vision API モデル使用して、画像内のオブジェクトの検知や OCR だけでなく、PDF / TIFF ファイル中のテキスト抽出等も行なえます。

その他の機能、また詳細については以下のドキュメントをご参照下さい。

参考:Features list

また、Cloud Vision API を用いたやってみた記事として、以下のようなものもあるので興味があれば御覧ください。

blog.g-gen.co.jp

今回扱うデータ

今回扱う PDF ファイルは、ダミーで作成した日報データです。

daily_report.pdf

構成図

今回の構成は以下のとおりです。

構成図

PDF 用バケットに PDF ファイル (日報) がアップロードされたことをトリガーに、 PDF からテキストを抽出する Cloud Functions 関数が起動し、抽出したテキストデータをテキスト用バケットに格納します。

次に、テキスト格納用バケットにデータがアップロードされたことをトリガーに、文章を要約する Cloud Functions 関数が起動し、要約したデータが要約された文章用バケットに格納します。

プロンプト設計

概要

プロンプトとは、簡単に言うと大規模言語モデル (LLM) に送信するリクエストのことです。

Vertex AI PaLM API からより良い回答を生成してもらうためには、プロンプトの設計が非常に重要です。

参考:プロンプト設計

今回は、自然な会話やチャットボット等のユースケースに特化した chat-bison@001 (チャット用言語モデル) を使用します。

チャット用言語モデルのプロンプトには、次の 3 つのコンポーネントで構成されます。

  • メッセージ [必須]
  • コンテキスト [オプション]
  • 入出力例の追加 [オプション]

コンテキスト

コンテキスト とは、モデルの応答方法を指示したり、モデルのペルソナを指定したりできます。

よって今回は、以下のようなコンテキストを設定します。

あなたはプロの編集者です。以下の制約条件に従って、入力する文章を要約してください。

制約条件
・300文字以内にまとめて要約した文章として出力。
・文章の意味を変更しない。
・架空の表現や言葉を使用しない。

入出力例の追加

入出力例の追加 とは、特定の入力例と、その入力に対するモデルの出力例、つまり入出力のペアリストのことです。

PDF から抽出されるテキストデータのサンプルとして、以下を入力例とします。

日付名前2023年7月24日営業 太郎日報業務報告お疲れ様です。 本日の活動について報告いたします。本日は、 当社の製品に関心を示していただいた新規のお客様への訪問を中心に行いました。 まず、 午前中にはA社を訪問し、当社製品の特徴と価格競争力についてプレゼンテーションを行いました。 彼らは特に当社の製品のコストパフォーマンスに感心していましたが、 具体的な購入の意志は明らかになりませんでした。 引き続き 情報提供を行い、 ビジネスを進展させるべく、 交渉を続けます。午後は、新たに興味を示してくださったB社とのミーティングを行いました。 当社の製品についての詳細な質問や、予算に関する議論が交わされました。B社は即決ではありませんでしたが、 当社の製品に強い興味を持っていることが伺え、有望な見込み客と判断しています。その他、進行中のC社との契約交渉については、一部微調整が必要な部分が見つかりました。 来週中には改めて会議を設定し、これをクリアにする予定です。全体として、本日は新規顧客開拓と既存顧客との継続的な関係強化に重点を置いた一日となりました。 明日はさらなる顧客訪問と情報収集、ならびに製品のプレゼンテーションを行う予定です。 ご支援のほど、よろしくお願い申し上げます。以上、本日の報告となります。

モデルの出力例としては、「日付 : 」「名前 : 」「要約 : 」を分けて出力してもらえるよう、以下のように設定します。

日付:2023年7月24日

名前:営業 太郎

要約:本日は、当社の製品に関心を示していただいた新規のお客様への訪問を中心に行いました。午前中にはA社を訪問し、当社製品の特徴と価格競争力についてプレゼンテーションを行いました。午後は、新たに興味を示してくださったB社とのミーティングを行いました。その他、進行中のC社との契約交渉については、一部微調整が必要な部分が見つかりました。明日はさらなる顧客訪問と情報収集、ならびに製品のプレゼンテーションを行う予定です。

準備

ディレクトリ構成

開発環境は Cloud Shell を用いて行います。ディレクトリ構造は以下のとおりです。

terraform ディレクトリ配下は、以下のとおりです。

terraform
|-- gcf_source_code
|   |-- pdf_to_text
|   |   |-- main.py
|   |   `-- requirements.txt
|   `-- sammarize
|       |-- main.py
|       `-- requirements.txt
`-- main.tf

main.tf

main.tf には Terraform のコードを記述しています。

locals {
  terraform_service_account            = ${Terraform 実行に使われるサービスアカウントのメールアドレス}
  project_name                         = ${プロジェクト名}
  project_id                           = ${プロジェクト ID}
  folder_id                            = ${フォルダ ID}
  billing_account_id                   = ${請求先アカウント ID}
}
  
# terraform & provider の設定
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 4.0.0"
    }
  }
  required_version = ">= 1.3.0"
  
  backend "gcs" {
    bucket = ${tfstate ファイルを格納する Cloud Storage バケット名}
    impersonate_service_account = ${Terraform 実行に使われるサービスアカウントのメールアドレス}
    }
}
  
# サービスアカウント権限借用の設定
provider "google" {
    alias = "impersonation"
    scopes = [
        "https://www.googleapis.com/auth/cloud-platform",
        "https://www.googleapis.com/auth/userinfo.email",
    ]
}
  
data "google_service_account_access_token" "default" {
    provider               = google.impersonation
    target_service_account = local.terraform_service_account
    scopes                 = ["userinfo-email", "cloud-platform"]
    lifetime               = "1200s"
}
  
# Google プロバイダの設定
provider "google" {
  project         = local.project_id
  region          = "asia-northeast1"
  access_token    = data.google_service_account_access_token.default.access_token
  request_timeout = "60s"
}
  
  
######################################
### プロジェクトの作成と API の有効化 ###
######################################
  
# プロジェクトの作成
resource "google_project" "poc" {
  name            = local.project_name
  project_id      = local.project_id
  folder_id       = local.folder_id
  billing_account = local.billing_account_id
}
  
# API の有効化
module "tenant_a_project_services" {
  source  = "terraform-google-modules/project-factory/google//modules/project_services"
  version = "14.2.1"
  project_id  = google_project.poc.project_id
  enable_apis = true
  activate_apis = [
    "iam.googleapis.com",
    "cloudbuild.googleapis.com",
    "run.googleapis.com",
    "cloudfunctions.googleapis.com",
    "pubsub.googleapis.com",
    "eventarc.googleapis.com",
    "artifactregistry.googleapis.com",
    "storage.googleapis.com",
    "vision.googleapis.com",
    "aiplatform.googleapis.com"
  ]
  disable_services_on_destroy = false
}
  
  
#######################################
### サービスアカウントの作成と権限の付与 ##
#######################################
  
# Cloud Functions 用サービスアカウントの作成と権限付与
resource "google_service_account" "sa_gcf" {
  project      = google_project.poc.project_id
  account_id   = "sa-gcf"
  display_name = "Cloud Functions 用サービスアカウント"
}
  
resource "google_project_iam_member" "invoke_gcf" {
  project = google_project.poc.project_id
  role    = "roles/run.invoker"
  member  = "serviceAccount:${google_service_account.sa_gcf.email}"
}
  
resource "google_project_iam_member" "storage_admin" {
  project = google_project.poc.project_id
  role    = "roles/storage.admin"
  member  = "serviceAccount:${google_service_account.sa_gcf.email}"
}
  
resource "google_project_iam_member" "event_receiving" {
  project = google_project.poc.project_id
  role    = "roles/eventarc.eventReceiver"
  member  = "serviceAccount:${google_service_account.sa_gcf.email}"
  depends_on = [google_project_iam_member.invoke_gcf]
}
  
resource "google_project_iam_member" "artifactregistry_reader" {
  project = google_project.poc.project_id
  role     = "roles/artifactregistry.reader"
  member   = "serviceAccount:${google_service_account.sa_gcf.email}"
  depends_on = [google_project_iam_member.event_receiving]
}

resource "google_project_iam_member" "vertex_ai_user" {
  project = google_project.poc.project_id
  role    = "roles/aiplatform.user"
  member  = "serviceAccount:${google_service_account.sa_gcf.email}"
}
  
# Eventarc のサービスアカウントに権限付与
resource "google_project_iam_member" "serviceAccount_token_creator" {
  project = google_project.poc.project_id
  role     = "roles/iam.serviceAccountTokenCreator"
  member   = "serviceAccount:service-${google_project.poc.number}@gcp-sa-pubsub.iam.gserviceaccount.com"
  depends_on = [module.tenant_a_project_services]
}
  
# Cloud Storage のサービスアカウントに権限付与
data "google_storage_project_service_account" "gcs_account" {
  project = google_project.poc.project_id
  depends_on = [ module.tenant_a_project_services ]
}
  
resource "google_project_iam_member" "gcs_pubsub_publishing" {
  project = google_project.poc.project_id
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:${data.google_storage_project_service_account.gcs_account.email_address}"
}
  
  
################################
### バケットとオブジェクトの作成 ###
################################
  
# Cloud Functions のソースコード格納用バケットの作成
resource "google_storage_bucket" "source_gcf" {
  project       = google_project.poc.project_id
  location      = "asia-northeast1"
  name          = "${google_project.poc.project_id}-source-gcf"
  force_destroy = true
}
  
# Cloud Functions で使うソースコードを ZIP 化
data "archive_file" "pdf_to_text" {
  type        = "zip"
  source_dir  = "./gcf_source_code/pdf_to_text"
  output_path = "./zip_source_code/pdf_to_text.zip"
}
  
data "archive_file" "sammarize" {
  type        = "zip"
  source_dir  = "./gcf_source_code/sammarize"
  output_path = "./zip_source_code/sammarize.zip"
}
  
# ZIP 化したソースコードをバケットに追加
resource "google_storage_bucket_object" "pdf_to_text" {
  name   = "pdf-to-text.${data.archive_file.pdf_to_text.output_md5}.zip"
  bucket = google_storage_bucket.source_gcf.name
  source = data.archive_file.pdf_to_text.output_path
}
  
resource "google_storage_bucket_object" "sammarize" {
  name   = "sammarize.${data.archive_file.sammarize.output_md5}.zip"
  bucket = google_storage_bucket.source_gcf.name
  source = data.archive_file.sammarize.output_path
}
  
# pdf_data バケットの作成
resource "google_storage_bucket" "pdf_data" {
  project       = google_project.poc.project_id
  location      = "asia-northeast1"
  name          = "${google_project.poc.project_id}-pdf-data"
  force_destroy = true
}
  
# text_data バケットの作成
resource "google_storage_bucket" "text_data" {
  project       = google_project.poc.project_id
  location      = "asia-northeast1"
  name          = "${google_project.poc.project_id}-text-data"
  force_destroy = true
}
  
# summarized_text_data バケットの作成
resource "google_storage_bucket" "summarized_text_data" {
  project       = google_project.poc.project_id
  location      = "asia-northeast1"
  name          = "${google_project.poc.project_id}-summarized-text-data"
  force_destroy = true
}
  
  
############################
### Cloud Functions 作成 ###
############################
  
# pdf_to_text 関数
resource "google_cloudfunctions2_function" "pdf_to_text" {
  depends_on = [
    google_project_iam_member.event_receiving,
    google_project_iam_member.artifactregistry_reader,
    google_project_iam_member.serviceAccount_token_creator
  ]
  name = "pdf-to-text"
  location = "asia-northeast1"
  description = "PDF からテキストを取得し text_data バケットに格納する関数"
  build_config {
    runtime     = "python310"
    entry_point = "main" # Set the entry point in the code
    source {
      storage_source {
        bucket = google_storage_bucket.source_gcf.name
        object = google_storage_bucket_object.pdf_to_text.name
      }
    }
  }
  service_config {
    max_instance_count  = 3
    min_instance_count = 1
    available_memory    = "256M"
    timeout_seconds     = 60
    environment_variables = {
      DESTINATION_BUCKET_NAME = google_storage_bucket.text_data.name
    }
    service_account_email = google_service_account.sa_gcf.email
  }
  event_trigger {
    trigger_region = "asia-northeast1"
    event_type = "google.cloud.storage.object.v1.finalized"
    retry_policy = "RETRY_POLICY_DO_NOT_RETRY"
    service_account_email = google_service_account.sa_gcf.email
    event_filters {
      attribute = "bucket"
      value = google_storage_bucket.pdf_data.name
    }
  }
}

# sammarize 関数
resource "google_cloudfunctions2_function" "sammarize" {
  depends_on = [
    google_project_iam_member.event_receiving,
    google_project_iam_member.artifactregistry_reader,
    google_project_iam_member.serviceAccount_token_creator
  ]
  name = "sammarize"
  location = "asia-northeast1"
  description = "Vertex AI PaLM API でテキストを要約し summarized_text_data バケットに格納する関数"
  build_config {
    runtime     = "python310"
    entry_point = "main" # Set the entry point in the code
    source {
      storage_source {
        bucket = google_storage_bucket.source_gcf.name
        object = google_storage_bucket_object.sammarize.name
      }
    }
  }
  service_config {
    max_instance_count  = 3
    min_instance_count = 1
    available_memory    = "256M"
    timeout_seconds     = 60
    environment_variables = {
      PROJECT_ID = google_project.poc.project_id
      DESTINATION_BUCKET_NAME = google_storage_bucket.summarized_text_data.name
    }
    service_account_email = google_service_account.sa_gcf.email
  }
  event_trigger {
    trigger_region = "asia-northeast1"
    event_type = "google.cloud.storage.object.v1.finalized"
    retry_policy = "RETRY_POLICY_DO_NOT_RETRY"
    service_account_email = google_service_account.sa_gcf.email
    event_filters {
      attribute = "bucket"
      value = google_storage_bucket.text_data.name
    }
  }
}

gcf_source_code/pdf_to_text

main.py

gcf_source_code/pdf_to_text には、PDF ファイルからテキストを抽出する Cloud Functions 関数のソースコードを格納しています。

import json
import re
import os
  
from cloudevents.http import CloudEvent
import functions_framework
from google.cloud import vision
  
  
DESTINATION_BUCKET_NAME = os.environ.get("DESTINATION_BUCKET_NAME")
  
# クライアントの初期化
vision_client = vision.ImageAnnotatorClient()
  
  
def async_detect_document(
        gcs_source_uri,
        gcs_destination_uri,
    ):
  
    # 入力設定を構成
    mime_type = "application/pdf"
    gcs_source = vision.GcsSource(uri=gcs_source_uri)
    input_config = vision.InputConfig(gcs_source=gcs_source, mime_type=mime_type)
  
    # 出力設定を構成
    feature = vision.Feature(
            type_=vision.Feature.Type.DOCUMENT_TEXT_DETECTION
    )
    gcs_destination = vision.GcsDestination(uri=gcs_destination_uri)
    output_config = vision.OutputConfig(
            gcs_destination=gcs_destination
    )
  
    # 非同期リクエストを実行
    async_request = vision.AsyncAnnotateFileRequest(
            features=[feature], 
            input_config=input_config, 
            output_config=output_config
    )
  
    # operations リソースでステータスを確認
    operation = vision_client.async_batch_annotate_files(requests=[async_request])
    print("Waiting for the operation to finish.")
  
    # 非同期処理が完了していない場合、最大 timeout 秒まで待機
    operation.result(timeout=420)
  
    return "ok"
  
@functions_framework.cloud_event
def main(cloud_event: CloudEvent):
  
    # CloudEvent から渡されたデータを取得
    data = cloud_event.data
  
    bucket_name = data["bucket"]
    file_name = data["name"]
    no_extension_file_name = file_name.split('.')[0]
  
    gcs_source_uri = f"gs://{bucket_name}/{file_name}"
    gcs_destination_uri = f"gs://{DESTINATION_BUCKET_NAME}/{no_extension_file_name}/"
  
    async_detect_document(
            gcs_source_uri = gcs_source_uri, 
            gcs_destination_uri = gcs_destination_uri,
    )
  
    return "ok"

PDF からテキストを取得する際、 AsyncBatchAnnotateFilesRequest メソッドを利用していますが、こちらは非同期でリクエストが行われます。 operations リソースを用いることで、そのステータスが確認でき、尚タイムアウトの指定も可能となります。

また、今回は検証のため対応しておりませんが、出力されるテキストが大きくなると複数ファイルに分かれて Cloud Storage に書き込まれる可能性がある為、本番運用時はこのあたりの考慮も必要となります。本検証は、1 ファイルのインプットにつき 1 ファイルのアウトプットのみに対応した構成となっています。

参考:GcsDestination

requirements.txt

functions-framework==3.*
cloudevents==1.9.0
google-cloud-vision==3.4.4

gcf_source_code/sammarize

main.py

gcf_source_code/sammarize には、テキストを要約する Cloud Functions 関数のソースコードを格納しています。

import json
import os
  
from cloudevents.http import CloudEvent
import functions_framework
import vertexai
from vertexai.preview.language_models import ChatModel, InputOutputTextPair
from google.cloud import storage
  
  
PROJECT_ID = os.environ.get("PROJECT_ID")
DESTINATION_BUCKET_NAME = os.environ.get("DESTINATION_BUCKET_NAME")
  
# 基盤モデルとストレージクライアントの初期化
vertexai.init(project=PROJECT_ID, location="us-central1")
chat_model = ChatModel.from_pretrained("chat-bison@001")
storage_client = storage.Client()
  
  
def download_json_from_bucket(bucket_name, blob_name):
    # バケットを取得
    bucket = storage_client.get_bucket(bucket_name)
  
    # オブジェクトを取得
    blob = bucket.blob(blob_name)
    json_string = blob.download_as_bytes().decode("utf-8")
    response = json.loads(json_string)

    return response
  
  
def summarize_text(text):
    # 言語モデルのリクエストパラメラータを指定
    parameters = {
        "temperature": 0.2,
        "max_output_tokens": 1024,
        "top_p": 0.8,
        "top_k": 40
    }
  
    # コンテキストと INPUT / OUTPUT の例を追加
    chat = chat_model.start_chat(
        context="""あなたはプロの編集者です。以下の制約条件に従って、入力する文章を要約してください。

制約条件
・300文字以内にまとめて要約した文章として出力。
・文章の意味を変更しない。
・架空の表現や言葉を使用しない。

#出力形式
日付:
名前:
要約した文章:""",
        examples=[
            InputOutputTextPair(
                input_text="""日付名前2023年7月24日営業 太郎日報業務報告お疲れ様です。 本日の活動について報告いたします。本日は、 当社の製品に関心を示していただいた新規のお客様への訪問を中心に行いました。 まず、 午前中にはA社を訪問し、当社製品の特徴と価格競争力についてプレゼンテーションを行いました。 彼らは特に当社の製品のコストパフォーマンスに感心していましたが、 具体的な購入の意志は明らかになりませんでした。 引き続き 情報提供を行い、 ビジネスを進展させるべく、 交渉を続けます。午後は、新たに興味を示してくださったB社とのミーティングを行いました。 当社の製品についての詳細な質問や、予算に関する議論が交わされました。B社は即決ではありませんでしたが、 当社の製品に強い興味を持っていることが伺え、有望な見込み客と判断しています。その他、進行中のC社との契約交渉については、一部微調整が必要な部分が見つかりました。 来週中には改めて会議を設定し、これをクリアにする予定です。全体として、本日は新規顧客開拓と既存顧客との継続的な関係強化に重点を置いた一日となりました。 明日はさらなる顧客訪問と情報収集、ならびに製品のプレゼンテーションを行う予定です。 ご支援のほど、よろしくお願い申し上げます。以上、本日の報告となります。""",
                output_text="""日付:2023年7月24日
名前:営業 太郎
要約:本日は、当社の製品に関心を示していただいた新規のお客様への訪問を中心に行いました。午前中にはA社を訪問し、当社製品の特徴と価格競争力についてプレゼンテーションを行いました。午後は、新たに興味を示してくださったB社とのミーティングを行いました。その他、進行中のC社との契約交渉については、一部微調整が必要な部分が見つかりました。明日はさらなる顧客訪問と情報収集、ならびに製品のプレゼンテーションを行う予定です。"""
            )
        ]
    )

    # 基盤モデルにリクエストを実行
    response = chat.send_message(text, **parameters)
  
    return response.text
  
  
def upload_text_to_bucket(bucket_name, blob_name, text_data):
    # バケットを取得
    bucket = storage_client.get_bucket(bucket_name)
  
    # バケットにアップロードするためのblobを作成
    blob = bucket.blob(blob_name)
  
    # テキストデータをアップロード
    blob.upload_from_string(text_data)
  
    return "OK"
  
  
@functions_framework.cloud_event
def main(cloud_event: CloudEvent):
  
    # CloudEvent から渡されたデータを取得
    data = cloud_event.data
    print(data)
  
    bucket_name = data["bucket"]
    blob_name = data["name"] # "Sheet2/output-1-to-1.json"
    folder_name = blob_name.split('/')[0]
    
    response = download_json_from_bucket(
            bucket_name = bucket_name,
            blob_name = blob_name
    )
  
    # json からテキストデータの取得
    first_page_response = response["responses"][0]
    response_text = first_page_response["fullTextAnnotation"]["text"]
  
    # 改行と「|」の削除
    corrected_text = response_text.replace('\n', '').replace('|', '')
  
    print(f"=====原文=====")
    print(corrected_text)
  
    print(f"=====サマリ=====")
    summarized_text = summarize_text(corrected_text) 
    print(summarized_text) 
  
    upload_text_to_bucket(
            bucket_name = DESTINATION_BUCKET_NAME, 
            blob_name = f"{folder_name}.txt", 
            text_data = summarized_text.encode("shift_jis")
    )
  
    return "ok"

Vertex AI PaLM API エンドポイントへのリクエストには、先程のプロンプト設計で作成したコンテキストと入出力例の追加を行っています。

requirements.txt

functions-framework==3.*
cloudevents==1.9.0
google-cloud-vision==3.4.4
google-cloud-storage==2.10.0
google-cloud-aiplatform==1.28.1

動作検証

検証データ

以下の PDF ファイルで動作検証を行います。尚、業務報告内の文字数は 736 文字となります。

daily_report.pdf

実行

PDF 格納バケットに daily_report.pdf をアップロードします。

格納バケットコンソール画面

直後に Cloud Storage トリガー経由で Cloud Functions が起動し、最終的に要約されたテキスト格納バケットにオブジェクトが生成されました。

要約されたテキスト格納バケット

中身は以下のようになってました。

daily_report.txt

入力コンテキストの制限事項には 300 文字以内で要約するよう記載していましたが、要約後のテキストでは 411 文字で出力されました 。圧縮率 (または要約率) 56%

何度か実行してみましたが 300 文字以内で要約することはできなかったため、制限事項に正確に従うことは現状難しいのかと思います。

しかし要約内容について、重要な箇所は漏れなく記載されており、文章全体にも違和感なく要約されています。

又吉 佑樹(記事一覧)

クラウドソリューション部

はいさい、沖縄出身のクラウドエンジニア!

セールスからエンジニアへ転身。Google Cloud 全 11 資格保有。Google Cloud Champion Innovator (AI/ML)。Google Cloud Partner Top Engineer 2024。Google Cloud 公式ユーザー会 Jagu'e'r でエバンジェリストとして活動中。好きな分野は AI/ML。