不可視点

search guy at cookpad.com

Netflixはどのように映画をジャンル分けしているか

映像コンテンツのストリーミングといえばNetflix、現在4400万人のユーザー(有料会員)がいる成熟したサービスですが、現在もすごいペースで成長しています。

利用できる地域は限られますが、日本でもレコメンデーションのコンテストNetflix prizeの開催や、AWSをいち早く活用した企業として知られています。

Netflixは先に紹介したNetfix Prizeでレコメンデーションの性能向上に懸賞金をかけたほど、レコメンデーションがサービスの重要な位置を占めています。 視聴された映画の2/3はレコメンデーション経由らしいです。

Todd Yellin(Vice President of Product Innovation at Netflix)は、「映画をピッタリの人にピッタリのタイミングで届ける」ことをパーソナライゼーションのゴールと言っており、例えばこのために、1つのアカウントで複数の人間のプロフィールを登録可能にし、どの人がどの映画を見たのかはっきりさせる機能を昨年リリースしています。これによって家庭にあるnetflixの共用端末で家族の誰がどの映画をいつ見たのかより正確につかむことでレコメンデーションをより正確にしようとしています。

そんなNetflixですが、最近おもしろい記事があったのでご紹介したいと思います。

Netflixはどのように映画をジャンル分けしているか

優れたアルゴリズムで選び出した映画も紹介のしかた次第コンバージョンは変わってくるものと思います。

例えば、ヒューマンドラマとかロードムービーが好きな人って分かってて

「ドラマから○○をオススメします」って書いても、 「確かにドラマは好きだけど、どんな映画でもいいわけじゃない、好きなテーマとしては…」

ってなると思います。映画のジャンル分けってザックリしすぎてマジで参考にならないんですよね。それはどんなドラマだよって。

でも、Netflixには映画をピッタリ言い表すジャンルが全部の映画にあるそうです!!

例:

って言われたら「おうそれそれ」ってなるかもしれません。

って言われたら、ほうほう!ってなるじゃないですか。

  • 「弱者が這い上がる系の伝記映画(Emotional Biographical Underdog Movies)からオススメ:

って言われたら歴史上の人物の伝記ものに興味ある人は興味を引かれると思います。

僕は打撃王以外は全部見たことあるんですが、ジャンル名はこれ以上ないとは言いませんが、違和感無いです。 これだよ!この粒度でこそジャンル意味ある!僕はそう思いました!

そして、Netflixにはこうした細かい映画のジャンルが現時点で7万7000ジャンルあり、 それぞれに何件かの映画が登録されているそうです。 そして、この細かいジャンルのことをaltgenreと呼んでいます。

altgenreの仕組み

altgenreは36ページのマニュアルを読み込んだ映画アノテーターが映画全てに細かくタグ付けしたデータを元に構築されます。

  • 映画の結末タイプ(例:ハッピーエンディング)
  • 主人公は社会的正義側か、アウトロー
  • 公開年代
  • ロケ地
  • テーマ(結婚、忠誠、家族、名声、猫、犬、馬、アート、フード、911、etc...)
  • 性的表現の程度
  • 残虐表現の程度
  • ロマンスレベル
  • etc...

これをコンテンツ産業に従事したことがある優秀な経験者数十人が人力で映画を見てタグ付けしているそうです。すごい!

そして、文が長くなり過ぎない範囲でこれを1つのセンテンスにしてaltgenreとしています。

また、文を生成するときにEnjoyableって書くためには、ハッピーエンディングで、主人公は社会的正義側のヒーローであることなど、ひとつの単語の成立条件を複数のタグで表現したりすることもあるそうです。興味深い。楽しいってどういうことなのかを要素分解できるわけですね。 Emotionalの成立条件ってどんなのなんですかね。

