📑

LLMを悩ませる"Excel文書"をうまく扱う方法

2024/12/08に公開

はじめに

株式会社ファースト・オートメーションCTOの田中(しろくま)です!
弊社では製造業向けのRAGを使ったチャットボットの開発を行っていますが、 RAGで読み取りづらいなと感じているドキュメントが"Excel文書"です。

LLMを悩ませる"Excel文書"とは

ここで"Excel文書"と呼んでいるドキュメントは、
「構造化されたテーブルを保存しているExcelファイル」
ではなく、
セルに文書を書いたり、オブジェクトや画像を挿入することで、いわゆる一般的な文書を作成しているExcelファイル
のことを呼んでいます。

そもそも一般的な文書作成においてはExcelではなく、Wordを使えばよいのでは?と思われるかもですが、以下の点でExcelで文書の資料を作成することはそれなりに便利な部分があると思っています。

  • 枠を使って、グルーピングすることでドキュメントの構成が見やすくなる
  • セルを使うことで、空白や文字の開始位置を正確に制御でき、文字を綺麗に揃えやすい
  • 先に一枚絵を作ってしまって、後からページの切り方を調整できる
  • グラフやオブジェクトも入れられる

このような機能は、WordやPowerPointに比べ、文書を 『整えながら自由に』 書くことができるため、製造業での見積書や仕様書の作成にはExcelがよく使用されています。
一方で、これらの利点はプログラム的にドキュメントを解析しデータをLLMに与える上ではデメリットが多いです。

  • 文字だけでなく枠の構造を視覚的に理解する必要がある
  • セルの大きさやセルからの文字のはみ出しなどもレイアウトの一部であることを認識する必要がある
  • ページの切り方がちゃんと設定されていないと、どこがページの区切りか分からない
  • グラフやオブジェクトも扱う必要がある

"Excel文書"の例

ここでは弊社が扱ってきた"Excel文書"の中で特に解析が難しかったドキュメントのサンプルをいくつか紹介します。
(あくまで、サンプルとして作ったもので、実際の企業のものではありません。)

A. 枠線で構造化

枠線を使って文書の構造化を行っている例です。
これの読み取りの難しいところは個々のセルではく枠線を認識する必要があるというところです。
さらにサンプルでは画像も挿入されています。

B. セルでフローチャート

オブジェクトやセルを駆使してフローチャートを作成している例です。
セルの枠線のスタイルや配置に意味があるため、それらを認識する必要があります。

C. オブジェクト、画像、グラフ混在

LLMで扱いづらいのが、オブジェクト、画像、グラフです。
基本的には画像として扱い、LLMの画像入力で処理するのがやりやすいですが、フローチャートなどはmermaidでテキスト化するという方法もあります。

"Excel文書"を扱うためにやってきたこと

ここからは弊社で"Excel文書"を扱うためにトライしてきたこと、ノウハウの一部を紹介していこうと思います。
3つに分けて紹介していきます。

  1. openpyxl等を使ってExcelをそのままパースする
  2. PDFに変換してPDFや画像として扱う
  3. PDF変換時にページの区切りをちゃんとする

1. openpyxl等を使ってExcelをそのままパースする

1.1. テキスト情報の抽出

おそらく、"Excel文書"を扱う上で最も簡単な方法は、openpyxl等のExcelパースツールで、文字列のみを抽出する方法です。
以下のコードではExcelファイルをopenpyxlで読み込み、各シート毎に文字列抽出して保存しています。

from openpyxl import load_workbook

workbook = load_workbook("<Excelファイルのパス>", data_only=True)
extracted_data = {}
for sheet_name in workbook.sheetnames:
    sheet = workbook[sheet_name]
    sheet_text = []
    for row in sheet.iter_rows(values_only=True):
        for cell in row:
            if cell is not None:
                sheet_text.append(str(cell))
    extracted_data[sheet_name] = "\n".join(sheet_text)
print(extracted_data)

1.2 表として読み込む

上記の方法だと、全てのセルをテキストとして読み込み結合しているだけなので、表としての情報がすべて失われています。
pandasを使用することで、Excelを表として読み込み、Markdownなどで出力することができます。ただし、セル毎に要素が格納されていると見なすので、上の例Aで示したような手動で書かれた枠線に沿った表はその通りにはなりません。
(ちなみに"Excel文書"ではない構造化されたテーブルデータであればこの方法で十分かなと思います。)

import pandas as pd

