GrafeoDB の WASM ビルドでブラウザに組込みグラフ DB を載せる
@grafeo-db/wasm を使って Cypher / GQL / SPARQL / SQL を 1 つの埋め込み DB で扱うブラウザ完結のグラフ DB パターン — importLpg でのデータ投入、executeCypher、textSearch / vectorSearch、exportSnapshot による zero-backend 配布、IndexedDB 永続化までを触れる demo 付きで整理する実装メモ。
検証日: 2026-05-10
使用バージョン:
@grafeo-db/wasm@0.5.x対象: 「ブラウザだけで完結する RAG / 知識グラフ」を組みたい人。バックエンドが立てられない静的サイトでも、Cypher で関係性を辿りたいケース
@grafeo-db/wasm で ブラウザに組込みグラフ DB を載せる パターン。Cypher / GQL / SPARQL / SQL を 1 DB で扱える + 全文検索(BM25)とベクトル検索(HNSW)も内蔵という性質を活かして、サーバーなしの RAG / 知識グラフを動く demo で確認します。
触って試す
ブラウザで WASM を読み込み、importLpg で 9 ノード / 10 エッジを投入したあと Cypher を実行している。MATCH (a:Person)-[:WROTE]->(b:Book) のような 関係を辿るクエリ が SQL の JOIN なしで書けるのが要点。
1. なぜ「ブラウザに DB」を再考するか
これまでブラウザ側 DB の選択肢は概ね:
- IndexedDB:Key-Value / オブジェクトストア。クエリ言語は弱い
sql.js/@sqlite.org/sqlite-wasm:SQL は書けるが、再帰クエリは重い@duckdb/duckdb-wasm:列指向で集計は速いが、辺の往来には不向き- PGlite:Postgres 完全互換だが size が大きい(数 MB+)
「N ホップ先のノードを辿る」「サブグラフを抽出する」用途にはどれも合わなかった。
GrafeoDB は Rust 製の組込みグラフ DB を WASM ビルドした もの。Cypher / GQL / SPARQL / SQL / GraphQL / Gremlin をすべて 1 DB で扱え、BM25 全文検索 + HNSW ベクトル検索 まで内蔵している。これにより「ブラウザだけで完結する RAG / 知識グラフ」が現実的になる。
2. 最小セットアップ(Vite / Astro)
WASM パッケージは wasm-bindgen 出力なので、Vite 系では追加プラグインが必要。
pnpm add @grafeo-db/wasm
pnpm add -D vite-plugin-wasm vite-plugin-top-level-await
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default {
plugins: [wasm(), topLevelAwait()],
optimizeDeps: {
exclude: ["@grafeo-db/wasm"], // dep optimizer に流すと壊れる
},
};
ポイント:
optimizeDeps.excludeが肝心。wasm-bindgen が出すimport * as wasm from "./...wasm"を Vite が事前バンドルすると壊れる- Next.js の場合は
next.config.jsでexperiments.asyncWebAssembly = true(turbopack はデフォルト対応) - WASM ファイルは
.wasmのまま fetch されるので、production では HTTP のContent-Type: application/wasm+gzip/br配信を確認
3. データを入れる(importLpg)
最小単位の「ノード + エッジ」一括投入は importLpg:
import { Database } from "@grafeo-db/wasm";
const db = new Database();
db.importLpg({
nodes: [
{ labels: ["Person"], properties: { name: "Alix", role: "author" } },
{ labels: ["Person"], properties: { name: "Gus", role: "author" } },
{ labels: ["Book"], properties: { title: "Graph Internals", year: 2024 } },
{ labels: ["Topic"], properties: { name: "graph" } },
],
edges: [
{ source: 0, target: 1, type: "KNOWS" },
{ source: 0, target: 2, type: "WROTE" }, // Alix -> Graph Internals
{ source: 2, target: 3, type: "USES_TOPIC" }, // Book -> Topic
],
});
ポイント:
source/targetは配列内のインデックス(0-origin)。db 内部 ID とは別物だが、初回 import では一致するケースが多い- labels / type は配列指定。複数ラベルや 1 つの labels に複数値を入れるパターンも可
- 大量データは
importRows:CSV / JSON 由来の行配列を{mode: "nodes" | "edges"}で流し込める
CSV からの一括投入例:
const people = await fetch("/data/people.csv")
.then((r) => r.text())
.then((t) => parse(t, { header: true }));
db.importRows(people, { mode: "nodes", label: "Person" });
db.importRows(edgesArr, { mode: "edges", edgeType: "KNOWS", source: "from", target: "to" });
4. Cypher で辿る
executeCypher に MATCH 文を投げると、ノードと関係を辿った結果が JSON で返ってくる:
const rows = db.executeCypher(`
MATCH (a:Person)-[:WROTE]->(b:Book)-[:USES_TOPIC]->(t:Topic)
WHERE t.name = 'graph'
RETURN a.name AS author, b.title AS book, b.year AS year
ORDER BY b.year DESC
`);
// [{author: "Alix", book: "Graph Internals", year: 2024}, ...]
JS オブジェクトの配列で返ってくる(columns: keys / values: any[] のような raw 形式が欲しい場合は executeRaw)。
特に強力なのが 可変長 path:
// 知人の知人(2 ホップ)
db.executeCypher(`
MATCH (start:Person {name: 'Alix'})-[:KNOWS*1..2]->(p:Person)
WHERE p.name <> 'Alix'
RETURN DISTINCT p.name
`);
*1..2 で「1 ホップ以上 2 ホップ以下」、* だけだと無制限。SQL では再帰 CTE が要るパターンが 1 行で書ける。
5. 全文検索 + ベクトル検索
GrafeoDB は BM25 テキスト検索 と HNSW ベクトル検索 を「ノードのプロパティに対するインデックス」として持てる。
// BM25
db.createTextIndex("Article", "content");
const bm25 = db.textSearch("Article", "content", "graph database", 10);
// [{id: 42, score: 2.5}, ...]
// 埋め込みベクトル(384 次元)
db.createVectorIndex("Article", "embedding", {
dimensions: 384,
metric: "cosine",
m: 16,
efConstruction: 128,
});
const vec = db.vectorSearch("Article", "embedding", queryVec, 10, { ef: 200 });
// ハイブリッド(BM25 + vector の RRF / 加重融合)
const hybrid = db.hybridSearch("Article", "content", "embedding", "graph database", 10);
返ってくる id を Cypher で再ヒット させて関連するノードや出典を引っ張ってくれば、これだけで Browser-only RAG が成立する:
const hits = db.hybridSearch("Article", "content", "embedding", question, 5);
const ids = hits.map((h) => h.id);
const ctx = db.executeCypher(`
MATCH (a:Article)-[:CITES]->(s:Source)
WHERE id(a) IN $ids
RETURN a.title AS title, a.content AS content, s.url AS source
`, { ids }); // ← パラメータ渡しは executeWithParams 系を使う
埋め込み生成は別途 transformers.js などをブラウザで動かす(MiniLM-L6 ~ 90MB / GTE-small ~ 130MB)。サーバ無しでセマンティック検索が動く のが新しい。
6. スナップショットで「ビルド時に DB 構築・ブラウザでロード」
毎回ブラウザで投入するのは無駄なので、ビルド時に 1 回だけグラフを構築 → 二進スナップショットを export、ブラウザは ロードするだけ にする。
import { Database } from "@grafeo-db/wasm";
import fs from "node:fs/promises";
const db = new Database();
// ... importLpg / importRows ...
db.createTextIndex("Article", "content");
db.createVectorIndex("Article", "embedding", { dimensions: 384, metric: "cosine", m: 16, efConstruction: 128 });
db.compact(); // 読み取り専用に最適化、~60x メモリ削減
const snapshot = db.exportSnapshot(); // Uint8Array
await fs.writeFile("public/graph.gsn", snapshot);
import { Database } from "@grafeo-db/wasm";
const buf = await fetch("/graph.gsn").then((r) => r.arrayBuffer());
const db = Database.importSnapshot(new Uint8Array(buf));
const rows = db.executeCypher("MATCH (n) RETURN count(n) AS n");
ポイント:
compact()= 読取専用 CompactStore に変換。~60x メモリ削減 / 100x+ 走査速度向上(write は不可になる)- 改ざん防止が要る場合:
exportSnapshotSigned(key)/importSnapshotSigned(buf, key)で HMAC 検証 - ビルド時 ↔ ブラウザで同一 wasm バージョンを使う(snapshot にバイナリ互換あり)
これで 数 MB の .gsn を CDN 配信するだけ で、サーバ立てずに辞書 / 知識グラフを配れる。
7. IndexedDB に永続化
スナップショットは Uint8Array なので IndexedDB にそのまま入る:
import { openDB } from "idb";
const idb = await openDB("graph-store", 1, {
upgrade(db) { db.createObjectStore("snapshots"); },
});
async function save(db: Database) {
await idb.put("snapshots", db.exportSnapshot(), "main");
}
async function load(): Promise<Database> {
const buf = await idb.get("snapshots", "main");
if (buf) return Database.importSnapshot(buf);
return new Database(); // 初回
}
書込のたびに save するのではなく、「N 件書いたら save」「タブ閉じる前に save」(beforeunload) のような遅延保存が現実的。
8. 4 つのクエリ言語の使い分け
| 言語 | 強み | 用途 |
|---|---|---|
Cypher(executeCypher) | パターンマッチが直感的、Neo4j で流通 | 関係を辿る、サブグラフ抽出 |
GQL(execute) | ISO 標準のグラフ問合言語 | 将来の互換性 |
SQL(executeSql) | 集計、JOIN | 数値計算、レポート |
SPARQL(executeSparql) | RDF / オントロジー | 既存 RDF データを取り込む時 |
| GraphQL / Gremlin | エコシステム互換 | 既存ツールチェーンとの接続 |
「辿る → Cypher、集計 → SQL、外部互換 → SPARQL/GraphQL」が判断軸。同じ DB 上で混ぜて使える。
9. 比較:他のブラウザ DB との位置取り
| GrafeoDB | DuckDB-WASM | sqlite-wasm | PGlite | |
|---|---|---|---|---|
| データモデル | グラフ + 表 | 列指向 | 行指向 | 行指向 |
| 主クエリ | Cypher / GQL / SQL | SQL(分析) | SQL | SQL(Postgres) |
| 全文検索 | ◎ BM25 | △(拡張) | △ FTS5 | △ |
| ベクトル検索 | ◎ HNSW | ✕(拡張) | ✕ | ◯ pgvector |
| サイズ感 | 数 MB | 数 MB | ~1 MB | ~3 MB |
| 強み | 辿る + RAG | 集計、parquet | 軽量 | 互換 |
「集計が要らず、関係性を辿りたい」のが GrafeoDB の最適領域。逆にユーザの売上を集計したいだけなら DuckDB-WASM が速い。
10. ユースケース整理
- 静的サイトの「関連書籍」「次の記事」レコメンド:著者 → トピック → 著者で 2-3 ホップ辿る
- オフライン辞書 / Wiki アプリ:ノード=ページ、エッジ=リンク、Cypher で「ここから辿れるすべて」
- ブラウザ完結の RAG:hybridSearch でドキュメント検索 + Cypher で出典を表示
- コードベース可視化:ファイル / 関数 / 依存関係をグラフ化、UI でクエリ
- 個人知識管理(PKM):ノートグラフを完全ローカルで永続化(プライバシー保持)
「サーバ運用が許されない / コストかけられない」コンテキストで、動的に関係を辿る体験 を出せるのが価値。
つまずいたポイント
- Vite の事前バンドルで壊れる:
optimizeDeps.exclude: ["@grafeo-db/wasm"]必須。これがないと dev server で__wbg_set_wasm周りが死ぬ - Top-level await が必要:
vite-plugin-top-level-awaitを入れないと build で fail。modern target なら不要だが、Vite はトランスパイル先が低いので必要 - SSR で
importするとサーバ側で wasm を読もうとして死ぬ:client:only="react"か React のlazy + Suspenseでクライアント限定にする - 大量 import で OOM:WASM heap には上限がある。10 万ノード超は
importRowsで chunk 分け + 都度compact()を検討 - スナップショット互換性:wasm バージョンを跨ぐと snapshot が読めなくなる場合がある。ビルド時 / ブラウザで同一バージョンに固定
close()/[Symbol.dispose]:WASM heap を確実に解放するにはdb.close()を React の cleanup で呼ぶ。長時間使うアプリではメモリリーク注意
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
JavaScriptによるはじめてのアルゴリズム入門
Python と JavaScriptではじめるデータビジュアライゼーション
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…