CreateField Blog

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

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ぐらい。参考

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千万という制限があります。

全文検索エンジンGroongaユーザ勉強会@神戸を主催しました

2014/06/27(金)に全文検索エンジンGroongaユーザ勉強会@神戸を主催しました。

開催のきっかけ

草の根Groongaイベントのお誘いを受けて、関西圏でもGroongaのイベントがあるといいなと思い、神戸でも開催してみることにしました。

会議室の確保

人の集まり具合がどうなるかわからなかったので、三宮近辺でできるだけ費用が抑えられるというポイントで会場を探しました。WordBench神戸などが利用されているような貸会議室ビジネスしているところだと、平日夜間でも1万円を超えるところが多かったです。人が数十人来ることがほぼ確定しているプロダクトの勉強会なら千円徴収すればなんとかなりますが、小規模が想定される勉強会での利用は難しかったです。

そこで、公民館など市営の施設の会議室をこちらから探してみました。公民館等の場合、だいたい10数名規模の部屋で数千円といったところでした。

その他、インターネットで探して見ると、KIITOが1時間につき500円で時間単位の貸し出しOKで、且つ、比較的、予約も空いていたので利用することにしました。無料で備品も貸出しており、なかなか穴場なんじゃないかなぁと思いました。ただ、費用は安く抑えられましたが、駅からやや遠いのが難点でした。

告知

DoorkeeperやTwitter、メーリングリストなどで告知しました。また、近隣のRuby関西GDG神戸のAngularJSの勉強会などで簡単な開催のお知らせをしました。

当日の様子

19:00を超えると、入り口がclosedって札が立つのを知らなくて、入るのに戸惑った人がいて申し訳ありませんでした。

平日ということもあって、19:30ぐらいになるまでなかなか人が集まりませんでしたが、最終的には、当日キャンセルなしで合計10名が参加していただけました。ありがとうございました。

今回の勉強会では、Groongaの紹介と事例紹介を発表しました。

Groongaの紹介では、私が説明しながら、Groongaの開発に携わっている須藤(@ktou)さんが適宜、補足や質問に答える形で進めました。開発に携わっている方から直接、詳細な情報が聞けるいい機会になったんじゃないかなと思います。

事例紹介では、特許の全文検索サービスPatentFieldを紹介しました。当サービスでは、PHPからMroongaを使っていますが、Groongaの機能をすべて使いたかったので、全文検索はUDFを使ってGroongaのコマンドを使っています。基本的にGroongaのほぼすべての機能を使っているので、実際に使っている様子と工夫した点を紹介しました。

参加していただいた@shigeponさんが、ブログに勉強会の感想を書いてくれています。よければ、こちらもご参照ください。

懇親会

勉強会終了後、終電間近まで懇親会が行われました。懇親会では、Groonga関連の話もGroongaに関係ない話も盛り上がってた思います。@soundkitchenさんからdocker-mroongaについて紹介がありました。MroongaをDockerを使って簡単にイメージ作るにはこれしかない!と熱弁されていました。Dockerを使って、Mroongaを簡単にサーバにデプロイしたい方は是非使ってみるといいのではないでしょうか。

感想・反省点

進行がなかなかうまくいかず、発表を用意していただいた方もいらっしゃったのですが、時間を確保できなくて申し訳ありませんでした。

実際にコマンドをうちながら説明したいなぁと思ったのですが、現場では案外パッとでてこないので、事前に準備しておいたほうがよかったなぁとおもいました。

また、平日ということもあり、なかなか19時丁度に全員集まるのは難しかったので、今後、開催することがあれば、できるだけ休日に開催してみようかなと思っています。

勉強会の主催経験がなく拙い進行でしたが、参加いただいた皆様、および、わざわざ東京から来ていただいた須藤(@ktou)さん、どうもありがとうございました!

Groongaの今後への期待

当日できなかったGroongaの今後への期待の資料です。この資料は、メーリングリストで案内があった[groonga-dev,02439] Groongaの今後の開発への協力のお願いへの回答です。

61st Ruby/Rails勉強会@関西でLTしました

以下は、LTの資料です。