excel_data = pd.read_excel("<Excelファイルのパス>", sheet_name=None)
markdown_data = {}
for sheet_name, df in excel_data.items():
    markdown_content = df.to_markdown(index=False, tablefmt="pipe")
    markdown_data[sheet_name] = markdown_content
print(markdown_data)

1.3. オブジェクト内のテキストの抽出

以下のようなExcelファイルをChatGPTに与えて質問すると、ファイルが空であるという内容が返ってきてしまいます。さて、何が起きているのでしょう?

実はこのExcelファイルはセルに文字が書かれているのではなく、矩形オブジェクトの中にテキストが埋められているというふうになっています。
なので、セルのパースだけでなく、オブジェクト内のテキストのパースが必要になります。
残念ながらExcelのオブジェクトはopenpyxlやpanadasでパースすることができず、Excel内のファイルを直接見に行って取得する必要があります。
xlsxなどのOfficeファイルはzipファイルなので、zipの中にある特定のxmlファイルにアクセスし、パースすることでこの情報を取得することができます。

import zipfile
from xml.etree import ElementTree as ET

textbox_contents: list[str] = []
with zipfile.ZipFile("<Excelファイルのパス>", "r") as z:
    for file_name in z.namelist():
        if "drawings" not in file_name:
            continue
        ns_xdr = "{http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing}"
        ns_a = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
        with z.open(file_name) as f:
            try:
                tree = ET.parse(f)
                root = tree.getroot()
            except ET.ParseError as e:
                print(f"Error parsing XML from file {file_name}: {e}")
                continue
            for two_cell_anchor in root.iter(f"{ns_xdr}twoCellAnchor"):
                for textbox in two_cell_anchor.iter(f"{ns_xdr}txBody"):
                    text_content = ""
                    for paragraph in textbox.iter(f"{ns_a}p"):
                        paragraph_content = ""
                        for text in paragraph.iter(f"{ns_a}t"):
                            if text.text:
                                paragraph_content += text.text
                        text_content += "\n" + paragraph_content
                    textbox_contents.append(text_content)
print(textbox_contents)

1.4. グラフ情報の抽出

次はExcel内のグラフ情報の抽出です。

グラフは画像として抽出してもよいのですが、どのデータを参照しているかが分かるようになっているので、LLMに与える上ではむしろデータの方を抽出して与えるという方法も有りかと思います。
以下では先ほどと同様にExcel内のzipファイルの中からチャート情報のxmlファイルを読み込んで、padansのデータフレームに変換しています。

import zipfile
import pandas as pd
from xml.etree import ElementTree as ET

def parse_xml_for_chart(xml_data):
    try:
        root = ET.fromstring(xml_data)
    except ET.ParseError as e:
        print(f"Error parsing XML: {e}")
        return {}
    ns = {"c": "http://schemas.openxmlformats.org/drawingml/2006/chart"}
    data = {}

    for series in root.findall(".//c:ser", namespaces=ns):
        cv_elem = series.find("./c:tx/c:strRef/c:strCache/c:pt/c:v", namespaces=ns)
        if cv_elem is None:
            series_name = ""
        else:
            series_name = cv_elem.text
        categories = [
            pt.find("./c:v", namespaces=ns).text
            for pt in series.findall("./c:cat/c:strRef/c:strCache/c:pt", namespaces=ns)
        ]
        series_values = [
            pt.find("./c:v", namespaces=ns).text
            for pt in series.findall("./c:val/c:numRef/c:numCache/c:pt", namespaces=ns)
        ]
        data[series_name] = dict(zip(categories, series_values))
    return data

doc_zip = zipfile.ZipFile("<Excelファイルのパス>")
zipped_files = doc_zip.namelist()
chart_tables: list[pd.DataFrame] = []
for file in zipped_files:
    if file.startswith(f"{file_type}/charts/chart") and file.endswith(".xml"):
        xml_data = doc_zip.read(file)
        data = parse_xml_for_chart(xml_data)
        df = pd.DataFrame(data)
        chart_tables.append(df)
print(chart_tables)

2. PDFに変換してPDFや画像として扱う

最近になって 画像や表を含んだPDFを視覚情報に基づいてかなり精度高く解析できるツールが増えてきました。 つまり以下のような流れでExcelをPDFに変換して、このようなツールを使うことで視覚情報も含めてある程度うまくパースすることができます。

