Play 2.0でBootstrap 2.0対応

Play2.0Bootstrap2.0を使う方法を紹介します。

Play2.0ではパッケージviews.html.helper.twitterBootstrapにBootstrap用のテンプレートがあるのでこれ使えばいいじゃんと思っていたのですが、残念ながらこれはBootstrapの2.0には対応していません。見てみたら簡単に対応出来そうなので、このコードを参考に作ってみました。

Form Template Helpers

と、その前に、Play2.0のテンプレートについて少し紹介します。Play2.0にはForm周りのコーディングを楽にする為のform template helpersという仕組みが提供されています。このテンプレートで入力フィールドは

<input type="text" name="username" id="username" value="@user.name">

※@から始まる記述が動的に評価されます

こんなふうに直接書くことも出来るのですが、このhelpersを使うと

@helper.inputText(myForm("username"))

こんな書き方が出来ます。最初の入力フィールドと同じ結果になるなら、なぜ別の書き方を覚えなきゃいけないのか、となりますが、上記の一文はデフォルトで以下のような少しリッチなHTMLに展開されます。

楽ですね。でも、このHTMLがそのまま欲しい記述とマッチしない場合も多いと思います。もちろん直接書いても良いのですが、helperの使い方を変えずに上記のテンプレートを切り替えるFieldConstructorという仕組みも提供されています。最初に紹介した予め提供されているBootstrap用のFieldConstrutorを使ってみましょう。先ほどのテンプレートのファイルに下記の一行を加えます。

@import helper.twitterBootstrap._

すると、同じhelperの記述のままで展開するHTMLが変わります。

