Tiny Universe, часть 3: TinyRAG — RAG своими руками
Полный перевод раздела TinyRAG из tiny-universe: пять модулей минимального RAG без фреймворков — эмбеддинги, загрузка и нарезка документов, векторное хранилище на NumPy, поиск по косинусному сходству и ответ LLM по найденному контексту.
TinyRAG
Заголовок раздела «TinyRAG»Эфир «RAG своими руками»: запись трансляции.
Сейчас я шаг за шагом проведу вас через реализацию простой модели RAG. Это упрощённая версия RAG, мы называем её Tiny-RAG. В ней есть только ядро RAG — поиск (Retrieval) и генерация (Generation). Её цель — помочь лучше понять, как устроен и как реализуется RAG.
Итак, начнём!
1. Что такое RAG
Заголовок раздела «1. Что такое 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["Ответ"]2. Векторизация
Заголовок раздела «2. Векторизация»Для начала своими руками реализуем класс векторизации — основу архитектуры 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 NotImplementedError3. Загрузка и нарезка документов
Заголовок раздела «3. Загрузка и нарезка документов»Теперь реализуем класс загрузки и нарезки документов: он загружает документы и режет их на фрагменты.
Какие документы мы режем? Это может быть статья, книга, диалог, код и т. д. — что угодно, лишь бы это был текст. Например, файлы 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_text4. База данных и векторный поиск
Заголовок раздела «4. База данных и векторный поиск»Итак, нарезка документов готова, загрузка модели эмбеддингов — тоже. Теперь нужно спроектировать векторную базу данных для хранения фрагментов документов и их векторных представлений.
Ещё нужен модуль поиска, который по запросу (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()5. Модуль большой модели
Заголовок раздела «5. Модуль большой модели»Вот и последний модуль — модуль большой модели. Он отвечает на вопрос пользователя по найденным документам.
Как и раньше, сначала реализуем базовый класс — тогда любую другую интересную вам модель можно будет быстро подключить.
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!
6. Демонстрация LLM Tiny-RAG
Заголовок раздела «6. Демонстрация LLM Tiny-RAG»Теперь посмотрим на демонстрацию Tiny-RAG!
from RAG.VectorBase import VectorStorefrom RAG.utils import ReadFilesfrom RAG.LLM import OpenAIChat, InternLMChatfrom 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 VectorStorefrom RAG.utils import ReadFilesfrom RAG.LLM import OpenAIChat, InternLMChatfrom 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))7. Итоги
Заголовок раздела «7. Итоги»Ну что, научились собирать минимальную архитектуру RAG? Уверен, что да, ха-ха.
Повторим ещё раз: из чего должен состоять минимальный RAG? (Напишите по памяти!)
- модуль векторизации;
- модуль загрузки и нарезки документов;
- база данных;
- векторный поиск;
- модуль большой модели.
Всё, вы научились — только не забудьте поставить проекту звёздочку!
8. Литература
Заголовок раздела «8. Литература»- When Large Language Models Meet Vector Databases: A Survey
- Retrieval-Augmented Generation for Large Language Models: A Survey
- Learning to Filter Context for Retrieval-Augmented Generation
- In-Context Retrieval-Augmented Language Models
От переводчика: как запустить на своей машине
Заголовок раздела «От переводчика: как запустить на своей машине»Что сделать руками:
- Положите в папку
dataтри-четыре своих конспекта уроков курса в Markdown, уберитеreplace(' ', '')вget_chunkи задайте вопросы по ним. Сравните ответы сk=1иk=3. - Найдите вопрос, на который система отвечает неверно, и выясните, где ошибка: в поиске (не тот фрагмент) или в генерации (фрагмент верный, ответ нет). Это главный навык отладки RAG из урока 8.2.2.
- Добавьте к поиску переранжирование (reranker) или гибридный поиск BM25 + векторы — и сравните.
Словарик
Заголовок раздела «Словарик»| Русский | Пояснение |
|---|---|
| генерация, дополненная поиском (RAG) | ответ LLM по найденным документам |
| галлюцинация (hallucination) | уверенная выдумка модели |
| векторизация, эмбеддинг (embedding) | текст → вектор |
| нарезка документов (chunking) | деление на куски-chunk |
| перекрытие (overlap, cover_content) | общий текст соседних кусков |
| векторная база данных (vector database) | хранит векторы и тексты |
| косинусное сходство (cosine similarity) | косинус угла между векторами |
| сохранение на диск (persist) | чтобы не пересчитывать эмбеддинги |
| поиск, извлечение (retrieval) | выбор top-k фрагментов |
| шаблон подсказки, промпта (prompt template) | текст с местами для вопроса и контекста |