Elasticsearch で天気予報の質疑応答システムを作ってみた

この記事は Elasticsearchを用いて、自然言語で天気予報の問い合わせができる質疑応答システム(Slackの Botアプリ)を作成する方法を調査してまとめたものです。

目次

やりたいこと

指定した地域の天気の情報を取得したいと思います。
Slack のスラッシュコマンドを作成して、例えば『/weather Osaka で大阪の天気を取得する』という方法もありそうですが…コマンドに慣れ親しんでいるエンジニア等、利用者が絞られてしまいそうです。
Elasticsearch で形態素解析後述)が行えるので、今回はこれを利用して、私たちが日常で使っている自然言語で天気予報を取得してみたいと思います。

完成イメージはこちらです。 f:id:ken01-linkode:20200415103143g:plain

前提

  • Elasticsearch、Kibana へアクセスできる環境が必要です。
    • 本記事では Amazon Elasticsearch Service(Amazon ES)を使用していますが、Elasticsearch 単体の動作確認はローカル環境に設置されたものでも行えます。
  • チャットツールとしてSlackを使用します。
  • Slack のボットアプリは AWS Lambda、実装言語はGoを使用します。
  • Elasticsearch へのデータの投入に Python を使用しているところがあります。
  • 天気情報の取得は、Open Weather Map を使用しています。
    • サイトの簡単な説明は行っていますが、APIの仕様や、取得した情報の加工についての解説は行いません。
  • こちらの記事もあわせてお読みください。 blog.linkode.co.jp blog.linkode.co.jp

Open Weather Map

https://openweathermap.org/

世界中の天気予報が取得できるサービスです。

  • 指定した地域の、5日分、3時間毎の天気予報が取得できます。
  • 地域の指定は地域名や City ID で指定できますが、曖昧性を避けるため、City ID での指定が推奨されています。
  • City ID の一覧(city.list.json.gz)は以下のURLからダウンロードできます。 http://bulk.openweathermap.org/sample/
  • リクエストは1分間に60回まで無料!

Elasticsearch

今回の主役です。
オープンソースで「貯める、検索する、集計する」が一つのプロダクトで出来て基本無料という、有名な全文検索エンジンですが、プラグインを用いることで形態素解析も行えます。
本記事では、形態素解析として Kuromoji を使用して、Kibana で各種設定を行っていきます。

形態素解析

自然言語を単語単位に分解して品詞(単語の分類)を判別する事を形態素解析といいます。

  • Wikipedia からの引用

    形態素解析(けいたいそかいせき、Morphological Analysis)とは、文法的な情報の注記の無い自然言語のテキストデータ(文)から、対象言語の文法や、辞書と呼ばれる単語の品詞等の情報にもとづき、形態素(Morpheme, おおまかにいえば、言語で意味を持つ最小単位)の列に分割し、それぞれの形態素の品詞等を判別する作業である。

Kuromoji

https://www.atilika.com/ja/kuromoji/

Javaで開発されているオープンソースの日本語形態素解析エンジンです。
Elasticsearch にも採用されていて、Amazon Elasticsearch Service (Amazon ES) では最初から Kuromoji がサポートされています。

Kibana

Kibanaは、Elasticsearch用のデータ視覚化ダッシュボードなのですが、内包されている『開発ツール(Dev Tools)』を使用することで、Elasticsearch の操作が簡単に行えるため、本記事の作業でも使用しています。
また、Amazon ES でも最初からサポートされています。

Wikipedia からの引用

Kibana is an open source data visualization dashboard for Elasticsearch. It provides visualization capabilities on top of the content indexed on an Elasticsearch cluster. Users can create bar, line and scatter plots, or pie charts and maps on top of large volumes of data.

(お試し翻訳 | みらい翻訳)

Kibanaは、Elasticsearchのオープンソースのデータ視覚化ダッシュボードだ。Elasticsearchクラスタでインデックスされたコンテンツの上に視覚化機能を提供する。ユーザーは、大量のデータの上に棒グラフ、折れ線グラフ、散布図、または円グラフと地図を作成できます。

