Unyablog.

のにれんのブログ

GKE の Preemptible VM を Spot VM にした

プライベートで GKE を Preemptible VM ベースで運用していた。だいぶ昔に Preemptible VM が Spot VM に変わるよという通知が GCP から来ていて 値段変わらないし 放置していたのだが、GKE のバージョンを上げるついでに Spot VM に変更した。

cloud.google.com

Preemptible VM は何がなんでも 24 時間以内に終了されるという AWS もびっくりの仕様だったが、 Spot VM ではそれがなくなり、 GCP のインフラ内で必要な場合にのみ終了されるようになった。

料金も変わらないのでユーザーにとってはメリットしかない変更のよう*1

GKE の Node pool を Spot VM にする

Node pool を直接 Spot VM に変更することはコンソールではできなさそうだったので、新しく Spot VM の Node pool を作って、その後 Preemptible VM の Node pool を削除した。

Spot VM の様子

5/4 に Spot VM を立ててから記事執筆時点で一週間弱立っているが、Terminate されることなく生き続けている。どれくらい生きてくれるのか、数ヶ月ぐらい様子を見て記事にできればと思う(希望)。

*1:とはいえ GKE の Node は k8s の特性上いつ壊れてもそれほど問題なく稼働し続けるので上げる理由もなく、コンソールポチポチする腰が上がらなかったのだった

TypeScript 型わくわく日記

TypeScript でオブジェクトのバリデータを作っている。それぞれの値ごとのバリデータは値の特性ごとに様々な関数がある((value: string) => Error[] のような形)。
時折 Optional な値があるものの、バリデータ自体は non-null な値のバリデーションに専念してほしくて、Optional / Required な判定は別の関数に分けたい気持ちがあった。

別の関数に分けないと、バリデータの中で一々 Optional かどうかの引数を取って判定したり、下のように各バリデータに対応して Optional な判定を加えた関数を作る必要があって面倒。

const validateOptional = (value: string | null | undefined, allowAsterisk: boolean): Error[] => {
    if (value == null) {
        return [] // ok
    }
    return validate(value, allowAsterisk)
}

これを、

  1. non-null 用のバリデーション関数を引数とし、null チェックを行う処理を差し込んだ nullable 用の新しいバリデーション関数を返す高階関数を作成する
  2. 様々なバリデーション関数の引数に対応するため、TypeScript の Variadic Tuple Types を使う

ことによってスッキリできた。

例として、non-null な値しか受け取らないバリデータに対して、値を Optional とするための高階関数を下のように作った。

const optional = <U, T extends unknown[]>(validate: (value: U, ...args: [...T]) => Error[]) => (value: (U | null | undefined), ...args: [...T]) => {
    if (value == null) {
        return []; // ok
    }
    return validate(value, ...args);
}

nullable な value を受け取って null チェックを行い、null | undefined であれば ok、non-null であれば validate 関数に再度投げている。それ自体は初めの関数と特に変わらない。

見所は Variadic Tuple Types を使っているところで、 validate 関数の1引数目以外をまとめて [...T] と受け取ることで、1つ目以外の引数(とその型情報)を維持したまま新しい関数を生成できている。
これがなかったら、バリデータ関数のオプションを全て2引数目に Object として押し込む (value: U, options: T) => Error[] のような形)ようにインターフェースを統一することになっていたと思う。

// 例
const validate = (val: string, allowAsterisk: boolean) => {
    const errors = [];
    if (val.length >= 10) {
        errors.push(new Error("value must be shorter than 10 chars"))
    }
    if (!allowAsterisk && val.includes('*')) {
        errors.push(new Error("value must not contain character *"))
    }
    return errors
}

// validate: (val: string, allowAsterisk: boolean) => Error[]
// optional(validate): (val: string | null | undefined, allowAsterisk: boolean) => Error[]

console.log(
    optional(validate)("*", true), // => [value must not contain character *]
    optional(validate)("*", false), // => []
    optional(validate)(undefined, true) // =>[]
)

TypeScript たのし~~

AlertManager: Slack の Incoming webhook URL が Kubernetes の Secret で扱いやすくなっていた

Prometheus の AlertManager は Slack などに通知を送る重要なコンポーネントである。

当然 Slack の Webhook URL といった Credential を設定ファイルに書く必要があるのだけど、AlertManager は強い意志で環境変数や複数設定ファイルに対応していないので、Kubernetes の Secret との相性がとても悪かった。

