CLOVER🍀

That was when it all began.

Apache LuceneのFlexible Query Parserを試す

Apache Luceneでちょっと気になっていた機能として、Flexible Query Parserがあります。

org.apache.lucene.queryparser.flexible.core (Lucene 6.5.1 API)

Apache LuceneのQueryParserといえば、Classic Query Parserです。

org.apache.lucene.queryparser.classic (Lucene 6.5.1 API)

QueryParserとは?

Apache LuceneのQueryは、TermQuery、BooleanQuery、FuzzyQuery、PhraseQueryなどいろいろありますが、特定のルールに則った
QueryStringを与えるとパースして、その内容からApache LuceneのQueryを生成してくれるパーサーです。

org.apache.lucene.search (Lucene 6.5.1 API)

各種Queryのインスタンスを個別に組み合わせていくのではなく、文字列から一気に作れるので便利です。

Classic Query Parserとは

割と前からある(Classic)QueryParserが、有名なのではないかなと思います。

QueryParser (Lucene 6.5.1 API)

特徴としては、JavaCCで自動生成されたクラスであり、QueryStringをパースして一気にQueryの構築まで行ってしまう性質になります。

https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj

Flexible Query Parserとは

Apache Lucene 3系ではContribにあったモジュールが、lucene-queryparserモジュールに移されて整理されたものみたいです。

org.apache.lucene.queryparser.flexible.core (Lucene 6.5.1 API)

「flexible query parser framework」っていうみたいですね。

Classic Query ParserのAPIドキュメントにも、よりカスタマイズ可能でモジュール化されたパーサーがあることが紹介されていたりします。

NOTE: there is a new QueryParser in contrib, which matches the same syntax as this class, but is more modular, enabling substantial customization to how a query is created.

https://lucene.apache.org/core/6_5_1/queryparser/org/apache/lucene/queryparser/classic/QueryParser.html

Flexible Query Parserは、少なくとも2つのフェーズ、オプショナルでもうひとつ、最大3つのフェーズで構成されます。

  • First Phase: Text Parsing
  • Second (optional) Phase: Query Processing
  • Third Phase: Query Building

Text Parsingフェーズでは、SyntaxParserを使ってQueryStringをパースし、QueryNodeを構築します。

Query Processingフェーズはオプションですが、QueryNodeProcessorによって実行されます。このフェーズでは、QueryNodeのツリーに対する
追加処理、検証、Queryの拡張などを行うフェーズになります。

Query Buildingフェーズでは、QueryBuilderを使用してQueryNodeを任意のオブジェクトに変換することを行います。変換されたオブジェクトは、
検索インデックスに対して実行されることになります。

ここで登場するモジュールを組み合わせてQueryParserを構成するわけですが、そのフロントエンドとしてはまずはStandardQueryParserを
使うことになるのではないかなと。

StandardQueryParser (Lucene 6.5.1 API)

準備

とまあ、前置きはこんな感じにして、Flexible Query Parser…というかStandardQueryParserを使ってみます。合わせて、比較のために
Classic Query Parserも使ってみましょう。

まずはビルド定義。
build.sbt

name := "lucene-flexible-query-parse"

version := "1.0"

scalaVersion := "2.12.2"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-unchecked", "-deprecation", "-feature")

updateOptions := updateOptions.value.withCachedResolution(true)

libraryDependencies ++= Seq(
  "org.apache.lucene" % "lucene-queryparser" % "6.5.1" % Compile,
  "org.apache.lucene" % "lucene-analyzers-kuromoji" % "6.5.1" % Compile,
  "org.scalatest" %% "scalatest" % "3.0.3" % Test
)

Flexible Query Parserを使うには、「lucene-queryparser」モジュールがあればOKです。これひとつで、Classic Query Parserと
Flexible Query Parserの両方を使うことができます。

Kuromojiが入っているのは、なんとなくパース時に形態素解析しておこうかな、くらいの感覚です。

