Разрабатываем первое AI приложение

Пошаговое руководство по разработке AI приложения с векторным хранилищем 

Схема 1

Схема 1

Почему нам нужен LLM

Эволюция языка сыграла значительную роль в развитии человечества. Она дает нам возможность делиться знаниями и работать вместе. Благодаря этому большая часть нашего опыта продолжает сохраняться и передаваться через разные письменные тексты.

За последние двадцать лет было предпринято много усилий для цифровизации информации и процессов. Большинство из них сосредоточено на накоплении данных в реляционных базах. Этот подход позволяет традиционным аналитическим методам машинного обучения обрабатывать и анализировать данные.

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

Приблизительно 80% данных в компаниях имеют неструктурированный формат. Такие как вакансии, резюме, электронные письма, текстовые документы, слайды PowerPoint, аудиозаписи и видео.

ec3132e35c4cc82318c41bb5e000f874.png

Fine-Tuning vs. Context Injection

Существует два подхода к тому, как заставить языковые модели отвечать на вопросы, на которые они не знаю ответы это Fine-Tuning и embeding

Fine-Tuning

Файн-тюнинг — это обучение существующей LLM на собственных данных под конкретную задачу. Но что бы не делать это с нуля использую уже обученную модель как BERT или LLama.

Команда из Гонконгского политехнического университета разработала LlamaCare, медицинскую языковую модель, дообученную на наборе данных, включающем более 116 000 образцов медицинских текстов. Эта модель демонстрирует высокую точность в ответах на медицинские запросы, превосходя аналогичные модели, такие как ChatGPT

Fine-tuning позволяет адаптировать модель к конкретным задачам, но не дает возможность внедрить уникальные знания из вашей области. Эта возникает из-за того, что модель уже была обучена на огромном наборе данных, и, как правило, специфические данные из вашей области недостаточны для изменения знаний, которые модель приобрела.

Context Injection

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

964cef70b6d5112afdb05b042e873d76.png

Ну что начнем создавать наше приложение

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

Для реализации мы будем использовать LangChain.

Наше приложение будет иметь следущий фнукционал:

  1. Принимать документы в которых находиться информация о нашей компании

  2. Отвечать на наши вопросы по базе, которую мы загрузим

Что такое LangChain?

LangChain — это фреймворк написанный на Python, которая помогает создавать приложения с использованием LLM, такие как чат-боты. Библиотека объединяет множество компонентов, которые можно комбинировать.

На представленном изображении ниже показано, как взаимодействуют эти компоненты. Мы загружаем неструктурированные данные с помощью загрузчиков документов, разбиваем их на чанки. Затем модуль генерирует промпт и отправляет его в модель.

Поиск по векторной базе данных

Поиск по векторной базе данных

Как показано на рисунке мы загружаем данные в векторную базу данных.

Загрузка документов используя Langchain

LangChain может загружать множество документов из самых разных источников. Среди них загрузчики для HTML-страниц, S3, PDF, Notion, Google Drive.

Ниже простой код загрузки:

from langchain.document_loaders import PyPDFLoader

# Укажите путь к вашему PDF файлу
pdf_path = "/путь/к/вашему/document.pdf"

# Создание загрузчика для PDF
loader = PyPDFLoader(pdf_path)

# Загрузка документа
documents = loader.load()

# Вывод загруженных документов
for doc in documents:
    print(doc.page_content)

Следующим очень важным этапом будет правильная разбивка документа на части

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

Разбивка на фиксированные чанки

Это при котором данные делятся на части фиксированного размера.

Разбиение по предложениям или абзацам

Чанки формируются на основе завершенных предложений или абзацев.

Преимущества: Сохраняет смысл и контекст, улучшая качество эмбеддингов.

Недостатки: Размер чанков может варьироваться, что усложняет обработку.

Перекрестные чанки

Создание чанков с перекрытием (например, 50% от длины предыдущего чанка).

