Livebook でグラフデータベース Neo4j を操作する

## はじめに

グラフデータベースとは、グラフ構造でデータの関係性を表すデータベースです

ここでいう「グラフ」とはグラフ理論の「グラフ」であり、ノード（頂点）をエッジ（辺）で繋いだ構造のことを示します

```mermaid
graph TB
  NodeA--> |Edge|NodeB
```

本記事では、グラフデータベースである Neo4j を Livebook から操作し、基本的な使い方を理解します

https://neo4j.com/

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/boltx/neo4j.livemd

## 環境構築

以下のような内容で `docker-compose.with-neo4j.yml` を作成します

```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'
```

:::note warn
`neo4j-for-livebook` というように Neo4j 側のサービス名はハイフン区切り（ケバブケース）にしています

`_` を使っていると、接続時にエラーが発生するためです
:::


以下のコマンドを実行すると、 Livebook と Neo4j がそれぞれコンテナで起動します

```bash
docker compose --file docker-compose.with-neo4j.yml up
```

Livebook にはコンテナ起動時に表示される URL （トークン付き）でアクセスします

![スクリーンショット 2025-01-06 14.18.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bc4b0d07-bd3a-ba80-4105-4ad3e60d7584.png)

右上の "+ New notebook" から新しいノートブックを開きます

![スクリーンショット 2025-01-06 14.22.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/118155aa-fccd-860e-0cbd-069fe39c6363.png)

Neo4j Browser（Neo4j の操作用コンソール）には `http://localhost:7474/` でアクセスします

認証なしの設定で起動しているため、 "Authentication type" に "No authentication" を選択して "Connect" をクリックしてください

![スクリーンショット 2025-01-08 10.56.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2e0d5edd-3997-be8c-ed99-6b061160d32b.png)

接続できると、 Neo4j の操作用コンソールが表示されます

![スクリーンショット 2025-01-08 11.08.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a5d6b185-6531-b0de-a7e4-8e74766f2666.png)

"Try the new Browser preview!" カードの "Let's go" ボタンをクリックすると、新しい UI に切り替わり、改めて接続を求められます

"Connection URL" に "localhost:7687" を入力し、 "Password" は空欄のまま "Connect" をクリックしてください

![スクリーンショット 2025-01-08 10.50.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/edc0809f-9ff6-74be-2d9e-69bbe55708a3.png)

新しい UI のコンソールが表示されます

![スクリーンショット 2025-01-08 11.12.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e9e409a0-9400-09b5-af56-f244aff47415.png)

:::note info
以降、コンテナ起動後の接続時には新しい UI の画面が表示されるようになります
:::

## セットアップ

Livebook のセットアップセルに以下のコードを入力し、必要なモジュールをインストールします

```elixir
Mix.install([
  {:boltx, "~> 0.0.6"},
  {:kino, "~> 0.14.2"}
])
```

Neo4j への接続には Boltx を使用しています

https://github.com/mschae/boltex

## データベースへの接続

コンテナで起動した Neo4j に接続します

`neo4j-for-livebook` の部分はホスト名で、今回のようにコンテナで起動した場合、サービス名でアクセスできます

```elixir
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)
```

接続できると以下のような結果が表示されます

```elixir
{:ok, #PID<0.1591.0>}
```

グラフデータベースでは Cypher というクエリ言語を使用します

https://neo4j.com/docs/cypher-manual/current/introduction/

単純なクエリを発行してみましょう

何も参照せずに、単に 1 を "number" という名前で返します

```elixir
conn
|> Boltx.query!("RETURN 1 AS number")
|> Boltx.Response.first()
```

実行結果

```elixir
%{"number" => 1}
```

## ノードの追加

一つノードを追加してみましょう

```elixir
node =
  conn
  |> Boltx.query!("""
  CREATE
  (node:Sweet {
    name: "チョコレートケーキ",
    category: "ケーキ",
    brand: "スイーツベーカリー",
    price: 450
  })
  RETURN node
  """)
  |> Map.get(:results)
  |> hd()
```

実行結果

```elixir
%{
  "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" をクリックすると、以下のように "チョコレートケーキ" のノードが表示されます

![スクリーンショット 2025-01-08 11.30.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/39b98cae-1b01-b525-cab7-d1de9ae00e30.png)

他のノードも一括追加します

```elixir
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` によって、一つのトランザクション内でクエリを実行することが出来ます

```elixir
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)
```

お菓子と素材のノードが全て追加されました

![スクリーンショット 2025-01-08 13.06.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d884174f-75d5-b149-ef88-be1a1d168721.png)

![スクリーンショット 2025-01-08 13.07.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bfeb3be9-b0dc-72cd-9714-e021e4ca8f6b.png)

## 制約の追加

お菓子の名前、素材の名前は一意なので、一意制約を付与します

一意制約を付けると、インデックスが自動的に張られます

```elixir
Boltx.query!(conn, """
CREATE CONSTRAINT FOR (s:Sweet) REQUIRE (s.name) IS UNIQUE
""")
```

実行結果

```elixir
%Boltx.Response{
  results: [],
  fields: [],
  records: [],
  plan: nil,
  notifications: [],
  stats: %{"constraints-added" => 1, "contains-updates" => true},
  profile: nil,
  type: "s",
  bookmark: "FB:kcwQEF/a2ERSQCqvIUiJunScwy2Q"
}
```

```elixir
Boltx.query!(conn, """
CREATE CONSTRAINT FOR (i:Ingredient) REQUIRE (i.name) IS UNIQUE
""")
```

## インデックスの追加

項目を指定してインデックスを追加することもできます

以下のクエリでは Sweet ラベルの price にインデックスを張っています

```elixir
Boltx.query!(conn, """
CREATE INDEX FOR (s:Sweet) ON (s.price)
""")
```

実行結果

```elixir
Boltx.query!(conn, """
CREATE INDEX FOR (s:Sweet) ON (s.price)
""")
```

## エッジの追加

以下のようにクエリを実行することで、チョコレートと小麦粉の間に "CONTAINS" というエッジ（関係性）が追加されます

```elixir
edge =
  conn
  |> Boltx.query!("""
  MATCH (s:Sweet {name:"チョコレートケーキ"})
  MATCH (i:Ingredient {name:"小麦粉"})
  CREATE (s)-[r:CONTAINS]->(i)
  RETURN r
  """)
  |> Map.get(:results)
  |> hd()
