Zenn
📘

【SIGNATE】第3回金融データ活用チャレンジ 42th Solution

2025/02/15に公開
7



About Competition

2025年1月15日~2月12日の間、SIGNATEのLLMコンペ (金融庁共催)第3回金融データ活用チャレンジ が開催されました。

https://signate.jp/competitions/1515

様々な企業のESG(環境・社会・ガバナンス)レポートや統合報告書を入力として、それに関連する質問に対して、自動的かつ正確に回答できるRAGシステムを構築し、その回答の正確性を競うコンペでした。

私は、ちょうど LangChainとLangGraphによるRAG・AIエージェント[実践]入門 を冬休みに読み進めていたこともあり、初体験となるLLMコンペに書籍片手に参加させて頂きました。

順位 (参加人数) PublicLB PrivateLB
42 (1544) 0.79 0.74

結果は、 Public→Private で若干 shake-down したものの、上位3%に入り銀メダルを獲得 (※執筆時点でまだ確定はしていませんが) 出来ました。初めてのLLMコンペにしては望外な結果だったかな と思います。

LLMコンペどころか、LLMを使ったプログラム自体が始めての経験で、コンペ期間中に得るものが沢山あったので、忘れないうちに書き残しておきたいと思います。

使用したツール

今回のコンペでは、マイクロソフト社様を始めとして、様々な企業からツールを無償で提供頂けました。私はその中で、以下のAPIを使わせて頂きました。

種類 提供元 モデル名
Embedding マイクロソフト社様提供 Azure OpenAI text-embedding-3-large
Chat 所属会社契約 Azure OpenAI GPT-4o (2024-08-06)
Chat 所属会社契約 Gemini 1.5 Pro (gemini-1.5-pro-002)

マイクロソフト社様から、Azure OpenAI GPT-4o mini もご提供頂いてたのですが、ハンズオンにて「回答生成 (Generate) 処理でのみ利用可能であり、前処理での利用は不可」とのアナウンスがあったとの事だった為、ちょうど前処理の方式変更 (non-LLM→LLM全振り) の検討していたところだったので、途中から Chat API は私が所属している会社で契約している GPT-4o、及び Gemini 1.5 Pro を使いました。

上記以外のツールについては、コンペのルール的にはGUIツールでなければ無制限利用可能だったのですが、基本的には無償利用可能なOSSを活用させて頂きました。

Solution概要

全体概要図


全体概要図

解法のポイント

  • 前処理
    • pdfをページ毎に画像としてLLMに与えてテキストに変換(文字起こし)させた。
    • ドキュメント毎にどの会社のドキュメントかを特定させて、company_list としてメタデータを保持した。
  • ベクトル化
    • 前処理にて変換したテキストデータを、Embedding API を使って、3種類のチャンクサイズにて ChromaDB に格納して、多様な質問に対して検索で引っかかりやすくした。
  • パイプライン
    • クエリ拡張 (QueryAlbumentator)
      • 与えられた質問をLLMを使って2つの違う表現に言い換えて、albumented_queries を作成。ベクトル検索時にヒットしやすくした。
      • 合わせて company_list の中のどの company に関する質問なのかを特定させて、検索対象を特定させた。
    • コンテキスト作成 (ContextGenerator)
      • QueryAlbumentator により得られた albumented_queriescompany を入力として、3つの ChromaDB に対して検索し、メインプロンプトに含める context を作成した。
    • 回答作成 (AnswerGenerator)
      • 質問に対する答えを context から (feedback がある場合はそれも踏まえて) 生成させる。その際に、どういうロジックでその答えに行きついたのか、根拠 (reason) も出力させる。
    • レビュー (AnswerReflector)
      • AnswerGenerator により作成された答えと、その根拠を見て、レビューをする。NGであれば feedback を付して AnswerGenerator に差し戻す。OKであれば答えと出力する。

前処理

ここからパート毎の詳細な実装、及び課題・やりのこしたこと等を書き連ねていきます。まずは前処理の部分です。


前処理部

LLMによるテキスト変換

fitzを使って1ページずつ画像として読み取り、BASE64エンコードに変換し、リクエストbodyに貼り付けてAPIで流し込んで、Gemini 1.5 Pro に Markdown 形式で文字起こし (キャプション) させた。以下、該当処理部の抜粋です。

