ククログ

株式会社クリアコード > ククログ > Ruby on RailsでGroongaを使って日本語全文検索を実現する方法

Ruby on RailsでGroongaを使って日本語全文検索を実現する方法

MySQL・PostgreSQL・SQLite3の標準機能では日本語テキストの全文検索に難があります。MySQL・PostgreSQLに高速・高機能な日本語全文検索機能を追加するMroongaPGroongaというプラグインがあります。これらを導入することによりSQLで高速・高機能な日本語全文検索機能を実現できます。詳細は以下を参照してください。

また、データはMySQL・PostgreSQL・SQLite3に保存して日本語全文検索機能は別途全文検索エンジンGroongaサーバーに任せるという方法もあります。詳細は以下を参照してください。

ここではMySQL・PostgreSQL・SQLite3を一切使わずに、データもGroongaに保存して日本語全文検索を実現する方法を紹介します。

Groongaにデータも保存して使うメリットは以下の通りです。

  • Groongaのフル機能を使える

  • 検索とデータの取得を一度にできるので速い

一方、デメリットは以下の通りです。

  • MySQL・PostgreSQL・SQLite3を使うだけの場合と比べて学習コストが高い(最初にGroongaのことを覚えないといけない)

  • マスターデータを別途安全に管理する必要がある(Groongaにはトランザクション・クラッシュリカバリー機能がないため)

このデメリットのうち学習コストの方をできるだけ抑えつつGroongaを使えるようにするためのライブラリーがあります。それがgroonga-client-modelです。groonga-client-modelがGroongaを使う部分の多くをフォローしてくれるため利用者は学習コストを抑えたままGroongaを使って高速な日本語全文検索システムを実現できます。

この記事ではRuby on Railsで作ったアプリケーションからGroongaを使って日本語全文検索機能を実現する方法を説明します。実際にドキュメント検索システムを開発する手順を示すことで説明します。ここではCentOS 7を用いますが、他の環境でも同様の手順で実現できます。

Groongaのインストール

まずGroongaをインストールします。CentOS 7以外の場合にどうすればよいかはGroongaのインストールドキュメントを参照してください。

% sudo -H yum install -y http://packages.groonga.org/centos/groonga-release-1.2.0-1.noarch.rpm
% sudo -H yum install -y groonga-httpd
% sudo -H systemctl start groonga-httpd

Rubyのインストール

CentOS 7にはRuby 2.0のパッケージがありますが、Ruby on Rails 5.0.1はRuby 2.2以降が必要なのでrbenvとruby-buildでRuby 2.4をインストールします。

% sudo -H yum install -y git
% git clone https://github.com/sstephenson/rbenv.git ~/.rbenv
% git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build
% echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
% echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
% exec ${SHELL} --login
% sudo -H yum install -y gcc make patch openssl-devel readline-devel zlib-devel
% rbenv install 2.4.0
% rbenv global 2.4.0

Ruby on Railsのインストール

Ruby on Railsをインストールします。

% sudo -H yum install -y sqlite-devel nodejs
% gem install rails

ドキュメント検索システムの開発

いよいよ日本語全文検索機能を持ったドキュメント検索システムを開発します。

まずはrails newで雛形を作ります。Active Recordを一切使わないので--skip-active-recordを指定しています。

% rails new document_search --skip-active-record
% cd document_search

Gemfileにgroonga-client-model gemを追加します。

gem 'groonga-client-model'

groonga-client-model gemをインストールします。

% bundle install

検索対象のドキュメントを格納するテーブルとそれを高速に検索するためのインデックスを定義します。定義はdb/schema.grnにGroongaのコマンドの書式で書きます。参考になるドキュメントは後で示すのでまずは実際の定義を確認しましょう。

db/schema.grn:

# ドキュメントを格納するテーブル。キーなし。
table_create \
  --name documents \
  --flags TABLE_NO_KEY
# ドキュメントのタイトルを格納するカラム。
column_create \
  --table documents \
  --name title \
  --flags COLUMN_SCALAR \
  --type ShortText
# ドキュメントの内容を格納するカラム。
column_create \
  --table documents \
  --name content \
  --flags COLUMN_SCALAR \
  --type Text

# 全文検索インデックス用のテーブル。
table_create \
  --name terms \
  --flags TABLE_PAT_KEY \
  --key_type ShortText \
  --normalizer NormalizerAuto \
  --default_tokenizer TokenBigram
# ドキュメントのタイトルと内容を全文検索するためのインデックス。
# Groongaではインデックスはカラムの一種。
column_create \
  --table terms \
  --name documents_index \
  --flags COLUMN_INDEX|WITH_POSITION|WITH_SECTION \
  --type documents \
  --source title,content

以下は参考になるドキュメントです。

作成したテーブル・インデックス定義はgroonga:schema:loadタスクでGroongaに取り込めます。

% bin/rails groonga:schema:load

これでGroongaに検索対象のドキュメントを格納するテーブルができたので対応するモデルを作ります

% bin/rails generate scaffold document title:text content:text

これでDocumentクラスがapp/models/document.rbに生成されます。DocumentオブジェクトはActive RecordのようなAPIを提供するのでActive Recordと同じような感じで使えます。

動作を確認するためにQiitaから検索対象のドキュメントを取得するRakeタスクを作ります

lib/tasks/data.rake:

require "open-uri"
require "json"

namespace :data do
  namespace :load do
    desc "Load data from Qiita"
    task :qiita => :environment do
      tag = "groonga"
      url = "https://qiita.com/api/v2/items?page=1&per_page=100&query=tag:#{tag}"
      open(url) do |entries_json|
        entries = JSON.parse(entries_json.read)
        entries.each do |entry|
          Document.create(title:   entry["title"],
                          content: entry["body"])
        end
      end
    end
  end
end

実行して検索対象のドキュメントを作ります。

% bin/rails data:load:qiita

http://localhost:3000/documentsにアクセスし、データが入っていることを確認します。

Qiitaのデータをロード

ビューにヒット件数表示機能と検索フォームをつけてコントローラーで全文検索するようにします。

検索フォームではqueryというパラメーターに検索クエリーを指定することにします。

@documents@requestに変更してビューで@request.responseとしているのは、コントローラーの時点ではまだGroongaにリクエストを発行せず、ビューで必要になった時点で発行するためです。(Active Recordも同じことをやっていますが、Active Recordはto_aが必要になった時点で暗黙的に行っているのでユーザーが気にすることはありません。groonga-client-modelも同じようにすることができるのですが…長くなるので別の機会に説明します。)

@request.responseとしている理由はもう1つあります。groonga-client-modelとActive Recordで検索結果が違うからです。Active Recordはヒットしたモデルの配列を返しますが、groonga-client-modelはそれだけではなくさらに追加の情報も返します。たとえば、「ヒット数」(@request.response.n_hits)も持っています。SQLでは別途SELECT COUNT(*)を実行しないといけませんが、Groongaでは1回の検索で検索結果もヒット数も両方取得できるので効率的です。

app/views/documents/index.html.erb:

 
 <h1>Documents</h1>
 
+<p><%= @request.response.n_hits %> records</p>
+
+<%= form_tag(documents_path, method: "get") do %>
+  <%= search_field_tag "query", @query %>
+  <%= submit_tag "Search" %>
+<% end %>
+
 <table>
   <thead>
     <tr>
@@ -12,7 +19,8 @@
   </thead>
 
   <tbody>
-    <% @documents.each do |document| %>
+    <% @request.response.records.each do |document| %>
       <tr>
         <td><%= document.title %></td>
         <td><%= document.content %></td>

app/controllers/documents_controller.rb:

@@ -4,7 +4,11 @@ class DocumentsController < ApplicationController
   # GET /documents
   # GET /documents.json
   def index
-    @documents = Document.all
+    @query = params[:query]
+    @request = Document.select.
+      query(@query)
   end
 
   # GET /documents/1

この状態で次のようにレコード数とフォームが表示されるようになります。

フォームを追加

また、この状態で日本語全文検索機能を実現できています。確認してみましょう。

フォームに「オブジェクト」と日本語のクエリーを入力します。元のドキュメントは100件あり、「オブジェクト」で絞り込んで4件になっています。日本語で全文検索できていますね。

「オブジェクト」で検索

次のようにOR検索もできます。「オブジェクト」単体で検索したときの4件よりも件数が増えているのでORが効いていることがわかります。

「オブジェクト OR API」で検索

全文検索エンジンならではの機能を利用

