Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Иллюстрация к статье о настройке полнотекстового поиска в PostgreSQL и GORM

Полнотекстовый поиск PostgreSQL в Go через GORM: от to_tsvector до GIN-индексов

Простой поиск по тексту через LIKE '%слово%' приводит к сканированию всей таблицы PostgreSQL и высокой нагрузке на базу данных. Показываем настройку полнотекстового поиска с типами tsvector и tsquery, создание генерируемых столбцов в GORM и ускорение запросов с помощью GIN-индексов.

Полнотекстовый поиск PostgreSQL в Go через GORM: от to_tsvector до GIN-индексов

При разработке бэкенд-сервисов на языке Go с использованием СУБД PostgreSQL задача поиска по текстовым полям часто решается простым использованием SQL-оператора LIKE '%слово%'. На начальном этапе разработки и малых объемах данных в несколько сотен записей такой подход кажется достаточным. Однако по мере роста базы данных поиск через LIKE начинает вызывать серьезные проблемы с производительностью.

Оператор LIKE с маской в начале строки не способен использовать стандартные B-tree индексы, принуждая СУБД выполнять последовательное сканирование всех страниц таблицы (sequential scan). Кроме того, LIKE не учитывает морфологию языка, чувствителен к регистру и не способен находить словоформы (например, поиск по слову «бежать» не найдет «бежал»). СУБД PostgreSQL предоставляет мощный встроенный инструмент полнотекстового поиска (Full-Text Search / FTS), который эффективно интегрируется в Go-приложения через ORM-библиотеку GORM.

Недостатки оператора LIKE и базовые примитивы полнотекстового поиска

Полнотекстовый поиск в PostgreSQL основан на двух ключевых типах данных:

  1. tsvector (text search vector): отсортированный набор уникальных нормализованных лексем (основ слов), полученных из исходного текста после удаления стоп-слов и нормализации морфологии.
  2. tsquery (text search query): поисковый запрос, содержащий лексемы и логические операторы сопоставления.

Проверка соответствия документа поисковому запросу выполняется с помощью специального булева оператора @@:

SELECT title FROM books WHERE to_tsvector('english', title) @@ to_tsquery('english', 'running');

В этом запросе функция to_tsvector нормализует строку до лексемы run, а to_tsquery преобразует искомую форму running в ту же лексему run. В результате оператор @@ возвращает true.

Выбор парсера поисковых запросов: plainto_tsquery против websearch_to_tsquery

При получении поиска от пользователя крайне важно правильно выбрать функцию формирования tsquery:

  • plainto_tsquery: принимает произвольную строку от пользователя и преобразует ее в набор лексем, соединенных логическим AND. Все служебные и пунктуационные символы игнорируются, что исключает ошибки синтаксиса.
  • websearch_to_tsquery: предоставляет продвинутый синтаксис, привычный пользователям поисковых систем. Текст в кавычках (например, "gorm postgres") преобразуется в поиск точной фразы, знак минус перед словом (-draft) исключает документы из выдачи, а слово OR задает альтернативный выбор. При этом пользовательский ввод также полностью защищен от синтаксических ошибок.

Базовый вариант поиска через вычисление вектора непосредственно в запросе

В библиотеке GORM полнотекстовый поиск можно реализовать без изменения структуры таблиц, вычисляя tsvector на лету внутри метода .Where():

package main

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type Product struct {
    ID          uint   `gorm:"primaryKey"`
    Title       string
    Description string
}

func SearchProducts(db *gorm.DB, term string) ([]Product, error) {
    var products []Product
    err := db.Where(
        "to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ websearch_to_tsquery('english', ?)",
        term,
    ).Find(&products).Error
    return products, err
}

Передача поискового термина через параметр ? гарантирует защиту от SQL-инъекций. Однако такой базовый вариант имеет главный недостаток: СУБД вынуждена заново вычислять to_tsvector для каждой строки таблицы при каждом поисковом запросе, из-за чего скорость поиска падает пропорционально размеру базы данных.

Оптимизация схемы: генерируемые столбцы tsvector STORED и GIN-индексы

Чтобы вычисление поискового вектора происходило один раз при записи или обновлении строки, в таблицу добавляют генерируемый столбец (generated stored column) со специальным типом индекса GIN (Generalized Inverted Index).

Инвертированный индекс GIN хранит элемент для каждой лексемы с вектором ссылок на строки таблицы. При поиске по нескольким словам GIN моментально пересекает списки строк, исключая сканирование всей таблицы.

В модели GORM генерируемый столбец помечается тегом -> (read-only), чтобы ORM не пыталась передавать его при вставках:

type Book struct {
    ID           uint   `gorm:"primaryKey"`
    Title        string
    Description  string
    SearchVector string `gorm:"->;type:tsvector"`
}

Практическая интеграция в Go с применением ORM-библиотеки GORM

Поскольку стандартный инструмент AutoMigrate в GORM не поддерживает генерацию специфического синтаксиса генерируемых столбцов PostgreSQL, добавление столбца и индекса выполняется через сырой SQL-скрипт после миграции:

func InitDatabase(db *gorm.DB) error {
    if err := db.AutoMigrate(&Book{}); err != nil {
        return err
    }

    // Создаем генерируемый столбец
    err := db.Exec(`
        ALTER TABLE books
        ADD COLUMN IF NOT EXISTS search_vector tsvector
        GENERATED ALWAYS AS (
            to_tsvector('english', coalesce(title, '')) || ' ' ||
            to_tsvector('english', coalesce(description, ''))
        ) STORED;
    `).Error
    if err != nil {
        return err
    }

    // Создаем GIN-индекс для мгновенного поиска
    return db.Exec(`
        CREATE INDEX IF NOT EXISTS idx_books_search_vector
        ON books USING gin(search_vector);
    `).Error
}

func FastSearch(db *gorm.DB, term string) ([]Book, error) {
    var results []Book
    err := db.Where("search_vector @@ websearch_to_tsquery('english', ?)", term).
        Find(&results).Error
    return results, err
}

Настройка ранжирования, взвешивание полей и регулярный аудит запросов

Для получения качественных результатов выдачи рекомендуется использовать функции ранжирования ts_rank или ts_rank_cd, а также назначать весовые коэффициенты (A, B, C, D) для разных полей документа (например, вес A для заголовка и вес B для описания):

ALTER TABLE books ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;

Перед выводом оптимизации в продакшен обязательно проверите план выполнения запроса с помощью команды EXPLAIN (ANALYZE, BUFFERS). План должен подтвердить использование созданного GIN-индекса (Bitmap Index Scan on idx_books_search_vector).

Подробное описание работы типов и индексов находится в официальном руководстве PostgreSQL по полнотекстовому поиску и документации по GIN-индексам.