Перейти к содержимому
ПереводыDatawhale и китайские курсы

Tiny Universe, часть 3: TinyRAG — RAG своими руками

Полный перевод раздела TinyRAG из tiny-universe: пять модулей минимального RAG без фреймворков — эмбеддинги, загрузка и нарезка документов, векторное хранилище на NumPy, поиск по косинусному сходству и ответ LLM по найденному контексту.

Эфир «RAG своими руками»: запись трансляции.

Сейчас я шаг за шагом проведу вас через реализацию простой модели RAG. Это упрощённая версия RAG, мы называем её Tiny-RAG. В ней есть только ядро RAG — поиск (Retrieval) и генерация (Generation). Её цель — помочь лучше понять, как устроен и как реализуется RAG.

Итак, начнём!

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

Именно на этом фоне и появилась технология генерации, дополненной поиском (Retrieval-Augmented Generation, RAG), — одна из главных тенденций эпохи ИИ.

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

Из каких частей состоит RAG?

  • Модуль векторизации — превращает фрагменты документов в векторы.
  • Модуль загрузки и нарезки документов — загружает документы и режет их на фрагменты.
  • База данных — хранит фрагменты документов и их векторные представления.
  • Модуль поиска — по запросу (Query, вопросу) находит релевантные фрагменты.
  • Модуль большой модели — отвечает на вопрос пользователя по найденным документам.

Вот это и есть все модули репозитория TinyRAG.

Рисунок в оригинале (папка images): интеллект-карта модулей RAG.

Теперь разберёмся, как выглядит процесс RAG:

  • Индексация: разрезать базу документов на короткие куски (chunk) и построить по ним векторный индекс с помощью кодировщика.
  • Поиск: найти релевантные фрагменты по сходству между вопросом и кусками.
  • Генерация: сгенерировать ответ на вопрос, опираясь на найденный контекст.

То есть процесс, показанный на рисунке ниже. Источник рисунка — Retrieval-Augmented Generation for Large Language Models: A Survey.

flowchart LR
D["Документы"] --> C["Нарезка на куски"]
C --> E1["Эмбеддинги кусков"]
E1 --> DB[("Векторная база")]
Q["Вопрос"] --> E2["Эмбеддинг вопроса"]
E2 --> S["Поиск top-k<br/>по косинусному сходству"]
DB --> S
S --> P["Промпт: вопрос + найденные куски"]
Q --> P
P --> L["LLM"] --> A["Ответ"]
Схема переводчика по рисунку images/RAG.png (индексация → поиск → генерация)

Для начала своими руками реализуем класс векторизации — основу архитектуры RAG. Он нужен, чтобы превращать фрагменты документов в векторы: отображать кусок текста в вектор.

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

class BaseEmbeddings:
"""
Базовый класс для эмбеддингов
"""
def __init__(self, path: str, is_api: bool) -> None:
self.path = path
self.is_api = is_api
def get_embedding(self, text: str, model: str) -> List[float]:
raise NotImplementedError
@classmethod
def cosine_similarity(cls, vector1: List[float], vector2: List[float]) -> float:
"""
косинусное сходство двух векторов
"""
dot_product = np.dot(vector1, vector2)
magnitude = np.linalg.norm(vector1) * np.linalg.norm(vector2)
if not magnitude:
return 0
return dot_product / magnitude

Посмотрим, какие методы есть у базового класса BaseEmbeddings. Метод get_embedding получает векторное представление текста, метод cosine_similarity считает косинусное сходство двух векторов. Кроме того, при создании объекта задаются путь к модели и то, является ли модель API-моделью. Например, при использовании Embedding API от OpenAI нужно выставить self.is_api=True.

Наследнику BaseEmbeddings достаточно написать метод get_embedding, а cosine_similarity унаследуется — им можно сразу пользоваться. В этом и польза базового класса.

class OpenAIEmbedding(BaseEmbeddings):
"""
класс для эмбеддингов OpenAI
"""
def __init__(self, path: str = '', is_api: bool = True) -> None:
super().__init__(path, is_api)
if self.is_api:
from openai import OpenAI
self.client = OpenAI()
self.client.api_key = os.getenv("OPENAI_API_KEY")
self.client.base_url = os.getenv("OPENAI_BASE_URL")
def get_embedding(self, text: str, model: str = "text-embedding-3-large") -> List[float]:
if self.is_api:
text = text.replace("\n", " ")
return self.client.embeddings.create(input=[text], model=model).data[0].embedding
else:
raise NotImplementedError

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

Какие документы мы режем? Это может быть статья, книга, диалог, код и т. д. — что угодно, лишь бы это был текст. Например, файлы pdf, md, txt.

Здесь показана только часть кода, полный код — в файле RAG/utils.py. В нём видно, что загружать можно файлы pdf, md и txt — для каждого типа достаточно написать свою функцию.

def read_file_content(cls, file_path: str):
# выбираем способ чтения по расширению файла
if file_path.endswith('.pdf'):
return cls.read_pdf(file_path)
elif file_path.endswith('.md'):
return cls.read_markdown(file_path)
elif file_path.endswith('.txt'):
return cls.read_text(file_path)
else:
raise ValueError("Unsupported file type")

Прочитав содержимое файлов, его ещё нужно нарезать! Как резать? Будем резать документ по длине в токенах. Зададим максимальную длину в токенах и будем резать по ней — получатся фрагменты примерно одинаковой длины.

Но при нарезке учтите: между соседними фрагментами лучше оставлять немного общего текста (перекрытие) — тогда при поиске релевантный фрагмент точно найдётся. И ещё: резать лучше по предложениям, то есть сначала грубо разбить текст по \n — так предложения в основном останутся целыми.

def get_chunk(cls, text: str, max_token_len: int = 600, cover_content: int = 150):
chunk_text = []
curr_len = 0
curr_chunk = ''
lines = text.split('\n') # считаем, что строки разделены символом перевода строки
for line in lines:
line = line.replace(' ', '')
line_len = len(enc.encode(line))
if line_len > max_token_len:
print('warning line_len = ', line_len)
if curr_len + line_len <= max_token_len:
curr_chunk += line
curr_chunk += '\n'
curr_len += line_len
curr_len += 1
else:
chunk_text.append(curr_chunk)
curr_chunk = curr_chunk[-cover_content:]+line
curr_len = line_len + cover_content
if curr_chunk:
chunk_text.append(curr_chunk)
return chunk_text

Итак, нарезка документов готова, загрузка модели эмбеддингов — тоже. Теперь нужно спроектировать векторную базу данных для хранения фрагментов документов и их векторных представлений.

Ещё нужен модуль поиска, который по запросу (Query, вопросу) находит релевантные фрагменты. Вперёд!

Какие функции должна уметь база данных минимальной архитектуры RAG?

  • persist: сохранить базу локально на диск;
  • load_vector: загрузить базу с диска;
  • get_vector: получить векторные представления документов;
  • query: найти фрагменты, релевантные вопросу.

Вот эти четыре функции и нужны базе данных минимального RAG. Полный код — в файле RAG/VectorBase.py.

class VectorStore:
def __init__(self, document: List[str] = ['']) -> None:
self.document = document
def get_vector(self, EmbeddingModel: BaseEmbeddings) -> List[List[float]]:
# получить векторные представления документов
pass
def persist(self, path: str = 'storage'):
# сохранить базу локально
pass
def load_vector(self, path: str = 'storage'):
# загрузить базу с диска
pass
def query(self, query: str, EmbeddingModel: BaseEmbeddings, k: int = 1) -> List[str]:
# найти фрагменты, релевантные вопросу
pass

Посмотрим, как устроен метод query.

Сначала векторизуем вопрос пользователя, затем ищем в базе релевантные фрагменты и возвращаем их. Для ускорения векторного поиска мы используем только NumPy — код очень легко понять и изменить.

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

def query(self, query: str, EmbeddingModel: BaseEmbeddings, k: int = 1) -> List[str]:
query_vector = EmbeddingModel.get_embedding(query)
result = np.array([self.get_similarity(query_vector, vector)
for vector in self.vectors])
return np.array(self.document)[result.argsort()[-k:][::-1]].tolist()

Вот и последний модуль — модуль большой модели. Он отвечает на вопрос пользователя по найденным документам.

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

class BaseModel:
def __init__(self, path: str = '') -> None:
self.path = path
def chat(self, prompt: str, history: List[dict], content: str) -> str:
pass
def load_model(self):
pass

В BaseModel два метода: chat и load_model. Для API-модели, например OpenAI, метод load_model не нужен. А если вы хотите запускать модель локально, то выберете открытую модель — и тогда load_model понадобится.

Для примера возьмём модель InternLM2-chat-7B. Поставьте, пожалуйста, звёздочку репозиторию InternLM — и мне тоже!