この状況下で Secret を使うには、Secret にまるまる設定ファイルを突っ込むか、envsubst を initContainer に指定して設定ファイルを動的に生成する必要があった。

Secret は sealed-secret で暗号化した状態でレポジトリに置くようにしているので、AlertManager の設定を変えるたびに暗号化をしなければならないのは面倒すぎる。とはいえ、 envsubst をするのは configmap-reload などと相性が悪い。

AlertManager 0.22

平文でレポジトリに置くしか無いのか・・・? と思っていた矢先、去年にリリースされた AlertManager 0.22 で slack_api_url_file というオプションが生えて、ファイルから Slack Incoming webhook URL を指定できるようになっていることに気づいた。

[ENHANCEMENT] Add support to set the Slack URL from a file. #2534

https://github.com/prometheus/alertmanager/releases/tag/v0.22.0

最高のアップデートじゃん!ということで、Secret に Slack の Webhook URL だけを置くような構成にすることができた。これで柔軟に Config を変更することができる。

Prometheus の Helm Chart だとこんな感じ。

alertmanager:
  extraSecretMounts:
    - name: alertmanager-secrets
      mountPath: /alertmanager-secrets
      secretName: prometheus-alertmanager-secrets
      readOnly: true
alertmanagerFiles:
  alertmanager.yml:
    global:
      slack_api_url_file: '/alertmanager-secrets/slack-api-url'
    receivers:
    - ...

Git でブランチがリモートリポジトリの HEAD から派生しているかどうかを確認する

Git でトピックブランチを切るときに、元ブランチで git pull し忘れて古いコミットから生やしてしまうときがある。

最新の変更を認識できなかったり、後々コンフリクトが起きたりと不便なことがままあるので、 Commit 前に HEAD がリモートリポジトリの HEAD から派生しているかを確認するスクリプトを書いてみた。

Git には Commit 前に pre-commit hook を実行することができる。そこで non-zero code で exit すれば Commit 処理が中止されるので、これで防ぐようにする。

コード

これを .git/hooks/pre-commit 内に置く。

git fetch origin
if ! git merge-base --is-ancestor origin/HEAD HEAD; then
  echo "ERROR: HEAD is not based on upstream HEAD"
  exit 1
fi

解説

まず git fetch origin でリモートリポジトリを fetch する。

その後、「ブランチが origin の HEAD から派生しているかどうかを確認する」ために git merge-base を使う。今回は HEAD の祖先に origin/HEAD がいるかどうかを確認しており、祖先にいなければ派生元のコミットが古いということで exit 1 し、祖先にいれば Commit を続行するようにしている。

git merge-base --is-ancestor が便利、というお話でした。

Python の subprocess.run は KeyboardInterrupt で強制的に kill される

Python から Terraform をインタラクティブな(stdin をそのまま渡す)形で呼ぶために、subprocess.run を使って subprocess で走らせようとしている。

ここで、 Ctrl-C (KeyboardInterrupt) のハンドリングがネックになる。 Terraform は Ctrl-C を打つと Graceful な終了を試みることがあるので、Ctrl-C が打たれてもしばらく subprocess を終了しないようにしたい。

try で囲んで except KeyboardInterrupt すれば実現できそうだが、残念ながら subprocess.runsubprocess.call では実現できず、すぐに kill されてしまう。

Python 3.9 で確認。

subprocess.run 中に Ctrl-C を押すとどうなるのか

subprocess.run 中に Ctrl-C を押すと、まず subprocess.run 中で使われている Popen.communicate でハンドリングされる。

ここでは、0.25秒(固定値)*1待った後に KeyboardInterrupt を re-raise している。

github.com

その後、subprocess.run でハンドリングされるが、そこで有無を言わさず process.kill() が呼ばれて kill されてしまう。

github.com

  • Popen が re-raise するまでの待ち時間を伸ばせないこと
  • re-raise した場合 subprocess.runprocess.kill() を呼んでしまうこと

から、 subprocess.run 中に Ctrl-C で subprocess が終了するのを防ぐ手段はない。

どうするか

subprocess.run の便利な機能を使えないのが残念だが、Popen を直接使うようにした。

with subprocess.Popen(args) as p:
    try:
        return_code = p.wait()
    except KeyboardInterrupt:
        # Ctrl-C が打たれたら、プロセスが終わるのを待ってから raise する
        # 補足: 2回目の p.wait() は try-except で囲んでいないので、もう一度 Ctrl-C が打たれた場合は特にハンドリングされず raise される
        return_code = p.wait()
        raise

*1:初回以降は wait しない。詳細: https://github.com/python/cpython/pull/5026

Elasticsearch: さようなら、query_string のスペース区切り

この記事は KMC advent calendar 2021 9日目の記事です。

昨日の記事は Elasticsearch: N-gram tokenizer と N-gram token filter の挙動の違い - Unyablog. でした。今日*1も Elasticsearch の話をします。

本題

サークル内で開発しているドキュメント検索ツール(Heineken)では Elasticsearch を内部で用いている。

この Elasticsearch を 5.x から 7.x に上げるにあたって、全文検索時に使っていた split_on_whitespaceauto_generate_phrase_queries オプションが使えなくなっていた。使えなくなった経緯と、それに伴う対応のメモ。

split_on_whitespaceauto_generate_phrase_queries とは

どちらも query_string query にあったオプション。

query_string はクエリの処理方法の一種で、与えられたクエリをクエリ言語として一旦パースしてから Search analyzer に投入する。クエリ言語が title:(quick OR brown) といった形式で人間が読み書きしやすいのが特徴で、Heineken でも使っていた。

split_on_whitespaceauto_generate_phrase_queries はそのクエリ言語の処理時のオプションで、 split_on_whitespace (デフォルトで true)は、パース時にスペース区切りを有効にするもの、auto_generate_phrase_queries (デフォルトで false)は split_on_whitespace で得られたそれぞれの文字列に対して自動的に Phrase query を生成するものだった。

stackoverflow.com

kmc test というクエリは、以下のような形で処理されて Analyzer に渡されていた*2

  1. kmc test
  2. kmc AND testsplit_on_whitespace=true
  3. "kmc" AND "test"auto_generate_phrase_queries=true

この結果、各単語に対して PhraseQuery (match_phrase 相当)が生成されていた。

何が嬉しかったのか

Heineken ではユーザーから受け取った検索クエリを直接 query_string のクエリとして投げていたが、それはこの2つのオプションのおかげで成り立っていた。

f:id:nonylene:20211227022943p:plain

auto_generate_phrase_queries

これは Tokenizer として N-gram を使っている場合に必要だった。

N-gram tokenizer を使うと kmc[km, mc] という Token に分解されるが、ここでもし Phrase 検索を使っていない場合は +body:km +body:mc のような BooleanQuery が生成される*3。これだと kmmc という文字さえ文章中に存在すればヒットするため、 mchoge kmpiyo といった文字列にもヒットしてしまい、 False positive が非常に多くなってしまう。

一方、Phrase 検索を使った場合は body:\"km mc\" という PhraseQuery が生成されるようになる。これは kmc という連続した文字列にしかヒットしないため、思ったとおりの結果を出すことができていた。

split_on_whitespace

これは直感的なスペース区切りの検索を実現するのに必要だった。

上述のように、N-gram では Phrase 検索を行うことが必要不可欠なわけだが、その Phrase 検索は単語ごとに行う必要がある。

もし split_on_whitespace が false だと、 kmc test と入力した場合に "kmc test" という Phrase 検索になってしまう。これだと、kmc test という連続した文字列がないとヒットせず、False negative が多く発生してしまう。この状態でも kmc AND test のように Operator を明示的に書けば別々の単語として投入してくれるが、いちいちユーザーに Operator を書かせるのは大変。

Elasticsearch での両オプションの廃止経緯

…と Heineken では活用していたこのオプションだが、どちらも 6.x, 7.x で廃止された。対応を検討するために、廃止になった経緯を調べてみた。

まず、もともと(5.0 まで)は query_stringsplit_on_whitespace というオプションはなく、デフォルトで空白文字区切りが有効になっていた。ただ、空白文字で区切られてしまうと不都合な点がいくつかあり、空白文字を含んだ形で Analyzer に渡すべき、という話があったらしい。

... the goal was to ultimately stop splitting on whitespace and let the analyzer do the right thing.

Make `split_on_whitespace` default to `false` · Issue #25470 · elastic/elasticsearch · GitHub

その後、Elasticsearch が内部で用いている Lucene でこの挙動を変更できるようになり、それに応じて Elasticsearch でも split_on_whitespace オプションが 5.1 で導入された。この Issue では "将来的にはデフォルトは false にしたいが今は機能を設定できるようにだけしておく" といった旨のコメントがある。