あっさり変わりました。HTML内に項目がいくつあっても@importの一行で全て変わってくれます。他にも個々のlabelの名称やclassなどもhelperの使い方で変えることが出来ます。(詳細はこちら

Bootstrap2.0対応のテンプレートを作る

さっきのFieldConstructorはBootstrap2.0に対応しておらず、少し表示が崩れてしまいます。簡単に出来るので作ってしまいましょう。Play2.0で作成したプロジェクトのapp/viewsにbootstrapField.scala.htmlというファイルを以下の内容で作ります。なおファイル名は呼び出し時の名称(関数名)になります。

同じディレクトリにこれをimplicitな値として保持するobjectを作ります。

これで準備完了です。後は適用したいテンプレートで

@import views.BootstrapHelper._

と書きましょう。これで先ほどのhelperの出力はこのようになります。

classが変わりましたね。ちなみに先ほどのobjectは書かずに直接テンプレートにimplicitな値として記述する方法もあります。

@implicitField = @{ FieldConstructor(bootstrapField.f) }

どちらでも好きな方を使いましょう。
さて、実際に今回の対応を行っただけで、ほぼ同じテンプレートでも見栄えはここまで変わります。

使用前(FieldConstructor指定無し、BootstrapのCSS無し)

使用後

素晴らしい!たったこれだけで、目も当てられない見た目から、大幅に改善しましたね。Bootstrap最高です。

複数のFieldConstructor

さて、これまでページ内の全てのフィールドを同じ記述にする方法について見ていきましたが、個別にFieldConstructorを適用することも可能です。FieldConstructorはimplicitパラメータとして受け取るようになっているので、渡さなければスコープ内のimplicitな値が使われますが、明示的に引数として渡すことが可能です。先ほどのbootstrapField.scala.htmlにborderを足したものをborderedBootstrapField.scala.htmlというファイル名で新たに作ります。

control-groupのdivにstyle属性を足しています。そしてこれを変更したい項目の引数として渡しましょう。(play.api.i18n.Langも必要となりますが、ここでは詳細を割愛します)

@inputText(userForm("username"))(FieldConstructor(borderedBootstrapField.f), lang)

見た目はこうなりました。(アラートとかは抜いています)

簡単でしたね。
Play2.0は再利用可能なテンプレートを簡単に作れるので、他のBootstrapのパーツなどもhelperにしてしまってどんどん作りましょう。

Akasaka.scala 31に参加してきた

今日はAkasaka.scala 31に参加してきました。その時のメモと、後で調べようと思っていたことを書きます。(Akasaka.scalaについてはこちらを見て下さい)

今日の内容はScalaの言語仕様読書会(第4章)と、Play2.0のハンズオンでした。

Scala言語仕様 2.8 リーディング

まず、言語仕様読書会ですが、今回は「基本的な定義と宣言」でした。毎回@seratchさんが前もって予習した内容を記事にして頂いているので、そちらとScala2.8の言語仕様日本語訳を中心に読み進めました。「値の定義でパターンをとる」が面白かったです。こんなのができます。

val Some(x) = Option("foo")
println(x) // foo

上記の最初の代入は下記のように展開されるようです。(コンパイラが頑張る)

val x = Option("foo") match { case Some(x) => x }

でも、この例、使うことあるんだろうか。。Optionという事はNoneの可能性があるんだし、実際こうするとMatchErrorが飛んでくる。。

val n: Option[String] = None
val Some(x) = n
// scala.MatchError: None (of class scala.None$)

この仕様を使ってTupleを返すメソッドの結果を、複数同時に束縛できるのは便利です。

def search() = (List("abc", "defg", "hi"), 233)
val (list, numFound) = search()

final valというのもありました。valが再代入不可なのに何をfinalにする必要があるのだろう、と思っていましたが、finalをつけると定数式として扱われ、Scalaコンパイラが定数へ評価できるそうです。・・・よく分からなかったのでコード書いて試してみました。final valを使ったコードをいったんscalacでコンパイルし、それをさらにJava Decompilerで逆コンパイルし、Javaでどのように表現されるかを確認しています。

元のscalaコードです。

上記コードをscalacでコンパイルしたものを逆コンパイルしたコードです。

変数v1にはfvが代入されているのではなく、直接"abcd"が代入されています。v2にはv1(実際にはv1()メソッド)の結果が代入されています。v3〜v5は比較用の非finalなvalのコードです。よく分からないのがfvの変数で、finalなのに代入されていません。これをJavaのコードにするとコンパイルエラーになるので、恐らくnullが入っています。(※Scalaではfvと書いても自動生成されたfv()が呼ばれるので、通常は問題になりません。)ホントかどうか試してみました。


nullになってますね。

Play 2.0 Scala

言語仕様の次はPlay2.0です。
Play2.0はセットアップからWelcomeページ表示までをやってみましょう、という事で、前もって準備して頂いていたこちらの記事の通りにやってみました。Eclipseを使ってみるとの事だったので、type Pで動くか心配でしたが、小さなサンプルレベルだったので何とか動きました。HOT reloadingと日本語の対応状況の確認ぐらいで時間切れでした。引き続き遊んでみて、Websocketsで何か作れるようになるぐらいまでやってみたいですね。何かできたらblog書いてみるつもりです。

AttributeFactory (New TokenStream API)

今回もNew TokenStream APIに関連した機能を見ていきます。前回はサンプルを通して同API全体的に見ていきましたが、今回は少し詳細な内容で、Attributeを生成する役割を持つAttributeFactoryについて紹介します。なお、今回紹介するLuceneのコードのバージョンも3.5です。

AttributeFactoryについて

さて、このAttributeFactoryですが、通常直接使う事はありません。同APIの内部処理としてAttributeを継承したインターフェースを受け取り、対応するAttributeImplのサブクラスのインスタンスを生成する役割を持っています。デフォルトのAttributeFactoryの実装も提供されており、通常はこれを使い(未指定で自動的に使われ)ます。デフォルトの実装では(前回も紹介しましたが)、インターフェースと同じパッケージで、インターフェース名に接尾辞'Impl'が付いたクラスをLookupしてインスタンスを生成します。

デフォルト実装のDefaultAttributeFactoryのコードは<こちら>です。やる事の割に少々長いコードになっていますが、その多くが例外処理と、一度Lookupしたクラスをキャッシュする処理で、それ以外をのぞくと以下の2つの処理が見えてきます。

  1. AttributeのサブインターフェースのClassインスタンス(Class)からインターフェース名+ImplのClassインスタンスを返す
    ただし、TermAttribute.classが渡された場合は例外的にCharTermAttributeImpl.classを返す
  2. 1で返されたClassのインスタンスを作成する

コードにもコメントが書かれていますが、1のTermAttributeは現在deprecatedなので、特別に上記のような対応が行われています。既存のコードでエラーにならないように、今の所CharTermAttributeImplはTermAttributeも実装しています。インターフェースのCharTermAttributeがTermAttributeを継承しているわけではない事に注意して下さい。(CharTermAttributeだとして取得した場合にはTermAttributeで定義されているメソッドは呼び出せません)4.0のtrunkからはTermAttribute及び上記対応は削除されています。
参考Issue:LUCENE-2372

デフォルト実装の紹介は以上で、次はAttributeFactoryの実装を別途作る事で、上記のデフォルトの振る舞いを変更してみます。
今回もgithubにコードを置いていますので、出来れば動かしながら見てみて下さい。sbtで動作しますが、Scala IDEとivyDEを入れたEclipse 3.6.2でも動作するようになっているので(テストは、、sbtがいいかも)、お好きな方で試してみて下さい。
<study-attributeFactory>

CustomAttributeFactoryを作ってみよう

最初に、デフォルト実装を変えたい時の要求を考えてみます。新たなAttributeを追加したいという事だけだったら、任意のパッケージにAttributeとAttributeImplのセットを作れば良いだけですので、カスタム実装を作る必要はありません。思いつくのは以下の2つぐらいです。(他にもあったら教えて下さい・・・)

  1. 既存のAttributeインターフェースに対して別の実装を割り当てたい
    (既存のTokenizerやTokenFilterのコードを変更せずに振る舞いを変えたい)
  2. あるAttributeインターフェースに対する複数の実装を、状況に応じて切り替えられるようにしたい
    (直接ハンドリングする為の情報は渡せないので、ThreadLocalを使って情報を受け渡して切り替えるイメージです)

2はサンプルとしては複雑になりそうだし、効果的な使い道を思いつかないので、今回は1を実現する実装を作ってみます。
次にAttributeFactoryを拡張するポイントを考えてみましょう。

No 変更点
1 マッチ方法を変える 接頭辞'Custom'+インターフェース名のクラスにマッチするクラスを探す
2 インターフェースと実装のパッケージを変えられるようにする 指定したパッケージからクラスを探す
3 マッチしなかった時の振る舞い エラーとする or デフォルト実装に委譲する

パフォーマンスや動的変更など他にもポイントはあると思いますが、機能的には大体こんなものでしょうか。1は分かりやすいですが、個人的にLuceneのパッケージ(org.apache.lucene.analysis.tokenattributes)に独自のコードを入れるのには少々抵抗がありますので、今回は2のやり方を選択します。また、3のマッチしなかった時の振る舞いも重要です。エラーとする場合、既存のインターフェースに対応する実装を全て2のパッケージに用意する必要があり、あまりよろしくありません。(全て変えたいなら別ですが・・・)なので、エラーとなった場合はデフォルト実装に委譲するというのが良さそうです。実装したコードはこちらです。→<CustomAttributeFactory>

まずコンストラクタでlookupするパッケージ名を受け取ります。そしてcreateAttributeInstanceメソッドで受け取ったパッケージ名とメソッドパラメータのクラスの単純名を使って実装クラスをlookupして、インスタンスの生成を試みます。エラーなくインスタンスの生成が出来ればそのまま返しますし、クラスが見つからない場合はClassNotFoundExceptionがthrowされますので、その場合はデフォルト実装を実行した結果を返しています。またコメントにも書いていますが、実際にこのような実装を使う場合は、デフォルト実装と同じように、毎回lookupせずにインターフェースに対応する実装クラスのClassインスタンスをキャッシュしておくのがいいでしょう。また、実際に使う際は、ほぼキャッシュが必須になるため、インスタンスをsingletonにする必要もあります。CustomAttributeFactory自体をobjectで書けば良かったのですが、コンストラクタでパッケージ名を受け取れるようにしたかったので、インスタンスを保持するクラスは別途作りました。→<Factory>
実際使う場合もデフォルト実装と同じようにstatic変数に入れておくなりすると良いでしょう。

Factoryが出来たので、早速使ってみます。次の2つを作りました。

  1. 動作確認用Attributeのインターフェースと実装
    <EmptyAttribute>
  2. CharTermAttributeの別実装
    <CharTermAttributeImpl>
    ※当初元のクラスを継承してちゃちゃっと終わりにする予定だったのですが、変更したいメソッドがことごとくfinalだったので、、インターフェースを全て実装して委譲する事にしました(次回以降で利用する予定)

内容はどちらも大したことはしてません。

さて、準備が整いましたので、早速テストコードで動かしてみましょう。→<CustomAttributeFactoryTest>

study-AttributeFactoryのディレクトリ直下でsbtとして、testでテストが動作します。無事に動きましたか?[success]と出れば正常にテストが完了しています。
このテストではインターフェースと実装クラスがペアになってるケースと、既存インターフェースに対する別実装を提供するケースと、対応する実装がパッケージにない場合にデフォルト実装が使われることを確認しています。

無事にNew TokenStream APIのデフォルト実装を切り替える事が出来ましたね!
今回はやりませんが、実際にSolrとかで使う場合は、Tokenizerのコンストラクタに今回のAttributeFactoryのインスタンスを渡す、TokenizerFactoryを作ってschema.xmlでそれを指定すればOKです。
という事で今回はAttributeFactoryについてでした。次回はTokenizerの紹介に(ようやく)戻る予定です。

LuceneのNew TokenStream API

今回はLuceneのanalysisパッケージのNew TokenStream APIを紹介します。名称にはNewとついていますが、もう2年以上前のLucene2.9で提供されたAPIで、従来の用途への影響を最小限に抑えつつ、それまでのものより柔軟な拡張が可能とする為に新しく作られました。このAPIに関する記事は関口さんもblogでも書かれていますし、LuceneのanalysisパッケージのJavadoc(ページ真ん中あたりから)にも詳細に書かれています。今回、今更ながらこのAPIを紹介するのは、TokenizerやTokenFilterをカスタマイズもしくは利用するのに知っておくべきポイントだからというのもあるのですが、それより私自身がTokenizer関連のコードを読み解くのが難しかった為、まずここを理解しなければ、と思ったのが正直なところです。

この記事では、このAPIJavadocの和訳(ほぼGoogle翻訳・・・)とそれらを利用するScalaのコードとサンプルコードを見ながら進めていきます。原文の訳文とそうでない文は文字色を変えて区別できるようにしています。使うコードは全て<こちら>にあります。動かしながら見てもらえると分かりやすくなると思います。sbtが必要なので、入ってない方はこちらに導入方法が載っていますので、参考にして入れてみて下さい。jar入れて起動スクリプトにパスを通すだけなので簡単です。記事の訳に誤りや分かりにくい箇所がたくさんあると思うので、指摘してもらえると嬉しいです。また、正確さを求める場合は原文のJavadocを読んで頂くようお願いします。それと、この記事の内容はLuceneの3.5時点のものですので、ご注意を。それでは冒頭の紹介の部分から始めましょう。

New TokenStream API

Luceneの2.9からの新しいTokenStream APIを紹介します。それまでのAPIはTokenを生成するために使用されました。Tokenは位置増分とTermテキストのような、さまざまなプロパティのgetterメソッドとsetterメソッドを持っています。このアプローチは、標準のインデックス作成のフォーマットのためには十分でしたが、それはFlexible Indexing(プラガブルで拡張可能なカスタムインデックスフォーマットの為のLuceneのIndexer作成をまとめた用語)の為には十分汎用的ではありませんでした。

完全にカスタマイズ可能なIndexerは、ユーザーがディスク上にカスタムデータ構造を格納できるようになることを意味します。したがってAPIには、文書からIndexerへのカスタムデータを転送することができる必要があります。
→これらはLucene 4で提供される予定です。LUCENE-1458LUCENE-2111

AttributeとAttributeSource

そこでLucene2.9から、AttributeとAttributeSourceと呼ばれるクラスの新しいペアを導入しています。Attributeはトークン文字列に関する情報の特定部分として機能します。例えば、TermAttributeは、トークンのタームテキストが含まれており、OffsetAttributeには、トークンの開始と終了の文字オフセットが含まれています。 AttributeSourceは、制約のあるAttributeのコレクションで、各Attributeの型のインスタンスは1つだけある可能性があります。 TokenStreamはAttributeSourceを拡張するようになり、それはTokenStreamにAttributeを追加できることを意味します。TokenFilterがTokenStream拡張するので、すべてのフィルタもAttributeSourceです。

Luceneは現在、Tokenクラスが持っていた変数を置き換える、すぐに使える次の6つ(3.5では8つ)のAttributeを提供しています:

Attribute名 役割
TermAttribute トークンのターム文字列。3.1よりdeprecatedで今後のリリースで廃止される予定
OffsetAttribute トークンの開始/終了のオフセット
PositionIncrementAttribute トークンの位置増分情報
PayloadAttribute トークンが任意で持つ事の出来るペイロード(バイト配列で保存する任意のデータ)
TypeAttribute トークンの種類。デフォルトは'word'
FlagsAttribute(※) トークンに紐づくビットフラグ。This API is experimentalとあるので、まだ積極的に使わない方がいいかも
CharTermAttribute TermAttributeの代わりにこれを使う事
KeywordAttribute Tokenがキーワードかどうか

※TermAttributeの実装のTermAttributeImplは、AttributeFactoryのデフォルトの実装では、TermAttributeが渡されるとCharTermAttributeImplが返されるようになっており、既に使えなくなっています。

新しいTokenStream APIの利用

効率的に新しいAPIを利用するために知っておくいくつかの重要な点をここにまとめます。まず以下の例(Exampleの章)を見て、その後、このセクションに戻ってくるといいでしょう。

    1. AttributeSourceは特定のAttributeインスタンスを1つだけ持つことができることを覚えておいて下さい。さらに、TokenStreamと複数のTokenFilterのチェーンで使用されている場合、チェーンの全てのTokenFilterはTokenStreamのAttributeを共有します。
      →テストコード書きました。まず、Attributeは種類ごとにインスタンスを一つだけしか持てないという点を、Tokenizerに同じAttributeをaddAttributeした時に、同じインスタンスが返されている事を確認しています。→<確認用コード>
      次にTokenStreamのチェーン内のAttributeが共有される事ですが、これはTokenizerとTokenFilterのいずれからaddAttributeした場合でも、同じインスタンスが返されている事を確認しています。→<確認用のコード>

    2. Attributeのインスタンスは、ドキュメントのすべてのトークンで再利用されています。従って、TokenStream/TokenFilterはincrementToken()で適切なAttribute(s)に更新する必要があります。消費者(一般的にLuceneのインデクサー)は、Attributeのデータを消費し、ストリームの終わりに到達したことを示すfalseが返されるまで、再度incrementToken()を呼び出します。これはincrementToken()の各呼び出しでTokenStream/TokenFilterは、安全にAttributeインスタンス内のデータを上書きできることを意味します。
      →<このコード>で確認出来るように、TokenizerとTokenFilterのincrementToken()で、同じAttributeインスタンスが使われます。TokenizerからTokenFilterにAttributeを直接渡すのではなく、それぞれの処理が勝手に参照すれば良くて、それぞれが更新すれば良いという仕組みになっています。
    3. パフォーマンス上の理由からTokenStream/TokenFilterは、初期化中にAttributeをadd/getする必要があります。例えば、コンストラクタでAttributeを作成してインスタンス変数に参照を保持します。incrementToken()でaddAttribute()/getAttribute()を呼び出す代わりにインスタンス変数を使用する事で、ドキュメントでのトークンごとのAttributeルックアップを避けることができます。
      →TokenStream/TokenFilterのコンストラクタでAttributeをaddして使おうね、incrementTokenで毎回addして参照しないでね、パフォーマンスが悪くなるからという事です。
    4. AttributeSourceのすべてのメソッドはべき等で、何度呼び出しても常に同じ結果が得られることを意味します。これは、addAttribute()を知るためには、特に重要です。このメソッドは引数として、Attributeの型(Class)を受け取り、インスタンスを返します。同じタイプのAttributeが以前追加されていた場合は、すでに存在するインスタンスを返し、そうでない場合は新しいインスタンスが作成されて返されます。したがってTokenStreams/TokenFilterは、同じアトリビュートの型で複数回addAttribute()を安全に呼び出すことができます。TokenStreamの消費者は、通常getAttribute()の代わりにaddAttribute()を呼び出す必要があります、なぜなら、TokenStreamがAttributeを保持していない場合に失敗するからです(getAttribute()は、Attributeが欠落している場合、IllegalArgumentExceptionをスローする)。より高度なコードは、単にhasAttribute()でチェックすることができ、もしTokenStreamが保持しているなら、条件付きで特別なパフォーマンスを得るための処理を省略することができます。
      →addAttributeを何回呼び出しても同じ結果を返すという点は、1の確認用コードでインスタンスを一つしか持てない事を既に確認しました。ここのポイントは、Attributeを取得したい場合は、通常addAttributeを使うという事です。getAttributeではありません。使う事は出来ますが、Attributeが登録されてない場合は例外が投げられます。→<確認用コード>

Example

この例では、WhiteSpaceTokenizerを作成し、2文字以下のすべての単語を抑制するLengthFilterを使用します。LengthFilterはLuceneコアの一部であり、新しいTokenStream APIの使用法を例示するためにここで説明されます。
その後、カスタムAttribute、PartOfSpeechAttributeを開発し、チェーンに新しいカスタムAttributeを利用する別のフィルタを追加し、それをPartOfSpeechTaggingFilterが呼び出します。

Whitespace tokenization

という事で、まず最初はWhitespaceTokenizerを使っただけのAnalyzerを作ります。→<コード>
このAnalyzerはtokenStreamメソッドで、WhitespaceTokenizerを返すだけです。まずはこれを使ってAttributeのインスタンスを利用してみましょう。最初に上のAnalyzerのインスタンスを作って、tokenStreamメソッドを呼び出します。→<コード>
次に文書が正しく各Tokenとして処理されているか確認します。→<確認用コード>
Tokenの内容は原文では標準出力に出力していましたが、この記事ではScalaTestを使って結果を確認しています。また、原文はTermAttributeを使っていますが、3.5時点でdeprecatedなので、この記事ではCharTermAttributeを使っています。15行目でCharTermAttributeのインスタンスを取得して、20行目で利用しています。CharTermAttributeでは、直接文字列を取得するメソッドがdeprecatedなので、buffer()とlength()をStringのコンストラクタで使って文字列を取得します。length()を指定しないと、CharTermAttributeはcharの配列を使い回す関係上、一番長いToken以外は、後ろに他のTokenの文字が入ってしまうので、注意が必要です。
結果は原文と同様にスペースで区切られたToken「"This", "is", "a", "demo", "of", "the", "new", "TokenStream", "API"」となります。

Adding a LengthFilter

2文字以下の全てのTokenを出力しないようにします。LengthFilterをchainに追加することで容易に実現できます。さっき作ったAnalyzerのtokenStream()メソッドのみを変更する必要があります。
という事で、WhitespaceTokenizerをLengthFilterのコンストラクタに渡してchainにしました。→<確認用コード>
LengthFilterは許可する文字数を指定するようになっており、ここでは2文字以下のTokenを除外したいので、3〜Integer.MAX_VALUEを指定します。
※原文のLengthFilterのコンストラクタの引数は3つですが、このコンストラクタはdeprecatedになっています。第一引数が追加されており、これにより、PositionIncrementAttributeを適切に記録するかどうかが変わります。原文では位置増分の情報は使っていないので不要なのですが、折角なのでどのように動いているかを確認するために、この記事ではtrueにしておきます。

ではこれを<利用するコード>を見ましょう。
最初のサンプルとの違いはPositionIncrementAttributeを使って位置増分も確認するようにしているところです。結果は2文字以下のTokenが削られて、List( ("This",1), ("demo",3), ("the",2), ("new",1), ("TokenStream",1), ("API",1) )と同じになります。削られた数もPositionIncrementAttributeで確認できます。

原文ではこの後でLengthFilterのコードがのっていますが、3.5時点の<LengthFilter>では主な実装が<FilteringTokenFilter>に移動してしまっているのと、原文で確認しているTokenizerとTokenFilterでのAttributeインスタンスの共有は、Using New TokenStream APIの章で既に確認しているので、この記事では省略します。比較的簡単なコードなので、興味のある方はリンクをはっていますのでそちらで確認してみて下さい。

Adding a custom Attribute

次に、品詞のタグ付けのために、PartOfSpeechAttributeという独自のカスタムAttributeを実装します。まず、新しいAttributeのインターフェースを定義する必要があります。
という事でtraitとPartOfSpeechクラスと関連クラスを作りました。まず品詞の列挙型はscalaのEnumerationを使って定義しています。→<コード>
インターフェースは原文のコードとほぼ同じです。→<コード>

次に実装クラスを記述する必要があります。クラスの名称は重要です:デフォルトではLuceneは、接尾辞が"Impl"という名称のクラス(インターフェース名Impl)があるかどうかをチェックします。この例ではこれから実装するクラスのPartOfSpeechAttributeImplを呼び出します。
これは通常の動作です。しかし、これらの命名規則を変更することができるexpert-API(AttributeSource.AttributeFactory)もあります。Factoryでは、引数としてAttributeインタフェースを受け取り、実際のインスタンスを返します。デフォルトの動作を変更する必要がある場合には独自のファクトリを実装することができます。
※ここに書かれているように独自のAttributeFactoryを用意すれば、既存の実装の変更(特定パッケージを優先的に探すとか)も可能ですが、そうするにはTokenizerに独自AttributeFactoryのインスタンスを渡すように修正しなければいけなくて、solrから使うなら、変えたい種類すべてのTokenizerFactoryを独自に作る必要がありそうですね。。。
→<実装クラスのPartOfSpeechAttributeImplのコード>

これは、単純なAttributeの実装で、Tokenの品詞を格納するだけ単一の変数を持っています。それは、新しいAttributeImplクラスを拡張し、抽象メソッドのcopyTo()、equals()、hashCode()、clear()を実装しています。それぞれのトークンにこの新しいPartOfSpeechAttributeを設定することができるTokenFilterが必要です。この例では、1文字目が大文字の単語を'Noun'として、それ以外を'Unknown'とする、非常に単純なフィルタ(PartOfSpeechTaggingFilter)を見せます。→<PartOfSpeechTaggingFilterのコード>

LengthFilter(2.9当時)のように、この新しいフィルタは、コンストラクタで必要なAttributeにアクセスし、インスタンス変数に参照を設定します。あなたは新しい属性のインターフェースを渡す必要があるだけで、正しいクラスが自動的に解決されてインスタンス化することに注意してください。次にchainにフィルタを追加する必要があります:
→<フィルタを追加したコード>
一番外側に今回作ったFilterを追加しています。早速動作確認してみましょう。→
→<確認用コード>

出力内容に品詞情報を追加して確認してみます。結果は、List( ("This",Noun,1), ("demo",Unknown,3), ("the",Unknown,2), ("new",Unknown,1), ("TokenStream",Noun,1), ("API",Noun,1) )に等しくなります。

品詞タグ付けの改善

各単語は、現在、割り当てられたPartOfSpeechタグが続きます。もちろん、これは素朴な品詞のタグ付けです。単語'This'にも名詞としてタグ付けされるべきではなく、それは文の最初の語であるだけで大文字で綴られています。実際にこれは練習のための良い機会です。新しいAPIの使い方を練習する為に、読者は既に、文の最初のトークンかどうかの場合に、各単語に指定できるAttributeとTokenFilterを書くことができます。その後PartOfSpeechTaggingFilterは、この知識を利用することができて、文章の最初の単語でない場合にのみ、大文字から始まる単語を名詞としてタグ付けすることができます(これが正しい動作ではないのは分かっているが、それは良い練習です)。

このあと原文にはFirstTokenOfSentenceAttributeImplを作ろうというヒントもあるので、これを参考に<FirstTokenOfSentenceAttributeインターフェース>と<実装クラス>、そしてこれらに値を設定する<FirstTokenOfSentenceFilter>を作ってみましょう。新しく作ったAttributeのインターフェースと実装は、最初の単語かどうかの値を保持するだけの役割なので、難しくないと思います。そして、先ほどのPartOfSpeechTaggingFilterを、このAttributeを使って、文章の最初の単語の場合には品詞としないように修正してみましょう。修正したコードが<こちら>]>です。PartOfSpeechTaggingFilterを継承して、品詞の判定部分のみoverrideしています。さらにこれらを使うようにしたAnalyzerが<こちら>です。先にFirstTokenOfSentenceAttributeが設定されるように、組み込む順番をPartOfSpeechTaggingFilter2より前にしています。順番を入れ替えると正常に動作しませんので気をつけて下さい。

