ククログ

株式会社クリアコード > ククログ > 階層構造データ用のGroongaのスキーマ設計方法

階層構造データ用のGroongaのスキーマ設計方法

Groongaを開発している須藤です。

GroongaにはRDBMS(MySQLやMariaDBやPostgreSQLなど)と同じようにテーブルとカラムがあり、それらに構造化したデータを保存します。しかし、用途が違う(Groongaは検索がメインでRDBMSは検索だけでなくデータの管理も大事)のでスキーマの設計方法はRDBMSでの設計方法と違う部分があります。そのため、RDBMSのスキーマの設計に慣れている人でもGroongaのスキーマをうまく設計できないことがあります。

この記事では少し複雑なデータをGroongaで検索するためのスキーマの設計方法を説明します。

なお、ここで使っている設計を実装したGroongaコマンドはgroonga/groonga-schema-design-exampleにあります。実際に試してみると理解が深まるはずなのでぜひ試してみてください。

データ

まず、検索対象のデータを説明します。

検索対象は次の2つです。

  • 論文

  • 書籍

それぞれのデータがもつ情報は同じものもあれば違うものもあります。

たとえば、「タイトル」は「論文」にも「書籍」にもあります。

しかし、「雑誌」は「論文」だけにあり、「書籍」にはありません。(論文が収録されている雑誌の情報です。)

また、次のような親子関係があります。「論文」はいくつも階層になった親があります。書籍は複数の親を持ちます。少し複雑なデータですね!

  • 出版元

    • 雑誌

        • 論文
  • 出版社

    • 書籍
  • 親カテゴリー

    • 子カテゴリー

      • 書籍
  • シリーズ

    • 書籍(シリーズに属さない書籍もある)

検索方法

この少し複雑なデータに対して次のような検索をするためのスキーマを設計します。

  • 「論文」と「書籍」を横断全文検索

  • 複数カラムで横断全文検索

    • たとえば「タイトル」と「著者」で横断全文検索
  • 「論文」だけを全文検索

  • 「書籍」だけを全文検索

  • 検索結果を任意の親でドリルダウン

    • たとえば「出版元」でドリルダウン
  • 指定した親に属する子レコードを検索

    • たとえば「出版元」が発行している「雑誌」を検索

設計の概要

Groongaでは検索対象を1つのテーブルに集めることが重要です。すごく重要です。本当に重要です。

今回のケースでは「論文」と「書籍」が検索対象なので、それらを同じテーブルに格納します。今回の設計では2つ合わせて「文献」と扱うことにし、Literatureテーブルを作成します。

Literatureテーブルの定義は次の通りです。

table_create Literature TABLE_HASH_KEY ShortText

主キーに設定する値は「論文」と「書籍」全体で一意にする必要があることに注意してください。「論文」ではISSNとかなにかを使って、「書籍」ではISBNを使うと一意にできる気がします。ここでは、どうにかして一意にできる前提で設計を進めます。

LiteratureTABLE_HASH_KEYを使っているのは、今回のケースでは主キーで完全一致検索できれば十分だからです。

参考:TABLE_*の特徴の違い

「論文」と「書籍」を同じテーブルに入れるため、区別するための情報も格納します。この設計ではtypeカラムを追加し、そこに"paper"(「論文」)または"book"(「書籍」)を格納することにします。

typeの型はShortTextでもよいのですが、検索効率および空間効率を考慮してTypes型にします。Typesはこの後にすぐ定義しますが、ただのテーブルです。カラムの型にテーブルを指定すると実際のデータ("paper""book")はTypesテーブルの主キーに入ります。カラムにはTypesテーブルの該当レコードのID(12とか)が入ります。各カラムに入るデータは単なる数値なので比較も高速(検索効率がよい)ですし、サイズも小さい(空間効率がよい)です。

column_create Literature type COLUMN_SCALAR Types

Typesは次のように定義したテーブルです。主キーには"paper"または"book"を設定します。主キーは完全一致だけできれば十分なのでTABLE_HASH_KEYにしています。

# "paper"または"book"
table_create Types TABLE_HASH_KEY ShortText

Literatureテーブルにレコードを追加したときにこのテーブルにも自動でレコードが追加されるので明示的に管理する必要はありません。typeカラムに"paper"を格納しようとすれば自動でTypesテーブルに主キーが"paper"のレコードが追加されます。すでにレコードがあればそのレコードを使います。つまり、typeカラムの型にShortTextを使ったときと同じように使えます。

型にテーブルを指定する方法はGroongaではよく使う方法です。用途はいろいろありますが、この使い方はRDBMSでいうenum型のようなものを実現するための使い方です。enum型のように値を制限することはできませんが。。。他の用途は後ででてきます。

