Chybějící podpora fulltextu je vážným argumentem proti používání PostgreSQL. Modul tsearch2 nepředstavuje obvyklé a ani úplně dokonalé řešení problému vyhledávání v textových položkách, nicméně nepochybně představuje jistý pokrok, alespoň co se týká funkcionality PostgreSQL, a navíc, jak jsem si uvědomil při debatě v konferenci, asi jen málokdo tady má alespoň malou představu, jak to funguje. Na rovinu je třeba říci, že specializované fulltextové systémy jsou se svými schopnostmi někde úplně jinde.
Modul z doplňků tsearch2 představuje původní nekomerční podporu pro fulltext v RDBMS PostgreSQL 7.3.X a vyšší. Nejedná se ale o obvyklé řešení fulltextu, tj. rozšíření možností indexace a vyhledávání nad textovými položkami, ale o implementaci nového datového typu tsvector, podporujícího fulltextové vyhledávácí operace s podporou indexu (využívá možností GiST indexů v PostgreSQL).
Projekt tsearch2 úzce souvisi s projektem OpenFTS, tj. fullttextového systému ukládajícího metainformace o dokumentech v klasické relační databázi, resp. představuje databázový backend tohoto projektu.
Samotná instalace je jednoduchá a neliší se od instalace jiných doplňků (Contrib modulů) v PostgreSQL. Stačí v adresáři /contrib/tsearch2 jako root zadat:
make make install ldconfig
Poté je třeba otevřít databázi, ve které chceme mít podporu typu tsvector, a naimportovat soubor /usr/local/pgsql/share/contrib/tsearch2.sql. (Jeho umístění může záležet na distribuci. Zmíněná cesta platí pro instalaci ze zdrojových textů.)
createdb ts psql ts ts=# \i /usr/local/pgsql/share/contrib/tsearch2.sql
Samotný indexovaný dokument se zpracovává v několika krocích. Nejdříve parser rozdělí daný text na tzv. tokeny: slova, číslice, mezery, html značky. Pomocí slovníku se každý token převede na tzv. lexém (pro slovesa se hledá infinitiv, podstatná jména se převádějí do jednotného čísla v prvním pádu atd). Lexikální analýzu můžeme provést buď nad slovníkem ispellu, nebo tzv. stemmer funkcí (Funkcionalita je stejná, nepotřebujeme ale slovník. Pro češtinu bohužel tato funkce není vytvořená, nebo alespoň jsem o ní nenašel na internetu jedinou zmínku.) Sloučením všech lexémů dokumentu dostaneme vektor (typu tsvector), s kterým se dále pracuje, resp. se uloží a lze jej indexovat a fulltextově prohledávat. Vektor kromě vlastních lexémů obsahuje i polohu lexémů v dokumentu.
Velikost slovníku má vliv na kvalitu a rychlost redukce textu na lexémy. Je podstatný rozdíl v odezvě prvního volání funkce převodu na lexém (lexize()), pokud má slovník 226 KB, nebo 2MB. Lexikální analýza probíhá jak při vkládání a modifikaci záznamů, tak při dotazování (viz konec textu).
Když token není v daném slovníku nalezen, je výsledkem analýzy prázdný řetězec. Tato vlastnost by mohla diskvalifikovat tsearch2 v praxi – např. některá příjmení nebo názvy v žádném slovníku nenajdeme. Díky genialitě tvůrců máme ale k dispozici tzv. simple slovník, který pouze převádí velká písmena na malá, a můžeme řadit za sebe více slovníků (slovník simple zařadíme na konec). Každý slovník tsearch2 může obsahovat seznam tzv. stop.words (blokovaných slov), tj. slov, která se neindexují (např. je zbytečné indexovat spojky a předložky). Neměl by být problém vytvořit vlastní slovník, např. oborový slovník, třídník součástek atd.
ts=# select dict_name, dict_comment, dict_initoption from pg_ts_dict;
dict_name dict_comment dict_initoption
simple Simple example of dictionary.
en_stem English Stemmer. Snowball. /usr/local/pgsql/share/contrib/english.stop
ru_stem Russian Stemmer. Snowball. /usr/local/pgsql/share/contrib/russian.stop
ispell_template ISpell interface. Must have .dict and .aff files
synonym Example of synonym dictionary
cz_ispell
DictFile="/usr/local/pgsql/share/contrib/czech.dict",
AffFile="/usr/local/pgsql/share/contrib/czech.aff",
StopFile="/usr/local/pgsql/share/contrib/czech.stop"
Poznámka: Slovník synonym je textový soubor obsahují na každém řádku dvojici slov – slovo a jedno z jeho synonym, např.
eroplán letadlo éro letadlo
Hodnota dict_initoption obsahuje cestu k tomuto souboru – obdoba en_stem nebo ru_stem (podrobnosti).
Za předpokladu, že máme dict, aff a stop soubory v adresáři /usr/local/pgsql/share/contrib, zaregistrujeme český slovník následujícím sql příkazem:
INSERT INTO pg_ts_dict (
SELECT 'cz_ispell', dict_init,
'DictFile="/usr/local/pgsql/share/contrib/czech.dict",
AffFile="/usr/local/pgsql/share/contrib/czech.aff",
StopFile="/usr/local/pgsql/share/contrib/czech.stop"', dict_lexize
FROM pg_ts_dict where dict_name='ispell_template')
Soubory dict a aff lze dohledat na internetu (jedná se o slovníky k ispellu) – není nutné instalovat samotný ispell. Doplněk obsahuje Makefile my2ispell převádějící slovníky z formátu OpenOffice MyIspell do formátu ispellu. Stačí si stáhnout příslušný slovník, zkopírovat jej do adresáře contrib/tsearch2/my2ispell a v adresáři sputit konverzi
make ZIPFILE=cs_CZ LANGUAGE=czech
Zkonvertované soubory včetně mnou vytvořeného seznam blokovaných slov naleznete na adrese postgresql.ok.cz/download/tsearch2cz.tar.gz.
Pokud máme nainstalovaný český slovník, můžeme vyzkoušet lexikální analýzu. V případě, že vše nechodí tak, jak by se zdálo, že by chodit mělo, autoři včetně mne doporučují restart klienta (pomůže to pouze po změnách v systémových tabulkách tsearch2).
SELECT lexize('cz_ispell','jablka'); => {jablko}
SELECT lexize('cz_ispell','jablkům'); => {jablko}
SELECT lexize('cz_ispell','jablek'); => {jablko}
SELECT lexize('cz_ispell','čekal'); => {čekal,čekat,čekat}
SELECT lexize('cz_ispell','počká'); => {počkat}
SELECT lexize('cz_ispell','počkala'); => {počkat,počkat}
SELECT lexize('cz_ispell','pravidelně'); => {pravidelný}
Zpět k parseru. Parser v tsearch2 slouží k jednoduchému rozdělení textu (zvládá i html) na jednotlivé tokeny – slova, číslice atd. Pokud je třeba, můžete použít jiný parser, musíte si jej ale napsat.
ts=# select * from parse('<h1>Nadpis</h2>Příliš žluťoučký kůň');
tokid | token
-------+-----------
13 |
1 | Nadpis
13 |
3 | Příliš
12 |
3 | žluťoučký
12 |
3 | kůň
Parser rozlišuje mezi slovy bez diakritiky a obsahujícími diakritiku – resp. mezi ascii psaným textem a ostatním textem. Zajímavé je chování parseru při zadání odkazu. Rozloží URL na protokol, url, adresu a stránku:
ts=# select * from parse('http://postgresql.ok.cz/index.html');
tokid | token
-------+-----------------------------
14 | http://
5 | postgresql.ok.cz/index.html
6 | postgresql.ok.cz
18 | /index.html
Tsearch2 obsahuje několik připravených konfigurací, tj. záznamů v tabulce pg_ts_cfg určujících locale a parser. Záznamy v tabulce pg_ts_cfgmap určují, který slovník se použije pro určitý typ tokenů (tokenid). Vzhledem k původu tsearch2 jsou připraveny pouze konfigurace default, default_russian a simple. Podporu češtiny si musíme do zmíněných tabulek doplnit sami (stačí nechat provést následující sql příkazy – předpokladem je funkční český slovník).
INSERT INTO pg_ts_cfg VALUES ('default_czech','default','cs_CZ');
INSERT INTO pg_ts_cfgmap
SELECT 'default_czech',tok_alias,dict_name
FROM pg_ts_cfgmap WHERE ts_name='default_russian';
UPDATE pg_ts_cfgmap SET dict_name='{cz_ispell,simple}'
WHERE ('ru_stem'=ANY(dict_name) OR 'en_stem' = ANY(dict_name))
AND ts_name='default_czech';
Pokud vše je nastaveno, můžeme testovat redukci tokenů a převod na lexémy
ts=# select to_tsvector('default_czech',
'Příliš žluťoučký kůň se napil žluté vody');
to_tsvector
---------------------------------------------------------------
'kůň':3 'voda':7 'napít':5 'žlutý':6 'příliš':1 'žluťoučký':2
Funkcí set_curcfg aktivujeme vybranou konfiguraci:
ts=# SELECT set_curcfg('default_czech');
set_curcfg
------------
(1 řádka)
Nastavená konfigurace se použije jen pro explicitní konverzní funkce to_tsvector(), to_tsquery() (prvním nepovinným parametrem funkcí může být specifikace konfigurace, viz výše uvedený příklad) a pro funkci ts_debug(). Implicitní konverze používají stále konfiguraci ‚default‘:
ts=# select tsvector 'Příliš žlutý kůň se napil žluté vody';
tsvector
----------------------------------------------------
'se' 'kůň' 'vody' 'napil' 'žluté' 'žlutý' 'Příliš'
(1 řádka)
Funkce ts_debug zobrazí podrobnější informace o převodu slov do tsearch2 vektoru:
ts=# select * from ts_debug('Příliš žluťoučký kůň se napil žluté vody');
ts_name | tok_type | description | token | dict_name | tsvector
--------------+----------+-------------+-----------+ -------------------+ ------------
default_czech | word | Word | Příliš | {cz_ispell,simple} | 'příliš'
default_czech | word | Word | žluťoučký | {cz_ispell,simple} | 'žluťoučký'
default_czech | word | Word | kůň | {cz_ispell,simple} | 'kůň'
default_czech | lword | Latin word | se | {cz_ispell,simple} |
default_czech | lword | Latin word | napil | {cz_ispell,simple} | 'napít'
default_czech | word | Word | žluté | {cz_ispell,simple} | 'žlutý'
default_czech | lword | Latin word | vody | {cz_ispell,simple} | 'voda'
(7 řádek)
Dále se s hodnotami typu tsvector bude zacházet stejně jako s hodnotami jiných datových typů. Vytvoříme tabulku se sloupcem tsvector a nad ním vytvoříme index.
CREATE TABLE foo(
id SERIAL PRIMARY KEY,
t text,
v tsvector
);
CREATE INDEX idxFTI_idx ON foo USING gist(v);
VACUUM FULL ANALYZE;
CREATE TRIGGER tsvectorupdate BEFORE UPDATE OR INSERT ON foo
FOR EACH ROW EXECUTE PROCEDURE tsearch2(v, t);
pak
ts=# insert into foo(t) VALUES ('Příliš žluťoučký kůň se napil žluté vody');
INSERT 154239 1
ts=# \x
Rozšířené zobrazení zapnuto.
ts=# SELECT * from foo;
-[ RECORD 1 ]-----------------------------------------------------
id | 1
t | Příliš žluťoučký kůň se napil žluté vody
v | 'kůň':3 'voda':7 'napít':5 'žlutý':6 'příliš':1 'žluťoučký':2
ts=# SELECT t from foo where v @@ to_tsquery('default_czech','(napil&žlutý)|!cotunení');
-[ RECORD 1 ]-------------------------------
t | Příliš žluťoučký kůň se napil žluté vody
Význam operátorů je klasický: & – AND, | – OR, ! – negace. Binární operátor @@ provádí fulltextové vyhledávání.
Typ tsquery je jakoby duální k tsvectoru. Oba obsahují lexémy. Jestliže tsvector představuje pouze posloupnost lexémů, pak tsquery představuje kombinaci lexémů a klasických boolovských operátorů ~ logický výraz.
ts=# SELECT to_tsquery('(napil&žluté)|!xx'); to_tsquery --------------------------- 'napít' & 'žlutý' | !'xx' (1 řádka)
Fulltextové vyhledávání je nyní triviální záležitostí. Použijeme operátor @@, kde je na jedné straně hodnota typu tsvector a na druhé straně typu tsquery.
Jelikož jsem nikdy nepoužil žádný jiný fulltextový systém, nemohu na závěr napsat porovnání s ostatními. Určitě by se našlo, co by se dalo vylepšit. Namátkou: použití frází – více slovních výrazů, použití zástupných symbolů. Pro někoho může být překážkou existence dalšího sloupce (tsvecor) v tabulce. V každém případě je použitelnost a funkčnost PostgreSQL opět o krok dál.