Полнотекстовый поиск на Go без Elasticsearch — гайд по Bleve

Полнотекстовый индекс, фасетный поиск, fuzzy-матчинг и highlighting — всё на чистом Go без внешних зависимостей. С бенчмарками против Elasticsearch.

Обложка: Полнотекстовый поиск на Go без Elasticsearch — гайд по Bleve

Когда в проекте появляется задача полнотекстового поиска, первая мысль — поставить Elasticsearch или Meilisearch. Оба инструмента отлично справляются. Но что, если вы не хотите зависеть от внешнего сервиса — или вам нужен полный контроль над хранением и обработкой данных?

В Go для этого есть Bleve — файловая библиотека полнотекстового индексирования. Она умеет индексировать любые Go-структуры с разумными настройками по умолчанию, поддерживает встроенный язык запросов в стиле Google, работает с миллионами записей и не требует отдельного сервера.

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

Ключевые выводы

- Bleve — встраиваемый полнотекстовый движок для Go: не нужен отдельный сервер, достаточно одной зависимости
- Кастомные маппинги позволяют настроить анализатор, стемминг и стоп-слова для каждого поля отдельно
- IndexAlias объединяет несколько индексов (например, по языкам) в один виртуальный — с единым интерфейсом поиска
- Курсорная пагинация через SearchAfter / SearchBefore работает стабильно даже при обновлении индекса между запросами
- Scorch-настройки (воркеры, размер сегментов, порог мёржа) критичны для производительности под нагрузкой

Создаём простой индекс

Начнём с минимального примера. Два базовых действия — индексирование (сохранение документа для последующего поиска) и запрос (извлечение документов, отсортированных по релевантности).

			package main

import (
	"fmt"
	"log"

	bleve "github.com/blevesearch/bleve/v2"
)

// Document represents the data we want to index and search.
type Document struct {
	Title string
	URL   string
	Text  string
}

func main() {
	// Create a new index on disk. If one already exists at that path, open it.
	mapping := bleve.NewIndexMapping()
	index, err := bleve.New("example.bleve", mapping)
	if err != nil {
		index, err = bleve.Open("example.bleve")
		if err != nil {
			log.Fatal(err)
		}
	}
	defer index.Close()

	// Index a handful of documents. The first argument is a unique ID;
	// the second is any Go value, Bleve will reflect over its fields.
	docs := map[string]Document{
		"1": {Title: "Go Programming", URL: "https://go.dev", Text: "Go is an open source programming language that makes it easy to build reliable software."},
		"2": {Title: "Bleve Search", URL: "https://blevesearch.com", Text: "Bleve is a full-text search and indexing library for Go."},
		"3": {Title: "Hister - Your own search engine", URL: "https://hister.org/", Text: "Full-text search across your files, browsing history and beyond."},
	}

	for id, doc := range docs {
		if err := index.Index(id, doc); err != nil {
			log.Printf("failed to index %s: %v", id, err)
		}
	}

	// Query the index. NewMatchQuery performs a full-text search across
	// all indexed fields and ranks results by relevance score.
	query := bleve.NewMatchQuery("Hister search engine")
	req := bleve.NewSearchRequest(query)
	req.Fields = []string{"Title", "URL"} // which stored fields to return
	req.Size = 10                         // maximum number of hits

	results, err := index.Search(req)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Found %d result(s):\n", results.Total)
	for _, hit := range results.Hits {
		fmt.Printf("  [%.4f] %s  %s\n", hit.Score, hit.Fields["Title"], hit.Fields["URL"])
	}
}
		

Несколько важных деталей:

  • bleve.New vs bleve.OpenNew создаёт новый индекс по указанному пути, 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 исключает из индексирования:

			import (
	"github.com/blevesearch/bleve/v2/analysis/analyzer/en"
	"github.com/blevesearch/bleve/v2/mapping"
)

