はじめに
仕事柄、グラフDBを使うことが多く、大抵いつもDockerでNeo4jを立ち上げるかという感じで。
まぁそれ自体は大した手間ではないのですが、もう少し気軽に導入して使えるような
「SQLiteみたいに、ファイル1個置いたら動くグラフDBないの?」
そう思って探していたら、LadybugDBに行き着きました。
LadybugDBとは
実はもともとKuzuというグラフDBがそのポジションを担っていらしく、組み込み型・Cypher対応・サーバー不要と三拍子揃ったプロダクトだったようです。最初、Geminiに質問したら提案されたのがこのKuzuでした。
ただ、調べていくと・・・
Appleに買収されていました!
しかも、GitHubリポジトリがアーカイブに!
何というタイムリー、しかし流石のGemini!
次の候補もきちんと探してくれました。
KuzuのOSSフォークとして生まれたのがLadybugDB
ざっと、以下の特徴を持っているよう。
- Kuzuのコードベースをベースに継続開発中
- MITライセンス
- Cypher対応(Neo4jと同じクエリ言語)
- Node.js / Python / Rust / Go など多言語バインディングあり
- サーバー不要、ファイルベースの組み込みDB
- カラム指向ストレージ+ベクトル実行による高速クエリ
ぱっと見た感じお試しをしてみるのは良さそう!
ということで、LadybugDBの構築と、簡単な経路検索をやってみました。
なお、本記事の執筆にはGemini及び、Claudeを活用しています。
動作確認環境
- Node.js v24.4.1
- @ladybugdb/core v0.15.2
- fastify v5.8.4
- macOS(Apple M1)
インストール
$ mkdir ladybug-fastify-sample
$ cd ladybug-fastify-sample
$ npm init -y
$ npm install fastify @ladybugdb/core
package.json に "type": "module" を追加する。
{
"type": "module",
"scripts": {
"start": "node server.js"
}
}
npm init -y のデフォルトでは "type": "commonjs" になっているが、後述のコードはESM(import構文)で書くため "module" に変更が必要。CommonJSのまま使いたい場合は require() 形式に書き直せば動く。
サンプルデータの準備
経路探索として今回は、駅データ.jpのCSVを使ってみました。
会員登録(無料)が必要ですが、商用利用・加工・再配布可能なライセンスで使いやすい。
以下の4ファイルをダウンロードして data/ ディレクトリに配置する:
| ファイル | 内容 |
|---|---|
| company.csv | 事業者データ(JR・私鉄など) |
| line.csv | 路線データ |
| station.csv | 駅データ(緯度経度・住所付き) |
| join.csv | 接続駅データ(隣の駅) |
※実際のデータはファイル名に日付が付いてますので、そこは読み替えて最新のデータを参照してください。
実際のCSVファイルの中身を見つつ、データ構造は以下を参照。
上記を見て気づいた人もいるかと思いますが、まさにこの構造自体がグラフの構造になっています。
(Company)-[:OPERATES]->(Line)-[:HAS_STATION]->(Station)
(Station)-[:NEXT_TO]->(Station)
特に join.csv はそのまま「隣の駅へのエッジ」として使える点が面白いです。
DBセットアップ(setup.js)
※:CSVファイル名は実際使用するファイル名に合わせてください。
import lbug from '@ladybugdb/core'
import { readFileSync, writeFileSync } from 'fs'
const DATA_DIR = './data'
const db = new lbug.Database('./graph.lbug')
const conn = new lbug.Connection(db)
console.log('📦 テーブル作成中...')
await conn.query(`
CREATE NODE TABLE IF NOT EXISTS Company(
company_cd INT64 PRIMARY KEY,
rr_cd INT64,
company_name STRING,
company_name_k STRING,
company_name_h STRING,
company_name_r STRING,
company_url STRING,
company_type INT64,
e_status INT64,
e_sort INT64
)
`)
await conn.query(`
CREATE NODE TABLE IF NOT EXISTS Line(
line_cd INT64 PRIMARY KEY,
company_cd INT64,
line_name STRING,
line_name_k STRING,
line_name_h STRING,
line_color_c STRING,
line_color_t STRING,
line_type INT64,
lon DOUBLE,
lat DOUBLE,
zoom INT64,
e_status INT64,
e_sort INT64
)
`)
await conn.query(`
CREATE NODE TABLE IF NOT EXISTS Station(
station_cd INT64 PRIMARY KEY,
station_g_cd INT64,
station_name STRING,
station_name_k STRING,
station_name_r STRING,
line_cd INT64,
pref_cd INT64,
post STRING,
address STRING,
lon DOUBLE,
lat DOUBLE,
open_ymd STRING,
close_ymd STRING,
e_status INT64,
e_sort INT64
)
`)
await conn.query(`CREATE REL TABLE IF NOT EXISTS OPERATES(FROM Company TO Line)`)
await conn.query(`CREATE REL TABLE IF NOT EXISTS HAS_STATION(FROM Line TO Station)`)
await conn.query(`CREATE REL TABLE IF NOT EXISTS NEXT_TO(FROM Station TO Station, line_cd INT64)`)
console.log('📥 データロード中...')
await conn.query(`COPY Company FROM '${DATA_DIR}/company.csv' (HEADER=TRUE)`)
console.log('✅ Company ロード完了')
// IGNORE_ERRORS=TRUE:廃止路線データなどに含まれる空値・型不一致をスキップする
// ※本番環境ではデータの事前検証を推奨
await conn.query(`COPY Line FROM '${DATA_DIR}/line.csv' (HEADER=TRUE, IGNORE_ERRORS=TRUE)`)
console.log('✅ Line ロード完了')
await conn.query(`COPY Station FROM '${DATA_DIR}/station.csv' (HEADER=TRUE, IGNORE_ERRORS=TRUE)`)
console.log('✅ Station ロード完了')
// joinCSVから存在する駅だけに絞り込む(後述:ハマりポイント③)
const stationCsv = readFileSync(`${DATA_DIR}/station.csv`, 'utf-8')
const stationCds = new Set(
stationCsv.split('\n').slice(1)
.filter(l => l.trim())
.map(l => l.split(',')[0])
)
const joinCsv = readFileSync(`${DATA_DIR}/join.csv`, 'utf-8')
const lines = joinCsv.split('\n')
const filtered = lines.slice(1).filter(l => {
const [, cd1, cd2] = l.split(',')
return cd1 && cd2 && stationCds.has(cd1) && stationCds.has(cd2)
})
writeFileSync(`${DATA_DIR}/join_filtered.csv`, [lines[0], ...filtered].join('\n'))
console.log(`✅ join_filtered.csv 作成: ${filtered.length}件`)
// カラム順を並び替えてロード(後述:ハマりポイント②)
await conn.query(`
COPY NEXT_TO FROM (
LOAD FROM '${DATA_DIR}/join_filtered.csv' (HEADER=TRUE)
RETURN station_cd1, station_cd2, line_cd
)
`)
console.log('✅ NEXT_TO ロード完了')
await conn.query(`
COPY OPERATES FROM (
MATCH (c:Company), (l:Line)
WHERE c.company_cd = l.company_cd
RETURN c.company_cd, l.line_cd
)
`)
console.log('✅ OPERATES ロード完了')
await conn.query(`
COPY HAS_STATION FROM (
MATCH (l:Line), (s:Station)
WHERE l.line_cd = s.line_cd
RETURN l.line_cd, s.station_cd
)
`)
console.log('✅ HAS_STATION ロード完了')
console.log('🎉 セットアップ完了!')
このスクリプトでは以下のようなことをやっています。
-
ノードテーブルの定義:
CREATE NODE TABLEでCompany・Line・Stationの3テーブルを定義。CSVのカラム構成に合わせて型を指定する。スキーマレスではなく事前にスキーマを宣言するのがLadybugDBの特徴。 -
リレーションテーブルの定義:
CREATE REL TABLEでエッジを定義。OPERATES・HAS_STATIONはCSVから直接ロードできない構造のため、ノードロード後にMATCHで結合して生成する。 -
CSVの直接ロード:
COPY ... FROMでCSVをそのままノードテーブルに流し込める。IGNORE_ERRORS=TRUEは型不一致などのエラーをスキップするオプション(廃止路線のデータなどに含まれる空値対策)。 - join.csvの前処理:後述のハマりポイント②③の対策として、Node.jsで事前にフィルタリングと列順の調整を行っている。
実行:
$ node setup.js
上記を実行すると、以下のファイルが作成されます。
-
graph.lbug… LadybugDBのデータベース本体(ディレクトリ) -
graph.lbug.wal… WAL(Write-Ahead Log)ファイル。クラッシュ時の復旧用 -
data/join_filtered.csv… 前処理済みのjoinデータ
DB を作り直す際は graph.lbug と graph.lbug.wal を両方削除してから再実行してください(ハマりポイント①参照)。
Fastify APIの実装(server.js)
import Fastify from 'fastify'
import lbug from '@ladybugdb/core'
const fastify = Fastify({ logger: true })
// 起動時に1回だけDBを開く(後述:並行処理モデル)
const db = new lbug.Database('./graph.lbug')
// 駅名から路線・事業者・station_cdを検索
fastify.get('/station/:name', async (req, reply) => {
const conn = new lbug.Connection(db)
const prepared = await conn.prepare(`
MATCH (s:Station)-[:HAS_STATION]-(l:Line)-[:OPERATES]-(c:Company)
WHERE s.station_name = $name
RETURN s.station_cd, s.station_name, l.line_name, c.company_name
`)
const result = await conn.execute(prepared, { name: req.params.name })
return await result.getAll()
})
// 最短経路を探索
fastify.get('/route', async (req, reply) => {
const { from, to } = req.query
const conn = new lbug.Connection(db)
try {
const prepared = await conn.prepare(`
MATCH p = (s1:Station)-[e:NEXT_TO* SHORTEST 1..30]-(s2:Station)
WHERE s1.station_cd = $from AND s2.station_cd = $to
RETURN properties(nodes(p), 'station_name') AS route,
length(e) AS stops
`)
const result = await conn.execute(prepared, {
from: parseInt(from),
to: parseInt(to)
})
return await result.getAll()
} catch (err) {
console.error('Query error:', err.message)
return reply.code(500).send({ error: err.message })
}
})
try {
await fastify.listen({ port: 3000 })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
ここでのポイントを2点説明します。
① Connectionはリクエストごとに生成する
Database オブジェクトは起動時に1回だけ作成し、Connection はリクエストごとに生成する。同一 Database からの複数 Connection はトランザクションマネージャーが安全に制御してくれる(後述:並行処理モデル)。
② パラメータバインディングを使う
LadybugDBはCypherのインジェクション対策としてパラメータバインディングをサポートしている。クエリ内の $name $from $to に対して、Node.jsでは query() はパラメータを受け取れないため、prepare() でクエリを事前コンパイルし、execute() にパラメータオブジェクトを渡す2ステップの形になる。ユーザー入力を文字列として直接埋め込む方法は公式でも非推奨とされているため、必ずこちらを使うようにしよう。
動作確認
まず /station/:name で駅名からstation_cdを調べます。後述のハマりポイント⑤の通り、同名駅が複数存在するため、経路探索には駅名ではなくstation_cdを使います。
# 渋谷駅の路線一覧とstation_cdを確認
$ curl http://localhost:3000/station/渋谷
[
{"s.station_cd": 1130205, "s.station_name": "渋谷", "l.line_name": "JR山手線", "c.company_name": "JR東日本"},
{"s.station_cd": 1132103, "s.station_name": "渋谷", "l.line_name": "東急東横線", "c.company_name": "東急電鉄"},
{"s.station_cd": 2600101, "s.station_name": "渋谷", "l.line_name": "東京メトロ銀座線", "c.company_name": "東京メトロ"},
...
]
station_cdが確認できたら経路探索:
# 山手線・渋谷(1130205)→山手線・新宿(1130208)の最短経路
$ curl "http://localhost:3000/route?from=1130205&to=1130208"
[{"route": ["渋谷", "原宿", "代々木", "新宿"], "stops": 3}]
Cypherの SHORTEST キーワード1つで最短経路が取れます。SQLで同じことをやろうとすると再帰CTEを書く必要があり、グラフDBの強みが実感できる部分です。
データを削除する場合は、graph.lbug と graph.lbug.wal を削除してください。
ハマりポイント集
実際に試して詰まった箇所をまとめておきます。
① WALファイルの削除を忘れずに
DBを作り直す際に graph.lbug だけ削除するとエラーになる。
Error: Database ID for temporary file 'graph.lbug.wal' does not match the current database.
WALファイルはDBと対になっているため、必ずセットで削除する:
$ rm -rf graph.lbug graph.lbug.wal
② joinのカラム順とNEXT_TOの期待する順が異なる
駅データ.jpの公式仕様によると、join.csvのカラム順は:
line_cd, station_cd1, station_cd2
一方LadybugDBの COPY REL TABLE が期待するのは:
from_pk, to_pk, [properties...]
つまり最初のカラムがエッジの始点のPK、次のカラムが終点のPKである必要がある。そのまま COPY FROM するとリレーションが正しくロードされず、経路探索が空を返す(エラーにはならないのでハマりやすい)。
LOAD FROM のサブクエリでカラムを並び替えることで解決できる:
COPY NEXT_TO FROM (
LOAD FROM 'join_filtered.csv' (HEADER=TRUE)
RETURN station_cd1, station_cd2, line_cd
)
③ 無料版ではstation.csvに新幹線駅が含まれていない
駅データ.jpの仕様によると、無料版では新幹線駅のデータがすべて除外されている。ただし join.csv には新幹線の接続データがそのまま残っており、station_cdが参照先のStationノードに存在しないためロード時にエラーになる:
Error: Copy exception: Unable to find primary key value 100201.
100201 は東海道新幹線(line_cd: 1002)の駅コード。
IGNORE_ERRORS=TRUE をサブクエリ形式で渡す方法は現バージョンではメモリエラーになるため、Node.jsで事前にstation.csvに存在するstation_cdだけに絞り込んだCSVを生成する方法が確実。
④ 最短経路のホップ数上限は30まで
LadybugDBの再帰リレーションの上限はデフォルト30。それを超えると:
Error: Binder exception: Upper bound of rel e exceeds maximum: 30.
駅間の探索なら30で十分だが、指定する際は SHORTEST 1..30 のようにリレーションパターンの内側に書く必要がある。
-- ❌ これは動かない(Neo4jっぽい書き方)
MATCH p = SHORTEST 1 (s1)-[:NEXT_TO*]->(s2)
-- ✅ これが正しい
MATCH p = (s1)-[e:NEXT_TO* SHORTEST 1..30]-(s2)
⑤ 同名駅が複数のstation_cdを持つ
「渋谷」は山手線・東急・メトロなど11路線が乗り入れており、それぞれ別のstation_cdが割り当てられている。これは駅データ.jpの仕様上、路線ごとに1レコードという設計になっているためだ。
駅名だけで経路探索すると最初にヒットした路線の駅から探索されるため、意図しない路線での経路が返ることがある。/station/:name で目的の路線のstation_cdを確認してから /route を叩くのが確実。
並行処理モデルについて
LadybugDBはSQLiteと同様、1プロセスでREAD_WRITEのDBオブジェクトは1つだけという制約がある。
Fastifyのように複数リクエストを並行処理するサーバーに組み込む場合、起動時に Database を1つだけ作成し、リクエストごとに Connection を生成する形だ。同一 Database オブジェクトからの複数 Connection はトランザクションマネージャーが安全に制御してくれる。
また、server.js起動中は別プロセスから同じDBファイルを開けない。setup.jsを再実行する際はserver.jsを止めてから行う必要がある。
Neo4jとの比較
| LadybugDB | Neo4j | |
|---|---|---|
| 起動 | ファイル1つ | サーバー必要 |
| インストール | npm install 1行 | Docker or インストーラ |
| クエリ言語 | Cypher(一部構文差異あり) | Cypher |
| 用途 | 組み込み・小〜中規模 | 大規模・本番運用 |
| ライセンス | MIT | Community版あり |
| 成熟度 | 発展途上(v0.15.x) | 安定 |
CypherはNeo4jとほぼ同じだが、一部構文に差異がある。詳細は公式ドキュメントのDifferences with Neo4jを参照。
現状の問題点・課題
最短経路 ≠ 最短時間
現状の経路探索は「ホップ数(駅数)が最小」という定義になっている。これはCypherの SHORTEST キーワードの動作によるもので、エッジの数が最も少ないパスを返すアルゴリズムだ。
例えば「渋谷→新宿」なら:
渋谷 -[1]→ 原宿 -[1]→ 代々木 -[1]→ 新宿 # ホップ数: 3
各エッジの重みが均一(すべて1)として最短を求めるイメージ。
しかし現実の最短経路は所要時間で考えるべきで、駅間の距離や列車の速度・停車パターンによって所要時間は大きく変わる。そこで登場するのが重み付き最短経路だ。エッジに「コスト(重み)」を持たせて、そのコストの合計が最小になるパスを求める。
渋谷 -[2分]→ 原宿 -[2分]→ 代々木 -[1分]→ 新宿 # 合計: 5分
渋谷 -[5分]→ 恵比寿 -[8分]→ ... # 別ルートはコスト大
LadybugDBには WSHORTEST(Weighted Shortest)という重み付き最短経路のキーワードがあるため、駅間の所要時間データさえ用意できれば対応できる(と、想定される)。
乗り換えできない
現状は同一路線内でしか経路を検索できない。ノードとエッジが同一路線内の隣駅関係しか持っていないためだ。
例えば「新宿 →[湘南新宿ライン]→ 横浜 →[京浜東北線]→ 大船」のような乗り換えを含む経路は現状では取れない。
解決のカギになるのが、駅データ.jpの station_g_cd(駅グループコード) だ。同じ物理的な駅に乗り入れる複数路線の駅レコードには、同一の station_g_cd が振られている。例えば新宿駅なら、山手線・中央線・小田急線など13路線すべてに同じ station_g_cd が付いている。
この station_g_cd を使って同一グループの駅ノード同士を乗り換えエッジ(TRANSFER)で繋ぐことで、路線をまたいだ経路探索が実現できる。実装については次回の記事で取り上げる予定です。
こういう人に向いています
- ローカルで手軽にグラフDBを試したい
- Neo4jは少し重いと感じている
- アプリに組み込み型で使いたい
まとめ
-
npm installだけで使えるグラフDBとして現時点で有力の選択肢 - Cypherがそのまま使えるのでNeo4j経験者はほぼ学習コストゼロ
- 駅データのような「つながり」を持つデータとの相性が抜群
- CSVから
COPY FROMで直接ロードできるのが地味に便利 - まだv0.15.x時点のため今後APIが変わる可能性あり。記事内のコードはすべてv0.15.x時点のもの
個人的な感覚としては、まずはお試しとしてグラフDBとはどういうものなのかを触れるには非常に良いものと思います。