Многие проекты рано или поздно утыкаются в «потолок» стандартного поиска. Обычный LIKE перестает справляться, когда данных становится больше 100 тысяч строк, а пользователи начинают ошибаться в каждом втором слове. Типовым решением в такой ситуации считается внедрение Elasticsearch или Meilisearch.

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

Схема работы движка
Схема работы движка

Архитектура: Конкуренция вместо очереди

Обычно логика «умного» поиска строится как цепочка: Сначала ищем точное совпадение -> Если нет, запускаем полнотекстовый поиск (FTS) -> Если нет, пробуем триграммы. В худшем случае (когда срабатывает последняя стадия) пользователь ждет сумму времен выполнения всех запросов.

Мы пошли другим путем и реализовали Hybrid Parallel Fast-Track. Движок запускает стратегии одновременно, но с разным приоритетом.

Fallback Chain изнутри

Движок не просто запускает всё подряд. У нас есть каскадная логика возврата:

  1.  Cache Hit: Сначала проверяем Redis/Memory. Если есть — отдаем за 1-2мс.

  2. Parallel Race: Запускаем FTSStrategy (лингвистика) и StandardSearch (триграммы + ILIKE).

  3. Fast-Track Winner: Если FTSStrategy вернула хоть один результат — это "победитель". Лингвистическая точность в 99% случаев выше триграммной на старте. Мы возвращаем её результат немедленно.

  4. 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.

  1. Node.js: Вы вызываете controller.abort().

  2. SDK: Сигнал прокидывается в DatabaseAdapter.

  3. Database Driver (pg/prisma): Библиотека ловит событие abort.

  4. Protocol: Драйвер открывает новое (краткосрочное) соединение с базой данных и отправляет команду CANCEL для конкретного backend_pid.

  5. 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. Инструмент работает как экспертная система:

  1. Tier Advisor: На основе вашего о��ъема данных подбирает оптимальные типы индексов (GIN, RUM или HNSW для векторов).

  2. SQL Generator: Выдает готовый код миграции прямо под ваши колонки. Вам не нужно помнить синтаксис to_tsvector или gin_trgm_ops.

  3. DX Bonus: В конце CLI сохраняет всё в файлы search-setup.sql и search-config.js.

Этап 2: Benchmarking (Проверка гипотез)

Для тех, кто не верит на слово, есть встроенный замерщик (npm run bench). Он позволяет увидеть реальный p99 на вашем железе. Это критично: вы можете сравнить производительность со стандартными индексами и "турбо-режимом" до того, как код попадет в продакшен.

Вместо заключения: Postgres может больше

Главный урок, который мы извлекли при создании pg-smart-search: производительность — это не только быстрые индексы, но и умная оркестрация. Переход от последовательного ожидания к агрессивной параллельной конкуренции стратегий позволил нам сократить время отклика в разы, не выходя за рамки стандартной реляционной СУБД.

Postgres — это не просто хранилище, это мощнейший поисковый движок, скрытый за слоями абстракций. Если вам нужен качественный поиск здесь и сейчас, не спешите плодить инфраструктурных сущностей — попробуйте использовать потенциал вашей базы на 100%.

Буду рад обсудить в комментариях ваш опыт борьбы с опечатками и медленными запросами в Postgres!

Репозиторий проекта