Узнайте, как использовать OpenSearch для настройки гибридной системы поиска, чтобы вы могли воспользоваться преимуществами как текстового, так и векторного поиска.

Текстовые базы данных играют решающую роль во многих бизнес-задачах, особенно в электронной коммерции, где клиенты полагаются на описания продуктов и обзоры для принятия обоснованных решений о покупке. Векторный поиск, метод, который использует встраивание текста для поиска семантически похожих документов, является еще одним мощным инструментом. Однако из-за опасений по поводу сложности его внедрения в текущий рабочий процесс некоторые предприятия могут не решиться попробовать векторный поиск. Но что, если я скажу вам, что это можно сделать легко и со значительной пользой?

В этой записи блога я покажу вам, как легко создать гибридную установку, которая сочетает в себе мощь текстового и векторного поиска. Эта настройка даст вам наиболее полные и точные результаты поиска. Я буду использовать OpenSearch в качестве поисковой системы и Преобразователи предложений Hugging Face для создания вложений. Набор данных, который я выбрал для этой задачи, — это набор данных XMarket (который более подробно описан здесь), где мы будем встраивать поле заголовка в векторное представление в процессе индексации.

Подготовка набора данных

Во-первых, давайте начнем с индексации наших документов с помощью Sentence Transformers. В этой библиотеке есть предварительно обученные модели, которые могут генерировать вложения для предложений или абзацев. Эти вложения действуют как уникальные отпечатки пальцев для фрагмента текста. В процессе индексации я преобразовал поле заголовка в векторное представление и проиндексировал его в OpenSearch. Вы можете сделать это, просто импортировав модель и закодировав любое текстовое поле.

Модель можно импортировать, написав следующие две строки:

from sentence_transformers import SentenceTransformer 

model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2') 

embedding = model.encode(text_field)

Это так просто!

Мы создадим индекс с именем «продукты», передав следующее сопоставление:

{ 
   "products":{ 
      "mappings":{ 
         "properties":{ 
            "asin":{ 
               "type":"keyword" 
            }, 
            "description_vector":{ 
               "type":"knn_vector", 
               "dimension":384 
            }, 
            "item_image":{ 
               "type":"keyword" 
            }, 
            "text_field":{ 
               "type":"text", 
               "fields":{ 
                  "keyword_field":{ 
                     "type":"keyword" 
                  } 
               }, 
               "analyzer":"standard" 
            } 
         } 
      } 
   } 
} 

asin — уникальный идентификатор документа, который берется из метаданных товара.

description_vector — здесь мы будем хранить закодированное поле названия продукта.

item_image — URL-адрес изображения товара

text_field — это название товара

Обратите внимание, что мы используем стандартный анализатор OpenSearch, который умеет разбивать каждое слово в поле на отдельные ключевые слова. OpenSearch берет эти ключевые слова и использует их для алгоритма Okapi BM25. Я также взял поле заголовка и дважды сохранил его в документе; один раз в необработанном формате и один раз в векторном представлении.

Затем я буду использовать модель для кодирования поля заголовка и создания документов, которые будут загружены в OpenSearch:

def store_index(index_name: str, data: np.array, metadata: list, os_client: OpenSearch): 
    documents = [] 
    for index_num, vector in enumerate(data): 
        metadata_line = metadata[index_num] 
        text_field = metadata_line["title"] 
        embedding = model.encode(text_field) 
        norm_text_vector_np = normalize_data(embedding) 
        document = { 
            "_index": index_name, 
            "_id": index_num, 
            "asin": metadata_line["asin"], 
            "description_vector": norm_text_vector_np.tolist(), 
            "item_image": metadata_line["imgUrl"], 
            "text_field": text_field 
        } 
        documents.append(document) 
        if index_num % 1000 == 0 or index_num == len(data): 
            helpers.bulk(os_client, documents, request_timeout=1800) 
            documents = [] 
            print(f"bulk {index_num} indexed successfully") 
            os_client.indices.refresh(INDEX_NAME) 
 
    os_client.indices.refresh(INDEX_NAME) 

Реализация гибридного поиска

План состоит в том, чтобы создать клиент, который будет принимать данные от пользователя, генерировать вложение с использованием модели Sentence Transformers и выполнять наш гибридный поиск. Пользователя также попросят указать уровень повышения, то есть степень значимости, которую он хочет придать текстовому или векторному поиску. Таким образом, пользователь может выбрать приоритет одного типа поиска над другим. Так что, если, например, пользователь хочет, чтобы семантическое значение его запроса учитывалось больше, чем простой текст в описании, тогда он придаст векторному поиску большее значение, чем текстовому поиску.

Поиск

Сначала мы запустим текстовый поиск по индексу, используя метод поиска OpenSearch. Этот метод принимает строку запроса и возвращает список документов, соответствующих запросу. OpenSearch получает результаты текстового поиска, используя Okapi BM25 в качестве алгоритма ранжирования. Текстовый поиск с помощью OpenSearch выполняется путем отправки следующего тела запроса:

bm25_query = {
    "size": 20,
    "query": {
        "match": {
            "text_field": query
        }
    },
    "_source": ["asin", "text_field", "item_image"],
}

Где textual_query — текст, написанный пользователем. Чтобы мои результаты возвращались в чистом виде, я добавил «_source», чтобы OpenSearch возвращал только те поля, которые мне интересны.

Поскольку алгоритм оценки ранжирования текстового и векторного поиска различен, нам нужно будет привести оценки к одной шкале, чтобы объединить результаты. Для этого мы нормализуем оценки для каждого документа из текстового поиска. Максимальный балл BM25 — это наивысший балл, который может быть присвоен документу в коллекции для данного запроса. Он представляет максимальную релевантность документа для запроса. Значение максимального балла BM25 зависит от параметров формулы BM25, таких как средняя длина документа, частота термина и обратная частота документа. По этой причине я взял максимальную оценку, полученную от OpenSearch для каждого запроса, и разделил на нее каждую оценку результатов, что дало нам оценки по шкале от 0 до 1. Следующая функция демонстрирует наш алгоритм нормализации:

def normalize_bm25_formula(score, max_score):
    return score / max_score

Далее проведем векторный поиск методом векторного поиска. Этот метод принимает список вложений и возвращает список документов, семантически похожих на вложения.

Поисковый запрос для OpenSearch выглядит следующим образом:

cpu_request_body = {
    "size": 20,
    "query": {
        "script_score": {
            "query": {
                "match_all": {}
            },
            "script": {
                "source": "knn_score",
                "lang": "knn",
                "params": {
                    "field": "description_vector",
                    "query_value": get_vector_sentence_transformers(query).tolist(),
                    "space_type": "cosinesimil"
                }
            }
        }
    },
    "_source": ["asin", "text_field", "item_image"],
}

Где get_vector_sentence_transformers отправляет текст в model.encode(text_input), который возвращает векторное представление текста. Также обратите внимание, что чем выше ваши результаты topK, тем точнее будут ваши результаты, но это также увеличит задержку.

Интерполировать результаты и применить усиление

Теперь нам нужно объединить два результата поиска. Для этого мы будем интерполировать результаты, чтобы каждый документ, найденный при обоих поисках, отображался выше в списке гибридных результатов. Таким образом, мы можем использовать сильные стороны как текстового, так и векторного поиска, чтобы получить наиболее полные результаты.

Следующая функция используется для интерполяции результатов поиска по ключевым словам и векторного поиска. Он возвращает словарь, содержащий общие элементы между двумя наборами обращений, а также оценки для каждого документа. Если документ появится только в одном из результатов поиска, мы присвоим ему самую низкую оценку из полученных.

def interpolate_results(vector_hits, bm25_hits):
    # gather all product ids
    bm25_ids_list = []
    vector_ids_list = []
    for hit in bm25_hits:
        bm25_ids_list.append(hit["_source"]["asin"])
    for hit in vector_hits:
        vector_ids_list.append(hit["_source"]["asin"])
    # find common product ids
    common_results = set(bm25_ids_list) & set(vector_ids_list)
    results_dictionary = dict((key, []) for key in common_results)
    for common_result in common_results:
        for index, vector_hit in enumerate(vector_hits):
            if vector_hit["_source"]["asin"] == common_result:
                results_dictionary[common_result].append(vector_hit["_score"])
        for index, BM_hit in enumerate(bm25_hits):
            if BM_hit["_source"]["asin"] == common_result:
                results_dictionary[common_result].append(BM_hit["_score"])
    min_value = get_min_score(common_results, results_dictionary)
    # assign minimum value scores for all unique results
    for vector_hit in vector_hits:
        if vector_hit["_source"]["asin"] not in common_results:
            new_scored_element_id = vector_hit["_source"]["asin"]
            results_dictionary[new_scored_element_id] = [min_value]
    for BM_hit in bm25_hits:
        if BM_hit["_source"]["asin"] not in common_results:
            new_scored_element_id = BM_hit["_source"]["asin"]
            results_dictionary[new_scored_element_id] = [min_value]

    return results_dictionary

