Семантический поиск в OpenSearch: k-NN, эмбеддинги и гибридный поиск

Как искать по смыслу, а не по словам: k-NN плагин OpenSearch, векторные эмбеддинги, методы ANN (HNSW/Faiss/Lucene) и гибридный поиск, сочетающий лексику и семантику

Полнотекстовый поиск (предыдущая статья) находит документы по словам и их формам, но не по смыслу: запрос «как быстрее добавлять документы пачками» не обязан находить статью про bulk API, если в её тексте нет ни слова «быстрее», ни слова «пачками» — совпадут только случайные термы, а не идея. Семантический поиск закрывает именно этот разрыв: текст превращается в вектор фиксированной размерности — эмбеддинг, а близость по смыслу становится измеримым расстоянием в векторном пространстве. OpenSearch делает это через плагин k-NN и его встроенную интеграцию ml-commons, и, как будет видно в разделе про гибридный поиск, лучший результат на практике даёт не чистая семантика, а связка векторного поиска с лексическим.

Это финальная статья серии «OpenSearch: глубокое погружение». Дальше — как эмбеддинги устроены и откуда берутся, как завести knn_vector-поле и метод приближённого поиска ближайших соседей (HNSW, Faiss, Lucene), как индексировать векторы и запрашивать их через knn/neural, как объединить лексику и семантику в одном запросе через search-pipeline с normalization-processor, и какие у всего этого границы по памяти, latency и стоимости.

Семантический поиск в OpenSearch

О версии. Примеры проверены на OpenSearch 3.5.0 (плагины k-NN, ml-commons, neural-search) на том же демонстрационном стенде, что и в предыдущих статьях серии. Эмбеддинги считает модель paraphrase-multilingual-MiniLM-L12-v2 (384 измерения, косинусная близость) — почему выбрана именно она, а не более очевидная англоязычная модель, разобрано в разделе «Эмбеддинги коротко». Воспроизводимый стенд — оба пути генерации эмбеддингов, гибридный поиск и запросы — лежит в digital-cookbook, opensearch/semantic/.

В статье

flowchart LR subgraph IDX["Индексирование"] D["Документ"] --> M1["Модель эмбеддингов"] --> V1["Вектор (384-dim)"] --> KI["knn-индекс (HNSW)"] end subgraph QRY["Запрос"] Q["Текст запроса"] --> M2["Та же модель"] --> V2["Вектор (384-dim)"] --> NN["Поиск ближайших соседей"] end KI -.-> NN

flowchart LR
    subgraph IDX["Индексирование"]
        D["Документ"] --> M1["Модель эмбеддингов"] --> V1["Вектор (384-dim)"] --> KI["knn-индекс (HNSW)"]
    end
    subgraph QRY["Запрос"]
        Q["Текст запроса"] --> M2["Та же модель"] --> V2["Вектор (384-dim)"] --> NN["Поиск ближайших соседей"]
    end
    KI -.-> NN
Путь эмбеддинга: индексирование документа и обработка запроса

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

Разрыв: лексика vs смысл

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

POST /articles-knn/_search
{
  "query": {
    "match": {
      "body": "увеличить скорость вставки"
    }
  }
}
total: 0   (ни один документ не найден)

Ноль хитов — и это не баг анализатора. В теле статьи про bulk написано про «быструю загрузку», «пропускную способность», «пакетную запись» — ни одна из этих словоформ не пересекается с термами запроса даже после стемминга (разобранного в предыдущей статье): match ищет совпадение термов, а не совпадение идеи, и там, где идея выражена другими словами, инвертированный индекс не помогает вообще. Та же судьба у соседних парафраз того же смысла: «оптимизировать производительность вставки», «повысить скорость сохранения множества элементов», «ускорить индексацию тысяч элементов» — все дают total: 0.

Хуже, чем просто ноль хитов — ситуация, где лексика находит документ, но не тот. Запрос-парафраз «как быстрее добавлять документы пачками» (снова о bulk-записи; «документы» ≠ «документов», «быстрее» ≠ «быстро», «пачками» ≠ «пачки» — словоформы не совпадают буквально) отрабатывает не пустой выдачей, а уверенным попаданием мимо цели:

POST /articles-knn/_search
{
  "query": {
    "match": {
      "body": "как быстрее добавлять документы пачками"
    }
  }
}
total 1:  0.7125  Полнотекстовый поиск и релевантность

