Привет, Хабр!

В современном поиске всё чаще используется поиск «по смыслу» с помощью векторных эмбеддингов. Вместо привычного анализа текста по словам мы представляем документы и запросы в виде многомерных векторов и ищем ближайших соседей по евклидовому или косинусному расстоянию. Это позволяет, например, находить документы, схожие по смыслу, а не только по точному совпадению слов. В Elasticsearch поддержка такого поиска реализована через поле dense_vector и алгоритм HNSW (Hierarchical Navigable Small World) для быстрого приближённого поиска ближайших соседей. В этой статье разберём, как настроить индекс с векторным полем, добавить документы с векторами и выполнять запросы kNN с возможностью фильтрации по дополнительным атрибутам.

Создание и настройка индекса

Первым делом создадим индекс с полем вектора. Тип поля — dense_vector. При настройке этого поля указываются:

  • dims — число измерений вектора (размерность).

  • similarity — метрика: обычно l2_norm (евклидово расстояние) или cosine.

  • index: true, чтобы вектор индексировался для поиска.

  • index_options — параметры HNSW:

    • type: "hnsw", чтобы использовать HNSW-индекс.

    • m: число связей между узлами графа (обычно 16–64).

    • ef_construction: точность построения графа (обычно 100–200).

Пример создания индекса с однодольным шардом и полем image_vector размерности 128:

from elasticsearch import Elasticsearch

es = Elasticsearch()

mapping = {
    "settings": {
        "number_of_shards": 1,
        "number_of_replicas": 0
    },
    "mappings": {
        "properties": {
            "title": {"type": "text"},
            "category": {"type": "keyword"},
            "image_vector": {
                "type": "dense_vector",
                "dims": 128,
                "similarity": "cosine",
                "index": True,
                "index_options": {
                    "type": "hnsw",
                    "m": 32,
                    "ef_construction": 100
                }
            }
        }
    }
}

es.indices.create(index="products", body=mapping)

image_vector — поле для хранения векторов (например, эмбеддинги изображений или текста). Указали dims: 128 (размерность вектора), similarity: cosine (сравнение по косинусу) и включили индексирование (index: true) с опцией hnsw. Параметры m и ef_construction контролируют качество и размер HNSW-графа: большее m и ef_construction дают более точный, но более тяжёлый для построения индекс. По дефолту Elasticsearch использует тип int8_hnsw, а для обычных float векторов можно использовать просто hnsw, как показано.

Параметры индексации:

  • m — число связей у каждой точки в графе HNSW. Большее m улучшает качество поиска за счёт более плотного графа, но увеличивает память индекса и время индексации.

  • ef_construction — число кандидатов при построении индекса. Увеличение повышает точность, но замедляет построение.

После создания индекса мы можем добавлять документы. Поле image_vector должно содержать числовой массив длины 128. Например:

docs = [
    {"id": 1, "title": "Green Forest", "category": "nature",
     "image_vector": [0.12, -0.07, 0.04, ..., 0.88]},
    {"id": 2, "title": "Mountain Lake", "category": "nature",
     "image_vector": [0.11, -0.06, 0.05, ..., 0.82]},
    {"id": 3, "title": "City Skyline", "category": "urban",
     "image_vector": [-0.05, 0.13, -0.02, ..., -0.75]}
]
for doc in docs:
    es.index(index="products", id=doc["id"], body=doc)

В body мы передаём вектор как список чисел. Можно заранее получить эти векторы из ML-моделей (например, BERT, CLIP, OpenAI Embeddings и т.д.). Elasticsearch сам не генерирует векторы — это ответственность приложения. Например, можно использовать Hugging Face или любой другой сервис, чтобы посчитать эмбеддинг и сохранить его в image_vector. Убедитесь, что вектор передаётся как float, и его длина совпадает с dims.

Поиск по векторам (kNN-запрос)

После индексации документов можно выполнять поиск ближайших соседей с помощью нового параметра knn. Простой пример: пусть нам нужно найти 5 ближайших к вектору запроса документов без дополнительных фильтров. В Python это делается через es.search, где в теле указываем раздел "knn" с полем, запросным вектором, k и num_candidates:

query_vector = [0.10, -0.05, 0.02, ..., 0.75]  # вектор запроса
response = es.search(index="products", body={
    "knn": {
        "field": "image_vector",
        "query_vector": query_vector,
        "k": 5,
        "num_candidates": 100
    },
    "_source": ["title", "category"]
})

Здесь:

  • field — имя векторного поля (image_vector).

  • query_vector — список float с теми же dims.

  • k — сколько ближайших соседей вернуть (например, 5).

  • num_candidates — сколько кандидатов рассмотреть при поиске на каждом шарде. Большее значение повышает качество поиска (recall) за счёт более продолжительного сканирования HNSW-графа, а меньшее — снижает задержку. Обычно num_candidates ставят существенно больше k, например 10–50×k, чтобы гарантировать хорошие результаты.

