CLOVER🍀

That was when it all began.

Apache Geodeで、Apache Luceneのインデックスとクエリを使う

Apache Geodeの1.0.0-incubating.M2で、Apache Luceneへの対応モジュールがひっそりとリリースされていました。

特にこちらや

Announcing Apache Geode Milestone Releases 1.0.0-incubating M1 & M2 – Seeking Testers : Apache Geode

Release Notesには載っていなさそうな感じですが。

Release Notes - ASF JIRA

ほとんど情報が出ていませんし、ドキュメント上にもまだ存在していないので、使う場合にはテストコードと実装のみが頼りなのですが、Wikiにはコンセプトが載っています。

Text Search With Lucene - Geode - Apache Software Foundation

Apache Geodeの、Apache Luceneへの対応について

Wikiにも書いていますが、Apache Geodeのユーザーに対して、Luceneのテキスト検索の能力と高可用性を持ったインメモリなインデックスを与えるための機能です。

キーポイントは、こちら。

  • 単一のインデックスを複数のRegionに保存することや、Region間のJOINはサポートしない
  • 単一のRegionにヘテロなオブジェクトを格納することはサポートする
  • トップレベルのフィールド、ネストされたオブジェクトはサポートするが、ネストされたコレクションはサポートしない
  • インデックス定義はデータを格納する前に作成する必要がある
  • ページネーションをサポートする

また、実装の詳細としては、こんなことが書かれています。

  • Luceneのインデックスを、ディスクの代わりにメモリに保存する
  • インデックスの保存には、ファイルシステムの代わりにApache Geodeの提供するRegionDirectoryを使用する
  • これにより、インデキシング時にApache Geodeレプリケーションおよびシャーディングの利点を得ることができる
  • LuceneIndexというオブジェクトが各インデックスに作成され、インデックスに関する属性(フィールド、AEQ(AsyncEventQueue) Listener、RegionDirectoryの配列)を管理する
  • もしユーザーのデータを保存するRegionがPartitioned Regionだった場合、LuceneIndexもPartitioned Regionとなる
  • データを保持する各Bucketは、インデックス管理のためのファイルシステムを持ち、RegionDirectoryを保持している
  • インデックスを管理するRegionには、FileRegionとChunkRegionの2つがある
  • FileRegionは、インデックスファイルに関するメタデータを保持する
  • ChunkRegionは、インデックスファイルの実データをチャンクとして保持する
  • インデックスへの反映は、非同期(AsyncEventQueue)に行われる

今のところ、データを持つRegion以外に、2つRegionが必要みたいですね。Partitioned RegionとReplicated Regionをサポートするみたいですが、1.0.0-incubating.M2時点ではPartitioned Regionのみが動作します。

この複数のRegionを使用するところは、すでにLuceneディレクトリを実装しているInfinispan(およびHibernate Search+Infinispan)と非常に近いです。

Infinispanの場合、もうひとつ必要でそれはロック用になります。Apache Geodeの場合はまだロックについてはTODOとして残っていました。将来的には増えるのかも?

なお、Walkthrough creating index in Geode regionの部分にも書かれているのですが、FileRegionとChunkRegionはデータを持つRegionと同じ属性である必要があるようで、最初ここに気付かずハマっていました…。

FileRegion and ChunkRegion use the same region attributes as dataregion. In partitioned region case, the FileRegion and ChunkRegion will be under the same parent region, i.e. /root in this example. In replicated region case, the index regions will be root regions all the time.

https://cwiki.apache.org/confluence/display/GEODE/Text+Search+With+Lucene

このあたりは、実コードで。

とまあ書いていってみましたが、現時点では思いっきり開発途中な雰囲気満載なので実際に使えるようになるのはもっと後になるのかなとは思いますが、今回はイントロ的な部分を扱う目的で取り上げてみます。

準備

では、Apache GeodeApache Luceneのインデックスを使ってみましょう。

まずはMaven依存関係から。

        <dependency>
            <groupId>org.apache.geode</groupId>
            <artifactId>geode-lucene</artifactId>
            <version>1.0.0-incubating.M2</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.4.1</version>
            <scope>test</scope>
        </dependency>

利用するApache Geodeバージョンは、1.0.0-incubating.M2とします。

「geode-lucene」モジュールがあれば、今回の目的としてはOKです。geode-coreは、geode-luceneの依存関係で付いてきます。またJUnitとAssertJをテストコード用に追加しています。