Единственный найденный документ — статья про полнотекстовый поиск, а не про bulk API: match зацепился за поверхностное слово «документы» (оно буквально встречается в тексте статьи про поиск — «поиск находит документы по словам») и вернул её с уверенным score 0.7125. Проблема здесь тоньше, чем в первом примере: движок не промолчал «не нашёл», он ответил с высокой уверенностью — и ответил неправильно. Пользователь, доверившийся первому результату, попадёт не в ту статью, а объяснения, почему так вышло, в самой выдаче нет: с точки зрения BM25 всё сработало штатно, термы совпали, score посчитан честно. Раздел «Эмбеддинги коротко» и следующий за ним «k-NN в OpenSearch» показывают, как тот же самый запрос через векторный поиск возвращает статью про bulk на первом месте, а ошибочную статью про полнотекстовый поиск — на втором: это одна из немногих ситуаций, где можно сравнить лексику и семантику не абстрактно, а на одном и том же запросе к одним и тем же данным.

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

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

Эмбеддинги коротко

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

На этом стенде эмбеддинги считает модель paraphrase-multilingual-MiniLM-L12-v2 — 384 измерения, косинусная близость (cosinesimil) как метрика расстояния. Выбор модели здесь не случаен и не был первым: изначально для демонстрации напрашивалась all-MiniLM-L6-v2 — компактная, быстрая, повсеместно упоминаемая в примерах sentence-transformers. Но эта модель англо-ориентирована, и на русскоязычных запросах она проявила себя предсказуемо плохо: живой прогон на демонстрационных данных показал, что нужная статья про bulk не выходила в топ выдачи, а все кандидаты получали недифференцированно близкий score в диапазоне 0.62–0.66 — модель не различала русские тексты по смыслу настолько, чтобы это можно было использовать. Замена на мультиязычную paraphrase-multilingual-MiniLM-L12-v2 (та же размерность 384, маппинг индекса менять не пришлось) сразу дала осмысленное разделение и правильный порядок в выдаче — тот самый результат, который разбирается в разделе «k-NN в OpenSearch». Практический вывод: размерность и метрика — не единственное, что нужно проверить при выборе модели; язык корпуса и язык, на котором модель обучалась и валидировалась, определяют, будет ли семантический поиск вообще работать, а не просто быть настроен технически правильно.

Перед тем как попасть в индекс, вектор обычно нормализуется — приводится к единичной длине (normalize_embeddings=True в терминах sentence-transformers). Нормализация важна конкретно потому, что метрика близости здесь — косинусная: она измеряет угол между векторами, а не абсолютное расстояние, и для нормализованных векторов расчёт упрощается и становится дешевле, а результат — стабильнее вне зависимости от длины исходного текста.

Правило, которое нельзя нарушать ни при каких обстоятельствах: одна и та же модель должна считать эмбеддинги и для документов при индексировании, и для запроса при поиске. Векторное пространство, в которое модель отображает тексты, — её внутреннее устройство, специфичное для конкретной модели и даже конкретной версии весов; у двух разных моделей эти пространства не совпадают и не сопоставимы, даже если совпадает размерность вектора. Проиндексировать документы одной моделью, а запрос считать другой — не ошибка, которая выдаст исключение: индекс примет вектор нужной размерности, knn-запрос выполнится и вернёт какие-то результаты с какими-то числами, но эти числа будут измерять расстояние между точками из двух разных, никак не связанных друг с другом пространств. Внешне это выглядит как работающий семантический поиск с правдоподобными на вид score — и именно поэтому такая ошибка тяжелее обнаружить, чем total: 0 из предыдущего раздела: он молчит, а рассинхронизация моделей выдаёт правдоподобный шум. Раздел «Типичные ошибки» возвращается к этому случаю отдельно.

k-NN в OpenSearch

Чтобы вектор вообще можно было положить в поле документа и потом искать по нему ближайших соседей, полю нужен отдельный тип маппинга — knn_vector. Это ровно тот же механизм, что и для любого другого поля из обзора типов полей: text описывает, как анализировать строку, keyword — что сравнивать буквально, а knn_vector описывает, сколько чисел в векторе и как измерять расстояние между такими векторами. Разница лишь в том, что вместо анализатора здесь настраивается метод приближённого поиска.

