🥁

PostgreSQLだけでElasticsearchのようなキーワード検索!ParadeDB触ってみた

はじめに

こんにちは、エンジニアの長谷です。

最近社内でPostgreSQL拡張をRustで実装しているのですが、世の中でもRust製のPostgreSQL拡張がいろいろと開発されているようです。
今日はその中の1つParadeDBをご紹介します。

ParadeDBとは

ParadeDBとはElasticsearchのような機能を持つPostgreSQL拡張で、Rustで実装されています。
似たような拡張としてはZomboDBがありますが、こちらはあくまでElasticsearchの導入が前提でPostgreSQLとElasticsearchを連携するような機能であるのに対し、ParadeDBはPostgreSQLのみで完結できるという特徴があります。
機能としては主にSearch(pg_search)とAnalytics(pg_lakehouse)を提供しており、さらにSearchに関しては全文検索、類似検索、それらを合わせたハイブリッド検索の3つが提供されているようです。本記事では全文検索に関して紹介します。

install

> docker run --name paradedb paradedb/paradedb
> docker exec -it paradedb psql -U postgres
# passwordはpostgres
Password for user postgres:
psql (16.2)
Type "help" for help.

postgres=#

検索前の準備

今回は以下のようなテーブルを用意して、ParadeDBを使ってみます。

create table search_doc (id int primary key, doc text);
insert into search_doc values(1, '東京ディズニーランドは東京都にはありません');
insert into search_doc values(2, '東京ディズニーランドと東京ディズニーシーの2種類があります');

ParadeDBでElasticsearchのような全文検索をする前に、indexの作成が必要です。
btree indexなどを作成するときに使うcreate indexのような構文とは違い、少々特殊な作成方法となっています。今回でいえば下記を実行するとindexが作成されます。

CALL paradedb.create_bm25(
  index_name => 'search_doc_idx',
  schema_name => 'public',
  table_name => 'search_doc',
  key_field => 'id',
  text_fields => '{doc: {tokenizer: {type: "japanese_lindera"}}}'
);

各項目の意味は次のようになります。

  • index_name: 作成されるindexの名前
    • これはPostgreSQLにおける一般的な意味でのindexではないです、意味としてはテーブルに近いです
  • schema_name: indexが作成されるテーブルが属するschema
  • table_name: indexが作成されるテーブル名
  • key_field: pkeyであるようなカラム
  • text_fields: text型のカラムに対しての設定。ここで文書に対する適切なtokenizerを指定する
    • 日本語に関してはjapanese_linderaを指定することで、いい感じに形態素解析し、tokenizeされそうです

その他にもいくつかoptionがあるのですが、詳細に関しては https://docs.paradedb.com/search/full-text/index を参照してください。

検索してみる

検索の構文は以下のようになります。<index_name>が先ほどindex作成時に指定したindex_nameです。<query>の部分でキーワードを渡すイメージで、ここは具体例を挙げながら説明します。

select * from <index_name>.search('<query>');

下記が最も簡単なクエリの例です。<query>='検索対象のcolumn:検索keyword'という形でクエリを実行しています。この例ではdocカラムに対して、"東京""京都"で検索していることになります。

select id, doc, paradedb.rank_bm25(id) from search_doc_idx.search('doc:東京');
 id |                            doc                            | rank_bm25
----+-----------------------------------------------------------+------------
  1 | 東京ディズニーランドは東京都にはありません                    | 0.25409526
  2 | 東京ディズニーランドと東京ディズニーシーの2種類があります      | 0.24737906
(2 rows)

select id, doc, paradedb.rank_bm25(id) from search_doc_idx.search('doc:京都');
 id | doc | rank_bm25
----+-----+-----------
(0 rows)

クエリの結果を見ていきましょう。

"東京"ではヒットしていますが、"京都"ではヒットしていません。よくある部分一致検索では、id=1のdocに"東京都"があるので"京都"で検索してもヒットしてしまいますが、今回はindex作成時に使用したtokenizer(japanese_lindera)のおかげでヒットしていないことがわかります。

続いてrank_bm25カラムを見てみましょう。

しれっとparadedb.rank_bm25という謎の関数を呼び出していますが、これはbm_25というアルゴリズムから得られたスコアを返す関数になります。このスコアは入力されたキーワードがどれだけ文書(今回の例ではdoc)とマッチしているかを表しており、スコアが高いほどマッチしていることを意味します。BM25はTF-IDFの改良版のようなもので、Elasticsearchでもデフォルトで採用されています。

スコアを比較してみると、id=1の文書の方がid=2よりも高くなっており、id=1の文書が"東京"というキーワードによりマッチしていそうな文書であることが分かります。どちらのdocでも"東京"という単語がちょうど2回登場しているので頻度としては同じですが、id=1の方が短い文書のため"東京"という単語の出現率(=ヒットした単語の数 / 文書の長さ)としてはid=1の文書の方がより高いと考えられるため、スコアが高くなっているのだと思われます。

もう少し複雑な検索をやってみる

