SolrのJSON Facetは必ずしも正確なカウント数を返さない

[Solr Advent Calendar 2016](http://qiita.com/advent-calendar/2016/solr) の 22 日目です。
といっても既にメリークリスマスです。すみませんorz
JSON Facet 使用上の注意点についてゆるふわに書きます。

## 結論から
- JSON Facet は必ずしも正確なカウント数を返す保証はありません。
- 集約処理による値 （count, sum, avg など) でバケットをソートする場合に、不正確になる場合があります。
- 正確さが求められる場合は、必要に応じて **overrequest** を利用してみましょう。

## JSON Facet とは
Solr-5.x から導入された機能です。リクエストが JSON 形式であり、それまでの ```facet=true``` でリクエストする (Legacy) Facet よりも高速に動作します。なぜ高速に動作するのかについては後ほど少しだけ触れます。

本記事では詳細な使い方や速度比較については触れませんので、必要であれば以下を参考にしてください。

- [JSON Facet API](http://yonik.com/json-facet-api/)
- [Facet & Analytics Performance](http://yonik.com/facet-performance/)
- [Apache Solr 5.xでファセットを試す](http://d.hatena.ne.jp/Kazuhira/20151003/1443892724)

## 試しに使ってみる
寿司を例に JSON Facet を試してみます。
\* 寿司の情報は[スシロー](https://www.akindo-sushiro.co.jp/menu/)を参考にさせていただきました。

### 環境とデータ

- SolrCloud
- shard 数は 4
- collection 名は sushi
- 全件数は 344
- フィールドは name, price, kcal の 3 フィールド

| フィールド | 説明 | カーディナリティ |
|---|---|---|
| name | 寿司のネタ名 | - |
| price | 価格(円) | 低い [ 9 種類 ] |
| kcal | カロリー(kcal) | 高い [ 96 種類 ] |

カーディナリティは、そのフィールドに含まれる値の種類数のことです。
例えば price は、以下の 9 種類のデータしか含まれておらず、カーディナリティは低いと言えます。

```
100 / 130 / 150 / 180 / 280 / 350 / 380 / 480 / 980 円
```

以下は実際に入っているデータになります。

```
<result name="response" numFound="344" start="0" maxScore="1.0">
  <doc>
    <str name="name">生本ずわい蟹</str>
    <int name="price">180</int>
    <int name="kcal">39</int>
  </doc>
  <doc>
    <str name="name">まぐろ</str>
    <int name="price">100</int>
    <int name="kcal">80</int>
  </doc>
  <doc>
    <str name="name">サーモン</str>
    <int name="price">100</int>
    <int name="kcal">84</int>
  </doc>
  <doc>
    <str name="name">いか</str>
    <int name="price">100</int>
    <int name="kcal">68</int>
  </doc>

  ...

</result>
```

### JSON Facet で各価格の件数を取得する
以下のような JSON 形式のリクエストにより取得できます。

```
$ curl http://localhost:8983/solr/sushi/select -d 'q=*:*&rows=0&
json.facet={
  price:{
    type:terms,
    field:price
  }
}
'
```
```xml
<lst name="facets">
  <long name="count">344</long>
  <lst name="price">
    <arr name="buckets">
      <lst>
        <long name="val">100</long>    // 100 円のネタが
        <long name="count">214</long>  // 214 件
      </lst>
      <lst>
        <long name="val">180</long>
        <long name="count">54</long>
      </lst>
      <lst>
        <long name="val">280</long>
        <long name="count">42</long>
      </lst>
      <lst>
        <long name="val">150</long>
        <long name="count">16</long>
      </lst>
      <lst>
        <long name="val">350</long>
        <long name="count">8</long>
      </lst>
      <lst>
        <long name="val">980</long>
        <long name="count">4</long>
      </lst>
      <lst>
        <long name="val">130</long>
        <long name="count">2</long>
      </lst>
      <lst>
        <long name="val">380</long>
        <long name="count">2</long>
      </lst>
      <lst>
        <long name="val">480</long>
        <long name="count">2</long>
      </lst>
    </arr>
  </lst>
</lst>
```

## JSON Facet vs. Legacy Facet
もちろん Legacy Facet でも取得できます。
結果を比較してみましょう。

### カーディナリティが低い場合 (price)
フィールド price を対象とした場合です。

**JSON Facet**（再掲）

```
$ curl http://localhost:8983/solr/sushi/select -d 'q=*:*&rows=0&
json.facet={
  price:{
    type:terms,
    field:price
  }
}
'
```
```xml
<lst name="facets">
  <long name="count">344</long>
  <lst name="price">
    <arr name="buckets">
      <lst>
        <long name="val">100</long>    // 100 円のネタが
        <long name="count">214</long>  // 214 件
      </lst>
      <lst>
        <long name="val">180</long>
        <long name="count">54</long>
      </lst>
      <lst>
        <long name="val">280</long>
        <long name="count">42</long>
      </lst>
      <lst>
        <long name="val">150</long>
        <long name="count">16</long>
      </lst>
      <lst>
        <long name="val">350</long>
        <long name="count">8</long>
      </lst>
      <lst>
        <long name="val">980</long>
        <long name="count">4</long>
      </lst>
      <lst>
        <long name="val">130</long>
        <long name="count">2</long>
      </lst>
      <lst>
        <long name="val">380</long>
        <long name="count">2</long>
      </lst>
      <lst>
        <long name="val">480</long>
        <long name="count">2</long>
      </lst>
    </arr>
  </lst>
</lst>
```

**Legacy Facet**

```
$ curl http://localhost:8983/solr/sushi/select -d 'q=*:*&rows=0&
facet=true
&facet.field=price
```
```xml
<lst name="facet_counts">
  <lst name="facet_queries"/>
  <lst name="facet_fields">
    <lst name="price">
      <int name="100">214</int>  // 100 円のネタが 214 件
      <int name="180">54</int>
      <int name="280">42</int>
      <int name="150">16</int>
      <int name="350">8</int>
      <int name="980">4</int>
      <int name="130">2</int>
      <int name="380">2</int>
      <int name="480">2</int>
    </lst>
  </lst>
  <lst name="facet_ranges"/>
  <lst name="facet_intervals"/>
  <lst name="facet_heatmaps"/>
</lst>
```

多少 XML に差はありますが、返ってきている内容（件数と並び）は同じであることが分かります。

結果が同じであり、処理が高速であるということであれば、JSON Facet 一択と言えそうでが、**カーディナリティの高いフィールドに対しては JSON Facet の利用で注意しなければならないこと**があります。

### カーディナリティが高い場合 (kcal)
対象フィールドを kcal として比較してみましょう。
説明のため limit を利用して 5 バケットに絞っています。

**JSON Facet**

```
$ curl http://localhost:8983/solr/sushi/select -d 'q=*:*&rows=0&
json.facet={
  kcal:{
    type:terms,
    field:kcal,
    limit:5
  }
}
'
```
```xml
<lst name="facets">
  <long name="count">344</long>
  <lst name="kcal">
    <arr name="buckets">
      <lst>
        <long name="val">74</long>
        <long name="count">11</long>
      </lst>
      <lst>
        <long name="val">72</long>
        <long name="count">10</long>
      </lst>
      <lst>
        <long name="val">82</long>
        <long name="count">10</long>
      </lst>
      <lst>
        <long name="val">66</long>
        <long name="count">9</long>
      </lst>
      <lst>
        <long name="val">68</long>
        <long name="count">8</long>
      </lst>
    </arr>
  </lst>
</lst>
```

**Legacy Facet**

```
$ curl http://192.168.110.11:8901/solr/sushi/select -d 'q=*:*&rows=0&
facet=true
&facet.field=kcal
&facet.limit=5
```
```xml
<lst name="facet_counts">
  <lst name="facet_queries"/>
  <lst name="facet_fields">
    <lst name="kcal">
      <int name="66">12</int>
      <int name="74">12</int>
      <int name="68">10</int>
      <int name="72">10</int>
      <int name="82">10</int>
    </lst>
  </lst>
  <lst name="facet_ranges"/>
  <lst name="facet_intervals"/>
  <lst name="facet_heatmaps"/>
</lst>
```

結果に差が出ています。どちらの正しい値かというと Legacy Facet が正しい値となります。試しに ```kcal:66``` で検索してみると、12 件と返ってきますが、JSON Facet の結果 では 9 件であり、誤った値になっています。

```
$ curl http://localhost:8983/solr/sushi/select?q=kcal:66

...

# numFound が 12
<result name="response" numFound="12" start="0" maxScore="4.048882"/>
```

### なぜ JSON Facet で誤った値が返ったのか？

これは、Legacy Facet では refine 処理が行われるのに対して、**JSON Facet では refine 処理が行われない**ためです。
簡単に JSON Facet と Legacy Facet の処理の違いを確認してみましょう。
尚、今回の説明はバケットのソートを件数順 (count) としている。

**JSON Facet**

1. shard ごとに各バケットに含まれる件数をカウントする。
2. 上位 limit 数だけのバケットを各 shard から取得し、件数をマージする。
3. 件数の多い上位 limit 件のバケットをレスポンスとして返す。

この時、図から分かるように、
shard1 の kcal:74、shard2 の kcal:66, kcal:68 の件数は無視されてしまっている。そのため、最終的に件数にずれが生じる。

![JSON Facet - 処理フロー](https://qiita-image-store.s3.amazonaws.com/0/155697/32118c1e-8d96-2beb-371d-96edd5072019.png)

**Legacy Facet**

1. shard ごとに各バケットに含まれる件数をカウントする。
2. 上位 limit 数だけのバケットを各 shard から取得し、件数をマージする。
3. 件数の多い上位 limit 件のバケットを**特定**する。
4. **再度各 shard に問い合わせ、特定したバケットについてバケットに含まれる件数をカウントする。（refine 処理)**
5. バケットを各 shard から取得し件数をマージする。
6. レスポンスとして返す。

Legacy Facet は JSON Facet と異なり、「4」にて各 shard に再度問い合わせることで、件数の refine 処理を行っている。

![Legacy Facet - 処理フロー1](https://qiita-image-store.s3.amazonaws.com/0/155697/f79e4d10-6f32-91b1-bc3b-4a11234b89ff.png)

![Legacy Facet - 処理フロー2](https://qiita-image-store.s3.amazonaws.com/0/155697/e919e768-3e3f-f881-4a22-df18f10eac9e.png)

両者を比較すると、**JSON Facet には Legacy Facet で行っている refine 処理がないため、高速に処理を返すことができる反面、件数にずれが生じる**ことが分かる。

### カーディナリティとの絡み

**カーディナリティが低い場合**は、バケット数が少ない状態であるため、最終的に上位になるバケットが各 shard の上位 limit に含まれやすくなる。そのため、**件数を取りこぼす可能性が低くなる**。

**カーディナリティが高い場合**は、その逆で、最終的に上位になるバケットが各 shard の上位 limit に含まれづらくなる。よって、**件数を取りこぼす可能性が高くなる**。

## じゃあどうすれば？

方法はいくつかあります。

1. Legacy Facet
2. 必要な件数より大きい limit を指定する（```limit:-1``` で全件)
3. shard 数を増やす
4. **overrequest** を利用する

いろいろメリット・デメリットがありますが、ここでは「4」の方法で話を進めます。

### overrequest
JSON Facet の overrequest は Solr-6.3.0 から実装されているオプションです。

> Add "overrequest" parameter to JSON Facet API to control amount of overrequest on a distributed terms facet

http://lucene.apache.org/solr/news.html

これを利用することで、各 shard から返却されるバケット数を limit よりも over に request することが可能になり、考慮できていなかった下位のバケットの件数も考慮することができるようになります。

![JSON Facet - overrequest](https://qiita-image-store.s3.amazonaws.com/0/155697/0370e9e8-9ef5-2f2b-4c5d-e1069df6c447.png)

実は、以前から JSON Facet の処理の内部では overrequest を行なっていたのですが、デフォルト値が指定されており、外からコントロールできませんでした。
以下の ```freq.limit``` はリクエスト時の limit オプションで指定した値ですが、これに定数倍しているのが分かります。
[Solr-6.0.0 - FacetField.java#L582-L583](https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.0.0/solr/core/src/java/org/apache/solr/search/facet/FacetField.java#L582-L583)

```java
// add a modest amount of over-request if this is a shard request
int lim = freq.limit >= 0 ? (fcontext.isShard() ? (int)(freq.limit*1.1+4) : (int)freq.limit) : Integer.MAX_VALUE;
```

↓

Solr-6.3.0 からはリクエスト時に指定した overrequest オプションの値 (```freq.overrequest```) を limit の値（```effectiveLimit```） に加算できるようになっているのが分かります。これで最終的に各 shard から返すバケット数が limit + overrequest に増えることになります。
[Solr-6.3.0 - FacetFieldProcessor.java#L221-L225](https://github.com/apache/lucene-solr/blob/releases/lucene-solr/6.3.0/solr/core/src/java/org/apache/solr/search/facet/FacetFieldProcessor.java#L221-L225)

```java
if (freq.overrequest == -1) {  // ← overrequest を指定しないときのデフォルト値は -1
  effectiveLimit = (long) (effectiveLimit*1.1+4); // default: add 10% plus 4 (to overrequest for very small limits)
} else {
  effectiveLimit += freq.overrequest;  // ← 指定することでコントロールできる
}
```

### overrequest を試す

overrequest の値を指定して Legacy Facet と同じ結果を取得できるか確認しておきます。

```
$ curl http://localhost:8983/solr/sushi/select -d 'q=*:*&rows=0&
json.facet={
  kcal:{
    type:terms,
    field:kcal,
    limit:5,
    overrequest:31
  }
}
'
```
```xml
<lst name="facets">
  <long name="count">344</long>
  <lst name="kcal">
    <arr name="buckets">
      <lst>
        <long name="val">66</long>
        <long name="count">12</long>
      </lst>
      <lst>
        <long name="val">74</long>
        <long name="count">12</long>
      </lst>
      <lst>
        <long name="val">68</long>
        <long name="count">10</long>
      </lst>
      <lst>
        <long name="val">72</long>
        <long name="count">10</long>
      </lst>
      <lst>
        <long name="val">82</long>
        <long name="count">10</long>
      </lst>
    </arr>
  </lst>
</lst>
```

はい、同じになりました。overrequest の値に 31 を指定していますが、今回のケースでは limit + 31 のバケットを各 shard から返す必要があったようです。31 という値は、結果が同じになるまでインクリメントした結果です・・・。紹介しておいてなんですが、正直使いづr（ry

## まとめ

- JSON Facet は Legacy Facet よりも速くて使いやすいですが、用法用量を守りましょう。
- カーディナリティの高いフィールドでは overrequest が効くケースもあるかもしれません。

## 補足

overrequest には、各バケットの件数の正確性を高めるためだけでなく、上位に来るバケット自体の正確性を高めるという目的もあります。これは JSON Facet、Legacy Facet いずれにも当てはまるものです。余力があれば、いつか書こうと思います。