横断検索で社内情報共有を加速させる

アプリケーションエンジニアの id:alpicola です。

このエントリは、はてなエンジニアアドベントカレンダー2018の24日目の記事です。昨日は id:miki_beneIntelliJを使ってPerlアプリケーションの開発をするでした。

背景

はてなでは業務の中で得た知見や考えたことなどを書き残し、社内外でどんどん共有していくオープンな文化があります。こうやって発信された情報はエンジニア同士で相互によいインプットになってきました。一方で、情報がそれを必要としている人に必ずしもアクセスしやすくないという課題も抱えています。

  • 発信される情報の量が多く、少し時間が経った情報はすぐ流れてしまう
  • 社内でグループウェア、GitHub Enterprise (GHE) 、Google Documentsなど様々なツールに情報が分散していてどこを探せばよいかわからない
  • グループウェアやGHEの(特に日本語テキストの)検索の精度が高くない

エンジニアリングの文脈以外の業務上の情報共有についても同様です。こうした状況を改善する試みの一つとしては、ストック型の情報共有を強化して情報の一覧性を高める取り組みが行われています。他のアプローチとして、検索性を高めるという方法もあります。検索が好きな私は、全社的な生産性向上のためのCTO室内ミニプロジェクトとして、社内文書の検索性の改善に取り組みました。社内の各種ツールの文書を横断検索できて、かつ高精度な検索が行えるようなアプリケーションを開発したので、そこで用いた技術について紹介します。

横断検索のアーキテクチャ

アプリケーションコードはPython、検索のミドルウェアにはElasticsearchを使っています。横断検索用に用意したElasticsearchの単一のインデックスにあらゆるツールの文書のテキストを入れて、まとめて検索できるようにするのが、横断検索の基本的なアイデアになります。ElasticsearchのインデックスにはURL、タイトル、本文、閲覧可能範囲(次節で詳しく説明)などの全文書共通のフィールド持つマッピングを設定します。

各種データソースから文書を取得するのはどういうやり方がよいでしょうか。大きく分けると次の2つが考えられます。

  • Push型: データソース側で文書の追加・更新時に、Webhookなどの仕組みで横断検索アプリケーションに文書が送られる
  • Pull型: 横断検索アプリケーションが定期実行のジョブで、データソース側のAPIを通じて文書をクロールする

データソース側にかかる負荷が少ないこと、またデータソースで行われた更新が検索に反映されるまでのタイムラグが小さいというリアルタイム性でpush型が優れています。しかし過去を遡って文書をインデックスしようと思うと結局pullも必要なことや、文書量はそこまでは多くなくクロールの負荷が小さいこと、リアルタイム性の要求も高くないことからpull型を採用しました。

  • 最近更新があった分をクロールしてインデックス更新するジョブ
    • 業務時間内1時間に1回実行、所要時間は10分程度
  • 全件をクロールしてインデックス更新するジョブ
    • 業務時間外に1日1回実行、所要時間は1, 2時間程度
    • データソースのAPIの仕様上最近分をインデックスをするジョブでは取りこぼす更新(閲覧権限の変更など)もこちらでカバーする

という2種類のジョブを使い分けることにより、正確性、リアルタイム性、クロール負荷のバランスをとっています。

全件のインデックスを更新するジョブが用意されていることは運用上の利点もあります。データソースがマスターデータを持っている限り簡単にインデックスを再構築できるので、アプリケーション単体としてはデータの欠損や消失にあまりセンシティブになる必要がありません。社内向けの小さなアプリケーションの運用に手をかけたくはないので、こういったところも意識して管理が楽になるようにしました。

閲覧可能範囲の実装

社内文書を扱う上で注意しなくてはならない点は閲覧可能範囲です。各データソースで異なるアカウント管理、異なる閲覧可能範囲の制限があり、これらを統一的に検索で考慮する仕組みが必要でした。

