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を使うと一意にできる気がします。ここでは、どうにかして一意にできる前提で設計を進めます。
Literature
でTABLE_HASH_KEY
を使っているのは、今回のケースでは主キーで完全一致検索できれば十分だからです。
「論文」と「書籍」を同じテーブルに入れるため、区別するための情報も格納します。この設計ではtype
カラムを追加し、そこに"paper"
(「論文」)または"book"
(「書籍」)を格納することにします。
type
の型はShortText
でもよいのですが、検索効率および空間効率を考慮してTypes
型にします。Types
はこの後にすぐ定義しますが、ただのテーブルです。カラムの型にテーブルを指定すると実際のデータ("paper"
と"book"
)はTypes
テーブルの主キーに入ります。カラムにはTypes
テーブルの該当レコードのID(1
と2
とか)が入ります。各カラムに入るデータは単なる数値なので比較も高速(検索効率がよい)ですし、サイズも小さい(空間効率がよい)です。
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
): シリーズ(「書籍」のみ)
title
とauthors
は全文検索のためのカラムです。
検索項目を増やす場合は単にカラムを増やしてインデックスを追加するだけです。追加方法は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
テーブルではTokenNgram
とNormalizerNFKC100
を使っています。他にも指定できるものはありますが、これらがバランスがよいので、まずはこれから始めるのがよいです。必要なら後で調整するとよいです。
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
title
とauthors
を両方検索対象にするには次のようにします。
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'
図示すると次の通りです。
次の親子関係も同様に実現できます。
-
出版社
- 書籍
-
シリーズ
- 書籍(シリーズに属さない書籍もある)
2段以上のドリルダウンの実現
続いて2段以上の親子関係のドリルダウンの実現方法について説明します。
まずは、次の2段のケースについて説明します。
-
雑誌
-
号
- 論文
-
その後、次の3段のケースについて説明します。
-
出版元
-
雑誌
-
号
- 論文
-
-
2段の場合もテーブルを型にしたカラムを作成するのは同じです。
今回の設計では「雑誌」用にMagazines
テーブルを作成します。
table_create Magazines TABLE_HASH_KEY ShortText
「号」が所属する「雑誌」を格納するカラムをVolumes
テーブルに追加します。
column_create Volumes magazine COLUMN_SCALAR Magazines
これで「号」から「雑誌」をたどることができます。
「号」と「雑誌」でドリルダウンするには次のようにします。ポイントは、.table
でvolumes
を指定しているところと、calc_target
・calc_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'
図示すると次の通りです。
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'
図示すると次の通りです。
次のケースも同様に実現できる。
-
親カテゴリ
-
子カテゴリ
- 書籍
-
子の一覧
親子階層の情報を使って子のレコードを検索する方法を説明します。
ここでは、対象の「出版元」内の「雑誌」の一覧を返すケースを例にして説明します。
まず、対象の「出版元」を絞り込む必要があります。ここでは「出版元」の名前(主キーに入っています)を全文検索して絞り込むとします。
全文検索用のインデックスのテーブルは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をもっと活用したい方はお問い合わせください。