Полнотекстовый поиск: 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 — для опечаток и сходства.
Проверьте себя
1. Почему запрос to_tsquery('russian', 'запрос') находит документ со словом «запросы»?
AПотому что tsvector хранит исходный текст целиком
BПотому что обе формы приводятся к одной лексеме 'запрос' за счёт стемминга
CПотому что to_tsquery ищет подстроку, как LIKE
DПотому что слово «запросы» добавляется в стоп-слова
2. Зачем по колонке tsvector строят GIN-индекс?
AЧтобы tsvector пересчитывался автоматически
BЧтобы оператор @@ искал по индексу «лексема → строки», а не сканировал всю таблицу
CЧтобы включить стемминг
DGIN по tsvector не нужен, поиск и так быстрый
3. Что произойдёт, если документ построен с to_tsvector('russian', ...), а запрос — с to_tsquery('english', ...)?
AПоиск отработает нормально — конфигурация не важна
BОсновы слов будут разными, и совпадений, скорее всего, не будет
CPostgreSQL автоматически приведёт конфигурации к одной
DЗапрос вернёт все строки таблицы
4. Для какой задачи лучше подходит расширение pg_trgm, а не полнотекстовый поиск?
AПоиск по словоформам в больших статьях
BРанжирование результатов по релевантности
CПоиск с учётом опечаток и по сходству (например, «постргес» → «postgres»), автодополнение
DОтбрасывание стоп-слов