はじめに
グラフデータベースとは、グラフ構造でデータの関係性を表すデータベースです
ここでいう「グラフ」とはグラフ理論の「グラフ」であり、ノード(頂点)をエッジ(辺)で繋いだ構造のことを示します
本記事では、グラフデータベースである Neo4j を Livebook から操作し、基本的な使い方を理解します
実装したノートブックはこちら
環境構築
以下のような内容で docker-compose.with-neo4j.yml
を作成します
services:
livebook_with_neo4j:
image: ghcr.io/livebook-dev/livebook:0.14.5
container_name: livebook_with_neo4j
ports:
- '8080:8080'
- '8081:8081'
neo4j-for-livebook:
image: neo4j:community
container_name: neo4j-for-livebook
tty: true
ports:
- 7474:7474
- 7687:7687
volumes:
- ./neo4j/data:/data
- ./neo4j/logs:/logs
- ./neo4j/conf:/conf
- ./certs:/var/lib/neo4j/certificates/bolt
environment:
- NEO4J_AUTH=none
user: '1000'
group_add:
- '1000'
neo4j-for-livebook
というように Neo4j 側のサービス名はハイフン区切り(ケバブケース)にしています
_
を使っていると、接続時にエラーが発生するためです
以下のコマンドを実行すると、 Livebook と Neo4j がそれぞれコンテナで起動します
docker compose --file docker-compose.with-neo4j.yml up
Livebook にはコンテナ起動時に表示される URL (トークン付き)でアクセスします
右上の "+ New notebook" から新しいノートブックを開きます
Neo4j Browser(Neo4j の操作用コンソール)には http://localhost:7474/
でアクセスします
認証なしの設定で起動しているため、 "Authentication type" に "No authentication" を選択して "Connect" をクリックしてください
接続できると、 Neo4j の操作用コンソールが表示されます
"Try the new Browser preview!" カードの "Let's go" ボタンをクリックすると、新しい UI に切り替わり、改めて接続を求められます
"Connection URL" に "localhost:7687" を入力し、 "Password" は空欄のまま "Connect" をクリックしてください
新しい UI のコンソールが表示されます
以降、コンテナ起動後の接続時には新しい UI の画面が表示されるようになります
セットアップ
Livebook のセットアップセルに以下のコードを入力し、必要なモジュールをインストールします
Mix.install([
{:boltx, "~> 0.0.6"},
{:kino, "~> 0.14.2"}
])
Neo4j への接続には Boltx を使用しています
データベースへの接続
コンテナで起動した Neo4j に接続します
neo4j-for-livebook
の部分はホスト名で、今回のようにコンテナで起動した場合、サービス名でアクセスできます
opts = [
hostname: "neo4j-for-livebook",
scheme: "bolt",
auth: [username: "neo4j", password: ""],
user_agent: "boltxTest/1",
pool_size: 15,
max_overflow: 3,
prefix: :default
]
{:ok, conn} = Boltx.start_link(opts)
接続できると以下のような結果が表示されます
{:ok, #PID<0.1591.0>}
グラフデータベースでは Cypher というクエリ言語を使用します
単純なクエリを発行してみましょう
何も参照せずに、単に 1 を "number" という名前で返します
conn
|> Boltx.query!("RETURN 1 AS number")
|> Boltx.Response.first()
実行結果
%{"number" => 1}
ノードの追加
一つノードを追加してみましょう
node =
conn
|> Boltx.query!("""
CREATE
(node:Sweet {
name: "チョコレートケーキ",
category: "ケーキ",
brand: "スイーツベーカリー",
price: 450
})
RETURN node
""")
|> Map.get(:results)
|> hd()
実行結果
%{
"node" => %Boltx.Types.Node{
id: 0,
properties: %{
"brand" => "スイーツベーカリー",
"category" => "ケーキ",
"name" => "チョコレートケーキ",
"price" => 450
},
labels: ["Sweet"],
element_id: "4:105fdad8-4452-402a-af21-4889ba749cc3:0"
}
}
ノードが Neo4j 上に追加され、 id や element_id の値が付与されました
Neo4j Browser を再読込すると、左上 "Nodes" の中に "Sweet" というラベルが追加されています
"Sweet" をクリックすると、以下のように "チョコレートケーキ" のノードが表示されます
他のノードも一括追加します
entities =
[
%{
label: "Sweet",
properties: %{
name: "イチゴのチーズケーキ",
category: "ケーキ",
brand: "チーズハウス",
price: 520
}
},
%{
label: "Sweet",
properties: %{
name: "アップルパイ",
category: "パイ",
brand: "パイファクトリー",
price: 400
}
},
%{
label: "Sweet",
properties: %{
name: "チョコチップクッキー",
category: "クッキー",
brand: "クッキーランド",
price: 300
}
},
%{
label: "Sweet",
properties: %{
name: "ストロベリーキャンディー",
category: "キャンディー",
brand: "キャンディーガーデン",
price: 100
}
},
%{
label: "Ingredient",
properties: %{
name: "小麦粉",
type: "粉類"
}
},
%{
label: "Ingredient",
properties: %{
name: "砂糖",
type: "粉類"
}
},
%{
label: "Ingredient",
properties: %{
name: "卵",
type: "液体"
}
},
%{
label: "Ingredient",
properties: %{
name: "バター",
type: "乳製品"
}
},
%{
label: "Ingredient",
properties: %{
name: "チョコレート",
type: "粉類"
}
},
%{
label: "Ingredient",
properties: %{
name: "牛乳",
type: "乳製品"
}
},
%{
label: "Ingredient",
properties: %{
name: "イチゴ",
type: "フルーツ"
}
},
%{
label: "Ingredient",
properties: %{
name: "リンゴ",
type: "フルーツ"
}
}
]
Boltx.transaction
によって、一つのトランザクション内でクエリを実行することが出来ます
Boltx.transaction(conn, fn conn ->
entities
|> Enum.each(fn entity ->
properties =
entity.properties
|> Enum.map(fn {key, value} -> "#{key}: \"#{value}\"" end)
|> Enum.join(",")
query =
"""
CREATE
(node:#{entity.label} {
#{properties}
})
"""
Boltx.query!(conn, query)
end)
end)
お菓子と素材のノードが全て追加されました
制約の追加
お菓子の名前、素材の名前は一意なので、一意制約を付与します
一意制約を付けると、インデックスが自動的に張られます
Boltx.query!(conn, """
CREATE CONSTRAINT FOR (s:Sweet) REQUIRE (s.name) IS UNIQUE
""")
実行結果
%Boltx.Response{
results: [],
fields: [],
records: [],
plan: nil,
notifications: [],
stats: %{"constraints-added" => 1, "contains-updates" => true},
profile: nil,
type: "s",
bookmark: "FB:kcwQEF/a2ERSQCqvIUiJunScwy2Q"
}
Boltx.query!(conn, """
CREATE CONSTRAINT FOR (i:Ingredient) REQUIRE (i.name) IS UNIQUE
""")
インデックスの追加
項目を指定してインデックスを追加することもできます
以下のクエリでは Sweet ラベルの price にインデックスを張っています
Boltx.query!(conn, """
CREATE INDEX FOR (s:Sweet) ON (s.price)
""")
実行結果
Boltx.query!(conn, """
CREATE INDEX FOR (s:Sweet) ON (s.price)
""")
エッジの追加
以下のようにクエリを実行することで、チョコレートと小麦粉の間に "CONTAINS" というエッジ(関係性)が追加されます
edge =
conn
|> Boltx.query!("""
MATCH (s:Sweet {name:"チョコレートケーキ"})
MATCH (i:Ingredient {name:"小麦粉"})
CREATE (s)-[r:CONTAINS]->(i)
RETURN r
""")
|> Map.get(:results)
|> hd()
実行結果
%{
"r" => %Boltx.Types.Relationship{
id: 0,
properties: %{},
start: 13,
end: 18,
type: "CONTAINS",
element_id: "5:105fdad8-4452-402a-af21-4889ba749cc3:0",
start_node_element_id: "4:105fdad8-4452-402a-af21-4889ba749cc3:13",
end_node_element_id: "4:105fdad8-4452-402a-af21-4889ba749cc3:18"
}
}
他のエッジを一括追加します
relations =
[
{"チョコレートケーキ", "CONTAINS", "卵"},
{"チョコレートケーキ", "CONTAINS", "バター"},
{"チョコレートケーキ", "CONTAINS", "チョコレート"},
{"イチゴのチーズケーキ", "CONTAINS", "小麦粉"},
{"イチゴのチーズケーキ", "CONTAINS", "砂糖"},
{"イチゴのチーズケーキ", "CONTAINS", "卵"},
{"イチゴのチーズケーキ", "CONTAINS", "バター"},
{"イチゴのチーズケーキ", "CONTAINS", "牛乳"},
{"イチゴのチーズケーキ", "CONTAINS", "イチゴ"},
{"アップルパイ", "CONTAINS", "小麦粉"},
{"アップルパイ", "CONTAINS", "砂糖"},
{"アップルパイ", "CONTAINS", "バター"},
{"アップルパイ", "CONTAINS", "リンゴ"},
{"チョコチップクッキー", "CONTAINS", "小麦粉"},
{"チョコチップクッキー", "CONTAINS", "砂糖"},
{"チョコチップクッキー", "CONTAINS", "バター"},
{"チョコチップクッキー", "CONTAINS", "チョコレート"},
{"ストロベリーキャンディー", "CONTAINS", "砂糖"},
{"ストロベリーキャンディー", "CONTAINS", "イチゴ"}
]
Boltx.transaction(conn, fn conn ->
relations
|> Enum.each(fn {src_name, relation, dest_name} ->
query =
"""
MATCH (s:Sweet {name:"#{src_name}"})
MATCH (i:Ingredient {name:"#{dest_name}"})
CREATE (s)-[r:#{relation}]->(i)
"""
Boltx.query!(conn, query)
end)
end)
Neo4j Browser を再読込すると、左上 Relationships に "CONTAINS" が追加されています
"CONTAINS" をクリックすると、お菓子と素材が CONTAINS で繋がったグラフが確認できます
クエリの実行
チョコレート(素材)のタイプを取得する場合、以下のようにクエリを実行します
結果を表として表示するため、 Kino.DataTable
を利用しています
conn
|> Boltx.query!("""
MATCH (i:Ingredient {name: "チョコレート"})
RETURN i.type AS タイプ
""")
|> Map.get(:results)
|> Kino.DataTable.new()
実行結果
価格が 400 を超えるお菓子の名前と価格は以下のようにして取得できます
conn
|> Boltx.query!("""
MATCH (s:Sweet) WHERE s.price > 400
RETURN s.name AS お菓子名, s.price AS 価格
""")
|> Map.get(:results)
|> Kino.DataTable.new()
チョコレートを使っているお菓子の名前とブランドは以下のようにして取得できます
conn
|> Boltx.query!("""
MATCH (s:Sweet)-[:CONTAINS]->(i:Ingredient {name: "チョコレート"})
RETURN s.name AS お菓子名, s.brand AS ブランド
""")
|> Map.get(:results)
|> Kino.DataTable.new()
乳製品を使っていないお菓子は以下のようにして取得できます
conn
|> Boltx.query!("""
MATCH (s:Sweet)
WHERE NOT (s)-[:CONTAINS]->(:Ingredient {type: "乳製品"})
RETURN s.name AS 乳製品不使用のお菓子
""")
|> Map.get(:results)
|> Kino.DataTable.new()
データの削除
全てのデータを削除します
Boltx.query!(conn, "MATCH (n) DETACH DELETE n")
実行結果
%Boltx.Response{
results: [],
fields: [],
records: [],
plan: nil,
notifications: [],
stats: %{"contains-updates" => true, "nodes-deleted" => 13, "relationships-deleted" => 19},
profile: nil,
type: "w",
bookmark: "FB:kcwQEF/a2ERSQCqvIUiJunScwzCQ"
}
制約を削除します
constraints =
conn
|> Boltx.query!("SHOW CONSTRAINTS YIELD name")
|> Map.get(:results)
実行結果
[%{"name" => "constraint_64f87830"}, %{"name" => "constraint_bdcef13f"}]
constraints
|> Enum.each(fn %{"name" => name} ->
Boltx.query!(conn, "DROP CONSTRAINT #{name}")
end)
インデックスを削除します
indexes =
conn
|> Boltx.query!("SHOW INDEXES YIELD name")
|> Map.get(:results)
実行結果
[%{"name" => "index_343aff4e"}, %{"name" => "index_cdfa3f1b"}, %{"name" => "index_f7700477"}]
indexes
|> Enum.each(fn %{"name" => name} ->
Boltx.query!(conn, "DROP INDEX #{name}")
end)
全てを削除しました
まとめ
Boltx を使うことで Livebook から Neo4j を操作できました
次は Neo4j によるグラフ RAG を実装してみます