LegalOn Technologies Engineering Blog

LegalOn Technologies 開発チームによるブログです。

条件付き文書ラベリングをスマートに解く - Structured Outputsでラベルの組み合わせ爆発を回避する方法

はじめに

こんにちは、株式会社LegalOn Technologies で検索・推薦チームに所属している福田と申します。

わたしたちのチームでは、LLM (大規模言語モデル) を用いて文書をラベリングし、そのラベルを検索や推薦のサービスで役立てています。

この記事では、LLMを使って文書をラベリングする際、付与されるラベル同士の制約がある場合に、どうやってLLMに制約を満たすような出力を強制させるかについて説明します。特にラベルの組み合わせ数が多い場合、Structured Outputs (Geminiで言うところのControlled generation) を利用した際にAPIで定められたEnumの要素数 (=ラベル数) の上限に達してしまいます。このようなケースでどう上限を回避しLLMにリクエストを送るかについても解説します。

想定読者

  • LLMの文書分類に興味がある方
  • Structured Outputsに触れたことがある方

背景/課題

LegalOn Technologiesでは「LegalOn Cloud」というサービスを提供しています。LegalOn Cloudは法務相談、契約書レビュー、契約書管理といった法務に関する業務の全てを一つのプラットフォームで完結できるAI法務プラットフォームです。

この「LegalOn Cloud」では「お客様が登録した契約書の種類と契約書上でのお客様の立場を自動的に抽出/識別したい」というニーズがあり、私たちのチームではこの課題に「LLMを用いた情報抽出」という手段で取り組んでいます。

契約書の種類と、お客様の立場について以下のような情報抽出がされます。

  • 契約書の種類
    • 業務委託契約/コンサルティング契約/取引基本契約(売買)/不動産売買契約
  • 契約書上でのお客様の立場
    • 委託側/受託側/買主側/売主側…

契約書の種類や立場はシステムで定義されたマスタデータがあるため、LLMに取りうる値を選んでもらう分類問題となります (文書ラベリング問題)。

LLMに文書ラベリング問題を解かせるためには、Structured Outputsを活用するのがナイーブな方法です。これはLLMに入力したpromptに対する回答として、通常のテキストの代わりにこちらが指定したクラスのオブジェクトを出力してもらう機能です。

以下のようなクラスを定義して、Literal型で取り得るラベルを設定します。LLMに対してラベリングを依頼するpromptを送ると、回答としてこのクラスのオブジェクトを得ることができます。

import pydantic
from openai import OpenAI

from typing import Literal

class ContractAttribute(pydantic.BaseModel):
    category: Literal["業務委託契約", "コンサルティング契約"]
    position: Literal["買主側", "売主側"]
    
client = OpenAI()
completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {
            "role": "user",
            "content": "以下の契約書および関連情報から属性を抽出してください。\n\nテキスト: ....",
        },
    ],
    response_format=ContractAttribute,
)

result_object = completion.choices[0].message.parsed
print(result_object.category) # 契約書の種類
print(result_object.position) # 契約書上でのお客様の立場

出力されたクラスのオブジェクトのcategory (契約書の種類) およびposition (契約書上でのお客様の立場) にアクセスすることで、ラベリングの結果を得ることができます。

課題

ただ、今回の場合、categoryとpositionの値は独立して完全に自由に決めていいわけではなく要件上以下のような対応関係を満たしている必要があります。

category position
業務委託契約 委託側/受託側
コンサルティング契約 委託側/受託側
取引基本契約(売買) 買主側/売主側
不動産売買契約 買主側/売主側

しかし、入力された文書によっては、LLMが本来存在し得ないcategoryとpositionのペアを返してしまう可能性があります。これを解決するためには以下のような方法が考えられますが、いずれも問題があります。

  • promptに対応関係を記述しそれを満たすよう出力を依頼する

    LLMの出力はStructured Outputsなどを利用して明示的に制約を持たせない限りprompt上での指示を必ず守れるとは限りません。特に契約の種類や立場の数が非常に多い場合はハルシネーションを起こして存在しないペアを出力してしまう可能性が高まります。

  • LLMへのラベリングのリクエストを2回に分ける

    1回目でcategoryだけを分類させ、その結果に応じてpositionを分類させる方法です。

    ただ、この場合LLMへのリクエスト回数が増えるので追加コストがかかってしまいます。

  • 取りうるcategoryとpositionのペアを全て展開し、一つのLiteralとして定義する

    以下のように出力クラスを変更すれば、categoryとpositionの対応関係は常に保証可能です。

