Полнотекстовый поиск PostgreSQL
Каталог уже умеет искать точный slug и свойства JSONB. Но пользователь обычно вводит слова из названия или описания, а не полный код объекта. Полнотекстовый поиск превращает документ и запрос в языковые представления, затем проверяет их соответствие. Он отличается от поиска подстроки: правила обработки слов и конфигурация становятся частью договора.
Используем PostgreSQL 17.11 и самостоятельный снимок catalog-db-advanced/lesson-25 из архива продолжения. В нём прежние шесть курсов, а searchable text составлен из title и description. SQL, построение документов и планы не запускались; ожидаемые совпадения описаны по фиксированным учебным текстам.
Документ для поиска
Тип tsvector представляет обработанные элементы текста и информацию, нужную поиску. Чтобы получить его, используется to_tsvector с явно выбранной конфигурацией russian. Мы не сохраняем пользовательский запрос в эту колонку: она описывает содержимое курса, а запрос имеет другое представление.
В reset колонка создаётся как вычисляемая хранимая:
search_document tsvector GENERATED ALWAYS AS (
to_tsvector('russian'::regconfig,
coalesce(title, '') || ' ' || coalesce(description, ''))
) STORED
Это фрагмент определения уже существующей в снимке колонки. Пустое описание не должно превращать весь исходный текст в SQL NULL, поэтому используется COALESCE. Пробел разделяет название и описание. Если просто склеить два слова без разделителя, поисковая подготовка получит другой вход и результат перестанет соответствовать видимому тексту карточки.
Конфигурация указана явно, а не зависит от неожиданного значения настройки сессии. Это важно также для выражения вычисляемой колонки. Хранение и индексация документов показаны в руководстве PostgreSQL. При изменении title или description вычисляемое поле следует определённому выражению, не требует ручного дублирования каждого изменения клиентом.
Выбранный русский анализ подходит учебным описаниям, но не гарантирует идеальный поиск по любым языкам и именам технологий. JavaScript, CSS и PostgreSQL являются кодовыми названиями, для которых нужно отдельно оценить ожидаемые запросы аудитории. Не следует обещать одинаковую морфологию всем словам только из-за одной настройки.
Пользовательская фраза
Для привычного свободного ввода применим websearch_to_tsquery. Он формирует поисковый запрос, а не SQL-код. Простой пример ищет слово «каталог»:
WITH query AS (
SELECT websearch_to_tsquery('russian', 'каталог') AS value
)
SELECT c.id, c.slug
FROM catalog.courses AS c CROSS JOIN query AS q
WHERE c.status = 'published' AND c.search_document @@ q.value
ORDER BY c.id;
Ожидаются HTML, JavaScript и PostgreSQL: их описания содержат «Макет каталога», «Поведение каталога» и «Модель каталога». Поисковое представление связывает словоформу с выбранным анализом. Мы не приводим выдуманный буквальный вывод внутреннего вектора: важен предметный набор совпавших карточек.
Оператор @@ проверяет соответствие документа запросу. Публичный статус остаётся обычным условием SQL, потому что совпадение текста само по себе не разрешает публикацию черновика. Тема, права и другие фильтры также должны быть включены отдельно согласно модели приложения.
Функции управления поиском описаны в официальном руководстве. websearch_to_tsquery помогает интерпретировать привычный формат ввода, но не оценивает намерение посетителя за приложение. Если продукт требует точную фразу, исключение или несколько альтернатив, соответствующие случаи должны быть частью понятного пользовательского договора.
Пустой запрос
После языковой обработки запрос может оказаться пустым, например когда не осталось значимых элементов. Это не повод убрать условие поиска и показать всю библиотеку без объяснения. Клиент может сообщить, что требуется более содержательное слово, или выбрать явно обозначенное поведение пустого ввода.
У SQL есть функция numnode, позволяющая посмотреть состав полученного запроса. В подготовленном снимке рядом с учебной фразой читается число узлов, но мы не приписываем любому произвольному пользовательскому вводу одинаковое значение. Различие между исходной строкой и обработанным запросом полезно сохранять при диагностике.
Также не обещайте поиск опечаток. Морфологическая подготовка и исправление неверно набранного слова — разные задачи. Если понадобится tolerant search или подсказки, их оценивают отдельно и не смешивают без модели ранжирования. Простой LIKE тоже не является автоматической заменой поискового договора, хотя может решать другой точный сценарий.
Порядок найденных карточек
Совпадение определяет набор, а ранжирование определяет порядок. Можно добавить ts_rank_cd над документом и запросом:
WITH query AS (
SELECT websearch_to_tsquery('russian', 'каталог') AS value
)
SELECT c.id, c.slug, ts_rank_cd(c.search_document, q.value) AS rank
FROM catalog.courses AS c CROSS JOIN query AS q
WHERE c.status = 'published' AND c.search_document @@ q.value
ORDER BY rank DESC, c.id;
Ожидаются те же три курса, однако численные оценки заранее не приводятся. Они зависят от поискового представления и выбранной функции. Второй ключ обеспечивает стабильный порядок при равных оценках. Оценка здесь является характеристикой поиска, а не качеством учебника и не прогнозом SEO-трафика.
Названия и описания могут получить разные веса при более подробной модели документа. Тогда нужно сформулировать, почему совпадение в названии важнее совпадения в описании, и посмотреть результаты на реальных вопросах аудитории. Механически увеличивать один коэффициент ради приятного первого результата недостаточно для разумной библиотеки.
Поиск по урокам и маршрут результата
Допустим, пользователь хочет найти не курс по описанию, а конкретную инструкцию внутри главы. В сегодняшнем документе course.search_document такого текста нет. Отсутствие совпадения не доказывает отсутствие инструкции на сайте: мы ещё не включили body урока в поисковую модель. Прежде чем улучшать словарь, нужно проверить, что нужный источник действительно участвует в подготовке документа.
Поиск отдельных глав требует вернуть идентификатор главы и адрес именно её страницы. Если вместо этого соединить найденные уроки с courses и вывести карточку на каждое совпадение, один курс появится несколько раз. Можно выбрать группировку результатов по курсу или самостоятельные результаты уроков, но пользователь должен понимать, куда ведёт каждый ответ.
Результат также полезно связать с ограничениями публикации. Опубликованная карточка в исходном договоре не обещает, что каждая потенциальная глава завершена. Поэтому включение body в будущий поиск потребует отдельного условия допустимости урока. Техническое совпадение слов не превращает отсутствующий или ещё редакционный материал в готовую страницу для посетителя.
Наконец, найденная фраза должна помогать объяснить совпадение, а не подменять заголовок случайным обрывком. Можно сохранять исходное название и безопасно показывать короткий контекст. Тогда изменение алгоритма ранжирования не ломает устойчивую ссылку и предметную идентичность материала. В нашем небольшом наборе сначала установлено, что ищется, и лишь после этого обсуждаются дополнительные уровни поиска.
Индекс и изменение анализа
В reset создан GIN по search_document. Это кандидат для соответствующих полнотекстовых операторов; рекомендации по типу индекса описаны в документации PostgreSQL. На шести курсах нельзя сделать вывод об измеренном ускорении, поэтому EXPLAIN в уроке остаётся будущим чтением плана без ANALYZE.
Поисковое поле не является архивом исходного Markdown. В нём только выбранные title и description курса. Если позже нужно искать по полным урокам, появится другой уровень документа и результата: отдельная глава либо агрегированный курс. Повторно используйте уже изученную проверку кратности, чтобы не умножить карточки соединением всех найденных текстов.
Изменение конфигурации анализа или словарей требует пересмотреть ранее подготовленные документы и индекс. Сам факт существования stored column не означает, что любая внешняя смена правила автоматически пересчитала все старые строки. Жизненный цикл поискового представления должен сопровождаться вместе с моделью и версией приложения.
Подсветка найденного фрагмента также не отменяет HTML-экранирование. Текст, возвращённый поисковой функцией для представления, нельзя без разбора вставлять как доверенный HTML, если исходное содержимое приходит от редактора или пользователя. В этой главе показываем значения и коды, а не создаём небезопасный браузерный preview.
При будущем ручном чтении сравните исходные описания трёх совпавших курсов с выбранной фразой. Затем отделите набор от ранга и фильтр статуса от обработки языка. Полученная модель поиска объясняет, какие тексты участвуют, по каким правилам сопоставляются и где заканчивается обещание простого PostgreSQL full-text сценария.