Полнотекстовый поиск в OpenSearch: запросы, анализаторы и подсветка результатов

Как устроен полнотекстовый поиск в OpenSearch: типы запросов, анализаторы для русского и английского языков, подсветка результатов, оптимизация релевантности и сравнение с полнотекстовым поиском в PostgreSQL и MongoDB

OpenSearch — не только хранилище логов. В предыдущих статьях серии кластер собирал данные, а индексы и маппинги задавали им схему. Но ядро OpenSearch — это полнотекстовый поисковый движок на базе Lucene, и именно ради него систему в большинстве случаев и разворачивают. Просто положить документы в индекс и отправить match-запрос — работает уже на первом занятии, но за этой простотой скрывается конвейер, от которого зависит, найдётся ли документ вообще: анализ текста, токенизация, разбор запроса на термы, скоринг по BM25.

В этой статье — то, что происходит между текстом поля и найденным документом. Сначала — pipeline анализа и разница между text и keyword, дальше — как один и тот же текст разбирается на термы стандартным анализатором и анализатором для конкретного языка, с реальными токенами standard и ru_custom/en_custom на одном стенде. Типы запросов, релевантность, подсветка, пагинация, агрегации и сравнение с FTS в PostgreSQL и MongoDB — в следующих разделах статьи.

Полнотекстовый поиск в OpenSearch

О версии. Примеры в статье проверены на OpenSearch 3.5.0 (Lucene 10.3.2) — том же стенде, что и в предыдущих статьях серии. Воспроизводимый стенд — анализаторы, запросы, релевантность и сравнение с PostgreSQL/MongoDB на одних данных — лежит в digital-cookbook, opensearch/fulltext/.

В статье

Под капотом

Полнотекстовый поиск в OpenSearch работает быстро не потому, что кластер перебирает документы один за другим в поисках совпадения, а потому, что при индексировании строится структура, которая сразу отвечает на вопрос «в каких документах встречается этот терм» — инвертированный индекс (inverted index). Вместо привычного «документ → его слова» здесь обратное отображение: «слово (терм) → список документов, где оно встречается», плюс позиции внутри документа для фразового поиска и частоты для скоринга. Запрос match на слово «кластер» не сканирует весь индекс — он идёт напрямую к записи термина «кластер» в этой структуре и получает уже готовый список документов-кандидатов. Поиск по большому индексу остаётся быстрым не за счёт мощности железа, а за счёт того, что алгоритмическая сложность поиска не растёт линейно с числом документов.

Но инвертированный индекс строится не из сырого текста поля, а из термов — того, что получилось после анализа. Анализ — это конвейер из трёх стадий, через который проходит содержимое каждого text-поля перед тем, как попасть в индекс:

flowchart LR T["Текст поля"] --> CF["char filters"] --> TK["tokenizer"] --> TF["token filters (lowercase, stemming, stop)"] --> I["термы в inverted index"]

flowchart LR
    T["Текст поля"] --> CF["char filters"] --> TK["tokenizer"] --> TF["token filters (lowercase, stemming, stop)"] --> I["термы в inverted index"]
Pipeline анализа текста: от строки до термов в индексе

Character filters работают первыми и ещё над сырой строкой целиком — до разбиения на слова: могут вырезать HTML-теги, заменить символы по регулярному выражению или отобразить один набор символов в другой. Tokenizer — единственный обязательный элемент конвейера — режет строку на отдельные токены; standard tokenizer в Lucene делает это по границам слов согласно Unicode Text Segmentation (пробелы, знаки пунктуации, с поправкой на то, что не всякий скрипт использует пробелы как разделитель слов). Token filters идут последними и работают уже с потоком токенов: приводят к нижнему регистру (lowercase), выбрасывают стоп-слова (stop), приводят словоформы к основе (stemmer) — этот последний фильтр и есть то место, где язык текста начинает иметь значение, и ему посвящён следующий раздел.

Важно, что этот же самый pipeline анализа применяется не только при индексировании документа, но и — если явно не указано иное — при разборе поискового запроса к text-полю: строка "поиск по большим объёмам" в теле match-запроса проходит через тот же анализатор, что и текст при индексации, и матчинг происходит уже между термами, а не между сырыми строками. Отсюда прямо следует, почему в OpenSearch два принципиально разных типа полей для строк. text — анализируется, хранится как набор термов в инвертированном индексе, подходит для полнотекстового поиска по словам и словоформам, но не хранит исходную строку целиком для точного сравнения. keyword — анализу не подвергается, индексируется как единое неделимое значение и подходит для точных совпадений, сортировки, агрегаций и фильтров (category: "operations" — либо совпадает целиком, либо нет). Поиск словом «поиск» по keyword-полю не найдёт документ со значением «Полнотекстовый поиск и релевантность» — совпадения по подстроке там нет, всё поле — один терм. Именно поэтому в маппинге из предыдущей статьи серии текстовое поле вроде title объявляют как мульти-поле: основной text для полнотекстового поиска плюс .keyword-субполе для точных фильтров и сортировки по тому же значению.

Отсюда и последнее наблюдение раздела: анализатор для text-поля выбирается до индексирования, а не подгоняется под запросы позже. Термы уже записаны в инвертированный индекс к моменту первого поиска, и если для body при индексации использовался анализатор без стемминга, а искать нужно по словоформам — придётся переиндексировать данные с новым маппингом, поменять анализатор существующего поля «на лету» нельзя. Отсюда следующий раздел: чем конкретно отличаются анализаторы для разных языков и почему выбор анализатора — это решение, которое принимается на этапе проектирования схемы, а не в момент, когда поиск не находит ожидаемый документ.

Анализаторы: русский и английский

Разницу между анализатором «по умолчанию» и анализатором, знающим язык текста, проще всего увидеть напрямую — через _analyze API, который прогоняет строку через конвейер и возвращает список токенов, не трогая никакой индекс. Вот реальный вывод стенда 3.5.0 (индекс articles, анализаторы ru_custom/en_custom из его маппинга) на одном и том же русском тексте.

POST /articles/_analyze
{
  "analyzer": "standard",
  "text": "поиск по большим объёмам логов"
}

Стандартный анализатор (только токенизация по границам слов плюс lowercase, без стемминга и без стоп-слов) отдаёт токены практически без изменений:

поиск | по | большим | объёмам | логов

Тот же текст через ru_custom (в маппинге индекса — standard tokenizer + lowercase + ru_stop + ru_stemmer):

POST /articles/_analyze
{
  "analyzer": "ru_custom",
  "text": "поиск по большим объёмам логов"
}
поиск | больш | объем | лог