MacとRabbitの操作に戸惑ってしまい、デモの検証が一部しかできませんでした。デモができなかった分の検証結果を追記しています。

2014/6/27(金)19:00~神戸でGroongaの勉強会をします。Doorkeeperで募集していますので、興味がある方は是非ご参加ください。

http://koberoonga.doorkeeper.jp/events/11578

国産の全文検索エンジンGroonga vs 世界的流行のElasticsearch

2014年4月21日は、第4回Elasticsearch勉強会ですね!

http://elasticsearch.doorkeeper.jp/events/8865

第4回Elasticsearch勉強会は、参加希望者が約200名の大反響なようです。

私は勉強会に参加できないので、C言語で書かれた国産の高速な全文検索エンジンGroongaと、Javaで書かれた世界的に勢いのあるElasticsearchについて性能の比較をしたいと思います。

  • 注意事項

今回の検証では1台あたりの馬力を比較するためにサーバ1台での全文検索性能について比較しています。

私は、Groonga(Mroonga)の利用暦が約2年であるのに対し、Elasticsearchの利用暦は2日です。このため、Elasticsearchに対するチューニングの不備や公平な比較になっていない点が含まれている可能性があります。

Elasticsearchでの言葉の扱いに慣れていないため、誤った語句を使っている可能性があります。

Groongaとは

GroongaはC言語で書かれた国産の高速な全文検索エンジンです。Groongaは、C言語から呼び出すことが可能な全文検索ライブラリとしての機能を有し、また、単体で全文検索サーバとしての機能も有します。  

Groongaでは、MySQLのストレージエンジンとしてSQLベースで容易に高速な全文検索が可能なMroongaが提供されています。GroongaやMroongaは、それ単体ではスケールしませんがfluentdのプラグインとして動作するDroongaSpiderストレージエンジンを使うことによりデータベースシャーディングが可能です。

Elasticsearchとは

Elasticsearchは、Javaで書かれた全文検索ライブラリApache Luceneを用いた全文検索、解析サーバです。Elasticsearchは、RESTfullで容易にアクセスすることができ、データを容易にスケールさせることができます。

また、Elasticsearchは、全文検索サーバSolrでも使われている歴史あるApache Luceneを使っていることもあり、非常に高度な全文検索機能、集計機能、豊富なプラグインを有し、多種多様な運用ツールとの連携の実績があります。

最近では、Elasticsearchとfluentdとkibanaを使ったログの可視化等が非常に流行っているようです。

検証データ

  • 日本語Wikipedia - Articles, templates, media/file descriptions, and primary meta-pages.

http://dumps.wikimedia.org/jawiki/20131119/ *1

XMLデータサイズ レコード数
7.3GiB 1,766,247

Wiki記法等は除去せずそのまま利用します。

7/19追加検証

http://dumps.wikimedia.org/jawiki/20140714/

検証環境

  • さくらのクラウド ×1台
CPU メモリ ディスク
4Core 16GB SSD 100GB

7/19追加検証

  • さくらの専用サーバ ×1台
CPU メモリ ディスク
Intel(R) Xeon(R) CPU E5620 @ 2.40GHz 1CPU 4Core 32GB HDD 2TB

Elasticsearchの検証手順

環境構築

CentOSにElasticsearchをインストールする方法の手順に沿って、Elasticsearch1.1.1をインストールします。 ヒープサイズを8GiBに設定し、JVM起動時にメモリを確保するように設定しています。 また、デフォルトのシャード数を1に設定しています。

% vi /etc/init.d/elasticsearch
ES_HEAP_SIZE=8g
MAX_OPEN_FILES=65535
MAX_LOCKED_MEMORY=unlimited
% vi config/elasticsearch.yml
http.port: 9200
index.number_of_shards: 1
bootstrap.mlockall: true

ElasticSearchの運用とか (2)を参考にさせていただきました。ありがとうございます。

スキーマ

以下のような簡潔なテーブル構造を作成することを想定します。