LLMによるテキスト変換実装コード
with fitz.open(file_name) as pdf_fitz:

  # pdfのページを画像として変数に格納
  scale_factor = 5              # 72 * 5 = 360DPI
  page_fitz = pdf_fitz[page_number]
  matrix = fitz.Matrix(scale_factor, scale_factor)
  pix = page_fitz.get_pixmap(matrix=matrix)
  image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)

  # 変数に格納した画像を、BASE64エンコードする
  buffered = io.BytesIO()
  image.save(buffered, format="JPEG")
  encoded_string = base64.b64encode(buffered.getvalue()).decode('utf-8')

  messages = {
      "contents": [
          {
              "role": "user",
              "parts": [
                  {
                      "inlineData": {
                          "mimeType": "image/jpeg",
                          "data": encoded_string
                      }
                  },
                  {
                      "text": "この画像は様々な企業に関するESGレポートや統合報告書の中の1ページです。この画像に含まれるすべての情報を、可能な限り正確に文字起こししてください。以下の指示に厳密に従ってください。\n\n* **出力形式:**出力はMarkdown形式でお願いします。但し、 <img> タグは使用しないでください。\n* **正確性最優先:** 画像に表示されているテキストをそのまま文字起こしし、画像に含まれる情報、図表、テキストなどを詳細に記述してください。一切の変更、修正、要約、推測を行わないでください。\n* **省略の禁止:** 書き起こし漏れは厳禁です。画像に表示されているものを、漏れなく書き起こしてください。\n* **数字の保持:** 数字は画像に表示されているとおりに正確に転記してください。単位や小数点も省略せずに含めてください。\n* **特殊文字の対応:** 特殊文字や記号も、可能な限り正確に文字起こししてください。\n*  **図・表・グラフの inclusion:** 図や表も可能な限りテキストで表現してください。グラフが入力にある場合は、それをあなたが読み取って、Markdownの表形式に変換出力してください。\n* **図・表・グラフの勝手な統合の禁止:** 図・表・グラフなどのオブジェクトが近くに複数配置されている場合、それらを勝手に1つの表に統合しないでください。再現性を重視し、1オブジェクトを、1つの表に出力してください。\n*  **句読点の保持:** 画像に句読点がある場合は、それも含めてください。\n\n"
                  }
              ]
          }
      ]
  }
  headers = {"Content-type": "application/json", "api-key": self.CFG['chatai_api_token']}

  try:
      res = self.send_request_with_retry(headers, messages)

ここでのポイントは以下の2点だったかと思います。

  • fitz.get_pixmap() による読み込みの解像度は default だと 72dpi なのだが、それだと LLM がドキュメントの仔細な文字の読み違えが見受けられた。財務諸表の細かい文字・数字等は読み違えが致命的だったので、最終的には scale_factor = 5 と設定して、72 x 5 = 360dpi に変換して処理させた。但し72dpi->360dpi で LLM による処理時間は 約1.5倍 に伸びた。
  • 図表は LLM が読み取って解釈して文字起こしをしてほしかったのに、肝心のところを imageタグ として文字起こしをサボったりしてしまったので、試行錯誤を重ねた末、上記の様なまどろっこしいプロンプトに行き着きました。

メタデータの付与 (ドキュメントの会社名)

ドキュメント毎の冒頭10ページまでを文字起こしした内容を踏まえて、GPT4-o に会社名を特定させて、それをDocumentsのメタ情報に設定しました。以下、実際の処理部抜粋です。

LLMによるメタデータの付与実装コード
preprocess_prompt = ChatPromptTemplate.from_template('''\
様々な企業に関するESGレポートや統合報告書の冒頭数ページをcontextに示しています。あなたはこの文脈を解析し、この文脈が、どの企業のレポートなのか回答してください。

## 指示:
- 回答するに当たっては、与えられた文脈を冷静に解釈して、論理的に考えて回答してください。
- 文脈中の情報だけから回答を導いてください。

## 文脈: """
{context}
"""
''')

if page_number == 10:
    chain = preprocess_prompt | llmmodel.with_structured_output(ReportAnalysis)
    analysis = chain.invoke({"context": content})


ベクトル化


ベクトル化処理部

RecursiveCharacterTextSplitter を使って、以下の3パターンのchunk-sizeでベクトル化したものをChromaに格納しました。

値自体にそこまで根拠は無いのですが、気持ちとしては、(chunk_size, chunk_overlap) = (500, 100) をベースとして、なるべく多様性のあるデータを引っ張ってこれるように、開始点が揃わないように設定しました。

chunk_size chunk_overlap 開始点
500 100 500 - 100 = 400 ずつ開始点が移動。
330 50 330 - 50 = 280 ずつ開始点が移動。
810 100 810 - 100 = 710 ずつ開始点が移動。

あとは、一気に処理をしようとするとAPIエラーが起きた時に無に帰してしまうので、add_documents() を使ってバッチ処理をさせました。

ベクトル化実装コード
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", " ", ""],
    chunk_size=chunk_size, 
    chunk_overlap=chunk_overlap
)

for i in tqdm(range(0, len(docs), batch_size), leave=True):
    batch_docs = docs[i:i+batch_size]
    retries = 0
    max_wait = 8
    while retries < max_retries:
        try:
            db.add_documents(batch_docs)
            break
        except Exception as e:
            retries += 1
            wait_time = min(2**retries, max_wait)
            time.sleep(wait_time)

パイプライン

パイプライン実装は、今回最大のモチベーションであり、今回のコンペ参加のきっかけとなった書籍 の実践の場と考えていたので、LangGraph で実装しました。


パイプライン

クエリ拡張 (QueryAlbumentator)

与えられた質問自体は、どことなく言葉足らずだったり、目的語がはっきりしないものもあったので、そのままベクトル検索しても不十分と考え、質問文をLLMを使って増幅させることとしました。

また、合わせて、前処理でリストアップした company_list の中のどの company に関する質問なのかを特定させて、検索対象を特定させることにしました。

以下、実装コードの一部抜粋です。

