オールアバウトTech Blog

株式会社オールアバウトのエンジニアブログです。

グラフDBのNeo4jでカフェのレコメンド機能を実験してみた

お世話になります。オールアバウトのsutchanです。
今回は、"Neo4j“というグラフデータベースを触って遊んでみた話をしたいと思います。

Neo4j(グラフデータベース)とは

まず、グラフデータベースとはなんぞやという話から。 グラフデータベースはグラフ構造を持ったデータを扱うのに特化したデータベースです。 グラフ構造は、電車の経路やSNSソーシャルグラフ、ドラマの人物相関図のようなものと言えば、イメージしやすいかもしれません。 図にあるように、グラフデータベースは、データの実体であるNodeと、それらをつなぐRelationで構成されていることがわかります。 NodeとRelationには、Property(Key-Value形式の付加情報)も付けることができます。 グラフデータベースは、その特徴から、電車の乗換案内や地図アプリの経路探索、SNSの「知り合いかも?」など、レコメンド機能によく使われているそうです。

f:id:allabout-techblog:20170222174934p:plain

では、このNeo4jを使ってどう遊ぶのか…… 今回は、弊社のスマホアプリ「CafeSnap」を題材に、 自分におすすめのカフェを推薦する機能(= カフェレコメンド機能)を実現する ことを目指して、Neo4jで遊んでみます!

CafeSnapとは

ちょっとだけ紹介です。
CafeSnapは、 日本全国の個性光るカフェを探すことのできる写真共有型カフェアプリ です。 タイムラインに投稿された写真を見ながらカフェを探せるだけでなく、カフェ探しに特化した検索機能も用意しています。さらに、行きたいカフェや、行ったカフェをブックマークしておける「お気に入り機能」も用意してあるので、突然のカフェミーティングやカフェ巡りでも、迷わずカフェを選べます。カフェを探す際にはぜひ、使って下さい!

f:id:allabout-techblog:20170224200513p:plain

cafesnap.me

Neo4jを使う準備

では、さっそくNeo4jを使う準備をしていきます。ここでは、インストールから、データのインポートまでを順を追ってお話します。

インストール

Dockerを使う場合

Dockerが使える環境でしたら、DockerHubに、公式のNeo4jリポジトリがあるので、それをクローンしたら一瞬で使えるようになります!

docker pull neo4j

Dockerを使わない場合

Macで環境を作ることを前提にしています。以下の条件のもと、インストールすることができます。

  • HomeBrewでサクッとインストールできます
brew install neo4j
  • Neo4jの実行には、JDK 1.8以上が必要です

起動

neo4j startで起動します。 以下のようなレスポンスが表示されるので、しばらくしてからopen http://localhost:7474/を叩くことで、Neo4jのダッシュボードをブラウザで起動できます。

> neo4j start
Starting Neo4j.
Started neo4j (pid 23398). By default, it is available at http://localhost:7474/
There may be a short delay until the server is ready.
See /usr/local/Cellar/neo4j/3.0.7/libexec/logs/neo4j.log for current status.

> open http://localhost:7474/

f:id:allabout-techblog:20170224185055p:plain

データのインポート

MySQLなどのRDBから直接インポートすることもできますが、今回は必要な情報が限定されているので、CSVでインポートします。なお、インポートするデータは、/usr/local/Cellar/neo4j/3.0.7/libexec/importに置きます。

ノードとして入れる情報をインポート

まずは、ノードに入れる情報をインポートします。まずCafe、User、Zoneの3種類をインポートします。 以下のような形式のCSVをインポートします。以下はUserの例。シンプルに、IDと名前だけのデータです。

user_id user_name
100 user A
101 user B

クエリは、こんな感じ。

LOAD CSV WITH HEADERS FROM "file:///[CSVのファイル名]" AS line
CREATE (n:User{user_id:line.user_id, user_name:line.user_name})

Neo4jではCypherQLという書き方でクエリを書いていきます。全体的には、平易な英文のように読めるので、分かりやすいかと思います。 CREATE 文は、特徴的ですね。"()“の範囲がノードを表し、”{}“の範囲はプロパティを表します。プロパティは、key:valueで記述します。 なお、上のCREATE文の n は変数としての宣言です(このクエリ内で後から使うことはないので省略可)。

