Elasticsearch + Sudachi + Go + echoで城検索APIを作ってみた


ElasticsearchとGo言語の学習がてらに検索APIを作ってみたので、成果物の利用方法、構成について、困ったことについてまとめてみました。

実行環境
https://github.com/takenoko-gohan/castle-search-api-environment
検索API
https://github.com/takenoko-gohan/castle-search-api

環境構築

環境構築では、dockerとdocker-composeを使用します。

git clone https://github.com/takenoko-gohan/castle-search-api-environment.git
cd castle-search-api-environment
docker-compose build --no-cache
docker-compose up -d
# elasticsearch 起動後しばらくしてから実行してください
sh es/script/es_init.sh 

利用方法

検索APIを利用する際は下記のような形でリクエストします。クエリパラメーター「keyword」では検索時のキーワードを指定します。クエリパラメーター「prefecture」では絞り込みたい都道府県を指定します。
下記コマンドは、都道府県が「福島県」でキーワード「鶴ヶ城」を含まれている城を検索します。

curl -XGET "http://localhost:8080/search?keyword=鶴ヶ城&prefecture=福島県"

構成

Elasticsearch

インデックスの設定

インデックスは下記のとおりに設定しました。
検索時、インデックス時のanalyzerは、トークンに分割するときにsearchモードを使用、品詞が助詞・助動詞・句点・読点のものは削除、トークンをSudachiNormalizedFormAttributeに変化するように設定しています。


index_settings.json
{
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 0,
      "analysis": {
        "tokenizer": {
          "sudachi_tokenizer": {
            "type": "sudachi_tokenizer",
            "split_mode": "C",
            "discard_punctuation": true,
            "resources_path": "/usr/share/elasticsearch/config/sudachi",
            "settings_path": "/usr/share/elasticsearch/config/sudachi/sudachi.json"
          }
        },
        "analyzer": {
          "sudachi_analyzer": {
            "filter": [
              "my_searchfilter",
              "my_posfilter",
              "sudachi_normalizedform"
            ],
            "tokenizer": "sudachi_tokenizer",
            "type": "custom"
          }
        },
        "filter":{
          "my_searchfilter": {
            "type": "sudachi_split",
            "mode": "search"
          },
          "my_posfilter":{
            "type":"sudachi_part_of_speech",
            "stoptags":[
              "助詞",
              "助動詞",
              "補助記号,句点",
              "補助記号,読点"
            ]
          }
        }
      }
    }
  }
}


インデックスのマッピング

インデックスのマッピングは下記のとおりにしました。

フィールド タイプ 備考
name text 城の名前
prefectures keyword 都道府県
rulers text 城の城主
description text 城の概要


index_mappings.json
{
  "properties": {
    "name": {"type" : "text", "analyzer": "sudachi_analyzer"},
    "prefecture": {"type": "keyword"},
    "rulers": {"type": "text", "analyzer": "sudachi_analyzer"},
    "description": {"type": "text", "analyzer": "sudachi_analyzer"}
  }
}


ドキュメント

検索用インデックスにはWikipediaの「Category:日本100名城」をもとに作成したデータを挿入しています。

検索API

検索APIはGo言語のフレームワーク「echo」とElasticsearchクライアント「go-elasticsearch」を使用して作成しました。
APIでは最初に受け取ったパラメーターをもとにElasticsearchへのクエリを作成・実行し、検索にヒットしたドキュメントの各フィールドをそのままクライアントにレスポンスする簡単な作りになっています。

クエリパラメーター「keyword」を使用しての検索では、boostを使用して「name > rulers > description」の順でスコアの重み付けをしております。
クエリパラメーター「prefecture」を使用しての検索では、フィールド「prefecture」に対する完全一致検索を行うようにしています。


クエリの作成
package search

