CreateField Blog

オープンソースを使って個人でWebサービスを開発・運営していたブログ

Deploy to HerokuボタンでGitベースのWiki gollumを無料で簡単に作れるようにした

GitHubのWikiが検索できなくて不便だなぁとか思ったりしてたら、このWikiはGitベースでできており、gollumというオープンソースで公開されていることを知りました。

github.com

そこで、ちょろっと試すためにHerokuで動くようにしてみました。 GitHubのトークンとHerokuのアカウントがあれば数分で個人用のWikiがつくれます。既存のGitHubのWikiを指定して開くこともできます。gollumには一応簡単な検索機能はあるようです。

Deploy

naoa/gollum-on-heroku · GitHub

  • デモ

Gollum on Heroku

追加機能

Herokuでの利用を想定して以下の機能を追加しています。

  • GitHubへの同期

gollumでは更新があるとローカルのgitリポジトリにcommitされます。そのため、Herokuではdynoが再起動されると更新が消えてしまいます。

そこでサーバ起動時にGitHubのリポジトリをcloneし、Wikiが更新されると自動的にGitHubにpushするようにしています。*1

  • Basic認証

gollumのフロントエンドはSinatraで非常にシンプルに実装されており、認証機能などはありません。環境変数で全ページもしくは編集機能のみをBasic認証を設定できるようにしています。

  • 複数のgitリポジトリを利用可

1つのgitリポジトリだけでなく、複数のgitリポジトリを指定して起動できるようにしています。

必要なもの

環境変数で以下を指定する必要があります。

  • GitHubのPersonal access tokens GITHUB_TOKEN

これを指定しておかないと、Heroku上で直接更新しても再起動時に消えてしまいます。*2 GitHubのsettings -> Personal access tokensから取得することができます。

  • リモートのgitリポジトリのURL GIT_REPO_URL_1 GIT_REPO_URL_2~

wikiによって生成されたmdファイルなどが保存されるgitリポジトリを指定します。 既存のGitHubの任意のリポジトリのwikiを直接指定することもできます。*3 複数指定することができます。複数指定した場合、リポジトリ名ごとにURLが割り当てられます。*4

  • 編集者のアカウントAUTHOR_NAME emailアドレス AUTHOR_EMAIL

git config user.name, user.emailとかで設定するやつを設定しておきます。 これがないとGitHubのコミット履歴がunknownになっちゃいます。*5

  • Basic認証用のアカウント BASIC_AUTH_USERNAME BASIC_AUTH_PASSWORD

認証が必要な場合。編集機能のみを保護したい場合はBASIC_AUTH_MANAGE_ONLYにtrueを設定します。

  • その他gollumの起動オプション GOLLUM_~

gollumの起動オプションを指定できます。詳細は以下参照。

https://github.com/gollum/gollum#configuration

おわりに

gollumは非常にシンプルでちょっとした個人用のwikiをさくっと使うためにはいいかもしれません。 またSintatraベースでカスタマイズしやすそうです。

共同で利用するには、OAUTH認証を追加したりsession['gollum.author']を設定したり、もう少し手を加える必要がありそうです。

*1:サイズがでかくなってくるとcloneに時間がかかるようになり、起動が遅くなるかも。

*2:ローカルなどで更新してDeployするだけであれば、なくてもいいかもしれません。

*3:GitHubのwikiってリポジトリ名に.wikiってつけてgit cloneなどすると直接取得できるんですね。初めて知りました。たとえば、https://github.com/naoa/test.wiki

*4:その場合、今のところルートはなにもありません。

*5:ちなみにGitHubのコミットログってアカウントの認証関係なく、適当にemailアドレス設定すると勝手に人の名前使えそうですね。

Railsで高速全文検索エンジンMroongaを使うためのチュートリアル

はじめに

MySQLでオープンソースの日本語対応の高速な全文検索エンジンGroongaが使えるMroongaを使って簡単に全文検索機能付きのRailsアプリケーションを作成する方法を紹介します。Railsのデモアプリケーションと実際に使えるサンプルの検索用のメソッドを使って具体的に説明します。

前準備

まず、RailsとMySQLとMroongaが使えるようにしてください。 これらはすでに情報がたくさんあると思うので、さほど苦なく用意できると思います。最近のMariaDBであればデフォルトでバンドルされていたりします。

簡単に試すことができるようにRubyとMySQLとMroongaが自動で環境構築されるVagrantファイルを用意しておきました。

naoa/start-mroonga-with-rails · GitHub

vagrantとubuntu/trusty64のboxとvagrant-omnibusあたりをあらかじめ用意しておき、以下のようにして仮想環境を構築してゲストOSにログインします。

% git clone https://github.com/naoa/start-mroonga-with-rails
% cd start-mroonga-with-rails
% vagrant up
% vagrant ssh

ちなみにRubyのビルドとかが入っているのでかなり時間がかかります。以下のコマンドを実行するとMySQLにMroongaが認識されていることがわかります。

% mysql -uroot -ppassword -e "SHOW ENGINES;"

Railsのインストール

bundlerを使ってRailsをインストールします。別にbundler管理下じゃなくてもいいです。すでにRails環境がある場合は不要です。

% cd /vagrant/
% bundle init
% Gemfile 
gem "rails" #コメントアウト除去
% bundle install

デモアプリケーションの作成

Railsチュートリアルの第2章にあるデモアプリケーションを作ります。

% bundle exec rails new demo_app -d mysql
% cd demo_app
% vi Gemfile
gem 'therubyracer', platforms: :ruby #コメントアウト除去
% bundle install

上記のVagrantfileではmysqlのrootユーザのパスワードをpasswordに設定しているので、config/database.yamlにパスワードを設定します。

% vi config/database.yml
password: password

チュートリアルの手順に沿って、UserとMicropostの簡単なCRUDアプリケーションを作ります。

% bundle exec rake db:create
% bundle exec rails generate scaffold User name:string email:string
% bundle exec rails generate scaffold Micropost content:string user_id:integer
% bundle exec rake db:migrate
% bundle exec rails s -b 0.0.0.0

これでブラウザでhttp://localhost:30000/microposts (上記のvagrantのやつの場合)にアクセスすることにより簡単なポスト機能のRailsアプリケーションが動作していることが確認できます。

このMicropostにMroongaを使った全文検索機能を追加してみます。

Migration関連の拡張Gemのインストール

MySQLのデフォルトでは、InnoDBというストレージエンジンが利用されます。

MySQLでストレージエンジンを指定するにはテーブルオプションにENGINE=を書きます。また、Mroongaでは、カラムとインデックスのコメントを利用することにより転置索引の見出し語をカスタマイズするためのトークナイザーや文字列を正規化するノーマライザーを変更することができます。

現状のActiveRecordではmigrationスクリプトのcreate_tableにテーブルオプションを書く機能はあるのですが、schema.rbにダンプする機能はありません。また、カラムコメントやインデックスコメントについても対応していません。

schema.rbにダンプできなくてもマイグレーションスクリプトによりSQL自体は実行できるのですが、rake db:resetのようなschema.rbを元にテーブルを復元するようなことはできなくなります。このため、SQLで直接テーブル定義を変更するような場合は、schema_format:sqlにする必要があります。この場合、schema.rbではなくstructure.sqlで管理することになります。

これらに対応するため、activereord-mysql-awesomeactiverecord-mysql-commentのgemをインストールします。両方ともRails4.2.0時点では正常に動作します。

% vi Gemfile
gem 'activerecord-mysql-awesome'
gem 'activerecord-mysql-comment'
% bundle install

なお、activerecord-mysql-commentactivereord-mysql-awesomeよりも後に書いてください(一部の追加アクセサが上書きされちゃうため)。

ストレージエンジン変更

micropostsのテーブルをMroongaストレージエンジンに変更します。Railsはテーブルオプションの変更に対応していないので、一旦、ロールバックで削除します。別にdrop_tableで削除してもかまいません。

% bundle exec rake db:rollback

マイグレーションスクリプトに以下のようにENGINE=Mroongaのテーブルオプションを追記します。

class CreateMicroposts < ActiveRecord::Migration
  def change
    create_table :microposts, options: 'ENGINE=Mroonga' do |t|
      t.string :content
      t.integer :user_id
      t.timestamps
    end
  end
end

これでマイグレートすれば、Mroongaストレージエンジンでmicropostsテーブルが作成されます。以下のコマンドでMySQLのテーブル定義を確認することができます。stringだと255文字と短いのでtextとかにしておいてもいいかもしれません。

% bundle exec rake db:migrate
% mysql -uroot -ppassword demo_app_development -e "SHOW CREATE TABLE microposts;"

+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table      | Create Table                                                                                                                                                                                                                                                                                                                                                                                                   |
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| microposts | CREATE TABLE `microposts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `user_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=Mroonga AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci |
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

全文インデックスの追加

Mroongaストレージエンジンで高速な全文検索機能を使うには、以下のようにしてFULLTEXT INDEXを追加します。

% bundle exec rails g migration AddFullTextIndexToMicroposts
class AddFullTextIndexToMicroposts < ActiveRecord::Migration
  def change
    add_index :microposts, :content, type: :fulltext
  end
end

これでマイグレートすれば、全文インデックスが追加されます。

% bundle exec rake db:migrate
% mysql -uroot -ppassword demo_app_development -e "SHOW CREATE TABLE microposts;"
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

| Table      | Create Table                                                                                                                                                                                                                                                                                                                                                                                                   |

+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| microposts | CREATE TABLE `microposts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `user_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  FULLTEXT KEY `index_microposts_on_content` (`content`)
) ENGINE=Mroonga AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci |
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

schema.rbでも以下のようにきちんとテーブルオプションとインデックスが出力されています。

  create_table "microposts", force: :cascade, options: "ENGINE=Mroonga DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci" do |t|
    t.string   "content",    limit: 255
    t.integer  "user_id",    limit: 4
    t.datetime "created_at"
    t.datetime "updated_at"
  end

  add_index "microposts", ["content"], name: "index_microposts_on_content", type: :fulltext

これでデータベースの準備はできました。次はRailsアプリケーションに検索機能を追加します。

Viewに検索フォームの追加

microposts#indexに非常に簡単な検索フォームを追加します。app/views/microposts/index.html.erbに以下を追記します。

<h2>Search Microposts</h2>

<%= render 'search_form' %>

以下のapp/views/microposts/_search_form.html.erbを追加します。

<%= form_tag microposts_path, method: :get do |f| %>
  <div class="field">
    <%= text_field_tag :keyword %>
  </div>
  <div class="actions">
    <%= submit_tag "Search", :name => nil %>
  </div>
<% end %>

これでSearchボタンをクリックするとフォームの内容がparams[:keyword]としてmicropostsコントローラのindexアクションに渡されます。

コントローラの修正

デモアプリケーションでは、app/controllers/microposts_controller.rbのindexアクションは以下のようになっており、Micropostの全てを出力するようになっています。

  def index
    @microposts = Micropost.all
  end

これを検索フォームのパラメータにより全文検索するように変更します。

  def index
    query = params[:keyword]
    columns = "content"
    @microposts = Micropost.where("MATCH(#{columns}) AGAINST('#{query}' IN BOOLEAN MODE)")
  end

MroongaではWHERE句にMATCH([col_name1, col_name2…]) AGAINST('検索クエリ' IN BOOLEAN MODE)の構文で全文検索することができます。通常はIN BOOLEAN MODEをつけてください。デフォルトではIN NATURAL LANGUAGE MODEの自然文検索になります*1。だいたいの用途ではIN BOOLEAN MODEでいいはずです。

これで検索フォームの内容で全文検索した結果が得られます。いくつかデータをポストしてみて試してみると検索したレコードのみが取得できることがわかると思います。

なお、このままでは検索フォームに値なしの場合0件になっているので注意です。フォームに値なしの場合をハンドリングすべきです。また、詳細なロジックを書く場合、モデルに書いた方が良いでしょう。

モデル共通のスコープ化

MATCH … AGAINST … IN BOOLEAN MODEとか長いの毎回書きたくないと思うので、ActiveSupport::Concernの機能で全モデル共通のスコープ化させておきます。

  • app/models/concerns/mroonga.rb
module Mroonga
  extend ActiveSupport::Concern
  included do
    scope :mrn_search, ->(query, columns) do
      return if query.nil?
      where("MATCH(#{columns}) AGAINST('#{query}' IN BOOLEAN MODE)")
    end
  end
end

全文検索したいモデルでincludeします。

  • app/models/micropost.rb
class Micropost < ActiveRecord::Base
  include Mroonga
end

これでコントローラー側はシンプルになります。ちなみにこの例のスコープの第二引数のcolumnsは、Arrayじゃなくて”,”区切りのStringです。

  • app/controlers/microposts.rb
  def index
    query = params[:keyword]
    columns = "content"
    @microposts = Micropost.mrn_search(query, columns)
  end

scopeでチェインしてActiveRecord::Relationが返るので、通常のActiveRecordと同様にソートなどのメソッドチェインやkaminariなどのページングライブラリなどがそのまま利用可能です。

なお検索フォームには、単語だけでなく、Googleなどと同様にAND OR NOTのブール演算式で複数の単語の組み合わせで全文検索したりフレーズ検索したりすることができます。MroongaではAND+ORORNOT-の演算子をつけます。たとえば、今日の両方が含まれるものを検索する場合には今日 +雨と入力します。デフォルトでは演算子なしはORになっているので注意してください。ANDに変更するには、先頭に*D+というDプラグマをつけます。詳細はこちらを参照してください。

ここまでで基本的な全文検索の機能は使えると思います。

この後はいくつかのオプション機能や関数を使う方法を紹介します。

スニペット

Mroongaでは全文検索でヒットしたキーワードの周辺のスニペット(断片)を抽出するmroonga_snippet関数が提供されています。

http://mroonga.org/ja/docs/reference/udf/mroonga_snippet.html

ちょっとすぐに試す気にはなれない、非常に長いシンタックスですね。そこで検索クエリとカラム指定だけで自動で組んでくれるスコープ例を作っておきました。app/model/concern/mroonga.rbmrn_snippetmrn_extract_keywordsをコピペして使ってみてください。

https://github.com/naoa/start-mroonga-with-rails/blob/master/mroonga.rb#L34-L89

これでMicropostモデルにmrn_snippet(query, columns)をチェインさせるだけでそのカラム全文の代わりに検索ワードがタグで囲まれた150バイト分のスニペットが得られるようになります。

  • app/controllers/microposts_controller.rb
  def index
    query = params[:keyword]
    columns = "content"
    @microposts = Micropost.mrn_search(query, columns).mrn_snippet(query, columns)
  end
...<span class="keyword">今日</span>は雨だな...

上記のMySQLのmroonga_snippet関数は検索クエリではなく検索ワードごとにタグを指定する必要があるのですが、mrn_snippetスコープは検索クエリから自動でワードを正規表現で抽出するようにしています。なお、だいたいは動くと思いますがあまり厳密にはやっていないので気になるところがあれば、適宜修正してください。

これで、検索結果一覧で検索ワードのみを太字やハイライトさせてその周辺のみを表示させることができます。

f:id:naoa_y:20150225115014p:plain

他の検索機能

Mroongaではデフォルトの演算子を変更するDプラグマやカラムごとの重みを変更するWプラグマや複数のワードが近い距離で出現している文書のみを抽出する近傍検索演算子*Nなどがあります。GitHubにざっくりとこれらを使えるオプションを全部盛りしたスコープ例mrn_searchを作っておいたので、よければコピペして試してみてください。これもだいたい動くと思いますが、あまりテストしていないので不具合があれば、適宜修正してください。

たとえば、近傍検索だと以下のようにオプションを指定します。

  • app/controllers/microposts_controller.rb
@microposts = Micropost
  .mrn_search(query, columns, near: {distance: 5, words: ['今日', '']})

これで今日5の距離以内に出現する文書のみが検索されます。距離はデフォルトではほぼ文字数に相当します。MeCabなどの形態素解析のトークナイザーを使う場合は単語の数だけ離れた距離になります。

トークナイザー、ノーマライザーの変更

MroongaのデフォルトのトークナイザーTokenBigramでは、アルファベット記号数字は同一字種ひとまとまりにしてトークナイズされます。たとえばDatabaseという単語に対して、tabaという検索クエリではヒットしません。アルファベットや記号を文字単位でヒットさせたい場合はTokenBigramSplitSymbolAlpha等を使います。トークナイザーの種類はこちらを参照してください。

まず、現在のインデックスを削除します。

% bundle exec rails g migration RemoveFullTextIndexFromMicroposts
class RemoveFullTextIndexFromMicroposts < ActiveRecord::Migration
  def up
    remove_index "microposts", name: "index_microposts_on_content"
  end
  def down
    add_index "microposts", ["content"], name: "index_microposts_on_content", type: :fulltext
  end
end
% bundle exec rake db:migrate

トークナイザーを変更するにはインデックスコメントで指定します。

% bundle exec rails g migration AddSFullTextIndexWithParserToMicroposts
class AddSFullTextIndexWithParserToMicroposts < ActiveRecord::Migration
  def change
    add_index "microposts", ["content"], name:"index_microposts_on_content", type: :fulltext, comment: 'parser "TokenBigramSplitSymbolAlpha"'
  end
end
% bundle exec rake db:migrate

activerecord-mysql-commentによってschema.rbにも反映されています。

  create_table "microposts", force: :cascade, options: "ENGINE=Mroonga DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci" do |t|
    t.string   "content",    limit: 255
    t.integer  "user_id",    limit: 4
    t.datetime "created_at"
    t.datetime "updated_at"
  end

  add_index "microposts", ["content"], name: "index_microposts_on_content", type: :fulltext, comment: "parser \"TokenBigramSplitSymbolAlpha\""

これで、Databaseという単語に対してtabaという検索クエリでもヒットさせることができるようになります。

念のため、以下のコマンドでGroonga側で本当にトークナイザーが認識できているか確認できます。これはmroonga_commandというMroongaの拡張関数を使ってGroonga側のコマンドを発行しています。

 mysql -uroot -ppassword demo_app_development -e "select mroonga_command('table_list');"
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mroonga_command('table_list')                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [[["id","UInt32"],["name","ShortText"],["path","ShortText"],["flags","ShortText"],["domain","ShortText"],["range","ShortText"],["default_tokenizer","ShortText"],["normalizer","ShortText"]],[259,"microposts","demo_app_development.mrn.0000103","TABLE_PAT_KEY|PERSISTENT","Int32",null,null,null],[265,"microposts-index_microposts_on_content","demo_app_development.mrn.0000109","TABLE_PAT_KEY|PERSISTENT","ShortText",null,"TokenBigramSplitSymbolAlpha","NormalizerMySQLUnicodeCI"]] |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

この他のMroongaのオプションの指定方法の詳細は以下を参照ください。Mroongaでコメント句で指定するものをそのままマイグレーションのコメントプションにかけば良いだけです。

http://blog.createfield.com/entry/2014/10/29/084941

https://github.com/naoa/activerecord-mysql-comment

RailsでGroongaを使う他の方法

RailsでGroongaを使う他の方法として、GroongaのRubyバインディングであるRroongaやRroongaをActiveRecordライクに利用できるActiveGroongaを利用する方法があります。

これらは、SQLiteと同様にライブラリとして全文検索の機能を提供しますが、サーバの機能は提供しないため、Railsと同じサーバに全文検索機能をのせる必要があります。

サーバを立てなくて良いというメリットでもあるので、大量アクセスをさばく必要がないようなRailsアプリケーションの場合はこちらの利用を検討してみはいかがでしょうか。

おわりに

上記のように、Mroongaを使えば、Railsアプリケーションに簡単に全文検索機能を追加することができます。MySQLなのでActiveRecord用の資産はほぼ使えますし、既存のコードを書き換える必要はほとんどありません。

MySQLでRailsを使っていて高速な日本語対応の全文検索機能が欲しいと思ったらMroongaを使ってみてはいかがでしょうか。

また、最近、PostgreSQLの拡張機能としてPGroongaが開発されています。こちらは少し構文が変わりますが、上記と同様にSQLで管理できます。そのうち、ActiveRecordで足りない部分を補足したりチュートリアルを書くかも?書かないかも?

HerokuでPGroongaが使えるようになるといいな!

*1:MySQLのデフォルト。

PostgreSQLの日本語対応全文検索モジュールpg_bigmとPGroongaを検証してみた

はじめに

最近、Web系のエンジニアに転職して、Railsをよく触っています。 Rails界隈では、HerokuかActiveRecordの関係かよくわかりませんがPostgreSQLが利用されていることが多いような気がします。

これまで個人的に全文検索のWebサービスを開発するためにGroongaとよく戯れていたのですが、最近はなかなか戯れることができていません。

最近になってRailsとPostgreSQLを触りはじめたという状況ですが、先日、PostgreSQLでGroongaが使えるPGroonga 0.20がリリースされたようです。

PostgreSQLで簡単に日本語対応で高速な全文検索が使えるようになるなんて素晴らしいじゃないですか。

最近はRailsの使い方ばっかり調べていて、若干知識欲が満たされない感があったので、PostgreSQLの知識向上がてら、PGroongaと、PGroongaと同じく日本語対応の全文検索モジュールであるpg_bigmの性能を検証してみました。

検証環境

  • ハードウェア
CPU メモリ ディスク
Intel(R) Xeon(R) CPU E5620 @ 2.40GHz 1CPU 4Core 32GB HDD 2TB


* ソフトウェア

ソフトウェア バージョン
PostgreSQL 9.4.0
PGroonga 0.3.0(2015/2/1時点最新マスター)
Groonga 4.0.8
pg_bigm 1.1
CentOS 6.5

テーブル定義

  • PGroonga
CREATE TABLE text (
  id integer,
  title text,
  text text
);
CREATE INDEX pgroonga_index ON text USING pgroonga (text);
  • pg_bigm
CREATE TABLE text (
  id integer,
  title text,
  text text
);
CREATE INDEX pg_bigm_index ON text USING gin (text gin_bigm_ops);

更新手順

  • Wikipedia(ja)のデータ1万件(XMLの状態で169MiB)を1件ずつスクリプトでシーケンシャルにinsert(バルクインサートはしない)
  • データ挿入中にWALログのcheckpointが走らないようにpostgresql.confに以下の設定を行う

  • postgresql.conf

checkpoint_segments = 64
checkpoint_timeout = 1h
  • 更新SQL
INSERT INTO text VALUES (1, "title", "text");
  • 最後に1回だけcheckpointコマンド実行
checkpoint;

検索手順

  • Wikipedia(ja)の日本語のみのカテゴリのうち、上記のデータで1件以上ヒットするものをランダムに500件抽出したものを1件ずつSELECTで全文検索を実行
  • 純粋なindexscanの速度を比較するため、pg_bigmとPGroongaではSELECTを発行する前に以下のようにしてシーケンシャルスキャンを無効
SET enable_seqscan TO off;
  • 以下のコマンドでキャッシュをクリアし、プロセスを再起動してから検索実行
echo 3 > /proc/sys/vm/drop_caches
  • 検索SQL(pg_bigm,indexなし)
SELECT COUNT(*) as cnt FROM text WHERE text LIKE '%カテゴリー%';
  • 検索SQL(PGroonga)
SELECT COUNT(*) as cnt FROM text WHERE text %% 'カテゴリー';

更新時間

indexなし pg_bigm pgroonga
1万件トータル 102.618sec 238.485 sec 191.193 sec
平均 0.0102 sec 0.0238 sec 0.0191 sec

更新時間はわずかにpg_bigmよりもPGroongaの方がやや速かったです。

f:id:naoa_y:20150203094238p:plain

検索時間

indexなし pg_bigm pgroonga
500件トータル 664.393 sec 38.764 sec 11.604 sec
平均 1.328 sec 0.0775 sec 0.0232 sec

検索時間はpg_bigmよりもPGroongaの方が3倍以上速かったです。indexなしに比べれば、pg_bigmもかなり速いことがわかります。

f:id:naoa_y:20150203094201p:plain

  • EXPLAIN例
pgroonga=# EXPLAIN ANALYZE SELECT COUNT(*) as cnt FROM text WHERE text %% 'テレビアニメ';
                                                              QUERY PLAN

---------------------------------------------------------------------------------------------------------------
------------------------
 Aggregate  (cost=487.81..487.82 rows=1 width=0) (actual time=532.997..532.997 rows=1 loops=1)
   ->  Bitmap Heap Scan on text  (cost=43.21..475.21 rows=5040 width=0) (actual time=454.347..532.810 rows=359
loops=1)
         Recheck Cond: (text %% 'テレビアニメ'::text)
         Heap Blocks: exact=173
         ->  Bitmap Index Scan on pgroonga_index  (cost=0.00..41.95 rows=5040 width=0) (actual time=454.102..45
4.102 rows=359 loops=1)
               Index Cond: (text %% 'テレビアニメ'::text)
 Planning time: 415.373 ms
 Execution time: 538.047 ms
(8 rows)
pg_bigm=# EXPLAIN ANALYZE SELECT COUNT(*) as cnt FROM text WHERE text LIKE '%テレビアニメ%';
                                                             QUERY PLAN

---------------------------------------------------------------------------------------------------------------
---------------------
 Aggregate  (cost=108.02..108.03 rows=1 width=0) (actual time=1036.064..1036.065 rows=1 loops=1)
   ->  Bitmap Heap Scan on text  (cost=104.01..108.02 rows=1 width=0) (actual time=165.421..1035.735 rows=359 l
oops=1)
         Recheck Cond: (text ~~ '%テレビアニメ%'::text)
         Rows Removed by Index Recheck: 321
         Heap Blocks: exact=214
         ->  Bitmap Index Scan on pg_bigm_index  (cost=0.00..104.01 rows=1 width=0) (actual time=121.101..121.1
01 rows=680 loops=1)
               Index Cond: (text ~~ '%テレビアニメ%'::text)
 Planning time: 115.805 ms
 Execution time: 1048.345 ms
(9 rows)

この検索クエリの例ではPGroongaの方がpg_bigmよりも2倍ぐらい速いですね。

  • 開発者のコメント

サイズ

indexなし pg_bigm pgroonga
92MiB 650MiB 672MiB*1

サイズは、PGroongaの方がpg_bigmよりも少しだけ大きくなっています。 現状のPGroongaでは、全文インデックス以外にデータがPostgreSQLだけでなくGroongaのストレージにも格納されており、サイズがやや大きくなっています。 現在の実装では、Groonga側のデータは利用されていません。これについては今後、圧縮等によりいくらか改善されるかもしれません。

f:id:naoa_y:20150203094301p:plain

おわりに

上記のように、PGroongaは高速な日本語全文検索機能を簡単に追加することができて非常に便利です。 もし、PGroongaのExtensionがHerokuのPostgreSQLアドオンに配備されるなんてことになれば、Herokuで簡単に 日本語対応の高速な全文検索ができるようになって素敵ですね!

期待しています!

参考

https://github.com/pgroonga/pgroonga

http://pgbigm.sourceforge.jp/pg_bigm-1-1.html

*1:このサイズはスパースが考慮されておらず、領域を確保した全サイズです。実際に使っているのは518MiBぐらい。参考

仕事でPG書いたことがない人間が知財のWeb系のスタートアップに転職した話

2014/12に関西の個人特許事務所を退職し、2015/1からグローバルな知的財産のマーケット、マッチングWebプラットフォームIPNexusを立ち上げているスタートアップのバッグエンドエンジニアに転職しました。

これで2回目の転職で、それぞれ、まったくキャリアの異なる業種への転職です。

新卒の就職活動時の動機

大学では情報系を専攻していましたが、単位をこなすためにプログラミングをやる程度でした。

IT業界について就職活動やネットで情報収集をしていると、日本のプログラマーという業種について、 以下のようなとても悪いイメージをうえつけられました。

  • ITゼネコン
  • 偽装請負、客先常駐
  • プログラミングは下流工程
  • コーディングは設計どおりにやるだけで誰がやっても同じ
  • 上流工程の設計、仕様変更の皺寄せをくらう
  • 激務
  • 単価が安い
  • 35歳定年説

こんな情報が蔓延していて、就職活動からプログラマーという選択肢が消えてしまいました。 そこで、元請になれる大手のSIベンダー、ユーザ系SIのSEを中心に就職活動をすることになります。

業種にもよるでしょうが大手のSIベンダーであっても基本的に客先常駐は変わらないだろうし、 それだったら、最初から顧客先のSEになったほうが当事者意識を持って働けそうと思って 金融系のユーザSIに就職しました。

1社目 金融系のユーザSI

新卒で入ったユーザ系のSIではITインフラの業務に携りました。

この会社では金融系ということもあり、勤怠管理や規則が非常に厳しく、残業時間が月に30時間を超えることはあまりありませんでした。今時、年齢給の部分があって勤務年数が増えるだけで給料があがっていき、役職に付けなくとも30ぐらいになれば、それなりの収入が見込めました。

この仕事では、安定性や収入、勤務時間等に不満はありませんでした。

しかしながら、この仕事では自身でシステム関係について手を動かすことはほとんどなく、仕事上で技術知識を得ることは困難でした。

SIベンダーの成果物を検証したり、作業報告を上司や社内に噛み砕いて報告できれば十分です。いくつものプロジェクトの進捗会議とその進捗資料(エクセル)を更新する作業や社内向けの調整や社内承認会議等が中心です。 印刷時にエクセルの枠内に文字をおさめる技術とかは身に付けられるかもしれません。

また、仕事の速度がとても遅く、数十万円程度の発注をするだけでも複数部署で稟議を通す必要があって数ヶ月かかります。ほとんどが社内調整や社内事務をこなすためだけに時間が割かれることになります。

このため、軽微なシステムの修正やバージョンアップ作業であっても、うん千万円という単位の工数費用見積になります。

これでは技術知識が得られないだけではなく、このような仕事にこれだけの時間とコストをかけてどれだけの社会的意味があるんだろう?っていう疑問を持つようになり、とても苦痛になっていきました。

たとえば、特許庁の情報システムの入札結果を見れば官公庁等が既存システムの維持開発でSIベンダーにどれぐらいのお金を降ろしているかがわかるでしょう。 http://www.jpo.go.jp/koubo/choutatu/choutatu2/h26system/h26menu.htm

業務系のSIベンダーもそのような業務から生じる仕事を受注している関係であり*1、ユーザ側からある程度仕事内容も見ることができたので、業務系のSIベンダーで働きたいとも思いませんでした。 この結果、SI業界自体に興味を失うことになります。

そこで、情報系の知識を活かしつつ、何か自分自身のスキルでお金を稼げることを実感できる仕事はないかなと思って、特許事務所で弁理士を目指すこととなります。

2社目 特許事務所

特許事務所での仕事は、発明者から発明の内容をヒアリングし、発明の権利範囲と構成と作用効果を文書化する特許出願の代行が主な仕事です。

システム開発等に比べれば、仕事の粒度はとても小さく、ほぼ1人で数日~1週間ぐらいで1件をこなします。 このため、マネージメントや他者に振り回されることがほぼなく、ほとんど自分だけで仕事が完結します。

こういう点では、自分自身のスキルでどの程度のお金になっているかを実感することができました。

しかしながら、基本的に他人の発明の特許出願の代行であるということと、それがどれぐらい活用されているかということを考えると、この仕事にモチベーションを保つことはできませんでした。

大手のメーカーには出願件数のノルマがあり、発明者自身ですら具体的な内容を考えていなかったり、発明者自身が興味がないような内容についても特許出願することがよくあります。当事者ですら興味のないことを意欲的に仕事をすることは困難です。

また、特許庁に審査を依頼するには出願時よりも高額な費用がかかり、特許出願をしたうち、何割かは審査にすらかけられません。 長時間をかけてやった仕事の何割かはほとんど活用されずに捨てられていきます。

技術の先行開示により他者の権利化を防ぐといった意味はあるのですが、知財情報は弁理士や審査官、サーチャーなどの専門家に稀に参照される程度です。 現状、知財情報の活用や知財の流通というのは、ほんの一部の大企業や専門家に限られています。

仕事外でのWebサービスの開発

上記のように知財が十分に活用されていないといった不満があり、また、知財が十分に活用されるためのプラットフォームがないと感じ、仕事以外の時間で独学で特許のWebサービスの開発を始めました。

幸い、そこそこの性能のサーバーを安価に借りることができるようになっており、Web開発に必要な技術情報はインターネットにあふれていて、オープンソースも十分に使えるまで発達していました。

仕事でプログラミングのスキルを得ることができなかったため、オープンソースのコミットログやソースは非常に良い勉強材料になりました。 自分で改造したり、ソースを確認することができるオープンソースを利用するのは自分にとって非常に面白いものでした。

今になって思えば、就職活動時にプログラマーとしての選択肢を消さずに、自社サービス開発をしているWeb系や海外といったところを視野にいれておけばよかったなと思います。

ブログを通じた仕事のオファー

Webサービスを作りながら技術内容についてブログに書いていると、いくつかの会社から声をかけていただけました。

その中からこれまで作っていた内容を活用でき、目指していたビジョンと共感ができる知財のWebプラットフォームを製作しているスタートアップに参加することになりました。

特定業務に捉われない技術的な内容はオープンにしていいということなので、これからも仕事以外の場での技術の吸収や公開は続けていきたいと考えています。最近、東京に引っ越してきて東京で働いていますので、何らかの交流の機会があればよろしくおねがいします。

*1:すべての業務がそのような仕事ではないと思います。

MroongaでGroongaの機能を使いこなす高度なテーブル設計をする方法

はじめに

MySQL/MariaDBで高速に全文検索するためのオープンソースのストレージエンジンMroongaは、以下のように、Engine=MroongaFULLTEXT INDEX (${source_column})と書くだけで非常に簡単に全文検索を使い始めることができます。

CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
FULLTEXT INDEX (content)
) Engine=Mroonga DEFAULT CHARSET=utf8;

検索するときも以下のようにMATCH ... AGAINSTを使うだけです。

mysql> INSERT INTO memos VALUES (1, "1日の消費㌍は約2000㌔㌍");
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM memos WHERE MATCH (content) AGAINST ("+消費" IN BOOLEAN MODE);
+----+----------------------------------+
| id | content                          |
+----+----------------------------------+
|  1 | 1日の消費㌍は約2000㌔㌍ |
+----+----------------------------------+
1 row in set (0.00 sec)

なお、IN BOOLEAN MODEをつけないと、自然文検索IN NATURAL LANGUAGE MODEとなり、検索クエリを所定のルールで分割したトークンの並び順を考慮しなかったりして、一見、不自然と思われる結果が得られるため注意が必要です。LIKE '%word%のような挙動(まったく同じではありませんが)を望むのであれば、IN BOOLEAN MODEを指定してください。

このように、非常に簡単に使えるのは、MroongaがGroonga全文検索用の語彙表テーブルインデックスカラムをいい感じに自動的に作成してくれるからです。

上記のように簡単な使い方だけでいいのであればそれでいいですが、よりGroongaの機能を使いこなしたい場合は、Groonga側の構成を理解して、それをMroongaから設定する方法を理解する必要があります。

GroongaとMroongaはほぼ毎月29日にリリースされており、10/29にはGroonga4.0.7Mroonga4.07がリリースされました。

Mroonga4.07からは、Groongaのテーブルオプション、カラムオプションの大半はMroongaでもできるようになったと思います。

この記事では、MroongaでGroongaの機能を使いこなすテーブル設計をする方法について紹介します。

FULLTEXT INDEXのCOMMENTによるオプション

FULLTEXT INDEXをつけてテーブルを作ることにより、MySQLからは見えない形で、${テーブル名}-${インデックス名}というGroonga全文検索用の語彙表テーブルが自動的に作成されます。

これは、Groongatable_listコマンドを使うことにより、以下のように確認することができます。

mysql> SELECT mroonga_command('table_list');
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mroonga_command('table_list')                                                                                                                                                                                                                                                                                                                                                                                                |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [[["id","UInt32"],["name","ShortText"],["path","ShortText"],["flags","ShortText"],["domain","ShortText"],["range","ShortText"],["default_tokenizer","ShortText"],["normalizer","ShortText"]],
[259,"memos","mrn_test600.mrn.0000103","TABLE_PAT_KEY|PERSISTENT","Int32",null,null,null],
[262,"memos-content","mrn_test600.mrn.0000106","TABLE_PAT_KEY|PERSISTENT","ShortText",null,"TokenBigram","NormalizerMySQLGeneralCI"]] |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

TokenBigramトークナイザー、NormalizerMySQLGeneralCIノーマライザーが設定されたキーの種類TABLE_PAT_KEYShortText型の memos-contentというテーブルが作成されています。これがGroongaでいう全文検索用の語彙表テーブルです。

あまり一般的ではないかもしれませんが、Groongaではインデックスのキー管理構造も普通のデータ用のテーブルと同様にして作ります。

全文検索用のパーサー(トークナイザー)の指定方法

FULLTEXT INDEXCOMMENTparserを指定することで、全文検索用のパーサー(トークナイザー)を変更することできます。(ドキュメント)

以下は、TokenBigramSplitSymbolAlphaトークナイザーを使用する例です。

CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
FULLTEXT INDEX (content) COMMENT 'parser "TokenBigramSplitSymbolAlpha"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

トークナイズされ方は、以下のようにGroongatokenizeコマンドで確認することができます。

mysql>  SELECT mroonga_command('tokenize TokenBigramSplitSymbolAlpha "This is a pen" NormalizerMySQLGeneralCI');
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mroonga_command('tokenize TokenBigramSplitSymbolAlpha "This is a pen" NormalizerMySQLGeneralCI')                                                                                                                                                                                      |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [{"value":"TH","position":0},{"value":"HI","position":1},
{"value":"IS","position":2},{"value":"S","position":3},
{"value":"IS","position":4},{"value":"S","position":5},
{"value":"A","position":6},{"value":"PE","position":7},
{"value":"EN","position":8},{"value":"N","position":9}] |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

TokenBigramSplitSymbolAlphaは、アルファベットと記号をバイグラムで分割させるトークナイザーです。このトークナイザーは、英単語をより細かい粒度で検索にヒットさせたい場合に有用です。ただし、アルファベットの異なり字数は26種しかないため、英語のみの文章において、転置索引方式で2文字ごとのトークナイズは、検索性能が劣化しやすいので注意してください。とはいっても、Groongaはかなり高速なので数GiB程度なら問題ないと思います。ただ、10GiB以上になってくると、かなり検索速度が劣化してくると思います。

ちなみにInnoDB FTSinnodb_ft_min_token_sizeのデフォルト値は3でそれ以下の文字数の単語は無視されます。やはり、転置索引方式では、アルファベットのトークンサイズは3文字以上はないと、データベースが有る程度大きくなってくると苦しくなるのでしょう。

ノーマライザーの指定方法

FULLTEXT INDEXCOMMENTnormalizerを指定することで、ノーマライザーを変更することできます。(ドキュメント)

以下は、NormalizerAutoノーマライザーを使用する例です。

CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
FULLTEXT INDEX (content) COMMENT 'normalizer "NormalizerAuto"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

ノーマライズされ方は、以下のようにGroonganormalizeコマンドで確認することができます。

mysql> SELECT mroonga_command('normalize NormalizerAuto "T1日の消費㌍は約2000㌔ ㌍"');
+------------------------------------------------------------------------------------------------+
| mroonga_command('normalize NormalizerAuto "T1日の消費㌍は約2000㌔㌍"')                |
+------------------------------------------------------------------------------------------------+
| {"normalized":"t1日の消費カロリーは約2000キロカロリー","types":[],"checks":[]} |
+------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

NormalizerAutoは、NFKCに独自のノーマライズを加えたもので合成文字が分解されたり、"がカタカナと合成されたりします。

トークンフィルターの指定方法

Groonga 4.0.7からはTokenFilterStopWordTokenFilterStemのトークンフィルターが追加されました。

FULLTEXT INDEXCOMMENTtoken_filtersを指定することで、トークンフィルターを設定することできます。(ドキュメント)

以下は、TokenFilterStemトークンフィルターを使用する例です。

mysql> SELECT mroonga_command('register token_filters/stem');
CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
FULLTEXT INDEX (content) COMMENT 'token_filters "TokenFilterStem"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

初回だけSELECT mroonga_command('register token_filters/stem');を実行してGroongaのデータベースに対してプラグインを登録する必要があります。

トークナイズされ方は、以下のようにGroongatokenizeコマンドで確認することができます。

mysql> SELECT mroonga_command('tokenize TokenBigram "There are cars" NormalizerAuto --token_filters TokenFilterStem');
+---------------------------------------------------------------------------------------------------------+
| mroonga_command('tokenize TokenBigram "There are cars" NormalizerAuto --token_filters TokenFilterStem') |
+---------------------------------------------------------------------------------------------------------+
| [{"value":"there","position":0},{"value":"are","position":1},{"value":"car","position":2}]              |
+---------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

carsがステミングされてcarとトークナイズされています。ステミングというのは、語句の語幹を抽出する処理のことで、複数形や過去形などの活用形の語尾が所定のルールで切除されます。 これにより、TokenBigram(デフォルト)で検索クエリにcarと入力してもcarsがヒットするようになります。なお、TokenFilterStemは、libstemmerがインストールされている必要があるため、注意が必要です。また、MySQL互換のノーマライザーではアルファベットが大文字にノーマライズされるため、ステミング対象にならないので注意してください。

追記
Groonga4.0.9以降ではMySQL互換のノーマライザーでもTokenFilterStemを利用することができるようになりました。 Groonga - Groonga 4.0.9リリース

カラムのCOMMENTによるオプション

圧縮カラムの指定方法

Groongaの圧縮カラム自体は以前からあったのですが、Groonga 4.0.7からは圧縮カラムで発生していたメモリリークが解消され、非常に高速な圧縮伸長が可能なLZ4のライブラリを利用できるようになっています。

以下は、COMPRESS_LZ4を使用する例です。

CREATE TABLE entries (
  id INT UNSIGNED PRIMARY KEY,
  content TEXT COMMENT 'flags "COLUMN_SCALAR|COMPRESS_LZ4"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

この他、COMPRESS_ZLIBを使用することができます。zlibの圧縮率は高いですが、圧縮、伸長にかかる時間が長いです。

以下のプルリクエストにWikipediaを使った実験結果をのせています。LZ4の場合は、普通に圧縮カラムを出力させるだけであれば、ほとんどqpsに影響がないことがわかります。

https://github.com/groonga/groonga/pull/221

https://github.com/groonga/groonga/pull/223

テーブル参照の指定方法

Groongaには、テーブル参照という機能があります。Groongaselectコマンドでは、テーブル参照のカラムを作っておけば、そのカラムの値をキーとする別テーブルのカラムを参照したり、検索したりできます。

このテーブル参照も一応Mroongaでも作れるようになっています。

CREATE TABLE refs (
   tag VARCHAR(255) PRIMARY KEY,
   description TEXT
) Engine=Mroonga DEFAULT CHARSET=utf8;
CREATE TABLE entries (
  id INT UNSIGNED PRIMARY KEY,
  tag VARCHAR(255) COMMENT 'type "refs"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

ただ、MroongaでSQLを使うだけなら使う必要はないと思います。SQLでは、Groongaのselectコマンドのように、参照先テーブルrefsの他のカラムを参照したりできません。参照できるのはカラムに追加した値だけです。

mysql> INSERT INTO refs VALUES ("Mroonga", "I found Mroonga that is a MySQL storage engine to use Groonga!");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO entries VALUES (1, "Mroonga");
Query OK, 1 row affected (0.01 sec)

mysql> SELECT id,tag,tag.description FROM entries;
ERROR 1054 (42S22): Unknown column 'tag.description' in 'field list'

mysql> SELECT id,tag FROM entries;
+----+---------+
| id | tag     |
+----+---------+
|  1 | MROONGA |
+----+---------+
1 row in set (0.00 sec)
mysql> SELECT mroonga_command('select entries --output_columns id,tag,tag.description');
+--------------------------------------------------------------------------------------------------------------------------------------------------------+
| mroonga_command('select entries --output_columns id,tag,tag.description')                                                                              |
+--------------------------------------------------------------------------------------------------------------------------------------------------------+
| [[[1],[["id","UInt32"],["tag","refs"],["tag.description","LongText"]],
[1,"MROONGA","I found Mroonga that is a MySQL storage engine to use Groonga!"]]] |
+--------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

テーブルのCOMMENTによるオプション

ここからは、Groongaと同じように全文検索用の語彙表テーブルを明示的に作る方法です。Groongaのtable_createコマンドを使ってテーブル定義を作る方法と同じような感じになります*1。MySQLのテーブル定義を作る方法のみが頭にあると混乱する可能性があります。興味のある方のみご参照ください。

これによるメリットは語彙表を可視化できて、SQLで語彙表のキーやストップワードのフラグを管理できるということです。

ただし、この方法は、テーブル間の依存関係ができ、mysqldumpで出力された順番によってはそのままではリストアができなくなる可能性があるので注意してください。名前の付け方やダンプファイルのテーブル定義の出力を調整したりする必要があります。

トークナイザーの指定方法

FULLTEXT INDEXのCOMMENTにtable "${テーブル名}"を指定することにより、${テーブル名}のテーブルが全文検索用の語彙表テーブルとして用いられます。${テーブル名}のテーブルCOMMENTのdefault_tokenizerにトークナイザーを指定することができます。

CREATE TABLE terms (
term VARCHAR(255) NOT NULL PRIMARY KEY
) Engine=Mroonga COMMENT='default_tokenizer "TokenBigram"' DEFAULT CHARSET=utf8;

CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
memo TEXT NOT NULL,
FULLTEXT INDEX (memo) COMMENT 'table "terms"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

これにより、以下のように全文検索用の語彙テーブルをSQLで参照することができるようになりますん。なりませんでした。4.08ではSQLのSELECTで参照できるようになるかもしれません。

12/30追記
4.09で参照できるようになりました。 Mroonga - Mroonga 4.09リリース!

mysql> INSERT INTO memos VALUES (1, "今日は雨だなぁ。");
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM terms;
+------+
| term |
+------+
|      |
|      |
|      |
|      |
|      |
|      |
|      |
|      |
+------+
8 rows in set (0.01 sec)
mysql> SELECT mroonga_command('select terms');
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mroonga_command('select terms')                                                                                                                                                                                                 |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [[[8],[["_id","UInt32"],["_key","ShortText"],["memo","memos"],["term","ShortText"]],
[8,"。",1,""],[7,"ぁ",1,""],[5,"だな",1,""],[6,"なぁ",1,""],
[3,"は雨",1,""],[1,"今日",1,""],[2,"日は",1,""],[4,"雨だ",1,""]]] |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

ノーマライザーの指定方法

テーブルCOMMENTでのノーマライザーの指定方法は以下のようになります。

CREATE TABLE terms (
term VARCHAR(64) NOT NULL PRIMARY KEY
) Engine=Mroonga COMMENT='default_tokenizer "TokenBigram", normalizer "NormalizerAuto"' DEFAULT CHARSET=utf8;
CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
FULLTEXT INDEX (content) COMMENT 'table "terms"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

トークンフィルターの指定方法

テーブルCOMMENTでのトークンフィルターの指定方法は以下のようになります。

以下は、TokenFilterStopWordトークンフィルターを使用する例です。

SELECT mroonga_command('register token_filters/stop_word');
CREATE TABLE terms (
term VARCHAR(64) NOT NULL PRIMARY KEY,
is_stop_word BOOL NOT NULL
) Engine=Mroonga COMMENT='default_tokenizer "TokenBigram", normalizer "NormalizerAuto", token_filters "TokenFilterStopWord"' DEFAULT CHARSET=utf8;
CREATE TABLE memos (
id INT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
FULLTEXT INDEX (content) COMMENT 'table "terms"'
) Engine=Mroonga DEFAULT CHARSET=utf8;

語彙表テーブルtermsis_stop_wordカラムがtrueのトークンが検索対象から除外されます。atheなど、どの文書にも現れており、検索精度に影響が小さく、検索速度に影響が大きいワードを検索対象から除外させることができます。TokenMecabなどを使って単語ごとにトークナイズしていれば、意図的に検索させたくないキーを設定することもできるでしょう。検索時のみ除外されるため、データ挿入後、運用中に適宜ストップワードを変更することができます。

mysql> INSERT INTO memos VALUES (1, "Hello");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO memos VALUES (2, "Hello and Good-bye");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO memos VALUES (3, "Good-bye");
Query OK, 1 row affected (0.00 sec)

mysql> REPLACE INTO terms VALUES ("and", true);
Query OK, 2 rows affected (0.00 sec)

mysql> SELECT * FROM memos
    -> WHERE MATCH (content) AGAINST ("+\"Hello and\"" IN BOOLEAN MODE);
+----+--------------------+
| id | content            |
+----+--------------------+
|  1 | Hello              |
|  2 | Hello and Good-bye |
+----+--------------------+
2 rows in set (0.01 sec)

この例では、andが除外されて、Helloのみで検索されています。

本来は、UPDATE terms SET is_stop_word = true WHERE term="${トークン名}";としたいところですが、Mroonga4.07では、PRIMARY KEYの値がSQLから取得できないため、REPLACEか事前にINSERTする必要があります。

ベクターカラムの作成方法

語彙表テーブルを指定する方法と上記のカラムCOMMENTによるテーブル参照を組み合わることによりMroongaでもベクターカラムを作成することができます。

CREATE TABLE Tags (
  name VARCHAR(64) PRIMARY KEY
) ENGINE=Mroonga DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='default_tokenizer "TokenDelimit"';
CREATE TABLE Bugs (
  id INT PRIMARY KEY AUTO_INCREMENT,
  tags TEXT COMMENT 'flags "COLUMN_VECTOR", type "Tags"',
  FULLTEXT INDEX bugs_tags_index(tags) COMMENT 'table "Tags"'
) ENGINE=Mroonga DEFAULT CHARSET=utf8;

詳細は、Qiitaの記事を参照してください。 ベクターカラムは、1:Nの関係がJoinを使わずに1テーブルで表現できたり、ドリルダウンを利用した集計が非常に便利です。

おわりに

トークナイザー、ノーマライザー、トークンフィルターは、共有ライブラリ形式のプラグインで拡張させることができます。

上記のように、これらは、GroongaだけでなくMroongaでも使うことができます。

興味がある人は、自作してみてはいかがでしょうか。

*1:インデックスをつくる対象のテーブルがGroongaと逆になっててさらに混乱しやすい感じです。

GroongaとTokyoCabinetのHash表のベンチマークについて

はじめに

全文検索エンジンGroongaは超高速な全文検索ライブラリとしての機能を有しますが、単純なハッシュ表等のAPIも提供されており、ファイルへの永続化前提のインプロセス型のKVS(key value store)としても利用することができます。

ファイルへの永続化前提のインプロセス型のKVSとしては、Tokyo Cabinetが有名です。

今回は、簡単にGroongaとTokyo Cabinetの速度と容量について比較してみました。

なお、Tokyo Cabinetには、後継のKyoto Cabinetがありますが、メンテナンス性等を重視してC++で実装されており、単純な性能であれば、Tokyo Cabinetの方がやや良いということでしたので、今回はTokyo Cabinetを利用してみました。

検証環境

項目 バージョン/種類
CPU Intel(R) Xeon(R) CPU E5620 @ 2.40GHz 1CPU 4Core
Memory 32GB
HDD 2TB(SATA 7200rpm) * 2 Hardware RAID 0
OS CentOS 6.4
Groonga 4.0.3
Tokyo Cabinet 1.4.33

検証手順

以下のようにして、Groonga、Tokyo Cabinetをインストールします。

% rpm -ivh http://packages.groonga.org/centos/groonga-release-1.1.0-1.noarch.rpm
% yum makecache
% yum install -y groonga groonga-devel
% yum install -y tokyocabinet tokyocabinet-devel

Tokyo Cabinetは、CentOS6のbaseリポジトリ入りしており簡単にインストールできていいですね。

検証用に作ったC言語のプログラムをダウンロードしてコンパイルします。

https://github.com/naoa/groonga-tokyocabinet-bench

% git clone https://github.com/naoa/groonga-tokyocabinet-bench
% cd groonga-tokyocabinet-bench
% make

第1引数に構築するハッシュ表のキー数を設定して、キーと値の追加時間とキーから値を取得する時間を計測します。

% ./grn_hash_bench 1000000
% ./tchdb_bench 1000000

作成されたハッシュ表のファイルサイズを比較します。

% ls -lh groonga.grh tokyocabinet.tch

キーと値の追加時間

キー数 Tokyo Cabinet Groonga
10万 0.03sec 0.03sec
100万 0.54sec 0.35sec
1000万 40.17sec 4.35sec

Tokyo Cabinetは1000万で極端に遅くなっておりますが、何らかのチューニングポイントがあるかもしれません。ちなみに64GB以上はラージモードを使う必要があるらしいです。

http://alpha.mixi.co.jp/2010/10717/

キーから値を取得する時間

キー数 Tokyo Cabinet Groonga
10万 0.05sec 0.03sec
100万 0.56sec 0.28sec
1000万 18.91sec 2.78sec

ハッシュ表のファイルサイズ

キー数 Tokyo Cabinet Groonga
10万 5.1MiB 17MiB
100万 47MiB 65MiB
1000万 459MiB 661MiB

おわりに

Groongaは、全文検索だけではなく、ファイルベースのKVSとしてもとても優秀な速度性能を有しています。Tokyo Cabinetの方も十分に速く、空間効率に優れ、さらに、ファイルとして保持する場合はトランザクションの機能もあって堅牢性にも優れています。

GroongaのRubyバインディングのRroongaを使えば、Rubyからでも簡単にGroongaのハッシュ表を利用することができます。

以下の検証結果を見るとRubyのHashよりもTokyo CabinetのHashの方が速いということですので、Rubyで多数のキーと値のペアを保持したい場合は、Rroongaを使うことでRubyのHashよりも良いパフォーマンスが得られると思います。

http://www.xmisao.com/2013/10/04/tokyocabinet-ruby-benchmark.html

全文検索にGroongaを利用していて、KVSも利用したいという方はGroongaでのKVSを検討してみてはいかがでしょうか。

GroongaとElasticsearchの転置索引の違いと更新反映速度について

はじめに

こちらの記事では、GroongaElasticsearchの単純な検索性能、更新性能、 ディスク使用効率を比較しました。

その結果では、Groongaの検索速度がElasticsearchよりも数倍ほど速く、Elasticsearchの更新速度がGroongaよりも数倍ほど速かったです。

なお、前回の記事では、Elasticsearchでフレーズ検索がされていなかったり*1、punctuation、whiespaceが転置索引に入っていなかったため、追加検証結果を追記しています。

シーケンシャルなスクリプトではGroongaの更新速度のほうが遅かったですが、これは、GroongaとElasticsearchが利用しているLuceneの転置索引の作成方法や管理方法の違いによるものです。

Groongaにおける転置索引

Groongaでは、即時更新に強く更新にかかる処理コストが低くリアルタイムサーチであるという売り文句があります。

これは、転置索引更新の際に間に挿し込めるスペースがあるため、このスペースを使いつぶすまでの間、低負荷、且つ、高速な更新が見込めるとのことです。 このスペースに転置索引を差し込むことにより転置索引の断片化を防ぐことができ、検索性能の劣化を防ぐことができるようです。

http://qiita.com/tamano/items/663c2a958e897226e138
http://groonga.org/ja/blog/2011/07/28/innodb-fts.html

Groongaでは、カラムの値の更新の前に転置索引の更新が行われ、更新リクエストは転置索引とカラムの値の更新が完了した段階でレスポンスが返ってきます。 そのため、更新リクエストのレスポンスが返ってくればすでに全文検索可能な状態となっています。

たとえば、groonga-column-holeプラグインを使えば、転置索引を作った後カラムに値がセットされる前に値を削除して、カラムに値を更新させないなんてこともできます。

GroongaはOS資源関連(ファイルオープン数やmmap周り)のカーネルパラメータ以外はほとんどチューニングはいりません。というかありません。

Elasticsearchにおける転置索引

Elasticsearchでは、Nearリアルタイムサーチをうたっており、通常は1秒で検索可能になるということが書かれています。

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/_basic_concepts.html

Elasticsearchでは、ドキュメントの更新と転置索引の更新(リフレッシュ、フラッシュ、マージ)が独立しており、更新リクエストは、ドキュメントの更新が完了したタイミングでレスポンスが返ってくるみたいです。

デフォルトのインデックスのリフレッシュインターバルは、1sに設定されています。 おそらく、このリフレッシュによりドキュメントから作成された転置索引がメモリ上に乗って、その時点で検索が可能になると思われます。

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/index-modules.html

リフレッシュの後、フラッシュという処理が走るようです。フラッシュっていうのはマージと違って、新しくセグメントを作ってインデックスすることみたいです。 この状態では、ディスク上の転置索引が断片化し検索性能の劣化が生じていると思います。 デフォルトのフラッシュは、5sに設定されています。

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/index-modules-translog.html#index-modules-translog

その後、ディスク上に小分けに断片化した転置索引を所定のポリシーでマージしてディスク上に再配置する処理が走るみたいです。

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/index-modules-merge.html

さらっと読んだだけではデフォルトはtieredというポリシーでこのポリシーはlog_bytes_sizeと似たようなものだということはわかりましたが、どういったタイミングでマージが走るかよくわかりませんでした。 Elasticsearchは、設定項目がめちゃくちゃたくさんあってチューニングが大変そうですね。

optimizeをするとマージが明示的に行われるようです。

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-optimize.html

10/1追記
以下の記事には、Kyoto Cabinetにおける転置インデックスのバッファリング戦略が記載されています。
http://fallabs.com/blog-ja/promenade.cgi?id=127

前回のシーケンシャルな更新スクリプトの実行時間はGroongaの方ではデータ+転置索引の更新時間であり、Elasticsearchの方はデータの更新時間のみでした。

Elasticsearchは、Nearリアルタイムサーチということですが、使ったことがないので実際にどの程度で検索できるようになるのかよくわかりません。

そこで、GroongaとElasticsearchでデータ更新をしつつ実際に検索ができるまでの時間と、リアルタイム更新後の検索性能の違いを計測してみました。

検証環境

項目 バージョン/種類
CPU Intel(R) Xeon(R) CPU E5620 @ 2.40GHz 1CPU 4Core
Memory 32GB
HDD 2TB(SATA 7200rpm) * 2 Hardware RAID 0
OS CentOS 6.4
Groonga 4.0.3
Elasticsearch 1.1.2

検証準備

こちらの記事で紹介したスキーマを用意し、WikipediaのXMLを投入する準備をします。

これで、GroongaとElasticsearchはほぼ同じBigramのルールで転置索引が作られます。

厳密に言うと少し違います。GroongaのBigramでは前方一致検索*2を使えば1文字でも検索できるように末尾は1文字でトークナイズされます。 ElasticsearchのBigramでは末尾も2文字でトークナイズされます。

GroongaとElasticsearchの更新の反映速度

検証手順

以下のスクリプトを実行して、更新開始から全文検索可能になる時間を計測します。

(1) WikipediaのXMLをパースし、1件ずつ更新します。titleカラムには、短いタイトルでもユニークにするためidとtitleを結合して格納します。
(2) 更新後、更新したidとtitleでタイトルに対して全文検索します。
(3) 全文検索がヒットするまで10msec間隔でループします。
(4) (1)-(3)を1000件ループします。

検証結果

項目 平均
Elasticsearch 1.01sec
Groonga 0.09sec

このように、Groongaでは0.09secで検索可能となったのに対し、Elasticsearchではリフレッシュの間隔である約1secで検索可能となります。 ここはチューニングが可能なパラメータのようなので、おそらくindex.refresh_intervalを短くすれば速くすることができると思います。 ただし、その分処理負荷が増えると思います。

追記
リフレッシュも明示的に実行することができるようです。1s未満で検索したいときはこれを使えば検索可能になるようです。@johtaniさんが教えてくれました。ありがとうございます。

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-refresh.html#indices-refresh

ElasticsearchのoptimizeとGroongaの静的索引構築

Elasticsearchでは、以下のようにoptimizeすると明示的に転置索引をマージさせることができます。

% curl -XPOST 'http://localhost:9200/wikipedia/_optimize'

Groongaには、静的索引構築というものがあります。

http://groonga.org/ja/docs/reference/indexing.html

これを使えば、リアルタイムに更新を検索結果に反映させることはできませんが高速に転置索引を構築することができます。静的索引構築では転置索引の隙間が埋められるため、ディスク使用効率が良くなります。なお、静的索引構築中のカラムに対しても転置索引を使わない検索、データ出力は可能です。ただし、静的索引構築中は負荷が高いです。

前回の追加検証にGroongaの静的索引構築の結果を追加すると以下のようになります。

更新時間

Elasticsearch Groonga
(リアルタイム)
(参考) Groonga
(静的索引構築)
8853sec(2h27min) 21914sec(6h05min) データ:4271sec(1h11min)
インデックス:1685sec(28min)
合計:5956sec(1h39min)

※トータルの更新時間には、WikipediaのXMLパースが含まれています。前回と同様ですがElasticsearchには転置索引の構築完了の時間が考慮されていません。Elasticsearchはパース済みのデータをバルク投入させれば速くなると思います。Groonga(静的索引構築)の方もバルク投入させればデータ部分は数分もかからないと思います。

ディスクサイズ

Elasticsearch
(optimize前)
Elasticsearch
(optimize後)
Groonga
(リアルタイム)
Groonga
(静的索引構築)
19.3GiB 16.5GiB 18.9GiB 15.4GiB

全文検索時間

種別 Elasticsearch
(optimize前)
Elasticsearch
(optimize後)
Groonga
(リアルタイム)
Groonga
(静的索引構築)
トータル
(1千件)
216.161sec 141.701sec 50.062sec 52.236sec
平均 0.216sec 0.141sec 0.050sec 0.052sec
最長 4.313sec 1.037sec 0.339sec 0.401sec
最短 0.000960sec 0.00317sec 0.00215sec 0.00182sec

Groongaでは、リアルタイム更新でも静的索引構築でも検索性能がほとんど変わりません。 Elasticsearchでは、optimizeによって検索性能がかなり変わります。

Groongaでは、転置索引の断片化が抑制されているためリアルタイム更新だけしていても検索性能にはほとんど影響がありません。

したがって、Groongaでは、通常使用時には原則、静的索引構築し直す必要が有りません。バックアップからの戻しでは、静的索引構築すれば高速にリストアが可能となっています。静的索引構築は、リアルタイム更新に比べて、転置索引の更新時間だけで言うと10倍近く速いです。

まとめ

Groongaは、カラムが更新されると即時、転置索引が更新されます。そのため、リアルタイムに更新を検索結果に反映させることができます。 また、転置索引の間に隙間が設けられているため、転置索引の断片化による検索性能の劣化がほとんどありません。

このように、Groongaは検索速度や即時更新性という点が優れています。

Groongaには、ほとんど設定項目はありませんが、ファセット(ドリルダウン)、サジェスト、スニペット等、最低限、全文検索に必要な機能は一通り揃っています*3

たとえば、よりリアルな状態を反映したいECサイトではGroongaの方が向いているのではないでしょうか。 ユーザがECサイトで在庫有のものを絞り込んで全文検索をし、クリックしてアクセスしたら実際は在庫切れだったということを防ぐことができます。 また、ニュースサイトなど情報の鮮度が重要な場合にも向いていそうです。

一方、Elasticsearchは、Nearリアルタイムサーチを実現するために、リフレッシュやフラッシュ、マージ処理といった転置索引の管理処理が裏で走っています。 このため、検索可能となる時間はリフレッシュ間隔に依存します。フラッシュの転置索引の断片化により検索性能が劣化するため、マージによる転置索引のディスク上の再配置が必要です。

しかし、Elasticsearchは、非常に多機能で多数の設定項目があります。また、Elasticsearchは、容易にシャーディング、レプリケーションさせることができます。

Elasticsearchは、TBクラス、PBクラスの解析が必要な大規模分散環境におけるログ解析や統計解析に向いていそうです。 分散環境での反映速度はわかりませんが、1sで検索可能なら単純な全文検索エンジンの用途としてもほとんどの環境で問題がなさそうです。

Javaの知識が豊富なのでJavaで作られたElasticsearchの方が親和性が高くカスタマイズし易そう、 Elasticsearchの方が多数機能、多数設定項目があるのでリッチな全文検索システムがつくりやすそう、 Elasticsearchの方が世界で使われているし仕事の需要がありそう、 Elasticsearchの方がオシャレ、JVMで運用するのは特に気にならない、 という理由でElasticsearchを選ぶのもありかもしれません。

この他、SolrAmazon CloudSearchTokyo Cabinet/Kyoto Cabinetを利用した全文検索システムなど、それぞれの特性や制限事項*4、コスト等を検討して用途や環境にフィットするものを使えばいいと思います。

私は単純な全文検索エンジンとして利用するのであれば、Groongaがおすすめです。

7/28追記
GroongaをKVSとして利用した場合のベンチマークについて記事を書きました。Groongaは単純なKVSとして利用しても非常に高速です。よければ、こちらもご参照ください。
GroongaとTokyoCabinetのHash表のベンチマークについて

おわりに

Groongaは、国産のオープンソースで開発者も日本人で、且つ、かなり親切なので貢献に対する敷居も低いです。
私は仕事でプログラムを書いたことがなく、Gitも使ったことがありませんでしたが、Groongaを使うことによりGitの使い方や開発手法などたくさんのことを学ぶことができました。

*1:たとえば、Elasticsearchでは空白が含まれていなくともダブルクォーテーションでくくらないと、「東京都」のBigramが東京 OR 京都で検索されます。

*2:文書全体ではなく転置索引のトークンに対する前方一致です。たとえば、「日本の首都は東京です」という文書に対してBigramにすると「す*」でヒットします。Oracle TextのNgramなどでも同様に末尾は1文字でトークナイズされるらしいです。

*3:アナライザーやフィルターのラインナップは、Elasticsearch(というかLucene)の方が豊富ですが、文字列処理ですしGroongaでもトークナイザやノーマライザをカスタマイズ可能となっているので多少工夫すればなんとかなります。

*4:現状のGroongaには、カラム256GiB制限やレコード2億6千万という制限があります。