В конечном итоге у нас будет словарь с идентификатором документа в качестве ключа и массивом значений оценок в качестве значения. Первый элемент массива — это оценка векторного поиска, а второй элемент — нормализованная оценка текстового поиска.

Наконец, мы применяем ускорение к нашим результатам поиска. Мы будем перебирать оценки результатов и умножать первый элемент на уровень повышения вектора, а второй элемент — на уровень повышения текста.

def apply_boost(combined_results, vector_boost_level, bm25_boost_level):
    for element in combined_results:
        if len(combined_results[element]) == 1:
            combined_results[element] = combined_results[element][0] * vector_boost_level + \
                                        combined_results[element][0] * bm25_boost_level
        else:
            combined_results[element] = combined_results[element][0] * vector_boost_level + \
                                        combined_results[element][1] * bm25_boost_level
    #sort the results based on the new scores
    sorted_results = [k for k, v in sorted(combined_results.items(), key=lambda item: item[1], reverse=True)]
    return sorted_results

Пришло время посмотреть, что у нас есть! Вот как выглядит полный рабочий процесс:

Я искал предложение «ложка мороженого» с усилением 0,5 для векторного поиска и усилением 0,5 для текстового поиска, и вот что я получил в нескольких верхних результатах:

Возврат векторного поиска —

Текстовый поиск вернулся —

Гибридный поиск вернулся —

В этом примере мы искали «ложку мороженого», используя как текстовый, так и векторный поиск. Текстовый поиск возвращает документы, содержащие ключевые слова «ан», «мороженое», «сливки» и «совок». Результат, занявший четвертое место по текстовому поиску, — автомат с мороженым, и уж точно не совок. Причина, по которой он получил такое высокое значение, заключается в том, что его название «Breville BCI600XL Smart Scoop Ice Cream Maker» содержало три ключевых слова в предложении: «Scoop», «Ice», «Cream» и, следовательно, получило высокую оценку на BM25, хотя оно не соответствует нашему запросу. С другой стороны, векторный поиск возвращает результаты, семантически схожие с запросом, независимо от того, присутствуют ли ключевые слова в документе или нет. Он знал, что тот факт, что слово «совок» появилось перед словом «мороженое», означает, что оно также не будет совпадать. Таким образом, мы получаем более полный набор результатов, включающий не только документы, в которых упоминается «ложка мороженого».

Очевидно, что если вы будете использовать только один тип поиска, вы упустите ценные результаты или отобразите неточные результаты и разочаруете своих клиентов. При использовании преимуществ обоих миров мы получаем более точные результаты. Итак, я считаю, что ответ на наш вопрос заключается в том, что вместе лучше, и это подтвердилось.

Но подождите, может лучше стать еще лучше? Одним из способов улучшить качество поиска является использование мощности APU (Associative Processing Unit) в OpenSearch. Выполняя векторный поиск на APU с помощью плагина Searchium.ai, мы можем воспользоваться преимуществами передовых алгоритмов и возможностей обработки для дальнейшего улучшения задержки и значительного сокращения затрат (например, 0,23 доллара США против 8,76 доллара США) нашего поиск, при этом получая похожие результаты для векторного поиска.

Мы можем установить плагин, загрузить индекс в APU и искать, отправив слегка измененное тело запроса:

apu_request_body = {
    "size": 20,
    "query": {
        "gsi_knn": {
            "field": "description_vector",
            "vector": get_vector_sentence_transformers(query).tolist(),
        }
    },
    "_source": ["asin", "text_field", "item_image"],
}                                                                                   

Все остальные шаги идентичны!

В заключение, объединив текстовый и векторный поиск с помощью OpenSearch и Sentence Transformers, предприятия могут легко улучшить результаты поиска. И, используя APU, предприятия могут вывести результаты поиска на новый уровень, а также сократить расходы на инфраструктуру. Не позволяйте опасениям по поводу сложности сдерживать вас. Попробуйте и убедитесь сами, какие преимущества это может принести. Удачных поисков!

Полный код можно найти здесь

Огромное спасибо Яниву Вакнину и Дафне Идельсон за помощь!