Полнотекстовый поиск на 100 миллионов документов: от Elasticsearch до собственного решения

Полнотекстовый поиск на 100 миллионов документов: от Elasticsearch до собственного решения

Начальная точка: Elasticsearch из коробки

Когда мы запустили первую версию поиска «ДокуФлоу», она работала на одном-единственном узле Elasticsearch со стандартными настройками. Сначала всё было неплохо: на 5 миллионах документов система вела себя вполне нормально, а на 20 миллионах — ещё худо-бедно работала. Но вот когда количество документов перевалило за 50 миллионов, поиск начал занимать уже 5-8 секунд. А при 100 миллионах кластер просто не выдерживал нагрузки и начинал падать.

Мы быстро осознали, что проблемы были системными и довольно глубокими. Маппинг оказался кривым, анализаторы под русский язык никто толком не оптимизировал, а шарды разрослись до просто гигантских размеров. Ко всему прочему, у нас напрочь отсутствовала какая-либо стратегия масштабирования.

Проектирование индекса и маппинга

Мы решили начать с самого главного — с правильного маппинга. Изначально в системе использовался dynamic mapping, что означало: Elasticsearch сам решал, какого типа должны быть поля. Результат был, к сожалению, предсказуем — число полей раздувалось до невероятных размеров, и мы полностью теряли над ними контроль.

PUT /documents
{
  "settings": {
    "number_of_shards": 10,
    "number_of_replicas": 1,
    "index.mapping.total_fields.limit": 200,
    "analysis": {
      "filter": {
        "russian_stop": {
          "type": "stop",
          "stopwords": "_russian_"
        },
        "russian_stemmer": {
          "type": "stemmer",
          "language": "russian"
        },
        "russian_morphology": {
          "type": "hunspell",
          "locale": "ru_RU",
          "dedup": true
        },
        "synonym_filter": {
          "type": "synonym_graph",
          "synonyms_path": "analysis/synonyms_ru.txt"
        },
        "edge_ngram_filter": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 15
        }
      },
      "analyzer": {
        "russian_full": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "russian_stop",
            "russian_morphology",
            "synonym_filter"
          ]
        },
        "autocomplete_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "edge_ngram_filter"
          ]
        },
        "autocomplete_search": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase"]
        }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "russian_full",
        "fields": {
          "exact": { "type": "keyword" },
          "autocomplete": {
            "type": "text",
            "analyzer": "autocomplete_analyzer",
            "search_analyzer": "autocomplete_search"
          }
        }
      },
      "body": {
        "type": "text",
        "analyzer": "russian_full"
      },
      "document_type": { "type": "keyword" },
      "department": { "type": "keyword" },
      "author": { "type": "keyword" },
      "created_at": { "type": "date" },
      "updated_at": { "type": "date" },
      "tags": { "type": "keyword" },
      "file_size": { "type": "long" },
      "access_level": { "type": "keyword" }
    }
  }
}

Ключевые решения: dynamic: strict запрещает автоматическое создание полей; multi-field маппинг для title позволяет и полнотекстовый поиск, и точное совпадение, и автокомплит; 10 шардов на 100 млн документов дают ~10 млн документов на шард (рекомендуемый потолок — 20-30 ГБ на шард).

Русская морфология: нетривиальная задача

Стандартный стеммер Elasticsearch для русского языка, честно говоря, работает очень топорно. Он просто обрубает окончания по своим правилам, и из-за этого возникают странные коллизии. Например, слова «замок» и «замочек» почему-то попадают в разные основы, а вот «банки» (те, что финансовые) и «банки» (те, что стеклянные) он почему-то сводит к одной основе.

Чтобы решить эту проблему, мы подключили плагин analysis-morphology, который идёт в комплекте с полноценным морфологическим словарём.

# Установка плагина
bin/elasticsearch-plugin install analysis-morphology

# Файл синонимов: analysis/synonyms_ru.txt
# Формат: синоним1, синоним2 => каноническая_форма
договор, контракт, соглашение => договор
счёт, счет, инвойс => счёт
акт, акт_выполненных_работ => акт
ООО, общество_с_ограниченной_ответственностью => ооо
ИП, индивидуальный_предприниматель => ип