После выполнения запроса Elasticsearch вернёт список документов, у которых векторы наиболее близки по косинусному расстоянию (мы указали similarity: cosine). Если мы хотим одновременно искать текст и вектор, можно использовать гибридный запрос: например, комбинировать обычный match с knn. Тогда структура будет такой:

response = es.search(index="products", body={
    "query": {
        "bool": {
            "should": [
                {"match": {"title": {"query": "lake", "boost": 0.8}}},
            ],
            "filter": [
                {"term": {"category": "nature"}}
            ]
        }
    },
    "knn": {
        "field": "image_vector",
        "query_vector": query_vector,
        "k": 5,
        "num_candidates": 100,
        "boost": 0.2
    },
    "size": 5
})

Elasticsearch найдет ближайших по вектору и также учтет обычный текстовый запрос по полю title. Очки из match и knn будут объединены (как если бы мы делали логическое ИЛИ). Можно регулировать важность при помощи boost.

Фильтрация результатов

Сам по себе векторный поиск возвращает ближайших по смыслу документов, но нередко нужно ограничить результаты дополнительными условиями. Например, искать «похожие по смыслу фотографии» только в категории "nature" или с ценой в заданном диапазоне. Elasticsearch поддерживает фильтрацию двумя способами: внутри kNN-запроса (prefiltering) или вне его (postfiltering).

  1. Prefiltering (фильтрация до поиска): вставляем условие фильтрации прямо в блок "knn". Тогда Lucene предварительно отмечает документы, прошедшие фильтр, и HNSW ищет среди них. Пример:

    response = es.search(index="products", body={
        "knn": {
            "field": "image_vector",
            "query_vector": query_vector,
            "k": 5,
            "num_candidates": 100,
            "filter": {"term": {"category": "nature"}}
        },
        "_source": ["title", "category"]
    })
    

    "filter": {"term": {"category": "nature"}} означает, что мы ищем ближайших соседей только среди документов с category=“nature”.

  2. Postfiltering (фильтрация после поиска): выполняем весь kNN-запрос по всем документам, затем применяем фильтр как обычный фильтр запроса. В DSL это делается разделом "post_filter" или сочетанием bool/filter. Например:

    response = es.search(index="products", body={
        "query": {
            "match_all": {}
        },
        "knn": {
            "field": "image_vector",
            "query_vector": query_vector,
            "k": 10,
            "num_candidates": 100
        },
        "post_filter": {
            "term": {"category": "nature"}
        },
        "size": 5
    })

    Сначала найдёт, скажем, 10 ближайших документов по вектору, а потом отсеет те, что не соответствуют категории. Однако есть нюанс: фильтрация после может вернуть меньше чем k результатов, если среди найденных соседей некоторые не проходят фильтр. Чтобы компенсировать, можно взять изначально k больше.

Какие выбрать? Если фильтр очень строгий (т.е. проходит мало документов), точный поиск (flat вместо HNSW) может работать быстрее и точнееe. Правило такое: если после фильтра осталось, скажем, <10k документов, часто выгоднее выполнять точный поиск (индекс типа flat), так как сравнивать 10k векторов — не великая нагрузка. При большом объёме данных HNSW (~approximate) быстрее, но при жёсткой фильтрации мы теряем выгоду HNSW: ему приходится обходить всю граф-сеть, чтобы собрать кандидатов, соответствующих фильтру. Т.е prefiltering уменьшает точность поиска (мы жертвуем небольшим качеством, чтобы удовлетворять фильтру), а postfiltering может вернуть меньше результатов, чем ожидалось. Elasticsearch автоматически выполняет оптимизацию: если условие сильно сужает множество документов, движок может переключиться на точный поиск по фильтрованному набору.

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

Кратко преимущества/недостатки фильтрации:

  • Prefiltering (внутрь knn): ищем сначала по фильтру (сохраняем фильтрованные документы в битсет), потом обходим HNSW-индекс, пропуская кандидатов без фильтра. Минус: всё равно просматриваем множество узлов графа, что замедляет поиск.

  • Postfiltering (после knn): выполняем kNN на всей коллекции, потом отбрасываем нефильтрованные. Минус: может вернуть <k результатов; чтобы этого избежать, надо поднять k и затем фильтровать.

  • Exact (flat) search: при сильном фильтре (очень мало документов) лучше перейти на точный поиск, тогда стоимость линейного сравнения небольшого набора ниже, чем приблизительный обход графа.

Elasticsearch позволяет управлять индексом для вектора: можно явно выбрать "type": "hnsw" (по умолчанию) или "type": "flat" для точного поиска. Это делается через index_options в маппинге. По умолчанию используется int8_hnsw (квантованный HNSW) для экономии памяти, и можно также указать int4_hnsw. А для валидных flat индексов (точный поиск) достаточно "type": "flat".