class ContractAttribute(pydantic.BaseModel):
    category_and_position: Literal[
        "業務委託契約-受託側",
        "業務委託契約-委託側",
        "コンサルティング契約-受託側",
        "コンサルティング契約-委託側",
        "取引基本契約(売買)-買主側",
        "取引基本契約(売買)-売主側",
        "不動産売買契約-買主側",
        "不動産売買契約-売主側",
    ]

ところが、Literal型の中に入れられる数は利用するLLMのサービスにもよりますが、上限値が存在します。残念ながら今回のケースでは有効なpositionとcategoryの数が多すぎて他のラベリング項目と合わせた有効なペアの数が上限値に引っかかってしまい、この方法は利用できませんでした。

例えばOpenAIのAPIの場合サポートされているEnumの総数 (= Literalの総数) は最大で500に制限されており、これがLiteral型として登録できるパターン数の上限になります。

※ 500という上限値は、Structured Outputsで指定されたクラスで定義された全Literal型のパターンの総数に対する制限です。本事例ではpositionとcategoryの有効な組み合わせは約170通りですが、他の同時付与したいラベリング項目と合わせると合計が500の制限を超過してしまいました。

エラーメッセージ

openai.BadRequestError: Error code: 400 
{
  'error':{
      'message': "Invalid schema for response_format 'ContractAttribute': Expected at most 500 enum values in total within a single schema when using Structured Outputss, but received 1003. Consider reducing the number of enums, or use 'strict: false' to opt out of Structured Outputss.", 
      'type': 'invalid_request_error', 
      'param': 'response_format',
      'code': None
     }
 }

まとめると、ラベル数が非常に多い場合、ラベル間の整合性を取ろうとすると以下の問題のどちらかに遭遇する可能性があります。

  • LLMの呼び出しを2回に分けて制約を満たそうとするとコストが増える
  • 総当たりで組み合わせを網羅しようとすると総Literal数がAPIの制限に引っかかる

解決策

1回のリクエストでcategoryとpositionの対応関係を保った状態でLLMに結果を出力させる方法として、ラベルをグループ化する方法があります。

以下のように、取りうるpositionの値が同じcategoryをグルーピングして、一つのクラスとして定義します (Group1, Group2)。そして、これらのUnionクラス (Group1 | Group2) として新しいクラス (CategoryAndPosition) を定義し、このクラスをLLMに出力してもらうことができます。

class Group1(pydantic.BaseModel):
    category: Literal[
        "業務委託契約", 
        "コンサルティング契約"
    ]
    position: Literal["委託側", "受託側"]


class Group2(pydantic.BaseModel):
    category: Literal[
        "取引基本契約",
        "動産売買契約",
        "不動産売買契約",
    ]
    position: Literal["買主側", "売主側"]


# Root objectに対してUnionクラスは許可されないので別クラスで包む必要がある。
class ContractAttributeGroup(pydantic.BaseModel):
    group: Group1 | Group2

このようにGroupNのようなクラスを設けることで分類ラベルを階層化させ、全てのペアを列挙する場合と比較して分類ラベル数の爆発を抑えることが可能です。

一点注意が必要なのが、今回は契約の種類と立場に関して、条件を満たす組み合わせが多対多の関係が多かったので今回のようなクラスを設けることで分類ラベル数が削減できました。しかし、ほとんどが1対1の関係にあるような分類項目同士に関しては、今回の方法で大幅な削減は見込めません。

また、クラス数に関しては例えばOpenAIの場合100個以下に抑える必要もあり、これが新しい上限値になります。

参考までですが、実際に利用する際にはクラスの数が膨大になる可能性があるため、手書きでハードコードするのではなく、以下のように動的にpydanticのクラスを生成しました。

from typing import Union
import pydantic

