MeCabを使ってMySQLで全文検索
複数文書に渡ってテキスト検索をする全文検索。GoogleやYahooのサーチエンジンなどで馴染み深いものでありながら、自分では設定したことがありませんでした。
前回久しぶりMySQLをインストールしてみて、ドキュメントを紐解くことがあったのですが、今になって全文検索用のインデックスがあることを知り、少し試してみたくなりました。合わせてMySQLと姉妹の関係にあるMariaDBの全文検索の対応状況も調べました。
転置インデックス
先のWikipediaには、全文検索時の処理の方法にLinuxのGrepコマンドのようにすべての文章を読み込むか、文章を単語に区切って索引を作るの2種類が紹介されていました。
MySQLの挙動は後者です。索引には「転置インデックス」を使います。
転置インデックスはひとつの単語に対して、該当する文書IDを保存しておくものです。
例えば「索引」という言葉がデータベースの3行目と6行目にあるのなら、「索引=>3,6」をいう感じに記録します。
また近接検索のための単語ごとの位置情報も保持するそうです。
単語の抽出の仕方ですが、MySQLデフォルトの挙動では空白によって文章を区切ることで抽出します。これは英語では機能しますが、単語を空白で区切る文法ではない日本語だとうまく機能しません。日本語で単語を抽出するにはパーサーを利用します。
テーブルの作成
テーブルの作成の方法は、対象にしたいカラムにCHARかVARCHAR、TEXTいずれかを設定した後、FULLTEXT INDEXでインデックスを追加します。
CREATE TABLE test_ft(
seq INT,
content TEXT,
FULLTEXT INDEX(content)
)CHARSET=utf8mb4;
全文検索をする時は次のようにカラムと、検索文字を指定します。
select * from テーブル名 where match(カラム名) against('検索文字');
しかしデフォルト設定では次のように、コマンドを実行すると残念な結果になります。
mysql> insert into test_ft values(1,'カレーライスを食べたい'),
(2,'カツカレーだと少し重い'),
(3,'カレーうどんだと物足りない'),
(4,'やっぱりハヤシライスにしよう');
select * from test_ft where match(content) against('カレー');
Empty set (0.00 sec)
select * from test_ft where match(content) against('カレーライスを食べたい');
+------+------------------------+
| seq | content |
+------+------------------------+
| 1 | カレーライスを食べたい |
+------+------------------------+
1 row in set (0.00 sec)
先ほども言ったようにこれは単語を空白文字で区切って管理する為「カレー」という単語は管理されないからです。「カレー ライスを食べたい」という文全体がそのまま登録されてしまいます。
そこで、カレーという言葉やほかの助詞等、単語の前後に空白を入れます。そうするとうまく機能します。
insert into test_ft values(5,'カレー ライス を 食べ たい'),
(6,'カツ カレー だ と 少し 重い'),
(7,'カレー うどん だ と 物足り ない'),
(8,'やっぱり ハヤシライス に し よう');
select * from test_ft where match(content) against('カレー');
+------+----------------------------
| seq | content
+------+----------------------------
| 5 | カレー ライス を 食べ たい
| 6 | カツ カレー だ と 少し 重い
| 7 | カレー うどん だ と 物足り ない
+------+-----------------------------
3 rows in set (0.00 sec)
MeCab
先ほどのように文章を単語毎に空白で区切ればいいのですが、それを自分で実装するのはなかなか難しいです。
文章を単語毎に分割するプログラムのことを形態素解析器というのですが、有名なところに以前このブログでも紹介したことのあるMeCabがあります。
MySQLでは全文検索に、単語抽出用ののパーサーとしてMeCabを設定できるようになっています。そこで、「MySQL 8.0 リファレンスマニュアル」にしたがってMeCabを設定してみます。
まずはMeCabをインストールします。MySQLでプラグインをインストールする際に本体がないと失敗します。
インストーラー形式なので、特に問題ないと思いますが、文字コード選択の部分だけデータベースに合わせます。ここではUTF-8を選択しました。
Windowsでは必要なライブラリや設定ファイルが初期のインストールに含まれますので設定していきます。
まず、MySQLの設定ファイルであるmy.iniを変更します。ここではd:¥mysql-8フォルダにMySQLバイナリがある前提で話を進めす。
- loose-mecab-rc-file
my.iniのloose-mecab-rc-fileのキーでmecabrcの場所を記述します。このファイルはMySQLのルートフォルダからたどって「lib/mecab/etc/mecabrc」にあると思います。mecabrcファイルではdicdirとして辞書ファイルのパスを記述します。
- innodb_ft_min_token_size
innodb_ft_min_token_sizeは管理する単語の最小の長さです。デフォルト値の3となっていて、3文字未満の言葉は無視されるようになっています。英語だと2文字以下の単語は全文検索の対象にしなくていい場合が多いと思いますが、日本語だとそうはいかないのでこの値を1または2にします。ちなみにmax(最大値)もあるのですがこちらのデフォルト値は84となっています。
my.ini
...
[mysqld]
loose-mecab-rc-file="D:/mysql-8/lib/mecab/etc/mecabrc"
innodb_ft_min_token_size=1
もうひとつ、my.iniで指定したmecabrcファイルにも設定が必要です。
dicdirにMeCab用の辞書を設定します。辞書が何をするものなのか気になるようでしたらMeCabで漢字のフリガナを取得した際の記事に書いてありますのでよろしければ合わせてお読みください。
辞書ファイルにはSJIS、UTF-8、EUCとありますが、ここではMySQLに設定した文字コードであるUTF-8のファイルを使います。
mecabrc
...
dicdir="D:/mysql-8/lib/mecab/dic/ipadic_utf-8"
設定が終わったらMySQLサーバーを再起動します。
起動時「unknown variable 'loose-mecab-rc-file...」というエラーメッセージが出ますが、問題ありません。これはプラグインをインストールしていないために表示されます。しかしプラグインをインストールするにはこのmecab-rc-fileというエントリーが必要です。そこで、起動時に設定エラーでサーバーの起動が止まらないようにloose-接頭をつけています。
再起動が終わったらrootでログインして、次のコマンドを実行します。windowsなので拡張子はsoでなくdllになります。
mysql>INSTALL PLUGIN mecab SONAME 'libpluginmecab.dll';
show pluginsコマンドでインストールを確認します。
mysql> show plugins; ... | mysqlx | ACTIVE ... | mecab | ACTIVE ... +--------------------------------- 46 rows in set (0.00 sec)
インデックスを張りなおすために一度ドロップします。無名でインデックスを作成してしまったので、名前を確認するために、show create tableを実行して取得しています。
また、FULLTEXT INDEXのパーサーにmecabを設定します。
mysql> SHOW CREATE TABLE test_ft; ... CREATE TABLE `test_ft` ( `seq` int DEFAULT NULL, `content` text, FULLTEXT KEY `content` (`content`) ... mysql> ALTER TABLE test_ft DROP INDEX content; ... mysql> ALTER TABLE test_ft ADD FULLTEXT INDEX(content) WITH PARSER mecab;
先ほどと同じようにカレーを検索してみます。
mysql> select * from test_ft where match(content) against('カレー');
+------+----------------------------+
| seq | content |
+------+----------------------------+
| 2 | カツカレーだと少し重い |
| 3 | カレーうどんだと物足りない |
+------+----------------------------+
2 rows in set (0.00 sec)
1の「カレーライスを食べたい」が表示されないのは、「カレーライス」までを1単語として判別されたからです。
Nグラム
合わせて、Nグラムでの設定の方法もMySQL:「12.10.8 ngram 全文パーサー」を参考に紹介しておきます。NグラムはMeCabと違って、単語という概念ではなくNに入る文字単位で文章を分割してインデックスを作成します。
その区切りを何文字にするかをmy.iniに設定します。これは1~10の間で設定が可能です。
my.ini
...
[mysqld]
...
ngram_token_size=2
...
たとえば「abcd」という文字があった時の結果は次のようになります。
- ngram_token_size=1
a,b,c,d
- ngram_token_size=2
ab,bc,cd
- ngram_token_size=3
abc,bcd
- ngram_token_size=4
abcd
1-2,3-4と単純に文字を分割するわけではないところがわかりにくい点です。
またNグラムの処理では空白は除去されます。分かりやすいように空白をアンダーバーに変えて説明すると、先の参考サイトではngram_token_size=2の時、「ab_cd」は「ab」と「cd」に、「a_bc」は「bc」になるという説明でした。
この説明の補足をすると、「ab_cd」の時は、「ab」、「b_」、「_c」、「cd」となり空白を含むものを除去すると、「ab」、「cd」となります。
「a_bc」の時は、「a_」、「_b」、「bc」となり空白を含むものを除去すると「bc」となります。
管理テーブル群
全文検索インデックスがどのように管理されているか知りたい場合があると思います。これらの情報は、 「15.15.4 InnoDB INFORMATION_SCHEMA FULLTEXT インデックステーブル」によれば、INFORMATION_SCHEMAにある、INNODB_FTから始まるテーブル群が全文検索の情報が確認できるテーブルだそうです。INNODBの接頭からもわかるように、INNODBに限定されます。これらのテーブルには次のようなものがあります。
- INNODB_FT_CONFIG
全文検索に関係するメタデータが入っています。
- INNODB_FT_DELETED
パフォーマンスの為に、削除したドキュメントIDを一時的にリストしておくテーブルです。
- INNODB_FT_BEING_DELETED
メンテナンス中に、INNODB_FT_DELETEDのスナップショットが入るテーブルで、ユーザー向けの用途は少ないものです。
- INNODB_FT_DEFAULT_STOPWORD
デフォルトストップワードのリストです。ストップワードについては次で説明します。
- INNODB_FT_INDEX_TABLE
倒置インデックスの情報です。
- INNODB_FT_INDEX_CACHE
新しく挿入されたデータのインデックス情報が含まれます。キャッシュがいっぱいになるか他インデックスが再構成時に統合されます。
主に参照するのは、倒置インデックスが入っているINNODB_FT_INDEX_TABLEとそのキャッシュであるINNODB_FT_INDEX_CACHEだと思います。これらのテーブルの構造はどちらも同じで、次のようになっています。
- WORD
単語
- FIRST_DOC_ID
単語が出現する最初のドキュメントID
- LAST_DOC_ID
単語が出現する最後のドキュメントID
- DOC_COUNT
全体を通して単語が出現する回数
- DOC_ID
ドキュメントID。全文検索管理用のIDです。詳細は後述します。
- POSITION
出現箇所ですが、これは相対的な番号で絶対的な位置を示すものではありません。
INNODB_FT_DEFAULT_STOPWORD以外のデーブルは通常は空です。クエリを実行する前に、innodb_ft_aux_table変数に、対象となるデータベース名/テーブル名を指定することで中にデータが格納されます。変数の設定は、全文検索用のテーブルに行を挿入後した後で問題ありません。
テーブルにデータが挿入されたばかりの場合はINNODB_FT_INDEX_CACHE側にデータが蓄積されていると思います。MySQLサーバーが再起動される等内部で「OPTIMIZE TABLE」が実行されるとINNODB_FT_INDEX_CACHEにあった内容は、INNODB_FT_INDEX_TABLE側に移動します。
もし手動でOPTIMIZE TABLEを発行したい場合は、「SET GLOBAL innodb_optimize_fulltext_only=ON」として、OPTIMIZE TABLEの挙動を制限して実行します。
mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_CACHE; ... 33 rows in set (0.000 sec); mysql> SET GLOBAL innodb_optimize_fulltext_only=ON ... mysql> OPTIMIZE TABLE 対象テーブル名 ... mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_CACHE; Empty set (0.000 sec) mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE; ... 33 rows in set (0.000 sec);
ストップワード
ストップワードは単純に言うと全文インデックスさせない言葉のリストです。INNODB_FT_DEFAULT_STOPWORDには英語における冠詞「a」など、インデックスさせても効果が見込めないものがデフォルトで設定されています。
このストップワードの挙動は、先ほどのNグラムの時にだけ若干挙動がかわります。通常は単語に完全一致した場合にインデックスしないのですが、Nグラムで区切る場合は、区切った文字列にストップワードが含まれていた場合(部分一致)で除外となります。
MeCabで切り分ける場合不要だと思われる格助詞や接続詞などををストップワードにすると精度が上がりそうです。Nグラムで運用する場合は部分一致で消されてしまうので微妙なところだと思います。たとえば、格助詞の「は」をストップワードとして除外した場合、ひらがなで書かれた「はなまる」「はなび」といった単語は原型を失ってしまいます。
オリジナルのストップワードリストを作るには、INNODB_FT_DEFAULT_STOPWORDと同じ構成のテーブルを作って、ストップワードを挿入し、最後にテーブル名をinnodb_ft_server_stopword_table変数にセットするだけです。テーブルを設置するデータベースは、ユーザー領域のどこでもいいようです。
innodb_ft_server_stopword_tableをセットした後に、目的のテーブルに全文インデックスを設定しないと、従来のINNODB_FT_DEFAULT_STOPWORDのストップワードを参照し続けるようです。変更するには一度ALTER TABLE で全文インデックスを削除して、innodb_ft_server_stopword_tableの値設定し、再度ALTER TABLEで全文インデックスを付与する必要があります。
また、テーブル毎にストップワードテーブルを変更したい場合は、innodb_ft_server_stopword_tableより優先度が高いinnodb_ft_user_stopword_table変数も用意されていますので、innodb_ft_server_stopword_tableへデフォルト値をセットしておき、例外時にuser例外時にinnodb_ft_user_stopword_tableをセットして全文インデックスを設定するといった使い方ができます。
ちなみにストップワードテーブルをオリジナルのものにした場合はINNODB_FT_DEFAULT_STOPWORDの値は利用されません。
mysql> CREATE TABLE innodb_ft_ft_my_stopwords(value VARCHAR(30)) ENGINE = INNODB; mysql> INSERT INTO innodb_ft_ft_my_stopwords VALUES ('は'), ('を'), ('が'), ('に'), ('へ'), ('で'), ('と'), ('や'), ('から'), ('まで'), ('も'), ('の'), ('ね'), ('よ'), ('か'), ('ほか'), ('しか'), ('さえ'), ('そして'), ('それから'), ('そのうえで'), ('それでは'), ('だから'), ('だが'), ('しかし'), ('でも'), ('しかも'), ('それに'), ('それから'), ('それでも'), ('そのために'), ('つまり'), ('すなわち'), ('または'), ('あるいは'); mysql>SET innodb_ft_server_stopword_table = 'database_name/innodb_ft_ft_my_stopwords';
自然言語全文検索
先ほど紹介したMATCH...AGAINSTの検索の方法は自然言語全文検索というモードでの検索方法でした。
AGAINSTに文字列を渡す際、モードを指定しないと、AGAINST ('カレー' IN NATURAL LANGUAGE MODE)とするのと同じことで、「自然言語全文検索」モードとなります。
この時、ORDERを指定しなければ、表示される結果は関連性の高い順になります。
関連性のスコアを知りたい場合は、次のようなクエリを実行することもできます。scoreカラムに目的の値が入ります。
単一の単語でなく複数の単語が含まれていることを条件にしたい場合はをダブルクォートを使います。
こうすると文中に「カレー」と「ライス」という複数の単語を含む行を検索します。それぞれの単語はどこにいてもいいですが、出現する順序は入力した通りでなければいけません。単語はMySQLの実装によって分割されます。,や.は単語に含まれる文字とは認識されないようです。先の例と「"カレー, ライス"」は検索時には同値になります。
「'カレー ライス'」とシングルクォートで囲んだ場合はそれ全体を単語として検索します。
ブラインドクエリ―拡張
たとえば本文に「みかん」「りんご」などといった言葉が存在し、「フルーツ」という単語がない状態の全文インデックスがあったとします。
人間の感覚では、「フルーツ」という検索で「みかん」や「りんご」も検出して欲しいところですが、機械的なインデックスはそれができません。そいういった機能を実装しようとする試みがブラインドクエリ―拡張です。別名で「 automatic relevance feedback ( 自動関連性フィードバック ) 」とも呼ばれるようです。
この機能はMySQL内部で、最初に本来の「フルーツ」で全文検索を行い、その結果から関連性の高い言葉を抽出します。その後、抽出した言葉を含んで全文検索をするという機能です。
先の例でいうと、「フルーツ」と検索してえられた結果から「りんご」という関連キーワードを抽出した場合、元の「フルーツ」という言葉はなくても、「りんご」が含まれる行が検索結果として返されれます。
ブラインドクエリ―拡張を実行するには、WITH QUERY EXPANSION というキーワードを追加して検索します。
...AGAINST ("カレー ライス" IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION) または ...AGAINST ("カレー ライス" WITH QUERY EXPANSION)
「WITH QUERY EXPANSION」では最初の検索から得られる関連キーワードによっては関連性の低い結果が多くなります。そのため、検索ワードが短い場合にのみに使用することが推奨されています。
ブール全文検索
全文検索時のもうひとつの方法にブール全文検索というものがあります。
この検索では検索条件を自然言語全文検索より柔軟に設定ができます。
単語の前に+を付けるとAND条件になります。
単語の前に-を付けると否定のAND条件になります。次の場合カレーという文字を含み、ライスという文字は含まないものを探します。
なにもつけない時は、存在すれば評価が高くなります。次の例はカレーという文字を含み、ライスという文字はなくてもいいですが、あった場合は評価が上がります。
なくてもよくて、存在した場合に評価を下げたい場合は~を使います。
()でグループを作れます。次の例では「カレー、うどん」か「カレー、ライス」いずれかの2語を含む文字が取得されます
>と<で特定の単語に対する。貢献度の調整もできます。「カレー、うどん」に対する評価を上げ、「カレー、ライス」対する評価を下げます
他には、検索語に「'カレー*'」というように、ワイルドカードを設定したり、「'"カレー ライス"'」とすることで2語で構成する単語全体を指定したり、@数値で単語間の距離を指定することもできます。距離の指定の仕方は次のようにします。
ここで用いられる+や-、@などはBOOLAN MODEで単語を構成する文字としては利用できないので注意が必要です。
関連性や、評価の上下という説明がありましたが、これは先ほどの自然言語全文検索でも登場したスコアのことです。自然言語同様に、対象カラムとして MATCH ... AGAINST (...IN BOOLEAN MODE)を設定すれば、スコアがでます。
ブール全文検索では、自然言語全文検索と違いデフォルトではスコアの順に表示されません。そうしたい場合は、カラムに指定してあるなら、別名をつけてそれをORDERで記述するか、直接ORDERにMATCH ... AGAINST (...IN BOOLEAN MODE)と書くこともできます。関連性の高い順に表示する場合は、DESCのキーワードも必要です。
評価の計算式については先のMySQLブール全文検索のリンクにて記述があります。
DOC_IDの詳細
INNODB_FT_INDEX_TABLEやINNODB_FT_INDEX_CACHEテーブルに出現するDOC_IDですが、これは管理用の連番です。連番の管理はINNODB_FT_CONFIGテーブルのkeyの値がsynced_doc_idとなっている行で行っています。
このDOC_IDをインデックスの元となったテーブルの行とどのように結び付けているかはMySQL公式ドキュメントの解説によると、元のテーブルにあるFTS_DOC_IDカラムに格納されているそうです。
全文インデックスを作成する際にこのカラムを明示的に作成しない場合、MySQLは暗黙的にカラムを作成し、それを非表示に設定するようです。
非表示といっても、このブログでも使い方を説明したことのある通常の非表示カラムと違ってselect時に明示しても出力されないので、このデータを利用したい場合は、テーブル作成時に自分でカラムを作成する必要があります。
すべて大文字でFTS_DOC_IDという名前のカラムをbigint unsinged not nullで生成します。
この時注意が必要なのはカラムを自作した場合、インデックスの管理はユーザーにゆだねられるということです。重複がないように管理しなければいけません。
この重複管理は、単にユニークであればいいというだけでなく、行更新時は新たなINDEXをつけないといけません。
そのため、auto_incrementを設定すると管理が楽だということです。その際は更新の時もauto_incrementの値を進めるように設定する必要があります。また、MySQLの仕様では複数の列にauto_incrementを指定できないので、その際は実装でカバーする必要があります。
公式ドキュメントではcreate tableで生成する方法が照会されていました。alter tableで行う場合は、一旦全文インデックスを削除してFTS_DOC_IDカラムを生成しないとFTS_DOC_IDの値が重複する事が原因でエラーになりました。一度、全文インデックスを削除していればFTS_DOC_IDの同値が許容されますのでそれで生成後、ユニークになるようにFTS_DOC_IDをふり直して、全文インデックスを再び設定することでエラーを回避することができました。
MariaDB
筆者はMariaDBを常用しているので、そちらの方で日本語の全文検索ができないかと調べてみました。ネット上では、MariaDBでは日本語の全文検索は使えないという話になっていますが、この「使えない」は「使い物にならない」の意味のようです。MariaDBでは、MeCabやNグラムのパーサーがないだけで、全文検索自体は日本語でも使えるようです。MariaDB10.6において、先に示したように自力で日本語をスペース区切りにしたカラムに全文インデックスを張れば意図した抽出ができることを確認しました(トップ画像)。
換言すると、本文とは別にインデックス用のカラムを持てばMariaDBで日本語の全文検索は可能だということです。ただ、実際使ってみた場合どれだけ有用性があるかは未知数なので、MariaDBで全文検索を稼働してみることにしました。報告すべきような結果がでましたら、後日リンク先に追記します。
ちなみに、MariaDBのストレージエンジンをMroongaにすることで、日本語の全文検索も可能になるということでしたがMariaDB 10.2.11を境にパッケージされなくなっているようです。さらにWindows環境においては、近年のバージョンのMariaDBでMroongaストレージエンジンを使いたい場合は、Mroongaが提供しているカスタムされたMariaDBを使うか自力でビルドしないといけないようです。