TL;DR
- DuckDB-Wasm でブラウザで完結する爆速リアルタイムSQL実行環境を作れる
- サンプルとして日本株の日次価格データ事例を紹介
- 定期的に
EXPORT DATA OPTIONS (format='PARQUET')で Parquet 化、 GCS → 静的ホスティングに配信しWebで集計
モチベーション
個人で日本株データを触りたかったが、 商用 API は使い勝手が悪いし既存サービスは UI 固定で集計の自由度が低いし、他のデータと組み合わせの視認性は悪い。 ひとまずBigQueryにデータ収集する環境を構築したものの毎度SQLを自前で書いてBigQueryコンソールを叩くのもだるかったので、小回りの効く集計をしたいと思った。 そこで 「日次データを BigQuery に貯める → Parquet で書き出す → ブラウザ DuckDB-Wasm で集計」 という構成でWeb実装した。 2025年に実装した内容だが、DuckDB-Wasmの実例紹介はあまり多くはないので書く。 (Stock BIというプロダクト名でリリースを目論んでいたがpending。EDINET CLIとかせっかく頑張って書いたので頑張れ自分)
なぜブラウザで完結させたいのか
本業でもビッグデータを対象にしたBIサービスを作っている。 集計対象にもよるものの、数億レコードの集計に対する複雑なロジック集計時1分〜かかることもあるが、この一分の間に他のことに思考がいってしまうのでコンテキストスイッチによってパフォーマンスが落ちる感じがある。 データ分析は目的ではなく手段であり、目的のための作業プロセスは爆速に終わらせたいという願望があり、 技術で解決できるのならそれは解決したいというのが根幹。要はエゴ、または浪漫。
ただし本業の数億レコードはサーバ側 (BigQuery 等) でなければそもそも捌けない。 一方で 個人実験の数 MB 〜 数百 MB レベル ならブラウザに収まるので、 ここなら数十 ms 集計が現実的。 今回はこの 「個人スコープ」 を切り出して、 静的サイトに動的 BI を埋め込んでみた、 という話。
ブラウザ DuckDB-Wasm は数年前まで WASM サイズが重くて非実用だったが、 最近の MVP/EH バンドル分離で実用域に入った。 一度ロードしてしまえばクエリは数十 ms オーダーで走るので動かしていて楽しい。(重要)
全体パイプライン
データソース (scheduled crawler)
↓
BigQuery (raw + datamart)
↓ EXPORT DATA OPTIONS (format='PARQUET')
GCS bucket
↓ sync (1日1回)
静的ホスティング (Cloudflare / R2)
↓ fetch (browser, lazy)
DuckDB-Wasm
↓ SQL aggregation
React UI (chart + table)デモ
下のデモは 記事を開いたあなたのブラウザ内 で動いている。 前述した仕込みで Parquet を読み込み、 DuckDB-Wasm で集計する。
集計企業をいくつ選んでも数十 ms で再集計される。 (初回 fetch (Parquet 10MB + DuckDB wasm 35MB) だけロードがかかる)
AI チャットも記事内で動かす (デモモード)
実プロダクトの Stock BI には 集計結果を自然言語で問える AI チャット が乗っている。いわゆるConversational Analyticsというやつ。 「直近で値上がり率が高い銘柄は?」 と聞くと、 内部で SemanticLayer(≒DB Schema + description + ER) → DuckDB → LLM ナラティブ生成、 という3段が走って答えが返ってくる、 という構成。
このデモでは LLM 呼びたくない (API key 露出問題、 誰の Claude/GPT を使うか問題) ので質問とナラティブ部分は固定値。 が、 SQL は本物が DuckDB に投げられて、 表示の数字はあなたのブラウザがその場で計算した結果 になっている。 つまり 「AI が SQL を書いて DuckDB に投げる → 結果を整形してナラティブにする」 という実プロダクトの中核ループのうち、 後半の SQL→結果の部分はリアルに動いている。
各 AI 応答の ▸ generated SQL を開くと内部クエリが見える。
実プロダクトの構成と、 デモで動いてる範囲の対応:
- 自然言語 → Semantic Layer の metric/dimension に変換 ─ デモではスクリプト固定 (チップが対応する SQL を選ぶだけ)
- SemanticLayer 経由で DuckDB / BigQuery にクエリ ─ デモでも本物が動く (ブラウザ DuckDB-Wasm)
- 結果 + 元の問いを LLM に渡してナラティブ生成 ─ デモではテンプレ文字列に
{table}を差し込むだけ
ブラウザ DuckDB-Wasm の仕込み
実装はこのくらい:
import * as duckdb from '@duckdb/duckdb-wasm';
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
const worker = new Worker(bundle.mainWorker!);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
await db.open({ path: ':memory:' });
// Parquet を fetch して登録、 View として宣言
const buf = new Uint8Array(await (await fetch('/data/stocks.parquet')).arrayBuffer());
await db.registerFileBuffer('stocks.parquet', buf);
const conn = await db.connect();
await conn.query(`CREATE VIEW stock_quotes AS SELECT * FROM read_parquet('stocks.parquet')`);補足
- 書き出した Parquet は 10MB。 中身は日次の
(symbol, date, open, high, low, close, volume)、 全銘柄 × 約1年分。 思ったより小さいデータに収まる。- 10MB は初回ロード重め (3G で2-3秒) なので、 用途次第では partition で月別 Parquet に分けて 1MB ずつ fetch する戦略もありえる。 今回はシンプル優先で全件 1ファイル。
:memory:で DB file を作らずインメモリ動作 ─ ページリロードで状態リセットされるパターンで動かしている。永続化したければ OPFS バックエンドが使える。
最後に
- 爆速にレンダリングされるSSGのテキストブログかと思いきや記事中にアプリケーション要素が組み込まれるという面白体験を自分もやってみたかったので試した。
- 初見は naoty.dev。記事中に棋譜ログを再生するコンポーネントが現れて真似したくなった。多分1年越しくらいにパクっている。