Kibana の Dev Tools は、ツールバーのレンチ🔧アイコンから開くことができます。
画面は以下のようになります。

f:id:ken01-linkode:20200415173755p:plain

左側が Elasticsearch へのコマンドを入力するエリア、右側が Elasticsearch からのレスポンスを確認するエリアになっていて、コマンドごとに実行、確認が行えます。

事前準備

Elasticsearch へ質問のパターンを登録

自然言語が「どうやら天気予報を問い合わせているようだ」ということを判別するために、いくつか「天気予報の要求」パターンを登録します。
Kibana の Dev Tools で以下のように入力、実行して request-weather-forecast というインデックスを作成し、ドキュメントを登録しました。

### 複数のドキュメントの一括登録
POST /request-weather-forecast/_bulk
{"index": {}}
{"message": "の天気を教えて"}
{"index": {}}
{"message": "の天気を教えて。"}
{"index": {}}
{"message": "の天気。"}
{"index": {}}
{"message": "の天気"}
{"index": {}}
{"message": "の天気は?"}
{"index": {}}
{"message": "天気"}
{"index": {}}
{"message": "天気。"}

検索をしてみます。

### 検索
GET /request-weather-forecast/_search
{
  "query": {
    "match": {
      "message": "大阪の天気を教えて"
    }
  }
}

検索にヒットしたものがある、という結果が返ってきました。

{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 7,
      "relation" : "eq"
    },
    "max_score" : 3.9457467,
    "hits" : [
      {
        "_index" : "request-weather-forecast",
        "_type" : "_doc",
        "_id" : "nwfXcnEBWDDK_RCJkv35",
        "_score" : 3.9457467,
        "_source" : {
          "message" : "の天気を教えて"
        }
      },
  ...<略>...

目的とは関係ないメッセージで検索してみます。

### 検索
GET /request-weather-forecast/_search
{
  "query": {
    "match": {
      "message": "大阪へ行きたい。"
    }
  }
}

ヒットしたデータは無し、となりますね。

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

抜けのあるパターンもありそうですが「どうやら天気予報を問い合わせているようだ」という判別が出来そうです。

地域の抽出

今度は、地域を表す単語を抽出するため、形態素解析(Kuromoji)を使用します。
Kibana の Dev Tools で location というインデックスを作成して、アナライザの設定を行いました。

KibanaからのKuromojiアナライザの設定

### インデックスの作成(アナライザの設定)
PUT /location
{
  "settings": {
    "analysis": {
      "analyzer": {
        "location_kuromoji_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "location_filter"
          ]
        }
      },
      "filter": {
        "location_filter": {
          "type": "kuromoji_part_of_speech",
          "stoptags": [
            "名詞",
            "名詞-一般",
            "名詞-固有名詞",
            "名詞-固有名詞-一般",
            "名詞-固有名詞-人名",
            "名詞-固有名詞-人名-一般",
            "名詞-固有名詞-人名-姓",
            "名詞-固有名詞-人名-名",
            "名詞-固有名詞-組織",
            "名詞-代名詞",
            "名詞-代名詞-一般",
            "名詞-代名詞-縮約",
            "名詞-副詞可能",
            "名詞-サ変接続",
            "名詞-形容動詞語幹",
            "名詞-数",
            "名詞-非自立",
            "名詞-非自立-一般",
            "名詞-非自立-副詞可能",
            "名詞-非自立-助動詞語幹",
            "名詞-非自立-形容動詞語幹",
            "名詞-特殊",
            "名詞-特殊-助動詞語幹",
            "名詞-接尾",
            "名詞-接尾-一般",
            "名詞-接尾-人名",
            "名詞-接尾-サ変接続",
            "名詞-接尾-助動詞語幹",
            "名詞-接尾-形容動詞語幹",
            "名詞-接尾-副詞可能",
            "名詞-接尾-助数詞",
            "名詞-接尾-特殊",
            "名詞-接続詞的",
            "名詞-動詞非自立的",
            "名詞-引用文字列",
            "名詞-ナイ形容詞語幹",
            "接頭詞",
            "接頭詞-名詞接続",
            "接頭詞-動詞接続",
            "接頭詞-形容詞接続",
            "接頭詞-数接続",
            "動詞",
            "動詞-自立",
            "動詞-非自立",
            "動詞-接尾",
            "形容詞",
            "形容詞-自立",
            "形容詞-非自立",
            "形容詞-接尾",
            "副詞",
            "副詞-一般",
            "副詞-助詞類接続",
            "連体詞",
            "接続詞",
            "助詞",
            "助詞-格助詞",
            "助詞-格助詞-一般",
            "助詞-格助詞-引用",
            "助詞-格助詞-連語",
            "助詞-接続助詞",
            "助詞-係助詞",
            "助詞-副助詞",
            "助詞-間投助詞",
            "助詞-並立助詞",
            "助詞-終助詞",
            "助詞-副助詞/並立助詞/終助詞",
            "助詞-連体化",
            "助詞-副詞化",
            "助詞-特殊",
            "助動詞",
            "感動詞",
            "記号",
            "記号-一般",
            "記号-読点",
            "記号-句点",
            "記号-空白",
            "記号-括弧開",
            "記号-括弧閉",
            "記号-アルファベット",
            "その他",
            "その他-間投",
            "フィラー",
            "非言語音",
            "語断片",
            "未知語"
          ]
        }
      }
    }
  }
}