あと、テストコードで確認しようと思うので、ScalaTestも加えておきます。

お題とテストコードの雛形

Apache Luceneのインデックスに登録するDocumentは、書籍をテーマに扱います。これを検索する際のQueryを構築する際の方法として、
Classic Query ParserとFlexible Query Parser(StandardQueryParser)の両方で行います。

テストコードの雛形は、こんな感じで作りました。
src/test/scala/org/littlewings/lucene/queryparser/FlexibleQueryParserSpec.scala

package org.littlewings.lucene.queryparser

import java.text.DecimalFormat

import org.apache.lucene.analysis.Analyzer
import org.apache.lucene.analysis.ja.JapaneseAnalyzer
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.document.Field.Store
import org.apache.lucene.document._
import org.apache.lucene.index.{IndexWriter, IndexWriterConfig}
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.queryparser.flexible.standard.StandardQueryParser
import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig
import org.apache.lucene.queryparser.flexible.standard.config.StandardQueryConfigHandler.ConfigurationKeys
import org.apache.lucene.search._
import org.apache.lucene.store.{Directory, RAMDirectory}
import org.scalatest.{FunSuite, Matchers}

import scala.collection.JavaConverters._

class FlexibleQueryParserSpec extends FunSuite with Matchers {
  // ここに、テストコードを書く!!
}

使うDocumentは、こんな感じで定義。

  val bookDocuments: Array[Document] = Array(
    createBookDocument("978-4774127804", "Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築", 2270),
    createBookDocument("978-4774189307", "[改訂第3版]Apache Solr入門――オープンソース全文検索エンジン", 4104),
    createBookDocument("978-4048662024", "高速スケーラブル検索エンジン ElasticSearch Server", 6642),
    createBookDocument("978-4774167534", "検索エンジン自作入門 〜手を動かしながら見渡す検索の舞台裏", 2894),
    createBookDocument("978-4822284619", "検索エンジンはなぜ見つけるのか", 2592)
  )

Documentの作成方法は、こんな感じ。

  protected def createBookDocument(isbn: String, title: String, price: Int): Document = {
    val document = new Document

    document.add(new StringField("isbn", isbn, Store.YES))
    document.add(new TextField("title", title, Store.YES))
    document.add(new IntPoint("price", price))
    document.add(new NumericDocValuesField("price", price))

    document
  }

Apache Lucene 6から、数値系のフィールドはIntPoint、LongPointなどを使うのがよいみたいなのですが、Point系のクラスではソートが
できないみたいなので、Doc Valuesを扱うフィールドも追加するようにしています。

java - IntPoint is not indexing integer values - Stack Overflow

How to sort IntPont or LongPoint field in Lucene 6 - Stack Overflow

というか、以前自分でDoc Valuesを調べた時にも、同じことをしていました…。

LuceneのDoc Valuesを調べてみる - CLOVER

参考)
moco(beta)'s backup: Hello Lucene 6.0! その1:PointValues を使ってみる

Lucene/Solr の数値フィールドについて

ちょっと脱線しましたね。あとはDirectoryの利用と、DocumentのIndexWriterへの書き込みはこんな簡易なメソッドを用意。

  protected def withDirectory(directory: Directory)(fun: Directory => Unit): Unit = {
    try {
      fun(directory)
    } finally {
      directory.close()
    }
  }

  protected def writeDocuments(directory: Directory, documents: Seq[Document], analyzer: Analyzer = new StandardAnalyzer): Unit = {
    val indexWriter = new IndexWriter(directory, new IndexWriterConfig(analyzer))
    try {
      documents.foreach(indexWriter.addDocument)
      indexWriter.commit()
    } catch {
      case e: Exception =>
        indexWriter.rollback()
        throw e
    } finally {
      indexWriter.close()
    }
  }

これらを利用して、テストコードを書いていきます。

Classic Query Parser

