AppBrew tech blog

AppBrewのエンジニアチームの日々です

スタートアップでも出来る分析基盤

こんにちは、遊撃エンジニア兼代表の深澤です。
最近はインフラからサーバーをメインにいじっています。昔はクライアントも書いていました。

弊社は、「再現性を持ってユーザーに刺さるプロダクトをつくる」ことを目指しチームビルディングをしています。
なので、創業からのてんやわんや(スタートアップは皆そうです)の中で、数字とちゃんと向き合う方法を模索してきました。
結果として、今現在どういった分析基盤で仕事をしているかに関して書きたいと思います。

※注

  • あくまで、2017年初頭にサービスインしたLIPSの分析基盤を、分析について何も知らない人間が組んできたという話です。開始の技術選定からは1年以上経っているので、参考程度にお願いします。
  • 技術的には枯れた内容しかやっていません。分析は、技術だけでなく、掛けるコストやオペレーションに組み込むレベルの話が出来てはじめて意味をなすものなので、そちらの話がメインです。

1. 前提:限られたリソース

AppBrewは、コスメの口コミアプリであるLIPSを運営している会社です。

  • イベントログ数は数千万per dayくらいの規模
  • アプリなので、ログ分析からのファネル改善・リテンション改善が最重要な業務であり、命
  • データ専任をおけるほどのリソースがない状態だった