これで基本的な全文検索機能は実現できていますが、せっかく全文検索エンジンを直接使って検索しているので全文検索エンジンならではの機能も使ってみましょう。

ドリルダウン

まずはドリルダウン機能を使います。ドリルダウンとはある軸に注目して情報を絞り込んでいくことです。例えば、商品カテゴリーに注目して商品を絞り込む(例:家電→洗濯機→ドラム式)、タグに注目して記事を絞り込むといった具合です。

まずは各ドキュメントにタグを付けられるようにしましょう。

タグ用のテーブルを作成し、ドキュメント用のテーブルからそのテーブルを参照するようにします。RDBMSと違い、Groongaは直接他のテーブルを参照する機能があります。

db/schema.grnに以下を追加します。

db/schema.grn:

# タグを格納するテーブル。正規化したタグ名がキー。
table_create \
  --name tags \
  --flags TABLE_HASH_KEY \
  --key_type ShortText \
  --normalizer NormalizerAuto
# 表示用のタグ名。たとえば、タグのキーは「rails」でラベルは「Rails」にする。
column_create \
  --table tags \
  --name label \
  --flags COLUMN_SCALAR \
  --type ShortText

# ドキュメントテーブルにタグテーブルを参照するカラムを追加。
# タグは複数設定できる。
column_create \
  --table documents \
  --name tags \
  --flags COLUMN_VECTOR \
  --type tags

# タグ検索を高速にするためのインデックスカラム。
column_create \
  --table tags \
  --name documents_tags \
  --flags COLUMN_INDEX \
  --type documents \
  --source tags

以下は参考になるドキュメントです。

更新したスキーマをロードします。

% bin/rails groonga:schema:load

タグを作ります

% bin/rails generate scaffold tag _key:string label:string

Qiitaのデータからタグ情報もロードするようにします。Tagを毎回createして大丈夫なのかと思うかもしれませんが、大丈夫です。groonga-client-modelはレコード保存にGroongaのloadコマンドを使っています。このloadコマンドの挙動はupsert(すでに同じキーのレコードがなかったら追加、あったら上書き)なのです。

lib/tasks/data.rake:

@@ -10,8 +10,12 @@ namespace :data do
       open(url) do |entries_json|
         entries = JSON.parse(entries_json.read)
         entries.each do |entry|
+          tags = entry["tags"].collect do |tag|
+            tag_name = tag["name"]
+            Tag.create(_key: tag_name, label: tag_name)
+          end
           Document.create(title:   entry["title"],
-                          content: entry["body"])
+                          content: entry["body"],
+                          tags:    tags)
         end
       end
     end

データベース内のデータを削除してQiitaのロードし直します。

% bin/rails runner 'Document.all.each(&:destroy)'
% bin/rails data:load:qiita

ビューにタグ情報も表示します。コントローラーでoutput_columnsを指定しているのは(参照先の)タグテーブルのラベルカラムも取得するためです。デフォルトではタグテーブルのキーしか取得しないので明示的に指定しています。

app/controllers/documents_controller.rb:

@@ -6,6 +6,7 @@ class DocumentsController < ApplicationController
   def index
     @query = params[:query]
     @request = Document.select.
+      output_columns(["_id", "_key", "*", "tags.label"]).
       query(@query)
   end

app/views/documents/index.html.erb:

@@ -14,6 +14,7 @@
     <tr>
       <th>Title</th>
       <th>Content</th>
+      <th>Tags</th>
       <th colspan="3"></th>
     </tr>
   </thead>
@@ -24,6 +25,13 @@
       <tr>
         <td><%= document.title %></td>
         <td><%= document.content %></td>
+        <td>
+          <ul>
+          <% document.tags.each do |tag| %>
+            <li><%= tag.label %></li>
+          <% end %>
+          </ul>
+        </td>
         <td><%= link_to 'Show', document %></td>
         <td><%= link_to 'Edit', edit_document_path(document) %></td>
         <td><%= link_to 'Destroy', document, method: :delete, data: { confirm: 'Are you sure?' } %></td>

「Tags」カラムにタグがあるのでタグがロードされていることを確認できます。

タグがロードされている

実はすでにタグで高速に検索できるようにもなっています。フォームに「tags:@全文検索」と入力すると「全文検索」タグで絞り込めます。(tags:@...は「tagsカラムの値を検索する」というGroongaの構文です。Googleのsite:...に似せた構文です。)

