Опирается на правила:
PG-FTS-001…PG-FTS-094из PostgreSQL Style Guide → раздел Полнотекстовый поиск.
Важно знать
- PG FTS закрывает 90% задач «поиск по статьям/товарам/контенту» без Elasticsearch.
- Хватает: ≤ 10M документов, ≤ 100 RPS, базовое ranking.
- Не хватает: ≫ 10M + ranking, fuzzy с typo tolerance, multi-language авто, aggregations.
tsvector+tsquery+@@оператор.- Generated column
tsvector ... STORED+ GIN-индекс.russianконфигурация для стемминга.websearch_to_tsquery(PG11+) — Google-style с-, кавычками.setweightA/B/C/D для важности полей.ts_headline— подсветка совпадений.pg_trgmдополняет — fuzzy +LIKE '%substring%'.
PostgreSQL FTS из коробки решает большинство search-задач без отдельного Elasticsearch.
Когда хватает PG FTS
PG-FTS-001..002:
Хватает:
- Корпоративный/админский поиск.
- E-commerce среднего масштаба.
- Поиск по комментариям, тикетам, документации.
- ≤ 10M документов, ≤ 100 RPS.
Не хватает (→ Elasticsearch):
- ≫ 10M с ranking-нагрузкой.
- Сложный fuzzy с typo tolerance.
- Multi-language с автоопределением.
- Aggregations + facets + analytics.
Базовые типы
PG-FTS-010..011:
SELECT to_tsvector('russian', 'Покупатели выбирают товары в каталоге');
-- 'выбира':2 'каталог':5 'покупател':1 'товар':3
SELECT to_tsquery('russian', 'покупатель & каталог');
-- 'покупател' & 'каталог'
SELECT to_tsvector('russian', '...') @@ to_tsquery('russian', 'покупатель & каталог');
-- t (true)
tsvector— токенизированный документ.tsquery— запрос.@@— оператор поиска.
Хранение tsvector
PG-FTS-020..022:
Generated column (PG12+, рекомендуется)
CREATE TABLE article (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search_doc tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(body, '')), 'B')
) STORED
);
CREATE INDEX ix_article_search_doc ON article USING gin (search_doc);
setweight('A') для важных полей (title), 'B'/'C'/'D' для менее важных.
PG автоматически пересчитывает search_doc при каждом INSERT/UPDATE.
Триггер (PG <12)
CREATE TRIGGER article_search_update
BEFORE INSERT OR UPDATE ON article
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_doc, 'pg_catalog.russian', title, body);
Не вычислять на лету — без stored column каждый запрос будет to_tsvector(...) @@ ... без индекса.
Запросы
PG-FTS-030..033:
SELECT id, title, ts_rank(search_doc, q) AS rank
FROM article, to_tsquery('russian', 'покупатель & товар') q
WHERE search_doc @@ q
ORDER BY rank DESC
LIMIT 20;
plainto_tsquery — для user input
WHERE search_doc @@ plainto_tsquery('russian', $1);
-- 'купить новый товар' → 'купи' & 'новый' & 'товар'
Безопаснее — экранирует синтаксис.
websearch_to_tsquery — Google-style
WHERE search_doc @@ websearch_to_tsquery('russian', $1);
-- 'покупатель -ребёнок "новый каталог"'
-- → minus, фразы в кавычках
Ranking
ts_rank— стандарт.ts_rank_cd— cover density (учитывает близость слов).
Веса через setweight обычно достаточны.
Подсветка ts_headline
PG-FTS-040:
SELECT
id,
title,
ts_headline('russian', body, q,
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MaxWords=20')
AS snippet
FROM article, websearch_to_tsquery('russian', $1) q
WHERE search_doc @@ q
ORDER BY ts_rank(search_doc, q) DESC
LIMIT 20;
Конфигурации
PG-FTS-050..052:
SELECT cfgname FROM pg_ts_config;
-- simple, english, russian, german, ...
simple— без стемминга, только lowercase.russian— стемминг по русскому языку («покупатель» и «покупателя» = один токен).
Кастомная конфигурация с синонимами:
CREATE TEXT SEARCH DICTIONARY my_synonyms (
template = synonym,
synonyms = 'my_synonyms'
);
CREATE TEXT SEARCH CONFIGURATION ru_extended (COPY = russian);
ALTER TEXT SEARCH CONFIGURATION ru_extended
ALTER MAPPING FOR word, asciiword
WITH my_synonyms, russian_stem;
В $SHAREDIR/tsearch_data/my_synonyms.syn:
postgresql postgres
postgres postgres pg
GIN vs GiST
PG-FTS-060..061:
| GIN | GiST | |
|---|---|---|
| Размер | больше | меньше |
| Build | медленнее | быстрее |
| Search | быстрее | медленнее |
| Update | медленнее | быстрее |
| Lossy | нет | да (false positive) |
В 95% случаев — GIN.
fastupdate — буфер pending-list ускоряет вставки. Default on. Для read-heavy с редкими вставками:
ALTER INDEX ix_article_search_doc SET (fastupdate = off);
pg_trgm — fuzzy
PG-FTS-070..071:
CREATE EXTENSION pg_trgm;
CREATE INDEX ix_customer_name_trgm
ON customer USING gin (full_name gin_trgm_ops);
-- LIKE '%substring%'
SELECT * FROM customer WHERE full_name ILIKE '%иван%';
-- fuzzy с опечатками
SELECT * FROM customer WHERE similarity(full_name, 'иванв') > 0.4
ORDER BY similarity(full_name, 'иванв') DESC LIMIT 10;
Комбинируй: FTS для длинного контента (тело статьи), pg_trgm для коротких полей с опечатками (имя автора, бренд).
Пагинация
PG-FTS-080..081:
OFFSET + LIMIT страдает на глубоких страницах — PG ranks все, потом дропает offset.
Keyset pagination по (rank, id):
SELECT id, title, ts_rank(search_doc, q) AS rank
FROM article, websearch_to_tsquery('russian', $1) q
WHERE search_doc @@ q
AND (ts_rank(search_doc, q), id) < ($prev_rank, $prev_id)
ORDER BY rank DESC, id DESC
LIMIT 20;
Не использовать ts_rank в WHERE — считается на каждом совпадении.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
to_tsvector(body) в WHERE без stored column | PG-FTS-090 | generated column + GIN |
simple для русского текста | PG-FTS-091 | russian со стеммингом |
Без setweight | PG-FTS-092 | A/B/C/D приоритеты |
| FTS для ≫ 10M документов с rankings | PG-FTS-093 | Elasticsearch |
pg_trgm на полях > 100 символов | PG-FTS-094 | FTS |
| GiST для read-heavy | PG-FTS-060 | GIN |
to_tsquery для user input | PG-FTS-031 | plainto_tsquery |
| OFFSET для глубокой пагинации | PG-FTS-080 | keyset |
Куда дальше
- PG → FTS — нормативные формулировки.
- Типы индексов — GIN, GiST детали.
- JSONB — GIN на jsonb параллельно.
- Расширения —
pg_trgmустановка. - Materialized views — pre-computed search-индекс.
- Антипаттерны типов — сводка.