2007 г
Введение в полнотекстовый поиск в PostgreSQL
Олег Бартунов, Федор Сигаев
Назад Оглавление
Приложение
Поиск с очепятками
Часто полнотекстовый поиск используется совместно с модулем
contrib/pg_trgm
, который на основе статистики триграмм
позволяет находить слова, наиболее близкие к запросу.
=# select show_trgm('supyrnova');
show_trgm
-------------------------------------------------
{" s"," su",nov,ova,pyr,rno,sup,upy,"va ",yrn}
С помощью функции stat
мы собираем информацию о всех индексируемых
словах и затем строим триграммный индекс.
=# select * into apod_words from stat('select fts from apod') order by ndoc desc,
nentry desc,word;
=# \d apod_words
Table "public.apod_words"
Column | Type | Modifiers
--------+---------+-----------
word | text |
ndoc | integer |
nentry | integer |
=# create index trgm_idx on apod_words using gist(word gist_trgm_ops);
Теперь мы можем быстро искать слова-кандидаты используя функцию
similarity
, которая подсчитывает похожесть слова используя
количество общих триграмм.
=# select word, similarity(word, 'supyrnova') AS sml
from apod_words where word % 'supyrnova' order by sml desc, word;
word | sml
---------------+----------
supernova | 0.538462
pre-supernova | 0.411765
(2 rows)
Из соображений производительности, слова, у которых похожесть не превышает
некоторый порог, отбрасываются. Посмотреть значение порога и изменить его
можно с помощью функций
show_limit()
и
set_limit(real)
. По умолчанию
используется значение 0.3.
Советы по повышению производительности
Если ваша коллекция документов очень большая и непрерывно пополняется, то
может возникнуть ситуация, когда скорость вставки в базу и поиск станут
не удовлетворять вас. PostgreSQL предоставляет много возможностей по
оптимизации, но мы кратко коснемся сегментирования и распределения данных.
Сегментирование данных
Сегментирование данных можно организовать с помощью наследования
(TABLE INHERITANCE
) и CE
(CONSTRAINT EXCLUSION
). Идея состоит в том, чтобы иметь
родительскую таблицу (класс), которая определяет основной набор атрибутов
и таблицы, которые наследуют структуру родительской таблицы, но имеющие
определенные ограничения на параметр, по которому проводится
сегментирование. Механизм наследования в PostgreSQL обеспечивает
выполнение запроса по всем таблицам автоматически, при этом наличие
CE
позволяет просматривать только те таблицы, которые
удовлетворяют условию на параметр.
Типичная ситуация, когда сегментирование идет
по времени, например, для хранение журналов веб-серверов.
В нашем примере мы создаем таблицу
apod_class
и две таблицы,
которые наследуют ее. Эти таблицы наследуют структуру родительской
таблицы, но при этом могут иметь свои специфические атрибуты.
Таблица apod_new
предназначена
для новых сообщений, а apod_archive
для неизменяющихся
архивных документов. Заметим, что для новых сообщений мы создали
GiST
индекс, который очень хорошо обновляется, а для архивной
таблицы создали GIN
индекс, который очень хорошо шкалируется, но
обновление, как и для всех обратных индексов, происходит очень медленно.
CREATE TABLE apod_class (
id integer,
title text,
body text,
sdate date,
keywords text,
fts tsvector
);
CREATE TABLE apod_new ( CHECK ( sdate >2001-08-08 ) ) INHERITS (apod_class);
CREATE INDEX gist_idx ON apod_new USING gist(fts);
CREATE TABLE apod_archive ( CHECK ( sdate ≤2001-08-08 ) ) INHERITS (apod_class);
CREATE INDEX gist_idx ON apod_new USING gin(fts);
PostgreSQL позволяет искать как по всей коллекции, указав таблицу
apod_class
, так и по отдельным частям.
В зависимости от задачи, сегментировать данные можно и по большему количеству
таблиц, например, распределять документы по годам, месяцам.
Оптимизатор PostgreSQL автоматически выбирает только те таблицы, которые
удовлетворяют условию
CHECK
, что очень благоприятно
сказывается на производительности запросов. Например, для запроса
apod=# select title,rank_cd(fts, q) from apod_class, to_tsquery('stars') q
where fts @@ q order by rank_cd desc limit 5;
будут просматриваться две таблицы, а для запроса
apod=# select title,rank_cd(fts, q) from apod_class, to_tsquery('stars') q
where fts @@ q and sdate > 2001-08-08 order by rank_cd desc limit 5;
будет использоваться только таблица
apod_new
. Отметим, что
для этого необходимо включить
CONSTRAINT EXCLUSION
SET constraint_exclusion TO on;
Распределение данных
Если сегментирование данных по таблицам недостаточно, то можно распределять
данные по серверам. В этом случае, с помощью модуля
contrib/dblink
можно исполнять поисковые запросы на разных
серверах, получать результаты, объединять их и выбирать необходимые
документы, например, топ-10 самых релевантных документов.
Вот пример запроса по коллекции, которая распределена по двум сервера
по диапазонам идентификатора документов.
select dblink_connect('pgweb','dbname=pgweb hostaddr='XXX.XXX.XXX.XXX');
select * from dblink('pgweb',
'select tid, title, rank_cd(fts_index, q) as rank from pgweb,
to_tsquery(''table'') q
where q @@ fts_index and tid >= 6000 order by rank desc limit 10' )
as t1 (tid integer, title text, rank real)
union all
select tid, title, rank_cd(fts_index, q) as rank from pgweb,
to_tsquery('table') q
where q @@ fts_index and tid < 6000 and tid > 0 order by rank desc limit 10
) as foo
order by rank desc limit 10;
Отметим, что ранжирующая функция требует только локальной информации, что
облегчает реализацию.
Словарь для целых чисел
В качестве примера нового словаря для полнотекстового поиска мы рассмотрим
словарь для целых чисел intdict
, который "обрезает" целые
числа, так что общее количество уникальных слов уменьшается, что в целом
благоприятно сказывается на производительности поиска. У словаря есть
два параметра MAXLEN
, который контролирует максимальную длину
числа, и REJECTLONG
, который указывает считать ли длинное целое
число стоп-словом или нет. По умолчанию MAXLEN=6,REJECTLONG=false
.
Для создания словаря необходимо написать две функции, имена которых потом
будут использованы в команде CREATE FULLTEXT DICTIONARY
ниже.
Функция init_intdict
инициализирует словарь -
задает значения параметров по умолчанию и принимает их новые значения,
функция dlexize_intdict
делает всю основную работу - возвращает NULL
, если слово
неопознанно, пустой массив, если словарь решил, что входная строка это
стоп-слово, или массив лексем, в противном случае.
Словарь просто обрезает длинные целые числа.
=# select lexize('intdict', 11234567890);
lexize
----------
{112345}
Теперь будем трактовать длинные целые числа как стоп-слово.
=# ALTER FULLTEXT DICTIONARY intdict SET OPTION 'MAXLEN=6, REJECTLONG=TRUE';
=# select lexize('intdict', 11234567890);
lexize
--------
{}
Файлы dict_tmpl.c,Makefile,dict_intdict.sql.in
надо положить в директорию contrib/dict_intdict
.
После этого надо установить словарь и загрузить словарь в базу
DBNAME
.
make && make install
psql DBNAME < dict_intdict.sql
Файл dict_tmpl.c
:
#include "postgres.h"
#include "utils/builtins.h"
#include "fmgr.h"
#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif
#include "utils/ts_locale.h"
#include "utils/ts_public.h"
#include "utils/ts_utils.h"
typedef struct {
int maxlen;
bool rejectlong;
} DictInt;
PG_FUNCTION_INFO_V1(dinit_intdict);
Datum dinit_intdict(PG_FUNCTION_ARGS);
Datum
dinit_intdict(PG_FUNCTION_ARGS) {
DictInt *d = (DictInt*)malloc( sizeof(DictInt) );
Map *cfg, *pcfg;
text *in;
if ( !d )
elog(ERROR, "No memory");
memset(d,0,sizeof(DictInt));
/* Your INIT code */
/* defaults */
d->maxlen = 6;
d->rejectlong = false;
if ( PG_ARGISNULL(0) || PG_GETARG_POINTER(0) == NULL ) { /* no options */
PG_RETURN_POINTER(d);
}
in = PG_GETARG_TEXT_P(0);
parse_keyvalpairs(in,&cfg);
PG_FREE_IF_COPY(in, 0);
pcfg=cfg;
while (pcfg->key) {
if ( strcasecmp("MAXLEN", pcfg->key) == 0 ) {
d->maxlen=atoi(pcfg->value);
} else if ( strcasecmp("REJECTLONG", pcfg->key) == 0 ) {
if ( strcasecmp("true", pcfg->value) == 0 ) {
d->rejectlong=true;
} else if ( strcasecmp("false", pcfg->value) == 0 ) {
d->rejectlong=false;
} else {
elog(ERROR,"Unknown value: %s => %s", pcfg->key,
pcfg->value);
}
} else {
elog(ERROR,"Unknown option: %s => %s", pcfg->key, pcfg->
value);
}
pfree(pcfg->key);
pfree(pcfg->value);
pcfg++;
}
pfree(cfg);
PG_RETURN_POINTER(d);
}
PG_FUNCTION_INFO_V1(dlexize_intdict);
Datum dlexize_intdict(PG_FUNCTION_ARGS);
Datum
dlexize_intdict(PG_FUNCTION_ARGS) {
DictInt *d = (DictInt*)PG_GETARG_POINTER(0);
char *in = (char*)PG_GETARG_POINTER(1);
char *txt = pnstrdup(in, PG_GETARG_INT32(2));
TSLexeme *res=palloc(sizeof(TSLexeme)*2);
/* Your INIT dictionary code */
res[1].lexeme = NULL;
if ( PG_GETARG_INT32(2) > d->maxlen ) {
if ( d->rejectlong ) { /* stop, return void array */
pfree(txt);
res[0].lexeme = NULL;
} else { /* cut integer */
txt[d->maxlen] = '\0';
res[0].lexeme = txt;
}
} else {
res[0].lexeme = txt;
}
PG_RETURN_POINTER(res);
}
Файл Makefile
:
subdir = contrib/dict_intdict
top_builddir = ../..
include $(top_builddir)/src/Makefile.global
MODULE_big = dict_intdict
OBJS = dict_tmpl.o
DATA_built = dict_intdict.sql
DOCS =
include $(top_srcdir)/contrib/contrib-global.mk
Файл dict_intdict.sql.in
:
SET search_path = public;
BEGIN;
CREATE OR REPLACE FUNCTION dinit_intdict(internal)
returns internal
as 'MODULE_PATHNAME'
language 'C';
CREATE OR REPLACE FUNCTION dlexize_intdict(internal,internal,internal,internal)
returns internal
as 'MODULE_PATHNAME'
language 'C'
with (isstrict);
CREATE FULLTEXT DICTIONARY intdict
LEXIZE 'dlexize_intdict' INIT 'dinit_intdict'
OPTION 'MAXLEN=6,REJECTLONG=false'
;
END;
Очень простой парсер
Предположим, что мы хотим создать свой парсер, который выделяет только
один тип токена - слово (3,word,Word) и подключить его к полнотекстовому
поиску. Для этого нам нужен еще один тип токена - это разделитель
(12, blank,Space symbols).
Идентификаторы типов (3,12) выбраны таким образом, чтобы можно было
использовать стандартную функцию headline
.
Поместите файлы test_parser.c, Makefile, test_parser.sql.in
в директорию contrib/test_parser
, затем загрузите парсер
в базу данных (в данном примере regression
).
make
make install
psql regression < test_parser.sql
Мы создали тестовую FTS конфигурацию testcfg
, для которой
определен парсер testparser
.
Для написания своего парсера необходимо разработать как-минимум 4 функции,
см. SQL команду CREATE FULLTEXT PARSER.
=# SELECT * FROM parse('testparser','That''s my first own parser');
tokid | token
-------+--------
3 | That's
12 |
3 | my
12 |
3 | first
12 |
3 | own
12 |
3 | parser
=# SELECT to_tsvector('testcfg','That''s my first own parser');
to_tsvector
-------------------------------------------------
'my':2 'own':4 'first':3 'parser':5 'that''s':1
=# SELECT headline('testcfg','Supernovae stars are the brightest phenomena in galaxies', to_tsquery('testcfg', 'star'));
headline
-----------------------------------------------------------------
Supernovae stars are the brightest phenomena in galaxies
Файл test_parser.c
#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif
/*
* types
*/
/* self-defined type */
typedef struct {
char * buffer; /* text to parse */
int len; /* length of the text in buffer */
int pos; /* position of the parser */
} ParserState;
/* copy-paste from wparser.h of tsearch2 */
typedef struct {
int lexid;
char *alias;
char *descr;
} LexDescr;
/*
* prototypes
*/
PG_FUNCTION_INFO_V1(testprs_start);
Datum testprs_start(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(testprs_getlexeme);
Datum testprs_getlexeme(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(testprs_end);
Datum testprs_end(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(testprs_lextype);
Datum testprs_lextype(PG_FUNCTION_ARGS);
/*
* functions
*/
Datum testprs_start(PG_FUNCTION_ARGS)
{
ParserState *pst = (ParserState *) palloc(sizeof(ParserState));
pst->buffer = (char *) PG_GETARG_POINTER(0);
pst->len = PG_GETARG_INT32(1);
pst->pos = 0;
PG_RETURN_POINTER(pst);
}
Datum testprs_getlexeme(PG_FUNCTION_ARGS)
{
ParserState *pst = (ParserState *) PG_GETARG_POINTER(0);
char **t = (char **) PG_GETARG_POINTER(1);
int *tlen = (int *) PG_GETARG_POINTER(2);
int type;
*tlen = pst->pos;
*t = pst->buffer + pst->pos;
if ((pst->buffer)[pst->pos] == ' ') {
/* blank type */
type = 12;
/* go to the next non-white-space character */
while (((pst->buffer)[pst->pos] == ' ') && (pst->pos < pst->len)) {
(pst->pos)++;
}
} else {
/* word type */
type = 3;
/* go to the next white-space character */
while (((pst->buffer)[pst->pos] != ' ') && (pst->pos < pst->len)) {
(pst->pos)++;
}
}
*tlen = pst->pos - *tlen;
/* we are finished if (*tlen == 0) */
if (*tlen == 0) type=0;
PG_RETURN_INT32(type);
}
Datum testprs_end(PG_FUNCTION_ARGS)
{
ParserState *pst = (ParserState *) PG_GETARG_POINTER(0);
pfree(pst);
PG_RETURN_VOID();
}
Datum testprs_lextype(PG_FUNCTION_ARGS)
{
/*
Remarks:
- we have to return the blanks for headline reason
- we use the same lexids like Teodor in the default
word parser; in this way we can reuse the headline
function of the default word parser.
*/
LexDescr *descr = (LexDescr *) palloc(sizeof(LexDescr) * (2+1));
/* there are only two types in this parser */
descr[0].lexid = 3;
descr[0].alias = pstrdup("word");
descr[0].descr = pstrdup("Word");
descr[1].lexid = 12;
descr[1].alias = pstrdup("blank");
descr[1].descr = pstrdup("Space symbols");
descr[2].lexid = 0;
PG_RETURN_POINTER(descr);
}
Файл Makefile
override CPPFLAGS := -I. $(CPPFLAGS)
MODULE_big = test_parser
OBJS = test_parser.o
DATA_built = test_parser.sql
DATA =
DOCS = README.test_parser
REGRESS = test_parser
ifdef USE_PGXS
PGXS := $(shell pg_config --pgxs)
include $(PGXS)
else
subdir = contrib/test_parser
top_builddir = ../..
include $(top_builddir)/src/Makefile.global
include $(top_srcdir)/contrib/contrib-global.mk
endif
Файл test_parser.sql.in
SET search_path = public;
BEGIN;
CREATE FUNCTION testprs_start(internal,int4)
RETURNS internal
AS 'MODULE_PATHNAME'
LANGUAGE 'C' with (isstrict);
CREATE FUNCTION testprs_getlexeme(internal,internal,internal)
RETURNS internal
AS 'MODULE_PATHNAME'
LANGUAGE 'C' with (isstrict);
CREATE FUNCTION testprs_end(internal)
RETURNS void
AS 'MODULE_PATHNAME'
LANGUAGE 'C' with (isstrict);
CREATE FUNCTION testprs_lextype(internal)
RETURNS internal
AS 'MODULE_PATHNAME'
LANGUAGE 'C' with (isstrict);
CREATE FULLTEXT PARSER testparser
START 'testprs_start'
GETTOKEN 'testprs_getlexeme'
END 'testprs_end'
LEXTYPES 'testprs_lextype'
;
CREATE FULLTEXT CONFIGURATION testcfg PARSER 'testparser' LOCALE NULL;
CREATE FULLTEXT MAPPING ON testcfg FOR word WITH simple;
END;
Назад Оглавление