Full-text search: tsvector, tsquery и pg_trgm
PostgreSQL имеет мощный встроенный полнотекстовый поиск, который закрывает большинство задач без Elasticsearch. Ключевые компоненты: tsvector — нормализованное представление документа (лексемы с позициями после стемминга), tsquery — условие поиска с операторами AND (&), OR (|), NOT (!), PHRASE (<->). Оператор @@ проверяет совпадение. ts_rank() вычисляет релевантность. GIN-индекс на tsvector делает поиск за O(log N). GENERATED ALWAYS AS ... STORED создаёт автоматически обновляемый столбец. pg_trgm добавляет нечёткий поиск по триграммам — для поиска с опечатками, LIKE-паттернов, похожих строк. Правильная схема: search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector(...)) STORED + CREATE INDEX USING GIN — поиск за миллисекунды на миллионах документов.
Почему это важно: LIKE '%слово%' — полный скан таблицы для каждого запроса (4+ секунды на 500K строк), нет релевантности, не понимает морфологию (ищет 'разработка' но не находит 'разработчик'). Elasticsearch решает эти проблемы, но добавляет отдельный сервис, синхронизацию данных, operational overhead. PostgreSQL FTS устраняет LIKE и Elasticsearch для большинства OLTP-приложений: один источник данных, ACID консистентность, поиск в той же транзакции что и запись, развитая поддержка русского языка через snowball-словари. Это стандартное решение для: поиска по товарам в интернет-магазине, поиска по статьям/документам, поиска по пользователям, автодополнения.
Главная идея
Полнотекстовый поиск PostgreSQL работает в три этапа. Индексирование: to_tsvector('russian', text) разбирает текст на токены, применяет стемминг (разработка → разработк, разработчик → разработк), убирает стоп-слова (и, в, на, для) и создаёт список лексем с позициями: разработк:1,5 postgresql:3. Поиск: to_tsquery('russian', 'разработка & postgresql') → разработк & postgresql. Оператор @@ проверяет соответствие. Ранжирование: ts_rank(tsvector, tsquery) вычисляет score на основе частоты лексем и их позиций. ts_headline() генерирует текстовые snippets с выделением совпадений. websearch_to_tsquery() парсит поисковый запрос в стиле Google: 'postgresql -mysql' → postgresql & !mysql. pg_trgm работает иначе: разбивает строку на n-граммы (триплеты символов) и ищет по ним — это позволяет LIKE '%pattern%' с индексом и поиск с опечатками через similarity().
Как это выглядит на практике
- Интернет-магазин: поиск товаров через WHERE name LIKE '%смартфон%'. При 200K товаров — 800ms на каждый запрос. Пользователи жалуются на медленный поиск.
- Первая попытка: добавляешь индекс на name. PostgreSQL не использует индекс для LIKE '%pattern%' (leading wildcard) — только для LIKE 'pattern%' (trailing wildcard). Скорость не улучшается.
- Узнаёшь про полнотекстовый поиск. Добавляешь столбец search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector('russian', name || ' ' || description)) STORED. Создаёшь GIN индекс. Поиск теперь 8ms.
- Пользователи пишут 'смартфоны samsung' — запрос с опечаткой 'самсунг' не работает. Добавляешь pg_trgm: GIN индекс с gin_trgm_ops на name. Нечёткий поиск через % оператор находит похожие строки.
- Улучшение релевантности: title должен весить больше description. Добавляешь setweight: to_tsvector... для title получает вес 'A', для description — 'B'. Теперь поиск 'iPhone 15' находит релевантные товары выше.
- ts_headline для сниппетов: показываешь пользователю контекст вокруг найденных слов с выделением. Добавляешь ts_headline() в SELECT — каждый результат показывает фрагмент с подсвеченными совпадениями.
- Автодополнение в поисковой строке: LIKE 'текст%' с GIN pg_trgm индексом — возвращает 5 подсказок за 3ms. Пользователи видят предложения по мере ввода.
- Мультиязычный контент: сайт на русском и английском. Добавляешь два столбца: search_vector (russian конфигурация) и search_vector_en (english). Поиск по обоим через OR — находит контент независимо от языка запроса.
- Синонимы и словари: пользователи ищут 'ноутбук' но товары называются 'лэптоп'. Добавляешь пользовательский словарь синонимов в PostgreSQL tsearch_data — поиск начинает работать для обоих вариантов.
- Нагрузочный тест: 500 одновременных поисковых запросов. GIN индекс с tsvector справляется — запросы за 10-15ms. Сравниваешь с LIKE — таймаут на большинстве запросов.
- Аналитика поиска: логируешь поисковые запросы без результатов. Находишь 'самсунг галакси' — часто ищут, нет результатов. Добавляешь синоним samsung → самсунг. Проблема решена на уровне поисковой конфигурации, не кода.
- Принятие решения о Elasticsearch: задача — поиск по 50M документам с фасетной фильтрацией, ML-ранжированием по поведению пользователей. PostgreSQL FTS не справится. Мигрируешь поиск в ES, оставляя PostgreSQL как источник данных.
Примеры кода
Настройка FTS: GENERATED column и GIN индекс
-- Таблица статей с FTS
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
author TEXT,
tags TEXT[],
published_at TIMESTAMPTZ,
-- GENERATED столбец: автоматически обновляется при INSERT/UPDATE
-- Конкатенируем title (вес A — важнее) и body (вес B)
search_vector TSVECTOR GENERATED ALWAYS AS (
setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(body, '')), 'B') ||
setweight(to_tsvector('russian', coalesce(author,'')), 'C')
) STORED
);
-- GIN индекс на search_vector — поиск за миллисекунды
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
-- Поиск с ранжированием
SELECT
id,
title,
author,
ts_rank(search_vector, query) AS rank,
ts_headline('russian', body, query,
'MaxWords=30, MinWords=15, StartSel=<b>, StopSel=</b>'
) AS snippet
FROM
articles,
websearch_to_tsquery('russian', 'PostgreSQL индексы производительность') AS query
WHERE search_vector @@ query
AND published_at >= NOW() - INTERVAL '1 year'
ORDER BY rank DESC
LIMIT 20;
-- websearch_to_tsquery парсит запросы в стиле Google:
-- 'postgresql indexes' → postgresql & indexes
-- '"full text"' → full <-> text (phrase)
-- 'postgresql -mysql' → postgresql & !mysql
-- 'postgresql OR mysql' → postgresql | mysql
GENERATED ALWAYS AS ... STORED — столбец автоматически пересчитывается при каждом INSERT/UPDATE. Не нужен триггер. setweight задаёт веса (A>B>C>D) — совпадения в заголовке ранжируются выше тела. ts_headline генерирует snippet с тегами для подсветки. websearch_to_tsquery — самый удобный для пользовательского ввода: поддерживает кавычки для фраз, минус для исключения.
Расширенный поиск: фразы, операторы, многоязычность
-- Операторы tsquery
-- AND: оба слова должны присутствовать
SELECT * FROM articles WHERE search_vector @@ to_tsquery('russian', 'postgresql & индекс');
-- Phrase: слова рядом (в указанном порядке)
SELECT * FROM articles WHERE search_vector @@ phraseto_tsquery('russian', 'полнотекстовый поиск');
-- Или явный оператор <->: следующий за предыдущим
SELECT * FROM articles WHERE search_vector @@ to_tsquery('russian', 'полнотекстов <-> поиск');
-- <2> означает расстояние 2 слова:
SELECT * FROM articles WHERE search_vector @@ to_tsquery('russian', 'полнотекстов <2> postgresql');
-- Поиск с фильтрацией и сортировкой по релевантности + дате
WITH search AS (
SELECT
a.*,
ts_rank_cd(
search_vector,
websearch_to_tsquery('russian', $1),
32 -- cover density ranking
) AS rank
FROM articles a
WHERE search_vector @@ websearch_to_tsquery('russian', $1)
AND published_at IS NOT NULL
)
SELECT *
FROM search
ORDER BY
rank DESC,
published_at DESC
LIMIT 10 OFFSET $2;
-- Многоязычный поиск (английский + русский)
ALTER TABLE articles ADD COLUMN search_vector_en TSVECTOR
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED;
CREATE INDEX ON articles USING GIN (search_vector_en);
-- Поиск по обоим языкам
SELECT * FROM articles
WHERE search_vector @@ websearch_to_tsquery('russian', $1)
OR search_vector_en @@ websearch_to_tsquery('english', $1);
phraseto_tsquery и оператор для поиска слов на определённом расстоянии. ts_rank_cd (cover density) часто лучше ts_rank для коротких документов. Для русскоязычных сайтов можно иметь два столбца search_vector (russian) и search_vector_en (english) — поиск по обоим одновременно.
pg_trgm: нечёткий поиск и LIKE с индексом
-- pg_trgm: поиск с опечатками, LIKE с индексом, похожие строки
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN или GiST индекс для триграммного поиска
CREATE INDEX products_name_trgm ON products USING GIN (name gin_trgm_ops);
-- LIKE с индексом (без pg_trgm — full scan!)
SELECT * FROM products WHERE name ILIKE '%смартф%';
-- С pg_trgm GIN индексом — использует индекс!
-- Поиск похожих строк (с опечатками)
SELECT
name,
similarity(name, 'смартфон samsung') AS sim_score
FROM products
WHERE name % 'смартфон samsung' -- оператор похожести
AND is_active = true
ORDER BY sim_score DESC
LIMIT 10;
-- similarity() возвращает 0.0-1.0, пороговое значение через pg_trgm.similarity_threshold
SET pg_trgm.similarity_threshold = 0.3; -- 30% совпадение триграмм
-- Автодополнение: строки начинающиеся с...
SELECT name, similarity(name, 'posta') AS sim
FROM products
WHERE name ILIKE 'posta%' -- GIN индекс работает для prefix LIKE
ORDER BY sim DESC, name
LIMIT 5;
-- Комбинация FTS + pg_trgm для лучшего UX
SELECT
id,
name,
description,
GREATEST(
similarity(name, $1),
ts_rank(search_vector, websearch_to_tsquery('russian', $1))
) AS combined_score
FROM products
WHERE
name % $1 -- триграммный поиск (с опечатками)
OR search_vector @@ websearch_to_tsquery('russian', $1) -- FTS
ORDER BY combined_score DESC
LIMIT 20;
pg_trgm разбивает строку на триграммы (три последовательных символа) и создаёт инвертированный индекс. Это позволяет: LIKE '%pattern%' с индексом (обычно требует full scan), поиск по похожести (опечатки), автодополнение. Комбинация FTS + pg_trgm: FTS для семантического поиска по морфологии, pg_trgm для поиска с опечатками и неполным вводом.
Что происходит под капотом
- Парсер текста (text parser): разбивает текст на токены по типам (слово, цифра, URL, email, имя файла). Каждый тип токена обрабатывается соответствующим словарём.
- Словари (dictionaries): применяются к токенам в заданном порядке. Russian словарь: стоп-слова (и, в, на, для, с → NULL), стемминг snowball алгоритм (разработчик → разработк). Simple словарь: lowercase без стемминга. Unaccent: убирает диакритические знаки.
- tsvector format: 'разработк':1,5,8 'postgresql':3,9 'приложен':2. Слово:позиции. Позиции используются для phrase search (<->) и вычисления ts_rank.
- GIN индекс (Generalized Inverted Index): инвертированный индекс лексема→список (docid, positions). При поиске по tsquery PostgreSQL находит документы для каждой лексемы, пересекает множества (AND), объединяет (OR). Сложность: O(log N + K) где K — число результатов.
- GiST vs GIN для tsvector: GIN быстрее при поиске но медленнее при обновлении. GiST быстрее при обновлении но медленнее при поиске и занимает меньше места. Для большинства случаев — GIN.
- ts_rank() алгоритм: считает частоту появления лексем запроса в документе, нормализует по длине документа (опционально). Веса A/B/C/D влияют на score: совпадение в заголовке (A) даёт больше очков чем в теле (D). ts_rank_cd использует cover density — учитывает расстояние между лексемами запроса в документе.
- pg_trgm триграммы: строка разбивается на все возможные триплеты символов с padding. 'cat' → {' c', ' ca', 'cat', 'at '}. GIN индекс хранит триграммы→список строк. LIKE '%patt%' — извлекает триграммы из паттерна и ищет строки их содержащие. similarity(a, b) = |trigramSet(a) ∩ trigramSet(b)| / |trigramSet(a) ∪ trigramSet(b)|.
- GENERATED STORED vs триггер: GENERATED ALWAYS AS STORED — декларативно, атомарно с INSERT/UPDATE, нельзя записать вручную. Триггер: гибче (можно вычислять из нескольких таблиц), но сложнее в поддержке. Для простых случаев — GENERATED STORED.
Типичные ошибки и заблуждения
- Ошибка: to_tsvector() в WHERE на каждый запрос достаточно быстро. to_tsvector без индекса = full scan с вычислением вектора для каждой строки. При 500K строк — 3-4 секунды. Всегда сохраняй tsvector как GENERATED STORED + GIN индекс.
- Ошибка: PostgreSQL FTS заменяет Elasticsearch для любых задач. PostgreSQL FTS отлично работает до ~10M документов с простой релевантностью. Elasticsearch нужен для: ML-ранжирования, фасетного поиска, агрегаций по полнотекстовому полю, поиска по 100M+ документам, real-time обновлений с поиском.
- Ошибка: LIKE '%слово%' и FTS дают одинаковые результаты. LIKE: точное совпадение подстроки (слово ≠ слова). FTS: стемминг (разработчик = разработка = разработок), релевантность, поддержка операторов. LIKE не понимает морфологию русского языка.
- Ошибка: tsvector GENERATED столбец обновляется только при явном UPDATE. GENERATED ALWAYS AS STORED пересчитывается при каждом INSERT или UPDATE строки — автоматически. Это не кеш, который нужно инвалидировать.
- Ошибка: GIN индекс замедляет INSERT/UPDATE в разы. GIN обновляется через pending list (накапливает изменения), который periodically merge в основную структуру. Overhead ~10-20% на write, зависит от размера и частоты обновлений. Приемлемо для большинства задач.
- Ошибка: pg_trgm и FTS взаимозаменяемы. FTS работает по морфологии (стемминг, стоп-слова) — понимает язык. pg_trgm работает по символьным триграммам — не понимает язык, зато ловит опечатки. Комбинируй: FTS для основного поиска, pg_trgm для fuzzy matching.
Ключевые выводы
- tsvector GENERATED STORED + GIN индекс — основа FTS: автоматически обновляется, поиск за миллисекунды.
- websearch_to_tsquery парсит пользовательский ввод в стиле Google — самый удобный для search box.
- setweight(tsvector, 'A') для заголовка, 'B' для тела — точная настройка релевантности.
- pg_trgm + GIN — нечёткий поиск с опечатками и LIKE '%pattern%' с использованием индекса.
- ts_headline() генерирует сниппеты с выделением совпадений для UI.
Термины урока
Связь с работой backend-разработчика
Готовая схема FTS для любого контента: (1) ADD COLUMN search_vector TSVECTOR GENERATED ALWAYS AS (setweight(to_tsvector('russian', coalesce(title,'')), 'A') || setweight(to_tsvector('russian', coalesce(body,'')), 'B')) STORED; (2) CREATE INDEX USING GIN (search_vector); (3) В запросе: WHERE search_vector @@ websearch_to_tsquery('russian', $query) ORDER BY ts_rank DESC. Добавь pg_trgm если нужен нечёткий поиск. Это закрывает 80% поисковых задач без Elasticsearch.
Мини-разбор реальной ситуации
Платформа документации: 300K статей, поиск через LIKE '%query%' — 6 секунд, пользователи уходят. Решение: GENERATED tsvector с весами (заголовок A, тело B, теги C) + GIN индекс + websearch_to_tsquery. Добавили pg_trgm на title для autocomplete. Результат: поиск — 12ms (500x быстрее), релевантность улучшилась (title-hits выше body-hits), autocomplete работает с опечатками. Elasticsearch не понадобился — вся функциональность в PostgreSQL, нет синхронизации данных, нет дополнительной инфраструктуры.
Что запомнить
- GENERATED STORED + GIN = тандем для FTS. Никогда не вычисляй to_tsvector() в WHERE без индекса.
- websearch_to_tsquery() для пользовательского ввода — поддерживает кавычки, минус, OR.
- pg_trgm нужен для LIKE '%pattern%' с индексом и поиска с опечатками.
Итог
PostgreSQL FTS — это мощный инструмент, который заменяет LIKE и часто Elasticsearch. Правильная схема (GENERATED STORED + GIN + ts_rank + ts_headline) даёт полнофункциональный поиск за минуты. Добавление pg_trgm делает поиск устойчивым к опечаткам. Для большинства продуктов (до 10M документов, один язык, базовая релевантность) PostgreSQL FTS — оптимальный выбор: единый источник данных, транзакционная консистентность, без дополнительных сервисов.