はじめに
こんにちは、LLM Advent Calendar 2023 4日目の記事を担当する_mkazutakaです。よろしくお願いします。 LLM Advent CalendarといってもRAGの話になりますが、ご容赦ください。
企業独自のデータを使ってLLMからの出力を制御する際には、検索拡張生成(いわゆるRAG)が使われます。 RAGの実装方法としては、「PDFからドキュメント情報を読み取り検索エンジンに保存」「ユーザの入力する質問文から関連するドキュメントを検索エンジンから取得」「取得したものをコンテキストとしてプロンプトに含める」という流れが一般的だと思います。
この際、RAGの課題の一つでもあるのですが、検索結果から取得するドキュメントのサイズ(いわゆるチャンクサイズ)をどれぐらいのものにするかというものがあります。チャンクサイズが小さすぎるとLLMは関連するコンテキストから正しい答えを生成できません。一方で、チャンクサイズが大きすぎると不要な情報が含まってしまったり、検索精度が落ちたり、トークン数が多くなるため不要なお金がかかってしまいます。
このチャンクサイズを決めるには、いくつか方法があります。
- 特定の文字数で分ける
- 特定の区切り文字(。、,.\n)などで分ける
- チャンクに前後の文脈を含ませる(チャンク間でテキストを重複させる)
などなど。最近だと、セマンティッククラスタリングと呼ばれる、前後のテキストの意味をもとにチャンク分けする方法も紹介されています。
というわけで、弊社でも色々試していたのですが、なんと11月にOpenAIから最大128,000トークン扱えるGPT-4 Turboが発表されてしまいました。これだけのトークン数があれば、ほとんどのPDFをまるまる渡すことができます。 となるとですが、細かくドキュメント分けるより、GPT-4 TurboにPDF内のテキスト全部渡していい感じに分けてっていえばいい感じにチャンクに分けてくれるんじゃ...というアイデアが思いつきます。
というわけで、これをやってみるのが今回の記事の内容になります。
アイデア
改めてですが、愚直なアイデアになります。
非構造化データからテキストをすべて読み取り、それをGPT-4 Turboに渡します。 GPT-4 Turboにプロンプトで以下のドキュメントをセクションごとに分けてといい、チャンクを得ます。
実装
ドキュメント読み取り
ドキュメントの読み取りの実装です。今回は、unstructuredを使って、PDFからドキュメントを読み取ります。 対象のドキュメントは、商工会議所の小冊子「今すぐ確認!中小企業・小規模事業者のためのインボイス制度対策【第3版】」を使います。
読み取り部分のコードは以下のような形です。unstructuredはPDFから表を読み取ってテーブル形式にしてくれたりするのですが、今回は不要なので、無視しています。
from typing import List from unstructured.partition.pdf import partition_pdf def parse_pdf(file: str) -> List[str]: elements = partition_pdf( file, languages=["jpn"], strategy="hi_res", infer_table_structure=True, ) contents = [] for el in elements: # 不要な部分は無視 if el is None or el.category in ['Image', 'Footer', 'Header', 'Table']: continue contents.append(el.text.strip()) filtered_contents = [c for c in contents if c not in ["", None]] return filtered_contents
チャンクに分ける部分
openaiライブラリを使います。JSONモードを使って出力をJSONに固定しています。 本来は、チャンクに分けた文章をそれぞれ出力したかったのですが、さすがにトークン数の制限に引っかかったためGPT-4 Turboが推定したセクションのタイトル一覧を出力し、それをコード上で扱いドキュメントをチャンクに分けています。
import openai SYSTEM_PROMPT = ( "あなたは、与えられたテキストを適切なセクションに分ける事が優秀なアシスタントです。" "セクションは可能な限り細かく分ける方が望ましいですが、意味がわからなくなるほど分ける必要はありません。" "与えられたテキストから、区切りとなるテキストをJSON形式の配列で出力することができます。" 'JSONは、{ "sections": [ str ] }の形式で出力します。' ) def split_to_sections(content: str) -> List[str]: response = openai.chat.completions.create( temperature=0.0, model="gpt-4-1106-preview", messages=[ { "role": "system", "content": SYSTEM_PROMPT }, { "role": "user", "content": content, }, ], response_format={"type": "json_object"} ) new_contents = [] delimiters = json.loads(response.choices[0].message.content)["sections"] print(delimiters) # delimitersを元にドキュメントを分解する for delimiter in delimiters: splitted = content.split(delimiter, 1) if splitted[0].strip() != "": new_contents.append(splitted[0].strip()) if len(splitted) > 1: content = delimiter + splitted[1] else: new_contents.append(content) return new_contents
実行部分
if __name__ == '__main__': documents = parse_pdf("data/invoice.pdf") print(documents) content = "\n".join(documents) new_documents = split_to_sections(content) print(new_documents)
結果
コードを実行した結果です。GPT-4 Turboで推定したセクションのタイトル一覧は以下の様になりました。
[ 'はじめに', 'インボイス制度とは', 'インボイス制度が始まると', '令和5年度税制改正 インボイス登録申請のスケジュール', '本書の見方・使い方', '目次', '消費税制度とインボイス制度の基礎知識', '消費税とは', '課税事業者と免税事業者', '軽減税率とは', '軽減税率対象品目の売上・仕入の経理処理', '仕入税額控除の仕組み', 'インボイスとは', 'インボイス制度が始まると', '免税事業者の対応・課税事業者の対応', '課税事業者になるかどうかの判断', '本則課税と簡易課税', '簡易課税を選択するには', 'みなし仕入率', '簡易課税のメリット・デメリット', '免税事業者の対応・課税事業者の対応', '免税事業者が取り得る選択肢の利益比較', 'インボイス発行事業者への登録手続', 'インボイス制度導入に向けた準備', 'インボイス発行事業者の登録申請', '免税事業者から課税事業者になる際の経過措置', 'インボイス導入に向けた具体的な対応', 'インボイスの記載事項', '返還インボイス', '適格簡易請求書(簡易インボイス)', '修正インボイス', '電子インボイス', 'インボイス発行事業者の義務', '禁止事項', 'インボイス受領者の留意点', '保存期間と保存場所', '簡易課税を選択している場合', '中小事業者等に対する事務負担の軽減措置(少額特例)', '電子帳簿保存法への対応', '経理業務のデジタル化に向けて', '消費税の納付', '売上・仕入に係る消費税額の計算方法', '仕入税額控除の経過措置', '消費税の納付方法', 'インボイス導入の際の注意点と経営力強化', 'インボイスを発行できない事業者への対応', 'インボイス非登録事業者と取引する場合の損益への影響', '資金繰りに注意', '商工会議所への相談', 'インボイス導入を契機とした経営の見直し', '価格表示のルール', 'インボイス制度 理解度チェックリスト', '参考サイト', '問い合わせ先', '仕入先がインボイス発行事業者かどうかを確認する際の様式の見本', 'MEMO', '発行日' ]
実際のページを確認してみましょう。以下のページにおいては理想的には4つ、「消費税とは」「課税事業者と免税事業者」「軽減税率とは」「軽減税率対象品目の売上・仕入の経理処理」に分けたいですね。というわけで、上のセクションタイトル一覧にそれぞれが含まれていれば成功なのですが...結果は4行目と5行目に含まれてそうです。
一方で、以下のページは「免税事業者である仕入先との交渉」「取引価格の変更は慎重に」「独占禁止法等により問題となる行為」「経費支出の際のルールの社内周知」に分けたいですが、上のセクションタイトル一覧に含まれていませんね。ページタイトルの「①インボイスを発行できない事業者への対応」は含まれているので、ページごと区切られている形になっていて、理想の分け方にはなってないですね。
上記のセクションタイトル一覧をもとにチャンクわけすると以下のようになります。
まとめ
GPT-4 Turboにドキュメントのチャンクを分けさせていました。個人的には思った以上に精度が出たなっていう印象です。 記事では載せてないですが、規約のような「〇〇章」などで区切られているドキュメントは、肌感いい感じになります。一方で、Excelのようなものだとまるまる表をいれるという結果になってしまいそうなので、要件によってうまく工夫すると良いかと思います。
その他
これを実運用に載せようと思うともう少し工夫が必要です。というのもチャンクサイズが大きくなったことで検索精度が落ちます。なので、RecursiveRetriverのような機構で検索はチャンク毎ではなく文章単位にする方法をおすすめします。 このあたりの話はまた機会があればしようと思います。