ChatGPTでCypherの書き方を学習してみる

523
ChatGPTでCypherの書き方を学習してみる

すっかりChatGPTを活用する事が当たり前になった今日この頃、ChatGPTを最も便利に使えるシーンの一つに「IT関連の学習」があると思うので、ちょっとそれをやってみる。お題はCypherとグラフDB。

ChatGPTとの対話

まずはChatGPTにグラフDBについて理解できるか聞いてみる。

私:「プロパティグラフとそのグラフのノードの間の関連について、あなたの理解できる形式のスキーマの例を出力してください」

ChatGPT:

プロパティグラフは、複数のノード(または頂点)と、それらのノード間のエッジ(または関係)から構成されるデータ構造です。ノードとエッジは両方ともプロパティを持つことができます。

例えば、ソーシャルネットワーキングサイトのシンプルなスキーマを考えてみましょう。

  1. ノード
    User: 各ユーザーは、一意のID、名前、メールアドレスなどのプロパティを持つことができます。
    Post: 各投稿は、一意のID、投稿の内容、投稿日時などのプロパティを持つことができます。
  2. エッジ(関係)
    FRIENDS_WITH: ユーザーとユーザーの間の関係を示します。このエッジは、友人関係が成立した日付などのプロパティを持つことができます。
    POSTED: ユーザーと投稿の間の関係を示します。このエッジは、投稿が行われた日付と時間などのプロパティを持つことができます。

このスキーマを使って、ユーザー間の関係、ユーザーが何を投稿したか、どのユーザーがどのユーザーと友人であるかなどの情報を表現することができます。

ちょっとこの例をYAML形式で書けるかどうか聞いてみよう。

私:「このスキーマをyamlで表現してください」

ChatGPT:

nodes:
  - type: User
    properties:
      - id: Integer
      - name: String
      - email: String

  - type: Post
    properties:
      - id: Integer
      - content: String
      - timestamp: DateTime

edges:
  - type: FRIENDS_WITH
    startNode: User
    endNode: User
    properties:
      - friendshipDate: Date

  - type: POSTED
    startNode: User
    endNode: Post
    properties:
      - postedDate: DateTime

このYAMLスキーマは、ノードとエッジのタイプとそのプロパティを定義しています。"startNode"と"endNode"フィールドは、エッジがどのノード間の関係を示しているかを定義します。

スキーマができた事だし、ひとまずChatGPTにCypherのクエリの例を聞いてみよう。せっかくグラフDBを使うので、関連が関わるものにしてみよう。

私:「このグラフに対して、1つ以上の投稿をしたユーザーを検索するCypherのクエリを書いてください」

ChatGPT:

Cypherは、グラフデータベースを操作するためのクエリ言語で、特にNeo4jで使われます。下記のCypherクエリは、1つ以上の投稿をしたユーザーを検索します。

MATCH (u:User)-[:POSTED]->(:Post)
RETURN DISTINCT u

このクエリでは、まず MATCH 句を使って、POSTED 関係を持つ User ノードと Post ノードのパターンをマッチさせます。その後、RETURN DISTINCT u で、重複なしにマッチした User ノードを返します。

注意: このクエリは User ノードが少なくとも1つの Post ノードに関連しているものを返します。もし特定の期間や投稿の内容によるフィルタリングが必要であれば、クエリはさらに調整が必要です。

きちんと答えが返ってきた。ではもう少し込み入ったクエリはどうだろう?

私:「上記のPostのプロパティに、他のユーザーからの良い評価の数を示すupVotesと、悪い評価を示すdownVotesを追加します。upVotesがdownVotesを上回る投稿を持つユーザーを検索するCypherのクエリを書いてください。」

ChatGPT:

その要求に応じて、以下のようなCypherクエリを作成することができます。

MATCH (u:User)-[r:POSTED]->(:Post)
WHERE r.upVotes > r.downVotes
RETURN DISTINCT u

このクエリでは、まず MATCH 句を使って、POSTED 関係を持つ User ノードと Post ノードのパターンをマッチさせます。その関係は r として指定されています。そして WHERE 句を用いて、良い評価(upVotes)が悪い評価(downVotes)を上回る条件を指定します。その後、RETURN DISTINCT u で、重複なしにマッチした User ノードを返します。