クエリ拡張実装コード
class QueryAlbumentator:
    def __init__(self, llmmodel, max_retries):
        self.llmmodel = llmmodel
        self.max_retries = max_retries

    def run(self, query: str, company_list: list) -> QueryAlbumentationOutput:
        # プロンプトの組み立て
        query_albumentation_prompt = ChatPromptTemplate.from_template('''\
        様々な企業に関するESGレポートや統合報告書を含んだベクターデータベースから関連文書を検索するために、入力された質問から、3つの異なる検索クエリを生成してください。距離ベースの類似性検索の限界を克服するために、ユーザーの質問に対して複数の視点を提供することが目的です。1つ目は入力された質問をそのまま質問そのまま変換せず出力としてください。2つ目は入力された質問の対象・主旨が変わってしまわない範囲で書き換えて出力してください。残りの1つは与えられた質問をあなたの解釈で拡張して作成してください。結果は要素数が3つの list として、albumented_queries に格納してください。
        また、与えられた質問が、以下の企業リストの中のどの企業に関するものかを特定してください。企業リストに含まれる企業名のいずれかを回答として出力してください。回答はlist形式で、特定し切れない場合は最大3つまでのlistの要素に入れて、出力してください。どうしても特定できない場合は、全ての企業リストの要素を入れたリストを出力してください。結果は 1つであったとしても 変数 company に list で格納してください。

        ## 企業リスト: """
        {company_list}
        """

        ## 質問: """
        {query}
        """
        ''')

        chain = query_albumentation_prompt | self.llmmodel.with_structured_output(QueryAlbumentationOutput)

        retries = 0
        while retries < self.max_retries:
            try:
                return chain.invoke({"query": query, "company_list": company_list})



どうしても、質問によっては会社を一つに絞り切れなかったり、また、以下の様な、人間が見ても「いったいこれはどこの会社のこと???」という質問もあったので、そういった場合はあえて絞らない可能性を考慮し、上記の様なプロンプトに行きつきました。

95,セグメント別営業利益において、2014年〜2024年の11年間の海外市場の平均額は何億円か。少数第一位を四捨五入して答えよ。

コンテキスト作成 (ContextGenerator)

albumented_queriescompany、及び ベクトル化の実装で作った Chromeオブジェクトのリスト vectordbs を入力として、VectorDBs からコサイン類似度上位 (実際は num_result=30 とした) を取得し、更にそのDocuments から重複を削除して、BM25スコアを算出して、閾値以上 (実際は bm25_threshold=0.3 とした) を取得して、context を作成しました。以下実装の一部抜粋です。

コンテキスト作成実装コード
class ContextGenerator:
    def __init__(self, llmmodel, num_results, bm25_threshold):
        self.llmmodel = llmmodel
        self.num_results = num_results
        self.bm25_threshold = bm25_threshold

    def _bm25_filtering(self, expanded_queries: List[str], retrieved_contents: List[str]) -> List[str]:
        # 検索結果をフラット化し、重複を削除
        documents = []
        doc_no = 0
        for sublist in retrieved_contents:
            for doc in sublist:
                doc = doc.page_content
                doc_no += 1
                if doc not in documents:
                    documents.append(doc)

        # 各クエリに対するBM25スコアを計算
        query_scores = []
        for query in expanded_queries:
            scores = []
            for doc in documents:
                scores.append(self._calculate_bm25_score(doc, query))
            query_scores.append(scores)

        # 各ドキュメントのスコアを統合(最大値)
        aggregated_scores = []
        for doc_idx in range(len(documents)):
            doc_scores = [query_scores[q_idx][doc_idx] for q_idx in range(len(expanded_queries))]
            aggregated_scores.append(max(doc_scores))

        # スコアとインデックスのペアを作成
        indexed_scores = [(score, index) for index, score in enumerate(aggregated_scores)]
        # 閾値以上のスコアを持つものをフィルタリング
        filtered_scores = [(score, index) for score, index in indexed_scores if score >= self.bm25_threshold]
        # スコアの降順にソート
        sorted_scores = sorted(filtered_scores, key=lambda x: x[0], reverse=True)
        # ソートされたインデックスを使用してドキュメントを取得
        filtered_documents = [documents[index] for _, index in sorted_scores]

        return filtered_documents

    def run(self, albumented_queries: list[str], company: list[str], vectordbs: list[str]) -> list[str]:
        # ベクトル検索
        search_results = []
        for vectordb in vectordbs:
            retrieved_contents = [vectordb.as_retriever(search_kwargs={
                    "k": self.num_results,
                    "filter": {"company": {"$in": company}},
                }).get_relevant_documents(q) for q in albumented_queries]
            filtered_documents = self._bm25_filtering(albumented_queries, retrieved_contents)
            search_results.extend(filtered_documents)
        
        return search_results



このやり方は、クエリ拡張 x マルチvectorDB にしたので当たり前なのですが、ドキュメントの同じ箇所が複数回取得される課題があると思っており、例えばキーワードを返すような質問には強いと思いますが、一方で、文脈全部を読み解く必要があるような、例えば

34,サントリーグループサステナビリティサイトにおいて、KPMGあずさサステナビリティ株式会社による第三者保証の対象となっている数値はいくつありますか。

