homeASCIIcasts

278: Sunspotで全文検索 

(view original Railscast)

Other translations: En Es Fr

Other formats:

Written by Naomi Fujimoto

SunspotはRubyアプリケーションに全文検索機能を追加するためのソリューションです。バックグラウンドでSolrを利用し、多くの優れた機能を提供します。今回のエピソードでは、過去のエピソードで使用したブログアプリケーションを例にして、Sunspotを使ってRailsアプリケーションに全文検索機能を追加します。

ブログアプリケーション

このアプリケーションは複数の記事を表示するページを持っており、それらを横断して検索を行う機能を実装していきます。これをSQLを使って行うのは困難になりやすく、多くの場合最善のアプローチとは言えません。Sunspotのような専用の全文検索ツールの方が、この機能を実装するにはずっと適した方法です。

Sunspotのインストール

Sunspotはgem形式で提供されるので、通常のようにGemfileに追記してbundleを実行することでインストールされます。

/Gemfile

source 'http://rubygems.org'

gem 'rails', '3.0.9'
gem 'sqlite3'
gem 'nifty-generators'
gem 'sunspot_rails'

gemとその依存関係がインストールされたら、Sunspotの設定ファイルを生成するために次のコマンドを実行します。

$ rails g sunspot_rails:install

このコマンドによって/config/sunspot.ymlにYMLファイルが作成されます。このファイルのデフォルト設定に修正を加える必要はありません。

Sunspotはgemの内部にSolrを組み込んでいるので、別途インストールする必要はありません。つまりインストールした素の状態で機能するので、開発時にはとても簡単に作業できます。起動させるために次のコマンドを実行します。

$ rake sunspot:solr:start

OS X Lionを利用していてJavaランタイムをまだインストールしていない場合、このコマンドを実行したときにランタイムをインストールするようプロンプトが出るでしょう。同じく非推奨の警告(deprecation warning)が表示されるかも知れませんが、これは無視しても問題ありません。このコマンドにより、詳細設定のための追加の設定ファイルが作成されます。それらについてはここでは触れませんが、ドキュメンテーションにこれらのファイルの修正方法の詳細情報があります。

Sunspotの利用

Sunspotをインストールできたので、Articleモデルから利用します。全文検索機能を追加するためにはsearchableメソッドを使用します。

/app/models/article.rb

class Article < ActiveRecord::Base
  attr_accessible :name, :content, :published_at
  has_many :comments
  
  searchable do
    text :name, :content
  end
end

このメソッドはブロックをとり、その中で検索したい属性を定義してSunspotにどのデータに索引を設定すればいいかを知らせます。textメソッドを使用して、全文検索を実行する対象とする属性を定義します。今回の記事の検索のためには、nameとcontentのフィールドを指定します。

Sunspotは自動的に新しいレコードに対して索引を作成しますが、既存のレコードに対してはそれを行いません。Sunspotに対して既存のレコードに索引を作成し直させる場合は、次のコマンドを実行します。

$ rake sunspot:reindex

これですべての記事がSolrのデータベースに入って検索できる状態になったので、indexページの一番上に検索フィールドを追加します。

/app/views/articles/index.html.erb

<% title "Articles" %>

<%= form_tag articles_path, :method => :get do %>
  <p>
    <%= text_field_tag :search, params[:search] %>
    <%= submit_tag "Search", :name => nil %>
<% end %>
<!-- rest of view omitted -->

このフォームはGETを用いてindexアクションに送信されるので、入力された検索パラメータがすべて検索文字列に追加されます。では次にコントローラを修正して、searchパラメータによって記事を取得するようにします。Sunspotで検索を行うには、モデルでsearchを呼び出してブロックを渡します。ブロック内では複雑な検索を処理するためにいろいろなメソッドを呼び出すことができます。ここではfulltextメソッドを使用して、それに対してフォームからの検索パラメータを渡します。最後にこの結果のすべてを@searchに割り当てます。これの結果を呼び出して、一致した記事のリストを取得します。

/app/controllers/articles_controller.rb

def index
  @search = Article.search do
    fulltext params[:search]
  end
  @articles = @search.results
end

ここまでの部分をテストするために記事のページを再読み込みしてキーワードで検索を行います。すると一致する記事のリストが返されます。

フィルタリングされた記事のリスト

検索は、それが記事のnamecontentかに関わらず、検索語を含んだ記事のリストを返します。

Articleモデルのsearchableブロックでは、これ以外にも多くのことが可能です。例えばboostを使って、タイトルが一致する記事の方が本文が一致する記事よりも重要性が高いというように、結果に重み付けをつけることができます。

/app/models/article.rb

class Article < ActiveRecord::Base
  attr_accessible :name, :content, :published_at
  has_many :comments
  
  searchable do
    text :name, :boost => 5
    text :content
  end
end

これは、結果をrelevance(適合度)で並べたい場合に重要です。今回の場合はタイトルに検索語を含む記事が本文に検索語を含む記事よりも検索結果リストの上位に現れます。

searchableブロックで指定する属性はデータベースの実際の列名である必要はなく、モデル内で定義するメソッドを使用することもできます。記事が発行された月と年を含む文字列を返すpublish_monthという列を作成し、データベースの列と同じようにそのメソッドに対して検索を行います。

/app/models/article.rb

class Article < ActiveRecord::Base
  attr_accessible :name, :content, :published_at
  has_many :comments
  
  searchable do
    text :name, :boost => 5
    text :content, :publish_month
  end
  
  def publish_month
    published_at.strftime("%B %Y")
  end
  
end

この新しい列で検索できるようにrake sunspot:reindexを再度実行してレコードに索引を設定し直す必要がありますが、そうすることで月名で記事を検索することができるようになります。