注意: このクエリは、upVotes が downVotes を上回る投稿を持つ User ノードを返します。ただし、これはユーザーが持つ全ての投稿に対してではなく、少なくとも1つの投稿が該当条件を満たすユーザーを返します。全ての投稿がこの条件を満たすユーザーのみを返すクエリはより複雑となります。

ん?PostにupVotesとdownVotesを追加したにも関わらず、POSTEDに対してupVotesとdownVotesを見に行っている?まあ直し方はわかるのだが、せっかくなので改めて聞いてみよう。

私:「upVotesとdownVotesはPostedではなくPostのプロパティですが、そのクエリで良いのですか?そうであれば、理由を書いてください。」

ChatGPT:

すみません、その点を見落としていました。upVotesとdownVotesがPostノードのプロパティであるなら、クエリは少し変わります。正確なクエリは以下のようになります:

MATCH (u:User)-[:POSTED]->(p:Post)
WHERE p.upVotes > p.downVotes
RETURN DISTINCT u

このクエリでは、まず MATCH 句を使って、POSTED 関係を持つ User ノードと Post ノードのパターンをマッチさせます。その後、WHERE 句で、Postノードの upVotes プロパティが downVotes プロパティを上回る条件を指定します。最後に、その条件を満たす User ノードを RETURN DISTINCT u で返します。

もともとのクエリはエッジ(Posted)のプロパティを参照していましたが、upVotesとdownVotesがPostノードのプロパティであるため、ノード(Post)のプロパティを参照するようにクエリを修正しました。

問題なく修正できた。

さらに込み入ったものを書けるか試してみよう。

私:「upVotesよりもdownVotesが上回る投稿の数が多いユーザーを検索するクエリを書いてください。」

ChatGPT:

指定された条件に基づいて、downVotesがupVotesを上回る投稿の数が多いユーザーを探すためのCypherクエリは次のようになります。

MATCH (u:User)-[:POSTED]->(p:Post)
WITH u, 
sum(CASE WHEN p.downVotes > p.upVotes THEN 1 ELSE 0 END) AS downVotesCount, 
count(p) AS totalPosts
WHERE downVotesCount > totalPosts / 2
RETURN u

このクエリでは、まず MATCH 句を用いて、POSTED 関係を持つ User ノードと Post ノードのパターンをマッチさせます。

次に WITH 句で、ユーザー u と2つの集計値を定義します:downVotesCount は downVotes が upVotes を上回る投稿の数を数え、totalPosts はユーザーが投稿した全投稿数を数えます。

そして WHERE 句で、downVotes が upVotes を上回る投稿の数が全投稿数の半数を超えるユーザーをフィルタします。

最後に、そのような条件を満たすユーザーを RETURN 句で返します。

さて、ここでちょっとだけ疑問が出てきた。uはMATCH句で既に出てきているが、WITH句で改めて定義されている。このクエリのWITH句のuを消すと実際の所は変数が未定義というエラーになって動かなくなるのだが、その理由を聞いてみよう。

私:「WITH句でuを改めて定義している理由を書いてください。」

ChatGPT:

CypherのWITH句は、クエリの中間結果をパイプライン化し、それらの結果を次のクエリ部分に渡すために使用されます。WITH句を使うと、クエリの部分ごとに異なる集計、フィルタリング、および並べ替え操作を行うことができます。

この特定の例では、WITH句がユーザーuと、そのユーザーの投稿に関連する集計結果(downVotesCountとtotalPosts)をグループ化します。その後のWHERE句は、この中間結果にフィルタを適用し、最終的な結果をRETURN句で返します。

したがって、WITH句でuを再定義する主な目的は、ユーザーとその関連集計結果を一緒にパイプライン化し、その結果に対してフィルタリングと集計を行うためです。

なるほど。Neo4Jのドキュメントと照らし合わせてもみたが、正しいようだ。

実行

まずはデータを登録してみよう。使うプログラミング言語はTypeScriptでneo4j-driverを利用。

import neo4j  from 'neo4j-driver'
const driver = neo4j.driver(
    'neo4j://localhost:7687',
    neo4j.auth.basic('neo4j', 'neo4jpasswprd')
  )

