PostgreSQLでXML(6): textsearch_sennaを用いたXML全文検索

さて、さらにPostgreSQLでXMLを使ってみようシリーズ、続きます。 前回が、 意図したことを実現できず、ちょっと不完全燃焼だったので、 今回はもう少し爽快なネタを扱おうと思います。

ここでは今まで、 PostgreSQLにおけるXMLの検索にはインデックスが重要であることを繰り返して来ましたが、 一方で今まで扱った内容では、XMLに関しては、インデックスを使った場合、次の検索しかできません。

  • GINインデックスを用いた、個々の要素は完全一致の集合演算的な条件検索
  • B-treeインデックスを用いた、完全一致及び前方一致検索

つまり、中間一致や後方一致の場合さえ、 今まで出た方法では高速検索ができないのです。 今回はこの制限を取っ払い、いきなり全文検索までたどり着いてしまおうと思います。

なお、今回も引き続き次のようなテーブルを前提とします。

CREATE TABLE testxml (
  id SERIAL PRIMARY KEY,
  xmldoc XML
);

コンテンツも引き続き、本サイトのXMLデータ(約1500件)を、 xmldocに突っ込んであります。idは連番です。

textsearch_sennaのインストール

現在のPostgreSQLには、全文検索のシステムが入っているのですが、 残念ながら日本語には対応がされていません。 日本語に対応し、PostgreSQLをサポートするシステムとしては、 現在は未来検索ブラジルのオープンソース全文検索エンジンSennaを用いた、textsearch_sennaというシステムがなかなかよさそうです。 これをPostgreSQLのXMLサポートと組み合わせて使ってみましょう。

以下のインストール作業はFreeBSDを前提にして解説しますが、 他のOSでは適宜参考にしてください。

まずはSennaのインストールです。 FreeBSDではSennaはportsソフトウェアに入っているので、 portsを使ってインストールできます。

# cd /usr/ports/textproc/senna/
# make install clean
#

"MECAB: use mecab for morphological analysis"のオプションと、 "NFKC: use nfkc based utf8 normalization"のオプションが表示されますが、 どちらもデフォルトの有効で良いと思います。

textsearch_sennaは、残念ながらFreeBSD portsには入っていません。 そのため、 textsearch_sennaのWebサイトから、 textsearch_senna-9.0.0.tar.gz をダウンロードします (9.0.0となっていますが、PostgreSQL 9.0.3でもそのまま利用できます)。 インストール法も上記のサイトを参考にすれば簡単です。

なお、以下にFreeBSDでのインストール手順を示しますが、 FreeBSDではmakeではなくgmakeを利用するので、 万が一gmakeがインストールされていなければportsからインストールして下さい。 ちなみに、make install前に念の為にPostgreSQLを停止しています。

% tar xvfz textsearch_senna-9.0.0.tar.gz
x textsearch_senna-9.0.0/
x textsearch_senna-9.0.0/textsearch_senna-8.4.sql.in
...(略)...
% cd textsearch_senna-9.0.0
% gmake USE_PGXS=1
cp textsearch_senna-9.0.sql.in textsearch_senna.sql.in
sed 's,MODULE_PATHNAME,$libdir/textsearch_senna,g' textsearch_senna.sql.in >textsearch_senna.sql
...(略)...
% su
Password: **********
# /usr/local/etc/rc.d/postgresql stop
# gmake USE_PGXS=1 install
/bin/sh /usr/local/lib/postgresql/pgxs/src/makefiles/../../config/install-sh -c -d '/usr/local/lib/postgresql'
/bin/sh /usr/local/lib/postgresql/pgxs/src/makefiles/../../config/install-sh -c -d '/usr/local/share/postgresql/contrib'
/bin/sh /usr/local/lib/postgresql/pgxs/src/makefiles/../../config/install-sh -c -m 755  textsearch_senna.so '/usr/local/lib/postgresql/textsearch_senna.so'
/bin/sh /usr/local/lib/postgresql/pgxs/src/makefiles/../../config/install-sh -c -m 644 ./uninstall_textsearch_senna.sql '/usr/local/share/postgresql/contrib'
/bin/sh /usr/local/lib/postgresql/pgxs/src/makefiles/../../config/install-sh -c -m 644 textsearch_senna.sql '/usr/local/share/postgresql/contrib'
#

