9. Создание вашей первой RAG-системы
Все приложения, которые мы создавали до сих пор, полагались только на предварительно обученные знания LLM. Вот почему они не могли отвечать на вопросы об информации, которую LLM никогда не изучала — например, о внутренних документах вашей компании или руководствах по продуктам.
RAG (Retrieval-Augmented Generation, генерация с дополненным поиском) решает эту проблему. Когда поступает вопрос пользователя, система сначала извлекает релевантные документы, затем передает извлеченный контент вместе с вопросом в LLM, чтобы она могла ответить на основе этого контента. Вы объединяете способность LLM к рассуждению со знаниями из ваших документов.
В этой главе мы создадим полный RAG-конвейер от подготовки документов (загрузка, разбиение на части, эмбеддинг) до генерации ответов на основе поиска. Готовая система извлекает релевантные документы при поступлении вопроса, затем передает их вместе с вопросом в LLM, чтобы она отвечала на основе содержимого этих документов. Она точно отвечает, когда информация есть в документах, и честно говорит "Я не знаю", когда её нет — это суть надежной RAG.
9.1) Понимание RAG
9.1.1) Проблема: LLM не знают ваших данных
LLM обучаются на интернет-данных, таких как Википедия, новостные статьи и публичный код. Они не знают о внутренних документах вашей компании или о контракте, который вы получили вчера. Поэтому они не могут ответить на такие вопросы, как:
- "Какова политика отпусков в нашей компании?"
- "Обобщите отчет о продажах за этот квартал"
- "Каковы условия возврата в контракте, который я только что получил?"
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Спрашиваем о частном документе, который LLM никогда не видела
response = llm.invoke("Какова политика возврата для Acme Corp?")
print(response.content)Вывод:
У меня нет конкретной информации о политике возврата Acme Corp. Я рекомендую
проверить их официальный веб-сайт или связаться напрямую с их службой поддержки
клиентов для получения наиболее точной и актуальной информации.В этом примере LLM честно говорит, что не знает. (Или она может сгенерировать правдоподобно звучащий, но выдуманный ответ.)
Но что, если мы предоставим документ с политикой возврата вместе с вопросом? LLM даст точный ответ на основе предоставленного контента. Это основная идея RAG.
9.1.2) Как следует предоставлять документ?
Самый простой подход — скопировать и вставить весь документ в промпт. Это действительно хорошо работает для коротких документов.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# В реальности это было бы намного длиннее, но предположим, что следующее — это весь документ
document_text = """
Политика возврата (действует с января 2026 года):
- Полный возврат средств в течение 30 дней с момента покупки при наличии оригинального чека.
- После 30 дней только кредит магазина.
- Цифровые продукты не подлежат возврату после загрузки.
- Дефектные товары могут быть возвращены в любое время для полного возврата средств.
"""
response = llm.invoke(
f"""Пожалуйста, ответьте на основе следующего документа:
{document_text}
Вопрос: Какова политика возврата для цифровых продуктов?"""
)
print(response.content)Вывод:
Цифровые продукты не подлежат возврату после загрузки.
...Это хорошо работает для коротких документов. Но что, если документ очень большой? Это вызывает следующие серьезные проблемы:
1. Ограничения контекстного окна: LLM имеют ограниченное количество токенов, которые они могут обработать за один раз. Для GPT-5-mini это 400 тысяч токенов. Однако вся документация вашей компании может легко превысить это. Даже если она помещается, ответы становятся медленнее и менее точными по мере увеличения длины контекста.
2. Стоимость: API LLM взимают плату за токен. Отправка всего документа, когда нужны только один или два абзаца, приводит к резкому росту затрат.
3. Снижение точности: Когда вы включаете весь документ, нужная вам информация теряется в нерелевантном контенте. Внимание LLM отвлекается на несвязанную информацию, что снижает качество ответа.
RAG решает все три проблемы, извлекая и предоставляя только релевантные части документа.
9.1.3) Основная идея: извлечение релевантных частей и их предоставление вместе с вопросом
Суть RAG проста: Перед отправкой вопроса в LLM сначала найдите релевантные части ваших документов и предоставьте их вместе с вопросом.
Вот как это работает:
- Пользователь задает вопрос.
- Система извлекает (Retrieval) релевантный контент из хранилища документов.
- Извлеченный контент добавляется (Augmentation) к промпту вместе с вопросом.
- LLM генерирует (Generation) ответ на основе извлеченного контента.
Эти три шага — откуда RAG (Retrieval-Augmented Generation, генерация с дополненным поиском) получает свое название.
9.1.4) Как извлекать релевантный контент? (Ограничения поиска по ключевым словам)
Этап извлечения критически важен для RAG. Вам нужно предоставить релевантный контент, чтобы получить правильные ответы. Так как же извлекать контент, связанный с вопросом?
Самый простой метод — поиск по ключевым словам: поиск документов, содержащих слова из вопроса. Например, если кто-то спрашивает "Какова политика возврата для цифровых продуктов?", вы будете искать документы, содержащие слова "возврат", "цифровые" и "продукты".
Но поиск по ключевым словам имеет критическую слабость: он может найти только точные совпадения слов.
Предположим, у вас есть документ с политикой возврата с таким содержанием:
"Полный возврат средств доступен в течение 30 дней с момента покупки."
Что произойдет, когда пользователь спросит "Как мне вернуть деньги?" Этот документ не будет извлечен. Документ не содержит фразу "вернуть деньги". Люди понимают, что "возврат средств" и "вернуть деньги" имеют одинаковое значение, но поиск по ключевым словам сопоставляет только слова, поэтому он не найдет его.
Поиск по ключевым словам сопоставляет только слова. Даже когда значение одинаковое, если слова различаются, он не найдет его.
Решение — семантический поиск. И что делает это возможным — это эмбеддинги.
9.1.5) Эмбеддинги: преобразование текста в числовые векторы
Эмбеддинги (embeddings) представляют значение текста в виде списка чисел (вектора). Когда вы вводите текст в модель эмбеддингов, она преобразует его в вектор из сотен или тысяч чисел.
from langchain_openai import OpenAIEmbeddings
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Встраиваем одно предложение
vector = embeddings_model.embed_query("Как мне вернуть деньги?")
print(f"Размерность вектора: {len(vector)}")
print(f"Первые 5 значений: {vector[:5]}")Вывод:
Размерность вектора: 1536
Первые 5 значений: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]Размерность — это количество значений, составляющих вектор. Модель text-embedding-3-small представляет весь текст в виде 1536 чисел.
Почему нам нужно так много чисел? Потому что каждая размерность фиксирует различные аспекты значения:
- Некоторые размерности могут различать "действие/состояние"
- Другие могут представлять степени "конкретное/абстрактное"
- Третьи могут указывать на "положительную/отрицательную" тональность
- ... (1536 семантических признаков — хотя мы не можем на самом деле интерпретировать, что представляет каждая размерность)
Так же, как 2D-координаты (x, y) представляют точку на плоскости, 1536-мерный вектор представляет точку в 1536-мерном "пространстве значений". Больше размерностей позволяет делать более тонкие различия в значении.
Похожие значения располагаются близко друг к другу в пространстве значений. "Метод возврата" и "вернуть деньги" используют разные слова, но поскольку они имеют похожие значения, они размещаются близко друг к другу в пространстве значений.
9.1.6) Семантический поиск: похожее значение, меньшее расстояние
После того как вы преобразовали и документы, и запросы в векторы, вы можете найти наиболее релевантные документы, измеряя сходство между векторами. Это называется семантический поиск — поиск по семантическому сходству, а не по совпадению ключевых слов.
Наиболее распространенная мера сходства — косинусное сходство, которое измеряет угол между двумя векторами. Когда векторы указывают в похожих направлениях, сходство выше. Ближе к 1.0 означает очень похожее значение, а ближе к 0 означает низкую релевантность.
Давайте фактически вычислим это:
from langchain_openai import OpenAIEmbeddings
import numpy as np
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Встраиваем запрос и два документа-кандидата
query_vec = embeddings_model.embed_query("Каков период возврата?")
doc1_vec = embeddings_model.embed_query("Полный возврат средств доступен в течение 30 дней с момента покупки.") # Связанный
doc2_vec = embeddings_model.embed_query("Наш офис расположен в центре Сиэтла.") # Несвязанный
def cosine_similarity(a, b):
"""Вычисляет косинусное сходство между двумя векторами."""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
print(f"Запрос vs документ 'возврат': {sim1:.4f}")
print(f"Запрос vs документ 'офис': {sim2:.4f}")Вывод:
Запрос vs документ 'возврат': 0.6415
Запрос vs документ 'офис': 0.1706(Фактические значения могут варьироваться в зависимости от модели)
Документ о возврате получает гораздо более высокий балл. Модель эмбеддингов понимает, что "период возврата" и "полный возврат в течение 30 дней" семантически связаны. Это семантический поиск, и это основной механизм RAG.
9.1.7) Обзор RAG-конвейера
Объединение концепций, которые мы изучили, создает следующий RAG-конвейер:
Конвейер состоит из двух фаз:
Загрузка знаний (выполняется один раз изначально или при изменении документов):
- Загрузка документов: Извлечение текстовых данных из различных источников (Markdown, PDF и т.д.).
- Разбиение текста (чанкинг): Разбиение длинных документов на более мелкие части (chunks) для улучшения точности извлечения и соблюдения ограничений на ввод LLM.
- Преобразование в векторы (эмбеддинг): Использование модели эмбеддингов для преобразования частей в числовые векторы на основе значения.
- Хранение векторов: Сохранение преобразованных векторов и исходного текста в векторной базе данных (индексирование).
Извлечение и генерация ответа (выполняется для каждого вопроса пользователя):
- Встраивание вопроса: Преобразование вопроса пользователя в числовой вектор с использованием той же модели, что использовалась при загрузке.
- Поиск по сходству (извлечение): Извлечение топ-K частей, которые семантически наиболее близки к вектору вопроса из векторной базы данных.
- Дополнение промпта: Объединение исходного вопроса с извлеченными частями для дополнения промпта.
- Генерация ответа: LLM ссылается на предоставленные части для генерации обоснованного ответа.
9.2) Загрузка и разбиение документов
Этот раздел охватывает первые два шага фазы загрузки знаний RAG-конвейера:
- Загрузка документов: Чтение текстовых данных из файлов
- Разбиение текста (чанкинг): Разбиение текстовых данных на небольшие, доступные для поиска части
В следующем разделе (9.3) мы узнаем, как преобразовать эти части в векторы и сохранить их.
9.2.1) Загрузка документов из файлов
Первый шаг в RAG-конвейере — загрузка документов в объекты Python. LangChain предоставляет загрузчики документов (document loaders) — классы, которые поддерживают различные форматы файлов. Основные загрузчики:
TextLoader: Обычный текст (.txt) и файлы Markdown (.md)PyPDFLoader: Файлы PDF (.pdf), загружаемые постраничноCSVLoader: Файлы CSV (.csv), где каждая строка загружается как отдельный документUnstructuredMarkdownLoader: Файлы Markdown (.md) с учетом структуры (заголовки, списки и т.д.)
Независимо от того, какой загрузчик вы используете, результат всегда возвращается в виде списка объектов Document. Каждый Document имеет два ключевых атрибута:
page_content: Текстовое содержимое документаmetadata: Словарь, содержащий метаинформацию, такую как путь к файлу и номер страницы
В этом руководстве мы будем использовать TextLoader для загрузки файлов Markdown.
Подготовка примеров документов
Сначала давайте создадим несколько примеров документов для работы. Создайте папку data/docs/ в вашем проекте и добавьте следующие файлы:
mkdir -p data/docsСоздайте data/docs/refund_policy.md:
# Политика возврата
**Дата вступления в силу**: 1 января 2026 года
## Стандартные возвраты
Все физические продукты могут быть возвращены в течение 30 дней с момента покупки для полного возврата средств.
Требуется оригинальный чек или электронное письмо с подтверждением заказа. Товары должны быть в оригинальной упаковке и неиспользованном состоянии.
После 30 дней возвраты принимаются только для кредита магазина. Кредит магазина не истекает.
## Цифровые продукты
Цифровые продукты (лицензии на программное обеспечение, электронные книги, онлайн-курсы) не подлежат возврату
после активации ссылки на загрузку или доступ. Если у вас возникли технические
проблемы, препятствующие доступу, свяжитесь со службой поддержки в течение 7 дней для замены или возврата средств.
## Дефектные товары
Дефектные товары могут быть возвращены в любое время для полного возврата средств или замены.
Пожалуйста, включите описание дефекта. Расходы на доставку для возврата дефектных товаров
покрываются компанией.
## Услуги по подписке
Ежемесячные подписки могут быть отменены в любое время. Возврат средств пропорционален
оставшимся дням в платежном цикле. Годовые подписки могут быть полностью возвращены
в течение первых 14 дней. После 14 дней возврат средств недоступен, но доступ продолжается до конца платежного периода.Создайте data/docs/shipping_info.md:
# Информация о доставке
## Внутренняя доставка
Стандартная доставка (5-7 рабочих дней): Бесплатно для заказов свыше $50, в противном случае $5.99.
Экспресс-доставка (2-3 рабочих дня): $12.99.
Доставка на следующий день (следующий рабочий день): $24.99.
## Международная доставка
Международные заказы отправляются отслеживаемой авиапочтой. Сроки доставки варьируются в зависимости от
пункта назначения, обычно 10-21 рабочий день. Стоимость международной доставки
рассчитывается при оформлении заказа на основе веса и пункта назначения.
Таможенные пошлины и налоги на импорт являются ответственностью покупателя и не включены в стоимость доставки.
## Отслеживание заказа
Все заказы включают номер отслеживания, отправленный по электронной почте в течение 24 часов после отправки.
Отслеживайте свой заказ через ссылку отслеживания в вашем электронном письме или через веб-сайт перевозчика.
## Потерянные или поврежденные посылки
Если ваша посылка потеряна или прибыла поврежденной, свяжитесь со службой поддержки в течение 48 часов.
Мы отправим замену без дополнительной платы. Для поврежденных товаров, пожалуйста,
предоставьте фотографии повреждения и упаковки.Теперь загрузите эти файлы с помощью TextLoader:
from pathlib import Path
from langchain_community.document_loaders import TextLoader
# Загружаем все .md файлы из директории data/docs
docs_dir = Path("data/docs")
for md_file in docs_dir.glob("*.md"):
loader = TextLoader(str(md_file), encoding="utf-8")
docs = loader.load()
if docs: # Проверяем, что файл не пустой
doc = docs[0] # Один файл = один Document
print(f"Файл: {doc.metadata['source']}")
print(f"Длина: {len(doc.page_content)} символов")
print(f"Предварительный просмотр: {doc.page_content[:80]}...")
print()Вывод:
Файл: data/docs/refund_policy.md
Длина: 1166 символов
Предварительный просмотр: # Политика возврата
...
Файл: data/docs/shipping_info.md
Длина: 972 символов
Предварительный просмотр: # Информация о доставке
...Примечание:
TextLoaderпринимает путь к одному файлу в качестве входных данных, но возвращаетList[Document]для согласованного интерфейса с другими загрузчиками. (Например,PDFLoaderвозвращает несколько Documents — по одному на страницу.)
9.2.2) Разбиение документов на части: чанкинг
Два документа выше намеренно короткие для целей этого руководства. В реальных приложениях вы часто будете работать с документами длиной в сотни или тысячи страниц. Если вы встроите весь документ как один вектор, тысячи концепций сжимаются в один — что делает невозможным точное извлечение того, что вам действительно нужно.
Чанкинг (chunking) — это процесс разбиения документов на небольшие, значимые части. Цель проста: когда пользователь задает вопрос, должны быть извлечены только конкретные абзацы, непосредственно релевантные ответу — а не весь документ.
Размер части напрямую влияет как на извлечение, так и на качество ответа:
- Слишком большой: Несколько тем смешиваются в одной части, что делает эмбеддинги менее точными и затрудняет извлечение. Даже когда правильная часть найдена, нерелевантный контент передается в LLM, снижая качество ответа.
- Слишком маленький: LLM может не получить достаточно информации для правильного ответа. Например, если извлечено только предложение "Стандартная доставка стоит $5.99", LLM не может знать, что это применяется только к заказам менее $50.
- Правильный размер: Каждая часть охватывает одну тему с достаточным контекстом, обеспечивая точное извлечение и ответы.
9.2.3) Управление размером части и перекрытием
Для разбиения документов на части вам нужен разделитель текста. Разделитель текста (text splitter) — это инструмент LangChain, который разбивает длинные документы на более мелкие части. Выбор правильного разделителя важен.
RecursiveCharacterTextSplitter: Пробует несколько разделителей в иерархическом порядке, чтобы сохранить как можно больше контекста. Наиболее широко используемый разделитель для общих целей.CharacterTextSplitter: Разбивает по одному разделителю (по умолчанию:\n\n). Подходит для документов с простой структурой.MarkdownHeaderTextSplitter: Разбивает по заголовкам Markdown (#,##). Эффективен, когда вы хотите сохранить структуру оглавления документа.
Почему RecursiveCharacterTextSplitter эффективен?
Этот разделитель работает, пробуя разделители от самой большой до самой маленькой единицы, чтобы найти лучшую точку разбиения. Порядок по умолчанию следующий (может быть изменен через параметр separators):
абзац (\n\n) → перенос строки (\n) → слово ( )
Он всегда сначала пытается разбить на самой большой значимой единице. Если абзац превышает chunk_size, он переходит к переносам строк, затем к словам. Поскольку он всегда находит наиболее естественную точку разбиения, а не разрезает произвольно посередине слова, полученные части с большей вероятностью содержат семантически полную информацию.
Ключевые параметры
chunk_size: Максимальное количество символов на часть. Например,chunk_size=400означает, что ни одна часть не превысит 400 символов.chunk_overlap: Количество перекрывающихся символов между соседними частями. Например,chunk_overlap=80означает, что последние 80 символов одной части повторяются в начале следующей.separators: Список разделителей, используемых для разбиения текста, пробуемых в порядке приоритета. Если разбиение по текущему разделителю превыситchunk_size, пробуется следующий разделитель, чтобы избежать превышенияchunk_size.
Что такое перекрытие и зачем оно нужно?
Перекрытие означает, что соседние части имеют общий контент — конец одной части включается в начало следующей.
Причина этого в том, чтобы гарантировать, что каждая часть может существовать самостоятельно с достаточным контекстом. При чтении части документа без какого-либо знания о том, что было раньше, может быть трудно понять, почему упоминается определенный контент. Перекрытие сохраняет конец одной части, переходящий в следующую, так что какая бы часть ни была извлечена, контент читается естественно.
Теперь давайте фактически разобьем документ:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Загружаем документ
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
# Настраиваем разделитель
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " "],
)
chunks = text_splitter.split_documents(docs)
print(f"Разбито на {len(chunks)} частей\n")
for i, chunk in enumerate(chunks):
print(f"--- Часть {i} (источник: {chunk.metadata['source']}) ---")
print(f"Длина: {len(chunk.page_content)} символов")
print(chunk.page_content[:120])
print()Вывод:
Разбито на 4 частей
--- Часть 0 (источник: data/docs/refund_policy.md) ---
Длина: 374 символов
# Политика возврата
...
--- Часть 1 (источник: data/docs/refund_policy.md) ---
Длина: 267 символов
## Цифровые продукты
...
--- Часть 2 (источник: data/docs/refund_policy.md) ---
Длина: 204 символов
## Дефектные товары
...
--- Часть 3 (источник: data/docs/refund_policy.md) ---
Длина: 315 символов
## Услуги по подписке
...Примечание: В приведенном выше примере перекрытие не произошло. Это потому, что каждый абзац был четко разбит на основе первого разделителя (
\n##), оставаясь в пределахchunk_size. Перекрытие происходит только тогда, когда конкретный абзац длиннее, чемchunk_size, и должен быть разделен на две или более частей.
9.3) Векторное хранилище и извлечение с ChromaDB
9.3.1) Что такое векторное хранилище?
Векторное хранилище (vector store) (также называемое векторной базой данных) — это база данных, оптимизированная для хранения и поиска данных с использованием векторов эмбеддингов. В отличие от традиционной базы данных, где вы запрашиваете по точным значениям полей (SELECT * FROM products WHERE category = 'electronics'), векторное хранилище находит элементы с наиболее похожим значением на ваш запрос.
В RAG векторное хранилище содержит части документов вместе с их эмбеддингами. Когда пользователь задает вопрос, вопрос преобразуется в вектор, и векторное хранилище извлекает части с наиболее похожими векторами.
9.3.2) Выбор векторного хранилища и настройка ChromaDB
Популярные векторные хранилища включают ChromaDB, Pinecone, Weaviate и pgvector (расширение PostgreSQL). Они различаются по модели хостинга (локальная или облачная), масштабу и операционной сложности. Для этой книги мы будем использовать ChromaDB — это открытый исходный код, работает полностью на вашей локальной машине без настройки сервера и полезен не только для разработки, но и для небольших и средних производственных рабочих нагрузок.
ChromaDB можно использовать несколькими способами:
- Локальный режим (pip): Установите его как библиотеку Python и используйте немедленно. Вы можете хранить и загружать данные в локальной директории без какой-либо отдельной серверной инфраструктуры.
- Автономный сервер (Docker): Запустите ChromaDB как отдельный серверный процесс. Полезно, когда несколько приложений должны использовать одно и то же векторное хранилище.
- Управляемый облачный сервис (Chroma Cloud): Используйте ChromaDB как облачный сервис. Chroma Cloud обрабатывает хостинг, масштабирование и обслуживание, позволяя вам предоставлять стабильный сервис без бремени управления инфраструктурой.
Давайте установим ChromaDB с помощью pip:
pip install chromadb langchain-chromachromadb — это основная библиотека векторного хранилища, а langchain-chroma — это пакет интеграции, который позволяет вам использовать ChromaDB напрямую в библиотеке LangChain.
9.3.3) Выбор модели эмбеддингов
Первое, что нужно решить, — какую модель эмбеддингов использовать. Векторы эмбеддингов можно сравнивать только тогда, когда они сгенерированы одной и той же моделью. Поэтому вы должны использовать одну и ту же модель эмбеддингов как для хранения документов, так и для запросов.
OpenAI предоставляет следующие модели эмбеддингов:
| Модель | Размерности | Примечания |
|---|---|---|
text-embedding-3-small | 1536 | Хороший баланс качества и стоимости |
text-embedding-3-large | 3072 | Более высокое качество, более высокая стоимость |
Для этой книги мы будем использовать модель text-embedding-3-small от OpenAI. Она предлагает высокую эффективность при низкой стоимости, что делает её практичным выбором для общего поиска, RAG и проектов, чувствительных к стоимости.
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Проверяем, что это работает
test_vector = embedding_model.embed_query("тест")
print(f"Размерность эмбеддинга: {len(test_vector)}")Вывод:
Размерность эмбеддинга: 1536Примечание о стоимости: Вызовы API эмбеддингов намного дешевле, чем вызовы LLM, но они все же влекут за собой затраты. При хранении документов в базе данных (индексирование) требуется один вызов API на часть, а когда пользователь задает вопрос (извлечение), требуется один вызов API для вопроса. Для текущих цен обратитесь к странице цен OpenAI.
9.3.4) Хранение частей в ChromaDB
Теперь давайте соберем все вместе. Мы загрузим документы, разобьем их на части, встроим части и сохраним их вместе с их векторами эмбеддингов в ChromaDB.
# ingest.py - Полный конвейер загрузки
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Шаг 1: Загружаем документы
# DirectoryLoader: сканирует директорию и загружает соответствующие файлы.
# Фактическая загрузка делегируется загрузчику, указанному в loader_cls.
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Загружено {len(documents)} документов")
# Шаг 2: Разбиваем на части
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Создано {len(chunks)} частей")
# Шаг 3: Создаем модель эмбеддингов
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Шаг 4: Создаем векторное хранилище и загружаем части
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Сохранено {len(chunks)} частей в ChromaDB по адресу data/chroma_db/")Вывод:
Загружено 2 документов
Создано 8 частей
Сохранено 8 частей в ChromaDB по адресу data/chroma_db/Метод Chroma.from_documents() выполняет две задачи в одном вызове:
- Передает части, предоставленные через параметр
documents, через модель эмбеддингов для получения векторов эмбеддингов. - Сохраняет каждую часть вместе с её вектором эмбеддинга в ChromaDB.
9.3.5) Загрузка сохраненного векторного хранилища
В предыдущем разделе мы сохранили документы в векторном хранилище. Эта операция хранения должна выполняться только один раз изначально (или при изменении документов). После этого вы можете просто загрузить сохраненное векторное хранилище и использовать его напрямую.
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Загружаем сохраненное векторное хранилище — повторное встраивание не требуется
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
print(f"Загружено векторное хранилище с {len(vector_store.get()['ids'])} частями")Вывод:
Загружено векторное хранилище с 8 частямиТеперь вы можете начать поиск немедленно, просто загрузив сохраненное векторное хранилище, без необходимости повторного встраивания ваших документов.
9.3.6) Поиск по сходству
С загруженным векторным хранилищем вы теперь можете искать части, которые семантически похожи на запрос. Параметр top-K указывает, сколько результатов вернуть:
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Ищем части, связанные с вопросом
query = "Могу ли я вернуть цифровой продукт?"
results = vector_store.similarity_search(query, k=2)
print(f"Запрос: {query}")
print(f"Найдено {len(results)} результатов\n")
for i, doc in enumerate(results):
print(f"--- Результат {i + 1} (источник: {doc.metadata['source']}) ---")
print(doc.page_content[:200])
print()Вывод:
Запрос: Могу ли я вернуть цифровой продукт?
Найдено 2 результатов
--- Результат 1 (источник: data/docs/refund_policy.md) ---
## Цифровые продукты
...
--- Результат 2 (источник: data/docs/refund_policy.md) ---
# Политика возврата
**Дата вступления в силу**: 1 января 2026 года
## Стандартные возвраты
...Для этого запроса часть о цифровых продуктах была извлечена с наивысшим сходством, за ней следует часть о политике возврата.
Вы также можете извлечь результаты с их баллами сходства, используя similarity_search_with_score:
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
for doc, score in results_with_scores:
# ChromaDB возвращает расстояние (меньше = более похоже)
print(f"Балл: {score:.4f} | Источник: {doc.metadata['source']}")
print(f" {doc.page_content[:200]}...")
print()Вывод:
Балл: 0.5942 | Источник: data/docs/refund_policy.md
## Цифровые продукты
...
Балл: 0.9577 | Источник: data/docs/refund_policy.md
# Политика возврата
...Обратите внимание, что ChromaDB использует баллы расстояния (меньше — более похоже), а не баллы сходства (больше — более похоже). Часть о цифровых продуктах имеет наименьшее расстояние 0.5942, что делает её наиболее релевантным результатом.
9.4) Создание полной RAG-цепочки
Теперь мы создадим полную RAG-систему: сначала извлечем релевантные документы, затем передадим их вместе с вопросом в LLM для генерации ответов на основе предоставленной информации.
9.4.1) Проектирование шаблона промпта
Самая важная часть шаблона промпта — это инструкция LLM отвечать только на основе предоставленного контекста. Без этой инструкции LLM может игнорировать результаты поиска и фабриковать ответы на основе своих обучающих данных.
from langchain_core.prompts import ChatPromptTemplate
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Вы представитель службы поддержки клиентов. "
"Отвечайте на вопрос пользователя, используя ТОЛЬКО предоставленный контекст. "
"Если контекст не содержит достаточно информации для ответа, "
"скажите \"У меня недостаточно информации, чтобы ответить на этот вопрос.\"\n\n"
"Контекст:\n{context}"),
("human", "{question}"),
])Системное сообщение заставляет LLM отвечать, используя только предоставленный контекст. Критически важно, что инструкция сказать "У меня недостаточно информации", когда контекста недостаточно, предотвращает фабрикацию LLM правдоподобных, но неподтвержденных ответов.
9.4.2) Создание RAG-цепочки
Теперь у нас есть все компоненты готовы. Нам просто нужно соединить ретривер, шаблон промпта и LLM.
Завершенная RAG-система будет работать следующим образом:
- Получить вопрос пользователя
- Извлечь релевантные части из векторного хранилища
- Передать части и вопрос в шаблон промпта для генерации промпта
- Сгенерировать ответ с помощью LLM
Давайте соединим RAG-цепочку, используя оператор LCEL | из главы 6.
# rag_chain.py - Полный RAG-конвейер
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
"""Объединяет извлеченные документы в одну строку контекста."""
return "\n\n---\n\n".join(doc.page_content for doc in docs)
def build_rag_chain():
"""Создает и возвращает полную RAG-цепочку."""
# Загружаем векторное хранилище
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Создаем ретривер (k=3 означает возврат топ-3 частей)
retriever = vector_store.as_retriever(search_kwargs={"k": 3})
# Определяем промпт
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Вы представитель службы поддержки клиентов. "
"Отвечайте на вопрос пользователя, используя ТОЛЬКО предоставленный контекст. "
"Если контекст не содержит достаточно информации для ответа, "
"скажите \"У меня недостаточно информации, чтобы ответить на этот вопрос.\"\n\n"
"Контекст:\n{context}"),
("human", "{question}"),
])
# Инициализируем LLM
llm = ChatOpenAI(model="gpt-5-mini")
# Составляем цепочку с использованием LCEL
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)
return rag_chain
if __name__ == "__main__":
chain = build_rag_chain()
answer = chain.invoke("Могу ли я вернуть цифровой продукт?")
print(answer)Вывод:
Цифровые продукты не подлежат возврату после активации ссылки на загрузку или доступ.
Если у вас возникли технические проблемы, препятствующие доступу, свяжитесь со службой поддержки в течение 7 дней для замены или возврата средств.Давайте разберем композицию цепочки шаг за шагом:
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)Когда вы вызываете chain.invoke("Могу ли я вернуть цифровой продукт?"), вот что происходит:
- Шаг словаря:
retriever | format_docs: Ищет в векторном хранилище с вопросом и объединяет части в одну строкуlambda x: x: Передает вопрос без изменений- Результат:
{"context": "извлеченные части (объединенные в одну строку)", "question": "Могу ли я вернуть цифровой продукт?"}
rag_prompt: Заполняет заполнители{context}и{question}в шаблоне промпта значениями из словаряllm: Отправляет завершенный промпт в LLMStrOutputParser(): Извлекает только текст из ответа LLM
Для более подробной информации о том, как работает LCEL, см. главу 6.
9.4.3) Тестирование с вопросами, на которые можно и нельзя ответить
RAG-система должна обрабатывать как вопросы, на которые она может ответить (информация существует в документах), так и вопросы, на которые она не может ответить (информации нет в документах). Давайте протестируем оба сценария:
# test_rag.py - Тестирование RAG-цепочки с различными вопросами
from rag_chain import build_rag_chain
chain = build_rag_chain()
test_questions = [
# На которые можно ответить — информация есть в документах
"Какова политика возврата для физических продуктов?",
"Сколько стоит экспресс-доставка?",
"Могу ли я вернуть дефектный товар через 6 месяцев?",
# На которые нельзя ответить — информации НЕТ в документах
"Какова политика отпусков для сотрудников?",
"Какие языки программирования используются?",
]
for question in test_questions:
print(f"В: {question}")
answer = chain.invoke(question)
print(f"О: {answer}\n")
print("-" * 60)Вывод:
В: Какова политика возврата для физических продуктов?
О: Все физические продукты могут быть возвращены в течение 30 дней с момента покупки для полного возврата средств. ...
------------------------------------------------------------
В: Сколько стоит экспресс-доставка?
О: Экспресс-доставка (2–3 рабочих дня) стоит $12.99.
------------------------------------------------------------
В: Могу ли я вернуть дефектный товар через 6 месяцев?
О: Да. Дефектные товары могут быть возвращены в любое время для полного возврата средств или замены. ...
------------------------------------------------------------
В: Какова политика отпусков для сотрудников?
О: У меня недостаточно информации, чтобы ответить на этот вопрос.
------------------------------------------------------------
В: Какие языки программирования используются?
О: У меня недостаточно информации, чтобы ответить на этот вопрос.
------------------------------------------------------------Результаты демонстрируют именно то поведение, которое мы хотим:
- Вопросы, на которые можно ответить: Предоставляют точные ответы на основе извлеченных документов. LLM не добавляет информацию, не содержащуюся в документах.
- Вопросы, на которые нельзя ответить: Отвечают "У меня недостаточно информации, чтобы ответить на этот вопрос." LLM правильно определяет, что извлеченный контекст не содержит релевантной информации, и отказывается фабриковать ответ.
Это сила RAG. Ваша LLM отвечает на вопросы о ваших данных и честно признается, когда не знает. Каждый ответ подкреплен документами, что делает систему гораздо более надежной, чем стандартная LLM.