
Отдельный поисковый кластер — это новые серверы, обучение команды, обновления, бэкапы и своя модель отказоустойчивости. Во многих продуктах этого не нужно. Если у вас:
то встроенный полнотекстовый поиск PostgreSQL закрывает задачу с отличной скоростью и качеством. Он не заменит специализированные движки для сложной аналитики, скоринга по кликам и синонимических графов на сотни миллионов документов, но для 80% прикладных сценариев — это «ещё как да».
Итого: один кластер, один бэкап, один мониторинг.
Начнём с простой модели каталога товаров. Сразу подключим морфологию для русского языка и модуль для опечаток.
-- Подключаем расширения (делается один раз в базе)
CREATE EXTENSION IF NOT EXISTS unaccent; -- нормализация текста (удаление диакритики)
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- триграммы для неточного поиска
-- Таблица с данными
CREATE TABLE IF NOT EXISTS product (
id bigserial PRIMARY KEY,
title text NOT NULL,
description text NOT NULL,
category text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Для быстрого LIKE/ILIKE и триграмм на title
CREATE INDEX IF NOT EXISTS idx_product_title_trgm
ON product USING gin (lower(title) gin_trgm_ops);
-- Вариант А: сгенерированная колонка (PostgreSQL 12+), без триггеров
-- Примечание: to_tsvector('russian', ...) детерминирован для одних и тех же входных данных.
ALTER TABLE product ADD COLUMN IF NOT EXISTS search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('russian', unaccent(coalesce(title, ''))), 'A') ||
setweight(to_tsvector('russian', unaccent(coalesce(category, ''))), 'B') ||
setweight(to_tsvector('russian', unaccent(coalesce(description, ''))), 'C')
) STORED;
-- GIN-индекс для полнотекстового поиска
CREATE INDEX IF NOT EXISTS idx_product_search_vector
ON product USING gin (search_vector);
-- Триггер на обновление updated_at (не обязателен, но полезен)
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END; $$;
DROP TRIGGER IF EXISTS trg_set_updated_at ON product;
CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON product FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
Если по политике вашей команды генераторы столбцов использовать нельзя — примените триггер для поддержки tsvector:
-- Вариант Б: триггер для tsvector вместо GENERATED столбца
ALTER TABLE product ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE OR REPLACE FUNCTION product_tsvector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('russian', unaccent(coalesce(NEW.title, ''))), 'A') ||
setweight(to_tsvector('russian', unaccent(coalesce(NEW.category, ''))), 'B') ||
setweight(to_tsvector('russian', unaccent(coalesce(NEW.description, ''))), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql IMMUTABLE; -- тело функции детерминированно для одинаковых входных данных
DROP TRIGGER IF EXISTS trg_product_tsvector ON product;
CREATE TRIGGER trg_product_tsvector
BEFORE INSERT OR UPDATE OF title, category, description ON product
FOR EACH ROW EXECUTE FUNCTION product_tsvector_update();
-- Не забудьте про индекс (если не создавали выше)
CREATE INDEX IF NOT EXISTS idx_product_search_vector
ON product USING gin (search_vector);
Мы говорим поиску, что совпадение в заголовке важнее (A) совпадения в описании (C). Это влияет на итоговый балл релевантности при ранжировании.
Есть три базовых способа превратить строку пользователя в запрос:
Рекомендуемый вариант для интерфейсов — websearch_to_tsquery.
-- Пример запроса с websearch_to_tsquery и ранжированием
WITH q AS (
SELECT websearch_to_tsquery('russian', unaccent($1)) AS query
)
SELECT p.id, p.title,
ts_rank_cd(p.search_vector, q.query, 1) AS rank,
p.category, p.created_at
FROM product p, q
WHERE p.search_vector @@ q.query
ORDER BY rank DESC, p.created_at DESC
LIMIT $2 OFFSET $3;
Пояснения:
Если нужен префиксный поиск (по мере ввода), используйте оператор :*:
-- Поддержка автодополнения: ищем слова с указанным префиксом
SELECT id, title
FROM product
WHERE to_tsvector('russian', unaccent(title)) @@ to_tsquery('russian', unaccent($1) || ':*')
ORDER BY id DESC
LIMIT 10;
Показать пользователю, почему документ попал в выдачу, — важно для доверия и кликабельности.
WITH q AS (
SELECT websearch_to_tsquery('russian', unaccent($1)) AS query
)
SELECT p.id,
p.title,
ts_headline('russian', p.description, q.query,
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MinWords=3, MaxWords=12') AS snippet,
ts_rank_cd(p.search_vector, q.query) AS rank
FROM product p, q
WHERE p.search_vector @@ q.query
ORDER BY rank DESC
LIMIT 20;
Результат даст два фрагмента описания с выделением совпадений через … — удобно отрендерить как HTML.
Пользователь ошибся на одну-две буквы? Добавим триграммный индекс и мягкий поиск. Это не заменяет полнотекстовый, а дополняет его.
-- Уже создали раньше индекс для title. Можно расширить покрытие:
CREATE INDEX IF NOT EXISTS idx_product_desc_trgm
ON product USING gin (lower(description) gin_trgm_ops);
-- Поиск по близости (similarity). Порог можно настроить, например 0.3–0.5
SELECT id, title
FROM product
WHERE similarity(lower(title), lower($1)) > 0.35
ORDER BY similarity(lower(title), lower($1)) DESC
LIMIT 20;
Комбинированный подход: сначала полнотекстовый поиск, если результатов мало — добираем триграммами.
-- $1 = строка поиска, $2 = сколько результатов хотим
WITH params AS (
SELECT unaccent($1)::text AS q, $2::int AS k
), fts AS (
SELECT p.id, p.title,
ts_rank_cd(p.search_vector, websearch_to_tsquery('russian', unaccent(params.q))) AS rank
FROM product p, params
WHERE p.search_vector @@ websearch_to_tsquery('russian', unaccent(params.q))
ORDER BY rank DESC
LIMIT params.k
), need_more AS (
SELECT (SELECT count(*) FROM fts) < (SELECT k FROM params) AS few
), trgm AS (
SELECT p.id, p.title,
similarity(lower(p.title), lower(params.q)) AS sim
FROM product p, params, need_more
WHERE need_more.few
AND similarity(lower(p.title), lower(params.q)) > 0.35
AND p.id NOT IN (SELECT id FROM fts)
ORDER BY sim DESC
LIMIT GREATEST(0, (SELECT k FROM params) - (SELECT count(*) FROM fts))
)
SELECT * FROM fts
UNION ALL
SELECT * FROM trgm;
Проверяйте планы:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM product
WHERE search_vector @@ websearch_to_tsquery('russian', 'красные кеды');
Ожидаем Bitmap Index Scan по idx_product_search_vector.
-- Если используете GENERATED — добавьте столбец сразу. Иначе сначала пустой столбец.
ALTER TABLE product ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_product_search_vector
ON product USING gin (search_vector);
-- Пример пакетного обновления на 10_000 строк за проход
WITH cte AS (
SELECT id
FROM product
WHERE search_vector IS NULL
ORDER BY id
LIMIT 10000
)
UPDATE product p
SET search_vector =
setweight(to_tsvector('russian', unaccent(coalesce(p.title, ''))), 'A') ||
setweight(to_tsvector('russian', unaccent(coalesce(p.category, ''))), 'B') ||
setweight(to_tsvector('russian', unaccent(coalesce(p.description, ''))), 'C')
FROM cte
WHERE p.id = cte.id;
Повторяйте до опустошения.
Включите поддержание вектора: либо переключитесь на GENERATED STORED, либо повесьте триггер на INSERT/UPDATE.
Переключите приложение на FTS‑запросы. Держите старый способ поиска как фолбэк на время отсечки метрик.
Очистите старые индексы LIKE, если они больше не нужны.
Пример с бустом по «свежести» и «наличию»:
WITH q AS (
SELECT websearch_to_tsquery('russian', unaccent($1)) AS query
)
SELECT p.id, p.title,
ts_rank_cd(p.search_vector, q.query, 1) *
(1.0 + LEAST(0.5, EXTRACT(EPOCH FROM (now() - p.created_at)) / (60*60*24*30) * -0.02)) -- мягкий буст к новым
+ CASE WHEN stock > 0 THEN 0.1 ELSE 0 END AS score
FROM (
SELECT id, title, search_vector, created_at, (random()*10)::int AS stock -- пример наличия
FROM product
) p, q
WHERE p.search_vector @@ q.query
ORDER BY score DESC
LIMIT 20;
(В реальном запросе замените генерацию stock на реальное поле.)
Полезные команды:
-- Топ запросов по времени
SELECT query, mean_time, calls
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 20;
-- Размеры индексов по таблице
SELECT indexrelid::regclass AS index, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_all_indexes
WHERE schemaname = 'public' AND relname = 'product'
ORDER BY pg_relation_size(indexrelid) DESC;
Итог для бизнеса: поиск работает быстро, стабильно и без отдельного поискового кластера. Меньше сервисов — ниже расходы и риски, а качество результатов — на уровне, достаточном для большинства случаев. Если вырастете до сценариев с миллиардом документов и сложным скорингом — тогда уже будет из чего мигрировать, имея чистую архитектуру и аккуратные данные в одной базе.