Я занимаюсь классическим ML, как это теперь принято называть. Делаю продвижение в поиске и рекомендациях Авито (и еще пишу в канал Big Ledovsky). Работа, признаюсь, интересная, и очень мне нравится. Однако этот хайп вокруг LLM.. Да даже не хайп, а просто бытовой опыт использования LLM говорит: нужно разобраться в этой технологии, это серьезный прорыв в отрасли.
И вот в преддверии AI Journey выложили соревнование, где нужно было построить ассистента для рекомендации товаров Мегамаркета, а в качестве модели использовать Gigachat через API. Я решил, что время поделать что-то руками настало. В итоге получилось нарешать на 3-е место.
Как человек, который первый раз делал RAG пайплайн, я получил много инсайтов и интуиции, которыми хочу поделиться. Всем заинтересованным добро пожаловать под кат.
В чем состояло задание
Ассистент, которого необходимо было построить, должен был выявить потребность пользователя в товаре и подобрать ему что-то подходящее. Пользователь что-то сообщает про товар, бот либо что-то уточняет, либо в какой-то момент выдает рекомендацию из 10 товаров. Метрикой качества был Recall@10 - средняя доля релевантных айтемов, попавших в топ-10.
Почему соревнование было реально крутым
Во-первых, задача построения бота для маркетплейса заслуживает большего внимания, чем может показаться на первый взгляд. Когда речь заходит про LLM, люди часто скептически говорят "где там деньги?". То что LLM может решить математическую задачу это конечно здорово, но можно ли на этом серьезно заработать? Ответ в том, что наверное самое значимое с финансовой точки зрения применение LLM - это диалог с ассистентом как альтернатива поиску. Неизвестно, выстрелит это или нет. Например, голосовые интерфейсы не пришли на замену традиционным. Но вероятность есть. Поэтому крупным компаниям нужно уже сейчас развивать ботов на LLM, иначе потом можно безнадежно отстать.
Во-вторых, в соревновании была проверочная система, основанная на общении бота с ботом. Без такой проверочной системы (или армии тестеров) не получится понять по-настоящему, что ваш пайплайн работает. Построить такую систему очевидно непросто, и соревнование было крутым как минимум потому, что в нем была такая система. Метрика качества оценивалась на 50 диалогах, а посылать можно было 4 раза в день. Что происходило внутри системы неизвестно, но скор реагировал на изменения в пайплайне.
Устройство бейзлайна
Я не стал придумывать велосипед и строил свое решение от бейзлайна. Бейзлайн был выполнен в виде графа на библиотеке langchain. Опуская подробности, граф состоял из следующих этапов
Проверка адекватности. Мы спрашиваем LLM, относится ли реплика к тематике покупки на маркетплейсе. Если нет, то просим сгенерить ответ, чтобы вернуть пользователя к теме подбора товаров.
Выделение сути запроса. Смотрим на всю историю диалога с пользователем и просим выделить одним предложением запрос со всеми уточнениями. "Куртка мужская, пуховик, 50 размер, черная".
Уточнение категории. Этот этап работал через векторный поиск. В задаче давалось дерево категорий. Все категории мы предварительно обработали моделью e5-large и сложили эмбеддинги в память с помощью chromadb. В процессе работы запрос пользователя также превращается эмбеддинг и к нему ищутся ближайшие категории.
Уточнение цены и других свойств. В бейзлайне было сделано буквально так: если цены нет с словаре состояния диалога, то мы просим LLM сгенерировать вопрос про цену. Также мы просим LLM сгенерировать 3 вопроса про товар.
Формирование выдачи. В бейзлайне применялся фильтр по категории и цене (а товаров там могло быть много) а затем.. запускался промпт, в котором мы давали сырые данные по товарам и просили выбрать наиболее релевантный запросу
В целом, было интересно познакомиться с langchain. Я бы не сказал, что она работает исключительно с LLM пайплайнами. Это просто библиотека для построения графа диалога. А внутри хоть регулярки пишите.
Как выглядит нода в langchain на примере выделения сути запроса
class RetrievalNode:
"""Retrieves core information from dialog of user and assistant."""
def __init__(self, llm: BaseChatModel, prompt: str, log=False) -> None:
self.extraction_model = PromptTemplate.from_template(prompt) | llm | StrOutputParser()
self.log = log
def invoke(self, state: AgentState) -> AgentState:
# У вашего бота есть состояние - state
# Оно сохраняется между репликами
# Во время каждой реплики вы проходите по всему графу
# В какой-то ноде графа нужно сформировать ответ и выйти
# Нода принимает и возвращает объект состояния
history = "\n".join([f"{msg.role}: {msg.content}" for msg in state["messages"][1:]
if isinstance(msg, (HumanMessage, AIMessage))])
response = self.extraction_model.invoke({"history": history})
if self.log is True:
print("RetrievalNode")
print(f"Response: {response}")
print()
message = FunctionMessage(
content=str(response),
name="retrieve_func",
)
messages = state["messages"] + [message]
return {"messages": messages, "requirements": state["requirements"]}
Что сработало
Я довольно быстро избавился от "лишних" нод. Не могу сказать, что это как-то повысило скор, но и не уронило. А производительность выросла. Сперва я выкинул проверку адекватности реплики. Потом под нож пошло ранжирование по сырым данным товаров с помощью LLM. Вместо него я сделал выдачу случайных кандидатов. LLM не может нормально ранжировать большое количество айтемов одним промптом. Как минимум потому, что все айтемы не влезают в контекст.
Основой моего скора стала работа с деревом категорий. По опыту работы над поиском Авито, я знаю, что определение категории - это очень важно. Я заметил, что эмбеддинги в целом дают релевантные категории, но плохо поранжированные. Обычную supervised модель ранжирования я построить не мог. Датасета не было, а синтетический я сходу придумать не смог. Поэтому я сделал ранжирование на основе LLM. Для этого я написал промпт для сравнения двух категорий и встроил запрос LLM в качестве компаратора в функцию сортировки.
Промпт ранжирования
RANK_PROMPTE_TEMPLATE = """
Определи какая категория из двух лучше подходит запросу
Отвечай 1 или 2.
== Примеры ==
Запрос:
Бас-гитара
Категория 1:
Гитары
Категория 2:
Музыкальные инструменты
Ответ:
1
Запрос:
Гитары
Категория 1:
Аккустические гитары
Категория 2:
Гитары
Ответ:
2
Запрос:
Рубашка
Категория 1:
Рубашки
Категория 2:
Мужские рубашки
Ответ:
1
Запрос:
iPhone 15
Категория 1:
Телефоны iPhone
Категория 2:
Телефоны iPhone 15
Ответ:
2
Запрос:
Плита
Категория 1:
Плиты газовые
Категория 2:
Плиты
Ответ:
2
== Конец примеров ==
Запрос:
{query}
Категория 1:
{cat_1}
Категория 2:
{cat_2}
Ответ:
"""
Класс для ранжирования
class Ranker():
def __init__(self, llm):
self.llm = llm
self.extraction_model = PromptTemplate.from_template(RANK_PROMPTE_TEMPLATE) | self.llm | StrOutputParser()
def compare(self, query: str, cat_1: str, cat_2: str) -> int:
res = self.extraction_model.invoke({"query": query, 'cat_1': cat_1, 'cat_2': cat_2})
try:
res = int(res)
if res == 1:
return 1
else:
return -1
# llm may return a non-valid value
except ValueError:
return 0
def rank(self, query: str, ids: list[int], docs: list[str]):
def cmp(id_1: int, id_2: int):
doc_1 = docs[id_1]
doc_2 = docs[id_2]
res = self.compare(query, doc_1, doc_2)
return res
return sorted(ids, key=functools.cmp_to_key(cmp))
Я очень долго подбирал промпт для ранжирования. Мне хотелось, чтобы на запрос "Гитара", самым релевантным была категория "Гитары", а не "Музыкальные инструменты" или "Электрогитары". Чтобы настроить промпт, я добавлял в него разные примеры, и в итоге качество меня удовлетворило.
Также я поработал над эвристиками похода по категорийному дереву: разрешил останавливаться на нелистовых категориях, начал добавлять родителей топа по эмбеддингам в кандидаты и прочее.
Остальные улучшения были относительно минорны. Я поработал над очевидными дополнительными вопросами, типа уточнения бренда. И еще из интересного обработал отдельно категорию Одежда. Дело в том, что в категорийном дереве одежда на верхнему уровне делится на мужскую и женскую. И если заранее не определить пол (а также тот факт, что одежда может быть детской), то можно уйти не в ту ветку категорийного дерева и никогда оттуда не вернуться.
Что не сработало
Изначально когда я отключал ранжирование айтемов через LLM, я думал заменить его на bm25. Идея вроде была очевидна, но по факту bm25 только ухудшил качество. Я думаю, это связано с тем, что часто мне не хватало нужных свойств в запросе и bm25 ранжировал заведомо неправильно. А случайные кандидаты оставляли хоть какой-то шанс для подходящих товаров.
Чтобы уточнять свойства товаров, я придумал способ задавать правильные вопросы. Я начал просить LLM назвать три главных свойства категории, затем искал ближайшие векторным поиском, а уже затем промптом задавал вопрос по найденному свойству. Результат ответов я просил извлечь и сложить в правильный JSON. Затем я пытался пофильтровать кандидатов по найденным свойствам. На моих локальных примерах такой подход улучшал выдачу, но при отправке качество ухудшилось.
Кажется, проблема была в необходимости нечеткой фильтрации. После окончания соревнования я узнал, что другой человек обучал бертоподобные модели для оценки релевантности свойств запроса свойствам айтема на синтетическом датасете. И это круто работало.
Мои инсайты про RAG пайплайны. Где LLM работает хорошо, а где нет?
На мой взгляд основная магия предложенного бейзлайна была в ноде выделения сути запроса. LLM очень сильны в ответе на вопросы по тексту и суммаризации. Также меня поразила способность LLM выдавать в качестве ответа валидные JSON с нужной структурой и ключами. Стабильность работы практически 100%! Даже если где-то и будет ошибка, на проде ее можно обработать ретраем. В общем, выдача JSON абсолютно применима на практике.
Однако слабостей у LLM тоже хватает. Например, на мой взгляд не нужно давать LLM самостоятельно отвечать на какие-то вопросы, как бы хорошо ее не обучали. Лучше подавать ей в промпт текст, найденным обычным поиском, и свести задачу к ответу на вопрос по тексту.
Также я заметил у LLM проблему с определением важности информации в тексте. Когда ты просишь выделить самое важное свойство или самую важную информацию из текста, модель не может понять, что на самом деле важно, а что нет. Например, я давал LLM параметры шин, и важной она считала страну производителя, а не толщину протектора.
Общие впечатления
Подводя итог, я получил огромное удовольствие от соревнования и опыта работы с RAG пайплайном. В процессе решения обучать никакие модели мне не пришлось, зато пришлось много писать промпты. Многие посмеиваются над этим занятием, но на самом деле оно достаточно увлекательное. Советую попробовать при случае сделать что-нибудь своими руками!
И отдельно хочется сказать спасибо организаторам соревнования и всей конференции AI Journey! Соревнование было очень интересным. Сама конференция будет проходить онлайн 11-13 декабря 2024, всем заинтересованным рекомендую ее посмотреть.