func createQuery(q *Query) map[string]interface{} {
    query := map[string]interface{}{}
    if q.Keyword != "" && q.Prefecture != "" {
        query = map[string]interface{}{
            "query": map[string]interface{}{
                "bool": map[string]interface{}{
                    "must": []map[string]interface{}{
                        {
                            "bool": map[string]interface{}{
                                "should": []map[string]interface{}{
                                    {
                                        "match": map[string]interface{}{
                                            "name": map[string]interface{}{
                                                "query": q.Keyword,
                                                "boost": 3,
                                            },
                                        },
                                    },
                                    {
                                        "match": map[string]interface{}{
                                            "rulers": map[string]interface{}{
                                                "query": q.Keyword,
                                                "boost": 2,
                                            },
                                        },
                                    },
                                    {
                                        "match": map[string]interface{}{
                                            "description": map[string]interface{}{
                                                "query": q.Keyword,
                                                "boost": 1,
                                            },
                                        },
                                    },
                                },
                                "minimum_should_match": 1,
                            },
                        },
                        {
                            "bool": map[string]interface{}{
                                "must": []map[string]interface{}{
                                    {
                                        "term": map[string]interface{}{
                                            "prefecture": q.Prefecture,
                                        },
                                    },
                                },
                            },
                        },
                    },
                },
            },
        }
    } else if q.Keyword != "" && q.Prefecture == "" {
        query = map[string]interface{}{
            "query": map[string]interface{}{
                "bool": map[string]interface{}{
                    "should": []map[string]interface{}{
                        {
                            "match": map[string]interface{}{
                                "name": map[string]interface{}{
                                    "query": q.Keyword,
                                    "boost": 3,
                                },
                            },
                        },
                        {
                            "match": map[string]interface{}{
                                "rulers": map[string]interface{}{
                                    "query": q.Keyword,
                                    "boost": 2,
                                },
                            },
                        },
                        {
                            "match": map[string]interface{}{
                                "description": map[string]interface{}{
                                    "query": q.Keyword,
                                    "boost": 1,
                                },
                            },
                        },
                    },
                    "minimum_should_match": 1,
                },
            },
        }
    } else if q.Keyword == "" && q.Prefecture != "" {
        query = map[string]interface{}{
            "query": map[string]interface{}{
                "bool": map[string]interface{}{
                    "must": []map[string]interface{}{
                        {
                            "term": map[string]interface{}{
                                "prefecture": q.Prefecture,
                            },
                        },
                    },
                },
            },
        }
    }

    return query
}


困ったとこ

ひとまず作成したところで動作確認をしたときに、下記のレスポンスを受け取りました。

curl -XGET "http://localhost:8080/search?keyword=若松城&prefectures=福島県"
{
    "message": "検索に成功しました。",
    "Results": [
        {
            "name": "若松城",
            "prefecture": "福島県",
            "rulers": [
                "蒲生氏、上杉氏、加藤氏、保科氏・会津松平家"
            ],
            "description": "若松城(わかまつじょう)は、福島県会津若松市追手 町1-1にあった日本の城である。地元では一般的に鶴ヶ城(つるがじょう)といい、地元以外では会津若松城と呼ばれることも多い。文献史上では黒川城(くろかわじょう)、会津城とされることもある。国の史跡としては、若松城跡(わかまつじょうあと)の名称で指定されている。"
        },
        {
            "name": "二本松城",
            "prefecture": "福島県",
            "rulers": [
                "加藤氏",
                "丹羽氏",
                "蒲生氏",
                "二本松氏",
                "上杉氏",
                "伊達氏"
            ],
            "description": "二本松城(にほんまつじょう)は、福島県二本松市郭内にある日本の城(平山城)。日本100名城の一つ。別名、霞ヶ城・白旗城と呼ばれる。平成19年(2007年)7月26日、二本松城跡として国の史跡に指定された。「霞ヶ城公園」として日本さくら名所100 選に選定されている。"
        },
        {
            "name": "白河小峰城",
            "prefecture": "福島県",
            "rulers": [
                "松平氏",
                "丹羽氏",
                "白河結城氏",
                "蒲生氏",
                "阿部氏_(徳川譜代)"
            ],
            "description": "白河小峰城(しらかわこみねじょう)は、福島県白河市(陸奥国白河郡白河)にあった日本の城。単に白河城または小峰城ともいう。国の史跡に指定されている。ほか、日本100名城のひとつに数えられる。"
        }
    ]
}

検索結果は若松城のみヒットすること想定したのですが、福島県に存在する他の城もヒットしていました。
そこで下記のコマンドどのように解析されるか確認したところ、若松城は「若松/城」で分かち書きされるようです。
そのため、検索時に分かち書きされた「城」で他の城もヒットしてしまったようです。

