Гибридный поиск в PostgreSQL: как объединить BM25 и векторы

freeCodeCamp.org 43,5 тыс. 1 ч 9 мин 21.10.2026
Главное

Разработчики часто сталкиваются с дилеммой при проектировании поисковых механизмов: использовать внешние специализированные системы вроде Elasticsearch или пытаться обойтись возможностями реляционной СУБД. Бо Карнс в практическом курсе на образовательной платформе freeCodeCamp.org демонстрирует, как построить полноценную гибридную поисковую систему по Википедии исключительно средствами PostgreSQL. Решение объединяет лексическое ранжирование BM25 через расширение pg_textsearch, семантический векторный поиск с помощью pgvector-scale и алгоритм Reciprocal Rank Fusion (RRF), сохраняя строгую транзакционную целостность данных в пределах единого контура базы.

🏗️ Архитектура единой базы против распределённого поиска 0:00

Типичный путь развития поискового функционала в проектах начинается с простых текстовых фильтров в SQL, однако рост требований пользователей к качеству выдачи вынуждает команды подключать внешние сервисы . При таком подходе инженерам приходится выстраивать конвейеры репликации (change data capture или фоновые задачи синхронизации), что неизбежно приводит к рассинхронизации . Задержки репликации, сбои очередей сообщений или длительная переиндексация вызывают эффект, когда пользователь обновляет статью, но в поисковой выдаче по-прежнему отображается устаревшая версия .

Размещение поисковых индексов непосредственно рядом с данными в PostgreSQL решает проблему принципиально:

В демонстрационном проекте «Поиск по Википедии» задействован реальный корпус Simple English Wikipedia, насчитывающий около 242 000 статей для лексического поиска и порядка 25 000 для ресурсоёмкого векторного анализа .

📐 Сопоставление против ранжирования и математика BM25 4:06

Поисковая операция внутри СУБД разделяется на два независимых этапа: сопоставление (matching), определяющее пригодность документа, и ранжирование (ranking), вычисляющее порядок выдачи . Если запрос пользователя состоит из нескольких слов (например, «извержение вулкана угроза авиации»), логика операторов AND или OR отбирает кандидатов, но ничего не говорит об их полезности .

Встроенная в PostgreSQL функция ts_rank оценивает совпадения внутри одного документа, но не учитывает частотность терминов по всему корпусу . Алгоритм BM25 (Best Matching 25) ликвидирует этот разрыв, опираясь на три взаимосвязанных принципа :

  1. Обратная частота документа (IDF): редкие слова несут больше полезной информации. Если из 1000 документов термин «база данных» встречается в 900, а «кластеризация» — лишь в 10, совпадение по второму слову обладает экспоненциально большим весом .
  2. Насыщение частоты термина (Term Frequency): частое упоминание слова подчеркивает центральную тему текста, но сотое вхождение не должно давать такой же прирост, как первое. В BM25 насыщение регулируется коэффициентом $k_1$, значение которого по умолчанию равно 1.2 .
  3. Нормализация длины документа: в длинных статьях чисто статистически выше вероятность встретить любое случайное слово. Параметр $b$ (по умолчанию 0.75) нивелирует преимущество длинных текстов относительно среднего размера документа в корпусе .

Применение оптимизации Block-Max WAND в расширении pg_textsearch позволяет СУБД динамически отсекать блоки инвертированного индекса, которые заведомо не способны превзойти текущий топ-10 результатов, избавляя движок от полного сканирования миллиардов записей .

🛠️ Настройка облачного окружения и расширений СУБД 11:39

Для практической реализации разворачивается экземпляр PostgreSQL. В видеокурс включена работа с Tiger Data Cloud — управляемой платформой, где расширения pg_textsearch, pgvector и pgvector-scale предустановлены . Впрочем, как отмечает Бо Карнс, стек является открытым и может быть развернут локально в Docker .

Перед запуском кода проверяется версия расширений через системный каталог:

SELECT * FROM pg_available_extensions WHERE name IN ('pg_textsearch', 'vector', 'vectorscale');

Для корректной работы примеров требуется pg_textsearch версии не ниже 1.5.1 . Активация компонентов выполняется с каскадным разрешением зависимостей :

CREATE EXTENSION IF NOT EXISTS "vector" CASCADE;
CREATE EXTENSION IF NOT EXISTS "vectorscale" CASCADE;
CREATE EXTENSION IF NOT EXISTS "pg_textsearch" CASCADE;

Скрипты проекта написаны на Python и взаимодействуют с базой данных через драйвер psycopg2 . Все параметры окружения (строка подключения DATABASE_URL, модель энкодера и аппаратный бэкенд) изолируются в файле .env .

📦 Загрузка корпуса Википедии и структура схемы 15:43

В качестве данных используется готовый срез корпуса Simple English Wikipedia с Hugging Face от 1 ноября 2023 года (20231101.simple), упакованный в сжатый Parquet-файл размером около 157 МБ . Исходный срез содержит ровно 241 787 статей .

Таблица articles изначально создается со следующей структурой :

CREATE TABLE IF NOT EXISTS articles (
    id BIGINT PRIMARY KEY,
    url TEXT NOT NULL,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    word_count INTEGER
);

Загрузчик load.py скачивает файл, проверяет контрольную сумму, читает записи пакетами и загружает их через команду PostgreSQL COPY во временную буферную таблицу . Далее данные переносятся в целевую таблицу с директивой разрешения конфликтов ON CONFLICT (id) DO NOTHING . Это предотвращает порчу данных при перезапуске импорта. Поле word_count рассчитывается по пробелам для грубой предварительной фильтрации текстов .

После импорта обязателен вызов ANALYZE, чтобы оптимизатор запросов получил корректную статистику распределения данных .

🔍 Базовый уровень: встроенный поиск и разбор планов запросов 20:30

Для сравнения эффективности сначала тестируется стандартный механизм PostgreSQL: построение лексического индекса GIN на базе генерации векторов лексем через to_tsvector .

CREATE INDEX idx_articles_body_gin ON articles USING gin(to_tsvector('english', body));

При тестировании запроса «volcanic eruption aviation hazard» с использованием plainto_tsquery стандартный поиск с условием совпадения всех слов возвращает пустой результат . Лексический анализатор требует обязательного присутствия всех пяти лексем (&), чего в реальных статьях может не быть . При замене условия на логическое «ИЛИ» (|) выдача появляется, но качество ранжирования через ts_rank оказывается посредственным, так как отсутствует глобальная статистика корпуса .

Анализ плана выполнения через EXPLAIN (ANALYZE, BUFFERS) наглядно вскрывает механизм работы СУБД :

⚡ Переход на BM25 с pg_textsearch 27:40

Расширение pg_textsearch предоставляет специализированный метод доступа bm25 для таблиц. Индекс создается на уровне сессии с выделением достаточного бюджета оперативной памяти под служебные операции (maintenance_work_mem = '256MB') :

CREATE INDEX idx_articles_body_bm25 ON articles 
USING bm25(body) WITH (text_config = 'english');

Для поиска используется кастомный бинарный оператор <@> между текстовым полем и специальной структурой запроса .

Особенности работы с оценками BM25 в pg_textsearch:

Сравнение планов запросов демонстрирует качественную разницу: вместо связки Bitmap Index Scan + явная сортировка, индекс bm25 возвращает строки уже в отсортированном по релевантности порядке напрямую в узел Limit . Запрос по фразе из пяти слов возвращает 10 точных документов примерно за 9 миллисекунд без предварительного отсечения жестким фильтром .

Для боевого применения индекс модифицируют, объединяя заголовки и текст статей в генерируемый столбец, а также подключают функцию ts_headline . Чтобы избежать бессмысленной генерации сниппетов для миллионов документов, подсветка синтаксиса накладывается исключительно на финальные 10 строк через подзапросы .

🧠 Семантический поиск: эмбеддинги и pgvector-scale 37:32

Полнотекстовый поиск бессилен в случаях, когда пользователь формулирует концепцию другими словами — например, «животные, производящие собственный свет» . Лексический индекс не свяжет эту фразу со статьей о «биолюминесценции», если прямой термин отсутствует в тексте .

Семантический поиск преобразует текст в числовой вектор фиксированной размерности (эмбеддинг). Для проекта выбрана компактная локальная модель, генерирующая векторы размерности 384 . В таблицу добавляется столбец типа vector(384):

ALTER TABLE articles ADD COLUMN embedding vector(384);

Векторизация охватывает срез из 25 000 самых длинных статей корпуса (на вход модели подаются заголовок и первые 1500 символов текста) . Загрузчик embed.py сбрасывает векторы в базу пачками .

Для быстрого приближенного поиска ближайших соседей (ANN) строится индекс DiskANN через расширение pgvector-scale :

CREATE INDEX idx_articles_embeddings ON articles 
USING diskann(embedding);

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

🔀 Гибридный поиск и слияние рангов через RRF 43:28

Прямое арифметическое сложение оценки BM25 и векторного расстояния невозможно из-за принципиальной несовместимости шкал . Для объединения результатов применяется алгоритм Reciprocal Rank Fusion (RRF) .

Балл RRF для каждого документа вычисляется по формуле:

$$RRF_Score = \sum \frac{w_i}{k + rank_i}$$