Literatureに「論文」と「書籍」の情報をすべて格納します。中には「論文」にしかない情報あるいは「書籍」にしかない情報も存在します。存在しない情報は該当カラムに値を設定しません。

たとえば、「子カテゴリー」情報は「書籍」にしか存在しないので「論文」用のレコードを格納するときは「子カテゴリー」情報のカラムに値を設定しません。

GroongaにはNULLはないので、値を設定しなかったカラムの値はその型の初期値になっています。たとえば、ShortTextなら空文字列ですし、Int32なら0です。

今回の設計ではLiteratureテーブルには次のカラムを用意します。

  • type (Types): 種類(「論文」("paper")か「書籍」("book"))

  • title (ShortText): タイトル

  • authors (Authors): 著者(複数)

  • volume (Volumes): 号(「論文」のみ)

  • book_publisher (BookPublishers): 出版社(「書籍」のみ)

  • child_category (ChildCategories): 子カテゴリー(「書籍」のみ)

  • series (Series): シリーズ(「書籍」のみ)

titleauthorsは全文検索のためのカラムです。

検索項目を増やす場合は単にカラムを増やしてインデックスを追加するだけです。追加方法はauthorsを例にして後述します。全文検索用のスキーマ設計の方法もあわせて説明します。

以下のカラムはドリルダウンのためのカラムです。

  • volume

  • book_publisher

  • child_category

  • series

これらの情報で親子関係を表現します。親の親がある場合でもGroongaでは各レコードは直接の親だけを格納していれば十分です。各レコードに親の情報だけでなく、親の親の情報も格納する必要はありません。これは正規化した状態のままでよいということです。正規化した状態のままで扱えるため情報の管理が楽です。たとえば、「雑誌」の名前を変更する時は雑誌テーブルの該当レコードを変更するだけでよく、「雑誌」情報を持っているすべてのレコードを変更する必要はないということです。

ドリルダウン用のスキーマ設計は後述します。

以上が設計の概要です。ポイントは次の通りです。

  • 横断検索対象の情報はすべて1つのテーブルにまとめる

  • 対象の種類を区別する必要がある場合はカラムにその情報を入れて区別する

  • 検索条件に使いたい情報を増やす場合はテーブルにカラムを追加する

  • 特定のレコードにしかない情報(「論文」にしかない情報や「書籍」にしかない情報)でもカラムを追加してよい

    • 情報が存在しないレコードでは単にカラムに値を設定しない
  • ドリルダウン用の情報は正規化したままでよい

検索項目の追加

著者情報を例に検索項目を追加する方法を示します。

著者は複数存在するので次のようにCOLUMN_VECTORで定義します。

column_create Literature authors COLUMN_VECTOR Authors

型はAuthorsテーブルにしていますがShortTextにしてもよいです。テーブルを使っている理由はtypeカラムのときと同じで検索効率および空間効率がよいからです。著者でドリルダウンするなら(今回は説明しません)テーブルにするべきです。計算効率が全然違います。

今回の設計では著者名を主キーにします。

table_create Authors TABLE_HASH_KEY ShortText

同姓同名の著者を別人として扱いたい場合は著者IDを振ってnameカラムを追加します。今回の説明ではそこは本質ではないので単に著者名を主キーにしています。

著者名で完全一致検索する場合は次のようにすれば効率よく検索できます。

select \
  --table Literature \
  --query 'authors:@山田太郎'

著者名で全文検索する場合は追加のインデックスが必要です。

まず、Authors._keyで全文検索するためのインデックスが必要です。

table_create Terms TABLE_PAT_KEY ShortText \
  --default_tokenizer TokenNgram \
  --normalizer NormalizerNFKC100

column_create Terms authors_key \
  COLUMN_INDEX|WITH_POSITION Authors _key

Termsテーブルは他の全文検索用インデックスでも共有可能です。共有するとトークン(全文検索用にテキストを分割したもの)の情報を共有でき、DB全体の空間効率がよくなります。

TermsテーブルではTokenNgramNormalizerNFKC100を使っています。他にも指定できるものはありますが、これらがバランスがよいので、まずはこれから始めるのがよいです。必要なら後で調整するとよいです。

Terms.authors_keyは全文検索用のインデックスなのでWITH_POSITIONを指定しています。

これで、著者名で全文検索して該当著者を検索できるようになります。しかし、その著者から該当「論文」を見つけることはまだできません。追加で次のインデックスが必要です。

column_create Authors literature_authors \
  COLUMN_INDEX Literature authors

このインデックスはどの著者がどの「論文」の著者かを高速に検索するためのインデックスです。このインデックスも作ることで「著者名で全文検索して著者を見つけ、さらに、その著者がどの論文の著者かを検索する」を実現できます。