この質問は、ドキュメントの複数ページに渡って、第三者保証の対象となっている数値に ★印 が付いており、その数を数える必要がある問題だったのですが、この様なある一定部分を網羅的に読み解く必要がある質問には、このcontext作成方法だと絶対答えられないだろうなぁ と思いながらも、一方で、今回の課題は全体的にドキュメントの特定部分を検索してくる質問が多かった印象だったので、そこは割り切って進めてしまいました。

回答作成 (AnswerGenerator)

問題のパートです。恥を忍んでコードを抜粋します。

回答作成実装コード
class AnswerGenerator:
    def __init__(self, llmmodel, max_retries):
        self.llmmodel = llmmodel
        self.max_retries = max_retries

    def run(self, query: str, contents: list[str], answers: list[str], feedbacks: list[str]) -> AnswerAndReason:
        # プロンプトの組み立て
        answer_generation_prompt = ChatPromptTemplate.from_template('''\
        ## 役割:
        様々な企業に関するESGレポートや統合報告書を含んだ、提示された文脈だけの内容を分析し、与えられた質問に対して正しい回答を作成する専門家です。以下のプロセスと指示に従って、メタ認知を駆使しながら推測を行ってください。

        ## 回答プロセス:
        1. 以下の全ての思考プロセスにおける計算・推論などを、変数 reason に<thinking>タグで囲んで、明示的に書き出してください。省略は許可されません。全てを書き出してください。
        2. 思考プロセスを明確なステップに分解し、<step>タグ内に記述してください。20ステップから始め、複雑な問題の場合は追加のステップを要求してください。
        3. 各ステップの後に<count>タグを使用して、残りのステップ数を示してください。0に達したら停止します。
        4. 各ステップを開始する前に、以前の回答とそれに対するフィードバックがあるか確認して下さい。フィードバックは、以前あなたが作成した回答に対するレビュワーからのフィードバックです。但し、レビュワーのフィードバックが正しいとは限りません。フィードバックがあった場合は、それを鵜呑みにするのではなくて、そのフィードバックを踏まえて改めて質問と文脈をよく確認し、回答作成のアプローチを行ってください。もしも前回と同じ回答を提示する場合は、レビュワーを説得するために、文脈から追加のエビデンスを引用し明示をすることにより、前回よりも定量的・具体的な根拠をもってレビュワーに対して反駁してください。
        5. 質問について、四捨五入が求められている質問かどうか判別してください。判別の結果が真の場合、四捨五入の仕方の統一の為に、必ず以下の **四捨五入が求められている問題についてのプロシージャ** を明示的に起動して、プロシージャに従ってステップバイステップで考えて推論を進めてください。あなたが知っている四捨五入の方法で勝手に推論を進めないでください。必ずプロシージャを起動して、それに従ってください。このプロシージャの過程についても、全てを明示的に書き出してください。
        6. 各ステップにおける結果について、与えられた質問に対する答えとしての評価を、第三者的な見地で評価してください。0.0から1.0の間の品質スコアを<reward>タグを使用して割り当ててください。このスコアを使用してアプローチを導いてください:
            - 0.8以上:現在のアプローチを継続
            - 0.5-0.7:軽微な調整を検討
            - 0.5未満:別のアプローチを試すことを真剣に検討
        7. 推論を継続的に調整し、進行に応じて戦略を適応させてください。
        8. 文脈から見つけられない場合は、戦略を変えて、与えられた質問の解釈を変更するなど、戦略を変更して、新しいアプローチを試してください。ステップが0になるまでは諦めずに、新しいアプローチで試し続けてください。
        9. 不確かな場合や報酬スコアが低い場合は、バックトラックして別のアプローチを試み、<thinking>タグ内で決定を説明してください。
        10. 可能な場合は複数の解決策を個別に探り、反省で各アプローチを比較してください。
        11. 全体的な解決策について最終的な反省を行い、効果、課題、解決策について議論し、最終的な報酬スコアを割り当ててください。
        12. ステップが0になっても分からない場合は、分からないと回答してください。
        13. 最終的な答えを格納する前に、いまいちど、あなたが reason に書いた論理と、answer に格納しようとしている結果が一致していることを確認して下さい。
        14. 最終的な答えを格納する前に、いまいちど、あなたが導き出した回答が、フィードバックを踏まえ、再度確認して下さい。但しフィードバックが正しいとは限らず、間違えている可能性があります。フィードバックを踏まえて、あなたの回答を修正するかどうかは、あなたの判断に任せますが、もしも前回と同じ回答を提示する場合は、レビュワーを説得するために、文脈から追加のエビデンスを引用し明示をすることにより、前回よりも定量的・具体的な根拠をもってレビュワーに対して反駁してください。
        15. 最終的な答えを 回答 (answer) に格納するとともに、最終的な報酬スコアを割り当ててください。
        16. 繰り返しになりますが、以上の全ての思考ステップを記述してください。省略は許可されません。

        ### 四捨五入が求められている問題についてのプロシージャ
        - 手順1: 質問で指定されている単位で計算する。質問が、"%" で提示することが求められている場合は、割合で計算していた場合は 100 を乗じて、単位を % にする。
        - 手順2: 質問の表現が以下のどのパターンになっているか("で" or "を" or "まで")に着目し、四捨五入した結果が何桁になるべきかを特定する。
            |質問の表現|求められている結果|
            |-------|-------|
            | 小数第三位**を**四捨五入 | 小数点第二位 |
            | 小数点第二位**を**四捨五入  | 小数点第一位 |
            | 小数第一位**を**四捨五入  | 整数 |
            | 小数第二位**で**四捨五入  | 小数点第一位 |
            | 小数点第1位**まで**の数字で四捨五入  | 小数点第一位 |
        - 手順3: 以下のPythonでの実装例の通り、求められている四捨五入した後の結果に応じて四捨五入を行う。
            - 求められている結果が **整数** の場合
                ```
                from decimal import Decimal, ROUND_HALF_UP
                rounded_number = number.quantize(Decimal('1'), rounding=ROUND_HALF_UP)
                rounded_number   # Decimal('3')
                ```
            - 求められている結果が **小数点第一位** の場合
                ```
                from decimal import Decimal, ROUND_HALF_UP
                rounded_number = number.quantize(Decimal('0.1'), rounding=ROUND_HALF_UP)
                rounded_number   # Decimal('3.1')
                ```
            - 求められている結果が **小数点第二位** の場合
                ```
                from decimal import Decimal, ROUND_HALF_UP
                rounded_number = number.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
                rounded_number   # Decimal('3.14')
                ```
        - 手順4: 得られた答えが、手順2 で特定した、求められている結果の桁数と同じであることを確認する。合っていない場合は、手順1 に戻る。

        ## 指示:
        1. **文脈の確認**: 以下に与えられた文脈を確認します。
        2. **質問の分析**: 質問の文脈やキーワードを分析します。
        3. **推測**: 質問に対する回答を明記します。

        ## 遵守事項:
        - 回答(answer)については 53トークン 以内で回答してください。これは全てに優先して守ってください。
        - 聞かれている質問に対して端的に答えてください。聞かれていない事については一切答えないでください。
        - 聞かれている質問に対して、必要かつ十分な表現で答えて下さい。冗長な表現はダメですし、必要な表現が足りていないのもダメです。
        - 与えられた文脈の内容を踏まえて、質問に関連した文章やキーワードがどこに含まれているかを逐一、注意深く確認し、ハルシネーションしないようにしてください。
        - 四捨五入することが求められている質問については、上述の **四捨五入が求められている問題についてのプロシージャ** を明示的に起動して、プロシージャに従ってステップバイステップで考えて推論を進めてください。あなたが知っている四捨五入の方法で勝手に推論を進めないでください。必ずプロシージャを起動して、それに従ってください。
        - 数量で答える問題の回答には、質問に記載の単位を使ってください。
        - 質問に含まれる指示に忠実に従ってください。カタカナで答えよと言われている場合は、全てカタカナで答えてください。
        - 文脈中の表現をそのまま用いてください。"「」" や "『』"、及び "。" などを、勝手に省略しないでください。
        - 文脈中に答えの手がかりが見つからないと判断される場合はその旨を「分かりません」と答えてください。
        - 理由(reason)についてはトークン制限はありません。文脈中の表現を引用するとともに、そこからあなたが回答を導くに至った論理を丁寧に解説してください。数式を計算する事で導出した場合は、結果だけでなく、その数式も提示してください。
        - 質問で「最も高い」、「最も小さい」、「すべてを挙げてください」、「2番目の」、「最も多い」等の表現で求められている場合、この質問に対して正確に答える為には、文脈全てを網羅的に確認した上で、回答を導出しないと不正確になります。そういう質問については、文脈を網羅的に確認したうえで回答を導出するとともに、最終的な結果だけでなく網羅的に確認した途中結果も含めて、エビデンスとして提示してください。
        - 以下に、いくつかの質問と最適な回答の例を示します。この形式を参考にして回答を生成してください。

        ### 質問と回答の例:
        - 質問: 大成温調の2024年3月期の売上高成長率が前期と同じであると仮定した場合、2025年3月期の売上高は何百万円になると予測できますか?十万円の位で四捨五入して答えてください。
        回答: 80,228百万円
        - 質問: 2020年度から2023年度までの日本化薬グループのリサイクル率の平均は何%か、小数第二位を四捨五入して答えよ。
        回答: 83.2%
        - 質問: ダイドーグループのグループ会社の中で「株式会社」という文字を除き、ひらがなのみで構成された会社名を答えよ。(回答には株式会社を含めること)
        回答: 株式会社たらみ
        - 質問: 東洋紡の取締役の在籍期間において、0~3年と4~9年ではどちらの方が取締役の人数が多いか
        回答: 0~3年
        - 質問: エクシオグループの本社所在地は何区ですか?
        回答: 渋谷区

        ## 文脈: """
        {context}
        """

        ## 質問: """
        {query}
        """

        ## 以前の回答: """
        {answers}
        """

        ## 以前の回答に対するフィードバック: """
        {feedbacks}
        """
        ''')

        chain = answer_generation_prompt | self.llmmodel.with_structured_output(AnswerAndReason)

        retries = 0
        while retries < self.max_retries:
            try:
                return chain.invoke({"query": query, "context": contents, "answers": answers, "feedbacks": feedbacks})