そしてPostgreSQLを再度立ち上げ、 データベースにtextsearch_sennaの関数を登録します。 ここではデータベース名としてtestxmlを仮定します。

# /usr/local/etc/rc.d/postgresql start
# psql -f /usr/local/share/postgresql/contrib/textsearch_senna.sql testxml pgsql
SET
CREATE FUNCTION
BEGIN
psql:/usr/local/share/postgresql/contrib/textsearch_senna.sql:10: NOTICE:  type "senquery" is not yet defined
...(略)...
#

textsearch_sennaのインデックス作成

さて、全文検索にはこのtextsearch_sennaのインデックス作成をしてみましょう。 作成のためのCREATE INDEXは次のようになります。

CREATE INDEX testxml_xmldoc_title_senna_idx ON testxml
USING SENNA (array_to_string2(xpath('//title/text()', xmldoc)::text[]))

ここで、array_to_string2()は第4回で紹介した次の関数です。

CREATE FUNCTION array_to_string2(text[]) RETURNS text
    LANGUAGE sql 
    IMMUTABLE STRICT 
    AS $$
        SELECT array_to_string($1, ' ') 
    $$

では実際に作ってみましょう。少々時間がかかりますが、 CREATE INDEXが成功しました。

testxml=# CREATE INDEX testxml_xmldoc_title_senna_idx ON testxml
testxml-# USING SENNA (array_to_string2(xpath('//title/text()', xmldoc)::text[]));
CREATE INDEX

ではこれで実際に検索してみましょう。「ラーメン」という文字列を含む<title>を検索してみる例を示します。…お見事。

testxml=# SELECT id,
testxml-# array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[])
testxml-# FROM testxml
testxml-# WHERE array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[]) %% 'ラーメン';
  id  |                array_to_string2                 
------+-------------------------------------------------
  183 | レンタサイクル・「第一旭」でのラーメン
  218 | 恵比寿のラーメン「瞠〜miharu〜」
  343 | そろそろ落ち着いて来た? ラーメン「むらさき山」
  508 | 山頭火の辛味噌ラーメン3辛
  616 | 麺場ハマトラのラーメン
  884 | 旭川に着いたら早速「天金」でラーメン
  885 | 旭川に着いたら早速「天金」でラーメン
  918 | 「青葉」でラーメンを食べて札幌に移動
 1100 | 旭川らあめん うえだの塩ラーメン
 1414 | 高知・須崎風鍋焼きラーメン
(10 行)

EXPLAIN ANALYZEで確認すると、 全文検索ながらきっちりとインデックスが使われており、 なおかつ4ms未満という非常に速いスピードで検索が終了しています。 素晴らしい、流石音速の貴公子Senna。

testxml=# EXPLAIN ANALYZE SELECT id,
testxml-# array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[])
testxml-# FROM testxml
testxml-# WHERE array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[]) %% 'ラーメン';
                                                               QUERY PLAN                                                                
-----------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on testxml  (cost=4.58..43.36 rows=10 width=36) (actual time=0.628..3.604 rows=10 loops=1)
   Recheck Cond: (array_to_string2((xpath('//title/text()'::text, xmldoc, '{}'::text[]))::text[]) %% 'ラーメン'::text)
   ->  Bitmap Index Scan on testxml_xmldoc_title_senna_idx  (cost=0.00..4.58 rows=10 width=0) (actual time=0.028..0.028 rows=10 loops=1)
         Index Cond: (array_to_string2((xpath('//title/text()'::text, xmldoc, '{}'::text[]))::text[]) %% 'ラーメン'::text)
 Total runtime: 3.733 ms
