CLOVER🍀

That was when it all began.

Apache Solr 5.xでFunction Query

Apache Solrを使っていて、ちょっと独自ソートをしたいみたいなことがありまして。

通常は価格みたいなフィールドでソートするんですけど、ある期間だけ別のフィールドでソートする、みたいな。キャンペーン的なやつですね。

で、こういうことをしたい場合、Solrだとどうするのかなーと調べた結果、このあたりにたどり着きました。

solr で独自基準ソート(function query) - LIFULL Creators Blog

solr で独自基準ソート(function query plugin) - LIFULL Creators Blog

solr で独自基準ソート(search component plugin 前編) - LIFULL Creators Blog

それぞれ、特徴があるのですが、自分がやりたいことをするにはFunction Queryを自作した方がいいのかなと思いまして。参照エントリだと、「solr で独自基準ソート(function query plugin)」と同じですね。

そもそも、Function Queryとは?

Function Query
Function Queries | Apache Solr Reference Guide 6.6

検索のスコアやソートを調整することができ、クエリやソート時に関数呼び出しのような記述を行って呼び出す仕組みです。

お題

というわけで、キャンペーンをお題として独自ソートを行うためのFunction Queryを実装してみます。

テーマは書籍で、以下の条件で。

  • 書籍は通常価格とキャンペーン価格を持つ
  • 普段は通常価格を使うが、キャンペーン期間の範囲であればキャンペーン価格を使う
  • 現在の時刻は、関数の引数としてもらう

こんなことができる、Function Queryを実装します。

準備

以下のようなpom.xmlを作成します。
pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.littlewings</groupId>
    <artifactId>solr-my-function-query</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>

    <build>
        <finalName>solr-my-function-query</finalName>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.apache.solr</groupId>
            <artifactId>solr-core</artifactId>
            <version>5.4.0</version>
        </dependency>
    </dependencies>
</project>

参考エントリではLuceneがあればよさそうですが、少なくともSolr 5系ではSolrへの依存関係が必要になります。

スキーマ定義

Solrへのスキーマ定義は、以下のようにします(フィールドタイプの定義は端折ります)。

    <field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" />
    <field name="_version_" type="long" indexed="true" stored="true"/>
    <field name="_root_" type="string" indexed="true" stored="false"/>
    <field name="_text_" type="text_general" indexed="true" stored="false" multiValued="true"/>

    <field name="name" type="text_ja" indexed="true" stored="true"/>
    <field name="price" type="tint" indexed="true" stored="true"/>
    <field name="campaign_price" type="tint" indexed="true" stored="true"/>
    <field name="start_date" type="string" indexed="true" stored="false"/>
    <field name="end_date" type="string" indexed="true" stored="false"/>

コア名は「mycore」とします。

Function Query(ValueSource/ValueSourceParserの実装)

Function Queryを実装するには、ValueSourceとValueSourceParserを実装する必要があるようです。

まずは、ValueSourceから。
src/main/java/org/littlewings/solr/PriceValueSource.java

package org.littlewings.solr;

import java.io.IOException;
import java.util.Map;
import java.util.Objects;

import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.queries.function.FunctionValues;
import org.apache.lucene.queries.function.ValueSource;
import org.apache.lucene.queries.function.docvalues.IntDocValues;

public class PriceValueSource extends ValueSource {
    protected ValueSource normalPrice;
    protected ValueSource campaignPrice;
    protected ValueSource startDate;
    protected ValueSource endDate;
    protected String now;

    public PriceValueSource(ValueSource normalPrice, ValueSource campaignPrice, ValueSource startDate, ValueSource endDate, String now) {
        this.normalPrice = normalPrice;
        this.campaignPrice = campaignPrice;
        this.startDate = startDate;
        this.endDate = endDate;
        this.now = now;
    }

    @Override
    public FunctionValues getValues(Map context, LeafReaderContext readerContext) throws IOException {
        FunctionValues normalPriceVals = normalPrice.getValues(context, readerContext);
        FunctionValues campaignPriceVals = campaignPrice.getValues(context, readerContext);
        FunctionValues startDateVals = startDate.getValues(context, readerContext);
        FunctionValues endDateVals = endDate.getValues(context, readerContext);

        return new IntDocValues(this) {
            @Override
            public int intVal(int doc) {
                if (startDateVals.exists(doc) && startDateVals.strVal(doc).compareTo(now) <= 0 &&
                        endDateVals.exists(doc) && endDateVals.strVal(doc).compareTo(now) >= 0) {
                    return campaignPriceVals.intVal(doc);
                } else {
                    return normalPriceVals.intVal(doc);
                }
            }
        };
    }

    @Override
    public boolean equals(Object o) {
        if (PriceValueSource.class.equals(o.getClass())) {
            PriceValueSource other = (PriceValueSource) o;
            return Objects.equals(normalPrice, other.normalPrice) &&
                    Objects.equals(campaignPrice, other.campaignPrice) &&
                    Objects.equals(startDate, other.startDate) &&
                    Objects.equals(endDate, other.endDate) &&
                    Objects.equals(now, other.now);
        } else {
            return false;
        }
    }