こちらのプロンプトについては、私は参加していませんでしたが、前回の RAG-1グランプリこちらの記事 をベースに、以下の様な観点で色々と試行錯誤しながら育てていったら、こんなモンスタープロンプトに行きついてしまいました 汗)

  • 今回のコンペでは、検証データとその模範解答 が与えられていたので、それを参考に、Few-shot プロンプト にした。
  • 四捨五入を正しい桁で行ってくれない問題。。Python のコードにすれば分かってくれるかな? と思い、コンペ最終日に自暴自棄になりながら書きました。それでも四捨五入問題の正答率は、体感で半分ぐらいにしかならなかった気がします。

最後の振り返りに繋がるのですが、人間と同じで、書けば (言えば) その通りにやってくれる という訳では全然無いので、ある意味「LLMの気持ち」を踏まえたプロンプティング (もといその前のワークフロー設計から) が重要 と改めて思いました。

レビュー (AnswerReflector)

これも恥を忍んでコードを抜粋します。

レビュー部実装コード
class AnswerReflector:
    def __init__(self, llmmodel, max_retries):
        self.llmmodel = llmmodel
        self.max_retries = max_retries
    
    def run(self, query: str, contents: list[str], answer: str, reason: str, feedbacks: list[str], iteration: int) -> AnswerReflectorOutput:
        # プロンプトの組み立て
        answer_reflection_prompt = ChatPromptTemplate.from_template('''\
        ## 役割:
        あなたは、様々な企業に関するESGレポートや統合報告書を含んだドキュメントに基づき、与えられた質問に対してリサーチャーが作成した回答に対して、その品質をチェックする専門家です。以下のプロセスに従って、リサーチャーが正しい回答を作成する為のレビューを行ってください。

        ## 前提:
        1. 今回の質問に対する回答は、与えられた文脈からのみ導き出す必要があります。他の手段はありません。そのため、最悪の場合、質問に対して答えられるエビデンスが文脈に含まれない場合、分かりません という答えを許容するものの、基本的には文脈内に質問を回答するに必要な情報はある前提で、レビューを行い、リサーチャーに対するフィードバックを行ってください。

        ## チェックプロセス: 
        2. 与えられた質問(変数: query)、リサーチャーが作成した回答(変数: answer)、それの回答を導出した根拠(変数: reason)、様々な企業に関するレポートや統合報告書を含んだドキュメントから抜粋された文脈(変数: context)、過去のあなたのフィードバック(変数: feedbacks) 、及び今回が何回目のレビューなのか(変数: iteration) が以下に示されてます。まずそれを確認してください。
        3. 以下に示すチェック項目それぞれについてチェックし、それぞれのチェック項目に対するあなたの思考プロセス(式、計算結果、論理等)の全てを、<item> タグ内に明示的に書き出して、変数 check_result_details に格納してください。もしもリサーチャーが作成した根拠や回答について誤りがあった場合は、どうすれば正しくなるのか、具体的な是正策も記載してください。また、誤りが無かった場合は、チェックした結果正しかった旨を明示的に書き出してください。省略は許されません。
        4. 何回もあなたが同じ指摘をしているにも関わらず、リサーチャーから追加のエビデンス・論理が示されない場合は、議論を収束させるために、iteration が 3以上を条件に、リサーチャーに対して「分かりません」と回答する事を促す旨、変数:check_result_details 、及び 変数:feedbacks に記載してください。iteration が 2以下の場合は、「分かりません」へ促さす様な記載は禁止です。
        5. 全てのチェック項目を確認し終わったら、リサーチャーの作成した回答に対する確認結果について、変数 check_result に格納してください。全てのチェック項目で問題が無ければ boolean型 の True を、1つでも問題があれば boolean型 の False を格納してください。
        6. 全てのチェック項目を確認し終わったら、リサーチャーが作成した根拠、回答に対してあなたが確認した見解について、日本語で要約して 変数 feedback に格納してください。リサーチャーが作成した根拠、回答に誤りや不足があった場合は、具体的にどうすれば正しくなるか、具体的な是正策、もしくはアドバイスを記載事項に含めてください。要約した結果を1つのstr型で格納してください。

        ## チェック項目:
        7. 根拠(変数: reason)と文脈(変数: context)を確認し、根拠が文脈に明記されている事実に基づいているかどうかを確認してください。もしも文脈に基づいていなければ、具体的にどういう観点で文脈に基づいていないのか、またどういう観点で不足しているのか を明示してください。
        8. 根拠(変数: reason)を確認し、根拠に記載されている計算式・論理が正しいかどうかの確認をしてください。もしも根拠に記載されている計算式・論理が正しくない場合、あなたが添削して正してください。
        9. 質問(変数: query) を確認し、四捨五入することが求められている質問かどうか判別してください。判別の結果が真の場合、四捨五入の仕方の統一の為に、根拠(変数: reason)と回答(変数: answer) を確認のうえ、必ず以下の **四捨五入が求められている問題についてのプロシージャ** を明示的に起動して、リサーチャーの根拠に対するステップバイステップでの確認を進めてください。あなたの知っている四捨五入の方法で勝手に確認を進めるのは禁止です。必ずプロシージャに従ってください。プロシージャにおける全ての手順について 変数:check_result_details に結果を格納してください。もしもリサーチャーの根拠が妥当ではない場合、あなたが添削して正してください。
        10. 回答(変数: answer)と根拠(変数: reason) を確認し、根拠から導かれた回答が、根拠と整合性が取れているかどうかの確認をしてください。もしも整合性が取れてない場合、あなたが添削して正してください。
        11. 質問(変数: query)と回答(変数: answer)を確認し、リサーチャーの回答が、質問で求めらている単位に従って答えているか、チェックをしてください。もしも妥当でない場合、あなたが添削してください。
        12. 質問(変数: query)と根拠(変数: reason)と文脈(変数: context)と回答(変数: answer)を確認し、質問で求めらていることに対して、以下の様な観点で正しく回答が出来ているかチェックをしてください。もしも正しくない場合は、あなた自身が文脈を確認する事で、正しい回答を提示してください。
            - 網羅性: 質問で「最も高い」、「最も小さい」、「すべてを挙げてください」、「2番目の」、「最も多い」等の表現で求められている場合、文脈全てを見た上で回答する必要があります。この観点で指摘をする場合は、リサーチャーに確認を促す旨のコメントをするだけでなく、あなた自身が文脈を全て確認したうえで、具体的且つ正しい回答を提示してください。もしもそれが出来ないのであれば、網羅性に関する指摘はせずに、リサーチャーの回答を受け入れてください。- 対象、目的語の正確性: リサーチャーの根拠について、質問において求められている、比較されているものは何と何なのか、対象が合っているか確認してください。
            - オペレーションの正確性: リサーチャーの根拠について、質問が言及している比較対象についての差を求めているのか、比率を求めているのか、間違えたオペレーションをしていないかの確認をしてください。
        13. 回答(変数: answer)について、質問(変数: query)で提示を求められていることに対して、以下具体例の様に、必要最低限の、簡潔な表現になっていることを確認してください。※根拠 (reason) の記述は冗長であっても問題無い。
            - 質問(query): XX社の取締役の在籍期間において、0~3年と4~9年ではどちらの方が取締役の人数が多いか
              回答(answer): 0~3年
              誤回答例: 0~3年のほうが取締役の人数が多い(冗長)
            - 質問(query): YY社の2024年度売上高は2023年度に比べて何%増加しているか?小数点第二位で四捨五入して答えよ。
              回答(answer): 7.4%
              誤回答例: 7.35%(四捨五入する桁が違う)、7.4%増加している(増加しているという表現が不要)
        14. 最後に、聞かれている質問(変数: query)に対して、回答(answer)が、十分な表現になっているか確認して下さい。具体的な事を聞かれている質問なのに、抽象的な答えになっていないか、確認してください。十分でない場合は、あなたが添削して適切な表現の回答を提示してください。

        ## 四捨五入が求められている問題についてのプロシージャ
        - 手順1: 質問で指定されている単位で計算する。質問が、"%" で提示することが求められている場合は、割合で計算していた場合は 100 を乗じて、単位を % にする。
        - 手順2: 質問の表現が以下のどのパターンになっているか("で" or "を" or "まで")に着目し、四捨五入した結果が何桁になるべきかを特定する。
            |質問の表現|求められている結果|
            |-------|-------|
            | 小数第三位**を**四捨五入 | 小数点第二位 |
            | 小数点第二位**を**四捨五入  | 小数点第一位 |
            | 小数第一位**を**四捨五入  | 整数 |
            | 小数第二位**で**四捨五入  | 小数点第一位 |
            | 小数点第1位**まで**の数字で四捨五入  | 小数点第一位 |
        - 手順3: 以下のPythonでの実装例の通り、求めらている四捨五入した後の結果に応じて四捨五入を行う。
            - 求められている結果が **整数** の場合
                ```
                from decimal import Decimal, ROUND_HALF_UP
                rounded_number = number.quantize(Decimal('1'), rounding=ROUND_HALF_UP)
                rounded_number   # Decimal('3')
                ```
            - 求められている結果が **小数点第一位** の場合
                ```
                from decimal import Decimal, ROUND_HALF_UP
                rounded_number = number.quantize(Decimal('0.1'), rounding=ROUND_HALF_UP)
                rounded_number   # Decimal('3.1')
                ```
            - 求められている結果が **小数点第二位** の場合
                ```
                from decimal import Decimal, ROUND_HALF_UP
                rounded_number = number.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
                rounded_number   # Decimal('3.14')
                ```
        - 手順4: 得られた答えが、手順2 で特定した、求められている結果の桁数と同じであることを確認する。合っていない場合は、手順1 に戻る。


        ## 質問: """
        {query}
        """

        ## 作成された回答: """
        {answer}
        """

        ## 回答の根拠: """
        {reason}
        """
        
        ## 文脈: """
        {context}
        """

        ## 過去のあなたのフィードバック: """
        {feedbacks}
        """

        ## 今回が何回目のレビューか: """
        {iteration}
        """
        ''')

        chain = answer_reflection_prompt | self.llmmodel.with_structured_output(AnswerReflectorOutput)
    
        retries = 0
        while retries < self.max_retries:
            try:
                return chain.invoke({"query": query, "answer": answer, "reason": reason, "context": contents, "feedbacks": feedbacks, "iteration": iteration})



言い訳がましいのですが、最初はもっと全然シンプルなプロンプトだったのですが、回答作成とレビュワーのやり取りにおいて、以下の様な課題が散見され、その対応をプロンプトに追記につぐ追記を繰り返した結果、こんなプロンプトになってしまいました。

  • レビュワーが強すぎて、回答作成者が折れてしまい、合っているのに 「分かりません」 に倒れてしまった。
  • レビュワーが アホで 根拠を見つけられない際に、「contextに基づいていないので誤りです」と否定をするのは簡単なのだが、否定するだけで解決策を示してくれない。
  • 前述の通り、context作成が完璧ではない (全ての質問を解くのに十分でない) のは分かっていたので、それを踏まえて良い塩梅で 「分かりません」 に誘導する。

にしても、「こんなレビュワー、絶対嫌だ」って思っちゃいますよね。。。またこんなチェックシート渡されてレビューするのも嫌だし。。。自分が嫌なことは他人 (LLM) にさせてはいけない って事を強く認識しました。


やりきれなかったこと

以下、今回やりきれなかったこと、次回このような機会があればチャレンジしたいこと/すべきこと を書いておきます。

non-LLMでの前処理

コンペに取り組み始めた当初は、前処理について、LLMを使わずに、fitz、pdfplumber、easyocr を使って、自前でテキスト変換の前処理を組んでいて、Public-LB で 0.44 位まで性能も出ていたのですが、それ以上を目指したときに、以下の様な限界を感じてしまい、ひとつづつやればやりきれないことは無いだろうが、一方でコンペの時間制約もあったので、前述の、LLM全振りの前処理方式に転換しました。

  • まちまちのレイアウトに対応出来うるだけの処理実装。
  • ocrでの円グラフの解釈。
  • テキスト情報で無い情報の読み取り。具体的には凡例が色で分けられている棒グラフの解釈。
  • 表の結合セルの扱い。

ワークフロー内での Multi-LLM の利用

回答作成は GPT-4o、レビュー Gemini 1.5 Pro の様に、異なる言語モデルを使って、エージェントに多様性を持たせる事による回答性能の向上を狙ったが、LangGraph の中で使う為に、langchain_google_vertexai を入れたところ、今まで私が使っていた環境の langchain_core と競合を起こしてしまい、仮想環境が壊れてしまいました。コンペ終了前々日に慌ててやることではありませんでした 汗)

Hybrid Retriever (Vector & Sparce) の利用

今回のタスクでは、財務諸表などの大きな表全部を正しく読み取らないと答えられない質問が幾つかありました。例えば以下の様な質問です。

4,2023年で即席めんの一人当たりの年間消費量が最も多い国はどこか。
66,明治ホールディングスの海外の売上高において、2013年度から2023年度までの11年間で、食品セグメントが医薬品セグメントを下回った年度を全てあげてください。

こういった問題は、チャンク分割してしまうと正しく引っ張ってこれない危惧があったので、VectorDB とは別に、ページ単位に、質問に応じてBM25スコアなどで検索をさせて、上位ページを context に埋め込む という事も試行しましたが、これは逆効果でした。原因は不明なのですが、context 含んだ プロンプト全体があまりにも長くなってしまうと、適当に返されてしまう印象です。

とはいっても、網羅性が必要とされる問題に限って、このような context の作り方をするのは、有効なのではとも思われ、この辺りも、行きつくところ以下のワークフロー設計の振り返りに行きつくと思いました。

ワークフロー設計

やはりこれが一番の反省です。

エージェントのタスクは、シンプルにした方が制御がしやすいのですが、今回私は、当初のワークフロー構成を最後まで引きずってしまった為に、一つ一つのエージェントに求める処理が複雑になってしまい、期待通りの答えが得られないケースがあったような気がします。なんとなくそれなりの結果を返してくれる様にはなったのですが、いざそれを更に良くしようとすると、積みあがっていかない というか。改善プロセスがうまく廻らなくなった気がします。

1ヶ月程度 というコンペ期間の制約もあったのですが、折角 LangGraph で実装したので、やろうと思えば機能分割も可能だったはず。今思えば、以下の様にAgentを分けて実装すべきだったと思います。

No. 変更/追加 タスク・役割 詳細
1 追加 ゴール設定 分かりにくい質問に対して解釈してゴールを明確にする。
2 クエリ拡張
3 追加 タスク分割 ゴールを導き出すためのタスクを作成する。
4 変更 タスク実行 今までは 回答作成 でひとくくりにしていたが、retriever 含めて、一つ一つのタスクとして実行させる
5 レビュー
6 追加 回答の整形 四捨五入問題含んだ、回答の最終整形



Solution紹介 というか、最後は反省文の様になってしまいましたが、これで終わりたいと思います。長文乱文失礼しました。

GitHubで編集を提案
7

Discussion

ログインするとコメントできます