Файл синонимов мы собирали вместе с предметными экспертами «ДокуФлоу». В документообороте важна доменная терминология: «допсоглашение» = «дополнительное соглашение», «акт сверки» — это не просто «акт».

Настройка релевантности

По умолчанию Elasticsearch использует алгоритм BM25 для ранжирования результатов. Но для документооборота стандартные веса, если честно, работали так себе. Представьте, контракт на 200 страниц, где запрос встречается всего один-единственный раз, мог вылезти в выдаче выше, чем коротенькое письмо, которое целиком посвящено этой теме.

GET /documents/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "multi_match": {
                "query": "акт выполненных работ ООО Рассвет",
                "fields": [
                  "title^3",
                  "body",
                  "tags^2"
                ],
                "type": "best_fields",
                "fuzziness": "AUTO"
              }
            }
          ],
          "filter": [
            { "term": { "access_level": "public" } },
            { "range": { "created_at": { "gte": "2024-01-01" } } }
          ]
        }
      },
      "functions": [
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale": "90d",
              "decay": 0.5
            }
          },
          "weight": 1.5
        },
        {
          "field_value_factor": {
            "field": "view_count",
            "modifier": "log1p",
            "factor": 0.5
          }
        },
        {
          "script_score": {
            "script": {
              "source": "doc['document_type'].value == 'contract' ? 1.2 : 1.0"
            }
          }
        }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  }
}

Мы настроили: boost для заголовка (x3) и тегов (x2), gaussian decay по дате (свежие документы выше), бонус для контрактов (основной тип документа), и учёт популярности через view_count.

Автокомплит и подсказки

Люди ждут подсказок прямо при вводе. Мы сделали два уровня:

# search_service.py
class SearchService:
    async def autocomplete(self, prefix: str, limit: int = 10) -> list[str]:
        """Быстрый автокомплит по edge_ngram индексу"""
        response = await self.es.search(index='documents', body={
            'size': 0,
            'query': {
                'match': {
                    'title.autocomplete': {
                        'query': prefix,
                        'operator': 'and'
                    }
                }
            },
            'aggs': {
                'title_suggestions': {
                    'terms': {
                        'field': 'title.exact',
                        'size': limit,
                        'order': {'max_score': 'desc'}
                    },
                    'aggs': {
                        'max_score': {'max': {'script': '_score'}}
                    }
                }
            }
        })
        return [
            bucket['key']
            for bucket in response['aggregations']['title_suggestions']['buckets']
        ]

    async def suggest(self, query: str) -> list[dict]:
        """Suggest с исправлением опечаток"""
        response = await self.es.search(index='documents', body={
            'suggest': {
                'text': query,
                'phrase_suggestion': {
                    'phrase': {
                        'field': 'title',
                        'size': 5,
                        'gram_size': 3,
                        'direct_generator': [{
                            'field': 'title',
                            'suggest_mode': 'popular',
                            'min_word_length': 3
                        }],
                        'collate': {
                            'query': {
                                'source': {'match': {'title': '{{suggestion}}'}}
                            },
                            'prune': True
                        }
                    }
                }
            }
        })
        return response['suggest']['phrase_suggestion'][0]['options']

Edge_ngram индекс обеспечивает автокомплит за 10-20 мс. Phrase suggester исправляет опечатки: «довогор поставки» превращается в «договор поставки».

Фасетный поиск и агрегации

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

GET /documents/_search
{
  "size": 20,
  "query": { "match": { "body": "поставка оборудования" } },
  "aggs": {
    "by_type": {
      "terms": { "field": "document_type", "size": 20 }
    },
    "by_department": {
      "terms": { "field": "department", "size": 50 }
    },
    "by_year": {
      "date_histogram": {
        "field": "created_at",
        "calendar_interval": "year"
      }
    },
    "size_stats": {
      "stats": { "field": "file_size" }
    }
  },
  "post_filter": {
    "term": { "document_type": "contract" }
  }
}

Обратите внимание на post_filter: он фильтрует результаты, но не агрегации. Это позволяет показать количество документов во всех категориях, даже если пользователь выбрал фильтр «контракты».

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

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

Кеширование

