はじめに
最初は普通に「LLMに自然言語で質問したらSQLを書いて実行してくれる」というやつを作っていました。BigQueryにデータを溜めて、Geminiに「このテーブルからこういう条件で集計して」って頼むとSQLを生成して投げてくれる、いわゆるText-to-SQLです。
動くには動くんですが、触っているうちにだんだん不安になってきました。複雑な質問を投げたときに、LLMが微妙に間違ったJOINやWHERE条件のSQLを書いてしまうケースがあって、しかも実行結果はそれっぽい数字がちゃんと返ってくるので、パッと見だと間違いに気づけないんですよね。これを「実務で使えるBIツール」として社内に展開するのは怖いなと。
それと、これをSlackから誰でも呼べるようにしたいと思ったときに、「LLMがその都度SQLを考える」という方式だと再現性がなくて、属人化というかブラックボックス化していく感覚がありました。同じ質問をしても、SQLの書き方が微妙に違えば結果も微妙に変わりうる。
そこで、セマンティックレイヤーという考え方に出会いました。今回はこれを自分で実装しながら理解して、実務で再現性のある仕組みまで持っていくのを目標にしています。ゴールは、LLMにSQLを書かせるのとはハッキリ性能の差を感じられるところまで。
結論から言うと、キモは「LLMにSQLを書く権限を一切渡さない」という設計でした。そこに至るまでの技術選定・アーキテクチャ・そして実際にたくさん踏んだバグの話を、順番に書いていきます。
技術選定の比較
まず整理したのが、この2つの方式の違いです。
方式A: LLMが直接SQLを書く(Text-to-SQL)
- 自然言語 → LLM → SQL文字列 → BigQueryで実行
- 自由度は高いが、LLMが「存在しないカラム名」「間違ったJOIN」「意図しない集計」を書いてしまう余地が常にある
- 同じ質問でも生成されるSQLが毎回微妙に違いうる(再現性が低い)
方式B: セマンティックレイヤー経由(今回採用)
- 自然言語 → LLM(Function Calling) → 「どの指標」「どの軸」「どんな条件」を選ぶだけ → セマンティックレイヤーが機械的にSQLへコンパイル → BigQueryで実行
- LLMはSQLを一切書かない。あらかじめ人間が定義した指標(measure)・軸(dimension)の中から選ぶだけ
- 存在しない指標名を選んだら定義側でエラーになる。JOINの仕方や集計方法も人間が事前に固定しているので、都度LLMが判断する余地がない
今回はOSSのセマンティックレイヤーである Cube Core を採用しました。自前でホスティングできてBigQueryにそのまま繋げるのと、YAMLで指標・軸を定義するだけで自動的にAPI(/meta, /load)が生えてくるのが決め手です。
全体アーキテクチャ
最終的にできあがった全体像はこんな感じです。
ポイントは、Geminiが触れるのはCube Coreの「カタログ(指標・軸の一覧)」と「JSON形式のクエリ引数」だけという点です。BigQueryにもCube Coreにも直接アクセスできず、SQLという文字列を生成する場面自体がアーキテクチャ上存在しません。
Slackの3秒応答制約(スラッシュコマンドは3秒以内に何か返さないとタイムアウト扱いになる)に対しては、Cloud Tasksでいったんキューに積んで非同期化し、実処理が終わったらresponse_urlという一時URLに結果をPOSTする形にしています。実測したらGemini→Cube→BigQueryの一連の処理は5秒前後かかっていたので、これがないと確実にタイムアウトしていました。
核心の設計思想
ここが今回一番伝えたいところです。
Cube Coreに投げるクエリはこういうJSONです。
{
"measures": ["sales.total_amount", "sales.count"],
"dimensions": ["sales.agent_name"],
"filters": [
{ "member": "sales.agent_name", "operator": "equals", "values": ["担当者A"] }
],
"timeDimensions": [
{ "dimension": "sales.action_date", "dateRange": ["2026-07-01", "2026-07-31"] }
]
}
Geminiにやらせているのは、この形式のJSONを「Function Calling」で組み立てることだけです。system promptには「SQLは絶対に書くな、必ずこの関数を呼べ」と明記し、measure/dimensionの名前は事前に用意したカタログに実在するものしか使えないようにしています。
これの何が強いかというと、LLMが間違えられる余地が構造的に狭いんです。SQLを自由記述させると「JOINの向き」「集計関数の選び方」「WHERE句の書き方」など間違えるポイントが無数にありますが、Function Callingで選ばせる方式だと、LLMの仕事は「どの指標」「どの軸」「どんな条件」を選ぶかだけに縮小されます。選んだ後のSQLへの変換は、人間が定義したロジックが機械的に行うので、そこにLLMのブレは一切入りません。
そしてもう一つ大事なのが、そのカタログ(セマンティックレイヤー)自体は人間が定義するという点です。最初は「AIに一括で判断させれば早いのでは」とも思ったんですが、実際にやってみると、指標の定義や結合条件の妥当性は人間がちゃんと目を通して判断すべき部分だと気づきました。この工程を丁寧にやるかどうかが、最終的な回答精度に直結します。
セットアップ手順
大まかな流れだけ書いておきます(社内固有の実データ定義は割愛)。
- BigQueryにデータを溜める: 既存の生ログをBigQueryのネイティブテーブルとして持ってくる
-
Cube CoreのYAMLで指標・軸を定義する:
measures(集計対象)とdimensions(集計の粒度)をYAMLで書く
cubes:
- name: sales
sql_table: my_project.my_dataset.sales_log
measures:
- name: count
type: count
- name: total_amount
sql: amount
type: sum
- name: avg_amount
sql: "SAFE_DIVIDE({total_amount}, {count})"
type: number
dimensions:
- name: agent_name
sql: agent_name
type: string
- name: action_date
sql: action_datetime
type: time
-
ローカルのdocker-composeで動作確認:
cubejs/cubeイメージを立てて/metaが正しくカタログを返すか確認 - Dockerfile化してCloud Runへデプロイ: Cloud Runにはdocker-composeのボリュームマウントに相当する仕組みがないので、YAML定義をイメージに焼き込むDockerfileを別途用意する
- Slack Appを作成し、Slash Commandを登録: Request URLをwebhook用Cloud RunのURLに向ける
精度・品質の話
ここからは実際に踏んだ「動くはずが動いてなかった」系の話です。地味に一番学びが多かったところです。
filtersが抜けていて、絞り込みのつもりが全件返ってきた
ローカルで動作確認していたとき、「担当者Aさんの実績を教えて」と聞いたら、担当者Aさんだけじゃなく全担当者分の内訳が返ってきたことがありました。
原因を追ったら、GeminiにFunction Callingで渡していたJSONスキーマに、そもそもfiltersとtimeDimensionsという絞り込み用のパラメータ自体を用意していませんでした。Geminiからすると「絞り込みたくても絞り込む手段がない」状態だったわけです。dimensionsに軸を指定するだけでは「その軸で内訳を出す」動きになるだけで、絞り込みにはならない、というのを実際のズレた回答を見て初めて理解しました。
これはローカルで実際に何度も質問を試していたから見つかったバグで、コードを読むだけだと気づけなかったと思います。
JOINしただけで指標が水増しされる(fan-out)
「指標Xと指標Yを一緒に見たい」と思って、それぞれ別のテーブルにある指標を単純にJOINしたcubeを作ったことがありました。テストで件数を見たら、片方の指標が本来の値より大きく水増しされていることに気づきました。
これは典型的な「fan-out」問題で、1対多の関係にあるテーブル同士をJOINしてから集計すると、JOINで行数が増えた分だけSUMやCOUNTが二重・三重にカウントされてしまう、というやつでした。Cube公式のドキュメントにもこのパターンの注意書きがあって、該当のJOINと合成指標を削除し、必要なら別々にクエリを投げて後からアプリ側で割り算する方式に切り替えました。
「JOINして一緒に見れたら便利そう」という直感だけで進めず、実際の集計値を元の生データと突き合わせて検算したから見つかったバグです。これ以降、指標を追加するたびに簡単なSQLで裏取りするのを習慣にしました。
注意点・ハマりどころ
Cloud Run IAMとCubeのJWT認証が同じヘッダーを取り合う
Cube CoreをCloud Runにデプロイする際、「Cloud Run自体のIAM認証(--no-allow-unauthenticated)」と「Cubeが持つJWT認証(CUBEJS_API_SECRET)」の二重で守ろうとしたんですが、これがハマりました。どちらの認証もAuthorization: Bearer <token>という同じヘッダーを使うのに、要求するトークンの種類が別物(GoogleのIDトークン vs Cube独自のJWT)なんです。片方を満たせばもう片方が「Invalid token」になり、両方同時に満たす方法が存在しませんでした。
Cubeの公式ドキュメントを確認しても別ヘッダー名にする設定はなく、これは設定ミスではなく構造的に両立しない組み合わせだと判断しました。最終的には、Cloud Run側のIAM保護だけに一本化する形に倒しています(低トラフィックな社内ツールという前提での判断です)。
YAMLの中のバックスラッシュが2段階で消える
正規表現を使うdimensionをCube YAMLに書いたとき、Cannot parse regular expressionというエラーが出ました。原因を追うと、Cubeの内部処理でYAML→SQLへコンパイルする過程でバックスラッシュが1段階減ってしまう仕様があり、さらにsql: "..."のようにダブルクォート文字列で書いていた場合、YAMLのパース自体でもバックスラッシュが1段階減ってしまい、都合2段階も消えることが分かりました。
対処法は、正規表現やバックスラッシュを含むsqlは必ずsql: >のような折り畳みブロックスカラー形式で書くことでした。ダブルクォート文字列にすると、Cubeに渡る前にYAMLパーサー自身がバックスラッシュを食べてしまうので注意が必要です。
まとめ
今回一番実感したのは、「LLMにSQLを書かせない」というアーキテクチャ上の制約そのものが、精度と信頼性の土台になる、ということでした。LLMが自由にSQLを書ける方式だと、間違いの入り込む余地は無限にありますが、Function Callingで「あらかじめ人間が定義した指標・軸から選ぶだけ」に絞ると、LLMが担当する範囲そのものが小さくなります。
そしてそのセマンティックレイヤーの定義自体は、今も昔もAIに丸投げせず自分で判断しながら作っています。「AIに一括生成させて終わり」にはせず、指標の定義やJOINの妥当性を自分の目で確認する工程を挟んだからこそ、fan-outのような一見わかりにくいバグにも気づけました。
「動くはずのJOIN」も「動くはずのフィルタ」も、実際に自分の手でターミナルを叩いて質問を投げてみて、初めて「あれ、なんかおかしいぞ」と気づけたものばかりです。実データで裏取りする、というごく地味な姿勢が、結局は一番効いていた気がします。
Slackから初めて正しい数値が返ってきたときは、単純に「やっとここまで来た」という達成感と、これを自分以外の人が実務で使えるようになると思うとちょっとワクワクしました。次はセマンティックレイヤーの定義をもう少し広いデータに広げていく予定です。