最後に<動作確認用のコード>です。先ほどのAnalyzerを使って、文章の最初の単語の'This'がNounとならずにUnknownとなりました。

読んでみて

最初はとにかく、クラス間でAttributeをどのように利用しているかが分かりづらく、理解が難しかったのですが、実際に動かしてみてからTokenizerやTokenFilterのコードを見てみると、随分理解しやすくなりました。また、新しいAPIでコードの見通しが悪くなった反面、既存コードに影響を与えずに拡張が出来るように考えられた作りになっている事が分かります。折角学んだので、まだ何も思いついてないですが、何か思いついたら独自のAttributeとFilterを作ってみて、また紹介したいと思います。
次回は今回詳細に見なかったAttributeSourceのコードを見ながら、より細かいところについて見ていく予定です。(記事はもっともっと短くします・・・)

SolrのResult Grouping

今回はSolrのResult Grouping(Field Collapsing)の機能について紹介します。

この機能は検索結果を特定のフィールドでグループ化します。SQLのgroup byみたいなものと思ってもらうと分かりやすいでしょう。具体的な例だとGoogle検索の以下のような表現が良い例ですね。

Solrでは上記のGoogle検索と同じ事が、通常の検索にgroup=true&group.field=グループ化する項目名というパラメータを付けるだけで行えます。Result Groupingの詳細な情報は、SolrのwikiFieldCollapsing」に詳しく書かれていますのでそちらを見て頂く事にして、ここでは一覧を簡単に紹介するだけにとどめ、主要なパラメータについて試しながら紹介していきたいと思います。