リレーションをインポート

次に、リレーションのデータをインポートします。こちらも、CSVでデータを用意しておきます。 以下は、ユーザとカフェの「行った」の関係を保存したCSVの例です。よくあるリレーション用テーブルのデータです。

user_id cafe_id
100 1
101 1

これを、インポートします!

LOAD CSV WITH HEADERS FROM "file:///[CSVファイル名]" AS line
MATCH (c:Cafe{id:line.cafe_id}), (u:User{user_id:line.user_id})
CREATE (u)-[:visited]->(c)

先ほどとはまた異なるクエリになりました。 まず MATCH 文で、CafeとUserのノードをフィルタして、cとuの変数に入れています。 フィルタの方法は、プロパティを指定するだけなので、簡単ですね! CREATE 文では、ユーザのノード(u)から、カフェのノード(c)に向けて、VISITED(行った)と名付けた矢印を引いてあげています。これで、ユーザからカフェに関係を与えることができました。同様に、CafeとZoneにもリレーションを付けておくことにしました。

f:id:allabout-techblog:20170222175300p:plain

これで(やっと!)準備完了です!!!

実験

それでは、実験を始めます。 今回実現するレコメンド機能は、 自分が行ったカフェの傾向を元に、多くのユーザが行っている(= 人気のある)カフェを求める というものです。すなわち、 「自分が行ったことのあるカフェ」に行っているユーザが、たくさん訪れているカフェのランキング を求めるということになります。 ちょっと複雑ですが、順を追って、やってみましょう!

自分が行ったカフェ

まず、自分が行ったことのあるカフェを求めます。 クエリは以下のような感じ。

MATCH (u:User{user_name:"[ユーザ名]"})-[:VISITED]->(c:Cafe)
RETURN u, c

クエリがシンプルで分かりやすいですね! グラフで表示すると以下のような感じ。

f:id:allabout-techblog:20170224185313p:plain

自分が行ったカフェは、69件ありました。大きい!

「自分が行ったカフェ」に行った人

早速クエリを書いてみましょう!
すこし複雑なクエリになってきますので、変数名は具体的な名前にしていきます
少々長くなりますが、お付き合い下さい…。

MATCH (me:User{user_name:"[ユーザ名]"})-[:VISITED]->(cafe:Cafe)<-[:VISITED]-(other_user:User)
RETURN me, cafe, other_user

今度は逆向きの矢印で、自分が行ったカフェに行った人を求めることができました!簡単ですね! なお、グラフで表示すると、こんな感じ……。

f:id:allabout-techblog:20170224190051p:plain

もはや自分がどこにいるのかわからないですねw
赤がUser、黄色がCafeです。
※対象となるノードが多すぎるため、3割しか表示していません

「『自分が行ったカフェ』に行った人」が行ったカフェを集計

上の見出しも複雑な表現になっていますが、頑張りましょう。 今回は、さらに逆向きの矢印が増えたり、WHERE文が増えたりしています!

MATCH (me:User{user_name:"[ユーザ名]"})-[:VISITED]->(cafe:Cafe)
<-[:VISITED]-(other_user:User)-[:VISITED]->(recommend_cafe:Cafe)
WHERE NOT (me)-[:VISITED]->(recommend_cafe)
RETURN recommend_cafe.cafe_name, count(recommend_cafe) as count
ORDER BY count DESC

今回は、先程のクエリに追加して、other_user が行ったカフェ recommend_cafe へのリレーションを追加しました。 これで、【「『自分が行ったカフェ』に行った人」が行ったカフェ】を求めることができました。 しかし、その recommend_cafe は、自分 me がすでに行ったことがある可能性があります(図)。 自分が行ったことのないカフェを求めたいので、 WHERE 文で me から recommend_cafe へのリレーションを NOT してあげます。これで、よりレコメンドらしい結果を求めることができました!

f:id:allabout-techblog:20170301142853p:plain

f:id:allabout-techblog:20170224191536p:plain

検証

求めた結果はどうだったか

自分の行く傾向に近いカフェを求めることができました。というのも、

  • 自分がよく行く恵比寿〜渋谷〜新宿周辺のカフェが表示された
  • 自分が行ったことあるけど、「行った」に登録していなかったカフェが出てきた(2番目のGLITCH 〜)

