Перейти к содержимому
ПереводыQwen, InternLM и другие

Курс Alibaba Cloud ACP, урок 2.2: расширяем знания бота (RAG)

Полный перевод урока 2.2 курса Alibaba Cloud ACP: как устроен RAG (индексация и поиск с генерацией), приложение на LlamaIndex по документам компании, сохранение и загрузка индекса, многоходовый RAG с переписыванием вопроса, задачи с разбором.

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

После урока вы сможете:

  • объяснить, как работает RAG;
  • создать RAG-приложение.

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

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

Отсюда ключевая идея, которую мы уже упоминали, — контекстная инженерия (Context Engineeringконтекст-инжиниринг (context engineering)EN context engineeringРешение, какие токены и в каком порядке попадают в окно на каждом вызове — инструкции, примеры, документы, история, результаты инструментов.): наполнять «контекстное окно» модели ровно той информацией, которая нужна для задачи. Информации мало — модель «не знает»; слишком много или не по делу — качество падает, а стоимость растёт.

генерация с дополнением извлечёнными даннымигенерация с дополнением извлечёнными данными (RAG)EN retrieval-augmented generation (RAG) (Retrieval Augmented Generation, генерация с дополнением найденным), которую мы сейчас изучим, — один из самых важных и действенных приёмов контекстной инженерии. Он решает именно проблему «модели не хватает знаний». RAG-приложение обычно состоит из двух частей: построение индекса и поиск с генерацией.

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

  1. Разбор документов. Вы превращаете увиденное в книге в понятный вам текст; так же и RAG-приложение сначала загружает документы базы знаний и разбирает их в текст, понятный модели.
  2. Нарезка текста. Решая задачу, вы не листаете всю книгу, а ищете несколько самых подходящих абзацев — поэтому заранее грубо делите материал на части. Так же RAG-приложение после разбора режет текст на фрагменты (нарезкачанкинг (нарезка документов)EN chunkingРазбиение документов на фрагменты (чанки) для индексации. Каждый чанк получает свой эмбеддинг.), чтобы потом быстро находить самое подходящее к вопросу.
  3. Векторизация текста. На экзамене вы сначала ищете в материалах самые подходящие абзацы, а потом отвечаете. В RAG-приложении для этого обычно используют модель-эмбеддерэмбеддинг-модельEN embedding modelМодель, которая превращает текст целиком в один вектор так, что близкие по смыслу тексты дают близкие векторы (BGE, multilingual-e5, Qwen3-Embedding).: она превращает и фрагменты, и вопрос в числа (векторы), а сравнение их сходства находит самые подходящие фрагменты. Превращение текста в числа и называется векторизацией.

    Если вам интересны детали этого процесса, загляните в раздел «Дополнительное чтение» в конце урока.

  4. Хранение индекса. Векторы фрагментов сохраняют в векторную базу данныхвекторная база данныхEN vector database, чтобы не повторять все шаги при каждом ответе — это ускоряет отклик.
flowchart LR
D["Документы<br/>PDF, DOCX, MD"] --> P["1. Разбор<br/>в текст"] --> C["2. Нарезка<br/>на фрагменты"] --> E["3. Векторизация<br/>моделью-эмбеддером"] --> S[("4. Векторная<br/>база")]
Построение индекса: четыре шага (в оригинале — картинка с той же схемой)

Построив индекс, RAG-приложение может находить фрагменты текста, относящиеся к вопросу пользователя.

Поиск и генерация — это два этапа из названия RAG: Retrieval и Generation. Поиск — как поиск сведений в материалах на открытом экзамене, генерация — как ответ на вопрос по найденным материалам.

  1. Поиск. На этом этапе находятся фрагменты, самые близкие к вопросу. Модель-эмбеддер превращает вопрос в вектор и сравнивает его по смысловой близости с векторами фрагментов в базе. Поиск — самое важное звено RAG: представьте, что на экзамене вы нашли не те материалы — ответ точно будет неверным. Этот шаг лучше всего показывает суть контекстной инженерии: «точно выбрать нужное» из огромного объёма знаний, чтобы наполнить контекст. Найти самое подходящее — первый шаг к качественной генерации. Для точности поиска, кроме сильной модели-эмбеддера, применяют переранжированиереранкерEN rerankerВторая ступень поиска — точная, но медленная модель (обычно кросс-энкодер) пересортировывает десятки кандидатов от быстрого ретривера. (rerank), поиск с окном предложений и другие приёмы — их вы изучите в следующих уроках.
  2. Генерация. Найдя фрагменты, RAG-приложение вставляет вопрос и фрагменты в шаблон промпта, и модель пишет ответ. Здесь важнее умение модели обобщать прочитанное, а не её собственные знания. Устройство шаблона — ещё одно ключевое звено контекстной инженерии: мало дать модели найденные «материалы», надо ещё ясно «объяснить», как ими пользоваться.

    Типичный шаблон (в переводе): Ответь на вопрос пользователя по следующим сведениям: {найденные фрагменты}. Вопрос пользователя: {question}.