「全文検索」タグで検索

それではこのタグ情報を使ってドリルダウンできるようにします。

ユーザーにとっては、タグをキーボードから入力して絞り込む(ドリルダウンする)のは面倒なので、クリックでドリルダウンできるようにします

コントローラーには次の2つの処理を追加しています。

  • クエリーパラメーターとしてtagが指定されていたらfilter("tags @ %{tag}", tag: tag)でタグ検索をする条件を追加する。

  • タグでドリルダウンするための情報(どのタグ名で絞りこめるのか、また、絞り込んだらどのくらいの件数になるのか、という情報)を取得する

「タグでドリルダウンするための情報を取得する」とはSQLでいうと「GROUP BY tagの結果も取得する」という処理になります。SQLではGROUP BYの結果も取得すると追加でSQLを実行しないといけませんが、Groongaでは1回のクエリーで検索もヒット数の取得もドリルダウン用の情報も取得できるので効率的です。

app/controllers/documents_controller.rb:

@@ -5,9 +5,18 @@ class DocumentsController < ApplicationController
   # GET /documents.json
   def index
     @query = params[:query]
-    @request = Document.select.
+    @tag = params[:tag]
+
+    request = Document.select.
       output_columns(["_id", "_key", "*", "tags.label"]).
       query(@query)
+    if @tag.present?
+      request = request.filter("tags @ %{tag}", tag: @tag)
+    end
+    @request = request.
+      drilldowns("tag").keys("tags").
+      drilldowns("tag").sort_keys("-_nsubrecs").
+      drilldowns("tag").output_columns(["_key", "_nsubrecs", "label"])
   end

ビューではクリックでドリルダウンできる(タグで絞り込める)ようにリンクを表示します。

app/views/documents/index.html.erb:

@@ -5,10 +5,21 @@
 <p><%= @request.response.n_hits %> records</p>
 
 <%= form_tag(documents_path, method: "get") do %>
+  <%= hidden_field_tag "tag", @tag %>
   <%= search_field_tag "query", @query %>
   <%= submit_tag "Search" %>
 <% end %>
 
+<nav>
+  <% @request.response.drilldowns["tag"].records.each do |tag| %>
+  <%= link_to_unless @tag == tag._key,
+                     "#{tag.label} (#{tag._nsubrecs})",
+                     url_for(query: @query, tag: tag._key) %>
+  <% end %>
+  <%= link_to "タグ絞り込み解除",
+              url_for(query: @query) %>
+</nav>
+
 <table>
   <thead>
     <tr>
@@ -27,7 +38,9 @@
         <td>
           <ul>
           <% document.tags.each do |tag| %>
-            <li><%= tag.label %></li>
+            <li><%= link_to_unless @tag == tag._key,
+                                   tag.label,
+                                   url_for(query: @query, tag: tag._key) %></li>
           <% end %>
           </ul>
         </td>

これで次のような画面になります。「全文検索 (20)」というリンクがあるので、「全文検索」タグでドリルダウンすると「20件」ヒットすることがわかります。

タグでドリルダウンできる

「全文検索 (20)」のリンクをクリックすると「全文検索」タグでドリルダウンできます。たしかに20件ヒットしています。

「全文検索」タグでドリルダウン

ここからさらにキーワードで絞り込むこともできます。以下はさらに「ruby」で絞り込んだ結果です。ヒット数がさらに減って3件になっています。

「全文検索」タグでドリルダウンして「ruby」で全文検索

全文検索エンジンの機能を使うと簡単・高速にドリルダウンできるようになります。

キーワードハイライト

検索結果を確認しているとき、キーワードがどこに含まれているかがパッとわかると目的のドキュメントかどうかを判断しやすくなります。そのための機能も全文検索エンジンならではの機能です。

highlight_html()を使うとキーワードを<span class="keyword">...</span>で囲んだ結果を取得できます。

snippet_html()を使うとキーワード周辺のテキストを取得できます。

これらを使ってキーワードをハイライトするには次のようにします。

app/controllers/documents_controller.rb:

@@ -8,7 +8,14 @@ class DocumentsController < ApplicationController
     @tag = params[:tag]
 
     request = Document.select.