以上のことから、レコメンドとしても十分使えるということがわかりました!

クエリはどうだったか

クエリは、直感的で分かりやすいです。 しかも、MySQLとくらべて短く書けるので、読んだ際に理解しやすい書き方になります。 レコメンドを求めたクエリで比較してみると、以下のようになります。

MATCH
        (u:User{user_id: [ユーザID]})-[:VISITED]->(c:Cafe)<-[:VISITED]-(u2:User)-[:VISITED]->(c2:Cafe)-[:IS_IN]->(z:Zone)
WHERE NOT
        (u)-[:VISITED]->(c2)
RETURN c2.cafe_name, count(u2) as count, z.zone_name
ORDER BY count DESC
LIMIT 10
// 「『自分が行ったカフェ』に行ったことのある人」が行ったことのある、かつ、自分が行ったことのないカフェ ランキング トップ10
SELECT  `cafes`.`id` ,  `cafes`.`name` , COUNT( `favorites`.`cafe_id` ) AS `count`
FROM  `favorites`
RIGHT JOIN  `cafes` ON  `favorites`.`cafe_id` =  `cafes`.`id`
WHERE  `user_id`
IN (
        // 「自分が行ったカフェ」に行った人(自分以外)
        SELECT  `user_id`
        FROM  `favorites`
        WHERE  `cafe_id`
        IN (
                // 自分が行ったカフェ
                SELECT  `cafe_id`
                FROM  `favorites`
                WHERE  `user_id` =[自分のユーザID]
                AND  `status` =[行った]
        )
        AND  `user_id` !=[自分のユーザID]
)
AND  `status` =[行った]
AND  `cafe_id` NOT
IN (
        // 自分が行ったカフェ をNOT
        SELECT  `cafe_id`
        FROM  `favorites`
        WHERE  `user_id` =[自分のユーザID]
        AND  `status` =[行った]
)
GROUP BY  `favorites`.`cafe_id`
ORDER BY  `count` DESC
LIMIT 10

もう少し短くできそうですが、MySQLではだいぶ複雑になってしまいました……。

速度

Neo4jでは結果を出すまでに数秒かかっていました。もちろん、これは、対象となるノードの数によって大きく変わります。 時間がかかる原因が、ブラウザの表示処理なのか、Javaなのか、使っていたMac Book Proが問題なのかわかりません。 ブラウザに出すよりは、ターミナルで表示するほうが3割くらい速そうだ、というところまではわかりました。

さらに判明したこと

この実験を通して、いかにも当然そうな事実も、データの裏付けを得ることができました。

  • 調布市にある 猿田彦珈琲アトリエ仙川 に行った人のうち3割は、恵比寿にある 猿田彦珈琲 にも行っていた。(集計した結果、トップが恵比寿の猿田彦。逆の組み合わせだと、この傾向は見られない)
MATCH (u1:User)-[r1:VISITED]->(c1:Cafe{cafe_name:"猿田彦珈琲 アトリエ仙川"}), (u1)-[r2:VISITED]->(c2:Cafe)
RETURN c2.cafe_name, count(c2) as count
ORDER BY count DESC

f:id:allabout-techblog:20170224192237p:plain

  • 「青山にあるブルーボトルコーヒーに行った人」が行ったことのあるカフェを集計すると、 すぐそばにある CAFE KITSUNÉがトップだった(ユーザ傾向が分散しているため、トップだったとしか言えない……)
MATCH (u1:User)-[r1:VISITED]->(c1:Cafe{cafe_name:"ブルーボトルコーヒー 青山カフェ"}), (u1)-[r2:VISITED]->(c2:Cafe)
RETURN c2.cafe_name, count(c2) as count
ORDER BY count DESC

f:id:allabout-techblog:20170224193318p:plain

まとめ

今回は、グラフデータベース「Neo4j」で遊んでみました。 レコメンドを求めるところまでは比較的簡単に実現することができました。学習コストも低いので、導入しやすいと思います。 一方で、速度を検証できていないので、そこは課題です。また、WHEREの条件の書き方には慣れが必要だなと感じました。 リレーションをNOTすることに気づいたのはだいぶ時間が経ってからなので……。

というわけで、今回は以上になります。 ありがとうございました!