Solrのdismaxを使ってお手軽（？）レコメンド

# はじめに

* レコメンドといえば協調フィルタリングがパッと思い浮かびますが、傾向を表すために必要なデータが不足している場合などはルールベースのレコメンドをまず検討するかと思います。
* 今回はSolrを使ってルールベースのレコメンドをどうやって（比較的楽に）実現するかを検討してみました。
* Mahout+Solrでゴリゴリやるぜ。という記事ではありません。

# 環境

* Solr4.10.4

# インデクスの準備

* データはiTunes Media API、スキーマ定義はDynamicFieldで定義しインデクシングしました。インデクシング部分は本筋ではないので割愛します。
* 型の定義は、SolrのExamplesのCollection1と同様です
* trackIdをuniqueKeyとしています。*_tはtext_ja,*_lはlong,*_dtはdateです。
* データのサンプルをお見せします。

```
"response": {
    "numFound": 924,
    "start": 0,
    "docs": [
      {
        "trackName_t": "時計じかけのオレンジ(字幕版)",
        "artistName_t": "Stanley Kubrick",
        "trackViewUrl_t": "https://itunes.apple.com/jp/movie/shi-jijikakenoorenji-zi-mu/id397795868?uo=4",
        "primaryGenreName_t": "SF／ファンタジー",
        "longDescription_t": "(字幕版)喧嘩、盗み、歌、タップ・ダンス、暴力。山高帽とエドワード7世風のファッションに身を包んだ、反逆児アレックスには、独特な楽しみ方がある。それは他人の悲劇を楽しむ方法である。アンソニー・バージェスの小説を元に、異常なほど残忍なアレックスから洗脳され模範市民のアレックスへ、そして再び残忍な性格に戻っていく彼を、スタンリー・キューブリックが近未来バージョンの映画に仕上げた。忘れられないイメージ、飛び上がらせる旋律、アレックスとその仲間の魅惑的な言葉の数々。キューブリックは世にもショッキングな物語を映像化。",
        "releaseDate_dt": "1972-04-29T08:00:00Z",
        "trackId": "397795868",
        "collectionPrice_l": 3400,
        "trackTimeMillis_l": 8198206,
        "_version_": 1497410172472000500
      },
以下略
```

# DisMaxQuery

* Solrにはあるクエリで検索した結果を使って再検索することで類似の検索結果を出すMoreLikeThisコンポーネントがあります。単純にそれだけで良いのであればMoreLikeThisを使いましょう。（使い方はSolr本でどうぞ）
* DisMaxQueryは検索するパラメータに重み（boost)を付けることでSolr内部で算出されるScoreに影響を与え、より細かく検索結果の並びを制御することができます。
* DisMaxQueryの使い方をまず覚えないことには始まりませんので順番に試していきます。

## qfを使った全文検索

* タイトルとジャンルとディスクリプションを合わせて検索する場合、一番簡単な方法はあるフィールドにCopyFieldの定義をしてインデクシングしておくことです。
* textというフィールドにcopyFieldを使う場合のschema.xmlは以下のように定義します。

```
  <field name="text" type="text_ja" indexed="true" stored="false" />
  <copyField source="trackName_t" dest="text"/>
  <copyField source="primaryGenreName_t" dest="text"/>
  <copyField source="longDescription_t" dest="text"/>
```

* CopyFieldを利用せず"ミッション"という文字をトラック名、ジャンル名、ディスクリプションに対して検索する場合には以下のようなクエリになります。

```CopyFieldを利用しない場合の複数検索.query
/movie/select?q=(trackName_t:"ミッション" OR longDescription_t:"ミッション" OR primaryGenreName_t="ミッション")&fl=trackName_t+primaryGenreName_t+releaseDate_dt+score&wt=json&indent=true&debugQuery=true
```

* 上記のようにすることで３フィールドへの全文検索はできますが、タイトル（trackName）が合致したものを優先的に出すなどの制御ができません。そこでdismaxを使います。
* dismaxを使うには検索対象のフィールド名qfを設定し、検索したい用語をqに設定します。

``` dismaxを使った検索.query
/movie/select?q="ミッション"&fl=trackName_t%2CprimaryGenreName_t%2CreleaseDate_dt%2Cscore&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t
```