まずは、Class Query Parserを使ったコードを書いていきましょう。

タイトル向けに「全文検索」と「入門」をAND条件に、価格をソートする感じで検索。

  test("classic query parser") {
    withDirectory(new RAMDirectory) { directory =>
      val analyzer = new JapaneseAnalyzer
      writeDocuments(directory, bookDocuments, analyzer)

      val searcherManager = new SearcherManager(directory, new SearcherFactory)
      val indexSearcher = searcherManager.acquire()
      try {
        val query = new QueryParser("isbn", analyzer).parse("title: 全文検索 AND title: 入門")
        val topDocs = indexSearcher.search(query, 3, new Sort(new SortField("price", SortField.Type.INT, true)))

        val scoreDocs = topDocs.scoreDocs
        scoreDocs should have size (3)

        indexSearcher.doc(scoreDocs(0).doc).get("title") should be("[改訂第3版]Apache Solr入門――オープンソース全文検索エンジン")
        indexSearcher.doc(scoreDocs(1).doc).get("title") should be("検索エンジン自作入門 〜手を動かしながら見渡す検索の舞台裏")
        indexSearcher.doc(scoreDocs(2).doc).get("title") should be("Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築")

        query should be(a[BooleanQuery])
        query.toString should be("+(title:全文 title:検索) +title:入門")
      } finally {
        searcherManager.release(indexSearcher)
      }
    }
  }

Classic Query Parserの場合は、インスタンス作成時にデフォルトフィールドとAnalyzer(今回はJapaneseAnalyzer)を渡し、parserメソッドの
呼び出し時の引数としてQueryStringを渡すだけです。

        val query = new QueryParser("isbn", analyzer).parse("title: 全文検索 AND title: 入門")

こうすると、Apache LuceneのQueryが返ってくるので、あとは検索に使用することになります。

次に、検索条件を変えて範囲検索(RangeQuery)にしてみましょう。

  test("classic query parser, with numeric") {
    withDirectory(new RAMDirectory) { directory =>
      val analyzer = new JapaneseAnalyzer
      writeDocuments(directory, bookDocuments, analyzer)

      val searcherManager = new SearcherManager(directory, new SearcherFactory)
      val indexSearcher = searcherManager.acquire()
      try {
        val query = new QueryParser("isbn", analyzer).parse("price: [2000 TO 3000]")
        val topDocs = indexSearcher.search(query, 3, new Sort(new SortField("price", SortField.Type.INT, true)))

        val scoreDocs = topDocs.scoreDocs
        scoreDocs should be(empty)

        query should be(a[TermRangeQuery])
      } finally {
        searcherManager.release(indexSearcher)
      }
    }
  }

価格を条件に範囲検索しましたが、検索結果は0件。

Queryが、RangeQueryの中でもTermRangeQueryになっていて、テキスト系のQueryになっているからですね。

        query should be(a[TermRangeQuery])

こういう挙動なので、Numericなフィールドに対するRangeQueryは、ふつうにやろうとするとQueryParserの外でやることになるわけです…。

Flexible Query Parser

続いて、Flexible Query Parser…StandardQueryParserを使ってみましょう。

作成したコードは、こんな感じ。

  test("standard query parser") {
    withDirectory(new RAMDirectory) { directory =>
      val analyzer = new JapaneseAnalyzer
      writeDocuments(directory, bookDocuments, analyzer)

      val searcherManager = new SearcherManager(directory, new SearcherFactory)
      val indexSearcher = searcherManager.acquire()
      try {
        val query = new StandardQueryParser(analyzer).parse("title: 全文検索 AND title: 入門", "isbn")
        val topDocs = indexSearcher.search(query, 3, new Sort(new SortField("price", SortField.Type.INT, true)))

        val scoreDocs = topDocs.scoreDocs
        scoreDocs should have size (3)

        indexSearcher.doc(scoreDocs(0).doc).get("title") should be("[改訂第3版]Apache Solr入門――オープンソース全文検索エンジン")
        indexSearcher.doc(scoreDocs(1).doc).get("title") should be("検索エンジン自作入門 〜手を動かしながら見渡す検索の舞台裏")
        indexSearcher.doc(scoreDocs(2).doc).get("title") should be("Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築")

        query should be(a[BooleanQuery])
        query.toString should be("+(title:全文 title:検索) +title:入門")
      } finally {
        searcherManager.release(indexSearcher)
      }
    }
  }