個人的に意外だったのは、人手がかなりかかっていることです。 高度なアルゴリズムによって自動的にタグが展開されているんだとばかり思ってたので、 やはり映画のプロットの重要な部分は人間が解釈してアノテーションする方法が選ばれているのが意外でした。 音楽ストリーミングサービスのpandoraのmusic genomeは400の特徴量を音楽から取り出してレコメンデーションに役立てているという記事を何処かで読んだことがあって同じようなものを想像していたためです。

また、視聴履歴と組み合わせれば、どこの地域で、どんなプロットやテーマが需要があるかとかもわかってしまう。 Netflixは最近自社でドラマを制作しましたが、どんなプロットにしようかとあれこれ悩まなくても一番数字取れるプロットを自動生成したのではないでしょうかね。

altgenreのより詳しい内容については原文をどうぞ。 この記事はnetflixの偉い人へのインタビューも含まれていますが、 基本は第三者がスクレイピングしてリバースエンジニアリングして分析しただけなので、 本当に細かいところまではわかりませんが。

関連記事

NetflixはTechblogがかなり面白いです。

あと、Netflixのレコメンデーション周りで面白かったのをあげときます

Elasticsearchチュートリアル

目的

検索用サーバーとして最近注目されているElasticsearchですが、ついに1.0 RC1がリリースされたそうです。 Googleトレンドを見ても、この分野で先行するApache Solrに迫る勢いを感じます。

そういうわけで私もElasticsearchについて興味を持って調べてみましたが情報がちょっと少ないですね…

  • 「調べたけど断片的な情報しかない」
  • 「公式doc英語だし、専門用語が多すぎてわからん」
  • 「え、できること多すぎ。よくわからん。どれが重要?」

と言った感じで、最初ちょっと大変… そこで調べ始める人が、概観をつかむためのチュートリアルをつくろうと思います。 コマンドを全部実行する必要ありません。用語をおさえることで調べものが捗ることがひとつのゴールです。

自分の理解の整理も兼ねています。間違ってる箇所あったら教えて下さい。

part 1:ESを使ってレストラン検索を作る

レストラン検索を作ることで、Elasticsearchの基本的な使い方を紹介します。 今回はlivedoor グルメの研究用データセットを使いレストラン検索をつくります。 このデータセットには、

  • レストラン情報:20万店舗
    • 店名(名称、読み)
    • 住所(郵便番号、住所、緯度経度)
    • 最寄り駅
    • モーニングの有無
    • ランチの有無
    • 写真アップロード数
    • PV etc
  • 口コミ:20万件
    • user_id
    • 総合評価(1-5)
    • 料理評価(1-5)
    • サービス評価(1-5)
    • 雰囲気評価(1-5)
    • コストパフォマンス評価(1-5)
    • 口コミタイトル
    • 口コミ本文
    • 利用目的
    • 投稿日
    • 口コミへの投票ログ etc

が含まれます。すごすぎる。こんな神データセットを提供しているLINE株式会社はヤバい。感謝です。

レストラン検索のゴール

このデータセットを使って以下の検索ができるようにしてみましょう。

  • 「地名 料理ジャンル/店名」と言った形でレストランが探せる
  • メニュー名からレストランが探せる(例えば「ハンバーガー」が美味しいお店)
  • PV順・口コミが多い順・評価が高い順など検索結果をソートできる

これらを実現する方法は様々ですがElasticsearchならけっこう簡単にサクッとできます。

準備

データの準備

では、早速データを準備しましょう。

ESのセットアップ

まずは http://www.elasticsearch.org/downloads/1-0-0-rc1/ からダウンロードしてください。 どんなセットアップ方法でもいいですが、ZIPを選んで解凍して使うことにします。

さらに、どう考えても必須のpluginがあるので以下の手順でインストールします。

~/Downloads/elasticsearch-1.0.0.RC1
$ ls
LICENSE.txt    NOTICE.txt     README.textile bin            config         lib

# headというpluginのインストール

~/Downloads/elasticsearch-1.0.0.RC1 
$ bin/plugin -install mobz/elasticsearch-head

では早速起動してみましょう。

~/Downloads/elasticsearch-1.0.0.RC1 
$ bin/elasticsearch

