Open6

PostgreSQL 14.5 ドキュメント読み解き - 3-12 全文検索

hassaku63hassaku63

12.1 導入

https://www.postgresql.jp/document/14/html/textsearch-intro.html

クエリを満たす「自然言語の文書」を識別し、クエリとの関連性の強さで並べることができる。最も一般的なのは、「検索語を含む文書」を探して「類似性」でソートした結果を返すというもの。

「問い合わせ」と「類似性」の記述方法は多くのバリエーションがあって、特定の用途で使い分けがある。
一番単純なのは、

  • 「クエリ」は単語の集合
  • 「類似性」は文書中のクエリ対象の単語の頻度

PostgreSQL はテキスト検索の演算子として ~ ~* LIKE ILIKE のようなものがあるが、これらは非常に大事な特徴が欠けている

  • 言語学的なサポート。例えば派生語(複数形や形容詞形)のバリエーションをすべて包括したような検索は難しい。単語によってはバリエーションが非常に多く、OR で派生語すべてを列挙することが現実的にはキツい
  • 検索結果を順序付けできない。多数の結果が見つかった場合、非効率
  • インデックスをサポートしないので毎回全スキャンが発生し、遅い

全文検索 におけるインデックスでは、文書を前もって処理しておき、後で素早く検索できるようにしておく。前処理とは、以下のようなもの。

[1] 文書からトークンを解析する
トークンを「クラス」に分けて識別するのが有効。例えば「数」「単語」「複合単語」「メールアドレス」など。クラスへの分類ができれば、それぞれに適した扱いができるようになる。
PostgreSQL は、パーサ を使ってこのステップを実行する。だいたいは標準で十分だが、カスタム仕様のパーサを作ることもできる

[2] トークンを語彙素 (lexemes) に変換する
違う形態の同じ単語が同じものとして見做せるように正規化されている。例えばレターケースや接尾辞。これをすることで、可能性のある変種をうまく扱えるようになる。
また、ここではストップワードの除去も行うことが多い ("the" "a" とか)。
PostgreSQL は辞書 を使ってこのステップを実行する。ここで利用する辞書は色々標準提供されているものがあるが、カスタム仕様の辞書も追加できる

[3] 検索に最適化された前処理済みの文書を保存する
正規化された語彙素をどうやって格納するか、の話。例えば語彙素の整列済みの配列として表現する。
また、ランキング付けのため、位置情報を格納しておくことがしばしば望まれる。

前処理した文書を格納するためのデータ型として tsvector 型が、処理済みのクエリを表現するために tsquery 型が提供されている。

https://www.postgresql.jp/document/14/html/datatype-textsearch.html

これらの型のための関数や演算子も多数用意されている。

https://www.postgresql.jp/document/14/html/functions-textsearch.html

全文検索はインデックスを使って高速化できる。GINGiST が使える。

https://www.postgresql.jp/document/14/html/textsearch-indexes.html

hassaku63hassaku63

Docker を使って tsvector, tsquery, GIN を試してみた。

-- data/init.sql

-- CREATE DATABASE postgres;

SET client_encoding = 'UTF8';

CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT,
    content_vector TSVECTOR,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_posts_content_gin_jp ON posts USING GIN (content);

---

INSERT INTO posts (title, content, content_vector) VALUES 
('Post 1', 'This is the first post. https://example.com', to_tsvector('This is the first post. https://example.com')),
('Post 2', 'This is the second post. foo@example.com', to_tsvector('This is the second post. foo@example.com')),
(
    'Post 3', 
    'There are two kinds of indexes that can be used to speed up full text searches. Note that indexes are not mandatory for full text searching, but in cases where a column is searched on a regular basis, an index is usually desirable.', 
    to_tsvector('There are two kinds of indexes that can be used to speed up full text searches. Note that indexes are not mandatory for full text searching, but in cases where a column is searched on a regular basis, an index is usually desirable.')
),
('Post 4', 'これはテストです', to_tsvector('これはテストです')),
('Post 5', 'This is the fifth post. https://example.com', to_tsvector('This is the fifth post. https://example.com')),
('Post 6', 'This is the sixth post. foo@example.com', to_tsvector('This is the sixth post. foo@example.com'));

Start postgres container

$ docker run --rm --name postgres -p 15432:5432 \
    -e POSTGRES_PASSWORD=postgres \
    -e POSTGRES_USER=postgres \
    -e POSTGRES_DB=postgres \
    -v $(pwd)/data:/docker-entrypoint-initdb.d \
    postgres:14

$ docker exec -it postgres bash

Query

-- SELECT id, title, content, ts_rank(content_vector, query) as rank
-- FROM posts, to_tsquery('english', 'post') query
-- WHERE query @@ content_vector;

postgres=# select id, title, content, ts_rank(content_vector, query) as rank from posts, to_tsquery('english', 'post') query where query @@ content_vector;
 id | title  |                   content                   |    rank    
----+--------+---------------------------------------------+------------
  1 | Post 1 | This is the first post.                     | 0.06079271
  2 | Post 2 | This is the second post.                    | 0.06079271
  5 | Post 5 | This is the fifth post. https://example.com | 0.06079271
  6 | Post 6 | This is the sixth post. foo@example.com     | 0.06079271

postgres=# select id, title, content_vector, ts_rank(content_vector, query) as rank from posts, to_tsquery('english', 'post') query where query @@ content_vector;
 id | title  |             content_vector             |    rank    
----+--------+----------------------------------------+------------
  1 | Post 1 | 'first':4 'post':5                     | 0.06079271
  2 | Post 2 | 'post':5 'second':4                    | 0.06079271
  5 | Post 5 | 'example.com':6 'fifth':4 'post':5     | 0.06079271
  6 | Post 6 | 'foo@example.com':6 'post':5 'sixth':4 | 0.06079271
hassaku63hassaku63

tsvector を使って PostgreSQL で全文検索をサポートしようとする場合は、「文書」は tsvector 形式に変換された状態で格納されなければならない。
検索とランキングの処理は、全部 tsvector 表現の上で実行される。

ここで、tsvector に変換する前の文書は、(検索、ランキングではなく)検索してきたユーザーへのプレゼンテーションのためだけに取り出される。

tsvector, tsquery を使った一例がこちら。 @@ 演算子は、文書 (tsvector) がクエリ (tsquery) に一致したら t を返す照合演算子。tsvector, tsquery の順序はどちらが先でもOK。

SELECT 'a fat cat sat on a mat and ate a fat rat'::tsvector @@ 'cat & rat'::tsquery;
 ?column?
----------
 t

SELECT 'fat & cow'::tsquery @@ 'a fat cat sat on a mat and ate a fat rat'::tsvector;
 ?column?
----------
 f

上記の例は単なる文字列をキャストしている。to_tsvector, to_tsquery で文字列をパースして正規化することもできる。

SELECT to_tsvector('fat cats ate fat rats') @@ to_tsquery('fat & rat');
 ?column? 
----------
 t

順序付きの隣接関係を <-> (FOLLOWED BY 演算子) で照合できる。tsvector 形式にパース・正規化する前の「元の文書」における隣接を評価することに注意。

SELECT to_tsvector('fatal error') @@ to_tsquery('fatal <-> error');
 ?column? 
----------
 t

SELECT to_tsvector('error is not fatal') @@ to_tsquery('fatal <-> error');
 ?column? 
----------
 f

FOLLOWED BY 演算子は <N> で一般化できる。「N個の間隔をおいて隣接関係にある」が表現できる。
phraseto_tsquery 関数を使って、ストップワードを含む複数語の句にマッチできるような tsquery を構築できる。

SELECT phraseto_tsquery('cats ate rats');
       phraseto_tsquery        
-------------------------------
 'cat' <-> 'ate' <-> 'rat'
hassaku63hassaku63

ここまで紹介したのが単純なテキスト検索。全文検索の機能を使えば、同義語 (Synonym) や賢いパース処理(単なる空白区切り以上のパース)ができるようになる。Text Search Configuration を使ってこれらの機能を制御できる。今ある言語用の設定は \dF コマンドで表示できる。

postgres=# \dF
               List of text search configurations
   Schema   |    Name    |              Description              
------------+------------+---------------------------------------
 pg_catalog | arabic     | configuration for arabic language
 pg_catalog | armenian   | configuration for armenian language
 pg_catalog | basque     | configuration for basque language
 pg_catalog | catalan    | configuration for catalan language
 pg_catalog | danish     | configuration for danish language
 pg_catalog | dutch      | configuration for dutch language
 pg_catalog | english    | configuration for english language
 pg_catalog | finnish    | configuration for finnish language
 pg_catalog | french     | configuration for french language
 pg_catalog | german     | configuration for german language
 pg_catalog | greek      | configuration for greek language
 pg_catalog | hindi      | configuration for hindi language
 pg_catalog | hungarian  | configuration for hungarian language
 pg_catalog | indonesian | configuration for indonesian language
 pg_catalog | irish      | configuration for irish language
 pg_catalog | italian    | configuration for italian language
 pg_catalog | lithuanian | configuration for lithuanian language
 pg_catalog | nepali     | configuration for nepali language
 pg_catalog | norwegian  | configuration for norwegian language
 pg_catalog | portuguese | configuration for portuguese language
 pg_catalog | romanian   | configuration for romanian language
 pg_catalog | russian    | configuration for russian language
 pg_catalog | serbian    | configuration for serbian language
 pg_catalog | simple     | simple configuration
 pg_catalog | spanish    | configuration for spanish language
 pg_catalog | swedish    | configuration for swedish language
 pg_catalog | tamil      | configuration for tamil language
 pg_catalog | turkish    | configuration for turkish language
 pg_catalog | yiddish    | configuration for yiddish language
(29 rows)

インストール時に適当な設定が選ばれ、default_text_search_config が postgresql.conf の中にセットされる。

hassaku63hassaku63

ランキングの関数について。

クエリに合致する文書が複数ある場合に、もっとも関連が強いものを上にしたい。そのためのランキング関数で、PostgreSQL では2つの定義済みの関数が用意されている。これらは辞書情報、近接度情報、構造的情報を加味しているが、何をもって「関連度」とするかはその用途にも強く依存する。

2つの関数が使える。

ts_rank([ weights float4[], ] vector tsvector, query tsquery [, normalization integer ]) returns float4
ts_rank_cd([ weights float4[], ] vector tsvector, query tsquery [, normalization integer ]) returns float4

weight で単語ごとの重みを設定できる。典型的なのは文章のタイトルのような、特定の場所にある単語を強調するための用法。

normalization は正規化のためのオプション。文章の長さがランクに影響を与えるかどうかを制御できる。

value description
0 (デフォルト) 文書の長さを無視します
1 ランクを(1 + log(文書の長さ))で割ります
2 ランクを文書の長さで割ります
4 ランクをエクステントの間の調和平均距離で割ります(これはts_rank_cdのみで実装されています)
8 ランクを文書中の一意の単語の数で割ります
16 ランクをlog(文書中の一意の単語の数)+1 で割ります
32 ランクをランク自身+1 で割ります