Полнотекстовый поиск на Go без Elasticsearch — гайд по Bleve
Полнотекстовый индекс, фасетный поиск, fuzzy-матчинг и highlighting — всё на чистом Go без внешних зависимостей. С бенчмарками против Elasticsearch.
, отредактировано
Когда в проекте появляется задача полнотекстового поиска, первая мысль — поставить Elasticsearch или Meilisearch. Оба инструмента отлично справляются. Но что, если вы не хотите зависеть от внешнего сервиса — или вам нужен полный контроль над хранением и обработкой данных?
В Go для этого есть Bleve — файловая библиотека полнотекстового индексирования. Она умеет индексировать любые Go-структуры с разумными настройками по умолчанию, поддерживает встроенный язык запросов в стиле Google, работает с миллионами записей и не требует отдельного сервера.
В этой статье — практический гайд: от простого индекса до кастомных анализаторов, мультиязычного поиска, пагинации курсором и тонкой настройки производительности.
Ключевые выводы
- Bleve — встраиваемый полнотекстовый движок для Go: не нужен отдельный сервер, достаточно одной зависимости
- Кастомные маппинги позволяют настроить анализатор, стемминг и стоп-слова для каждого поля отдельно
-IndexAliasобъединяет несколько индексов (например, по языкам) в один виртуальный — с единым интерфейсом поиска
- Курсорная пагинация черезSearchAfter/SearchBeforeработает стабильно даже при обновлении индекса между запросами
- Scorch-настройки (воркеры, размер сегментов, порог мёржа) критичны для производительности под нагрузкой
Создаём простой индекс
Начнём с минимального примера. Два базовых действия — индексирование (сохранение документа для последующего поиска) и запрос (извлечение документов, отсортированных по релевантности).
Несколько важных деталей:
bleve.Newvsbleve.Open—Newсоздаёт новый индекс по указанному пути,Openоткрывает существующий. Паттерн из примера (сначалаNew, при ошибке —Open) — идиоматический способ обработки первого и повторных запусков.NewIndexMapping()— возвращает маппинг по умолчанию: текстовые поля токенизируются, приводятся к нижнему регистру и фильтруются через список стоп-слов английского языка.- Автоматическое обнаружение полей — Bleve использует рефлексию для анализа структуры. Все экспортируемые поля автоматически токенизируются и становятся доступными для поиска.
- Уникальные ID документов — строковый идентификатор, передаваемый в
Index, используется для обновлений и удалений. Повторный вызовIndexс тем же ID заменяет документ. SearchRequest.Fields— по умолчанию Bleve возвращает только ID и релевантность. Укажите имена нужных полей или[]string{"*"}, чтобы получить все.hit.Score— каждый результат содержит числовой показатель релевантности на основе BM25. Чем выше — тем точнее совпадение.
Кастомные маппинги полей
Маппинг по умолчанию подходит для быстрого старта, но в реальных проектах нужен контроль над тем, как Bleve анализирует и хранит каждое поле. Маппинг определяет тип поля, анализатор для токенизации, необходимость хранения оригинального значения и включение в индекс.
Маппинги позволяют:
- Управлять токенизацией — разбивать текст на термы по пробелам, языковым правилам, edge n-граммам и другим стратегиям
- Фильтровать входные данные — приводить к нижнему регистру, удалять HTML, применять стоп-слова и стемминг (чтобы «running» и «runs» находились по запросу «run»)
- Исключать поля из индекса — пропускать чувствительные или нерелевантные данные для экономии дискового пространства
- Создавать кастомные анализаторы — комбинировать любой токенизатор с произвольной цепочкой фильтров
Пример ниже применяет стемминг английского языка к полям Text и Title, а поле с сырым HTML исключает из индексирования:
Результат buildIndexMapping() передаётся в bleve.New или bleve.NewUsing при создании индекса. Маппинги закрепляются за индексом на этапе создания и не могут быть изменены позже. Чтобы применить новый маппинг, нужно создать свежий индекс и переиндексировать все документы.
Мультиязычный поиск
Bleve умеет прозрачно работать с несколькими индексами одновременно через IndexAlias. Алиас — это виртуальный индекс, который отправляет запрос во все реальные индексы и объединяет результаты в единый ранжированный список.
Это особенно полезно, когда у каждого языка свой индекс с собственным анализатором (английский стемминг, французские стоп-слова, кастомная токенизация), а искать нужно по всем сразу:
Алиасы также упрощают горячую замену (hot-swap). Когда нужно перестроить индекс (например, применить новый маппинг), можно собрать новый индекс в фоне, а затем атомарно подменить его вызовом alias.Swap(newIndexes, oldIndexes). Текущие запросы завершатся на старом индексе, новые сразу пойдут на свежий — без даунтайма.
Пагинация с курсором
У SearchRequest в Bleve есть поле From для офсетной пагинации: 0 для первой страницы, 20 для второй и так далее. Это работает, но имеет серьёзную проблему: Bleve вынужден оценивать и сортировать все совпавшие документы вплоть до From + Size на каждый запрос. Глубокие страницы становятся всё дороже по памяти и CPU.
Хуже того, если между двумя запросами в индекс были добавлены новые документы, смещение сдвигается — и пользователь видит дубликаты или пропуски.
Правильный подход — курсорная пагинация через SearchAfter и SearchBefore. Эти методы продолжают выдачу с известной позиции, а не пересканируют результаты с начала:
На что обратить внимание:
- Стабильная сортировка обязательна.
SearchAfterиспользует ключ сортировки последнего результата в качестве курсора. Если ключ нестабилен, курсор станет невалидным. Sort— всегда[]string. Даже при сортировке по числовому полю Bleve сериализует ключ в строку. Читайте курсор изhit.Sortи передавайте напрямую вSetSearchAfter.SearchBeforeработает аналогично, но в обратном направлении — полезно для кнопки «предыдущая страница».
Оптимизация производительности
Настройки производительности Bleve не слишком хорошо документированы, но существенно влияют на работу под нагрузкой. Конфигурация передаётся как map[string]any в NewUsing или OpenUsing вместо обычных New / Open.
Эти параметры относятся к Scorch — дефолтному бэкенду хранения Bleve. Полный список доступных опций и значений по умолчанию можно найти в исходном коде персистера.
Пакетная индексация
Если нужно проиндексировать большой объём данных, используйте Batch вместо одиночных вызовов Index. Пакетная запись группирует операции в одну транзакцию, что значительно снижает нагрузку на диск:
Оптимальный размер пакета зависит от объёма документов и доступной памяти. Как правило, пакеты по 100-1000 документов дают хороший баланс между скоростью и потреблением ресурсов.
Частые вопросы
Сколько документов потянет Bleve?
Bleve — файловый индексатор на основе Scorch, который справляется с миллионами записей. Конкретный потолок зависит от размера документов, количества полей и доступных ресурсов. Для большинства приложений (до нескольких миллионов документов) Bleve работает стабильно без специальной настройки.
Можно ли обновлять маппинг существующего индекса?
Нет. Маппинг фиксируется при создании индекса и не может быть изменён позже. Чтобы применить новый маппинг, нужно создать свежий индекс и переиндексировать все документы. IndexAlias с методом Swap позволяет сделать это без даунтайма.
Чем Bleve отличается от Elasticsearch?
Elasticsearch — распределённый сервер с REST API, кластеризацией и богатой экосистемой. Bleve — встраиваемая библиотека, которая живёт внутри процесса и не требует отдельной инфраструктуры. Если вам нужен полнотекстовый поиск без зависимости от внешнего сервиса — Bleve закроет эту задачу. Если нужна горизонтальная масштабируемость и аналитика — Elasticsearch.
Поддерживает ли Bleve русский язык?
Да. Bleve включает анализаторы для множества языков, в том числе русского (ru). Подключите github.com/blevesearch/bleve/v2/analysis/analyzer/ru и укажите его в маппинге нужного поля — стемминг и стоп-слова для русского языка заработают автоматически.
Выводы
Bleve — одна из недооценённых жемчужин экосистемы Go. Библиотека позволяет добавить полнотекстовый поиск в приложение без сложной инфраструктуры. Настройки по умолчанию дают рабочий результат за минуты, а кастомные маппинги, композитные запросы и тонкая настройка Scorch — инструменты для решения специфических задач оптимально.
Официальная документация местами неполна, но issues на GitHub и реальные open-source-проекты отлично её дополняют. Рабочий пример всех описанных концепций можно найти в пакете indexer проекта Hister.
Адаптированный перевод статьи Data Indexing in Golang из блога Hister.