# Кеш популярных запросов на уровне приложения
class SearchCache:
    def __init__(self, redis_client, ttl=300):
        self.redis = redis_client
        self.ttl = ttl

    async def search(self, query: str, filters: dict) -> dict:
        cache_key = self._make_key(query, filters)
        cached = await self.redis.get(cache_key)
        if cached:
            return json.loads(cached)

        result = await self.es_service.search(query, filters)

        # Кешируем только если запрос популярный (>5 раз за час)
        counter_key = f"query_count:{cache_key}"
        count = await self.redis.incr(counter_key)
        await self.redis.expire(counter_key, 3600)
        if count >= 5:
            await self.redis.set(cache_key, json.dumps(result), ex=self.ttl)

        return result

Routing

Документы одного отдела обычно ищутся вместе. Routing направляет документы одного отдела в один шард, и поиск по отделу обращается к одному шарду вместо десяти:

# Индексация с routing
await es.index(index='documents', id=doc_id, body=doc,
               routing=doc['department'])

# Поиск с routing — обращается к одному шарду
await es.search(index='documents', body=query,
                routing='accounting')

Это дало прирост в 5-7x для поисков внутри отдела.

Масштабирование кластера

Итоговая конфигурация кластера:

# docker-compose.yml (упрощённо, в prod — Kubernetes)
# 3 master-eligible ноды (для кворума)
# 6 data-нод (по 500 ГБ SSD, 64 ГБ RAM, 16 CPU)
# 2 coordinating-only ноды (принимают запросы, агрегируют ответы)
# 2 ingest-ноды (парсинг PDF, DOCX через attachment pipeline)

# elasticsearch.yml для data-ноды
cluster.name: docuflow-search
node.roles: [data]
path.data: /data/elasticsearch
bootstrap.memory_lock: true

# 50% RAM для JVM heap (не больше 31 ГБ для compressed oops)
# Остальные 50% — для filesystem cache (критично для Lucene)

# Индексная стратегия: ILM (Index Lifecycle Management)
# hot:  текущий месяц, SSD, 1 реплика
# warm: 1-12 месяцев, HDD, 1 реплика
# cold: >12 месяцев, HDD, 0 реплик (данные есть в бекапе)
PUT _ilm/policy/documents_policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_size": "50gb",
            "max_age": "30d"
          }
        }
      },
      "warm": {
        "min_age": "30d",
        "actions": {
          "shrink": { "number_of_shards": 2 },
          "forcemerge": { "max_num_segments": 1 },
          "allocate": {
            "require": { "data": "warm" }
          }
        }
      },
      "cold": {
        "min_age": "365d",
        "actions": {
          "allocate": {
            "number_of_replicas": 0,
            "require": { "data": "cold" }
          }
        }
      }
    }
  }
}

Когда Elasticsearch не хватило: ML-ранжирование

BM25 с function_score покрывает 90% случаев. Но оставшиеся 10% — это запросы, где релевантность определяется не текстовым совпадением, а контекстом: какие документы пользователь открывал раньше, какой у него отдел, какие документы смотрели коллеги с похожим запросом.

Мы добавили второй этап ранжирования (re-ranking) на базе LightGBM:

# reranker/model.py
import lightgbm as lgb
import numpy as np

class SearchReranker:
    def __init__(self, model_path: str):
        self.model = lgb.Booster(model_file=model_path)

    async def rerank(self, query: str, es_results: list[dict],
                     user_context: dict) -> list[dict]:
        features = []
        for doc in es_results:
            feature_vector = self._extract_features(query, doc, user_context)
            features.append(feature_vector)

        scores = self.model.predict(np.array(features))

        # Комбинируем ES score и ML score
        for doc, ml_score in zip(es_results, scores):
            doc['final_score'] = (
                0.4 * self._normalize(doc['_score']) +
                0.6 * ml_score
            )

        return sorted(es_results, key=lambda d: d['final_score'], reverse=True)

    def _extract_features(self, query, doc, user_context):
        return [
            doc['_score'],                          # ES BM25 score
            len(query.split()),                     # длина запроса
            doc['_source'].get('view_count', 0),    # популярность
            self._days_since(doc['_source']['created_at']),  # возраст
            1 if doc['_source']['department'] == user_context['department'] else 0,
            self._query_doc_overlap(query, doc['_source']['title']),
            user_context.get('query_history_similarity', 0),
        ]

