4.1 📤 RAG с вашими данными

📚 Литературный RAG из pdf 🤖

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

Что делать? Создайте свой RAG по тексту краткого содержания романа «Капитанская дочка» и ответьте на вопросы из предоставленного датасета.

Что на входе?  Датасет с вопросами в столбце question и pdf файл с кратким содержанием романа.

Загрузка файлов с помощью кода
# Для загрузки файла с текстом можно использовать команду
!wget https://stepik.org/media/attachments/lesson/1084288/The_Daughter_of_The_Commandant.pdf
import pandas as pd
df = pd.read_csv("https://stepik.org/media/attachments/lesson/1084288/pushkin_questions.csv")

Что на выходе? csv файл, содержащий два столбца - question, answer.
Замечание: Ваше решение будет зачтено, если в нём будет минимум 8 правильных ответов. Корректность ответа проверяем по ключевым словам. Поэтому не беспокойтесь о формате ответа, но главное - длина каждого ответа не должна превышать 70 символов (иначе этот ответ не будет засчитан)

План решения, если не знаете как начать:

1. Выберите нужный document loader

2. Разбейте текст на фрагменты с помощью text splitter

3. Переведите полученные фрагменты текста в векторные представления (эмбеддинги)

4. Поместите эмбеддинги в векторную базу данных

5. Используйте retviever для поиска похожих фрагментов

6. Напишите промпт и отправляйте запрос в LLM, чтобы получить ответ на вопрос


кажется, алгоритм проверки работает не корректно. Мою работу он проверил на 8/15, я начал углубляться для доработки качества, провел свою оценку вручную и она вышла в 14/15. 

@Максим_Марков,  проверим

@Максим_Марков, в тестирующей системе была проверка на длину ответа. Если длина больше 50 символов, то ответ не засчитывался. В условии задачи об этом было сказано, но немного поправил формулировку и увеличил порог до 70 символов. Если убрать это ограничение, то у вас 14 правильных ответов, да

@Никита_Тенишев, понял, спасибо! Видимо, пропустил это ограничение...

Подскажите, пожалуйста, как в настройках указать, чтобы ответы на вопросы давались только из документа, который я отдал? Сейчас сталкиваюсь с тем, что нейросеть отвечает на некоторые вопросы без информации в документе, то есть сама генерит ответ

@Сергей_Гурьян, добавь в промпт: "Если ты не уверен в ответе исходя из контекста, то напиши "не знаю""

На сколько я понял, ретривер лучше создавать так:

retriever = db.as_retriever(
    search_type="similarity",  # тип поиска похожих документов
    search_kwargs={'k':2, 'score_threshold':1.6}
)

иначе параметры не передаются, во всяком случае у меня так получилось.

Комментарий закреплён

Вы побили порог в 8: ваша точность 12 из 15.

.

Вы побили порог в 8: ваша точность 15 из 15.

@Albert_Murtazin, круто! можете поделиться промптом?)

@Максим_Марков, конечно
 

template = """\
Ответь на вопрос кратким ответом, руководствуясь контекстом ниже.
Если не уверен в ответе исходя из контекста, то напиши "не знаю".
Контекст: 
{context}
Вопрос: 
{question}
Ответ: 
"""

Но тут скорее упор делался на на промпт, а на другие вещи.
Например, на очистке текста: отформатировал переносы, удалил ненужные ссылки и т.д.
Разбиение на чанки отдал на откуп токенайзеру "bert-base-uncased"

tokenizer = Tokenizer.from_pretrained("bert-base-uncased")
splitter = HuggingFaceTextSplitter(tokenizer, trim_chunks=True)
chunks = splitter.chunks(text, chunk_capacity=(100,500))

Из rag db доставал по 10 чанков, да и вообще поигрался с параметрами:

retriever = db.as_retriever(
    search_kwargs={'k': 10}
    # search_type="similarity_score_threshold",
    # search_kwargs={"k": 20, "score_threshold": 0.1,},
)