Разница видна сразу в двух местах. Во-первых, предлог «по» исчез целиком — это сработал стоп-фильтр: предлоги, союзы и частицы почти никогда не несут поискового смысла, но зато встречаются в каждом втором документе и без пользы раздувают инвертированный индекс. Во-вторых, оставшиеся три слова стянулись к основе: «большим» → «больш», «объёмам» → «объем» (стеммер заодно нормализует «ё» в «е» — частый источник промахов при поиске, если этого не делать), «логов» → «лог». Стемминг — это и есть ответ на вопрос «почему поиск по слову „поиск“ находит документ, где написано „поиска“»: обе словоформы после стемминга превращаются в один и тот же терм, и совпадение термов в инвертированном индексе происходит независимо от падежа и числа в исходном тексте.

Тот же эффект на английском тексте, только с en_custom (standard tokenizer + lowercase + en_stop + en_stemmer) вместо ru_custom:

POST /articles/_analyze
{
  "analyzer": "standard",
  "text": "Indexing and searching documents"
}
indexing | and | searching | documents
POST /articles/_analyze
{
  "analyzer": "en_custom",
  "text": "Indexing and searching documents"
}
index | search | document

Союз «and» ушёл стоп-фильтром, а стемминг привёл все три оставшихся слова к основе: «indexing» → «index», «searching» → «search», «documents» → «document». Механика та же, что и в русском примере, только словарь стоп-слов и алгоритм стемминга — свои под язык: ru_stop/ru_stemmer и en_stop/en_stemmer в маппинге — это кастомные фильтры поверх встроенных в Lucene языковых пресетов (словарь стоп-слов _russian_/_english_ и стеммер соответствующего языка), а не универсальный алгоритм, одинаково работающий для любого языка. Смешать их местами (применить английский стеммер к русскому тексту или наоборот) не даст ошибки — конвейер отработает, — но термы на выходе будут почти случайными, и поиск по словоформам перестанет работать молча, без всякого сообщения об ошибке.

Стандартный анализатор без языковых фильтров — не всегда «хуже», это осознанный компромисс: он не делает предположений о языке текста и одинаково (посредственно) работает на любом контенте, что уместно для смешанных или неизвестных заранее языков, коротких идентификаторов, кода. Но для контента на известном языке — статей, логов на естественном языке, описаний товаров — языковой анализатор вроде ru_custom/en_custom почти всегда даёт заметно более полезный поиск: меньше «мусорных» термов от стоп-слов, больше совпадений за счёт стемминга.

Кроме языковых стеммеров есть ещё два инструмента анализа, которые в реальных схемах встречаются рядом с ru/en-анализаторами. ICU analyzer (плагин analysis-icu) решает не морфологию, а нормализацию на уровне Unicode: приводит текст к каноничной форме до токенизации, что важно для языков с несколькими способами закодировать визуально одинаковый символ (например, композитные и декомпозитные формы диакритических знаков) — без ICU-нормализации два «одинаковых» на вид символа могут оказаться разными кодовыми точками и, соответственно, разными термами. edge_ngram — token filter другого назначения: не для полнотекстового поиска по смыслу, а для автодополнения по префиксу. Он режет каждый токен на все его начальные подстроки ("поиск"по, пои, поис, поиск при min_gram/max_gram в диапазоне, скажем, 2–5), и тогда запрос из трёх введённых пользователем символов совпадает с термом-префиксом сразу на этапе набора, ещё до того, как слово введено целиком:

{
  "settings": {
    "analysis": {
      "filter": {
        "edge_ngram_filter": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 15
        }
      },
      "analyzer": {
        "autocomplete": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "edge_ngram_filter"]
        }
      }
    }
  }
}

Здесь важна асимметрия, которую легко упустить: edge_ngram-анализатор применяют при индексировании (поле генерирует все префиксы), а при поиске используют обычный standard-анализатор без нарезки на n-граммы, иначе запрос сам раздробится на префиксы и подсчёт релевантности исказится. На практике для этого заводят отдельное мульти-поле (title.autocomplete) с edge_ngram только на стороне индекса — но это уже частность конкретной схемы автодополнения, не тема этого раздела.

Типы запросов

Термы в инвертированном индексе готовы, анализаторы разобраны — дальше вопрос в том, каким запросом до этих термов добраться. У OpenSearch (как и у Lucene под ним) единого «поиска» нет: есть семейство типов запросов, каждый со своей семантикой матчинга и своим вкладом в скоринг. Ниже — реальные результаты на демонстрационном индексе articles (6 статей, id 1–6) с уже знакомыми по предыдущему разделу анализаторами ru_custom/en_custom.

match — базовый полнотекстовый запрос: строка проходит через анализатор поля, термы ищутся в инвертированном индексе, документ попадает в выдачу, если совпал хотя бы один терм (по умолчанию OR между термами запроса). Запрос поиск по полю body:

POST /articles/_search
{
  "query": {
    "match": {
      "body": "поиск"
    }
  }
}
id=2 score=0.6845  Полнотекстовый поиск и релевантность
id=6 score=0.4981  Мониторинг и наблюдаемость кластера

Второй документ (id=6) в выдаче не потому, что в тексте буквально встречается слово «поиск» — там написано «заметить деградацию поиска». Но ru_custom при индексации привёл «поиска» к той же основе, что и «поиск» в запросе, термы совпали — и это ровно тот эффект стемминга, который разбирался в предыдущем разделе, только теперь на реальном матчинге, а не в выводе _analyze.

match_phrase — то же самое, но с требованием, чтобы термы шли в документе подряд и в том же порядке (используются позиции токенов, которые инвертированный индекс хранит наряду со списком документов). Запрос "полнотекстовый поиск":

POST /articles/_search
{
  "query": {
    "match_phrase": {
      "body": "полнотекстовый поиск"
    }
  }
}
id=2 score=1.2798  Полнотекстовый поиск и релевантность

Документ id=6 из выборки по одиночному match здесь закономерно пропал — «деградацию поиска» не образует фразу «полнотекстовый поиск», а match_phrase не разбивает требование порядка на «хотя бы одно совпадение», как это делал match. Заметьте и то, что score у match_phrase (1.2798) выше, чем у match на том же документе (0.6845). Из этого не стоит делать вывод, что фраза даёт «бонус» к релевантности: это два разных запроса с разными наборами термов, и их абсолютные score между собой напрямую не сопоставимы — фразовое совпадение по умолчанию не добавляет к BM25 отдельного множителя «за фразу». Как вообще получается число вроде 0.6845 — в следующем разделе.

multi_match — тот же match, но сразу по нескольким полям, с возможностью задать вес каждому через ^N. Запрос "загрузка данных" по title (вес ^2) и body:

POST /articles/_search
{
  "query": {
    "multi_match": {
      "query": "загрузка данных",
      "fields": ["title^2", "body"]
    }
  }
}
id=1 score=2.5890  Массовая загрузка данных через bulk API   (совпадение в title, вес x2)
id=3 score=0.7785  Репликация и отказоустойчивость кластера
id=6 score=0.7452  Мониторинг и наблюдаемость кластера

