概要

手軽にJavaのアプリに形態素解析+全文検索機能を組み込みたい欲求があったので、Java製のデータベースエンジンで、Luceneを利用した全文検索も可能なH2 Databaseを試してみる。

残念なことにH2のLucene関連のクラスはStandardAnalyzer(英語は空白で分割、それ以外はunigram)しか利用できない。ロードマップにはその辺が課題として載ってたけど、いつ対応されるかは不明。

仕方ないのでAnalyzerを定義しているところのソースをコピーして、無理矢理なんとかしようとしてうだうだしてみた。

結果、かなり無理矢理感が溢れる状態だけど、とりあえず形態素解析+全文検索はできた。ただ、動きを見る限りはEmbeddedSolrServer使った方が便利だなぁという感想で終わってしまった。

@CretedDate 2012/02/16
@Versions Lucene/Solr3.5.0, H2-1.3.163, lucene-gosen-1.2.1

当該ソースの説明

H2のソースはSubversionで取得できる。

Source Checkout - h2database
http://code.google.com/p/h2database/source/checkout

ライセンスはMPL1.1/EPL1.0。ソースコードを改変した場合は開示を求めるけど、改変せずにリンクした場合は開示の必要はない、LGPL的な感じのヤツ。

I-2-6. MPLの特徴、MPLとソフトウェア特許 | 日本OSS推進フォーラム
http://ossforum.jp/en/node/498

以下のファイルが当該ソース。

バージョン1.3.164では、上記ファイルの266行目に下記のような記述がある。

Directory indexDir = FSDirectory.open(f);
boolean recreate = !IndexReader.indexExists(indexDir);
Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_30);

このようにDirectoryやAnalyzerがハードコーディングされているため、ソースを書き換えない限りは他のAnalyzerは使えない。逆に言えば、ここを指定のAnalyzerやDirectoryに書き換えてしまえば、とりあえず好きな設定を適用できそう。

一般的なH2+Luceneの全文検索

形態素解析版の話をする前に、普通にH2を使って全文検索をする際の動きを見てみる。

動作させるにはH2 Database本体と、Luceneが必要になる。

H2 Database Engine
http://www.h2database.com/html/download.html

Lucene
http://lucene.apache.org/core/

上記URLからファイルを落としてきて、以下のjarを持ってくる。(バージョンは適宜読み替えてください)

クラスパスを通して、サーバモードでH2を立ち上げてみる。

$ java -cp h2-1.3.163.jar:lucene-core-3.5.0.jar:lucene-analyzers-3.5.0.jar org.h2.tools.Server

コンソール画面を開き、Connecする
http://localhost:8082/

以下のコマンドを実行する。

// テスト用のテーブル(名前はSAMPLEとする)を作成。
CREATE TABLE SAMPLE (ID IDENTITY PRIMARY KEY, TEXT VARCHAR);

// 全文検索をするためのFunctionの用意
CREATE ALIAS IF NOT EXISTS FTL_INIT FOR "org.h2.fulltext.FullTextLucene.init";
CALL FTL_INIT();

// SAMPLEテーブルのTEXTというカラムをLuceneのインデックスの対象に登録
CALL FTL_CREATE_INDEX('PUBLIC', 'SAMPLE', 'TEXT');

// テスト用に3行ほどINSERTしてみる
INSERT INTO SAMPLE (TEXT) VALUES('最終的にはSolrでいいとも思った');
INSERT INTO SAMPLE (TEXT) VALUES('でもリレーショナルが恋しくなる時もあると思う');
INSERT INTO SAMPLE (TEXT) VALUES('アシッドが頼もしい時もある');
COMMIT;

