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

## はじめに

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

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

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

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

https://github.com/FalkorDB/FalkorDB

ちなみに Falkor （ファルコー）は映画ネバーエンディングストーリーに登場する幸運の竜です（日本語ではファルコンと呼ばれています）

https://amzn.asia/d/31mANgX

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

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/redisgraph/falkor_db.livemd

## 環境構築

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

```yml
services:
  livebook_with_falkor_db:
    image: ghcr.io/livebook-dev/livebook:0.14.5
    container_name: livebook_with_falkor_db
    ports:
      - '8080:8080'
      - '8081:8081'

  falkor_db_for_livebook:
    image: falkordb/falkordb:edge
    container_name: falkor_db_for_livebook
    tty: true
    ports:
      - 6379:6379
      - 3000:3000
    volumes:
      - ./falkor_db/data:/data
```

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

```bash
docker compose --file docker-compose.with-falkor-db.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)

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

![スクリーンショット 2025-01-06 14.17.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/39eb5963-18a3-177c-089c-e0983fec629f.png)

認証の設定をしていないので、 "User Name" と "Password" の欄を空にして "Connect" をクリックしてください

以下のような画面に遷移します

![スクリーンショット 2025-01-06 14.20.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c4e3cd68-835a-14bb-4b58-59976880a215.png)

## セットアップ

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

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

alias RedisGraph.{Node, Edge, Graph, QueryResult}
```

FalkorDB への接続には RedisGraph を使用しています

https://github.com/crflynn/redisgraph-ex

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

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

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

`6379` はポート番号です

```elixir
{:ok, conn} = Redix.start_link("redis://falkor_db_for_livebook:6379")
```

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

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

## グラフの作成

新しいグラフ "Sweets" を作成します

```elixir
graph = Graph.new(%{name: "Sweets"})
```

実行結果

```elixir
%RedisGraph.Graph{name: "Sweets", nodes: %{}, edges: []}
```

この時点では FalkorDB には何も作成されていません

最終的にコミットしたタイミングで作成されます

## ノードの追加

新しいノードを作成します

```elixir
node = Node.new(%{
  label: "Sweet",
  properties: %{
    name: "チョコレートケーキ",
    category: "ケーキ",
    brand: "スイーツベーカリー",
    price: 450
  }
})
```

実行結果

```elixir
%RedisGraph.Node{
  id: nil,
  alias: nil,
  label: "Sweet",
  properties: %{
    name: "チョコレートケーキ",
    category: "ケーキ",
    brand: "スイーツベーカリー",
    price: 450
  }
}
```

`label` はノードの型であり、例えば「Person」や「Country」など、ノードの種類を表すものです

この時点では `id` と `alias` が `nil` になります

新しいノードをグラフに追加します

```elixir
{graph, john} = Graph.add_node(graph, node)

graph
```

実行結果

```elixir
%RedisGraph.Graph{
  name: "Sweets",
  nodes: %{
    "sdrayosuel" => %RedisGraph.Node{
      id: nil,
      alias: "sdrayosuel",
      label: "Sweet",
      properties: %{
        name: "チョコレートケーキ",
        category: "ケーキ",
        brand: "スイーツベーカリー",
        price: 450
      }
    }
  },
  edges: []
}
```

グラフの `nodes` にノードが追加されました

また、ノードの `alias` にランダムな値が設定されています

残りのノードを一気に追加しておきます

```elixir
nodes = %{"チョコレートケーキ" => node}

{graph, nodes} =
  [
    %{
      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: "フルーツ"
      }
    }
  ]
  |> Enum.reduce({graph, nodes}, fn entities, {acc_graph, acc_nodes} ->
    {graph, node} = Graph.add_node(acc_graph, Node.new(entities))
  
    nodes = Map.put(acc_nodes, entities.properties.name, node)
  
    {graph, nodes}
  end)
```

## エッジの追加

"チョコレートケーキ" が "砂糖" を含んでいる、という関係性をエッジとして追加します

`relation` に関係性を示します