検索クエリーは次のようになります。完全一致検索のときとの違いはauthors:@._keyが加わってauthors._key:@となっているところです。

select \
  --table Literature \
  --query 'authors._key:@山田'

各インデックスカラムの役割を図示すると次の通りです。

ネストした検索

authorsは複数の著者が存在するためCOLUMN_VECTORを使っています。また、重複した情報が多くなるため型にテーブルを利用しました。そのため、少し複雑になっています。

titleのように単純な情報の場合は次のようにするだけで十分です。

column_create Literature title COLUMN_SCALAR ShortText
column_create Terms literature_title \
  COLUMN_INDEX|WITH_POSITION Literature title

titleauthorsを両方検索対象にするには次のようにします。

select \
  --table Literature \
  --match_columns 'title || authors._key' \
  --query 'キーワード'

「論文」(type"paper")だけを検索する場合は次のように--filterで条件を追加します。selectでは--query--filterで条件を指定できますが、--queryはユーザーからの入力をそのまま入れる用のオプションで--filterはシステムでより詳細な条件を指定する用のオプションです。

select \
  --table Literature \
  --match_columns 'title || authors._key' \
  --query 'キーワード' \
  --filter 'type == "paper"'

参考:select

1段のドリルダウンの実現

検索対象のデータには2段以上の親子関係のドリルダウンがありますが、まずは1段の親子関係のドリルダウンの実現方法について説明します。

例として次の親子関係のドリルダウンの実現方法について説明します。

    • 論文

効率的なドリルダウンを実現するためにテーブルを型にしたカラムを作成します。(enum型っぽい使い方とは別の型にテーブルを使う使い方。)

今回の設計では「号」用にVolumesテーブルを作成します。

table_create Volumes TABLE_HASH_KEY ShortText

「論文」はLiteratureテーブルなので、Literatureテーブルにvolumeカラムを作成します。型はVolumesテーブルです。

column_create Literature volume COLUMN_SCALAR Volumes

これでvolumeカラムで効率的にドリルダウンできます。次のようにすれば、「号」でドリルダウンし、その「号」には何件の「論文」があるかを検索できます。

select \
  --table Literature \
  --drilldowns[volumes].keys 'volume' \
  --drilldowns[volumes].output_columns '_key,_nsubrecs'

図示すると次の通りです。

1段のドリルダウン

次の親子関係も同様に実現できます。

  • 出版社

    • 書籍
  • シリーズ

    • 書籍(シリーズに属さない書籍もある)

2段以上のドリルダウンの実現

続いて2段以上の親子関係のドリルダウンの実現方法について説明します。

まずは、次の2段のケースについて説明します。

  • 雑誌

      • 論文

その後、次の3段のケースについて説明します。

  • 出版元

    • 雑誌

        • 論文

2段の場合もテーブルを型にしたカラムを作成するのは同じです。

今回の設計では「雑誌」用にMagazinesテーブルを作成します。

table_create Magazines TABLE_HASH_KEY ShortText

「号」が所属する「雑誌」を格納するカラムをVolumesテーブルに追加します。

column_create Volumes magazine COLUMN_SCALAR Magazines

これで「号」から「雑誌」をたどることができます。

「号」と「雑誌」でドリルダウンするには次のようにします。ポイントは、.tablevolumesを指定しているところと、calc_targetcalc_typesです。

select \
  --table Literature \
  --drilldowns[volumes].keys 'volume' \
  --drilldowns[volumes].output_columns '_key,_nsubrecs' \
  --drilldowns[magazines].table 'volumes' \
  --drilldowns[magazines].keys 'magazine' \
  --drilldowns[magazines].calc_target '_nsubrecs' \
  --drilldowns[magazines].calc_types 'SUM' \
  --drilldowns[magazines].output_columns '_key,_sum'

--drilldowns[${LABEL}]は高度なドリルダウンのためのパラメーターです。

参考:高度なドリルダウン関連のパラメーター

このselectでは以下の2つのドリルダウンを実行します。

  • --drilldowns[volumes]: 「号」でドリルダウン

  • --drilldowns[magazines]: 「雑誌」でドリルダウン

--drilldowns[magazines].tableで他のドリルダウンの結果を指定できます。指定するとドリルダウン結果をさらにドリルダウンできます。今回のように親子関係がある場合は子のドリルダウン結果から親のドリルダウン結果を計算します。

ただ、普通にドリルダウンすると、カウントした件数は「論文」の件数ではなく、「号」の件数になります。孫(「論文」)でドリルダウンしているのではなく、子(「号」)でドリルダウンしているからです。孫(「論文」)の件数をカウントするには子(「号」)でカウントした件数をさらにカウントする。その設定が次のパラメーターです。

  • --drilldowns[magazines].calc_target '_nsubrecs'

  • --drilldowns[magazines].calc_types 'SUM'

