導入
ZEN Study の新しい教材基盤 (Kotlin) では、現在コンテンツ管理のための全文検索機能の導入中で、AWS OpenSearch Service を利用する予定です。
この記事は、OpenSearch導入にあたって各種概念モデルの概要を把握するために主にOpenSearch公式ドキュメントから調べたことをまとめたものになります。
あくまでOpenSearchを使った日本語全文検索のためのモデル理解を目的としており、以下のような点についてはこの記事の対象外としています。
- 日本語全文検索と直接関係しない検索に関する機能の話
- クラスタリングやシャーディングなど、物理設計に関する話
- エンドポイント仕様など、OpenSearchの具体的な利用方法やサンプルコード
- ドメイン設定など、AWS OpenSearch Service の独自部分の話
ドキュメントとインデックス
ドキュメント
ドキュメント (Document) はOpenSearchに登録され、検索対象となる文書データで、RDBのレコードに相当します。 OpenSearchでは、ドキュメントはすべてJSON形式で保持されます。
各ドキュメントは必ず _id
という一意の文字列フィールドを持ちます。これは、ドキュメントの更新時などにURLのパスパラメータとして利用されます。
_id
はドキュメントの新規登録時に明示的に指定することもできますし、指定せずにOpenSearch側で自動的に採番させることもできます。
また、全てのドキュメントは _version
という整数フィールドを持っており、ドキュメントが更新された回数が記録されています。
インデックス
インデックス (Index) はドキュメントの論理的な格納場所で、RDBのテーブルに相当します。
インデックスには、シャーディングや各種パフォーマンスの数値設定の他に、アナライザ (Analyzer) と マッピング (Mapping) の設定が存在します。後者2つについては、後のセクションで詳しく説明します。
インデックスの設定項目には、いつでも変更可能な 動的な設定 と、作成時に設定したら変更できない 静的な設定* があります。 例えば、シャードの分割数やマッピングなどは作成時にしか設定できない静的な設定で、後から変更するためにはインデックスまるごと作り直す必要があります。
アナライザ
全文検索では一般的に、単語などの文中の要素がどの文書のどの位置に存在するかをまとめた、転置インデックスと呼ばれるデータ構造を利用します。
アナライザ は、ドキュメント内の文字列から転置インデックスを作る方法を指定する部品で、全文検索機能の肝となります。
アナライザはちょうど1つの Tokenizer と、0個以上の Character Filter / Token Filter から構成されます。 各構成要素の役割は次の通りです。
Tokenizer
Tokenizerは、文字列を転置インデックスの見出し語となる単位 (トークン) へと 分割する方法を指定する 役割を持った要素です。 アナライザの処理の中心となります。
日本語の全文検索で用いられるトークンの分割方法には、n-gram を用いた方法と 形態素解析 を用いた方法があります。
n-gram
n-gram は文章を機械的にn文字単位で分割する方法です。
例えば2文字単位の分割 (bi-gram)を使うと、「私は東京都立大学に通います」という文は 私は / は東 / 東京 / 京都 / 都立 / 立大 / 大学 / 学に / に通 / 通い / いま / ます
という形に分割されます。
このような方法は分割と検索結果共にシンプルでわかりやすいですが、「京都」という予期せぬ単語でヒットしてしまうという問題があります。 また、細かく分割されるため、データ量も大きくなりやすいです。
全文検索の用途では2文字単位のbi-gramを基本として、1文字の検索語にも対応できるように1文字単位の分割 (uni-gram) も併用することが多いです。
形態素解析
形態素解析は、文章を名刺や動詞・助詞などの単語の単位で分割する方法です。
例えば、「私は東京都立大学に通います」という文は 私 / は / 東京 / 都立 / 大学 / に / 通い / ます
のような形に分割されます。(一例です)
このような分割の場合、「京都」のような存在しない単語に対してヒットしないという動作が実現できる一方で、「東京都」という検索語でもヒットさせられません。 もしくは、仮に「東京都立大学」が1つの固有名詞として判断された場合は、「東京」や「大学」に対してもヒットしなくなってしまうため、分析結果が検索品質に直接影響してきます。
英語のような分かち書きされる言語に比べて、日本語の分割はずっと複雑で難しい課題です。分割結果は、採用するアルゴリズムや単語辞書に応じて大きく変化します。
Amazon OpenSearch Service では、kuromoji と Sudachi という2種類の形態素解析エンジンがプラグインとして用意されています。
Character Filter
Character Filter は、Tokenizerによる 分割の前処理 の機能を持った要素です。
例えば、以下のような処理が該当します。
- 全角半角や記号など、表記の正規化
- HTMLタグなどの除去
日本語全文検索では、ここの処理結果が続くTokenizerの分析精度にも影響するので、英語等と比べて重要度が高くなっています。
Token Filter
Character Filter は、Tokenizerによる 分割の後処理 の機能を持った要素です。
例えば、以下のような処理が該当します。
- 同義語などの別パターンのトークン追加
- 表記揺れの統一
- 助詞や間投詞などのストップワードの除去
例えば、前述の「東京都立大学」が固有名詞として認識された場合でも Token Filter を使って、一単語の 東京都立大学
と 東京 / 都立 / 大学
という分割された形の2パターンで登録させることができれば、「東京」でヒットしないという問題が起きるのを防ぐことができます。
マッピング
マッピングは、ドキュメントに含まれる属性やその型を定義する部品で、RDBにおけるテーブルスキーマ定義に相当します。 また、OpenSearchでは、ドキュメントの各属性のことを フィールド (Field) と呼びます。
マッピングには、フィールド一覧を予め設定せずにフィールド名などの条件から自動で型を決定して保存する 動的マッピング (Dynamic Mapping) と、最初に定義した一覧に含まれるフィールドのみを決められた型でインデックスする 明示的マッピング (Explicit Mapping) の2種類があります。
フィールド型
フィールドの型は、数値やブール値・日時など、一般的なものが一通り用意されていますが、一部の型については独自の挙動が存在するので注意が必要です。
文字列
OpenSearch の文字列型は、 text と keyword という2つの区別があります。
text型は全文検索用の型です。 文字列はアナライザによって分析され、単語や n-gram を用いた転置インデックスの形で保存されます。 OpenSearch は全文検索を目的として導入されることが多いので、一般的にはこちらの型を選択することが多いでしょう。
keyword型は主に完全一致検索用の型で、アナライザによる分析無しに、そのままの形でインデックスに登録されます。 例えばEnum形式で表されるような、決まった選択肢から選ばれるタイプの文字列には、こちらを利用するのが適切でしょう。
配列
配列型は存在しません。 代わりに全ての型のフィールドに複数の値を含めることができます。
概ね配列型と同じように扱うことができますが、複数条件を組み合わせた複雑な検索クエリなどでは、期待通りの結果にならないことがあるようです。
null
nullも存在しません。 ドキュメント登録時などにnullを指定したフィールドは、デフォルトでは属性名ごと存在しない扱いにされてしまいます。
もし、あるフィールドが空配列であるかどうかや isNull()
のような条件で検索したい場合は、フィールドの型の指定時に null_value として扱う値を指定しておく必要があります。
Multifields
マッピング定義で各フィールドの方を決めるときに、同じフィールドをtext型とkeyword型の両方で扱いたかったり、同じ文字列の転置インデックスを形態素解析とn-gramの2つの方法で作成したかったりすることがあると思います。
OpenSearchでは、そのような目的のために multifields という方法で、同じデータで別の型を持ったフィールドをネストさせることが可能です。
multifieldsはマッピング定義でベースとなるフィールド型を指定する際に fields
というオブジェクト属性を使って、ネストされるフィールド名とその型を指定して作成します。
検索クエリ
OpenSearchの検索は、JSONで表現される独自のDSLを用いて行われます。
検索結果には、ヒットしたドキュメント一覧や総件数の他、各ドキュメントのクエリへの一致度を示す _score
が含まれます。
また、_score
を含めた任意のフィールドでのソートやページネーションの他、クエリとの一致部分を示す highlight
オプションが利用可能です。
クエリは主に単一フィールドを検索対象とする leaf query と、それらを組み合わせた compound query の2つに大別することができます。
Leaf Query
leaf query はさらに term-level query と full-text query に分けられます。
term-level query はドキュメントが条件に一致するか否かだけで判断されるクエリで、text型以外のフィールドを対象とします。
結果が true / false の 二値で判断 されるので、ドキュメントの関連度を _score
はヒットした全てのドキュメントに対して同じ値になります。
full-text query はtext型を対象とした全文検索用のクエリです。
各ドキュメントは 「クエリに どれくらい マッチするか」で判断され、_score
を使って関連度順にソート可能な結果を返します。
full-text query は転置インデックスを検索対象とするため、クエリで指定した文字列もアナライザを使ってトークン単位に分割されます。 基本的にはドキュメント登録と同じものが利用されますが、マッピング登録時や検索実行時に別のアナライザを指定することもできます。
全文検索で利用可能な full-text query には、例えば以下のような種類があります。
match
単一フィールドに対する検索語を指定します。検索語中の各単語をAND条件とするかOR条件とするかを指定可能です。
match_bool_prefix
match
と同様の検索ですが、検索語中の最後の単語がプレフィックス指定として解釈され、前方一致で検索されます。
match_phrase
いわゆる「フレーズ検索」です。 検索語をバラバラにせず、全ての単語が書いてある通りの順番で連続して出現するドキュメントを検索します。
multi_match
同じ検索語で複数フィールドに対して同時に match
同様の検索を行います。
検索対象の各フィールドに対して重み付けを設定することが可能です。
query_string
検索語の中に AND
や OR
のようなオペレーターや title: wind
のような対象フィールドの指定を含めることで、細かい条件をユーザーが指定可能にする検索です。
Compound Query
全文検索で利用できる compound query は boolean query と、_score
調整用の各種クエリがあり、本記事では前者について説明します。
Boolean Query
boolean query は、複数のクエリをANDやORで組み合わせるためのクエリです。
OpenSearchのクエリDSLでは AND
や &&
、括弧などを使わずに、JSONオブジェクトの階層構造で論理構造を表現します。
boolean query は、bool
という属性名のオブジェクトで表され、そこには must
や should
などの属性が含まれます。
must
がAND条件に、should
がOR条件に該当します。
例えば、(title: 日記 AND author: 太郎) OR author: 次郎
のような内容1を match
クエリの組み合わせで表すと、以下のような構造になります。
{ "query": { "bool": { "should": [ { "bool": { "must": [ { "match": { "title": "日記" } }, { "match": { "author": "太郎" } } ] }, "match": { "author": "次郎" } } ] } } }
あとがき
調べたことのまとめは以上になります。
仕様の詳細や使い方は書きませんでしたが、この記事の内容を頭に入れておけば、公式ドキュメントなどに書いてある内容がきっと理解しやすくなると思います。
私たちの新教材基盤システムでは導入に向けて、まだ鋭意実装作業中ですが、もし実際に運用して気付いた知見等があれば、また記事にまとめたいと思います。
We are hiring!
株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。
私の所属するチームは企画職と企画開発エンジニア、データエンジニア、データサイエンティストが混在して様々な実験的な取り組みを一緒にドライブするとっても刺激的な職場です。
カジュアル面談も行っています。お気軽にご連絡ください!
開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。
- このクエリ文字列はOpenSearchで使えるクエリストリングの文法とは異なります↩