У knn_vector три обязательных параметра. dimension — длина вектора, задаётся моделью эмбеддингов и не может отличаться от документа к документу: если модель выдаёт 384 числа, поле принимает только 384-мерные векторы, попытка записать вектор другой длины — ошибка индексирования. space_type — метрика расстояния: cosinesimil для косинусной близости (то, что нужно для нормализованных векторов — см. предыдущий раздел), l2 для евклидова расстояния, есть и другие варианты под конкретные модели. method — как именно строится структура для приближённого поиска: движок (engine), алгоритм (name) и его параметры.

Вот реальный маппинг поля embedding с этого стенда — индекс articles-knn, шесть демонстрационных статей:

"embedding": {
  "type": "knn_vector",
  "dimension": 384,
  "method": {
    "engine": "lucene",
    "space_type": "cosinesimil",
    "name": "hnsw",
    "parameters": {}
  }
}

Размерность 384 — эмбеддинг модели paraphrase-multilingual-MiniLM-L12-v2 из предыдущего раздела, cosinesimil — метрика под нормализованные векторы, hnsw — алгоритм приближённого поиска ближайших соседей. Помимо самого поля, индекс должен быть создан с "settings": {"index": {"knn": true}} — без этого флага k-NN плагин не построит поисковую структуру для поля knn_vector, даже если маппинг формально корректен. Остальные поля документа — title и body как text, tags как keyword — размечены обычным образом и участвуют в лексическом поиске из предыдущих статей серии; knn_vector в этой схеме не заменяет их, а добавляется рядом.

Название hnsw (Hierarchical Navigable Small World) — не единственный вариант. Точный перебор всех векторов (k-NN brute force) даёт идеальную точность, но линейно растёт по времени с числом документов и на сколько-нибудь крупном индексе непригоден для интерактивного поиска. Приближённые методы (ANN — Approximate Nearest Neighbors) жертвуют точностью ради скорости: HNSW строит многоуровневый граф соседства и обходит его при поиске, Faiss — библиотека Meta с несколькими алгоритмами (в том числе HNSW и IVF) и сильным упором на компрессию векторов, Lucene — нативная реализация HNSW в движке Lucene, на котором построен OpenSearch (это и есть engine: lucene в маппинге выше). Выбор движка — не только про алгоритм, но и про то, где и как хранится структура индекса: у Faiss и nmslib она вне сегментов Lucene, у Lucene-движка — внутри, что упрощает эксплуатацию (сегменты сливаются штатным merge), но исторически ограничивает часть настроек компрессии, доступных только Faiss.

Все ANN-методы вместо гарантированно точного ответа дают компромисс между тремя величинами: памятью (структура графа или индекса хранится дополнительно к самим векторам и может занимать сопоставимый или больший объём), скоростью поиска и точностью (recall — долей действительно ближайших соседей среди тех, что вернул приближённый поиск). Крутить этот компромисс можно двумя параметрами HNSW: m — число связей на узел графа (больше m — точнее и быстрее поиск, но больше памяти и медленнее индексирование) и ef_construction — насколько тщательно строится граф при индексировании (больше значение — качественнее граф и выше recall при поиске, но дольше сама индексация). На демонстрационном стенде с шестью документами разница между значениями по умолчанию и настроенными вручную не видна — компромисс становится ощутимым на индексах от сотен тысяч векторов и выше, и настраивать его вслепую, без собственных данных и собственной нагрузки, смысла нет.

Индексирование и запрос

С маппингом из предыдущего раздела документ индексируется как обычно — вектор кладётся в поле embedding наравне с остальными полями, никакого отдельного API для векторных полей не требуется:

POST /articles-knn/_doc
{
  "title": "Массовая загрузка данных через bulk API",
  "body": "...",
  "tags": ["bulk", "indexing"],
  "embedding": [0.0123, -0.0456, 0.0789, "... ещё 381 число ..."]
}

На практике вектор в embedding считает не человек руками, а модель эмбеддингов при подготовке документа к загрузке — фрагмент такого пайплайна разбирается в разделе «Генерация эмбеддингов». Для загрузки нескольких документов подряд работает обычный _bulk — так же, как для любых других полей, векторное поле не требует отдельного эндпоинта или специального формата.

Запрос к такому индексу — тип knn вместо match: вместо текста в запросе передаётся уже посчитанный вектор запроса и число k — сколько ближайших соседей вернуть.

POST /articles-knn/_search
{
  "query": {
    "knn": {
      "embedding": {
        "vector": ["... 384 числа ..."],
        "k": 3
      }
    }
  }
}

Это тот же запрос-парафраз из раздела «Разрыв», «как быстрее добавлять документы пачками», только вектор в vector посчитан той же моделью paraphrase-multilingual-MiniLM-L12-v2, что индексировала документы. Результат:

0.7083  Массовая загрузка данных через bulk API
0.6707  Полнотекстовый поиск и релевантность
0.6245  Репликация и отказоустойчивость кластера

Это и есть обещанный в разделе «Разрыв» контраст на одном и том же запросе к одним и тем же данным. Лексический match на этот запрос вернул одну-единственную статью — про полнотекстовый поиск, — да ещё с уверенным score 0.7125, потому что зацепился за слово «документы». Семантический knn ставит на первое место именно то, что нужно по смыслу — статью про bulk, со score 0.7083, — а статью, которую match ошибочно принял за лучший результат, сдвигает на второе место с заметно меньшим score (0.6707). Порядок здесь важнее абсолютных чисел: 0.6–0.7 — типичный диапазон косинусной близости для этой модели на этих данных, конкретные значения зависят от модели и корпуса, а вот то, что смысловая победа bulk над fulltext видна и в самих числах, а не только в позиции в списке, — уже полезный сигнал при отладке.

Знать top-k соседей мало, если нужно ограничить поиск дополнительным условием — например, искать только среди статей с определённым тегом или за определённый период. Здесь возможны два принципиально разных подхода. Post-filter применяет фильтр уже после того, как найдены k ближайших соседей по вектору: если после фильтрации осталось меньше документов, чем хотелось, восполнить нехватку неоткуда — тот самый ближайший сосед, что отсеялся фильтром, для дальнейшего поиска потерян. Pre-filter, наоборот, сначала сужает пространство поиска условием фильтра и только внутри этого сужения ищет k ближайших соседей — результат полнее, но такой поиск дороже, потому что приближённая структура (HNSW-граф) строится по всему индексу, а не по отфильтрованному подмножеству, и точное соответствие фильтру приходится проверять параллельно с обходом графа. На демонстрационном стенде с шестью документами разница между этими стратегиями не проявляется — она становится ощутимой там, где фильтр отсекает существенную долю индекса, и выбор между pre- и post-filter в такой ситуации обычно приходится проверять на собственных данных, а не выводить теоретически.

Генерация эмбеддингов: ml-commons vs внешняя

Все примеры до этого момента брали вектор как данность — «… 384 числа …» в теле запроса, будто он появился сам собой. На практике вектор кто-то должен посчитать, и здесь есть развилка на уровне архитектуры: считать эмбеддинги внутри кластера через плагин ml-commons или снаружи, в приложении, обычной моделью на Python. Оба пути дают одинаковый по смыслу результат — вектор нужной размерности в поле embedding, — но по-разному распределяют работу и ответственность между кластером и приложением.

ml-commons: эмбеддинг внутри кластера

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

PUT _cluster/settings
{
  "persistent": {
    "plugins.ml_commons.allow_registering_model_via_url": true,
    "plugins.ml_commons.only_run_on_ml_node": false,
    "plugins.ml_commons.native_memory_threshold": 100
  }
}

allow_registering_model_via_url разрешает подтягивать модель по URL (в том числе pretrained-модели из встроенного каталога ml-commons), only_run_on_ml_node: false снимает требование выделенного ML-узла — на демонстрационном стенде с единственным узлом это единственный способ вообще запустить модель, native_memory_threshold — порог использования нативной памяти, после которого ml-commons откажется разворачивать новые модели.

Здесь есть тонкость, которая стоит отдельного упоминания: plugins.ml_commons.model_access_control_disabled в 3.5.0 не распознаётся кластером как известная настройка. Cluster settings применяются атомарно — один нераспознанный ключ в теле PUT _cluster/settings роняет весь запрос целиком, включая соседние, вполне корректные ключи. В частности, если этот ключ попадает в тот же PUT, что и only_run_on_ml_node, последний тоже не применяется — и следующий шаг, регистрация модели, падает с «No eligible node found», хотя причина в совершенно другой настройке. Лечится просто: убрать нераспознанный ключ и отправить PUT заново тем составом, что показан выше.

Дальше — сама регистрация модели. ml-commons поставляется со встроенным каталогом pretrained-моделей, и paraphrase-multilingual-MiniLM-L12-v2 в нём уже есть — не нужно вручную заливать веса:

POST _plugins/_ml/models/_register
{
  "name": "huggingface/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
  "version": "1.0.1",
  "model_format": "TORCH_SCRIPT"
}