class InternLMChat(BaseModel):
def __init__(self, path: str = '') -> None:
super().__init__(path)
self.load_model()
def chat(self, prompt: str, history: List = [], content: str='') -> str:
prompt = PROMPT_TEMPLATE['InternLM_PROMPT_TEMPALTE'].format(question=prompt, context=content)
response, history = self.model.chat(self.tokenizer, prompt, history)
return response
def load_model(self):
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
self.tokenizer = AutoTokenizer.from_pretrained(self.path, trust_remote_code=True)
self.model = AutoModelForCausalLM.from_pretrained(self.path, torch_dtype=torch.float16, trust_remote_code=True).cuda()

Все подсказки удобно хранить в одном словаре — так их проще поддерживать.

PROMPT_TEMPLATE = dict(
InternLM_PROMPT_TEMPALTE="""Сначала кратко изложи содержание контекста, затем используй контекст, чтобы ответить на вопрос пользователя. Если не знаешь ответа, так и скажи. Всегда отвечай на китайском.
Вопрос: {question}
Контекст, на который можно опереться:
···
{context}
···
Если по данному контексту ответить нельзя, ответь, что в базе данных этого нет и ты не знаешь.
Полезный ответ:"""
)

Вот так мы и сможем делать RAG на модели InternLM2!

Теперь посмотрим на демонстрацию Tiny-RAG!

from RAG.VectorBase import VectorStore
from RAG.utils import ReadFiles
from RAG.LLM import OpenAIChat, InternLMChat
from RAG.Embeddings import JinaEmbedding, ZhipuEmbedding
# База ещё не сохранена
docs = ReadFiles('./data').get_content(max_token_len=600, cover_content=150) # читаем все файлы из папки data и режем на куски
vector = VectorStore(docs)
embedding = ZhipuEmbedding() # создаём модель эмбеддингов
vector.get_vector(EmbeddingModel=embedding)
vector.persist(path='storage') # сохраняем векторы и тексты в папку storage — в следующий раз базу можно просто загрузить
question = 'Как устроен git?'
content = vector.query(question, EmbeddingModel=embedding, k=1)[0]
chat = InternLMChat(path='model_path')
print(chat.chat(question, [], content))

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

from RAG.VectorBase import VectorStore
from RAG.utils import ReadFiles
from RAG.LLM import OpenAIChat, InternLMChat
from RAG.Embeddings import JinaEmbedding, ZhipuEmbedding
# База уже сохранена
vector = VectorStore()
vector.load_vector('./storage') # загружаем базу с диска
question = 'Как устроен git?'
embedding = ZhipuEmbedding() # создаём модель эмбеддингов
content = vector.query(question, EmbeddingModel=embedding, k=1)[0]
chat = InternLMChat(path='model_path')
print(chat.chat(question, [], content))

Ну что, научились собирать минимальную архитектуру RAG? Уверен, что да, ха-ха.

Повторим ещё раз: из чего должен состоять минимальный RAG? (Напишите по памяти!)

  • модуль векторизации;
  • модуль загрузки и нарезки документов;
  • база данных;
  • векторный поиск;
  • модуль большой модели.

Всё, вы научились — только не забудьте поставить проекту звёздочку!

От переводчика: как запустить на своей машине

Заголовок раздела «От переводчика: как запустить на своей машине»

Что сделать руками:

  1. Положите в папку data три-четыре своих конспекта уроков курса в Markdown, уберите replace(' ', '') в get_chunk и задайте вопросы по ним. Сравните ответы с k=1 и k=3.
  2. Найдите вопрос, на который система отвечает неверно, и выясните, где ошибка: в поиске (не тот фрагмент) или в генерации (фрагмент верный, ответ нет). Это главный навык отладки RAG из урока 8.2.2.
  3. Добавьте к поиску переранжирование (reranker) или гибридный поиск BM25 + векторы — и сравните.
РусскийПояснение
генерация, дополненная поиском (RAG)ответ LLM по найденным документам
галлюцинация (hallucination)уверенная выдумка модели
векторизация, эмбеддинг (embedding)текст → вектор
нарезка документов (chunking)деление на куски-chunk
перекрытие (overlap, cover_content)общий текст соседних кусков
векторная база данных (vector database)хранит векторы и тексты
косинусное сходство (cosine similarity)косинус угла между векторами
сохранение на диск (persist)чтобы не пересчитывать эмбеддинги
поиск, извлечение (retrieval)выбор top-k фрагментов
шаблон подсказки, промпта (prompt template)текст с местами для вопроса и контекста