Привет, Хабр!
Меня зовут Анатолий, занимаюсь автоматизацией бизнес-процессов и применением Искусственного Интеллекта в бизнесе.
Кейсовая задача - создать Систему генерации ответов на основе существующей истории тикетов поддержки. При этом Система должна работать в закрытом контуре.
Общий ход
Датасет, поиск релевантного фрагмента, генерация ответа
Подготовка данных
Исходные данные представляли собой большой CSV-файл, полученный как экспорт истории тикетов поддержки, по нескольким филиалам, на нескольких языках.
Простыми операциями pandas (loc, dropna) были выбраны ответы сотрудников определенного офиса, на русском языке и не пустые.
Получившиеся реплики сохранены в date_massive.
Question-Answering
Одним из первых исторически сложившихся способов поиска ответа в текста считается Question-Answering. С него и хотелось начать.
Модель
Была выбрана модель timpal0l/mdeberta-v3-base-squad2 - модель,основана на предобученной BERT.
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 символов. При этом все блоки должны быть не только одинаковой длины (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
Система хорошо находит ответы в тикетах, но работает медленно, так как при каждом вопросе требуется токенизация всей базы. Поэтому ее можно использовать там, где время ответа в минутах не является критичным, например, при формировании ответа для электронного письма, но не подходит для живого чата.
Учитывая вышеизложенное, я предпочел другой способ, в котором токенизацию всей базы можно осуществить заранее и сохранить полученные эмбеддинги, а при поступлении вопроса вычислять только эмбеддинг вопроса. Это значительно сокращает количество вычислений и уменьшает время поиска ответов.
Об этом способе планируется рассказать в следующей части.
