OpenSearch — не только хранилище логов. В предыдущих статьях серии кластер собирал данные, а индексы и маппинги задавали им схему. Но ядро OpenSearch — это полнотекстовый поисковый движок на базе Lucene, и именно ради него систему в большинстве случаев и разворачивают. Просто положить документы в индекс и отправить match-запрос — работает уже на первом занятии, но за этой простотой скрывается конвейер, от которого зависит, найдётся ли документ вообще: анализ текста, токенизация, разбор запроса на термы, скоринг по BM25.
В этой статье — то, что происходит между текстом поля и найденным документом. Сначала — pipeline анализа и разница между text и keyword, дальше — как один и тот же текст разбирается на термы стандартным анализатором и анализатором для конкретного языка, с реальными токенами standard и ru_custom/en_custom на одном стенде. Типы запросов, релевантность, подсветка, пагинация, агрегации и сравнение с FTS в PostgreSQL и MongoDB — в следующих разделах статьи.
О версии. Примеры в статье проверены на OpenSearch 3.5.0 (Lucene 10.3.2) — том же стенде, что и в предыдущих статьях серии. Воспроизводимый стенд — анализаторы, запросы, релевантность и сравнение с PostgreSQL/MongoDB на одних данных — лежит в digital-cookbook,
opensearch/fulltext/.
В статье
- Под капотом
- Анализаторы: русский и английский
- Типы запросов
- Релевантность
- Подсветка результатов
- Пагинация
- Агрегации
- OpenSearch vs PostgreSQL vs MongoDB
- Практический кейс
- Типичные ошибки
- Что дальше
- Источники
Под капотом
Полнотекстовый поиск в OpenSearch работает быстро не потому, что кластер перебирает документы один за другим в поисках совпадения, а потому, что при индексировании строится структура, которая сразу отвечает на вопрос «в каких документах встречается этот терм» — инвертированный индекс (inverted index). Вместо привычного «документ → его слова» здесь обратное отображение: «слово (терм) → список документов, где оно встречается», плюс позиции внутри документа для фразового поиска и частоты для скоринга. Запрос match на слово «кластер» не сканирует весь индекс — он идёт напрямую к записи термина «кластер» в этой структуре и получает уже готовый список документов-кандидатов. Поиск по большому индексу остаётся быстрым не за счёт мощности железа, а за счёт того, что алгоритмическая сложность поиска не растёт линейно с числом документов.
Но инвертированный индекс строится не из сырого текста поля, а из термов — того, что получилось после анализа. Анализ — это конвейер из трёх стадий, через который проходит содержимое каждого text-поля перед тем, как попасть в индекс:
flowchart LR
T["Текст поля"] --> CF["char filters"] --> TK["tokenizer"] --> TF["token filters (lowercase, stemming, stop)"] --> I["термы в inverted index"]
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 | documentsPOST /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: 1calendar_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_at — keyword и 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.
Источники
- OpenSearch: Full-text queries (Query DSL)
- OpenSearch: Analyzers
- OpenSearch: Highlight results
- OpenSearch: Aggregations
- OpenSearch: Sort search results
- OpenSearch: search_after
- OpenSearch: Point in Time (PIT)
- PostgreSQL Documentation: Full Text Search
- MongoDB Documentation: Text Search
- MongoDB Documentation: $text
- MongoDB Documentation: Text Indexes
Комментарии