curl -XGET 'http://localhost:9200/castle/_analyze?pretty' -H 'Content-Type: application/json' -d '
{
  "text": "若松城",
  "analyzer": "sudachi_analyzer"
}'
{
  "tokens" : [
    {
      "token" : "若松",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "城",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}

そこで、こちらの記事を参考に下記のような形でCSVファイルを作成し、analyzerに各城の名前が記載されいるユーザー辞書を登録しました。

若松城,4786,4786,5000,若松城,名詞,固有名詞,一般,*,*,*,ワカマツジョウ,若松城,*,*,*,*,*

ユーザー辞書登録後、解析結果を確認しましたが、今度は固有名詞「若松城」となりました。

curl -XGET 'http://localhost:9200/castle/_analyze?pretty' -H 'Content-Type: application/json' -d '
{
  "text": "若松城",
  "analyzer": "sudachi_analyzer"
}'
{
  "tokens" : [
    {
      "token" : "若松城",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    }
  ]
}

再度、検索APIで検索したところ想定どおり若松城のみヒットするようになりました。

curl -XGET "http://localhost:8080/search?keyword=若松城&prefecture=福島県"
{
    "message": "検索に成功しました。",
    "Results": [
        {
            "name": "若松城",
            "prefecture": "福島県",
            "rulers": [
                "蒲生氏、上杉氏、加藤氏、保科氏・会津松平家"
            ],
            "description": "若松城(わかまつじょう)は、福島県会津若松市追手 町1-1にあった日本の城である。地元では一般的に鶴ヶ城(つるがじょう)といい、地元以外では会津若松城と呼ばれることも多い。文献史上では黒川城(くろかわじょう)、会津城とされることもある。国の史跡としては、若松城跡(わかまつじょうあと)の名称で指定されている。"
        }
    ]
}

しかし、別の問題が発生しました。
今度は、keywordを若松、prefectureを福島県で検索したところ何もヒットしませんでした。
ユーザー辞書を登録したことによって、若松城が「若松/城」で分かち書きされなくなったためヒットしなかったようです。

curl -XGET "http://localhost:8080/search?keyword=若松&prefecture=福島県"
{
    "message": "検索に成功しました。",
    "Results": null
}

こちらによるとCSVファイルの16列目にA単位に分割するための情報を記載することができるようです。
そこでCSVファイルを下記の形で修正し、searchモードでC単位とA単位で分割できるようにしました。(若松城、二本松城、白河小峰城だけですが...)

若松城,4786,4786,5000,若松城,名詞,固有名詞,一般,*,*,*,ワカマツジョウ,若松城,*,C,650091/368637,*,*
二本松城,4786,4786,5000,二本松城,名詞,固有名詞,一般,*,*,*,ニホンマツジョウ,二本松城,*,C,281483/368637,*,*
白河小峰城,4786,4786,5000,白河小峰城,名詞,固有名詞,一般,*,*,*,シラカワコミネジョウ,白河小峰城,*,C,584799/394859/368637,*,*
curl -XGET 'http://localhost:9200/castle/_analyze?pretty' -H 'Content-Type: application/json' -d '
{
  "text": "若松城",
  "analyzer": "sudachi_analyzer"
}'
{
  "tokens" : [
    {
      "token" : "若松城",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "若松",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "城",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}

これで、keywordを若松、prefectureを福島県で検索しても若松城がヒットするようになりました。
思ったとおりに検索できるようにするのは中々大変ですね。

curl  -XGET "http://localhost:8080/search?keyword=若松城&prefecture=福島県"
{
    "message": "検索に成功しました。",
    "Results": [
        {
            "name": "若松城",
            "prefecture": "福島県",
            "rulers": [
                "蒲生氏、上杉氏、加藤氏、保科氏・会津松平家"
            ],
            "description": "若松城(わかまつじょう)は、福島県会津若松市追手 町1-1にあった日本の城である。地元では一般的に鶴ヶ城(つるがじょう)といい、地元以外では会津若松城と呼ばれることも多い。文献史上では黒川城(くろかわじょう)、会津城とされることもある。国の史跡としては、若 松城跡(わかまつじょうあと)の名称で指定されている。"
        }
    ]
}

参考

Elasticsearch + Sudachi + Docker でユーザー辞書を作ってみるハンズオン
Sudachi ユーザー辞書作成方法
elasticsearch-sudachi README
go-elasticsearch README