第3回金融データ活用チャレンジ奮闘記 〜PDFの読み取りが得意なLLM探しとローカルOpenSearchによるハイブリッド検索〜

2025/02/17に公開

こんにちは、フリーランスの機械学習エンジニア/データサイエンティストとして働いているashenOneと申します。zennでのブログ投稿はこれが初めてですが、今後は機械学習をはじめとするデータ系の技術記事を執筆していければと思っています。

さて今回は、先日までSIGNATEで開催されていた第3回金融データ活用チャレンジへの取り組みについて書いていきます。

なお、全体のソースコードはこちらのリポジトリに公開しているので、そちらも併せて御覧ください。

はじめに、私自身の結果は、最終順位49位 (参加者1544名、投稿チーム数635チーム) です。
スコアとしては残念ながら上位のみなさまには遠く及びませんでしたが、今回のコンペには特定のテーマをもって取り組み得られた知見も多くありました。特にGPT, Gemini, Claudeといった各種生成AIの強みが知りたい方、ハイブリッド検索等のRAG技術に興味がある方はお読みいただけると幸いです。

本記事ではタイトルにある通り、以下2点を中心にお伝えします。

  • PDFの読み取りに長けたLLMの調査
  • ローカルのOpenSearch環境におけるハイブリッド検索

コンペ概要

本題に入る前に、まずは今回のコンペがどういった問題であったか、そして私自身がどんな方針で取り組んだかといった概要についてお話します。

コンペのより詳細な情報に関しては、SIGNATEの公式ページを御覧ください。

コンペテーマ

第3回金融データ活用チャレンジのテーマは、ズバリ「RAG」です。
参加者には、事前に用意されたいくつかの問題に対して正確に回答することができる高精度なRAGシステムの構築が求められます。

問題設定

参加者には、コンペのデータとして主に以下が配布されました。

  • 「PDF資料」(document)
    • PDF資料は合計で19ファイル。1ファイルあたりのページ数は数十から数百ページまでさまざま。
    • 各PDFファイルには特定の企業が外部に公開している包括的なレポート。財務情報、中長期経営計画、環境問題への取り組み姿勢など幅広い内容を含む。
  • 「問題」(problem)
    • 「A社の2024年の営業利益は何億円?」といった、PDF資料に記載されてある情報に基づいて回答することができる内容によって構成される。

すなわち、参加者のゴールは、「問題」(problem)に対する回答を「PDF資料」(document)に基づいて回答するRAGシステムを構築することです。

コンペのポイント

次に、コンペに取り組んでいて、ここがおそらく勝敗を左右するポイントになるであろうと感じた点を列挙します。
(※ 上位入賞者の解法がまだ未公開のため、このポイント自体がズレていて上位入賞者はまったく別のポイントで競っていたという可能性があります)

  • Point1. 「PDF資料」の情報を損なわずに抽出する
    • 今回配布された「問題」には、前述した通り特定の数値情報について尋ねられる問題が多く含まれています。そして、それらの数値データは「PDF資料」において、テキスト/表/グラフといった様々な形で登場します。特に表やグラフといった視覚的表現に関しては、単にPDFファイル内のテキストデータをそのままパースして抽出するという方法だと、単なる数値の羅列が出力されてしまい元の情報量を失ってしまいます。
  • Point2. 「PDF資料」から「問題」の答えが書いてある箇所を特定する
    • ここはまさにRAGの本質といったところなので言わずもがなかと思います。各PDFファイルの文字起こし結果は最大数十万文字にのぼります。昨今、生成AIの最大トークン数が増えているとはいえやはり文字数が長くなればなるほど出力の精度は下がってしまうことから、以下に重要な情報のみを抽出して生成AIに与えるかが肝になります。
  • Point3. 答えとして求められるフォーマットを忠実に守る
    • 詳しくは公式ページを参照いただければと思いますが、今回のコンペでは運営側が想定する回答フォーマットからズレてしまうと、たとえ応答として意味が通る回答であっても減点が入る評価方法となっていました。そのため、各問題に対してどのような答えが設定されているのかを予測したうえで、回答のフォーマットをそれに合わせにいくことが、スコアアップにおいて重要なのではないかと考えました。

以降はPoint1と2に焦点をあてて、私自身がどのように取り組んだか及び取組の結果得られた知見をご共有できればと思います。
(残念ながら、Point3に関してはそれほど力を入れて取り組むことができなかったため割愛します。)

私の解法

解法の全体像

はじめに、全体の見通しを良くするために私の解法の全体像をお見せします。