ブラウザで http://localhost:9200/_plugin/head/ を開いてみてください。 http://mobz.github.io/elasticsearch-head/ で紹介されているような画面が出ればOKです。

livedoor グルメのデータセットのダウンロード

https://github.com/livedoor/datasets からldgourmet.tar.gzをダウンロードして下さい。

データの取り込み

さっそくデータをElasticsearchに登録してみましょう。

Elasticsearchのデータ構造

基本的に以下のような構造になっています。 先ほど起動したのは1nodeのクラスタです。

data model

MySQLで言うと、databaseindextabletypeと対応します。

インデックス・スキーマの定義

外部データをElasticsearch上でどのようなスキーマとして表現するか定義することをmappingと呼びます。

Elasticsearchはデータのスキーマを定義することなくデータを登録できますが、 あとあとそれが問題となってやり直すことがあったりします。 ここでは明示的にスキーマを与えてデータを登録します。

mapping

実際にmappingを行うにはJSONでmappingファイルを作成し、ElasticsearchにPOSTすればOKです。

# ldgourmetという名前のindexをmapping.jsonに書かれた内容に従って作成

curl -XPOST localhost:9200/ldgourmet -d @mapping.json

今回は以下の様なmappingにします。

mapping.json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "ngram_analyzer": {
          "tokenizer": "ngram_tokenizer"
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "nGram",
          "min_gram": "2",
          "max_gram": "3",
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      }
    }
  },
  "mappings": {
    "restaurant": {
      "properties": {
        "restaurant_id": {
          "type": "integer"
        },
        "name": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "name_alphabet": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "name_kana": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "address": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "description": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "purpose": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "category": {
          "type": "string",
          "analyzer": "whitespace"
        },
        "photo_count": {
          "type": "integer"
        },
        "menu_count": {
          "type": "integer"
        },
        "access_count": {
          "type": "integer"
        },
        "closed": {
          "type": "boolean"
        },
        "location": {
          "type": "geo_point",
          "store": "yes"
        }
      }
    },
    "rating": {
      "properties": {
        "rating_id": {
          "type": "integer"
        },
        "total": {
          "type": "integer"
        },
        "food": {
          "type": "integer"
        },
        "service": {
          "type": "integer"
        },
        "atmosphere": {
          "type": "integer"
        },
        "cost_performance": {
          "type": "integer"
        },
        "title": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "body": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "purpose": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        }
      }
    }
  }
}

ここで、integerstringなどのフィールドの型が出てきました。

core types で他にどんな型があるのか知ることが出来ます。

そして、analyzerという定義も出てきました。ここがいろいろ出てきて一番しんどいです…

文字列処理(analyzer)の定義

analyzerとは以下の様なものです。

文字列の分割方法を定義するtokenizerと、分割後の文字列の整形処理を定義するfilterによって構成されます。

例えば、tokenizerngramで文字列を分割し、

filterで大文字小文字を小文字に統一してしまうなどといった定義をすることが出来ます。

analyzerはいくつでも定義することが出来き、フィールドごとにどのanalyzerを利用するか決めることが出来ます。

例えば、店名は、「部分文字列でも検索できるようにしたい」という欲求があるので、ngram_analyzerを指定し、カテゴリはタグのようなもので、タグそれ自体を分割する必要はないのでwhitespace analyzerを利用するといった具合です。

上記のmapping.jsonにはこのanalyzer自体の定義や、どのフィールドでどのanalyzerを使うかの指定も含まれます。

今回は、簡単のためcategory以外は全部の文字列をngramで扱うことにします。

analyzerはelasticsearchに標準で登録されているものと、自分で組み合わせて定義するものがあります。

実際にデータを取り込む

データをJSONで表現し、POSTすることで登録していくことが出来ます。 正直ここは迷うことはない上に、実際には何らかの言語のライブラリを使って 呼び出すことになりますので詳細省きます。

クエリの組み立て

さていよいよデータが準備できました! クエリを投げてみましょう。

マッチングの方法

特別な設定をしない限り、登録したフィールド全てを検索可能です。 mappingを前提としつつもElasticsearchは多様な検索を実行することが出来ます。

queryとfilter

Elasticsearchの検索はqueryfilterによって行います。 それぞれ、query約40種類 filter約30種類あります。 フィールドの型やtypeの構成等によって適切なものを使えばかなり柔軟に検索できることがわかると思います。

ちなみに、両者の違いは

queryはスコアリングに影響する」

filterはスコアリングに影響しない、キャッシュされる」

ということを覚えておくと便利です。

全部を紹介するのは難しいので、ここでは1つか紹介するにとどめます。

「地名 料理ジャンル/店名」と言った形でレストランを探す

ここではsimple_query_stringを使ってみましょう。

$ curl -XGET 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "simple_query_string" : {
      "query": "目黒 とんき",
      "fields": ["_all"],
      "default_operator": "and"
    }
  }
}
'

Elasticsearchに対してクエリを発行するときは、localhost:9200/index名/type名/_searchに対して、JSONで書いたクエリを発行します。

simple_query_stringクエリは便利クエリの一つで、AND OR ()などの基本的演算子と、検索対象のフィールドなどを指定できます。 他にもクエリのカッコの数が一致しなかったり、エラーが出そうなときにエラーにするかしないかなどが選べたりします。

より複雑なクエリを発行したい場合は、query_stringクエリを使うとよいでしょう。

fieldsに登録した覚えのないフィールド_allが有ります。 これは、Elasticserchの特別なフィールドの一つです。 このフィールドには自動的に登録されたすべてのフィールドがコピーされています。 なので、住所や店名、ジャンルといったデータから横断的に検索したい場合はこのフィールドが便利です。

他にも任意のフィールドを指定し検索することも出来ます。

$ curl -XGET 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "simple_query_string" : {
      "query": "白金台 カフェ ボエム",
      "fields": ["name", "name_kana", "address"],
      "default_operator": "and"
    }
  }
}
'
{
  "hits": {
    "hits": [
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 10727,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "2,5,6,8",
          "_id": "6598",
          "name": "カフェ・ラ・ボエム",
          "name_alphabet": "cafe LA BOHEME",
          "name_kana": "かふぇらぼえむ",
          "category": "バー イタリアン ピザ パスタ カフェ",
          "address": "港区白金台4-19-17",
          "location": [
            139.43,
            35.38
          ],
          "description": "白金台駅から徒歩約5分  (料理・営業時間・カード・禁煙欄を更新しました。 東京グルメ ユーザー自治班 2007/12/9)"
        },
        "_score": 3.6026812,
        "_id": "6598",
        "_type": "restaurant",
        "_index": "ldgourmet"
      }
    ],
    "max_score": 3.6026812,
    "total": 1
  },
  "_shards": {
    "failed": 0,
    "successful": 5,
    "total": 5
  },
  "timed_out": false,
  "took": 5
}

こんなかんじで何十種類もクエリがあるので、面倒なことをほとんどElasticsearchに押し付けられそうですね。便利!

「メニュー名からレストランが探せる」ようにするのはpart 2以降で紹介する機能で実現しようと思いますので今回は割愛します。

検索結果の並び替え

マッチングの方法を理解し、とりあえず検索結果が帰ってくるところまで来ました。 ところで、検索アプリケーションでは1ページ目が重要であると言われています。 登録されたデータの件数が多くなるほど検索結果の並び替えは重要になってきます。

Elasticsearchでは検索結果の並び替えに使える仕組みが幾つか準備されていて、 かなり柔軟に検索結果の提示順序をコントロールできます。

PV順・口コミが多い順・評価が高い順など検索結果をソート

Elasticsearchで並び順をコントロールする方法は大きく2つあります。 ひとつは、クエリとドキュメントの関連性を一切考慮せず、特定のフィールドの値でソートする方法です。