```elixir
edge = Edge.new(%{
  src_node: Map.get(nodes, "チョコレートケーキ"),
  dest_node: Map.get(nodes, "砂糖"),
  relation: "CONTAINS"
})

{:ok, graph} = Graph.add_edge(graph, edge)
```

他のエッジも一気に追加します

```elixir
graph =
  [
    {"チョコレートケーキ", "CONTAINS", "卵"},
    {"チョコレートケーキ", "CONTAINS", "バター"},
    {"チョコレートケーキ", "CONTAINS", "チョコレート"},
    {"イチゴのチーズケーキ", "CONTAINS", "小麦粉"},
    {"イチゴのチーズケーキ", "CONTAINS", "砂糖"},
    {"イチゴのチーズケーキ", "CONTAINS", "卵"},
    {"イチゴのチーズケーキ", "CONTAINS", "バター"},
    {"イチゴのチーズケーキ", "CONTAINS", "牛乳"},
    {"イチゴのチーズケーキ", "CONTAINS", "イチゴ"},
    {"アップルパイ", "CONTAINS", "小麦粉"},
    {"アップルパイ", "CONTAINS", "砂糖"},
    {"アップルパイ", "CONTAINS", "バター"},
    {"アップルパイ", "CONTAINS", "リンゴ"},
    {"チョコチップクッキー", "CONTAINS", "小麦粉"},
    {"チョコチップクッキー", "CONTAINS", "砂糖"},
    {"チョコチップクッキー", "CONTAINS", "バター"},
    {"チョコチップクッキー", "CONTAINS", "チョコレート"},
    {"ストロベリーキャンディー", "CONTAINS", "砂糖"},
    {"ストロベリーキャンディー", "CONTAINS", "イチゴ"}
  ]
  |> Enum.reduce(graph, fn {src_name, relation, dest_name}, acc_graph ->
    {:ok, graph} = 
      Graph.add_edge(
        acc_graph,
        Edge.new(%{
          src_node: Map.get(nodes, src_name),
          dest_node: Map.get(nodes, dest_name),
          relation: relation
        })
      )
  
    graph
  end)
```

## コミット

ここまでの操作を FalkorDB に反映します

```elixir
{:ok, commit_result} = RedisGraph.commit(conn, graph)
```

実行結果

```elixir
{:ok,
 %RedisGraph.QueryResult{
   conn: #PID<0.239.0>,
   graph_name: "Sweets",
   raw_result_set: [
     ["Labels added: 2", "Nodes created: 13", "Properties set: 36", "Relationships created: 20",
      "Cached execution: 0", "Query internal execution time: 3.757917 milliseconds"]
   ],
   header: nil,
   result_set: nil,
   statistics: %{
     "Labels added" => "2",
     "Nodes created" => "13",
     "Nodes deleted" => nil,
     "Properties set" => "36",
     "Query internal execution time" => "3.757917",
     "Relationships created" => "20",
     "Relationships deleted" => nil
   },
   labels: nil,
   property_keys: nil,
   relationship_types: nil
 }}
```

ラベルが2種類、ノードが13個、エッジが20個つくられました

## FalkorDB での確認

FalkorDB Browser で以下のクエリを実行します

```cypher
MATCH (n)-[r]->(m) RETURN n,r,m
```

:::note info
このクエリは OpenCypher というグラフデータベース用の言語で書いています

https://opencypher.org/
:::

実行結果

![スクリーンショット 2025-01-06 15.11.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3c85008c-0240-fd70-0da0-41c5d1f2a8b6.png)

ノードやエッジがつながってグラフになっていることが分かります

"チョコチップクッキー"の素材のグラフは以下のクエリで参照できます

```cypher
MATCH (n {name: "チョコチップクッキー"})-[r]->(m)
RETURN n,r,m
```

![スクリーンショット 2025-01-06 15.23.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1fe2629e-4894-d71e-4ab4-acae7243f3bb.png)

"イチゴ" を含んでいるお菓子のグラフは以下のクエリで参照できます

```cypher
MATCH (n)-[r]->(m {name: "イチゴ"})
RETURN n,r,m
```

![スクリーンショット 2025-01-06 15.22.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/bc6d919b-2bb3-bf7c-70e6-acf19d84b1cb.png)

