Полнотекстовый поиск в Drizzle ORM с ParadeDB — BM25 поиск
Технический разбор · 14 июля 2026

Полнотекстовый поиск в Drizzle ORM
с ParadeDB — BM25 без второй системы

BM25-поиск напрямую в PostgreSQL через Drizzle ORM. Никакого Elasticsearch, никакого ETL, никакой отдельной инфраструктуры. Семь практических примеров кода — от установки до продакшена.

Олег Максимов 14 июля 2026 16 мин чтения

Что вы узнаете

Почему BM25 важен (и ts_vector не справляется)

Встроенный полнотекстовый поиск PostgreSQL использует tsvector / tsquery с функцией ts_rank. Он работает для базового сопоставления ключевых слов, но его оценка учитывает только частоту термина (как часто слово встречается в документе) и длину документа. Он игнорирует inverse document frequency (IDF) — насколько редок термин во всём корпусе документов.

BM25 (Best Matching 25) учитывает все три фактора. Слово, которое встречается в каждом документе — например «товар» или «продукт» — вносит меньший вклад в оценку, чем редкое слово, появляющееся лишь в нескольких документах. Это даёт значительно более качественное ранжирование релевантности, поэтому BM25 является основой Elasticsearch, Lucene и большинства современных поисковых систем.

ParadeDB добавляет BM25 в PostgreSQL как нативный метод доступа к индексу (IAM) через расширение pg_search. Вместо запуска второй системы для поиска ваш BM25-индекс живёт внутри PostgreSQL, транзакционно согласован с вашими данными и доступен через вашу ORM — в данном случае Drizzle ORM.

Установка: Drizzle ORM + ParadeDB + PostgreSQL

1. Установка зависимостей

Вам нужен PostgreSQL с ParadeDB (либо штатный PostgreSQL с установленным расширением pg_search). Затем добавьте Node-пакеты:

npm install drizzle-orm @paradedb/drizzle-paradedb postgres
npm install -D drizzle-kit @types/node

2. Определение схемы

Начнём со стандартной схемы Drizzle для таблицы товаров — она будет нашей целью поиска на протяжении всего руководства:

import { pgTable, serial, text, integer, boolean, jsonb } from "drizzle-orm/pg-core";

export const mockItems = pgTable("mock_items", {
  id: serial("id").primaryKey(),
  description: text("description").notNull(),
  category: text("category").notNull(),
  rating: integer("rating"),
  inStock: boolean("in_stock").default(false),
  metadata: jsonb("metadata"),
});

3. Создание BM25-индекса

Вместо создания индекса через сырой SQL используйте API indexing из @paradedb/drizzle-paradedb:

import { indexing } from "@paradedb/drizzle-paradedb";
import { sql } from "drizzle-orm";
import { db } from "./db";
import { mockItems } from "./schema";

// Создание BM25-индекса по текстовым, числовым и JSON-колонкам
await indexing
  .bm25Index("search_idx")
  .on(
    mockItems.id,
    mockItems.description,
    mockItems.category,
    mockItems.rating,
    mockItems.inStock,
    mockItems.metadata,
  );

// Эквивалентный SQL:
// CREATE INDEX search_idx ON mock_items
// USING bm25 (id, description, category, rating, in_stock, metadata)
// WITH (key_field='id');

key_field должен быть UNIQUE-колонкой — обычно первичный ключ. Только один BM25-индекс может существовать на таблицу, поэтому индексируйте все колонки, по которым будете искать или фильтровать.

4. Настройка токенизатора

По умолчанию текстовые колонки токенизируются по стандарту Unicode Segmentation. Для поиска на русском или английском со стеммингом настройте токенизатор simple со стеммером:

import { indexing, tokenizer } from "@paradedb/drizzle-paradedb";

await indexing
  .bm25Index("search_idx")
  .on(
    mockItems.id,
    indexing.bm25Field(
      mockItems.description,
      tokenizer.simple({ stemmer: "english" }),
    ),
    mockItems.category,
    mockItems.rating,
    mockItems.inStock,
    mockItems.metadata,
  );

Стеммер сводит слова к корневой форме — «бегу», «бежит» и «бежал» все соответствуют токену «бег». Это значительно улучшает полноту поиска.

Базовый поиск по ключевым словам

ParadeDB предоставляет BM25-поиск через SQL-операторы. В Drizzle вы передаёте запрос как сырое SQL-выражение через db.execute или шаблонный тег sql.

Оператор ||| выполняет match disjunction — находит документы, соответствующие одному или нескольким терминам запроса:

import { sql } from "drizzle-orm";

const results = await db.execute(
  sql`
    SELECT id, description, category, rating,
           paradedb.score(id) as bm25_score
    FROM mock_items
    WHERE description ||| 'беговые кроссовки'
    ORDER BY paradedb.score(id) DESC
    LIMIT 20
  `
);

Функция paradedb.score(id) возвращает оценку релевантности BM25 для каждой строки. Документы с более высокими оценками отображаются первыми. Это тот же алгоритм, который Elasticsearch использует внутри — вы получаете поиск production-качества без запуска второй базы данных.

Для фразового поиска (слова в определённом порядке) используйте оператор @@@:

// Поиск фразы — «трейловый» должен быть перед «бег»
const results = await db.execute(
  sql`
    SELECT id, description, paradedb.score(id) as score
    FROM mock_items
    WHERE description @@@ 'трейловый бег'
    ORDER BY paradedb.score(id) DESC
    LIMIT 10
  `
);

Фасетный поиск: фильтрация по категории, наличию и рейтингу

Одно из преимуществ BM25 перед tsvector в том, что индекс хранит и нетекстовые колонки, обеспечивая быструю фильтрацию и агрегацию вместе с текстовым поиском:

// Полнотекстовый поиск + фасетные фильтры в одном запросе
const results = await db.execute(
  sql`
    SELECT
      id, description, category, rating, in_stock,
      paradedb.score(id) as score
    FROM mock_items
    WHERE
      description ||| 'водонепроницаемый трекинг'
      AND category = 'Спорт'
      AND in_stock = true
      AND rating >= 4
    ORDER BY paradedb.score(id) DESC
    LIMIT 20
  `
);

Поскольку все эти колонки находятся в одном BM25-индексе, фильтры выполняются эффективно — без отдельных обращений к базе данных. Вы также можете агрегировать внутри индекса:

// Количество совпадений по категориям
const facets = await db.execute(
  sql`
    SELECT category, COUNT(*) as count
    FROM mock_items
    WHERE description ||| 'походное снаряжение'
    GROUP BY category
    ORDER BY count DESC
  `
);

Автодополнение с префиксным поиском

Автодополнение требует префиксного сопоставления — поиска записей, где поле начинается с введённых пользователем символов. ParadeDB поддерживает это оператором ^:

// Префиксный поиск для автодополнения
const results = await db.execute(
  sql`
    SELECT id, description, category
    FROM mock_items
    WHERE description ^ 'пала'
    LIMIT 5
  `
);
// Найдёт: "Палатка", "Палаточный кемпинг", "Палаточная стойка"

Для production-эндпоинта автодополнения комбинируйте префиксный поиск с фильтрацией по категории:

// Автодополнение с фильтром по категории
async function autocomplete(query: string, category?: string) {
  const sql_query = category
    ? sql`
        SELECT id, description, category
        FROM mock_items
        WHERE description ^ ${query}
          AND category = ${category}
        LIMIT 8
      `
    : sql`
        SELECT id, description, category
        FROM mock_items
        WHERE description ^ ${query}
        LIMIT 8
      `;
  return await db.execute(sql_query);
}

const suggestions = await autocomplete("пала", "Спорт");

Гибридный поиск: BM25 + pgvector с RRF

Для современных поисковых приложений часто нужна как точность ключевых слов (точное совпадение терминов), так и семантическая близость (векторные эмбеддинги). ParadeDB поддерживает гибридный поиск через Reciprocal Rank Fusion (RRF), объединяющий BM25-оценки с pgvector-оценками схожести.

Сначала добавьте векторную колонку в таблицу и сгенерируйте эмбеддинги:

import { vector } from "drizzle-orm/pg-core";

// Расширяем схему векторной колонкой
export const mockItems = pgTable("mock_items", {
  id: serial("id").primaryKey(),
  description: text("description").notNull(),
  category: text("category").notNull(),
  rating: integer("rating"),
  inStock: boolean("in_stock").default(false),
  metadata: jsonb("metadata"),
  embedding: vector("embedding", { dimensions: 384 }),
});

Затем выполняйте гибридный запрос, ищущий одновременно по BM25 и векторной схожести:

// Гибридный поиск: BM25 + pgvector
const results = await db.execute(
  sql`
    SELECT
      id, description, category,
      paradedb.score(id) as bm25_score,
      embedding <=> ${queryEmbedding}::vector as vector_score
    FROM mock_items
    WHERE description ||| 'походное снаряжение'
       OR embedding IS NOT NULL
    ORDER BY
      paradedb.score(id) * 0.3 + (1 - (embedding <=> ${queryEmbedding}::vector)) * 0.7
    DESC
    LIMIT 20
  `
);

Весовые коэффициенты (0.3 для BM25, 0.7 для векторов) позволяют настраивать баланс между точностью ключевых слов и семантической релевантностью. Для интернет-магазинов обычно вес BM25 выше (0.6-0.7), так как пользователи ищут конкретные названия товаров. Для контентных платформ векторная составляющая получает больший вес.

RAG-паттерн: BM25 как механизм извлечения

Retrieval-Augmented Generation (RAG) обычно использует векторный поиск для нахождения релевантных документов, но BM25 не менее эффективен — особенно для фактических запросов, где важны точные совпадения ключевых слов (спецификации товаров, документация, юридические тексты).

// RAG-извлечение через BM25
async function retrieveContext(
  query: string,
  topK: number = 5
): Promise<string> {
  const results = await db.execute(
    sql`
      SELECT description, category,
             paradedb.score(id) as score
      FROM mock_items
      WHERE description ||| ${query}
      ORDER BY paradedb.score(id) DESC
      LIMIT ${topK}
    `
  );

  return results.rows
    .map((r: any) => `[${r.category}] ${r.description}`)
    .join("\n\n");
}

// Использование в LLM-запросе
const context = await retrieveContext("водонепроницаемая палатка четыре сезона");
const llmResponse = await callLLM(
  `Ответь на основе этого контекста:\n\n${context}\n\nВопрос: Какие четырёхсезонные палатки у вас есть?`
);

Преимущество перед чистым векторным поиском: BM25 находит точные совпадения названий товаров и артикулов, которые векторные эмбеддинги часто пропускают. Комбинированный BM25 + векторный гибридный поиск (как показано выше) даёт лучшее из двух миров для production RAG-систем.

Сравнение: ParadeDB vs альтернативы

Вот как ParadeDB + Drizzle выглядит на фоне основных альтернатив для добавления поиска в PostgreSQL-приложение:

Характеристика ParadeDB + Drizzle PostgreSQL tsvector Elasticsearch MeiliSearch
Алгоритм ранжирования BM25 ts_rank (без IDF) BM25 Свой
Инфраструктура Тот же Postgres Тот же Postgres Отдельный кластер Отдельный сервис
Транзакционная согласованность Мгновенная Мгновенная Задержка (ETL) Задержка (синхр.)
Фасетный поиск Встроенный Вручную Встроенный Встроенный
Гибрид (BM25 + Vector) Нативный (RRF) Нет Через плагин Нет
TypeScript ORM API Drizzle fluent API Только сырой SQL @elastic/elasticsearch meilisearch JS
Пользовательские токенизаторы Да (стемминг, ICU) Фикс. конфиги Обширные Ограниченные
Сложность установки Низкая Низкая Высокая Средняя

Суть: если ваши данные живут в PostgreSQL и объём поиска умещается в одну базу данных, ParadeDB устраняет самую болезненную часть добавления поиска — ETL-пайплайн и вторую инфраструктуру. Для масштабных поисковых кластеров google-масштаба Elasticsearch всё ещё выигрывает по пропускной способности и шардированию.

Производительность

