😺

LLMで行数の多い日本語CSVを扱うための前処理

に公開

はじめに

株式会社ファースト・オートメーションCTOの田中(しろくま)です!
弊社では製造業向けに複雑なExcelなどの文書データから情報を読み取り、文書生成やチャットを行うツールを開発しています。
前回、「LLMを悩ませる"Excel文書"をうまく扱う方法」という記事で"Excel文書"の読み取りの難しさとその解決方法の一部について解説しました。
https://zenn.dev/firstautomation/articles/aed95bce20e900

今回は、記事で言及していたような"Excel文書"ではなく、いわゆる"表形式の構造化されたExcelやCSV"をLLMで扱う話なのですが、そのようなデータでも扱いづらいものがあったりします。

例えば以下のような表です。

管理番号 受注日 検査日 数量 温度範囲
1 21K-0131 2025-06-01 2025/06/02 120 20から30
2 21K-0132 2025年6月3日 R7.06.04 150 30以上40以下
3 21K-0133 2025年6月5日 2025-06-06 123 5〜7
4 21K-0150 令和7年6月7日 2025/06/08(月) 200 70超90以下
5 21K-0200 2025年6月8日 2025/06/09 80 60より下
...

見ると分かると思うのですが、
1つ厄介なのは、日付の書き方など、
プログラムを使った検索やフィルタリングのしにくい値が表に含まれている
のが分かります。

そしてこれと合わせ技で厄介なのが、
行数がめちゃくちゃ多くてLLMのプロンプトにすべてのデータを入れることができない
という場合です。

1万行とかあるようなExcelのデータをすべてプロンプトに含めるのは現実的ではありません。このような場合、大抵はLLMに必要となるデータにアクセスするためのコードをpandas等を用いて書いてもらって、それを実行することでデータをフィルタリングするという方法を取るかと思います。

しかし上記のような表で、例えば日付のカラムから「〇月×日以降のデータがほしい」というような日付検索を行うとなると、日本語の日付の順序を理解する必要があり、さらにカラム内で書き方が一様ではないケースも考えられ、いろんな書き方に対応できる必要があります。
このような日付や時刻、漢数字も含めた数値などの日本語特有の表現を含むカラムは事前に検索しやすいように正規化を行うことである程度プログラム的に扱いやすくすることが可能です。

既存ツールで日本語CSVをいろいろ正規化しておく

日本語のCSVファイルで前処理としてやっておいたほうがいい正規化ツールとして以下のようなものがあります。

  • neologdn (python)
    • 日本語正規化全般
    • 英数字の全角半角の統一
    • スペースや記号の統一
  • jaconv (python)
    • 日本語のかな文字の全角半角統一
  • ja-timex (python)
    • 時刻の正規化
  • yurenizer (python)
    • 単語の表記ゆれ統一(例: パソコン → パーソナルコンピューター)
  • ja_cvu_normalizer
    • 旧字体の標準字体への変換
  • pyNormalizeNumExp
    • 単位を含む数値表現全般の統一
    • 時間の範囲表現
  • normalize-japanese-address (typescript)
    • 住所の正規化

"範囲"の正規化

上記ツール以外で範囲表現も正規化したいことが多々あったため、今回、範囲表現の正規化パッケージを作って対応しました。
範囲表現と言っているのは例えば以下のようなものです。

  • 2から10
  • -1以上、30以下
  • 5〜7
  • 70超90以下
  • 60より下
    など...

pyNormalizeNumExp も一部の範囲表現に対応しているのですが、時刻の範囲であることと「以上以下」のような書き方には対応していないので、上記のようなあらゆる範囲表現に対応できるパッケージにしました。
これらの範囲表現はpandasではIntervalというクラスを使って表すことができます。

  • pd.Interval(2, 10, closed="both")
  • pd.Interval(-1, 30, closed="both")
  • pd.Interval(5, 7, closed="both")
  • pd.Interval(70, 90, closed="right")
  • pd.Interval(float("-inf"), 60, closed="left")

Intervalのように正規化した表現にしておくことで、以下のようにデータの検索性を向上させることができます。

import pandas as pd
import numpy as np

df = pd.DataFrame(
    {
        "id": ["A", "B", "C", "D", "E"],
        "range": [
            pd.Interval(0, 5, closed="both"),   # [0, 5]
            pd.Interval(3, 8, closed="both"),   # [3, 8]
            pd.Interval(8, 10, closed="right"), # (8, 10]
            pd.Interval(15, 20, closed="both"), # [15, 20]
            pd.Interval(float("-inf"), 2, closed="left"), # (-∞, 2]
        ],
        "sample_value": [1, 4, 9, 18, -3],
    }
)

