[メモ]ElasticsearchでIS NULL的な検索をしたい

## TL;DR

`must_not`と`exists`を組み合わせて実現できる
パフォーマンス面の検証はしていないのであしからず


## 実行環境

Elasticsearch: 7.9.3
使ったインデックス定義やサンプルデータは[こちら](https://github.com/wmichi/20210314_elasticsearch_is_null_search)

## 前置き

あるフィールドについて検索条件を設定したとき、そのフィールドにnullがセットされたドキュメントは検索結果に入ってこない
例えば以下のようなインデックスとデータを考える

```json
PUT simple_index
{
    "mappings":
    {
        "_routing": {"required": false},
	    "properties":
	    {
	    	"id": {"type": "keyword"},
	    	"name": {"type": "keyword"},
	    	"int_value": {"type": "integer"}
	    }
    }
}


POST simple_index/_bulk
{"create":{"_index": "simple_index"}}
{ "id": "1","name": "sample_1","int_value": 10}
{"create":{"_index": "simple_index"}}
{ "id": "2","name": "null","int_value": 20}
{"create":{"_index": "simple_index"}}
{ "id": "3","name": "sample_3","int_value": null}
```

上記のようなデータの場合、int_valueを対象にした以下のような検索をした場合`id=3`のドキュメントは引っかからない

```json
GET simple_index/_search
{
  "size": 10,
  "query": {
    "bool": 
    {
      "must": [
        {
          "range": {
            "int_value": {
              "lte": 10
            }
          }
        }
      ]
    }
  }
}
```

また明示的にnullと書かずとも、あるフィールドそのものが欠けて登録された場合も同じような挙動になる。つまり

```json
POST simple_index/_bulk
{"create":{"_index": "simple_index"}}
{ "id": "8","name": "sample_8"}
```

というドキュメントを登録した場合、この`id=8`のレコードはint_valueを対象にした検索には引っかからないことになる。


ここまで検索に引っかからないケースの説明をしたが、あるフィールドがNULLであるドキュメントを検索したいようなケースも存在する
今回はそのようなケースに対応するための方策を考えていく

## null_value

インデックスの定義で各フィールドに`null_value`というものを設定すると、ドキュメント登録時当該フィールドがnullならば特定の値でインデックスされる
例えば先程の例のインデックス定義を少し変えた新しいインデックスを以下のように定義する

```json
PUT simple_index_with_null_value
{
    "mappings":
    {
        "_routing": {"required": false},
	    "properties":
	    {
	    	"id": {"type": "keyword"},
	    	"name": {"type": "keyword", "null_value": "NULL"},
	    	"int_value": {"type": "integer", "null_value": -10}
	    }
    }
}
```

上記では`name`と`int_value`それぞれに"null_value"という項目を以下の通り設定している

- name: このフィールドがnullで登録された場合"NULL"としてインデックスする
- int_value: このフィールドがnullで登録された場合-10としてインデックスする

例えばnameに対するNULLの検索は「nameがNULLという文字列である」といったやり方で検索できる。つまり

```json
GET simple_index_with_null_value/_search
{
  "size": 10,
  "query": {
    "term": {
      "name": "NULL"
    }
  }
}
```

といった形で検索できる。
このとき検索結果は以下のようになっている。

```json
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "simple_index_with_null_value",
        "_type" : "_doc",
        "_id" : "iikWM3gBWn03-3bcybO_",
        "_score" : 1.0,
        "_source" : {
          "id" : "1",
          "name" : "sample_1",
          "int_value" : 10
        }
      },
      {
        "_index" : "simple_index_with_null_value",
        "_type" : "_doc",
        "_id" : "jCkWM3gBWn03-3bcybO_",
        "_score" : 1.0,
        "_source" : {
          "id" : "3",
          "name" : "sample_3",
          "int_value" : null
        }
      }
    ]
  }
}
```


あくまでインデックスされるときにNULLとしてインデックスされるだけで、`_source`の中身が変わるわけではない点に注意[^1]

数値型のフィールドに対しても同様にnull_valueを設定できるが、null_valueの設定方法によっては予期せぬ検索結果を返してしまうことがある
↑の例ではnull_valueを-10にしているが、以下のようなクエリの場合本来NULLにしたいはずのid=3のレコードが検索に引っかかってしまう

```json
GET simple_index_with_null_value/_search
{
  "size": 10,
  "query": {
    "bool": 
    {
      "must": [
        {
          "range": {
            "int_value": {
              "lte": 10
            }
          }
        }
      ]
    }
  }
}
```

検索結果は以下の通り

```json
{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "simple_index_with_null_value",
        "_type" : "_doc",
        "_id" : "4HBWMXgBO19uWpKIOI1_",
        "_score" : 1.0,
        "_source" : {
          "id" : "1",
          "name" : "sample_1",
          "int_value" : 10
        }
      },
      {
        "_index" : "simple_index_with_null_value",
        "_type" : "_doc",
        "_id" : "4nBWMXgBO19uWpKIOI1_",
        "_score" : 1.0,
        "_source" : {
          "id" : "3",
          "name" : "sample_3",
          "int_value" : null
        }
      }
    ]
  }
}
```

文字列型の場合はある程度このままでも使えそうだが、数値型の場合この方法だと思わぬ挙動になってしまうのであまりよろしくない
したがって別の方法を考える


## existsを使う

existsクエリ自体は「あるフィールドにインデックスされた値[^2]を含むドキュメントを返す」というもの
例えば先の`simple_index`の場合、int_valueがNULLでないドキュメントを取得するクエリは以下のようになる


```json
GET simple_index/_search
{
  "query": {
  	"bool": {
  		"must": {
		    "exists": {
		      "field": "int_value"
		    }	
  		}
  	}
  }
}
```

この[existsとmust_notを組み合わせることで](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html)「あるフィールドがNULLであるようなドキュメント」を検索することができる
例えば以下のようなクエリ

```json
GET simple_index/_search
{
  "query": {
  	"bool": {
  		"must_not": {
		    "exists": {
		      "field": "int_value"
		    }	
  		}
  	}
  }
}
```

検索結果

```json
{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.0,
    "hits" : [
      {
        "_index" : "simple_index",
        "_type" : "_doc",
        "_id" : "X3BRMXgBO19uWpKIwo1N",
        "_score" : 0.0,
        "_source" : {
          "id" : "3",
          "name" : "sample_3",
          "int_value" : null
        }
      }
    ]
  }
}
```

ここで注意として、インデックスの定義でnull_valueを設定している場合、null_valueの設定されたフィールドがnullであるドキュメントが上記のクエリで引っかからなくなるということが挙げられる
例えば先程null_valueを設定した`simple_index_with_null_value`で同様のクエリを投げると検索結果が変わる


```json
GET simple_index_with_null_value/_search
{
  "query": {
  	"bool": {
  		"must_not": {
		    "exists": {
		      "field": "int_value"
		    }	
  		}
  	}
  }
}
```

検索結果

```json
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}
```


### （個人的な興味）nestedで使うにはどうする？

個人的にネストされた項目に対する検索をすることが多いので、nested内でも同じことができるか確認
結論、nestedでも問題なく使える

以下のようなインデックス定義を考える

```json
PUT nested_index
{
    "mappings": 
    {
        "_routing": {"required": false},
	    "properties": 
	    {
	    	"parent_id": {"type": "keyword"},
	    	"parent_name": {"type": "keyword"},
	    	"child_info": {
	    		"type": "nested",
	    		"properties": {
		    		"child_id": {"type": "keyword"},
		    		"child_name": {"type": "keyword"},
		    		"seq_no": {"type": "integer"},
		    		"int_value": {"type": "integer"}
	    		}
	    	}
	    }
    }
}
```

データ投入

```json
{"create":{"_index": "nested_index"}}
{ "parent_id": "1","parent_name": "sample_1","child_info": [{ "child_id": "11","child_name": "sample_11", "seq_no": 1,"int_value": 10},{ "child_id": "11","child_name": "sample_11", "seq_no": 2,"int_value": 10}]}
{"create":{"_index": "nested_index"}}
{ "parent_id": "2","parent_name": null, "child_info": [{ "child_id": "12","child_name": "sample_12", "seq_no": 1,"int_value": 20},{ "child_id": "22","child_name": "sample_22", "seq_no": 2,"int_value": 10}]}
{"create":{"_index": "nested_index"}}
{ "parent_id": "3","parent_name": "sample_3","child_info": [{ "child_id": "13","child_name": "sample_13", "seq_no": 1,"int_value": null},{ "child_id": "23","child_name": "sample_23", "seq_no": 2,"int_value": 10}]}
{"create":{"_index": "nested_index"}}
{ "parent_id": "4","parent_name": "sample_4","child_info": [{ "child_id": "14","child_name": "sample_14", "seq_no": 1,"int_value": 40},{ "child_id": "24","child_name": "sample_24", "seq_no": 2,"int_value": 10}]}
{"create":{"_index": "nested_index"}}
{ "parent_id": "5","parent_name": "sample_5","child_info": [{ "child_id": "15","child_name": "sample_15", "seq_no": 1,"int_value": 50},{ "child_id": "25","child_name": "sample_25", "seq_no": 2,"int_value": 10}]}
{"create":{"_index": "nested_index"}}
{ "parent_id": "6","parent_name": "sample_6","child_info": [{ "child_id": "16","child_name": "sample_16", "seq_no": 1,"int_value": null},{ "child_id": "26","child_name": "sample_26", "seq_no": 2,"int_value": 10}]}```
```

単純な検索
ここでは`child_info.int_value`がNULLであるフィールドを含むドキュメントを検索

```json
GET nested_index/_search
{
	"query": {
  		"bool": {
  			"must": [
  				{
  					"nested": {
  						"path": "child_info",
  						"query": {
  							"bool": {
  								"must_not": {
  									"exists": {
  										"field": "child_info.int_value"
  									}
  								}
  							}
  						},
  						"inner_hits": {}
  					}
  				}
  			]
  		}
  	}
}
```

検索結果

```json
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 0.0,
    "hits" : [
      {
        "_index" : "nested_index",
        "_type" : "_doc",
        "_id" : "3XBzMXgBO19uWpKIrZDn",
        "_score" : 0.0,
        "_source" : {
          "parent_id" : "3",
          "parent_name" : "sample_3",
          "child_info" : [
            {
              "child_id" : "13",
              "child_name" : "sample_13",
              "seq_no" : 1,
              "int_value" : null
            },
            {
              "child_id" : "23",
              "child_name" : "sample_23",
              "seq_no" : 2,
              "int_value" : 10
            }
          ]
        },
        "inner_hits" : {
          "child_info" : {
            "hits" : {
              "total" : {
                "value" : 1,
                "relation" : "eq"
              },
              "max_score" : 0.0,
              "hits" : [
                {
                  "_index" : "nested_index",
                  "_type" : "_doc",
                  "_id" : "3XBzMXgBO19uWpKIrZDn",
                  "_nested" : {
                    "field" : "child_info",
                    "offset" : 0
                  },
                  "_score" : 0.0,
                  "_source" : {
                    "child_id" : "13",
                    "child_name" : "sample_13",
                    "seq_no" : 1,
                    "int_value" : null
                  }
                }
              ]
            }
          }
        }
      },
      {
        "_index" : "nested_index",
        "_type" : "_doc",
        "_id" : "4HBzMXgBO19uWpKIrZDn",
        "_score" : 0.0,
        "_source" : {
          "parent_id" : "6",
          "parent_name" : "sample_6",
          "child_info" : [
            {
              "child_id" : "16",
              "child_name" : "sample_16",
              "seq_no" : 1,
              "int_value" : null
            },
            {
              "child_id" : "26",
              "child_name" : "sample_26",
              "seq_no" : 2,
              "int_value" : 10
            }
          ]
        },
        "inner_hits" : {
          "child_info" : {
            "hits" : {
              "total" : {
                "value" : 1,
                "relation" : "eq"
              },
              "max_score" : 0.0,
              "hits" : [
                {
                  "_index" : "nested_index",
                  "_type" : "_doc",
                  "_id" : "4HBzMXgBO19uWpKIrZDn",
                  "_nested" : {
                    "field" : "child_info",
                    "offset" : 0
                  },
                  "_score" : 0.0,
                  "_source" : {
                    "child_id" : "16",
                    "child_name" : "sample_16",
                    "seq_no" : 1,
                    "int_value" : null
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}
```


ネストされた内容に対する複合的な検索
ここでは「child_infoのint_valueがNULLで、かつchild_infoのchild_nameがsample_13」であるようなドキュメントを検索している

```json
GET nested_index/_search
{
	"query": {
  		"bool": {
  			"must": [
  				{
  					"nested": {
  						"path": "child_info",
  						"query": {
  							"bool": {
  								"must": [
  									{
  										"match": {
  											"child_info.child_name": "sample_13"
  										}
  									}
  								],
  									"must_not": [
  									  {
  									    "exists": {
  									      "field": "child_info.int_value"
  									    }
  									  }
  									]
  							}
  						},
  						"inner_hits": {}
  					}
  				}
  			]
  		}
  	}
}
```


検索結果

```json
{
  "took" : 3,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 2.1594841,
    "hits" : [
      {
        "_index" : "nested_index",
        "_type" : "_doc",
        "_id" : "3XBzMXgBO19uWpKIrZDn",
        "_score" : 2.1594841,
        "_source" : {
          "parent_id" : "3",
          "parent_name" : "sample_3",
          "child_info" : [
            {
              "child_id" : "13",
              "child_name" : "sample_13",
              "seq_no" : 1,
              "int_value" : null
            },
            {
              "child_id" : "23",
              "child_name" : "sample_23",
              "seq_no" : 2,
              "int_value" : 10
            }
          ]
        },
        "inner_hits" : {
          "child_info" : {
            "hits" : {
              "total" : {
                "value" : 1,
                "relation" : "eq"
              },
              "max_score" : 2.1594841,
              "hits" : [
                {
                  "_index" : "nested_index",
                  "_type" : "_doc",
                  "_id" : "3XBzMXgBO19uWpKIrZDn",
                  "_nested" : {
                    "field" : "child_info",
                    "offset" : 0
                  },
                  "_score" : 2.1594841,
                  "_source" : {
                    "child_id" : "13",
                    "child_name" : "sample_13",
                    "seq_no" : 1,
                    "int_value" : null
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}

```

## 考慮できていないこと

existsとmust_notを使った場合のパフォーマンスについては未検証
missingと比べたら早いという[やり取り](https://discuss.elastic.co/t/exists-vs-missing-performance/54420)は見たが、実際のデータを投入したときにどうなるか検証する必要がありそう

以上

## 参考資料

[Exists query - Elasticsearch Reference [7.x]](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/query-dsl-exists-query.html)
[Boolean query - Elasticsearch Reference [7.x]](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/query-dsl-bool-query.html)
[Need to get the field having only null value using query](https://discuss.elastic.co/t/need-to-get-the-field-having-only-null-value-using-query/54686/3)

[^1]: `The null_value only influences how data is indexed, it doesn’t modify the _source document`との注釈[こちら](https://www.elastic.co/guide/en/elasticsearch/reference/current/null-value.html)にされている
[^2]: [Exists query - Elasticsearch Reference [7.x]](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/query-dsl-exists-query.html)にあるフィールドの値がインデックスされない理由が記載されているが、ここでは省略