Полнотекстовый поиск на 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

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

загрузка...