    @Override
    public int hashCode() {
        return Objects.hash(normalPrice, campaignPrice, startDate, endDate, now);
    }

    @Override
    public String description() {
        return PriceValueSource.class.getSimpleName();
    }
}

クラスのフィールド定義はこんな感じですが、

    protected ValueSource normalPrice;
    protected ValueSource campaignPrice;
    protected ValueSource startDate;
    protected ValueSource endDate;
    protected String now;

ValueSourceなフィールドは、Solrのフィールド値そのものを参照するイメージです。Stringやintのフィールドは、関数呼び出し時のリテラル値を持つ感じですね。

ValueSourceの実装は、ほとんど似通ったものになるような気がしますが、そのうちgetValuesメソッド内に主な処理を実装することになります。

    @Override
    public FunctionValues getValues(Map context, LeafReaderContext readerContext) throws IOException {
        FunctionValues normalPriceVals = normalPrice.getValues(context, readerContext);
        FunctionValues campaignPriceVals = campaignPrice.getValues(context, readerContext);
        FunctionValues startDateVals = startDate.getValues(context, readerContext);
        FunctionValues endDateVals = endDate.getValues(context, readerContext);

        return new IntDocValues(this) {
            @Override
            public int intVal(int doc) {
                if (startDateVals.exists(doc) && startDateVals.strVal(doc).compareTo(now) <= 0 &&
                        endDateVals.exists(doc) && endDateVals.strVal(doc).compareTo(now) >= 0) {
                    return campaignPriceVals.intVal(doc);
                } else {
                    return normalPriceVals.intVal(doc);
                }
            }
        };
    }

このメソッドの戻り値はFunctionValuesなのですが、今回はintを返すためIntDocValuesを返すように実装しています。

で、こちらに対応するValueSourceParserの実装。
src/main/java/org/littlewings/solr/PriceValueSourceParser.java

package org.littlewings.solr;

import org.apache.lucene.queries.function.ValueSource;
import org.apache.solr.search.FunctionQParser;
import org.apache.solr.search.SyntaxError;
import org.apache.solr.search.ValueSourceParser;

public class PriceValueSourceParser extends ValueSourceParser {
    @Override
    public ValueSource parse(FunctionQParser fp) throws SyntaxError {
        ValueSource normalPrice = fp.parseValueSource();
        ValueSource campaignPrice = fp.parseValueSource();
        ValueSource startDate = fp.parseValueSource();
        ValueSource endDate = fp.parseValueSource();
        String now = fp.parseArg();

        return new PriceValueSource(normalPrice, campaignPrice, startDate, endDate, now);
    }
}

ValueSourceParserは、Solrの提供になります(以前はLuceneのパッケージにいたようです)。FunctionQParserから、ValueSourceを取得する場合はparserValueSourceを、リテラルを取得する場合はparseArgやparseIntなどを使用します。

ビルドと配置

では、作成したFunction Queryをビルドします。

$ mvn package

今回のpom.xmlの定義だと、「solr-my-function-query.jar」というJARファイルができるので、これをSolrのコアの適当なディレクトリに配置します。

今回は、コアのディレクトリ配下にlibディレクトリを作成して配置することにしました。

$ mkdir /var/solr/data/mycore/lib
$ cp /path/to/solr-my-function-query.jar /var/solr/data/mycore/lib/lib/solr-my-function-query.jar

そうしたら、solrconfig.xmlに以下の記述を加えます。

  <lib path="../lib/solr-my-function-query.jar" />

関数の定義。

  <valueSourceParser name="campaign_price_func"
                     class="org.littlewings.solr.PriceValueSourceParser" />

どのあたりに書くのかは、solrconfig.xml内にサンプルがあるので、その近くに書けばよいでしょう。

ここで関数名も決まります。今回は「campaign_price_func」としました。

ここまで行ったら、Solrのコアをリロードします。

データ登録

検索に使うデータを登録します。使用したデータは、こちら。
book.json

[
    {
        "id": "1",
        "name": "Book-A",
        "price": 5000,
        "campaign_price": 1000,
        "start_date": "2015-12-24 12:00:00",
        "end_date": "2015-12-24 18:00:00"
    },
    {
        "id": "2",
        "name": "Book-B",
        "price": 4000,
        "campaign_price": 2000,
        "start_date": "2015-12-24 12:00:00",
        "end_date": "2015-12-24 18:00:00"
    },
    {
        "id": "3",
        "name": "Book-C",
        "price": 3000,
        "campaign_price": 3000,
        "start_date": "2015-12-24 12:00:00",
        "end_date": "2015-12-24 18:00:00"
    },
    {
        "id": "4",
        "name": "Book-D",
        "price": 2500,
        "campaign_price": 8000,
        "start_date": "2015-12-01 12:00:00",
        "end_date": "2015-12-01 18:00:00"
    },
    {
        "id": "5",
        "name": "Book-F",
        "price": 2300,
        "campaign_price": 9000,
        "start_date": "2015-12-01 12:00:00",
        "end_date": "2015-12-01 18:00:00"
    }
]

Book-DとFだけ、微妙に期間が違います。

登録。