ここでは横断検索用インデックスの permissions という名前のkeyword型のフィールドに閲覧可能なログイン情報を表すトークンを(複数)登録し、閲覧可能範囲を表現するということをやっています。データソース A のある文書がデータソース A のアカウント x, y で閲覧可能な場合は、その文書の permissionsA:x, A:y を含むという具合です。検索時、ユーザーがデータソース A のアカウント x 及びデータソース B のアカウント z の認証情報を持っていたとすると、Termsクエリ "terms": {"permissions": ["A:x", "B:z"]} を検索条件に加えることで、Termsクエリに与えたトークンのうちの少なくとも一つを permissions フィールドに含む文書、すなわちそのユーザーが閲覧可能な文書だけがヒットするようになります。

各データソースで文書に対する閲覧可能なアカウント情報が列挙可能であれば、上の方法で画一的に閲覧可能範囲を扱えます。実際はデータソースによってはAPI仕様的に閲覧可能なアカウントを正確に列挙することが難しいケースもありましたが、その場合は他のロジックで確実に閲覧可能なアカウントなのがわかるものを列挙し、検索の利便を損なわない程度に安全側(閲覧可能範囲を狭める側)に倒すようにしています。

ユーザーの各データソースに対する認証にはOAuthを使います。潜在的なセキュリティリスクにならないよう、取得したOAuthトークンはユーザーのアカウント情報を確認するためだけ(要求する権限もそれに絞る)に使い、トークン自体は保存していません。

検索精度を高める工夫

このシステムで扱う文書数は数百万くらいのオーダーで、まるで少ないというわけでもないですが、本番サービスで扱う規模と比べればずっと小さいものです。また社内アプリケーションで負荷やレイテンシの要件も厳しくないため、本番サービスでは安易に導入することが難しいような実験的試みもやりやすいです。高精度な検索を行うためにやったことをいくつか挙げます。

形態素解析器Sudachiの使用

まず日本語の形態素解析器としてデフォルトのkuromojiではなくSudachiを使用しています。Elasticsearch向けのプラグインelasticsearch-sudachiが提供されており、導入は簡単でした。次のようなDockerfileでプラグインと辞書がインストールされたElasticsearchのイメージを作成できます。

FROM docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.2

WORKDIR /tmp

RUN wget -q https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v6.4.2-1.1.0/analysis-sudachi-elasticsearch6.4.2-1.1.0.zip \
 && elasticsearch-plugin install file:///tmp/analysis-sudachi-elasticsearch6.4.2-1.1.0.zip \
 && rm analysis-sudachi-elasticsearch6.4.2-1.1.0.zip
RUN wget -q https://github.com/WorksApplications/Sudachi/releases/download/v0.1.1/sudachi-0.1.1-dictionary-core.zip \
 && unzip sudachi-0.1.1-dictionary-core.zip \
 && mkdir -p /usr/share/elasticsearch/config/sudachi \
 && mv system_core.dic /usr/share/elasticsearch/config/sudachi

Sudachiはよくメンテされた辞書、複数の分割単位での出力、表記の正規化などの強みを持った形態素解析器です。今回使った感触でいうと、とりわけ表記揺れに対する頑健性が役に立っています。例えば「コーヒーの淹れ方」というテキストは「淹れ方」が「入れ方」だったり「いれかた」だったりしますが、これらは全て同じトークンの列に正規化されるため、一様に検索にヒットさせることができます。他には英語の単語をアルファベットで表記するか、カタカナで表記するかの違い(「document」「ドキュメント」、「job」「ジョブ」など)も吸収してくれて便利です。「コンテキスト」「コンテクスト」のようなカタカナにしたときの表記揺れも問題ありません。

N-gramインデックスの併用

