29
30

More than 5 years have passed since last update.

Elasticsearchを使って、青空文庫検索エンジンの作り方を 1 から丁寧に解説してみる

Last updated at Posted at 2018-12-01

kaibaと申します。

本年はElasticsearch関連でこんなことがありました。

  • 業務でElasticsearchを導入する機会に恵まれ
  • 技術書典で検索だけじゃない Elasticsearch 入門という本を書き、100冊近く売れ
  • その他、アウトプットもぼちぼち出して
  • 中の人johtaniさんにも良くしていただきました

Elasticsearch元年になり、Elasticsearchで年を締めくくろうとしております。

今回は検索だけじゃない Elasticsearch 入門に対する発展編として、
青空文庫検索システムを例に、できる限りわかりやすく書いてみたいと思います。
この本は、僕も時々リファレンス的に見直すことがあり、なかなか良い本だと思いますので是非読んでみてください。

何を作るか

青空文庫に作家別作品一覧拡充版:全て(CSV 形式、UTF-8、zip 圧縮)というのがありました。
sjisだろうな〜、文字コード変換やだな〜、と思っていたので、感動しました。
悪しきken_all.csvも見習ってほしい。

これを使って、「青空文庫検索エンジン」を作ろうと目指そうと思います。

結果だけ見たい&コピペ辛い方向け

仕様

Googleをリスペクトしつつ、中を知っている感じの仕様になりますが、以下のようにします。

  • サジェストを有効に使って補助したい。ここは単純に前方一致が良い。
    • わがはい =>「吾輩は猫である」
    • みやざわ =>「宮沢賢治」
  • もしかして
    • 吾輩は猫であろ =>「吾輩は猫である」
    • 宮沢賢二 =>「宮沢賢治」
  • 作品タイトル、著者名で検索できる。ここはサジェストがあるので日本語の検索を意識する。
    • 吾輩は猫 =>「吾輩は猫である」
    • わがはいはねこ =>「吾輩は猫である」
    • みやざわ => 宮沢賢治の書籍がヒット
    • 京都 =>「東京都」はヒットしない。

設計

仕様を満たすために以下の設計にします。
業務だと「Googleみたいにして!」って言われることが多いので、ここはちゃんとしておかないと痛い目を見ます。

サジェスト

  • 単純に前方一致させるためにデフォルトのanalyserを使う
  • データ名は *_suggestionとする
  • completion suggesterという機能で実現します

もしかして

  • こちらも標準のanalyerで単純に近いものを探します。
  • term suggesterという機能で実現します

検索

  • サジェストである程度 カバーできるはずなので、日本語を意識して検索する
    • 「こんにちは東京都」という書籍に対して、「京都」でヒットさせたくない

環境構築

公式の Docker Imageを使ってみます。
Docker知らない人も、怖がらずに。
いやいや、DockerじゃなくてAWS Elasticsearch Serviceでやりたいんだ、という方は、検索だけじゃない Elasticsearch 入門をどうぞ!(しつこい)

docker pull docker.elastic.co/elasticsearch/elasticsearch:6.5.1
docker run -p 9200:9200 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.5.1

なんかElasticsearchのdocker imageをpullしてきて
port 9200で動かした、というのがわかれば十分です。

ついでに、日本語向けのプラグインを入れます。
公式のDocker Imageには日本語プラグイン設定済みのものがなかったので、
職人が手作業で日本語のプラグインを入れちゃいます。
手作業が嫌な人はこんなかんじで Dockerfileを作ると良いでしょう。
僕が用意しても良かったんですが、こちらの方がわかりやすいかな、と思いまして…。
停止すると挿入したデータは消えちゃいます…。
このへん、Dockerの勉強にも良いかと。

# コンテナIDを調べる
docker ps
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED             STATUS              PORTS                              NAMES
0d008241f0e9        docker.elastic.co/elasticsearch/elasticsearch:6.5.1   "/usr/local/bin/dock…"   33 seconds ago      Up 32 seconds       0.0.0.0:9200->9200/tcp, 9300/tcp   festive_bhaskara

# コンテナにshでつなぐインストール
$ docker exec -it 0d008241f0e9 sh

sh-4.2# elasticsearch-plugin install analysis-kuromoji
-> Downloading analysis-kuromoji from elastic
[=================================================] 100%??
-> Installed analysis-kuromoji

