ローカルLLM + Cube.js + EChartsで自前のBIツールを試しに作ってみた
はじめに
BIツールは便利ですが、商用ツールはライセンス費用が高額だったり、クラウド依存だったりします。「ちょっとしたデータ可視化がしたいだけなのに...」という場面も多いのではないでしょうか。
本記事では、完全にローカルで動作する軽量BIツールを自作した話を紹介します。自然言語でグラフを生成でき、時間軸の動的な切り替えにも対応しています。
BIツールに必要な要素
一般的なBIツールを置き換えるにあたって、以下の要素が必要だと考えました。
1. セマンティックレイヤー
生のSQLを直接書くのではなく、「売上」「顧客数」といったビジネス用語でデータを扱える抽象化レイヤーが必要です。
- measures(指標): 集計値(合計、平均、カウントなど)
- dimensions(ディメンション): グループ化や軸に使う属性(日付、地域、カテゴリなど)
- フィルター: 条件による絞り込み
2. 時間軸の柔軟な操作
データ分析では時間軸の操作が頻繁に発生します。
- 時間単位の切り替え(年 → 四半期 → 月 → 週 → 日)
- 日付範囲の指定(2022年1月〜12月のみ表示)
- これらをUIから動的に変更できること
3. 直感的なグラフ生成
SQLやクエリ言語を知らなくても、自然言語でグラフを作れると便利です。
- 「売上上位10店舗を棒グラフで」
- 「月別の売上推移を折れ線グラフで」
4. グラフの保存・再利用
作成したグラフを保存して、後から参照・編集できる機能も必要です。
作ったもの: AI de Graph
上記の要件を満たすため、以下の構成でツールを作りました。
[React SPA] <--REST--> [FastAPI] <---> [Cube.js] <---> [PostgreSQL]
|
+--> [Ollama (ローカルLLM)]