Ответ — не сама модель, а task_id: регистрация асинхронна, потому что кластеру нужно скачать и распаковать веса (порядка 135 МБ для этой модели), а это не укладывается в обычный HTTP-таймаут. Статус проверяется отдельным запросом:

GET _plugins/_ml/tasks/<task_id>
state: COMPLETED
model_id: <model_id>

<model_id> дальше используется во всех операциях с этой моделью — он инстанс-специфичный, при повторной регистрации на другом кластере будет другим. Зарегистрированная модель ещё не готова принимать запросы — её нужно явно развернуть:

POST _plugins/_ml/models/<model_id>/_deploy
model_state: DEPLOYED

С этого момента модель загружена в память узла и готова считать эмбеддинги по запросу — либо через ingest-pipeline при индексировании, либо напрямую в neural-запросе.

Автоматический эмбеддинг документов настраивается через ingest-pipeline с процессором text_embedding: он берёт текст из одного поля документа и кладёт посчитанный вектор в другое.

PUT _ingest/pipeline/embed-pipeline
{
  "processors": [
    {
      "text_embedding": {
        "model_id": "<model_id>",
        "field_map": {
          "body": "embedding"
        }
      }
    }
  ]
}

Индекс articles-neural создаётся с тем же knn_vector-маппингом, что и articles-knn из раздела «k-NN в OpenSearch», плюс ссылка на pipeline как default_pipeline — тогда пайплайн применяется ко всем документам автоматически, без явного указания в каждом запросе индексирования:

PUT articles-neural
{
  "settings": {
    "index": { "knn": true },
    "default_pipeline": "embed-pipeline"
  },
  "mappings": {
    "properties": {
      "embedding": {
        "type": "knn_vector",
        "dimension": 384,
        "method": {
          "engine": "lucene",
          "space_type": "cosinesimil",
          "name": "hnsw",
          "parameters": {}
        }
      }
    }
  }
}

Дальше документы загружаются обычным _bulk — без вектора в теле, ровно как обычные текстовые документы:

POST articles-neural/_bulk
→ errors: false

errors: false здесь означает не только успешную запись — ingest-pipeline на лету посчитал вектор для каждого документа и записал его в поле embedding, приложение не передавало ни одного числа.

Запрос к такому индексу — не knn с готовым вектором, а отдельный тип neural: в него передаётся сырой текст запроса, а модель, зарегистрированная в кластере, сама превращает его в вектор той же размерности:

POST articles-neural/_search
{
  "query": {
    "neural": {
      "embedding": {
        "query_text": "как быстрее добавлять документы пачками",
        "model_id": "<model_id>",
        "k": 3
      }
    }
  }
}
0.7316  Массовая загрузка данных через bulk API
0.6845  Полнотекстовый поиск и релевантность
0.6554  Репликация и отказоустойчивость кластера

Тот же запрос-парафраз, тот же смысловой payoff, что и в разделе «Индексирование и запрос»: bulk-статья на первом месте, ошибочный кандидат match-поиска — на втором. Разница только в том, кто считал векторы: здесь — ingest-pipeline при индексировании и сам neural при запросе, а не приложение, как в knn-примере из предыдущего раздела.

Внешняя генерация: эмбеддинг в приложении

Второй путь — не доверять эмбеддинг кластеру вообще: модель разворачивается в приложении, векторы считаются там же, а в OpenSearch попадает уже готовое число, как в примерах из раздела «Индексирование и запрос». Библиотека sentence-transformers — самый прямой способ получить эмбеддинг из текста на Python:

model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
vec = model.encode(text, normalize_embeddings=True).tolist()   # 384-dim
# _bulk update: {"doc": {"embedding": vec}}

qvec = model.encode(QUERY, normalize_embeddings=True).tolist()  # та же модель на запрос!
# POST articles-knn/_search {"query":{"knn":{"embedding":{"vector": qvec, "k": 3}}}}

normalize_embeddings=True — та самая нормализация к единичной длине из раздела «Эмбеддинги коротко», обязательная для косинусной метрики. Обратите внимание на комментарий на последней строке: он не случаен — это ровно то правило «одна и та же модель на документ и на запрос», которое здесь соблюдено явно, в одном файле, одной и той же переменной model. Дальше вектор vec ложится в поле embedding документа через _bulk update, а вектор qvec — в тело knn-запроса, который уже разбирался в разделе «Индексирование и запрос».

