
Предисловие
Всем привет! Меня зовут Артемий Иванов, и это моя первая статья на Хабре. В ней я хочу поделиться опытом, который получил, работая над задачей кастомизации поиска.
Столкнулся с тем, что стандартный поиск работал слишком жёстко: он плохо справлялся с опечатками, склонениями и специфичными наименованиями, из-за чего терялись релевантные результаты.
Разобраться во всех нюансах было непросто — приходилось вникать в обилие терминов и тонкостей «на ходу». В этой статье я покажу, как можно сделать поиск гибче с помощью Spring Data Elasticsearch — и всё это на конкретных примерах из практики.
Введение
Пару слов об Elasticsearch. Это NoSQL база данных, которая оптимизирована под быстрый поиск документов. Под капотом использует библиотеку Lucene, имеет удобный API, анализаторы и многое другое. Spring Data Elasticsearch выступает удобным клиентом, который позволяет интегрировать механизм в Spring-экосистему и пользоваться всеми преимуществами семейства технологий.
Для примера рассмотрим города и набор их характеристик, например:
Москва - Красная площадь, Кремль, МКАД
Уфа - Мед, Шиханы, Три шурупа
Наша задача - выдать набор всех характеристик по запросу. Пользователь может искать по точному названию города. Однако зачастую необходимо получить результат по части характеристик, выполнив так называемый полнотекстовый поиск. Также мы должны учитывать регистр, опечатки, склонения и т.д. Сначала попробуем найти описание по точному названию города, а затем перейдем к поиску по описанию.
Используемые инструменты
Java 21
Spring Boot
Spring Data Elasticsearch
Maven
Elasticsearch 8.17.4
Intellij IDEA
Postman
Настройка окружения
Elasticsearch можно использовать через docker-образ, а можно выкачать на локальную машину и запустить. Процесс установки достаточно прост. На данный момент не получится скачать установщик без vpn из России. После скачивания и установки необходимо зайти вelasticsearch/config/elasticsearch.yml
и отключить кластерные настройки безопасности, для локального запуска они нам не пригодятся:
xpack.security.enabled: false
xpack.security.enrollment.enabled: false
xpack.security.http.ssl:
enabled: false
xpack.security.transport.ssl:
enabled: false
Далее запускаем сам Elasticsearch из elasticsearch-8.17.4\bin\elasticsearch.bat
. В случае успешного запуска по запросу на
GET / HTTP/1.1
Host: localhost:9200
Получим ответ:
{
"name": "HOME",
"cluster_name": "elasticsearch",
"cluster_uuid": "gPEw_vMLTTa1W8k5sMvSqg",
"version": {
"number": "8.17.4",
"build_flavor": "default",
"build_type": "zip",
"build_hash": "c63c7f5f8ce7d2e4805b7b3d842e7e792d84dda1",
"build_date": "2025-03-20T15:39:59.811110136Z",
"build_snapshot": false,
"lucene_version": "9.12.0",
"minimum_wire_compatibility_version": "7.17.0",
"minimum_index_compatibility_version": "7.0.0"
},
"tagline": "You Know, for Search"
}
В качестве менеджера зависимостей будем использовать Maven/Gradle, выбирайте что вам удобнее. Я буду использовать Maven. Список зависимостей:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</dependency>
</dependencies>
В application.yml
укажем путь для подключения к эластику:
elasticsearch.url=localhost:9200
Приступим к коду
Контроллер будет содержать 2 метода: один для добавления города, второй для поиска:
@RestController
@RequestMapping("/city")
@RequiredArgsConstructor
public class CityController {
private final CityService cityService;
@PostMapping
public ResponseEntity<String> add(@RequestBody AddCityRequestBodyDto addCityRequestBodyDto) {
cityService.addCity(addCityRequestBodyDto);
return ResponseEntity.ok("Ok");
}
@PostMapping("/search")
public ResponseEntity<List<String>> search(@RequestBody SearchCityRequestBodyDto searchCityRequestBodyDto){
List<String> cities = cityService.searchCity(searchCityRequestBodyDto);
return ResponseEntity.ok(cities);
}
}
Dto со структурой запросов:
public record AddCityRequestBodyDto(String name, String description) {
}
public record SearchCityRequestBodyDto(String name) {
}
Интерфейс и реализация сервиса:
public interface CityService {
void addCity(AddCityRequestBodyDto addCityRequestBodyDto);
List<String> searchCity(SearchCityRequestBodyDto searchCityRequestBodyDto);
}
@Service
@RequiredArgsConstructor
public class CityServiceImpl implements CityService{
private final CityElasticSearchClient client;
@Override
public void addCity(AddCityRequestBodyDto addCityRequestBodyDto) {
City city = CityMapper.map(addCityRequestBodyDto);
client.addCity(city);
}
@Override
public List<String> searchCity(SearchCityRequestBodyDto searchCityRequestBodyDto) {
String search = searchCityRequestBodyDto.name();
List<City> cities = client.searchCity(search);
return cities.stream().map(City::name).collect(Collectors.toList());
}
}
Далее сам клиент эластика:
@Service
@RequiredArgsConstructor
public class CityElasticSearchClient {
private final ElasticsearchClient client;
@SneakyThrows
public void addCity(City city) {
client.index(i -> i.index("city").document(city));
}
@SneakyThrows
public List<City> searchCity(String name) {
SearchResponse<City> response = client.search(s -> s
.index("city")
.query(q -> q
.match(m -> m
.field("name")
.query(name)
)
), City.class);
return response.hits().hits().stream()
.map(hit -> hit.source())
.toList();
}
}
Стоит отметить, что можно использовать поиск с репозиториями, по аналогии с JPA, где сам Spring по правильному названию метода их сгенерирует, но я предпочитаю клиент из co.elastic.clients.elasticsearch.ElasticsearchClient
.
Структура документа:
@Document(indexName = "city")
public record City(@Id @Field(type = FieldType.Keyword) String name, @Field(type = FieldType.Text) String description) {
}
Документ - это описание структуры сущности, которая будет храниться в индексе. Spring предоставляет несколько типов для полей, и так как сейчас мы используем точный поиск, нам подойдет тип keyword
. Для дальнейшей кастомизации поиска по описанию - тип text
.
И наконец, маппер:
@UtilityClass
public class CityMapper {
public static City map(AddCityRequestBodyDto addCityRequestBodyDto) {
return new City(addCityRequestBodyDto.name(), addCityRequestBodyDto.description());
}
}
Не могу не отметить чудесный инструмент - Elasticvue. Можно скачать desktop-версию, а можно использовать как расширение браузера. Для просмотра содержимого эластика будем пользоваться им.

