読者です 読者をやめる 読者になる 読者になる

「関連する○○」機能を手軽に実現できる。そう、Elasticsearch ならね。

セコン (id:secondlife, @hotchpotch) です。ウェブサービスにはよく「このエントリーに関連するブログ記事」や「このレシピに関連するレシピ」という機能が実現されてますよね。さて、この機能はどのように実現すれば良いでしょうか。例えば tf-idf で単語の類似度を求め…といった実装が必要になり、いささか面倒です。

しかしながら Elasticsearch や Solr *1を使うと手軽に実現できます。例えば、クックパッドニュースの記事では Solr を使い「この記事を読んだ人におすすめ」の機能に、最近クックパッドにジョインしたインドネシアの会社の DapurMasak では Elasticsearch を使い「Resep serupa(関連レシピ)」の機能で利用しています。

クックパッドニュースでのこの記事を読んだ人におすすめ

DapurMasak での関連レシピ

使い方は非常に簡単で、Elasticsearch にドキュメントを登録して検索できるような状態にしておけば、あとは Elasticsearch の more like this の API を叩くだけで、関連する○○を実現できます。

はてなブログの関連記事を表示する

先日、はてなブログに記事の export 機能が実装されたので、この export されたファイルを Elasticsearch に読み込んで、似ている記事を表示してみます。Elasticsearch は http の RESTful な API が用意されてますが、今回は抽象化された ruby のライブラリ、elasticsearch-model *2 を使います。

# entry.rb

require 'sqlite3'
require 'active_record'

ActiveRecord::Base.establish_connection(
  adapter: 'sqlite3',
  database: 'hatena-blog-entry.sqlite'
)

unless ActiveRecord::Base.connection.table_exists? 'entries'
  ActiveRecord::Schema.define(version: 1) {
    create_table(:entries) {|t|
      t.string :title
      t.text :body
    }
  }
end

require 'elasticsearch/model'

class Entry < ActiveRecord::Base
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks

  mapping do
    indexes :id, type: 'string', index: 'not_analyzed'
    indexes :title, type: 'string', analyzer: 'kuromoji'
    indexes :body, type: 'string', analyzer: 'kuromoji'
  end

  def more_like_this(mlt_fields: 'title,body', min_doc_freq: 0, min_term_freq: 0, min_word_len: 0, search_size: 10, body: {})
    target_id = self.id
    es = __elasticsearch__
    searcher = Class.new do
      define_method(:execute!) do
        es.client.mlt(
          search_size: search_size,
          index: es.index_name,
          type: es.document_type,
          body: body,
          id: target_id,
          mlt_fields: mlt_fields,
          min_doc_freq: min_doc_freq,
          min_term_freq: min_term_freq,
          min_word_len: min_word_len
        )
      end
    end.new
    Elasticsearch::Model::Response::Response.new(self.class, searcher)
  end
end

このように、ActiveRecord のモデルに Elasticsearch::Model を include し、more_like_this メソッドを生やします。また日本語の形態素解析のため、アナライザには kuromoji を指定してます。

続いて、はてなブログの export した記事を読み込み、Elasticsearch に保存します。export されたファイルが MovableType 形式ェ…。

# hatena-blog-importer.rb
require './entry'
require 'time'
require 'nokogiri'

source = ARGV[0]
abort "usage: bundle exec ruby #{$0} hatenablog.export.txt" unless source

entries = File.read(source).scan(/
              TITLE:\s(.*?)\n.*?
              STATUS:\s(.*?)\n.*?
              DATE:\s(.*?)\n.*?
              BODY:\n(.*?)
              ----\n
            /mx)

entries.each do |entry|
  title, status, id, body = *entry
  if status == "Publish"
    Entry.where(id: id.gsub(/\D/, '')).first_or_create(title: title, body: Nokogiri::HTML(body).text)
  end
end

export してきたデータを流し込みます。

$ bundle exec ruby hatena-blog-importer.rb techlife.cookpad.com.export.txt

これで Elasticsearch にドキュメントが登録された状態になります。Elasticsearch を使った検索を試してみましょう。

$ bundle exec pry
> require './entry'
=> true
> Entry.search('ActiveRecord').records.map(&:title)
=> ["クックパッドにおける最近のActiveRecord運用事情",
      "Rails アプリケーションのパフォーマンスについて RubyKaigi 2013 で発表しました",
      "クックパッドとマイクロサービス"]

うまく動いてますね。続いて「類似する記事」を more_like_this メソッドを叩いて表示してみましょう。

> entry = Entry.where(title: '5分でわかるBatteryHistorianによるAndroidアプリの解析方法').first
> entry.more_like_this(search_size: 3).records.map(&:title)
=> ["Android Publisherによるストア管理の自動化",
      "Amazon Cognitoについて - AWSが提案するモバイル時代のアカウント管理",
      "ドイツでIoTについて考えた"]

ちゃんと5分でわかるBatteryHistorianによるAndroidアプリの解析方法に近しい記事がマッチしてますね。スコアも入ってます。

> entry.more_like_this(search_size: 3).map {|r| [r.title, r._score]}
=> [["Android Publisherによるストア管理の自動化", 0.7496942],
      ["Amazon Cognitoについて - AWSが提案するモバイル時代のアカウント管理", 0.58310145],
      ["ドイツでIoTについて考えた", 0.45574456]]

なお、これらのサンプルコードは、以下に push してあります。

まとめ

サイトに何らかの検索機能を付与する場合、今は Elasticsearch や Solr を検索エンジンとして使うことが多いと思います。そんなときは、すでにドキュメントを検索エンジンに登録してるでしょうから、あっという間に「関連する○○」機能を実現できるでしょう。

他にも「もしかして」を実現する phrase suggester API や、autocomplete を実現する completion suggester API など、Elasticsearch や Solr *3 には便利な API がそろってたり*4します。

実際にユーザに対してベストな解決策*5では無い場合もありますが、プロトタイピングで使ったり、実際に「関連する○○」を用意した場合、どれぐらい便利に使われるのか確かめるMVPとしては非常に便利ですね。

*1:タイトルでは触れてませんでしたが、Solr でも簡単です

*2:elasticsearch-model は elasticsearch 社のオフィシャルライブラリとして提供されてますが、オフィシャルになる以前は tire という名前で、オフィシャルになった後 tire の方は retire という名前に変更されました…リタイヤ…

*3:両方とも検索のエンジンのコアには Lucene を使ってるので、Lucene に入った機能はそのうち利用できるようになる可能性が高いです

*4:なおそれっぽい記事を書いてますが、私自身弊社検索野郎の @PENGUINANA_ に教えて貰うまで、more like this API を知りませんでした…。どんなことが出来るのか、しっかりと知ることも重要ですね。

*5:例えば「もしかして」機能は文字の編集距離を求めて算出するより、検索ログを追いかけてどうユーザが正解の単語を入力したかから学習したほうが基本的には精度が高い、など

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/