# ------------------------------------------------------
# ② あるスカラー値 x が区間に「含まれる」行を抽出 (∈)
# ------------------------------------------------------
x = 4
rows_containing_x = df[df["range"].apply(lambda iv: x in iv)]
print("x を含む行:\n", rows_containing_x, "\n")

# ------------------------------------------------------
# ③ スカラー値 x が区間に「含まれない」行を抽出 (¬∈)
# ------------------------------------------------------
rows_not_containing_x = df[~df["range"].apply(lambda iv: x in iv)]
print("x を含まない行:\n", rows_not_containing_x, "\n")

# ------------------------------------------------------------
# ④ 指定 Interval と「重なる(overlaps)」行を抽出 (∩ ≠ ∅)
# ------------------------------------------------------------
query_iv = pd.Interval(2, 6, closed="both")   # [2, 6]
rows_overlapping = df[df["range"].apply(lambda iv: iv.overlaps(query_iv))]
print("query_iv と重なる行:\n", rows_overlapping, "\n")

# ------------------------------------------------------------
# ⑤ 指定 Interval と「重ならない」行を抽出 (∩ = ∅)
# ------------------------------------------------------------
rows_non_overlapping = df[~df["range"].apply(lambda iv: iv.overlaps(query_iv))]
print("query_iv と重ならない行:\n", rows_non_overlapping, "\n")

作成したパッケージは以下になります。
https://github.com/first-automation/jp-range

テストケースにいろいろと書いているのですが、以下のような書き方に対応しています。
間にスペースが入るとか、全角半角とかのバリエーションにも対応できています。

@pytest.mark.parametrize(
    "text, lower, upper, lower_inc, upper_inc, contains, not_contains",
    [
        ("", None, None, False, False, [], []),
        (1, 1, 1, True, True, [1], []),
        ("20から30", 20, 30, True, True, [20, 25, 30], [19, 31]),
        ("30以上40以下", 30, 40, True, True, [], []),
        ("30以上,40以下", 30, 40, True, True, [], []),
        ("30以上そして40以下", 30, 40, True, True, [], []),
        ("40以上50未満", 40, 50, True, False, [40, 49.9], [50]),
        ("50より上", 50, None, False, False, [51], [50]),
        ("60より下", None, 60, False, False, [59], [60]),
        ("\u300040  以上\u300050 未満\u3000", 40, 50, True, False, [], []),
        ("20〜30", 20, 30, True, True, [], []),
        ("70超90以下", 70, 90, False, True, [], []),
        ("10を超え20未満", 10, 20, False, False, [19.9], [10]),
        ("80以上", 80, None, True, False, [], []),
        ("10個以上", 10, None, True, False, [], []),
        ("100未満", None, 100, False, False, [], []),
        ("90前後", 85.5, 94.5, True, True, [], []),
        ("90m程度", 85.5, 94.5, True, True, [], []),
        ("±10", -10, 10, True, True, [], []),
        ("プラスマイナス10", -10, 10, True, True, [], []),
        ("1±0.1", 0.9, 1.1, True, True, [], []),
        ("1プラスマイナス0.1", 0.9, 1.1, True, True, [], []),
        ("(2,3]", 2, 3, False, True, [], []),
        ("最大10、最小マイナス5", -5, 10, True, True, [], []),
        ("最大値100 最小値10", 10, 100, True, True, [], []),
        ("大3,小1", 1, 3, True, True, [], []),
        ("最小-10,-5未満", -10, -5, True, False, [], []),
        ("5以上、最小1", 5, None, True, False, [], []),
        ("100より上、小10", 100, None, False, False, [], []),
        ("30以下20以上", 20, 30, True, True, [], []),
        ("最小-5最大50", -5, 50, True, True, [], []),
    ],
)

工夫したところ

いろんな範囲を扱おうとすればするほど、範囲と類似した表現も範囲とみなしてしまうという問題に遭遇することが多くなります。
特に弊社が扱うような製造業で範囲に類似したものが "製造番号" です。
例えば以下のようなものになります。

  • 21K-0131
  • P34-9871

"21-131"のような書き方は範囲として見たいので、すべてを取り除くことは不可能ですが、上記のような特徴を持った番号、つまり

  • アルファベットが先頭の文字の方に付いている(20-30mみたいな書き方を許すため、後ろはOK)
  • 数字の先頭に0が付いている
  • 数字の先頭にアルファベットが付いている