Entity

Luceneのインデックスを使うためには、Stringといった単一ものではなく、Entityっぽいものが必要なようなので書籍をお題に用意しておきます。
src/main/java/org/littlewings/geode/lucene/Book.java

package org.littlewings.geode.lucene;

import java.io.Serializable;

public class Book implements Serializable {
    private static final long serialVersionUID = 1L;

    private String isbn;

    private String title;

    private int price;

    public static Book create(String isbn, String title, int price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);
        return book;
    }

    public String getIsbn() {
        return isbn;
    }

    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }
}

特に変わったところはありません。

Apache GeodeApache Lucene

それでは、テストコードの雛形を作成します。
src/test/java/org/littlewings/geode/lucene/LuceneTest.java

package org.littlewings.geode.lucene;

import java.util.Arrays;
import java.util.List;

import com.gemstone.gemfire.cache.Cache;
import com.gemstone.gemfire.cache.CacheFactory;
import com.gemstone.gemfire.cache.Region;
import com.gemstone.gemfire.cache.RegionShortcut;
import com.gemstone.gemfire.cache.lucene.LuceneQuery;
import com.gemstone.gemfire.cache.lucene.LuceneQueryResults;
import com.gemstone.gemfire.cache.lucene.LuceneResultStruct;
import com.gemstone.gemfire.cache.lucene.LuceneService;
import com.gemstone.gemfire.cache.lucene.LuceneServiceProvider;
import org.apache.lucene.queryparser.classic.ParseException;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class LuceneTest {
    private static final Book SPRING_BOOT_BOOK = Book.create("978-4777518654", "はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発", 2700);
    private static final Book JAVA_EE_7_BOOK = Book.create("978-4798140926", "Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築", 4104);
    private static final Book ELASTICSEARCH_BOOK = Book.create("978-4048662024", "高速スケーラブル検索エンジン ElasticSearch Server", 3024);
    private static final List<Book> BOOKS = Arrays.asList(SPRING_BOOT_BOOK, JAVA_EE_7_BOOK, ELASTICSEARCH_BOOK);

    // ここに、テストを書く!
}

で、なんとなく書いてみたコードですが、最初は失敗。

    @Test
    public void failCreateIndexAfterCreateRegion() {
        try (Cache cache = new CacheFactory().create()) {
            Region<String, Book> region =
                    cache.<String, Book>createRegionFactory(RegionShortcut.PARTITION_REDUNDANT).create("bookRegion");

            LuceneService luceneService = LuceneServiceProvider.get(cache);

            assertThatThrownBy(() -> luceneService.createIndex("bookIndex", "/bookRegion", "isbn", "title", "price"))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessage("The lucene index must be created before region");
        }
    }

LuceneServiceProviderというクラスからLuceneServiceインターフェースの実装を得る必要があるのですが、先にも書いていた通り、先にRegionを作ってしまうと失敗します。先に、Luceneのインデックスを作成する必要があります。

これは、LuceneService#createIndexで行います。

では、気を取り直してもう1度。

    @Test
    public void gettingStarted() throws ParseException {
        try (Cache cache = new CacheFactory().create()) {
            LuceneService luceneService = LuceneServiceProvider.get(cache);
            luceneService.createIndex("bookIndex", "/bookRegion", "isbn", "title", "price");

            Region<String, Book> region =
                    // PARTITION_REDUNDANTは作成に失敗する…
                    cache.<String, Book>createRegionFactory(RegionShortcut.PARTITION).create("bookRegion");

            BOOKS.forEach(b -> region.put(b.getIsbn(), b));

            LuceneQuery<String, Book> query =
                    luceneService
                            .createLuceneQueryFactory()
                            .setResultLimit(200)  // デフォルト200
                            .setPageSize(0)  // デフォルト0
                            .create("bookIndex", "/bookRegion", "title:spring OR title:elasticsearch");

            LuceneQueryResults<String, Book> results = query.search();
            assertThat(results.hasNextPage()).isTrue();
            List<LuceneResultStruct<String, Book>> resultsList = results.getNextPage();

            assertThat(resultsList).hasSize(2);
            assertThat(resultsList.get(0).getValue().getTitle())
                    .isEqualTo("はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発");
            assertThat(resultsList.get(1).getValue().getTitle())
                    .isEqualTo("高速スケーラブル検索エンジン ElasticSearch Server");
        }
    }