Разрыв между id=1 и остальными двумя — не только в том, что первый документ совпал по обоим полям, а в первую очередь в весе title^2: совпадение в заголовке для этого запроса вдвое важнее, чем такое же совпадение в теле. Про boost как самостоятельный инструмент управления релевантностью — в следующем разделе.

bool — композитный запрос, комбинирующий несколько условий через must (обязательно, влияет на score), filter (обязательно, не влияет на score и кешируется), should (не обязательно, но повышает score при совпадении) и must_not (исключение). Запрос: обязательно body содержит «кластер», обязательно category равна operations (фильтр), и дополнительно поднимается score, если в body есть «реплики»:

POST /articles/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "body": "кластер" } }
      ],
      "filter": [
        { "term": { "category": "operations" } }
      ],
      "should": [
        { "match": { "body": "реплики" } }
      ]
    }
  }
}
id=3 score=1.5547  Репликация и отказоустойчивость кластера   (should «реплики» поднял score)
id=6 score=0.4981  Мониторинг и наблюдаемость кластера

Оба документа прошли must и filter (иначе их не было бы в выдаче вовсе), но id=3 вырвался вперёд именно за счёт should: он единственный, где встречается и «кластер», и «реплики», и второе совпадение добавилось к общему score. Обратите внимание на роль filter: category: operations — точная фильтрация по keyword-полю, а не полнотекстовый поиск, и правильное место для неё — filter, а не must, потому что булев факт «подходит категория» не должен участвовать в скоринге и грамотно кешируется движком отдельно от полнотекстовой части запроса.

fuzzy — терпимость к опечаткам: совпадение ищется не только по точному терму, но и по термам на заданном расстоянии Левенштейна (fuzziness: "AUTO" сам выбирает допустимую дистанцию по длине слова — обычно 1 правку для коротких слов и 2 для длинных). Запрос с опечаткой «кластор» вместо «кластер»:

POST /articles/_search
{
  "query": {
    "match": {
      "body": {
        "query": "кластор",
        "fuzziness": "AUTO"
      }
    }
  }
}
id=3 score=0.4460  Репликация и отказоустойчивость кластера
id=6 score=0.4269  Мониторинг и наблюдаемость кластера

Терм «кластор» после ru_custom-стемминга не совпадает с термом «кластер» точно, но отличается от него на одну букву — и fuzzy (через match с параметром fuzziness) находит оба тех же документа, что нашёл бы точный запрос на «кластер», просто со сниженным score из-за приближённого совпадения. Полезно для опечаток пользователя, но не бесплатно: fuzzy дороже точного match, потому что вместо прямого попадания в терм движку приходится перебирать термы в пределах дистанции редактирования, и на широких запросах (короткие или частые термы) это может заметно просаживать производительность — использовать точечно, а не как поведение по умолчанию для всех полей.

Отдельно стоит сказать, когда не тянуться за wildcard и regexp, даже если их API выглядит соблазнительно простым для «поиска по части слова». Оба запроса работают не по анализированным термам семантически, а по буквальному сопоставлению с термами в индексе: wildcard со шаблоном вроде "класт*" или regexp с полноценным регулярным выражением не проходят через стемминг и не понимают словоформы — под капотом это перебор термов словаря индекса на соответствие шаблону, и чем шаблон менее селективен слева (особенно ведущий *, как в "*кластер"), тем дороже запрос: в худшем случае это сканирование всего словаря термов, а не быстрый переход к готовой записи инвертированного индекса, на котором строится обычный match. На больших индексах такие запросы — частая причина деградации кластера под нагрузкой без видимой на первый взгляд причины. Для типичной задачи «найти по началу слова, пока пользователь печатает» правильный инструмент — не wildcard, а edge_ngram-поле, разобранное в предыдущем разделе, или match_phrase_prefix (тот же фразовый матчинг, что у match_phrase, но последний терм запроса трактуется как префикс) — оба используют обычный механизм инвертированного индекса вместо полного перебора словаря. wildcard/regexp оправданы для точечных технических полей — keyword-поле с версией пакета, путём файла, кодом ошибки, — а не как замена полнотекстовому поиску по содержимому статьи.

Релевантность

Каждый результат в примерах выше пришёл с числом — score. За этим числом стоит конкретная формула, и понимание того, как она считается, отвечает на практический вопрос: почему один документ выше другого и как на это влиять осознанно, а не подбором запросов наугад.

По умолчанию OpenSearch скорит совпадения алгоритмом BM25 (Best Matching 25) — эволюцией классического TF-IDF, ставшей стандартом де-факто для полнотекстового поиска. Для терма t в документе d вклад в score раскладывается на две части, idf и tf, перемноженные (плюс boost, если он задан). Вот как это выглядит на реальном документе: _explain для match(body: "поиск") на документе id=2 из первого запроса раздела «Типы запросов» (там его score был 0.6845):

GET /articles/_explain/2
{
  "query": {
    "match": {
      "body": "поиск"
    }
  }
}
0.6845  weight(body:поиск in 1), result of:
  0.6845  score(freq=2.0), computed as boost * idf * tf from:
    1.0296  idf = log(1 + (N - n + 0.5)/(n + 0.5))
      2.0  n  — документов с термом
      6.0  N  — всего документов с полем
    0.6649  tf = freq/(freq + k1*(1 - b + b*dl/avgdl))
      2.0  freq — вхождений терма в документ
      1.2  k1 — параметр насыщения
      0.75 b  — нормализация по длине

Читается это так. idf (inverse document frequency, обратная частота документа) отвечает на вопрос «насколько редкий этот терм в индексе»: в демонстрационном индексе всего N = 6 документов с полем body, терм «поиск» (после стемминга) встречается в n = 2 из них, и по формуле log(1 + (N - n + 0.5)/(n + 0.5)) это даёт idf ≈ 1.0296. Смысл — чем реже терм встречается по индексу в целом, тем больше он говорит о содержимом документа, где найден, и тем выше его вес; терм, встречающийся в каждом документе, почти не помогает отличить релевантный документ от нерелевантного и получил бы idf, близкий к нулю.

tf (term frequency, частота терма) отвечает на вопрос «насколько сильно этот терм характеризует именно этот документ» — но не линейно от числа вхождений, а с насыщением: tf = freq/(freq + k1·(1 - b + b·dl/avgdl)). В _explain выше терм «поиск» встретился в документе freq = 2 раза, параметр насыщения k1 = 1.2 (стандартное значение), параметр нормализации по длине b = 0.75 (тоже стандартное значение) — вместе это даёт tf ≈ 0.6649. Ключевая идея BM25 в этой формуле — насыщение: если терм встречается не 2, а 20 раз, tf не вырастет в 10 раз, а выйдет на плато — BM25 сознательно не даёт документу с искусственно раздутой плотностью ключевого слова неограниченно доминировать в выдаче (в отличие от «наивного» TF-IDF без насыщения). Параметр b отвечает за вторую часть формулы — нормализацию по длине поля: dl (длина текущего документа) относительно avgdl (средняя длина по индексу) уменьшает tf, если документ длиннее среднего (терм в нём «размывается» большим объёмом текста вокруг), и наоборот. При b = 0 этой нормализации нет вовсе, при b = 1 — она максимальна; 0.75 — компромиссное значение по умолчанию.