Теперь добавим пару документов в индекс:
POST /city HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 88
{
"name": "Moscow",
"description": "Кремль, Красная площадь, Москва-сити"
}
POST /city HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 70
{
"name": "Ufa",
"description": "Мед, Шиханы, Три шурупа"
}
Смотрим в Elasticvue:

Как видим, документы успешно добавились. Теперь попробуем поискать:
POST /city/search HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 25
{
"name": "Ufa"
}
Результат:
[
"Мед, Шиханы, Три шурупа"
]
Замечательно! Однако данный способ подходит, если мы ищем по точному названию города, ведь найти уже не получится, если мы, например, поменяем регистр. Теперь попробуем выполнить поиск по содержимому документа - так называемый полнотекстовый поиск. Для этого добавим соответствующий метод в контроллер:
@PostMapping("/searchByDesc")
public ResponseEntity<List<String>> searchByDesc(@RequestBody SearchCityByDescRequestBodyDto searchCityRequestBodyDto) {
List<String> cities = cityService.searchCityByDesc(searchCityRequestBodyDto);
return ResponseEntity.ok(cities);
}
Добавим новую сущность:
public record SearchCityByDescRequestBodyDto(String description) {
}
Новые сервисные методы:
List<String> searchCityByDesc(SearchCityByDescRequestBodyDto searchCityByDescRequestBodyDto);
@Override
public List<String> searchCityByDesc(SearchCityByDescRequestBodyDto searchCityByDescRequestBodyDto) {
String search = searchCityByDescRequestBodyDto.description();
List<City> cities = client.searchCityByDesc(search);
return cities.stream().map(City::description).collect(Collectors.toList());
}
Метод клиента:
@SneakyThrows
public List<City> searchCityByDesc(String description) {
SearchResponse<City> response = client.search(s -> s
.index("city")
.query(q -> q
.match(m -> m
.field("description")
.query(description)
)
), City.class);
return response.hits().hits().stream()
.map(hit -> hit.source())
.toList();
}
Теперь попробуем поискать:
POST /city/searchByDesc HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 32
{
"description": "Три"
}
Результат:
[
"Мед, Шиханы, Три шурупа"
]
Как видим, Elasticsearch отдает нужный ответ, содержащий полное описание. Мало того, работает и поиск с нижним регистром:
POST /city/searchByDesc HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 32
{
"description": "три"
}
Запрос выше даст аналогичный ответ. Чудеса! Однако при поиске по склонению:
POST /city/searchByDesc HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 33
{
"description": "трое"
}
ответ пуст, магии не случилось. Так как же поиск все-таки работает? Для понимания необходимо разобраться с внутренней структурой работы эластика. Пришло время поговорить об анализаторах, фильтрах и токенизации. Elasticsearch анализирует вашу строку и составляет мапу - токены в качестве ключей и ваша исходная строка как значение. В дальнейшем при поиске эластик сравнивает токен (получившийся после трансформации поискового слова анализатором, об этом далее) с токенами-ключами и выдает результат. Сам процесс разбиения на токены происходит в несколько основных этапов:
Character Filters (посимвольная фильтрация)
Tokenizer (токенизация)
Token Filters (фильтрация токенов, включая стемминг)
Все эти этапы описываются в виде единого анализатора с соответствующими настройками, пример мы рассмотрим далее. В нашем случае для индекса city
анализатор мы не прописывали, поэтому используется стандартный анализатор. В стандартном происходит следующее:
Разбиение на токены (Tokenization) — использует стандартный токенизатор (Standard Tokenizer), который разделяет текст на слова по пробелам и знакам пунктуацию.
Приведение к нижнему регистру (Lowercasing) — часть этапа фильтрации, все токены приводятся к нижнему регистру.
Стоит отметить, что анализатор по умолчанию используется как при индексации, так и при поиске. В случае необходимости можно использовать разные для разных этапов.
Таким образом, для Мед, Шиханы, Три шурупа
сопоставятся токены [мед, шиханы, три, шурупа]
.
Помимо стандартного, существуют и другие виды токенизаторов, которые можно использовать. Пример самых популярных:
Whitespace
- Разбивает только по пробелам, не удаляет знаки препинанияLetter
- Разбивает по символам, которые не являются буквамиPattern
- Использует регулярное выражение для разбивкиKeyword
- Не разбивает вообще, возвращает все как один токенngram / edge_ngram
- Используется для автодополнения / частичного поиска
Немного об ngram
токенизаторе. Первым делом он разбивает фразу на отдельные слова, игнорируя знаки препинания, затем каждое слово на все возможные подстроки фиксированной длины, используется для полнотекстового поиска по частям слова (не обязательно с начала слова). Edge-ngram же делает подстроки только с начала слова, используется для поиска по префиксу (например, автокомплит). Дополнительно используются настройки min_gram
и max_gram
для ограничения длины получаемых токенов.
Магию с поиском в другом регистре поняли - слово при поиске также приводится в нижний регистр на этапе фильтрации в дефолтном анализаторе. В дальнейшем поиск по слову три
даст нам наш исходный результат, так как слово входит в токен. Теперь, когда мы разобрались с внутренним процессом токенизации, рассмотрим 2 основных способа, как можно сделать поиск более гибким, например, при опечатках и склонениях.
На этапе post-индесации через fuziness
Fuzziness — это параметр в Elasticsearch, который позволяет находить совпадения даже при опечатках или небольших различиях в словах. Он основан на расстоянии Левенштейна (количество изменений: вставка, удаление, замена символов), необходимых для превращения одного слова в другое. Если установить параметру fuzziness значение 2, Elasticsearch будет искать слова, отличающиеся от запроса не более чем на 2 редактирования. Важно не переборщить со значением, ведь при слово может начать кардинально отличаться от искомого. Таким образом, при проходе по токенам эластик динамически будет сравнивать их со словом поиска с отличиями не более чем в 2 символах. Для этого достаточно просто добавить параметр fuzziness в метод поиска:
@SneakyThrows
public List<City> searchCityByDesc(String description) {
SearchResponse<City> response = client.search(s -> s
.index("city")
.query(q -> q
.match(m -> m
.field("description")
.query(description)
.fuzziness("2")
)
), City.class);
return response.hits().hits().stream()
.map(hit -> hit.source())
.toList();
}
Перезапускаем приложение, и теперь эластик даст нам информацию, даже если мы отправим запрос вида:
POST /city/searchByDesc HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 33
{
"description": "трое"
}
[
"Мед, Шиханы, Три шурупа"
]
Через stemming и словари
2 способ основан на кастомизации анализатора и изменению/добавлению различных словоформ. Из минусов - плохая справляемость с опечатками. Это зависит от задачи и необходимой степени точности, выбирать вам. Здесь 2 основных пути - через stemming или словари. Stemming это преобразование, которое на этапе фильтрации позволяет привести склонение слова к корню.
Взглянем на процесс создания кастомного анализатора. Перед экспериментом обязательно удалите настройку для fuzziness из метода searchCityByDesc! В папке resources
проекта создадим папку elasticsearch
, в нее добавим analyzer-settings.json
, содержимое будет использовать дефолтный стеммер:
{
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "russian_stemmer"]
}
},
"filter": {
"russian_stemmer": {
"type": "stemmer",
"language": "russian"
}
}
}
}
Стоит также отметить, что последовательность в фильтрации важна. То есть, наше описание Мед, Шиханы, Три шурупа
сначала будет разбито стандартным токенизатором на [Мед, Шиханы, Три, Шурупа]
, далее привидется к нижнему регистру и только затем в дело вступит стеммер - он приведет к корням - [мед, шиха, три, шуруп]
. Почему шиха? Название специфичное, и стеммеры порой в этом случае ведут себя непредсказуемо. Посмотреть, на какие токены в итоге разобралась наша исходная строка, можно по адресу
POST /city/_analyze HTTP/1.1
Host: localhost:9200
Content-Type: application/json
Content-Length: 70
{
"field": "description",
"text": "Мед, Шиханы, Три шурупа"
}
{
"tokens": [
{
"token": "мед",
"start_offset": 0,
"end_offset": 3,
"type": "<ALPHANUM>",
"position": 0
},
{
"token": "шиха",
"start_offset": 5,
"end_offset": 11,
"type": "<ALPHANUM>",
"position": 1
},
{
"token": "три",
"start_offset": 13,
"end_offset": 16,
"type": "<ALPHANUM>",
"position": 2
},
{
"token": "шуруп",
"start_offset": 17,
"end_offset": 23,
"type": "<ALPHANUM>",
"position": 3
}
]
}
Допишем в документ и пропишем для поля используемый анализатор:
@Document(indexName = "city")
@Setting(settingPath ="/elasticsearch/analyzer-settings.json"
)
public record City(@Id @Field(type = FieldType.Keyword) String name, @Field(type = FieldType.Text, analyzer = "my_custom_analyzer") String description) {
}
Настройка индекса поменялась, поэтому удалим предыдущий индекс через Elasticsvue и запустим приложение по новой, после чего добавив пару исходных документов. Теперь если попробуем поискать по склонениям, например шурупов
, меда
или шихан
, мы получим нашу строку.
Существует способ с использованием словарей, которые по набору форм и правил приводят слово к корневому, например, Hunspell. Преимущество в том, что вы сами можете добавлять все необходимые вам формы в этот, либо ваш собственный словарь в зависимости от запроса. Вы также можете создавать собственный набор правил, морфологии и исключений.
Скачать словарь можно на просторах интернета, он есть в различных вариантах. Создадим в папке с установленным Elasticsearch директорию \config\hunspell\ru_RU
. Добавим туда
ru_RU.dic
- сам словарьru_RU.aff
- аффиксы (окончания, приставки и т. д.)
Меняем настройки анализатор в analyzer-settings.json
:
{
"analysis": {
"filter": {
"russian_hunspell": {
"type": "hunspell",
"locale": "ru_RU"
}
},
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"russian_hunspell"
]
}
}
}
}
Удаляем индекс, перезапускаем проект и добавляем документ. Этот поиск также позволит искать по склонениям, но в то же время предоставляет больше возможностей для кастомизации конкретных слов и добавлению новых. Например, в словарь можно добавить специфичное название адреса вручную, а также добавить правила для его склонения. В стеммер также можно добавлять определенные правила, но не настолько гибко, как в Hunspell.
Подход со стеммерами и словарями занимает большее время при индексации массивов данных, поэтому следует внимательно выбирать способ поиска, учитывая конкретные требования и задачи.
Выводы

Мы рассмотрели приемы, которые предлагает Elasticsearch для кастомизации поиска, и воспользовались им, используя Spring Data Elasticsearch. Если вам требуется гибкий поиск, который терпим к опечаткам и дающий множество вариантов, можно использовать post-обработку с fuzziness-подходом. Если вам необходим ручной контроль над результатами и поиск должен быть более строгим, с определенными правилами, используйте стеммеры или словари. Надеюсь, что статья была интересной и полезной, до скорых встреч!