-      output_columns(["_id", "_key", "*", "tags.label"]).
+      output_columns([
+                       "_id",
+                       "_key",
+                       "*",
+                       "tags.label",
+                       "highlight_html(title)",
+                       "snippet_html(content)",
+                     ]).
       query(@query)
     if @tag.present?
       request = request.filter("tags @ %{tag}", tag: @tag)

app/views/documents/index.html.erb:

@@ -33,8 +33,16 @@
   <tbody>
     <% @request.response.records.each do |document| %>
       <tr>
-        <td><%= document.title %></td>
-        <td><%= document.content %></td>
+        <td><%= document.highlight_html.html_safe %></td>
+        <td>
+          <% if document.snippet_html.present? %>
+            <% document.snippet_html.each do |chunk| %>
+              <div>...<%= chunk.html_safe %>...</div>
+            <% end %>
+          <% else %>
+            <%= document.content %>
+          <% end %>
+        </td>
         <td>
           <ul>
           <% document.tags.each do |tag| %>

app/assets/stylesheets/documents.scss:

@@ -1,3 +1,7 @@
 // Place all the styles related to the documents controller here.
 // They will automatically be included in application.css.
 // You can use Sass (SCSS) here: http://sass-lang.com/
+
+.keyword {
+  color: red;
+}

「全文検索」タグでドリルダウンして「ruby」で全文検索した状態では次のようになります。どこにキーワードがあるかすぐにわかりますね。

「全文検索」タグでドリルダウンして「ruby」で全文検索した結果をハイライト

スコアでソート

検索結果の表示順はユーザーが求めていそうな順番にするとユーザーはうれしいです。

Groongaはスコアという数値でどれだけ検索条件にマッチしていそうかという情報を返します。スコアでソートすることでユーザーが求めていそうな順番にできます。

@@ -15,8 +15,12 @@ class DocumentsController < ApplicationController
                        "tags.label",
                        "highlight_html(title)",
                        "snippet_html(content)",
-                     ]).
-      query(@query)
+                     ])
+    if @query.present?
+      request = request.
+        query(@query).
+        sort_keys(["-_score"])
+    end
     if @tag.present?
       request = request.filter("tags @ %{tag}", tag: @tag)
     end

ページネーション

groonga-client-modelは標準でページネーション機能を提供しています。Kaminariと連携することでページネーションのUIもすぐに作れます。

Gemfile:

@@ -53,3 +53,4 @@ end
 gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
 
 gem 'groonga-client-model'
+gem 'kaminari'

app/controllers/documents_controller.rb:

@@ -27,7 +27,8 @@ class DocumentsController < ApplicationController
     @request = request.
       drilldowns("tag").keys("tags").
       drilldowns("tag").sort_keys("-_nsubrecs").
-      drilldowns("tag").output_columns(["_key", "_nsubrecs", "label"])
+      drilldowns("tag").output_columns(["_key", "_nsubrecs", "label"]).
+      paginate(params[:page])
   end

app/views/documents/index.html.erb:

@@ -2,7 +2,7 @@
 
 <h1>Documents</h1>
 
-<p><%= @request.response.n_hits %> records</p>
+<p><%= page_entries_info(@request.response) %></p>
 
 <%= form_tag(documents_path, method: "get") do %>
   <%= hidden_field_tag "tag", @tag %>
@@ -62,4 +62,6 @@
 
 <br>
 
+<%= paginate(@request.response) %>
+
 <%= link_to 'New Document', new_document_path %>

RubyGemsを追加したのでGemfile.lockを更新します。アプリケーションサーバーを再起動することも忘れないでください。

% bundle install

画面の上にはページの情報が表示されます。

ページの情報

画面の下にはページを移動するためのリンクが表示されます。

ページネーション

まとめ

MySQL・PostgreSQL・SQLite3を一切使わずにRuby on Railsアプリケーションで日本語全文検索機能を実現する方法を説明しました。データの保存も取得も検索もすべてGroongaで実現しました。単に全文検索できるようにするだけではなく、ドリルダウンやハイライトといった全文検索ならではの機能の実現方法も紹介しました。

Groongaを使いたいけど学習コストが増えそうだなぁと思っていた人は試してみてください。実際に試してみて詰まった場合や、ここには書いていないこういうことをしたいけどどうすればいいの?ということがでてきた場合は以下の場所で相談してください。

Groongaを用いた全文検索アプリケーションの開発に関するご相談は問い合わせフォームからご連絡ください。

Groonga関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。