Итоговый score документа id=2 — idf · tf = 1.0296 · 0.6649 ≈ 0.6845, ровно то число, что было в выдаче match-запроса из предыдущего раздела. Для запроса из нескольких термов score по каждому терму суммируется (плюс сложение по полям для bool/multi_match, разобранное там же).

boost — прямой рычаг влияния на итоговый score без изменения самой формулы BM25: множитель, который применяется к вкладу конкретного запроса или поля. Именно boost стоит за разрывом в примере multi_match из предыдущего раздела: "title^2" домножает score от совпадения в title на 2 относительно совпадения в body — не потому что заголовок длиннее или терм в нём реже, а потому что это явное решение «совпадение в заголовке важнее». boost можно задать на уровне отдельного запроса внутри bool ("match": {"body": "кластер", "boost": 2}), на уровне поля в multi_match, или использовать как множитель в function_score, о котором ниже.

BM25 (с учётом boost) отвечает на вопрос «насколько текст документа релевантен тексту запроса», но ничего не знает о метаданных документа — его дате, популярности, статусе. Когда в ранжирование нужно добавить сигнал за пределами текста, используется function_score: запрос оборачивает базовый BM25-score и модифицирует его дополнительной функцией, а способ комбинации задаёт boost_mode (multiply, sum, replace и другие). Частный случай — decay-функции (gauss, exp, linear) для затухания score по удалённости числового или date-поля от заданной точки — типичный сценарий: «релевантный, но старый документ не должен всегда обходить свежий».

На демонстрационном индексе это видно на контрасте до и после. Обычный bool-запрос must(body:кластер) из раздела «Типы запросов» без учёта даты давал порядок id=3 (1.5547) ≫ id=6 (0.4981) — документ про репликацию (опубликован 2026-02-20) уверенно обходил документ про мониторинг (опубликован 2026-06-01) чисто по текстовому совпадению. Оборачиваем тот же match(body:кластер) в function_score с gauss-затуханием по published_at (origin: "2026-07-03" — точка отсчёта, scale: "90d" — за сколько дней score падает до decay, decay: 0.5 — во сколько раз падает score на расстоянии scale) и умножаем (boost_mode: multiply) на базовый BM25:

POST /articles/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": { "body": "кластер" }
      },
      "functions": [
        {
          "gauss": {
            "published_at": {
              "origin": "2026-07-03",
              "scale": "90d",
              "decay": 0.5
            }
          }
        }
      ],
      "boost_mode": "multiply"
    }
  }
}
id=6 score=0.4563  pub=2026-06-01  Мониторинг и наблюдаемость кластера
id=3 score=0.1145  pub=2026-02-20  Репликация и отказоустойчивость кластера

Порядок переворачивается полностью: id=6 (свежее, но текстово менее релевантен) обходит id=3 (текстово более релевантен, но опубликован на четыре с лишним месяца раньше точки отсчёта — за пределами scale, где gauss уже заметно просадил множитель). Это не «правильный» или «неправильный» результат сам по себе — это осознанный выбор, на что настраивается формула ранжирования: чистый BM25 отвечает на вопрос «насколько точно текст совпал с запросом», function_score с decay отвечает на вопрос «насколько точно совпал текст и насколько давно это было», и то, какой ответ нужен продукту — новостной ленте, где свежее почти всегда важнее, или архиву документации, где дата почти не должна влиять на ранжирование, — решается на уровне бизнес-требований, а не техники. Инструмент один и тот же, разница в том, что помимо BM25 в него добавили.

Подсветка результатов

Score и _explain объясняют, почему документ попал в выдачу, но пользователю это число не показывают — ему нужно увидеть, в каком месте текста нашлось совпадение. За это отвечает highlighting: OpenSearch возвращает не только сам документ, но и короткие фрагменты его текста с найденными термами, обёрнутыми в теги. Запрос match(body: "поиск") из раздела «Типы запросов» с добавленным блоком highlight по тому же полю:

POST /articles/_search
{
  "query": {
    "match": { "body": "поиск" }
  },
  "highlight": {
    "fields": {
      "body": {}
    }
  }
}
id=2 score=0.6845
  Полнотекстовый <em>поиск</em> находит документы по словам и их формам, а не по точному совпадению строки.
  Правильный анализатор для языка критично влияет на качество <em>поиска</em>.
id=6 score=0.4981
  Своевременные алерты помогают заметить деградацию <em>поиска</em> до жалоб пользователей.

Обратите внимание на doc2: подсвечены обе формы слова — «поиск» и «поиска», хотя в запросе было только «поиск». Highlighter не сравнивает исходную строку запроса с исходным текстом документа посимвольно — он работает с уже проанализированными термами, точно так же, как сам матчинг из раздела «Типы запросов». «Поиска» после ru_custom-стемминга превращается в тот же терм, что и «поиск», термы совпадают — и highlighter подсвечивает оба вхождения в исходном (неанализированном) тексте, которые дали это совпадение. Это прямое следствие анализа текста, разобранного в начале статьи: подсветка настолько же «умная» в вопросах словоформ, насколько умён анализатор поля.

По умолчанию OpenSearch использует highlighter unified — именно он отработал в примере выше без явного указания типа. Он строит фрагменты на основе позиций термов, которые инвертированный индекс и так хранит для фразового поиска, и не требует от поля никакой дополнительной настройки в маппинге. Кроме unified, доступны ещё два типа.

plain — самый старый highlighter, пересобранный вокруг Lucene Highlighter: заново прогоняет текст документа через анализатор поля в момент запроса, чтобы найти совпадения. Это делает его точным на простых случаях, но дорогим на больших полях (body статьи, лог целиком) — переанализ на каждый highlight в каждом хите не бесплатен, и на полях с быстрым слайсингом текста через no_match_size или сложными запросами (bool с несколькими условиями) он ведёт себя менее предсказуемо, чем unified.

fvh (fast vector highlighter) — самый быстрый вариант для больших текстовых полей, но с ценой на стороне маппинга: поле обязано хранить term_vector: with_positions_offsets, иначе fvh работать не будет.

{
  "mappings": {
    "properties": {
      "body": {
        "type": "text",
        "analyzer": "ru_custom",
        "term_vector": "with_positions_offsets"
      }
    }
  }
}