自然言語に含まれる、「地域」だけを抜き出したいので、品詞によるフィルタリングを施しています。
フィルタリングされる品詞は stoptags で指定します。
指定可能な stoptag の一覧は以下を参照しました。
https://github.com/apache/lucene-solr/blob/master/lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/stoptags.txt

※stoptags.txt は、Kuromoji を導入した Elasticsearch 7.6.0 では ./plugins/analysis-kuromoji/lucene-analyzers-kuromoji-8.4.0.jar に含まれています。

指定できる品詞のうち、名詞-固有名詞-地域 以外をすべてフィルタリングするように指定しています。

Kibana の Dev Tools から、地域を表す単語を含んだ文章を解析してみます。

### 解析
GET /location/_analyze
{
  "analyzer": "location_kuromoji_analyzer", 
  "text": "青森の津軽地方のスーパーの鮮魚売り場で普通に売っている、ある魚とは?"
}

青森、津軽、が抽出されました。

{
  "tokens" : [
    {
      "token" : "青森",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "津軽",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 2
    }
  ]
}

地域を含まない文章も試してみます。

### 解析
GET /location/_analyze
{
  "analyzer": "location_kuromoji_analyzer", 
  "text": "超田舎に住む人が異常に警戒する、都会では一般的なある人とは?"
}

全ての単語がフィルタリングされて、結果は空の配列となりました。

{
  "tokens" : [ ]
}

これで、自然言語に含まれている「地域」の抽出が行えました。

地域名と City ID を結びつけるデータの投入

まず、Open Weather Map から City ID の一覧(city.list.json.gz)を取得します。
JSONファイルの中身を確認してみると、ID、都市名、国、緯度経度等が含まれた都市の情報の配列になっていることがわかります。
大阪の City ID は、1853909となっているようですね。