(5 行)

では、今度はさらに<body>内部の全文章という形でインデックスを張ってみましょう。XPathを使って次のようにすれば実現できます。

CREATE INDEX testxml_xmldoc_body_senna_idx ON testxml
USING SENNA (array_to_string2(xpath('//body//text()', xmldoc)::text[]))

ではこれで、同様に「ラーメン」で検索してみましょう。文書の中で一度でも「ラーメン」という文字列が使われていれば反応しているので、上の例よりも多い、67件が検索されてきています。検索にかかった時間もわずか40ms弱でした。

testxml=# SELECT id,
testxml-# array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[])
testxml-# FROM testxml
testxml-# WHERE array_to_string2(xpath(E'//body//text()'::text, xmldoc)::text[]) %% 'ラーメン';
  id  |                                         array_to_string2                                         
------+--------------------------------------------------------------------------------------------------
    2 | KOKIA/elly/aika/Key/友香「水の音 vol.11」@渋谷O-West
   87 | 宝美 他 ライブ @ 渋谷 7th floor
  159 | 「燻とん」の豚丼
  183 | レンタサイクル・「第一旭」でのラーメン
...(略)...
 1515 | Lily Chou-Chou 2010.12.15 Live "エーテル"
 1526 | 昨日と逆方向の羽田まで
(67 行)

LIKE演算子による検索

では、部分一致の%%演算子ではなく、 LIKE演算子で検索を行ってみましょう。 <title>に対してLIKEの検索を行いたい場合は、 次のようにlike_ops演算子クラスを指定してインデックスを張ります。

CREATE INDEX testxml_xmldoc_title_like_idx ON testxml
USING SENNA (array_to_string2(xpath('//title/text()', xmldoc)::text[]) like_ops)

この場合は、たとえば次のような後方一致のインデックスを用いた検索も可能です。

testxml=# SELECT id,
testxml-# array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[])
testxml-# FROM testxml
testxml-# WHERE array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[]) LIKE '%ラーメン';
  id  |            array_to_string2            
------+----------------------------------------
 1100 | 旭川らあめん うえだの塩ラーメン
  616 | 麺場ハマトラのラーメン
  884 | 旭川に着いたら早速「天金」でラーメン
  885 | 旭川に着いたら早速「天金」でラーメン
 1414 | 高知・須崎風鍋焼きラーメン
  183 | レンタサイクル・「第一旭」でのラーメン
(6 行)

EXPLAIN ANALYZEを確認すると、きちんと新しいインデックスtestxml_xmldoc_title_like_idxが用いられていることがわかります。

testxml=# EXPLAIN ANALYZE SELECT id,
testxml-# array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[])
testxml-# FROM testxml
testxml-# WHERE array_to_string2(xpath(E'//title/text()'::text, xmldoc)::text[]) LIKE '%ラーメン';
                                                               QUERY PLAN                                                               
----------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using testxml_xmldoc_title_like_idx on testxml  (cost=0.25..8.77 rows=1 width=36) (actual time=1.273..5.588 rows=6 loops=1)
   Index Cond: (array_to_string2((xpath('//title/text()'::text, xmldoc, '{}'::text[]))::text[]) ~~ '%ラーメン'::text)
 Total runtime: 5.685 ms
(3 行)

ということで、素晴らしいですね、textsearch_senna!

ただし、textsearch_sennaはPostgreSQL標準ではないため、 インデックスの削除などに気を付けなければならない点がありますので、 詳細はtextsearch_sennaのページをお読みください。

また、PostgreSQLのバージョンアップを行ったりする際は、Sennaインデックスの削除→PostgreSQLのバージョンアップ→textsearch_sennaの再インストール→再度Sennaインデックス作成などの手順を踏む必要があります。気をつけましょう。

関連記事