Привет Хабр! Меня зовут Михаил. Я backend-разработчик в команде Биллинга в Тензоре.
Недавно мне поставили задачу. Звучала она достаточно тривиально:
В таблице существует поле формата JSONB, обеспечить оптимальный поиск по тексту в нём.
Структура поля следующая:
{
"ru": {
"Много текста в Русской локали спец символов нет все слова разделены пробелами"
},
"en":{
"There is a lot of text in the English locale there are no special characters all words are separated by spaces"
},
.....
}
На первый взгляд всё достаточно просто: если мы не хотим менять структуру поля, то достаточно накатить на него GIN индекс. Но этот вариант не самый оптимальный. Наверняка есть ещё способы.
Эта статья — обзор вариантов индексации таких полей и оптимального поиска текста в них, который мы проделали вместе с командой. Так как используем PostgresSQL, все варианты валидны для него.
Композитный индекс по группам
Postgres, как и множество других СУБД, поддерживает композитный индекс.
Композитный индекс — индекс, строящийся по нескольким столбцам таблицы, а не по одному. Этот индекс оптимизирует запросы, которые фильтруют или сортируют данные на основе комбинации этих столбцов, так как он обрабатывает значения в определенной последовательности, обеспечивая быстрый поиск по нескольким критериям одновременно.
Окей, а как нам использовать этот тип индекса по отношению к нашему полю? Ведь мы не очень явно можем выделить какие-то группы в рамках одного поля. Но, как оказалось, Postgres умеет отлично строить этот тип индекса и для таких не стандартных полей. Достаточно написать триггерную функцию, которая будет парсить наше поле на 4 группы и на основе их и строить индекс. Почему именно 4 группы? Потому что в Postgres на эту фишку заложено всего 2 бита — подробнее про то, как реализована эта фишка, можно почитать тут. Сам способ, как построить такой индекс, включая пример триггер функции, описан здесь.
Если же говорить кратко, то это работает следующим образом:
Создаётся новое поле формата tsvector и триггер, который, при обновлении\записи поля, разбивает входящий jsonb на 4 под-группы следующим образом:
Поле -> ключ_1 ; Поле -> ключ_2; Поле -> ключ_3; Поле -> ключ_4.
Далее поверх этого нового поля накатывается GIN индекс.
Пока что такое решение нам не очень подходит, так как требуется триггер-функция и новое поле. Кроме того, всего можно будет составить 4 группы — у нас же локалей кратно больше. Но разберём плюсы и минусы, чтобы точно убедиться:
Минусы |
Плюсы |
Нужна триггер функция. |
Достаточно точный поиск, т.к сравниваем 2 значения лексем в векторах. |
Растёт размер таблицы за счёт создания нового поля. |
Это будет быстро. |
Так как у PG зарезервировано только 2 Бита на эту фишку, то мы можем так поддержать максимум 4 группы. Поэтому общее кол-во индексов на поле будет равным: общее кол-во локалей \ 4. |
Возможность поиска с опечатками, т.к можем регулировать, при каком проценте совпадения мы выдаём результат. По умолчанию что-то в районе 60%. |
Имя группы должно быть из 1 символа, у нас же есть локализация на языки, в которых первые буквы одинаковы. | |
Нам точно нужно знать, в какой группе мы будем производить поиск. Для этого нам всегда необходимо определять локаль текста, который ввёл пользователь. |
По итогу решили отказаться от этого решения, так как его минусы существенно перевешивают плюсы, да и мы не особо хотели изменять структуру нашей таблицы и писать триггеры.
Использование расширения JsQuery
Поиск в интернете подсказал, что для работы с JSON полями в Postgers существует удобное расширение JsQuery. Это звучит уже интересно, так как в документации по данному расширению указано, что существует поддержка GIN индекса для поиска, а это как раз то, что нам необходимо.
Помимо разнообразных плюшек, которое даёт это расширение при работе с JSON и JSONB, нас интересуют 2 функции:
-
jsonb_path_value_ops - Класс jsonb_path_value_ops представляет элемент в виде пары хеша пути и значения. В итоге получается следующая структура: (hash(элемент_пути_1.элемент_пути_2. ... .элемент_пути_n); значение)
При сравнении записей хеш пути является старшей частью записи, а значение — младшей. Это определяет характеристики этого класса операторов. Так как путь хешируется и оказывается в верхней части записи, нам нужно знать полный путь к значению, чтобы использовать его для поиска. Но если путь известен, мы можем применять и точный поиск, и поиск значений в интервале.
jsonb_value_path_ops - Класс jsonb_value_path_ops представляет элемент в виде пары значения и фильтра блума для пути. (значение; bloom(элемент_пути_1) | bloom(элемент_пути_2) | ... | bloom(элемент_пути_n)). При сравнении записей значение является старшей частью записи, а фильтр Блума для пути — младшей. Это определяет характеристики этого класса операторов. Так как значение находится в старшей части, мы можем выполнять очень эффективно только поиск точного значения. Поиск значений в интервале возможен, но для этого придётся отфильтровать все другие пути, в которых будут найдены подходящие значения. Фильтр Блума по элементам пути позволяет использовать индекс для условий, содержащихся в путях % и *.
На первый взгляд из очевидных минусов использования этого решения только то, что JsQuery отсутствует в стандартных расширениях для Postgres. Это значит, что нам нужно будет согласовывать его включение. Ещё в индексе будет большое количество записей, так как на каждый ключ нашей локали мы будем строить свой собственный путь. Но прежде, чем выдавать вердикт, рассмотрим ещё и плюсы этого решения:
Минусы |
Плюсы |
JsQuery отсутствует в стандартном пакете расширений. Добавление только через согласование. |
Гибкий инструмент по работе с JSON и подобными объектами, массивами в PGSQL. |
В индексе будет большое кол-во записей, т.к на каждый ключ локализации мы будем строить путь. |
Можно создать GIN индекс на значение: путь. Тогда суммарное кол-во записей, попадающих в индекс = кол-во записей в таблице * суммарное кол-во локалей. При таком подходе возможно реализовать триграмм индекс GIN для поиска с опечатками. |
К сожалению, такой подход решили не использовать, так как проект близится к завершению — мы просто не успеем согласовать включение нового расширения, но присмотримся к нему в будущих проектах.
Мультииндекс GIN для большого кол-во ключей
Помимо композитного индекса, в PG так же существует и мультииндекс — индекс, который включает в себя два и более столбца в одном объекте. Здесь можно узнать про мультииндекс подробнее.
И как оказалось, PG прекрасно умеет сам строить мультииндекс на поле формата JSONB. Более того, это решение подходит для ситуаций, когда у нас есть большое количество ключей в нашем объекте. А у нас как раз может быть достаточно много локалей. Также мы можем забить на точное указание языка и индексировать просто всю строку целиком.
Как это работает: cоздаём GIN индекс с использованием jsonb_to_tsvector и конфигурацией только на текстовые значения без уточнения языка. Т.е что то типа такого: GIN(jsonb_to_tsvector('simple', data, '["string"]'))
Тут:
simple -- Используется для общего поиска без языковой морфологии. Такое решение подходит для мультиязычных данных.
string -- Указатель на то, что индексируем только строковые значения без цифр и спец. символов.
Окей, как это работает — понятно, но подходит ли нам в нашей ситуации решение? Или пойдём искать дальше?
Как и выше, распишем очевидные плюсы и минусы в виде таблицы и сделаем выводы:
Минусы |
Плюсы |
Индекс растёт пропорционально данным в таблице. |
Не нужно знать ключи — индексируем все строковые значения. |
Если много неуникального текста, то каждый текст будет индексироваться. |
Если много уникального текста, то проиндексирует только уникальный текст. Т.к по факту индексируем только лексемы и их позиции в тексте. |
При каждом обновлении нам придётся пересчитывать лексемы в индексе. Но это решается созданием новой колонки, где будем хранить текст в векторе вне зависимости от локализации. | |
Это решение будет работать очень хорошо, только если у нас будет большое количество уникального текста. В нашем случае это не гарантированно, да и индекс может получиться очень громоздким — для скорости и оптимальности нам потребуется новое поле в БД, чего хотелось бы избежать.
MATERIALIZED VIEW
Это решение мы подсмотрели у коллег, занимающихся разработкой навигации в картах. Оно включает в себя использование материализованного представления.
Решение нам показалось достаточно изящным и вот почему:
Предвычисляем tsvector и объединяем для каждого поля всё в единый tsvector вне зависимости от локализации. Это позволит избежать повторных вычислений при каждом поиске.
Вешаем GIN индекс на наш созданный tsvector.
При изменении данных обновляем представление.
Это решение отлично подходит нам, так как не нужно создавать новые поля, а значит, не нужно пересогласовывать структуру таблицы под конец проекта, не нужно писать триггеры или процедуры для работы. Всё, что от нас требуется, — это только вовремя обновить представление.
Как обычно, распишем очевидные плюсы и минусы:
Минусы |
Плюсы |
Нужно доп. время на обновление представления, что может незначительно замедлить запись данных. |
Быстрый и оптимизированный поиск за счёт представления и индексации. |
Занимаем доп. пространство в памяти. |
Денормализуем данные -> упрощаем запрос. |
Данные обновляются только триггером либо при прямом указании Refresh после записи данных в основную таблицу. |
Можем обновлять данные по расписанию в случае необходимости. |
Не очень хорошее решение при высокой частоте обновления \ записи данных. Т.к может привести к overhed. |
Хорошее решение для большого кол-во данных (а в прайсах текста и локалей много). |
После анализа очевидных плюсов и минусов этого решения, мы пришли к выводу, что оно нас полностью устраивает. Его уже реализовали наши коллеги и получили согласование. Конечно, огорчает то, что будем занимать больше места в памяти и что нам необходимо каждый раз обновлять представление при изменении данных, но, на мой взгляд, это малая цена за стабильно работающее решение.
Бонусный "грязный" вариант
В какой-то момент закралась мысль: а какой самый дешёвый способ решения проблемы? И появился этот вариант. Не очень хочется длинно расписывать его, поэтому осветим кратко.
Меняем формат нашего поля на текстовое и пишем туда весь текст одной строкой без разбития на локали. Далее векторизуем это дело и накатываем GIN индекс.
Да, конечно, придётся поменять формат поля, чего мы не хотели делать. Да и если весь текст во всех локалях будет писаться в одну строку, то нам гарантированы ложные срабатывания. Могут быть ситуации, когда одно и то же слово будет встречаться в нескольких разных записях.
Посмотрим внимательнее на решение со всех сторон:
Минусы |
Плюсы |
Ложные срабатывания. Допустим, на слово Clients, нам отдадут 2 записи а не 1. |
Ложные срабатывания не критичны. |
При изменения текста в одной локали придётся переписывать всю строку. |
Быстрое -> дешёвое решение. |
Легко поддерживать, суммарно выходит 1 индекс. |
По итогу это оказалось самым простым и дешёвым решением. Да, конечно, будут возвращаться лишние записи, да и при изменение одного слова в одной локали нам необходимо будет переписать всю строку с текстом для записи. Но если вы не часто меняется значение поля или для вас не критичны ложные срабатывания, то, возможно, вам стоит пойти именно этим путём.
Полезный материал
В ходе поиска решения наткнулся на гайд от МГУ. Он достаточно старый (написан для версии 8.3 вышедший в 2008), но очень подробный и интересный.
Заключение
В этой небольшой статье мы привели варианты решения достаточно распространённой в разработке задачи. С радостью отвечу на ваши вопросы. Пишите, о каких ещё способах знаете? Может, кто-то сталкивался с подобной задачей? Если да, то как её решали? Буду рад обратно связи и дополнениям!
Комментарии (4)

imbaprofeeder
25.12.2025 08:56Manticore/ElasticSearch/Meilisearch - ткнуть пальцем любое специализированное решение и это будет работать лучше и быстрее из коробки чем эти костыли для PostgreSQL.
Нет, я конечно всё понимаю, сидишь такой и от нечего делать появляется желание переписать весь проект на Go, и базу свою сделать, не осуждаю.
antonb73
Честно говоря никогда не понимал нецелевое использование инструментов. Это как писать frontend код на assembler.
Есть же специализированные системы для полнотекстового поиска - Elastic Search например.
char32t
Есть специализированные системы, но если часть данных уже хранится в PostgreSQL, то потребуется дополнительно делать интеграцию с Elasticsearch. К тому же Elasticsearch сам по себе потребует много доп. вычислительных ресурсов. Ну и транзакционную целостность проще обеспечить, храня всё в PostgreSQL, если она требуется.
antonb73
Все верно потребуется интеграция для помещения данных в Elastic для формирования поискового индекса, при этом доменные данные по прежнему лучше хранить в транзакционной СУБД. Затраты конечно будут, но самое главное не будет сложного, кастомного решения по поиску данных, а это перевесит все указанные вами недостатки.