Преимущества: Уменьшает вероятность потери контекста между чанками.

Недостатки: Увеличивает объем данных для обработки.

Контекстуальное разбиение

Использование алгоритмов для определения логических границ (например, темы или структурные элементы).

Преимущества: Оптимизирует разбиение в зависимости от содержания текста.

Недостатки: Требует сложных алгоритмов и анализа текста.

Использование метаданных

Включение метаданных (например, заголовков) для определения границ чанков.

Преимущества: Позволяет лучше структурировать данные и сохранять контекст.

Недостатки: Зависит от наличия качественных метаданных.

Пример кода для разбивки на чанки

from transformers import AutoTokenizer

def chunk_text(text, max_tokens=512):
    # Создаем токенизатор, используя предобученную модель BERT
    tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
    
    # Преобразуем текст в токены
    tokens = tokenizer.encode(text)
    
    # Инициализируем пустой список для хранения чанков
    chunks = []
    
    # Разбиваем токены на чанки заданного размера
    for i in range(0, len(tokens), max_tokens):
        chunk = tokens[i:i + max_tokens]
        chunks.append(tokenizer.decode(chunk))
    
    return chunks

# Пример использования функции
text = "Ваш текст здесь."
chunks = chunk_text(text)

# Вывод каждого чанка
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1}: {chunk}")

Этот код разбивает текст на чанки фиксированной длины, используя токенизатор BERT.

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

Что такое эмбеддинг?

Представьте, что каждое слово — это точка в пространстве. Эмбеддинг — это способ описания этих точек с помощью чисел. Например, слово «кошка» может быть представлена как вектор $$[0.1, 0.3, 0.5]$$.

Почему это нужно?

Слова имеют разные значения и используются в разных контекстах. Эмбеддинги помогают учитывать эти различия. Например:

  1. «король» и «королева» будут ближе друг к другу в пространстве, чем «король» и «стол».

  2. Слова, которые часто встречаются вместе (например, «чай» и «кофе»), тоже будут ближе.

Как создаются эмбеддинги?

Эмбеддинги обучаются на больших текстовых данных с использованием алгоритмов, таких как Word2Vec или GloVe. Вот как это работает:

  1. Контекст: Алгоритм анализирует, какие слова часто появляются рядом друг с другом.

  2. Обучение: На основе этого анализа он создает векторы для слов так, чтобы похожие слова имели похожие векторы.

Пример работы с эмбеддингами

Допустим, у нас есть три слова: «кошка», «собака» и «машина». После обучения эмбеддинги могут выглядеть так:

  1. «кошка»: $$[0.2, 0.8]$$

  2. «собака»: $$[0.3, 0.7]$$

  3. «машина»: $$[0.9, 0.1]$$

Здесь видно, что «кошка» и «собака» находятся ближе друг к другу, чем «машина», что отражает их схожесть как домашних животных.

e3929c715c923d4fe37410c5f1bc013b.png

Применение эмбеддингов

Эмбеддинги используются в различных задачах:

  1. Поиск информации: Поиск документов по смыслу.

  2. Классификация текста: Определение темы текста.

  3. Перевод: Улучшение качества машинного перевода.

Эмбеддинги слов позволяют компьютерам более эффективно обрабатывать и понимать язык.

000b7bf442a18cd38adba705fc73a0b0.png

from gensim.models import Word2Vec

# Пример текстов
sentences = [
    ['кошка', 'сидит', 'на', 'ковре'],
    ['собака', 'играет', 'с', 'мячом'],
    ['кошка', 'и', 'собака', 'друзья'],
]

# Обучение модели Word2Vec
model = Word2Vec(sentences, vector_size=100, window=5, min_count=1, workers=4)

# Получение вектора для слова "кошка"
vector = model.wv['кошка']
print(vector)

# Поиск слов, похожих на "кошка"
similar_words = model.wv.most_similar('кошка')
print(similar_words)

Word2Vec предполагает, что слова, встречающиеся в похожих контекстах, имеют близкие значения. Для этого используются два метода обучения: CBOW предсказывает текущее слово на основе окружающих слов, а Skip-Gram наоборот, предсказывает окружающие слова, исходя из текущего. Обучение модели происходит на большом объеме текста, в результате чего для каждого слова создаются векторы, уменьшающие расстояния между схожими словами.

a5626f043a7e972a85cbb0e73f011d45.png

То, что я только что попытался объяснить на простом примере в 2-мерном пространстве, также относится и к более крупным моделям. Например, стандартные векторы Word2Vec имеют 300 измерений, в то время как модель Ada от OpenAI имеет 1536 измерений. Эти предварительно обученные векторы позволяют нам улавливать отношения между словами и их значениями с такой точностью, что мы можем выполнять с ними вычисления. Например, используя эти векторы, мы можем найти, что Яблоко + красный — зеленый = груша

Здесь мы можем представить, что «яблоко» ассоциируется с красным цветом, а «зеленый» — с другим фруктом, и в результате получаем «грушу», которая также может быть связана с этими цветами.

c7c2e488082e75d6bc86bcfb7cdd9f53.png

В Langchain мы можем подключить разные модели и интеграции, включая GPT от OpenAI и ресурсы от Huggingface. Если мы выберем GPT от OpenAI для работы с языком, первым делом нам нужно будет указать API-ключ. OpenAI предоставляет определенное количество бесплатного использования, но при превышении лимита токенов в месяц потребуется перейти на платный тариф.

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

import openai
import pandas as pd
import numpy as np
from numpy.linalg import norm
from langchain.text_splitter import RecursiveCharacterTextSplitter
from PyPDF2 import PdfReader

####################################################################
# загрузка PDF документа
####################################################################
# Функция для извлечения текста из PDF файла
def extract_text_from_pdf(file_path):
    reader = PdfReader(file_path)
    text = ''
    for page in reader.pages:
        text += page.extract_text()
    return text

# Загрузка текста из PDF
file_path = 'your_pdf_document.pdf'  # Замените на фактический путь к вашему PDF файлу
pdf_text = extract_text_from_pdf(file_path)

####################################################################
# разбиение текста
####################################################################
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=20,
    length_function=len,
)

# Разбиение текста из PDF на более мелкие части
texts = text_splitter.create_documents([pdf_text])

####################################################################
# расчет встраиваний
####################################################################
# Создание списка всех текстовых фрагментов
text_chunks = [text.page_content for text in texts]

# Создание DataFrame с текстовыми фрагментами
df = pd.DataFrame({'text_chunks': text_chunks})

# Функция для получения встраиваний с помощью модели text-embedding-ada-002
def get_embedding(text, model="text-embedding-ada-002"):
    text = text.replace("\n", " ")
    return openai.Embedding.create(input=[text], model=model)['data'][0]['embedding']

# Генерация встраиваний для каждого текстового фрагмента
df['ada_embedding'] = df.text_chunks.apply(lambda x: get_embedding(x, model='text-embedding-ada-002'))

####################################################################
# расчет встраивания для вопроса пользователя
####################################################################
users_question = "Как зовут твоего кота?"

# Вычисление встраивания для вопроса пользователя
question_embedding = get_embedding(text=users_question, model="text-embedding-ada-002")

# Создание списка для хранения рассчитанных косинусных сходств
cos_sim = []

# Вычисление косинусного сходства для каждого текстового фрагмента относительно вопроса пользователя
for index, row in df.iterrows():
    A = np.array(row.ada_embedding)
    B = np.array(question_embedding)

    # Вычисление косинусного сходства
    cosine_similarity = np.dot(A, B) / (norm(A) * norm(B))
    cos_sim.append(cosine_similarity)

# Добавление косинусного сходства в DataFrame
df['cos_sim'] = cos_sim

# Сортировка DataFrame по косинусному сходству
sorted_df = df.sort_values(by=['cos_sim'], ascending=False)

# Вывод отсортированного DataFrame для просмотра совпадений
print(sorted_df)

Что такое токен?

Токен — это слово или группа слов. В английском языке слова могут принимать разные формы, включая времена глаголов, множественное число и составные слова. Для решения этой проблемы применяется токенизация на уровне субслов, которая разбивает слова на более мелкие части, такие как корень, префикс и суффикс. Например, слово «tiresome» можно разделить на «tire» и «some», а «tired» — на «tire» и «d». Это позволяет увидеть, что «tiresome» и «tired» имеют общий корень (Wang, 2023).

OpenAI предлагает токенизатор на своем сайте, чтобы помочь понять, что такое токен. По данным OpenAI, в среднем один токен соответствует примерно 4 символам текста на английском, что примерно равно ¾ слова (то есть 100 токенов — это около 75 слов). Вы можете воспользоваться приложением Tokenizer на сайте OpenAI для более детального изучения.

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

При выборе языковой модели (LLM) вы можете заранее установить некоторые параметры. В OpenAI Playground доступна возможность экспериментировать с разными настройками перед тем, как сделать окончательный выбор. 

В следующей примере мы расмотрим как LLM могут отвечать на вопросы. В нашем примере мы хотим реализовать решение, которое извлекает данные из Википедии и взаимодействует с пользователем как чат-бот.

Чтобы направить LLM в нужное русло, я добавляю следующую инструкцию в запрос:

«Вы — чат-бот, который любит помогать людям! Используя только приведенные ниже разделы контекста, ответьте на вопрос.
Если вы не уверены и ответ явно не написан в документации, скажите: «Извините, я не знаю, как с этим помочь.»

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

В качестве контекста я просто использую 50 текстовых фрагментов с наибольшим сходством к вопросу. Больший объем текстовых фрагментов мог бы быть более полезным, так как обычно мы можем ответить на большинство вопросов с помощью одного или двух текстовых отрывков.

from langchain import PromptTemplate
from langchain.llms import OpenAI
import openai
import pandas as pd
import numpy as np
from numpy.linalg import norm
from langchain.text_splitter import RecursiveCharacterTextSplitter
from PyPDF2 import PdfReader

####################################################################
# загрузка PDF документа
####################################################################
# Функция для извлечения текста из PDF файла
def extract_text_from_pdf(file_path):
reader = PdfReader(file_path)
text = ''
for page in reader.pages:
text += page.extract_text()
return text

# Загрузка текста из PDF
file_path = 'your_cat_document.pdf' # Замените на фактический путь к вашему PDF файлу о котиках
pdf_text = extract_text_from_pdf(file_path)

####################################################################
# разбиение текста
####################################################################
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
length_function=len,
)

# Разбиение текста из PDF на более мелкие части
texts = text_splitter.create_documents([pdf_text])

####################################################################
# расчет встраиваний
####################################################################
# Создание списка всех текстовых фрагментов
text_chunks = [text.page_content for text in texts]

# Создание DataFrame с текстовыми фрагментами
df = pd.DataFrame({'text_chunks': text_chunks})

# Функция для получения встраиваний с помощью модели text-embedding-ada-002
def get_embedding(text, model="text-embedding-ada-002"):
text = text.replace("\n", " ")
return openai.Embedding.create(input=[text], model=model)['data'][0]['embedding']

# Генерация встраиваний для каждого текстового фрагмента
df['ada_embedding'] = df.text_chunks.apply(lambda x: get_embedding(x, model='text-embedding-ada-002'))

####################################################################
# расчет сходства с вопросом пользователя
####################################################################
# Вычисление встраивания для вопроса пользователя
users_question = "Как зовут твоего кота?"
question_embedding = get_embedding(text=users_question, model="text-embedding-ada-002")

# Создание списка для хранения рассчитанных косинусных сходств
cos_sim = []

# Вычисление косинусного сходства для каждого текстового фрагмента относительно вопроса пользователя
for index, row in df.iterrows():
A = np.array(row.ada_embedding)
B = np.array(question_embedding)

# Вычисление косинусного сходства
cosine_similarity = np.dot(A, B) / (norm(A) * norm(B))
cos_sim.append(cosine_similarity)

# Добавление косинусного сходства в DataFrame
df['cos_sim'] = cos_sim
df.sort_values(by=['cos_sim'], ascending=False, inplace=True)

####################################################################
# создание подходящего запроса и отправка
####################################################################
# Определение LLM, которую вы хотите использовать
llm = OpenAI(temperature=1)

# Определение контекста для запроса путем объединения наиболее релевантных текстовых фрагментов
context = " ".join(df.head(50)['text_chunks'])

# Определение шаблона запроса
template = """
Вы — чат-бот, который любит помогать людям! Используя только приведенные ниже разделы контекста, ответьте на вопрос.
Если вы не уверены и ответ явно не написан в документации, скажите: "Извините, я не знаю, как с этим помочь."

Разделы контекста:
{context}

Вопрос:
{users_question}

Ответ:
"""

prompt = PromptTemplate(template=template, input_variables=["context", "users_question"])

# Заполнение шаблона запроса
prompt_text = prompt.format(context=context, users_question=users_question)
response = llm(prompt_text)

# Вывод ответа
print(response)

Создание векторной базы данных

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

Для нашего примера я выбрал ChromaDB

eef8b687c3b4834e9d9328b3e680ffa3.png

import chromadb
from langchain.llms import OpenAI
from langchain import PromptTemplate
from langchain.text_splitter import RecursiveCharacterTextSplitter
import wikipediaapi

# Создание интерфейса для Википедии
wiki_wiki = wikipediaapi.Wikipedia('en')

# Загрузите статью из Википедии
def load_wikipedia_article(title: str):
    page = wiki_wiki.page(title)
    if page.exists():
        return page.text
    else:
        raise Exception(f"Page {title} does not exist.")

# Загрузить текст статьи о премьер-министре Великобритании
article_title = "Prime Minister of the United Kingdom"
article_text = load_wikipedia_article(article_title)

# Разбейте статью на более мелкие текстовые фрагменты
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    length_function=len
)
texts = text_splitter.create_documents([article_text])

# Создаем векторное хранилище с ChromaDB
chroma_client = chromadb.Client()

for idx, text in enumerate(texts):
    chroma_client.insert_document(f"doc_{idx}", text)

# Запрос пользователя
users_question = "Кто премьер-министр Великобритании?"

# Симулируйте поиск в векторном хранилище
results = chroma_client.query(users_question, top_k=5)

# Подготовьте контекст из результатов поиска
context = " ".join(result['content'] for result in results)

# Определите шаблон подсказки
template = """
Вы — чат-бот, который любит помогать людям! Используя только приведенные ниже разделы контекста, ответьте на вопрос.
Если вы не уверены и ответ явно не написан в документации, скажите: "Извините, я не знаю, как с этим помочь."

Разделы контекста:
{context}

Вопрос:
{users_question}

Ответ:
"""

prompt = PromptTemplate(template=template, input_variables=["context", "users_question"])

# Заполните шаблон подсказки
prompt_text = prompt.format(context=context, users_question=users_question)

# Определите LLM, который вы хотите использовать
llm = OpenAI(temperature=1)

# Задайте вопрос определённой модели
response = llm(prompt_text)

# Выведите ответ
print(response)

Резюме

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

Цель статьи заключалась в демонстрации минималистичного подхода к использованию моделей эмбеддинга, векторных хранилищ и LLM для обработки пользовательских запросов.

© Habrahabr.ru