使い方はClassic Query Parserとほとんど同じですね。デフォルトフィールドは、parseの引数に渡すようになっているくらいです。

        val query = new StandardQueryParser(analyzer).parse("title: 全文検索 AND title: 入門", "isbn")

ところで、Numericなフィールドで検索しようとするどうなるか…ですが、実はこのままだとClassic Query Parserと同じ結果に
なったりします。

  test("standard query parser, with numeric") {
    withDirectory(new RAMDirectory) { directory =>
      val analyzer = new JapaneseAnalyzer
      writeDocuments(directory, bookDocuments, analyzer)

      val searcherManager = new SearcherManager(directory, new SearcherFactory)
      val indexSearcher = searcherManager.acquire()
      try {
        val query = new StandardQueryParser(analyzer).parse("price: [2000 TO 3000]", "isbn")
        val topDocs = indexSearcher.search(query, 3, new Sort(new SortField("price", SortField.Type.INT, true)))

        val scoreDocs = topDocs.scoreDocs
        scoreDocs should be(empty)

        query should be(a[TermRangeQuery])
      } finally {
        searcherManager.release(indexSearcher)
      }
    }
  }

FieldConfigを使って、PointRangeQueryNodeProcessorを有効化する

なにも設定しないStandardQueryParserだとPoint系のフィールド向けのRangeQueryが生成できないのですが、PointRangeQueryNodeProcessor向けの
設定を追加することでTermRangeQuery以外にも、PointRangeQueryを生成できたりできるようになります。

変更した結果は、このように。
※)
StandardQueryParser#setPointsConfigMapが使えるという話をご指摘いただいたので、修正しました。

  test("standard query parser, with numeric, with point-config") {
    withDirectory(new RAMDirectory) { directory =>
      val analyzer = new JapaneseAnalyzer
      writeDocuments(directory, bookDocuments, analyzer)

      val searcherManager = new SearcherManager(directory, new SearcherFactory)
      val indexSearcher = searcherManager.acquire()
      try {
        val queryParser = new StandardQueryParser(analyzer)
        queryParser
          .setPointsConfigMap(Map("price" -> new PointsConfig(new DecimalFormat("###"), classOf[Integer])).asJava)
        val query = queryParser.parse("price: [2000 TO 3000]", "isbn")
        val topDocs = indexSearcher.search(query, 3, new Sort(new SortField("price", SortField.Type.INT, true)))

        val scoreDocs = topDocs.scoreDocs
        scoreDocs should have size (3)

        indexSearcher.doc(scoreDocs(0).doc).get("title") should be("検索エンジン自作入門 〜手を動かしながら見渡す検索の舞台裏")
        indexSearcher.doc(scoreDocs(1).doc).get("title") should be("検索エンジンはなぜ見つけるのか")
        indexSearcher.doc(scoreDocs(2).doc).get("title") should be("Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築")

        query should be(a[PointRangeQuery])
      } finally {
        searcherManager.release(indexSearcher)
      }
    }
  }

ポイントは、StandardQueryParser#setPointsConfigMapでフィールド名をキーに、値をPointsConfigとしたMapを渡すことです。

        queryParser
          .setPointsConfigMap(Map("price" -> new PointsConfig(new DecimalFormat("###"), classOf[Integer])).asJava)

また、こちらでも同じ意味になります。

        queryParser
          .getQueryConfigHandler
          .set(ConfigurationKeys.POINTS_CONFIG_MAP, Map("price" -> new PointsConfig(new DecimalFormat("###"), classOf[Integer])).asJava)

