mroonga - MySQLで高速に全文検索するためのオープンソースのストレージエンジン

3.3.1. ストレージモード

ここでは mroonga におけるストレージモードの利用方法を説明します。

3.3.1.2. 検索スコアの取得方法

ノート

1.0.0以前のmroongaではMySQLの標準的な検索スコアの取得方法ではなく、 _score という専用のカラムを作成するという独自の方法でした。1.0.0からはMySQLの標準的な取得方法になっています。

全文検索を行う際、指定したキーワードにより内容が一致するレコードを上位に表示したいというような場合があります。そうしたケースでは検索スコアを利用します。

検索スコアはMySQLの標準的な方法 [1] で取得できます。つまり、SELECTの取得するカラム名を指定するところやORDER BYのところにMATCH...AGAINSTを指定します。

それでは実際にやってみましょう。:

mysql> INSERT INTO diaries (content) VALUES ("It's fine today. It'll be fine tomorrow as well.");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO diaries (content) VALUES ("It's fine today. But it'll rain tomorrow.");
Query OK, 1 row affected (0.00 sec)

mysql> SELECT *, MATCH (content) AGAINST ("fine") FROM diaries WHERE MATCH (content) AGAINST ("fine") ORDER BY MATCH (content) AGAINST ("fine") DESC;
+----+--------------------------------------------------------------+------------------------------------+
| id | content                                                      | MATCH (content) AGAINST ("fine") |
+----+--------------------------------------------------------------+------------------------------------+
|  3 | It's fine today. It'll be fine tomorrow as well. |                                  2 |
|  1 | It'll be fine tomorrow.                      |                                  1 |
|  4 | It's fine today. But it'll rain tomorrow.    |                                  1 |
+----+--------------------------------------------------------------+------------------------------------+
3 rows in set (0.00 sec)

検索対象の文字列 晴れ をより多く含む、すなわち検索スコアの高い id = 3 のメッセージが上に来ていることが確認できます。また、SELECT句にMATCH AGAINSTを記述しているため、検索スコアも取得できています。

属性名を変更したい場合は AS を使って下さい。

mysql> SELECT *, MATCH (content) AGAINST ("fine") AS score FROM diaries WHERE MATCH (content) AGAINST ("fine") ORDER BY MATCH (content) AGAINST ("fine") DESC;
+----+--------------------------------------------------------------+-------+
| id | content                                                      | score |
+----+--------------------------------------------------------------+-------+
|  3 | It's fine today. It'll be fine tomorrow as well. |     2 |
|  1 | It'll be fine tomorrow.                      |     1 |
|  4 | It's fine today. But it'll rain tomorrow.    |     1 |
+----+--------------------------------------------------------------+-------+
3 rows in set (0.00 sec)

3.3.1.5. レコードIDの取得方法

groongaではテーブルにレコードを追加した際にレコードを一意に識別するための番号が割当てられます。

mroongaではアプリケーションの開発を容易にするため、このレコードIDをSQLで取得できるようになっています。

レコードIDを取得するためには、テーブル定義時に _id という名前のカラムを作成して下さい。

mysql> CREATE TABLE memos (
    ->   _id INT,
     >   content VARCHAR(255),
    ->   UNIQUE KEY (_id) USING HASH
    -> ) ENGINE = mroonga;
Query OK, 0 rows affected (0.04 sec)

_idカラムのデータ型は整数型(TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT)である必要があります。

また_idカラムにはインデックスを作成することが可能ですが、HASH形式である必要があります。

INSERTでテーブルにレコードを追加してみましょう。_idカラムは仮想カラムとして実装されており、また_idの値であるレコードIDはgroongaにより割当てられるため、SQLによる更新時に値を指定することはできません。更新対象から外すか、値に null を使用する必要があります。

mysql> INSERT INTO memos VALUES (null, "Saury for today's dinner.");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO memos VALUES (null, "Update mroonga tomorrow.");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO memos VALUES (null, "Buy some dumpling on the way home.");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO memos VALUES (null, "Thank God It's meat day.");
Query OK, 1 row affected (0.00 sec)

レコードIDを取得するには、_idカラムを含むようにしてSELECTを行います。

mysql> SELECT * FROM memos;
+------+------------------------------------------+
| _id  | content                                  |
+------+------------------------------------------+
|    1 | Saury for today's dinner.                    |
|    2 | Update mroonga tomorrow. |
|    3 | Buy some dumpling on the way home.                 |
|    4 | Thank God It's meat day.                 |
+------+------------------------------------------+
4 rows in set (0.00 sec)

また直前のINSERTにより割当てられたレコードIDについては、last_insert_grn_id関数により取得することもできます。

mysql> INSERT INTO memos VALUES (null, "Just one bottle of milk in the fridge.");
Query OK, 1 row affected (0.00 sec)

mysql> SELECT last_insert_grn_id();
+----------------------+
| last_insert_grn_id() |
+----------------------+
|                    5 |
+----------------------+
1 row in set (0.00 sec)

last_insert_grn_id関数はユーザ定義関数(UDF)としてmroongaに含まれていますが、インストール時にCREATE FUNCTIONでMySQLに追加していない場合には、以下の関数定義DDLを実行しておく必要があります。

mysql> CREATE FUNCTION last_insert_grn_id RETURNS INTEGER SONAME 'ha_mroonga.so';

ご覧のように_idカラムやlast_insert_grn_id関数を通じてレコードIDを取得することができました。ここで取得したレコードIDは後続のUPDATEなどのSQL文で利用すると便利です。

mysql> UPDATE memos SET content = "So much milk in the fridge." WHERE _id = last_insert_grn_id();
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

3.3.1.6. スニペットの取得方法 (文脈付き索引)

ノート

この機能は実験的です。引数や引数の値が予告なく変更される可能性があります。

キーワードと周辺のテキストを検索結果として取得したいことがあります。

スニペットは'キーワードと関連するテキスト'を意味します。文脈付き索引ともいいます。

mroonga_snippet 関数は検索結果からスニペットを取得する方法を提供します。

mroonga_snippet 関数の構文:

SELECT mroonga_snippet(document, max_length, max_count, encoding,
  skip_leading_spaces, html_escape, prefix, suffix,
  word1, word1_prefix, word1_suffix,
  word2, word2_prefix, word2_suffix, ...);

mroonga_snippet の引数の詳細です。

document

カラム名もしくは文字列を指定。

max_length

スニペットの長さの最大バイト数を指定。

max_count

スニペットの最大個数を指定。

encoding

文書のエンコーディングを指定します。値として 'ascii_general_ci'、'cp932_japanese_ci'、'eucjpms_japanese_ci'、'utf8_japanese_ci'などが指定できます。

skip_leading_spaces

先頭の空白を無視するかを指定します。1なら無視し、0なら無視しません。

html_escape

HTMLのエスケープを行うか指定します。1ならエスケープし、0ならそのまま出力します。

prefix

スニペットの開始テキストを指定。

suffix

スニペットの終了テキストを指定。

wordN

単語を指定。

wordN_prefix

N番目の単語の開始テキストを指定。

wordN_suffix

N番目の単語の開始テキストを指定。

mroonga_snippet 関数はユーザ定義関数(UDF)としてmroongaに含まれていますが、インストール時にCREATE FUNCTIONでMySQLに追加していない場合には、以下の関数定義DDLを実行しておく必要があります。

mysql> CREATE FUNCTION mroonga_snippet RETURNS STRING SONAME 'ha_mroonga.so';

mroonga_snippet 関数は MATCH ... AGAINST構文とともに使用して、テキストに含まれるキーワードとその関連語句を検索するのに便利です。

'fulltext'というキーワードと'MySQL'と'search'という関連語を含む文書の検索をしてみましょう。

mroonga_snippet 関数は上記のことができます。