パラメータ一覧

パラメータ名 指定する値 説明
group true/false trueでこの機能を有効にする。省略時はfalse
group.field 項目名 グループ化する項目。
3.xでは文字列系(StrField, TextFieldなど)の項目以外は指定できない。またmulti-valuedの項目も指定できない
group.func function query グループ化する為のfunction queryを指定。4.0からの機能
group.query query 項目ではなく条件でグループ化するためのクエリーを指定。
同名のパラメータで異なるクエリーを指定可能。
使用例:group.query=price:[0 TO 999]&group.query=price:[1000 TO *]
検索結果を1000円未満と1000円以上でグループ化
rows 数値 一度の結果で返す最大数。formatがgroupedの場合はドキュメント数ではなくグループ数を示す
start 数値 結果の表示開始位置。rowsと同様にグループ数なので注意
group.limit 数値 グループ化したドキュメントの表示最大数。省略時は1
group.offset 数値 グループ化したドキュメントの表示開始位置。省略時は0
sort ソート条件 検索結果のソート順。省略時はLuceneが算出するスコア順
group.sort ソート条件 グループ化したドキュメントの表示順。省略時はLuceneが算出するスコア順
group.format grouped/simple 検索結果の表示形式。省略時はgrouped
group.main true/false trueで検索結果の表示形式を、通常の検索結果と同じ形式にする。使えなくなるパラメータ有り(詳細は後述)
省略時はfalse
group.ngroups true/false trueでグループ単位の検索結果総数を結果に加える。省略時はfalse
group.truncate true/false trueでファセットカウントをドキュメント数ではなくグループ単位の数にする。省略時はfalse
group.cache.percent 0〜100 1以上の値を指定する事でグループ化のための検索をキャッシュする。
(1以上の値を設定してもquery result cacheにもキャッシュされず、詳細は不明・・・。パフォーマンス計測の結果、効果はあったが、wikiには簡単なqueryに適用するとパフォーマンスに悪影響が出るかも、と書いてる・・・。)
省略時はfalse