func buildIndexMapping() *mapping.IndexMappingImpl {
	// English analyzer: tokenizes, lowercases, removes stop words, and stems.
	// "running" and "runs" will both match a search for "run".
	englishField := bleve.NewTextFieldMapping()
	englishField.Analyzer = en.AnalyzerName

	// A keyword analyzer treats the entire field value as a single token
	// useful for exact-match fields like URLs or tags.
	keywordField := bleve.NewTextFieldMapping()
	keywordField.Analyzer = "keyword"

	// Disable indexing for a field we only want to store, not search.
	storedOnlyField := bleve.NewTextFieldMapping()
	storedOnlyField.Index = false

	docMapping := bleve.NewDocumentMapping()
	docMapping.AddFieldMappingsAt("title", englishField)
	docMapping.AddFieldMappingsAt("text", englishField)
	docMapping.AddFieldMappingsAt("url", keywordField)
	docMapping.AddFieldMappingsAt("raw_html", storedOnlyField) // stored but not indexed

	indexMapping := bleve.NewIndexMapping()
	indexMapping.AddDocumentMapping("document", docMapping)
	indexMapping.DefaultAnalyzer = en.AnalyzerName

	return indexMapping
}
		

Результат buildIndexMapping() передаётся в bleve.New или bleve.NewUsing при создании индекса. Маппинги закрепляются за индексом на этапе создания и не могут быть изменены позже. Чтобы применить новый маппинг, нужно создать свежий индекс и переиндексировать все документы.

Мультиязычный поиск

Bleve умеет прозрачно работать с несколькими индексами одновременно через IndexAlias. Алиас — это виртуальный индекс, который отправляет запрос во все реальные индексы и объединяет результаты в единый ранжированный список.

Это особенно полезно, когда у каждого языка свой индекс с собственным анализатором (английский стемминг, французские стоп-слова, кастомная токенизация), а искать нужно по всем сразу:

			enIndex, _ := bleve.Open("index_en.bleve")
frIndex, _ := bleve.Open("index_fr.bleve")
deIndex, _ := bleve.Open("index_de.bleve")

// Combine all language indexes behind a single alias.
alias := bleve.NewIndexAlias(enIndex, frIndex, deIndex)

// Query the alias exactly as you would a regular index.
req := bleve.NewSearchRequest(bleve.NewMatchQuery("Hister search engine"))
req.Size = 20
results, _ := alias.Search(req)
		

Алиасы также упрощают горячую замену (hot-swap). Когда нужно перестроить индекс (например, применить новый маппинг), можно собрать новый индекс в фоне, а затем атомарно подменить его вызовом alias.Swap(newIndexes, oldIndexes). Текущие запросы завершатся на старом индексе, новые сразу пойдут на свежий — без даунтайма.

Пагинация с курсором

У SearchRequest в Bleve есть поле From для офсетной пагинации: 0 для первой страницы, 20 для второй и так далее. Это работает, но имеет серьёзную проблему: Bleve вынужден оценивать и сортировать все совпавшие документы вплоть до From + Size на каждый запрос. Глубокие страницы становятся всё дороже по памяти и CPU.

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

Правильный подход — курсорная пагинация через SearchAfter и SearchBefore. Эти методы продолжают выдачу с известной позиции, а не пересканируют результаты с начала:

			const pageSize = 20

// First page, no cursor needed.
req := bleve.NewSearchRequest(myQuery)
req.Size = pageSize
req.SortBy([]string{"_score", "_id"}) // stable sort is required for cursors

results, _ := index.Search(req)

// Subsequent pages, pass the sort key of the last hit as the cursor.
if len(results.Hits) == pageSize {
    lastHit := results.Hits[len(results.Hits)-1]
    cursor := lastHit.Sort // []string, one element per sort field

    nextReq := bleve.NewSearchRequest(myQuery)
    nextReq.Size = pageSize
    nextReq.SortBy([]string{"_score", "_id"})
    nextReq.SetSearchAfter(cursor)

    nextResults, _ := index.Search(nextReq)
    // ...
}
		