Настройка производительности

Немного о настройке HNSW. Мы уже упомянули m и ef_construction. Ещё есть параметр ef_search – максимальное число узлов, которые HNSW обходит при каждом запросе. Обычно его задают через num_candidates в запросе, а ef_search устанавливается равным num_candidates автоматически. Если num_candidates слишком мало, поиск быстрый, но можно упустить нужных соседей; если большое – доля найденных верно растёт, но латентность растёт. Оптимизируйте num_candidates, исходя из целей: для критичных по качеству задач ставьте ≥50×k, для быстрого отклика снижайте до 10–20×k.

Также в Elasticsearch есть квантование векторов (byte-формат). В поле dense_vector можно указать element_type: "byte" и хранить int8-вектора. Это уменьшает память и ускоряет кэширование. Даже можно загружать float-векторы, но хранить их в индексе в виде байтов (int8) с функцией quantization. Это опция "type": "int8_hnsw" в index_options. Тогда поиски сначала проходят по квантованным векторам, а потом пересчитывают точную схожесть по оригинальным float-векторам для топ-результатов.

Все описанные возможности (dense_vector, KNN-запрос и фильтрация) были значительно расширены в Elasticsearch 8.x. В версии 7.x и ниже был только скриптовый поиск по вектору (script_score), который медленный. Начиная с 7.10 появился dense_vector, а с 8.0 — полноценный API knn. На новых версиях рекомендуют использовать именно knn-поиск, а не старые скрипты.

Пример

Для иллюстрации соберём все вышеописанные куски кода вместе. Ниже — пример на Python, который создаёт индекс, индексирует несколько документов с векторами и выполняет поиск по вектору с фильтрацией:

from elasticsearch import Elasticsearch

es = Elasticsearch()

# Создаём индекс с dense_vector (128) и включенным HNSW
mapping = {
    "settings": {"number_of_shards": 1},
    "mappings": {
        "properties": {
            "title": {"type": "text"},
            "category": {"type": "keyword"},
            "image_vector": {
                "type": "dense_vector",
                "dims": 128,
                "similarity": "l2_norm",
                "index": True,
                "index_options": {"type": "hnsw", "m": 16, "ef_construction": 100}
            }
        }
    }
}
es.indices.create(index="products_knn", body=mapping)

# Индексируем документы с векторами
documents = [
    {"id": 1, "title": "Cat playing", "category": "animal",
     "image_vector": [0.5, -0.1, 0.3, ..., 0.8]},
    {"id": 2, "title": "Puppy sleeping", "category": "animal",
     "image_vector": [0.45, -0.05, 0.33, ..., 0.78]},
    {"id": 3, "title": "Car on road", "category": "vehicle",
     "image_vector": [-0.2, 0.6, -0.1, ..., -0.4]},
]
for doc in documents:
    es.index(index="products_knn", id=doc["id"], body=doc)
es.indices.refresh(index="products_knn")

# Вектор запроса (например, эмбеддинг изображения кота)
query_vec = [0.48, -0.08, 0.31, ..., 0.79]

# Поиск 5 ближайших с фильтром на category='animal'
result = es.search(index="products_knn", body={
    "knn": {
        "field": "image_vector",
        "query_vector": query_vec,
        "k": 5,
        "num_candidates": 50,
        "filter": {"term": {"category": "animal"}}
    },
    "_source": ["title", "category"]
})
for hit in result["hits"]["hits"]:
    print(hit["_source"]["title"], hit["_source"]["category"], "=> score", hit["_score"])

Этот код иллюстрирует полный цикл: от создания индекса до поиска. Мы использовали фильтр внутри "knn", поэтому в результирующем списке будут только документы категории "animal". По результатам мы получим топ-5 схожих векторов, принадлежащих этой категории.


Итоги

Векторный поиск в Elasticsearch с dense_vector и HNSW позволяет реализовать быстрый семантический поиск по большим коллекциям эмбеддингов. Правильная настройка индекса (тип HNSW, параметры m и ef_construction) и разумные значения num_candidates помогут найти оптимальный баланс между скоростью и качеством поиска. Фильтрация по атрибутам (term, диапазоны и т.д.) расширяет функциональность: можно искать ближайших по смыслу соседей в нужных категориях или с подходящими свойствами. При этом важно понимать влияние фильтра на производительность: если фильтр очень жёсткий, имеет смысл переключиться на точный (flat) поиск или увеличить num_candidates.


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

  • 18 сентября, 20:00 — Мощный функционал OpenSearch, доступный бесплатно. Записаться

  • 24 сентября, 20:00 — Что нового появилось в ElasticSearch за 4 года после появления OpenSearch. Записаться

Нужно самостоятельно развернуть, настроить и внедрить Observability-платформу? Приходите на курс "Elastic/OpenSearch Advanced".

Комментарии (0)