実行例で使用するスキーマ定義はこちら:

CREATE TABLE `snippet_test` (
  `id` int(11) NOT NULL,
  `text` text,
  PRIMARY KEY (`id`),
  FULLTEXT KEY `text` (`text`)
) ENGINE=mroonga DEFAULT CHARSET=utf8

実行例で使用するサンプルデータはこちら:

insert into snippet_test (id, text) values (1, 'An open-source fulltext search engine and column store.');
insert into snippet_test (id, text) values (2, 'An open-source storage engine for fast fulltext search with MySQL.');
insert into snippet_test (id, text) values (3, 'Tritonn is a patched version of MySQL that supports better fulltext search function with Senna.');

実行結果はこちら:

mysql> select * from snippet_test;
+----+-------------------------------------------------------------------------------------------------+
| id | text                                                                                            |
+----+-------------------------------------------------------------------------------------------------+
|  1 | An open-source fulltext search engine and column store.                                         |
|  2 | An open-source storage engine for fast fulltext search with MySQL.                              |
|  3 | Tritonn is a patched version of MySQL that supports better fulltext search function with Senna. |
+----+-------------------------------------------------------------------------------------------------+
3 rows in set (0.00 sec)

mysql> select id, text, mroonga_snippet(text, 8, 2, 'ascii_general_ci', 1, 1, '...', '...<br>', 'fulltext', '<span class="w1">', '</span>', 'MySQL', '<span class="w2">', '</span>', 'search', '<span calss="w3">', '</span>') from snippet_test where match(text) against ('fulltext');
+----+-------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| id | text                                                                                            | mroonga_snippet(text, 8, 2, 'ascii_general_ci', 1, 1, '...', '...<br>', 'fulltext', '<span class="w1">', '</span>', 'MySQL', '<span class="w2">', '</span>', 'search', '<span calss="w3">', '</span>') |
+----+-------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|  1 | An open-source fulltext search engine and column store.                                         | ...<span class="w1">fulltext</span>...<br>... <span calss="w3">search</span> ...<br>                                                                                                                   |
|  2 | An open-source storage engine for fast fulltext search with MySQL.                              | ...<span class="w1">fulltext</span>...<br>... <span calss="w3">search</span> ...<br>                                                                                                                   |
|  3 | Tritonn is a patched version of MySQL that supports better fulltext search function with Senna. | ...f <span class="w2">MySQL</span> ...<br>...<span class="w1">fulltext</span>...<br>                                                                                                                   |
+----+-------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
3 rows in set (0.00 sec)

'fulltext'というキーワードと関連する'MySQL'と'search'が抽出できました。

3.3.1.7. groongaコマンドの実行方法

ストレージモードではmroongaはすべてのデータをgroongaのデータベースに保存します。mroongaを使うことでSQLでgroongaのデータベースにアクセスできるようになります。SQLはとても強力ですが、ファセット検索などのようないくつかの操作が得意ではありません。

最近ではファセット検索は一般的なものになりました。amazon.comやebay.comのように多くのオンラインショッピングサイトがファセット検索をサポートしています。ファセット検索はユーザが検索結果を絞り込む前に絞り込み検索をし、その絞り込み検索の結果を表示します。ユーザは絞りこまれた結果から自分が探しているものを選ぶだけです。ファセット検索を使うとユーザは以下のようなメリットがあります。

  • ユーザはどうやって検索結果を絞り込むかを考える必要はありません。単に表示された絞り込み結果から選ぶだけです。

  • ユーザは「見つかりませんでした」ページを見ることがありません。ファセット検索では絞り込んだ結果のうち、ヒットする項目がある結果だけを表示します。

絞り込み検索は検索結果に対して複数の GROUP BY 操作を実行する必要があります。SQLでファセット検索をやろうとすると、複数の SELECT リクエストが必要になります。これは効率的ではありません。