アーキテクチャ

以降、Point1, 2を中心に、番号をつけた各ステップで行った取り組みについて順に紹介します

① テキスト部分の書き起こし と ② 表/グラフ部分の文字起こし

まずは、 『Point1. 「PDF資料」の情報を損なわずに抽出する』 です。
この工程での工夫ポイントは以下の2点です。

  • a. PDFデータを画像データとして生成AIに読み込ませ、markdown形式で文字起こしを行った
  • b. 「テキスト」を抽出する工程と「表/グラフ等の視覚的表現」を抽出する工程を分けた

『a. PDFデータを画像データとして生成AIに読み込ませ、文字起こしした』は、序盤に述べた通り、PDFデータの中身をそのままテキストデータとして抽出してしまうと、グラフや表といった視覚的な表現を含む情報が正しく抽出できないといった問題に対応するためのものです。

この問題に対しては、「視覚的な情報を持つデータならばそのまま視覚情報たる画像データとして生成AIに情報を認識させそれを文字起こしして貰えば良いだろう」ということで各種生成AI(Gemini/ChatGPT/Claude等)による文字起こし性能検証を行いました。また、出力の形式をmarkdown形式に指定し表やグラフ形式のデータをmarkdown記法の表として出力させることで、元のデータが持つ情報量を損なわないまま、生成AIが扱うことのできるテキストデータに落とし込むことができます。

次に『b. 「テキスト」を抽出する工程と「表/グラフ等の視覚的表現」を分けた』です。
まず、「テキスト」と「表/グラフ等の視覚的表現」を別々に扱ったとはどういうことを行ったかというと、以下のような工程を踏みました。

  1. 元のPDFファイルを各ページごとに1枚の画像として保存する
  2. 画像データをGemini-2.0-flashに読み込ませ、「テキストの文字起こし」及び「表/グラフ等の視覚的表現を含むか否かの判定」を実行する
  3. 1で「表/グラフ等の視覚的表現を含む」と判定されたPDFページのみClaude-3.5-sonnetによる「表/グラフの文字起こし」を行う

なぜわざわざこれらの処理を分けて行ったかというと、この構成が各生成AIの強みを最大限活かすうえで最適と感じたためです。ここで、PDF画像データの文字起こしという用途におけるGemini-2.0-flashとClaude-3.5-sonnetを比較した印象を表にまとめました。

Gemini-2.0-flash Claude-3.5-sonnet
「テキスト」の文字起こし精度 ほぼ忠実に文字起こし可能 ほぼ忠実に文字起こし可能 (ただし、出力が長いとどうしても途中で中断してしまう)
「表/グラフ」の文字起こし精度 認識できない数値が多く発生 一部の数値を除いて概ね正しく文字起こしが可能
コスト 安い (1ページあたり0.5円未満) 高い (1ページ平均約5円)

※ 補足) gpt-4o-miniに関しては上記の比較表でいうとGemini-2.0-flashに近い性能ですが、コスト効率の面からGeminiを選択しています。

上記のように、文字起こし性能面だけを見るとClaude-3.5-sonnetが明確に優れています。
特に、表やグラフといった視覚的情報の文字起こし性能においてはClaude-3.5-sonnetが圧倒的に精度が優れていました。具体的な違いのイメージとしては下記のような感じです。

PDF文字起こし

Geminiに関しては、「行や列がズレている」「ほとんどのセルが空白になっている」という例が多くありました。特に画像内の文字やグラフが小さい場合にこのような事象が多かった印象です。(Googleの公式ドキュメントによると、Geminiで画像を処理する際は強制的に解像度が特定の値に変更されるとのことなので、この解像度変更によ特定の文字や図が読み取れないほど小さくなっているということが起きているのかななどと推察しています。)

※ 参考: Gemini API でビジョン機能を試す

一方、Claudeに関しては、特定の数値を間違って出力する場合もあるものの、その頻度は圧倒的に少なく概ねの値が正しく文字起こしできています。

以上の結果から、単純に文字起こし性能の面だけ見るとClaudeでいいじゃん、となるのですが、前述の比較表にも記載した通り、Claudeには「コストが高い(Geminiに比べて10倍以上)」「出力が長いと、"これ以降も文字起こしが必要であれば追加で依頼してください"といった文章が出力され、途中で中断してしまう(俗に言われる生成AIがサボる現象が起きる)」といった課題がありました。そこで、Geminiには十分の性能を発揮することができるテキスト部分の文字起こしだけを担当してもらい、Claude-3.5-sonnetには表/グラフの文字起こし部分のみを担当してもらうことで上記の問題を回避するという方針をとることにしました。

