発表資料: Elasticsearchによる 全文検索の実装

Rails Developers Meetup 2018 (#railsdm) で話した資料です。Railsの話はほとんどなくて、全文検索の仕組みとスコアリングについてのまとめが主です。

Q&Aシステムでの質問もここで回答します。

Q. データの同期はどうされていますか?

同期はActiveRecordのcallbackでActiveJobに更新jobを投げる形で非同期で行っています。また、データ構造などの更新がある場合にindex再構築するときのためのblue-green deployment用のバッチがあります。

Q. 何かgemを使われていますか?使われているなら、どんな選定理由ですか?

いまはelasticsearch-railsを使っていますが、このエントリの後半にあるような理由で捨てようと思っています。移行先はまだ決めていません。

Q. 辞書を作ったりしていますか?

Amazon Elasticsearch Serviceなのでカスタム辞書は使っていません。AESがカスタム辞書に対応したら考えます。

Q. 社内用語のような一般的ではない単語(かつ形態素解析で微妙な位置で区切られてしまう)で検索されるケースが多いとおもいますが、そういうものも、ngramとのハイブリッドで解決していますか?

現在のところ特に工夫はしていません。ngramとのハイブリッドである程度は解決出来ると思っています。

Q. スコアリングの評価について自動化や定量化は行えていますか? 何をもって改善したあるいはその逆であったと言えるか。

現在はスコアリングの評価について自動化や定量化はしていません。現在はユーザーからのフィードバックを元に行っている改善が主です。

ただ定量化自体はすべきだと思っていて、たとえば(私の前職でもある)クックパッド社では「検索成功率」というKPIを定義しており*1、これをもとに検索体験の改善を行っています。Kibelaでも同様にKPIを定義してそれを観測するということは必要だろうと思います。


最初は elasticsearch-rails の使い方の話を中心にしようと思ったのですが、まず全文検索そのものの話を整理しないとそこにたどり着かないなということでその話を中心にしました。

なおKibelaでは現在 elasticsearch-rails を使っていますが、次の理由から別のelasticsearch clientに移行する予定です。

  • elasticsearch-rails はあまりメンテされていない
  • elasticsearch-rails は ActiveRecordのmodel classにフォーカスしすぎていて多くの機能が不要
    • たとえば Model.search というメソッドが定義されるが、基本的に復数modelにまたがった検索しかしないのでこのメソッドを使うことがない
  • elasticsearch-rails から返るモデルが同値比較できなくてテストで困る問題 - Islands in the byte stream で示すようなモンキーパッチがいくつかあって厳しい
    • PR を送ろうにもローカルでテストを走らせるのが難しく、またそもそもあまりメンテされてないっぽいのでやる気がそがれる

じゃあ移行先はどうかというと searchkick はクエリの抽象化が高度すぎてもっと生のelasticsearch queryを使いたいんですよね。GitHubが開発している elastomer-client はわりとよさそうだなと思ってます。

elasticsearch-rails から返るモデルが同値比較できなくてテストで困る問題

elasticsearch-rails はElasticsearchとRails / ActiveRecordのインテグレーションをしてくれるgemです。

こいつを使っているとき、 特定条件下でモデル同士を比較するテストがコケるようになる という現象に悩まされてきました。pryなどで確認しても、たとえば model_a がes-railsからの返却値であるとき、 model_a == Model.find(model_a.id) がコケるのです。これはクラスのリロードがうまくいっていないからではという示唆を得てSpringを切ると、たしかに再現しなくなりました。そこで elasticsearch-rails を調べたところ、 Elasticsearch::Model::Registry がモデルクラスのインスタンスを保持しているようでした。 Springのドキュメントにも触れているとおり、これはNGです。

https://github.com/rails/spring#class-reloading

Rails前提のgemがSpringに配慮したコードになっていないというのはバグといえるかどうか微妙なところですが、とにかく動かないのは困るのでモンキーパッチを当てて凌ぐことにしました。

Monkey Patch

module ElasticsearchMultipleRecordsExtension
  if defined?(::Spring)
    # Workaround for elasticsearch-rails's bug that Elasticsearch::Model::Registry keeps class instances but they are not reloaded on Spring
    ORIGINAL_TYPE_FOR_HIT = Elasticsearch::Model::Adapter::Multiple::Records.instance_method(:__type_for_hit)

    def __type_for_hit(hit)
      type = ORIGINAL_TYPE_FOR_HIT.bind(self).call(hit)
      Object.const_get(type.name)
    end
  end
end

Elasticsearch::Model::Adapter::Multiple::Records.prepend(ElasticsearchMultipleRecordsExtension)

elasticsearch-rails にもpull-requestを送ろうと思ったのですが、bundle installがコケるレベルでメンテされてないので一旦諦め。

Elasticsearchのドキュメントの直し方

Elastic社の @johtani さんに教えてもらったんですが、たとえば Elasticsearchのドキュメント(たとえばFunction Score Query )をみて不足があるなーと思ったとき、Edit リンクからGitHubの該当ページを直接開けるので即pull-reqを作れます。

f:id:gfx:20180228150033p:plain

あとはCLAにサインして数日待つとレビューを経てマージされたりされなかったりします。

実例: [document] s/logarithm/common logarithm/ by gfx · Pull Request #28821 · elastic/elasticsearch

DroidKaigi 2018で『すばらしきGraphQLのSEKAIへようこそ』という発表をしました

DroidKaigi 2018 に参加して発表してきました。

今回はDroidKaigiということでクライアントサイドの視点から解説しています。よって、サーバーサイドの実装についてはまったく触れていません。サーバーサイドの実装については以前発表をしたことがあるのでそちらもどうぞ。

RejectKaigi 2017で「GraphQL on Rails」という発表をしました - Bit Journey's Tech Blog

今回のDroidKaigiはday 1で当日スタッフとして司会もしましたし、個人的にもDroidKaigi Prelude というイベントをするなどして思い出深いkaigiになりました。また来年もできるといいですね。

DroidKaigi PreludeのKPT

やりました。 運営の @okoysm さん、tarappo さん、および会場提供のDeNA様、ありがとうございました。また、一緒に解説していただいた @muumuumuumuu さんもありがとうございました。もう一人の解説者(予定)だったshirajiさんは家庭の事情で参加できず残念でした。インフルエンザが流行しがちな季節は避けたほうが無難かもしれない…。

総じて楽しかったので次に繋げるために一人KTPを残しておきます。

Keep

  • DroidKaigiの前にtimetableをざっと眺める機会があるのはよい
  • 「ニッチそうなセッションを中心に攻める」というのを裏テーマとしてやったのはよかった
    • 非公式ということもありあまり公平性にはこだわらず、好きなように喋れた
    • 「公平性を重視する」みたいにしたらちょっとつらかったと思われる
  • 30分 x 2という時間は丁度よかった
    • 終盤は疲れてきたのでこれ以上時間を書けるのは体力的に無理だったと思われる
  • プレミアムフライデータイム(16:00~19:00)なので家族持ちにはちょうどよかった
  • LT (Rejected LTs + α) もわりと盛り上がったのでよかった

Problem

  • プレミアムフライデータイムなので参加者が限られてしまった
    • 個人的にもすぐ帰らなければならず、懇親もあまりできなくて残念だった
    • プレミアムフライデータイムのミートアップは総じて見ると自分にとってはプラスだけど、マイナス面も無視できない
  • 徹底解説するつもりが、「timetableをネタに適当に喋る」だけだった感はある
    • これについては「徹底解説」と名付けたのがイマイチだったかも
    • それぞれの話のバックグラウンドなどにもちょっと触れたかったが余裕がなかったという反省もある
    • 個人的には、この1年はAndroid開発から離れているので企画自体かなり無理があったともいえる…

Try

  • このミートアップ自体はとてもよいと思うし、来年は自分はやらないと思うけど誰かやってほしい
    • 「DroidKaigiのtimetableの眺めながら1年を振り返る座談会」みたいなのでもいいかもしれない

JavaScript Quiz: (new Array(2)).map($ => 'hello') === ?

まあ、分かってしまえばなんということはないんですが、結構面食らいますね。

MDNにはちゃんと書いてありました。

Array.prototype.map() - JavaScript | MDN

(callback) is not called for missing elements of the array (...) Due to the algorithm defined in the specification if the array which map was called upon is sparse, resulting array will also be sparse keeping same indices blank.

つまり、空要素に対してはcallbackは呼ばれず、配列のsparse状態(空要素があるという情報)も維持されるということなので、仕様どおりの挙動ではあります。

この「空要素(missing element)」はちょっと分かりにくいんですが、要素の置き場所だけが確保されていて実際には何の値も入っていないという要素です。式として評価すると undefined になります。こんなふうに:

console.log( (new Array(2))[0] === undefined ); // true

in 演算子だと空要素かどうかを調べられます。

console.log( 0 in (new Array(2) )); // false
console.log( 0 in [undefined] ); // true

そして Array.from(iterable)[...iterable] はこのsparse状態は保たず、undefined に変換してしまうようです。

console.log(new Array(2)); // [ <2 empty items> ]
console.log([...new Array(2)]); // [ undefined, undefined ]
console.log(Array.from(new Array(2))); // [ undefined, undefined ]

面白いですね!

Ruby 2.5未満でRuby 2.5のStructのkeyword_initを使う

追記: backports v3.11.0の時点ではstruct/new.rbの冒頭に変なputsがあるなど使われている形跡がありません。またフィールド名を予約語にすると使えないという問題もありました。一旦自前のmonkey patchを使うことにしてます。

NEWS-2.5.0 - Documentation for Ruby trunk

Struct.new takes keyword_init: true option to initialize members with keyword arguments. [Feature #11925]

これですね。

こんな感じのモンキーパッチでいけそうです。

# frozen_string_literal: true

# Backport Ruby 2.5's `keyword_init: true`
module Ruby25StructExtension
  module ClassMethods
    def new(*args, keyword_init: false, &block)
      super(*args) do
        if keyword_init
          define_method(:initialize) do |**kwargs|
            super(*members.map { |k| kwargs[k] })
          end
        end

        if block
          class_eval(&block)
        end
      end
    end
  end
end


if RUBY_VERSION < "2.5.0"
  Struct.singleton_class.prepend(Ruby25StructExtension::ClassMethods)
end

実装したあとで、そういえばbackports gemがあったなと思って確認すると実装済でした😇

backports/new.rb at master · marcandre/backports · GitHub

なのでRuby 2.5未満で keyword_init が欲しければ(そして他のRuby 2.5で追加されたメソッドも!)backports gemを使えばいいですね。

IntelliJ IDEAで特定行のGitHub PRを開く"Find Pull Request" pluginがまじイノベーティブ

Kazuho's Weblog: git blameでプルリクエストの番号を表示する

をみて、vscodeとかIntelliJ IDEAのpluginないかな〜と思っていたら、IntelliJ IDEA用にはすでにありました。インストールして数週間使っていますが、git blame を眺める機会が激減していい感じです。ぼくはIDEやeditorにはプラグインを最低限しかいれない派なんですが、これは便利なので "最低限のプラグイン" に入りそうです。

plugins.jetbrains.com

作者は @shiraj_i さんで、ソースコードも公開されてます。

github.com

保育所の公式データは統一されたフォーマットで提供してほしい

三行まとめ

  • 保育所*1の電子化データは提供元によってファイル形式もデータの構造も異なるためプログラムで加工しにくい
  • 東京都の場合、認可保育所一覧はそれぞれの区が管理しており探すのが大変
  • 保育所のデータはプログラムで加工しやすい統一されたフォーマットで提供してほしい

保育所データの現状

保育所を検索するAndroidアプリでも作ってみるかなと思って保育所のデータを探しているのですが、いまのところプログラムで簡単に加工できるデータを見つけられていません。

たとえば、東京都の場合、代表的な保育所の種類としては「認可」「認証」「認可外」などがあります。

認可保育所の一覧は23区などの自治体が提供しているため、それぞれの区のサイトを探す必要があります。

たとえば港区と目黒区をみるとこんな感じです:

他の区は調べていませんが、上記三区と同じように区ごとにまったく異なる方式で提供されていると思われます。

認証保育所は都内の一覧があります。

認可外保育施設も同様に一覧はありますが、認可・認証と同様に利用できる「保育所」でないものも含むため、認可・認証の保育所とは比較できないかもしれません*2

このように、ファイル形式や提供されている情報、あるいは分類方法も区ごと・保育所の種類ごとにバラバラなため、プログラムで電子化データとして加工するのは非常に手間がかかります。プログラムで処理するよりもクラウドソーシングなどのほうが簡単なのではないかと思えるほどです。

一覧データを販売している施設サイトもあるにはありますが、一次情報は公開されているのでできればそれを利用したいところです。

(追記) 全保育所の一覧らしきものはありました。
東京都福祉保健局 > 福祉保健の基盤づくり > 社会福祉法人・施設情報 > 社会福祉施設等一覧 > 施設等一覧
http://www.fukushihoken.metro.tokyo.jp/kiban/fukushi_shisetsu/shs_list/shisetsuitiran.html (Excel)

なぜプログラムで加工しやすいことが重要か

保育所を必要とする人が適切な保育所を探すコストを減らすためです。

プログラムで加工しやすい電子データがあれば、低コストで信頼性のたかい関連サービスやアプリを作れます*3。関連サービスというのは、たとえば保育所検索サイトや口コミサイトなどです。今は基本的に自治体が配布している冊子を眺めてメモを取りながら保育所を探すというのが一般的なフローだと思いますが、これは非常にコストの高いタスクです。質のよい検索サイトがあればその時間を大幅に削減できるはずで、それはとりもなおさず子育てのコスト自体を減らすことにつながります。

保育所データに望むこと

ファイル形式はCSVで、純粋にデータとして提供してほしい

  • Excelで提供されているデータも少しありますが、PDFの原本として使われるためか余計な文章(見出し、注意書き)が入っていてプログラムでは加工しにい状態です
  • 純粋に保育所一覧データのみのCSVとして提供されるとプログラムで加工しやすいので助かります

提供されるデータの質の統一

  • 現状、提供されているデータで共通なのは名前と住所と電話番号くらいで、そのほか収容人数や種類(=認可・認証など)、経営主体などは提供されていることもあれば、されていないこともあります
  • データの提供元(=区や都などの自治体)が違っても同じ質のデータを得られるとプログラムで加工しやすいので助かります

提供されるデータへの統一されたアクセス

  • 現状、区や都のサイトを探し回らないといけないため、非常に時間がかかります
  • 都道府県のウェブサイトの1ページで全保育所の一覧にアクセスできるとデータの収集が簡単で助かります

*1:保育園

*2:認可外保育施設について http://www.fukushihoken.metro.tokyo.jp/kodomo/hoiku/ninkagai/qa.html

*3:東京都の保育所検索アプリは、iPhone用はあるようですが、Android用は見つけられませんでした。

2017年まとめ

キャリア

  • Bit Journey(as エンジニア)とSpeee(as 技術顧問)は引き続き
    • スタートアップでサービス開発するのは学びがあってよい
    • 技術顧問業は難しい。成果はあったりなかったりする
  • 35歳になった
    • 2017年の初頭に 定年説をめぐって — 1. ミームの濫用 – To Phantasien *1 を読んでキャリアについて考えた
      • ぼくはこれを「35歳定年説は捨てていいとして、その後50代や60代を見据えたキャリアプランを真面目に考えるべきでは?」という話だと解釈した
    • 自分に関して言うと、まだ Plan B(≒ プログラマ以外の職種)は考えられないが…
  • 子供が産まれた
    • 家事と子育てに割く時間が非常に大きい
    • これは好むと好まざるとに関わらず、キャリアに対する影響は大きい
      • 時間もそうだが、家事と子育てで疲労がたまることにより他のことをする気力がなくなるという問題もある
        • 結果、子供が産まれてから個人プロジェクトの進捗がほぼゼロに
    • 子供が産まれたのは嬉しいし、家事も子育てもやぶさかではない
      • ただキャリアとの兼ね合いが難しいのは確かだと思う
      • 現職はwork from home可能なので、家庭と仕事の両立という点ではだいぶ恵まれてはいる
    • こういうときこそ write code every day

技術的なアクティビティ

*1:これは三部作の第一部で、どちらかというと第二部、第三部のほうが重要。