## boost queryを使った重み付け

* トム・クルーズが出ている映画の一覧を出してみます。

``` トム・クルーズの映画一覧.query
/movie/select?q="トム・クルーズ"&fl=trackName_t%2CprimaryGenreName_t%2CreleaseDate_dt%2Cscore&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t
```

* 結果5レコード表示されました。

``` トム・クルーズで検索した結果.query
  "response":{"numFound":5,"start":0,"maxScore":1.0218632,"docs":[
      {
        "trackName_t":"ミッション：インポッシブル／ゴースト・プロトコル (字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2011-12-16T08:00:00Z",
        "score":1.0218632},
      {
        "trackName_t":"ナイト&デイ（日本語吹替版）",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2010-10-09T07:00:00Z",
        "score":1.0218632},
      {
        "trackName_t":"アウトロー (字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2013-02-01T08:00:00Z",
        "score":0.89413035},
      {
        "trackName_t":"アイズ ワイド シャット(字幕版)",
        "primaryGenreName_t":"ドラマ",
        "releaseDate_dt":"1999-07-31T07:00:00Z",
        "score":0.89413035},
      {
        "trackName_t":"卒業白書(字幕版)",
        "primaryGenreName_t":"ドラマ",
        "releaseDate_dt":"1983-01-01T08:00:00Z",
        "score":0.89413035}]
  }}
```

* flは抽出するフィールド名をカンマ区切りで記載します。scoreを指定することでsolrで算出されたscoreを表示できます。

### 日付での重み付け