```

実行結果

```elixir
%{
  "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"
  }
}
```

他のエッジを一括追加します

```elixir
relations =
  [
    {"チョコレートケーキ", "CONTAINS", "卵"},
    {"チョコレートケーキ", "CONTAINS", "バター"},
    {"チョコレートケーキ", "CONTAINS", "チョコレート"},
    {"イチゴのチーズケーキ", "CONTAINS", "小麦粉"},
    {"イチゴのチーズケーキ", "CONTAINS", "砂糖"},
    {"イチゴのチーズケーキ", "CONTAINS", "卵"},
    {"イチゴのチーズケーキ", "CONTAINS", "バター"},
    {"イチゴのチーズケーキ", "CONTAINS", "牛乳"},
    {"イチゴのチーズケーキ", "CONTAINS", "イチゴ"},
    {"アップルパイ", "CONTAINS", "小麦粉"},
    {"アップルパイ", "CONTAINS", "砂糖"},
    {"アップルパイ", "CONTAINS", "バター"},
    {"アップルパイ", "CONTAINS", "リンゴ"},
    {"チョコチップクッキー", "CONTAINS", "小麦粉"},
    {"チョコチップクッキー", "CONTAINS", "砂糖"},
    {"チョコチップクッキー", "CONTAINS", "バター"},
    {"チョコチップクッキー", "CONTAINS", "チョコレート"},
    {"ストロベリーキャンディー", "CONTAINS", "砂糖"},
    {"ストロベリーキャンディー", "CONTAINS", "イチゴ"}
  ]
```

```elixir
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 で繋がったグラフが確認できます

![スクリーンショット 2025-01-08 13.16.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1c0b327e-6155-31a7-3fad-e30d52264db3.png)

## クエリの実行

チョコレート（素材）のタイプを取得する場合、以下のようにクエリを実行します

結果を表として表示するため、 `Kino.DataTable` を利用しています

```elixir
conn
|> Boltx.query!("""
MATCH (i:Ingredient {name: "チョコレート"})
RETURN i.type AS タイプ
""")
|> Map.get(:results)
|> Kino.DataTable.new()
```

実行結果

![スクリーンショット 2025-01-08 14.03.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5172d57a-a7ad-f621-10b3-60d74d834357.png)

価格が 400 を超えるお菓子の名前と価格は以下のようにして取得できます

```elixir
conn
|> Boltx.query!("""
MATCH (s:Sweet) WHERE s.price > 400
RETURN s.name AS お菓子名, s.price AS 価格
""")
|> Map.get(:results)
|> Kino.DataTable.new()
```

![スクリーンショット 2025-01-08 14.07.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/41a97c38-d988-c90c-2d24-da209736aa5c.png)

チョコレートを使っているお菓子の名前とブランドは以下のようにして取得できます

```elixir
conn
|> Boltx.query!("""
MATCH (s:Sweet)-[:CONTAINS]->(i:Ingredient {name: "チョコレート"})
RETURN s.name AS お菓子名, s.brand AS ブランド
""")
|> Map.get(:results)
|> Kino.DataTable.new()
```

![スクリーンショット 2025-01-08 14.09.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/68eab9cf-ef96-acfe-10c5-3bf9fe25dc21.png)

乳製品を使っていないお菓子は以下のようにして取得できます

```elixir
conn
|> Boltx.query!("""
MATCH (s:Sweet)
WHERE NOT (s)-[:CONTAINS]->(:Ingredient {type: "乳製品"})
RETURN s.name AS 乳製品不使用のお菓子
""")
|> Map.get(:results)
|> Kino.DataTable.new()
```

![スクリーンショット 2025-01-08 14.11.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b2e23a8f-1d99-6b9c-5ac7-91a59f75e26d.png)

## データの削除

全てのデータを削除します

```elixir
Boltx.query!(conn, "MATCH (n) DETACH DELETE n")
```

実行結果

```elixir
%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"
}
```

制約を削除します

```elixir
constraints =
  conn
  |> Boltx.query!("SHOW CONSTRAINTS YIELD name")
  |> Map.get(:results)
```

実行結果

```elixir
[%{"name" => "constraint_64f87830"}, %{"name" => "constraint_bdcef13f"}]
```

```elixir
constraints
|> Enum.each(fn %{"name" => name} ->
  Boltx.query!(conn, "DROP CONSTRAINT #{name}")
end)
```

インデックスを削除します

```elixir
indexes =
  conn
  |> Boltx.query!("SHOW INDEXES YIELD name")
  |> Map.get(:results)
```

実行結果

```elixir
[%{"name" => "index_343aff4e"}, %{"name" => "index_cdfa3f1b"}, %{"name" => "index_f7700477"}]
```

```elixir
indexes
|> Enum.each(fn %{"name" => name} ->
  Boltx.query!(conn, "DROP INDEX #{name}")
end)
```

全てを削除しました

## まとめ

Boltx を使うことで Livebook から Neo4j を操作できました

次は Neo4j によるグラフ RAG を実装してみます

https://qiita.com/RyoWakabayashi/items/6f5d3976509e1a89be76