04 авг. 2025 г.·6 мин

Поиск «везде" в PostgreSQL: полнотекст, триграммы и частичные индексы

Узнайте, как спроектировать «поиск везде" на PostgreSQL для внутренних экранов: когда выбирать полнотекст, триграммы и частичные индексы для быстрых результатов.

Поиск «везде" в PostgreSQL: полнотекст, триграммы и частичные индексы

Что на самом деле значит «поиск везде» для внутренних инструментов

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

Поэтому медленный поиск ощущается хуже, чем медленная загрузка страницы. Страница загружается один раз. Поиск запускается много раз подряд, часто когда человек на звонке или занимается триажем. Если результаты занимают 2–3 секунды, пользователь меняет запрос, стирает символы, пробует другой термин, и в итоге система получает большую нагрузку и больше раздражения.

Из одного поля ввода пользователи ждут набора поведений: частичные совпадения ("alex" находит "Alexander"), терпимость к небольшим опечаткам ("microsfot" всё ещё находит "Microsoft"), разумная сортировка «лучших результатов» (точные ID или email поднимаются вверх), небольшая склонность к недавним данным и фильтры, применяемые по умолчанию (открытые тикеты, активные клиенты).

Сложность в том, что одно поле часто скрывает несколько намерений. Агент может вставить номер тикета, ввести фрагмент имени, искать по email или ввести телефон. Каждое намерение требует своей стратегии, своих индексов и иногда своего правила ранжирования.

Поэтому не начинайте с индексов. Сначала перечислите те немногие намерения поиска, которые у ваших пользователей действительно есть, и разделите поля идентификации (ID, email) от нечётких полей (имена, темы) и длинного текста (заметки).

Начните с определения данных и поведения поиска

Прежде чем выбирать индекс, выпишите, что люди реально вводят. «Поиск везде» в PostgreSQL звучит как одна фича, но на практике это смесь очень разных поисков.

Внутренние инструменты смешивают «жёсткие" идентификаторы (order ID, ticket number, invoice code) с «мягким" текстом (имя клиента, email, заметки, теги). Эти группы ведут себя по-разному в PostgreSQL, поэтому одинаково относиться к ним — быстрый путь к медленным запросам.

Дальше разделите поведения:

  • Точное совпадение: кто-то ищет TCK-104883 и ждёт один точный результат.
  • Нечёткий поиск: при вводе john smth пользователь хочет терпимый поиск по именам (и, возможно, по email) и просматривает короткий список.
  • Поиск с фильтрами: кто-то выбирает «Status = Open» и «Assigned to = Me» — в основном это фильтрация; поле ввода вторично.

Решите заранее, нужно ли ранжирование (лучшие совпадения первым) или достаточно простого фильтра. Ранжирование важно для заметок и длинных описаний. Для ID и email ранжирование часто выглядит случайным и добавляет накладные расходы.

Короткий чек-лист обычно помогает:

  • Какие поля ищут ежедневно?
  • Какие вводы точные (ID, коды), нечёткие (имена) или длинные (заметки)?
  • Какие фильтры применяются почти в каждом поиске?
  • Нужна ли сортировка по «лучшей совпадающей» релевантности или любое совпадение достаточно?
  • Насколько быстро будет расти таблица: тысячи, сотни тысяч или миллионы?

Если вы заранее запишете эти решения, выбор индексов перестанет быть угадыванием.

Базовый уровень: точные совпадения и почему ILIKE часто вреден

Закрепите простые выигрыши в первую очередь. Для многих внутренних экранов обычный B-tree индекс даёт мгновенные результаты для точных совпадений: ID, номера заказов, email и внешние ссылки.

Если пользователь вставляет точное значение, убедитесь, что запрос действительно точный. WHERE id = ... или WHERE email = ... могут быть очень быстрыми с обычным индексом. Уникальный индекс на email часто окупается дважды: скорость и качество данных.

Проблемы начинаются, когда «поиск везде" тихо превращается в ILIKE. Запрос вроде name ILIKE '%ann%' имеет ведущий подстановочный символ, поэтому PostgreSQL не может использовать обычный B-tree индекс. Он проверяет много строк, и с ростом таблицы работает заметно медленнее.

Префиксный поиск может работать, но только когда шаблон заякорен в начале: name ILIKE 'ann%'. Даже тогда важны детали (колляция, учёт регистра и индекс того же выражения, что и в запросе). Если UI должен быть регистронезависимым, часто делают запрос lower(name) и создают соответствующий индекс на lower(name).