Запись векторов в индекс здесь ничем принципиально не отличается от обычной ingest-нагрузки: тот же _bulk, тот же клиент, тот же паттерн пакетной записи, что разбирался для текстовых полей в статье «Сбор данных в OpenSearch: API, клиенты и bulk-загрузка» — просто вместо title и body в документе появляется ещё и число-массив embedding, посчитанный до отправки запроса, а не после.

Связанность против контроля

Оба пути приходят к одному и тому же knn_vector-полю и тому же диапазону score, но платят за это разным. ml-commons избавляет приложение от всякого кода, связанного с эмбеддингом: модель, её версия, память под неё и деплой — целиком забота кластера, а индексирование и запрос выглядят как обычные _bulk и neural, без единой строчки, работающей с векторами напрямую. Цена — связанность: модель теперь часть кластера, её обновление, откат на предыдущую версию и мониторинг занятой ею памяти нужно делать средствами ml-commons, а не привычными средствами CI/CD приложения, и случай с model_access_control_disabled выше — не случайность, а иллюстрация того, что настройки ml-commons — это ещё одна поверхность эксплуатации кластера, которой не было бы при внешней генерации.

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

Прямого правила «когда что выбирать» здесь нет — есть компромисс. ml-commons снимает работу с приложения ценой того, что модель становится частью эксплуатации кластера; внешняя генерация возвращает контроль над моделью и железом приложению ценой того, что синхронизация версий и корректность самого правила «одна модель на оба пути» больше никем не проверяется автоматически.

Гибридный поиск

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

Запрос — точный термин «BM25»: акроним, который лексический поиск найдёт мгновенно и однозначно (он либо есть в тексте, либо нет), а модель эмбеддингов не «понимает» как понятие — для неё это просто последовательность символов, у которой нет устойчивого смыслового соседства с другими словами корпуса. neural-запрос с этим термином возвращает результат, в котором сразу видна проблема:

POST articles-neural/_search
{
  "query": {
    "neural": {
      "embedding": {
        "query_text": "BM25",
        "model_id": "<model_id>",
        "k": 3
      }
    }
  }
}
0.6178  Полнотекстовый поиск и релевантность
0.5985  Мониторинг и наблюдаемость кластера
0.5832  Репликация и отказоустойчивость кластера

Первое место в выдаче действительно занимает правильная статья — про полнотекстовый поиск, где BM25 упоминается по существу. Но обратите внимание на разброс: все три score лежат в узком коридоре 0.58–0.62, и разница между первым местом и третьим — меньше 0.04. Для модели эмбеддингов это означает, что документы почти равноудалены от запроса: она не нашла в тексте про BM25 ничего настолько специфичного, чтобы уверенно оттолкнуться от двух других статей. Ранжирование формально верное, но шаткое — небольшое изменение в тексте любой из трёх статей или в самой модели вполне может поменять порядок мест.

Лексический сигнал в этой же ситуации не колеблется. Термин «BM25» либо встречается в тексте статьи, либо нет — и среди шести документов он встречается ровно в одном. Объединить оба сигнала в одном запросе позволяет тип hybrid: он принимает список под-запросов произвольных типов (здесь — match и neural) и передаёт их результаты в search-pipeline, который нормализует и объединяет скоры каждого под-запроса в один итоговый.

PUT _search/pipeline/hybrid-pipeline
{
  "phase_results_processors": [
    {
      "normalization-processor": {
        "normalization": {
          "technique": "min_max"
        },
        "combination": {
          "technique": "arithmetic_mean",
          "parameters": {
            "weights": [0.3, 0.7]
          }
        }
      }
    }
  ]
}

Два разных под-запроса дают скоры в принципиально разных шкалах — BM25-score match ничем не ограничен сверху, косинусная близость neural лежит в диапазоне [-1, 1] — и складывать их напрямую бессмысленно. normalization-processor решает эту задачу в два шага. Сначала normalization приводит скор каждого под-запроса к общей шкале — техника min_max растягивает результаты каждого под-запроса в диапазон [0, 1] независимо друг от друга, так что после неё «сила» лексического и семантического сигнала становится сравнимой. Затем combination сводит нормализованные скоры в один: arithmetic_mean — взвешенное среднее, а weights: [0.3, 0.7] — доля, с которой в итоговый скор входят лексика и семантика соответственно (порядок весов соответствует порядку под-запросов в списке queries запроса hybrid). Веса 0.3/0.7 здесь — не универсальная константа, а параметр, подбираемый под задачу: корпус, где точные термины и коды встречаются часто, потребует больше веса на лексику, корпус с преобладанием свободного текста и парафраз — больше веса на семантику. Кроме arithmetic_mean, combination в normalization-processor поддерживает geometric_mean и harmonic_mean. Существует и принципиально иной способ свести под-запросы — RRF (Reciprocal Rank Fusion): отдельная техника ранговой фузии, которая объединяет не сами скоры, а позиции документа в ранжировании каждого под-запроса, и полезна именно тогда, когда шкалы скоров разных под-запросов настолько разнородны, что нормализация min_max не даёт устойчивого результата.