id(integer) title(string) text(string)
150813 スポーツのプロリーグ一覧 '''スポーツのプロリーグ一覧'''(スポーツの...
150815 Category:スポーツ競技大会 {{Pathnav|主要カテゴリ|文化|娯楽|スポーツ|...

上記のテーブル構造に相当するスキーマを作成します。 アナライザーは、Groongaに合わせるためにngram_tokenizerでmin_grammax_gramを2にしています*2。 なお、Elasticsearchでは文字の正規化やタグ除去、ストップワード除去等フィルタがかなり豊富に用意されていますが、今回は単純な全文検索性能を比較するため、それらを使用しません。

% vi mapping.json
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ngram_analyzer": {
          "tokenizer": "ngram_tokenizer"
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "nGram",
          "min_gram": "2",
          "max_gram": "2",
          "token_chars": [
            "letter",
            "digit",
            "punctuation",
            "whitespace",
            "symbol"
          ]
        }
      }
    }
  },
  "mappings": {
    "text": {
      "properties": {
        "id": {
          "type": "integer"
        },
        "title": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "text": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        }
      }
    }
  }
}

スキーマファイルをPOSTします。

curl -XPOST localhost:9200/wikipedia -d @mapping.json

これにより、wikipediaという名前のindexのスキーマが設定されました。なお、Elasticsearchは、スキーマレスでも動作します。

7/19追加検証
最初の検証では、punctuationとwhitespaceが入っておらず、Groongaと転置索引のトークンの量に違いがありました。追加検証では、Groongaと合わせるためにpunctuationとwhitespaceを有効にしています。

更新手順

更新は、たとえば、以下のようなPUTアクセスで行います。

% curl -XPUT 'http://localhost:9200/wikipedia/text/1' -d '
 { "id": "1", "title": "title1", "text": "text1" }
'

Gistに上げたPHPスクリプトを用いて、WikipediaのXMLデータを1件ずつPUTで更新します。

Elasticsearchでは、バルクアクセスのAPIや各スクリプト言語のライブラリを用いることもできるようですが、今回は単純に1件ずつCURLPUTするだけにしました。

検索手順

検索は、たとえば、以下のようなGETアクセスで行います。titleフィールドとtextフィールドに対してsearchqueryを全文検索し、一致するidを0件出力します。ここでは、全文検索性能のみを比較するため、出力数は0にしています。

curl -XGET http://localhost:9200/wikipedia/text/_search -d'
{
  "from" : 0, "size" : 0,
  "fields": ["id"],
  "query":
    {
      "multi_match" : {
        "query":    "\"searchquery\"",
        "fields": [ "title", "text" ]
      }
    }
}'

7/19追加検証
以前の検証結果では、"でくくっていなかったため、OR検索となっていました。追加検証では、"でくくってフレーズ検索としています。Elasticsearchでは、空白が含まれていなくとも、"でくくらないと、アナライザーによって分割されたトークンがOR検索されます。たとえば、「東京都」は、「東京」OR「京都」で検索されます。

Wikipediaのカテゴリのうち5文字以上の日本語のみのカテゴリからランダムに1万件を抽出して全文検索します。

Gistに上げたPHPスクリプトを用いて、1万件のカテゴリを1件ずつGETで全文検索します。更新と同様に、単純にCURLGETするだけです。

なお、Elasticsearchでの名前体系や扱い方に慣れておらず、不平をつぶやいていたら、@johtaniさんがフィールドの指定方法やカウントの見方を教えてくれました。ありがとうございました。Elasticsearchについて無知なにも関わらず、色々不平をつぶやいて大変失礼しました。

GroongaやDroongaでの名前体系に慣れていると、Elasticsearchの名前体系に慣れるのがなかなか大変でストレスを感じます。たぶん、Elasticsearchに慣れている方がGroongaやDroongaを触ろうとしてもそう感じるんじゃないかなと思います。

Groongaの検証手順

環境構築

Groonga4.0.1とnginxベースのgroonga-httpdをインストールします。

% rpm -ivh http://packages.groonga.org/centos/groonga-release-1.1.0-1.noarch.rpm
% yum makecache
% yum install -y groonga
% yum install -y groonga-tokenizer-mecab
% yum install -y groonga-normalizer-mysql
% yum install groonga-httpd

スキーマ