groongaは1回のgroongaコマンドでファセット検索をできます。これは効率的です。groongaには select コマンドというファセット検索に対応した検索コマンドがあります。groongaではファセット検索は ドリルダウン(drilldown) と呼ばれています。groongaの select コマンドの詳細については groongaのドキュメント を参照してください。

mroongaは mroonga_command() 関数を提供しています。この関数を使えばSQLの中で好きなgroongaコマンドを実行できます。しかし、使うのは select コマンドだけにしておくべきです。スキーマやデータを変更するコマンドを使うと一貫性が壊れてしまうかもしれません。

実行例で使用するスキーマ定義はこちら:

CREATE TABLE diaries (
  id INT PRIMARY KEY AUTO_INCREMENT,
  content VARCHAR(255),
  date DATE,
  year YEAR,
  `year_month` VARCHAR(9),
  tag VARCHAR(32),
  FULLTEXT INDEX (content)
) ENGINE = mroonga DEFAULT CHARSET utf8;

実行例で使用するサンプルデータはこちら:

INSERT INTO diaries (content, date, year, `year_month`, tag)
       VALUES ('Groonga is an open-source fulltext search engine and column store.',
               '2013-04-08',
               '2013',
               '2013-04',
               'groonga');
INSERT INTO diaries (content, date, year, `year_month`, tag)
       VALUES ('Mroonga is an open-source storage engine for fast fulltext search with MySQL.',
               '2013-04-09',
               '2013',
               '2013-04',
               'MySQL');
INSERT INTO diaries (content, date, year, `year_month`, tag)
       VALUES ('Tritonn is a patched version of MySQL that supports better fulltext search function with Senna.',
               '2013-03-29',
               '2013',
               '2013-03',
               'MySQL');

各レコードは tag として groongaMySQL が付いています。各レコードは yearyear_month も持っています。ファセット検索のキーとして tagyearyear_month を使えます。

groongaはファセット検索のことをドリルダウンと呼んでいます。そのため、groongaでのパラメータ名は --drilldown となっています。groongaは検索結果をJSONで返します。そのため、 mroonga_command() も検索結果をJSONで返します。これはSQLらしくありません。JSON形式の検索結果は自分でパースしないといけません。

以下は利用可能なファセット検索キーをすべて使った例です。(結果のJSONは整形済み):

SELECT mroonga_command("select diaries --output_columns _id --limit 0 --drilldown tag,year,year_month") AS faceted_result;
+-----------------------------+
| faceted_result              |
+-----------------------------+
| [[[3],                      |
|   [["_id","UInt32"]]],      |
|  [[2],                      |
|   [["_key","ShortText"],    |
|    ["_nsubrecs","Int32"]],  |
|   ["groonga",1],            |
|   ["MySQL",2]],             |
|  [[1],                      |
|   [["_key","Time"],         |
|    ["_nsubrecs","Int32"]],  |
|   [1356998400.0,3]],        |
|  [[2],                      |
|   [["_key","ShortText"],    |
|    ["_nsubrecs","Int32"]],  |
|   ["2013-04",2],            |
|   ["2013-03",1]]]           |
+-----------------------------+
1 row in set (0.00 sec)

詳細は groongaのselectコマンドのドキュメント を確認してください。

3.3.1.8. ログ出力

mroongaではデフォルトでログの出力を行うようになっています。

ログファイルはMySQLのデータディレクトリ直下に groonga.log というファイル名で出力されます。

以下はログの出力例です。

2010-10-07 17:32:39.209379|n|b1858f80|mroonga 1.10 started.
2010-10-07 17:32:44.934048|d|46953940|hash get not found (key=test)
2010-10-07 17:32:44.936113|d|46953940|hash put (key=test)

ログのデフォルトの出力レベルはNOTICE(必要な情報のみ出力。デバッグ情報などは出力しない)となっております。

ログの出力レベルは mroonga_log_level というシステム変数で確認することができます(グローバル変数)。またSET文で動的に出力レベルを変更することもできます。

