Векторный поиск: поле dense_vector и запрос kNN

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

Идея такая: любой объект — текст, товар, изображение — можно представить как точку в многомерном пространстве. Если два объекта схожи по смыслу, их точки окажутся рядом. Такое числовое представление называется эмбеддингом (embedding), а сам массив чисел — вектором. Нейросетевая модель принимает текст и возвращает, например, массив из 384 чисел — координаты этого текста в 384-мерном «семантическом пространстве».

flowchart TD A["Текст запроса\n'наушники для путешествий'"] --> B["ML-модель\nгенерирует эмбеддинг"] B --> C["Вектор запроса\n[0.038, -0.117, 0.084, ...]"] C --> D["kNN-запрос в ES\nfield: embedding, k: 10"] D --> E["HNSW-граф\nбыстрая навигация по num_candidates"] E --> F["Ранжирование\nпо метрике cosine / dot_product / l2_norm"] F --> G["Топ-k документов\n(похожих по смыслу)"]
flowchart TD
    A["Текст запроса\n'наушники для путешествий'"] --> B["ML-модель\nгенерирует эмбеддинг"]
    B --> C["Вектор запроса\n[0.038, -0.117, 0.084, ...]"] 
    C --> D["kNN-запрос в ES\nfield: embedding, k: 10"]
    D --> E["HNSW-граф\nбыстрая навигация по num_candidates"]
    E --> F["Ранжирование\nпо метрике cosine / dot_product / l2_norm"]
    F --> G["Топ-k документов\n(похожих по смыслу)"]
Путь от текстового запроса до результатов векторного поиска в Elasticsearch

Elasticsearch хранит такие векторы в специальном поле dense_vector и умеет по запросному вектору находить $k$ ближайших соседей — это и есть kNN-поиск (k-Nearest Neighbors).


Quick recall
What is an embedding in the context of vector search?

Поле dense_vector: маппинг

Прежде чем индексировать документы с векторами, нужно описать поле в маппинге. Минимальный вариант:

PUT /products
{
  "mappings": {
    "properties": {
      "name":        { "type": "text" },
      "description": { "type": "text" },
      "embedding":   {
        "type":       "dense_vector",
        "dims":       384,
        "similarity": "cosine"
      }
    }
  }
}

Разберём параметры:

  • dims — количество измерений вектора. Должно совпадать с размерностью выбранной модели (384, 768, 1536 — зависит от модели). Максимум 4096.
  • similarity — метрика, по которой ES будет считать похожесть. Подробнее ниже.
  • indextrue по умолчанию, что означает: ES строит граф HNSW для быстрого приближённого поиска. Если выставить false, приближённый kNN недоступен.
  • element_type — тип элементов: float (по умолчанию, 4 байта), byte (1 байт, экономит место, теряет точность), bit (1 бит).

Метрики похожести

Метрика определяет, как ES измеряет «расстояние» между двумя векторами $\mathbf{a}$ и $\mathbf{b}$ из $n$ измерений.

cosine — косинусное сходство (по умолчанию для float и byte):