実際に、視覚情報も考慮したPDFパーサやAIツールには以下のようなものがあります。
(商用での使用に関しては必ずそれぞれのライセンスを確認してください。)

https://github.com/opendatalab/MinerU
https://github.com/DS4SD/docling
https://github.com/getomni-ai/zerox
https://github.com/run-llama/llama_parse
https://github.com/pymupdf/RAG
https://github.com/kotaro-kinoshita/yomitoku
https://github.com/QwenLM/Qwen2-VL
https://openai.com/index/hello-gpt-4o/
https://docs.anthropic.com/en/docs/build-with-claude/pdf-support

MinerU、docling、yomitokuは表や画像を検出する検出器やOCRを行う機械学習モデルを使ってパースを行っています。その中でyomitokuはさらに日本語に特化した機械学習モデルを使っています。
一方でzeroxやllama_parseはChatGPTなどのLLMを用いてパースを行います。
後半のQwen2-VLとGPT-4oを使う場合はPDFを画像に変換して使用します。これらのモデルはOCRの性能が高いため、画像から文書ファイルを読むことができます。
また、プログラム的にExcelをPDFに変換する際はunoconvgotenbergなどが使えるかと思います。
https://github.com/unoconv/unoconv
https://gotenberg.dev/

これらの方法により例えば"Excel文書"の例Aで出したような枠線で構造化されたデータやフローチャートもうまく読み込むことができるようになります。
弊社でもLLMを用いたPDF→Markdown変換を行うことで表やフローチャートをうまく抽出するツールを開発しており、それを用いることで"Excel文書"の例Aで出したファイルを以下のようにMarkdownの表としてテキスト化することができます。

| 承認  | 照査  | 作成  |
| --- | --- | --- |
工程管理書
| 施工場所          | 伊藤製作所 埼玉工場                                                                                                                                     | 埼玉工場 ⇒ 伊藤製作所                                                                                                                                         | 埼玉工場 ⇒ 伊藤製作所                                                                                         |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| 日程(完了日)       | -                                                                                                                                              | 2月24日(Sat)                                                                                                                                           | 3月25日(Mon)                                                                                           |
| 工程内容          | ① 部品の発注<br><br>部品名と部品番号:発注する部品の名前と識別番号<br>発注数量:発注する部品の数量<br>発注先:部品を購入する会社の名前<br>発注日:実際に部品を発注した日<br>納品予定日:発注先から部品が納品される予定の日<br>納品日:実際に部品が納品された日 | ② 工程確認<br><br>工程:各工程の名称<br>担当者:その工程を担当する人物<br>期限:その工程が完了するべき日付<br>状況:現在の進捗状況(例:完了、進行中、未完了)<br>問題点:発生した問題点を記載<br>対応策:問題に対する対応策を記載<br>確認日:工程の状況を確認した日付 | ③ 工場での組み立て<br><br>必要な部品を倉庫から取り出す<br>部品の数量と品質を確認<br>作業場の清掃と整頓<br><br>ネジ、ボルト、ナットの締め付け                  |
| 作業手順          | 1. 必要な部品と数量を確認<br>2. 発注先の選定(既存の取引先や新規の業者)<br>3. 発注書の確認と承認<br>4. 納品後の検品と在庫登録                                                                    | 1. 工程確認のスケジュールを設定<br>※ 現場に出向き、作業環境と進捗状況を確認                                                                                                           | ![image_0](image_0.png)                                                                              |
| 検査項目          | 部品名、数量、価格、納期などが正確に記載されているか<br>品質証明書や検査成績書が添付されているか<br>見積もりと実際の発注価格が一致しているか                                                                     |                                                                                                                                                      |                                                                                                      |
| 確認者<br>(担当窓口) | 埼玉工場:飯島<br>伊藤製作所:永山                                                                                                                            | 埼玉工場:飯島<br>伊藤製作所:田中                                                                                                                                  | 埼玉工場:松本<br>伊藤製作所:永山                                                                                  |
| 備考            |                                                                                                                                                | 組み立ての途中で中間検査を行います。                                                                                                                                   | 必要な部品と工具をすべて揃えます。部品リストを確認し、不足がないかをチェックします。<br>次に、作業エリアを整理し、安全対策を確認します。作業台や周辺の清掃を行い、工具がすぐに使える状態に整えます。 |
(株)伊藤製作所 2023年12月25日(月)

同様に"Excel文書"の例Bのファイルは以下のようにmermaidとしてテキスト化されます。