flowchart LR
Q["Вопрос"] --> V["Вектор вопроса"] --> R{"Поиск близких<br/>фрагментов"}
DB[("Векторная база")] --> R
R --> T["Шаблон промпта:<br/>фрагменты + вопрос"] --> L["LLM"] --> A["Ответ"]
Поиск и генерация

Реализовать всё это вручную непросто. Но с LlamaIndex для этого хватит совсем немного кода.

Прежде чем запускать код урока, убедитесь, что переключились на созданное ранее окружение Python — например, Python (llm_learn) из прошлых уроков.

Внимание: в каждом следующем уроке проверяйте, не нужно ли вручную переключить окружение ноутбука.

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

# Загружаем ключ API Bailian для вызова моделей Qwen
import os, sys
os.chdir(os.path.join(os.path.dirname(os.path.abspath('')), 'course_core'))
sys.path.insert(0, os.getcwd())
from config.load_key import load_key, load_nltk
load_key()
# В продакшене не выводите ключ API в логи, чтобы он не утёк
print(f'''Ваш ключ API: {os.environ["DASHSCOPE_API_KEY"][:5]+"*"*5}''')
load_nltk()

В папке docs мы подготовили несколько вымышленных документов компании (регламенты, описание должностей и т. п.). Сейчас вы создадите по ним RAG-приложение.

# Импортируем зависимости
from llama_index.embeddings.dashscope import DashScopeEmbedding,DashScopeTextEmbeddingModels
from llama_index.core import SimpleDirectoryReader,VectorStoreIndex
from llama_index.llms.openai_like import OpenAILike
# Пропускаем загрузку NLTK: LlamaIndex по умолчанию использует NLTK для предобработки текста, но в некоторых окружениях
# загрузка падает из-за сети. Переменная окружения LLAMA_INDEX_DISABLE_NLTK=1 отключает NLTK.
import os
os.environ["LLAMA_INDEX_DISABLE_NLTK"] = "1"
# Эти две строки убирают предупреждения (WARNING), чтобы не мешали читать; в продакшене настраивайте уровень логов по необходимости
import logging
logging.basicConfig(level=logging.ERROR)
print("Разбираем файлы...")
# В LlamaIndex есть SimpleDirectoryReader: он загружает файлы из папки как объекты document — это шаг разбора.
# Здесь читаем только нужные курсу форматы и пропускаем ноутбуки, чтобы не запускать медленный разбор через nbconvert.
documents = SimpleDirectoryReader('./docs', required_exts=['.pdf', '.docx', '.md']).load_data()
print("Создаём индекс...")
# from_documents включает шаги нарезки и построения индекса
index = VectorStoreIndex.from_documents(
documents,
# указываем модель-эмбеддер
embed_model=DashScopeEmbedding(
# можно взять и другие модели эмбеддингов Alibaba Cloud: https://help.aliyun.com/zh/model-studio/getting-started/models#3383780daf8hw
model_name=DashScopeTextEmbeddingModels.TEXT_EMBEDDING_V2
))
print("Создаём движок вопросов...")
query_engine = index.as_query_engine(
# потоковый вывод
streaming=True,
# здесь модель qwen-plus; можно взять и другие текстовые модели Qwen в Alibaba Cloud: https://help.aliyun.com/zh/model-studio/getting-started/models#9f8890ce29g5u
llm=OpenAILike(
model="qwen-plus",
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key=os.getenv("DASHSCOPE_API_KEY"),
is_chat_model=True
))
print("Генерируем ответ...")
streaming_response = query_engine.query('Каким инструментом управления проектами пользоваться в нашей компании?')
print("Ответ:")
# потоковый вывод
streaming_response.print_response_stream()

Вы, наверное, заметили, что индекс строится довольно долго. Если сохранить его на диск и загружать при необходимости, а не строить заново, ответ будет гораздо быстрее. В LlamaIndex для этого есть простые методы.

# Сохраняем индекс в локальные файлы
index.storage_context.persist("knowledge_base/test")
print("Файлы индекса сохранены в knowledge_base/test")
# Загружаем индекс из локальных файлов
from llama_index.core import StorageContext,load_index_from_storage
storage_context = StorageContext.from_defaults(persist_dir="knowledge_base/test")
index = load_index_from_storage(storage_context,embed_model=DashScopeEmbedding(
model_name=DashScopeTextEmbeddingModels.TEXT_EMBEDDING_V2
))
print("Индекс успешно загружен из knowledge_base/test")

Загрузив индекс с диска, снова задайте вопрос и убедитесь, что всё работает.

print("Создаём движок вопросов...")
query_engine = index.as_query_engine(
# потоковый вывод
streaming=True,
# здесь модель qwen-plus; можно взять и другие текстовые модели Qwen в Alibaba Cloud
llm=OpenAILike(
model="qwen-plus",
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key=os.getenv("DASHSCOPE_API_KEY"),
is_chat_model=True
))
print("Генерируем ответ...")
streaming_response = query_engine.query('Каким инструментом управления проектами пользоваться в нашей компании?')
print("Ответ:")
streaming_response.print_response_stream()

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

from chatbot import rag
# Индекс уже построен на предыдущих шагах, поэтому его можно просто загрузить. Если нужно перестроить индекс,
# добавьте строку: rag.indexing()
index = rag.load_index(persist_path='./knowledge_base/test')
query_engine = rag.create_query_engine(index=index)
rag.ask('Каким инструментом управления проектами пользоваться в нашей компании?', query_engine=query_engine)

В прошлых уроках вы научились вести многоходовый диалог с моделью, храня список messages. Но в RAG-приложении многоходовый диалог сталкивается с особой трудностью.

Вспомните этап поиска: система сравнивает вопрос пользователя по смыслу с фрагментами базы знаний и находит самые близкие. А что, если вопрос опирается на контекст из истории диалога?

Пример:

  • Первый ход: пользователь спрашивает «Где рабочее место Чжан Саня?»
  • Второй ход: пользователь продолжает: «А кто его руководитель?»

Если искать только по вопросу второго хода «А кто его руководитель?», система не знает, кто такой «он», и, скорее всего, найдёт не те фрагменты — ответ будет неточным.

Если подать в поиск всю историю диалога вместе с вопросом, текст получится слишком длинным, и качество эмбеддингов упадёт. Общепринятое в индустрии решение:

  1. Переписать вопрос: модель по истории диалога переписывает текущий вопрос, вкладывая в него нужный контекст;
  2. Искать как обычно: поиск и генерация идут по переписанному вопросу.

В примере выше модель перепишет «А кто его руководитель?» в «Кто руководитель Чжан Саня?» — и поиск найдёт нужное.

В LlamaIndex есть удобные средства для многоходового диалога в RAG-приложении.

from llama_index.core import PromptTemplate
from llama_index.core.llms import ChatMessage, MessageRole
from llama_index.core.chat_engine import CondenseQuestionChatEngine
# Шаблон промпта для переписывания вопроса (переведён с китайского)
custom_prompt = PromptTemplate(
"""
Дана история диалога (между человеком и помощником) и следующий вопрос человека.
Перепиши этот вопрос в самостоятельный, включив в него весь нужный контекст из диалога.
<История диалога>
{chat_history}
<Следующий вопрос>
{question}
<Переписанный самостоятельный вопрос>
"""
)
# Имитируем историю диалога
custom_chat_history = [
ChatMessage(role=MessageRole.USER, content="Какие бывают специализации у инженеров по разработке контента?"),
ChatMessage(role=MessageRole.ASSISTANT, content="Инженер по разработке контента — комплексная техническая должность."),
]
# Создаём движок вопросов
query_engine = index.as_query_engine(
streaming=True,
llm=OpenAILike(
model="qwen-plus",
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key=os.getenv("DASHSCOPE_API_KEY"),
is_chat_model=True
))
# Создаём чат-движок с поддержкой многоходового диалога
chat_engine = CondenseQuestionChatEngine.from_defaults(
query_engine=query_engine,
condense_question_prompt=custom_prompt,
chat_history=custom_chat_history,
llm=OpenAILike(
model="qwen-plus",
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key=os.getenv("DASHSCOPE_API_KEY"),
is_chat_model=True
),
verbose=True # подробный вывод: видно переписанный вопрос
)
# Спрашиваем только «Каковы основные обязанности?», не упоминая «инженера по разработке контента»
streaming_response = chat_engine.stream_chat("Каковы основные обязанности?")
for token in streaming_response.response_gen:
print(token, end="")