# 再起動して反映
exit
docker restart 0d008241f0e9

_nodes/pluginsにGETして、kuromojiの存在を確認します。

curl http://localhost:9200/_nodes/plugins\?pretty -X GET | grep kuromoji
          "name" : "analysis-kuromoji",
          "description" : "The Japanese (kuromoji) Analysis plugin integrates Lucene kuromoji analysis module into elasticsearch.",
          "classname" : "org.elasticsearch.plugin.analysis.kuromoji.AnalysisKuromojiPlugin",

良さそう。

mapping

どのフィールドをどのように解析するか、の設定をします。
日本語の設定が不要であれば、Elasticsearchがよしなにやってくれますので、
必須の操作ではありません。
今回は日本語を使う要件がありますので設定します。

挿入するデータ

以下のデータを入れることにします。

データ名 内容
id 青空文庫の本ID 002672
title タイトル 吾輩は猫である
author 著者名 夏目漱石
title_yomi タイトル(よみがな) わがはいはねこである
author_yomi 著者名(よみがな) なつめそうせき
*_ja 上記の日本語検索用データ
*_suggestion 上記のサジェスト用データ

id は一意のIDでこれをキーにdocumentを更新します。

mapping

上記のデータを挿入するための設定です。

mapping.json
{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "ja_tokenizer": {
            "type": "kuromoji_tokenizer"
          }
        },
        "analyzer": {
          "ja_analyzer": {
            "type": "custom",
            "tokenizer": "ja_tokenizer"
          }
        }
      }
    }
  },
  "mappings": {
    "books": {
      "dynamic_templates": [
        {
          "ja_string": {
            "match_mapping_type": "string",
            "match": "*_ja",
            "mapping": {
              "type": "text",
              "analyzer": "ja_analyzer"
            }
          }
        },
        {
          "yomi_string": {
            "match_mapping_type": "string",
            "match": "*_suggestion",
            "mapping": {
              "type": "completion"
            }
          }
        }
      ],
      "properties": {
        "title": {
          "type": "text",
          "copy_to": ["title_ja", "title_suggestion"]
        },
        "title_ja": {
          "type": "text",
          "store": true
        },
        "title_yomi": {
          "type": "text",
          "copy_to": ["title_yomi_suggestion"]
        },
        "author": {
          "type": "text",
          "copy_to": ["author_ja", "author_suggestion"]
        },
        "author_ja": {
          "type": "text",
          "store": true
        },
        "author_yomi": {
          "type": "text",
          "copy_to": ["author_yomi_suggestion"]
        }
      }
    }
  }
}
  • title, authorをコピーしてtitle_ja, author_jaを作成しています。
  • *_jaはcustum analyzerを定義し、tokenizerに kuromoji_tokenizer を使用しています。

設定を反映します。

curl http://localhost:9200/aozora\?pretty -X PUT -H "Content-Type: application/json" -d @mapping.json

CSVの全データを挿入する

プログラム

雑にもほどがありますが、GemなしのRubyで書いてみました。

require "csv"
require "net/http"
require "uri"
require "json"

INDEX_ID = 0
INDEX_TITLE = 1
INDEX_TITLE_YOMI = 2
INDEX_AUTHOR_SEI = 15
INDEX_AUTHOR_MEI = 16
INDEX_AUTHOR_SEI_YOMI = 17
INDEX_AUTHOR_MEI_YOMI = 18

class Book
  # FIXME: 環境ごとに違うはずでこんなところに書いちゃ駄目だぞ!
  ENDPOINT = "http://localhost:9200/aozora"

  def initialize(id, title, title_yomi, author, author_yomi)
    @id = id
    @title = title
    @title_yomi = title_yomi
    @author = author
    @author_yomi = author_yomi
  end

  def post_index
    # NOTE: IDはURLの末尾に指定します。RESTですね。
    uri = URI.parse("#{ENDPOINT}/books/#{@id}")
    http = Net::HTTP.new(uri.host, uri.port)
    req = Net::HTTP::Post.new(uri.request_uri)
    req["Content-Type"] = "application/json"
    req.body = to_json
    res = http.request(req)
    p "#{@title} #{res.response.code}"
  end

  private

  def to_json
    # メタプロするとパラメータの追加に動的に対応できそう。メタプロは用法用量を正しく(略)
    {title: @title, title_yomi: @title_yomi, author: @author, author_yomi: @author_yomi}.to_json
  end
