株式会社クリアコード > ククログ

ククログ

タグ:

LaravelでPostgreSQLとPGroongaを使って日本語全文検索を実現する方法

はじめに

※この記事は、Laravelを使った開発の経験がある人を対象としています。Laravelの基本的な使い方自体の説明は含まれていませんので、ご注意下さい。

Webアプリケーションを開発していると、「検索窓に入力された語句を含むレコードを一覧表示する」といった機能を付けたくなる場面がよくあります。この時よく使われる手軽な方法としてSQLのLIKEがありますが、LIKEには検索対象のレコードの数が増えれば増えるほど処理に時間がかかるという欠点があります。そこで登場するのが全文検索という手法です。全文検索では事前に用意しておいたインデックス情報を使うことにより、レコード数が増加しても安定して高速な検索を行うことができます。サービスの利用者が増加して数万・数十万といった数のレコードを取り扱うような必要が生じてきた場合、全文検索の導入を検討する価値は十分にあるでしょう。

以前、Ruby on Railsで作ったアプリケーションからPGroongaを使って日本語全文検索機能を実現する方法を紹介しました。今回はそのPHP版として、Laravelで作ったブログ風のアプリケーションにPGroongaを使った日本語全文検索機能を組み込む方法をご紹介します。

この記事では、適当なLaravelアプリケーションが手元に無い場合を想定し、解説用に用意したLaravelアプリケーションとPostgreSQLとの組み合わせに対して、全文検索機能を組み込む手順を解説しています。すでに開発中・運用中のPostgreSQLを使用したアプリケーションがある場合には、そのアプリケーションに組み込む手順としてテーブル名等を適宜読み替えて下さい。

また、タイトルにも書いてある通りですが、この記事で紹介しているPGroongaは、PostgreSQLに対して全文検索機能を提供するソフトウェアです。開発・運用中のアプリケーションがPostgreSQL以外のデータベースを使用している場合にはPGroongaを使えませんので、くれぐれもご注意下さい。(他のデータベースを使っている場合、例えばMySQLやMariaDBであれば、PGroongaの代わりにMroongaを使うことになります。その場合の手順はここでは解説していませんので、あしからずご了承ください。)

開発環境の準備

それでは、開発環境を用意していきます。まず、以下の2つを用意します。

  • Homesteadを動作させるホストマシン(ここではUbuntu 16.04LTSと仮定します)
  • 動作確認用のWebブラウザが動作するクライアント

ホストマシンとしてLinuxのデスクトップ環境を用意して、ホストマシン自身をクライアントとして使うのが最も簡単です。別々のマシンを使う場合には、両者は同じネットワーク上に存在するか、もしくはホストマシンのネットワーク上の任意のコンピュータにクライアントから自由に接続できるものとします。

Laravelには開発環境を簡単に構築するためのHomesteadという枠組みがあり、今回はそれを使ってみることにします。Vagrantが必要なので、事前にホストマシンにVagrant 1.9以上VirtualBoxをインストールしておいて下さい。

VMの準備(ホストマシン上の操作)

開発環境のboxイメージが公開されているので、まずはそれを導入します。

% vagrant box add laravel/homestead
==> box: Loading metadata for box 'laravel/homestead'
box: URL: https://atlas.hashicorp.com/laravel/homestead
This box can work with multiple providers! The providers that it
can work with are listed below. Please review the list and choose
the provider you will be working with.

1) parallels
2) virtualbox
3) vmware_desktop

ここではVirtualboxのイメージを使うので2を選択します。

Enter your choice: 2
==> box: Adding box 'laravel/homestead' (v2.1.0) for provider: virtualbox
box: Downloading: https://atlas.hashicorp.com/laravel/boxes/homestead/versions/2.1.0/providers/virtualbox.box
==> box: Successfully added box 'laravel/homestead' (v2.1.0) for 'virtualbox'!
vagrant box add laravel/homestead 14.75s user 7.42s system 19% cpu 1:53.24 total

boxを正常にダウンロードできたので次に進みます。

Homesteadの準備(ホストマシン上の操作)

Homesteadのリポジトリを以下のようにホストの作業ディレクトリにcloneします。

% mkdir ~/work
% cd ~/work
% git clone https://github.com/laravel/homestead.git

masterブランチは不安定な場合があるため、今回はリリースブランチを使います。

