Пошаговое руководство по настройке полнотекстового поиска на русском в PostgreSQL с индексами, морфологией и ранжированием. Примерное время выполнения: 60–120 минут в зависимости от объёма данных.
Что вы изучите
Как хранить и индексировать текст на русском с помощью tsvector/tsquery.
Поддержка русской морфологии и устранение ударений/ё через unaccent.
Создание и эксплуатация GIN-индекса для быстрого поиска.
Построение триггеров для автоматического обновления индексного поля и стратегия реиндексации.
Примеры ранжирования с ts_rank и комбинации с pg_trgm для нечёткого поиска.
Требования
PostgreSQL 16/17 (релизы 2023–2024), тесты приведены для PostgreSQL 17.x (сборки 2025-03). Минимально: PostgreSQL 14 (2022) для базовой FTS.
ОС: Ubuntu 22.04 LTS или 24.04 LTS, Debian 12/13. Для production рекомендуется Linux x86_64.
Минимум 2 CPU, 4 GB RAM для тестовой машины; для индексирования больших наборов данных — 8+ GB RAM и быстрый NVMe.
Порт PostgreSQL: 5432. Диск: от 20 GB свободного для индексов (зависит от объёма данных).
Войдите или зарегистрируйтесь, чтобы оставить комментарий
Загрузка комментариев…
Elasticsearch хорош для распределённых поисковых кластеров и сложных аналитических задач, но у него есть эксплуатационные и лицензированные накладные расходы, которые не всегда оправданы. Операция запуска и поддержки ES-кластера обычно требует минимум три ноды, каждая с ~4–8 GB RAM и 20–40 GB диска; официальный Docker-образ Elasticsearch 8.x весит порядка 1 GB (в 2025 году похожие образы остаются тяжёлыми). Для многих приложений PostgreSQL уже хранит данные, и встроенный FTS покрывает 80–95% сценариев без дополнительного компонента.
PostgreSQL FTS даёт транзакционную согласованность (ACID), проще интегрируется в бэкап/репликацию, занимает меньше оперативного обслуживания и не требует отдельного CI/CD для поисковой схемы. Если вам нужны сложные ранжирования с ML, агрегируемые документы и распределённый индекс на тысячи шар, Elasticsearch остаётся вариантом. Для типичных веб-приложений и внутренних сервисов PostgreSQL FTS экономит ресурсы и снижает задержки между записью и видимостью в поиске.
Шаг 1: tsvector и tsquery
Команда: создайте таблицу с полем tsvector и выполните базовый поиск через @@.
-- Создать таблицу статей
CREATE TABLE articles (
id serial PRIMARY KEY,
title text,
body text,
body_tsv tsvector
);
-- Заполнить тестовые данные
INSERT INTO articles (title, body, body_tsv)
VALUES
('Про PostgreSQL', 'Полнотекстовый поиск в PostgreSQL на русском языке.', to_tsvector('russian', 'Полнотекстовый поиск в PostgreSQL на русском языке.'));
-- Поиск
SELECT id, title
FROM articles
WHERE body_tsv @@ to_tsquery('russian', 'полнотекстовый & поиск');
Пояснение: функция to_tsvector('russian', ...) нормализует слова (stemming), убирает стоп-слова и формирует tsvector, а to_tsquery превращает запрос в tsquery. Оператор @@ проверяет совпадение.
Ожидаемый вывод:
id | title
----+-----------------
1 | Про PostgreSQL
(1 row)
Типичная ошибка: ERROR: configuration "russian" does not exist. Фикс: проверьте доступные конфигурации и установите модули.
-- Список конфигураций
SELECT cfgname FROM pg_ts_config;
-- Если нет russian: установить пакеты и добавить конфигурацию
-- На Ubuntu: sudo apt-get install postgresql-contrib-17
Шаг 2: русская морфология
Команда: установите расширения unaccent и убедитесь, что используется русская конфигурация, добавьте нормализацию «ё» и удаление диакритики.
-- Включение расширения unaccent
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- пригодится далее
-- Пример нормализации перед созданием tsvector
SELECT to_tsvector('russian', unaccent('ёлка ёжик, ПоИскинг-тест'));
Пояснение: unaccent удаляет акценты и заменяет «ё» на «е», если настроено; это повышает совпадения для русских слов с разными написаниями. Snowball-стеммер для конфигурации «russian» выполняет отрезание окончаний. Для более точной морфологии можно использовать внешние словари (ispell/rumorph), но это сложнее в поддержке.
Типичная ошибка: ERROR: extension "unaccent" does not exist. Фикс: установите пакет с расширениями для вашей версии PostgreSQL, например sudo apt-get install postgresql-contrib-17 и перезапустите службу PostgreSQL.
Шаг 3: GIN индекс
Команда: создайте GIN-индекс по выражению или по колонке tsvector. Для больших таблиц используйте CONCURRENTLY.
-- Если храните tsvector в колонке
CREATE INDEX CONCURRENTLY idx_articles_body_tsv ON articles USING GIN (body_tsv);
-- Если хотите индексировать выражение напрямую
CREATE INDEX CONCURRENTLY idx_articles_body_expr ON articles USING GIN (to_tsvector('russian', unaccent(body)));
-- Пример запроса с EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT id FROM articles WHERE to_tsvector('russian', unaccent(body)) @@ to_tsquery('russian', 'поиск');
Пояснение: GIN-индекс хорошо подходит для совпадения множества лексем. Индекс по выражению избавляет от хранения дополнительного столбца, но обновляется при изменении полей, и его создание занимает примерно 0.5–2× объём данных по размеру в индексе (зависит от уникальности токенов). На практике: 1 миллион коротких документов (по 200 байт текста) даёт GIN-индекс порядка 200–600 MB.
Ожидаемый вывод EXPLAIN (пример):
Seq Scan on articles (cost=0.00..123.45 rows=10 width=4)
-- или с индексом:
Bitmap Index Scan on idx_articles_body_tsv (cost=12.34..45.67 rows=10 width=4)
Bitmap Heap Scan on articles (cost=12.34..45.67 rows=10 width=4)
Типичная ошибка: ERROR: could not create unique index using gin или проблема блокировок. Фикс: используйте CREATE INDEX CONCURRENTLY чтобы не блокировать таблицу, и убедитесь, что у вас есть достаточный WAL и disk space.
Снимок экрана: EXPLAIN ANALYZE поиска с GIN-индексом
Команда: создайте триггерную функцию, чтобы поддерживать колонку body_tsv в актуальном состоянии при INSERT/UPDATE.
-- Добавить колонку, если её нет
ALTER TABLE articles ADD COLUMN IF NOT EXISTS body_tsv tsvector;
-- Триггерная функция
CREATE OR REPLACE FUNCTION articles_tsv_trigger() RETURNS trigger AS $$
begin
new.body_tsv := to_tsvector('russian', unaccent(coalesce(new.title, '') || ' ' || coalesce(new.body, '')));
return new;
end
$$ LANGUAGE plpgsql;
-- Подключение триггера
CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE
ON articles FOR EACH ROW EXECUTE FUNCTION articles_tsv_trigger();
-- Тест
INSERT INTO articles (title, body) VALUES ('Тест триггера', 'Проверка обновления tsvector');
SELECT id, body_tsv FROM articles WHERE title = 'Тест триггера';
Пояснение: Триггер генерирует tsvector из полей и сохраняет в колонку. Это ускоряет поиск (чтение уже индексируемого поля) и уменьшает повторные вычисления в запросах.
Типичная ошибка: ERROR: function articles_tsv_trigger() does not exist — вызвано, если триггерная функция не создана или названа иначе. Фикс: проверьте наличие функции в pg_proc и права пользователя; функция должна быть в том же схеме, где создаётся триггер, либо укажите схему явно.
Шаг 5: реиндексация и поддержка индексов
Команда: реиндексируйте и обслуживайте индексы, используйте подход «create index concurrently -> drop index concurrently -> rename», чтобы избежать долгих эксклюзивных блокировок.
-- Создать новый индекс параллельно
CREATE INDEX CONCURRENTLY idx_articles_body_tsv_new ON articles USING GIN (body_tsv);
-- Проверить размер и состояние
SELECT indexrelid::regclass, pg_relation_size(indexrelid) AS size
FROM pg_stat_user_indexes WHERE schemaname = 'public' AND relname = 'articles';
-- После успешной проверки удалить старый и переименовать
DROP INDEX CONCURRENTLY IF EXISTS idx_articles_body_tsv;
ALTER INDEX idx_articles_body_tsv_new RENAME TO idx_articles_body_tsv;
-- Полный реиндекс (редко):
REINDEX INDEX CONCURRENTLY idx_articles_body_tsv;
Пояснение: Для минимизации простоев используйте CONCURRENTLY. Реиндексация требуется, если индекс повреждён, слишком раздут или после массовой загрузки данных. Планируйте такие операции в окнах низкой нагрузки и контролируйте диск и WAL.
Типичная ошибка: ERROR: cannot run CREATE INDEX CONCURRENTLY inside a transaction block. Фикс: запускайте команду вне BEGIN/COMMIT — непосредственно в psql или через отдельный скрипт.
Снимок экрана: pg_stat_user_indexes показывает размер GIN-индекса
Какие ограничения?
PostgreSQL FTS хорошо работает для корневых сценариев, но имеет ограничения по сравнению с полнофункциональными поисковыми движками. Он не предназначен для распределённых индексов на тысячи нод, у него ограниченные возможности для синонимов и сложных промо-правил. Поиск фраз и расстояние между словами реализуются через дополнительные приёмы (преобразование в массивы позиций), но для этого потребуется кастомная логика. Для нечёткого поиска (опечатки, частичные совпадения) стандартный GIN не всегда оптимален — здесь помогает pg_trgm и комбинированные индексы. Отдельные ограничения:
Ограниченная поддержка синонимов — требуется внешний словарь или ручная нормализация.
Фразовый поиск и расстояния между словами требуют позиций в tsvector и дополнительной логики.
GIN-индексы бывают довольно большими по объёму и ресурсоёмки при массовых обновлениях.
Сложные ранжирования типа ML-рекомердаций должны быть объединены вне FTS (или интегрированы через дополнительные поля).
Как делать ранжирование?
Команда: используйте ts_rank или ts_rank_cd с весами, комбинируйте с trigram для нечёткого совпадения.
-- Присвоение весов: заголовок важнее тела
UPDATE articles SET body_tsv = setweight(to_tsvector('russian', unaccent(coalesce(title, ''))), 'A') ||
setweight(to_tsvector('russian', unaccent(coalesce(body, ''))), 'B');
-- Поиск с ранжированием
SELECT id, title, ts_rank(body_tsv, q) AS rank
FROM articles, to_tsquery('russian', 'поиск | запрос') q
WHERE body_tsv @@ q
ORDER BY rank DESC
LIMIT 10;
-- Комбинация с pg_trgm для нечёткого поиска
SELECT a.id, a.title, ts_rank(a.body_tsv, q) * 0.8 + similarity(a.title, 'поиск') * 0.2 AS score
FROM articles a, to_tsquery('russian', 'поиск') q
WHERE a.body_tsv @@ q
ORDER BY score DESC LIMIT 10;
Пояснение: setweight позволяет назначать разный вклад полям (A, B, C, D). ts_rank возвращает нормализованное значение релевантности; ts_rank_cd даёт более консистентную нормализацию по документу. Комбинирование с pg_trgm (функции similarity(), индекс pg_trgm) помогает при опечатках и частичном вводе, но требует дополнительного индекса (GIN или GiST с pg_trgm).
Ожидаемый вывод (пример):
id | title | rank
----+-------------------+------------
7 | Быстрый поиск | 0.529343
13 | Документация по FTS| 0.412112
(2 rows)
Типичные ошибки и их фикс:
Низкая релевантность: проверьте веса и используйте setweight для заголовков; добавьте unaccent перед tsvector.
Опечатки не ищутся: добавьте pg_trgm и используйте similarity() или %% оператор.
Медленные запросы с сортировкой по rank: используйте LIMIT/offset и предфильтрацию по index-совместимым условиям.
Внутренние ссылки: читаемые материалы по PostgreSQL и DevOps: PostgreSQL, DevOps.
Частые вопросы
Как настроить поиск с учётом морфологии русского языка?
Для русского используйте конфигурацию russian в to_tsvector и to_tsquery. Добавьте расширение unaccent для удаления диакритики и нормализации «ё» -> «е». При необходимости подключайте внешние словари (ispell) или настраивайте собственные словари для синонимов; это потребует установки пакетов постфикса и прав на сервер. Тестируйте на репрезентативном наборе документов и корректируйте стоп-слова и весовые коэффициенты.
Что делать, если пользователи вводят опечатки?
Добавьте pg_trgm и используйте оператор сходства (similarity или %%). Стратегия: первым этапом применяйте FTS-фильтр для сокращения кандидатов, затем ранжируйте результирующий набор с учётом trigram-совпадения. Можно комбинировать веса: 0.7 от ts_rank и 0.3 от similarity. Для автодополнения делайте индекс на префикс с pg_trgm и используйте запросы вида WHERE title ILIKE 'поиск%' с триграм-индексом.
Почему GIN-индекс растёт слишком быстро и как уменьшить размер?
GIN индекс хранит множество токенов, поэтому его размер зависит от лексемности документа. Снизить размер можно сжатием текста перед индексированием (лишние поля), уменьшением числа стоп-слов, использованием выражений для исключения нерелевантных полей, а также через регулярную реиндексацию и VACUUM. При больших объёмах храните tsvector в отдельной партицированной таблице или используйте внешние механизмы хранения.
Где эффективнее хранить tsvector: в колонке или вычислять на запросе?
Хранение tsvector в колонке и индекс по нему даёт наилучшую производительность при быстрых чтениях. Индекс по выражению (to_tsvector(body)) экономит место в схеме, но при этом каждый UPDATE поля заставит пересчитывать индекс для строки. Для высокочастотных записей лучше хранить колонку и поддерживать её триггером; для редко изменяемых данных допустимо выражение.
Сколько оперативной памяти требуется для индексирования миллиона документов?
Невозможно дать точное число без профиля документов, но ориентир: для 1M коротких документов (несколько сотен байт) потребуется 4–8 GB RAM для комфортного индексирования с учётом работы PostgreSQL и GIN-алгоритмов; индекс может занять 0.2–0.6 GB на 100k документов, итого 2–6 GB для 1M. При массовом создании индекса используйте опцию maintenance_work_mem (увеличьте до нескольких сотен MB или GB на время индексации) и мониторьте диск и WAL.
Комментарии (0)
Войдите или зарегистрируйтесь, чтобы оставить комментарий
Загрузка комментариев…