С зарегистрированным pipeline запрос hybrid к тому же термину «BM25» выглядит так:

POST articles-neural/_search?search_pipeline=hybrid-pipeline
{
  "query": {
    "hybrid": {
      "queries": [
        {
          "match": {
            "body": "BM25"
          }
        },
        {
          "neural": {
            "embedding": {
              "query_text": "BM25",
              "model_id": "<model_id>",
              "k": 3
            }
          }
        }
      ]
    }
  }
}
1.0     Полнотекстовый поиск и релевантность
0.3094  Мониторинг и наблюдаемость кластера
0.0007  Репликация и отказоустойчивость кластера

Разница с чистой семантикой не в том, что первое место сменилось — оно и там, и там правильное. Разница в уверенности: вместо коридора 0.58–0.62 без выраженного отрыва — 1.0 против 0.3094 против 0.0007, три порядка разброса между первым и третьим местом. Единственная статья, где термин «BM25» реально встречается в тексте, получает максимальный нормализованный скор, а две остальные, для которых у семантики не было устойчивого основания их развести, теперь разведены лексическим сигналом уверенно и с большим отрывом. Ровно это и предсказывалось в разделе «Разрыв»: семантика хороша для смысла и парафраз, но на точном термине она размазывает скор; лексика, наоборот, беспомощна перед парафразом, но безошибочна на точном совпадении. Гибрид не выбирает одно вместо другого — он берёт сильную сторону каждого из них там, где она сильна.

Границы и стоимость

Всё, что показано в статье до этого момента, — маппинг, knn/neural-запросы, hybrid-пайплайн — работает одинаково что на шести демонстрационных документах, что на индексе с миллионами записей. Но ощутить, во что это обходится, на шести документах невозможно: HNSW-граф на таком объёме тривиален, и любое измерение памяти или latency здесь будет измерением шума, а не сигнала. Дальше — не результаты бенчмарка (на этом стенде его никто не запускал), а оценка по формуле, с явной оговоркой: реальные числа получаются только на реальном объёме данных, здесь — лишь порядок величины и то, от чего он зависит.

Память. knn_vector-поле хранит не только сами векторы, но и структуру HNSW — граф соседства, который позволяет искать приближённо, не перебирая весь индекс. Размер самих векторов оценивается прямо: dimension × число_документов × 4 байта (float32) — для 384-мерного вектора и миллиона документов это около 1.5 ГБ только на векторы, без учёта графа. Граф добавляет к этому оверхед, зависящий от параметра m (числа связей на узел): чем больше m, тем плотнее граф, тем точнее и быстрее поиск, но и тем больше памяти он занимает сверх самих векторов — на некоторых конфигурациях оверхед графа сопоставим по порядку с объёмом самих векторов, а не пренебрежимо мал по сравнению с ними. Как именно эта память ложится на узел, зависит от выбранного движка (см. «k-NN в OpenSearch»): нативные библиотеки (Faiss, nmslib) держат индексы приближённого поиска в off-heap native-памяти, которую нужно планировать отдельно от heap JVM; Lucene-движок (тот, что использован в примерах этой статьи) хранит векторные данные через codec в сегментах Lucene и опирается в том числе на страничный кеш ОС. В любом случае память под векторный поиск — heap JVM, native-память и страничный кеш под сегменты Lucene — планируется отдельно от дискового объёма, и рост векторного индекса требует пересмотра ресурсов узла, а не только места на диске.

