Разработчики часто сталкиваются с дилеммой при проектировании поисковых механизмов: использовать внешние специализированные системы вроде 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) ликвидирует этот разрыв, опираясь на три взаимосвязанных принципа :
- Обратная частота документа (IDF): редкие слова несут больше полезной информации. Если из 1000 документов термин «база данных» встречается в 900, а «кластеризация» — лишь в 10, совпадение по второму слову обладает экспоненциально большим весом .
- Насыщение частоты термина (Term Frequency): частое упоминание слова подчеркивает центральную тему текста, но сотое вхождение не должно давать такой же прирост, как первое. В BM25 насыщение регулируется коэффициентом $k_1$, значение которого по умолчанию равно 1.2 .
- Нормализация длины документа: в длинных статьях чисто статистически выше вероятность встретить любое случайное слово. Параметр $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) наглядно вскрывает механизм работы СУБД :
- Условие
LIMIT 10ограничивает только размер финального ответа клиенту, но не объем работы . - При классическом подходе база сначала находит по индексу сотни подходящих строк, затем читает их со страниц диска или буферного пула, вычисляет для каждой
ts_rankи выполняет явную сортировкуSortв памяти . - При пустой выборке запрос завершается за доли миллисекунды (0.3 мс), однако при наличии тысяч совпадений накладные расходы на сортировку резко возрастают .
⚡ Переход на 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:
- СУБД возвращает отрицательные значения: чем меньше (отрицательнее) число, тем сильнее соответствие документа запросу . Оценка
-20свидетельствует о более высокой релевантности, чем-5. - В блоке
ORDER BYсортировка обязана выполняться по возрастанию (ASC), что отличается от классического убывающего порядкаts_rank. - Оценка BM25 не является процентной вероятностью; это относительный скор, рассчитанный внутри контекста данного индекса и набора терминов .
Сравнение планов запросов демонстрирует качественную разницу: вместо связки 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% ключевым словам) часто дает наиболее устойчивые результаты при смешанных пользовательских запросах .
При добавлении новых статей сервис обеспечивает строгую согласованность:
- Бэкенд генерирует векторное представление для нового текста .
- Открывается транзакция в PostgreSQL .
- Текст, метаданные и вектор записываются одной командой
INSERT. - Индексы BM25 и DiskANN обновляются автоматически .
Если на этапе генерации вектора произошел сбой, транзакция не открывается, и в базе не возникает «висячих» записей без эмбеддингов .
📊 Масштабирование и выводы 57:24
В рамках нагрузочного тестирования компания Tiger Data опубликовала замеры производительности на корпусе из 138 миллионов текстовых фрагментов, сравнив pg_textsearch 1.0 и расширение ParadeDB 0.21.6 . По данным разработчиков, pg_textsearch показал более высокую скорость обработки коротких запросов и выдержал больший уровень параллелизма при прогретом кэше, в то время как ParadeDB продемонстрировал примерно двукратное преимущество в скорости первичного построения индекса .
Бо Карнс подчеркивает, что архитектура с единой базой PostgreSQL устраняет сложность инфраструктуры для огромного пласта прикладных задач, сохраняя простоту SQL-запросов и надежность транзакций . Дальнейшее улучшение качества поиска может включать расширение векторного покрытия на весь массив текстов, разбиение лонгридов на независимые смысловые чанки и добавление легковесной модели переранжирования (re-ranker) на финальном этапе отбора кандидатов .