$ curl -XGET 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "simple_query_string" : {
      "query": "東京 ラーメン",
      "fields": ["name", "name_kana", "address"],
      "default_operator": "and"
    }
  },
  "sort" : [{ "access_count" : {"order" : "desc", "missing" : "_last"}}]
}
'

このクエリでは、東京のラーメンのお店で一番PVが多かったお店を検索します。 結果には各ドキュメントの値がsortという項目で報告されます。クエリで指定したフィールドの値が表示されます。

{
  "hits": {
    "hits": [
      {
        "sort": [
          35328
        ],
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 35328,
          "menu_count": 0,
          "photo_count": 19,
          "purpose": "1,4",
          "_id": "304902",
          "name": "ラーメン二郎",
          "name_alphabet": null,
          "name_kana": "らーめんじろう",
          "category": "ラーメン全般 豚骨ラーメン 醤油ラーメン  ",
          "address": "西東京市谷戸町3-27-24ひばりヶ丘プラザ1F",
          "location": [
            139.32,
            35.44
          ],
          "description": "西武池袋線 ひばりヶ丘駅南口下車 徒歩3分  2006年6月11日(日)営業開始    【註】電話番号非公開です。  (from 東京グルメ 2006/06/11)    店名修正しました(サポート 2006/06/14)  "
        },
        "_score": null,
        "_id": "304902",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "sort": [
          13432
        ],
        "_source": {
          "type": "restaurant",
          "closed": 1,
          "access_count": 13432,
          "menu_count": 0,
          "photo_count": 3,
          "purpose": "4",
          "_id": "15078",
          "name": "東京ラーメン",
          "name_alphabet": null,
          "name_kana": "とうきょうらーめん",
          "category": "醤油ラーメン 塩ラーメン 味噌ラーメン 餃子 ",
          "address": "葛飾区亀有5-49-6",
          "location": [
            139.51,
            35.45
          ],
          "description": "亀有駅から3分。北口を出て駅前ロータリーをまっすぐ行き、交差点で斜め右へ進み、街道に出たら、黄色い看板が目に入る。  古っぽいけど、入口は自動ドア。入って左側にセルフの給水器あり。  カウンターのみ12席。"
        },
        "_score": null,
        "_id": "15078",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      ...],
    "max_score": null,
    "total": 46
  },
  "_shards": {
    "failed": 0,
    "successful": 5,
    "total": 5
  },
  "timed_out": false,
  "took": 5
}

より複雑な並び替え

もう一つは、スコアリングに基づく並び替えです。 検索結果の1ページ目をコントロールしようとした場合、 「PVが多くて」「レビューが付いてて」「駅から近くて」「現在地からも近く」「値段も手頃な」お店がいい。 など、実際の検索ユーザーの欲求にそって検索結果を並び替え、1ページ目にユーザーに響く結果を表示しなくてはなりません。

それを単一のフィールドのソートで表現するのはかなり難しいです。 そんなときはスコアリングに基づく並び替えが便利です。 Elasticsearchにおいてはfunction score queryというクエリが便利です。

余りにもできることが多いのでベストプラクティスが何かはちょっとわからないんですが一例をご紹介

ドキュメントのスコアを自分で定義する

$ curl -XGET -o result.json 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "function_score" : {
      "query" : {
        "simple_query_string" : {
          "query": "ラーメン",
          "fields": ["name", "name_kana", "address"],
          "default_operator": "and"
        }
      },
      "boost_mode": "replace",
      "script_score" : {
        "script" : "doc[\"access_count\"].value * doc[\"photo_count\"].value"
      }
    }
  }
}
'

このクエリでは、先ほどのsimple_query_stringでマッチさせたドキュメント群をaccess_count * photo_countというスコアを定義し、その値が大きい順にソートして返すようにしています。 scriptはjavascriptやmvel(デフォルト)が使えます。また、掛け算だけでなく多様な関数が準備されていて、 例えば、doc["location"].distance(lat, lon)とすると、任意の地点からの距離を自分のスコアの中で扱うことが出来ます。