これらのパラメータは、大きく分けると以下のようなグループに分けられます。

  1. 基本的なパラメータ
  2. 結果のフォーマット
  3. ファセットの為の機能

これらをそれぞれ試してみます。今回サンプルデータとして、日本語のwikipediaの記事jawiki-latest-pages-articles.xml.bz2)を使わせてもらいました。

基本的なパラメータ

まず、Result Groupingを見る前に、比較の為に通常の検索結果を見てみます。(以降すべて「うどん」の検索結果です。)
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on

<response>
 <lst name="responseHeader">
  ... (経過時間や指定したパラメータなどのヘッダ情報) ...
 </lst>
 <result name="response" numFound="2491" start="0"> ・・・①
  <doc>
   <str name="contributor-id">24321</str>
   <str name="contributor-name">千葉県民</str>
   <str name="id">767255</str>
   <date name="timestamp">2006-11-16T23:25:10Z</date>
   <str name="title">田舎うどん</str>
   <str name="url">
   http://ja.wikipedia.org/wiki/%E7%94%B0%E8%88%8E%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>
   <str name="contributor-id">40160</str>
   <str name="contributor-name">中村派</str>
   <str name="id">540810</str>
   <date name="timestamp">2006-05-17T13:09:14Z</date>
   <str name="title">天ぷらうどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E5%A4%A9%E3%81%B7%E3%82%89%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  ... (以下hitしたドキュメントの情報が続く) ...
 </result>
</response>

上記が通常の検索結果で、①のresult要素の子要素のdocがhitしたドキュメントです。また①のresult要素のnumFound属性が、hitしたドキュメントの総数です。では、条件は同じまま、contributor-id(wikipediaのcontributorに割り当てられている内部ID)でグループ化してみます。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped"> ・・・①
  <lst name="contributor-id">
   <int name="matches">2491</int> ・・・②
   <arr name="groups"> ・・・③
    <lst>
     <str name="groupValue">24321</str> ・・・④
     <result name="doclist" numFound="3" start="0"> ・・・⑤
      <doc>
       <str name="contributor-id">24321</str>
       <str name="contributor-name">千葉県民</str>
       <str name="id">767255</str>
       <date name="timestamp">2006-11-16T23:25:10Z</date>
       <str name="title">田舎うどん</str>
       <str name="url">
        http://ja.wikipedia.org/wiki/%E7%94%B0%E8%88%8E%E3%81%86%E3%81%A9%E3%82%93
       </str>
      </doc>
     </result>
    </lst>
    ... (以下hitしたグループの情報が続く) ...
   </arr>
  </lst>
 </lst>
</response>

Result Groupingのパラメータを追加する事で、結果がcontributorごとにグループ化され、それぞれのcontributorごとに1つの結果のみが返されるようになりました。また、通常の検索と比べてかなり構造も変わりました。

  • ①は通常検索のresult要素と同じで、結果部分のroot要素です。
  • ②は通常検索のnumFound属性と同じで、この検索でhitしたドキュメントの総数です(グループ数ではありません)
  • ③の子要素のlst要素が、それぞれのグループごとにまとめられた結果の要素です。
  • ④はグループ化した項目の値です(contributorのID)
  • ⑤の要素はグループ化したドキュメントを保持します。最大でgroup.limitで指定した数を保持します。numFoundは、グループ内のhitしたドキュメントの総数です。

