Полнотекстовый поиск (предыдущая статья) находит документы по словам и их формам, но не по смыслу: запрос «как быстрее добавлять документы пачками» не обязан находить статью про bulk API, если в её тексте нет ни слова «быстрее», ни слова «пачками» — совпадут только случайные термы, а не идея. Семантический поиск закрывает именно этот разрыв: текст превращается в вектор фиксированной размерности — эмбеддинг, а близость по смыслу становится измеримым расстоянием в векторном пространстве. OpenSearch делает это через плагин k-NN и его встроенную интеграцию ml-commons, и, как будет видно в разделе про гибридный поиск, лучший результат на практике даёт не чистая семантика, а связка векторного поиска с лексическим.
Это финальная статья серии «OpenSearch: глубокое погружение». Дальше — как эмбеддинги устроены и откуда берутся, как завести knn_vector-поле и метод приближённого поиска ближайших соседей (HNSW, Faiss, Lucene), как индексировать векторы и запрашивать их через knn/neural, как объединить лексику и семантику в одном запросе через search-pipeline с normalization-processor, и какие у всего этого границы по памяти, latency и стоимости.
О версии. Примеры проверены на OpenSearch 3.5.0 (плагины k-NN, ml-commons, neural-search) на том же демонстрационном стенде, что и в предыдущих статьях серии. Эмбеддинги считает модель
paraphrase-multilingual-MiniLM-L12-v2(384 измерения, косинусная близость) — почему выбрана именно она, а не более очевидная англоязычная модель, разобрано в разделе «Эмбеддинги коротко». Воспроизводимый стенд — оба пути генерации эмбеддингов, гибридный поиск и запросы — лежит в digital-cookbook,opensearch/semantic/.
В статье
- Разрыв: лексика vs смысл
- Эмбеддинги коротко
- k-NN в OpenSearch
- Индексирование и запрос
- Генерация эмбеддингов
- Гибридный поиск
- Границы и стоимость
- Типичные ошибки
- Что дальше
- Источники
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>/_deploymodel_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: falseerrors: 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-модели. Семь статей, один кластер, один и тот же демонстрационный стенд от первой строки до последней — на этом серия завершена.
Источники
- k-NN search — обзор плагина k-NN:
knn_vector, методы, движки - k-NN vector search — маппинг поля
knn_vector, параметрыdimension,space_type,method - Approximate k-NN search — HNSW, Faiss, Lucene, компромисс память/скорость/точность
- Neural search — запросы
neural, интеграция с ml-commons - ML Commons plugin — обзор ml-commons: регистрация, deploy, инференс моделей
- Using ML models within OpenSearch — register/deploy модели,
model_id, ingest-процессорtext_embedding - Pretrained models — встроенный каталог моделей, включая
paraphrase-multilingual-MiniLM-L12-v2 - Hybrid search — запрос
hybrid, объединение лексических и векторных под-запросов - Search pipelines — normalization processor — нормализация
min_max/l2и combinationarithmetic_mean/geometric_mean/harmonic_mean - Sentence-Transformers — библиотека для генерации эмбеддингов вне кластера
- sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 — карточка модели на Hugging Face
Комментарии