end

# NOTE: CSVを同じディレクトリにおいてね!
# FIXME: 本来であれば一括で挿入(bulk insert)できるのでそうした方が速いよ!
CSV.foreach("list_person_all_extended_utf8.csv") do |line|
  next unless line[INDEX_ID] =~ /^[0-9]+/
  p line[INDEX_TITLE]
  Book.new(
        line[INDEX_ID],
        line[INDEX_TITLE],
        line[INDEX_TITLE_YOMI],
        line[INDEX_AUTHOR_SEI] + line[INDEX_AUTHOR_MEI],
        line[INDEX_AUTHOR_SEI_YOMI] + line[INDEX_AUTHOR_MEI_YOMI]
  ).post_index
end

要件を確認していく

サジェスト

前方一致でサジェストが得られるか試します。

suggest_query.json
{
  "suggest": {
    "book_suggest_title": {
      "prefix": "わがは",
      "completion": {
        "field": "title_yomi"
      }
    },
    "book_suggest_author": {
      "prefix": "わがは",
      "completion": {
        "field": "author_yomi"
      }
    }
  }
}

タイトル、著者のよみがなから探します。
今回はauthorでヒットしませんが、実際のクエリを意識してこのようにしています。

curl http://localhost:9200/aozora/books/_search\?pretty -X POST -H "Content-Type: application/json" -d @suggest_query.json
  "suggest" : {
    "book_suggest_author" : [
      {
        "text" : "わがは",
        "offset" : 0,
        "length" : 3,
        "options" : [ ]
      }
    ],
    "book_suggest_title" : [
      {
        "text" : "わがは",
        "offset" : 0,
        "length" : 3,
        "options" : [
          {
            "text" : "『わがはいはねこである』げへんじじょ",
            "_index" : "aozora",
            "_type" : "books",
            "_id" : "002672",
            "_score" : 1.0,
            "_source" : {
              "title" : "『吾輩は猫である』下篇自序",

良さそう!

もしかして

term_suggest_query.json
{
  "suggest": {
    "suggest_title_yomi": {
      "text": "なつめそうせい",
      "term": {
        "field": "title_yomi_suggestion"
      }
    },
    "suggest_title": {
      "text": "なつめそうせい",
      "term": {
        "field": "title_suggestion"
      }
    },
    "suggest_author_yomi": {
      "text": "なつめそうせい",
      "term": {
        "field": "author_yomi_suggestion"
      }
    },
    "suggest_author": {
      "text": "なつめそうせい",
      "term": {
        "field": "author_suggestion"
      }
    }
  }
}

作者名読みのサジェストを得る例です。
今回はサジェスト_author_yomi以外でヒットしませんが、実際のクエリを意識してこのようにしています。

curl http://localhost:9200/aozora/books/_search\?pretty -X POST -H "Content-Type: application/json" -d @term_suggest_query.json
    "suggest_author_yomi" : [
      {
        "text" : "なつめそうせい",
        "offset" : 0,
        "length" : 7,
        "options" : [
          {
            "text" : "なつめそうせき",
            "score" : 0.85714287,
            "freq" : 105
          }
        ]
      }
    ],

よさそう!

作品タイトル、著者名で検索

query.json
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": "吾輩"
          }
        },
        {
          "match": {
            "title_yomi": "吾輩"
          }
        },
        {
          "match": {
            "author": "吾輩"
          }
        },
        {
          "match": {
            "author_yomi": "吾輩"
          }
        }
      ]
    }
  }
}
curl http://localhost:9200/aozora/books/_search\?pretty -X POST -H "Content-Type: application/json" -d @query.json
  "hits" : {
    "total" : 63,
    "max_score" : 10.369913,
    "hits" : [
      {
        "_index" : "aozora",
        "_type" : "books",
        "_id" : "058086",
        "_score" : 10.369913,
        "_source" : {
          "title" : "我輩の智識吸収法",
          "title_yomi" : "わがはいのちしききゅうしゅうほう",
          "author" : "大隈重信",
          "author_yomi" : "おおくましげのぶ"
        }
      },

よさそう。

まとめ

  • 検索もサジェストも難しい!
  • Googleみたいに! って絶対言われるから、ちゃんと調査、設計して合意を取るのが重要かも。
  • ElasticsearchもGoogleもすごい!
29
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
30