* 80年代の映画は上位に表示させてみます。公開日が80年代の映画のスコアを重み付け（ブースト）します。
* ある特定のカラムのある条件をブーストさせたい場合はbq(boostquery)を利用します。
* ここでは、公開日が1980年1月1日〜1989年12月31日のデータは5倍の重みをつけなさい。とSolrにお願いしています。
* 日付のboostqueryの書き方については[Working with Date](https://cwiki.apache.org/confluence/display/solr/Working+with+Dates)に詳しく載っています。例えば、2000年から現在日までのスコアをブーストする。なんてことも可能になります。

``` 80sをブースト.query
/movie/select?q="トム・クルーズ"&fl=trackName_t%2CprimaryGenreName_t%2CreleaseDate_dt%2Cscore&wt=json&indent=true&defType=dismax&qf=trackName_t+longDescription_t&debugQuery=true&bq=releaseDate_dt:%5B1980-01-01T00:00:00Z+TO+1989-12-31T00:00:00Z%5D^5
```

```80sをブースト結果.query
  "response":{"numFound":5,"start":0,"maxScore":1.234275,"docs":[
      {
        "trackName_t":"卒業白書(字幕版)",
        "primaryGenreName_t":"ドラマ",
        "releaseDate_dt":"1983-01-01T08:00:00Z",
        "score":1.234275},
      {
        "trackName_t":"ミッション：インポッシブル／ゴースト・プロトコル (字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2011-12-16T08:00:00Z",
        "score":1.0393851},
      {
        "trackName_t":"ナイト&デイ（日本語吹替版）",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2010-10-09T07:00:00Z",
        "score":1.0393851},
      {
        "trackName_t":"アウトロー (字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2013-02-01T08:00:00Z",
        "score":0.909462},
      {
        "trackName_t":"アイズ ワイド シャット(字幕版)",
        "primaryGenreName_t":"ドラマ",
        "releaseDate_dt":"1999-07-31T07:00:00Z",
        "score":0.909462}]
  },
```

* ちゃんと卒業白書が上に来ていますね。scoreも1.2となっており、0.89から大幅アップしているのがわかります。

## 複数項目をブースト

* 複数項目でブーストしたい場合は+で繋いであげるだけです。
* 80sが一番上位で、次にジャンルがドラマになっているものを次に表示させたい場合はこのように行います。

```80sが一番上位で、次にジャンルがドラマ.query
/movie/select?q="トム・クルーズ"&fl=trackName_t%2CprimaryGenreName_t%2CreleaseDate_dt%2Cscore&wt=json&indent=true&defType=dismax&qf=trackName_t+longDescription_t&debugQuery=true&bq=releaseDate_dt:%5B1980-01-01T00:00:00Z+TO+1989-12-31T00:00:00Z%5D^5+primaryGenreName_t:"ドラマ"^2
```

* ORも使えます。ジャンル名がアニメかドラマのものを抽出したい。という場合はこのように行います。

```
&bq=primaryGenreName_t:("ドラマ" OR "アニメ")^5
```

* ドラマとアニメの重み付けを変えたいかもしれません。その場合はこのようにします。

```
&bq=primaryGenreName_t:"キッズ"^2+primaryGenreName_t:"ドラマ"^5
```

* お気づきかと思いますが、２項目程度なら問題ないのですがこの項目数が増える毎にboostの値をうまく決めてあげないと思った並び順になりません。これは職人芸ですね。。。。

# お手軽（？）レコメンド

映画レコメンドサイトのTOPページにリコメンドエリアがあると仮定します。
ユーザ側の属性とサイト運営者の思いを上手くマッチングしてあげたいと思います。

## レコメンド要件

ユーザ側

* ユーザは好きなジャンルを登録できる。
* Aさんは「SF」が好き。

サイト運営者の思い

* trackId: 641620897「ノッティング・ヒルの恋人」が今日のおすすめ。常に一番上に表示したい。
* 次に「SF」好きの人には「特撮」映画をおすすめしたい。
* 今月はアクション特集なので、ユーザが登録したジャンルに紐付くおすすめ映画の次にはアクション映画をおすすめしたい。
* 上記以外はリリース日の新しいものから並べたい。
（2番目にSF映画を出して上げるべきかもしれませんが、説明の都合上省きました。）

## dixmaxを使って全件検索

* ポイントはqを使わないところです。dismaxの場合はqにワイルドカードは指定できませんのでq.altで指定してあげます。

```dixmaxを使って全件検索.query
/movie/select?q.alt=*&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t
```

## 特定の映画を一番上にする

* trackIdを指定してブーストしてあげます。

```ノッティング・ヒルの恋人を一番上へ.query
/movie/select?q.alt=*&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t&bq=trackId:641620897^10
```

## 特定ジャンル(特撮)をブーストする。

```特撮をノッティング・ヒルの下へ.query
/movie/select?q.alt=*&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t&bq=trackId:641620897^10+primaryGenreName_t:"特撮"^5
```

## 特撮の次にアクション映画を並べる

* 特撮映画より重み付けを抑えてあげます。ここでは特撮映画は5倍、アクションは2倍にしてあげてます。

```
/movie/select?q.alt=*&fl=trackName_t%2CprimaryGenreName_t%2CreleaseDate_dt%2Cscore&rows=100&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t&bq=trackId:641620897^10+primaryGenreName_t:"特撮"^5+primaryGenreName_t:"アクション"^2
```

## リリース日の降順

* solrではsortができますが、ここで以下のようにsortを指定してしまうとsortがdixmaxのqueryより勝ってしまい、dismaxが効かなくなります。

```
&sort=releaseDate_dt%20desc
```

じゃあどうすんだ？

* [How can I boost the score of newer documents](http://wiki.apache.org/solr/SolrRelevancyFAQ#How_can_I_boost_the_score_of_newer_documents)に答えがありました。
* function queryでrecip(ms(NOW,releaseDate_dt),3.16e-11,1,1)を指定すればできるぞ・・・と。
* dixmaxでfunction queryを使うにはbfを指定します。

``` リリース日の降順で並べたい.query
/movie/select?q.alt=*&fl=trackName_t%2CprimaryGenreName_t%2CreleaseDate_dt%2Cscore&rows=100&wt=json&indent=true&defType=dismax&qf=trackName_t+primaryGenreName_t+longDescription_t&bq=trackId:641620897^10+primaryGenreName_t:"特撮"^5+primaryGenreName_t:"アクション"^2&bf=recip(ms(NOW,releaseDate_dt),3.16e-11,1,1)
```

## 結果

* ノッティング・ヒルの恋人を一番上に表示し、次に特撮映画を表示して、次にアクション映画を指定して、それぞれがリリース日の降順で表示された結果がこちら。

```
 "response":{"numFound":924,"start":0,"maxScore":6.6815825,"docs":[
      {
        "trackName_t":"ノッティングヒルの恋人 Notting Hill （日本語字幕版）",
        "primaryGenreName_t":"コメディ",
        "releaseDate_dt":"1999-09-04T07:00:00Z",
        "score":6.6815825},
      {
        "trackName_t":"忍風戦隊ハリケンジャー 10YEARS AFTER",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2013-08-09T07:00:00Z",
        "score":1.8878123},
      {
        "trackName_t":"劇場版　仮面ライダーオーズ　ＷＯＮＤＥＲＦＵＬ　将軍と２１のコアメダル",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2011-08-06T07:00:00Z",
        "score":1.885687},
      {
        "trackName_t":"ゴーカイジャー　ゴセイジャー　スーパー戦隊１９９ヒーロー大決戦",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2011-06-11T07:00:00Z",
        "score":1.8855976},
      {
        "trackName_t":"オーズ・電王・オールライダー　レッツゴー仮面ライダー",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2011-04-01T07:00:00Z",
        "score":1.885492},
      {
        "trackName_t":"天装戦隊ゴセイジャーＶＳシンケンジャー　エピックＯＮ銀幕",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2011-01-22T08:00:00Z",
        "score":1.8853971},
      {
        "trackName_t":"侍戦隊シンケンジャーＶＳゴーオンジャー　銀幕ＢＡＮＧ！！",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2010-01-30T08:00:00Z",
        "score":1.8849983},
      {
        "trackName_t":"侍戦隊シンケンジャー銀幕版　天下分け目の戦",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2009-08-08T07:00:00Z",
        "score":1.8848455},
      {
        "trackName_t":"劇場版 仮面ライダーディケイド オールライダー対大ショッカー",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2009-08-08T07:00:00Z",
        "score":1.8848455},
      {
        "trackName_t":"劇場版　超・仮面ライダー電王＆ディケイド　ＮＥＯジェネレーションズ　鬼ヶ島の戦艦",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2009-05-01T07:00:00Z",
        "score":1.8847685},
      {
        "trackName_t":"忍風戦隊ハリケンジャー　シュシュッと　ＴＨＥ　ＭＯＶＩＥ",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"2002-08-17T07:00:00Z",
        "score":1.8838372},
      {
        "trackName_t":"秘密戦隊ゴレンジャー　爆弾ハリケーン",
        "primaryGenreName_t":"特撮",
        "releaseDate_dt":"1976-07-18T07:00:00Z",
        "score":1.8832049},
      {
        "trackName_t":"22ジャンプストリート(字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2015-03-04T08:00:00Z",
        "score":0.12217128},
      {
        "trackName_t":"ベイマックス (日本語吹替版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2014-12-20T08:00:00Z",
        "score":0.1202932},
      {
        "trackName_t":"ベイマックス (字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2014-12-20T08:00:00Z",
        "score":0.1202932},
      {
        "trackName_t":"ホビット 決戦のゆくえ (字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2014-12-13T08:00:00Z",
        "score":0.12014551},
      {
        "trackName_t":"ホビット 決戦のゆくえ(日本語吹替版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2014-12-13T08:00:00Z",
        "score":0.12014551},
      {
        "trackName_t":"エネミー・ライン4 ネイビーシールズ最前線 (日本語字幕版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2014-12-03T08:00:00Z",
        "score":0.11994183},
      {
        "trackName_t":"エネミー・ライン4 ネイビーシールズ最前線 (日本語吹替版)",
        "primaryGenreName_t":"アクション／アドベンチャー",
        "releaseDate_dt":"2014-12-03T08:00:00Z",
        "score":0.11994183},
以下略
```

# まとめ

* お手軽では無くなってきましたが、dismaxの使い方はよくわかりました。
* 整理してみるとロジックはそんなに難しくないのですが、検索項目の組み合わせと重み付けの値をうまく決めてあげないと思った通りの並びにはならないのが一番むずかしいポイントかなと思いました。

* 実際のプロジェクトでは、マッチングする要素を早目に決めて、できるだけリアルなIndexを投入しQueryを流しながら重み付けを決めていくようなアプローチが必要そうですね。
* 結果を確認しながらユーザと進めていくことで認識ズレがなくなり手戻りが少なくなるのかなと思いました。