次はgroup.limitを指定してみます。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.limit=2

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int>
   <arr name="groups">
    ... (中略) ...
    <lst>
     <str name="groupValue">103575</str>
     <result name="doclist" numFound="4" start="0"> ・・・①
      <doc>
       <str name="contributor-id">103575</str>
       <str name="contributor-name">Pcs34560</str>
       <str name="id">24461</str>
       <date name="timestamp">2003-11-07T16:50:09Z</date>
       <str name="title">饂飩</str>
       <str name="url">http://ja.wikipedia.org/wiki/%E9%A5%82%E9%A3%A9</str>
      </doc>
      <doc>
       <str name="contributor-id">103575</str>
       <str name="contributor-name">Pcs34560</str>
       <str name="id">24460</str>
       <date name="timestamp">2011-09-21T13:21:13Z</date>
       <str name="title">うどん</str>
       <str name="url">
        http://ja.wikipedia.org/wiki/%E3%81%86%E3%81%A9%E3%82%93
       </str>
      </doc>
     </result>
    </lst>
     ... (以下hitしたグループの情報が続く) ...
   </arr>
  </lst>
 </lst>
</response>

二つ表示されるようになりました。上の①のnumFoundでグループ内には全部で4件hitしたドキュメントがある事が分かります。この情報を利用すればGoogle検索と同じように「○○をもっと見る」が実現できますね。
次はグループ化した時のページング処理に必要となる、group.ngroupsを指定してみます。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.ngroups=true

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int> ・・・①
   <int name="ngroups">1244</int> ・・・②
   <arr name="groups">
    <lst>...</lst>
    <lst>...</lst>
    <lst>...</lst>
     ... (略) ...
   </arr>
  </lst>
 </lst>
</response>

上記①は、通常の検索hit総件数(ドキュメント数)で、②が総グループ数となります。1ページの表示最大数はグループ数で数えられる為、Result Groupingの検索のページングには②の数値を用います。

結果のフォーマット

これまで見てきたResult Groupingの検索結果の構造は、そうではない検索結果の構造と比較すると、結構違いがあります。パッと見構造が複雑ですし、結果の解析もその分大変になります。それらを軽減する為に、これまで見てきたものを合わせて3種類の形式が用意されています。あとの2種類はいずれもこれまでよりシンプルな構造になりますが、出来る事が変わります。パフォーマンスはどれもあまり変わらなかったので、用途に合ったフォーマットを選ぶと良いでしょう。選択できるフォーマットは以下の3つです。

No 名称 説明
1 grouped これまで見てきた構造(デフォルトの形式)
2 simple groupedよりシンプルな構造
3 main 通常の検索結果とほぼ同じ構造

groupedはこれまで見てきた形式ですが、おさらいしましょう。こんな構造でした。

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int>
   <int name="ngroups">1244</int>
   <arr name="groups">
    <lst>
     <str name="groupValue">24321</str>
     <result name="doclist" numFound="3" start="0">
      <doc>
       <str name="contributor-id">24321</str>
       ... (略) ...
      </doc>
      <doc>... (略) ...</doc>
      <doc>... (略) ...</doc>
      ... (以下group.limitを上限としてdoc要素を繰り返す) ...
     </result>
    </lst>
    <lst>...</lst>
    <lst>...</lst>
     ... (以下グループ単位でlimitを上限としてlst要素を繰り返す) ...
   </arr>
  </lst>
 </lst>
</response>

2番目のsimpleは、途中までの構造はgroupedと同じですが、ドキュメントがグループごとにまとめられず、全て平らな構造になっているのが特徴です。また、groupedとは事なり、ページあたりの表示数はグループ数ではなくドキュメント数となります。その為、ngroupsの指定と取得も出来るのですが、あまり使い道はないでしょう。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.ngroups=true&group.format=simple

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int>
   <int name="ngroups">1244</int>
   <result name="doclist" numFound="2491" start="0">
    <doc>... (略) ...</doc>
    <doc>... (略) ...</doc>
    <doc>
     <str name="contributor-id">30901</str>
     <str name="contributor-name">”D”</str>
     <str name="id">457105</str>
     <date name="timestamp">2006-02-23T11:08:35Z</date>
     <str name="title">冷凍うどん</str>
     <str name="url">
      http://ja.wikipedia.org/wiki/%E5%86%B7%E5%87%8D%E3%81%86%E3%81%A9%E3%82%93
     </str>
    </doc>
    ↓グループ化されずフラットに扱われる
    <doc>
     <str name="contributor-id">14864</str>
     <str name="contributor-name">逃亡者</str>
     <str name="id">473792</str>
     <date name="timestamp">2006-03-09T19:52:36Z</date>
     <str name="title">肉うどん</str>
     <str name="url">
      http://ja.wikipedia.org/wiki/%E8%82%89%E3%81%86%E3%81%A9%E3%82%93
     </str>
    </doc>
    <doc>
     <str name="contributor-id">14864</str>
     <str name="contributor-name">逃亡者</str>
     <str name="id">1332131</str>
     <date name="timestamp">2010-12-29T12:09:31Z</date>
     <str name="title">タヌキ (曖昧さ回避)</str>
     <str name="url">
      http://ja.wikipedia.org/wiki/%E3%82%BF%E3%83%8C%E3%82%AD_%28%E6%9B%96%E6%98%A7%E3%81%95%E5%9B%9E%E9%81%BF%29
     </str>
    </doc>
    <doc>... (略) ...</doc>
    <doc>... (略) ...</doc>
    <doc>... (略) ...</doc>
    <doc>... (略) ...</doc>
    <doc>... (略) ...</doc>
   </result>
  </lst>
 </lst>
</response>

simpledの用途は、検索結果はフラットに表示したいが検索結果をグループごとにまとめて表示したい、といった時に使えるでしょう。ただし、このように表示したい場合なら、大抵の場合は次のmainを使うのが良いでしょう。mainはほぼ通常の検索結果と同じ構造でレスポンスを返します。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.ngroups=true&group.main=true

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped"/> ・・・①
 <result name="response" numFound="2491" start="0">
  <doc>
   <str name="contributor-id">24321</str>
   <str name="contributor-name">千葉県民</str>
   <str name="id">767255</str>
   <date name="timestamp">2006-11-16T23:25:10Z</date>
   <str name="title">田舎うどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E7%94%B0%E8%88%8E%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>
   <str name="contributor-id">40160</str>
   <str name="contributor-name">中村派</str>
   <str name="id">540810</str>
   <date name="timestamp">2006-05-17T13:09:14Z</date>
   <str name="title">天ぷらうどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E5%A4%A9%E3%81%B7%E3%82%89%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>
   <str name="contributor-id">30901</str>
   <str name="contributor-name">”D”</str>
   <str name="id">457105</str>
   <date name="timestamp">2006-02-23T11:08:35Z</date>
   <str name="title">冷凍うどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E5%86%B7%E5%87%8D%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
 </result>
</response>

通常の検索結果との違いは、上記の①の部分だけです。この唯一の違いの要素は空要素で何の属性も持たないため、何に使うのか分かりません。。Result Groupingを示す目印でしょうか。。この形式の注意点は、ngroupsが取得できなくなる事です。グループ単位の総件数を取得したい場合は、simpleもしくはgroupedを使って下さい。(3.4時点ではまだ取得できませんが、@johtaniさんに教えてもらったこのページによると将来的に取得できるようになるかもしれません)また、groupedもしくはsimpleのフォーマット指定は無視されます。