今度は、成功するコードになりました。

順を追って見てみましょう。まず、Cache作成後にLuceneのインデックスを作成します。

        try (Cache cache = new CacheFactory().create()) {
            LuceneService luceneService = LuceneServiceProvider.get(cache);
            luceneService.createIndex("bookIndex", "/bookRegion", "isbn", "title", "price");

LuceneService#createIndexには、インデックス名、Regionへのパス、インデックス対象のフィールドを指定する必要があり、今回はEntityのフィールドを全部並べてみました。
※priceは、実は意味がなかったりしますが後述

Regionを作成。

            Region<String, Book> region =
                    // PARTITION_REDUNDANTは作成に失敗する…
                    cache.<String, Book>createRegionFactory(RegionShortcut.PARTITION).create("bookRegion");

PARTITION_REDUNDANTはうまくいかないとか書いていますが、それも後述。

とりあえず、データを登録。

            BOOKS.forEach(b -> region.put(b.getIsbn(), b));

データが入ったら、検索のためのクエリを作成します。

            LuceneQuery<String, Book> query =
                    luceneService
                            .createLuceneQueryFactory()
                            .setResultLimit(200)  // デフォルト200
                            .setPageSize(0)  // デフォルト0
                            .create("bookIndex", "/bookRegion", "title:spring OR title:elasticsearch");

Luceneのクエリは、LuceneService#createLuceneQueryFactoryでLuceneQueryFactoryを取得し、そこからcreateすることで作成します。

LuceneQueryFactory#createには、インデックス名、Regionへのパス、そしてLuceneのクエリを文字列として渡します。LuceneAPIは、表には出てきません。

このクエリは、LuceneのMultiFieldQueryParserでパースされます。要は、QueryParserが使われるということですね。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/StringQueryProvider.java#L69

なお、ここで使われるAnalyzerは、現時点ではStandardAnalyzer固定です。

そうそう、インデックス定義時もフィールド指定していませんね。現時点では、こちらもStandardAnalyzer固定です(developブランチでは、指定できそう)。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneServiceImpl.java#L103

正確に言うと、フィールド単位でも1.0.0-incubating.M2時点で指定できそうな感じになっていますが、LuceneServiceインターフェースの該当メソッドがdeprecatedになっていますし、なによりクエリの部分がStandardAnalyzer固定なので、変えても意味がありません。

また、クエリ作成時のオプションとしては、リミット(ResultLimit)、ページサイズ(PageSize)、およびProjection利用時に取得するフィールドが指定可能です。

それだけですが。

あとは、LuceneQuery#searchを呼び出すと、検索を行うことができます。

            LuceneQueryResults<String, Book> results = query.search();
            assertThat(results.hasNextPage()).isTrue();
            List<LuceneResultStruct<String, Book>> resultsList = results.getNextPage();

            assertThat(resultsList).hasSize(2);
            assertThat(resultsList.get(0).getValue().getTitle())
                    .isEqualTo("はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発");
            assertThat(resultsList.get(1).getValue().getTitle())
                    .isEqualTo("高速スケーラブル検索エンジン ElasticSearch Server");

Regionについて補足

では、いくつか飛ばした部分を書いていきましょう。

まず、先の例ではPARTITION_REDUNDANTでうまく動かないということを書きました。これは、実装の詳細について書いているところで載せていますが、Apache GeodeLuceneのインデックスを利用するには、FileRegionとChunkRegionが必要です。今回作成したRegionは、データそのものを保持するRegionだけです。

この2つがなかった場合は、暗黙的に作成します。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneIndexForPartitionedRegion.java#L88-L94

ここで、データ用のRegionだけバックアップ数をいじってしまうと、暗黙的に作成されるFileRegionおよびChunkRegionと属性が合わなくなるので怒られるのです…。

また、Partitioned RegionおよびReplicated Regionをサポートしているとも書きましたが、現在の実装だと確かにそのどちらかを利用するようになっています。データ保持用のRegionの種類に従う、といった方が正しいですが。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneServiceImpl.java#L180-L186

ですが、Replicated Regionの方はまだUnsupportedOperationExceptionを投げる状態なので、未実装です。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneIndexForReplicatedRegion.java

StandardAnalyzerについて補足

Luceneを知らないと、StandardAnalyzerってなに?みたいな状態になると思うので、簡単にテストコードで動作を載せておきます。

Luceneでは、テキストを単語に分割してインデックスを作成しますが、StandardAnalyzerは簡単に言うと英単語は空白で区切って小文字に、CJK文字はUni-gramにするAnalyzerです。

「はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発」という言葉を与えると、以下のように単語分割されます。
src/test/java/org/littlewings/geode/lucene/StandardAnalyzerTest.java

package org.littlewings.geode.lucene;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class StandardAnalyzerTest {
    @Test
    public void anayzerTest() throws IOException {
        Analyzer analyzer = new StandardAnalyzer();
        try (TokenStream tokenStream = analyzer.tokenStream("", "はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発")) {
            CharTermAttribute charAttribute = tokenStream.addAttribute(CharTermAttribute.class);
            List<String> tokens = new ArrayList<>();

            tokenStream.reset();

            while (tokenStream.incrementToken()) {
                tokens.add(charAttribute.toString());
            }

            tokenStream.end();

            assertThat(tokens)
                    .containsExactly(
                            "は", "じ", "め", "て", "の",
                            "spring", "boot",
                            "spring", "framework",
                            "で",
                            "簡", "単",
                            "java", "アプリ",
                            "開", "発"
                    );
        }
    }
}

今回はLuceneメインではないのであまり詳細は書きませんが、日本語を相手にするとちょっと検索ノイズが多くなるかな、と思います。

数字のフィールドについて

先ほどの例で、「priceフィールドは意味がない」と書きましたが、こちらを実際に試してみましょう。

    @Test
    public void usingNumeric() throws ParseException {
        try (Cache cache = new CacheFactory().create()) {
            LuceneService luceneService = LuceneServiceProvider.get(cache);
            luceneService.createIndex("bookIndex", "/bookRegion", "isbn", "title", "price");

            Region<String, Book> region =
                    cache.<String, Book>createRegionFactory(RegionShortcut.PARTITION).create("bookRegion");

            BOOKS.forEach(b -> region.put(b.getIsbn(), b));

            LuceneQuery<String, Book> query =
                    luceneService
                            .createLuceneQueryFactory()
                            .setResultLimit(200)  // デフォルト200
                            .setPageSize(0)  // デフォルト0
                            .create("bookIndex", "/bookRegion", "price: [3000 TO 4000]");

            LuceneQueryResults<String, Book> results = query.search();
            assertThat(results.hasNextPage()).isFalse();
            assertThat(results.getNextPage()).isNull();
        }
    }

priceに対して範囲のクエリを投げているのですが、ヒット件数が0になります。

            LuceneQuery<String, Book> query =
                    luceneService
                            .createLuceneQueryFactory()
                            .setResultLimit(200)  // デフォルト200
                            .setPageSize(0)  // デフォルト0
                            .create("bookIndex", "/bookRegion", "price: [3000 TO 4000]");

これは、通常Luceneで数値を相手にする時は数値用のクエリを使う必要があり、QueryParserだと考慮されないからです…。

現時点では、Stringのフィールド専用ということになっちゃいますね。

Cache XMLで、事前にLuceneのインデックスを定義する

最後に、Cache XMLLuceneのインデックスを定義する方法を載せて、終わりにしたいと思います。

RegionやLuceneのクエリを利用する側のコードは、先ほどまでとそう変わりません。

    @Test
    public void preDefinedLuceneIndex() throws ParseException {
        try (Cache cache = new CacheFactory().set("cache-xml-file", "lucene-index-cache.xml").create()) {
            Region<String, Book> region = cache.getRegion("bookRegion");

            BOOKS.forEach(b -> region.put(b.getIsbn(), b));

            LuceneService luceneService = LuceneServiceProvider.get(cache);
            LuceneQuery<String, Book> query =
                    luceneService.
                            createLuceneQueryFactory()
                            .create("bookIndex", "/bookRegion", "title:spring OR title:elasticsearch");

            LuceneQueryResults<String, Book> results = query.search();
            assertThat(results.hasNextPage()).isTrue();
            List<LuceneResultStruct<String, Book>> resultsList = results.getNextPage();

            assertThat(resultsList).hasSize(2);
            assertThat(resultsList.get(0).getValue().getTitle())
                    .isEqualTo("はじめてのSpring Boot―「Spring Framework」で簡単Javaアプリ開発");
            assertThat(resultsList.get(1).getValue().getTitle())
                    .isEqualTo("高速スケーラブル検索エンジン ElasticSearch Server");
        }
    }

設定ファイルは、「lucene-index-cache.xml」としました。

その中身は、こちら。
src/test/resources/lucene-index-cache.xml

<?xml version="1.0" encoding="UTF-8"?>
<cache
        xmlns="http://geode.apache.org/schema/cache"
        xmlns:lucene="http://geode.apache.org/schema/lucene"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://geode.apache.org/schema/cache
        http://geode.apache.org/schema/cache/cache-1.0.xsd
        http://geode.apache.org/schema/lucene
        http://geode.apache.org/schema/lucene/lucene-1.0.xsd"
        version="1.0">

    <region name="bookIndex#_bookRegion.files" refid="PARTITION_REDUNDANT"/>
    <region name="bookIndex#_bookRegion.chunks" refid="PARTITION_REDUNDANT"/>

    <region name="bookRegion" refid="PARTITION_REDUNDANT">
        <lucene:index name="bookIndex">
            <lucene:field name="isbn"/>
            <lucene:field name="title"/>
        </lucene:index>
    </region>
</cache>

XML Schemaの指定が、Apache Geodeの1.0?みたいな感じになっています。

Wikiを見ていると、GemFireの9.0指定みたいな感じだったのですが、
https://cwiki.apache.org/confluence/display/GEODE/Text+Search+With+Lucene

テストコードを見ていると、Geode 1.0?みたいになっていたので、今回はこちらに従いました。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/test/resources/com/gemstone/gemfire/cache/lucene/internal/xml/LuceneIndexXmlParserIntegrationJUnitTest.createIndex.cache.xml#L19-L27

で、今回はJavaコードよりもちょっと進めています。

まず、FileRegionとChunkRegionを明示的に定義しました。

    <region name="bookIndex#_bookRegion.files" refid="PARTITION_REDUNDANT"/>
    <region name="bookIndex#_bookRegion.chunks" refid="PARTITION_REDUNDANT"/>

この2つを定義する場合は、データ用のRegionよりも前の位置で定義する必要があります。

Luceneのインデックスは、データ用のRegionの子要素として定義します。priceはもう外してしまいました。

    <region name="bookRegion" refid="PARTITION_REDUNDANT">
        <lucene:index name="bookIndex">
            <lucene:field name="isbn"/>
            <lucene:field name="title"/>
        </lucene:index>
    </region>

今回は、FileRegionとChunkRegionを明示したので、定義が合わせられているのでPARTITION_REDUNDANTが利用可能になっています。

なお、FileRegionとChunkRegionの名前はデータ用のRegionの名前とインデックス名から自動的に決まるようになっています。
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneIndexForPartitionedRegion.java#L85
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneIndexForPartitionedRegion.java#L97
https://github.com/apache/incubator-geode/blob/rel/v1.0.0-incubating.M2/geode-lucene/src/main/java/com/gemstone/gemfire/cache/lucene/internal/LuceneServiceImpl.java#L93-L99

具体的に言うと、「インデックス名#_データ用Region名.files」および「インデックス名#_データ用Region名.chunks」です。そんな感じの名前になっていますね?

    <region name="bookIndex#_bookRegion.files" refid="PARTITION_REDUNDANT"/>
    <region name="bookIndex#_bookRegion.chunks" refid="PARTITION_REDUNDANT"/>

気になるところ

パッと、こんなところ。

  • Analyzerに自由度がない(インデックス定義の方は、次のバージョンくらいにはできそう?)
  • ソートを指定する方法がない(デフォルトっぽいので、スコア順?)
  • QueryParserをカスタマイズできない
  • IndexWriterConfigを触れない?(Luceneのインデックスとしての設定ができない)

ソースコード上もいろいろTODOが見えるので、まだまだ開発途中なようです。

まとめ

まだ出てきたばかりですが、Apache GeodeApache Luceneのインデックスおよびクエリを利用した機能を見てみました。

できないこともまだまだありますしドキュメントもない状態ですが、個人的には期待したい機能なので今後もちょっと見ておこうと思います。

インデックスの管理に複数のRegionを使っているところを見て、やっぱりそういう方向になるのだなーと思いました。