Введение

Для этой задачи использую LLM (Large Language Models - например, chatGPT или opensouce модели) для внутренних задач (а-ля поиск или вопрос-ответную систему по необходимым данным).

Я пишу на языке R и также увлекаюсь NLP (надеюсь, я не один такой). Но есть сложности из-за того, что основной язык для LLM - это python. Соответственно, на R мало примеров и документации, поэтому приходится больше времени тратить, чтобы “переводить” с питона, но с другой стороны прокачиваюсь от этого.

Чтобы не городить свою инфраструктуру, есть уже готовые решения, чтобы быстро и удобно подключить и использовать. Это LangChain и LlamaIndex. Я обычно использую LangChain (дальше он и будет использоваться). Не могу сказать, что лучше, просто так повелось, что использую первое. Они написаны на питоне, но с помощью библиотеки reticulate всё работает и на R.

Данные и окружение

Будем использовать модели от OpenAI и Hugging Face.

Устанавливаем и подключаем библиотеки

library(reticulate)
library(data.table)
library(magrittr)
library(stringr)

py_install(c('torch', 
             'torchvision', 
             "torchaudio", 
             'langchain', 
             'docx2txt', 
             'huggingface_hub', 
             'sentence-transformers',
             'transformers',
             'faiss-cpu', 
             'openai==0.28.1', 
             'tiktoken'), pip = TRUE)


langchain <- import('langchain')
transformers <- import('transformers')

Константы

Сразу для двух примеров определим (chatGPT и HF модели).

OPENAI_API_KEY <- '<YOUR OPENAI TOKEN>'
MODEL_NAME_GPT <- "gpt-4"

HUGGINGFACEHUB_API_TOKEN <- '<YOUR HUGGINGFACE TOKEN>'
EMBEDDINGS_MODEL_HF <- "distiluse-base-multilingual-cased-v2"
REPO_ID <- "mistralai/Mistral-7B-Instruct-v0.1"
MODEL_NAME_PIPE <- "cointegrated/rut5-base-multitask"

MAX_LEN <- 128L
TEMPERATURE <- 0.5

Данные

Чтобы показать возможности, нужны были данные, которые языковые модели ещё не видели. Для примера я составил вопросы и ответы из карачаево-балкарского нартского эпоса.

data <- data.table(questions = c("Гемуда - чей конь?", "Как обычно выглядят эмегены?", "Как появился двухглавый Эльбрус?", "Где родился Сосурук?", "Как Сатанай спасла мужа?"),
                   answer = c("Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.",
                              "Алауган стал разглядывать эмегеншу: рот ее был в одну сажень, нижняя губа свисала до грудей, на лбу у нее был один глаз, а на затылке ярко блестел, как утренняя звезда, другой глаз, величиной с человеческую голову. На красной, как медь, голове не было волос. Она была громадной, словно девять буйволов вместе. Руки и ноги ее были необъятной толщины. Ногти ее были длинными и острыми, как клюв орла.",
                              "— Не бей меня, — сказал Гемуда, — Если ты будешь достойным мужчиной, Я в море буду как рыба, обгоню даже рыб, Грудью ударив, подводные скалы могу разбить. В темном небе, как орел, могу летать. Только на земле не могу быть таким же быстрым, как Генжетай. А в морях и озерах я ему первенства не уступлю. Ведь Генжетай — из степных, а я — из морских коней. Вот потому я — первый на воде, А он в степи обгоняет всех коней. Молвив это, Гемуда к подножию Минги тау поднялся. Перепрыгнул через него на другую сторону. Его задние ноги задели его вершину и сделали гору двуглавой. Потом оттуда обратно перепрыгнул.", 
                              "Однажды, когда нарты возвращались из похода, на островке одной могучей реки они увидели маленького мальчика: на глазах нартов раскололся большой камень состар и этот мальчик появился из него. Нарты остановились на этом месте, чтобы дать отдых коням. Они стали препираться друг с другом: «Ты поплыви к острову за мальчиком». — «Нет, ты». Наконец один из нартов решился поплыть. Его звали Ёрюзмеком. Привязали
к поясу Ёрюзмека аркан и спустили его в реку. Ерюзмек доплыл до острова, снял аркан с пояса, привязал его к камню, а сам пошел к месту, где находился мальчик. Подошел он и увидел богатыря-младенца. Ёрюзмек протянул мальчику указательный палец. Мальчик тут же схватил его палец. Ёрюзмек пытался вытащить свой палец, но никак не мог, и, чтобы освободиться от мальчика, он так дернул руку, что вывихнул палец. Ёрюзмек вправил свой палец и доставил мальчика с острова к нартам.",
"Тогда взбешенный Ёрюзмек пошел к своей жене Сатанай за советом. Сатанай посоветовала ему идти на пир, но так как его хотят отравить там бузой, то
вставить в горло медную трубочку, через которую бы питье проливалось наружу. Она сама сделала это, и Ёрюзмек отправился на пир. Пир был громадный, Ёрюзмека чествовали больше всех, но он во рту вливал бузу вместо горла в медную трубку и остался совершенно трезвым и неотравленным, так как буза была подносима ему с отравой. "
                              ))

Поиск

Чтобы осуществлять поиск, сначала нужно проиндексировать данные, а для этого векторизируются они через эмбеддинги. По сути эмбеддинг - это вектор слова (точнее токена), отражающий его характеристику. Чтобы получить эмбеддинги (превратить в вектора слова), то можно использовать решение OpenAI или либо на базе открытых модель. В качестве векторной БД использую FAISS - быстрое решение для поиска схожести векторов от Meta, но можно использовать и другие БД.

# эмбеддинги chatGPT 
embeddings_gpt = langchain$embeddings$OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY)
# эмбеддинги SentenceTransformers 
# embeddings_hf = langchain$embeddings$HuggingFaceEmbeddings()
embeddings_hf = langchain$embeddings$HuggingFaceEmbeddings(model_name=EMBEDDINGS_MODEL_HF)

# загружаем датасет в лоадер, выделив колонку для векторизации
loader <- langchain$document_loaders$DataFrameLoader(data, page_content_column='questions')
# docx: loader <- langchain$document_loaders$Docx2txtLoader(data_path)
# pdf: loader <- langchain$document_loaders$PDFMinerLoader(data_path)
# txt: loader <- langchain$document_loaders$TextLoader(data_path)

documents <- loader$load()
documents[[1]]
### Document(page_content='Гемуда - чей конь?', metadata={'answer': 'Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.'})

# создаем сплиттер документов, чтобы уложиться в лимит по токенам, в нашем случае это не очень полезный шаг
text_splitter<- langchain$text_splitter$RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=0)
texts <- text_splitter$split_documents(documents)

# создаем хранилище
db_gpt <- langchain$vectorstores$FAISS$from_documents(texts, embeddings_gpt)
db_hf <- langchain$vectorstores$FAISS$from_documents(texts, embeddings_hf)

# db_hf$as_retriever()

# также можно сохранить хранилище локально
# db_hf$save_local(path_save_index)

# Загружаем индексы
# db = langchain$vectorstores$FAISS$load_local(path_save_index, embeddings_hf)
db_hf
### <langchain.vectorstores.faiss.FAISS object at 0x0000025D9ED4AD60>

# запрос
query <- "Какому нарту в конечном итоге принадлежит лошадь по имени Гемуда?"


# поиск. Чем меньше значение, тем ближе
# chatGPT
db_gpt$similarity_search_with_score(query)[[1]] %>% print
### [[1]]
### Document(page_content='Гемуда - чей конь?', metadata={'answer': 'Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.'})
### 
### [[2]]
### [1] 0.2217191

# Hugging Face
db_hf$similarity_search_with_score(query)[[1]] %>% print

### [[1]]
### Document(page_content='Гемуда - чей конь?', metadata={'answer': 'Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.'})
### 
### [[2]]
### [1] 0.4073001

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

Теперь сделаем не просто поиск, а чтобы языковая модель могла отвечать на вопрос. По сути RetrievalQA. Не буду углублятся в то, как это работает (можно поизучать на LangChain и DeepLearningAI (видео на английском), всё на питоне, конечно, но может добавить понимания). Сначала объединим для поиска вопрос и ответ.

# загружаем датасет в лоадер, выделив колонку для векторизации
loader_n <- langchain$document_loaders$DataFrameLoader(copy(data)[, search := paste0("Question: ", questions, "\nAnswer: ", answer)], page_content_column = "search")
# docx: loader <- langchain$document_loaders$Docx2txtLoader(data_path)
# pdf: loader <- langchain$document_loaders$PDFMinerLoader(data_path)
# txt: loader <- langchain$document_loaders$TextLoader(data_path)

documents_n <- loader_n$load()
documents_n[[1]] %>% print()
### Document(page_content='Question: Гемуда - чей конь?\nAnswer: Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.', metadata={'questions': 'Гемуда - чей конь?', 'answer': 'Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.'})

# создаем хранилище
db_gpt_n <- langchain$vectorstores$FAISS$from_documents(documents_n, embeddings_gpt)
db_hf_n <- langchain$vectorstores$FAISS$from_documents(documents_n, embeddings_hf)

А теперь используем всю силу LLM.

# Open AI
llm_gpt <- langchain$chat_models$ChatOpenAI(temperature = TEMPERATURE, 
                                            openai_api_key = OPENAI_API_KEY, 
                                            model_name = MODEL_NAME_GPT, 
                                            max_tokens = MAX_LEN)

chain_gpt <- langchain$chains$RetrievalQA$from_chain_type(
  llm = llm_gpt,
  chain_type = 'stuff',
  retriever = db_gpt_n$as_retriever()
  )

# Hugging Face
llm_hf <- langchain$llms$HuggingFaceHub(repo_id = REPO_ID, 
                                        huggingfacehub_api_token = HUGGINGFACEHUB_API_TOKEN, 
                                        model_kwargs = list(temperature = TEMPERATURE, 
                                                            max_length = MAX_LEN), 
                                        # task = 'text2text-generation',
                                        task = 'text-generation'
                                        )

chain_hf <- langchain$chains$RetrievalQA$from_chain_type(
  llm = llm_hf,
  chain_type = 'stuff',
  retriever = db_hf_n$as_retriever()
  )

# Результат
chain_gpt$run(query) %>% cat()
### Лошадь Гемуда принадлежит Алаугану, который показал его своему сыну Карашауаю, предполагая, что если Карашауай вырастет настоящим мужчиной, Гемуда будет для него достойным конем.

chain_hf$run(paste0("[INST] ", query, " [/INST]")) %>% cat()
### Кроме Карашауая, у Алаугана детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в под

Не плохо справилась модель мистраль, хоть и на английском ответила.

Генерация ответа локальной моделью

Например, у вас есть собственная модель или вы хотите, чтобы какая-нибудь модель работала локально. Это можно реализовать таким образом. Для разнообразия поменяем модель.

tokenizer <- transformers$T5Tokenizer$from_pretrained(MODEL_NAME_PIPE)
model <- transformers$T5ForConditionalGeneration$from_pretrained(MODEL_NAME_PIPE)


# загружаем датасет в лоадер, выделив колонку для векторизации
loader_pipeline <- langchain$document_loaders$DataFrameLoader(data, page_content_column = "answer")
documents_pipeline <- loader_pipeline$load()
documents_pipeline[[1]] %>% print()
### Document(page_content='Судя по рассказам, Алауган был спокойным, скромным человеком огромного. Кроме Карашауая, у него детей не было. Он очень любил своего единственного сына и радовался, видя, что маленький Карашауай растет настоящим богатырем. Как-то однажды Алауган взял с собой Карашауая и повел его в подземелье, где находился Гемуда. Когда они открыли замок и вошли туда, он показал Гемуду и ушел, сказав: — Вот мой конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.', metadata={'questions': 'Гемуда - чей конь?'})

texts_pipeline <- text_splitter$split_documents(documents_pipeline)
db_hf_pipeline <- langchain$vectorstores$FAISS$from_documents(texts_pipeline, embeddings_hf)


# Pipeline
pipe <- transformers$pipeline(task = 'text2text-generation',
                              model = model, 
                              tokenizer = tokenizer)

llm_hf_pipeline = langchain$llms$huggingface_pipeline$HuggingFacePipeline(pipeline = pipe, 
                                                                          model_kwargs = list(temperature = TEMPERATURE))



chain_hf_pipeline <- langchain$chains$RetrievalQA$from_chain_type(
  llm = llm_hf_pipeline,
  chain_type = 'stuff',
  retriever = db_hf_pipeline$as_retriever()
  )

# Результат
chain_hf_pipeline$run(paste0("reply | ", query)) %>% cat()
### конь, на котором я езжу.

Ответ одно предложение из чанки попытался выдернуть.

texts_pipeline[[2]]
### Document(page_content='конь, на котором я езжу. Если ты будешь настоящим мужчиной, он всегда будет для тебя достойным конем. Он будет понимать все, что ты скажешь, и делать все то, что ты велишь. С этого дня вы будете неразлучны, познакомьтесь друг с другом.', metadata={'questions': 'Гемуда - чей конь?'})

Не самая подходящая модель для данного случая, но относительно лёгкая и для примера не плоха.

Заключение

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

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


  1. MentalSky
    12.01.2024 15:55

    не пробовали предобученные веса torch-NLP моделей загружать в torch в R, без reticulate?


    1. TSjB Автор
      12.01.2024 15:55
      +1

      да, про торч на R знаю, проблема в том, что langchain на pythone, можно пойти дальше и сделать свой кастомный ретривер исключительно на R


  1. ArchMikhail
    12.01.2024 15:55

    Не силен в R. Но для чего здесь этот костыль в виде R, если по сути вся "внутрянка" от Python?


    1. TSjB Автор
      12.01.2024 15:55
      +1

      Это для тех, у кого проекты исключительно на R и/или кому не удобно на питоне писать