📊

ローカル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": ["東京都区部"]
    }
  ]
}

granularitymonthに変えれば月単位、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への対応

リポジトリ

ソースコードは以下で公開しています。

https://github.com/tamoco-mocomoco/ai-de-graph

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を採用し、timeDimensionsgranularitydateRangeを動的に変更できるUIを実装しました。

正直なところ、まだまだ荒削りな部分は多いです。LLMが生成するクエリの精度、複雑なグラフへの対応、パフォーマンスの最適化など、考えるべきことは山積みです。

しかし、「セマンティックレイヤー + LLM + 可視化ライブラリ」という組み合わせには可能性を感じています。もう少し煮詰めていけば、実用的なツールとして育てていけるのではないか——そんな可能性を感じた次第です。

Discussion