mysql> SHOW VARIABLES LIKE 'mroonga_log_level';
+-------------------+--------+
| Variable_name     | Value  |
+-------------------+--------+
| mroonga_log_level | NOTICE |
+-------------------+--------+
1 row in set (0.00 sec)

mysql> SET GLOBAL mroonga_log_level=DUMP;
Query OK, 0 rows affected (0.00 sec)

mysql> SHOW VARIABLES LIKE 'mroonga_log_level';
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| mroonga_log_level | DUMP  |
+-------------------+-------+
1 row in set (0.00 sec)

設定可能なログレベルは以下の通りです。

  • NONE
  • EMERG
  • ALERT
  • CRIT
  • ERROR
  • WARNING
  • NOTICE
  • INFO
  • DEBUG
  • DUMP

詳細は mroonga_log_level を参照してください。

またFLUSH LOGSでログの再オープンを行うことができます。MySQLサーバを停止せずにログのローテートを行いたいような場合には、以下の手順で実行すると良いでしょう。

  1. groonga.log ファイルの名前を変更(OSコマンドのmvなどで)

  2. MySQLサーバに対して"FLUSH LOGS"を実行(mysqlコマンドあるいはmysqladminコマンドにて)

3.3.1.9. カラムの刈り込み

groongaでは各カラムごとにファイルを分けてデータを格納する「カラムストア方式」が採用されており、mroongaではこの特性を活かすためにテーブルアクセス時に必要なカラムに対してのみアクセスを行う実装を行っています。

この高速化の仕組みはmroonga内部で自動的に行われるため、特に設定などを行う必要はありません。

例えば以下のようにカラムが20個定義されているテーブルが存在するものと仮定します。

CREATE TABLE t1 (
  c1 INT PRIMARY KEY AUTO_INCREMENT,
  c2 INT,
  c3 INT,
  ...
  c11 VARCHAR(20),
  c12 VARCHAR(20),
  ...
  c20 DATETIME
) ENGINE = mroonga DEFAULT CHARSET utf8;

この時、以下のようなSELECT文が発行される場合、mroongaではSELECT句およびWHERE句で参照しているカラムに対してのみデータの読み取りを行ってSQL文を処理します(内部的に不要なカラムに対してはアクセスしません)。

SELECT c1, c2, c11 FROM t1 WHERE c2 = XX AND c12 = "XXX";

このケースではc1,c2,c11,c12に対してのみアクセスが行われ、SQL文が高速に処理されることになります。

3.3.1.10. 行カウント高速化

COUNT(*)などの行カウントを行う場合と通常のSELECTによるデータ参照を行う場合に対して、従来よりMySQLではストレージエンジンの呼び出しを行う部分(=ストレージエンジンインタフェース)における区別が存在していないため、行数をカウントするだけで良いような場合にもレコードアクセス(SELECTの結果には含まれないデータへのアクセス)が行われる問題があります。

mroongaの前身であるTritonn(MySQL+Senna)ではこの問題に対して"2indパッチ"という不要なレコードアクセスを省略する仕組みを独自に実装してこの性能問題を回避していました。

これに引き続き、mroongaでも行カウントを高速化するための仕組みを実装しています。

例えば以下のSELECT文では不要なカラムデータの読み取りは省略され、必要最小限のコストで行カウントの結果を返すことができます。

SELECT COUNT(*) FROM t1 WHERE MATCH(c2) AGAINST("hoge");

この最適化処理が行われたかどうかはステータス変数で確認することもできます。:

mysql> SHOW STATUS LIKE 'Mroonga_count_skip';
+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| Mroonga_count_skip | 1     |
+--------------------+-------+
1 row in set (0.00 sec)

行カウント高速化の処理が行われる度に Mroonga_count_skip ステータス変数がインクリメントされます。

備考:この高速化機能はインデックスを用いて実装されています。現在のところインデックスアクセスのみでレコードが特定できるパタンでのみ有効に機能します。