6. Full Text Search

Если в вашем приложении на Go возникает бизнес-требование «реализовать быстрый поиск по каталогу товаров, статьям блога или сообщениям пользователей», первое интуитивное (и самое опасное) решение — написать запрос с оператором LIKE '%query%':

SQL
SELECT * FROM articles WHERE content LIKE '%query%';

Однако оператор LIKE '%...' страдает двумя фатальными пороками:

  1. Алгоритмическая деградация $O(N)$: Символ подстановки % в начале шаблона делает невозможным использование классического B-Tree индекса. Сервер вынужден запустить Sequential Scan, методично считывая с диска в память абсолютно каждую 8-килобайтную страницу таблицы Heap.
  2. Полная лингвистическая слепота: LIKE производит побайтовое сравнение подстрок. Запрос LIKE '%бег%' никогда не найдет слово «бежал» или «бегущий», а поиск LIKE '%apple%' проигнорирует форму множественного числа «apples».

Второй порыв инженера — немедленно затащить в архитектуру внешний поисковый движок вроде Elasticsearch или OpenSearch. Но этот путь часто оказывается классическим преждевременным усложнением (Overengineering). Внешний кластер порождает болезненную проблему двойной записи (Dual Write): транзакция в базе данных уже завершилась, а событие в поисковый индекс еще не доехало или потерялось из-за сетевого сбоя. В итоге данные неизбежно рассинхронизируются, а команда вынуждена обслуживать дополнительную распределенную систему на JVM с собственным мониторингом и репликацией.

В 95% прикладных задач бэкенда для полнотекстового поиска (Full Text Search, FTS) более чем достаточно встроенных возможностей PostgreSQL. Они работают транзакционно согласованно, не требуют сетевых прыжков и демонстрируют выдающуюся скорость, если понимать их физическое устройство.


Фундаментальные типы данных: tsvector и tsquery

В ядре PostgreSQL полнотекстовый поиск базируется на двух специализированных типах данных, преобразующих неструктурированный текст в математически строгие множества лексем.

1. tsvector (Текстовый вектор документа)

PostgreSQL принципиально не производит поиск по сырым текстовым строкам TEXT или VARCHAR. Текст предварительно нормализуется и компилируется в тип tsvector — отсортированный массив уникальных нормальных форм слов (лексем, lexemes) с фиксацией их порядковых позиций в исходном документе:

SQL
SELECT to_tsvector('russian', 'Мальчик быстро бежал по улице, пока другие мальчики играли');
-- Результат: 'бежа':3 'быстр':2 'друг':7 'игра':9 'мальчик':1,8 'пок':6 'улиц':5

Обратите внимание: слова «Мальчик» и «мальчики» склеились в единую лексему 'мальчик', а числа 1,8 указывают, на каких позициях в предложении это слово встречалось.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Raw["Сырой текст <br> (VARCHAR / TEXT)"]:::process --> Parser["1. Parser <br> (Выделение токенов)"]
    Parser --> StopWords["2. Stop-words <br> (Удаление предлогов)"]
    StopWords --> Stemmer["3. Snowball Stemmer <br> (Стемминг до лексем)"]
    Stemmer --> TSV["tsvector <br> (Упорядоченные лексемы и позиции)"]
  

2. tsquery (Дерево поискового запроса)

Поисковая фраза пользователя точно так же нормализуется и трансформируется в тип tsquery. Это не просто строка, а логическое булево дерево предикатов.

В tsquery поддерживаются классические логические операторы:

  • & (AND — логическое И)
  • | (OR — логическое ИЛИ)
  • ! (NOT — логическое отрицание)
  • <-> (Followed by — оператор фразового расстояния, требующий, чтобы слова следовали строго друг за другом)
SQL
SELECT to_tsquery('russian', 'мальчик & (бег | игра)');
-- Результат: 'мальчик' & ( 'бег' | 'игр' )

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

SQL
SELECT to_tsvector('russian', 'Мальчик играл') @@ to_tsquery('russian', 'мальчик & игра');
-- Результат: true

Индексация: GIN под капотом полнотекстового поиска

Вычисление функции to_tsvector() "на лету" при каждом пользовательском SELECT моментально сожжет ресурсы CPU. Полнотекстовые векторы обязаны быть проиндексированы.

Для этой задачи идеально подходит знакомый нам по лекции 5. JSONB и работа с JSON инвертированный индекс GIN (Generalized Inverted Index).
В листовых узлах GIN-дерева в качестве ключей хранятся уникальные лексемы всех проиндексированных документов, а в качестве значений — упорядоченные списки физических адресов (CTID) страниц Heap, где эти лексемы присутствуют.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph GINIndex ["Листовые страницы GIN Индекса"]
        Lex1["Лексема: 'бежа'"]:::process --> List1["Posting List: [ (1, 3), (4, 12), ... ]"]
        Lex2["Лексема: 'мальчик'"]:::process --> List2["Posting List: [ (1, 3), (2, 8), ... ]"]
    end

    subgraph QueryExecution ["Поиск по запросу: 'мальчик & бежа'"]
        Intersect["Пересечение списков (Intersection) <br> Итог: CTID (1, 3)"]
    end

    List1 --> Intersect
    List2 --> Intersect
    Intersect -.->|"Чтение единственной страницы"| HeapPage["Heap Page 1 (8 KB)"]
  