... we had an agreement that splitOnWhitespace=false should be the default but it would change the expectations for a lot of users so the proposed plan is to expose the feature in 5.x (with default to true) and change the default to false in 6.0.

Expose splitOnWhitespace in `Query String Query` · Issue #20841 · elastic/elasticsearch · GitHub

そして、6.x 以降では両オプションを無視するようにして、7.x では無くすという判断がなされ、実際に Whitespace は Operator としてはみなされなくなった。

github.com

Whitespaces and empty queriesedit
Whitespace is not considered an operator.

Query string query | Elasticsearch Guide [7.16] | Elastic

すなわち、kmc test で AND 検索できていたものを kmc AND test と明示的に書かなければいけなくなった。

auto_generate_phrase_queries に関しては、代替として type というオプションが増えて、クエリからパースされた各文字列がどのように扱われるかを決めれるようになった。例えば type: phrase とすると、内部的には各文字列に対して match_phrase 検索が実行される。

phrase
Runs a match_phrase query on each field and uses the _score from the best field. See phrase and phrase_prefix.

Query string query | Elasticsearch Guide [7.16] | Elastic

ただ、split_on_whitespace は削除されているため*4、今までのクエリがそのまま使えるわけではない。

Heineken での対応

形態素解析などで Token が意味を残した形で分解される場合は、 Phrase 検索は必須でないため今回の変更はそこまで大きな影響とならないと思われる。

しかし、Heineken は N-gram で Token を生成しているため、今回の変更との相性が悪かった。

前述のように、N-gram では特性上 Phrase 検索できないと False positive が大量に発生してしまうので Phrase 検索は自動でやりたい。そこで type: phrase を設定しても、スペース区切りが廃止されているため kmc test"kmc test" のようなクエリに解釈されてしまう*5。これでは多くの人にとって意図した検索結果にはならないだろう。

結局 query_string に直接クエリを入れるのを諦め、独自に検索クエリをパースして Elasticsearch に渡すことにした。

github.com

まとめ

www.youtube.com

N-gram を用いて False positive を減らす検索システムを作りたい場合、Phrase 検索を行う必要がある。今までは Elasticsearch の query_string でそれが実現できていた。しかし、split_on_whitespace の廃止によって query_string に直接渡すような実装は相性が悪くなってしまい、 Heineken では独自のパースを行うことになった。

今までありがとう、そしてさようなら、split_on_whitespaceauto_generate_phrase_queries 👋👋

*1:12月27日

*2:なお、この記事では default_operator を AND とした挙動を書いている

*3:ここでは body というフィールドに対して検索をかけている想定

*4:keyword だけは同等の機能が使えるようになっている https://github.com/elastic/elasticsearch/issues/30393 が、今回は text なので使えない

*5:この状態で今までのような Phrase 検索を維持するには Analyzer 内で空白区切りで分解した上で分解した Token に対して Phrase 検索を有効化し、そこに N-gram をかけるといったことが必要になるが、少なくとも Elasticsearch に用意されているものではそのようなことはできない(はず)

Elasticsearch: N-gram tokenizer と N-gram token filter の挙動の違い

この記事は KMC advent calendar 8日目の記事ということにしています。

adventar.org

KMC では部内ドキュメント検索システムで Elasticsearch を使用している。最近 Elasticsearch のバージョンを上げる準備をしていて、設定の見直しの中で N-gram token filter を使ってみたら想定と違った挙動をしたのでメモ。

Disclaimer

  • 7.16 で確認
  • 検索結果に False positive, False negative がないかの視点で見ている。スコアリング関係は見ていない。
  • Edge n-gram 、CJK bi-gram については見ていない
  • エラサーのプロではないので間違っていたら教えて下さい…

TL;DR

  • N-gram tokenizer はイメージ通り、与えられた文字列を N-gram で各 Token に分解する。各 Token は別々のものになる。
  • N-gram token filter は与えられた Token を N-gram し、新たな Token を登録する。分解された Token は元の Token の情報を引き継ぐので、Token のバリエーションが増えたように(= Synonym)解釈される。

この違いは、インデックス・検索時のパース処理や Highlight 機能に大きく関わってくる。

詳細

前提知識 (Tokenizer と Token filter について)

Elasticsearch では Analyzer でインデックス時や検索時の文字列の処理方法を決める。Analyzer は Char filter, Tokenizer, Token filter で構成される。

www.elastic.co

Tokenizer は Char filter で整形された文字列を受け取って、それを Token に分解する。Token filter は分解された Token を編集したり、追加・削除を行う。

N-gramTokenizerToken filter もある。一体どういう違いがあるのか?

挙動の違い

Analyzer API を用いて kmc test という文字列に Bi-gram を適用し、どのように展開されるかを確認してみる。

N-gram tokenizer

Analyzer API 結果詳細(Click to open)

$ curl -X GET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'{
   "tokenizer": {
     "type": "ngram", "min_gram": 2, "max_gram": 2
   },
  "text": "kmc test",
  "explain": true
}'

{
  "detail" : {
    "custom_analyzer" : true,
    "charfilters" : [ ],
    "tokenizer" : {
      "name" : "__anonymous__ngram",
      "tokens" : [
        {
          "token" : "km",
          "start_offset" : 0,
          "end_offset" : 2,
          "type" : "word",
          "position" : 0,
          "bytes" : "[6b 6d]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : "mc",
          "start_offset" : 1,
          "end_offset" : 3,
          "type" : "word",
          "position" : 1,
          "bytes" : "[6d 63]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : "c ",
          "start_offset" : 2,
          "end_offset" : 4,
          "type" : "word",
          "position" : 2,
          "bytes" : "[63 20]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : " t",
          "start_offset" : 3,
          "end_offset" : 5,
          "type" : "word",
          "position" : 3,
          "bytes" : "[20 74]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : "te",
          "start_offset" : 4,
          "end_offset" : 6,
          "type" : "word",
          "position" : 4,
          "bytes" : "[74 65]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : "es",
          "start_offset" : 5,
          "end_offset" : 7,
          "type" : "word",
          "position" : 5,
          "bytes" : "[65 73]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : "st",
          "start_offset" : 6,
          "end_offset" : 8,
          "type" : "word",
          "position" : 6,
          "bytes" : "[73 74]",
          "positionLength" : 1,
          "termFrequency" : 1
        }
      ]
    },
    "tokenfilters" : [ ]
  }
}

["km", "mc", "c ", " t", "te", "es", "st"] といった Token 列に分解されている。各 Token の位置情報(positionstart, end_offset)は Token によって異なっており、全て独立した Token として扱われている事がわかる。

N-gram token filter

この記事では、Tokenizer には Standard tokenizer を使う*1

Analyzer API 結果詳細(Click to open)

$ curl -X GET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'{
 "filter": [{
   "type": "ngram", "min_gram": 2, "max_gram": 2
  }],
  "tokenizer": "standard",
  "text": "kmc test",
  "explain": true
}'

{
  "detail" : {
    "custom_analyzer" : true,
    "charfilters" : [ ],
    "tokenizer" : {
      "name" : "standard",
      "tokens" : [
        {
          "token" : "kmc",
          "start_offset" : 0,
          "end_offset" : 3,
          "type" : "<ALPHANUM>",
          "position" : 0,
          "bytes" : "[6b 6d 63]",
          "positionLength" : 1,
          "termFrequency" : 1
        },
        {
          "token" : "test",
          "start_offset" : 4,
          "end_offset" : 8,
          "type" : "<ALPHANUM>",
          "position" : 1,
          "bytes" : "[74 65 73 74]",
          "positionLength" : 1,
          "termFrequency" : 1
        }
      ]
    },
    "tokenfilters" : [
      {
        "name" : "__anonymous__ngram",
        "tokens" : [
          {
            "token" : "km",
            "start_offset" : 0,
            "end_offset" : 3,
            "type" : "<ALPHANUM>",
            "position" : 0,
            "bytes" : "[6b 6d]",
            "positionLength" : 1,
            "termFrequency" : 1
          },
          {
            "token" : "mc",
            "start_offset" : 0,
            "end_offset" : 3,
            "type" : "<ALPHANUM>",
            "position" : 0,
            "bytes" : "[6d 63]",
            "positionLength" : 1,
            "termFrequency" : 1
          },
          {
            "token" : "te",
            "start_offset" : 4,
            "end_offset" : 8,
            "type" : "<ALPHANUM>",
            "position" : 1,
            "bytes" : "[74 65]",
            "positionLength" : 1,
            "termFrequency" : 1
          },
          {
            "token" : "es",
            "start_offset" : 4,
            "end_offset" : 8,
            "type" : "<ALPHANUM>",
            "position" : 1,
            "bytes" : "[65 73]",
            "positionLength" : 1,
            "termFrequency" : 1
          },
          {
            "token" : "st",
            "start_offset" : 4,
            "end_offset" : 8,
            "type" : "<ALPHANUM>",
            "position" : 1,
            "bytes" : "[73 74]",
            "positionLength" : 1,
            "termFrequency" : 1
          }
        ]
      }
    ]
  }
}