2. スタートアップの分析基盤に必要なもの

  • 技術(凝りすぎない程度に、とはいえちゃんとわかった上で組みたい
  • 文化(リソース不足の状況だと、こちらが重要かなと思います

3. 技術面:基盤構成

LIPSのサービスはAWS上に載っています。 f:id:appbrew:20180326162227p:plain

特にバックエンドの部分を抜き出すと上のようになっています。 ポイントとしては、

  • EC2の各インスタンス上からfluentdでkinesisに投げる
  • kinesisからfirehoseでredshiftのインスタンスに流し込む
  • kinesisからfirehoseでS3に取り置く(Athenaで引ける(引くとは言っていない
  • kinesisからlambdaを発火して、低レイテンシーでデータ分析用に使いたいデータ・サービスから改めて使いたいデータ(クリックログなど)をDynamoDBに流し込む
  • Redash(Sassバージョン)を使い、Redshiftをメインのバックエンドとして利用する
  • Redashは非エンジニア含む社員全員に公開している

他に蛇足として

  • メインのRDSから必要なテーブルをdata pipelineでredshiftにdumpしているが、管理が面倒・タスクのトレーサビリティが低いので直近でembulkを利用する計画
  • firebaseを利用しているので、BigQueryに流し込んではいる(あまり使えていない

というような構成になっています。 個別に説明すると、

Redshiftを利用している理由

  • 個人的に慣れがあった
  • サーバー立てる形式なので、高いとはいえ価格が見積もりやすく、皆が触れるBIバックエンドとしては安心感(クエリ課金は怖い
  • 標準のpsqlがほぼ使えるので、導入しやすい(BigQueryがstandardSQLに対応した前後がサービスインのタイミングでした

Redshiftのデメリット

  • スキーマを自前できっちり組まないといけない
  • インデックスを適切に貼ったうえで、vacuumを定期的に回さないといけない(CIでやってます

なので、わかる人一人しかいない状態などであれば、firebaseからBigQueryに流し込んでいるものをそのまま使うなどがいいかもしれません。
スピードに関しては、インスタンスサイズ・スキーマやクエリ・インデックスの状態によって違い、完全にどちらが早いというようなデータは見つからなかったので言及は避けます。
参考データは以下

aws.amazon.com

www.periscopedata.com

Dynamoを利用している理由

  • サービス側から引きたいデータはredshift上ではスケーラビリティ・レスポンスタイムの関係で無理がある(当然
  • クリックログやベクトルのようなデータだと、RDBMSはあまり向いてはいないし、メインのDB利用は密結合になるので嫌だった
  • 永続化したいのでオンメモリのKVS等にのせるのも不安があった
  • AWSを使っているので、Dynamoが選択肢に上がった

また、クリックログのようなデータであれば、スケーラビリティや速度(ある程度)は求められますが、ImmutableでConsistencyは不要なのでDynamoを導入しました。 ここからPythonの分析用インスタンスでデータを引いて、ごにょっています。

以下のリンクもかなり参考にしました。

tech.gunosy.io

とはいえ、そもそもスタートアップであれば普通はここまでやらなくていいかと思います。

4. 文化面:皆で分析したい

スタートアップのあるあるとして

  • 肝心なところにログを仕込んでいない→欲しい時に数値取れない
  • 一人に分析タスクが集中して、ボトルネック化・つらい
  • リリースフローが雑なのですぐログが壊れる

三番目はちゃんとやれって話なのですが、上2つは、データに皆で触れる文化形成の問題が大きいのかなと思います。
AppBrewでは、意識を高めるために、オープン&フラットな組織に加え、皆でクエリを書くようなRedash活用を心がけています。

Redash活用法

  • 皆でログ仕込む・クエリ書く(エンジニアは5人、クエリを書く人は7人 全員が毎日書いているわけではないですが、ビズサイド含めかなりの人数がRedashで書く側に回っています
  • slackに垂れ流す(毎日Redash見にいく習慣がないとなかなか活用出来ないので、slackで特定のチャンネルにKPI定期で貼っています
  • issueにもredashのクエリをちゃんと貼る(まだ始めたばかりの習慣ですが、定性的な内容も出来る限り数字で裏を取ろうとします
  • ちゃんと改善につなげる(CTRを上げたり、アクション率を上げたり、ちゃんと数字につなげるように業務自体を進めます
  • 失敗した施策は取り下げる(リリース後も数字を確認して、失敗したらもとに戻すことを心がけます

※画像はイメージです

f:id:appbrew:20180326183532j:plain

全部当たり前のことなのですが、全部が普段の業務レベルで染み付いていないとなかなか定着しないのも事実だと思います。

弊社もまだまだ改善中なので、こんな事例あるよというのがあれば、是非教えてください!

5. 最後に

今回は、AppBrewではベンチャーなりにちゃんとデータを活用していますという話を紹介しました。

このようにデータの下で仕事をすることで、再現性を持つ新規立ち上げプロセスを作ることを目標にしており、新規事業等も計画しています。

もしご興味がある方がいらっしゃったら是非私のtwitter
Yuta Fukazawa (@YFuka86) | Twitter
か、以下のwantedly(データ・サーバエンジニアの募集書いてませんでした)までご気軽にお願いします!

www.wantedly.com

RailsプロジェクトでのElasticsearchとの付き合い方について

こんにちは、AppBrewのエンジニアの吉野です。 前回の記事ではElasticsearchとはどういうものか、ということについて書きました。

tech.appbrew.io

この記事では、

①ローカルでのElasticsearchの環境構築

から、

Railsとどうつなぎ合わせるか

③実運用上での注意点など

について書いていきたいと思います。

動作環境

Rails 4.2.8 Elasticsearch 2.4.5

①ローカルでのElasticsearchの環境設定

開発するにあたり、ローカルで環境がほしいと思います。 ElasticSearchのAnalyzerは標準としていくつかありますが、日本語のTokenizeなどをするならば Analyzer として、kuromoji Analyzer、icu_normalizer あたりを入れていきましょう。 GUIはelasticsearch-headを入れておくことにします。

brew install elasticsearch@2.4
echo 'export PATH="/usr/local/opt/elasticsearch@2.4/libexec/bin:$PATH"' >> ~/.bash_profile
source ~/.bash_profile
plugin install analysis-kuromoji analysis-icu mobz/elasticsearch-head

次に設定を多少いじりましょう /usr/local/opt/elasticsearch@2.4/libexec/config/elasticsearch.ymlに以下の行を追加しておくと何かと便利です。

script.inline: on
script.indexed: on
script.engine.groovy.inline.search: on
script.search: on
script.groovy.sandbox.enabled: true

起動は elasticsearchで可能です。

$ elasticsearch
[2018-02-25 16:53:50,683][INFO ][node                     ] [Mandroid] version[2.4.6], pid[1939], build[5376dca/2017-07-18T12:17:44Z]
[2018-02-25 16:53:50,683][INFO ][node                     ] [Mandroid] initializing ...
[2018-02-25 16:53:51,358][INFO ][plugins                  ] [Mandroid] modules [reindex, lang-expression, lang-groovy], plugins [head, analysis-kuromoji, analysis-icu], sites [head]
[2018-02-25 16:53:51,420][INFO ][env                      ] [Mandroid] using [1] data paths, mounts [[/ (/dev/disk1)]], net usable_space [40.6gb], net total_space [232.5gb], spins? [unknown], types [hfs]
[2018-02-25 16:53:51,420][INFO ][env                      ] [Mandroid] heap size [990.7mb], compressed ordinary object pointers [true]
[2018-02-25 16:53:51,420][WARN ][env                      ] [Mandroid] max file descriptors [10240] for elasticsearch process likely too low, consider increasing to at least [65536]
[2018-02-25 16:53:53,126][INFO ][node                     ] [Mandroid] initialized
[2018-02-25 16:53:53,126][INFO ][node                     ] [Mandroid] starting ...
[2018-02-25 16:53:53,213][INFO ][transport                ] [Mandroid] publish_address {127.0.0.1:9300}, bound_addresses {[fe80::1]:9300}, {[::1]:9300}, {127.0.0.1:9300}
[2018-02-25 16:53:53,219][INFO ][discovery                ] [Mandroid] elasticsearch_yosshi/yI1KRtR_TGCC34vpmzoJEQ
[2018-02-25 16:53:56,268][INFO ][cluster.service          ] [Mandroid] new_master {Mandroid}{yI1KRtR_TGCC34vpmzoJEQ}{127.0.0.1}{127.0.0.1:9300}, reason: zen-disco-join(elected_as_master, [0] joins received)
[2018-02-25 16:53:56,281][INFO ][http                     ] [Mandroid] publish_address {127.0.0.1:9200}, bound_addresses {[fe80::1]:9200}, {[::1]:9200}, {127.0.0.1:9200}
[2018-02-25 16:53:56,281][INFO ][node                     ] [Mandroid] started
[2018-02-25 16:53:56,363][INFO ][gateway                  ] [Mandroid] recovered [6] indices into cluster_state
[2018-02-25 16:53:57,592][INFO ][cluster.routing.allocation] [Mandroid] Cluster health status changed from [RED] to [YELLOW] (reason: [shards started [[post_development_3][1]] ...]).

デフォルトはlocalhostの9200番でサーバーが立ちます。 また、 http://localhost:9200/_plugin/head/ へとアクセスすると香ばしいGUIでElasticsearchの中にあるデータを確認できます。

f:id:yosshi0774:20180314000102p:plain

ローカルの設定は以上です。

Railsとどうつなぎ合わせるか

ここからRails側でElasticsearchを使うための設定について書いていきます。

自前で必要なAPIを叩くようなものをwrapしていっても良いわけですが、 便利なものが世の中にはあるので使っていきましょう。 今回使うGemは以下の4つです。

gem 'elasticsearch', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git'
gem 'elasticsearch-dsl', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git'
gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'
gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'

それぞれのgemは以下のような機能を提供してくれます。

  • elasticsearch
    • ElasticsearchのrubyのAPIClientを提供してくれています。
    • 通信部分で気になることがあればこれを見ましょう
  • elasticsearch-dsl
    • Elasticsearchのmappingの定義、検索のクエリ設計などをrubyっぽく書くことができるようになります。
    • このライブラリ中を見てみると検索クエリの書き方のサンプルが多くのっているのでわからなくなったら見てみましょう。
  • elasticsearch-model
    • 名前の通りincludeすることでそのmodelに対してsearchやimport などElasticsearchに関連したメソッドを生やしてくれるものです
  • elasticsearch-rails
    • development環境でelasticsearch関連のlogをキレイに吐いてくれたり、データ投入のためのrake taskなどを提供してくれます。

必要なgemは入れたので実装に移りましょう。

Searchable moduleを実装

mappingは各modelによって違うとして、いくつか共通化したい処理や設定が存在するかと思います。 そのため、searchable.rbのようなmoduleを作り、共通化していきましょう。

searchable moduleでは2つの役割を担っています

①カスタムしたanalyzerの設定 ②レコードに関するコールバックの設定 ③APIクライアントの初期化

具体的には以下のようなものとなっています。

// 'app/models/concerns/searchable.rb'
require 'elasticsearch/model'

Elasticsearch::Model.client = Elasticsearch::Client.new log: true, scheme: 'https', host: , port: 'ポート番号', user: 'user名', password: 'password'

module Searchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model

    after_commit on: [:create] do
      ElasticSearchIndexerJob.perform_later(self.class.to_s, self.id) unless Rails.env.development?
    end

    after_commit on: [:update] do
      ElasticSearchIndexUpdaterJob.perform_later(self.class.to_s, self.id) unless Rails.env.development?
    end

    after_commit on: [:destroy] do
      begin
        __elasticsearch__.delete_document unless Rails.env.development?
      rescue => ex
      end
    end
     
   settings analysis: {
      analyzer: {
        ....
      }
    }
  end
end

本来createやupdateはbulkで行うべきなのでしょうが、軽い実装なら上記のようなコールバックを用意しておき、以下のようなjobを用意してあげれば、dbとelasticsearchの同期をとることができます。

class ElasticSearchIndexerJob < ActiveJob::Base
  queue_as :default

  def perform(klass, id)
    sleep(1)
    record = klass.constantize.find_by(id: id)
    record.__elasticsearch__.index_document if record.present?
  end
end

各modelの設定

moduleを作ったため、これをincludeして、各モデルの設定をすれば大丈夫です。 やることは以下4点です。

①indexの定義 ②mappingの定義 ③as_indexed_jsonのオーバーライド ④search queryの設計

まずサンプルコードを貼ると以下のような形となります。

class Post < ActiveRecord::Base
  include Searchable

 index_name "post" #①

  mappings dynamic: 'false' do  #②
    indexes :content, analyzer: 'kuromoji', type: 'string'
    indexes :n_content, analyzer: 'ngram_analyzer', type: 'string'

    indexes :like_count, type: 'integer'
    indexes :media_status, type: 'integer'
    indexes :published_at, type: 'date'

    indexes :info, analyzer: 'ngram_analyzer', type: 'string'

    indexes :products, type: 'nested' do
      indexes :name, analyzer: 'ngram_analyzer', type: 'string'
      indexes :id, type: 'integer'
    end
    ...
  end

  def as_indexed_json(option = {}) #③
     info = "hogehoge"
     attributes
      .symbolize_keys
      .slice(:content, :like_count, :published_at)
      .merge(n_content: self.content)
      .merge(media_status: media_status)
      .merge(info: info)
      .merge(products: products.map{|p| {id: p.id, name: p.name}})
      ...
  end

  has_many :products, through: :post_products

  def self.search(params) #④
    search_definition = Elasticsearch::DSL::Search.search {
      from (page.to_i - 1) * 20
      size 20
      query {
        function_score {
          query {
            filtered {
              query {
                multi_match {
                  query text
                  operator 'and'
                  fields %w{ content n_content info } 
                 }
              }
           }
         ...
    }   
    __elasticsearch__.search(search_definition)
  end
end

それぞれについて見ていきましょう

indexの定義

これはわざわざ説明するまでのこともなくindexの名前を設定しています。 バージョニングなどを考えたindex名については後述しますが、基本的にはわかりやすい名前をつけておけば大丈夫です。

mappingの定義

どのfiledがどういう型か、どういったanalyzerを使用するかということを定義しています。 RDBでいうところの1対多の関係を表すときは type: 'nested'をつけておくと配列をindexingすることができます。

as_indexed_jsonのオーバーライド

mappingに則り、indexingするデータをhashの形へと変換してあげます。 ここで不足があると mappingは定義したのにデータが投入されない ということが起こってしまうので気をつけましょう

search queryの設計

最後にmappingをもとに検索クエリを組み立てて行きます。 本当に多くのクエリを用いて検索ができるため、詳細は省きますが、elasticsearch-dslのgithubを見てみると多くのサンプルを見ることができます。

これでsearchクエリさえ組み立てることができれば大丈夫というところまできました。 mappingの定義が大丈夫となったら (Model名).__elasticsearch__.import をしてデータを投入してみましょう。

これでいつでも検索できるようになりました!

③実運用上の問題点と解決法

最後に運用上でやっていることや問題となったことについてまとめたいと思います。

いまいちどの辺がクエリにヒットしてレコードが引っ張られているのかわからない問題

我々がやったことといえば、mappingを決め、要件をJSONに落としこんだだけですが、 実際与えらえれたクエリに対してどの辺がヒットしているのか? ということを確認したいニーズは多少あるかと思います。 そんなときは highlight という機能を使うと幸せになれるかと思います。

上記self.search(params)のクエリ定義の中に

  def self.search(params) 
    search_definition = Elasticsearch::DSL::Search.search {
      from (page.to_i - 1) * 20
      size 20
      highlight {                                       
        fileds: [:n_content, :content]          # ここを追加
      }                                                        
      query {
        function_score {
          query {
       ...

highlightというブロックを追加します。 そして検索して結果をほっていくと

 pry(main)> Post.search({text: "キャンメイク"}).response.hits.hits.map(&:highlight)
 => {"n_content"=>
   ["<em>キャンメイク春のコスメ</em>♡\n" + "可愛<em>い</em>過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色<em>ん</em>なカラーが出ますね。\n" + "すご<em>く</em>楽しみです!\n" + "セザ<em>ンヌの新色も楽しみで欲しいのい</em>っぱ<em>い</em>w"],
  "content"=>["<em>キャンメイク</em>春のコスメ♡\n" + "可愛い過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色んなカラーが出ますね。\n" + "すごく楽しみです!\n" + "セザンヌの新色も楽しみで欲しいのいっぱいw"]

となり、ひっかかった部分が強調タグで囲まれていることがわかりますね。 "キャンメイク"と打ったのに”ク”だけに反応していたりして、なかなか難しそうですね。

引っ張ってきたレコード、スコア順じゃない問題

elasticsearch-modelの恩恵は偉大で (Model名).search(params).records.to_aとするとSQLを発行し、レコードを取得できるわけですが、クエリを見てみると

  Post Load (0.5ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`id` IN (301306, 300359, 297179, 304908, 302542, 299076, 312292, 303429, 308305, 304312, 311940, 297988, 301020, 307750, 305654, 298394, 311388, 308077, 297068, 296636)

のようにwhereで引いてきているため、elasticsearchでのスコア順が保証してくれません。

そのため自前でソートするのがよいかと思います。

(例)

Post.search(params).map(&:id).tap { |ids| Post.where(id: ids).order("field(posts.id, #{ids.join(',')})") }

で順番が保証されます。

activerecord-importとelasticsearch-modelのimportがconflictする問題

(※導入しようとした当時はconflictしてたのですが、現在ではactiverecord-importのimportメソッドはbulk_importにrenameしたようです。 参考: https://github.com/zdennis/activerecord-import/blob/master/lib/activerecord-import/import.rb)

一応困ってる人がいたりするかもしれないので軽く残しておくと elasticsearch-modelが各モデルに対して生やしてくれるメソッドとactiverecord-importが ActiveRecord::Associations::CollectionAssociation に生やしてくれるimportメソッドが被ってしまうという問題があり、メソッドの探索順序的に elasticsearch-modelが生やしてくれるimportが常に使われてしまうという問題がありました。

これはconfig/application.rb

require 'activerecord-import/base'

class ActiveRecord::Base
  class << self
    alias :bulk_insert :import
    remove_method :import
  end
end

としてaliasを貼ってあげることで 解決していました

fieldを追加したりmappingを変えたくなったんだけどサービスはとめたくない問題

ベストプラクティスかどうかはわかりませんが、 弊社ではindex_nameにsuffixをつけることでblue-greenの切り替えをを行っています。

具体的にはconfig/initializersに以下のような定数を記述したファイルをつくり、

  CURRENT_ES_POST_INDEX = 2
  CURRENT_ES_PRODUCT_INDEX = 1
  CURRENT_ES_USER_INDEX = 2
  CURRENT_ES_ARTICLE_INDEX = 0

各modelのindex_nameを

class Post < ActiveRecord::Base
  include Searchable
  index_name "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX}"
  ...
end

のようにしておくと

①mappingを定義しなおしたものをデプロイ ②rake taskなどでCURRENT_HOGEHOGE_INDEX をインクリメントしたインデックスをつくりデータを投入

Post.__elasticsearch__.index_name = "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX + 1}"
Post.__elasticsearch__.import

③作り終えたら実際にconfig/initializersの下にある定数をインクリメントしデプロイ

というフローでblue-greenを実現することができます。

検索候補とか出したい

検索システムを作ってると候補を出したいってなることがあるかと思います。

書き始めると長くなるので、とても参考になるURLを貼っておきます。

https://medium.com/hello-elasticsearch/elasticsearch-%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E3%82%B5%E3%82%B8%E3%82%A7%E3%82%B9%E3%83%88%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E8%A8%AD%E8%A8%88-352a230030dd

基本的には,弊社では検索のログや、ブランド名、商品名、ユーザー名などを、(とても参考になるURLで言われているような)Analyzerを用いてtokenizeするだけでそこそこのものができたりします。

try! Swift 2018参加レポート

みなさんこんにちは。Androidアプリエンジニアの岡田です。 2018年3/1~3/3に行われたtry! Swiftに参加して来ました。try! Swiftは海外の方も多数登壇数する大きなカンファレンスで、東京で開催されるのは3回目です。 たくさんあったトークの中で気になったトークをいくつか紹介します。

Optimizing Swift code for separation of concerns and simplicity

Swiftのコードをより簡潔に書くためのテクニック、Tipsの紹介でした。 変数を関数に切り出す例やprotocol extensionを使用する例では実装の意図が伝わりやすくなるという意味で参考になりました。他の例ではUIStackViewなどのUIKitを使った物があるので、iOSアプリ開発で活かせる場面が多々あると思います。 個人的にはExample10の印象が強かったです。ローディング中、ローディング完了、ロード失敗の状態を管理する方法なのですが、enumのAssociated Valueを使って表示したいViewを保持しています。enumの特性が上手く活用されていて見習いたい実装例でした。 よりSwiftyなコードを書くために紹介されていた方法を取り入れていきたいです。

Kotlin for Swift Developers

Swiftとの比較をベースにKotlinの紹介をするというトークでした。 始めにKotlinとSwiftの似ている点についていくつか述べられていました。 次にSwiftのgurad letをkotlinに,kotlinのapplyをswitで使えるようになると便利なのではないかという提案があり、納得感のある提案だなと思いました。普段両言語を書いている方はそう思われる方が多いのではないでしょうか。 またkotlin nativeについての紹介もあり、現在はプロダクションで導入するのはリスクを取る必要があるとの事でした。 kotlinを学んでAndroidアプリ開発にもtryしていきたいですね。

Preparing for Swift 5 Ownership

Swift5で導入予定のOwnershipについてのトークでした。 新しく導入される要素にsharedキーワードがあり、これを関数の引数につける事でreadonlyの参照渡しになります。このようにコピーと参照を制御するキーワードを使用する事でメモリ管理をより詳細に行うことができます。他にmovedキーワードもあり、これらを上手く使いこなせばパフォーマンス向上だけでなく、実装の意図が分かりやすくなります。 こういった要素が増えると導入障壁が上がるのかと思いますがopt inなので機能を使用しないことも可能なようです。 Swif5リリースまでに導入される概念や機能について理解し、スムーズに以降したいです。

UIImageView vs Metal

GPUがどんな物なのかという導入からMetalを使った自作ライブラリのパフォーマンス測定、その過程で得られた知見についてのトークでした。 MetalとGPUについては知識は殆どなかったのですが、GPUとCPUでのコマンドがどのようにやり取りされているか紹介されていたので処理の概要について知ることができました。 自作ライブラリのパフォーマンス測定ではInstrumentsを使用したデバッグの過程が詳細に述べられており、非常に興味深い内容でした。個人的にはInstrumentsを上手く使いこなせていなかったので、その使用例としても面白い内容でした。 iOSアプリは画像描画の速度でユーザー体験が大きく変わるのでパフォーマンス改善をする際にはMetalの部分まで考慮して実装していきたいです。

会場の様子

IMG_3976 2.JPG

IMG_7156.JPG

感想

3回目の参加になるのですが今回もSwiftに関する様々な発表を聞く事ができ、非常に貴重な経験になりました。 懇親会も盛り上がっており、日本の方だけでなく海外の方ともSwiftについて話すことができました。 とても素晴らしいカンファレンスなので来年以降もぜひ参加したいです。

Elasticsearchの基本的な知識について

はじめまして、AppBrewでコスメを中心としたコミュニティである「LIPS」のアプリや サーバーサイドを担当しています吉野です。

弊社ではあるコスメの/あるユーザーの投稿を探す というようなニーズを満たすために 全文検索エンジンとしてElasticsearchを使用しています。

そこで、いくつか学んだ点などがあったため、 Elasticsearch基礎編とElasticSearch+Railsの運用編の二本立てて書きたいと思います。

参考:https://www.elastic.co/guide/en/elasticsearch/guide/current/index.html

Elasticsearchとは

通常RDBで柔軟な検索をしようとすると、例えば以下のようなあえて非正規化したようなTableが必要となります。

id hira_name kana_name eiji_name
1      よしのかつき ヨシノカツキ    yoshinokatsuki

これでは非常に変更に弱く、カラムもいくつも増やす必要があり、実用的とは言えません。 我々のぞむインターフェイスとしては以下のような感じですよね。

f:id:yosshi0774:20180314000037p:plain

ここでいうところの、 なんかいい感じにしてくれるやつ がElasticsearch です。

もう少し詳しく言うと(よく言われている言葉を使わせて貰えば) ビジネス要件をJSONに落とし込むだけで全文検索が可能となる というやつです。

この記事ではElasticsearch基礎知識についてまとめたいと思います。

Elasticsearchの基礎知識

まず、上記の通りElasticsearchはDBのようにデータを保持し、扱うものの 通常のDBのような形ではないというわけです。それではどのような形でデータを扱い、やりとりするのでしょうか?

Elasticsearchとのふれあい方について

ElasticsearchはSQLの代わりに RESTful APIJSON により データの追加削除を行っています。

つまり、エンドポイントを知っていて、JSONが用意できれば どんなデバイスからでも利用することができるということですね。 (弊社ではすべてRailsサーバーを介して行われていますが)

ちなみにRDBとは用語が異なり以下のような用語の対応となっています。

RDB Elasticsearch
Database Index
Table (Mapping)Type
Column Field
Record Document

このような言葉遣いなため、Elasticsearchを扱う話題では index という言葉にいろいろ意味があったりしますが、品詞の違いや表記法の違いなどで区別しています。

  • index(名詞)
    • これは上記の通りDatabaseを指し、 慣例的にindexes , indices と書きます。
  • index(動詞)
    • これは新しいDocumentを追加すること(RDBでいうところのRecordを追加すること)を指します。こちらはindexする,indexingするなどと言うかもしれません。

また、columnとfieldが対応しているというからにはそのfieldがどんなものであるか?(railsでいうところのschema.rb)を定義するものはElasticsearchに存在し、 そのfieldはどのような型でどのようなAnalyzer(後述)を使用するか? ということを決めることができます。

ちなみにElasticsearchでは以下のような一般的な型を使用できるようになっています。

String: string Whole number: byte, short, integer, long Floating-point: float, double Boolean: boolean Date: date

参考: https://www.elastic.co/guide/en/elasticsearch/guide/2.x/mapping-intro.html

これを踏まえ弊社の投稿に関するmapping定義を見てみましょう。

[5] pry(main)> Post.__elasticsearch__.mappings
=> #<Elasticsearch::Model::Indexing::Mappings:0x007fa69dd41fc0
 @mapping=
  {:content=>{:analyzer=>"kuromoji", :type=>"string"},
   :n_content=>{:analyzer=>"ngram_analyzer", :type=>"string"},
   :like_count=>{:type=>"integer"},
   :media_status=>{:type=>"integer"},
   :info=>{:analyzer=>"ngram_analyzer", :type=>"string"},
   :products=>{:type=>"nested", :properties=>{:name=>{:analyzer=>"ngram_analyzer", :type=>"string"}, :id=>{:type=>"integer"}}},
   :brands=>{:type=>"nested", :properties=>{:name=>{:analyzer=>"ngram_analyzer", :type=>"string"}, :variant_name=>{:analyzer=>"ngram_analyzer", :type=>"string"}, :id=>{:type=>"integer"}}},
   :categories=>{:type=>"nested", :properties=>{:name=>{:analyzer=>"ngram_analyzer", :type=>"string"}, :id=>{:type=>"integer"}}},
   :tags=>{:type=>"nested", :properties=>{:name=>{:analyzer=>"keyword", :index=>"not_analyzed", :type=>"string"}, :id=>{:type=>"integer"}}},
   :user=>
    {:type=>"object",
     :properties=>
      {:name=>{:analyzer=>"ngram_analyzer", :type=>"string"},
       :nickname=>{:analyzer=>"kuromoji", :type=>"string"},
       :skin_type=>{:analyzer=>"kuromoji", :type=>"string"},
       :age_type=>{:analyzer=>"kuromoji", :type=>"string"}}},
   :published_at=>{:type=>"date"}},
 @options={:dynamic=>"false"},
 @type="post">

細かい話は置いておき、content(本文)はstringでkuromoji analyzerを使用しており、like_count(いいね数)はintegerで定義されているということがわかりますね。

用語の紹介はこれくらいにして、Elasticsearchがなにをしてくれているのか ということについて見ていきましょう。

Elasticsearchを使うと何が嬉しいの?

Elasticsearchは検索に留まらずログ解析などとしても利用可能ですが、 検索の部分に限った話をすれば嬉しいことは以下の2点です。

それぞれについて見ていきましょう

Analyzerによる正則化形態素解析

Analyzerとはその名の通り解析してくれるものであるわけですが、 以下の3つの要素から成り立ちます。

  • Character Filters
  • Tokenizer
  • Token Filters

再掲となりますがAnalyzerは図でいうところの ①データベースにあるデータを登録 の部分において、検索しやすいいい感じにしてくれるものです。

f:id:yosshi0774:20180314000037p:plain

それでは各要素の役割について例を挙げて見ていきましょう。

f:id:yosshi0774:20180314000055p:plain

上図のように各要素では

  • Character Filter
    • カタカナや特殊文字を所定の形に変換するフィルタ
  • Tokenizer
    • 形態素への分割、空白区切り、句読点区切りなどの規則に則り,文字列をtokenに変換する。
  • Token Filters
    • ノイズとなるようなtoken( 助詞や句読点 )を除くフィルタ

といったような役割があることがわかり、それぞれの要素(特にTokenizer)の設計が鍵となってくるわけですが、幸いなことに各言語においてAnalyzerはいくつか存在し、日本語においてはkuromoji というAnalyzerが存在します。 これにより莫大な構文解析済みコーパスなどを持っていなくても簡単に文字列を解析することが可能となっています。良い世の中ですね。

さて、以上のようにAnalyzerによる処理を介してRDBの各データはmappingに従い Elasticsearchへと保存されるわけですが、それらがどのように扱われるのでしょうか?


ちょっと雑談

実際、検索のためにanalyzerを使用しようとすると、一つのfield(column)に対して様々なAnalyzeをしたいというケースがありますが、もちろんこれらは可能です。 例えば文章を日本語でもtokenizeしたいし、ローマ字でもtokenizeしたいというようなケースです。 うまくAnalyzerを組み合わせることでfuzzyな検索にも対応できるというわけです。 それでは本文に戻りましょう。


転置インデックスによる高速な検索

tokenizeされた語句達は転置インデックスという索引方法により有用に扱うことができ、 最初の図でいうところの

②適当に検索 ③スコアリングしてデータを返してくれる

を実現するための索引方法ともいえます。

例えば、↑で使った例文(仮にid1としましょう)に加え 運転免許とるなら、合宿免許wa◯!! という例文があり [運転, 免許, とる, 合宿, wa◯] と解析されたとしましょう。(idは2としましょう。) この時の転置インデックスをすると、

token ids
合宿 1,2
免許 1,2
wa◯ 1,2
社長 1
トン 1
0.08 1
とる 2
運転 2

のようになるわけですね。 これがどのように使われるかをみるために、例えば[社長 免許 wa◯] というクエリで検索されたとしましょう。すると下の表のように集計されます。

token ids include?
合宿 1,2 false
免許 1,2 true
wa◯ 1,2 true
社長 1 true
トン 1 false
0.08 1 false
とる 2 false
運転 2 false

あくまでイメージですがこの例ではtrueが多いidのものから引っかかる というわけです。 すなはち今回の場合はスコアとしてid:1>id:2という関係になるわけですね。

まとめ

少し長くなりましたが、Elasticsearchがelasticにsearchしてくれることはおわかりいただけたかと思います。 以上のことをまとめると

①indexesを作成し ②analyzerを用意し ③mappingを定義し ④データを投入すれば

そこそこ快適な検索ライフがおくれるということになります。 このElasticsearchと弊社のRailsプロジェクトをどのように結びつけているか どう運用しているかについては次の記事で書こうかと思います。