На что обратить внимание:

  • Стабильная сортировка обязательна. SearchAfter использует ключ сортировки последнего результата в качестве курсора. Если ключ нестабилен, курсор станет невалидным.
  • Sort — всегда []string. Даже при сортировке по числовому полю Bleve сериализует ключ в строку. Читайте курсор из hit.Sort и передавайте напрямую в SetSearchAfter.
  • SearchBefore работает аналогично, но в обратном направлении — полезно для кнопки «предыдущая страница».

Оптимизация производительности

Настройки производительности Bleve не слишком хорошо документированы, но существенно влияют на работу под нагрузкой. Конфигурация передаётся как map[string]any в NewUsing или OpenUsing вместо обычных New / Open.

			config := map[string]any{
	// How long the BoltDB storage layer will wait for a write lock
	// before returning an error. Increase this if you see timeout
	// errors under concurrent write load.
	"bolt_timeout": "2s",

	"scorchPersisterOptions": map[string]any{
		// Number of goroutines that flush in-memory segments to disk
		// in parallel. More workers help throughput on multi-core machines
		// at the cost of higher memory usage during flushing.
		"NumPersisterWorkers": 4,

		// Maximum bytes each persister worker holds in memory before
		// flushing. Larger values reduce I/O by writing bigger segments,
		// but increase peak memory consumption.
		"MaxSizeInMemoryMergePerWorker": 80 * 1024 * 1024, // 80 MB

		// The persister pauses merging when the number of on-disk segment
		// files is below this threshold, reducing unnecessary write
		// amplification when the index is small or lightly loaded.
		"PersisterNapUnderNumFiles": 100,
	},

	"scorchMergePlanOptions": map[string]any{
		// Segments smaller than this size are candidates for merging.
		// Raising this value reduces the total number of segments (and
		// therefore read latency) at the cost of more merge I/O.
		"FloorSegmentFileSize": 20 * 1024 * 1024, // 20 MB
	},
}

index, err := bleve.OpenUsing("my.bleve", config)
		

Эти параметры относятся к Scorch — дефолтному бэкенду хранения Bleve. Полный список доступных опций и значений по умолчанию можно найти в исходном коде персистера.

Пакетная индексация

Если нужно проиндексировать большой объём данных, используйте Batch вместо одиночных вызовов Index. Пакетная запись группирует операции в одну транзакцию, что значительно снижает нагрузку на диск:

			batch := index.NewBatch()
for id, doc := range docs {
    batch.Index(id, doc)
}
if err := index.Batch(batch); err != nil {
    log.Fatal(err)
}
		

Оптимальный размер пакета зависит от объёма документов и доступной памяти. Как правило, пакеты по 100-1000 документов дают хороший баланс между скоростью и потреблением ресурсов.

Частые вопросы
1
Сколько документов потянет Bleve?

Bleve — файловый индексатор на основе Scorch, который справляется с миллионами записей. Конкретный потолок зависит от размера документов, количества полей и доступных ресурсов. Для большинства приложений (до нескольких миллионов документов) Bleve работает стабильно без специальной настройки.

2
Можно ли обновлять маппинг существующего индекса?

Нет. Маппинг фиксируется при создании индекса и не может быть изменён позже. Чтобы применить новый маппинг, нужно создать свежий индекс и переиндексировать все документы. IndexAlias с методом Swap позволяет сделать это без даунтайма.

3
Чем Bleve отличается от Elasticsearch?

Elasticsearch — распределённый сервер с REST API, кластеризацией и богатой экосистемой. Bleve — встраиваемая библиотека, которая живёт внутри процесса и не требует отдельной инфраструктуры. Если вам нужен полнотекстовый поиск без зависимости от внешнего сервиса — Bleve закроет эту задачу. Если нужна горизонтальная масштабируемость и аналитика — Elasticsearch.

4
Поддерживает ли 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.