$$\text{cosine}(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{|\mathbf{a}|\,|\mathbf{b}|}$$

Показывает угол между векторами. $1$ — идентичны по направлению, $0$ — перпендикулярны, $-1$ — противоположны. Нормирование происходит автоматически, поэтому длина вектора не важна — только направление.

dot_product — скалярное произведение:

$$\mathbf{a} \cdot \mathbf{b} = \sum_{i=1}^{n} a_i b_i$$

Работает корректно только с нормированными векторами (у которых $|\mathbf{a}| = 1$). Быстрее косинуса, потому что не нужно делить на длины. Если ваша модель нормирует выходы — используйте dot_product. Для ненормированных векторов результат будет некорректным.

l2_norm — евклидово расстояние:

$$d(\mathbf{a}, \mathbf{b}) = \sqrt{\sum_{i=1}^{n}(a_i - b_i)^2}$$

Чем меньше расстояние, тем похожее объекты. Хорош, когда важна абсолютная разница в координатах, а не только направление — например, для изображений.

Как выбирать: большинство текстовых моделей (sentence-transformers, OpenAI, многие HuggingFace-модели) возвращают нормированные векторы → берите dot_product или cosine. Если документация модели не уточняет — cosine безопаснее, он нечувствителен к длине вектора.

Check yourself
У вас есть текстовая модель (sentence-transformers), которая возвращает нормированные векторы длиной 1. Какую метрику `similarity` выбрать — и почему не `cosine`?

Quick recall
What does the `dims` parameter in a `dense_vector` field specify?

Индексирование документов с эмбеддингами

Вектор передаётся как обычный массив чисел прямо в теле документа:

POST /products/_doc
{
  "name":        "Беспроводные наушники Sony WH-1000XM5",
  "description": "Наушники с активным шумоподавлением, до 30 часов работы",
  "embedding":   [0.021, -0.134, 0.057, 0.312, ...] 
}

Массив должен содержать ровно dims чисел — столько, сколько указано в маппинге. Если длина не совпадает, ES вернёт ошибку.

На практике вектор генерирует ваша модель. Типичная схема в проде: бэкенд отправляет текст во внешнюю ML-модель (или в inference-эндпоинт ES), получает массив чисел, затем кладёт документ в ES. Статьи ELSER и разреженные векторы для семантики и Поле semantic_text и inference-эндпоинты описывают, как это автоматизировать прямо внутри ES.


Точный и приближённый kNN: при чём тут HNSW

Найти $k$ ближайших соседей «честно» означает сравнить запросный вектор с каждым документом в индексе. При миллионе документов и 768 измерениях — это $768 \times 10^6$ умножений за один запрос. Дорого.

Поэтому ES по умолчанию использует приближённый kNN (ANN) — на основе алгоритма HNSW (Hierarchical Navigable Small World). HNSW строит граф, где каждый вектор связан с несколькими «соседями», и во время поиска навигирует по этому графу, не проверяя все документы подряд. Скорость — миллисекунды даже на очень больших индексах. Жертва точностью — минимальная: при правильных параметрах ANN находит те же результаты, что и точный поиск, в 95–99% случаев.

Точный kNN (bruteforce) строится через script_score с функциями cosineSimilarity / dotProduct — он гарантирует правильный ответ, но не масштабируется. Используется редко: для маленьких индексов или финальной верификации.

Check yourself
Почему в продакшене почти всегда используют приближённый kNN, а не точный перебор всех векторов?

Quick recall
Why is Elasticsearch's approximate kNN (HNSW) preferred over exact brute-force kNN in production?

Запрос kNN: параметры k и num_candidates

Основной способ выполнить kNN-поиск в ES 8 — секция knn в теле запроса _search:

GET /products/_search
{
  "knn": {
    "field":         "embedding",
    "query_vector":  [0.015, -0.142, 0.063, 0.298, ...],
    "k":             5,
    "num_candidates": 50
  }
}

Оба параметра напрямую управляют балансом точности и скорости:

  • k — сколько результатов вернуть. В примере выше — 5 наиболее похожих товаров.
  • num_candidates — сколько кандидатов собрать с каждого шарда перед финальным ранжированием. ES обходит HNSW-граф, набирает num_candidates претендентов, потом из них выбирает лучшие k. Чем больше это число — тем точнее результат, но и медленнее запрос. Если не указать, ES по умолчанию берёт $1{,}5 \times k$. На практике разумный диапазон — от $k$ до $10 \times k$.

Важно: num_candidates должен быть не меньше k. Ставить num_candidates = k — значит жертвовать точностью почти полностью. Хорошее эмпирическое правило: начните с $\text{num\_candidates} = 5k$, замерьте качество, затем при необходимости увеличьте.

Check yourself
Что произойдёт, если поставить `num_candidates = k`? Например, `k = 10`, `num_candidates = 10`?

Пример: семантический поиск по каталогу

Предположим, пользователь вводит запрос «наушники для путешествий». Ваш бэкенд передаёт этот текст в модель и получает вектор. Затем отправляет запрос в ES:

GET /products/_search
{
  "knn": {
    "field":          "embedding",
    "query_vector":   [0.038, -0.117, 0.084, 0.251, ...],
    "k":              10,
    "num_candidates": 100
  },
  "_source": ["name", "description"]
}

ES вернёт 10 товаров, чьи эмбеддинги геометрически ближе всего к запросному вектору. В выдаче могут оказаться «шумоподавляющие наушники», «компактные наушники с долгим зарядом», «аудиогид для путешественников» — даже если ни в одном из них нет слова «путешествия».

Фильтрация внутри kNN-запроса

Часто нужно сузить поиск по обычным критериям — например, только товары в наличии. Используйте параметр filter прямо внутри knn:

GET /products/_search
{
  "knn": {
    "field":          "embedding",
    "query_vector":   [0.038, -0.117, 0.084, 0.251, ...],
    "k":              10,
    "num_candidates": 100,
    "filter": {
      "term": { "in_stock": true }
    }
  }
}

Фильтр применяется во время обхода HNSW-графа (pre-filter), а не после. Это значит, что кандидаты, не прошедшие фильтр, вообще не попадают в пул num_candidates. Результат точнее, чем если бы фильтр применялся post-hoc.


Комбинация kNN с обычным поиском

kNN-секцию можно совмещать с обычным Query DSL в одном запросе:

GET /products/_search
{
  "knn": {
    "field":          "embedding",
    "query_vector":   [0.038, -0.117, 0.084, 0.251, ...],
    "k":              10,
    "num_candidates": 100
  },
  "query": {
    "match": { "name": "наушники" }
  }
}

ES выполнит оба поиска, объединит результаты и перемешает скоры. Это базовый вариант гибридного поиска — более мощная его форма с RRF описана в статье Гибридный поиск: retrievers и RRF.


См. также