先ほどまでは<query> = '検索対象のcolumn:検索keyword'でしたが、<query> = query => paradedb.検索関数()のような形でより複雑な検索をしてみます。ここでは2つほど紹介しますが、そのほかには https://docs.paradedb.com/search/full-text/complex に載っているのでぜひ参考にしてみてください。

fuzzy term

  • paradedb.fuzzy_term関数を使うことであいまい検索が可能です。
  • "ディズニー"はもちろん、"デズニー"や"ディニー"でもヒットしました
select id, doc, paradedb.rank_bm25(id) 
from search_doc_idx.search(
  query => paradedb.fuzzy_term(field => 'doc', value => 'ディズニー')
);
 id |                            doc                            | rank_bm25
----+-----------------------------------------------------------+-----------
  2 | 東京ディズニーランドと東京ディズニーシーの2種類があります      |         2
  1 | 東京ディズニーランドは東京都にはありません                    |         1
(2 rows)

select id, doc, paradedb.rank_bm25(id) 
from search_doc_idx.search(
  query => paradedb.fuzzy_term(field => 'doc', value => 'デェズニー')
);
 id |                            doc                            | rank_bm25
----+-----------------------------------------------------------+-----------
  2 | 東京ディズニーランドと東京ディズニーシーの2種類があります      |         1
  1 | 東京ディズニーランドは東京都にはありません                    |       0.5
(2 rows)

select id, doc, paradedb.rank_bm25(id) 
from search_doc_idx.search(
  query => paradedb.fuzzy_term(field => 'doc', value => 'ディゼニー')
);
 id |                            doc                            | rank_bm25
----+-----------------------------------------------------------+-----------
  2 | 東京ディズニーランドと東京ディズニーシーの2種類があります      |         1
  1 | 東京ディズニーランドは東京都にはありません                    |       0.5
(2 rows)

Highlighting

  • paradedb.highlight関数を使うことで、文書内でヒットしたキーワードをハイライトできる機能です
    • defaultは<b>タグで囲みますが、paradedb.highlight関数の引数であるprefixとpostfixを指定することで任意の文字で囲むことができます
  • 現在ハイライト機能とあいまい検索機能は同時に使えないようです
select
  id
  ,paradedb.highlight(id, field => 'doc') as highlight_doc
  ,paradedb.rank_bm25(id) 
from search_doc_idx.search(query => paradedb.parse('doc:東京'));
 id |                              highlight_doc                              | rank_bm25
----+-------------------------------------------------------------------------+------------
  1 | <b>東京</b>ディズニーランドは<b>東京</b>都にはありません                    | 0.25409526
  2 | <b>東京</b>ディズニーランドと<b>東京</b>ディズニーシーの2種類があります      | 0.24737906
(2 rows)

select
  id
  ,paradedb.highlight(id, field => 'doc', prefix => '<div class="highlight">', postfix => '</div>') as highlight_doc
  ,paradedb.rank_bm25(id) 
from search_doc_idx.search(query => paradedb.parse('doc:東京'));
 id |                                                    highlight_doc                                                    | rank_bm25
----+---------------------------------------------------------------------------------------------------------------------+------------
  1 | <div class="highlight">東京</div>ディズニーランドは<div class="highlight">東京</div>都にはありません                    | 0.25409526
  2 | <div class="highlight">東京</div>ディズニーランドと<div class="highlight">東京</div>ディズニーシーの2種類があります      | 0.24737906
(2 rows)

select
  id
  ,paradedb.highlight(id, field => 'doc', prefix => '<<<< ', postfix => ' >>>>') as highlight_doc
  ,paradedb.rank_bm25(id) 
from search_doc_idx.search(query => paradedb.parse('doc:東京'));
 id |                                 highlight_doc                                 | rank_bm25
----+-------------------------------------------------------------------------------+------------
  1 | <<<< 東京 >>>>ディズニーランドは<<<< 東京 >>>>都にはありません                    | 0.25409526
  2 | <<<< 東京 >>>>ディズニーランドと<<<< 東京 >>>>ディズニーシーの2種類があります      | 0.24737906
(2 rows)

まとめ

ParadeDBのpg_searchを実際に触ってみた感想をまとめると下記になります。

  • 良かったところ
    • bm_25アルゴリズムでスコアリングしてキーワード検索ができる
    • あいまい検索やハイライト機能がある
    • Elasticsearchを入れる必要がなくPostgreSQLだけで完結するのでPostgreSQLユーザにはハードルが低い
  • 気になるところ
    • PostgreSQLだけで完結するとはいえ構文は結構癖がある
    • Elasticsearchのanalyzerほどの細かな設定はできなさそう
    • 文字列がどのように分析されているのかを簡単には確認できなさそう

全文検索の機能は現状ではElasticsearchの代替とまではならなそうな気がしますが、PostgreSQLだけでElasticsearchっぽい検索をお手軽に試すにはいいのかなと感じました。また今回は紹介できませんでしたが、ベクトル検索やハイブリッド検索などの機能もあるようなのでまた触ってみたいと思います。

この記事を書いた人

長谷 洋斗
2021年新卒入社
東京ディズニーランドは千葉県にあります

FORCIA Tech Blog

Discussion