async function register() {
    const users = [{ id: 1, name: 'Foo'}, { id: 2, name: 'Bar'}, { id: 3, name: 'Buz'}]
    const posts = [{ id: 1, title: 'test1', description: 'description1', upVotes: 2, downVotes: 0 }, { id: 2, title: 'test2', description: 'description3', upVotes: 2, downVotes: 1 }, { id: 3, title: 'test3', description: 'description3', upVotes: 0, downVotes: 1 }]
    users.forEach((user) => {
        driver.session().executeWrite(tx => {
            tx.run('MERGE (u: User {id: $id, name: $name})', user)
        })
    })
    posts.forEach(async (post) => {
        await driver.session().executeWrite(tx => {
            tx.run('MERGE (u: Post {id: $id, title: $ title, description: $description, upVotes: $upVotes, downVotes: $downVotes})', post)
        })
    })
    await driver.session().executeWrite(tx => {
        tx.run(`MATCH (u:User {id: 1}), (p:Post {id: 1}) CREATE (u)-[:POSTED]->(p)`)
        tx.run(`MATCH (u:User {id: 1}), (p:Post {id: 2}) CREATE (u)-[:POSTED]->(p)`)
        tx.run(`MATCH (u:User {id: 2}), (p:Post {id: 3}) CREATE (u)-[:POSTED]->(p)`)
    })
}

これはid: 1のユーザーにはupVotesの方が多い投稿を2つ、2のユーザーにはdownVotesが多い投稿を1つ関連付けて、3のユーザーには何も投稿を関連付けていない。

続いて先ほどChatGPTに書いてもらったクエリを投げてみよう。

async function query1() {
    const ret = await driver.session().executeRead(async tx => {
        const result = await tx.run(`MATCH (u:User)-[:POSTED]->(:Post)
        RETURN DISTINCT u`)
        return result.records.map(r => {
            return {
                id: r.get('u').properties.id,
                name: r.get('u').properties.name
            }
        })
    })
    console.log(JSON.stringify(ret))
}

この関数の実行結果は以下。

[{"id":1,"name":"Foo"},{"id":2,"name":"Bar"}]

期待通りの結果になっている。残りのクエリのコードと実行結果は割愛するが、正しい結果が得られた。

さて、ここまでChatGPTに教えてもらったので、以下のような関連をとクエリを書いてみよう。(モデルの良し悪しは気にしない)

  • Commentモデルを追加
  • PostからCommentにCOMMENT関連を追加
  • UserからCommentにCOMMENTED関連を追加
  • idが1のUserからコメントされたPostを検索する

そしてこれまでの知識でネックになるのは、特定のユーザーからのコメント一覧から、さらにそのコメントに関連した記事を絞り込むというところ。恐らくWITH句とMATCH句の組み合わせだと思うが、ここでChatGPTに質問を投げてみよう。

私:「MATCH句はWITH句の中で使えますか?」

ChatGPT:

CypherのWITH句の中では、直接MATCH句を使用することはできません。しかし、WITH句の後に新たにMATCH句を記述することで、中間結果に基づいて新たなマッチングを行うことが可能です。

例えば、まずMATCH句で特定のユーザーを探し出し、それをWITH句で保持した後、新たにMATCH句でそのユーザーが投稿した記事を検索するというような使い方があります。

以下にそのようなクエリの例を示します:

MATCH (u:User {name: 'Alice'})
WITH u
MATCH (u)-[:POSTED]->(p:Post)
RETURN u, p

このクエリは、まず最初のMATCH句で名前が'Alice'のユーザーを探し出し、その結果をWITH句で保持します。そして次のMATCH句で、そのAliceが投稿した記事を探し出します。そして最終的な結果として、Aliceと彼女が投稿した記事を返します。

Cypherの構文的にはかなり的外れっぽい質問を投げたつもりだったが、ちゃんとした答えが返ってきた。ともあれ、WITH句で一旦抽出結果を変数に束縛して、その後に改めてMATCH句を使えば良さそうだ。

というわけで、先の要件に合わせたクエリは以下のようになる。

MATCH (u:User { id: 1 })-[:COMMENTED]->(c:Comment)
WITH c
MATCH (c:Comment)<-[:COMMENT]-(p:Post)
RETURN DISTINCT p

<-は関連を逆に辿る記法

総評

AIの応答が正しいかどうかを動かして検証できるので、プログラミングとジェネラティブAIの相性良すぎ。