% cd homestead
% git checkout v5.4.0
...
HEAD is now at f54a9f0... Tagging 5.4.0 (#595)
(END):
Homestead.yamlの準備(ホストマシン上の操作)

ホストにて init.sh を実行します。

% bash init.sh
Homestead initialized!

すると以下の3つのファイルが同じディレクトリに作成されます。

  • Homestead.yaml
  • after.sh
  • aliases

Homestead.yaml はVMの設定ファイルです。

今回は以下の項目を変更します。

  • ip: 異なるネットワークのIPアドレスを指定する。(ホストマシンが接続しているネットワークが192.168.10.0/24なら、192.168.20.0/24などのアドレスにする)
  • authorize: ホストマシンで作成したsshの公開鍵のパスを指定する。
  • keys: ホストマシンで作成したsshの秘密鍵のパスを指定する。
  • folders: ホストマシンのディレクトリーをゲスト上から見えるようにする指定だが、ホストマシン上にはLaravelアプリケーション用のファイルを設置しないため不要なので、コメントアウトする。
  • sites: ゲスト上にこれから作成するアプリケーションに合わせてパスを書き換える。
  • networks: ゲストがホストマシンが接続しているネットワークにブリッジ接続するための設定を追加する。

上記変更を反映した Homestead.yaml は次の通りです。

---
ip: "192.168.20.10" # ホストマシンと異なるネットワークになるようにする。
memory: 2048
cpus: 1
provider: virtualbox

authorize: ~/id_homestead.pub #公開鍵のパス

keys:
    - ~/id_homestead #秘密鍵のパス

#folders:
#    - map: ~/work/Blog
#      to: /home/vagrant/Blog

sites:
    - map: homestead.app
      to: /home/vagrant/Blog/public # 後々、この位置にファイルが作られる。

databases:
    - homestead

networks:
    - type: "public_network"
      bridge: "eth0" # "wlan0"など、ホストマシンの主なインターフェースに合わせる。

IPアドレスやブリッジのインタフェース名、鍵の情報は環境に応じて適宜読み替えてください。

VMの起動(ホストマシン上の操作)

設定ファイルとコマンドの準備ができたので、ホストマシンからVMを起動します。

% vagrant up

設定に問題なければVMが正常起動します。 続けて、クライアントからゲスト上のLaravelアプリケーションに接続するために、起動したVMのブリッジ接続でのIPアドレスを調べます。

% host_if=eth0
% host_nw="$(ip addr | grep -o "inet.*$host_if" | cut -d ' ' -f 2 | cut -d '.' -f -3)."
% vagrant ssh -- ip addr | grep "$host_nw"
    inet 192.168.10.52/24 brd 192.168.10.255 scope global enp0s9

この例では192.168.10.52が割り当てられているので、クライアントからは http://192.168.10.52/ をブラウザで開けば動作を確認できることになります。

hostsファイルの編集(クライアント上の操作)

http://192.168.10.52/のようなIPアドレスの直接入力は煩雑なので、クライアントのhostsファイルを編集して、homestead.appというホスト名で参照できるようにしておきます。クライアントのhostsに以下の内容を書き足しましょう。

192.168.10.52 homestead.app

クライアントがUbuntuのデスクトップ環境の場合、hostsは /etc/hostsです。クライアントがWindowsの場合は、hostsは C:\windows\system32\drivers\etc\hosts の位置にあります。

以上で準備完了です。クライアント上のWebブラウザで http://homestead.app/ を開いてみて下さい。以下のようなエラーページが表示されるはずです。

(エラーページのスクリーンショット)

これはHomestead上のnginxが返しているエラーで、所定の位置にまだLaravelアプリケーションが存在していないという事を示しています。

Laravelアプリケーションのセットアップ

それではLaravelによるブログ風のアプリケーションを用意していきます。といっても、Laravelでのブログ風アプリケーションの開発の仕方そのものはここでは重要ではないので、あらかじめこちらで用意したサンプルのブログ風アプリケーションを使うことにしましょう。

まず、ホストマシンからゲストマシンへsshでログインします。

% vagrant ssh
vagrant@homestead:~$

そうしたら、サンプルアプリケーションのGitリポジトリをcloneします。この時、clone先のディレクトリ名をBlogと明示して、ファイルの設置先がホストマシン上のHomestead.yamlの内容と一致するようにする必要があることに気をつけて下さい。

vagrant@homestead:~$ git clone https://github.com/clear-code/pgroonga-example-laravel.git Blog

cloneし終えたら、アプリケーションを初期化します。

vagrant@homestead:~$ cd Blog
vagrant@homestead:~/Blog$ composer install
vagrant@homestead:~/Blog$ php artisan migrate
vagrant@homestead:~/Blog$ php artisan db:seed
サンプルアプリケーションの仕様

以上の手順をすべて実施したら、クライアントから http://homestead.app/posts を開いてみて下さい。以下のような記事一覧ページが表示されるはずです。

(サンプルアプリケーションのスクリーンショット)

Homestead.yamlの設定(例えば、Laravelのアプリケーションの設置先パス)を間違えていると、先のエラーページと同じ物が表示されるかもしれません。その場合、Homestead.yamlを修正してから vagrant provision を実行し、ゲスト上のnginx等を再起動する必要があります。

記事一覧ページの右上にある検索窓に「Groonga」のようなキーワードを入力すると、そのキーワードの検索結果として、postsテーブルのレコードのうちbodyカラムにキーワードを含むレコードの一覧が表示されます。ただ、コントローラの実装(app/Http/Controllers/PostController.php)を見ると分かりますが、これは以下のような単純なSQLのLIKEによる絞り込みの結果です。Groonga OR Mroongaのような凝った検索は行えません。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PostController extends Controller
{
  public function index(Request $request)
  {
    $query = $request->get('query');
    if (!empty($query)) {
      $posts = \App\Post::where('body', 'like', "%{$query}%")->orderBy('id', 'desc')->get();
    }
    else {
      $posts = \App\Post::orderBy('id', 'desc')->get();
    }
    return \View::make('posts.index')
             ->with('posts', $posts)
             ->with('query', $query);
  }
}
注意点

Homesteadの環境で普通にlaravel new Blogすると、MySQLを使うように設定されたLaravelアプリケーションが作られます。しかし今回はPGroongaの使い方の解説なので、この例では以下のように.envを編集して、データベースにはPostgreSQLを使うよう設定してあります。

commit 5f48f346ccf36e07aefdf062b2f374d83dc6151d
Author: YUKI Hiroshi <yuki@clear-code.com>
Date:   Mon Jun 26 01:56:56 2017 +0000

    Use PostgreSQL by default

diff --git a/.env b/.env
index f41ce19..ceaa34d 100644
--- a/.env
+++ b/.env
@@ -5,9 +5,9 @@ APP_DEBUG=true
 APP_LOG_LEVEL=debug
 APP_URL=http://localhost

-DB_CONNECTION=mysql
+DB_CONNECTION=pgsql
 DB_HOST=127.0.0.1
-DB_PORT=3306
+DB_PORT=5432
 DB_DATABASE=homestead
 DB_USERNAME=homestead
 DB_PASSWORD=secret
diff --git a/config/database.php b/config/database.php
index cab5d06..abf3d43 100644
--- a/config/database.php
+++ b/config/database.php
@@ -13,7 +13,7 @@ return [
     |
     */

-    'default' => env('DB_CONNECTION', 'mysql'),
+    'default' => env('DB_CONNECTION', 'pgsql'),

     /*
     |--------------------------------------------------------------------------

冒頭にも述べていますが、PGroongaはPostgreSQLに対して全文検索機能を提供する物なので、それ以外のデータベースに対しては使えません。ご注意下さい。

PGronngaによる全文検索機能の組み込み(ゲスト上での作業)

お待たせしました! ようやくここからが本題です。

すでにあるLaravelアプリケーションでPGroongaを使って全文検索をするには、以下の4つのステップを踏みます。

  1. PGroongaのインストール
  2. PGroongaの有効化
  3. インデックスの作成
  4. PGroongaを使って検索するように問い合わせ部分を変更

それでは順番に見ていきましょう。

PGroongaのインストール

何はともあれPGroongaのインストールが必要です。Homesteadの環境はUbuntu 16.04ベースということで、今回はUbuntu用のインストール手順を参照しました。以下は実際に実行したコマンドです。

$ sudo add-apt-repository -y universe
$ sudo add-apt-repository -y ppa:groonga/ppa
$ sudo apt-get update
$ sudo apt-get install -y -V postgresql-9.5-pgroonga

PGroongaのインストール手順は実行環境によって異なります。Ubuntu 16.04以外の環境でのインストール手順については、PGroongaのプロジェクトサイトで公開されているインストール手順の説明を参照して下さい。

PGroongaの有効化

PGroongaは、サーバー上にパッケージをインストールしただけでは使えません。機能を利用するには、PostgreSQLのデータベースに対してCREATE EXTENSION pgroonga;というSQL文を明示的に実行する必要があります。

ということで、このSQL文を実行するためのマイグレーションを作成します。

$ php artisan make:migration install_pgroonga
Created Migration: 2017_06_23_091529_install_pgroonga
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class InstallPgroonga extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
      // ここから追記
      DB::statement('CREATE EXTENSION pgroonga');
      // ここまで追記
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
      // ここから追記
      DB::statement('DROP EXTENSION pgroonga CASCADE;');
      DB::statement('DELETE FROM pg_catalog.pg_am WHERE amname = \'pgroonga\';');
      // ここまで追記
    }
}

upに書かれている内容はPGroongaのインストール手順にあるもので、downに書かれている内容はアンインストール手順にあるものです。

用意ができたら、このマイグレーションを実行します。

$ php artisan migrate

これでPGroongaを使う準備ができました。

検索対象のカラムに全文検索用のインデックスを作成する

次に、PGroongaで全文検索するためのインデックスを作ります。

Laravelのマイグレーションではindexなどのメソッドでインデックスを定義するのが一般的です。しかし、PGroonga用のインデックスを定義する書き方は少々独特で、Laravelの機能経由では実施できません。なので、こちらも直接SQL文を実行する必要があります。

まずSQL文を実行するためのマイグレーションを作成します。

$ php artisan make:migration add_posts_body_index
Created Migration: 2017_06_23_091530_add_posts_body_index
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddPostsBodyIndex extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
      // ここから追記
      DB::statement('CREATE INDEX pgroonga_body_index ON posts USING pgroonga (body);');
      // ここまで追記
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
      // ここから追記
      DB::statement('DROP INDEX pgroonga_body_index CASCADE;');
      // ここまで追記
    }
}

インデックスの名前は自由に付けられますが、PGroongaでは慣習的に、pgroonga_インデックス対象のカラム名_indexという名前を付けることが多いです。CREATE INDEX pgroonga_body_index ON posts USING pgroonga (body);で、「postsテーブルのbodyカラムに対して、PGroonga用のインデックスをpgroonga_body_indexという名前で作成する」という意味になります。

そして、マイグレーションを実行します。

$ php artisan migrate

これによって、postsテーブルのbodyカラムを対象とした全文検索用のインデックスが作成されます。既存のレコードに対するインデックスもこの時一緒に作成されますし、この後で作成されたレコードに対しても自動的にインデックスが作成されるようになります。

以上で、全文検索の準備が整いました。

PGroongaを使って検索するように問い合わせ部分を変更

最後に仕上げとして、全文検索にPGroongaを使うように検索処理を書き換えます。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PostsController extends Controller
{
  public function index(Request $request)
  {
    $query = $request->get('query');
    if (!empty($query)) {
      // 変更前
      // $posts = \App\Models\Post::where('body', 'like', "%{$query}%")->orderBy('id', 'desc')->get();
      // 変更後
      $posts = \App\Models\Post::whereRaw('body @@ ?', $query)->orderBy('id', 'desc')->get();
    }
    else {
      $posts = \App\Models\Post::orderBy('id', 'desc')->get();
    }
    return \View::make('posts.index')
             ->with('posts', $posts)
             ->with('query', $query);
  }
}

PGroongaで全文検索をするためには、PGroonga専用の演算子を使います。whereメソッドではそれらの演算子を取り扱えないので、ここではwhereRawメソッドを使ってSQLの式を直接書いています。

今回使った@@という演算子(この演算子はPGroongaの現在のドキュメントでは&?で置き換えられていることになっていますが、&?PHPの実装上の都合で使えないため、非推奨のこちらを使用しています)は、与えられた検索クエリを一般的なWebの検索エンジンの検索クエリのように取り扱う物です。よって、Groonga リリース(Groongaとリリースの両方の語を含む)やGroonga OR PGroonga(GroongatoPGroongaのどちらか片方だけでも含む)や( Mroonga OR PGroonga ) リリース(MroongaかPGroongaのどちらか片方に加えて、リリースという語句を含む)のような複雑なクエリも、検索窓に入力するだけでそのまま使うことができます。

(複雑なクエリで検索した状態のスクリーンショット)

以上で、PGroongaによる全文検索への乗り換えが完了しました。実際に検索を実行して、期待通りの結果が返ってくるか確かめてみて下さい。

まとめ

以上、LaravelアプリケーションでPGroongaを使って全文検索を行う手順をご紹介しました。

この解説を見ると分かる通り、すでにPostgreSQLを使っている環境であれば、PGroongaを使い始めるのは非常に簡単です。また、アプリケーション内の変更はごく一部だけで済むため、LIKE検索との性能比較もやりやすいでしょう。全文検索を使ったことがない人は、これを機にぜひ一度試してみて下さい。

タグ: Groonga
2017-06-26

redmine.tokyo第12回勉強会:GroongaでRedmineを高速全文検索 #redmineT

須藤です。開発しているRedmineのプラグインはWiki Change NotifierJournal Change Notifierです。

2017年5月13日にredmine.tokyo第12回勉強会が開催されました。ここでGroongaを使ってRedmineを高速全文検索するプラグインを紹介しました。

redmine.tokyoのこれまでの勉強会の資料を見てもわかるとおり、Redmineはよく作られたプロダクトです。ただ、全文検索処理には速度面・制度面で課題があり、それを解決し、Redmineの弱点を克服するのがこのプラグインです。今回の発表では、現在のRedmineの弱点を克服するだけでなく、むしろ検索を強みにするにはどうすればよいかという話もしました。たとえば、Redmine内の情報から機械学習で有用な情報を抽出し、それを活かした検索をすることでこれまでより効率よくRedmineを使えるようにする、といった具合です。このあたりに取り組むためには検索技術・データ分析技術・分析対象のデータ・開発費用などが必要です。すでにRedmineを活用していて大量の情報を保存している方で、Redmineをさらに活用することに興味のある方はご連絡ください。一緒にRedmineのさらなる活用に取り組みましょう。

関連リンク:

内容

この発表では次のことを紹介しました。

  • Redmineの全文検索をマイナスから少しプラスへ
  • Redmineの全文検索を少しプラスからすごくプラスへ
  • Redmineの開発に参加しよう
Redmineの全文検索をマイナスから少しプラスへ

Redmineはプロジェクト管理に使われるため、Redmineにはプロジェクトの情報がたくさん入っています。Redmineを活用し続ければ続けるほど増えていきます。Redmine内の情報を有効活用できればさらに効率よくプロジェクトに取り組めます。たとえば、過去の類似のバグレポートを参照することで新規のバグをすぐに解決できる、といった具合です。Redmineの情報を有効活用するために重要なのが全文検索です。

たくさんの情報の中には、重要な情報もありますが、雑多な情報もあります。そのため、単に全文検索しただけで重要な情報が見つかるわけではありません。雑多な情報もヒットしてしまい、それが重要な情報を隠してしまうからです。現在のRedmineの全文検索は重要な情報も雑多な情報も一緒くたに扱っているため、重要な情報を見つけにくくなっています。これが課題の1つです。

重要な情報を見つけやすいかどうか以前にそもそも速く検索できるかどうかも重要です。重要な情報を見つけやすかったとしても検索結果を表示するまでに時間がかかっていれば(たとえば10秒とか)使いやすくありません。検索キーワードを試行錯誤することも難しいですし、同時に複数の人が検索することもままなりません。これが現在のRedmineの全文検索のもう1つの課題です。

Redmineを高速全文検索するプラグインを使うと、大量のデータが入っていても高速(1秒以内)に重要なものを見つけやすくなります。

速度面においては、200万チケットがあるRedmineでも380msで検索できます。(従来は1時間以上かかっていたケース。)

重要なものを見つけやすくするという観点では、ヒットした内容それぞれにどれだけキーワードにマッチしていそうかの度合いをつけて、それを使ってマッチしていそうなものから提示します。これにより最初の10件程度を確認するだけで重要なものを見つけることができます。

実は、このような全文検索機能は「全文検索システム」という文脈では普通のことです。そのため、これらを実現したのはRedmineの全文検索機能をマイナスから少しプラスにした、という程度のことです。しかし、これだけでもずいぶんRedmineの検索は使いやすくなります。検索が使い物にならなくて使っていなかったという方は、ぜひこのプラグインをインストールして検索してみてください。今度はもっと活用したくなるはずですよ。

Redmineの全文検索を少しプラスからすごくプラスへ

高速・高精度の全文検索を実現するために全文検索エンジンという専用のモジュールがあります。全文検索エンジンというと「キーワードを入力して検索する」ことだけを頑張っていると思っている人が多いかもしれません。しかし、実は、検索を軸にしてもっとたくさん便利にできます。

現在のプラグインは、まだ単に高速・高精度で全文検索できるようにしているだけですが、今後はもっとRedmineを活用できるように育てていきたいです。いくつかそのアイディアを紹介します。クリアコードだけでなく、もっとRedmineを活用したい人たちと協力しながら実現したいです。実現した成果はRedmineと同じくフリーソフトウェアとして多くのRedmineユーザーが利用できるようにします。実現案も簡単に説明するので、興味のある方(実装したい方、データを持っている方、開発費を払ってでも実現したい方)はぜひご連絡ください。

類似issue検索

1つめのアイディアはissue作成時・閲覧時に自動で類似issueを提示する機能です。新しい問題が発生したときに、プラグインが類似issueを提示することで既存のノウハウを活用して早期に問題を解決できます。

類似issue検索の実現案を説明します。issueのテキストを使った類似文書検索(全文検索エンジンGroongaの標準機能)を軸に、Redmine内のメタデータを活用することで精度をあげます。メタデータとは手動で設定した関連issue情報やWiki内のテキストから機械学習した類義語情報などです。

入力補完

2つめのアイディアは入力時に適切な値を随時ユーザーに提示する機能です。たとえば、検索ボックスでキーワードの一部を入力するだけでキーワード全体の補完候補を提示します。これにより、ユーザーの入力コストを下げるだけでなく、適切なキーワードに自然と誘導することになるので、より見つかりやすい検索を実行できます。Groongaは日本語に特化した機能を組み込みで提供しており、ローマ字から日本語を入力補完することもできます。これを使うと「tok」(日本語入力OFFの状態で入力)から「東京都」を補完候補に出すこともでき、よりユーザーの入力コストを下げることができます。

検索というと見つけるためだけに使うというイメージがあるかもしれませんが、入力時にも有用です。タイトルやバージョンなどの入力欄で入力候補を提示することにより、入力内容の表記揺れ・表現の揺れが自然と少なくなります。表記揺れ・表現の揺れが少ないと全文検索エンジンもうれしいですが、理解しやすくなるので読む人もうれしいです。より活用しやすいデータということです。

入力補完の実現案を説明します。Groongaに前方一致検索(ローマ字変換対応)があるので、それを活用します。単に候補を提示するだけでは使いやすいものになりません。候補の最初の方に必要なものが提示されている必要があります。精度も大事ということです。精度をあげるために、全文検索用のインデックスの統計情報を活用します。たとえば、あまりにもヒットしすぎるようなキーワード(たとえば「です」とか)は適切ではありません。統計情報を活用してそのような不要なキーワードを除去します。また、ログやメタデータも活用します。文脈(プロジェクトやトラッカーなど)毎に候補に提示するリストを調整し、より精度の高い候補を提示します。

同義語展開

3つめのアイディアは同義語管理を支援する機能です。日本語では同じものをいろんな呼び方で示すことができます。たとえば、「ネジ」も「ねじ」も「ボルト」も同じものを指します。検索精度を高めるにはこのような同義語に関する情報が必要です。しかし、同義語の管理は大変です。たくさんありますし、文脈が違えば同義語のリストも変わるため共有することが難しいからです。

同義語のリストがあればGroongaが組み込み機能で提供している同義語展開機能を利用できます。検索部分に問題はありません。同義語管理がネックなのです。

同義語管理支援の実現案を説明します。同義語を自動で完璧に整備するのは難しいですが、人の作業を大幅に削減する支援ならできます。そこを狙います。Redmine内には大量のテキストデータがあるはずなので、機械学習で同義語候補を自動で抽出します。また、同義語を管理するUIも実装し、より人の作業を減らします。

スマートナビ(仮)

4つめは「言わなくても欲しいものを提示する機能」です。駅の近くでスマートフォンを見ると、その駅の時刻表を「自動で」表示していませんか?そのように、現在のユーザーの状況から欲しそうなものを自動で検索して提示する機能です。

共同開発者募集

もっとRedmineを活用できるようなアイディアをいくつか紹介しました。クリアコードだけでなく、もっとRedmineを活用したい人たちと協力しながら実現したいです。実現した成果はRedmineと同じくフリーソフトウェアとして多くのRedmineユーザーが利用できるようにします。実現案も簡単に説明するので、興味のある方(実装したい方、データを持っている方、開発費を払ってでも実現したい方)はぜひご連絡ください。

Redmineの開発に参加しよう

Redmineの開発に参加したいとは思っているけどなかなか実現できていない、という方はいませんか?そんな方にオススメなのがOSS Gateが開催しているイベントです。OSS Gateは「OSSの開発に参加する人を継続的に増やす取り組み」です。OSS GateではOSSの開発に参加する人をサポートするイベントを開催しています。RedmineもOSSなのでこのイベントでサポートしてもらいながらRedmineの開発に参加できます。最初の一歩をなかなか踏み出せていない、という人は活用してください。

東京では5月は22日(月)と27日(土)にイベントが開催されます。

大阪と札幌でも開催しています。(redmine.tokyo第12回勉強会には日本各地から参加者がいたので東京以外の情報も紹介しました。)

まとめ

redmine.tokyo第12回勉強会で、Groongaを使ってRedmineの全文検索機能を高速化するプラグインを紹介しました。現時点でできることだけでなく、今後の野望とRedmineの開発に参加したい人向けの情報も紹介しました。

Redmineのデータをもっと活用してより効率的に仕事を進めたくて、検索技術・データ分析技術・分析対象のデータ・開発費用を持っている方はご連絡ください。一緒に実現しましょう。

タグ: Groonga
2017-05-15

Groonga Meatup 2017:Groonga族2016 #groonga

2017年2月9日(年に一度の肉の日!)にGroonga Meatup 2017が開催されました。ここでGroongaMroongaPGroongaの2016一年間での進化の様子を紹介しました。最近のGroonga・Mroonga・PGroongaの情報をざっと把握できるのでご活用ください。

関連リンク:

内容

2016年はGroonga・PGroongaの改良が多く、Mroongaの改良は少なめでした。ただ、MroongaはGroongaを使っているので、Groongaの改良(の多く)はそのままMroongaでも利用できます。そのため、Groonga・PGroongaだけでなくMroongaも進化しています。

Groongaの大きな改良はこのあたりです。

Mroongaの大きな改良はこのあたりです。

  • FOREIGN KEY制約をサポート
  • マルチカラムインデックスの更新性能劣化を解消
  • *SSプラグマを追加(MATCH AGAINSTないでGroongaの検索条件を使える。Groongaの検索条件では複数インデックスを使えるのでMySQLで検索するよりも圧倒的に速くなる。)

PGroongaの大きな改良はこのあたりです。

  • ストリーミングレプリケーションをサポート
  • 検索の高速化(PostgreSQLがより適切な実行計画を選べるように、PostgreSQLにより精度の高い情報を提供するようになった)
  • Zstandardによるカラム圧縮をサポート
  • 各種便利演算子を追加(類似文書検索演算子・前方一致検索演算子・前方一致RK検索演算子)

他にもいろいろあるのでぜひスライドも確認してください。

まとめ

Groonga Meatup 2017で2016年のGroonga族の様子を紹介しました。

なお、今回のGroonga Meatup 2017は東京で開催しましたが、今月、名古屋と大阪でも開催されるので近辺の方はぜひお越しください!

他の発表については以下を参照してください。

おまけみたいな書き方になってしまいますが、同日、初心者向けのMroongaの電子書籍Groongaではじめる全文検索がリリースされました!初心者の人がくじけずに始められるようにできるだけ短い内容(約30ページ!)で動くものが作れる、という内容になっています。具体的にはPHPでMroongaを使ってPDF検索システムを作っています。全文検索をしたいけどどこから始めればいいかピンときていない…という方はまずはこれを読んでみてください。

タグ: Groonga
2017-02-09

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関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。

タグ: Ruby | Groonga
2017-01-27

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

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

ここではMroonga・PGroongaを使わずに日本語全文検索を実現する方法を紹介します。それはGroongaを使う方法です。

GroongaはMroonga・PGroongaのバックエンドで使われている全文検索エンジンです。

Groongaを直接使うメリットは以下の通りです。

  • MySQL・PostgreSQLのオーバーヘッドがない分Mroonga・PGroongaよりもさらに速い
  • 1つのSQLでは表現できないような検索を1クエリーで実現できる(のでさらに速い)

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

  • Mroonga・PGroongaに比べて学習コストが増える(Mroonga・PGroongaはSELECTWHEREでの条件の書き方を学習するくらいでよいが、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.rbApplicationSearcherを作成します。(ジェネレーターはまだ実装されていません。)

class ApplicationSearcher < Groonga::Client::Searcher
end

Documentモデル用のサーチャーDocumentsSearcherapp/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にアクセスし、データが入っていることを確認します。

Qiitaのデータをロード

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

検索フォームでは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が効いていることがわかります。

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

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

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

ドリルダウン

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

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

タグを作ります

% 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件になっています。

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

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

キーワードハイライト

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

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」で全文検索した状態では次のようになります。どこにキーワードがあるかすぐにわかりますね。

「全文検索」タグでドリルダウンして「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関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。

つづき: 2017-01-27
タグ: Ruby | Groonga
2016-12-22

PGConf.ASIA 2016:PGroonga – PostgreSQLを全言語対応高速全文検索プラットフォームに! #pgconfasia

2016年12月1日から12月3日にかけて開催されたPostgreSQLの国際カンファレンスPGConf.ASIA 2016PGroongaというPostgreSQLの全文検索モジュールの話をしました。

関連リンク:

内容

前半はPostgreSQLの全文検索まわりの課題の説明とPGroongaがどうやってそれらの課題を解決するかという内容になっています。

後半はPGroongaの説明になっています。

PostgreSQLの全文検索まわりの課題は次の通りです。

  • PostgreSQLが標準で提供する全文検索機能(textesarchcontrib/pg_trgm)は日本語や中国語などアジア圏の言語をサポートしていない。(アジア圏の言語以外にもサポートしていない言語はある。)
  • pg_bigmというモジュールを導入することで全言語をサポートできるがヒット数が増えると遅くなりがち。

PGroongaはこれらの課題を解決します。PGroongaは全言語対応でヒット数が増えても高速な全文検索機能を提供します。

以下はpg_bigmとの検索時間の比較です。棒が短いほど速いです。pg_bigm(青い棒)は極端に遅くなるケースがありますが、PGroonga(紫の棒)は安定して高速です。

PGroongaとpg_bigmの検索時間

この検索時間の比較はデータとして日本語版Wikipediaを用いています。他にもデータとして英語版Wikipediaを用いて、PGroongaとPostgreSQLが標準で提供する全文検索機能を比較したデータもあります。詳細はスライドの内容あるいは以下のドキュメントを参照してください。

後半のPGroongaの説明では以下のことに触れました。詳細はスライドと以下のリストに含まれているリンクを参考にしてください。

まとめ

PGConf.ASIA 2016でPGroongaについて話しました。現在のPostgreSQLの全文検索機能には課題がありますが、PGroongaを組み込むことで、PostgreSQLでアジア圏の言語(もちろん日本語も含む!)でも実用的な全文検索機能を実現することができます。PostgreSQLでの全文検索機能でこまったらPGroongaを検討してみてください。

つづき: 2017-01-05
タグ: Groonga
2016-12-06

Groongaでのmrubyの組み込み方:ビルド周り

全文検索エンジンGroongamrubyを組み込んでいます。理由は、速度はそれほど必要ではなく込み入った処理をCではなくRubyで書けると開発が捗るからです。

この記事ではどのようにmrubyを組み込んでいるかについてビルド周りだけを説明します。(ビルド周り以外には、バインディングをどうやって書くか、.rbファイルをどこに置くか、実装をCにするかRubyにするかの判断基準などの話があります。)

方針

mrubyはRakeを使ったビルドシステムを使っています。GroongaはGNU AutotoolsまたはCMakeを使ったビルドシステムを使っています。(どちらでもビルドできます。)

mrubyはRakeを使ってビルド、GroongaはGNU Autotoolsを使ってビルド、とするとコンパイルオプションの統一・クロスコンパイルあたりで面倒になります。また、Rakeを使ってビルドするためにはビルド時にRubyが必要になるのも面倒です。Groongaの開発者がGroongaをビルドするためにRubyが必要になるのはよいですが、GroongaのユーザーがGroongaをビルドするためにRubyが必要になるのはビルドの敷居が上がるのでできれば避けたいです。

以上の理由からGroongaではmrubyをビルドするためにRakeを使っていません。

ではどうしているかというと、次のようにしています。

  1. Groonga開発者はRakeを使って(= Rubyが必要)mrubyのビルドに必要なファイルを生成(一部自動生成のファイルがあるため)
  2. Groongaはmrubyのビルドに必要なファイルをGNU Autotools(またはCMake)を使ったビルドシステムに統合(= mrubyのビルドにRakeを使っていない = Rubyは必要ない)
  3. Groonga開発者はリリース版のソースアーカイブに1.で生成したファイルをすべて含める(= Groongaユーザーは1.を実行する必要がない = Rubyは必要ない)

それぞれどのようにしているか説明します。

Groongaの開発者はRakeを使ってmrubyのビルドに必要なファイルを生成

Groongaはvendor/mruby-sourceをGitのsubmoduleにしています。つまり、mrubyのリポジトリーのソースをまるっと参照しています。

この状態ではmrubyのビルドに必要なファイルは足りません。具体的にはRubyで実装されたコードをmrubyに組み込むファイル(mrblib.cmrbc -Bで生成するファイル。)や利用するmrbgemsを組み込むファイル(mrbgems_init.c)が足りません。

これらを生成するためにRakeを使ってビルドします。出力先はvendor/mruby-build/にしています。ビルドした後vendor/mruby-build/に出力されたファイルの中から必要なファイルをvendor/mruby/にコピーします。vendor/mruby-build/を直接ビルドに使って「いません」。ビルドに使っているのはvendor/mruby/にコピーしたファイルです。このあたりの実装がvendor/mruby/mruby_build.rbです。

Groongaはmrubyのビルドに必要なファイルをGNU Autotools(またはCMake)を使ったビルドシステムに統合

必要なファイルが揃ったらGNU Autotools(またはCMake)のビルドシステムに統合することは難しくありません。

GNU Autotoolsの場合はvendor/mruby/Makefile.amで実現しています。

CMakeの場合はvendor/mruby/CMakeLists.txtで実現しています。

工夫しているところはソースファイルのリストを共有しているところくらいです。mruby本体やmrbgemsが更新されるとソースファイルのリストは変わることがあるのでvendor/mruby/update.rbで自動生成しています。Makefile.amCMakeLists.txtでは自動生成されたリストを読み込んでビルドシステムに統合しています。

Groonga開発者はリリース版のソースアーカイブに生成したファイルをすべて含める

mrubyをRakeでビルドして生成されたファイルをソースアーカイブに含めることでGroongaユーザーはビルドするときにRubyがなくてもビルドできます。

ソースアーカイブには生成されたファイルといつ生成されたかを示すタイムスタンプファイルを入れています。タイムスタンプファイルが新しければ再生成(mrubyをRakeでビルドし直すこと)せずにすでにあるファイルを使うようにしています。こうすることでソースアーカイブからビルドするGroongaユーザーはRubyがなくてもビルドできるようになっています。

実現方法のポイントはvendor/mruby/Makefile.amBUILT_SOURCESlibmruby_la_SOURCESに追加しているところとmruby_build.timestampがあったら再生成しないようにしているところです。

libmruby_la_SOURCES += $(BUILT_SOURCES)
mrblib.c: mruby_build.timestamp
mrbgems_init.c: mruby_build.timestamp
# ...

mruby_build.timestamp: build_config.rb version
#   ...

まとめ

Groongaがどうやってmrubyを組み込んでいるかについてビルド周りだけを説明しました。

Groongaのmruby組み込み周りを触る人や自分のアプリケーションにmrubyを組み込みたい人は参考にしてください。

つづき: 2017-01-05
タグ: Groonga
2016-10-13

MySQLとPostgreSQLと日本語全文検索3:MroongaとPGroongaの導入方法例 #mypgft

2016年9月29日(肉の日!)に「MySQLとPostgreSQLと日本語全文検索3」というイベントを開催しました。その名の通りMySQLとPostgreSQLでの日本語全文検索についての話題を扱うイベントです。今回もDMM.comラボさんに会場を提供してもらいました。

2月9日に開催した1回目のイベントではMroongaPGroongaについては次の2つのことについて紹介しました。

  • Mroonga・PGroongaが速いということ
  • Mroonga・PGroongaの使い方

6月9日に開催した2回目のイベントではMroonga・PGroongaについては次の2つのことについて紹介しました。

  • Mroonga・PGroongaのオススメの使い方
  • レプリケーションまわり

今回はMroonga・PGroongaについては次のことについて紹介しました。

関連リンク:

Redmineへの導入方法

Redmineというチケット管理システムへのMroonga・PGroongaの導入方法を説明します。RedmineはRuby on Railsを利用しているのでRuby on Railsを使っているアプリケーションに導入する例ということになります。

Redmineは右上の検索ボックスから全文検索できます。ここから全文検索したときにMroonga・PGroongaを使うようにします。

Redmineの検索ボックス

redmine_full_text_searchプラグインを使うとRedmineでMroongaまたはPGroongaを使って全文検索できるようになります。

このプラグインを使うとRedmineの全文検索が高速になります。たとえば、クリアコードで使っているRedmineには3000件くらいのチケットがありますが、その環境では次のように高速になりました。

プラグイン 時間
なし 467ms
あり 93ms

200万件のチケットがある環境でも約380msで検索できているという報告もあります。

Mroongaを導入する方法

Mroongaはトランザクションに対応していないのでトランザクションが必須のRedmineに組み込む場合はひと工夫必要になります。単純に、ALTER TABLE table ENGINE=Mroonga ADD FULLTEXT INDEX (column)とするわけにはいきません。

ではどうするかというと別途全文検索用のテーブルを作成して元のテーブルとはJOINできるようにします。(他にもレプリケーションしてレプリケーション先をMroongaにするという2回目のイベントで紹介した方法もありますが、プラグインでやるには大掛かりなのでこの方法を使っています。)

マイグレーションファイルでいうと次のようにします。ここではissuesテーブル用の全文検索用のテーブルを作成しています。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
    t.index [:subject, :description], type: "fulltext"
  end
end

全文検索用のテーブルには元のデータをコピーする必要があります。マイグレーション時には既存のデータを一気にコピーします。そのため、本当のマイグレーションの内容は次のようになります。データコピー後にインデックスを追加するようにしているのはそっちの方が速いからです。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
  end
  execute("INSERT INTO " + # データをコピー
            "fts_issues(issue_id, subject, description) " +
            "SELECT id, subject, description FROM issues;")
  add_index(:fts_issues, [:subject, :description],
            type: "fulltext") # 静的インデックス構築(速い)
end

このテーブルのモデルは次のようになります。

class FtsIssue < ActiveRecord::Base
  # 実際はissue_idカラムは主キーではない。
  # 主キーなしのテーブルなので
  # Active Recordをごまかしているだけ。
  self.primary_key = :issue_id
  belongs_to :issue
end

Mroonga導入後に更新されたデータはアプリケーション(Redmine)側でデータをコピーします。Active Recordのafter_saveフックを利用します。Mroongaがトランザクションをサポートしていないため、ロールバックのタイミングによってはデータに不整合が発生することがありますが、再度保存すれば復旧できることとそれほどロールバックは発生しないため、実運用時には問題になることはないでしょう。

class Issue
  # この後にロールバックされることがあるのでカンペキではない
  # 再度同じチケットを更新するかデータを入れ直せば直る
  after_save do |record|
    fts_record = FtsIssue.find_or_initialize_by(issue_id: record.id)
    fts_record.subject     = record.subject
    fts_record.description = record.description
    fts_record.save!
  end
end

全文検索時は全文検索用のテーブルをJOINしてMATCH AGAINSTを使います。

issue.
  joins(:fts_issue).
  where(["MATCH(fts_issues.subject, " +
               "fts_issues.description) " +
          "AGAINST (? IN BOOLEAN MODE)",
         # ↓デフォルトANDで全文検索
         "*D+ #{keywords.join(', ')}"])

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

PGroongaを導入する方法

PGroongaはトランザクションに対応しているので別途全文検索用のテーブルを作成する必要はありません。既存のテーブルに全文検索用のインデックスを作成します。

マイグレーションファイルでいうと次のようにします。ここではissuesテーブルに全文検索用のインデックスを作成しています。enable_extension("pgroonga")はPGroongaを使えるようにするためのSQLです。

def up
  enable_extension("pgroonga")
  add_index(:issues,
            [:id, :subject, :description],
            using: "pgroonga")
end

あとは検索時に全文検索条件をつけるだけです。

issue.
  # 検索対象のカラムごとに
  # クエリーを指定
  where(["subject @@ ? OR " +
         "description @@ ?",
         keywords.join(", "),
         keywords.join(", ")])

この説明もわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

Zulipへの導入方法

ZulipというチャットツールへのPGroongaの導入方法を説明します。ZulipはPostgreSQLを使っているので、導入するのはPGroongaだけです。ZulipはDjangoを使っているのでDjangoを使っているアプリケーションに導入する例ということになります。

Zulipは上部の検索ボックスから全文検索できます。ここから全文検索したときにPGroongaを使うようにします。

Zulipの検索ボックス

Zulipはチャットツールです。チャットツールなので小さなテキストの書き込みが頻繁に発生する傾向があります。各書き込みは十分速く完了する必要があります。書き込みが遅いとユーザーの不満が溜まりやすいからです。

Zulipは書き込みをできるだけ速くするためにインデックスの更新を遅延させています。インデックスの更新はデータの追加よりも重い処理なので、その処理を後回しにしているということです。(PGroongaは検索だけでなく更新も速いので遅延させずにリアルタイムで更新しても十分速いかもしれません。アプリケーションの要件次第でどのような実装にするか検討する必要があります。)

Zulipは、インデックスの更新を遅延させるため、カラムの値を直接全文検索対象にせずに、別途全文検索用のカラム(zulip_message.search_pgroongaカラム)を用意しています。その全文検索用のカラムの更新を後回しにすることでインデックスの更新を遅延させています。

マイグレーションファイルでいうと次のようにします。最初のALTER ROLEはPGroongaが提供する@@という全文検索用の演算子の優先順位を調整するためのものです。本質ではないのでここでは気にしなくて構いません。

migrations.RunSQL("""
ALTER ROLE zulip SET search_path
  TO zulip,public,pgroonga,pg_catalog;
ALTER TABLE zerver_message
  ADD COLUMN search_pgroonga text;
UPDATE zerver_message SET search_pgroonga =
  subject || ' ' || rendered_content;
CREATE INDEX pgrn_index ON zerver_message
  USING pgroonga(search_pgroonga);
""", "...")

全文検索対象のカラム(zerver_message.subjectカラムとzerver_message.rendered_contentカラム)が更新されたらそのレコードのIDをログテーブル(fts_update_logテーブル)に追加します。Zulipは次のトリガーでこれを実現しています。

CREATE FUNCTION append_to_fts_update_log()
  RETURNS trigger
  LANGUAGE plpgsql AS $$
    BEGIN
      INSERT INTO fts_update_log (message_id) VALUES (NEW.id);
      RETURN NEW;
    END
  $$;
CREATE TRIGGER update_fts_index_async
  BEFORE INSERT OR UPDATE OF
    subject, rendered_content ON zerver_message
  FOR EACH ROW
    EXECUTE PROCEDURE append_to_fts_update_log();

全文検索対象のカラムのインデックスは別プロセスで更新します。別プロセスで更新するためには、全文検索対象のカラムが更新されたことをその別プロセスが知らなければいけません。これを実現するためにはポーリングする方法と更新した側から通知を受け取る方法があります。PostgreSQLにはLISTEN/NOTIFYという通知の仕組みがあるので、Zulipはこれらを利用して通知を受け取る方法を実現しています。

更新した側は次のトリガーで更新したことを(fts_update_logチャネルに)通知します。このトリガーはログテーブル(fts_update_logテーブル)にレコードが追加されたら呼ばれるようになっているので、レコードが追加されるごとに通知しているということです。

CREATE FUNCTION do_notify_fts_update_log()
  RETURNS trigger
  LANGUAGE plpgsql AS $$
    BEGIN
      NOTIFY fts_update_log;
      RETURN NEW;
    END
  $$;
CREATE TRIGGER fts_update_log_notify
  AFTER INSERT ON fts_update_log
  FOR EACH STATEMENT
    EXECUTE PROCEDURE do_notify_fts_update_log();

通知を受け取るプロセスはPythonで実装されています。単純化すると次のようになっています。(詳細はpuppet/zulip/files/postgresql/processftsupdatesを参照。)(fts_update_logチャネルに)通知がきたら全文検索用カラムを更新する(update_fts_columns(cursor)を実行する)ということを繰り返しています。

  import psycopg2
  conn = psycopg2.connect("user=zulip")
  cursor = conn.cursor
  cursor.execute("LISTEN fts_update_log;")
  while True:
      if select.select([conn], [], [], 30) != ([], [], []):
          conn.poll()
          while conn.notifies:
              conn.notifies.pop()
              update_fts_columns(cursor)

全文検索用カラムの更新(update_fts_columnsの実装)は次のようになっています。ログテーブル(fts_update_logテーブル)から更新されたレコードのIDを取得してきて各レコードごとに全文検索用カラムを更新しています。最後に処理したレコードのIDをログテーブルから削除します。

def update_fts_columns(cursor):
    cursor.execute("SELECT id, message_id FROM fts_update_log;")
    ids = []
    for (id, message_id) in cursor.fetchall():
        cursor.execute("UPDATE zerver_message SET "
                       "search_pgroonga = "
                       "subject || ' ' || rendered_content "
                       "WHERE id = %s", (message_id,))
        ids.append(id)
    cursor.execute("DELETE FROM fts_update_log "
                   "WHERE id = ANY(%s)", (ids,))

このようにしてインデックスの更新を遅延し、書き込み時の処理時間を短くしています。書き込み時のレスポンスが大事なチャットツールならではの工夫です。

インデックスが更新できたらあとは全文検索するだけです。全文検索は次のようにWHERE search_pgroonga @@ 'クエリー'を追加するだけです。

from sqlalchemy.sql import column
def _by_search_pgroonga(self, query, operand):
    # WHERE search_pgroonga @@ 'クエリー'
    target = column("search_pgroonga")
    condition = target.op("@@")(operand)
    return query.where(condition)

全文検索してヒットしたキーワードがどこにあるかを見つけやすくするために、Zulipはキーワードハイライト機能を実現しています。以下は「problem」というキーワードをハイライトしている様子です。

Zulipのキーワードハイライト機能

PostgreSQLには標準でts_headline()関数というキーワードハイライト機能がありますが、ZulipのようにHTMLで結果を取得したい場合には使えません。これはts_headline()関数はHTMLエスケープ機能を提供していないからです。HTMLエスケープ機能がないと次のように不正なHTMLができあがってしまいます。

SELECT  ts_headline('english',
                    'PostgreSQL <is> great!',
                    to_tsquery('PostgreSQL'),
                    'HighlightAll=TRUE');
--           ts_headline          
-- -------------------------------
--  <b>PostgreSQL</b> <is> great!
-- (1 row)  不正なHTML↑

そのため、ZulipではPostgreSQLにキーワード出現位置を返す関数を追加して、Zulip側でキーワードハイライト機能を実現しています。PGroongaを使っている場合はpgroonga.match_positions_byte()関数pgroonga.query_extract_keywords()関数を利用してこの機能を実現しています。

なお、PGroongaはHTMLエスケープ機能付きのハイライト関数pgroonga.highlight_html()を提供しているため、Zulipのようにアプリケーション側でハイライト機能の一部を実装する必要はありません。Zulipではすでに実装されていたためPGroongaを使った場合でもpgroonga.highlight_html()関数を使わずにハイライト機能を実現しています。

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

まとめ

MySQLとPostgreSQLと日本語全文検索3で、実例をもとにMroonga・PGroongaの導入方法を紹介しました。たとえMySQL・PostgreSQLレベルで日本語全文検索できても、実際にアプリケーションで使えるようにならないとユーザーに日本語全文検索を提供できません。そのため、このような導入方法の紹介にしました。

アプリケーションごとになにを大事にするかは変わるので、この事例をそのまま適用できるわけではありませんが、Mroonga・PGroongaを導入する際には参考にしてください。

つづき: 2017-01-05
タグ: Groonga
2016-09-29

MariaDBコミュニティイベント in Tokyo:Mroonga 2016 - 高速日本語全文検索 for MariaDB

2016年7月21日に「MariaDBコミュニティイベント in Tokyo」というイベントが開催されました。発表中でも随時参加者から質問があがり質疑応答が始まるという、日本のイベントではあまり見られない活発なイベントでした。

このイベントでMariaDBユーザー向けの高速全文検索ソリューションとしてMroongaを紹介しました。

関連リンク:

Mroongaを知らない人向けの資料となっています。MariaDBで日本語全文検索を実現する方法を検討している方はご活用ください。

つづき: 2017-01-05
タグ: Groonga
2016-07-22

MariaDB コミュニティイベント in Tokyo開催のおしらせ

2016年7月21日(木)の午後に「MariaDB コミュニティイベント in Tokyo」というイベントが開催されます。MariaDBの開発に関わっている人(エヴァンジェリストの人)が来日しており、その人からMraiaDBの最新情報を説明してもらったり、その人に質問したりできます。他にもAll AboutがどのようにMariaDBを使っているかという話(たぶん)や、MariaDBにバンドルされているMroonga(全文検索用プラグイン)・Spider(シャーディング用プラグイン)の最新情報の話もあります。MroongaとSpiderはそれぞれ開発している人が話をするので突っ込んだ質問をすることもできます。この機会をぜひ活かしてください。

イベントに参加するには事前に登録する必要があります。以下のどのイベントページからでも登録できるので自分が使いやすいイベントページを選んでください。

無料のイベントなので、MariaDBの最新情報を知りたい、MariaDBがどのように使われているか知りたい、MariaDBでの全文検索・シャーディングについて知りたい、という方はぜひお気軽にお越しください。

つづき: 2016-07-22
タグ: Groonga
2016-07-12

2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|
タグ:
RubyKaigi 2015 sponsor RubyKaigi 2015 speaker RubyKaigi 2015 committer RubyKaigi 2014 official-sponsor RubyKaigi 2014 speaker RubyKaigi 2014 committer RubyKaigi 2013 OfficialSponsor RubyKaigi 2013 Speaker RubyKaigi 2013 Committer SapporoRubyKaigi 2012 OfficialSponsor SapporoRubyKaigi 2012 Speaker RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer badge_speaker.gif RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer
SapporoRubyKaigi02Sponsor
SapporoRubyKaigi02Speaker
RubyKaigi2009Sponsor
RubyKaigi2009Speaker
RubyKaigi2008Speaker