CreateField Blog

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

Groongaでのタグ検索と表記揺れとの戦い at Groonga Meatup 2015

Groonga Meatup 2015 - Groonga | Doorkeeper で発表してきました。

www.slideshare.net

英語のタグ検索での表記揺れをTrieで前方一致検索絞込、編集距離(Damerau–Levenshtein distance)、キーボード距離、DFを元に誤記を抽出して対応した話です。

naoa/groonga-term-similar · GitHub

naoa/groonga-tag-synonym · GitHub

ElasticsearchでもDamerauとprefixであいまい検索やっているみたい。 あいまい検索つくってみようかな〜

How to Use Fuzzy Searches in Elasticsearch | Elastic

MySQLでカラムごとに圧縮する方法

MySQLでデータサイズが非常に大きいような場合、データを圧縮して格納したくなることがあります。

InnoDBではROW_FORMAT=compressedとすることで、テーブルを圧縮することができます。 MyISAMではmyisampackコマンドを利用することにより、テーブル全体を圧縮することができます。ただし、MyISAMでは読み取り専用となります。

通常、主キーやタイトル、メタデータなどのサイズは小さく、bodyなどのサイズが大きいことが多いと思います。そのため、テーブル全体ではなく、特定のカラムのみを圧縮するだけで事足りることが大半だと思います。

MySQLではCOMPRESS関数とUNCOMPRESS関数があります。

MySQL :: MySQL 5.6 Reference Manual :: 12.13 Encryption and Compression Functions

そこで、これとBLOB型のカラムを利用することによりカラム単位で圧縮することができます。

COMPRESS関数ではZLIB圧縮されるため、30%〜50%ぐらいになります。ただし、その分、伸長にかかるCPU負荷が増えるはずです*1

CREATE TABLE `comp` (
  `body` longblob NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT comp VALUES(COMPRESS("hoge hoge hoge hoge hoge hoge"));

SELECT UNCOMPRESS(body) FROM comp;
+-------------------------------+
| UNCOMPRESS(body)              |
+-------------------------------+
| hoge hoge hoge hoge hoge hoge |
+-------------------------------+
1 row in set (0.00 sec)

これで、アプリ側でCOMPRESSとUNCOMPRESSつけるのが若干めんどいですがカラム単位で圧縮することができます。

理想的にはアプリを改修することなく、圧縮・解凍できるようにMySQL側で自動でCOMPRESSとUNCOMPRESSができるようにしたいですね。

実現方法はQuery Rewrite Pluginぐらいしかないのかしら。なんかいい方法ないかなぁ。

追記

generated columnを使えば自動的にUNCOMPRESSはできるみたい。

MySQLでgenerated columnを使って圧縮したデータを自動的に解凍する - CreateField Blog

*1:具体的な負荷は検証してません。ところで、マニュアルではsuch as zlibっていってますが、他の圧縮ライブラリが使えるように実装されているんですかね?LZ4やsnappyなどの伸長速度優先のアルゴリズムが利用できれば、高速に伸長できて良さそうです。そのうち調べるかも、調べないかも

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と逆になっててさらに混乱しやすい感じです。