まず、Standard tokenizer によって ["kmc", "test"] といった Token 列に分解される。その後、 N-gram token filter によって各 Token がさらに分解され、最終的に ["km", "mc", "te", "es", "st"] という Token 列が生成されている。

注目すべきは N-gram token filter は分解前の Token の位置情報(positionstart,end_offset)を保持している点。その結果、同じ位置情報を持つ Token が複数生成されている*2

イメージとしてはこんなかんじ。

f:id:nonylene:20211226015323p:plain

ここで、N-gram token filter で展開された位置情報が同じ Token は Synonym (類義語)として解釈される。

www.elastic.co

その結果、N-gram tokenizer と挙動が異なってくる。

挙動の違いによる影響

インデックスで用いた場合

検索結果

N-gram tokenizer は通常の N-gram として想定したような結果になる。

一方、N-gram token filter の場合、先述したように Synonym としてまとめて解釈されてしまい、細かい位置情報が失われるので phrase 検索がまともに動かない*3。通常の検索でも、スコアなどの精度が大幅に悪化しそう*4

Highlight

Highlight は検索でマッチしたときにマッチした場所を示す用のテキストを抽出し、さらに検索語句に一致する Token に <mark> タグをつけてくれる Elasticsearch の機能。

f:id:nonylene:20211225193326p:plain
"のにれん" で検索した結果の Highlight の例。黄色くなっている箇所が mark

N-gram tokenizer であれば実際に一致した箇所のみがハイライトされるが、N-gram token filter の場合は Token filter が Token の位置情報(start, end_offset)を変更しないので、分解前の Token 全体がハイライトされる。

例えば、highlight this というテキストに対して gh で検索したとする。先述の N-gram tokenizer を使うパターンでは gh のみがハイライトされるが、N-gram token filter を使うパターンでは highlight がハイライトされて、やや違和感のある形になる*5

github.com

検索クエリで用いた場合

query_string クエリで test"test" (Phrased) を入れた場合に、最終的にどのようなクエリが実行されるかを見る*6

  • N-gram tokenizer

    • test: BooleanQuery, body:te body:es body:st
      • Token それぞれがクエリとなる。OR か AND になるかは default_operator の設定による。
    • "test": PhraseQuery, body:\"te es st\"
      • Token に分解された上で、 Phrase 検索が有効になっていることが分かる。test という文字列が存在する場合にのみマッチする。
  • N-gram token filter

    • test: SynonymQuery, Synonym(body:es body:st body:te)
      • Token filter によって分解された Token は Synonym とみなされ、一括で検索になっている。es, st , te のうち一つでも文章に存在すればマッチする。
    • "test": SynonymQuery, Synonym(body:es body:st body:te)
      • 通常の検索と同じ挙動になっており、Phrase 検索にはなっていない

このように、N-gram Token filter を使うと類義語のようにみなされる結果、SynonymQuery が発行されてしまい、想定と異なる結果(大量の False positive)になってしまう。

まとめ

Elasticsearch の Token filter は元々の Token の位置情報を変更しない、という性質を持つため、N-gram token filter では分解した Token が Synonym として解釈されてしまう。その結果、インデックス時や検索時に意図しない挙動になってしまいやすい。

N-gram tokenizer ではそのようなことがなく、意図した通りの N-gram 処理が行えるのでオススメ。

*1:今回は Tokenizer と Token filter の関係を分かりやすくするために、一旦 Tokenizer でスペース区切りが行われるようにした。Tokenizer が何も区切らずに Token filter に渡しても、この記事で言っている事象が起きることは変わらない。

*2:この挙動は Elasticsearch が内部で用いている Lucene の NGramTokenFilter のドキュメントにも書いてある

*3:検索時の Analyzer として N-gram tokenizer を使うと基本的にヒットしなくなるし、N-gram token filter を使うと後述のように SynonymQuery となって意図しないものが大量にヒットする

*4:詳細は見ていないが、同義語として解釈されるのは明らかに違うので誤ったスコアリングがなされそう

*5:もし Standard tokenizer ではなく何も分割しないような Tokenizer を使うと、全てがハイライトされるでしょう :party_parrot:

*6:profile オプションを有効にすると見れる