← назад к разделу

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

фильтр отсеивает без оценки, оценку получают только оставшиеся match name: «шоколад» query context — считает _score filter in_stock: true filter context — да или нет Шоколад горький ×1, поле 3 слова in_stock: true Набор конфет ×2, поле 15 слов in_stock: true Шоколад молочный ×1, поле 4 слова in_stock: false оценка не считалась _score 3.05 _score 2.72 выдача: сортировка по _score 1. Шоколад горький — 3.05 одно совпадение в коротком поле 2. Набор конфет — 2.72 два совпадения в длинном поле «Шоколад молочный» подходил по тексту, но выпал раньше подсчёта оценки

Фильтр отсекает документы до подсчёта: «Шоколад молочный» подходил по тексту, но его даже не оценивали. Оставшимся BM25 считает оценку, и одно вхождение в коротком названии перевешивает два вхождения в длинном описании — формула делит частоту слова на длину поля.

Обязательно

Два режима: «насколько подходит» и «подходит или нет»

Самое важное различие в Elasticsearch — запрос может работать в двух режимах:

Полнотекстовый поиск (query context) — Elasticsearch задаёт вопрос «насколько хорошо этот документ отвечает на запрос?» и вычисляет числовую оценку (_score). Документы сортируются по этой оценке — самые релевантные идут первыми.

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

Пример, где оба режима используются вместе:

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "шоколад" } }
      ],
      "filter": [
        { "term":  { "in_stock": true } },
        { "range": { "price": { "gte": 50, "lte": 500 } } }
      ]
    }
  }
}

match в must — полнотекстовый поиск, влияет на оценку. Фильтры в filter — точные условия, кэшируются.

Практическое правило: всё, что не для ранжирования, кладите в filter — это быстрее и не искажает оценку релевантности.

Виды запросов

match — поиск по тексту

Самый частый запрос для текстовых полей:

{ "match": { "name": "шоколадные конфеты" } }

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

Чтобы потребовать все термины:

{ "match": { "name": { "query": "шоколадные конфеты", "operator": "and" } } }

match_phrase — точная фраза

{ "match_phrase": { "name": "шоколадные конфеты" } }

Термины должны идти в нужном порядке и стоять рядом. Подходит для точных названий. Параметр slop позволяет разрешить небольшой разрыв между словами.

multi_match — поиск сразу по нескольким полям

{
  "multi_match": {
    "query": "шоколад",
    "fields": ["name^3", "description^1", "tags^2"],
    "type": "best_fields"
  }
}

name^3 означает, что совпадение в поле name ценится в три раза дороже, чем в description. Удобно, когда заголовок важнее тела.

Режимы поиска отличаются тем, как складываются оценки по отдельным полям:

  • best_fields — берётся максимум. Подходит, когда все слова запроса обычно лежат в одном поле. Тонкость: остальные поля по умолчанию не учитываются совсем (tie_breaker равен 0), и документ, где совпало сразу по двум полям, получит ту же оценку, что и документ с одним совпадением. Лечится тем же tie_breaker — его поднимают, скажем, до 0.3.
  • most_fields — берётся сумма: одно и то же слово могло попасть в разные поля.
  • cross_fields — все поля считаются одним, как имя и фамилия, разнесённые по колонкам.

term и terms — точное значение

{ "term":  { "category_id": 1 } }
{ "terms": { "category_id": [1, 2, 3] } }

term не анализирует текст, поэтому не подходит для текстовых полей — только для чисел, идентификаторов и keyword-полей. На текстовых полях результат будет неожиданным: Elasticsearch будет искать дословное значение в индексе, где хранятся уже обработанные термины.

range — диапазон

{ "range": { "price": { "gte": 50, "lte": 500 } } }
{ "range": { "created_at": { "gte": "now-7d/d", "lte": "now/d" } } }

Работает с числами и датами. Для дат поддерживается математика: now-7d — семь дней назад, /d — округлить до начала дня.

exists — поле заполнено

{ "exists": { "field": "image_url" } }

Находит документы, где поле присутствует и не равно null.

bool — основа всех сложных запросов

bool позволяет комбинировать запросы:

{
  "bool": {
    "must":     [ ... ],   // обязательно, влияет на оценку
    "filter":   [ ... ],   // обязательно, без оценки
    "should":   [ ... ],   // желательно, повышает оценку при совпадении
    "must_not": [ ... ]    // исключить
  }
}

must и filter — обязательные условия, разница между ними только в оценке. must_not выбрасывает документы. Интереснее всего should: добавьте в запрос из первого примера

"should": [ { "term": { "is_featured": true } } ]

— и рекомендованные товары поднимутся выше, но обычные из выдачи не исчезнут: рядом есть must и filter, поэтому should необязателен. А вот если should остался в bool один — то есть рядом нет ни must, ни filter, — он становится обязательным: хотя бы одно из его условий выполнить придётся. Это и есть minimum_should_match: без must и filter он по умолчанию равен 1, с ними — 0.

Как Elasticsearch решает, кто первый: алгоритм BM25

Когда вы ищете «шоколад», почему один документ оказывается выше другого? Elasticsearch использует формулу BM25 (Best Matching 25) — стандартный алгоритм из теории информационного поиска.

Упрощённо, оценка зависит от трёх вещей:

  • Частота термина — слово «шоколад» встречается в документе 5 раз? Это лучше, чем один раз. Но не в 5 раз лучше — отдача убывает.
  • Редкость термина — «шоколад» встречается в 10% всех документов, а «и» — в 99%. Редкое слово несёт больше информации, значит его совпадение ценится дороже.
  • Длина документа — короткое название «Шоколадные конфеты» с совпадением ценится выше, чем длинное описание с тем же словом.
оценка BM25 частота слова отдача убывает редкость слова редкое дороже длина поля короткое выше

Три слагаемых оценки и направление каждого: повторы слова дают всё меньшую прибавку, редкое слово весит дороже частого, а короткое поле перевешивает длинное с тем же совпадением.

Механику видно на маленькой программе: индекс здесь — три товара, фильтр отсекает то, чего нет в наличии, а оставшимся считается та же формула, что и в Elasticsearch.

живой пример

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;

public class RelevanceDemo {

    record Doc(String title, String text, boolean inStock) {}
    record Hit(String title, double score) {}

    static final double K1 = 1.2;
    static final double B = 0.75;
    static final double AVG_LEN = 12;
    static final int TOTAL_DOCS = 1000;
    static final int DOCS_WITH_TERM = 120;

    public static void main(String[] args) {
        String term = "шоколад";
        List<Doc> docs = List.of(
                new Doc("Шоколад горький", "шоколад какао 70", true),
                new Doc("Набор конфет", "набор конфет ассорти шоколад молочный вафли карамель нуга печенье шоколад подарочная коробка на новый год", true),
                new Doc("Шоколад молочный", "шоколад молочный 33 какао", false));

        double idf = Math.log(1 + (TOTAL_DOCS - DOCS_WITH_TERM + 0.5) / (DOCS_WITH_TERM + 0.5));
        System.out.printf(Locale.ROOT, "idf(%s) = %.2f: слово в %d документах из %d%n",
                term, idf, DOCS_WITH_TERM, TOTAL_DOCS);

        List<Hit> hits = new ArrayList<>();
        for (Doc doc : docs) {
            if (!doc.inStock()) {
                System.out.println("фильтр отсеял " + doc.title() + ": оценку не считали");
                continue;
            }
            String[] tokens = doc.text().toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}]+");
            long freq = Arrays.stream(tokens).filter(term::equals).count();
            double norm = 1 - B + B * tokens.length / AVG_LEN;
            hits.add(new Hit(doc.title(), idf * freq * (K1 + 1) / (freq + K1 * norm)));
        }

        hits.sort(Comparator.comparingDouble(Hit::score).reversed());
        for (Hit hit : hits) {
            System.out.printf(Locale.ROOT, "%.2f  %s%n", hit.score(), hit.title());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"math"
	"regexp"
	"sort"
	"strings"
)

type doc struct {
	title, text string
	inStock     bool
}

type hit struct {
	title string
	score float64
}

const (
	k1           = 1.2
	b            = 0.75
	avgLen       = 12.0
	totalDocs    = 1000
	docsWithTerm = 120
)