position_and_category_list = [
    {
        "position_names": ["委託側", "受託側"],
        "category_names": [
            "業務委託契約",
            "コンサルティング契約"
        ],
    },
    {
        "position_names": ["買主側", "売主側"],
        "category_names": [
            "取引基本契約",
            "動産売買契約",
            "不動産売買契約",
        ],
    },
]

class_definitions = []
for i, record in enumerate(position_and_category_list):
    position_names = record["position_names"]
    category_names = record["category_names"]
    # クラスを動的に生成
    class_definition = pydantic.create_model(
        f"Group{str(i)}",
        category_names=(Literal[tuple(category_names)], pydantic.Field(..., title="契約書の種類")),
        position_names=(Literal[tuple(position_names)], pydantic.Field(..., title="自社の立場")),
    )
    class_definitions.append(class_definition)


# 生成したクラスのUnionを取る
class DynamicCategoryAndPositionGroup(pydantic.BaseModel):
    group: Union[tuple(class_definitions)]
    

また、上記のContractAttributeのクラスはOpenAIのStructured OutputsのAPIが呼び出される際に以下のようなjson schemaに変換されます。OpenAIへの生のリクエストの形が気になった方は確認してみてください。

【参考】DynamicCategoryAndPositionGroupのjson schema
Literal classはenum, Union classはproperties.anyOfとして表現されています。

{
    "$defs": {
        "Group0": {
            "properties": {
                "category_names": {
                    "enum": [
                        "業務委託契約",
                        "コンサルティング契約"
                    ],
                    "title": "契約書の種類",
                    "type": "string"
                },
                "position_names": {
                    "enum": [
                        "委託側",
                        "受託側"
                    ],
                    "title": "自社の立場",
                    "type": "string"
                }
            },
            "required": [
                "category_names",
                "position_names"
            ],
            "title": "Group0",
            "type": "object"
        },
        "Group1": {
            "properties": {
                "category_names": {
                    "enum": [
                        "取引基本契約",
                        "動産売買契約",
                        "不動産売買契約"
                    ],
                    "title": "契約書の種類",
                    "type": "string"
                },
                "position_names": {
                    "enum": [
                        "買主側",
                        "売主側"
                    ],
                    "title": "自社の立場",
                    "type": "string"
                }
            },
            "required": [
                "category_names",
                "position_names"
            ],
            "title": "Group1",
            "type": "object"
        }
    },
    "properties": {
        "group": {
            "anyOf": [
                {
                    "$ref": "#/$defs/Group0"
                },
                {
                    "$ref": "#/$defs/Group1"
                }
            ],
            "title": "Group"
        }
    },
    "required": [
        "group"
    ],
    "title": "DynamicCategoryAndPositionGroup",
    "type": "object"
}

OpenAIのStructured Ouputsに関するより詳しい仕様に関しては公式ドキュメントをご確認ください。

結果

今回の事例では、総当たりで組み合わせを網羅した場合、出力クラスのLiteralの総数がもともとの約90件から約170件と2倍近くに増加していました。しかし、上記の手法を用いることで Literalの総数を元の90件に近い値に抑えながら整合性を維持したラベル出力が可能になりました(グループ化したクラスは29個となりました)。

まとめ

この記事では、ラベル間の整合性が求められる文書ラベリングをLLMで行う際

  • 一回のLLMとのやりとりで済ませたい
  • 整合性を満たすペアを全展開するとラベル数が多すぎてStructured OutputsのAPIの上限にひっかかる

というケースに対し、ラベルをグループ化することでラベル数を抑えて整合性を維持したラベルを出力させる方法を紹介しました。同様の課題を抱える方の参考になれば幸いです。

謝辞

問題解決する際にアドバイスいただいた KosukeArase さん、本ブログのレビューをしてくださったkayoshii-eveさんmaomao905さんjusui さんhikaru-ida さんWinField95さんm-hamashitaさんありがとうございます。

これらの方々に深く感謝します。

仲間募集

私たちのチームでは、一緒に働く仲間を募集しています。 ご興味がある方は、以下のサイトからぜひご応募ください。 皆様のご応募をお待ちしております。