Базы данных

PostgreSQL Full-Text Search vs Elasticsearch: когда встроенного поиска достаточно в 2026 году

Ruslan Ismailov Опубликовано 12 мин чтения
P

Введение: почему выбор инструмента поиска важен в 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 как основной инструмент поиска, если выполняются следующие условия:

  1. Объём данных до 10–50 млн записей в поисковой таблице. GIN-индекс справляется с такими объёмами при правильно настроенном железе.

  2. Простая модель релевантности: ранжирование по частоте и весу полей без машинного обучения.

  3. Поиск по структурированным документам с чётко определёнными полями (статьи, товары, пользователи).

  4. Нет требований к real-time индексированию с задержкой менее 1 секунды.

  5. Нет фасетной навигации (фильтры по категориям с подсчётом количества).

  6. Команда уже работает с 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. Подробнее обо мне →