上記と同様のテーブル構造に相当するテーブル定義を作成します。 トークナイザーはTokenBigramを設定し、ノーマライザーは設定していません。 こうすると、Groongaでは、一般的なバイグラムのルールに従ってテキストがトークナイズされます。

% vi table.ddl
table_create text TABLE_PAT_KEY UInt32
column_create text text COLUMN_SCALAR LongText
column_create text title COLUMN_SCALAR LongText
table_create text-text TABLE_PAT_KEY ShortText --default_tokenizer TokenBigram
column_create text-text index COLUMN_INDEX|WITH_SECTION|WITH_POSITION text title,text

Groongaのテーブルを作成します。

% groonga -n /var/lib/groonga/db/db < table.ddl
[[0,1397980056.85291,0.00288963317871094],true]
[[0,1397980056.85586,0.00232267379760742],true]
[[0,1397980056.8582,0.00235915184020996],true]
[[0,1397980056.86058,0.00229644775390625],true]
[[0,1397980056.86289,0.00667881965637207],true]

コマンドによってデータベースが新規作成された場合、groonga-httpdから操作できるようにファイルオーナーをgroonga:groongaに修正します。groonga-httpdが起動している場合、すでにgroonga:groongaでデータベースがつくられていると思います。

% chown groonga:groonga /var/lib/groonga/db/db*
% service groonga-httpd restart

これにより、textという名前のテーブルが作成されました。Groongaでは、MySQLなどと同様に明示的なテーブル定義を必要とします。

更新手順

更新は、たとえば、以下のようなPOSTアクセスで行います。

% curl -X POST 'http://localhost:10041/d/load?table=text' -d '
 [{"_key":1,"text":"text1","title":"title1"}]
'

Gistに上げたPHPスクリプトを用いて、WikipediaのXMLデータを1件ずつPOSTで更新します。

Groongaではデータのみを追加した後に全文インデックスを追加することにより最適化された静的インデックス構築ができますが、今回は単純にCURLで1件ずつPOSTするだけです。

なお、普段はGroongaをMySQLから利用できるMroongaを利用しており、GroongaのHTTPサーバに対してPOSTでうまく更新できないなぁとつぶやいていたら、@ktouさんがnginxベースのgroonga-httpdを使えばいいことを教えてくれました。ありがとうございました。

検索手順

検索は、たとえば、以下のようなGETアクセスで行います。titleカラムとtextカラムに対してsearchqueryを全文検索し0件出力します。ここでは、Elasticsearch同様、全文検索性能のみを比較するため、出力数は0にしています。また、Groongaでは、ヒット数が0件の場合、自動的に前方一致検索にエスカレーションするという機能があるためこれを抑制しています。

% curl 'http://localhost:10041/d/select?table=text&match_columns=title||text&query=searchquery&limit=0&match_escaltion_threshold=-1'

Wikipediaのカテゴリのうち5文字以上の日本語のみのカテゴリから上記と同じカテゴリ1万件を全文検索します。

Gistに上げたPHPスクリプトを用いて、1万件のカテゴリを1件ずつGETで全文検索します。更新と同様に、単純にCURLで1件ずつGETするだけです。

性能比較

更新時間

Elasticsearch Groonga
8853sec(2h27min) 21914sec(6h05min)

いつインデックスが更新されているかまでは追っていないので単純な比較はできないかもしれませんが、シーケンシャルなスクリプトの実行時間で比較すると、Groongaの方が2.5倍ほど遅かったです。

追記
Groongaの方はデータと転置索引の更新の両方が含まれた時間であり、Elasticsearchの方は転置索引の更新の時間は含まれていません。Elasticsearchは、リフレッシュ、フラッシュ、マージなどの転置索引の更新処理がデータの更新とは別に裏で走ります。

※トータルの更新時間には、XMLのパースが含まれています。

7/19追加検証

Elasticsearch Groonga
6689sec(1h51min) 20958sec(5h49min)

ディスク使用量

Elasticsearch Groonga
12.7GiB 18.9GiB

ディスク使用量は、Elasticsearchの方がコンパクトでGroongaの方が1.5倍ほど大きいですね。 Elasticsearchの方は設定をミスってしまって空白と所定の区切り文字が転置インデックスに入っていないですが、それを差し引いてもGroongaの方が大きいでしょう。

なお、Groongaの場合、インデックスのみを後で静的インデックス構築をすれば、ディスク使用効率が上がり検索性能がさらに向上します。なお、この方法はリアルタイム更新ではないのでここでは比較しません。

6/30追記
Groongaドキュメント読書会1で学んだ事のメモ - Qiitaを参考にすると、Groongaは、インデックスの即時更新のため、インデックス各データの間に隙間があり、データを間に差し込めるようになっているようです。このため、Elasticsearchに比べ、Groongaの方がディスク使用量が大きくなっているものと思われます。その分、インデックス更新にかかる処理コストが低いというメリットがあるようです。

http://groonga.org/ja/blog/2011/07/28/innodb-fts.html

7/19追加検証

Elasticsearch
(optimize前)
Groonga
19.3GiB 18.9GiB

punctuationとwhitespaceを有効にしたらElasticsearchの方が大きくなってしまいました。 おそらく、これはマージが終わっていないのでしょう。

以下のようにoptimizeをするとサイズが減少しました。

% curl -XPOST 'http://localhost:9200/wikipedia/_optimize'
Elasticsearch
(optimize後)
Groonga
16.5GiB 18.9GiB

検索時間

種別 Elasticsearch Groonga
トータル(1万件) 1050.737sec 424.797sec
平均 0.105sec 0.042sec
最長 0.792sec 0.368sec
最短 0.000992sec 0.000842sec

上記を比較すると、全文検索速度はGroongaの方が約2.5倍ほど速いことが判ります。 経験則から言うと、Groongaはこの数倍~10倍ぐらいのデータサイズであっても1台で十分に高速に全文検索できると思います*3

ただし、Groongaは、単体ではシャーディングすることができません。一方、Elasticsearchは、デフォルトのシャード数が5であることからもわかるように、複数台構成が前提となっており容易にシャーディングすることができます。

また、今回は単一アクセスの検索性能しか試していません。同時実行性能を含めるとどちらが勝っているかは判りません。

7/19追加検証
フレーズ検索でもGroongaの方がElasticsearch(optimize後)よりも約2.8倍ほど速いことが判ります。Elasticsearch(optimize前)であれば約4.3倍ほど速いことが判ります。ちなみにGroongaでは、リアルタイム更新をしても速度劣化はほとんどないため、optimizeという処理はありません。詳しくはこちら

種別 Elasticsearch
(optimize前)
Elasticsearch
(optimize後)
Groonga
トータル
(1千件)
216.161sec 141.701sec 50.062sec
平均 0.216sec 0.141sec 0.050sec
最長 4.313sec 1.037sec 0.339sec
最短 0.000960sec 0.00317sec 0.00215sec

おわりに

以上、サーバ1台、単一アクセスにおけるGroongaとElasticsearchの性能を比較しました。 まとめると以下のような比率になります。数値が高い方が性能が良いことを示しています。

性能種別 Elasticsearch Groonga
更新性能 2.5 1
ディスク使用効率 1.5 1
検索性能 1 2.5

7/19追加検証

性能種別 Elasticsearch
(optimize前)
Groonga
更新性能 3.13 1
ディスク使用効率 1 1.02
検索性能 1 4.31


性能種別 Elasticsearch
(optimize後)
Groonga
更新性能 3.13 1
ディスク使用効率 1.14 1
検索性能 1 2.83

上記の比較例では、更新性能、ディスク使用効率ではElasticsearchが勝っており、検索性能ではGroongaが勝っていました。なお、あくまで今回のテストケースによる結果であり、他のテストケースではどうかわかりません。

Elasticsearchは、今、世界的に勢いがあり、容易なスケール機能、豊富な機能、豊富なプラグイン、運用ツールとの連携等、様々なメリットがあります。 アナライザーやフィルターのラインナップは、歴史が長いApache Luceneのライブラリを使っていることもあり、現状、GroongaよりもElasticsearchの方が優れているでしょう。 MoreLikeThisや類似画像検索は、現在のGroongaにはない機能です。

