BM25-поиск напрямую в PostgreSQL через Drizzle ORM. Никакого Elasticsearch, никакого ETL, никакой отдельной инфраструктуры. Семь практических примеров кода — от установки до продакшена.
ts_rank PostgreSQL по качеству ранжирования@paradedb/drizzle-paradedb с Drizzle ORM и PostgreSQL
Встроенный полнотекстовый поиск 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.
Вам нужен PostgreSQL с ParadeDB (либо штатный PostgreSQL с установленным расширением
pg_search). Затем добавьте Node-пакеты:
npm install drizzle-orm @paradedb/drizzle-paradedb postgres
npm install -D drizzle-kit @types/node
Начнём со стандартной схемы 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"),
});
Вместо создания индекса через сырой 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-индекс может существовать на таблицу, поэтому индексируйте все колонки,
по которым будете искать или фильтровать.
По умолчанию текстовые колонки токенизируются по стандарту 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("пала", "Спорт");
Для современных поисковых приложений часто нужна как точность ключевых слов (точное совпадение терминов), так и семантическая близость (векторные эмбеддинги). 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), так как пользователи ищут конкретные названия товаров. Для контентных платформ векторная составляющая получает больший вес.
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 + 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-нагрузок) при сохранении быстрых чтений.
pg_stat_progress_create_index при начальном построении.
@paradedb/drizzle-paradedb предоставляет TypeScript API для создания BM25-индексов и запросов через Drizzle ORM — без отдельной поисковой системы. Определяете схему в Drizzle, создаёте индекс через indexing.bm25Index(), ищете через db.execute().Добавление production-качественного поиска — это не просто установка пакета. Требуется продуманное моделирование данных, настройка индексов, оптимизация запросов и постоянное обслуживание. Если вы планируете поисковую функцию для своего проекта, свяжитесь со мной. Я предоставляю бесплатные предварительные консультации.
Я — full-stack разработчик с 20+ годами опыта создания приложений с интенсивной работой с данными на PostgreSQL, Drizzle ORM и современном TypeScript. Живу в Минске, работаю по всему миру. Обсудим ваш проект.
Расскажите о ваших требованиях к поиску — я порекомендую оптимальную архитектуру и сделаю предварительную оценку. Бесплатно.