技術選定
| 要素 | 選んだ技術 | 選定理由 |
|---|---|---|
| セマンティックレイヤー | Cube.js | OSSで無料、REST API提供、スキーマ定義が簡単 |
| グラフ描画 | ECharts | 高機能、日本語対応、React連携が容易 |
| 自然言語→クエリ変換 | Ollama + qwen2.5-coder | 完全ローカル動作、無料、コード生成に強い |
| バックエンド | FastAPI | Python製、非同期対応、型安全 |
| フロントエンド | React + TypeScript | 型安全、エコシステムが充実 |
すべてDockerで動作し、外部APIへの依存がありません。
Cube.jsによるセマンティックレイヤー
スキーマ定義
例として、消費者物価指数(CPI)のスキーマを見てみます。
// cube/model/cpi.js
cube(`Cpi`, {
sql: `SELECT *, MAKE_DATE(year, month, 1) as period_date FROM open_data_cpi`,
measures: {
avgCpi: {
type: `avg`,
sql: `cpi_total`,
title: `平均消費者物価指数`,
},
maxCpi: {
type: `max`,
sql: `cpi_total`,
title: `最大物価指数`,
},
},
dimensions: {
city: {
sql: `city`,
type: `string`,
title: `都市`,
},
periodDate: {
sql: `period_date`,
type: `time`,
title: `期間`,
},
},
});
これにより、SQLを意識せずに「都市別の平均物価指数」といった指標を扱えます。
クエリ例
Cube.jsへのクエリはJSON形式で記述します。
{
"measures": ["Cpi.avgCpi"],
"timeDimensions": [
{
"dimension": "Cpi.periodDate",
"granularity": "quarter",
"dateRange": ["2020-01-01", "2023-12-31"]
}
],
"filters": [
{
"member": "Cpi.city",
"operator": "equals",
"values": ["東京都区部"]
}
]
}
granularityをmonthに変えれば月単位、yearに変えれば年単位に集計されます。
LLMによる自然言語→クエリ変換
プロンプト設計
ユーザーの自然言語入力をCube.jsクエリ + EChartsオプションに変換するため、以下のようなシステムプロンプトを使用しています。
SYSTEM_PROMPT = """あなたはデータ分析アシスタントです。
利用可能なCube.jsキューブ:
1. Cpi (消費者物価指数データ)
- measures: avgCpi, maxCpi, minCpi
- dimensions: city, periodDate(time型)
...
必ず以下のJSON形式のみで回答してください:
{
"cube_query": { ... },
"echarts_option": { ... },
"title": "グラフのタイトル",
"description": "グラフの説明"
}
"""
スキーマ情報をプロンプトに含めることで、LLMが正しいmeasures/dimensionsを選択できるようにしています。
リトライ機構
LLMの出力が不正な場合や、Cube.jsクエリがエラーになった場合は、エラー内容をフィードバックして再生成させています。
retry_message = (
f"前回生成したCube.jsクエリがエラーになりました。修正してください。\n"
f"失敗したクエリ: {failed_query}\n"
f"エラー内容: {error_message}\n"
)
最大3回リトライすることで、成功率を高めています。
時間軸の動的切り替え
UI実装
時系列データを含むグラフでは、生成後にUIから時間軸を変更できます。
時間単位: [年 ▼] 期間: [2022-01-01] 〜 [2022-12-31] [適用]
バックエンドの再クエリAPI
時間軸を変更すると、/api/requeryエンドポイントを呼び出します。
@router.post("/requery")
async def requery_cube(req: RequeryCubeRequest):
query = req.cube_query.copy()
if req.granularity or req.date_range:
time_dims = query.get("timeDimensions", [])
for td in time_dims:
if req.granularity:
td["granularity"] = req.granularity
if req.date_range:
td["dateRange"] = req.date_range
data = await execute_cube_query(query)
return {"data": data}
フロントエンドはレスポンスを受け取り、EChartsのオプションを更新してグラフを再描画します。
使用例
基本的な棒グラフ
入力: 人口が多い上位10都道府県を棒グラフで表示して
生成されるCube.jsクエリ:
{
"measures": ["Population.totalPopulation"],
"dimensions": ["Population.prefecture"],
"order": {"Population.totalPopulation": "desc"},
"limit": 10
}
時系列グラフ(時間コントロール付き)
入力: 東京都区部の消費者物価指数を四半期ごとに表示して
生成後、UIから以下の操作が可能:
- 時間単位を「月」に変更 → 月単位のデータに更新
- 期間を「2022-01-01〜2022-12-31」に変更 → 2022年のみ表示
複数系列の比較
入力: 東京と大阪の物価指数を月ごとに比較
生成されるCube.jsクエリ:
{
"measures": ["Cpi.avgCpi"],
"dimensions": ["Cpi.city"],
"timeDimensions": [
{
"dimension": "Cpi.periodDate",
"granularity": "month",
"dateRange": ["2020-01-01", "2023-12-31"]
}
],
"filters": [
{
"member": "Cpi.city",
"operator": "equals",
"values": ["東京都区部", "大阪市"]
}
]
}
バックエンドでは、dimensionsに都市が含まれている場合、自動的に都市ごとに別の系列(series)としてEChartsオプションを生成します。これにより、複数の都市の物価推移を同じグラフ上で比較できます。
まとめ
実現できたこと
| 要件 | 実装 |
|---|---|
| セマンティックレイヤー | Cube.jsでmeasures/dimensionsを定義 |
| 時間軸の柔軟な操作 | timeDimensionsのgranularity/dateRangeで対応 |
| 自然言語でのグラフ生成 | ローカルLLMでCube.jsクエリを生成 |
| グラフの保存・再利用 | PostgreSQLにJSON形式で保存 |
商用BIツールとの比較
| 観点 | 商用BIツール | AI de Graph |
|---|---|---|
| コスト | 月額数万円〜 | 無料(セルフホスト) |
| データの場所 | クラウド送信が必要な場合も | 完全ローカル |
| カスタマイズ性 | 制限あり | 自由にカスタマイズ可能 |
| 機能の豊富さ | 非常に豊富 | 必要最小限 |
| サポート | あり | なし(自己責任) |
「本格的なBIツールほどの機能は不要だが、ちょっとしたデータ可視化を手軽にやりたい」という用途には十分使えると思います。
今後の展望
- ダッシュボード機能(複数グラフの配置)
- データソースの追加(CSV/Excel読み込み)
- より高性能なローカルLLMへの対応
リポジトリ
ソースコードは以下で公開しています。
git clone https://github.com/tamoco-mocomoco/ai-de-graph.git
cd ai-de-graph
docker compose up
http://localhost:5183 にアクセスすれば、すぐに試せます。
おわりに
BIツールは便利ですが、使いこなすにはそれなりの知識が必要です。どのディメンションを選ぶか、どのメジャーを使うか、フィルターをどう設定するか——これらを理解していないと、思い通りのグラフは作れません。
今回の試みでは、この「BIツールを使うための知識」をLLMに肩代わりさせることで、自然言語だけでグラフを生成できるようにしました。
また、BIツールが持つ「時間単位の切り替え」「日付範囲の指定」といった機能は、セマンティックレイヤーという仕組みによって実現されています。これを再現するためにCube.jsを採用し、timeDimensionsのgranularityやdateRangeを動的に変更できるUIを実装しました。
正直なところ、まだまだ荒削りな部分は多いです。LLMが生成するクエリの精度、複雑なグラフへの対応、パフォーマンスの最適化など、考えるべきことは山積みです。
しかし、「セマンティックレイヤー + LLM + 可視化ライブラリ」という組み合わせには可能性を感じています。もう少し煮詰めていけば、実用的なツールとして育てていけるのではないか——そんな可能性を感じた次第です。
Discussion