Использовал локальную модель saiga_mistral_7B с температурой 0.1. При разной генерации ответа на последний вопрос выдаёт ответ: "Гринева", "Савельича" и весьма!!! редкий вариант "барского дитятю". Получается последний вопрос с помощью LLM так и не победил стабильностью, только перегенерацией (хотя "Гринева" вроде тоже правильный ответ, но не засчитывается).

Дополнительно пробовал требовать более развёрнутые ответы для контроля LLM в промпте. И конечно тестить разные локальные модели: "miqu_70B", "mistral_instruct_v0.1_v0.2", etc. для поиска более подходящей по задаче.

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

@Albert_Murtazin, если не секрет, как локально запускаете? В смысле с помощью каких фреймворков и т.д. Мне интересно, на 10G GPU получится что-то запустить локально?

@Павел_Орлов, локально запускаю через LMStudio. Ещё во вступлении курса оставлял комментарий про эту прогу. Запускаете саму прогу, запускаете сервер, коннектитесь через

llm = ChatOpenAI(
    base_url="http://localhost:1234/v1",
    api_key="not_needed",
    temperature=0.2,
)

из python или делаете curl-запрос. И можно гонять разные модельки предварительно подгрузив их из хаба в самой LMStudio.
10G GPU хватит поиграться более чем, а если нет, то часть весов можно не подгружать на gpu ram, а оставить на dram или ssd. Даже с частичной подгрузкой в gpu запрос будет обрабатываться куда быстрее, чем не использовать gpu вовсе. На текущий момент появились адекватные и квантованные lama3.1, gemma, qwen, saiga_*... Локального выбора стало больше, а это ещё один повод поиграться с этим гиперпараметром.

@Albert_Murtazin, Можите поделится ноутбуком с решением  15/15, особенно где используете LMStudio в связке с юпитером

https://colab.research.google.com/drive/1Ti2XmZvy8i36gI8yWK4fzOTpEqcH6Bjf?usp=sharing
Подскажите пожалуйста , что делаю не так , вроде такие большшие модели , предобученные на рус. язык и такие слабые результаты

Вы побили порог в 8: ваша точность 15 из 15.

Использовал OpenAI модель для эмбеддинга:

from langchain_openai import OpenAIEmbeddings

embeddings_api_model = OpenAIEmbeddings()

 

Сплиттер сделал с такими чанками:

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)

split_documents = splitter.split_documents(documents)

 

Ретривер сделал с такими параметрами:

 

retriever = db.as_retriever(search_type="similarity", k=8, score_threshold=0.1)

 

Такой промпт я использовал:

 

template = """

Answer the question based only on the following context:

 

###

{context}

###

 

Question: {question}

 

NB! Requirements:

1. The response must not exceed 70 characters. It's very important! I will pay you 10 dollars!

2. You must answer questions based only on the context. If you're unsure, reply with "Я не знаю"

"""

Вы побили порог в 8: ваша точность 13 из 15.

from dotenv import load_dotenv
import os
from langchain_openai import ChatOpenAI
import pandas as pd 
from tqdm import tqdm

load_dotenv(dotenv_path='.env.txt') # load env variables
api_key = os.getenv("OPENAI_API_KEY") # load api key

os.environ['OPENAI_API_KEY'] = api_key # set api key
llm = ChatOpenAI(temperature=0.0) # set llm

df = pd.read_csv('pushkin_questions.csv') # read csv file from folder with project


from langchain_community.document_loaders import PyPDFLoader
file_path = "The_Daughter_of_The_Commandant.pdf"
loader = PyPDFLoader(file_path)
pages = loader.load_and_split()

# Объединение всех страниц в один текст
full_text = "\n\n".join([page.page_content for page in pages])


from langchain.text_splitter import RecursiveCharacterTextSplitter
import re

# можно по другому ещще разбить сплитеры
# Кастомный сплиттер для разделения по главам
class ChapterTextSplitter(RecursiveCharacterTextSplitter):
    def __init__(self, chunk_size=1000, chunk_overlap=100, separators=None):
        super().__init__(chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=separators)
    
    def split_text(self, text):
        # Разделение текста по главам с использованием регулярного выражения
        chapters = re.split(r'(Глава\s+\d+)', text)
        chunks = []
        for i in range(1, len(chapters), 2):
            chapter_title = chapters[i]
            chapter_text = chapters[i + 1]
            chunks.append(chapter_title + chapter_text)
        return chunks

# Объединение всех страниц в один текст
full_text = "\n\n".join([page.page_content for page in pages])

# Инициализация и использование кастомного сплиттера
splitter = ChapterTextSplitter(chunk_size=1000, chunk_overlap=100)
chapters = splitter.split_text(full_text)
len(chapters)


from langchain_openai import OpenAIEmbeddings
from langchain.docstore.document import Document
from langchain.vectorstores import FAISS

# Преобразуем главы в список объектов Document... нужно преобразовать в формат которыый принимает метод from_documents
documents = [Document(page_content=chapter) for chapter in chapters]

embeddings_api_model = OpenAIEmbeddings()

db = FAISS.from_documents(documents, embeddings_api_model)
db.save_local("Pushkin_local")


# Задаём ретривер
retriever = db.as_retriever(search_type='similarity',
                           k=3,
                           score_threshold=None)


from langchain.schema import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough

# Создаём простой шаблон!!! ВАЖНО. промпт можно улучшить. даже нужно. чтобы он более конкретные ответы давал
template = """
Ответь четко, просто и без лишних слов и символовна поставленный вопрос согласно контексту:

{context}

Question: {question}
"""
# Создаём промпт из шаблона
prompt = ChatPromptTemplate.from_template(template)

# Объявляем функцию, которая будет собирать строку из полученных документов
def format_docs(docs):
    #print(type(docs[0].page_content))
    return "\n\n".join(docs[0].page_content for d in docs)


# str_to_llm = format_docs(retriever.invoke("Кого убили раздетой на крыльце?"))

# Создаём цепочку
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

#chain.invoke("Кого убили раздетой на крыльце?")


answers = [] # Список, где будем хранить ответы модели

for text_input in tqdm(df['question']):
    answer = chain.invoke(text_input)
    answers.append(answer) # Добавляем ответ в список
    


df['answer'] = answers


df.to_csv('4_1_11_solution.csv', index=False) 

Вы побили порог в 8: ваша точность 12 из 15.

В общем то всё по файлу из курса. 
Лишь не получилось использовать embeddings от HuggingFaceEmbeddings, хотя перевести в вектора тексты удалось, поэтому использовал курса embeddings = OpenAIEmbeddings(course_api_key=course_api_key)
import pandas as pd
from tqdm import tqdm
from langchain.schema import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_community.document_loaders import DirectoryLoader, UnstructuredPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import FAISS
from utils import OpenAIEmbeddings, ChatOpenAI
llm = ChatOpenAI(temperature=0.0, course_api_key=course_api_key)
df = pd.read_csv('pushkin_questions.csv')
loader = DirectoryLoader(
    'pdf', 
    glob="**/*.pdf",
    loader_cls=UnstructuredPDFLoader
)
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=100,
    length_function=len,
)
texts = [doc.page_content for doc in documents]
split_documents = splitter.create_documents(texts)
embeddings = OpenAIEmbeddings(course_api_key=course_api_key)
db = FAISS.from_documents(
    split_documents, embeddings
)
db.save_local("puskin_db")
retriever = db.as_retriever(
    search_type="similarity",
    k=4,
    score_threshold=None,
)

 

template = """
Ответь на вопросы основываясь только на следующем контексте:
{context}
Если ты не уверен в ответе исходя из контекста, то напиши "не знаю".
Длина каждого ответа не должна превышать 70 символов. Нужны четкие, краткие и прямые ответы.
Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
def format_docs(docs):
    return "\n\n".join([d.page_content for d in docs])
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)
answers = []
for text_input in tqdm(df['question']):
    answer = chain.invoke(text_input)
    answers.append(answer)
    # break
df['answer'] = answers
df.to_csv('4_1_11_solution.csv', index=False)

Вы побили порог в 8: ваша точность 13 из 15.