Latency и точность: ANN против exact k-NN. Точный перебор (exact k-NN, brute force) гарантированно находит истинных ближайших соседей, но делает это за время, линейно растущее с числом документов — на крупном индексе это неприемлемо для интерактивного поиска. ANN (приближённый поиск, HNSW в частности) обходит эту проблему ценой точности: вместо гарантии он даёт recall — долю действительно ближайших соседей среди тех, что вернул приближённый поиск, обычно меньше 100%. Управляет этим компромиссом параметр поиска ef_search (насколько широко обходится граф при запросе): больше ef_search — выше recall и точнее результат, но дороже и медленнее сам запрос; меньше — быстрее, но растёт риск пропустить действительно ближайший документ. Это тот же принцип, что и у m/ef_construction из раздела «k-NN в OpenSearch», только ef_search крутится не при индексировании, а при каждом запросе, и его можно менять без переиндексирования всего корпуса.

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

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

  • Разные модели на индексирование и запрос. Векторное пространство специфично для конкретной модели (и даже конкретной версии весов) — эмбеддинг документа моделью A и эмбеддинг запроса моделью B, даже при совпадающей размерности, лежат в несопоставимых системах координат. Ошибка не бросает исключение: knn/neural-запрос отработает и вернёт какие-то score, которые выглядят правдоподобно, но измеряют расстояние между точками из двух не связанных друг с другом пространств — молчаливая поломка тяжелее диагностировать, чем явный total: 0. Правило одно и без исключений: одна и та же модель на документ и на запрос (см. «Эмбеддинги коротко»). Живой пример из этой же серии: первой на выбор напрашивалась англо-ориентированная all-MiniLM-L6-v2, и на русскоязычном корпусе она не столько путала модели местами, сколько вообще не различала тексты по смыслу — все кандидаты получали недифференцированно близкий score. Урок тот же по духу: модель для эмбеддингов выбирается под язык корпуса, а не по популярности в примерах, отсюда и переход на мультиязычную paraphrase-multilingual-MiniLM-L12-v2.
  • Размерность вектора не совпадает с dimension в маппинге. knn_vector с dimension: 384 примет только 384-мерные векторы — попытка проиндексировать вектор другой длины (например, после смены модели без пересоздания индекса) закончится ошибкой индексации, а не молчаливой подгонкой размера. Размерность — свойство модели, а не индекса, и меняется она только вместе с моделью; смена модели на другую размерность требует нового маппинга и переиндексирования, а не правки одного поля (см. «k-NN в OpenSearch»).
  • Чистая семантика без лексического якоря. На точных терминах, кодах, акронимах и идентификаторах модель эмбеддингов не «понимает» текст как понятие — для неё это просто символы без устойчивого смыслового соседства, и score на таких запросах размазывается по кандидатам почти без разброса (пример с термином «BM25» и коридором score 0.58–0.62 — в разделе «Гибридный поиск»). Ставить в проде только knn/neural без лексического сигнала рядом — значит терять именно ту уверенность в ранжировании, которую даёт точное совпадение; гибрид через hybrid-запрос и normalization-processor закрывает это тем, что берёт сильную сторону каждого из двух подходов.
  • Недооценка памяти под k-NN. HNSW-граф — не пренебрежимо малая надстройка над векторами, а структура, чей оверхед растёт вместе с параметром m и на некоторых конфигурациях сопоставим по порядку с объёмом самих векторов; куда именно эта память ложится, зависит от движка — нативные библиотеки (Faiss, nmslib) требуют off-heap native-памяти, Lucene-движок опирается на сегменты и страничный кеш ОС. На демонстрационном стенде с шестью документами это незаметно, но на индексе от сотен тысяч записей и выше ресурсы узла — heap JVM, native-память и страничный кеш — нужно планировать заранее, а не по факту OOM (см. «Границы и стоимость»).

Что дальше / итог серии

На этом заканчивается путь всей серии «OpenSearch: глубокое погружение»: от установки кластера через Ansible — через проектирование индексов и маппингов, сбор данных и bulk-загрузку, полнотекстовый поиск и релевантность, retention и ISM, Dashboards — до семантического поиска в этой статье: кластер поднят, данные в нём лежат, устаревшие данные удаляются по расписанию, а искать по ним можно и по точному слову, и по смыслу, и гибридом того и другого. Дальше в этой теме — за рамками серии, но не за рамками интереса — три направления, каждое из которых заслуживает отдельного разбора, а не абзаца здесь: RAG и чат-ассистент поверх поиска (семантический поиск как retriever для LLM, а не конечная точка); мультимодальные эмбеддинги — тот же принцип «вектор фиксированной размерности», но для изображений и аудио вместо текста; дообучение и тюнинг моделей эмбеддингов под собственный домен и корпус, а не только выбор готовой pretrained-модели. Семь статей, один кластер, один и тот же демонстрационный стенд от первой строки до последней — на этом серия завершена.

Источники

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

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

Комментарии