_nsubrecsには子(「号」)でカウントした孫(「論文」)の件数が入っています。それのSUM(総計)を計算するので孫の件数になります。出力する時は_nsubrecsではなく_sumで参照します。

--drilldowns[magazines].output_columns '_key,_sum'

図示すると次の通りです。

2段のドリルダウン

3段になった次のケースも同様です。

  • 出版元

    • 雑誌

        • 論文

まず、出版元を効率よくドリルダウンするためにPaperPublishersテーブルを作ります。

table_create PaperPublishers TABLE_HASH_KEY ShortText

Magazinesテーブル(「雑誌」)に出版元を格納するカラムを追加します。

column_create Magazines publisher COLUMN_SCALAR PaperPublishers

これで「雑誌」から「出版元」をたどることができます。

「号」と「雑誌」と「出版元」でドリルダウンするには次のようにします。ポイントは、「出版元」のドリルダウンのcalc_target_nsubrecsではなく_sumを使っているところです。「出版元」のドリルダウンで「論文」の件数をカウントするには「雑誌」のドリルダウンでカウント済みの「論文」の件数の総計を計算します。そのカウント済みの「論文」の件数が_nsubrecsではなく_sumにあるので_sumを使います。

select \
  --table Literature \
  --drilldowns[volumes].keys 'volume' \
  --drilldowns[volumes].output_columns '_key,_nsubrecs' \
  --drilldowns[magazines].table 'volumes' \
  --drilldowns[magazines].keys 'magazine' \
  --drilldowns[magazines].calc_target '_nsubrecs' \
  --drilldowns[magazines].calc_types 'SUM' \
  --drilldowns[magazines].output_columns '_key,_sum' \
  --drilldowns[paper_publishers].table 'magazines' \
  --drilldowns[paper_publishers].keys 'publisher' \
  --drilldowns[paper_publishers].calc_target '_sum' \
  --drilldowns[paper_publishers].calc_types 'SUM' \
  --drilldowns[paper_publishers].output_columns '_key,_sum'

図示すると次の通りです。

3段のドリルダウン

次のケースも同様に実現できる。

  • 親カテゴリ

    • 子カテゴリ

      • 書籍

子の一覧

親子階層の情報を使って子のレコードを検索する方法を説明します。

ここでは、対象の「出版元」内の「雑誌」の一覧を返すケースを例にして説明します。

まず、対象の「出版元」を絞り込む必要があります。ここでは「出版元」の名前(主キーに入っています)を全文検索して絞り込むとします。

全文検索用のインデックスのテーブルはAuthors._key用に作ったTermsテーブルを流用します。

column_create Terms paper_publishers_key \
  COLUMN_INDEX|WITH_POSITION PaperPublishers _key

これで「出版元」の名前で全文検索できます。しかし、authorsのときと同じで、「出版元」は絞り込めますが、絞り込んだ「出版元」を元に「雑誌」を絞り込むことはできません。「雑誌」も絞り込めるようにするには追加で次のインデックスが必要です。

column_create PaperPublishers magazines_publisher \
  COLUMN_INDEX Magazines publisher

このインデックスは「出版元」をキーにどの「雑誌」がその「出版元」を参照しているかを高速に検索するためのインデックスです。このインデックスがあることで、絞り込んだ「出版元」を元に「雑誌」を絞り込めます。

次のようなクエリーで「出版元」の名前で全文検索し、絞り込んだ「出版元」が発行している「雑誌」を出力できます。

select \
  --table Magazines \
  --match_columns 'publisher._key' \
  --query 'おもしろ雑誌' \
  --output_columns '_key, publisher._key'

他の親子関係のケースも同様に実現できます。

まとめ

Groongaで以下の機能を効率的に実現するためのスキーマ設計方法について説明しました。

  • 「論文」と「書籍」を横断全文検索

  • 複数カラムで横断全文検索

    • たとえば「タイトル」と「著者」で横断全文検索
  • 「論文」だけを全文検索

  • 「書籍」だけを全文検索

  • 検索結果を任意の親でドリルダウン

    • たとえば「出版元」でドリルダウン
  • 指定した親に属する子レコードを検索

    • たとえば「出版元」が発行している「雑誌」を検索

今回の設計を実装したGroongaコマンドはgroonga/groonga-schema-design-exampleにあります。実際に試してみると理解が深まるはずなのでぜひ試してみてください。

クリアコードではGroongaのスキーマ設計もサポートしています。Groongaをもっと活用したい方はお問い合わせください。