Запустив этот код, вы увидите: хотя в последнем вопросе нет слов «инженер по разработке контента», модель по истории диалога переписала его в «Каковы основные обязанности инженера по разработке контента?» — нашла нужные сведения и ответила точно.

💡 Подсказки:

  • CondenseQuestionChatEngine сам ведёт историю диалога; текущую историю можно посмотреть через chat_engine.chat_history.
  • verbose=True показывает переписанный вопрос — удобно для отладки и понимания работы системы.
  • Качество переписывания прямо влияет на поиск; настраивайте шаблон переписывания под свою задачу.

В этом уроке вы изучили, как работает RAG, и своими руками построили полноценное RAG-приложение. Повторим:

  1. Как работает RAG. Полное RAG-приложение обычно состоит из двух этапов: построение индекса и поиск с генерацией. Построение индекса — это четыре шага: разбор документов, нарезка текста, векторизация и хранение индекса. Понимая принцип работы, вы сможете лучше улучшать и совершенствовать RAG-приложения.
  2. Создание RAG-приложения. С помощью высокоуровневых средств LlamaIndex вы быстро создали RAG-приложение и научились сохранять и загружать индекс.
  3. Многоходовый диалог в RAG. Вы узнали об особой трудности многоходового RAG (при поиске теряется контекст) и о стандартном решении (переписать вопрос). С CondenseQuestionChatEngine из LlamaIndex вы сделали RAG-приложение, понимающее контекст диалога.

RAG-приложение уже неплохо отвечает на вопросы вроде «каким инструментом управления проектами пользоваться в компании», но пока его возможности базовые. В следующих уроках мы разберём, как их расширить. Следующий урок — о том, как улучшить ответы RAG-приложения с помощью промптов.

Компьютер не понимает напрямую, насколько похожи фразы «я люблю есть яблоки» и «обожаю есть яблоки». Зато он умеет измерять сходство двух векторов одной размерности (обычно через косинусное сходствокосинусное сходствоEN cosine similarityКосинус угла между векторами — скалярное произведение, делённое на произведение длин. Для нормированных векторов равно скалярному произведению.). Векторизация с помощью модели-эмбеддера переводит естественный язык в понятную компьютеру числовую форму.

Обучение моделей-эмбеддеров обычно включает этап контрастивного обученияконтрастивное обучениеEN contrastive learningсближаем представления «правильных пар» и раздвигаем «неправильные»: на вход подают множество пар текстов (s1,s2)(s_1, s_2) с пометкой «связаны / не связаны», и цель обучения — чтобы у связанных пар сходство векторов росло, а у несвязанных — падало.

На этапе построения индекса, если после нарезки получилось nn фрагментов [c1,c2,c3,…,cn][c_1, c_2, c_3, \dots, c_n], модель-эмбеддер превращает каждый в вектор [v1,v2,v3,…,vn][v_1, v_2, v_3, \dots, v_n], и они сохраняются в векторную базу.

На этапе поиска вопрос пользователя qq модель превращает в вектор vqv_q и находит в базе nn самых похожих на vqv_q векторов (это число вы задаёте сами); по связи «вектор — фрагмент» получаются фрагменты текста — это и есть результат поиска.

Задачи из оригинала (одиночный выбор) с разбором. Ответ отмечайте — после ответа появится объяснение авторов курса.

Проверь себя2 вопроса
1 Как правильно искать в многоходовом диалоге RAG-приложения?
2 После запуска бот на вопрос «сколько осталось дней ежегодного отпуска» правильно находит регламент учёта рабочего времени. А на «хочу отдохнуть, сколько у меня оплачиваемого отпуска?» не находит ничего. Самая вероятная причина?

Урок можно пройти на своей машине. Нужны две замены:

# pip install llama-index llama-index-llms-openai-like llama-index-embeddings-huggingface
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.openai_like import OpenAILike
# 1) Эмбеддер — локальная модель с Hugging Face или ModelScope (многоязычная, понимает китайский и русский)
embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-m3")
# 2) LLM — ваш vLLM или Ollama с OpenAI-совместимым API (урок 8.1.1)
llm = OpenAILike(model="Qwen/Qwen3-8B", api_base="http://localhost:8000/v1",
api_key="EMPTY", is_chat_model=True)

Дальше передавайте embed_model=embed_model и llm=llm вместо DashScopeEmbedding(...) и OpenAILike(... qwen-plus ...). Документы курса лежат в репозитории (course_core/docs). С Qwen3 добавьте к запросам /no_think или выключите размышление на сервере, иначе ответы будут медленнее (см. документацию Qwen, часть 2).