BM25-индекс ParadeDB хранится на диске как LSM-дерево, где каждый сегмент объединяет инвертированный индекс (для текстового поиска) и колоночный индекс (для фасетных фильтров и агрегаций). Эта архитектура оптимизирована для высокочастотных записей (типично для OLTP-нагрузок) при сохранении быстрых чтений.

FAQ

Что такое ParadeDB и как он работает с Drizzle ORM?
ParadeDB — это расширение PostgreSQL (pg_search), добавляющее нативные BM25-индексы для полнотекстового поиска. Пакет @paradedb/drizzle-paradedb предоставляет TypeScript API для создания BM25-индексов и запросов через Drizzle ORM — без отдельной поисковой системы. Определяете схему в Drizzle, создаёте индекс через indexing.bm25Index(), ищете через db.execute().
Чем BM25 отличается от встроенного tsvector-поиска PostgreSQL?
Встроенная функция ts_rank учитывает только частоту термина и длину документа. BM25 также учитывает IDF — редкость термина во всём корпусе — что даёт значительно лучшее ранжирование. BM25 также поддерживает колоночные агрегации, фасетную фильтрацию, настраиваемые токенизаторы со стеммингом и параметры BM25 (k1, b). Для сравнения с векторным поиском в Node.js читайте руководство по Node.js 26.
Можно ли использовать BM25 и pgvector в одной базе данных?
Да. Гибридный поиск ParadeDB объединяет BM25-оценки с pgvector-оценками схожести через взвешенную сумму или Reciprocal Rank Fusion (RRF). Это позволяет искать одновременно по смыслу (вектора) и по тексту (BM25) в одном запросе — критически важно для RAG-пайплайнов и современного поиска товаров.
ParadeDB полностью заменяет Elasticsearch?
Для большинства приложений на PostgreSQL — да. BM25-индексы ParadeDB по релевантности сравнимы с Elasticsearch, работают за миллисекунды и устраняют ETL — поисковые данные всегда транзакционно согласованы. Для сверхвысоконагруженных кластеров (>50M документов с 10K+ запросов/с) выделенная поисковая система может быть оправдана. Для лёгких альтернатив смотрите AI-поиск для малого бизнеса.
Какие версии Drizzle ORM и Node.js нужны?
Требуются drizzle-orm 0.31+ и drizzle-kit 0.22+ (или drizzle-orm 1.0.0-beta.2+ для getColumns). Пакет @paradedb/drizzle-paradedb поддерживает Node.js 20+ и TypeScript 5.x. На стороне БД нужен ParadeDB-совместимый PostgreSQL. Общий обзор экосистемы Node.js — в руководстве по Node.js 26.
Как настроить автодополнение с ParadeDB и Drizzle?
ParadeDB поддерживает префиксный поиск оператором ^ (например, description ^ 'пала' находит «Палатка», «Палаточный кемпинг»). Комбинируйте с фильтрацией по категории для контекстных подсказок. Для production используйте debouncing (300мс) и ограничение 5-8 результатов.
Какие альтернативы ParadeDB существуют для поиска в PostgreSQL?
Основные альтернативы: встроенный tsvector/ts_rank PostgreSQL (бесплатно, но слабое ранжирование), SQL-реализации BM25 через вспомогательные таблицы (медленно, write amplification), внешние поисковые системы — Elasticsearch, MeiliSearch, Typesense (мощные, но требуют отдельной инфраструктуры и ETL) — и pgvector для семантического поиска. Для обзора AI-готовых веб-приложений смотрите руководство по AI-ready приложениям.

Нужен поиск в вашем приложении?

Добавление production-качественного поиска — это не просто установка пакета. Требуется продуманное моделирование данных, настройка индексов, оптимизация запросов и постоянное обслуживание. Если вы планируете поисковую функцию для своего проекта, свяжитесь со мной. Я предоставляю бесплатные предварительные консультации.

Я — full-stack разработчик с 20+ годами опыта создания приложений с интенсивной работой с данными на PostgreSQL, Drizzle ORM и современном TypeScript. Живу в Минске, работаю по всему миру. Обсудим ваш проект.

Контакты

Обсудим ваш проект

Расскажите о ваших требованиях к поиску — я порекомендую оптимальную архитектуру и сделаю предварительную оценку. Бесплатно.