全文検索エンジンとして有名なLucene/Solr。
この子を使って日本語文書のインデックスを作成したい場合、形態素解析かNgramを用いるのが一般的。
Ngramを選択した場合に良く利用されるのがCJKAnalyzer。日本語や英語なんかが混ざった文章を解析する時にはそこそこに便利。
ただ、その仕様や作成されるインデックスのサイズが必ずしも要件に合うとは限らない。これを自前で改変できるようになれば、用途に合った、よりコンパクトなインデックスが作成されるんじゃないだろうか。
そんなことを思ったので、気の向くままに「1文字をインデックスに入れない」とか「カタカナはBi-gramでなくまとめて登録する」とか「顔文字の検索を考慮する」などを試してみた。
改変を始める前に、CJKAnalyzerの挙動を確認する。基本的には英語は単語、日本語などその他の文字はBi-gramで分割される。
以下のように。
動作のTEST中 | ⇒ | 動作 | 作の | test | 中 |
記号は除外される。そのため、記号を含んだ文字は以下のようにインデックスが貼られる。
「記号」は、無視(涙)。 | ⇒ | 記号 | は | 無視 | 涙 |
但し例外があって、アンスコ(_)、プラス(+)、シャープ(#)は除外対象にならず英数と同じ扱いになるらしい。
a_b+c#d | ⇒ | a_b+c#d |
また、STOP_WORDSに指定されている文字や記号は除去される。デフォルトのSTOP_WORDSは以下。
a, and, are, as, at, be, but, by, for, if, in, into, is, it, no, not, of, on, or, s, such, t, that, the, their, then, there, these, they, this, to, was, will, with, www |
STOP_WORDSには頻繁に出現し過ぎて検索しても意味が薄くなる単語が入れられてるのだけど、この並びはどう見ても英語用なので、日本語だけ扱う場合もこれでいいかは微妙なところ。
CJKAnalyzerは基本はBi-gramなので、1文字でインデックスが貼られることは多くない。が、以下のような孤立した文字は1文字でインデックスができる。
AppleのiPhone | ⇒ | apple | の | iPhone |
Pマーク | ⇒ | p | マー | ーク |
disる | ⇒ | dis | る |
こうしてインデックスが貼ってあれば、PマークとかJリーグとかが検索できるようになる。
けど、「A」「S」「T」の3文字は、STOP_WORDSに登録されているので、デフォルトのままだと「Aチーム」とか「Tポイント」とかは検索できない。
なら、いっそのこと男らしく「単一の文字はインデックスに登録しない」ことにしてインデックスの量を削減してしまってはどうだろうか。
直し方は簡単。CJKTokenizerの289行目でif (length > 0)としているところを、if (length > 1)とするだけ。
このAnalyzerを利用すると、以下のように1文字の部分が無視されてインデックスが貼られる。
AppleのiPhone | ⇒ | apple | iPhone |
Pマーク | ⇒ | マー | ーク |
disる | ⇒ | dis |
実際に検索する場合、例えば「Pマーク」で検索してもPが無視され「マーク」が存在するテキストがすべて引っ掛かるようになる。
実際にこれを使ってみたところ、思ったよりサイズは減らずその割に検索できない単語は増えるので微妙だった。
サイズの削減に関する詳細は後述の付録2を参照。
Luceneで利用する場合は、コンストラクタにSet型を入れることで任意のSTOP_WORDSを指定できる。下の例では「ああ」と「いい」をSTOP_WORDSに設定している。
// 「ああ」と「いい」をSTOP_WORDSに入れる
Set stopWords = new HashSet();
stopWords.add("ああ");
stopWords.add("いい");
// コンストラクタで設定
CJKAnalyzer analyzer = new CJKAnalyzer(Version.LUCENE_35, stopWords);
デフォルト | ああいいうう | ⇒ | ああ | あい | いい | いう | うう |
上の例 | ああいいうう | ⇒ | あい | いう | うう |
Solrで利用する場合は、STOP_WORDSに入れる単語をテキストファイルに書いておき、StopFilterFactoryで指定する。
schema.xmlに以下のように記述し、タグの中で記述したstopwords.txtというファイルに改行区切りでSTOP_WORDSに入れたい単語を入れる。
<fieldtype name="text_cjk" class="solr.TextField" omitNorms="false">
<analyzer>
<tokenizer class="solr.CJKTokenizerFactory" />
<filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt"/>
</analyzer>
</fieldtype>
デフォルトの挙動だとカタカナはBi-gramで分割される。
ひらがなカタカナalpha漢字 | ⇒ | ひら | らが | がな | カタ | タカ | カナ | alpha | 漢字 |
でも、カタカナは単語としてまとまっている場合が多いので、わざわざBi-gramにしなくても良いような気がする。
複数のカタカナ語が連結されている場合は個別検索ができなくなってしまうというデメリットがあるけど、最悪、前方一致で前の単語は検索できないこともない。
というわけで、結果が下記のようになるよう改変してみた。
ひらがなカタカナalpha漢字 | ⇒ | ひら | らが | がな | カタカナ | alpha | 漢字 |
改変方法は簡単。
CJKAnalyzerはSINGLE_TOKEN_TYPEとDOUBLE_TOKEN_TYPEの2種類の状態を持っている。これにKATAKANA_TOKEN_TYPEを加えて、あとはUnicodeBlock.BASIC_LATIN判定をしているところを真似してUnicodeBlock.KATAKANAで判定してそれ用の処理を書く。
この方法を使うとそこそこインデックスサイズの削減が期待でき、その割に検索精度はそれほど悪くなった印象はなかった。
顔文字を検索したい、という要件が発生することはなきにしもあらず。顔文字を検索するには記号が除去されない様にしないといけない。
記号をNgramで登録するか、記号の連続をひとまとめにして登録してしまうか、どちらが良いか。
おはよー( ̄O ̄)ノ | ⇒ | おは | はよ | よー | ( ̄ |  ̄O |  ̄) | )ノ |
おはよー( ̄O ̄)ノ | ⇒ | おは | はよ | よー | ( ̄O ̄)ノ |
顔文字は記号以外が出現することも多く(上の例でも口の部分はアルファベットのO、手はカタカナのノ)、実際には上記のように綺麗にインデックスに入れることは難しい。
今回は試しに「2文字以上続く記号はBi-gramでインデックスに加える」としつつ、URLの表記で頻出しそうな「://」や一般的に用いられそうな「!!」「?!」「!?」などをSTOP_WORDSに加えるといった処置を取ってみる。
これで上の例で出した顔文字は下記のようになった。
おはよー( ̄O ̄)ノ | ⇒ | おは | はよ | よー | ( ̄ | O |  ̄) | )ノ |
「o」や「ノ」が記号ではないので分割されてしまってはいるけど、フレーズ検索で引っ掛けることはできそう。その他にもいくつか顔文字を検索してみる。
あら (~_~;) | ⇒ | あら | (~_~;) | うまく取れる例 | |||
ペコ≦(._.)≧ | ⇒ | ペコ | ≦(._.)≧ | 当符号などの2バイトの記号も大丈夫 | |||
はは(笑) | ⇒ | はは | 笑 | 連続しない記号は無視される | |||
やあ( ´・ω・)ノ | ⇒ | やあ | ω | )ノ | スペースとかの影響でうまくいかない例 |
使ってみた感じでは、デフォルトのCJKAnalyzerに比べればそこそこに顔文字が検索でき、インデックスのサイズも常識的なサイズに収まりそうだった。
通常のCJKAnalyzerで読点とか句点がインデックスに入らないのはどこで排除してるのか見てみたところ、Character.isLetterで判定していた。
句読点はUnicodeBlock的にはCJK_SYMBOLS_AND_PUNCTUATIONに入っている。このブロックには句読点などのisLetterがfalseのものと、「々」や「〆」などのisLetterがtrueのものが混在している。
isLetterのコードを見たらそういったところも気遣って頑張って文字かどうかを判定していた。
今回作ったものを使ってSolrでインデックスを作って、出来上がったインデックスファイルのサイズを比較してみた。
データは自然言語好きなご家庭によく転がっているTwitterのStreaminAPIで取得したデータ1ヶ月分(日本語のみ、1GB強)を利用する。データのストアはしていない。
パーセンテージはデフォルトのCJKAnalyzerを100%として算出。また、参考値としてlucene-gosenのJapaneseAnalyzerも並べておく。
タイプ | 投入時間(%) | インデックスサイズ(%) | |
---|---|---|---|
1 | 普通のCJKAnalyzer | 549sec(100%) | 849M(100%) |
2 | 1文字は除外するCJKAnalyzer | 545sec(98.47%) | 836M(98.47%) |
3 | カタカナをまとめるCJKAnalyzer | 525sec(95.67%) | 849M(92.93%) |
4 | 2文字以上の記号を含めるCJKAnalyzer | 587sec(106.92%) | 875M(103.06%) |
5 | カタカナをまとめ1文字を除外するCJKAnalyzer | 514sec(93.62%) | 768M(90.46%%) |
6 | すべての品詞を含むJapaneseAnalyzer(形態素解析) | 787sec(143.34%) | 714M(84.10%) |
7 | 接続詞や助詞を除くJapaneseAnalyzer(形態素解析) | 868sec(158.11%) | 561M(66.08%) |
7番以外はすべてAnalyzerタグでclass指定しており、フィルタ等は特に噛ませていない。
3番を見ての通り、カタカナをまとめると7%程度の削減になっている。こうした数値はデータの状態によって変化するのでアテになるかどうかは微妙だけど、そこそこ効果がありそうに見える。
形態素解析で品詞や接続詞を除いた場合(7番)は、やはりCJKAnalyzerよりもかなりインデックスのサイズを削減できるようだ。Twitterのような不自然言語系のデータではうまく検索できないケースも多いが。
大したコードでもないしあまりテストもしてないけど、とりあえず今回作ったjarを置いておく。
Luceneで利用する場合は以下のAnalyzerを利用する。利用方法はCJKAnalyzerと同じ。
クラス名 | 概要 |
---|---|
CJKAnalyzerNoSingleChar | CJKAnalyzerから1文字のインデックスを除外したもの |
CJKAnalyzerNoSplitKatakana | カタカナを分割せずにインデックスを貼ったもの |
CJKAnalyzerSaveKaomoji | 顔文字を検索できるように記号も含めたもの |
CJKAnalyzerNoSingleCharNoSplitKatakana | カタカナを分割せず、1文字のインデックスを除外したもの |
パッケージはすべてjp.mwsoft.cjkanalyzersに配置。cjkパッケージの下に置いて誰かと被ってもなんなので。
Solrでも利用できるようにTokenizerFactoryも作っておいた。
schema.xmlの記述方法は以下。
<!-- 1文字のインデックスを除く場合 -->
<fieldtype name="text_cjk" class="solr.TextField" omitNorms="false">
<analyzer>
<tokenizer class="jp.mwsoft.cjkanalyzers.CustomCJKTokenizerFactory" tokenizerClass="jp.mwsoft.cjkanalyzers.CJKTokenizerNoSingleChar" />
</analyzer>
</fieldtype>
<!-- カタカナを連結する場合 -->
<fieldtype name="text_cjk" class="solr.TextField" omitNorms="false">
<analyzer>
<tokenizer class="jp.mwsoft.cjkanalyzers.CustomCJKTokenizerFactory" tokenizerClass="jp.mwsoft.cjkanalyzers.CJKTokenizerNoSplitKatakana" />
</analyzer>
</fieldtype>
<!-- 記号をインデックスに入れる場合 -->
<fieldtype name="text_cjk" class="solr.TextField" omitNorms="false">
<analyzer>
<tokenizer class="jp.mwsoft.cjkanalyzers.CustomCJKTokenizerFactory" tokenizerClass="jp.mwsoft.cjkanalyzers.CJKTokenizerSaveKaomoji" />
</analyzer>
</fieldtype>