[
  {
    "id": 833,
    "name": "Ḩeşār-e Sefīd",
    "state": "",
    "country": "IR",
    "coord": {
      "lon": 47.159401,
      "lat": 34.330502
    }
  },
  ...<略>...
  {
    "id": 1853909,
    "name": "Osaka",
    "state": "",
    "country": "JP",
    "coord": {
      "lon": 135.502182,
      "lat": 34.693741
    }
  },
  ...<略>...

日本語で地域を指定して天気予報を取得したいので、機械翻訳スクリプト等で、翻訳された都市名とIDのみのデータを作成します。

[
  {
    "id": 833,
    "name": "Ḩeşār-e Sefīd"
  },
  ...<略>...
  {
    "id": 1853909,
    "name": "大阪"
  },
  ...<略>...
  • 世界中の都市の名前なので、日本語として翻訳できないものもたくさんありました。
  • IDが割り振られている地域は20万箇所以上、日本だけでも1402箇所ありました。
    本当にたくさんの地域がありますね。

このJSONcity.list.jp.json というファイル名で保存しておきます。
Elasticsearch の location インデックスへこのデータを投入していきます。

ここでは Python3.x でスクリプトを作成しました。
事前に以下のコマンドで Elasticsearchクライアントモジュールをインストールしておく必要があります。

pip install elasticsearch

また、HOSTはlocalhost、PORTはデフォルトの9200としていますが、適宜置き換えてください。

# -*- coding: utf-8 -*-
import json
import codecs
from elasticsearch import Elasticsearch
from elasticsearch import helpers
 
def read_city_list():
 
    json_file = codecs.open('city.list.jp.json','r','utf-8')
    city_list = json.load(json_file)
    json_file.close()
 
    return city_list
 
def main():
 
    TARGET_INDEX = 'location'
 
    HOST = 'localhost'
    PORT = 9200
    es = Elasticsearch(host=HOST, port=PORT)
 
    city_list = read_city_list()
 
    # bulkでまとめて登録
    BULK_MAX_SIZE = 10000 # 一度に登録するサイズ
    actions = []
    print('-*- Begin helpers.bulk -*-')
 
    for city in city_list:
        actions.append({'_index':TARGET_INDEX, '_type':'_doc', '_source':city})
        if len(actions) >= BULK_MAX_SIZE:
            print('bulk actions...')
            helpers.bulk(es, actions)
            actions = []
 
    if len(actions) >= 0:
        print('bulk actions...')
        helpers.bulk(es, actions)
 
    print('-*- End helpers.bulk -*-')
 
if __name__ == '__main__':
    main()

登録が完了したら、Kibana から確認してみます。

### 「location」インデックスで "大阪" を検索
GET /location/_search
{
  "query": {
    "match": {
      "name": "大阪"
    }
  }
}

どうやら登録できているようです。

{
  "took" : 47,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 261,
      "relation" : "eq"
    },
    "max_score" : 14.986439,
    "hits" : [
      {
        "_index" : "location",
        "_type" : "_doc",
        "_id" : "BggidnEBWDDK_RCJr6uM",
        "_score" : 14.986439,
        "_source" : {
          "id" : 1853909,
          "name" : "大阪"
        }
      },
  ...<略>...

検索時のスコアの高い、一番最初の地域データを参照して、そのIDを取得することで、地域の名称からIDを取得できそうです。

自然言語から City ID の割り出し

事前準備ができたので、自然言語での問い合わせから City ID の割り出しまでを Go で実装してみます。

Elasticsearch のクライアントには Elastic社公式の go-elasticsearch を使用します。

実装全体は以下の通りです。

main.go

package main
 
import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "strings"
 
    "github.com/elastic/go-elasticsearch/v7"
    "github.com/elastic/go-elasticsearch/v7/esapi"
)
 
const (
    requestWeatherForecastIndex = "request-weather-forecast"
    locationIndex               = "location"
)
 
// ElasticsearchClient Elasticsearchのクライアントを内包した構造体です.
type ElasticsearchClient struct {
    es *elasticsearch.Client
}
 
// NewElasticsearchClient Elasticsearchのクライアントを内包した構造体を生成して返却します.
func NewElasticsearchClient() (*ElasticsearchClient, error) {
 
    ec := new(ElasticsearchClient)
 
    cfg := elasticsearch.Config{
        Addresses: []string{"http://localhost:9200"},
    }
    es, err := elasticsearch.NewClient(cfg)
    if err != nil {
        log.Printf("Error creating the client: %s", err)
        return nil, err
    }
 
    ec.es = es
 
    return ec, nil
 
}
 
func (ec *ElasticsearchClient) search(index string, target string, result interface{}) (ok bool) {
 
    es := ec.es
 
    body := fmt.Sprintf(`{
      "query": {
          "match": {
              %s
          }
      }
  }`, target)
 
    res, err := es.Search(
        es.Search.WithContext(context.Background()),
        es.Search.WithIndex(index),
        es.Search.WithBody(strings.NewReader(body)),
        es.Search.WithTrackTotalHits(true),
        es.Search.WithPretty(),
    )
    if err != nil {
        log.Fatalf("Error getting response: %s", err)
    }
 
    defer res.Body.Close()
 
    if res.IsError() {
        log.Printf("[%s] Error", res.Status())
    } else {
        if err := json.NewDecoder(res.Body).Decode(result); err == nil {
            return true
        }
 
        log.Printf("Error parsing the response body: %s", err)
    }
 
    return false
}
 
func (ec *ElasticsearchClient) analyzeLocation(text string) (cityName string, ok bool) {
 
    es := ec.es
 
    body := fmt.Sprintf(`{
      "analyzer": "location_kuromoji_analyzer",
      "text": "%s"
  }`, text)
 
    req := esapi.IndicesAnalyzeRequest{
        Index: locationIndex,
        Body:  strings.NewReader(body),
    }
 
    res, err := req.Do(context.Background(), es)
    if err != nil {
        log.Fatalf("Error getting response: %s", err)
    }
 
    defer res.Body.Close()
 
    if res.IsError() {
        log.Printf("[%s] Error", res.Status())
    } else {
        type result struct {
            Tokens []struct {
                Token       string `json:"token"`
                StartOffset int    `json:"start_offset"`
                EndOffset   int    `json:"end_offset"`
                Type        string `json:"type"`
                Position    int    `json:"position"`
            } `json:"tokens"`
        }
 
        var r result
        if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
            log.Printf("Error parsing the response body: %s", err)
        } else {
            if len(r.Tokens) > 0 {
                log.Printf("City is " + r.Tokens[0].Token)
                return r.Tokens[0].Token, true
            }
        }
    }
 
    return "<nil>", false
 
}
 
func isWeatherForecastRequest(ec *ElasticsearchClient, message string) (yes bool) {
 
    target := fmt.Sprintf(`"message": "%s"`, message)
 
    type result struct {
        Hits struct {
            Hits []struct {
                Source struct {
                    Message string `json:"message"`
                } `json:"_source"`
            } `json:"hits"`
        } `json:"hits"`
    }
 
    var r result
 
    if ec.search(requestWeatherForecastIndex, target, &r) {
 
        if len(r.Hits.Hits) > 0 {
            return true
        }
 
        return false
    }
 
    log.Printf("There was an error in the search.")
    return false
}
 
func searchCityID(ec *ElasticsearchClient, text string) (cityID int, ok bool) {
 
    cityName, ok := ec.analyzeLocation(text)
    if !ok {
        return 0, false
    }
 
    target := fmt.Sprintf(`"name": "%s"`, cityName)
 
    type result struct {
        Hits struct {
            Hits []struct {
                Source struct {
                    ID   int    `json:"id"`
                    Name string `json:"name"`
                } `json:"_source"`
            } `json:"hits"`
        } `json:"hits"`
    }
 
    var r result
 
    if ec.search(locationIndex, target, &r) {
 
        if len(r.Hits.Hits) > 0 {
            return r.Hits.Hits[0].Source.ID, true
        }
 
        log.Printf("No results...")
        return 0, false
    }
 
    log.Printf("There was an error in the search.")
    return 0, false
}
 
func main() {
 
    msg := "大阪の天気を教えて。"
    //msg := "天気が知りたい。"
    //msg := "大阪へ行きたい。"
 
    ec, err := NewElasticsearchClient()
    if err != nil {
        log.Fatalf("Error creating the client: %s", err)
    }
 
    if isWeatherForecastRequest(ec, msg) {
        cityID, ok := searchCityID(ec, msg)
 
        if ok {
            log.Printf("Found ID %d", cityID)
        } else {
            log.Printf("Not Found ID.")
        }
    } else {
        log.Printf("It is not a weather forecast inquiry.")
    }
 
}

各関数の解説

以下、各関数の解説です。

func NewElasticsearchClient()

func NewElasticsearchClient() (*ElasticsearchClient, error)

独自に定義している構造体 ElasticsearchClient を生成します。
あわせて、内包している elasticsearch.Client の生成と初期化を行っています。

func (ec *ElasticsearchClient) search(index string, target string, result interface{})

func (ec *ElasticsearchClient) search(index string, target string, result interface{}) (ok bool)

ElasticsearchClient のメソッド search を定義しています。
関数の引数にしている indexはインデックス名、targetJSONの変数名と値のペア、resultは Elasticsearch のレスポンスのデコード対象となるJSONをあらわした型で定義した変数のアドレスが渡されることを想定しています。

Kibana の Dev Tools で以下のように検索のリクエストをした際と同様の振る舞いになります。
※ index-name、target-name、target-value は適宜、置き換えてください。

GET /index-name/_search
{
  "query": {
    "match": {
      "target-name": "target-value"
    }
  }
}

func (ec *ElasticsearchClient) analyzeLocation(text string)

func (ec *ElasticsearchClient) analyzeLocation(text string) (cityName string, ok bool)

ElasticsearchClient のメソッド analyzeLocation を定義しています。
引数の text 内に、地域をあら合わす単語が含まれているかを解析し、単語の配列が取得できたら、最初の要素を返却します。

Kibana の Dev Tools で以下のようにリクエストした際と同様の処理を行います。

GET /location/_analyze
{
  "analyzer": "location_kuromoji_analyzer",
  "text": "解析の対象となる文字列です。適宜、置き換えてください。"
}

func isWeatherForecastRequest(ec *ElasticsearchClient, message string)

func isWeatherForecastRequest(ec *ElasticsearchClient, message string) (yes bool)

ElasticsearchClient.search を呼び出して、メッセージが「どうやら天気予報を問い合わせているようだ」ということを判別するための関数です。

func searchCityID(ec *ElasticsearchClient, message string)

func searchCityID(ec *ElasticsearchClient, message string) (cityID int, ok bool)

引数 message 内に、地域を表す単語が含まれているかを解析し、単語が見つかった場合は地域に紐づく City ID を検索して返却する関数です。

動作確認

main 関数内に定義している msg 変数の文字列を変更することで動作確認が行なえます。


msg := "大阪の天気を教えて。"
City is 大阪
Found City ID = 1853909

msg := "天気が知りたい。"
Not Found City ID.

msg := "大阪へ行きたい。"
It is not a weather forecast inquiry.

この処理を Slack の Botアプリへ組み込み、Amazon ES と連携して取得できた City ID を Open Weather Map の API で指定することで、冒頭の『やりたいこと』が実現できます。

まとめ

  • 製品レベルの質疑応答システムはもっと複雑で意図解釈の精度も高いと思いますが、Elasticsearchへ投入するデータやアナライザの設定、検索の仕方で天気予報以外にも様々な事に応用できる可能性を感じました。

  • 改善の余地

    • 意図解釈の精度アップ
      • メッセージの意図の解釈が、request-weather-forecast インデックスに登録されているドキュメントの検索にヒットするかどうか、という大雑把な判定なので、例えば
        「○○の天気を知りたくありません」
        といった意地悪な(?)メッセージも「天気予報が要求されている」と解釈されてしまいます。
      • 検索時のスコアや、登録しているドキュメント自体、またドキュメントへ付与する要素、検索の仕方等で精度を上げる工夫ができるのではないかと考えています。
    • 文脈を考慮する
      • 本記事の内容では、利用者は「天気が知りたい」という事と、「地域」を一度に伝える必要があります。
        例えば、利用者が「天気を教えて」とだけ伝えると
        システムは「どこの天気が知りたいですか?」と返答したり、
        また、以前通知した地域を記録しておいてわざわざ地域を指定しなくても天気予報を返す等でもっと賢い質疑応答システムが作れそうです。