望ましい条件を列挙し、どれかにヒットすれば加点

 curl -o result.json 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "function_score" : {
      "query" : {
        "simple_query_string" : {
          "query": "ハンバーガー",
          "fields": ["name", "name_kana", "address"],
          "default_operator": "and"
        }
      },
      "score_mode": "first",
      "boost_mode": "multiply",
      "functions" : [
        {
          "filter" : { "range" : { "access_count" : { "from" : 500 }}},
          "boost_factor" : 10
        },
        {
          "filter" : { "range" : { "photo_count" : { "from" : 3 }}},
          "boost_factor" : 10
        },
        {
          "filter" : { "geo_distance" : { "distance" : "2km", "location" : [139.43 , 35.38] }},
          "boost_factor" : 100
        }
      ]
    }
  }
}
'

この例では、白金台から2kmの距離にあればスコアが100倍、access countが500以上か、photo_countが3以上であればそれぞれスコアが10倍になります。 これによって、1ページ目は至近の店があればまずそれを。その次に有名店を表示できます。 すべての条件を満たす店舗があればかなり強い加点がされますが、それぞれの条件にマッチしないレストランもきちんと表示されます。

{
  "hits": {
    "hits": [
      {
        "_source": {
          "type": "restaurant",
          "closed": 1,
          "access_count": 687,
          "menu_count": 0,
          "photo_count": 1,
          "purpose": null,
          "_id": "10039",
          "name": "ザ・ハンバーガーイン",
          "name_alphabet": null,
          "name_kana": "ざはんばーがーいん",
          "category": "カフェ ハンバーガー   ",
          "address": "港区六本木3-15-22",
          "location": [
            139.44,
            35.39
          ],
          "description": "2005年10月25日で閉店されたそうです。  (from 東京グルメ 2006/11/09)"
        },
        "_score": 2232.2642,
        "_id": "10039",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 1424,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "1,2,4",
          "_id": "328829",
          "name": "ザ・ハンバーガー・イン 一番東京ハンバーガー",
          "name_alphabet": "the Hamburger Inn",
          "name_kana": "ざはんばーがーいんいちばんとうきょうはんばーがー",
          "category": "ハンバーガー    ",
          "address": "港区西麻布2-25-23バルビゾン27 3F",
          "location": [
            139.43,
            35.39
          ],
          "description": null
        },
        "_score": 242.6197,
        "_id": "328829",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 2846,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "4",
          "_id": "317834",
          "name": "ハンバーガーイン",
          "name_alphabet": null,
          "name_kana": "はんばーがーいん",
          "category": "ハンバーガー    ",
          "address": "港区西麻布1-11-6メンテルス六本木1F",
          "location": [
            139.43,
            35.39
          ],
          "description": null
        },
        "_score": 228.74405,
        "_id": "317834",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 1642,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "1,2,3,4",
          "_id": "325617",
          "name": "一番東京ハンバーガー",
          "name_alphabet": null,
          "name_kana": "いちばんとうきょうはんばーがー",
          "category": "ハンバーガー サンドイッチ パン  ",
          "address": "渋谷区神宮前5-29-10",
          "location": [
            139.42,
            35.39
          ],
          "description": "店舗一時休止中"
        },
        "_score": 200.72649,
        "_id": "325617",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 1189,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "4",
          "_id": "357191",
          "name": "ハンバーガー マクドナルド",
          "name_alphabet": null,
          "name_kana": "はんばーがーまくどなるどうぃんぐたかなわてん",
          "category": "ハンバーガー    ",
          "address": "港区高輪4-10-18京急ショッピングプラザ ウィング高輪 WEST 1F",
          "location": [
            139.44,
            35.37
          ],
          "description": null
        },
        "_score": 200.72649,
        "_id": "357191",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 3287,
          "menu_count": 9,
          "photo_count": 5,
          "purpose": "1,3,4",
          "_id": "17019",
          "name": "エリックスハンバーガーショップ",
          "name_alphabet": "ericshamburgershop",
          "name_kana": "えりっくすはんばーがーしょっぷ",
          "category": "ハンバーガー その他軽食   ",
          "address": "世田谷区若林1-17-5メゾン川奈1F",
          "location": [
            139.39,
            35.38
          ],
          "description": "17:00〜19:00まで日替わりの1品が半額になるハッピーアワーあり。"
        },
        "_score": 171.55804,
        "_id": "17019",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 402,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "1,3,4",
          "_id": "396955",
          "name": "今屋のハンバーガー",
          "name_alphabet": null,
          "name_kana": "いまやのはんばーがー",
          "category": "ハンバーガー    ",
          "address": "福岡市中央区西公園13-13 西公園展望台",
          "location": [
            130.22,
            33.35
          ],
          "description": null
        },
        "_score": 2.358243,
        "_id": "396955",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 553,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": null,
          "_id": "305259",
          "name": "ハンバーガージュリア",
          "name_alphabet": null,
          "name_kana": "はんばーがーじゅりあ",
          "category": "ハンバーガー    ",
          "address": "唐津市二タ子1丁目16-744",
          "location": [
            129.57,
            33.27
          ],
          "description": null
        },
        "_score": 2.063463,
        "_id": "305259",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 383,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": null,
          "_id": "354167",
          "name": "ハンバーガー&シェイク T's★Diner ",
          "name_alphabet": null,
          "name_kana": "はんばーがーあんどしぇいくてぃーずすたーだいなーほんてん",
          "category": "アメリカ料理 バー その他軽食  ",
          "address": "高槻市城北町2-11-5南園ビル1F",
          "location": [
            135.37,
            34.5
          ],
          "description": null
        },
        "_score": 1.7686824,
        "_id": "354167",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 624,
          "menu_count": 0,
          "photo_count": 3,
          "purpose": "3",
          "_id": "415300",
          "name": "焼肉屋さんのハンバーガー あいかわ",
          "name_alphabet": null,
          "name_kana": "やきにくやさんのはんばーがー あいかわ",
          "category": "ハンバーガー    ",
          "address": "佐世保市有福町297-20",
          "location": [
            129.47,
            33.07
          ],
          "description": "16席・駐車場10台"
        },
        "_score": 1.7205129,
        "_id": "415300",
        "_type": "restaurant",
        "_index": "ldgourmet"
      }
    ],
    "max_score": 2232.2642,
    "total": 12
  },
  "_shards": {
    "failed": 0,
    "successful": 5,
    "total": 5
  },
  "timed_out": false,
  "took": 3
}