さらに別解としてはQueryConfigHandlerを取得して、FieldConfigListenerを追加することです。
FieldConfigListenerはインターフェースで、抽象メソッドはひとつなのでLambda式で表現することができます。

        val queryParser = new StandardQueryParser(analyzer)
        queryParser
          .getQueryConfigHandler
          .addFieldConfigListener(fieldConfig =>
            if (fieldConfig.getField == "price") {
              fieldConfig.set(
                ConfigurationKeys.POINTS_CONFIG,
                new PointsConfig(new DecimalFormat("###"), classOf[Integer])
              )
            }
          )

このいずれかの方法をとることで、Point系のQueryに関するQueryNodeProcessorを有効化することができます。
対象のフィールドは、priceに絞っています。

PointsConfigには、NumberFormatのインスタンスと、どのNumber型であるかのClassクラスを渡す必要があります。NumberFormatは、
検索条件値のパースに利用されます。

結果としては、パースすると無事PointRangeQueryが無事生成され、数値を含めた値でもQueryStringから検索可能なQueryが構築
できました、と。

        val topDocs = indexSearcher.search(query, 3, new Sort(new SortField("price", SortField.Type.INT, true)))

        val scoreDocs = topDocs.scoreDocs
        scoreDocs should have size (3)

        indexSearcher.doc(scoreDocs(0).doc).get("title") should be("検索エンジン自作入門 〜手を動かしながら見渡す検索の舞台裏")
        indexSearcher.doc(scoreDocs(1).doc).get("title") should be("検索エンジンはなぜ見つけるのか")
        indexSearcher.doc(scoreDocs(2).doc).get("title") should be("Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築")

        query should be(a[PointRangeQuery])

もうちょっと中身を

StandardQueryParserのコンストラクタを見ると、なにやらいろいろ設定されています。

  public StandardQueryParser() {
    super(new StandardQueryConfigHandler(), new StandardSyntaxParser(),
        new StandardQueryNodeProcessorPipeline(null),
        new StandardQueryTreeBuilder());
    setEnablePositionIncrements(true);
  }

https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java#L109-L114

StandardQueryConfigHandlerでは、設定のデフォルト値的なものが登録されているので、1度見ておいた方がよいかもしれません。

残りの3つは、SyntaxParser、QueryNodeProcessor、QueryBuilderの各フェーズ(というかインターフェース)の実装になります。

このうち、StandardSyntaxParserはJavaCCで生成されたものになります。中身を見ると、Classic Query ParserのJavaCC
ほぼ同じ設定が書かれています。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj

StandardQueryNodeProcessorPipelineには、利用可能なQueryNodeProcessorが登録されています。どのようなものが登録されているかは
コンストラクタに書かれているので(それだけです)、やっぱりカスタマイズの際には見ておくとよいと思います。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/StandardQueryNodeProcessorPipeline.java

そして、StandardQueryTreeBuilderには各種NodeをQueryに変換するQueryBuilderの実装が登録されています。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/StandardQueryTreeBuilder.java

とまあ、こういう構成です。

StandardSyntaxParserはJavaCCで作成されていますが、それ以外に関しては各種クラスの組み合わせでQueryNodeの変換、
QueryNodeからQueryへの変換を表現しているので、これらのクラスおよび登録されている各種実装を見ていくと
カスタマイズなどができるようになるのでないでしょうか。

例として、今回設定変更したPointRangeQueryNodeProcessorについて見てみましょう。PointRangeQueryNodeProcessorでは、
Queryの条件に指定されたフィールドの対となるFieldConfigに、PointsConfigが登録されていれば有効になるように
なっています。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/PointRangeQueryNodeProcessor.java#L73-L75

しかし、デフォルトではその設定はありません。そこで、今回はFieldConfigListenerでPointsConfigを追加するように
設定しました。

