PostgreSQL Full-Text Search vs Elasticsearch: когда встроенного поиска достаточно в 2026 году
Введение: почему выбор инструмента поиска важен в 2026 году
Полнотекстовый поиск — одна из тех задач, где разработчики традиционно тянутся к «правильному» инструменту: Elasticsearch или его форку OpenSearch. Но в 2026 году PostgreSQL FTS заметно повзрослел, а стоимость поддержки отдельного поискового кластера стала ощутимее. Вопрос «нужен ли нам Elasticsearch?» звучит всё чаще на архитектурных ревью.
Эта статья адресована backend-разработчикам на Go и PHP/Laravel, а также архитекторам, которые проектируют поисковую функциональность. Мы разберём, как работает встроенный FTS в PostgreSQL, где он справляется, а где его возможностей объективно не хватает, и как принять взвешенное решение без предвзятости в сторону модных инструментов.
Как работает Full-Text Search в PostgreSQL: tsvector, tsquery, GIN и GiST
PostgreSQL реализует полнотекстовый поиск через два ключевых типа данных и набор функций над ними.
tsvector и tsquery
tsvector — это нормализованное представление документа: список лексем с позициями и весами. tsquery — поисковый запрос с булевыми операторами (&, |, !) и поддержкой фразового поиска.
-- Преобразование текста в tsvector
SELECT to_tsvector('russian', 'Быстрая коричневая лиса прыгает через ленивую собаку');
-- Результат: 'быстр':1 'коричнев':2 'лен':7 'лис':3 'прыга':4 'собак':8 'через':5
-- Простой поиск
SELECT to_tsvector('russian', 'Быстрая коричневая лиса') @@ to_tsquery('russian', 'лиса');
-- t
-- Фразовый поиск
SELECT to_tsvector('russian', 'PostgreSQL — мощная база данных') @@ phraseto_tsquery('russian', 'база данных');
-- tИндексы GIN и GiST
Для ускорения FTS PostgreSQL предлагает два типа индексов:
GIN (Generalized Inverted Index) — инвертированный индекс, аналогичный Elasticsearch. Быстрее при поиске, медленнее обновляется. Оптимален для статичных или редко меняющихся данных.
GiST (Generalized Search Tree) — более компактный, быстрее обновляется, но медленнее читает. Подходит для часто обновляемых таблиц.
-- Добавление вычисляемого столбца и GIN-индекса
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED;
CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);
-- Теперь поиск использует индекс
EXPLAIN ANALYZE
SELECT id, title FROM articles
WHERE search_vector @@ to_tsquery('russian', 'PostgreSQL & индекс');С PostgreSQL 15+ оптимизатор научился лучше использовать GIN-индексы в сочетании с другими предикатами, что снизило необходимость в ручных хинтах.
Практические примеры FTS: ранжирование, подсветка, русский язык
Ранжирование результатов
Функция ts_rank вычисляет релевантность на основе частоты лексем и их позиций. ts_rank_cd учитывает «покрытие» документа запросом.
SELECT
id,
title,
ts_rank(search_vector, query) AS rank
FROM
articles,
to_tsquery('russian', 'поиск & PostgreSQL') query
WHERE
search_vector @@ query
ORDER BY rank DESC
LIMIT 20;Подсветка найденных фрагментов
SELECT
title,
ts_headline(
'russian',
body,
to_tsquery('russian', 'полнотекстовый & поиск'),
'MaxWords=50, MinWords=20, StartSel=<mark>, StopSel=</mark>'
) AS snippet
FROM articles
WHERE search_vector @@ to_tsquery('russian', 'полнотекстовый & поиск');Поиск на русском языке
Для корректной работы с русским языком необходима конфигурация russian, которая поставляется вместе с PostgreSQL и использует словари Snowball для стемминга. Однако для более точного морфологического анализа рекомендуется подключить расширение ispell с русскими словарями:
-- Проверка доступных конфигураций
SELECT cfgname FROM pg_ts_config;
-- Анализ токенизации для русского текста
SELECT * FROM ts_debug('russian', 'разработчики используют PostgreSQL');
-- Поиск с учётом морфологии
SELECT title FROM articles
WHERE search_vector @@ to_tsquery('russian', 'разработчик');
-- Найдёт: «разработчики», «разработчика», «разработчику»Ограничение: стеммер Snowball для русского языка хуже справляется с неправильными глаголами и омонимами по сравнению с коммерческими решениями. Для e-commerce с развёрнутым каталогом это может быть критично.
Возможности и ограничения PostgreSQL FTS
Что умеет PostgreSQL FTS
Поиск с булевой логикой и фразами
Ранжирование по tf-idf-подобной формуле
Подсветка фрагментов (
ts_headline)Поиск с опечатками через
pg_trgm(trigram-индекс)Мультиязычные конфигурации
Весовые категории (A, B, C, D) для разных полей
-- Поиск с нечётким совпадением через pg_trgm
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_articles_trgm ON articles USING GIN(title gin_trgm_ops);
SELECT title, similarity(title, 'постгрес') AS sim
FROM articles
WHERE title % 'постгрес'
ORDER BY sim DESC;Ограничения
Нет встроенной поддержки синонимов и тезауруса «из коробки» (требует ручной настройки словарей)
Нет faceted search / агрегаций в духе Elasticsearch
Нет distributed search — масштабирование только вертикальное или через партиционирование
Нет встроенного автодополнения (suggest/autocomplete)
Индексирование больших таблиц блокирует ресурсы (решается через
CREATE INDEX CONCURRENTLY)
Когда PostgreSQL FTS достаточно: критерии принятия решения
Используйте PostgreSQL FTS как основной инструмент поиска, если выполняются следующие условия:
Объём данных до 10–50 млн записей в поисковой таблице. GIN-индекс справляется с такими объёмами при правильно настроенном железе.
Простая модель релевантности: ранжирование по частоте и весу полей без машинного обучения.
Поиск по структурированным документам с чётко определёнными полями (статьи, товары, пользователи).
Нет требований к real-time индексированию с задержкой менее 1 секунды.
Нет фасетной навигации (фильтры по категориям с подсчётом количества).
Команда уже работает с PostgreSQL и не хочет добавлять новый операционный компонент.
По опыту реальных проектов: внутренний поиск по корпоративной базе знаний, поиск по блогу или новостному порталу, поиск пользователей в SaaS-приложении — всё это отличные кандидаты для PostgreSQL FTS.
Когда нужен Elasticsearch или аналоги
Elasticsearch оправдан, когда требования выходят за рамки возможностей PostgreSQL:
Многополевая faceted search с агрегациями: «найти товары в категории X, от Y до Z рублей, с рейтингом выше 4» — и рядом счётчики по каждому фильтру.
Сотни миллионов документов с горизонтальным масштабированием через шарды.
Персонализированный поиск с Learning to Rank (LTR) и пользовательскими сигналами.
Многоязычный поиск с анализаторами для 30+ языков, включая иероглифические.
Near real-time индексирование: новый документ доступен для поиска через ~1 секунду.
Поиск по логам и метрикам (Elastic Stack / ELK) — это нативная область применения.
Автодополнение и поиск по префиксу на больших объёмах с низкой латентностью.
Если ваш проект — это маркетплейс с фасетами, крупный e-commerce или поиск по логам микросервисов, Elasticsearch остаётся стандартом де-факто.
Интеграция PostgreSQL FTS с Go и Laravel
Go: pgx и sqlc
В Go наиболее производительный драйвер для PostgreSQL — pgx. Для типобезопасных запросов используйте sqlc.
// Запрос через pgx с передачей tsquery-параметра
package search
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type Article struct {
ID int
Title string
Rank float32
}
func SearchArticles(ctx context.Context, db *pgxpool.Pool, query string) ([]Article, error) {
sql := `
SELECT
id,
title,
ts_rank(search_vector, websearch_to_tsquery('russian', $1)) AS rank
FROM articles
WHERE search_vector @@ websearch_to_tsquery('russian', $1)
ORDER BY rank DESC
LIMIT 20
`
rows, err := db.Query(ctx, sql, query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.Title, &a.Rank); err != nil {
return nil, err
}
results = append(results, a)
}
return results, nil
}Функция websearch_to_tsquery (PostgreSQL 11+) парсит пользовательский ввод в безопасный tsquery без риска синтаксических ошибок — идеальна для поисковых строк в REST API.
Laravel: Scout и нативные запросы
В Laravel для PostgreSQL FTS есть два подхода. Первый — через пакет laravel/scout с драйвером teamtnt/laravel-scout-tntsearch-driver или кастомным драйвером под Postgres. Второй — нативные запросы Eloquent.
<?php
// app/Models/Article.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Article extends Model
{
public function scopeSearch(Builder $query, string $term): Builder
{
return $query
->selectRaw("
*,
ts_rank(
search_vector,
websearch_to_tsquery('russian', ?)
) AS rank
", [$term])
->whereRaw(
"search_vector @@ websearch_to_tsquery('russian', ?)",
[$term]
)
->orderByDesc('rank');
}
}
// Использование в контроллере
$results = Article::search($request->input('q'))->paginate(20);
Для создания триггера, автоматически обновляющего search_vector, используйте миграцию Laravel:
<?php
// В миграции
DB::unprepared("
CREATE OR REPLACE FUNCTION update_article_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('russian', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(NEW.body, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER articles_search_vector_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION update_article_search_vector();
");
Производительность: нагрузочное тестирование и индексирование
Реальные бенчмарки на таблице 5 млн статей (PostgreSQL 16, 32 GB RAM, NVMe SSD):
Поиск без индекса (seq scan): ~8–12 секунд на запрос — неприемлемо.
GIN-индекс, простой запрос: 5–20 мс при 95-м перцентиле.
GIN + pg_trgm (fuzzy search): 20–80 мс при 95-м перцентиле.
Elasticsearch 8.x, аналогичный датасет: 3–10 мс при 95-м перцентиле.
Разница в 2–3 раза по латентности при простых запросах — это реальность. Но для большинства веб-приложений 20 мс против 5 мс не критичны, если запрос не вызывается 10 000 раз в секунду.
Создание индекса на большой таблице
-- CONCURRENTLY не блокирует таблицу на запись
CREATE INDEX CONCURRENTLY idx_articles_search_gin
ON articles USING GIN(search_vector);
-- Настройка maintenance_work_mem ускоряет создание GIN-индекса
SET maintenance_work_mem = '1GB';
CREATE INDEX idx_articles_search_gin ON articles USING GIN(search_vector);
-- Анализ размера индекса
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'articles';GIN-индекс на 5 млн строк занимает порядка 2–4 GB. Это нужно учитывать при планировании дискового пространства.
Гибридный подход: PostgreSQL FTS + Redis для кэширования
Даже при хорошей производительности PostgreSQL FTS поисковые запросы часто повторяются. Кэширование результатов в Redis позволяет снизить нагрузку на базу и улучшить время ответа до 1–2 мс.
// Go: кэширование поисковых результатов в Redis
package search
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/jackc/pgx/v5/pgxpool"
)
type SearchService struct {
db *pgxpool.Pool
cache *redis.Client
}
func (s *SearchService) Search(ctx context.Context, query string, page int) ([]Article, error) {
cacheKey := fmt.Sprintf("search:%s:page:%d", query, page)
// Проверяем кэш Redis
cached, err := s.cache.Get(ctx, cacheKey).Bytes()
if err == nil {
var articles []Article
if json.Unmarshal(cached, &articles) == nil {
return articles, nil
}
}
// Запрос к PostgreSQL
articles, err := SearchArticles(ctx, s.db, query)
if err != nil {
return nil, err
}
// Кэшируем на 5 минут
if data, err := json.Marshal(articles); err == nil {
s.cache.Set(ctx, cacheKey, data, 5*time.Minute)
}
return articles, nil
}Стратегия инвалидации кэша: сбрасывайте кэш при обновлении статей через PostgreSQL NOTIFY или очередь задач. В Laravel это удобно реализуется через Observer и событие saved.
Важный нюанс: кэшируйте только популярные запросы (top-N по частоте). Long-tail запросы кэшировать нецелесообразно — они займут память Redis без реального выигрыша.
Заключение: матрица выбора инструмента поиска
Ниже — практическая матрица для принятия решения. Используйте её как отправную точку, а не как абсолютное правило.
PostgreSQL FTS — отличный выбор, если: объём до 20–50 млн документов, нет фасетного поиска, команда владеет PostgreSQL, бюджет ограничен, нужна простота инфраструктуры.
PostgreSQL FTS + Redis — хороший выбор, если: добавляются высокие нагрузки на повторяющиеся запросы, нужна снизить задержку ответа без смены стека.
Elasticsearch/OpenSearch — необходим, если: фасетный поиск, 100+ млн документов, персонализация, автодополнение на больших объёмах, поиск по логам.
Typesense / Meilisearch — рассмотрите как альтернативу Elasticsearch, если нужен простой в операционной поддержке движок с хорошим UX «из коробки».
В 2026 году PostgreSQL FTS — это зрелое, production-ready решение для широкого класса задач. Не нужно тянуть Elasticsearch в каждый проект. Начните с PostgreSQL, добавьте Redis для кэширования горячих запросов, и вы получите стек, который обслуживает миллионы пользователей без дополнительной операционной нагрузки. Переходите на специализированный поисковый движок только тогда, когда упрётесь в конкретные ограничения — не раньше.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →