Привет, Хабр!
Меня зовут Анатолий, занимаюсь автоматизацией бизнес-процессов и применением Искусственного Интеллекта в бизнесе.

Кейсовая задача - создать Систему генерации ответов на основе существующей истории тикетов. При этом Система должна работать в закрытом контуре.

Общий ход

Датасет, поиск релевантного тикета, генерация ответа

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

Исходные данные представляли собой большой CSV-файл, полученный как экспорт истории тикетов поддержки, по нескольким филиалам, на нескольких языках.
Простыми операциями pandas (loc, dropna) были выбраны ответы сотрудников определенного офиса, на русском языке и не пустые.
Получившиеся реплики сохранены в date_massive.

Question-Answering

Сначала хотелось потестировать именно Question-Answering, поэтому была выбрана модель timpal0l/mdeberta-v3-base-squad2.

import numpy as np
import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModelForQuestionAnswering

model_name = "timpal0l/mdeberta-v3-base-squad2"
model = AutoModelForQuestionAnswering.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)

if torch.cuda.is_available():
  device = torch.device("cuda:0")
  model = model.to(device)

Окно контекста модели составляет 512 токенов, поэтому тексты тикетов пришлось разбивать на несколько фрагментов.

max_length=512
overlap=128
fragments = []

for key in data_massive:
  if len(str(key)) <= 512:
    fragments.append(str(key))
  else:
    key_fragments = [str(key)[i:i+max_length] for i in range(0, len(str(key)), max_length-overlap)]
    for key2 in key_fragments:
      fragments.append(str(key2))

Необходимо отметить, что разбиение текстов на фрагменты по 512 символов не является корректным, ведь должно быть 512 токенов, а не символов. Более того, данная модель основана на предобученной BERT и для нее необходим совершенно иной способ разбиения - все блоки должны быть одинаковой длины (512 токенов) и начинаться с токенов вопроса. То есть нужно токенизировать совместно текст вопроса и текст тикета, и если токенов больше 512, то разбивать полученные токены на блоки по 512, причем так, чтобы в начале каждого блока были токены вопроса, а потом остальные токены. С точки зрения кода это реализуется достаточно просто, но я оставил так, потому что в дальнейшем это перестало быть важным и скоро станет понятно, почему именно. А пока при данном способе разбиения просто почти все получающиеся блоки не будут превышать 512 токенов, так как в общем случае токенов получается меньше, чем символов.

Поиск ответа

Базовая функция для поиска ответа

def find_answers(question):

    answers = []
    for fragment in tqdm(fragments):

        # Токенизация входных данных
        inputs = tokenizer.encode_plus(question, fragment, return_tensors='pt')
        if torch.cuda.is_available():
          inputs = inputs.to(device)

        # Получение ответа
        outputs = model(**inputs)
        answer_start = torch.argmax(outputs.start_logits)
        answer_end = torch.argmax(outputs.end_logits) + 1

        # Декодирование ответа
        answer = tokenizer.convert_tokens_to_string(tokenizer.convert_ids_to_tokens(inputs['input_ids'][0][answer_start:answer_end]))

        if answer != '[CLS]' and str(answer).strip() != '':
          answers.append(answer)

    return answers

Отправляем в функцию вопрос и получаем массив ответов.

question = "Сколько дней обрабатывается поручение на изменение сведений?"
answers = find_answers(question)

Мне понравилось,что ответы находились довольно точно (даже при не совсем корректном разбиении на фрагменты).
Например,
трех рабочих дней
3-5 рабочих дней
5 рабочих дней
до 3-х рабочих дней
до 5-и рабочих дней

то есть, как было написано в реальных тикетах, так и находилось

Ответов много, и из них хочется выбрать "лучшие".

Добавляем показатели

Question-Answering позволяет определять вероятность нахождения ответа в заданном фрагменте.

Функция для поиска ответом с учетом вероятности

def find_answers_few(question):

    answers = []
    for fragment in tqdm(fragments):

        # Токенизация входных данных
        inputs = tokenizer.encode_plus(question, fragment, return_tensors='pt')

        if torch.cuda.is_available():
          inputs = inputs.to(device)

        # Получение выходных вероятностей
        outputs = model(**inputs)
        start_logits = outputs.start_logits
        end_logits = outputs.end_logits

        # Вычисление вероятностей начала и конца ответа
        start_probs = F.softmax(start_logits, dim=1)
        end_probs = F.softmax(end_logits, dim=1)

        # Вычисление вероятности нахождения ответа в заданном фрагменте
        answer_prob = torch.max(start_probs) * torch.max(end_probs)


        # Получение ответа
        answer_start = torch.argmax(outputs.start_logits)
        answer_end = torch.argmax(outputs.end_logits) + 1

        # Декодирование ответа
        answer = tokenizer.convert_tokens_to_string(tokenizer.convert_ids_to_tokens(inputs['input_ids'][0][answer_start:answer_end]))

        if answer != '[CLS]' and str(answer).strip() != '':
          answers.append([answer_prob.item(), torch.max(start_probs).item(), torch.max(end_probs).item(), max(torch.max(start_probs).item(), torch.max(end_probs).item()), answer, fragment, len(fragment)])

    return answers

Здесь появляется вероятность начала ответа и вероятность окончания ответа, а также их "объединенная" вероятность как их произведение.
Можно ранжировать как по "объединенной" вероятности, так и по максимальной из двух. Если взять по 10 "лучших" ответов, то наборы будут примерно одинаковыми.

Время обработки

Время обработки оказалось очень большим.
Даже на минимальном тестовом наборе из всего 1000 тикетов обработка на CPU заняла примерно 18 минут, а на GPU порядка 2 минут.
Это очень долго.
Такое время обработки и оказалось причиной, почему я даже не стал доводить до корректного разбиения.

Генерация ответа

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

Для автоматизации ответов клиентам я развернул экземпляр Ollama на сервере и ответы генерировались с применением квантизованной версию модели llama3.2 и полученных фрагментов.

Выводы по Question-Answering

Система хорошо находит ответы в тикетах, но работает медленно. Поэтому её можно использовать там, где время ответа в минутах не является критичным, например, как ассистент сотрудника при формировании ответа для электронного письма, но не подходит для живого чата.

Время обработки велико, так как токенизация вопроса и остального текста выполняется совместно. Это означает, что при каждом новом вопросе вся вычислительная работа по токенизации выполняется заново, что является затратным процессом и не может быть выполнено заранее.

Также важно учитывать малое окно контекста, что означает, что ответ в тексте должен быть коротким и четким. Например, подойдет срок оформления заявки, но не подробная инструкция по какой-либо опции.

Учитывая вышеизложенное, я предпочел другой способ - использование эмбеддингов и косинусной близости (семантический поиск). Преимущество этого метода в том, что эмбеддинги фрагментов можно вычислить заранее и сохранить, а для нового вопроса вычисляется только эмбеддинг вопроса. Это значительно сокращает количество вычислений и уменьшает время поиска ответов, при этом ответы могут быть более развернутыми.
Об этом способе планируется рассказать в следующей части.

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