はじめに
RAGを社内で使おうとすると、だいたい「資料はExcelで管理してます」と言われます。でもこのExcelはそのままRAGに入れるとあまり精度が出ないというのはあるあるです。(私だけ?)
AWSのナレッジベースはExcelだけマネージドな処理から見事に外れています。
なので「どんなに汚いExcelが来てもRAGに載る形に変換する」パイプラインを自作してみました。途中で検算そのものが嘘をついていたりして、なかなか学びが多かったです。
Bedrock Knowledge Bases は Excel をどこまで読めるのか
まず前提の確認からです。Knowledge Bases は .xls/.xlsx をサポートしています。ただし、処理できるのはデフォルトパーサーだけでした。
| パーサー | 対象形式 | xlsx |
|---|---|---|
| Bedrock default parser | txt/md/html/doc(x)/xls(x)/pdf のテキストのみ | 通るが平坦化される |
| Bedrock Data Automation | PDF, TIFF, JPEG, PNG, DOCX, 音声, 動画 | 非対応 |
| Foundation model as parser | .pdf, .jpeg, .png のみ | 非対応 |
| Smart Parsing(Managed KB) | PDF, PPT/PPTX, DOCX, 音声, 動画 | 記載なし |
| Amazon Textract | JPEG, PNG, PDF, TIFF | 非対応 |
動画まで読めるようになったBDAも、Textractも、Excelには口がありません。デフォルトパーサーの説明には「図や表や画像を含むならBDAかFMを使え」と書いてあるのですが、そのBDAもFMもxlsxを見てくれないという状態です。
さらに調べていくと、KBの中に前処理を差し込む余地もありませんでした。カスタム変換Lambdaの stepToApply は POST_CHUNKING のみなんですよね。
つまりLambdaが呼ばれるのは「パース済み・チャンク済み」の後です。Excelの構造が壊れた後のテキストしか触れません。
なので、前処理はS3に置く前に外出しするしかなということになります。
そのまま入れると何が失われるのか
検証用に、わざと汚いExcelを作りました。結合セルの2段見出し、1シートに3つの表、小計行の下に続くデータ、値キャッシュのない数式、埋め込み円グラフ、セルコメント、表の外に書かれた単位…といった罠を10個仕込んでいます。
素朴にテキスト化するとこうなります。
2025年度 部門別業績報告
単位: 千円(人員数を除く)
作成: 経営企画部 / 2026-04-10
2024年度 2025年度
部門 上期 下期 上期 下期
営業部 12000 13500 14200 15800
開発部 8000 8200 9100 9500
管理部 3000 3100 3050 3200
小計
製造部 5000 5200 5400 5600
親見出しが2列分しか出ていないので、どの数字がどの年度の上期なのか復元できません。小計行は数値が消えて行名だけが残っています。単位は表から3行離れたところに漂っています。
実際に数えてみると、こんな感じでした。
| 失われるもの | 件数 |
|---|---|
| 未計算数式(空欄になる) | 13 |
| 結合セルの親見出し(対応が切れる) | 8 |
| 表外の単位情報 | 2 |
| セルコメント | 1 |
| 埋め込み画像 | 1 |
設計:LLMに数値を触らせない
素直に組むなら「シートをLLMに見せて整形済みの表を出力させる」になると思います。でもこれ、絶対にやってはいけないと思っていて。LLMに数値を書き写させると、数字が化けるからです。RAGの元データで金額が一桁変わったら、後段でどれだけ精度を上げても意味がありません。
なので役割をこう分けました。
| 誰が | 何を |
|---|---|
| LLM | 「どこからどこまでが表か」を指示する(レシピJSONを出す) |
| openpyxl | そのレシピ通りに機械的に値を抜く |
こうすると、LLMが誤っても誤るのは範囲指定であって数値ではないという状態を作れます。そして範囲指定の誤りは、後段の検算で機械的に検出できます。
作ったもの
全体は4段構えです。
構造を測る → 設計図を描く → 機械的に抜く → 元と突き合わせる
Excelの構造を把握する
Excelをそのままレシピ生成に渡すのではなく、座標と書式マーカーを付けたダンプに変換します。
B5: [MERGED:B5:C5|B|FILL] 2024年度
A6: [B|FILL|BOX] 部門
A7: [BOX] 営業部
E7: [BOX|COMMENT|FMT:#,##0] 15800
B10: [B|BOX|FORMULA] =SUM(B7:B9) =>値:None
座標を付けるのは、LLMに位置で答えさせるためです。書式マーカーを付けるのは、人間が「これは見出しだ」と分かるのが太字や塗りや罫線という見た目だからで、それをテキストで伝えないとLLM側にも分かりません。
レシピをJSONを出させる
Bedrockの構造化出力(output_config.format)でJSON Schemaを強制します。実際にClaude Haiku 4.5が返してきたのがこれです。
{
"title": "2025年度 部門別業績報告",
"first_col": "A", "last_col": "E",
"header_rows": [5, 6],
"is_multilevel_header": true,
"data_row_spans": [{"start_row": 7, "end_row": 9},
{"start_row": 12, "end_row": 13}],
"subtotal_rows": [10],
"total_rows": [14],
"unit_note": "千円(人員数を除く)"
}
見出しが行5と行6の2段であること、データが (7-9)と(12-13)に分断されていること、行10が小計・行14が合計でデータ本体ではないこと、表の3行上にあった単位。この4つを正しく読み取れています。
Extract:コードが機械的に抜く
ここにLLMは一切関与しません。結合セルの「親を辿る地図」を先に作っておくのがコツでした。
# 結合範囲の全座標 → 左上座標 のマップ
merged = {"B5": "B5", "C5": "B5", "D5": "D5", "E5": "D5"}
C5は空セルですが、この地図を引けばB5に飛んで「2024年度」が取れます。これで 2024年度_下期 という列名が作れるわけです。
未計算数式については、data_only=True が None を返したら自分で計算し直しています。ただし汎用の数式エンジンは作りません。SUMと単純な加減算だけです。計算できなかったときは空欄にせず「未計算」として記録します。空欄にした瞬間、下流はそれを0と読んでしまうので。
元シートと1対1で突き合わせる
Extractのときに source_rows を記録してあるので、出力のどのセルが元のどの座標から来たかが分かります。これを全セルで照合します。
そのうえで、覆えなかったセルを「意図的に除外した小計行」と「気づかず落としたもの」に分けます。この区別がないと、網羅率90%と出たときに安心していいのか判断できません。
通した結果
汚いExcel(3シート / 118セル)を通した結果です。
数値の1対1一致 : PASS (不一致 0件)
セル網羅率 : 100.0% (118/118)
情報網羅率(画像込) : 100.0% (119/119)
画像・グラフ : 1/1 処理済み
再計算した数式 : 5件
説明のつかない欠落 : 0件
出力はこんな感じになりました。
# 2025年度 部門別業績報告
出典: シート「売上実績」 A7:E13
単位: 千円(人員数を除く)
| 部門 | 2024年度_上期 | 2024年度_下期 | 2025年度_上期 | 2025年度_下期 |
|---|---|---|---|---|
| 営業部 | 12,000 | 13,500 | 14,200 | 15,800 |
| 開発部 | 8,000 | 8,200 | 9,100 | 9,500 |
## 注記
- 営業部: E7: 2025年度下期は大型案件(A社向けシステム更改)3,200千円を含む。
単位も但し書きも本文に残っているのがポイントです。これならRAGで「営業部の売上は何円ですか」と聞かれても答えられます。
コストは3シートでin=9,209 / out=530トークン、Haiku 4.5で1円台でした。
AWS上にどう載せるか
AWS上でのパイプラインを作るとこんな感じになります。
汚いファイルを扱う以上どこかで必ず失敗するので、失敗が黙って消えないようにするのが本題でした。
| 仕掛け | 役割 |
|---|---|
| S3 → EventBridge → SQS → Lambda | Lambda直呼びだと同時実行上限でイベントが消える |
| DynamoDBの処理台帳 | 「網羅率100%でないレコード」を1クエリで引ける |
document_id を決定的に生成 |
同じIDで再投入すればin-place更新される |
| 検算 → 差し戻し | 欠落した行番号とセル値をレシピ生成に返す |
最後のS3 Inventory突合が地味に大事です。通知設定漏れや一括アップロードで、イベントは普通に取りこぼされるので。
あとアラームはセル網羅率と情報網羅率を別々に持ちます。一つにまとめると、後述の「セルは100%だけど図が落ちている」状態がまた見えなくなります。
コスト試算
単価はレシピ生成が $0.0064/シート、画像説明が $0.0024/枚(Haiku 4.5)です。
| 規模 | Bedrock費用 |
|---|---|
| 100ファイル × 5シート | 約 $3 |
| 1,000ファイル × 5シート + 画像3,000枚 | 約 $40 |
1000ファイルを丸ごと処理して6,000円くらいです。Planは2.5秒/シートなので5,000シートを逐次だと3.5時間ですが、MaxConcurrencyを40にすれば5分程度に収まります。
この構成はまだ設計段階で、実際にデプロイして動かしたわけではありません。メモリ・時間・コストの数値はすべてローカルでの実測値ですが、構成そのものの検証は次回やります。
ハマったポイント
1. data_only=True は数式の値を返さないことがある
これが一番ヤバいやつです。data_only=True で返るのはExcelが最後に保存したキャッシュ値なので、プログラムで生成されて一度もExcelで開かれていないファイルでは None になります。今回のサンプルでは数式13個すべてが該当しました。
wb_val = load_workbook(path, data_only=True) # 数式の計算結果
wb_fml = load_workbook(path, data_only=False) # 数式そのもの
両方で開いて突き合わせるしかありません。ちなみに data_only=True で開いて save() すると数式が値に永久置換されるので、原本を壊さないよう注意です。
2. モデルを上げる前にスキーマを直す
初版のスキーマだと、Haiku 4.5は多段見出しと「小計行の下に続くデータ」を取りこぼしました。製造部と物流部が消えるという結構致命的なやつです。
ここでSonnetに上げたくなったのですが、その前にスキーマを作り直したらHaikuのまま完全正解しました。Sonnet 4.6と結果は一字一句同じで、単価は1/3です。
| 追加したもの | 効果 |
|---|---|
data_row_spans を区間のリストに |
小計で分断される構造を表現できる |
is_multilevel_header を必須に |
多段か否かを必ず判断させる |
subtotal_rows / total_rows を分離 |
データ本体と混ざらない |
3. 検算が数えていないものは、存在しないのと同じ
これが今回いちばんの学びです。初版の検算処理は「セル網羅率100%」と報告していましたが、分母は非空セルだけで埋め込み画像を数えていませんでした。16KBの円グラフが丸ごと落ちているのに満点が出ていたわけです。
なのでセル網羅率とは別に「情報網羅率(画像込み)」を分けて出すようにしました。
セル網羅率 : 100.0% (118/118)
情報網羅率(画像込) : 99.2% (118/119) ← 本当の数字
画像・グラフ : 0/1 処理済み
検算項目は「何を数えていないか」を意識して設計しないと嘘をつきます。これはExcelに限らず、評価設計そのものの教訓だなと感じました。
4. 小さいサンプルでは出ないバグがある
8シート・21,000セルでスケール試験をしたら、1200行と800行の台帳が丸ごと落ちていました。sheet_kind=ledger と判定されたシートで、私が書いたプロンプトが「表がないシートはtablesを空にせよ」と読ませていたのが原因です。304行のシートは table と分類されて助かり、行数の多いものだけが消えるという...。
なおプレビューは60行で頭打ちにしているので、1204行でも304行でも約4,600字に収まります。コストはシート数にのみ比例するので、行数が増えても怖くありません。
さいごに
やってみて分かったのは、Excelの前処理は「変換」より「検算」が本体だということです。
変換自体はopenpyxlで頑張ればできます。でも「どんなに汚いファイルが来ても大丈夫」と言えるかは、出力を元データと突き合わせて数字で示せるかにかかっています。21,000セルを処理して取りこぼしが4行あったのですが、これは目視では絶対に見つかりません。
一方で限界もあって、60行を超えた位置にある2つ目以降の表はまだ拾えません。プレビューを「同じ形の行が続く区間は1行に圧縮する」方式にすれば解決しそうなのですが、そこはまだ手を付けられていません。
あと、全件を自動で通そうとしないことも大事だと思っています。落ちたものは人間キューに回す前提で組むのが健全かなと。
次はこれを実際にKnowledge Basesに載せて、素のExcelを入れたKBと検索精度を比較してみる予定です。
参考
