MySQL・PostgreSQL・SQLite3の標準機能では日本語テキストの全文検索に難があります。MySQL・PostgreSQLに高速・高機能な日本語全文検索機能を追加するMroonga・PGroongaというプラグインがあります。これらを導入することにより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
にアクセスし、データが入っていることを確認します。
ビューにヒット件数表示機能と検索フォームをつけてコントローラーで全文検索するようにします。
検索フォームでは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が効いていることがわかります。
全文検索エンジンならではの機能を利用
これで基本的な全文検索機能は実現できていますが、せっかく全文検索エンジンを直接使って検索しているので全文検索エンジンならではの機能も使ってみましょう。
ドリルダウン
まずはドリルダウン機能を使います。ドリルダウンとはある軸に注目して情報を絞り込んでいくことです。例えば、商品カテゴリーに注目して商品を絞り込む(例:家電→洗濯機→ドラム式)、タグに注目して記事を絞り込むといった具合です。
まずは各ドキュメントにタグを付けられるようにしましょう。
タグ用のテーブルを作成し、ドキュメント用のテーブルからそのテーブルを参照するようにします。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件になっています。
全文検索エンジンの機能を使うと簡単・高速にドリルダウンできるようになります。
キーワードハイライト
検索結果を確認しているとき、キーワードがどこに含まれているかがパッとわかると目的のドキュメントかどうかを判断しやすくなります。そのための機能も全文検索エンジンならではの機能です。
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」で全文検索した状態では次のようになります。どこにキーワードがあるかすぐにわかりますね。
スコアでソート
検索結果の表示順はユーザーが求めていそうな順番にするとユーザーはうれしいです。
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関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。