記事に発行月でフィルタがかけられる

メソッドを作成する代わりに、ブロックを渡してブロックの戻り値に対して検索を行うことができます。記事には多くのコメントがついているので、ブロックを用いてコメントの内容を検索する機能を追加します。

/app/models/article.rb

class Article < ActiveRecord::Base
  attr_accessible :name, :content, :published_at
  has_many :comments
  
  searchable do
    text :name, :boost => 5
    text :content, :publish_month
    text :comments do
      comments.map(&:content)
    end
  end
  
  def publish_month
    published_at.strftime("%B %Y")
  end
  
end

ブロック内のコンテキストはArticleのインスタンスなので、 その中で記事に対するコメントを取得して各コメントの本文にマッピングすることができます。これは配列を返しますが、Sunspotはコメントのすべてに索引を作成し検索可能にします。

属性に対する検索

単純な全文検索以上の検索機能を、ある特定の属性に対して追加したい場合はどうすればいいでしょうか。このためには、検索対象にしたい属性のタイプ(文字列か整数か浮動小数かタイムスタンプか)を渡します。検索フィールドにpublished_at属性を追加するので、timeメソッドを利用します。

/app/models/article.rb

class Article < ActiveRecord::Base
  attr_accessible :name, :content, :published_at
  has_many :comments
  
  searchable do
    text :name, :boost => 5
    text :content, :publish_month
    text :comments do
      comments.map(&:content)
    end
    time :published_at
  end
  
  def publish_month
    published_at.strftime("%B %Y")
  end
  
end

ArticlesControllerでこれを利用して、検索対象をpublished_atの日付が現在の時刻よりも前のものに制限します。そのためにwithメソッドを利用します。

/app/controllers/articles_controller.rb

def index
  @search = Article.search do
    fulltext params[:search]
    with(:published_at).less_than(Time.zone.now)
  end
  @articles = @search.results
end

これを設定することで、発行されていない記事は検索されなくなります。渡すことができる属性に関する優れたドキュメンテーションがSunspotのwikiページにあります。

ファセット検索

ファセット検索を使用すると、ある属性、例えば記事が発行された月で検索結果をフィルタリングできます。例えば、発行された記事が存在する月のリンクの一覧リストを追加したいとしましょう。リンクをクリックすると、記事のリストにフィルタがかけられて、その月に発行された記事だけを表示します。

これを行うためにまず、publish_monthメソッドのsearchableブロックにstring属性を追加します。

/app/models/article.rb

class Article < ActiveRecord::Base
  attr_accessible :name, :content, :published_at
  has_many :comments
  
  searchable do
    text :name, :boost => 5
    text :content, :publish_month
    text :comments do
      comments.map(&:content)
    end
    time :published_at
    string :publish_month
  end
  
  def publish_month
    published_at.strftime("%B %Y")
  end
  
end

ArticlesControllersearchブロックでfacetを呼び出すことでこれをファセットに変えることができます。

/app/controllers/articles_controller.rb

def index
  @search = Article.search do
    fulltext params[:search]
    with(:published_at).less_than(Time.zone.now)
    facet(:publish_month)
  end
  @articles = @search.results
end

以下のコードを検索ボックスと記事の一覧の間に追加することで、indexページにこれらのファセットを表示できます。

/app/views/articles/index.html.erb

<div id="facets">
  <h3>Published</h3>
  <ul>
    <% for row in @search.facet(:publish_month).rows %>
      <li>
        <% if params[:month].blank? %>
          <%= link_to row.value, :month => row.value %> (<%= row.count %>)
        <% else %>
          <strong><%= row.value %></strong> (<%= link_to "remove", :month => nil %>)
        <% end %>
      </li>
    <% end %>
  </ul>
</div>

このコードで、publish_monthの各ファセット項目をループして表示しています。@searchオブジェクトで.facetを呼び出して、ファセットの並び順として指定する属性を渡します。今回の例で言うと:publish_month、そしてそれに対して.rowsを呼び出すと、その属性でのファセットオプションを返します。

row.valueを呼び出すと、その属性の値、例えば「January 2011」を返します。またrow.countを呼び出せば、その値に一致する記事数が返されます。もし検索文字列にmonthパラメータがあったら、その値を、パラメータを削除するための「remove(削除)」リンクと共に表示します。これによって、与えられたファセットを選択して、それをmonthパラメータを介して渡すという便利な機能を利用できます。

ここでページを読み込み直すと、レコードの索引を設定し直したので、パネルにファセットのリストが月名とその月に発行された記事数という形で表示されます。月を選択すると、検索文字列にmonthパラメータとして表示されますが、記事はフィルタリングされません。これを修正するために、コントローラのsearchにもう一つwithパラメータを追加して、monthパラメータが存在したら月でフィルタリングするようにします。

/app/controllers/articles_controller.rb

def index
  @search = Article.search do
    fulltext params[:search]
    with(:published_at).less_than(Time.zone.now)
    facet(:publish_month)
    with(:publish_month, params[:month]) ↵ 
      if params[:month].present?
  end
  @articles = @search.results
end

月を選択すると、その月に発行された記事で正しくフィルタリングされたリストが表示されます。

ファセットを用いて月でフィルタリングされた記事

「remove」リンクをクリックすると全リストが返されます。検索結果もこれに合わせて動作します。検索語を入力すると、一致する記事がある月がリスト表示されます。

フィルタリングされた記事に一致する月がサイドバーに表示される

ファセットは検索と合わせて利用できる優れた機能です。

Sunspotに関する今回のエピソードは以上です。SunspotはRailsアプリケーションに全文検索機能を追加する優れた方法で、ここで触れることができなかった多くの追加機能を持っています。さらに情報を得るために、忘れずにwikiを参照してください。