概要

全文検索エンジンとして有名なLucene/Solr。

この子を使って日本語文書のインデックスを作成したい場合、形態素解析Ngramを用いるのが一般的。

Ngramを選択した場合に良く利用されるのがCJKAnalyzer。日本語や英語なんかが混ざった文章を解析する時にはそこそこに便利。

ただ、その仕様や作成されるインデックスのサイズが必ずしも要件に合うとは限らない。これを自前で改変できるようになれば、用途に合った、よりコンパクトなインデックスが作成されるんじゃないだろうか。

そんなことを思ったので、気の向くままに「1文字をインデックスに入れない」とか「カタカナはBi-gramでなくまとめて登録する」とか「顔文字の検索を考慮する」などを試してみた。

@CretedDate 2012/01/15
@Versions Lucene/Solr3.5.0<, Java6

CJKAnalyzerの挙動

改変を始める前に、CJKAnalyzerの挙動を確認する。基本的には英語は単語、日本語などその他の文字はBi-gramで分割される。

以下のように。

動作のTEST中動作作のtest

記号は除外される。そのため、記号を含んだ文字は以下のようにインデックスが貼られる。

「記号」は、無視(涙)。記号無視

但し例外があって、アンスコ(_)プラス(+)シャープ(#)は除外対象にならず英数と同じ扱いになるらしい。

a_b+c#da_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には頻繁に出現し過ぎて検索しても意味が薄くなる単語が入れられてるのだけど、この並びはどう見ても英語用なので、日本語だけ扱う場合もこれでいいかは微妙なところ。

1文字の場合はインデックスから除外してみる

CJKAnalyzerは基本はBi-gramなので、1文字でインデックスが貼られることは多くない。が、以下のような孤立した文字は1文字でインデックスができる。

AppleのiPhoneappleiPhone
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のiPhoneappleiPhone
Pマークマーーク
disるdis

実際に検索する場合、例えば「Pマーク」で検索してもPが無視され「マーク」が存在するテキストがすべて引っ掛かるようになる。

実際にこれを使ってみたところ、思ったよりサイズは減らずその割に検索できない単語は増えるので微妙だった。

サイズの削減に関する詳細は後述の付録2を参照。

付録1 : STOP_WORDSの指定方法

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のコードを見たらそういったところも気遣って頑張って文字かどうかを判定していた。

付録2 : インデックスファイルのサイズ比較

今回作ったものを使ってSolrでインデックスを作って、出来上がったインデックスファイルのサイズを比較してみた。

データは自然言語好きなご家庭によく転がっているTwitterのStreaminAPIで取得したデータ1ヶ月分(日本語のみ、1GB強)を利用する。データのストアはしていない。

パーセンテージはデフォルトのCJKAnalyzerを100%として算出。また、参考値としてlucene-gosenのJapaneseAnalyzerも並べておく。

タイプ投入時間(%)インデックスサイズ(%)
1普通のCJKAnalyzer549sec(100%)849M(100%)
21文字は除外するCJKAnalyzer545sec(98.47%)836M(98.47%)
3カタカナをまとめるCJKAnalyzer525sec(95.67%)849M(92.93%)
42文字以上の記号を含めるCJKAnalyzer587sec(106.92%)875M(103.06%)
5カタカナをまとめ1文字を除外するCJKAnalyzer514sec(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のような不自然言語系のデータではうまく検索できないケースも多いが。

付録3 : 今回作ったjarの使用方法(Lucene編)

大したコードでもないしあまりテストもしてないけど、とりあえず今回作ったjarを置いておく。

jarはこちら

Luceneで利用する場合は以下のAnalyzerを利用する。利用方法はCJKAnalyzerと同じ。

クラス名概要
CJKAnalyzerNoSingleCharCJKAnalyzerから1文字のインデックスを除外したもの
CJKAnalyzerNoSplitKatakanaカタカナを分割せずにインデックスを貼ったもの
CJKAnalyzerSaveKaomoji顔文字を検索できるように記号も含めたもの
CJKAnalyzerNoSingleCharNoSplitKatakanaカタカナを分割せず、1文字のインデックスを除外したもの

パッケージはすべてjp.mwsoft.cjkanalyzersに配置。cjkパッケージの下に置いて誰かと被ってもなんなので。

付録4 : jarの使用方法(Solr編)

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>