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を実装します。
準備
<?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')