// 「もある」で検索してみる
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('もある', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 上記の検索結果
3 アシッドが頼もしい時もある
2 でもリレーショナルが恋しくなる時もあると思う

FTL_INITのAILIASを登録して、FTL_INITを実行して必要なFunctionを登録し、FTL_CREATE_INDEXでインデックスを作って、FTL_SEARCH_DATAで検索する、というのがざっくりとした流れ。

FTL_SEARCH_DATAで指定している数値は、LIMITとOFFSET。デフォルトでは上限100件で取得してくる。

日本語部分はuni-gramなのでたいていの文字は検索可能。「so」などの英単語の一部分で検索した場合はヒットしない。但し「so*」のように前方一致であれば検索可能。

この他にもインデックスを貼り直すFTL_REINDEXや、インデックスとFunctionの設定を削除するFTL_DROP_ALLなどが用意されている。

後で邪魔になるので、ここで作ったALIAS等は削除しておく。

// FunctionとIndexのDROP
CALL FTL_DROP_ALL();

// FTL_INITのALIASを削除
DROP ALIAS FTL_INIT;
DROP ALIAS FTL_CREATE_INDEX;
DROP ALIAS FTL_REINDEX;
DROP ALIAS FTL_DROP_ALL;
DROP ALIAS FTL_SEARCH;
DROP ALIAS FTL_SEARCH_DATA;

// テーブルのDROP
DROP TABLE SAMPLE;

Analyzerの選択を可能にしてみる

本題のAnalyzerの選択について。

今回はとりあえずシステムプロパティでAnalyzerのクラスを指定できるように改変してみた。FTL_INIT時に引数で指定する方法も考えたのだけど、修正範囲が少し大きくなりそうだったのでパス。

また、件のソースを継承してAnalyzerを指定しているメソッドだけ書き換えようかとも思ったのだけど、private staticな変数が邪魔してこれまた修正範囲が広がりそうだったので、やはりパス。

利用するには、まず以下のjarを入手する。

Downloads - lucene-gosen
http://code.google.com/p/lucene-gosen/downloads/list

・h2-lucene-ex-1.3.163.jar(自分が改変したソースを固めたもの)
https://github.com/mwsoft/h2-lucene-ex/raw/master/target/h2-lucene-ex-1.3.163.jar

上記のjarのソースはこちら
https://github.com/mwsoft/h2-lucene-ex/blob/master/src/main/java/org/h2/fulltext/FullTextLuceneEx.java

以下のように、-Dh2.luceneAnalyzerでAnalyzerのクラス名を設定し、各jarをクラスパスに入れてサーバモードで起動する。

$ java -Dh2.luceneAnalyzer=org.apache.lucene.analysis.ja.JapaneseAnalyzer -cp h2-1.3.163.jar:h2-lucene-ex-1.3.163.jar:lucene-core-3.5.0.jar:lucene-gosen-1.2.1-ipadic.jar  org.h2.tools.Server

先ほどと同じ手順で全文検索をしてみる。

// テスト用のテーブル(名前はSAMPLEとする)を作成。
CREATE TABLE SAMPLE (ID IDENTITY PRIMARY KEY, TEXT VARCHAR);

// 全文検索をするためのFunctionの用意(FullTextLuceneExを指定)
CREATE ALIAS IF NOT EXISTS FTL_INIT FOR "org.h2.fulltext.FullTextLuceneEx.init";
CALL FTL_INIT();

// SAMPLEテーブルのTEXTというカラムをLuceneのインデックスの対象に登録
CALL FTL_CREATE_INDEX('PUBLIC', 'SAMPLE', 'TEXT');

// テスト用に3行ほどINSERTしてみる
INSERT INTO SAMPLE (TEXT) VALUES('最終的にはSolrでいいとも思った');
INSERT INTO SAMPLE (TEXT) VALUES('でもリレーショナルが恋しくなる時もあると思う');
INSERT INTO SAMPLE (TEXT) VALUES('アシッドが頼もしい時もある');
COMMIT;

// 「もある」で検索してみる
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('もある', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 検索結果
(該当行無し, 94 ms)

// 「思った」で検索してみる
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('思った', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 検索結果
2	でもリレーショナルが恋しくなる時もあると思う
1	最終的にはSolrでいいとも思った

lucene-gonseはデフォルトでは助詞や接続詞はインデックスにいれないため「も(助詞) | ある」は引っかからない。

「思った」で検索した場合は、動詞の揺れが吸収され「思う」や「思った」がヒットする。

とりあえずこんな感じで、好きなAnalyzerを使って検索することは可能なようです。

今回作った機能は、System.getPropertyで指定されたクラス名に対してforNameでConstructorを取得、Versionを引数に渡して初期化している。

Constructor<Analyzer> constructor = 
    (Constructor<Analyzer>) Class.forName(className).getConstructor(Version.class);
return constructor.newInstance(LUCENE_VERSION);

ということで、引数がVersionではないAnalyzerは受け取れない。そうしたAnalyzer(Kuromojiとか)を利用する場合は、自前でラップしてVersionを引数に取るように改変してやる必要がある。

指定できるパラメータ

以下の値をシステムプロパティで設定できるようにした。最後のstoreDocumentTextInIndexはH2に元々付いている設定。

h2.luceneAnalyzerAnalyzerのクラス名を指定する。デフォルトはStandardAnalyzer。
(例:-Dh2.luceneAnalyzer=org.apache.lucene.analysis.ja.JapaneseAnalyzer)
h2.luceneVersionAnalyzerに指定するLuceneのバージョン。3.5.0の場合は35を指定。デフォルトは35。
(例 : -Dh2.luceneVersion=34)
h2.useRamDirectorytrueの場合はRAMDirectoryを利用。falseの場合はFSDirectoryを利用。デフォルトはfalse。
(例 : -h2.useRamDirectory=true)
h2.isTriggerCommittrueの場合はINSERT時に逐次インデックスの更新を行う。デフォルトはtrue。
(例 : -Dh2.isTriggerCommit=false)
h2.storeDocumentTextInIndexLuceneに本文をストアする。デフォルトはfalse。
(例 : -Dh2.storeDocumentTextInIndex=true)

複数のAnalyzerを使い分けることはできない。用途を組込モードに絞るなら改修は楽そうだけど、サーバモードも考えるとけっこう改修箇所が大きくなりそうだった。

useRamDirectoryとisTriggerCommitの詳細については後述。

トリガーについて

今回の内容とは直接関係ないけど、トリガーに関して自分の中で整理しておきたかったので少しメモ。

CALL FTL_CREATE_INDEXを実行すると、その時点でテーブルに入っている行のインデックスが作成される。

また上記のFunctionを実行するとトリガーが設定され、それ以降にデータがINSERTされると自動でLuceneのインデックスも作成されるようになる。

以下、例文。

// テーブルを再作成
CALL FTL_DROP_ALL();
DROP TABLE SAMPLE;
CREATE TABLE SAMPLE (ID IDENTITY PRIMARY KEY, TEXT VARCHAR(128));

// 適当にデータを入れる
INSERT INTO SAMPLE (TEXT) VALUES('最終的にはSolrでいいとも思った');
INSERT INTO SAMPLE (TEXT) VALUES('でもリレーショナルが恋しくなる時もあると思う');
INSERT INTO SAMPLE (TEXT) VALUES('アシッドが頼もしい時もある');
COMMIT;

// この時点ではインデックスは作成されていないため、FTL_SEARCH_DATAで検索してもヒットしない
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('思う', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 検索結果
(該当行無し, 29 ms)

// インデックスを作成
CALL FTL_CREATE_INDEX('PUBLIC', 'SAMPLE', 'TEXT');

// 再度FTL_SEARCH_DATAで検索をすると、今度は検索が引っかかる
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('思う', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 検索結果
2	でもリレーショナルが恋しくなる時もあると思う
1	最終的にはSolrでいいとも思った

// 新しくデータを登録する
INSERT INTO SAMPLE (TEXT) VALUES('トリガーが効いてるか確認');

// 新しく入れた行が引っかかる条件で検索
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('確認', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// ちゃんと新しい行が検索結果で取れる
4	トリガーが効いてるか確認

データはINSERTされるたびにLucene側にCOMMITされる。DBがROLLBACKされた場合は、律儀にLucene側のデータを消しに行く。

この処理内容を見る限り、どうも処理時間に難がありそうな気がする。

データの投入時間について

全文検索を利用した場合、データ投入時間はどの程度変化するのか。

試しにTwitterのStreamingAPIから収集した日本語のテキスト1日分(約28万件、40MB)を投入したところ、実行速度は以下のようになった。AnalyzerはJapanseAnalyzerを利用。

概要所要時間秒間%
1Luceneのインデックスを貼らない場合92.4秒3030件100%
2データ投入後にLuceneのインデックスを一括作成139.1秒2012件150.5%
3トリガーを使って逐次インデックスを作成1時間以上?
4データ投入後にLuceneのインデックスを一括作成(RAMDirectory)135.2秒2071件146.3%
5トリガーを使って逐次インデックスを作成(RAMDirectory)483.6秒578件523.4%

見ての通り、トリガーが貼られた状態(3番)でデータ投入を行うと、ものすごい投入時間(1時間以上)がかかった。

これは1行INSERTされるたびに逐次Luceneのコミットが実行され、そのたびにSearcher取り直しているため。

Luceneはコミットをしないと情報が検索結果に反映されない。でもDBはコミットしなくてもトランザクション内からはデータが見える。その違いを吸収するため、上記のような処理になっているのだろうか。

デフォルトではFSDirectory(インデックスをファイルで持つ)を利用しているが、これをRAMDirectory(インデックスをメモリ上だけに持つ)にした場合は483.6秒(秒間580件くらい)とまあなんとか現実的な速度になった。

RAMDirectoryはアプリケーションが終了するとインデックスの内容は失われる。そのため、DB起動時に毎回インデックスを再作成するなどの運用の工夫は必要になる。

LuceneのCOMMITを明示的に行う

INSERTが走るごとに逐次Commitはせずに、DBをCommitするタイミングでLucene側もCommitするようにすれば、1つのトランザクション内で大量データを投入してもそれほど速度は低下しない。

DBのCommit時にトリガーでLuceneもCommitするような記述ができれば良かったのだけど、H2のソースを見た限りではCommit時はトリガーは呼ばれていない。呼ぶことは可能そうだけど気乗りしなかったのでパス。(たぶん面倒ではない。気力の問題)

簡単にできる改修方法として、Lucene側をCOMMITするためのFunctionを用意して、プログラマがタイミングを指定できるようにしてみる。

以下のFunctionを追加。

FTL_COMMITLuceneのCommitを行う。
FTL_COMMIT_ALLLuceneのcommitを行い、続いてDBのcommitを行う。h2.isTriggerCommitがfalseの場合はこれでCOMMITする。
FTL_FLUSH_RAMインデックスの内容をFSDirectoryにコピーする(RAMDirectory利用時のみ有効)

これだけ機能を作っておけば、状況に応じた使い分けもそこそこにできそうな気がする。

以下、使用例。

LuceneGosenを使用、INSERT時のTrigger内でのCOMMITはしない、RAMDirectoryを使用、という設定でサーバ起動。

$ java -Dh2.luceneAnalyzer=org.apache.lucene.analysis.ja.JapaneseAnalyzer -Dh2.isTriggerCommit=false -Dh2.useRamDirectory=true -cp h2-1.3.163.jar:h2-lucene-ex-1.3.163.jar:lucene-core-3.5.0.jar:lucene-gosen-1.2.1-ipadic.jar  org.h2.tools.Server
// テーブルを再作成
CALL FTL_DROP_ALL();
DROP TABLE SAMPLE;
CREATE TABLE SAMPLE (ID IDENTITY PRIMARY KEY, TEXT VARCHAR(128));

// インデックス作成
CALL FTL_CREATE_INDEX('PUBLIC', 'SAMPLE', 'TEXT');

// 適当にデータを入れる
INSERT INTO SAMPLE (TEXT) VALUES('最終的にはSolrでいいとも思った');
INSERT INTO SAMPLE (TEXT) VALUES('でもリレーショナルが恋しくなる時もあると思う');
INSERT INTO SAMPLE (TEXT) VALUES('アシッドが頼もしい時もある');

// isTriggerCommitがfalseになっているので、INSERTしただけではLucene側からは検索できない
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('思う', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 検索結果
(該当行無し, 49 ms)

// FTL_COMMITでLucene側をCommitすると、検索結果に反映される
CALL FTL_COMMIT();
SELECT S.ID, S.TEXT FROM FTL_SEARCH_DATA('思う', 0, 0) FT, SAMPLE S WHERE FT.TABLE='SAMPLE' AND S.ID=FT.KEYS[0];

// 検索結果
2	でもリレーショナルが恋しくなる時もあると思う
1	最終的にはSolrでいいとも思った

// FTL_COMMIT_ALLはH2とLucene、双方のCommitを行う(Luceneが先、H2が後に実行される)
CALL FTL_COMMIT_ALL();

// RAMDirectoryを利用しているため、現状ではインデックスディレクトリは存在しない
// FTL_FLUSH_RAMを実行すると、インデックスのディレクトリが生成される
CALL FTL_FLUSH_RAM();

まとめ

というわけで、H2で形態素解析結果を全文検索しようとしてうだうだしたメモでした。

Wikipediaの文書を突っ込んで、lift-mapperと組み合わせてORMで全文検索とかは普通に動いてくれた。遊びで使う分にはある程度使えそう。

実戦で使えるレベルにしようと思ったらソース全体組み直さないと厳しいなぁというのが印象。