ファセットの為の機能

ファセット検索でのファセットごとのカウントに使う機能も提供されています。通常はマッチするドキュメント単位の数がファセットカウントに紐付けられますが、この値をグループ単位にする事が出来ます。まずはResult Groupingとファセットをそのまま使ってみましょう。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&facet=true&facet.date=timestamp&facet.date.start=NOW%2FMONTH-2YEARS&facet.date.end=NOW%2FMONTH-1MONTH&facet.date.gap=%2B1MONTH

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">... (検索結果省略) ...</lst>
 <lst name="facet_counts">
  <lst name="facet_queries"/>
  <lst name="facet_fields"/>
  <lst name="facet_dates">
   <lst name="timestamp">
    <int name="2009-10-01T00:00:00Z">11</int>
    <int name="2009-11-01T00:00:00Z">9</int>
    <int name="2009-12-01T00:00:00Z">11</int>
    <int name="2010-01-01T00:00:00Z">13</int>
    <int name="2010-02-01T00:00:00Z">9</int>
    <int name="2010-03-01T00:00:00Z">19</int>
    <int name="2010-04-01T00:00:00Z">7</int>
    <int name="2010-05-01T00:00:00Z">15</int>
    <int name="2010-06-01T00:00:00Z">18</int>
    <int name="2010-07-01T00:00:00Z">31</int>
    <int name="2010-08-01T00:00:00Z">21</int>
    <int name="2010-09-01T00:00:00Z">25</int>
    <int name="2010-10-01T00:00:00Z">29</int>
    <int name="2010-11-01T00:00:00Z">30</int>
    <int name="2010-12-01T00:00:00Z">36</int>
    <int name="2011-01-01T00:00:00Z">64</int>
    <int name="2011-02-01T00:00:00Z">69</int>
    <int name="2011-03-01T00:00:00Z">54</int>
    <int name="2011-04-01T00:00:00Z">80</int>
    <int name="2011-05-01T00:00:00Z">111</int>
    <int name="2011-06-01T00:00:00Z">145</int>
    <int name="2011-07-01T00:00:00Z">236</int>
    <int name="2011-08-01T00:00:00Z">425</int>
    <str name="gap">+1MONTH</str>
    <date name="start">2009-10-01T00:00:00Z</date>
    <date name="end">2011-09-01T00:00:00Z</date>
   </lst>
  </lst>
  <lst name="facet_ranges"/>
 </lst>
</response>

うどんと書かれた記事の、最終更新日の月ごとのドキュメント数が取得できました。ただし、グループごとにまとめて表示する場合は、表示する件数はドキュメント数より少なくなってしまいます。そこで、ファセットカウントをグループ数にしてみましょう。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.truncate=true&facet=true&facet.date=timestamp&facet.date.start=NOW%2FMONTH-2YEARS&facet.date.end=NOW%2FMONTH-1MONTH&facet.date.gap=%2B1MONTH

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">... (検索結果省略) ...</lst>
 <lst name="facet_counts">
  <lst name="facet_queries"/>
  <lst name="facet_fields"/>
  <lst name="facet_dates">
   <lst name="timestamp">
    <int name="2009-10-01T00:00:00Z">4</int>
    <int name="2009-11-01T00:00:00Z">7</int>
    <int name="2009-12-01T00:00:00Z">5</int>
    <int name="2010-01-01T00:00:00Z">8</int>
    <int name="2010-02-01T00:00:00Z">8</int>
    <int name="2010-03-01T00:00:00Z">7</int>
    <int name="2010-04-01T00:00:00Z">4</int>
    <int name="2010-05-01T00:00:00Z">9</int>
    <int name="2010-06-01T00:00:00Z">11</int>
    <int name="2010-07-01T00:00:00Z">14</int>
    <int name="2010-08-01T00:00:00Z">11</int>
    <int name="2010-09-01T00:00:00Z">19</int>
    <int name="2010-10-01T00:00:00Z">15</int>
    <int name="2010-11-01T00:00:00Z">17</int>
    <int name="2010-12-01T00:00:00Z">21</int>
    <int name="2011-01-01T00:00:00Z">40</int>
    <int name="2011-02-01T00:00:00Z">38</int>
    <int name="2011-03-01T00:00:00Z">31</int>
    <int name="2011-04-01T00:00:00Z">43</int>
    <int name="2011-05-01T00:00:00Z">54</int>
    <int name="2011-06-01T00:00:00Z">76</int>
    <int name="2011-07-01T00:00:00Z">113</int>
    <int name="2011-08-01T00:00:00Z">200</int>
    <str name="gap">+1MONTH</str>
    <date name="start">2009-10-01T00:00:00Z</date>
    <date name="end">2011-09-01T00:00:00Z</date>
   </lst>
  </lst>
  <lst name="facet_ranges"/>
 </lst>
</response>

group.truncate=trueを指定する事でファセットカウントが減り、グループ単位になりました。ここでは紹介しませんが、StatsComponentの結果にも有効です。

こんな感じでResult Groupingは簡単に使えます。3.5と4.0から部分的に分散検索に対応しますし、パフォーマンスの改善予定もあるようなので、今後使う機会が増えそうですね。

独自CharFilter作ってみた

今回はCharFilterの総集編(?)ということで、「昭和フィルター」という独自フィルターを題材に、独自CharFilterの作り方と使い方を紹介します。「独自」とは言っても、MappingCharFilterPatternReplaceCharFilterでほとんどのCharFilterでやるべきことは出来るはずで、なかなか独自のものを必要とする機会は少ないと思います。(こういうのが、というのがあれば是非教えて下さい)この記事を書くにあたり考えてみたのですが、私には実用的なものは思いつきませんでした。(今回の例はほぼネタです。実際には使わないで下さい)さて、まずは昭和フィルターの紹介からです。

昭和フィルターとは

昔のレトロな表記でよくある「ろどいび」や「こばた」など“右から左“に書いてある文字を、Tokenizerが解析できるように逆順(「びいどろ」や「たばこ」)にしてあげるCharFilterです。あくまで検索に使うINDEXが逆順になるだけで、内容は右から左のまま変わりません。動作イメージは以下のスクリーンショットの通りです。

利用シーン

コンテンツ例 マッチするクエリー文字列
社会式株CBA
ABC株式会社
華中しや冷
たしまめじは
冷やし中華
送運ーらーそ
運送
(“ーそ“がハイライトされます・・・)

制限事項

  • Luceneの制限だと思うのですが、文字の順番が入れ替わる位置調整は試したところうまく動作しませんでした。それが原因で、上記の例にもある通り、行全体とマッチするもの以外は適切にハイライトしません(※)
  • サロゲートペアには対応していません(面倒だったので・・)
  • 1行が長すぎる文章の場合、メモリを大量に消費します・・

※そもそも、今回の方法は逆順で登録するのではなく、通常通りLTRで登録し、スタイルシートでRTLを指定すればいいだけの事です。このフィルターを使わなくても実現できますし、そうした方が正しくハイライトできます。あくまで独自CharFilterのサンプルという事で。

コード

