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

Для создания телеграм-бота используй библиотеку aiogram3.

При отправке Telegram-боту команды /help он должен возвращать информацию о базе знаний:

1.тематика,
2.число записей в базе знаний,
3.пример запроса к базе.

первая часть урока: 

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

Если мы просто в лоб спросим у chatGPT, про события 2023 или 2024 года, то он нам ответит, что не владеет этой информацией, либо ответит, что событие ещё не наступило. Проверим это на практике!

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

!pip install openai

# Импортируем библиотеки:
# Для вызова OpenAI API
from openai import OpenAI
# Для работы с системными командами
import os
import getpass

# Выбираем модель GPT
GPT_MODEL = "gpt-3.5-turbo"

# Помещаем OpenAI ключ в переменную окружения с именем 'OPENAI_API_KEY'
# Привыкайте использовать переменные окружения в своем коде. Это вам позволит легко переносить код на удаленный сервер со своим окружением.

os.environ["OPENAI_API_KEY"] = getpass.getpass("Введите OpenAI API Key:")


# Запрос к chatGPT
query = 'Когда проходил чемпионат?'
# Создаём объект OpenAI
openai = OpenAI(
  api_key = os.environ.get("OPENAI_API_KEY"),
)
# Отправляем запрос к OpenAI API
response = openai.chat.completions.create(
    messages=[
        {'role': 'system', 'content': 'Вы отвечаете на вопросы о Чемпионате мира по крикету 2023'},
        {'role': 'user', 'content': query},
    ],
    model=GPT_MODEL,
    temperature=0,
)

# Выводим результат
print(response.choices[0].message.content)

И так. Разберём пример выше. Мы видим, что GPT ответил нам, что Чемпионат мира по крикету 2023 не проходил, а только запланирован. Это подтверждает тот факт, что о текущих события ChatGPT  ничего не знает, он не умеет "ходить в интернет" и собирать "свежие" данные. Так как политика OpenAI направлена на 100% покрытие данных из Википедии, то к моменту прохождения урока, возможно, эти данные уже попадут в обучающую выборку chatGPT. Когда вы начинаете решать с помощью chatGPT свои узко спецализированные задачи, то чаще эти данные берутся из текущей деятельности, а не из Википедии и chatGPT о них, естественно, ничего не знает!

Фактически мы отправили через API два сообщения:

messages=[
        {'role': 'system', 'content': 'Вы отвечаете на вопросы о Чемпионате мира по крикету 2023'},
        {'role': 'user', 'content': 'Когда проходил чемпионат?'}
    ]

Это очень простой способ взаимодействия с API. Главное, что нужно знать, это то, что в списке `messages` есть словарь с 2 ключами: `role` и `content`.

`content` – это текст сообщения.
`role` - определяет назначение данного сообщения.

Существует три основные роли: `system`, `user` или `assistant`.

`user` – это тот, кто даёт инструкции. Эта роль уже используется в приведённом выше коде.

`system` - определяет поведение, задает контекст, в котором будет работать GPT.

`assistant` - используется в качестве словаря для хранения предыдущих ответов.



GPT отлично отвечает на вопросы, но только по темам, которые он помнит из своих обучающих данных.

Что вам следует делать, если вы хотите, чтобы GPT отвечал на вопросы по незнакомым темам?

Так как же разработчик должен перед собой поставить задачу, чтобы учесть эту особенность GPT?

Самым логичным здесь кажется дообучить GPT.



## Дообучение chatGPT

**Выделяют 2 метода дообучения chatGPT:**
* Search-Ask - с помощью входных данных модели, т.е. вставки знаний во входное сообщение

* Fine-tuning - с помощью весов модели, т.е. точной настройки модели на обучающем наборе


В этом Блокноте мы рассмотрим двухэтапный метод Search-Ask и применим его к нашей задачи. Метод позволяет GPT отвечать на вопросы, используя библиотеку справочного текста:

> Блок с отступами

> Блок с отступами





* На первом этапе (Search) мы ищем в библиотеке текстов соответствующие текстовые разделы.
* На втором этапе (Ask) мы вставляем извлеченные текстовые фрагменты в сообщение для GPT и задаем ему вопрос.

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

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

Одним из недостатков `Search-Ask` по сравнению с тонкой настройкой является то, что каждая модель ограничена максимальным объемом текста, который она может прочитать за один раз:


<table>
<tr><th>Модель</th><th>Максимальная длина текста</th></tr>
<tr><td>gpt-3.5-turbo</td><td>4,096 токенов (около 5 страниц)</td></tr>
<tr><td>gpt-4</td><td>8,192 токенов (около 10 страниц)</td></tr>
<tr><td>gpt-4-32k</td><td>gpt-4-32k	32,768 токенов (около 40 страниц)</td></tr>
</table>

В методе `Search-Ask` поиск по тексту может осуществляться различными способами, например:

* Поиск на основе лексики (Lexical-based search)
* Поиск на основе графов (Graph-based search)
* Поиск на основе эмбендингов (Embedding-based search)

В этом примере блокнота используется поиск на основе [эмбендингов](https://platform.openai.com/docs/guides/embeddings/what-are-embeddings). Эмбендинги просты в реализации и особенно хорошо работают с вопросами, поскольку вопросы часто лексически не совпадают с их ответами.

Поисковые системы могут сочетать несколько методов поиска, а производительность поиска вопросов и ответов может быть улучшена с помощью таких методов, как [HyDE](https://arxiv.org/abs/2212.10496), в которых вопросы сначала преобразуются в гипотетические ответы, а затем внедряются.

Также при выборе конкретной модели GPT и варианта обучения необходимо учитывать и расходы. К сожалению, chatGPT платный и платить придется за каждый запрос. С [ценовой политикой](https://openai.com/pricing) можно познакомиться на сайте openAI, только не забывайте пользоваться VPN.

Так например, при использовании gpt-3.5-turbo вам придет заплатить 0,001 доллар за 1000 токенов за отправку данных (входящие токены) к GPT и 0,002 доллара за 1000 токенов ответа (исходящие токены).  
Для gpt-4 за 1000 входящих токенов придется заплатить 0,03 доллара и 0,06 за 1000 исходящих токенов (по состоянию на 1 января 2024 года).

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

Токен - это минимальная единица, на которую разбивается входная текстовая последовательность. Мы с вами скоро дойдем до темы токинизации текстовых последовательностей и вы все подробно изучите. Сейчас же важно понимать, что для chatGPT на английском языке токеном будет являться слово, а на русском языке - одно слово в среднем 3-5 токенов. Т.е. слово при токенизации разбивается на более употребляемые морфемы. Также chatGPT дает более адекватные ответы на английском языке. Поэтому часто прибегают к хитрости: переводят запрос на английский язык, а после ответ на русский. Так и дешевле, и выше качество ответов.

### Пример вставки знаний в запрос

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

# Установим библиотеку для работы с Википедией
!pip install wikipedia

Сначала найдем необходимую статью на Википедии.

import wikipedia
language = "ru"
# Установим русский  язык, значит будем выполнять поиск в русско-язычном секторе Википедии (https://ru.wikipedia.org/)
wikipedia.set_lang(language)

# Ищем информацию о чемпионате по крикету в 2023 году
result = wikipedia.search("Чемпионат по крикету 2023")
print(result)

Из найденных статей выбираем первую:

page = wikipedia.page(result[0])
# В качестве дополнительных знаний для GPT используем содержание страницы без HTML
query = page.content
print(f'Число символов в статье: {len(page.content)}')


Чтобы определить число токенов существует удобная библиотека `tiktoken`.

С помощью нее напишем 2 вспомагательные функции:
* num_tokens - для определения числа токенов в строке с учетом заданной модели
* clip_tokens - для обрезки строки до заданного числа токенов и с учетом модели

# устанавливаем библиотеку
!pip install tiktoken

import tiktoken

# Функция возвращает число токенов в строке для заданной модели
def num_tokens(text: str, model: str = GPT_MODEL) -> int: # Новая для вас конструкция `-> int` указывает тип возвращаемого значения функции
    # получаем токинизатор для заданной модели
    encoding = tiktoken.encoding_for_model(model)
    # переводим текст в набор токенов (кодируем с помощью токенизатора)
    return len(encoding.encode(text))

# Функция обрезает строку до заданного числа токенов, учитывая модель
def clip_tokens(text: str, length: int = 4096, model: str = GPT_MODEL) -> str: # Новая для вас конструкция `-> str` указывает тип возвращаемого значения функции
    # получаем токинизатор для заданной модели
    encoding = tiktoken.encoding_for_model(model)

    # переводим текст в набор токенов (кодируем с помощью токенизатора)
    tokens=encoding.encode(text=text)

    # с помощью генератора цикла декодируем обратно нужное число токенов (4096 предел для нашей модели)
    # так как мы игнорируем при декодировании все символы, которые невозможно преобразовать (в статье попадаются фразы на языке Хинди), то токенов будет меньше 4096
    token_value = [
            encoding.decode_single_token_bytes(token).decode("utf-8", errors="ignore")
            for token in tokens[0:length]
        ]

    # объединяем элементы списка в строку
    return "".join(token_value)




Мы еще с вами будем разбирать, что такое токенизатор и как происходит токенизация текста. В упрощенном виде эту процедуру можно представить в следующем виде:
1. Определяется частотность слов в обучающей выборки для модели.
2. Слова ранжируются по частоте использования. Самые редкие слова в конце.
3. Каждому слову присваивается число. Чем реже слово встречается, тем больше число.
4. Любой текст, который подается модели преобразуется в числовую последовательность, происходит токенизация текста.  

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

Выше мы определили 2 вспомогательные функции: `num_tokens` - для определения числа токенов в тексте и `clip_tokens` -  для обрезки текста до максимального числа токенов. Применим их к нашей статье:

print(f'Число токенов в статье до обрезки: {num_tokens(page.content)}')
print(f'Число токенов в статье после обрезки: {num_tokens(clip_tokens(page.content))}')
print(f'Число символов в статье после обрезки: {len(clip_tokens(page.content))}')

Теперь мы можем передать нашу статью из Википедии вместе с основным запросом в OpenAI API:

# Создаём объект OpenAI
openai = OpenAI(
  api_key = os.environ.get("OPENAI_API_KEY"),
)

query_gpt = f"""Используйте приведенную ниже статью для ответа на вопрос. Отвечай кратко. Если ответ не найден, напишите "Я не знаю."
Вопрос: Когда проводился чемпионат и кто в нем победил?
Статья:
\"\"\"
{query}
\"\"\"

"""


response = openai.chat.completions.create(
    messages=[
        {'role': 'system', 'content': 'Ты отвечаешь на вопросы о Чемпионате мира по крикету 2023'},
        {'role': 'user', 'content': clip_tokens(query_gpt)},
    ],
    model=GPT_MODEL,
    temperature=0,
)

print(response.choices[0].message.content)

### Алгоритм обучения Search-Ask

Описанный выше пример ограничен числом токенов, которые мы можем подать на вход модели. Даже одна статья из Википедии не смогла уместиться в это ограничение.

А что делать, если мы хотим использовать не статью, а целую категорию из Википедии, например, посвященную [Зимним Олимпийским играм 2022 года](https://en.wikipedia.org/wiki/Category:2022_Winter_Olympics)? В этом разделе урока мы покажем, что это возможно на примере целой категории статей о Зимних Олимпийских играх 2022 года.

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

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

Таким же самым методом, вы можете выгружать историю вашей переписки с друзьями или клиентами из мессенджеров и задавать вопросы к ним. Например, "Что чаще всего хотел купить клиент?", "Просил ли он скидку?", "В каком эмоциональном состоянии он прибывал чаще всего в беседе?". ChatGPT умеет по эмоциональной окраске текста даже делать оценку психотипа клиента.

Еще одно часто используемое применение чат-бота с GPT (не только chatGPT, об аналогах мы поговорим позже) - анализ проведенных видеоконференций. С помощью библиотеки **fast-whisper** мы можем преобразовать запись zoom-конференции в текст, а после отправить chatGPT вместе с вопросами.

С помощью алгоритма Search-Ask мы будем обучать chatGPT отвечать на вопросы о

---

Зимних Олимпийских играх 2022 года. ChatGPT не обучался на этих знаниях и видит их впервые (см. замечание выше, возможно уже "видел"). Наш алгоритм будет выглядеть следующим образом:


1. Подготовка поисковых данных (один раз для каждого документа):
  * *Сбор*: загружаем документы, например, статьи из Википедии о Зимних Олимпийских играх 2022 года;
  * *Фрагментация*: документы разбиваются на короткие, в основном автономные разделы для токенизации;
  * *Токенизация*: каждый раздел преобразуется в последовательность токенов с помощью Openal API;
  * *Хранилище*: последовательности токенов сохраняются (для больших наборов данных рекомендуется использовать векторную базу данных);
2. Search (один раз для каждого запроса):
  * Вопрос пользователя преобразуем в последовательность токенов с помощью OpenAI API;
  * Используя токенизированный пользовательский запрос, *ранжируем* текстовые разделы (фрагментированные документы) по релевантности (схожести) запросу;
3. Ask (один раз за запрос):
  * Вставляем пользовательский вопрос и наиболее релевантные разделы в сообщение для GPT;
  * Получаем и обработаем ответ от GPT.

Первый пункт сбора данных достаточно трудоемкий и мы его рассмотрим в [следующем колабе](https://colab.research.google.com/drive/1GmRiwmUH8E8KJ4d1g8vDVMGSdfmFFjd5). Сейчас же сосредоточимся на пунктах 2 и 3.




#### 1. Подготовьте данные для поиска

Мы подготовили предварительно набор данных из нескольких сотен статей Википедии о зимних Олимпийских играх 2022 года.

В следующей части урока вы сможете это сделать самостоятельно.

Давайте сразу договоримся о терминологии. Речь идет об эмбединге (**embedding**) - это наша последовательность токенизированных слов. В некоторой литературе их так и называют `эмбединг`, в некоторой пишут на английский манер с двумя `д`, в некоторой литературе их переводят как `встраивание`, а где-то как `вложения`. Давайте договоримся употреблять термин `эмбединг`, подразумевая под ним преобразованную строку в последовательность токенов.

Создадим DataFrame и загрузим в него данные:

import pandas as pd
import ast
embeddings_path = "https://storage.yandexcloud.net/academy.ai/winter_olympics_2022.csv"

df = pd.read_csv(embeddings_path)

# Конвертируем наши эмбединги из строк в списки
df['embedding'] = df['embedding'].apply(ast.literal_eval)

# Наш датафрейм состоит из 2 колонок text и embedding
df.head()

#### 2. Search (Поиск)

Далее, определим функцию поиска, которая:

* Принимает пользовательский запрос и DataFrame со столбцами `text` и `embedding`;
* Токенизирует пользовательский запрос с помощью OpenAI API (для этого используется токенизатор `text-embedding-ada-002`);
* Использует косинусное расстояние для определения схожести между токенизированым пользовательским запросом и эмбендингами в DataFrame для ранжирования текстов по схожести или релевантности;
* Возвращает два списка:
  * N лучших текстов, ранжированных по релевантности;
  * Их соответствующие оценки релевантности.

from scipy import spatial  # вычисляет сходство векторов
EMBEDDING_MODEL = "text-embedding-ada-002"

# Функция поиска
def strings_ranked_by_relatedness(
    query: str, # пользовательский запрос
    df: pd.DataFrame, # DataFrame со столбцами text и embedding (база знаний)
    relatedness_fn=lambda x, y: 1 - spatial.distance.cosine(x, y), # функция схожести, косинусное расстояние
    top_n: int = 100 # выбор лучших n-результатов
) -> tuple[list[str], list[float]]: # Функция возвращает кортеж двух списков, первый содержит строки, второй - числа с плавающей запятой
    """Возвращает строки и схожести, отсортированные от большего к меньшему"""

    # Отправляем в OpenAI API пользовательский запрос для токенизации
    query_embedding_response = openai.embeddings.create(
        model=EMBEDDING_MODEL,
        input=query,
    )

    # Получен токенизированный пользовательский запрос
    query_embedding = query_embedding_response.data[0].embedding

    # Сравниваем пользовательский запрос с каждой токенизированной строкой DataFrame
    strings_and_relatednesses = [
        (row["text"], relatedness_fn(query_embedding, row["embedding"]))
        for i, row in df.iterrows()
    ]

    # Сортируем по убыванию схожести полученный список
    strings_and_relatednesses.sort(key=lambda x: x[1], reverse=True)

    # Преобразовываем наш список в кортеж из списков
    strings, relatednesses = zip(*strings_and_relatednesses)

    # Возвращаем n лучших результатов
    return strings[:top_n], relatednesses[:top_n]

Разберем, что такое косинусное расстояние.

**Косинусное расстояние** — это угол между векторами пространства внутреннего произведения. Он определяет, указывают ли векторы в одном направлении. Косинусное расстояние можно использовать тогда, когда величина векторов не имеет значения.



![image.png](data:image/png;base64,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)

Предположим, что $A$ и $B$ — две точки на двумерной плоскости.

$A = (X1, Y1)$

$B = (X2, Y2)$

$cosθ = (a · b) / (|a| * |b|)$

$(a · b)$ — скалярное произведение векторов $a$ и $b$.

$|а|$ — длина вектора $а$.

$|b|$ - длина вектора $b$.

$(|a| * |b|)$ — векторное произведение векторов $a$ и $b$.

Формула косинусного расстояния:

$d = 1 — cos θ$

$d = 1 — (a · b) / (|a| * |b|)$

>**Пример**. Предположим, что $(5,3)$ и $(2,4)$ — две точки на двумерной плоскости.
  $
  (a \cdotp b) = (5 * 2) + (3 * 4) = 10 + 12 = 22
  \\
  |a| = \sqrt{5^2 + 3^2} = 5.83
  \\
  |b| = \sqrt{2^2 + 4^2} = 4.47
  \\
  \\
  \text{ Используя формулу косинусного расстояния: }
  \\
  d = 1 – 22 / (5.83 * 4.47)
  \\
  d = 1–0.844
  \\
  d = 0.156
  $

# тот же пример на python
from scipy.spatial import distance
A = (5, 3)
B = (2, 4)
d = distance.cosine(A, B)
print('Косинусное расстояние:', d)

Когда значение косинусного расстояния стремится к нулю, то точки совпадают. Мы в примере выше используем значение: $1 - spatial.distance.cosine(x, y)$, так мы переходим к понятию релевантности: чем значение ближе к единице, тем вероятность выше, что данная строка дата фрейма ближе по смыслу к пользовательскому запросу.

Проверим наше предположение:

strings, relatednesses = strings_ranked_by_relatedness("curling gold medal", df, top_n=5)
for string, relatedness in zip(strings, relatednesses):
    print(f"{relatedness=:.3f}")
    display(string)

Как мы видим выше, по запросу золотые медали в керлинге для 5 лучших результатов, мы получили схожести (или релевантности выше 86%). И действительно, в каждом из примеров мы видим содержаться слова `Curling`, `Gold medal game` и множество другой полезной информации.

#### 3. Ask

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

Ниже мы определяем функцию ask, которая:

* Принимает запрос пользователя
* Ищет в базе знаний текст релевантный запросу
* Вставляет этот текст в сообщение для GPT
* Отправляет сообщение в GPT
* Возвращает ответ GPT

# с этой функцией мы уже знакомы
def num_tokens(text: str, model: str = GPT_MODEL) -> int:
    """Возвращает число токенов в строке для заданной модели"""
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

# Функция формирования запроса к chatGPT по пользовательскому вопросу и базе знаний
def query_message(
    query: str, # пользовательский запрос
    df: pd.DataFrame, # DataFrame со столбцами text и embedding (база знаний)
    model: str, # модель
    token_budget: int # ограничение на число отсылаемых токенов в модель
) -> str:
    """Возвращает сообщение для GPT с соответствующими исходными текстами, извлеченными из фрейма данных (базы знаний)."""
    strings, relatednesses = strings_ranked_by_relatedness(query, df) # функция ранжирования базы знаний по пользовательскому запросу
    # Шаблон инструкции для chatGPT
    message = 'Use the below articles on the 2022 Winter Olympics to answer the subsequent question. If the answer cannot be found in the articles, write "I could not find an answer."'
    # Шаблон для вопроса
    question = f"\n\nQuestion: {query}"

    # Добавляем к сообщению для chatGPT релевантные строки из базы знаний, пока не выйдем за допустимое число токенов
    for string in strings:
        next_article = f'\n\nWikipedia article section:\n"""\n{string}\n"""'
        if (num_tokens(message + next_article + question, model=model) > token_budget):
            break
        else:
            message += next_article
    return message + question


def ask(
    query: str, # пользовательский запрос
    df: pd.DataFrame = df, # DataFrame со столбцами text и embedding (база знаний)
    model: str = GPT_MODEL, # модель
    token_budget: int = 4096 - 500, # ограничение на число отсылаемых токенов в модель
    print_message: bool = False, # нужно ли выводить сообщение перед отправкой
) -> str:
    """Отвечает на вопрос, используя GPT и базу знаний."""
    # Формируем сообщение к chatGPT (функция выше)
    message = query_message(query, df, model=model, token_budget=token_budget)
    # Если параметр True, то выводим сообщение
    if print_message:
        print(message)
    messages = [
        {"role": "system", "content": "You answer questions about the 2022 Winter Olympics."},
        {"role": "user", "content": message},
    ]
    response = openai.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0 # гиперпараметр степени случайности при генерации текста. Влияет на то, как модель выбирает следующее слово в последовательности.
    )
    response_message = response.choices[0].message.content
    return response_message


Изучите листинг выше, постарайтесь максимально понять, что делает этот код, опираясь на комментарии. Если что-то не понятно, попробуйте заменить функции линейным кодом и выполнить последовательно, добавьте дополнительные блоки `print()` для вывода промежуточных данных.   

Перейдем к самим запросам к нашей базе знаний:

ask('Which athletes won the gold medal in curling at the 2022 Winter Olympics?')

ask('How many records were set at the 2022 Winter Olympics?')

ask('Did Jamaica or Cuba have more athletes at the 2022 Winter Olympics?')

В вопросе ```'How many records were set at the 2022 Winter Olympics?'```, мы получили ответ ```I could not find an answer```. Такой ответ является причиной несовершенного рассуждения на этапе запроса, а не из-за несовершенного извлечения данных из базы знаний на этапе поиска. Улучшить рассуждения можно, если использовать GPT-4 вместо GPT-3.

Если вы оформите подписку на GPT-4 (```20`` в месяц), то на вопрос:

```
ask('Which athletes won the gold medal in curling at the 2022 Winter Olympics?', model="gpt-4")
```

получите ответ:

```"The athletes who won the gold medal in curling at the 2022 Winter Olympics are:
Men's tournament: Niklas Edin, Oskar Eriksson, Rasmus Wranå, Christoffer Sundgren, and Daniel Magnusson from Sweden.
Women's tournament: Eve Muirhead, Vicky Wright, Jennifer Dodds, Hailey Duff, and Mili Smith from Great Britain.
Mixed doubles tournament: Stefania Constantini and Amos Mosaner from Italy."
```

практическая часть: 

Перед вами практическая часть урока. Проанализируйте данный Блокнот и попробуйте самостоятельно сформировать базу знаний (файл `csv`) по интересующей вас теме. В домашней работе, вы будете использовать ваш файл базы знаний, чтобы создать телеграм-бот, который отвечает на вопросы по вашей базе знаний. Мы настоятельно рекомендуем ознакомиться с [домашним заданием](https://colab.research.google.com/drive/13zTaGNA9cr9SjaYFzpDPp-72i8SQFqrp) перед выбором тематики для вашей базы знаний. Ваша база знаний может быть как результатом парсинга Википедии, так и любого другого ресурса, включая выгрузку из мессенджеров, zoom-конференций, нормативной базы компании.

И так вспомним алгоритм Search-Ask, а точнее его первый пункт о подготовке поисковых данных:
* Сбор: загружаем документы;
* Фрагментация: документы разбиваются на короткие, в основном автономные разделы для токенизации;
* Токенизация или вычисление эмбендингов: каждый раздел преобразуется в последовательность токенов с помощью OpenAI API;
* Хранилище: последовательности токенов сохраняются.


## Сбор данных и фрагментация

Для сбора и подготовки данных нам необходимо:
* Собрать все заголовки статей заданной категории статей Википедии, заходя на первый уровень вложенности статей.
* Разделить каждую статью на разделы (секции) и подразделы. Отбросить менее релевантные разделы, такие как внешние ссылки и сноски.
* Очистить текст, удалив теги ссылок, пробелы и очень короткие секции (разделы).
* Добавить заголовки и подзаголовки к тексту каждого раздела, чтобы помочь chatGPT понять контекст.

### Сбор заголовков статей из категории

# Отключим предупреждения в колабе. Будет меньше лишней информации в выводе
import warnings
warnings.filterwarnings('ignore')

!pip install openai mwclient mwparserfromhell tiktoken

# imports
import mwclient  # библиотека для работы с MediaWiki API для загрузки примеров статей Википедии
import mwparserfromhell  # Парсер для MediaWiki
import openai  # будем использовать для токинизации
import pandas as pd  # В DataFrame будем хранить базу знаний и результат токинизации базы знаний
import re  # для вырезания ссылок <ref> из статей Википедии
import tiktoken  # для подсчета токенов

# поиск страниц Википедии о зимних Олимпийских играх 2022 года

# Задаем категорию и англоязычную версию Википедии для поиска
CATEGORY_TITLE = "Category:2022 Winter Olympics"
WIKI_SITE = "en.wikipedia.org"

# Соберем заголовки всех статей
def titles_from_category(
    category: mwclient.listing.Category, # Задаем типизированный параметр категории статей
    max_depth: int # Определяем глубину вложения статей
) -> set[str]:
    """Возвращает набор заголовков страниц в данной категории Википедии и ее подкатегориях."""
    titles = set() # Используем множество для хранения заголовков статей
    for cm in category.members(): # Перебираем вложенные объекты категории
        if type(cm) == mwclient.page.Page: # Если объект является страницей
            titles.add(cm.name) # в хранилище заголовков добавляем имя страницы
        elif isinstance(cm, mwclient.listing.Category) and max_depth > 0: # Если объект является категорией и глубина вложения не достигла максимальной
            deeper_titles = titles_from_category(cm, max_depth=max_depth - 1) # вызываем рекурсивно функцию для подкатегории
            titles.update(deeper_titles) # добавление в множество элементов из другого множества
    return titles

# Инициализация объекта MediaWiki
# WIKI_SITE ссылается на англоязычную часть Википедии
site = mwclient.Site(WIKI_SITE)

# Загрузка раздела заданной категории
category_page = site.pages[CATEGORY_TITLE]
# Получение множества всех заголовков категории с вложенностью на один уровень
titles = titles_from_category(category_page, max_depth=1)


print(f"Создано {len(titles)} заголовков статей в категории {CATEGORY_TITLE}.")

### Извлечение секций из документов

Определим секции в документах, которые менее релевантны и их можно отбросить:



# Задаем секции, которые будут отброшены при парсинге статей
SECTIONS_TO_IGNORE = [
    "See also",
    "References",
    "External links",
    "Further reading",
    "Footnotes",
    "Bibliography",
    "Sources",
    "Citations",
    "Literature",
    "Footnotes",
    "Notes and references",
    "Photo gallery",
    "Works cited",
    "Photos",
    "Gallery",
    "Notes",
    "References and sources",
    "References and notes",
]


# Функция возвращает список всех вложенных секций для заданной секции страницы Википедии

def all_subsections_from_section(
    section: mwparserfromhell.wikicode.Wikicode, # текущая секция
    parent_titles: list[str], # Заголовки родителя
    sections_to_ignore: set[str], # Секции, которые необходимо проигнорировать
) -> list[tuple[list[str], str]]:
    """
    Из раздела Википедии возвращает список всех вложенных секций.
    Каждый подраздел представляет собой кортеж, где:
      - первый элемент представляет собой список родительских секций, начиная с заголовка страницы
      - второй элемент представляет собой текст секции
    """

    # Извлекаем заголовки текущей секции
    headings = [str(h) for h in section.filter_headings()]
    title = headings[0]
    # Заголовки Википедии имеют вид: "== Heading =="

    if title.strip("=" + " ") in sections_to_ignore:
        # Если заголовок секции в списке для игнора, то пропускаем его
        return []

    # Объединим заголовки и подзаголовки, чтобы сохранить контекст для chatGPT
    titles = parent_titles + [title]

    # Преобразуем wikicode секции в строку
    full_text = str(section)

    # Выделяем текст секции без заголовка
    section_text = full_text.split(title)[1]
    if len(headings) == 1:
        # Если один заголовок, то формируем результирующий список
        return [(titles, section_text)]
    else:
        first_subtitle = headings[1]
        section_text = section_text.split(first_subtitle)[0]
        # Формируем результирующий список из текста до первого подзаголовка
        results = [(titles, section_text)]
        for subsection in section.get_sections(levels=[len(titles) + 1]):
            results.extend(
                # Вызываем функцию получения вложенных секций для заданной секции
                all_subsections_from_section(subsection, titles, sections_to_ignore)
                )  # Объединяем результирующие списки данной функции и вызываемой
        return results

# Функция возвращает список всех секций страницы, за исключением тех, которые отбрасываем
def all_subsections_from_title(
    title: str, # Заголовок статьи Википедии, которую парсим
    sections_to_ignore: set[str] = SECTIONS_TO_IGNORE, # Секции, которые игнорируем
    site_name: str = WIKI_SITE, # Ссылка на сайт википедии
) -> list[tuple[list[str], str]]:
    """
    Из заголовка страницы Википедии возвращает список всех вложенных секций.
    Каждый подраздел представляет собой кортеж, где:
      - первый элемент представляет собой список родительских секций, начиная с заголовка страницы
      - второй элемент представляет собой текст секции
    """

    # Инициализация объекта MediaWiki
    # WIKI_SITE ссылается на англоязычную часть Википедии
    site = mwclient.Site(site_name)

    # Запрашиваем страницу по заголовку
    page = site.pages[title]

    # Получаем текстовое представление страницы
    text = page.text()

    # Удобный парсер для MediaWiki
    parsed_text = mwparserfromhell.parse(text)
    # Извлекаем заголовки
    headings = [str(h) for h in parsed_text.filter_headings()]
    if headings: # Если заголовки найдены
        # В качестве резюме берем текст до первого заголовка
        summary_text = str(parsed_text).split(headings[0])[0]
    else:
        # Если нет заголовков, то весь текст считаем резюме
        summary_text = str(parsed_text)
    results = [([title], summary_text)] # Добавляем резюме в результирующий список
    for subsection in parsed_text.get_sections(levels=[2]): # Извлекаем секции 2-го уровня
        results.extend(
            # Вызываем функцию получения вложенных секций для заданной секции
            all_subsections_from_section(subsection, [title], sections_to_ignore)
        ) # Объединяем результирующие списки данной функции и вызываемой
    return results

# Разбивка статей на секции
# придется немного подождать, так как на парсинг 100 статей требуется около минуты
wikipedia_sections = []
for title in titles:
    wikipedia_sections.extend(all_subsections_from_title(title))
print(f"Найдено {len(wikipedia_sections)} секций на {len(titles)} страницах")

### Очистка текста (теги ссылок, пробелы и короткие секции)

# Очистка текста секции от ссылок <ref>xyz</ref>, начальных и конечных пробелов
def clean_section(section: tuple[list[str], str]) -> tuple[list[str], str]:
    titles, text = section
    # Удаляем ссылки
    text = re.sub(r"<ref.*?</ref>", "", text)
    # Удаляем пробелы вначале и конце
    text = text.strip()
    return (titles, text)

# Применим функцию очистки ко всем секциям с помощью генератора списков
wikipedia_sections = [clean_section(ws) for ws in wikipedia_sections]

# Отфильтруем короткие и пустые секции
def keep_section(section: tuple[list[str], str]) -> bool:
    """Возвращает значение True, если раздел должен быть сохранен, в противном случае значение False."""
    titles, text = section
    # Фильтруем по произвольной длине, можно выбрать и другое значение
    if len(text) < 16:
        return False
    else:
        return True


original_num_sections = len(wikipedia_sections)
wikipedia_sections = [ws for ws in wikipedia_sections if keep_section(ws)]
print(f"Отфильтровано {original_num_sections-len(wikipedia_sections)} секций, осталось {len(wikipedia_sections)} секций.")


Для наглядности выведем 5 секций, заголовок и текст (50 первых символов) каждой секции

for ws in wikipedia_sections[:5]:
    print(ws[0])
    display(ws[1][:50] + "...")
    print()

### Фрагментация документов

Поскольку chatGPT может одновременно считывать только ограниченный объем токенов, нам необходимо разделить каждый документ на фрагменты (части).

Далее мы рекурсивно разделим длинные разделы на более мелкие. Идеального рецепта разделения текста на разделы не существует.

Однако, можно сделать следующие предположения:

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

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

GPT_MODEL = "gpt-3.5-turbo"  # only matters insofar as it selects which tokenizer to use

# Функция подсчета токенов
def num_tokens(text: str, model: str = GPT_MODEL) -> int:
    """Возвращает число токенов в строке."""
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

# Функция разделения строк
def halved_by_delimiter(string: str, delimiter: str = "\n") -> list[str, str]:
    """Разделяет строку надвое с помощью разделителя (delimiter), пытаясь сбалансировать токены с каждой стороны."""

    # Делим строку на части по разделителю, по умолчанию \n - перенос строки
    chunks = string.split(delimiter)
    if len(chunks) == 1:
        return [string, ""]  # разделитель не найден
    elif len(chunks) == 2:
        return chunks  # нет необходимости искать промежуточную точку
    else:
        # Считаем токены
        total_tokens = num_tokens(string)
        halfway = total_tokens // 2
        # Предварительное разделение по середине числа токенов
        best_diff = halfway
        # В цикле ищем какой из разделителей, будет ближе всего к best_diff
        for i, chunk in enumerate(chunks):
            left = delimiter.join(chunks[: i + 1])
            left_tokens = num_tokens(left)
            diff = abs(halfway - left_tokens)
            if diff >= best_diff:
                break
            else:
                best_diff = diff
        left = delimiter.join(chunks[:i])
        right = delimiter.join(chunks[i:])
        # Возвращаем левую и правую часть оптимально разделенной строки
        return [left, right]


# Функция обрезает строку до максимально разрешенного числа токенов
def truncated_string(
    string: str, # строка
    model: str, # модель
    max_tokens: int, # максимальное число разрешенных токенов
    print_warning: bool = True, # флаг вывода предупреждения
) -> str:
    """Обрезка строки до максимально разрешенного числа токенов."""
    encoding = tiktoken.encoding_for_model(model)
    encoded_string = encoding.encode(string)
    # Обрезаем строку и декодируем обратно
    truncated_string = encoding.decode(encoded_string[:max_tokens])
    if print_warning and len(encoded_string) > max_tokens:
        print(f"Предупреждение: Строка обрезана с {len(encoded_string)} токенов до {max_tokens} токенов.")
    # Усеченная строка
    return truncated_string

# Функция делит секции статьи на части по максимальному числу токенов
def split_strings_from_subsection(
    subsection: tuple[list[str], str], # секции
    max_tokens: int = 1000, # максимальное число токенов
    model: str = GPT_MODEL, # модель
    max_recursion: int = 5, # максимальное число рекурсий
) -> list[str]:
    """
    Разделяет секции на список из частей секций, в каждой части не более max_tokens.
    Каждая часть представляет собой кортеж родительских заголовков [H1, H2, ...] и текста (str).
    """
    titles, text = subsection
    string = "\n\n".join(titles + [text])
    num_tokens_in_string = num_tokens(string)
    # Если длина соответствует допустимой, то вернет строку
    if num_tokens_in_string <= max_tokens:
        return [string]
    # если в результате рекурсия не удалось разделить строку, то просто усечем ее по числу токенов
    elif max_recursion == 0:
        return [truncated_string(string, model=model, max_tokens=max_tokens)]
    # иначе разделим пополам и выполним рекурсию
    else:
        titles, text = subsection
        for delimiter in ["\n\n", "\n", ". "]: # Пробуем использовать разделители от большего к меньшему (разрыв, абзац, точка)
            left, right = halved_by_delimiter(text, delimiter=delimiter)
            if left == "" or right == "":
                # если какая-либо половина пуста, повторяем попытку с более простым разделителем
                continue
            else:
                # применим рекурсию на каждой половине
                results = []
                for half in [left, right]:
                    half_subsection = (titles, half)
                    half_strings = split_strings_from_subsection(
                        half_subsection,
                        max_tokens=max_tokens,
                        model=model,
                        max_recursion=max_recursion - 1, # уменьшаем максимальное число рекурсий
                    )
                    results.extend(half_strings)
                return results
    # иначе никакого разделения найдено не было, поэтому просто обрезаем строку (должно быть очень редко)
    return [truncated_string(string, model=model, max_tokens=max_tokens)]


# Делим секции на части
MAX_TOKENS = 1600
wikipedia_strings = []
for section in wikipedia_sections:
    wikipedia_strings.extend(split_strings_from_subsection(section, max_tokens=MAX_TOKENS))

print(f"{len(wikipedia_sections)} секций Википедии поделены на {len(wikipedia_strings)} строк.")


# Напечатаем пример строки
print(wikipedia_strings[1])


### Токенизация и сохранение результата

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

from openai import OpenAI
import os
import getpass

EMBEDDING_MODEL = "text-embedding-ada-002"  # Модель токенизации от OpenAI

os.environ["OPENAI_API_KEY"] = getpass.getpass("Введите OpenAI API Key:")
client = OpenAI(api_key = os.environ.get("OPENAI_API_KEY"))

# Функция отправки chatGPT строки для ее токенизации (вычисления эмбедингов)
def get_embedding(text, model="text-embedding-ada-002"):

   return client.embeddings.create(input = [text], model=model).data[0].embedding


Не будем мучать chatGPT 6 тыс. запросами, сформируем базу данных для 10 строк. Мы всегда должны помнить о расходах при работе с chatGPT. Сформировать полноценный `csv` файл с эмбедингами для 6 тыс. строк нам обойдется чуть больше доллара.

df = pd.DataFrame({"text": wikipedia_strings[:10]})

df['embedding'] = df.text.apply(lambda x: get_embedding(x, model='text-embedding-ada-002'))

SAVE_PATH = "./winter_olympics_2022.csv"
# Сохранение результата
df.to_csv(SAVE_PATH, index=False)

Поскольку в этом примере используется всего 6 тысяч строк, мы сохраним базу знаний в CSV-файле.

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

df.head()

вот названия столбцов

Index(['company', 'category', 'sales_in_millions_2019',
       'sales_per_unit_thousands_2019', 'franchised_units_2019',
       'company_owned_units_2019', 'total_units_2019',
       'unit_change_from_2018'],
      dtype='object')