6. Full Text Search
Если в вашем приложении на Go возникает бизнес-требование «реализовать быстрый поиск по каталогу товаров, статьям блога или сообщениям пользователей», первое интуитивное (и самое опасное) решение — написать запрос с оператором LIKE '%query%':
SELECT * FROM articles WHERE content LIKE '%query%';
Однако оператор LIKE '%...' страдает двумя фатальными пороками:
- Алгоритмическая деградация $O(N)$: Символ подстановки
%в начале шаблона делает невозможным использование классического B-Tree индекса. Сервер вынужден запустить Sequential Scan, методично считывая с диска в память абсолютно каждую 8-килобайтную страницу таблицы Heap. - Полная лингвистическая слепота:
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) с фиксацией их порядковых позиций в исходном документе:
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 — оператор фразового расстояния, требующий, чтобы слова следовали строго друг за другом)
SELECT to_tsquery('russian', 'мальчик & (бег | игра)');
-- Результат: 'мальчик' & ( 'бег' | 'игр' )
Сопоставление документа и запроса выполняется с помощью специализированного оператора совпадения @@:
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)"]
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: чем значение выше, тем точнее совпадение.
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 через наивную склейку:
// АНТИПАТТЕРН: Приведет к падению при вводе скобок, амперсандов или кавычек!
safeQuery := strings.ReplaceAll(q, " ", " & ")
недопустима в промышленном коде.
Начиная с версии PostgreSQL 11, введена специализированная функция websearch_to_tsquery(). Она реализует привычный всем синтаксис веб-поисковиков:
- Пробелы автоматически трактуются как оператор
AND. - Фразы в кавычках (
"go runtime") превращаются в оператор фразового поиска<->. - Префикс минус (
-исключение) трансформируется в оператор отрицания!. - Функция никогда не выбрасывает синтаксических исключений, даже при наличии мусорных спецсимволов.
Идиоматичный 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
}
Резюме для инженера
- Встроенный FTS решает проблему Dual Write: Использование возможностей PostgreSQL избавляет систему от рассинхронизации данных и накладных расходов на кластер Elasticsearch.
- Тандем
tsvectorиtsquery: Текст преобразуется в лексемы через стемминг и стоп-слова, а запросы строятся в виде булевых деревьев. - Generated Columns + GIN: Создавайте постоянные вычисляемые колонки
tsvectorи покрывайте их GIN-индексами для предсказуемой производительности. - Безопасность ввода: Всегда применяйте
websearch_to_tsqueryвместоto_tsqueryпри обработке данных от пользователей. - Осторожность с
ts_rank: Функция требует физического чтения страниц Heap. Ограничивайте выборку кандидатов через CTE перед сортировкой по релевантности.
PostgreSQL впечатляет богатством стандартных возможностей — от JSONB до полнотекстового поиска. Но его истинное величие кроется в модульной природе: вы можете динамически загружать сторонние скомпилированные C-библиотеки прямо в рантайм базы данных, наделяя ее новыми типами данных, индексами и фоновыми процессами. О том, как устроена экосистема расширений — в следующей лекции: 7. Расширения PostgreSQL.