Полнотекстовый поиск: tsvector и tsquery
Как искать по словам и их формам, а не по подстроке: лексемы, запросы, индекс и ранжирование результата.
tsvector — нормализованное представление текста в виде набора лексем (приведённых к основе слов) с позициями; tsquery — поисковый запрос из лексем, который к нему применяют.
LIKE '%постгрес%' ищет подстроку буква-в-букву: не найдёт «PostgreSQL», не поймёт, что «индексы» и «индексов» — одно слово, и не отсортирует по релевантности. Полнотекстовый поиск PostgreSQL решает всё это: он приводит текст к лексемам и ищет совпадение по смыслу слова, а не по символам.
Зачем это на практике
Поиск по статьям, описаниям товаров, тикетам, документации — везде, где пользователь вводит слова и ждёт, что найдётся и другая форма этого слова, а самые релевантные результаты окажутся сверху. LIKE такого не умеет в принципе; полнотекстовый поиск умеет это «из коробки», без внешнего поискового движка.
to_tsvector и to_tsquery
Две главные функции. to_tsvector(конфиг, текст) превращает текст в набор лексем. to_tsquery(конфиг, запрос) строит из запроса дерево лексем, соединённых операторами & (И), | (ИЛИ), ! (НЕ). Сопоставляет их оператор @@.
-- во что превращается фраза (русская конфигурация)
SELECT to_tsvector('russian', 'Быстрые индексы ускоряют запросы');
-- 'быстр':1 'индекс':2 'запрос':4 'ускор':3
-- сопоставление: документ против запроса
SELECT to_tsvector('russian', 'Быстрые индексы ускоряют запросы')
@@ to_tsquery('russian', 'индекс & запрос') AS matched;
-- matched = true
Главное наблюдение: «Быстрые» стало 'быстр', «запросы» — 'запрос'. Это стемминг: слово приведено к основе. Поэтому запрос запрос найдёт и «запросы», и «запросов» — формы схлопнуты в одну лексему. Для фразы целиком есть оператор <-> (followed by): 'индекс <-> ускор' найдёт лексемы, идущие подряд.
Хранить tsvector в колонке
Считать to_tsvector на каждый запрос расточительно. Обычно лексемы хранят в отдельной колонке tsvector, которую PostgreSQL пересчитывает сам — через генерируемую колонку.
CREATE TABLE doc (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
-- автоматически вычисляемый tsvector из title и body
search tsvector GENERATED ALWAYS AS (
to_tsvector('russian', title || ' ' || body)
) STORED
);
INSERT INTO doc (title, body) VALUES
('Индексы', 'Индекс ускоряет выборку строк в больших таблицах'),
('Транзакции', 'Транзакция гарантирует атомарность изменений');
Колонка search теперь всегда актуальна: при любом INSERT/UPDATE базы её пересчитывает движок. Искать будем уже по ней.
GIN-индекс и сам поиск
Чтобы поиск по tsvector был быстрым, на колонку строят GIN-индекс — он хранит обратное отображение «лексема → строки», ровно как поисковый индекс.
CREATE INDEX idx_doc_search ON doc USING GIN (search);
-- найти документы про ускорение выборки
SELECT title
FROM doc
WHERE search @@ to_tsquery('russian', 'ускор & выборк');
Вывод:
Индексы
Запрос нашёл документ, хотя в нём слова «ускоряет» и «выборку» — в других формах. GIN по tsvector — то, что превращает полнотекстовый поиск из «считаем для каждой строки» в «достаём из индекса нужные строки».
Ранжирование: ts_rank
Полнотекстовый поиск умеет не только фильтровать, но и ранжировать: функция ts_rank(tsvector, tsquery) даёт вес тем выше, чем чаще и «плотнее» встречаются искомые лексемы. По нему сортируют выдачу.
SELECT title, ts_rank(search, q) AS rank
FROM doc, to_tsquery('russian', 'индекс | транзакц') AS q
WHERE search @@ q
ORDER BY rank DESC;
Здесь запрос ищет документы про индексы или транзакции и сортирует по релевантности. Для подсветки совпавших слов в тексте есть парная функция ts_headline, а взвесить заголовок выше тела помогает setweight — так совпадение в title поднимает строку выше совпадения в body.
Словари и стемминг
За нормализацию отвечает конфигурация поиска ('russian', 'english', 'simple'). Внутри неё работают словари: они отбрасывают стоп-слова («и», «в», «the», «a»), приводят слова к основе (стеммер Snowball) и раскрывают синонимы, если настроены. Поэтому «индексы», «индексов», «индексам» дают одну лексему 'индекс', а служебные слова в индекс вообще не попадают.
| Конфигурация | Что делает |
'russian' | русский стемминг + русские стоп-слова |
'english' | английский стемминг + стоп-слова |
'simple' | без стемминга и стоп-слов: лексема = слово в нижнем регистре |
Важно использовать одну и ту же конфигурацию при построении tsvector и tsquery: иначе основа слова в документе и в запросе не совпадёт, и поиск ничего не найдёт.
Как это работает под капотом
На входе текст разбивается на токены (парсер выделяет слова, числа, e-mail и т.п.). Каждый токен прогоняется через словари конфигурации: стоп-слова выбрасываются, остальные приводятся к основе. Получается tsvector — упорядоченный набор лексем с позициями. Запрос проходит ту же нормализацию и становится tsquery. Оператор @@ проверяет, удовлетворяет ли набор лексем дереву запроса. GIN-индекс хранит для каждой лексемы список строк, поэтому фильтр search @@ q сводится к выборке и пересечению этих списков — без перебора всей таблицы. Позиции лексем нужны и для фразовых операторов (<->), и для расчёта ts_rank.
Сравнение с LIKE и pg_trgm
Три инструмента под три разные задачи:
- LIKE / ILIKE — поиск подстроки. Не понимает словоформы и язык, не ранжирует.
LIKE 'индекс%'с индексом по префиксу быстр, но'%индекс%'почти всегда полный скан. Хорош для точных префиксов и шаблонов, плох для «поиска по словам». - Полнотекстовый поиск (tsvector) — поиск по словам с учётом форм, стоп-слов и релевантности. Это правильный выбор для поиска по статьям, описаниям, документации.
- pg_trgm — расширение для поиска по сходству через триграммы (тройки символов). Ловит опечатки и частичные совпадения (
similarity(), оператор%), ускоряет даже'%подстрока%'через GIN/GiST. Берут для «искал «постргес» — найди «postgres»» и автодополнения, где важна устойчивость к ошибкам, а не словоформы.
На практике их комбинируют: полнотекстовый поиск как основа, pg_trgm — как страховка от опечаток.
Частые ошибки
- Разные конфигурации у документа и запроса.
to_tsvector('russian', ...)иto_tsquery('english', ...)дадут разные основы — совпадений не будет. Конфигурация должна быть одна. - Передают в to_tsquery сырую фразу пользователя.
to_tsqueryждёт операторов и упадёт на пробеле или спецсимволе. Для ввода «как есть» используйтеplainto_tsqueryилиwebsearch_to_tsquery. - Забывают GIN-индекс. Без него
@@работает, но через полный скан. На больших таблицах поиск без индекса поtsvector— это медленно. - Пытаются заменить полнотекст на LIKE '%...%'. Тот не учитывает словоформы и не ранжирует, а с двусторонним
%ещё и не использует обычный индекс.
Итоги
to_tsvectorприводит текст к лексемам (стемминг + стоп-слова),to_tsqueryстроит запрос; сопоставляет их@@.- Храните
tsvectorв генерируемой колонке и стройте по ней GIN-индекс — это и есть быстрый поиск. ts_rankранжирует выдачу по релевантности;setweightподнимает совпадения в заголовке.- Конфигурация должна совпадать у документа и запроса;
LIKE— для подстрок,pg_trgm— для опечаток и сходства.