Семантический Поиск в Managed OpenSearch
Mikhail KhludnevСемантический Поиск в Managed OpenSearch
В предыдущем посте мы говорили о текстовом поиске, сегодня пост о векторном (семантическом) поиске.
Если мы используем OpenSearch в Yandex Cloud представляется логичным использовать модели вложений этого же облака.
См. пример.
Этот код можно запустить как Python Cloud Function, написан он исходя из того что в каталоге сервисного аккаунта под которым запускается функция доступна модель вложений (embedding). Детали подключения к кластеру описаны в документации.
Рассмотрим один крайний случай: если мы подключаемся указывая FQDN DATA узлов у которых не включен публичный доступ, то функция должна запускаться в сети кластера OpenSearch, иначе они будут не доступны. Альтернативные варианты: подключаться через «Особый FQDN» или узел DASHBOARD с публичным доступом.
Код создаёт тестовый индекс с текстовым и векторным полем, явно вызывает embedding model через REST API создавая вектора вложений для документов и запроса. И выполняет векторный поиск демонстрируя способ интеграции. Обратите внимание на способ выбора разных моделей для документов и запросов.
Получение вложений через OpenSearch Ingest Pipelines
Другой вариант интеграции с моделью вложений – OS ingest pipelines в этом случае клиент отправляет только текст документах, а вызов модели векторизации выполняет OpenSearch.
В общем виде процесс подключения внешней модели описан тут. Последовательность шагов для подключения модели вложений из YC AI Studio описана в заявке на добавление в документацию OS Connector Blueprints. Пришлось столкнуться со следующими трудностями:
Подключение Manages OpenSearch к моделям AI Studio
DATA хосты подключаются к моделям YC через интернет (до тех пор пока не реализовано что-то подобное). Соответственно, DATA узел без публичного доступа не может вызвать модель AI Studio. Есть два варианта решения:
- включаем публичный доступ для DATA хостов. Это немного контр интуитивно. Кажется что «публичность» определяет входящий трафик, но немного поразмыслив, становится очевидно, что она же требуется и в другую сторону – для исходящего подключения к модели.
- создание NAT шлюза с таблицей маршрутизации (Next hop) на этот шлюз и привязка этой таблицы к сети кластера OpenSearch. Тарифицируется NAT отдельно (дополнительно), но выглядит более безопасно чем позволять всему интернету подбирать пароль к DATA хостам.
Решение этой проблемы физического подключения не отменяет необходимости разрешить OpenSearch отправлять запросы в AI Stutio как указано в последовательности шагов.
Функции процессинга в коннекторе
Другая трудность - функции пре/пост-процессинга. К счастью подошли функции от Amazon Bedrock. Эти функции нужны для адаптации интерфейса вызова коннектора в ml_commons и Embedding REST API. Грубо говоря, из списка строк выбирается первый элемент, который принимает в виде одной строки text REST API. У меня возникли некоторые сомнения насчёт предсказуемости всего конвейера ml_commons: OpenSearch унаследовал от Elasticsearch свободное смешивание списков и отдельных значений, поэтому мне показалось что не исключена ситуация, когда вместо списка из одного элемента на вход придут несколько строк и будут отброшены. Попробую это уточнить в дальнейшем. Базовые примеры работают ожидаемо.
В случае необходимости функции пре/пост-процессинга можно написать самому, но советую быть в стороне от этого «увлекательного» занятия: пишутся они на Painless (диалекте JavaScript), скрипт этот формирует JSON (Java Script Object Notation, you know), потом этот скрипт эскейпится и передаётся строкой в JSON. В результате имеем интуитивно понятные нет серии обратных слэшей и кавычек \"\\\", разбираться c которыми - то ещё удовольствие. Диагностика проблем затруднительна – немногословные ответы с ошибками без стэк-трейсов и логов. Мне пришлось запускать OpenSearch локально под отладчиком, но даже так, некоторые исходники не скачались из-за каких-то конфликтов версий.
Другой возможный подход – использование Open AI совместимого Embedding API, возможно это упростит конфигурацию коннектора, но исходя из того, что несколько строк в один запрос этого API не отправить, преимущества такого подхода не очевидны.
Токены доступа
В конфигурации коннектора используется API Key. Другой возможный способ – Bearer ${IAM_TOKEN}. Но «IAM-токен действует не больше 12 часов», в принципе Cloud Function может периодически обновлять Bearer получая его из контекста как в примерах рассмотренных выше. Если быть честным, мне не удалось обновить токен обновляя конфигурацию коннектора. Требует дальнейшего исследования.
Конвейер индексации
После проверки модели _predict можно симулировать процессор без создания пайплайна отправив в него пару одностроковых документов.
POST /_ingest/pipeline/_simulate
{
"pipeline" :
{
"processors": [
{
"text_embedding": {
"model_id": "6cbj05oB7Vkgz3is7UJH",
"field_map": {
"text": "text_embedding"
}
}
}
]
},
"docs": [
{
"_index": "my_hybrid_index",
"_id": "1",
"_source": {
"text": "Poet birthday"
}
},
{
"_index": "my_hybrid_index",
"_id": "2",
"_source": {
"text": "Blossing plant"
}
}
]
}
// ответ
{
"docs": [
{
"doc": {
"_index": "my_hybrid_index",
"_id": "1",
"_source": {
"text_embedding": [
0.068237305,
-0.03338623,
0.023925781,
...
-0.076538086,
0.021759033,
0.065979004
],
"text": "Poet birthday"
},
"_ingest": {
"timestamp": "2025-11-30T08:41:11.016017656Z"
}
}
},
{
"doc": {
"_index": "my_hybrid_index",
"_id": "2",
"_source": {
"text_embedding": [
-0.038879395,
-0.037139893,
...
-0.023345947,
-0.001789093
],
"text": "Blossing plant"
},
"_ingest": {
"timestamp": "2025-11-30T08:41:11.016022038Z"
}
}
}
]
}
Видно как тесты были дополнены векторами. Ранее писал о том что ml_commons, как мне кажется не полностью обрабатывает все возможные виды документов. Вот пример:
POST /_ingest/pipeline/_simulate
{
"pipeline" :
{
"processors": [
{
"text_embedding": {
"model_id": "6cbj05oB7Vkgz3is7UJH",
"field_map": {
"text_field": "vector_field",
"obj.text_field": "vector_field"
}
}
}
]
},
"docs": [
{
"_index": "second-index",
"_id": "1",
"_source": {
"text_field": ["array","isn't handled properly"],
"obj.text_field": "another way ",
"obj":{
"text_field": "to pass an array "
}
}
}
]
}
В общем, если вам действительно нужно что-то такое обрабатывать, то придётся приложить некоторые усилия.
Создаём пайплайн для индексации.
PUT /_ingest/pipeline/_yc_embeddings_pipeline
{
"description": "Pipeleine with YC embeddings",
"processors": [
{
"text_embedding": {
"model_id": "6cbj05oB7Vkgz3is7UJH",
"field_map": {
"text": "text_embedding"
}
}
}
]
}
Код клиента индексации и поиска.
Модифицируем код демо-функции: при индексировании указываем пайплайн для конверсии текстов в вектора. При поиске используем немного другой запрос передавая в него идентификатор созданной модели, которая конвертирует текст запроса в вектор. Вы же помните, что для запроса нужно указывать другую модель вложений? При запуске кода видим что вектора были созданы при индексации и запросе. Результат поиска:
[ {
"_index": "my_hybrid_index",
"_id": "8saY1JoB7Vkgz3ish0Ky",
"_score": 0.5327221,
"_source": {
"text_embedding": [
-0.027313232,
-0.077697754,
0.043762207,
...
-0.0029201508,
-0.065979004
],
"text": "Alexander Sergeyevich Pushkin ....."
}
},
{
"_index": "my_hybrid_index",
"_id": "88aY1JoB7Vkgz3ish0LY",
"_score": 0.47184184,
"_source": {
"text_embedding": [
0.03225708,
...,
0.0029239655,
0.1027832
],
"text": "Matricaria is a genus of annual flowering plants ...."
}
}
]
}
В отличии от предыдущей версии кода, при использовании пайплайнов и ссылки на модель функции не нужен сервисный аккаунт, а только пароль OpenSearch, т.к. сама функция не вызывает модель из AI Studio.
Использование конвейера запроса
Следующая попытка: использовать процессор в конвейере запроса который векторизует текст запроса вызывая модель вложений YC. На мой взгляд, очень монструозно. Продемонстрирую его добавляя конфигурацию процессора запроса сразу в запрос, в рабочем режиме предполагается что пайплайн конфигурируется и применяется к запросам по имени или неявно.
POST my_hybrid_index/_search?verbose_pipeline=true
{
"query": {
"match": {
"text": {"query":"blossing plant"}
}
},
"search_pipeline": {
"request_processors": [
{
"ml_inference": {
"model_id": "6cbj05oB7Vkgz3is7UJH",
"input_map": [
{
"inputText": "query.match.text.query"
}
],
"output_map": [
{
"vector_out": "$.inference_results.*.output.*.data"
}
],
"query_template": """{
"size": 2,
"query": {
"knn": {
"text_embedding": {
"vector": ${vector_out},
"k": 5
}
}
}
}"""
}
}
]}
}
Здесь кстати используется трюк с тремя кавычками немного облегчающий ад эскейпинга, на который я жаловался выше. Но работает он только в DevTools. Вот так это значение выглядит при Copy as cURL.
"query_template": "{\n \"size\": 2,\n \"query\": {\n \"knn\": {\n \"text_embedding\": {\n \"vector\": ${vector_out},\n \"k\": 5\n }\n }\n }\n }"
Получаем аналогичный результат поиска по вектору, кроме того verbose_pipeline=true показывает результат работы процессора – вектор knn запроса
{
"took": 29,
..
"hits": { ... },
"processor_results": [
{
"processor_name": "ml_inference",
"duration_millis": 27756171,
"status": "success",
"input_data": { ....},
"output_data": {
"size": 2,
"query": {
"knn": {
"text_embedding": {
"vector": [
0.060516357,
-0.04724121,
...
0.06149292,
-0.009025574
],
"boost": 1,
"k": 5
}
}
}
В следующих постах хотелось бы обсудить использование генеративной и ранжирующей моделей.