Term vector — это заранее посчитанные и сохранённые в индексе позиции и смещения каждого терма поля; fvh читает их напрямую вместо повторного анализа текста при каждом запросе highlight, поэтому и выигрывает в скорости на длинных полях с большим числом хитов. Плата — лишнее место в индексе на каждый документ (term vector хранится для всех документов с этим полем, используется он там или нет) и то, что включить fvh задним числом на уже заполненном индексе нельзя: как и с анализатором, term_vector — часть маппинга поля, и для существующих данных потребуется переиндексация. Разумное правило: unified — вариант по умолчанию для большинства статей и логов среднего размера, fvh — осознанный выбор для полей, где highlight запрашивается часто и на объёмном тексте, а экономия на CPU оправдывает лишний term vector в индексе.

Внешний вид подсветки настраивается независимо от типа highlighter’а. Теги по умолчанию — <em> (как в примере выше), но их можно заменить, например, на <mark>, привычный по браузерным Ctrl+F:

POST /articles/_search
{
  "query": {
    "match": { "body": "поиск" }
  },
  "highlight": {
    "pre_tags": ["<mark>"],
    "post_tags": ["</mark>"],
    "fields": {
      "body": {
        "number_of_fragments": 2,
        "fragment_size": 150
      }
    }
  }
}

number_of_fragments ограничивает, сколько отдельных фрагментов текста вернётся на поле (0 — вернуть всё поле целиком с подсветкой вместо нарезки на фрагменты, что имеет смысл на коротких полях вроде title, но не на объёмном body). fragment_size задаёт примерный размер каждого фрагмента в символах — highlighter старается не разрывать фрагмент посреди слова, поэтому итоговая длина плавает вокруг заданного числа, а не совпадает с ним точно. Оба параметра — компромисс между «показать пользователю достаточно контекста вокруг совпадения» и «не превратить сниппет в половину статьи».

Подсветку можно запросить сразу по нескольким полям в одном ответе — например, и title, и body, если запрос был multi_match по обоим:

POST /articles/_search
{
  "query": {
    "multi_match": {
      "query": "загрузка данных",
      "fields": ["title^2", "body"]
    }
  },
  "highlight": {
    "fields": {
      "title": {},
      "body": { "number_of_fragments": 1 }
    }
  }
}

Каждое поле в блоке fields настраивается независимо: title короткий, и его чаще подсвечивают целиком (number_of_fragments: 0 или просто дефолт для коротких полей), а body разумно ограничить одним-двумя фрагментами — иначе ответ раздувается совпадениями из середины длинного текста, которые пользователю нужны в последнюю очередь.

Пагинация

Выдача из _search по умолчанию возвращает 10 документов, и для просмотра «следующей страницы» напрашивается очевидный инструмент — from/size: from задаёт смещение, size — сколько документов вернуть с этого смещения. На демонстрационном индексе, отсортированном по published_at по убыванию, страница 1 (from=0, size=2) и страница 2 (from=2, size=2):

POST /articles/_search
{
  "query": { "match_all": {} },
  "sort": [{ "published_at": "desc" }],
  "from": 2,
  "size": 2
}
Страница 1 (from=0, size=2): id=6, id=5
Страница 2 (from=2, size=2): id=4, id=2

Для нескольких первых страниц from/size работает без проблем и остаётся самым простым способом пагинации там, где он уместен. Проблема — в том, как устроен запрос под капотом в распределённом индексе: каждый шард не отдаёт координирующему узлу только нужный кусок, а обязан посчитать и отсортировать у себя from + size документов, отправить их координатору, и только там результаты со всех шардов сливаются и обрезаются до финальных size. При from=10000, size=10 каждый шард считает и пересылает 10010 документов ради 10 в итоговом ответе — расходы на CPU, память и сеть растут с глубиной страницы, а не с размером страницы. Поэтому в OpenSearch есть жёсткий предел index.max_result_window (по умолчанию 10 000): from + size, превышающий это значение, завершается ошибкой, а не деградацией производительности. Предел можно поднять настройкой индекса, но это лечит симптом, а не причину, и на практике сигнал — не «увеличь лимит», а «выбери другой механизм пагинации», как только читается за пределами первых нескольких экранов.

Для глубокой пагинации — «показать следующую страницу» без ограничения на количество страниц — используется search_after: вместо смещения от начала выдачи запрос отталкивается от значений сортировки последнего документа предыдущей страницы. Обязательное условие — сортировка должна включать поле с уникальными значениями как последний ключ-тай-брейкер, иначе документы с одинаковым значением первого поля сортировки могут задвоиться или потеряться между листами. Тай-брейкером берём собственное поле id (тип integer в маппинге) — не метаполе _id: OpenSearch запрещает сортировку, агрегации и скриптинг по _id (оно не хранит doc_values), поэтому sort по _id завершится ошибкой. Обычное же числовое или keyword-поле сортируется через колоночные doc_values практически бесплатно (см. маппинги). Первый лист (без search_after, size=3, сортировка [published_at desc, id asc]):

POST /articles/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "published_at": "desc" },
    { "id": "asc" }
  ],
  "size": 3
}
id=6 (2026-06-01)
id=5 (2026-05-12)
id=4 (2026-04-05)

Последний документ листа (id=4) отдаёт в ответе sort: [1775347200000, 4] — это и есть курсор: значение published_at в миллисекундах эпохи плюс id как тай-брейкер. Следующий лист запрашивается с этим курсором в search_after, без from вовсе:

POST /articles/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "published_at": "desc" },
    { "id": "asc" }
  ],
  "search_after": [1775347200000, 4],
  "size": 3
}
id=2 (2026-03-10)
id=3 (2026-02-20)
id=1 (2026-01-15)

Каждый шард теперь ищет позицию курсора и отдаёт только size документов после неё — не from + size, независимо от того, насколько глубоко пользователь пролистал. Плата за это — сама модель навигации: search_after не умеет прыгать на произвольную страницу («страница 47»), только последовательно двигаться вперёд от известного курсора, что для интерфейса «дальше» подходит идеально, а для интерфейса с номерами страниц — нет.

Третий механизм, Scroll, исторически предшествовал search_after и решает другую задачу — не постраничную навигацию для пользователя, а полный обход большого набора документов: экспорт данных, снапшот индекса, batch-обработку. Scroll открывает на стороне кластера контекст, фиксирующий состояние индекса на момент первого запроса, и живёт по таймауту (scroll=1m и так далее), что дорого держать открытым при обычной пользовательской пагинации с непредсказуемыми паузами между кликами. В современных версиях OpenSearch Scroll считается инструментом для экспорта и постепенно уступает связке search_after + Point in Time (PIT) — явно создаваемого снимка состояния индекса, который передаётся в search_after-запросы вместо привязки к scroll-контексту и не требует держать курсор в состоянии кластера между запросами тем же способом, что и Scroll.

Отдельный момент, общий для всех трёх механизмов, — сортировка. По умолчанию результаты сортируются по _score, и это осмысленно для полнотекстового поиска, где важнее всего релевантность. Но sort по дате или по произвольному полю, как в примерах выше, требует, чтобы это поле годилось для сортировки — а text-поле для этого не годится: оно разбито на термы анализатором, и «отсортировать по полю, которого как единого значения не существует», не имеет смысла. Именно поэтому сортировка по заголовку статьи в реальной схеме идёт через title.keyword, а не через title — то самое мульти-поле из раздела «Под капотом», где keyword-субполе хранит значение целиком и неанализированным, специально для точных сравнений, фильтров и сортировки.

Агрегации

Поиск отвечает на вопрос «какие документы подходят», агрегации — на вопрос «что представляет собой вся выдача целиком». Это два независимых блока одного запроса к _search: query отбирает документы, aggs считает по ним статистику — количество по группам, минимум и максимум, распределение по интервалам времени. В OpenSearch агрегации делятся на две категории. Bucket-агрегации раскладывают документы по корзинам — terms группирует по значениям поля, date_histogram — по календарным интервалам, range — по заданным диапазонам числового или date-поля. Metric-агрегации считают число по каждой корзине или по всей выдаче — avg, sum, min, max, stats (все перечисленные сразу одним запросом).

Самый частый сценарий — faceted search: фасеты, которые в интерфейсе интернет-магазина или каталога статей показывают рядом с результатами поиска количество документов по каждому тегу или категории ещё до того, как пользователь кликнет по фильтру. Это terms-агрегация по индексу целиком, без какого-либо query:

POST /articles/_search
{
  "size": 0,
  "aggs": {
    "by_tag": {
      "terms": { "field": "tags" }
    }
  }
}
opensearch: 6
ai: 1            analyzers: 1      bulk: 1
cluster: 1       ingestion: 1      knn: 1
mappings: 1      monitoring: 1     observability: 1

"size": 0 в теле запроса — не размер агрегации, а размер выдачи документов: ноль означает «посчитай агрегации, но сами документы в ответе не возвращай». Для чистого фасетного запроса, где нужны только числа по корзинам, а не карточки статей, это не микрооптимизация, а обязательный элемент — без него OpenSearch честно соберёт и вернёт ещё и весь _source подходящих документов, которые тут же выбрасываются на стороне клиента.

Агрегации умеют вкладываться друг в друга: terms по категории, а внутри каждой корзины — свои metric-агрегации по этой группе документов. Ниже — terms по category с вложенными min/max по published_at, то есть «для каждой категории — сколько статей и диапазон дат публикации»:

POST /articles/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": { "field": "category" },
      "aggs": {
        "oldest": { "min": { "field": "published_at" } },
        "newest": { "max": { "field": "published_at" } }
      }
    }
  }
}
databases:  2   oldest=2026-01-15  newest=2026-05-12
operations: 2   oldest=2026-02-20  newest=2026-06-01
search:     2   oldest=2026-03-10  newest=2026-04-05

Каждая корзина by_category получила собственную пару oldest/newest, посчитанную не по всему индексу, а только по документам этой категории — вложенная агрегация выполняется в контексте родительской корзины, а не заново по всей выборке.

Для временных рядов вместо terms по дате (что дало бы отдельную корзину на каждое уникальное значение метки времени) используется date_histogram — он группирует документы по календарным или фиксированным интервалам:

POST /articles/_search
{
  "size": 0,
  "aggs": {
    "by_month": {
      "date_histogram": {
        "field": "published_at",
        "calendar_interval": "month"
      }
    }
  }
}
2026-01: 1   2026-02: 1   2026-03: 1
2026-04: 1   2026-05: 1   2026-06: 1

calendar_interval: "month" — в отличие от fixed_interval — учитывает, что месяцы разной длины (28–31 день), и создаёт корзину на каждый календарный месяц, а не на равный промежуток времени. На демонстрационном наборе из 6 статей, по одной на месяц, это ровно то, что и ожидалось — но на реальном потоке публикаций та же агрегация превращается в график активности по месяцам без единой строчки кода на стороне клиента.

Агрегации не обязаны считаться по всему индексу — они выполняются над тем же набором документов, что и query, то есть работают как фасеты по результату поиска, а не по индексу целиком. Это отличает faceted search в OpenSearch от наивной реализации, где фасеты и результаты поиска — два разных запроса, которые потом сводятся на клиенте:

POST /articles/_search
{
  "query": {
    "match": { "body": "кластер" }
  },
  "aggs": {
    "by_category": {
      "terms": { "field": "category" }
    }
  }
}
total hits = 2
operations: 2

Запрос match(body: "кластер") — тот же самый, что находил doc3 и doc6 в разделе «Типы запросов» (total=2), только теперь рядом с двумя хитами возвращается ещё и разбивка найденных документов по категориям — operations: 2. В интерфейсе это ровно то поведение, которое ожидает пользователь: ввёл поисковый запрос — увидел не только список результатов, но и счётчики по фильтрам, которые можно применить дальше, причём счётчики посчитаны именно по текущей выдаче, а не по всему индексу заранее.

Все агрегации выше построены на полях tags, category и published_atkeyword и date в маппинге индекса. Это не случайное совпадение примеров, а требование движка: terms, date_histogram, min/max/avg/sum/stats и сортировка работают через doc_values — колоночную структуру, которую OpenSearch строит на диске для keyword, числовых и date-полей автоматически. Для text-полей doc_values не строятся, а альтернатива — fielddata в памяти кучи — по умолчанию отключена именно потому, что держать там весь набор проанализированных токенов дорого и на реальных объёмах данных ведёт к OOM. Попытка агрегировать напрямую по body или title вернёт не медленный результат, а ошибку Fielddata is disabled on text fields by default. Поэтому в маппинге из раздела «Индексы и маппинги» поля вроде title, по которым нужна и полнотекстовая выдача, и точная агрегация или сортировка, заводятся как мульти-поле: основной text для поиска, .keyword-субполе — для terms, sort и точных фильтров.

OpenSearch vs PostgreSQL vs MongoDB

Всё, что показано выше, — не единственный способ получить полнотекстовый поиск. У PostgreSQL и MongoDB есть встроенный FTS, и если поиск в проекте — не основная нагрузка, а один из десятка сценариев над данными, которые и так живут в реляционной или документной БД, вопрос «зачем вообще заводить отдельный поисковый движок» встаёт закономерно. Чтобы сравнение было честным, а не «BM25 звучит солиднее», ниже — один и тот же запрос («поиск») на одних и тех же шести демо-статьях, выполненный тремя движками: OpenSearch 3.5.0, PostgreSQL 17.9 и MongoDB 8.2.11.

PostgreSQL: tsvector, GIN, ts_rank

Полнотекстовый поиск в PostgreSQL строится на типе tsvector — предварительно разобранном представлении текста, где для каждой словоформы хранится нормализованная лексема и позиция. Разбор выполняет словарь конкретного языка: to_tsvector('russian', body) токенизирует текст, отбрасывает стоп-слова и приводит оставшиеся слова к основе через стеммер Snowball — по сути тот же конвейер, что делает ru_custom в OpenSearch, только внутри движка БД, а не внешнего поискового сервиса. Запрос сравнивается с полем через оператор @@, а plainto_tsquery разбирает поисковую фразу тем же словарём, что и индексируемый текст:

SELECT id, title, ts_rank(fts, plainto_tsquery('russian', 'поиск')) AS rank
FROM articles
WHERE fts @@ plainto_tsquery('russian', 'поиск')
ORDER BY rank DESC;
id |                title                 |  rank
----+---------------------------------------+--------
  2 | Полнотекстовый поиск и релевантность | 0.0827
  6 | Мониторинг и наблюдаемость кластера  | 0.0608
(2 rows)

Быстрым этот поиск делает не сам tsvector, а GIN-индекс поверх него (CREATE INDEX ... USING gin(fts)) — без него @@ работал бы последовательным сканированием таблицы, разбирая текст каждой строки на лету при каждом запросе. Ранжирование считает ts_rank — функция, похожая по идее на TF-IDF: чем чаще и «плотнее» встречаются лексемы запроса в документе, тем выше ранг, но формула у неё другая, чем у BM25, и числа несопоставимы по шкале с OpenSearch напрямую.

Словарь russian в PostgreSQL и ru_custom в OpenSearch решают одну и ту же задачу и на этом наборе данных сходятся до буквы: plainto_tsquery('russian', 'поиск по большим объёмам логов') разбирает фразу в 'поиск' & 'больш' & 'объем' & 'лог' — ровно тот набор основ, что даёт анализатор ru_custom из раздела «Анализаторы» (поиск | больш | объем | лог). Совпадение не случайно: оба стека в итоге используют один и тот же класс алгоритмов стемминга (Snowball) для русского языка, и стоп-слово «по» отбрасывается в обоих случаях одинаково.

MongoDB: text index, $text, textScore

В MongoDB полнотекстовый поиск встроен через text index — индекс по одному или нескольким строковым полям с указанием языка (default_language), который определяет набор стоп-слов и правила стемминга. Запрос выполняется оператором $text, а релевантность документа возвращается через проекцию $meta: "textScore":

db.articles.find(
  { $text: { $search: "поиск" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } });
id=2  score=1.4722  Полнотекстовый поиск и релевантность
id=6  score=0.5185  Мониторинг и наблюдаемость кластера

textScore считается по собственной формуле MongoDB на основе частоты терма в документе и числа полей, где он встретился, — снова не BM25 и не ts_rank, третья независимая шкала. У встроенного $text есть практический потолок: один text-индекс на коллекцию, ограниченная поддержка сложных запросов (нет полноценного bool с весами по полям, нет function_score), и на больших объёмах — заметно более узкие возможности тюнинга релевантности, чем у специализированного движка. Отдельно стоит Atlas Search — полнотекстовый поиск на базе Lucene, встроенный в MongoDB Atlas: это не расширение $text, а параллельный механизм с собственным индексом, собственным синтаксисом агрегационного пайплайна ($search) и возможностями, близкими к тому, что показано в этой статье для OpenSearch, — но доступен только в managed Atlas и требует отдельного проектирования индекса, а не пары строк на существующей коллекции.

OpenSearch: тот же запрос, для контекста

Тот же запрос «поиск» через match(body: "поиск") разобран в разделе «Типы запросов», а его score — в разделе «Релевантность» через _explain: id=2 с score=0.6845 и id=6 с score=0.4981, ранжирование по BM25. Дополнительно к самому ранжированию OpenSearch на этом же запросе даёт то, что не входит в задачи tsvector или text index: подсветку найденных фрагментов («Подсветка результатов»), фасетные агрегации по результату поиска («Агрегации») и function_score с decay-функциями для смешивания текстовой релевантности с другими сигналами («Релевантность»).

Свод

Все три движка на запросе «поиск» нашли одинаковый набор документов (id=2, id=6) в одинаковом порядке (2 обходит 6). Абсолютные значения score между движками не сравнить — это разные шкалы и разные формулы:

Движок Результат Порядок Score (doc2 / doc6)
OpenSearch 3.5.0 (BM25) id=2, id=6 2 > 6 0.6845 / 0.4981
PostgreSQL 17.9 (ts_rank) id=2, id=6 2 > 6 0.0827 / 0.0608
MongoDB 8.2.11 (textScore) id=2, id=6 2 > 6 1.4722 / 0.5185

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

Критерий PostgreSQL 17.9 MongoDB 8.2.11 OpenSearch 3.5.0
Язык / морфология tsvector + словарь (Snowball-стемминг) text index + default_language анализаторы (ru_custom/en_custom), гибкая настройка pipeline
Релевантность ts_rank textScore BM25 + boost + function_score/decay
Highlight / facets ts_headline (базовый), фасеты — вручную через GROUP BY нет встроенного highlight на $text, фасеты — отдельная агрегация highlight из коробки, aggs над той же выдачей («Агрегации»)
Рост объёма GIN-индекс, вертикальное масштабирование, шардирование — не встроено шардирование коллекций доступно, text index растёт вместе с коллекцией горизонтальное шардирование и реплики — часть модели с самого начала
FTS в БД vs отдельный движок встроен, без нового сервиса в стеке встроен ($text); Atlas Search — уже отдельный managed-слой на Lucene отдельный сервис: своя эксплуатация, зато поиск — не побочная функция БД
Семантический / векторный поиск pgvector — отдельное расширение Atlas Vector Search — часть Atlas есть штатно (kNN); тема одной из следующих статей серии

Практический вывод из этой таблицы простой: если полнотекстовый поиск в проекте — вспомогательная функция над данными, которые и так лежат в PostgreSQL или MongoDB, а нагрузка и требования к релевантности скромные, tsvector/ts_rank или $text/textScore избавляют от отдельного сервиса в инфраструктуре и лишней точки отказа. Отдельный поисковый движок вроде OpenSearch стоит своей сложности эксплуатации тогда, когда поиск — не вспомогательная, а основная функция: нужны кастомные анализаторы под конкретный язык и предметную область, тонкая настройка релевантности, подсветка и фасеты из коробки, а объём данных и нагрузка растут быстрее, чем это готова тянуть реляционная или документная БД рядом с остальными своими задачами.

Практический кейс

Разобранные по отдельности инструменты — bool, highlight, terms-агрегации, function_score — в реальном приложении почти никогда не встречаются поодиночке. Типичная задача «поисковая выдача блога» требует их всех сразу, в одном запросе: пользователь вводит слово, ожидает увидеть подсвеченные совпадения, фасеты по тегам для дальнейшей фильтрации — и разумный порядок, где не только текстовое совпадение играет роль, но и свежесть публикации. Ниже — один составной запрос, который собирает воедино всё, что было показано порознь в разделах «Типы запросов», «Подсветка результатов», «Агрегации» и «Релевантность», на том же демонстрационном индексе articles.

Сценарий: пользователь ищет «кластер», хочет видеть только статьи раздела operations, ожидает подсветку найденного в тексте, фасет по тегам рядом с результатами — и чтобы недавние публикации не терялись за старыми, даже если у старых текстовое совпадение чуть точнее.

POST /articles/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            { "match": { "body": "кластер" } }
          ],
          "filter": [
            { "term": { "category": "operations" } }
          ]
        }
      },
      "functions": [
        {
          "gauss": {
            "published_at": {
              "origin": "2026-07-03",
              "scale": "90d",
              "decay": 0.5
            }
          }
        }
      ],
      "boost_mode": "multiply"
    }
  },
  "highlight": {
    "fields": {
      "body": { "number_of_fragments": 1 }
    }
  },
  "aggs": {
    "by_tag": {
      "terms": { "field": "tags" }
    }
  }
}

Запрос читается по слоям, снаружи внутрь. function_score — внешняя обёртка, отвечающая за итоговое ранжирование: она берёт результат вложенного query и домножает его на gauss-затухание по published_at (boost_mode: multiply) — та же конструкция, что и в разделе «Релевантность», где decay по свежести переворачивал порядок doc3/doc6. Внутри неё — bool: must(match(body: "кластер")) даёт текстовое совпадение и полноценный BM25-score, filter(term(category: "operations")) жёстко отсекает документы не той категории, не участвуя в скоринге и кешируясь отдельно от полнотекстовой части — то самое разделение ролей между must и filter, разобранное в разделе «Типы запросов». highlight и aggs — уже не часть ранжирования, а два независимых довеска к тому же запросу: highlighter возвращает фрагмент body с найденным термом (и его словоформами — стемминг работает точно так же, как в разделе «Подсветка результатов»), а terms-агрегация по tags считает фасеты не по всему индексу, а по документам, прошедшим bool-фильтр, — интерфейс получает не только карточки статей, но и счётчики по тегам для дальнейшего уточнения поиска.

На демонстрационных данных bool-часть без свежести (тот же must(кластер) + filter(operations)) находит doc3 и doc6, где doc3 (репликация, опубликован 2026-02-20) обходит doc6 (мониторинг, опубликован 2026-06-01) чисто по тексту — ровно та ситуация, что уже разбиралась в разделе «Релевантность». Обёртка gauss-decay поверх этого же bool даёт тот же эффект, что и там: doc6 как более свежий вырывается вперёд, несмотря на менее точное текстовое совпадение (0.4563 против 0.1145 — числа из раздела «Релевантность» для того же decay поверх похожего match). В боевой выдаче это значит, что пользователь увидит недавнюю статью про мониторинг первой, а рядом — подсвеченный фрагмент, где встретилось слово «кластер», и фасет тегов, по которому можно сузить поиск дальше. Один запрос, три независимых механизма OpenSearch, работающих вместе на одну и ту же задачу.

Типичные ошибки

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

Поиск по keyword-полю вместо text. Запрос match на category.keyword (или на любое поле, объявленное как keyword без анализа) ищет точное совпадение всего значения целиком, а не по словам внутри него — keyword-поле не проходит через анализатор ни при индексации, ни при запросе, о чём подробно шёл разговор в разделе «Под капотом». Искать словом «поиск» по title.keyword со значением «Полнотекстовый поиск и релевантность» бессмысленно: совпадения по подстроке там нет, всё значение — один терм. keyword — для точных фильтров, сортировки и агрегаций; для полнотекстового поиска по смыслу и словоформам нужен text.

Неверный анализатор для языка текста. Смешать en_custom с русским текстом (или наоборот) не даёт ошибки при индексации — конвейер отработает молча, — но словарь стоп-слов и алгоритм стемминга рассчитаны на другой язык, и результат в разделе «Анализаторы» показывает, к чему это ведёт: без ru_stop/ru_stemmer предлоги остаются в индексе как полноценные термы, а словоформы вроде «большим»/«объёмам»/«логов» не стягиваются к общей основе — поиск по слову «поиск» просто не найдёт документ со словом «поиска», потому что термы не совпали. Это не сбой, а тихая деградация релевантности, которую сложно заметить без прямого сравнения через _analyze.

from+size для глубокой пагинации. Для первых нескольких страниц выдачи from/size — нормальный и самый простой инструмент, разобранный в разделе «Пагинация». Проблема начинается, когда пользователь (или бот-краулер) листает вглубь: каждый шард обязан посчитать и переслать координатору from + size документов ради того, чтобы вернуть последние size из них, и стоимость запроса растёт с глубиной страницы, а не с её размером. OpenSearch защищается жёстким пределом index.max_result_window — по умолчанию 10 000, превышение завершается ошибкой, а не деградацией. Решение — не поднимать лимит, а перейти на search_after (в связке с Point in Time для консистентного снимка), как только нужна навигация глубже первых нескольких экранов.

Агрессивный fuzzy. Терпимость к опечаткам через fuzziness расширяет recall — находит больше релевантных документов за счёт совпадений на расстоянии Левенштейна, — но не бесплатно: чем шире допустимая дистанция редактирования, тем больше в выдаче случайных совпадений с не связанными по смыслу словами, и precision падает. На коротких словах особенно заметно: два-три символа расстояния от короткого терма могут совпасть с десятком случайных слов языка. fuzziness: "AUTO" (адаптивная дистанция по длине слова) — разумный компромисс по умолчанию; включать fuzzy на все поля и по любому запросу, а не точечно для полей ввода, где опечатки пользователя ожидаемы, — верный способ засорить выдачу мусором и просадить производительность заодно, как отмечено в разделе «Типы запросов».

Сортировка или агрегация по text-полю без .keyword. Как разобрано в разделе «Агрегации», terms, date_histogram, min/max/avg/sum/stats и sort работают через doc_values — колоночную структуру, которую OpenSearch не строит для анализируемых text-полей. Попытка отсортировать или агрегировать напрямую по body или title возвращает не медленный результат, а прямую ошибку Fielddata is disabled on text fields by default. Решение — то самое мульти-поле из раздела о маппингах: основной text для полнотекстового поиска, .keyword-субполе — для сортировки, точных фильтров и агрегаций по тому же значению.

Что дальше

Полнотекстовый поиск находит документы по словам и их формам — но не по смыслу: запрос «как ускорить запись» не найдёт статью про «bulk-загрузку», если в её тексте нет слова «ускорить». Эту границу снимает семантический и векторный поиск — эмбеддинги, kNN и гибридные схемы, сочетающие лексику с семантикой, — тема следующей статьи серии, «Семантический поиск в OpenSearch». Всё, что показано в этой статье через curl и сырой JSON, — match, bool, highlight, агрегации — доступно и визуально, с построением графиков и дашбордов поверх тех же запросов, в OpenSearch Dashboards.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии