CreateField Blog

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

全文検索エンジンGroongaからword2vecを簡単に使えるプラグイン

はじめに

Groonga Advent Calendar 2015の11日目の記事です。

GroongaはC/C++で書かれた高速な国産の全文検索エンジンです。

word2vecは、Googleが研究評価用に作った単語の特徴をベクトルで表現しニューラルネットモデルで教師なし学習をさせるツールです。

単語を文脈も考慮させたベクトルで表現しニューラルネットで学習することで、単語の意味的な足し引きができているんじゃないか( kingからman引いてwoman足したらqueenがでてきましたよとか。)ということで少し前に結構流行ったようです。

また、word2vecの作者が簡易的にsentenceのベクトル表現を使えるようにしたword2vecのコードもあるようです。

普段、Groongaを使ったそこそこの規模のデータベースをもっているので、Groongaに格納されたデータからword2vec/sentence2vecで簡単に学習させることができ、また、Groongaから単語ベクトルの演算ができるプラグインを作成しました*1

naoa/groonga-word2vec · GitHub

dump_to_train_fileコマンド Groongaのカラムから学習ファイル生成

Groongaのカラムからword2vecで学習できるように整形してファイルに出力します。

形態素解析やノーマライズ、記号削除などの前処理がオプション指定で行えるようになっています。

sentence_vectorsオプションを使うとdoc_id:とGroongaのテーブルの_idが紐付けられて出力されます。これにより後のベクトル演算時に元のテーブルと関連付ける事が可能です。

また、column名の後にカッコでカテゴリなど特定のカラムに格納されたタグ等に任意のラベルを付与できます。 たとえば、category:カテゴリAやcompany:会社名Aなどの形で学習させることで類似するカテゴリや会社のみを抽出することができます。

実行例

dump_to_train_file docs companies_[company:],categories_[category:],title/,body/@$ \
  --filter 'year >= 2010' --sentence_vectors 1

会社名とカテゴリは、スペースを_でつなげてフレーズ化してラベルをつけ、TitleとBodyは形態素解析、記号削除などをおこなっています。また--filterでテーブルの検索結果のみを出力することができます。

word2vec_trainコマンド word2vecコマンドを実行して学習

これはオプションを少しいじって実行パスにあるword2vecコマンドを実行するだけです。 このプラグインをインストールすると上記のsentence vector追記版のword2vecが自動的にbindirにインストールされます*2。 学習はGroongaを介して実行しなくても構いません。

word2vec_train --window 20 --cbow 0 --threads 12 --iter 10 --sentence_vectors 1

word2vec_distanceコマンド ベクトル距離を演算

word2vecで学習させたバイナリファイルを読み込み、単語ベクトルを演算します。初回のみバイナリファイル読み込んでをメモリに展開するため、少し時間がかかります。Groongaを常駐でサーバ実行している場合は一度読みこめば2度目からは素早く計算することができます。同時に複数のモデルファイルを読み込むことができます(20個まで)。

word2vecに付属しているdistanceコマンドをベースに以下を改良しています。

  • スペースと+/- で単語ベクトルの演算可
  • offset,limit,thresholdなどページ制御のためのオプション
  • sentence_vectorの場合、元のテーブルをひも付けてカラム出力、ソート
  • 高速化のため語彙表を配列ではなくPatricia Trieで保持

元々のdistance.cは語彙表から一致する単語を探すのに線形探索をしていますが、Patricia Trieを使うことによりマッチ時間の短縮が見込めます。

doc_id:や、category:など特定のラベル付けしたもののみを取得するような場合、Patricia Trieで非常に高速な前方一致検索が可能です。

以下の例では、2sec近くかかっていたのが0.01sec以下で類似カテゴリが取得可能となっています。

  • 改良前(全ワード+正規表現"company:.*"で絞込)
> word2vec_distance "company:apple" --is_phrase 1 --limit 5 --offset 0 \
  --n_sort 5 --white_term_filter "company:.*" --output_pretty yes
[
  [
    0,
    1449782162.00164,
    2.34969544410706
  ],
  [
    [
      5
    ],
    [
      [
        "_key",
        "ShortText"
      ],
      [
        "_value",
        "Float"
      ]
    ],
    [
      "company:google technology holdings",
      0.644274830818176
    ],
    [
      "company:research in motion",
      0.641874372959137
    ],
    [
      "company:htc",
      0.63908725976944
    ],
    [
      "company:lenovo (singapore) pte",
      0.63323575258255
    ],
    [
      "company:blackberry",
      0.622487127780914
    ]
  ]
]
  • 改良後(パトリシアトライで"company:"に前方一致)
> word2vec_distance "company:apple" --is_phrase 1 --limit 5 --offset 0 \
  --n_sort 5 --prefix_filter "company:" --output_pretty yes