より高度な内容

データをElasticsearch上に登録し、簡単な検索を行うまでの流れを紹介しました。 以降は予定。飽きたら終了です。1.0 stableが出るまでに書きたい。

Elasticsearch勉強会#1で発表してきました

ニコニコデータセットっていう800万動画のメタデータと25億件くらいのコメントをJSONで提供しているデータセットがあり、それをindexingする中でちょっと大きめのデータをどう扱うかについて発表しました。

ちょっと前っていうか8月(!)にやりました http://atnd.org/event/E0018616
勉強会の様子は主催のjohtaniさんのブログなどでわかります。

第2回の計画が進んでいるらしいっす。kibana(http://www.elasticsearch.org/overview/kibana/)についての発表もあるらしいっす。

Solrとelasticsearchについて発表してきました @ Solr勉強会

Solr勉強会でElasticsearchを発表(ゴリ押し)してきました
資料: solrとelasticsearchの比較 // Speaker Deck

サンプルにつかったRailsのコード:https://github.com/penguinco/ld_gourmet_search
文章の追加から、検索まで書いたのは100行無いと思いますのでさくっと試せます。

勉強会の内容については以下の方々が詳しくまとめてくれています

今回思ったのはkuromojiいいですねっていうことと、
jettyのマルチテナントを活用した開発環境の紹介は非常に勉強になりました。こういうのがないと開発者が多い場合に大変ですよね。
あとは、懇親会でいろいろな方がSolrをいろんな用途に使っている事がわかって改めてこれはすごいなと思いました。

Solrを使ったレシピ検索のプロトタイピング

モーショノロジー#1で発表してきました http://atnd.org/events/23608
資料: http://www.slideshare.net/penguinana/solr-11287004


他の発表の資料は順次以下から見れるようになるようです。

クックパッドではchankoという仕組みを使って本番で複数のバージョンを安全にデプロイできるようにしています。
これによって1つの場所を複数のチームが同時に改善したりできるようになりました。
chankoについては以下をお読みください。

「Solr@Cookpad」- Solr勉強会で発表してきました

9月12日にECナビにて第6回Solr勉強会がありました。事例紹介に「Solr@cookpad」という発表で参加させて頂いた時の資料を公開しておきます。
みなさんの発表が面白くてすごく勉強になりました。勉強会の様子はこちらに詳細に書かれています。 http://johtani.jugem.jp/?eid=26

スライド:Solr@cookpad

分かりにくいスライドかも…お気づきの点ありましたらコメント欄などでお知らせください。

ところで

また、スライドでも少し紹介したtwitter検索(yats)ですが、15億docのセットを150msで7000万req/月で返していて、Solrのベンチマークにも役立っておったのですがAPIの規約にひっかかっている(第三者がオリジナルのAPIを無許諾で提供してはならない)部分があって、先月更新を止めました。今まで使ってくださっていたみなさんありがとうございました...!

yatsが回収した2011年のお正月ダンプ

久しぶりにTwitter日本語圏のダンプを公開したいと思います。
2010年12月31日から2011年1月1日のつぶやきのMySQLダンプです(load dataで取り込むタイプ)

yatsの収集対象は

  • 公開ユーザー状態でつぶやかれたもののうち
    • 過去3週間以内につぶやいたユーザーからのもの、
    • 累積400〜つぶやきの日本語ユーザーからのもの

です。ベストエフォートです。
streaming apiで流れてくるつぶやきもだいたい記録しています。

スキーマ

CREATE TABLE `buffer_2011` (
  `id_autoinc` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `id` bigint(20) unsigned NOT NULL,
  `user` varchar(20) NOT NULL,
  `content` text NOT NULL,
  `source` text,
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id_autoinc`),
  UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8

記録されている内容:

CREATE TABLE `buffer_yyyymmdd` (
  `id_autoinc` yats内部ID,
  `id` ステータスID,
  `user` スクリーンネーム,
  `content` 本文,
  `source` 投稿に用いたクライアント情報,
  `time` つぶやかれた時刻,
  PRIMARY KEY (`id_autoinc`),
  UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8

データ:

http://api.yats-data.com/data/20101231-201101.bz2

使い方:

$ wget http://api.yats-data.com/data/20101231-201101.bz2
$ bunzip2 20101231-201101.bz2
$ mysql -u hoge
mysql > use hogebase;
mysql > CREATE TABLE `buffer_2011` (
  `id_autoinc` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `id` bigint(20) unsigned NOT NULL,
  `user` varchar(20) NOT NULL,
  `content` text NOT NULL,
  `source` text,
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id_autoinc`),
  UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
mysql > LOAD DATA INFILE 'buffer_20101231-201101' INTO TABLE buffer_2011;
mysql> select * from buffer_2011 where user like 'fuba' limit 1\G
...

その他の使い方:

MySQLダンプですがスキーマは必要以上にインデックスを張っていません。
用途に応じて張ってみてください。

MySQLに入れずに使うことも出来ます。
bunzip2後のファイルは以下のようになっています。

$ bzcat 20101231-201101.bz2 |head
id_autoinc	id    user    content    source    time
id_autoinc	id    user    content    source    time
id_autoinc	id    user    content    source    time

本文などに改行が入る場合など例外もありますのでちょっと処理が面倒かもですが、
違うデータベースに入れてもいいし、そのまま使っても良いと思います。