$ curl -XPOST -H 'Content-Type: application/json' 'http://localhost:8983/solr/mycore/update?commit=true' -d @book.json

検索してみる

それでは、検索してみましょう。検索クエリを全部書くと長いので、検索条件とソート条件を書きます。

まずはソート条件。Book-A、B、Cの期間に収まる現在時刻でdesc。

campaign_price_func(price, campaign_price, start_date, end_date, '2015-12-24 16:00:00') desc

検索クエリはこちら。

{!func}campaign_price_func(price, campaign_price, start_date, end_date, '2015-12-24 16:00:00')

も加えておきます。

結果はこちら。

  "response":{"numFound":5,"start":0,"docs":[
      {
        "id":"3",
        "name":"Book-C",
        "price":3000,
        "campaign_price":3000,
        "start_date":"2015-12-24 12:00:00",
        "end_date":"2015-12-24 18:00:00",
        "_version_":1520995281691213824},
      {
        "id":"4",
        "name":"Book-D",
        "price":2500,
        "campaign_price":8000,
        "start_date":"2015-12-01 12:00:00",
        "end_date":"2015-12-01 18:00:00",
        "_version_":1520995281691213825},
      {
        "id":"5",
        "name":"Book-F",
        "price":2300,
        "campaign_price":9000,
        "start_date":"2015-12-01 12:00:00",
        "end_date":"2015-12-01 18:00:00",
        "_version_":1520995281691213826},
      {
        "id":"2",
        "name":"Book-B",
        "price":4000,
        "campaign_price":2000,
        "start_date":"2015-12-24 12:00:00",
        "end_date":"2015-12-24 18:00:00",
        "_version_":1520995281690165248},
      {
        "id":"1",
        "name":"Book-A",
        "price":5000,
        "campaign_price":1000,
        "start_date":"2015-12-24 12:00:00",
        "end_date":"2015-12-24 18:00:00",
        "_version_":1520995281688068096}]
  }

Book-DとF以外は、「campaign_price」で降順になっています。

今度は少し期間をずらして

campaign_price_func(price, campaign_price, start_date, end_date, '2015-12-10 16:00:00') desc

としてみます。

クエリはこちら。

{!func}campaign_price_func(price, campaign_price, start_date, end_date, '2015-12-10 16:00:00')

結果。

  "response":{"numFound":5,"start":0,"docs":[
      {
        "id":"1",
        "name":"Book-A",
        "price":5000,
        "campaign_price":1000,
        "start_date":"2015-12-24 12:00:00",
        "end_date":"2015-12-24 18:00:00",
        "_version_":1520995281688068096},
      {
        "id":"2",
        "name":"Book-B",
        "price":4000,
        "campaign_price":2000,
        "start_date":"2015-12-24 12:00:00",
        "end_date":"2015-12-24 18:00:00",
        "_version_":1520995281690165248},
      {
        "id":"3",
        "name":"Book-C",
        "price":3000,
        "campaign_price":3000,
        "start_date":"2015-12-24 12:00:00",
        "end_date":"2015-12-24 18:00:00",
        "_version_":1520995281691213824},
      {
        "id":"4",
        "name":"Book-D",
        "price":2500,
        "campaign_price":8000,
        "start_date":"2015-12-01 12:00:00",
        "end_date":"2015-12-01 18:00:00",
        "_version_":1520995281691213825},
      {
        "id":"5",
        "name":"Book-F",
        "price":2300,
        "campaign_price":9000,
        "start_date":"2015-12-01 12:00:00",
        "end_date":"2015-12-01 18:00:00",
        "_version_":1520995281691213826}]
  }

通常価格でのソートになりましたね。

できましたー。

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

ハマったこと

実装は簡単なのですが、呼び出しと結果確認にはけっこうハマりました。

何にハマったかって、ソート結果が変わらない。

正確に言うと、1回目に発行したクエリの条件が変わらなければ、ソートの条件(今回でいえば「現在時刻」の部分)を変更しても、結果は変わらないみたいです。

デバッグしてみた感じだと、キャッシュされて関数呼び出しが行われないみたいですね…。1回目はうまくいくんですけど、条件を変えた時にうまくいかなくてかなり悩みました。

あと、最初関数呼び出しに渡すフィールドの値をたまたまインデックスに保存していなくて、これをやっていないとソートしようとした時に失敗します。が、メッセージが

  "error": {
    "msg": "Can't determine a Sort Order (asc or desc) in sort spec 'campaign_price_func(normal_price, campaign_price, start_date, end_date, '2015-12-24 15:00:00') desc', pos=33",
    "code": 400
  }

くらいなので、原因になかなか気付きませんでした。

少なくとも、関数呼び出しに使用するフィールドはインデックス保存対象にはしておきましょうね、と。当たり前といえば当たり前ですが…。

Function Queryを検索条件に使用する

最初にFunction Queryはスコアとソートに使えるみたいだとは書きましたが、一応検索条件としても使えるようです。

が、なんかうまく動かせていないような…また今度確認してみます。

{!frange l=1000 r=1500}campaign_price_func(price, campaign_price, start_date, end_date, '2015-12-24 16:00:00')

http://blog.booklive.jp/?p=653