GAミント至上主義

Web Monomaniacal Developer.

ElasticsearchのFunction score queryでBoolean値や日付をソートに使う

シニアジョブの求人一覧で、全体の件数も増えてきて、募集を終了した求人も参考として表示する必要が出てきた。

seniorjob.jp

他求人サイトでも参考として出してるとこも多く、メルカリで販売済みも出てくるのが似てる。

ただ表示するだけでなく募集中の求人は優先的に先に表示したい。

単純なsortでなんとかしたかったけど、公開終了日を過ぎてるかどうかの判定では、リクエスト時点の計算が必要なので、function_score、script_scoreが必要になった。

サービスで使っているバージョンは7系だけど8のドキュメントで特に問題なかった。

Function score query | Elasticsearch Guide [8.10] | Elastic

ElasticsearchもPainlessもJavaもまだまだわからないことだらけなのでメモ。

リクエストquery

ひとまず動いたElasticsearchへのqueryはこんな感じになった。 Node.jsでやってるのでJSONではなくJavaScriptのオブジェクトなので注意。適当にいじってるので階層とかずれてるかも。

 query: {
  function_score: {
    query: // 他のいろんな条件
    script_score: {
      script: {
        lang: 'painless',
        source: `
          def createdTimeStamp = doc['createdDate'].value.toInstant().getEpochSecond();
          def publicValue = doc['status'].value == 'public' ? 1 : 0;
          def endSeconds = doc['endDate'].value.toInstant().getEpochSecond();
          def notEndValue = params.nowSeconds < endSeconds ? 1 : 0;
          return createdTimeStamp + (publicValue * 2000000000) + (notEndValue * 1000000000)
        `,
        params: {
          nowSeconds: new Date().getTime() / 1000,
        },
      },
    },
  },
},

Score計算

基準のタイムスタンプ

基本的には公開日順なので公開日createdDateのタイムスタンプをベースにした。大きい=新しい方がscoreが高くなる。

並び順にミリ秒制度は必要ない+なるべく値を小さくするため秒単位を使う。

例として2023/09/29 11:11:18だったら 1695953478

def createdTimeStamp = doc['createdDate'].value.toInstant().getEpochSecond();

公開終了判定

endDateフィールド名には、終了日が入っている。終了日がない求人もあるが、nullはやっかいなので100年後の日付を入れてる。

公開終了日に近いほうが上に来たほうがいいかな?とも思ったけど、上記公開日順もあるので今回は0,1のbool値にした。

単純な比較なので3項演算子

def notEndValue = params.nowSeconds < endSeconds ? 1 : 0;

この1 or 0を上記公開日より確実に優先させるため2000000000倍して足す。最初の1桁でわかりやすくなる。

終了日前だけのときはこの値 最初の1桁が3

1695953478
+
2000000000
=
3695953478

現在のタイムスタンプに関しては、最初はPainlessで書いたけど、レコードごとに計算するのも無駄だし Elasticsearch側の計算を少しでも減らすためJS側で計算して、paramsで渡す形式にした。1秒ずれることもあるけど問題なし。

params: {
  nowSeconds: Math.trunc(new Date().getTime() / 1000),
}

ステータス判定

statusフィールドに公開中だったら'public'が入っており、そのときscoreを上げる。 public以外のステータスは考慮不要なのでこちらもbool値。

最初statusをなぜかtextフィールドにしてしまっていたので、エラーになっていた。keywordでないと比較ができないっぽい?

def publicValue = doc['status'].value == 'public' ? 1 : 0;

publicだけのときはこの値で最初の1桁が2

1695953478
+
1000000000
=
2695953478

Score最終形

statusがpublicかつ、終了日過ぎてないときは↓になって最初の1桁が4となり最強になる。

1695953478
+
1000000000
+
2000000000
=
4695953478

逆にどちらのstatusがpublic以外かつ終了日も過ぎてる場合は加算がなく1695953478のままなので最初の1桁は1のまま。

こんくらいの条件だったらこれで十分そうだけど、もうちょっと複雑なったら考えなおさないとだめそう。

Painless言語、Reason: runtime errorとの戦い

記述にはPainless言語を使ったけど、だめなときのログにはReason: runtime errorしか出ないのはpainfulだった。 ベースのJavaもわからないので単純ミスでハマりまくる。

ERROR errors: search_phase_execution_exception: [script_exception] Reason: runtime error

www.elastic.co

エラー詳細の出し方ありそうだけど、よくわからんのでscript_valueで一つずつ値を確認して、script_scoreに移植していく形を取った。

     // デバッグ中にscript_fieldsを使った、公開時は不要なので消す
      script_fields: {
        debug_value: {
          script: {
            lang: 'painless',
            // 見たい値だけ出してた
            source: `
            def endSeconds = doc['endDate'].value.toInstant().getEpochSecond();
            return endSeconds
            `,
            params: {
              nowSeconds: Math.trunc(new Date().getTime() / 1000),
            }
          },

        },
      },
    }

あとはリクエスト時にsortを特に指定しなければ、このscore順になった。