現状、大規模なWebサービス等でサーバを何十台、何百台とたくさん並べて運用する場合は、Elasticsearchの方が利用しやすいかもしれません。

しかし、私は、サーバ1台あたりの全文検索自体の馬力ではGroongaの方が勝っていると考えています。また、弱点だったスケール機能は、2014年2月にDroongaがメジャーリリースされ解消されつつあります。 さらに、Groongaは、MySQLのストレージエンジンであるMroongaを利用することによりSQLベースで非常に容易に高速な全文検索をすることもできます。私はMroongaがあったので、データベースも全文検索の知識もまったくなかった状態からある程度Groongaが扱えるようになりました。

kuromojiトークナイザの検索モードやフィルタ機能程度の利点であれば、多少工夫すればGroongaやMroongaでも補えます。今、Mroongaを使っていて、高度な検索式や豊富な全文検索機能を追加したいと考えている方は、まずは、GroongaやDroongaを検討してもいいと思います。Mroongaをストレージモードで使っているならデータベースそのままでGroongaのコマンドが利用できますし、MySQLの制約からくるインデックスが使用できないことによる速度劣化であれば、Groongaのコマンドに置き換えるだけで10倍以上の検索速度になったりすると思います。詳しくはこちらを参照してください。

上記の比較はあくまで一例にすぎませんが、そこそこ大規模なデータベースに対して少ないサーバ台数で高速な全文検索を実現したいならば、私はGroongaがおすすめだと思っています。

私はGroonga(Mroonga)を使うことにより、データサイズが400GiB超のデータベースを専用サーバ1台でそこそこ実用的な全文検索速度を実現させることができました*4

データベースの規模や特性に応じて、ElasticsearchとGroongaを使い分けるのも良いかもしれません。

最近、Mroongaを使って全文検索Webサービスを作ったときにはまったことについて、いくつかブログ記事を書きました。GroongaやMroongaに興味があれば、こちらも参考になるかもしれません。

Mroongaを使って全文検索Webサービスを作ったときにはまったこと(第1回)
Mroongaのラッパーモードからストレージモードに変えた理由
数百GiBの全文検索用データベースをMroongaのストレージモードにしてはまったこと
Groongaがあまり得意でない類似文書検索に連想検索エンジンGETAssocを使った話

今後は、Groonga(Mroonga)を使って工夫した点等を書いていきたいと思っています。

7/22追記
GroongaとElasticsearchの転置索引の違いと更新反映速度の差、およびGroongaの静的索引構築とリアルタイム更新時の性能差について検証した記事を追加しました。よければ、こちらもご参照ください。
GroongaとElasticsearchにおける転置索引の違いと更新反映速度について

7/28追記
GroongaをKVSとして利用した場合のベンチマークについて記事を書きました。Groongaは単純なKVSとしてもかなり高速な性能を有しています。
GroongaとTokyoCabinetのHash表のベンチマークについて

9/9追記
ちなみに、Groongaの場合、トークナイザーをTrigramにし、さらに効率化した自作トークナイザ―を使うと検索速度が8倍ほど速くなります(平均 0.0063 sec)。

https://github.com/naoa/groonga-tokenizer-yangram

Groongaの自作トークナイザーの紹介 - Qiita

2015/3/9追記
最新のGroongaで以下のパッチを適用すると、さらに検索速度が1.5倍〜2倍ほど速くなります。

頻出トークンとレアトークンを一緒に検索したときの性能向上パッチ (groonga-dev,03095) - Groonga - fulltext search engine. (グルンガ) - SourceForge.JP

*1:データがやや古いのは、以前、Mroongaの性能をいろいろ検証したときと同じものを使ったためです。

*2:厳密にGroongaに合わせるためには、token_charsにwhitespaceとpunctuationも指定すべきでした。

*3:インデックスが適正に使われていることが前提です。Mroongaの場合、MySQLによる制約でインデックスが利かないケースが多々あります。

*4:アクセスが増えると参照分散させるために台数を増やす必要があると思います。実用的な速度かどうかは、アプリの要求次第によって変わると思います。デフォルトのトークナイザTokenBigramではこのサイズはさばけないと思います。自前でトークナイザを改修したり工夫しています。