## クエリの実行

「イチゴを含むお菓子の名前と価格」は以下のようにして取得できます

```elixir
{:ok, query_result} = RedisGraph.query(conn, graph.name, """
MATCH (n:Sweet)-[r:CONTAINS]->(m {name: "イチゴ"})
RETURN n.name AS name, n.price AS price
""")

query_result
```

実行結果

```elixir
%RedisGraph.QueryResult{
  conn: #PID<0.239.0>,
  graph_name: "Sweets",
  raw_result_set: [
    [[1, "name"], [1, "price"]],
    [
      [[2, "ストロベリーキャンディー"], [3, 100]],
      [[2, "イチゴのチーズケーキ"], [3, 520]]
    ],
    ["Cached execution: 0", "Query internal execution time: 1.732500 milliseconds"]
  ],
  header: ["name", "price"],
  result_set: [
    ["ストロベリーキャンディー", 100],
    ["イチゴのチーズケーキ", 520]
  ],
  statistics: %{
    "Labels added" => nil,
    "Nodes created" => nil,
    "Nodes deleted" => nil,
    "Properties set" => nil,
    "Query internal execution time" => "1.732500",
    "Relationships created" => nil,
    "Relationships deleted" => nil
  },
  labels: ["Sweet", "Ingredient"],
  property_keys: ["name", "category", "brand", "price", "type"],
  relationship_types: ["CONTAINS"]
}
```

このままの形式だと扱いにくいので、マップに変換します

```elixir
QueryResult.results_to_maps(query_result)
```

実行結果

```elixir
[
  %{"name" => "ストロベリーキャンディー", "price" => 100},
  %{"name" => "イチゴのチーズケーキ", "price" => 520}
]
```

表として表示する機能もありますが、日本語だと表示が崩れるので微妙です

```elixir
query_result
|> QueryResult.pretty_print()
|> Kino.Text.new(terminal: true)
```

実行結果

![スクリーンショット 2025-01-06 15.36.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a56d95a1-c150-a6e5-39e8-475a18474662.png)

Livebook 上であれば `Kino.DataTable` を使いましょう

```elixir
query_result
|> QueryResult.results_to_maps()
|> Kino.DataTable.new()
```

実行結果

![スクリーンショット 2025-01-06 15.38.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5467485a-2776-4248-d8a0-c500ebc338fd.png)

もっと複雑な例として、乳製品不使用のお菓子を取得してみましょう

```elixir
# 乳製品不使用のお菓子
{:ok, query_result} = RedisGraph.query(conn, graph.name, """
MATCH (s:Sweet)
WHERE NOT (s)-[:CONTAINS]->(:Ingredient {type: "乳製品"})
RETURN s.name AS name
""")

query_result
|> QueryResult.results_to_maps()
|> Kino.DataTable.new()
```

実行結果

![スクリーンショット 2025-01-06 15.41.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e469e17e-c3a7-1b2d-50c3-fb0f36a4464f.png)

## データの削除

以下のコードで全てのデータを削除します

```elixir
RedisGraph.query(conn, graph.name, "MATCH (n) DETACH DELETE n")
```

実行結果

```elixir
{:ok,
 %RedisGraph.QueryResult{
   conn: #PID<0.239.0>,
   graph_name: "Sweets",
   raw_result_set: [
     ["Nodes deleted: 13", "Relationships deleted: 20", "Cached execution: 0",
      "Query internal execution time: 2.147084 milliseconds"]
   ],
   header: nil,
   result_set: nil,
   statistics: %{
     "Labels added" => nil,
     "Nodes created" => nil,
     "Nodes deleted" => "13",
     "Properties set" => nil,
     "Query internal execution time" => "2.147084",
     "Relationships created" => nil,
     "Relationships deleted" => "20"
   },
   labels: nil,
   property_keys: nil,
   relationship_types: nil
 }}
```

## まとめ

Livebook から FalkorDB を操作することができました

次はグラフデータベースを利用してグラフRAGを構築してみます

https://qiita.com/RyoWakabayashi/private/3c3c2ba472a41a329fa7