MySQL・PostgreSQL・SQLite3の標準機能では日本語テキストの全文検索に難があります。MySQL・PostgreSQLに高速・高機能な日本語全文検索機能を追加するMroonga・PGroongaというプラグインがあります。これらを導入することによりSQLで高速・高機能な日本語全文検索機能を実現できます。詳細は以下を参照してください。
ここではMroonga・PGroongaを使わずに日本語全文検索を実現する方法を紹介します。それはGroongaを使う方法です。
GroongaはMroonga・PGroongaのバックエンドで使われている全文検索エンジンです。
Groongaを直接使うメリットは以下の通りです。
-
MySQL・PostgreSQLのオーバーヘッドがない分Mroonga・PGroongaよりもさらに速い
-
1つのSQLでは表現できないような検索を1クエリーで実現できる(のでさらに速い)
一方、デメリットは以下の通りです。
-
Mroonga・PGroongaに比べて学習コストが増える(Mroonga・PGroongaは
SELECT
のWHERE
での条件の書き方を学習するくらいでよいが、Groongaはインデックスの設計やクエリーの書き方について学習する必要がある) -
MySQL・PostgreSQLだけでなくGroongaサーバーも管理する必要があるので運用コストが増える
このデメリットのうち学習コストの方をできるだけ抑えつつGroongaを使えるようにするためのライブラリーがあります。それがgroonga-client-railsです。groonga-client-railsがGroongaを使う部分の多くをフォローしてくれるため利用者は学習コストを抑えたままGroongaを使って高速な日本語全文検索システムを実現できます。
この記事ではRuby on Railsで作ったアプリケーションからGroongaを使って日本語全文検索機能を実現する方法を説明します。実際にドキュメント検索システムを開発する手順を示すことで説明します。ここではCentOS 7を用いますが、他の環境でも同様の手順で実現できます。
なお、この記事ではMySQL・PostgreSQLではなくSQLite3を使っていますが、アプリケーションのコードは変更せずにMySQL・PostgreSQLでも動くので気にしないでください。
@KitaitiMakotoさんが書いたgroonga-client-railsの使い方を紹介した記事もあるのでそちらもあわせて参照してください。違った視点で紹介しているので理解が深まるはずです。
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.3をインストールします。
% 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.3.3
% rbenv global 2.3.3
Ruby on Railsのインストール
Ruby on Railsをインストールします。
% sudo -H yum install -y sqlite-devel nodejs
% gem install rails
ドキュメント検索システムの開発
いよいよ日本語全文検索機能を持ったドキュメント検索システムを開発します。
まずはrails new
で雛形を作ります。
% rails new document_search
% cd document_search
データベースを作成します。
% bin/rails db:create
% bin/rails generate scaffold document title:text content:text
% bin/rails db:migrate
ここまでは(Groongaのインストール以外は)Groongaと関係ない手順です。
ここからはGroongaを使う場合に特有の手順になります。
まずGemfileにgroonga-client-rails gemを追加します。
gem 'groonga-client-rails'
groonga-client-rails gemをインストールします。
% bundle install
それではアプリケーション側に全文検索機能を実装します。
まず、サーチャーというオブジェクトを定義します。これはGroongaでいい感じに全文検索するための機能を提供するオブジェクトです。
サーチャー用のディレクトリーを作成します。
% mkdir -p app/searchers
app/searchers/application_searcher.rb
にApplicationSearcher
を作成します。(ジェネレーターはまだ実装されていません。)
class ApplicationSearcher < Groonga::Client::Searcher
end
Document
モデル用のサーチャーDocumentsSearcher
をapp/searchers/documents_searcher.rb
に作成します。
class DocumentsSearcher < ApplicationSearcher
# Documentモデルのtitleカラムを全文検索するためのインデックスを作成
schema.column :title, {
type: "ShortText",
index: true,
index_type: :full_text_search,
}
# Documentモデルのcontentカラムを全文検索するためのインデックスを作成
schema.column :content, {
type: "Text",
index: true,
index_type: :full_text_search,
}
end
モデルのカラムとサーチャーのインデックスを対応付けるコードをモデルに追加します。
app/models/document.rb
:
class Document < ApplicationRecord
# DocumentモデルをDocumentsSearcherの検索対象とする
source = DocumentsSearcher.source(self)
# Documentのtitleカラムと
# DocumentsSearcherのtitleインデックスを対応付ける
source.title = :title
# Documentのcontentカラムと
# DocumentsSearcherのcontentインデックスを対応付ける
source.content = :content
end
この対応付けをGroongaのサーバーに反映します。
% bin/rails groonga:sync
動作を確認するために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
にアクセスし、データが入っていることを確認します。
ビューにヒット件数表示機能と検索フォームをつけてコントローラーで全文検索するようにします。
検索フォームではquery
というパラメーターに検索クエリーを指定することにします。
@documents
を@result_set
に変更している理由はあとでわかります。端的に言うとヒットしたドキュメントだけでなくさらに情報も持っているので@result_set
(結果セット)にしています。たとえば、「ヒット数」(@result_set.n_hits
)も持っています。SQLでは別途SELECT COUNT(*)
を実行しないといけませんが、Groongaでは1回の検索で検索結果もヒット数も両方取得できるので効率的です。
なお、ヒットしたドキュメントに対応するDocument
モデルは@result_set.records.each {|record| record.source}
でアクセスできます。そのため、モデルが必要な処理(たとえばURLの生成)もこれまで通りの方法で使えます。
app/views/documents/index.html.erb
:
<h1>Documents</h1>
+<p><%= @result_set.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| %>
+ <% @result_set.records.each do |record| %>
+ <% document = record.source %>
<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]
+ searcher = DocumentsSearcher.new
+ @result_set = searcher.search.
+ query(@query).
+ result_set
end
# GET /documents/1
この状態で次のようにレコード数とフォームが表示されるようになります。
また、この状態で日本語全文検索機能を実現できています。確認してみましょう。
フォームに「オブジェクト」と日本語のクエリーを入力します。元のドキュメントは100件あり、「オブジェクト」で絞り込んで11件になっています。日本語で全文検索できていますね。
次のようにOR検索もできます。「オブジェクト」単体で検索したときの11件よりも件数が増えているのでORが効いていることがわかります。
全文検索エンジンならではの機能を利用
これで基本的な全文検索機能は実現できていますが、せっかく全文検索エンジンを直接使って検索しているので全文検索エンジンならではの機能も使ってみましょう。
ドリルダウン
まずはドリルダウン機能を使います。ドリルダウンとはある軸に注目して情報を絞り込んでいくことです。例えば、商品カテゴリーに注目して商品を絞り込む(例:家電→洗濯機→ドラム式)、タグに注目して記事を絞り込むといった具合です。
まずは各ドキュメントにタグを付けられるようにしましょう。
% bin/rails generate scaffold tags name:string
% bin/rails generate model tagging document:references tag:references
スキーマを更新します。
% bin/rails db:migrate
モデルに関連情報を追加します。
app/models/document.rb
:
@@ -1,4 +1,7 @@
class Document < ApplicationRecord
+ has_many :taggings
+ has_many :tags, through: :taggings
+
source = DocumentsSearcher.source(self)
source.title = :title
source.content = :content
app/models/tag.rb
:
@@ -1,2 +1,4 @@
class Tag < ApplicationRecord
+ has_many :taggings
+ has_many :documents, through: :taggings
end
Qiitaのデータからタグ情報もロードするようにします。
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.find_or_create_by(name: tag["name"])
+ end
Document.create(title: entry["title"],
- content: entry["body"])
+ content: entry["body"],
+ tags: tags)
end
end
end
データベース内のデータを削除してQiitaのロードし直します。
% bin/rails runner Document.destroy_all
% bin/rails data:load:qiita
ビューにタグ情報も表示します。
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.name %></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」カラムにタグがあるのでタグがロードされていることを確認できます。
それではこのタグ情報を使ってドリルダウンできるようにします。
Groongaでタグ情報を使えるようにするにはサーチャーとモデルにタグ情報を使うというコードを追加します。
app/searchers/documents_searcher.rb
:
@@ -9,4 +9,11 @@ class DocumentsSearcher < ApplicationSearcher
index: true,
index_type: :full_text_search,
}
+ schema.column :tags, {
+ type: "ShortText",
+ reference: true, # 文字列でドリルダウンをするときは指定すると高速になる
+ normalizer: false, # タグそのもので検索する
+ vector: true, # 値が複数あるときは指定する
+ index: true,
+ }
end
app/models/document.rb
:
@@ -5,4 +5,7 @@ class Document < ApplicationRecord
source = DocumentsSearcher.source(self)
source.title = :title
source.content = :content
+ source.tags = ->(model) do
+ model.tags.collect(&:name) # タグモデルではなくタグ名をGroongaに渡す
+ end
end
マッピングを変更したらgroonga:sync
で同期します。
% bin/rails groonga:sync
これでGroongaでタグ情報を使えるようになりました。フォームに「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,16 @@ class DocumentsController < ApplicationController
# GET /documents.json
def index
@query = params[:query]
+ @tag = params[:tag]
+
searcher = DocumentsSearcher.new
- @result_set = searcher.search.
- query(@query).
+ request = searcher.search.query(@query)
+ if @tag.present?
+ request = request.filter("tags @ %{tag}", tag: @tag)
+ end
+ @result_set = request.
+ drilldowns("tag").keys("tags").
+ drilldowns("tag").sort_keys("-_nsubrecs").
result_set
end
ビューではクリックでドリルダウンできる(タグで絞り込める)ようにリンクを表示します。
app/views/documents/index.html.erb
:
@@ -5,10 +5,21 @@
<p><%= @result_set.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>
+ <% @result_set.drilldowns["tag"].records.each do |record| %>
+ <%= link_to_unless @tag == record._key,
+ "#{record._key} (#{record._nsubrecs})",
+ url_for(query: @query, tag: record._key) %>
+ <% end %>
+ <%= link_to "タグ絞り込み解除",
+ url_for(query: @query) %>
+</nav>
+
<table>
<thead>
<tr>
@@ -27,8 +38,10 @@
<td><%= document.content %></td>
<td>
<ul>
- <% document.tags.each do |tag| %>
- <li><%= tag.name %></li>
+ <% record.tags.each do |tag| %>
+ <li><%= link_to_unless @tag == tag,
+ tag,
+ url_for(query: @query, tag: tag) %></li>
<% end %>
</ul>
</td>
これで次のような画面になります。「全文検索 (20)」というリンクがあるので、「全文検索」タグでドリルダウンすると「20件」ヒットすることがわかります。
「全文検索 (20)」のリンクをクリックすると「全文検索」タグでドリルダウンできます。たしかに20件ヒットしています。
ここからさらにキーワードで絞り込むこともできます。以下はさらに「ruby」で絞り込んだ結果です。ヒット数がさらに減って3件になっています。
全文検索エンジンの機能を使うと簡単・高速にドリルダウンできるようになります。
キーワードハイライト
検索結果を確認しているとき、キーワードがどこに含まれているかがパッとわかると目的のドキュメントかどうかを判断しやすくなります。そのための機能も全文検索エンジンならではの機能です。
highlight_html()
を使うとキーワードを<span class="keyword">...</span>
で囲んだ結果を取得できます。
snippet_html()
を使うとキーワード周辺のテキストを取得できます。
これらを使ってキーワードをハイライトするには次のようにします。
app/controllers/documents_controller.rb
:
@@ -13,6 +13,12 @@ class DocumentsController < ApplicationController
request = request.filter("tags @ %{tag}", tag: @tag)
end
@result_set = request.
+ output_columns([
+ "_key",
+ "*",
+ "highlight_html(title)",
+ "snippet_html(content)",
+ ]).
drilldowns("tag").keys("tags").
drilldowns("tag").sort_keys("-_nsubrecs").
result_set
app/views/documents/index.html.erb
:
@@ -34,8 +34,16 @@
<% @result_set.records.each do |record| %>
<% document = record.source %>
<tr>
- <td><%= document.title %></td>
- <td><%= document.content %></td>
+ <td><%= record.highlight_html.html_safe %></td>
+ <td>
+ <% if record.snippet_html.present? %>
+ <% record.snippet_html.each do |chunk| %>
+ <div>...<%= chunk.html_safe %>...</div>
+ <% end %>
+ <% else %>
+ <%= document.content %>
+ <% end %>
+ </td>
<td>
<ul>
<% record.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」で全文検索した状態では次のようになります。どこにキーワードがあるかすぐにわかりますね。
スコアでソート
検索結果の表示順はユーザーが求めていそうな順番にするとユーザーはうれしいです。
Groongaはスコアという数値でどれだけ検索条件にマッチしていそうかという情報を返します。スコアでソートすることでユーザーが求めていそうな順番にできます。
@@ -8,7 +8,12 @@ class DocumentsController < ApplicationController
@tag = params[:tag]
searcher = DocumentsSearcher.new
- request = searcher.search.query(@query)
+ request = searcher.search
+ if @query.present?
+ request = request.
+ query(@query).
+ sort_keys("-_score")
+ end
if @tag.present?
request = request.filter("tags @ %{tag}", tag: @tag)
end
ページネーション
groonga-client-railsは標準でページネーション機能を提供しています。Kaminariと連携することでページネーションのUIもすぐに作れます。
Gemfile
:
@@ -53,3 +53,4 @@ end
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem 'groonga-client-rails'
+gem 'kaminari'
app/controllers/documents_controller.rb
:
@@ -26,6 +26,7 @@ class DocumentsController < ApplicationController
]).
drilldowns("tag").keys("tags").
drilldowns("tag").sort_keys("-_nsubrecs").
+ paginate(params[:page]).
result_set
end
app/views/documents/index.html.erb
:
@@ -2,7 +2,7 @@
<h1>Documents</h1>
-<p><%= @result_set.n_hits %> records</p>
+<p><%= page_entries_info(@result_set, entry_name: "documents") %></p>
<%= form_tag(documents_path, method: "get") do %>
<%= hidden_field_tag "tag", @tag %>
@@ -63,4 +63,6 @@
<br>
+<%= paginate(@result_set) %>
+
<%= link_to 'New Document', new_document_path %>
RubyGemsを追加したのでGemfile.lock
を更新します。アプリケーションサーバーを再起動することも忘れないでください。
% bundle install
画面の上にはページの情報が表示されます。
画面の下にはページを移動するためのリンクが表示されます。
まとめ
MySQL・PostgreSQL・SQLite3とGroongaを使ってRuby on Railsアプリケーションで日本語全文検索機能を実現する方法を説明しました。単に全文検索できるようにするだけではなく、ドリルダウンやハイライトといった全文検索ならではの機能の実現方法も紹介しました。
Groongaを使いたいけど学習コストが増えそうだなぁと思っていた人は試してみてください。実際に試してみて詰まった場合や、ここには書いていないこういうことをしたいけどどうすればいいの?ということがでてきた場合は以下の場所で相談してください。
Groongaを用いた全文検索アプリケーションの開発に関するご相談は問い合わせフォームからご連絡ください。
Groonga関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。