社内用語や技術用語、また記号を含んだコード片を検索するときなど、単純な字面の一致で検索できる方がよいこともあります。そこでn-gramのインデックスも併用するようにしています。例えば、コードベースの中でPerlのベビーカー演算子 @{[...]} を見て、なんだこれは!となった人も、 @{[ で検索してベビーカー演算子について言及した文書を見つけることができます。

N-gramのサイズはbigramとtrigramをそれぞれ試し、trigramの方が検索速度で優っていたのでこちらをメインで使っています。Bigramでは、転置インデックスでの文書ヒット数が多く、その後のフレーズ検索のためのトークン位置の読み込みやスコアリングに時間がかかっているものだと思われます。

ここまで説明したことをまとめると、実際に使っているものより簡略化していますが、インデックスの設定は次のような感じです。

{
  "settings": {
    "index": {
      "number_of_shards": "1",
      "number_of_replicas": "0",
      "refresh_interval": "1h",
      "analysis": {
        "analyzer": {
          "sudachi_analyzer": {
            "filter": [],
            "type": "custom",
            "tokenizer": "sudachi_tokenizer"
          },
          "ngram_analyzer": {
            "filter": [],
            "char_filter": [],
            "type": "custom",
            "tokenizer": "ngram_tokenizer"
          }
        },
        "tokenizer": {
          "sudachi_tokenizer": {
            "mode": "search",
            "resources_path": "/usr/share/elasticsearch/config/sudachi",
            "type": "sudachi_tokenizer",
            "discard_punctuation": "true"
          },
          "ngram_tokenizer": {
            "type": "nGram",
            "min_gram": "3",
            "max_gram": "3"
          }
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic": "strict",
      "properties": {
        "url": { "type": "keyword" },
        "title": {
          "type": "text",
          "analyzer": "sudachi_analyzer",
          "fields": {
            "ngram": {
              "type": "text",
              "analyzer": "ngram_analyzer"
            }
          }
        },
        "body": {
          "type": "text",
          "analyzer": "sudachi_analyzer",
          "fields": {
            "ngram": {
              "type": "text",
              "analyzer": "ngram_analyzer"
            }
          }
        },
        "permissions": { "type": "keyword" },
        "datetime": { "type": "date" }
      }
    }
  }
}

文書のタイトルと本文を表す titlebody フィールドはmulti-fieldで、Sudachiとn-gramのそれぞれを使ってインデックスするようになっています。

検索時は、1つのフレーズに対して

{
  "multi_match": {
    "query": "hoge",
    "type": "phrase",
    "field": ["title^2", "title.ngram", "body^2", "body.ngram"],
    "tie_breaker": 0.3
  }
}

のようなMulti Matchクエリを使い、文書のタイトル及び本文、そしてそれぞれSudachiのインデックスとn-gramのインデックスを総合的に見て検索結果を返すようにしています。N-gramのインデックスは、本来の単語区切りにはそぐわない一続きの文字列ともマッチし検索ノイズの元となるので、 title^2, body^2 のようにSudachiのインデックスのマッチの重みを大きくしています。tie_breakertitle^2, title.ngram, body^2, body.ngram の中でマッチのスコアが一番が高かったもの以外のスコアも、全体のスコアで考慮させるための設定で、これを 0 より大きい値にすることで、Sudachiで同じに正規化される語でも、検索語と字面が一致するもの(n-gramでマッチするもの)が優先されます。これにより例えば「job」で検索した時に、「ジョブ」より「job」が上位に出やすくなります。

前述したようなインデキシングの仕組みで、可用性やデータの維持にあまりこだわらなくてよいようにしているため、Elasticsearchはシングルノード、1シャード、レプリカなしで使っています。EC2インスタンスタイプはt2.mediumと小さめで、ストレージもEBSですが、それくらいの環境でも、上記設定の検索で、インデックスがファイルシステムキャッシュに乗っていれば0.5秒、そうでなくても高々2, 3秒以内で実行できていそうでした。社内ツールとしては十分なパフォーマンスが出ていると考えています。

おわりに

横断検索があれば、探したい情報がどこにありそうか考える過程をスキップして、いきなり検索を始めることができます。先日から社内で利用を始め、この横断検索のもたらす利便性、そして検索の精度や速度も好評でした。今後、社内の知識共有や業務効率の改善に貢献してくれそうです。

また社内アプリケーションという場を生かしてSudachiのような新技術を採用したりしましたが、この事例をもとにサービスでの導入も検討していきたいと思っています。