PointRangeQueryNodeProcessorの説明を見ると、以下のようになっています。

This processor is used to convert TermRangeQueryNodes to PointRangeQueryNodes. It looks for StandardQueryConfigHandler.ConfigurationKeys.POINTS_CONFIG set in the FieldConfig of every TermRangeQueryNode found. If StandardQueryConfigHandler.ConfigurationKeys.POINTS_CONFIG is found, it considers that TermRangeQueryNode to be a numeric range query and convert it to PointRangeQueryNode.

https://lucene.apache.org/core/6_5_1/queryparser/org/apache/lucene/queryparser/flexible/standard/processors/PointRangeQueryNodeProcessor.html

TermRangeQueryNodesをPointRangeQueryNodesに変換しますよ、と。あとPointsConfigを設定してね、と。

ただ、具体的な設定方法はこれだとよくわからないので、関連するクラスのAPIドキュメントや実装を漁る感じになりそうですね。

FieldConfigListenerでハマったこと

追記
StandardQueryParser#setPointsConfigMapを使ったことで、この話題は不要になった気もしますが、一応残しておきます。

最初、設定を変えるのにFieldConfigListenerを使うという発想がありませんでした。

なぜなら、FieldConfigにはsetメソッドがあるからです。よって、最初は思わずこう考えたのですが…

        queryParser
          .getQueryConfigHandler
          .getFieldConfig("price").set(ConfigurationKeys.POINTS_CONFIG,
                          new PointsConfig(new DecimalFormat("###"), classOf[Integer]))  

これだとうまくいきません。

StandardQueryConfigHolderの親クラスであるQueryConfigHandlerのgetFieldConfigメソッドを見ると、FieldConfigは呼び出しする度に
新規に作成する実装になっているからです。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/config/QueryConfigHandler.java#L57-L66

なので、毎回設定を行う必要があります。ここで使われるのがFieldConfigListenerというわけです。

追記
だったのですが、FieldConfig#get時にConfigurationKeyに関連付けたMapの内容を見るようなので、Listenerでなくてもよくなりました、と。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/config/AbstractQueryConfig.java#L57

StandardQueryParserにAnalyzerを設定しなかった場合は?

先ほどの例では、StandardQueryParserのコンストラクタにAnalyzerを設定しました。

ですが、Analyzerを引数に取らないコンストラクタもあります。Classic Query ParserがAnalyzerを要求するので、これはけっこう
驚いて気になって試してみました。

結果は、このように。

  test("standard query parser, non analyzer") {
    val query = new StandardQueryParser().parse("title: 全文検索 AND title: 入門 AND title:SPRING", "isbn")

    query should be(a[BooleanQuery])
    query.toString should be("+title:全文検索 +title:入門 +title:SPRING")
  }

なにかデフォルトのAnalyzerが動くわけでもなく、設定した条件の値がそのままQueryに反映されるようになっています。

これ、どういうことかというとAnalyzerが設定されていない場合は、AnalyzerQueryNodeProcessorを使用したAnalyzerを使う処理が
飛ばされるからです。
https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.5.1/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/AnalyzerQueryNodeProcessor.java

結果、設定した値が特にAnalyzeされずにQueryに反映される、と…。

こんな感じで、気になる挙動は各種構成要素の実装だったりを見ていくことになるのではないかと思います。

まとめ

Apache LueceneのFlexible Query Parserを、Classic Query Parserと対比しつつ、中身を少し覗きつつ試してみました。

長らくClassic Query Parserを使っていましたが、Flexible Query Parserも扱えそうな感じなので、今後はこちらに
切り替えていこうかなと思います。

単に面倒にしていただけなのですが…こちらの方がカスタマイズだったり設定だったりする余地がありそうですからね。

今回作成したコードは、こちらに置いています。
https://github.com/kazuhira-r/lucene-examples/tree/master/lucene-flexible-query-parser