Полезно договориться о том, что означает «быстро":

  • Около 200 мс или меньше для работы базы на тёплом кэше
  • Менее 1 секунды от запроса до отображения, включая сеть и рендер
  • Никакого видимого состояния загрузки для частых поисков

С такими целями легче решить, хватит ли префиксного и точного поиска или пора использовать полнотекстовый поиск или триграммы.

Когда полнотекстовый поиск — правильный инструмент

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

Главный выигрыш — ранжирование. Вместо длинного списка, где лучший результат теряется, полнотекст может сортировать по релевантности. В внутренних инструментах это важно: человеку нужен ответ за секунды, а не после просмотра 50 строк.

На высоком уровне полнотекст состоит из трёх частей:

  • tsvector (поисковый текст, сохранённый или генерируемый)
  • tsquery (то, что ввёл пользователь, преобразованное в запрос)
  • Конфигурация языка (как нормализуются слова)

Конфигурация языка — там, где поведение становится заметным. PostgreSQL удаляет стоп-слова (например, «the» или «and") и применяет стемминг, поэтому «pay», «paid» и «payment" могут совпадать. Это отлично для заметок и сообщений, но может удивить, если пользователь ищет короткое распространённое слово и ничего не находит.

Синонимы — ещё одна точка принятия решения. Они полезны, когда в компании для одного и того же понятия используют разные слова (например, «refund" vs «chargeback"), но требуют поддержки со временем. Держите список синонимов коротким и основанным на реальных вводах поддержки или ops.

Практический пример: поиск «can’t login after reset» должен найти тикеты с текстом «cannot log in after password reset», даже если формулировки отличаются. Такое «найти по смыслу» — то, для чего создан полнотекстовый поиск, и в большинстве случаев это лучше, чем пытаться заставить ILIKE работать как поисковый движок.

Когда выигрывают триграммы

Расширьте поиск с помощью ИИ
Добавляйте интеграции с ИИ, когда нужно умнее искать или автоматически сортировать тикеты.
Построить с ИИ

Триграммные индексы хороши, когда пользователи вводят фрагменты, делают опечатки или помнят «что-то похожее». Они отлично работают для коротких полей, где полнотекст слишком строг: имена людей, названия компаний, темы тикетов, SKU, коды заказов и артикулы.

Триграмма — это 3-символьный фрагмент текста. PostgreSQL сравнивает строки по числу общих триграмм. Поэтому он может сопоставить "Jon Smth" с "John Smith" или "ACM" с "ACME" и находить совпадения, когда запрос — середина слова.

Часто это самый быстрый путь к «терпимому" полю поиска, когда задача — «найти нужную строку», а не «найти документы по теме».

Где триграмма лучше полнотекста

Полнотекст отлично работает с длинными текстами и ранжированием по смыслу, но не справляется естественно с частичными строками и мелкими опечатками в коротких полях. Триграмм создан именно для такой нечёткости.

Сохраняйте разумную нагрузку на запись

Триграммные индексы больше по размеру и увеличивают нагрузку на записи, поэтому будьте точны. Индексируйте только те колонки, которые реально используют люди:

  • name, email, company, username
  • короткие идентификаторы (SKU, code, reference)
  • краткое поле заголовка (не большие поля заметок/комментариев)

Если вы можете назвать ровно те поля, которые команда вводит в строку поиска, триграммные индексы обычно остаются компактными и быстрыми.

Частичные индексы для фильтров, которые люди действительно используют

Сделайте поиск предсказуемым
Используйте drag-and-drop бизнес-логику, чтобы поведение поиска оставалось последовательным на разных экранах.
Построить логику

В поле «поиск везде" обычно есть скрытые фильтры по умолчанию. Люди ищут внутри рабочей области, по активным элементам и исключают удалённые записи. Если эти фильтры есть почти в каждом запросе, ускорьте обычный путь индексом, который покрывает только релевантные строки.

Частичный индекс — это обычный индекс с WHERE-условием. PostgreSQL хранит в нём только записи, которые вам важны. Это значит меньше страниц для чтения и лучшие попадания в кэш.

Типичные цели для частичных индексов: активные строки (status = 'active'), мягкие удаления (deleted_at IS NULL), ограничение по арендаторам и окна по «последним" (например, последние 90 дней).

Ключевое правило — совпадение с UI. Если экран всегда скрывает удалённые строки, запросы тоже всегда должны включать deleted_at IS NULL, а частичный индекс должен иметь ту же самую проверку. Малейшее несоответствие, например is_deleted = false в одном месте и deleted_at IS NULL в другом, может помешать использованию индекса планировщиком.

Частичные индексы работают вместе с полнотекстом и триграммами. Например, индекс полнотекста только для не удалённых строк помогает держать размер индекса под контролем.

Трейд-офф: частичные индексы хуже помогают при редких запросах. Если кто-то время от времени ищет по удалённым записям или по всем рабочим областям, PostgreSQL может выбрать медленный план. Решайте это через отдельный путь для админов или добавляйте второй индекс только если редкий запрос станет частым.

Смешивание подходов без превращения поиска в мистерию

Большинство команд в итоге комбинируют техники, потому что одно поле должно обслуживать разные намерения. Цель — сделать порядок операций понятным, чтобы результаты казались предсказуемыми.

Простая лестница приоритетов помогает, реализована она либо отдельными запросами, либо одним запросом с явной логикой CASE.

Предсказуемая лестница приоритетов

Начинайте строго, затем ослабляйте правила, только если нужно:

  • Сначала точное совпадение (ID, email, номер тикета) с B-tree индексами
  • Затем префиксный поиск там, где это уместно
  • Потом триграмма для опечаток и фрагментов в именах и заголовках
  • И в конце полнотекст для длинных заметок, описаний и свободного текста

Если придерживаться одной лестницы, пользователи поймут, «что значит" поле поиска. Они перестанут думать, что система сломана, когда «12345" мгновенно находит тикет, а «refund policy" идёт по длинному тексту.

Сначала фильтровать, потом — нечётко

Нечёткий поиск становится дорогим, когда ему приходится смотреть на всю таблицу. Сузьте кандидатов фильтрами, которые люди действительно выбирают (status, assigned team, date range, account), а затем выполняйте триграмму или полнотекст для оставшегося набора. Даже быстрый триграммный индекс может казаться медленным, если нужно оценивать миллионы строк.

Стоит также написать одно абзацное правило для не технических коллег, например: «Мы сначала ищем точный номер тикета, затем имя клиента с допуском опечаток, затем — заметки». Это общее определение предотвратит споры о том, почему та или иная запись попала в результаты.

Пошагово: выберите подход и внедрите его безопасно

Прототип многозначного поиска
Прототипируйте единую строку поиска, которая направляет ID, email и имена в нужный путь запроса.
Создать прототип

Быстрый поиск — это набор небольших решений. Запишите их сначала, и работа с базой станет проще.

  1. Определите входы. Одно поле или поле плюс фильтры (status, owner, date range)?
  2. Выберите типы совпадений для полей. ID и коды — точные. Имена и email — префикс или нечёткий поиск. Длинные заметки — полнотекст.
  3. Добавьте нужные индексы и подтвердите, что они используются. Создайте индекс и проверьте реальный запрос с EXPLAIN (ANALYZE, BUFFERS).
  4. Добавьте ранжирование или сортировку, соответствующую намерению. Если пользователь вводит «invoice 1042", точные совпадения должны подняться. При опечатке имени должно побеждать сходство.
  5. Тестируйте реальными запросами: опечатки, очень короткие термины (например, "al"), длинный вставленный текст, пустой ввод и режим «только фильтры".

Для безопасного релиза меняйте по одной вещи и держите откат простым. Для новых индексов на больших таблицах используйте CREATE INDEX CONCURRENTLY, чтобы не блокировать записи. По возможности выкатывайте за флагом и сравнивайте задержки до и после.

Практическая схема для «поиск везде": сначала точное совпадение (быстро и точно), затем триграмма для «человеческих" полей с опечатками, и полнотекст для длинных текстов, где выгодно ранжирование.

Реалистичный пример: одно поле поиска в панели поддержки

Представьте панель поддержки, где команда имеет одно поле поиска, но ожидает, что оно найдёт клиентов, тикеты и даже заметки. Классическая проблема «одно поле — много смыслов".

Первый выигрыш — выявить намерение без лишних шагов. Если запрос похож на email или телефон, обрабатывайте как поиск клиента. Если он похож на номер тикета (например, "TKT-10482"), направляйте сразу в тикеты. Всё остальное падает в общий текстовый поиск по теме тикета и заметкам.

Для поиска клиентов триграммы обычно дают лучшее впечатление. Имена и компании грязные, люди вводят фрагменты. Триграммный индекс делает быстрым поиск вроде «jon smi" или «acm".

Для заметок тикетов используйте полнотекст. Заметки — настоящие предложения, и чаще нужны релевантные совпадения, а не просто «содержит подстроку". Ранжирование помогает, когда десятки тикетов упоминают одно и то же ключевое слово.

Фильтры важнее, чем многие команды думают. Если агенты живут в «открытых тикетах", добавьте частичный индекс, покрывающий только открытые строки. То же для «активных клиентов". Это держит индексы компактнее и делает путь по умолчанию быстрым.

Очень короткие запросы заслуживают правил, иначе база делает дорогую работу ради шума:

  • 1–2 символа: показывайте недавние открытые тикеты и недавно обновлённых клиентов
  • 3+ символов: запускайте триграмму для полей клиентов и полнотекст для текста тикетов
  • Нет явного намерения: показывайте смешанный список, но ограничивайте каждую группу (например, 10 клиентов и 10 тикетов)

Типичные ошибки, которые делают поиск медленным или запутанным

Избегайте технического долга поиска
Получайте production-ready исходный код по мере изменения требований к поиску.
Сгенерировать код

Большинство проблем «почему поиск медленный" — самоубийственные решения. Цель — не индексировать всё подряд, а индексировать то, что люди реально делают.

Одна ловушка — добавление множества индексов «на всякий случай". Чтение может ускориться, но каждая вставка и обновление теперь выполняет больше работы. В внутренних инструментах, где записи часто меняются (тикеты, заказы, пользователи), скорость записи важна.

Другая ошибка — использовать полнотекст там, где на самом деле нужен нечёткий поиск имён или email. Полнотекст хорош для документов и описаний, но не для «Jon" vs «John" или «gmail.con" vs «gmail.com". Для этого чаще подходят триграммы.

Фильтры тоже могут тайно ломать план. Если большинство поисков делается с фиксированным фильтром (например, status = 'open' или org_id = 42), лучший индекс может быть частичным, покрывающим это условие. Если вы это забудете, PostgreSQL может просканировать намного больше строк, чем вы ожидаете.

Несколько частых ошибок:

  • Добавление многих индексов без оценки стоимости записи
  • Ожидание от полнотекста поведения, похожего на нечёткий автокомплит
  • Игнорирование того, как часто используемые фильтры меняют выбор индекса
  • Тестирование на маленьких чистых данных вместо реальной частоты терминов
  • Сортировка по столбцу без индексной поддержки, что заставляет медленный внешний сорт

Пример: экран поддержки ищет тикеты по теме, имени клиента и номеру тикета, затем сортирует по latest_activity_at. Если latest_activity_at не индексирован для отфильтрованного набора (например, только открытые тикеты), эта сортировка может свести на нет выигрыш от индекса поиска.

Быстрые проверки перед релизом

Владейте своим стеком
Сохраняйте полный контроль с экспортом исходников для самостоятельного хостинга и глубокой кастомизации.
Экспортировать исходники

Перед тем как объявить «поиск везде" готовым, чётко зафиксируйте поведение, которое вы обещаете.

  • Ищут ли люди запись по точному идентификатору (номер тикета, email)?
  • Ожидают ли они нечёткого соответствия при опечатках?
  • Нужны ли ранжированные результаты для длинных заметок и описаний?

Если вы смешиваете режимы, решите, какой из них имеет приоритет при конфликте.

Затем выделите 2–3 поля, которые формируют большинство поисков. Если 80% запросов по email, имени и ID тикета, оптимизируйте их сначала, остальное — вторично.

Короткий предрелизный чек-лист:

  • Подтвердите основной режим совпадения для каждого поля (точный, нечёткий или ранжированный текст)
  • Перечислите фильтры, которые пользователи применяют ежедневно, и убедитесь, что индексы соответствуют этим сочетаниям
  • Решите, как обрабатывать очень короткие и пустые запросы (например, требовать 2–3 символа для нечёткого поиска; показывать «недавние" для пустого)
  • Сделайте порядок сортировки объяснимым: недавние, лучшее текстовое совпадение или простое комбинированное правило

И, наконец, тестируйте на реалистичном объёме данных и при реальных временных характеристиках. Запрос, который кажется мгновенным на 1,000 строк, может тормозить на 1,000,000.

Следующие шаги: превратите план в быстрый внутренний экран поиска

Строка поиска остаётся быстрой, когда команда договорилась о том, как она должна работать. Запишите правила простым языком: что значит «совпадение" (точное, префикс, с допуском опечаток), какие поля ищутся и как фильтры меняют набор результатов.

Держите небольшой набор реальных поисков как регрессионный тест. 10–20 запросов обычно достаточно: пара популярных имён, несколько частичных email, опечатка, фрагмент длинной заметки и случай пустого результата. Прогоняйте их до и после изменений, чтобы оптимизация не сломала релевантность.

Если вы создаёте внутренние инструменты в AppMaster (appmaster.io), полезно фиксировать правила поиска рядом с моделью данных и бизнес-логикой, чтобы поведение UI и выбор базы данных не расходились по мере изменения требований.

Вопросы и ответы

Что обычно означает “search everywhere” в внутреннем инструменте?

Рассматривайте это как «быстро найдите нужную запись», а не как серфинг по данным. Сначала выпишите реальные намерения пользователей (поиск по ID, поиск по имени/email с допуском опечаток, поиск по длинным заметкам) и фильтры по умолчанию, которые они почти всегда применяют. Эти решения подскажут, какие запросы запускать и какие индексы стоят того, чтобы их поддерживать.

Почему `ILIKE '%...%'` делает поиск медленным?

ILIKE '%term%' с ведущим шаблоном вынуждает PostgreSQL проверять много строк, потому что обычный B-tree индекс использовать нельзя. На маленьких таблицах это может казаться нормальным, но при росте данных производительность падает резко. Если нужен поиск по подстроке или устойчивость к опечаткам, планируйте триграммный индекс или полнотекстовый поиск, а не полагайтесь на ILIKE.

Какой самый быстрый способ обрабатывать точный поиск по ID или email?

Используйте точные сравнения, например WHERE id = $1 или WHERE email = $1, и поддерживайте их B-tree индексом (для email часто имеет смысл уникальный индекс). Точные запросы — самые дешёвые, и они делают результаты предсказуемыми. Если пользователь вставляет полный номер тикета или email, сначала направляйте запрос по этому пути.

Как сделать регистронезависимый префиксный поиск, не ломая индексы?

Предпочитайте префиксный шаблон вроде name ILIKE 'ann%' и создавайте индекс в том же виде, в котором вы выполняете запрос. Для надёжного регистронезависимого поиска часто делают lower(name) в запросе и создают индекс на lower(name), чтобы планировщик мог его использовать. Если вы не якорите шаблон в начале, префиксный поиск не поможет.

Когда стоит использовать триграммные индексы для строки поиска?

Применяйте триграммный индекс, когда пользователи вводят фрагменты, совершают мелкие опечатки или помнят только «что-то похожее», особенно для коротких полей: имён, заголовков, кодов, username. Триграммы хорошо находят совпадения внутри слова и близкие по написанию строки. Будьте выборочны: триграммные индексы больше по размеру и добавляют нагрузку на запись.

Когда полнотекстовый поиск PostgreSQL — лучший выбор?

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

Как частичные индексы помогают экранам “search everywhere"?

Добавляйте частичные индексы, когда большинство запросов всегда используют одни и те же фильтры, например deleted_at IS NULL, status = 'open' или ограничение по рабочей области. Такой индекс хранит только нужные строки, остаётся меньше и отдача от него выше. Важно: условие в запросе должно точно совпадать с условием частичного индекса, иначе планировщик может его проигнорировать.

Как сочетать точный, триграммный и полнотекстовый поиск, не запутывая пользователей?

Используйте согласованную лестницу приоритета, чтобы результаты были стабильными: сначала точное совпадение для ID/email, затем префикс, затем триграмма для нечётких совпадений по именам/заголовкам, и в конце — полнотекст для длинных заметок и описаний. Сначала применяйте фильтры, чтобы сузить набор кандидатов, тогда нечёткий поиск не будет сканировать лишние строки.

Что делать с запросами в 1–2 символа или пустым вводом?

Ввод длиной 1–2 символа лучше не запускать для нечёткого поиска: показывайте недавно открытые тикеты или недавно обновлённых клиентов. Для нечёткого поиска требуйте 3+ символов. Короткие запросы создают много шума и приводят к дорогим операциям с низкой ценностью. Решите заранее, что показывать при пустом вводе, чтобы UI не «бомбил" базу запросами «match everything».

Как проверить производительность и безопасно выпустить изменения поиска?

Создайте индекс, затем проверьте реальный запрос через EXPLAIN (ANALYZE, BUFFERS) на данных, близких к продакшену. Внедряйте изменения по одному и держите лёгкий откат; для больших таблиц создавайте индексы CREATE INDEX CONCURRENTLY, чтобы не блокировать записи. Если вы строите экран в AppMaster (appmaster.io), делайте правила поиска рядом с моделью данных и бизнес-логикой, чтобы поведение UI не рассинхронизировалось с реализацией.

Легко начать
Создай что-то невероятное

Экспериментируйте с AppMaster с бесплатной подпиской.
Как только вы будете готовы, вы сможете выбрать подходящий платный план.

Попробовать AppMaster