Многие проекты рано или поздно утыкаются в «потолок» стандартного поиска. Обычный LIKE перестает справляться, когда данных становится больше 100 тысяч строк, а пользователи начинают ошибаться в каждом втором слове. Типовым решением в такой ситуации считается внедрение Elasticsearch или Meilisearch.
Но внешние движки — это всегда «налог» на инфраструктуру: лишняя память, задержки на сетевой хоп и, самое главное, головная боль с синх��онизацией данных. В этой статье мы разберем, как выжать из PostgreSQL производительность специализированного поисковика, используя Node.js как оркестратор параллельных стратегий и механизм AbortSignal для предотвращения лишней нагрузки на БД. Разбираем внутреннее устройство SDK pg-smart-search.

Архитектура: Конкуренция вместо очереди
Обычно логика «умного» поиска строится как цепочка: Сначала ищем точное совпадение -> Если нет, запускаем полнотекстовый поиск (FTS) -> Если нет, пробуем триграммы. В худшем случае (когда срабатывает последняя стадия) пользователь ждет сумму времен выполнения всех запросов.
Мы пошли другим путем и реализовали Hybrid Parallel Fast-Track. Движок запускает стратегии одновременно, но с разным приоритетом.
Fallback Chain изнутри
Движок не просто запускает всё подряд. У нас есть каскадная логика возврата:
Cache Hit: Сначала проверяем Redis/Memory. Если есть — отдаем за 1-2мс.
Parallel Race: Запускаем
FTSStrategy(лингвистика) иStandardSearch(триграммы + ILIKE).Fast-Track Winner: Если
FTSStrategyвернула хоть один результат — это "победитель". Лингвистическая точность в 99% случаев выше триграммной на старте. Мы возвращаем её результат немедленно.Graceful Fallback: Если FTS пустой — ждем завершения триграмм.
Механизм Zombie Query Prevention
Параллельные запросы — это не только скорость, но и риск перегреть базу. Если полнотекстовый поиск (FTS) нашел точный ответ за 7мс, нам физически не нужно дожидаться работы тяжелой триграммной ветки, которая может занять 100мс+.
Для этого в SDK интегрирован механизм отмены через AbortSignal, который прокидывается до самого драйвера БД.
Как это работает на уровне пакета pg:
Когда вызывается internalController.abort(), AbortSignal отправляет сигнал в драйвер. Драйвер (например, pg или prisma) отправляет в PostgreSQL команду CANCEL через отдельное служебное соединение. БД моментально прекращает выполнение текущего процесса (PID) для этого запроса.
// Реализация параллельного Fast-Track в TrigramSearchEngine async search(query: string, options: SearchOptions) { const internalController = new AbortController(); const signal = options.abortSignal || internalController.signal; // Стартуем две стратегии одновременно const ftsPromise = this.ftsStrategy.search(query, { ...options, abortSignal: signal }); const standardPromise = this.standardSearch(query, { ...options, abortSignal: signal }); try { const results = await ftsPromise; // Если лингвистический поиск (FTS) нашел совпадения if (results.pagination.total > 0) { // Убиваем "зомби-запрос" (триграммы) прямо в базе данных internalController.abort(); // Проглатываем ошибку отмены, чтобы не упал весь процесс standardPromise.catch(() => {}); return results; } // Если FTS пуст, возвращаем результаты стандартного/fuzzy поиска return await standardPromise; } catch (err: any) { if (err.name === 'AbortError' || err.message === 'AbortError') { return this.emptyResult(); } throw err; } }
Эволюция BM25 и взвешенное ранжирование 📊
Просто найти слово — мало. Нужно выдать релевантные результаты сверху. В Postgres есть встроенная функция ts_rank_cd (Cover Density), которая учитывает близость слов и их частоту.
В SDK мы пошли дальше и реализовали систему весов для колонок. Например, совпадение в заголовке (title) должно быть ценнее, чем в описании (description). Вот как это превращается в SQL:
// Пример генерации взвешенного ранга const weights = { title: "A", description: "B", content: "C" }; const weightedVector = this.config.searchColumns .map( (col) => setweight(to_tsvector('${lang}', ${col}), '${weights[col] || "D"}'), ) .join(" || "); const sql = ` SELECT *, ts_rank_cd( ${weightedVector}, ${tsquery}, 32 /* rank(1) + rank(2) + rank(4) + rank(8) + rank(16) */ ) AS relevance FROM ${this.config.tableName} WHERE ${weightedVector} @@ ${tsquery} ORDER BY relevance DESC; `;
При объединении стратегий (Hybrid Search) мы нормализуем эти веса, чтобы триграммная схожесть (0.0 - 1.0) и FTS rank не конфликтовали, используя линейную комбинацию: final_score = (fts_rank 0.7) + (trigram_sim 0.3).
Deep Dive: Как AbortSignal доходит до ядра БД 🔌
Многие думают, что abort() в JS просто обрывает HTTP-запрос. Но в pg-smart-search сигнал проходит длинный путь до реального процесса в PostgreSQL.
Node.js: Вы вызываете
controller.abort().SDK: Сигнал прокидывается в
DatabaseAdapter.Database Driver (pg/prisma): Библиотека ловит событие
abort.Protocol: Драйвер открывает новое (краткосрочное) соединение с базой данных и отправляет команду
CANCELдля конкретногоbackend_pid.PostgreSQL: Ядро БД в��дит сигнал прерывания и моментально останавливает выполнение тяжелого
INDEX SCANилиJOIN.
Это критично для производительности: мы не просто игнорируем результат, мы освобождаем CPU базы данных для других пользователей прямо в момент нахождения первого результата.
Эволюция производительности: Реальные замеры 📈
В процессе разработки мы замеряли прогресс на типовом наборе текстовых данных из ~15 000 записей. Вот как менялись цифры по мере внедрения каждой фичи — без выдумок и маркетинга. Замеры проводились на локальной машине разработчика — на вашем железе цифры будут отличаться, для этого в SDK есть встроенный npm run bench
Этап 1: Базовая логика (Последовательное выполнение)
Здесь всё было просто: сначала ищем через ILIKE, потом полнотекстовый поиск, потом триграммы.
Average Latency: 85.04ms p50 (Median): 89.89ms p99 (Worst case): 123.56ms Throughput: 11 req/sec
Этап 2: Первые оптимизации (FTS в приоритете + кэш)
Мы перешли на преимущественное использование FTS и добавили базовое кэширование.
Average Latency: 46.7ms p50 (Median): 47.82ms p99 (Worst case): 93.58ms Throughput: 21 req/sec
Этап 3: Параллельный запуск стратегий
Мы начали запускать FTS и триграммы одновременно, чтобы не ждать их последовательно.
Average Latency: 34.87ms p50 (Median): 31.36ms p95 Latency: 48.33ms p99 (Worst case): 64.45ms Throughput: 28 req/sec
Этап 4: Elite Tier (Turbo Mode + Zombie Prevention)
Финальный аккорд: хранение tsvector в БД и мгновенная отмена проигравших параллельных запросов через AbortSignal.
Elite Tier (Turbo Hit): 12.33ms (FTS Fast-Track) Standard Fallback: 59.59ms (Parallel Sync) Throughput: 81 req/sec
Итог эволюции: Мы разогнали пропускную способность системы в 7.3 раз (с 11 до 81 req/sec), а среднюю задержку для попаданий снизили в 6.8 раз.
Подводные камни: Что не сработало 🕳️
Вот решения, которые мы попробовали и выбросили:
1. Индексация "на лету" (Functional Indexes)
Мы пробовали создавать индексы типа CREATE INDEX ON items USING GIN (to_tsvector('russian', text)).
Почему не взлетело: Это работает для простых случаев, но как только логика индексации усложняется (склейка нескольких колонок, разные языки), Postgres часто срывается в Full Table Scan. Кроме того, это создает огромную нагрузку на CPU при каждом обновлении записи. Вердикт: Только STORED GENERATED колонки.
2. Автоматическая смена раскладки через SQL
Я пытался написать хранимую функцию на PL/pgSQL, которая конвертирует gjbcr в поиск прямо внутри запроса.
Почему не взлетело: Это делает SQL-запрос нечитаемым "монстром", который невозможно нормально профилировать через EXPLAIN ANALYZE. Плюс, передача словарей раскладок в БД — это лишний overhead. Вердикт: Весь процессинг запроса должен быть на стороне Node.js.
3. Materialized Views для поиска
Была идея вынести поиск в отдельную материализованную вьюху, чтобы не нагружать основные таблицы.
Почему не взлетело: Проблема консистентности. Либо мы обновляем вьюху CONCURRENTLY (что долго и сложно автоматизировать), либо пользователь видит старые данные. Для большинства проектов real-time поиск важнее, чем микро-оптимизация через вьюхи.
Принцип No-Magic: Почему библиотека ничего не создает сама 🛡️
Один из главных вопросов: создает ли SDK таблицы или индексы автоматически?
Ответ: Нет. И это осознанное архитектурное решение.
Автоматические миграции «под капотом» — это зло для продакшена. Вы должны контролировать каждый CREATE INDEX, особенно на таблицах с миллионами строк. Вместо «магии» мы выбрали путь SQL Generation:
Библиотека генерирует оптимальный SQL-код.
Вы проверяете его и добавляете в свои миграции (Prisma, TypeORM, Flyway).
Архитектура интеграции: Адаптеры и жизненный цикл запроса 🛠️
Теория — это хорошо, но как это выглядит в реальном коде? Главная проблема многих библиотек для поиска — жесткая привязка к ORM. Мы решили эту проблему через паттерн Adapter.
Это позволяет внедрить SDK в любой стек (TypeORM, Prisma, Kysely, Sequelize), не меняя архитектуру вашего приложения, а просто описав мостик для взаимодействия с базой.
Важный нюанс: при реализации адаптера критически важно прокидывать AbortSignal. Без этого механизм Zombie Query Prevention, о котором мы говорили выше, просто не будет работать — запросы будут «повисать» в базе до полного выполнения, съедая ресурсы.
Вот пример интеграции с Prisma, где мы явно связываем сигнал отмены из Node.js с низкоуровневым запросом к Postgres:
import { TrigramSearchEngine, SearchTier } from "pg-smart-search"; const engine = new TrigramSearchEngine( { // Адаптер: мост между SDK и вашей базой query: async (sql, params, { signal }) => { // Важно: Prisma 5.3+ поддерживает signal для отмены нативного запроса return prisma.$queryRawUnsafe(sql, ...params, { signal }); }, execute: (sql) => prisma.$executeRawUnsafe(sql), }, { tableName: "Articles", searchColumns: ["title", "content"], tier: SearchTier.STANDARD, // Выбор стратегии влияет на генерируемый SQL }, ); ``` ### 3. Вызов поиска ```typescript const results = await engine.search({ query: "производительность базы данных", language: "ru", limit: 10, }); console.log(results.data); // Массив совпадений с нормализованным рангом
CLI: Автоматизация настройки и Benchmarking 🛠️
Чтобы не превращать настройку индексов в гадание на документации, мы упаковали всю экспертизу в интерактивный CLI. Он разделяет процесс на два этапа: Setup и Verification.
Этап 1: Интерактивный Setup
Запускаем npx pg-smart-init. Инструмент работает как экспертная система:
Tier Advisor: На основе вашего о��ъема данных подбирает оптимальные типы индексов (GIN, RUM или HNSW для векторов).
SQL Generator: Выдает готовый код миграции прямо под ваши колонки. Вам не нужно помнить синтаксис
to_tsvectorилиgin_trgm_ops.DX Bonus: В конце CLI сохраняет всё в файлы
search-setup.sqlиsearch-config.js.
Этап 2: Benchmarking (Проверка гипотез)
Для тех, кто не верит на слово, есть встроенный замерщик (npm run bench). Он позволяет увидеть реальный p99 на вашем железе. Это критично: вы можете сравнить производительность со стандартными индексами и "турбо-режимом" до того, как код попадет в продакшен.
Вместо заключения: Postgres может больше
Главный урок, который мы извлекли при создании pg-smart-search: производительность — это не только быстрые индексы, но и умная оркестрация. Переход от последовательного ожидания к агрессивной параллельной конкуренции стратегий позволил нам сократить время отклика в разы, не выходя за рамки стандартной реляционной СУБД.
Postgres — это не просто хранилище, это мощнейший поисковый движок, скрытый за слоями абстракций. Если вам нужен качественный поиск здесь и сейчас, не спешите плодить инфраструктурных сущностей — попробуйте использовать потенциал вашей базы на 100%.
Буду рад обсудить в комментариях ваш опыт борьбы с опечатками и медленными запросами в Postgres!