1ファイルにするために、CharFilterFactoryのstaticなメンバークラスとしてCharFilterを定義しています。CharFilterFactoryはorg.apache.solr.analysis.CharFilterFactoryを実装し、CharFilterはorg.apache.lucene.analysis.CharStreamを継承したクラスを作成します。それぞれに共通の処理を定義した、BaseCharFilterFactoryBaseCharFilterという抽象クラスが用意されているので、今回のサンプルと同じように、通常はそれらを継承して作成するといいでしょう。では実際にコードを見ていきましょう。

このコードのポイントは42〜58行目(行数が出てませんが・・・)のreadLineメソッドです。ここでは必要に応じてReaderから1行読み込み、逆順にしつつ、文字ごとのオフセットの調整(addOffCorrectMapメソッドの呼び出し)を行っています。オフセットの調整は、本当なら逆順にしなければいけませんが、先の制限事項でも書いた通り、ちゃんと指定してしまうと正しく動作しませんので、今回の実装では固定で0を設定しています。CharFilterの実装では、このOffsetの調整が面倒ですが、とても重要です。正しく行わないと、ハイライトが正しく動かなくなってしまいますので。
また、今回のサンプルでは行いませんでしたが、外部ファイルを利用する場合は、org.apache.solr.util.plugin.ResourceLoaderAwareを実装する事で、informメソッドでリソースの読込を行うことが出来ます。この際、パラメータのResourceLoaderによって、coreのconfからの相対パス、カレントディレクトリからの相対パス絶対パス、もしくはClassLoaderが自動的に選択される、とても便利な読込手段が提供されていますので、独自に作りこまずにResourceLoaderを利用するようにしましょう。

Solrへの導入方法

以下の三つを行うだけです。

  1. jarを作る
  2. solrconfig.xmlで指定するか、sharedLibに入れる
  3. schema.xmlのcharFilter要素のclassに今回作った"analysis.ShowaFilterFactory"を設定(Tokenizerはお好きなものを)

※パッケージ名はorg.apache.solr.analysisに属している場合、solr.クラス名と省略して記述できます。

なかなか独自のCharFilterが必要となる事はなさそうですが、実装もそれほど難しくなく、また、容易に組み込めるようになっていますので、機会があればどんどん作ってみましょう。

次回はTokenizerFactoryを紹介していく予定です。

SolrのAnalyzerについて

Lucene/Solrには、任意の文書をキーワードで検索するための索引(INDEX)を作るための方法として、Analyzerという仕組みを提供しています。INDEXは文書を検索のための文書の最小要素(Token)が登録され、検索の際にはこのTokenと一致することが検索にhitする基本的な条件となります。その為、このTokenの生成方法によって検索エンジンの使い勝手は完全に変わります。ですからこのAnalyzerの仕組みに関する理解とAnalyzerの選択/設定は、Lucene/Solrを利用するシステムを構築する上で、一番肝の部分と言っても過言ではありません。また、SolrではこのAnalyzerをclassファイルで直接指定することも出来ますが、定義ファイルによるパーツごとの組み合わせとパラメータ設定によって、柔軟にカスタマイズする仕組みも提供しています。今回の記事では、後者の組み合わせ可能な方法について紹介します。

まず、パーツごとの役割についてですが、以下の3つの役割に分かれて構成されています。

No 名称 役割
1 CharFilter Tokenに分割する前の文字列に対して処理を行います
2 Tokenizer 文字列をTokenに分割します
3 TokenFilter 分割されたTokenに対して処理を行います

これらを組み合わせることで様々な検索要件に対応したAnalyzerを、コードを書く事なく簡単に定義する事が可能となっています。それぞれの役割の処理イメージを下記に示します。

このようにそれぞれが細かく役割分担をする事で、再利用しやすい形で提供されています。次に、Analyzerの構成要素である2つのFilterとTokenizerについて紹介します。

CharFIlterについて

CharFilterは、TokenizerがTokenに分割する前処理として、文書に対する単純な文字列処理を行います。文字列処理で文字数が増減する場合は、検索で適切な場所をhighlightするために、CharFilterの実装は文字単位の元の位置(差分)を正しく記録するべきです。
主な用途は単純な文字の置換(一文字→一文字、一文字→複数文字、複数文字→一文字、複数文字→複数文字)や、単純な削除処理(HTMLタグの除去)で使われます。類義語を用いた処理や、単語の品詞によって処理するなど、予めある単位に分割する必要がある場合は、TokenizerによってTokenに分割された後のTokenFilterによって行います。

CharFilterは一つのAnalyzerにつき0個以上の指定が可能です。省略することも出来ますし、複数指定する事で記述した順序で処理を行わせる事も可能です。

Tokenizerについて

TokenizerはAnalyzerの処理の中で最も重要な役割を担います。文書中の文字列を、それぞれのTokenizerのルールでTokenに分割します。最初にも書いた通り、このTokenが検索の為の索引となります。一般的なTokenの生成方法は、文書の単語区切りによる方法です。これは、英語のように、スペースで分割すれば単語ごとに区切ることの出来る言語でだと楽で良いのですが、日本語のように簡単なルールで単語ごとに区切ることの出来ない言語は厄介です。下の例を見て下さい。
===============
MultiSearcher is deprecated
↓スペースで区切る
MultiSearcher | is | deprecated

すもももももももものうち
↓どうやって区切る?
すもも | も | もも | も | もも | の | うち
===============

日本語だと上記のように形態素解析を用いて区切る方法と、単純にN文字ずつ区切るN-Gramという方法が主流で、詳細は色んなサイトで紹介されているのでここでは割愛しますが、それぞれ特性は一長一短です。他にも言語ごとの特性に合わせたものや、英語でもスペース以外で区切るもの、ユニークなものだとファイルパスを区切るTokenizerなんかも提供されています。

Tokenizerは、どういった文言で検索させるかを考えて選ばないと、想定した検索キーワードでhitしなかったり、想定していない結果が返される事があるので、それぞれの特性をよく考えて設定するようにしましょう。

Tokenizerは一つのAnalyzerにつき一つの指定を行います。省略することは出来ず、複数指定する事も出来ません。

TokenFilterについて

TokenFilterは、Tokenizerの後処理として、Tokenの削除や追加、内容自体の編集を行います。一部のFilterでは、CharFilterと似たもの、ほぼ同じものもあります。CharFilterとの明確な使い分けのルールはありませんが、Tokenに関係するものはTokenFilterで行うと良いでしょう。実際にTokenを直接増減させられる為、CharFilterより提供されているクラスの数は多いです。

主な実装としては、一般的な頻出するTokenの削除や同義語のToken追加、動詞などの活用形を基本形へ変換するクラスなどがあります。

TokenFilterはCharFilterと同じように、一つのAnalyzerにつき0個以上の指定が可能です。省略することも出来ますし、複数指定する事で記述した順序で処理を行わせる事も可能です。

まとめ

  • Analyzerは文書をTokenに分割する
  • Analyzerはclassで指定するやり方と、FilterとTokenizerを組み合わせた細かい指定の方法がある
  • 一つのAnalyzerにつき、CharFilterとTokenFilterは複数指定可能で、Tokenizerの指定は一つのみ
  • Tokenizerの前処理がCharFilter、後処理がTokenFilter