などは範囲としては省くようにしました。

以上の仕様をAI(Codex)に伝えて作ってもらう

以上の仕様を何回かに分けて、Codexに指示を与えて作ってもらいました。
以下のように細かく指示を分けて、少しずつ仕様を満たすように作ってもらうことで、ほぼCodexのみでコーディングができました。

これらの正規化を使って日本語CSVを正規化

最初のサンプルで出した表をja-timexjp-rangeを用いて正規化してみます。

import re
from datetime import datetime
from dateutil import parser as dtparser     # ja-timex で取れなかったときの保険
import pandas as pd
from ja_timex import TimexParser
from jp_range import parse as parse_range

raw = [
    ["21K-0131", "2025-06-01",      "2025/06/02",     "120",   "20から30"],
    ["21K-0132", "2025年6月3日",     "R7.06.04",       "150", "30以上40以下"],
    ["21K-0133", "2025年6月5日",     "2025-06-06",     "123",   "5〜7"],
    ["21K-0150", "令和7年6月7日",    "2025/06/08(月)", "200",   "70超90以下"],
    ["21K-0200", "2025年6月8日",     "2025/06/09",     "80",    "60より下"],
]
df = pd.DataFrame(
    raw,
    columns=["管理番号", "受注日", "検査日", "数量", "温度範囲"]
)

_timex = TimexParser()

def normalize_date(text: str) -> pd.Timestamp:
    """
    ja-timex で parse → 最初の DATE を YYYY-MM-DD に。
    落ちたら dateutil.parser で汎用解析。
    """
    timexes = _timex.parse(text)
    for t in timexes:
        if t.type == "DATE":
            return pd.Timestamp(t.to_datetime().date())
    # ja-timex で取れなかった場合(例: "R7.06.04")
    # 元号の簡易変換(例: R=令和, H=平成 等)
    text_conv = re.sub(
        r"([RrR][0-9]{1,2})\.",
        lambda m: f"令和{int(m.group(1)[1:])}年",
        text
    )
    return pd.Timestamp(dtparser.parse(text_conv, dayfirst=False, yearfirst=False).date())

def normalize_qty(text: str) -> int:
    """全角→半角 & int キャスト"""
    z2h = text.translate(str.maketrans({chr(0xFF10 + i): str(i) for i in range(10)}))
    return int(z2h)

def normalize_range(text: str) -> pd.Interval:
    """jp-range で pandas.Interval に"""
    return parse_range(text)

df["受注日_norm"]   = df["受注日"].apply(normalize_date)
df["検査日_norm"]   = df["検査日"].apply(normalize_date)
df["数量_norm"]     = df["数量"].apply(normalize_qty)
df["温度範囲_norm"] = df["温度範囲"].apply(normalize_range)

# 必要なら列の並び替え
cols = ["管理番号", "受注日_norm", "検査日_norm", "数量_norm", "温度範囲_norm"]
df_normalized = df[cols]

print(df_normalized)

これを実行すると以下のようなDataFrameが得られます。

       管理番号   受注日_norm   検査日_norm  数量_norm     温度範囲_norm
0  21K-0131 2025-06-01 2025-06-02      120  [20.0, 30.0]
1  21K-0132 2025-06-03 2025-06-04      150  [30.0, 40.0]
2  21K-0133 2025-06-05 2025-06-06      123    [5.0, 7.0]
3  21K-0150 2025-06-07 2025-06-08      200  (70.0, 90.0]
4  21K-0200 2025-06-08 2025-06-09       80  (-inf, 60.0)

これで最初の表がプログラム的に扱いやすくなり、LLMエージェントでもうまく処理できそうです。

まとめ

行数の多い日本語CSVの正規化について、既存のツールの紹介や、新たに"範囲"を正規化するためのパッケージ作成を行い、実際に正規化を行ってみました。
最初はこういうのをLLMに文字変換してもらっていたのですが、行数が多いとトークン制限で難しくなるので、正規化できるものは正規化するという考え方に落ち着きました。
日本語文字の正規化はいろんなパターンが考えられるので、自分で作るにはかなり頭を悩ませる感じで面倒ですが、Codexのようなコーディングエージェントを使用することで、気楽に作れるようになったのはすごいことですね。
今後も日本語ドキュメントの扱いについていろいろと共有できたらと思ってます。

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

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

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

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

Discussion