Где $rank_i$ — позиция документа в конкретном списке выдачи (1, 2, 3…), константа сглаживания $k$ установлена равной 60, а $w_i$ — весовой коэффициент поискового метода .

Документ, попавший в оба списка (например, 2-е место по BM25 и 3-е место по векторам), суммирует баллы и гарантированно обходит документы, найденные только одним методом .

В SQL гибридный поиск оформляется через обобщенные табличные выражения (CTE) :

WITH keyword_candidates AS (
    SELECT id, row_number() OVER () AS rank
    FROM articles
    WHERE search_text <@ 'apollo 13'::bm25query
    ORDER BY search_text <@ 'apollo 13'::bm25query ASC
    LIMIT 20
),
vector_candidates AS (
    SELECT id, row_number() OVER () AS rank
    FROM articles
    ORDER BY embedding <=> '[...]'::vector
    LIMIT 20
)
SELECT 
    coalesce(k.id, v.id) AS id,
    coalesce(0.5 / (60 + k.rank), 0.0) + 
    coalesce(0.5 / (60 + v.rank), 0.0) AS rrf_score
FROM keyword_candidates k
FULL OUTER JOIN vector_candidates v ON k.id = v.id
ORDER BY rrf_score DESC
LIMIT 10;

Анализ плана показывает, что база выбирает по 20 кандидатов от каждого индекса (суммарно 40 строк), дедуплицирует их через хеш-агрегацию (HashAggregate) до 32 уникальных записей, пересчитывает веса и отдает топ-10 . Вся операция внутри СУБД занимает около 71 миллисекунды .

🌐 Интеграция с FastAPI и транзакционная запись 52:13

Поисковая подсистема выставляется наружу через веб-сервис на базе FastAPI . Чтобы не создавать подключение к СУБД на каждый входящий HTTP-запрос, организуется пул соединений (connection pool) на 4 постоянных подключения с обязательным тайм-аутом исполнения запросов во избежание зависания сессий . Модель для генерации эмбеддингов кэшируется в оперативной памяти приложения .

В API реализован гибкий параметр балансировки весов semantic_weight в диапазоне от 0.0 (чистый BM25) до 1.0 (чистый векторный поиск) . На практике значение 0.7 (70% веса семантике и 30% ключевым словам) часто дает наиболее устойчивые результаты при смешанных пользовательских запросах .

При добавлении новых статей сервис обеспечивает строгую согласованность:

  1. Бэкенд генерирует векторное представление для нового текста .
  2. Открывается транзакция в PostgreSQL .
  3. Текст, метаданные и вектор записываются одной командой INSERT .
  4. Индексы BM25 и DiskANN обновляются автоматически .

Если на этапе генерации вектора произошел сбой, транзакция не открывается, и в базе не возникает «висячих» записей без эмбеддингов .

📊 Масштабирование и выводы 57:24

В рамках нагрузочного тестирования компания Tiger Data опубликовала замеры производительности на корпусе из 138 миллионов текстовых фрагментов, сравнив pg_textsearch 1.0 и расширение ParadeDB 0.21.6 . По данным разработчиков, pg_textsearch показал более высокую скорость обработки коротких запросов и выдержал больший уровень параллелизма при прогретом кэше, в то время как ParadeDB продемонстрировал примерно двукратное преимущество в скорости первичного построения индекса .

Бо Карнс подчеркивает, что архитектура с единой базой PostgreSQL устраняет сложность инфраструктуры для огромного пласта прикладных задач, сохраняя простоту SQL-запросов и надежность транзакций . Дальнейшее улучшение качества поиска может включать расширение векторного покрытия на весь массив текстов, разбиение лонгридов на независимые смысловые чанки и добавление легковесной модели переранжирования (re-ranker) на финальном этапе отбора кандидатов .

💬 Цитаты

«Размещение индексов рядом с данными дает нам единую транзакционную границу и позволяет объединять поиск с привычными реляционными запросами.»

«BM25 возвращает отрицательные значения: чем сильнее число уходит в минус, тем выше релевантность совпадения.»

👥 Спикер
🔗 Упомянутые сайты и проекты
📖 Термины
BM25
Вероятностный алгоритм ранжирования поисковой выдачи, учитывающий частоту слова в документе и редкость во всей коллекции.
Reciprocal Rank Fusion (RRF)
Метод объединения нескольких ранжированных списков в один на основе порядковых позиций элементов без нормализации баллов.
Block-Max WAND
Алгоритм оптимизации инвертированного индекса, позволяющий пропускать нерелевантные блоки документов без вычисления их полного скора.
Эмбеддинг
Векторное представление текста в многомерном пространстве, отражающее его смысловое значение.
📊 Цифры
⚖️ Другая сторона
Технологии и IT PostgreSQL pg_textsearch pgvector-scale FastAPI Бо Карнс