SQL
ALTER TABLE articles 
ADD COLUMN search_vector tsvector 
GENERATED ALWAYS AS (to_tsvector('russian', title || ' ' || content)) STORED;

CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

Теперь запрос вида WHERE search_vector @@ to_tsquery('...') будет выполняться за доли миллисекунды прямо по GIN-индексу.


Ранжирование (Ranking): Сортировка по релевантности

Найти подходящие строки в базе — только половина дела. Пользователь ожидает увидеть наиболее релевантные совпадения на первых позициях выдачи.
Для вычисления коэффициента релевантности в PostgreSQL используются функции:

  • ts_rank(tsvector, tsquery): Базовый подсчет частоты встречаемости лексем (TF-IDF модель).
  • ts_rank_cd(tsvector, tsquery): Функция Cover Density — дополнительно учитывает физическое расстояние между искомыми словами внутри текста (слова, стоящие рядом в одном предложении, получают существенно больший вес).

Обе функции возвращают число типа float4: чем значение выше, тем точнее совпадение.

SQL
SELECT id, title, ts_rank(search_vector, query) as rank
FROM articles, to_tsquery('russian', 'горутины & каналы') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

Mechanical Sympathy: Почему ранжирование может обвалить базу?


Безопасная интеграция с языком Go

Типичная авария в Go-сервисах: пользователь вводит в строку поиска "iphone 15 pro". Если разработчик наивно передаст эту строку в стандартную функцию to_tsquery('iphone 15 pro'), PostgreSQL вернет ошибку синтаксиса syntax error in tsquery: "iphone 15 pro", поскольку to_tsquery ожидает строгого наличия логических операторов (&, |), требуя формат вроде iphone & 15 & pro.

Попытка «чинить» это в коде на Go через наивную склейку:

Go
// АНТИПАТТЕРН: Приведет к падению при вводе скобок, амперсандов или кавычек!
safeQuery := strings.ReplaceAll(q, " ", " & ") 

недопустима в промышленном коде.

Начиная с версии PostgreSQL 11, введена специализированная функция websearch_to_tsquery(). Она реализует привычный всем синтаксис веб-поисковиков:

  • Пробелы автоматически трактуются как оператор AND.
  • Фразы в кавычках ("go runtime") превращаются в оператор фразового поиска <->.
  • Префикс минус (-исключение) трансформируется в оператор отрицания !.
  • Функция никогда не выбрасывает синтаксических исключений, даже при наличии мусорных спецсимволов.

Идиоматичный Go-репозиторий с оптимизацией ранжирования

Go
package repository

import (
    "context"
    "database/sql"
    "fmt"
)

type Article struct {
    ID    int
    Title string
    Rank  float32
}

// SearchArticles выполняет высокопроизводительный FTS с защитой от Random I/O
func SearchArticles(ctx context.Context, db *sql.DB, userInput string) ([]Article, error) {
    // 1. Используем websearch_to_tsquery для парсинга пользовательской строки
    // 2. С помощью CTE ограничиваем объем строк для ранжирования первыми 500 записями
    const query = `
        WITH matched AS (
            SELECT id, title, search_vector
            FROM articles
            WHERE search_vector @@ websearch_to_tsquery('russian', $1)
            ORDER BY created_at DESC 
            LIMIT 500
        )
        SELECT 
            id, 
            title, 
            ts_rank(search_vector, websearch_to_tsquery('russian', $1)) AS rank
        FROM matched
        ORDER BY rank DESC
        LIMIT 10;
    `

    rows, err := db.QueryContext(ctx, query, userInput)
    if err != nil {
        return nil, fmt.Errorf("search query failed: %w", 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, fmt.Errorf("row scan failed: %w", err)
        }
        results = append(results, a)
    }

    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("rows iteration error: %w", err)
    }

    return results, nil
}

Резюме для инженера

  1. Встроенный FTS решает проблему Dual Write: Использование возможностей PostgreSQL избавляет систему от рассинхронизации данных и накладных расходов на кластер Elasticsearch.
  2. Тандем tsvector и tsquery: Текст преобразуется в лексемы через стемминг и стоп-слова, а запросы строятся в виде булевых деревьев.
  3. Generated Columns + GIN: Создавайте постоянные вычисляемые колонки tsvector и покрывайте их GIN-индексами для предсказуемой производительности.
  4. Безопасность ввода: Всегда применяйте websearch_to_tsquery вместо to_tsquery при обработке данных от пользователей.
  5. Осторожность с ts_rank: Функция требует физического чтения страниц Heap. Ограничивайте выборку кандидатов через CTE перед сортировкой по релевантности.

PostgreSQL впечатляет богатством стандартных возможностей — от JSONB до полнотекстового поиска. Но его истинное величие кроется в модульной природе: вы можете динамически загружать сторонние скомпилированные C-библиотеки прямо в рантайм базы данных, наделяя ее новыми типами данных, индексами и фоновыми процессами. О том, как устроена экосистема расширений — в следующей лекции: 7. Расширения PostgreSQL.