このような役割分担の結果として、Claude-3.5-sonnetで全てを処理する場合と比べ概ね5分の1ほどのコストで精度の高い文字起こしを実現することができています。

以下、文字起こしを実行した際のプロンプト及びPythonコードです。

■ Geminiによる「テキストの文字起こし」を行った際のプロンプト

<instruction>

添付した画像ファイルからテキスト情報を正確に抽出し、Markdown形式のテキストとして出力してください。
また、画像の文字起こしを行う際は、下記の<constraint>に記載した制約に従ってください。

</instruction>

<constraint>

1. 抽出した情報をMarkdown形式で転記し、次のガイドラインに従います。
  - コンテンツを構造化するために、適切な見出しレベル(#はh1、##はh2など)を使用します。
  - 箇条書きは`- `を使用します。
  2. 出力するデータは「テキストデータ」のみとしてください。
  - 「テキストデータ」と「グラフや表といった視覚的なデータ」は別々に扱うことになっています。
  - あなたは「テキストデータ」専門のプロフェッショナルです。そのため、「グラフや表といった視覚的なデータ」は出力しないでください。
  - あなたは「グラフや表といった視覚的なデータ」の出力をスキップしますが、そこにどのようなグラフや表があるのかという事実だけは括弧付きで出力してください。
    - 例. [2001年から2020年までの健康寿命と平均寿命の推移」に関する折れ線グラフ]
    - 例. [2005年から2020年までの人口推移」に関する表]
3. 画像からグラフや表を除いたすべてのテキストをmarkdown形式に書き起こし、そのテキストを"markdown_text"に出力してください。
4. 画像内にグラフ(chart)や表(table)が存在するか否かを判定し、それらの存在を"there_is_chart_or_table"に出力してください。
  - グラフ(chart)や表(table)が存在する場合は"there_is_chart_or_table"にtrue, 存在しない場合は"there_is_chart_or_table"にfalseを出力してください。
5. 数値データの正確性には、細心の注意を払ってください。
  - 特に、数値データの単位は正確に出力してください。
6. 画像コンテンツ以外の余計な情報は出力しないでください。
  - 「今から画像の転記を始めます」「画像の出力は以上です」などのテキストは出力しないでください。

</constraint>

<example_output>
{
    "markdown_text": "# h1見出し\n## h2見出し\n- 箇条書き1\n- 箇条書き2\n\n[「2001年から2020年までの健康寿命と平均寿命の推移」に関する折れ線グラフ]\n[「2005年から2020年までの人口推移」に関する表]",
    "there_is_chart_or_table": true,
}
</example_output>

■ Claudeによる「表/グラフの文字起こし」を行った際のプロンプト

あなたの仕事は、提供された画像ファイルからすべてのグラフや表データを正確に抽出し、構造化されたMarkdown形式に転記することです。

次の指示には注意深く従ってください。

0. 数値データの正確性には、細心の注意を払ってください。
  - すべての数値情報を例外なく含めます。
  - 各数値の単位を例外なく含めます。
1. 画像からすべてのグラフ及び表をすべて書き起こしてください。
  - グラフや表に関係のないテキストは出力しないでください。
2. 図やグラフなどの視覚的な表現を正確にテキストに落とし込んでください
  - 色とデータポイントの関係を注意深く分析します。
3. 抽出した情報をMarkdown形式で転記し、次のガイドラインに従います。
  a. コンテンツを構造化するために、適切な見出しレベル(#はh1、##はh2など)を使用します。
  b. 表形式のデータは、Markdownテーブル構文を使用して表現します。
  c. グラフ(折れ線グラフ、棒グラフなど)はすべてMarkdownテーブルに変換し、すべてのデータポイントを漏らさず出力してください
  d. 表の中には結合されたセルを含む場合があります。その場合は2つのセルに同じ値を入力するようにしてください。
  e. 「グラフや表といった視覚的なデータ」をmarkdown形式の表として書き起こす際は、事前に下記のような概要を出力してください。
    - 例. ■ A株式会社の財務情報の推移 (行: 財務情報の種別(例. 売上高、営業利益), 列: 年月日)
  f. 表データを出力する1つのセルの中に2つの値を出力しないでください。
    - もし仮に表データの1つのセルの中に2つの値がある場合は、そのセルを2つのセルに分割したうえで1つのセルに1つの値が含まれた状態で出力してください。
  g. 表データを出力する際は、特に行名や列名(カラム名)に特に気をつけて正しい文字列をそのまま出力してください。
    - 特に、行名や列名(カラム名)に括弧書きや補足説明がある場合は、それを省略せず全てそのまま出力してください。
    - 特に、年月ごとの数値を含むデータの場合は実績値だけでなく見込み値や予測値、目標値である場合があります。そのような場合は、その値が実績値なのか見込み値なのか予測値なのか目標値なのかをそれぞれ判断したうえで行名や列名(カラム名)に出力してください。
4. 画像コンテンツ以外の余計な情報は出力しないでください。
  - 例. 「今から画像の転記を始めます」「画像の出力は以上です」などのテキストは出力しないでください。

出力構造の例:

■ 表: 健康寿命と平均寿命の推移
行: 年齢, 列: 年

(単位: 歳)

元のグラフにおける色と情報の関係性は下記のとおりです。
- オレンジ色の黒丸: 平均寿命(女性)
- 青色の黒丸: 平均寿命(男性)
- オレンジ色の白丸: 健康寿命(女性)
- 青色の白丸: 健康寿命(男性)

|  | 2007年 | 2013年 | 2020年 |
|------|---------|---------|---------|
| 平均寿命 (女性) | 87.17 | 87.45 | 87.71 |
| 平均寿命 (男性) | 81.34 | 81.41 | 81.56 |
| 健康寿命 (女性) | 75.21 | 75.38 | 75.55 |
| 健康寿命 (男性) | 72.51 | 72.68 | 72.85 |

■ 表: 食料品アクセス困難人口の推移
行: 種別, 列: 年

(単位: 千人)

元のグラフにおける色と情報の関係性は下記のとおりです。
- 薄い水色: 全体
- 薄紫色: 75歳以上

|  | 2005年 | 2010年 | 2015年 | 2020年 |
|------|---------|---------|---------|---------|
| 全体 | 6784 | 7327 | 8246 | 9043 |
| 75歳以上 | 3767 | 4466 | 5355 | 5658 |

それでは、画像の内容を注意深く分析し、指示に従ってMarkdown形式に変換してください。

class ImageAnalysisResult(typing.TypedDict):
    markdown_text: str
    there_is_chart_or_table: bool

async def convert_image2markdown(image_filepath: str) -> ImageAnalysisResult:
    for _ in range(10):
        image = Image.open(image_filepath)
        prompt = read_txt_file(PROMPT_DIR / "read_image_text.txt")

        is_error = False

        # 画像化したPDFを文字起こしする
        try:
            response = await asyncio.to_thread(  # asyncio.to_thread で囲む
                functools.partial(
                    GENAI_MODEL.generate_content,
                    [image, prompt],
                    generation_config=genai.GenerationConfig(
                        response_mime_type="application/json",
                        response_schema=ImageAnalysisResult,
                    ),
                )
            )
        except Exception as e:
            print(f"エラーが発生しました。60秒待って再試行します。: {e}")
            time.sleep(60)
            continue

        # レスポンスをパースする
        try:
            response_dict = json.loads(response_text)
            markdown_text = response_dict["markdown_text"]
            there_is_chart_or_table = response_dict["there_is_chart_or_table"]
        except Exception as e:
            markdown_text = response.text
            there_is_chart_or_table = True
            is_error = True
            print(f"responseのパース時にエラーが発生しました。: {e}")

        break

    return markdown_text, there_is_chart_or_table, is_error

async def process_image_file(image_filepath):
    async with semaphore:
        basename = image_filepath.name
        stem, _ = basename.split(".")
        pdf_index, page_index = stem.split("-")
        index = f"{pdf_index}-{page_index}"
        markdown_filepath = MARKDOWN_DOCUMENTS_DIR / f"{stem}.md"

        if pdf_index not in printed_pdfs:
            printed_pdfs.add(pdf_index)
            current_time = datetime.now().strftime("%Y年%m月%d日%H時%M分%S秒")
            print(f"\n===== pdf{pdf_index} - {current_time}=====")

        print(f"{int(page_index)},", end="")

        (markdown_text,
        there_is_chart_or_table,
        is_error,) = await convert_image2markdown(image_filepath)

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

    else:
        markdown_text = read_txt_file(markdown_filepath)
        there_is_chart_or_table = True
        is_error = False

    return {
        "pdf_index": pdf_index,
        "page_index": page_index,
        "markdown_text": markdown_text,
        "there_is_chart_or_table": there_is_chart_or_table,
        "is_error": is_error,
    }


# Create and launch asynchronous tasks for all image file paths
tasks = [process_image_file(fp) for fp in image_filepath_list]
results = await asyncio.gather(*tasks)
# Filter out any None results (files not in target_index_list)
rows = [res for res in results if res is not None]

# Claudeによる「グラフ/表の文字起こし」はほぼ同様のため略

③ チャンク分割 & ④ Embedding (ベクトル化)

3-4

次に、③ チャンク分割です。
まず、チャンク分割に関しては、Gemini-2.0-flashで実施しています。前述したように、Geminiはコスト面で大変優れており、入力, 出力ともに膨大な文字量となるチャンク分割においては性能/コスト面で高いパフォーマンスを発揮してくれました。

チャンク分割は以下のようなプロンプト及びプログラムで実行しています。
(処理時間短縮のため一部で非同期処理を行っています)

あなたは、企業のPDF文書から抽出されたテキストを分析し、意味のある区分に整理する優秀な文書アナリストです。以下の指示に従って作業を進めてください。

与えられる情報:
<markdown_text>
{{markdown_text}}
</markdown_text>

<company_name>
{{company_name}}
</company_name>

タスク:
1. 上記のmarkdownテキストを分析し、意味の塊ごとに分割してください。
2. 分割する際は、元の文脈や意味が失われないよう注意してください。
3. 見出しで分割する場合は、上位の見出しも含めてください。
4. 表がある場合は、必ずその表に含まれる値をそのまま変えずにMarkdown形式で出力してください。また、その表の直前にその表がどのような情報を含む表なのかを記述してください。
5. 全く意味を持たない連続した記号が登場する場合があります。その場合は、その記号を取り除いたうえで出力してください。

出力形式:
- 結果は文字列のリストとして出力してください。
- 各文字列は、一つの意味のある区分を表します。
- リストはJSON形式で出力してください。

処理手順:
分析プロセスを<analysis_process>タグ内に記述してください。以下の手順に従って分析を行ってください:

1. PDF本文の構造を分析し、主要なセクションと小セクションを特定し、リストアップします。
2. 各セクションの内容を確認し、関連性のある情報をグループ化します。
3. 各区分に{{company_name}}の値が含まれていることを確認します。
4. 区分ごとに、文脈や意味が保たれているか確認します。
5. 必要に応じて、上位の見出しを含めて文脈を明確にします。
6. 表がある場合は、それらを特定し、Markdown形式の表としてそのままコピーして出力する計画を立てます。
7. 最終的な区分をJSON形式のリストとして構成します。

上記の手順に従って、PDF本文を分析し、適切に分割された区分のリストを作成してください。
各ステップでの考察や判断を<analysis_process>タグ内に記述し、最終的な出力をJSON形式で提示してください。分析プロセスの記述は長くなっても構いません。

出力構造の例:

{
  "analysis_process": "ここに分析プロセスを記入します",
  "sections": ["section1", "section2", "section3", ...]
}
class SplittedText(typing.TypedDict):
    analysis_process: str
    chunk_list: list[str]


def split_page_into_chunk(text, company_name):
    input_text = prompt_template.replace("{{markdown_text}}", text).replace(
        "{{company_name}}", company_name
    )

    result = GENAI_MODEL.generate_content(
        input_text,
        generation_config=genai.GenerationConfig(
            response_mime_type="application/json",
            response_schema=SplittedText,
            max_output_tokens=MAX_OUTPUT_TOKENS,
        ),
    )
    try:
        result_dict = json.loads(result.text)
    except:
        print(f"Error happend. result: {result}")
        result_dict = {"analysis_process": "", "sections": []}

    return result_dict


async def async_split_page_into_chunk(text, company_name, semaphore):
    async with semaphore:
        return await asyncio.to_thread(split_page_into_chunk, text, company_name)


async def process_page(row, semaphore):
    company_name = row["company_name"]
    result_dict = await async_split_page_into_chunk(
        row["text"], company_name, semaphore
    )
    analysis_process = result_dict["analysis_process"]
    chunk_list = result_dict["chunk_list"]

    return {
        "pdf_filename": row["pdf_filename"],
        "company_name": company_name,
        "pdf_index": row["pdf_index"],
        "page_index": row["page_index"],
        "part_index": row["part_index"],
        "analysis_process": analysis_process,
        "chunk_list": chunk_list,
    }

# 非同期処理のタスクを作成
tasks = []
for _, row in df.iterrows():
    tasks.append(asyncio.create_task(process_page(row, semaphore)))

# 非同期処理を実行
responses = await asyncio.gather(*tasks)

# 非同期処理の結果を処理
rows_chunk_all = []
for result_dict in responses:
    for chunk in result_dict["chunk_list"]:
        rows_chunk_all.append(
            {
                "pdf_filename": result_dict["pdf_filename"],
                "company_name": result_dict["company_name"],
                "pdf_index": result_dict["pdf_index"],
                "page_index": result_dict["page_index"],
                "part_index": result_dict["part_index"],
                "text": chunk,
                "analysis_process": result_dict["analysis_process"],
            }
        )

df_chunk_all = pd.DataFrame(rows_chunk_all)

次に ④ Embedding (ベクトル化)です。
ここで、③で得た各チャンクごとのベクトル表現を獲得しています。
なおEmbeddingモデルとしては、OpenAIのtext-embedding-3-largeを使用しました。

async def async_embed_text(text, model):
    """embed_textの同期処理をrun_in_executorで非同期化する。
    セマフォで並列実行数を制限する。
    """
    async with semaphore:
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, embed_text, text, model)

# 非同期タスクのリストとDataFrameのインデックス保存用リストを用意
tasks = []
row_indices = []
previous_pdf_index = None
for i, (df_idx, row) in enumerate(df.iterrows()):
    pdf_index = row["pdf_index"]

    # 非同期版 embed_text を呼び出すタスクを追加
    tasks.append(async_embed_text(row["text"], EMBEDDING_MODEL))
    row_indices.append(df_idx)

# gatherで全タスクを並列実行
results = await asyncio.gather(*tasks)

⑤ OpenSearchインデックスへ格納

次に、⑤ OpenSearchインデックスへ格納 です。
各チャンクを元のテキスト及びベクトルデータ等といっしょに格納しています。

なお今回、OpenSearchはハイブリッド検索を実現するためのツールとして使用しています。
ただハイブリッド検索を実現するためだけの手段であれば他にも手軽な方法は存在するのですが、今回は個人的な理由からOpenSearchを選定しました。(過去に業務内でOpenSearchを用いた検索システムの構築を行ったことがあり、なおかつそのときはコスト面の問題から実現することでできなかったハイブリッド検索を実現したいというごく個人的なものです。)

そのため、OpenSearchの詳細については本コンペの解法としての本筋から脱線してしまうのでここでは深く扱わず別の記事にまとめようと思います。

⑥ 問題のEmbedding (ベクトル化) & ⑦ ハイブリッド検索

6-7

ここからは、下段の回答パートへ移ります。

はじめに、前述のパートでPDFのチャンクに対して適用したのと同じくtext-embedding-3-largeによるベクトル化を問題データに対して実行します。

次に、問題データのテキスト及びベクトルをOpenSearchに対するクエリとして発行し、ヒットしたチャンクを取得します。

※ 補足

細かい工夫ポイントとしては、ここで会社に関するフィルタも行っています。
手順としては、

    1. 事前に各PDFファイルの先頭数ページに基づき会社名の抽出を行う
    1. 各問題が会社名リストのうちどの会社についての質問を投げかけているかを分類する

もちろん問題にほって分類不可能な場合もあるため、そのときはフィルタなしで検索を実行します。
この工夫により、仮にハイブリッド検索の結果、問題で聞かれている企業とは別の企業のドキュメントがヒットしたとしても、会社フィルタでそれらの結果を除外することが可能となります。

各ステップで使用しているプロンプトは以下のとおりです。

■ 1. 事前に各PDFファイルの先頭数ページに基づき会社名の抽出を行うプロンプト

<instruction>
次の<input>に与える文章は特定の会社/企業グループに関して述べられています。
どの会社/企業グループについて述べられているのかを出力してください
</instruction>

<constraints>
- 会社名は日本語の正式な名称とすること
- 元の会社名に「株式会社」「グループ」等の接頭辞、接尾辞が含まれる場合は、出力にもその接頭辞、接尾辞を含めること
</constraints>

<example>

<sample_input>
101-0021 東京都千代田区外神田二丁目2番15号
https://www.welcia.co.jp地域No.1の
地域ナンバー1の健康ステーションへ
統合報告書 2024
ウェルシアホールディング株式会社
</sample_input>


<sample_output>
ウェルシアホールディング株式会社
</sample_output>

</example>

<input>
{pdf_text}
</input>

■ 2. 各問題が会社名リストのうちどの会社についての質問を投げかけているかを分類する

<instruction>
これからあなたに入力として<query>と<company_name_list>と与えます。
<query>には特定の会社に関する質問文が記載されています。
<company_name_list>には会社名のリストが記載されています。

あなたはその<query>がどの会社に関する質問であるかを分析し、<company_name_list>から選んでその会社名を出力してください。
その際、下記の<constraints>に従って分析と出力を実行してください

<constraints>
- 会社名は<company_name_list_str>の中から選ぶこと
- <query>がどの会社に関する質問であるかを正確に判断できない場合は、"不明"と出力すること
</constraints>

</instruction>

<query>
{{query_text}}
</query>

<company_name_list>
{{company_name_list_str}}
</company_name_list>

⑧ 関連文書の抽出 & ⑨ 回答の生成

8

次に、⑧ 関連文書の抽出です。
ここではいくつか工夫したポイントがあるので、まずはそれらのポイントを列挙します。

  • a. チャンクだけではなく、そのチャンクを含む前後のページまで生成AIに参照させる
  • b. 与えた文章の中に正解の根拠となる文章を含むか否か事前に判定する

『a. チャンクだけではなく、そのチャンクを含む前後のページまで生成AIに参照させる』では、チャンクだけだと前後の文脈が欠落していることから不十分な情報で不正確な回答をしてしまうことを懸念してこの対応を取りました。つまり、「検索はチャンク単位で実行」するが、「生成AIに与えるデータはページ単位 (前後のページ)」といった具合に検索単位と生成AIによる参照単位を別々に設けています。

『b. 与えた文章の中に正解の根拠となる文章を含むか否かを事前に判定する』では、つまるところ検索はうまく行っているのか(=ちゃんと回答の根拠となる箇所を検索上位にヒットできているのか)を確認しています。当然ながら、検索は完璧ではないので上位にヒットしたチャンクの中に、回答の根拠となる情報が含まれない場合もままあります。その判定を生成AIを使って事前に実行し、回答の根拠が参照文書の中に存在すると判定された場合にのみそのまま答えを出力してもらいます。一方で、回答の根拠は"ない"と判定された場合はというとgemini-2.0-thinkingのモデルを用いてその会社(以前のセクションで述べた会社の分類結果に従う)のPDF文書を全て与えて回答の根拠となる部分のみを抜き出してもらうというというやり方に分岐します。gemini-2.0-thinkingは現在プレビューの状態ではなるものの、非常に長い文章であっても比較的正確に回答の根拠を抽出することが可能です。そうやって抽出した文書を改めて生成AIに与えることで、今度こそ回答を生成してもらうというフローを回しています。

■ 参照文書の中に回答の根拠があるか否かを判定するプロンプト

あなたは、議会での質疑に備えて関連情報を収集する、非常に優秀な政治家の秘書です。
あなたの任務は、与えられた参考資料と質問を分析し、質問に回答するために必要なエビデンスがすでに揃っているかどうかを判断することです。

以下は、あなたが分析する必要がある参考資料です:

<reference_document>
{{reference_document}}
</reference_document>

そして、これがあなたが対応すべき質問です:

<problem>
{{query}}
</problem>

指示:

1. 質問<problem>と参考資料<reference_document>を注意深く分析してください。
2. 質問<problem>に回答するために必要なエビデンスが参考資料<reference_document>ですでに揃っているかどうかを判断してください。
3. 出力形式は、以下に例に示すような"there_is_sufficient_evidence"をキーに持つJSON形式で出力してください。
4. 必要なエビデンスが揃っている場合は、"there_is_sufficient_evidence"の値にbool値のtrueを入れてください。
  - 必要なエビデンスが揃っていない場合は追加の参考資料<reference_document>を提示してください。これで正解を導くためのエビデンスがすべて揃ったと絶対的な確信がある場合にのみtrueを出力してください。
5. 必要なエビデンスが揃っていない場合は、"there_is_sufficient_evidence"の値にbool値のfalseを入れてください。

出力形式:

{"there_is_sufficient_evidence": true or false}

■ 正解を生成するプロンプト

あなたは高度な分析能力を持つAIアシスタントです。
与えられた問題に対して、参照文書を基に正確で簡潔な回答を提供することが求められています。

以下に参照文書<reference_document>と問題<problem>を示します:

<reference_document>
{{reference_document}}
</reference_document>

<problem>
{{query}}
</problem>

回答を作成する際は、以下の手順に従ってください:

1. 質問と参照文書を注意深く読み、関連する情報を特定してください。
2. "thinking_process"内で、回答を導き出すための詳細な思考過程を日本語で示してください。以下の点に特に注意してください:
   - 参照文書から関連する部分を引用し、それが回答の根拠としてふさわしいを説明してください。
   - 計算が必要な場合は、各ステップを番号付けして明確に示してください。
   - 四捨五入や小数点以下の桁数指定がある場合は、正確に従い、その過程を詳細に説明してください。
   - 問題<problem>に単位が含まれる場合は、回答にもその単位を含めてください。
3. "answer"は以下の制約に従って出力してください:
   - 単語または短い句で回答し、文章にしないでください。
   - 複数の回答がある場合は、読点(、)で区切ってください。
   - 参照文書から回答を導き出せない場合は、「分かりません」と回答してください。
     - 誤った回答を出力してしまった場合は重いペナルティが課されます。回答の正しさに絶対的な確信がない限りは「分かりません」と回答してください。
   - 回答の長さは50文字以内にしてください。
   - 回答は問題<problem>に指定がない限りは約したりすることなく、正確な値を出力してください。
   - 問題<problem>に単位が含まれている場合は、回答にも必ずその単位を含めてください。
4. 出力は以下のキーを持つjson形式で行ってください:
   - "thinking_process": 回答を導き出すまでの思考過程
   - "answer": 問題<problem>に対する回答<answer>

それでは、与えられた質問に対して回答を作成してください。

■ PDF全体から正解の根拠となる文書を抽出するプロンプト

<role>
あなたは著名な政治家の秘書として、非常に優秀です。
主な職務は、議会での質疑に備え、関連情報を参考資料から収集することです。あなたの任務は、以下の参考資料と質問を分析し、質問に回答するために必要な関連情報をすべて抽出して提示することです。
</role>

<task>
回答の候補となる情報が記載された参考資料は以下の通りです。

<reference_document>
{{reference_document}}
</reference_document>

そして、あなたが対応すべき質問は以下の通りです。

<problem>
{{query}}
</problem>

</task>

<instruction>
1. 質問と参照文書を注意深く分析してください。
2. 参照文書から質問の回答に役立つと思われる関連情報をすべて抽出してください。
3. 質問に関連する表データを見つけた場合は、変更を加えずに元のフォーマットのまま含めてください。
4. 抽出した各情報について、参照文書内の該当ページのページ番号を記録してください。
5. 表データを見つけた場合は、表が何を意味するのかを簡単に説明し、関連する測定単位を含めます。
6. あなたはあくまでリサーチャーです。あなたは質問に対する回答を出すのではなく、質問に対する回答に必要な情報を抽出することです。自分自身の解釈は含めずに客観的な情報だけを淡々と抽出してください。
</instruction>

<task_execution_process>
最終的なアウトプットを提供する前に、回答の根拠を見つけ出すための計画を立て<plan>タグで囲んだ場所に記入します。
<plan>セクションでは、以下のことを行います。

a. クエリの主な要点を要約する
b. クエリから主要トピックまたはキーワードをリストアップする
c. 要求された特定のデータタイプ(統計、日付、名称など)を記載する
d. 参照文書をどのような戦略で戦略の概要を記載する

<plan>が完了したら、その<plan>をもとに、<extracted_information>に記載されたフォーマットで回答の根拠となりうる情報を抽出してください。

</task_execution_process>

分析後、抽出した情報を以下のフォーマットで提示してください

<example_output_format>

<plan>
[クエリと参照文書の詳細な分析(関連情報の特定方法の説明)]
</plan>

<extracted_information>

# ページ: [番号]

## 概要

[ここに、解答の根拠の概要を記載する]

## 書き起こし

[ここに解答の根拠となる情報を元の文書の該当箇所からそのまま転記する]

# ページ: [番号]

## 概要

[ここに、解答の根拠の概要を記載する]

## 書き起こし

[ここに解答の根拠となる情報を元の文書の該当箇所からそのまま転記する]

(必要に応じて他のページの情報を続ける)

</extracted_information>

</example_output_format>

<notice>
- 関連する情報をすべて漏れなく記載すること。
- 表データはすべて元のフォーマットで保存すること。
- 各情報には必ずページ番号を記載すること。
- 表データには説明を記載し、該当する場合は単位を含める。
- 関連情報が発見できない場合は、「回答の根拠となりうる情報が見つからなかった」旨を明確に記載する。
</notice>

さいごに

最後に、この度素晴らしいコンペを開いてくださった運営のみなさま、ありがとうございました。
おかげさまで夢中になって楽しみながら、各種生成AIの強みやRAGについて学習することができました。
今後も楽しいコンペを楽しみにしております!

Discussion