## Sheet1
```mermaid<!-- start mermaid -->
flowchart TD
    A[ロボット起動] -->|立ち上がり:10秒| B[食品到着センサー]
    B -->|待機| C[食品選別]
    C -->|認識時間:20秒| D[食品ピックアップ]
    D --> E[パレタイズ位置決定]
    E --> F[食品移動]
    F --> G[コンベア移動]
    G --> H[パレタイズ位置決定]
```<!-- end mermaid -->

3. PDF変換時にページの区切りをちゃんとする

上記のようにExcelをPDFに変換する際に、Excel側でちゃんとページの設定がされていないと変なところでページが切れてしまうという問題が発生します。
例えば、以下の防衛省が出しているシステム経費の実績の資料なのですが、これをそのままPDFに変換するとその下のように表が途中で切れたPDFが生成されてしまいます。
これは適切にユーザがページの区切りを設定しておらず、Office側で適当なところで区切ってしまうために発生してしまう問題です。

unoconvやgotenbergはページの区切りまで調整することができないため、この辺りをよしなにやってPDF変換してくれるツールを用いる必要があります。
APIも提供されているWebサービスだと以下のようなものがあります。

https://experienceleague.adobe.com/ja/docs/acrobat-services-learn/tutorials/overview
https://www.ilovepdf.com/ja/blog/what-is-ilovepdf-developers

しかし、これらのツールは一定無料枠があるとはいえ、プロダクト内での使用を考えると有料課金が必要になります。
たかがExcel→PDF変換で課金したくないなぁというのと、弊社としてはこのあたりを自分たちでも調節できるようオリジナルで作っておきたいというのもあり、ChatGPTにもやり方を教えてもらいつつ、自分たちで作ることにしました。
Githubで公開しており、Dockerを使用してExcel→PDF変換を行うAPIサーバとして使用できるようになっています。

https://github.com/first-automation/office-to-pdf-serve

これでExcel→PDF変換で上記のような有料ツールに頼らずに済みます!!
実際に先程の防衛省資料は以下のようにちゃんと一枚ページで出力されるようになります。

仕組みとしては、LibreOffice(Linuxで動くフリーのOfficeライクなツール)を内部的にサーバとして稼働させておいて、それに対してAPIでセル情報やページ情報などにアクセス・編集し、最終的にPDFに変換してもらうというようになっています。
LibreOfficeをサーバとして立ち上げるコマンドは以下になります。

soffice --accept=socket,host=localhost,port=2002;urp;StarOffice.ServiceManager --headless

これを立ち上げた状態で、unoというpythonパッケージを使用することで、セルやページの情報にアクセスできるようになります。

import sys
sys.path.append("/usr/lib/python3/dist-packages/")  # unoはLibreOfficeと一緒にシステムのPythonパッケージとしてインストールされるのでパスを登録しておく
import uno

local_context = uno.getComponentContext()
resolver = local_context.ServiceManager.createInstanceWithContext(
    "com.sun.star.bridge.UnoUrlResolver", local_context
)
context = resolver.resolve(
    f"uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext"
)
desktop = context.ServiceManager.createInstanceWithContext(
    "com.sun.star.frame.Desktop", context
)
input_url = "file:///<Excelファイルのパス>"
document = desktop.loadComponentFromURL(input_url, "_blank", 0, ())
for sheet in self.document.Sheets:
    cell = sheet.getCellByPosition(0, 0)
    print(cell)

ここまでの対策によって、様々な"Excel文書"から精度高く情報抽出ができるようになってきました。

まとめ

RAGとLLMのシステムで扱いの難しい"Excel文書"をうまく扱うための手法をいくつか紹介させていただきました。
実際に弊社のプロダクトでは今回紹介した方法を複合的に使用して文書の解析を行っています。
ここで紹介したサンプルよりももっと複雑な"Excel文書"も多く、まだまだ読み取りの精度の改善を進めていっています。

最後までお読みいただきありがとうございました。
最後に宣伝ですが、株式会社ファースト・オートメーションは一緒に働いて下さる仲間を絶賛募集中です!

  • RAGを使ったLLMのプロダクトに興味がある
  • 生成AIの社会実装に貢献したい
  • 製造業をより良くしたい

といったことに少しでも興味がある方、ぜひ下記応募リンクからご連絡下さい!
https://www.wantedly.com/projects/1856170

株式会社ファースト・オートメーション

Discussion