Модель обучается на кликах пользователей: если после поискового запроса пользователь открыл документ и провёл в нём больше 30 секунд — это позитивный сигнал. Обучение происходит еженедельно на свежих данных.

Гибридный поиск: векторы + ключевые слова

И наконец, последний, но очень важный штрих — мы внедрили векторный поиск для семантического сопоставления. Представьте, пользователь вбивает «компенсация ущерба при просрочке», а в документе фигурирует формулировка «штрафные санкции за нарушение сроков». В такой ситуации BM25, к сожалению, пасует, но векторный поиск успешно находит нужные результаты.

# Добавляем dense_vector поле в маппинг
PUT /documents/_mapping
{
  "properties": {
    "body_embedding": {
      "type": "dense_vector",
      "dims": 768,
      "index": true,
      "similarity": "cosine"
    }
  }
}

# Гибридный запрос (ES 8.x)
GET /documents/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "multi_match": {
            "query": "компенсация ущерба при просрочке",
            "fields": ["title^3", "body"],
            "boost": 0.7
          }
        },
        {
          "knn": {
            "field": "body_embedding",
            "query_vector": [0.12, -0.34, ...],
            "num_candidates": 100,
            "boost": 0.3
          }
        }
      ]
    }
  }
}

Для генерации эмбеддингов мы используем модель intfloat/multilingual-e5-base, запущенную на GPU-сервере. Индексация 100 млн документов заняла 3 дня на одном A100.

Инфраструктурные затраты

КомпонентКонфигурацияСтоимость/мес
Data-ноды (6 шт.)16 CPU, 64 ГБ RAM, 500 ГБ SSD180 000 руб.
Master-ноды (3 шт.)4 CPU, 16 ГБ RAM, 100 ГБ SSD27 000 руб.
Coordinating (2 шт.)8 CPU, 32 ГБ RAM24 000 руб.
Ingest (2 шт.)8 CPU, 16 ГБ RAM16 000 руб.
GPU-сервер (эмбеддинги)A100, 40 ГБ VRAM95 000 руб.
Итого342 000 руб.

Бенчмарки производительности

ЗапросДо оптимизацииПосле
Простой поиск по ключевому слову5.2 с85 мс
Поиск с фильтрами (тип + отдел + дата)8.1 с120 мс
Автокомплит2.3 с15 мс
Фасетный поиск с агрегациями12.4 с250 мс
Гибридный поиск (keyword + vector)N/A340 мс
Поиск внутри отдела (с routing)3.8 с35 мс

Все замеры — это p95 при нагрузке 200 запросов в секунду.

Выводы

Elasticsearch — штука мощная, но «из коробки» поиск на больших объёмах он не вытягивает. Что мы для себя вынесли:

  • Маппинг и анализаторы — 50% успеха. Настраивайте их под свой домен с первого дня.
  • Русская морфология требует специального плагина и ручной работы с синонимами.
  • Routing даёт кратный прирост, если есть естественное разбиение данных.
  • BM25 достаточно для 90% запросов. ML-ранжирование — для оставшихся 10%, но именно они определяют восприятие качества поиска.
  • Векторный поиск — не замена, а дополнение к полнотекстовому. Гибридный подход даёт лучший результат.

Если вашему продукту нужен качественный поиск — свяжитесь с нами. Мы поможем спроектировать решение под ваши объёмы и требования.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#devops#elasticsearch#mlранжирование#автокомплит#агрегации#бенчмарки#векторы#выводы
Комментарии 0

Оставить комментарий

загрузка...

Подпишитесь на рассылку ITfresh

Раз в неделю — практические гайды для руководителя IT и сисадмина: безопасность, 1С, миграции, резервные копии, лайфхаки из реальных проектов.

Реквизиты оператора персональных данных

ООО «АЙТИ-ФРЕШ», ИНН 7719418495, КПП 771901001. Юридический адрес: 105523, г. Москва, Щёлковское шоссе, д. 92, корп. 7. Контакт: info@itfresh.ru, +7 903 729-62-41. Оператор обрабатывает e-mail подписчика в целях рассылки информационных и рекламных материалов до момента отзыва согласия.