func main() {
	term := "шоколад"
	docs := []doc{
		{"Шоколад горький", "шоколад какао 70", true},
		{"Набор конфет", "набор конфет ассорти шоколад молочный вафли карамель нуга печенье шоколад подарочная коробка на новый год", true},
		{"Шоколад молочный", "шоколад молочный 33 какао", false},
	}

	idf := math.Log(1 + (totalDocs - docsWithTerm + 0.5) / (docsWithTerm + 0.5))
	fmt.Printf("idf(%s) = %.2f: слово в %d документах из %d\n", term, idf, docsWithTerm, totalDocs)

	splitter := regexp.MustCompile(`[^\p{L}\p{N}]+`)
	var hits []hit
	for _, d := range docs {
		if !d.inStock {
			fmt.Println("фильтр отсеял " + d.title + ": оценку не считали")
			continue
		}
		tokens := splitter.Split(strings.ToLower(d.text), -1)
		freq := 0.0
		for _, t := range tokens {
			if t == term {
				freq++
			}
		}
		norm := 1 - b + b*float64(len(tokens))/avgLen
		hits = append(hits, hit{d.title, idf * freq * (k1 + 1) / (freq + k1*norm)})
	}

	sort.Slice(hits, func(i, j int) bool { return hits[i].score > hits[j].score })
	for _, h := range hits {
		fmt.Printf("%.2f  %s\n", h.score, h.title)
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const K1 = 1.2, B = 0.75, AVG_LEN = 12, TOTAL_DOCS = 1000, DOCS_WITH_TERM = 120;
const term = 'шоколад';
const docs = [
  { title: 'Шоколад горький', text: 'шоколад какао 70', inStock: true },
  { title: 'Набор конфет', text: 'набор конфет ассорти шоколад молочный вафли карамель нуга печенье шоколад подарочная коробка на новый год', inStock: true },
  { title: 'Шоколад молочный', text: 'шоколад молочный 33 какао', inStock: false },
];

const idf = Math.log(1 + (TOTAL_DOCS - DOCS_WITH_TERM + 0.5) / (DOCS_WITH_TERM + 0.5));
console.log(`idf(${term}) = ${idf.toFixed(2)}: слово в ${DOCS_WITH_TERM} документах из ${TOTAL_DOCS}`);

const hits = [];
for (const doc of docs) {
  if (!doc.inStock) { console.log(`фильтр отсеял ${doc.title}: оценку не считали`); continue; }
  const tokens = doc.text.toLowerCase().split(/[^\p{L}\p{N}]+/u);
  const freq = tokens.filter((t) => t === term).length;
  const norm = 1 - B + B * tokens.length / AVG_LEN;
  hits.push({ title: doc.title, score: idf * freq * (K1 + 1) / (freq + K1 * norm) });
}

hits.sort((a, b) => b.score - a.score);
for (const hit of hits) console.log(`${hit.score.toFixed(2)}  ${hit.title}`);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import math
import re
from dataclasses import dataclass


@dataclass(frozen=True)
class Doc:
    title: str
    text: str
    in_stock: bool


K1, B, AVG_LEN, TOTAL_DOCS, DOCS_WITH_TERM = 1.2, 0.75, 12, 1000, 120
term = "шоколад"
docs = [
    Doc("Шоколад горький", "шоколад какао 70", True),
    Doc("Набор конфет", "набор конфет ассорти шоколад молочный вафли карамель нуга печенье шоколад подарочная коробка на новый год", True),
    Doc("Шоколад молочный", "шоколад молочный 33 какао", False),
]

idf = math.log(1 + (TOTAL_DOCS - DOCS_WITH_TERM + 0.5) / (DOCS_WITH_TERM + 0.5))
print(f"idf({term}) = {idf:.2f}: слово в {DOCS_WITH_TERM} документах из {TOTAL_DOCS}")

hits: list[tuple[str, float]] = []
for doc in docs:
    if not doc.in_stock:
        print(f"фильтр отсеял {doc.title}: оценку не считали")
        continue
    tokens = re.split(r"[^\w]+", doc.text.lower())
    freq = tokens.count(term)
    norm = 1 - B + B * len(tokens) / AVG_LEN
    hits.append((doc.title, idf * freq * (K1 + 1) / (freq + K1 * norm)))

for title, score in sorted(hits, key=lambda h: h[1], reverse=True):
    print(f"{score:.2f}  {title}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Настройки BM25 в большинстве случаев менять не нужно.

Управление оценкой: поднять важные документы

Иногда нужно, чтобы определённые документы оказывались выше не за счёт текстового совпадения, а по бизнес-логике: «новые товары важнее», «рекомендованные товары в топ».

Простой способ — boost у отдельного условия: { "match": { "name": { "query": "шоколад", "boost": 3 } } } поднимает вклад названия втрое, ровно как запись name^3 в multi_match.

function_score — комбинированное ранжирование

Для более сложной логики есть function_score:

{
  "query": {
    "function_score": {
      "query": { "match": { "name": "шоколад" } },
      "functions": [
        {
          "filter": { "term": { "is_featured": true } },
          "weight": 2.0
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale": "30d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

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

Начинайте с одного-двух факторов — с function_score легко перемудрить.

Чем ответить на вопрос «почему этот документ выше»

Есть два инструмента, и они отвечают на разные вопросы.

explain объясняет оценку конкретного документа: как сложилась сумма, какие слагаемые дали какой вклад, какие значения частот использовались.

GET /products/_explain/42
{ "query": { "match": { "title": "кофемолка ручная" } } }

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

То же самое, но для всей выдачи разом, включают флагом в запросе ("explain": true) — тогда у каждого попадания появится своё объяснение. Это шумно, но для разбора одной выдачи удобно.

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

GET /products/_search
{ "profile": true, "query": { ... } }

Практический порядок такой: выдача неправильная — explain на конкретном документе; выдача правильная, но медленная — profile.

Сортировка отключает оценку

Ловушка, из-за которой релевантность теряют молча. Как только в запросе появляется явная sort (по цене, по дате), Elasticsearch перестаёт считать оценку: в ответе _score будет null, а порядок будет ровно тем, что вы просили.

Само по себе это правильно — но отсюда две ошибки. Первая: сортировку по дате ставят «чтобы свежие были выше», и релевантность исчезает совсем, хотя хотели «сначала подходящие, а среди них свежие». Правильный ответ — не сортировка, а влияние на оценку: свежесть добавляют функцией затухания, и тогда порядок остаётся по релевантности с поправкой на дату. Второй вариант — сортировка по двум ключам: сначала по оценке, потом по дате ("sort": ["_score", { "createdAt": "desc" }]), — тогда дата разрешает только равные оценки.

Вторая ошибка — фасеты и сортировка вместе: пользователь выбрал «сортировать по цене», и дальше вся выдача перестаёт быть релевантной, хотя поисковая строка не изменилась. Это продуктовое решение, но принять его надо осознанно, а не получить как побочный эффект.

Фасеты приблизительны

Числа, которые агрегация возвращает рядом с фильтрами, на нескольких шардах приблизительны, и это заложено в устройство. Каждый шард отдаёт свой список самых частых значений (по умолчанию побольше, чем просили, но не все), инициатор их складывает — и значение, не попавшее в верхушку ни на одном шарде, может недосчитаться.

Видно это в ответе: у агрегации есть поле doc_count_error_upper_bound — верхняя граница возможной ошибки, и sum_other_doc_count — сколько документов не попало в показанные ведра. Если первое не ноль, числа в фасетах могут быть меньше настоящих.

Что с этим делают. Для небольшого числа значений (десятки брендов, статусы) увеличивают shard_size — сколько каждый шард отдаёт инициатору, — и ошибка исчезает. Для больших словарей (миллионы значений) точность недостижима в принципе, и тогда либо соглашаются на приблизительность (для интерфейса это обычно приемлемо), либо считают точные числа отдельным запросом по конкретному значению. И отдельно: на индексе из одного шарда агрегация точна всегда — поэтому маленькие каталоги этой проблемы не знают.

Условия внутри одного элемента массива

bool с двумя условиями по массиву объектов найдёт не то, что вы просите. Запрос «цвет красный и размер 42» по полю вариантов совпадёт с документом, где есть красный сорок нулевого размера и синий сорок второго: обычное отображение массива объектов не помнит, какое значение из какого элемента.

Лечение — тип nested в отображении и отдельный вид запроса:

GET /products/_search
{ "query": { "nested": {
    "path": "variants",
    "query": { "bool": { "filter": [
        { "term": { "variants.color": "красный" } },
        { "term": { "variants.size": 42 } } ] } } } } }

Два следствия, которые стоит знать. Обычный term по вложенному полю ничего не найдёт — вложенные поля доступны только через nested-запрос, и это частая причина «фильтр не работает». И для фасетов по вложенным полям нужна nested-агрегация, иначе числа будут считаться по скрытым документам, а не по товарам.

Синонимы и опечатки

Две разные задачи, которые часто путают.

Опечатки решает нечёткий поиск: "fuzziness": "AUTO" в match-запросе разрешает расхождение в одну-две буквы в зависимости от длины слова. Это работает на запросе и стоит времени: вариантов слова становится больше. Для автодополнения вместо этого обычно берут поиск по префиксу или специальные типы полей для подсказок — они дешевле.

Синонимы — это словарь («телевизор» = «тв» = «телек»), и у него два места применения, между которыми надо выбрать.

На индексации: синонимы разворачиваются при записи, в индекс попадают все варианты. Поиск быстрый (никакого расширения запроса), зато изменение словаря требует переиндексации всего индекса.

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

Практический выбор: словарь стабильный и большой — на индексации; словарь меняется вместе с продуктом (новые названия, сленг покупателей) — на запросе. Частая рабочая схема — оба: устойчивая часть на индексации, экспериментальная на запросе. И в любом случае словарь синонимов живёт в файле или в отдельной сущности, а не в коде запроса.

Aggregations: фасеты и аналитика

Aggregations (агрегации) — это подсчёт статистики по найденным документам. Именно они строят фасеты каталога: «в каждой категории X товаров», «цена от Y до Z».

Запрос с агрегациями не требует возвращать сами документы — можно поставить size: 0 и получить только статистику:

{
  "query": { "match": { "name": "шоколад" } },
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": { "field": "category_id", "size": 10 }
    }
  }
}

В ответе агрегация terms возвращает список корзин со счётчиками — это и есть готовые фасеты:

"by_category": {
  "buckets": [
    { "key": 1, "doc_count": 50 },
    { "key": 2, "doc_count": 12 }
  ]
}

Агрегации вкладываются друг в друга: положите внутрь by_category ещё одну, "avg_price": { "avg": { "field": "price" } }, и средняя цена посчитается отдельно по каждой категории. Это один HTTP-запрос вместо нескольких запросов в базу данных.

Собираем всё вместе: запрос каталога

Реальный запрос для каталога товаров с поиском, фильтрами и фасетами:

{
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "шоколадные конфеты",
            "fields": ["name^3", "description"],
            "type": "best_fields",
            "fuzziness": "AUTO"
          }
        }
      ],
      "filter": [
        { "terms": { "category_id": [1, 2] } },
        { "range": { "price": { "gte": 50, "lte": 500 } } },
        { "term":  { "in_stock": true } }
      ]
    }
  },
  "aggs": {
    "categories": { "terms": { "field": "category_id", "size": 20 } }
  }
}

fuzziness: AUTO позволяет находить документы даже при опечатках: для длинных слов допускаются отклонения до двух символов.

Пагинация: почему from плохо работает на глубоких страницах

Стандартная пагинация через from и size имеет ограничение. Запрос from: 9000, size: 20 вынуждает Elasticsearch обработать и отсортировать первые 9 020 документов на каждом шарде, а потом выбросить 9 000 из них. Поэтому дальше десяти тысяч по умолчанию не пускают вовсе — index.max_result_window равен 10 000, и from: 10000, size: 20 вернёт ошибку.

Там же прячется вторая половина беды, из-за которой ломается привычная надпись «страница 3 из 47». С версии 7.0 Elasticsearch считает общее число совпадений точно только до 10 000, а дальше отвечает «больше 10 000» и дальше не считает — экономит. Нужно честное число — попросите его явно, "track_total_hits": true, и будьте готовы, что запрос станет дороже.

Для постраничного листания (особенно бесконечной прокрутки) лучше использовать search_after:

{
  "size": 20,
  "query": { "match": { "name": "шоколад" } },
  "sort": [ { "_score": "desc" }, { "sku": "desc" } ],
  "search_after": [0.78, "SKU-12345"]
}

search_after принимает значения из последнего документа предыдущей страницы и продолжает список с этого места. Прыгнуть сразу на страницу 50 нельзя — зато нет проблем с производительностью.

from 9000 каждый шард сортирует 9020 выбросил 9000 осталось 20 search_after каждый шард идёт от метки сортирует 20 лишнего нет

Одна и та же двадцатка на глубокой странице: с from каждый шард сортирует девять тысяч лишних документов и выбрасывает их, с search_after он стартует от метки последнего документа предыдущей страницы.

Одного search_after для листания мало. Пока читатель идёт по страницам, в индекс пишут, и выдача под ним «плывёт»: документ может оказаться на двух страницах сразу или не попасть ни на одну. Поэтому с 7.10 страницы берут вместе с точкой во времени — _pit: открываете её один раз, передаёте её идентификатор в каждый запрос страницы и закрываете в конце. Все страницы тогда читаются из одного и того же снимка индекса.

Второе поле в сортировке обязательно, и оно должно быть уникальным: релевантность у разных товаров легко совпадает, и без такого «разделителя» страницы начнут перекрываться. Напрашивается взять _id, но в Elasticsearch 8 сортировка по нему по умолчанию запрещена — она слишком дорога по памяти. Поэтому заводят обычное поле-ключ вроде артикула.

Дополнительно: при первом чтении можно пропустить

Глубже: векторный и гибридный поискрасширенное

BM25 находит слова, а не смысл: запрос «обувь для бега» не найдёт «беговые кроссовки», если синонимов нет. Поле dense_vector из статьи про основы решает это иначе: текст превращают в вектор чисел моделью эмбеддингов, близкие по смыслу тексты дают близкие векторы, и поиск ищет ближайшие векторы к вектору запроса.

Вектор считает модель снаружи (сервис эмбеддингов, локальная модель, встроенный в Elasticsearch ELSER), и в индекс попадает уже массив чисел. Маппинг объявляет размерность и меру близости, а поиск идёт запросом knn:

PUT /products
{ "mappings": { "properties": {
  "name_vector": { "type": "dense_vector", "dims": 384, "index": true, "similarity": "cosine" }
} } }

POST /products/_search
{ "knn": { "field": "name_vector", "query_vector": [0.12, -0.03, ...], "k": 10, "num_candidates": 100 } }

k это сколько вернуть, num_candidates сколько просмотреть на каждом шарде; поиск приближённый, по графу HNSW, и увеличение num_candidates повышает точность за счёт времени. Память: граф и векторы живут в оперативной памяти вне кучи, по грубой оценке размерность умножить на четыре байта на документ плюс граф, то есть миллион документов с 384 измерениями это около двух гигабайт, и это первое, что упирается в размер узла.

Гибридный поиск объединяет обе выдачи, потому что у каждой свои слепые зоны: вектор находит смысл, но путает артикулы и имена собственные, BM25 наоборот. Оценки у них в разных шкалах, и складывать их напрямую нельзя; объединяют по позициям, взаимным ранговым слиянием (RRF): документ получает сумму 1 / (60 + позиция) по каждой выдаче, и то, что высоко в обеих, поднимается наверх. В Elasticsearch это retriever с rrf поверх двух подзапросов, стандартного и knn. Фильтры (категория, наличие) применяют внутри knn полем filter, а не после, иначе десять ближайших окажутся не в наличии и выдача опустеет.

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

Глубже: качество выдачи как процесс: синонимы, опечатки и контрольный наборрасширенное

Поиск «работает» с первого дня, а «находит то, что нужно» только после того, как за качеством начали следить. Три инструмента и один процесс.

Синонимы. «Кроссовки, кеды» или «ноутбук, лэптоп» задают фильтром synonym_graph в анализаторе. Есть два места, куда его поставить. На индексации синонимы записываются в индекс, поиск быстрый, но правка списка требует переиндексации всего. На поиске (отдельный search_analyzer с синонимами) список меняется без переиндексации, а запрос просто расширяется, и это обычный выбор для витрины, где список правят еженедельно. С версии 8.10 список хранится в самом кластере через _synonyms и обновляется вызовом API без перезагрузки анализаторов.

Опечатки. fuzziness: AUTO в match уже есть в примере выше, и у него есть цена: нечёткое сравнение дороже точного и на коротких словах даёт мусор («кот» найдёт «код»), поэтому AUTO не трогает слова короче трёх букв, а prefix_length: 1 не даёт менять первую букву. Второй инструмент это подсказки: suggest типа term и phrase предлагает «возможно, вы имели в виду», а completion с отдельным полем даёт автодополнение по префиксу за миллисекунды. Раскладка «rhjccjdrb» вместо «кроссовки» это не опечатка, а перевод, и его делают до запроса в приложении.

Контрольный набор. Без него любое изменение весов это гадание. Набор это сто-двести реальных запросов из логов с ручной разметкой, какие документы для каждого правильные. По нему считают метрики: доля правильных в первой десятке (precision@10), полнота, nDCG, где правильный документ на первом месте ценнее, чем на десятом. Elasticsearch умеет считать это сам: API _rank_eval принимает запросы с разметкой и возвращает метрики, и его запускают в CI перед выкатом новых весов или анализатора. Отдельный документ разбирают через _explain: он показывает, из каких слагаемых сложилась оценка и почему «правильный» товар оказался ниже.

Процесс. Из логов поиска раз в неделю берут запросы с пустой выдачей и запросы, после которых пользователь ничего не открыл; из них растут синонимы и правки маппинга. Изменение проверяют на контрольном наборе, выкатывают на долю пользователей и смотрят на долю кликов по первым трём результатам. Формула весов при этом живёт в одном месте, в шаблоне запроса (search_template) или в коде сервиса поиска, а не размазана по клиентам, иначе улучшение в одном месте не доезжает до остальных.

Коротко

  • Запросы работают в двух режимах: query context вычисляет оценку релевантности, filter context просто отсеивает без оценки — и кэшируется.
  • match — для текста, term/terms — для точных значений, range — для диапазонов; term по текстовому полю почти всегда промах.
  • bool собирает всё вместе: must и filter обязательны, should поднимает, must_not исключает; should без must и filter рядом становится обязательным.
  • Оценку считает BM25: частота слова в документе, редкость слова в индексе и длина поля.
  • Поднять нужные документы можно через boost или function_score — начиная с одного-двух факторов.
  • Aggregations считают статистику по найденным документам и дают фасеты, но на нескольких шардах приблизительно (doc_count_error_upper_bound, лечится shard_size); для глубокой пагинации нужен search_after с _pit, а не большой from (предел 10 000).
  • Векторный поиск: dense_vector с эмбеддингами снаружи, запрос knn с num_candidates, память на HNSW; гибрид с BM25 через RRF, фильтры внутри knn.
  • Качество выдачи это процесс: синонимы выбирают между индексацией (быстро, но переиндексация) и запросом (гибко, дороже), опечатки закрывает fuzziness с prefix_length, контрольный набор запросов и _rank_eval идут в сборку, а разбирают выдачу _explain (оценка документа) и profile (время запроса).
  • Явная sort отключает оценку (_score пустой): свежесть добавляют затуханием или вторым ключом сортировки, а не заменой релевантности.
  • Условия по массиву объектов требуют nested и в отображении, и в запросе: обычный term по вложенному полю не находит ничего.

Что почитать дальше

  • Fundamentals — как устроен индекс и как документы попадают в поиск.
  • Клиенты и интеграция — как отправлять эти запросы из приложения на примере Java; в Go, Node и Python официальные клиенты принимают тот же JSON.
  • Operations — производительность и управление индексами.
  • Поиск: PostgreSQL FTS или Elasticsearch — где хватает tsvector, а где нужен отдельный движок.