[
  [
    0,
    1449781678.28364,
    0.00766372680664062
  ],
  [
    [
      5
    ],
    [
      [
        "_key",
        "ShortText"
      ],
      [
        "_value",
        "Float"
      ]
    ],
    [
      "company:google technology holdings",
      0.644274830818176
    ],
    [
      "company:research in motion",
      0.641874372959137
    ],
    [
      "company:htc",
      0.63908725976944
    ],
    [
      "company:lenovo (singapore) pte",
      0.63323575258255
    ],
    [
      "company:blackberry",
      0.622487127780914
    ]
  ]
]

このようにGroongaは全文検索だけでなくPatricia Trie、Double Array、HashのCライブラリとしても有用に使うことができます。今回のケースでは更新をしないので、Marisa Trieが使えたらより省メモリで構築できてよいかもしれません。

word2vec実行例

会社名のラベルをつけて類似会社名のみを取得してみます。

> word2vec_distance "company:トヨタ自動車株式会社" --limit 10 --offset 0 \
 --n_sort 10 --prefix_filter "company:" --output_pretty yes
[
  [
    0,
    1449815073.42663,
    0.138718605041504
  ],
  [
    [
      5
    ],
    [
      [
        "_key",
        "ShortText"
      ],
      [
        "_value",
        "Float"
      ]
    ],
    [
      "company:日産自動車株式会社",
      0.911168932914734
    ],
    [
      "company:三菱自動車工業株式会社",
      0.891977429389954
    ],
    [
      "company:富士重工業株式会社",
      0.870425820350647
    ],
    [
      "company:ダイハツ工業株式会社",
      0.858096361160278
    ],
    [
      "company:本田技研工業株式会社",
      0.839616179466248
    ]
  ]
]

類似しているぽい会社名が取得できました。

ベクトルの加減算も自由に行えます。

> word2vec_distance "company:トヨタ自動車株式会社 - company:日産自動車株式会社 + company:グーグル_インコーポレイテッド" \
--limit 5 --n_sort 5 --output_pretty yes
[
  [
    0,
    1449815137.37063,
    0.501566171646118
  ],
  [
    [
      5
    ],
    [
      [
        "_key",
        "ShortText"
      ],
      [
        "_value",
        "Float"
      ]
    ],
    [
      "company:グーグル__インコーポレイテッド",
      0.87317681312561
    ],
    [
      "company:グーグル・インコーポレーテッド",
      0.868086099624634
    ],
    [
      "company:ヤフー!_インコーポレイテッド",
      0.836208581924438
    ],
    [
      "company:アリババ・グループ・ホールディング・リミテッド",
      0.827649772167206
    ],
    [
      "company:マイクロソフト_コーポレーション",
      0.825306117534637
    ]
  ]
]

あ、表記ゆれが結構あるな。。

会社名の総数は単語の語彙数よりは数が少ないのでそこそこ高速に検索できています。最初にカテゴリーかなにかで分類すれば、結構高速に得られますね。

sentence2vec実行例

dump_to_train_file Entries title,tag,tags --sentence_vectors 1
word2vec_train --min_count 1 --cbow 1 --sentence_vectors 1
word2vec_distance "doc_id:2" --sentence_vectors 1 --table Entries --column _id,title,tag
[
  [
    0,
    0.0,
    0.0
  ],
  [
    [
      2
    ],
    [
      [
        "_id",
        "UInt32"
      ],
      [
        "title",
        "ShortText"
      ],
      [
        "tag",
        "Tags"
      ]
    ],
    [
      3,
      "Database",
      "Server"
    ],
    [
      1,
      "FulltextSearch",
      "Library"
    ]
  ]
]

doc_idを指定することにより(たぶん)類似する文書の取得でき、そのカラムを直接取得することができます。

実際の実行例のせようと思ってたのですが、min_countオプションを指定するのを忘れてdoc_idが除去されてしまいました。min_conutのデフォルトは5で出現数が5に満たない語彙は捨てられます。sentence vectorの場合は、--min_count 1にして除外されないようにしないといけませんね。実験結果はそのうち載せようと思います。

試してみました。

Groongaからword2vecを使って類似文書を取得してみる - CreateField Blog

おわりに

Groongaからword2vecを簡単に使うためのプラグインを紹介しました。 GroongaはMySQLでテーブル構築やデータ管理ができるMroongaや、PosrgreSQLからGroongaのインデックスを使うことができるPGroongaが開発されています。これらを使えば簡単にGroongaのテーブルを作ることができ、このプラグインを使えばword2vecを簡単に試すことができます。私はMroongaからこのプラグインを利用しています。GroongaやMySQLにデータがあって、わざわざ整形したりするのが面倒という場合はこのプラグインを使ってみてはいかがでしょうか。

word2vecと似たようなものでGloVeというのもあるみたいです。

この発表では編集距離ベースに誤記の表記揺れを抽出した例を紹介しましたが、word2vecをうまく使えば、略語や言い換えなど意味的な表記揺れもある程度取得可能かもしれませんね*3

*1:普通の人はgensimなどでPythonから使いたいでしょうが、なぜかGroonga first脳なので

*2:インストールしないことも可能

*3:ノイズも多いでしょうが

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:すべての業務がそのような仕事ではないと思います。