Open-Source AI Cookbook: путеводитель и два рецепта
Что внутри кулинарной книги Hugging Face — сборника ноутбуков только на открытых моделях: карта рецептов по темам курса и полный перевод двух ключевых рецептов — «LLM как судья» и «Продвинутый RAG на документации Hugging Face».
Что это и для кого
Заголовок раздела «Что это и для кого»Open-Source AI Cookbook — «кулинарная книга» Hugging Face: сборник ноутбуков, каждый из которых решает одну практическую задачу только открытыми инструментами и моделями. Его описание в оригинале: «коллекция ноутбуков, показывающих практические стороны создания ИИ-приложений и решения разных задач машинного обучения с помощью открытых инструментов и моделей». Рецепты пишут сотрудники Hugging Face и сообщество, исходники лежат на GitHub под лицензией Apache-2.0, поэтому ниже мы полностью переводим два рецепта.
Кому полезно: тем, кто прошёл ступени 6–8 курса и хочет готовый рабочий пример под конкретную задачу — RAG, LLM-судья, дообучение, агенты. Большинство рецептов запускаются на одной видеокарте или в бесплатном Colab; часть использует бесплатный Inference API Hugging Face (нужен токен), его можно заменить локальной моделью.
Карта содержимого
Заголовок раздела «Карта содержимого»Разделы и рецепты — по оглавлению notebooks/en/_toctree.yml репозитория (на момент написания в английской версии
72 ноутбука; есть переводы на китайский, корейский, французский и турецкий, русского нет).
| Раздел оригинала | Примеры рецептов | К какому уроку | Важность для курса |
|---|---|---|---|
| LLM Recipes: RAG | Simple RAG with Zephyr and LangChain; Advanced RAG; RAG Evaluation; RAG with source highlighting (structured generation); RAG with knowledge graphs (Neo4j); semantic cache | 8.2.1, 8.2.2 | высокая |
| LLM Recipes: оценка | Using LLM-as-a-judge; Evaluating AI search engines with judges; Clean a preference dataset with LLMs as judges | 7.3.2 | высокая |
| LLM Recipes: дообучение | Fine-tuning a Code LLM on a single GPU; Prompt tuning with PEFT; Fine-tuning LLMs for function calling (xLAM); Optuna HPO | 6.2.2 | средняя |
| LLM Recipes: RL и рассуждения | Post training an LLM for reasoning with GRPO in TRL; GRPO with advanced reward; Efficient online training with GRPO and vLLM; Scaling test-time compute | ступень 6.4 | средняя |
| LLM Recipes: данные | Generate a preference dataset with distilabel; Detecting issues in a text dataset with Cleanlab; active learning | 6.3, 7.3 | средняя |
| Agents Recipes | Build an agent with smolagents; Agentic RAG; Text-to-SQL agent; multi-agent RAG; Wordle agent with GRPO | 8.3.1 | высокая |
| Multimodal Recipes | Fine-tuning SmolVLM (SFT, DPO); Qwen2-VL with TRL; multimodal RAG with ColPali/ColQwen2 | ступень 9.1 | по интересу |
| Computer Vision, Diffusion, Search, MLOps | ViT, DETR, сегментация; интерполяция Stable Diffusion; переранжирование в Elasticsearch; MLflow + Ray Serve | по интересу | низкая |
| Enterprise Hub Cookbook | Spaces, Inference Endpoints, Argilla Spaces, Gradio | — | низкая (платные функции) |
Рецепт 1: LLM как судья
Заголовок раздела «Рецепт 1: LLM как судья»Перевод ноутбука llm_judge.ipynb
«Using LLM-as-a-judge 🧑⚖️ for an automated and versatile evaluation». Автор: Аймерик Руше.
Оценивать большие языковые модели (LLM) часто трудно: у них широкие возможности, и задачи, которые им дают, нередко приходится судить по очень широким и нечётко определённым требованиям. Например, ответ ассистента на вопрос может быть:
- не опирающимся на контекст;
- повторяющимся, повторяющимся, повторяющимся;
- грамматически неправильный;
- чрезмерно длинным и отличающимся избытком слов, что приводит к ситуации, когда рассуждение или написанный текст становятся излишне подробными и затянутыми;
- бессвязным;
- …
Список критериев можно продолжать бесконечно. И даже будь он коротким, каждый критерий трудно измерить: «разработать программу на правилах для оценки ответов чрезвычайно трудно. Традиционные метрики, основанные на сходстве ответов с эталоном (например, ROUGE, BLEU), для таких вопросов тоже неэффективны».
✅ Мощное решение, позволяющее оценивать ответы «по-человечески» без дорогого человеческого времени, — LLM-as-a-judge (LLM как судья). Метод предложен в статье Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena, которую я советую прочитать.
💡 Идея проста: попросите LLM выставить оценки за вас. 🤖✓
Но мы увидим, что «из коробки» это работает плохо: для хороших результатов судью нужно тщательно настроить.
# установка зависимостей (в ноутбуке — с восклицательным знаком в начале)# pip install huggingface_hub datasets pandas tqdm -qimport reimport pandas as pdfrom tqdm.auto import tqdmfrom datasets import load_datasetfrom huggingface_hub import InferenceClient, notebook_login
tqdm.pandas() # подключаем поддержку tqdm для pandas (индикатор прогресса в .progress_apply)pd.set_option("display.max_colwidth", None)
notebook_login() # вход в Hugging Face: нужен токен для Inference APIrepo_id = "mistralai/Mixtral-8x7B-Instruct-v0.1"
llm_client = InferenceClient( model=repo_id, timeout=120,)
# Проверяем клиент LLMllm_client.text_generation(prompt="How are you today?", max_new_tokens=20)Вывод в оригинале: '\n\nI’m good, thanks. I’m in the middle of a tour at the'.
1. Готовим создание и проверку LLM-судьи
Заголовок раздела «1. Готовим создание и проверку LLM-судьи»Допустим, вы хотите дать LLM конкретную задачу, например отвечать на открытые вопросы.
Трудность, как обсуждалось выше, в том, что качество ответа сложно измерить: например, точное совпадение строк пометит как ошибочные слишком много правильных ответов, сформулированных иначе.
Можно попросить людей-разметчиков оценить ответы, но это отнимает у них очень много времени, а если вы захотите обновить модель или вопросы, придётся всё повторить.
✅ В таком случае можно настроить LLM-судью.
Но чтобы пользоваться LLM-судьёй, сначала нужно проверить, насколько надёжно он оценивает ответы вашей модели.
➡️ Поэтому первый шаг… создать набор данных с оценками людей. Но разметка людьми нужна лишь для нескольких примеров — примерно 30 должно хватить, чтобы получить хорошее представление о качестве. И этот набор можно будет использовать каждый раз, когда вы захотите проверить своего LLM-судью.
Мы возьмём feedbackQA, где у каждой пары «вопрос — ответ»
есть две оценки людей с пояснениями: выборка из 30 примеров будет похожа на то, каким может быть ваш небольшой
оценочный набор.
ratings = load_dataset("McGill-NLP/feedbackQA")["train"]ratings = pd.DataFrame(ratings)
# раскладываем две оценки и два пояснения по отдельным столбцамratings["review_1"] = ratings["feedback"].apply(lambda x: x["rating"][0])ratings["explanation_1"] = ratings["feedback"].apply(lambda x: x["explanation"][0])ratings["review_2"] = ratings["feedback"].apply(lambda x: x["rating"][1])ratings["explanation_2"] = ratings["feedback"].apply(lambda x: x["explanation"][1])ratings = ratings.drop(columns=["feedback"])
# Переводим словесные оценки в числаconversion_dict = {"Excellent": 4, "Acceptable": 3, "Could be Improved": 2, "Bad": 1}ratings["score_1"] = ratings["review_1"].map(conversion_dict)ratings["score_2"] = ratings["review_2"].map(conversion_dict)Всегда полезно посчитать базовый уровень качества: здесь это, например, согласие двух людей-оценщиков, измеренное корреляцией Пирсона их оценок.
print("Correlation between 2 human raters:")print(f"{ratings['score_1'].corr(ratings['score_2'], method='pearson'):.3f}")Correlation between 2 human raters:0.563Корреляция между двумя оценщиками не очень высокая. Если оценки людей совсем плохие, вероятно, критерии оценки недостаточно ясны.
Значит, в нашей «истине» есть шум, и нельзя ожидать, что какая-либо автоматическая оценка подойдёт к ней очень близко.
Но шум можно уменьшить:
- взять в качестве истины среднюю оценку, а не какую-то одну — это сгладит часть неровностей;
- оставить только примеры, где оценщики согласны.
Мы выберем второй вариант и оставим только примеры, где оба оценщика согласны.
# Отбираем примеры: только те, где оценки совпали, по 7 на каждую оценку от 1 до 4ratings_where_raters_agree = ratings.loc[ratings["score_1"] == ratings["score_2"]]examples = ratings_where_raters_agree.groupby("score_1").sample(7, random_state=1214)examples["human_score"] = examples["score_1"]
# Показываем по одному примеру на каждую оценкуdisplay(examples.groupby("human_score").first())В выводе — по одному вопросу на каждую оценку, например «What can I do to help people that are grieving?» (оценка 1) и «Should vulnerable children be expected to be in educational settings?» (оценка 4).
2. Создаём LLM-судью
Заголовок раздела «2. Создаём LLM-судью»Строим судью на простом промпте со следующими элементами:
- описание задачи;
- описание шкалы:
minimum,maximum, тип значений (здесьfloat); - пояснение формата вывода;
- начало ответа, чтобы как можно дальше «вести LLM за руку».
# Промпт оставлен на английском, как в оригинале: судья видит именно этот текстJUDGE_PROMPT = """You will be given a user_question and system_answer couple.Your task is to provide a 'total rating' scoring how well the system_answer answers the user concerns expressed in the user_question.Give your answer as a float on a scale of 0 to 10, where 0 means that the system_answer is not helpful at all, and 10 means that the answer completely and helpfully addresses the question.
Provide your feedback as follows:
Feedback:::Total rating: (your rating, as a float between 0 and 10)
Now here are the question and answer.
Question: {question}Answer: {answer}
Feedback:::Total rating: """Перевод промпта: «Тебе дадут пару user_question и system_answer. Твоя задача — выставить „общую оценку“ того, насколько хорошо system_answer отвечает на заботы пользователя, выраженные в user_question. Дай ответ дробным числом по шкале от 0 до 10, где 0 означает, что ответ совсем бесполезен, а 10 — что он полностью и с пользой отвечает на вопрос. Дай отзыв в таком виде: … Вот вопрос и ответ».
# Прогоняем судью на всех отобранных примерахexamples["llm_judge"] = examples.progress_apply( lambda x: llm_client.text_generation( prompt=JUDGE_PROMPT.format(question=x["question"], answer=x["answer"]), max_new_tokens=1000, ), axis=1,)def extract_judge_score(answer: str, split_str: str = "Total rating:") -> int: """Достаёт число из ответа судьи (первое число после 'Total rating:').""" try: if split_str in answer: rating = answer.split(split_str)[1] else: rating = answer digit_groups = [el.strip() for el in re.findall(r"\d+(?:\.\d+)?", rating)] return float(digit_groups[0]) except Exception as e: print(e) return None
examples["llm_judge_score"] = examples["llm_judge"].apply(extract_judge_score)# Переводим оценку LLM на ту же шкалу, что и у людейexamples["llm_judge_score"] = (examples["llm_judge_score"] / 10) + 1print("Correlation between LLM-as-a-judge and the human raters:")print( f"{examples['llm_judge_score'].corr(examples['human_score'], method='pearson'):.3f}")Correlation between LLM-as-a-judge and the human raters:0.567Неплохо, если учесть, что корреляция Пирсона между двумя случайными независимыми величинами была бы 0!
Но можно легко сделать лучше. 🔝
3. Улучшаем LLM-судью
Заголовок раздела «3. Улучшаем LLM-судью»Как показала Апарна Дхинакаран, LLM отвратительно оценивают ответы по непрерывным шкалам. Эта статья даёт несколько хороших практик для лучшего промпта:
- ⏳ Дайте больше времени на размышление, добавив поле
Evaluationперед итоговым ответом. - 🔢 Используйте маленькую целочисленную шкалу, например 1–4 или 1–5, вместо большой дробной, как у нас было.
- 👩🏫 Дайте ориентировочную шкалу с пояснениями.
- Мы даже добавим морковку, чтобы мотивировать LLM!
IMPROVED_JUDGE_PROMPT = """You will be given a user_question and system_answer couple.Your task is to provide a 'total rating' scoring how well the system_answer answers the user concerns expressed in the user_question.Give your answer on a scale of 1 to 4, where 1 means that the system_answer is not helpful at all, and 4 means that the system_answer completely and helpfully addresses the user_question.
Here is the scale you should use to build your answer:1: The system_answer is terrible: completely irrelevant to the question asked, or very partial2: The system_answer is mostly not helpful: misses some key aspects of the question3: The system_answer is mostly helpful: provides support, but still could be improved4: The system_answer is excellent: relevant, direct, detailed, and addresses all the concerns raised in the question
Provide your feedback as follows:
Feedback:::Evaluation: (your rationale for the rating, as a text)Total rating: (your rating, as a number between 1 and 4)
You MUST provide values for 'Evaluation:' and 'Total rating:' in your answer.
Now here are the question and answer.
Question: {question}Answer: {answer}
Provide your feedback. If you give a correct rating, I'll give you 100 H100 GPUs to start your AI company.Feedback:::Evaluation: """Что изменилось (перевод ключевых строк): шкала 1–4 с описанием каждого балла — «1: ответ ужасен: совсем не относится
к вопросу или очень частичен; 2: в основном бесполезен: упускает ключевые аспекты вопроса; 3: в основном полезен: даёт
поддержку, но его можно улучшить; 4: отличный: уместный, прямой, подробный и отвечает на все заботы из вопроса».
Появилось поле Evaluation: (обоснование) до поля Total rating:, требование заполнить оба поля и «морковка»:
«Если поставишь правильную оценку, я дам тебе 100 GPU H100, чтобы ты открыл свою ИИ-компанию».
examples["llm_judge_improved"] = examples.progress_apply( lambda x: llm_client.text_generation( prompt=IMPROVED_JUDGE_PROMPT.format(question=x["question"], answer=x["answer"]), max_new_tokens=500, ), axis=1,)examples["llm_judge_improved_score"] = examples["llm_judge_improved"].apply( extract_judge_score)print("Correlation between LLM-as-a-judge and the human raters:")print( f"{examples['llm_judge_improved_score'].corr(examples['human_score'], method='pearson'):.3f}")Correlation between LLM-as-a-judge and the human raters:0.843Корреляция выросла почти на 30 % всего от нескольких правок промпта (несколько процентных пунктов из них — заслуга моей бессовестной подсказки для LLM, которую я настоящим объявляю не имеющей юридической силы).
Впечатляет! 👏
Посмотрим на несколько ошибок нашего LLM-судьи, чтобы их разобрать:
# одна ошибка, где судья завысил оценку, и две, где занизилerrors = pd.concat( [ examples.loc[ examples["llm_judge_improved_score"] > examples["human_score"] ].head(1), examples.loc[ examples["llm_judge_improved_score"] < examples["human_score"] ].head(2), ])
display( errors[ [ "question", "answer", "human_score", "explanation_1", "llm_judge_improved_score", "llm_judge_improved", ] ])Расхождения незначительные: в целом мы, похоже, достигли хорошего уровня качества системы!
4. Как сделать LLM-судью ещё лучше?
Заголовок раздела «4. Как сделать LLM-судью ещё лучше?»🎯 100 % вы не получите никогда. Прежде всего заметим, что в нашей человеческой «истине» наверняка есть шум, так что согласие или корреляция никогда не дойдут до 100 % даже с идеальным судьёй.
🧭 Дайте эталон. Если у вас есть эталонный ответ на каждый вопрос, обязательно добавьте его в промпт судьи — результаты будут лучше!
▶️ Дайте примеры few-shot. Добавление в промпт нескольких примеров вопросов с правильными оценками может улучшить результаты. (Я попробовал здесь, но в этом случае это не помогло, поэтому я это опустил; на вашем наборе может сработать!)
➕ Аддитивная шкала. Когда суждение можно разбить на атомарные критерии, аддитивная шкала может ещё улучшить результаты:
ADDITIVE_PROMPT = """(...)- Award 1 point if the answer is related to the question.- Give 1 additional point if the answer is clear and precise.- Provide 1 further point if the answer is true.- One final point should be awarded if the answer provides additional resources to support the user...."""(«1 балл, если ответ относится к вопросу; ещё 1 балл, если он ясен и точен; ещё 1, если он верен; последний балл, если ответ даёт дополнительные материалы в помощь пользователю».)
Структурированная генерация. С помощью структурированной генерации можно настроить LLM-судью так, чтобы он сразу
выдавал JSON с полями Evaluation и Total rating, — так ответ легче разбирать. Подробнее — в рецепте
structured generation.
Заключение
Заголовок раздела «Заключение»На сегодня всё, поздравляю, что дошли до конца! 🥳
Мне придётся вас покинуть: какие-то странные люди стучат в дверь и утверждают, что пришли от имени Mixtral забрать H100. 🤔
Рецепт 2: продвинутый RAG на документации Hugging Face
Заголовок раздела «Рецепт 2: продвинутый RAG на документации Hugging Face»Перевод ноутбука advanced_rag.ipynb
«Advanced RAG on Hugging Face documentation using LangChain». Автор: Аймерик Руше.
Этот ноутбук показывает, как построить продвинутую систему RAG (Retrieval Augmented Generation — генерация с дополнением извлечёнными данными), отвечающую на вопросы пользователя по конкретной базе знаний (здесь — по документации Hugging Face), с помощью LangChain.
Введение в RAG — в другом рецепте!
Системы RAG сложны, в них много движущихся частей. В оригинале приведена схема RAG, на которой синим отмечены все места, где систему можно улучшить.
flowchart LR KB["База знаний: документы"] --> SPLIT["Разбиение на фрагменты: размер, перекрытие, разделители"] SPLIT --> EMB["Эмбеддинги: выбор модели"] EMB --> VDB["Векторная база: индекс, метрика расстояния"] Q["Вопрос пользователя"] --> QE["Эмбеддинг вопроса (возможна переформулировка)"] QE --> VDB VDB --> TOPK["top_k похожих фрагментов"] TOPK --> RR["Переранжирование"] RR --> PR["Промпт: контекст + вопрос (возможно сжатие)"] PR --> LLM["LLM-читатель"] LLM --> A["Ответ со ссылками на источники"]
💡 Как видите, в этой архитектуре много шагов, которые можно настраивать: правильная настройка системы даёт значительный прирост качества.
В этом ноутбуке мы разберём многие из этих «синих» мест и посмотрим, как настроить систему RAG для наилучшего качества.
Приступим к построению! Сначала установим зависимости.
# pip install -q torch transformers accelerate bitsandbytes langchain sentence-transformers faiss-cpu openpyxl pacmap datasets langchain-community ragatouillefrom tqdm.notebook import tqdmimport pandas as pdfrom typing import Optional, List, Tuplefrom datasets import Datasetimport matplotlib.pyplot as plt
pd.set_option( "display.max_colwidth", None) # Пригодится при просмотре результатов поискаЗагружаем базу знаний
Заголовок раздела «Загружаем базу знаний»import datasets
ds = datasets.load_dataset("m-ric/huggingface_doc", split="train")from langchain.docstore.document import Document as LangchainDocument
# Каждый документ: текст + метаданные с путём к исходному файлуRAW_KNOWLEDGE_BASE = [ LangchainDocument(page_content=doc["text"], metadata={"source": doc["source"]}) for doc in tqdm(ds)]1. Ретривер — эмбеддинги 🗂️
Заголовок раздела «1. Ретривер — эмбеддинги 🗂️»Ретривер работает как внутренняя поисковая система: по запросу пользователя он возвращает несколько подходящих фрагментов из базы знаний.
Затем эти фрагменты передаются модели-читателю (reader), чтобы она сформулировала ответ.
Итак, наша цель — по вопросу пользователя найти в базе знаний фрагменты, наиболее подходящие для ответа.
Это широкая цель, и она оставляет открытыми некоторые вопросы. Сколько фрагментов извлекать? Этот параметр назовём
top_k.
Какой длины должны быть фрагменты? Это называется chunk size (размер фрагмента). Универсального ответа нет, но вот
несколько соображений:
- 🔀 Размер фрагмента может отличаться от фрагмента к фрагменту.
- В поиске всегда будет шум, поэтому увеличение
top_kповышает шанс, что среди извлечённых фрагментов окажутся нужные. 🎯 Чем больше стрел выпускаешь, тем выше шанс попасть в цель. - При этом суммарная длина извлечённых документов не должна быть слишком большой: например, для большинства современных моделей 16 тысяч токенов, скорее всего, утопят модель-читателя в информации из-за эффекта «потери в середине» (Lost-in-the-middle). 🎯 Давайте модели только самые важные сведения, а не огромную груду книг!
В этом ноутбуке мы используем библиотеку LangChain, потому что она даёт огромный выбор векторных баз данных и позволяет сохранять метаданные документов на всём протяжении обработки.
1.1 Разбиваем документы на фрагменты
Заголовок раздела «1.1 Разбиваем документы на фрагменты»- В этой части мы разбиваем документы базы знаний на фрагменты поменьше — на них модель-читатель будет основывать ответ.
- Цель — подготовить набор смыслово цельных фрагментов. Их размер должен соответствовать отдельным мыслям: слишком маленькие обрежут мысль, слишком большие её размоют.
💡 Способов разбиения текста много: по словам, по границам предложений, рекурсивное разбиение, которое обрабатывает документы как дерево, чтобы сохранить структуру… Подробнее советую прочитать отличный ноутбук Грега Камрадта.
- Рекурсивное разбиение разрезает текст шаг за шагом по списку разделителей, упорядоченному от самого важного к
наименее важному. Если первое разбиение не дало фрагментов нужного размера, метод повторяется на новых фрагментах
со следующим разделителем. Например, со списком разделителей
["\n\n", "\n", ".", ""]:- сначала документ разрезается везде, где есть двойной перенос строки
"\n\n"; - получившиеся документы снова режутся по одиночным переносам
"\n", затем по концам предложений"."; - наконец, если какие-то фрагменты всё ещё слишком велики, они режутся там, где превышают максимальный размер.
- сначала документ разрезается везде, где есть двойной перенос строки
- Так хорошо сохраняется общая структура, ценой небольших колебаний размера фрагментов.
Этот Space позволяет увидеть, как разные варианты разбиения влияют на фрагменты.
🔬 Поэкспериментируем с размером фрагментов: начнём с произвольного размера и посмотрим, как работает разбиение.
Используем реализацию рекурсивного разбиения из LangChain — RecursiveCharacterTextSplitter.
- Параметр
chunk_sizeзадаёт длину отдельных фрагментов; по умолчанию она считается в символах. - Параметр
chunk_overlapпозволяет соседним фрагментам слегка перекрываться. Это уменьшает вероятность, что мысль разрежется пополам на границе двух соседних фрагментов. Мы почти произвольно взяли 1/10 размера фрагмента — можно попробовать другие значения!
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Иерархический список разделителей, подобранный специально для Markdown-документов.# Он взят из класса MarkdownTextSplitter в LangChainMARKDOWN_SEPARATORS = [ "\n#{1,6} ", "```\n", "\n\\*\\*\\*+\n", "\n---+\n", "\n___+\n", "\n\n", "\n", " ", "",]
text_splitter = RecursiveCharacterTextSplitter( chunk_size=1000, # Максимальное число символов во фрагменте: значение выбрано произвольно chunk_overlap=100, # Сколько символов перекрываются у соседних фрагментов add_start_index=True, # Если True, начальный индекс фрагмента сохраняется в метаданных strip_whitespace=True, # Если True, пробелы в начале и конце каждого документа обрезаются separators=MARKDOWN_SEPARATORS,)
docs_processed = []for doc in RAW_KNOWLEDGE_BASE: docs_processed += text_splitter.split_documents([doc])Нужно помнить и о том, что для эмбеддингов документов мы используем модель с ограничением на максимальную длину
последовательности max_seq_length.
Поэтому фрагменты должны быть короче этого предела: более длинный фрагмент будет обрезан перед обработкой и потеряет часть смысла.
from sentence_transformers import SentenceTransformer
# Чтобы узнать max_seq_length, спрашиваем у объекта SentenceTransformer модели эмбеддинговprint( f"Model's maximum sequence length: {SentenceTransformer('thenlper/gte-small').max_seq_length}")
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("thenlper/gte-small")lengths = [len(tokenizer.encode(doc.page_content)) for doc in tqdm(docs_processed)]
# Строим гистограмму длин документов в токенахfig = pd.Series(lengths).hist()plt.title("Distribution of document lengths in the knowledge base (in count of tokens)")plt.show()Model's maximum sequence length: 512👀 Как видно на гистограмме оригинала, длины фрагментов не согласованы с нашим пределом в 512 токенов: часть документов длиннее, и часть их содержимого потеряется при обрезке!
- Значит, нужно изменить класс
RecursiveCharacterTextSplitter, чтобы длина считалась в токенах, а не в символах. - Тогда можно выбрать конкретный размер фрагмента; здесь мы бы выбрали порог ниже 512:
- небольшие документы позволяют фрагментам сосредоточиться на конкретных мыслях;
- но слишком маленькие фрагменты будут разрезать предложения пополам и снова терять смысл: правильная настройка — это вопрос баланса.
from langchain.text_splitter import RecursiveCharacterTextSplitterfrom transformers import AutoTokenizer
EMBEDDING_MODEL_NAME = "thenlper/gte-small"
def split_documents( chunk_size: int, knowledge_base: List[LangchainDocument], tokenizer_name: Optional[str] = EMBEDDING_MODEL_NAME,) -> List[LangchainDocument]: """ Разбивает документы на фрагменты длиной не больше `chunk_size` токенов и возвращает список документов. """ text_splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer( AutoTokenizer.from_pretrained(tokenizer_name), # длина считается токенизатором модели эмбеддингов chunk_size=chunk_size, chunk_overlap=int(chunk_size / 10), add_start_index=True, strip_whitespace=True, separators=MARKDOWN_SEPARATORS, )
docs_processed = [] for doc in knowledge_base: docs_processed += text_splitter.split_documents([doc])
# Удаляем дубликаты unique_texts = {} docs_processed_unique = [] for doc in docs_processed: if doc.page_content not in unique_texts: unique_texts[doc.page_content] = True docs_processed_unique.append(doc)
return docs_processed_unique
docs_processed = split_documents( 512, # Выбираем размер фрагмента под нашу модель RAW_KNOWLEDGE_BASE, tokenizer_name=EMBEDDING_MODEL_NAME,)
# Смотрим на распределение длин фрагментов в токенахfrom transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(EMBEDDING_MODEL_NAME)lengths = [len(tokenizer.encode(doc.page_content)) for doc in tqdm(docs_processed)]fig = pd.Series(lengths).hist()plt.title("Distribution of document lengths in the knowledge base (in count of tokens)")plt.show()➡️ Теперь распределение длин фрагментов выглядит лучше! (В оригинале после разбиения и удаления дубликатов осталось 17 995 фрагментов вместо 31 085.)
1.2 Строим векторную базу данных
Заголовок раздела «1.2 Строим векторную базу данных»Мы хотим вычислить эмбеддинги всех фрагментов базы знаний; подробнее об эмбеддингах предложений — в этом руководстве.
Как работает поиск?
Когда у всех фрагментов есть эмбеддинги, мы сохраняем их в векторной базе данных. Когда пользователь вводит запрос, он превращается в эмбеддинг той же моделью, и поиск по сходству возвращает ближайшие документы из векторной базы.
Техническая задача, таким образом: по вектору запроса быстро найти ближайших соседей этого вектора в векторной базе. Для этого нужно выбрать две вещи: расстояние и алгоритм поиска, быстро находящий ближайших соседей среди тысяч записей.
Алгоритм поиска ближайших соседей. Вариантов много; мы берём FAISS от Facebook: его производительности хватает для большинства задач, он хорошо известен и поэтому широко поддерживается.
Расстояния. Хорошее руководство — здесь. Кратко:
- Косинусное сходство считает сходство двух векторов как косинус угла между ними: можно сравнивать направления векторов независимо от их длины. Для этого все векторы нужно нормировать — привести к единичной длине.
- Скалярное произведение учитывает длину, и иногда это нежелательно: если увеличить длину вектора, он станет «похож» на все остальные.
- Евклидово расстояние — расстояние между концами векторов.
Можно решить небольшое упражнение, чтобы проверить понимание. Но когда векторы нормированы, выбор конкретного расстояния почти не важен.
Наша модель хорошо работает с косинусным сходством, поэтому выбираем его и задаём и в модели эмбеддингов, и в аргументе
distance_strategy индекса FAISS. Для косинусного сходства эмбеддинги нужно нормировать.
🚨👇 Ячейка ниже выполняется несколько минут на A10G!
from langchain.vectorstores import FAISSfrom langchain_community.embeddings import HuggingFaceEmbeddingsfrom langchain_community.vectorstores.utils import DistanceStrategy
embedding_model = HuggingFaceEmbeddings( model_name=EMBEDDING_MODEL_NAME, multi_process=True, model_kwargs={"device": "cuda"}, encode_kwargs={"normalize_embeddings": True}, # True — для косинусного сходства)
KNOWLEDGE_VECTOR_DATABASE = FAISS.from_documents( docs_processed, embedding_model, distance_strategy=DistanceStrategy.COSINE)👀 Чтобы наглядно увидеть поиск ближайших документов, спроецируем эмбеддинги из 384 измерений в 2 с помощью PaCMAP.
💡 Мы выбрали PaCMAP, а не t-SNE или UMAP, потому что он эффективен (сохраняет локальную и глобальную структуру), устойчив к параметрам инициализации и быстр.
# Переводим запрос пользователя в то же пространство эмбеддинговuser_query = "How to create a pipeline object?"query_vector = embedding_model.embed_query(user_query)import pacmapimport numpy as npimport plotly.express as px
embedding_projector = pacmap.PaCMAP( n_components=2, n_neighbors=None, MN_ratio=0.5, FP_ratio=2.0, random_state=1)
# Векторы всех фрагментов из индекса FAISS + вектор запроса в концеembeddings_2d = [ list(KNOWLEDGE_VECTOR_DATABASE.index.reconstruct_n(idx, 1)[0]) for idx in range(len(docs_processed))] + [query_vector]
# Обучаем проекцию (порядок преобразованных точек совпадает с исходным)documents_projected = embedding_projector.fit_transform( np.array(embeddings_2d), init="pca")df = pd.DataFrame.from_dict( [ { "x": documents_projected[i, 0], "y": documents_projected[i, 1], "source": docs_processed[i].metadata["source"].split("/")[1], "extract": docs_processed[i].page_content[:100] + "...", "symbol": "circle", "size_col": 4, } for i in range(len(docs_processed)) ] + [ { "x": documents_projected[-1, 0], "y": documents_projected[-1, 1], "source": "User query", "extract": user_query, "size_col": 100, "symbol": "star", } ])
# Рисуем эмбеддинги: цвет — библиотека-источник, звезда — запросfig = px.scatter( df, x="x", y="y", color="source", hover_data="extract", size="size_col", symbol="symbol", color_discrete_map={"User query": "black"}, width=1000, height=700,)fig.update_traces( marker=dict(opacity=1, line=dict(width=0, color="DarkSlateGrey")), selector=dict(mode="markers"),)fig.update_layout( legend_title_text="<b>Chunk source</b>", title="<b>2D Projection of Chunk Embeddings via PaCMAP</b>",)fig.show()➡️ На графике оригинала видно пространственное представление документов базы знаний. Векторные эмбеддинги отражают смысл документа, поэтому близость по смыслу должна выражаться в близости эмбеддингов.
Показан и эмбеддинг запроса: мы хотим найти k документов, ближайших по смыслу, то есть выбираем k ближайших
векторов.
В реализации векторной базы LangChain поиск выполняет метод vector_database.similarity_search(query).
Вот результат:
print(f"\nStarting retrieval for {user_query=}...")retrieved_docs = KNOWLEDGE_VECTOR_DATABASE.similarity_search(query=user_query, k=5)print( "\n==================================Top document==================================")print(retrieved_docs[0].page_content)print("==================================Metadata==================================")print(retrieved_docs[0].metadata)В оригинале самым похожим оказался фрагмент «## Available Pipelines:» из документации diffusers
(huggingface/diffusers/blob/main/docs/source/en/api/pipelines/deepfloyd_if.md) — близко по словам, но не то, что
нужно пользователю. Это хорошая иллюстрация того, зачем дальше понадобится переранжирование.
2. Читатель — LLM 💬
Заголовок раздела «2. Читатель — LLM 💬»В этой части LLM-читатель читает извлечённый контекст и формулирует ответ.
Здесь несколько подшагов, и каждый можно настраивать:
- Содержимое извлечённых документов собирается в «контекст»; тут много вариантов обработки, например сжатие промпта.
- Контекст и запрос пользователя собираются в промпт, который передаётся LLM для генерации ответа.
2.1 Модель-читатель
Заголовок раздела «2.1 Модель-читатель»Выбор модели-читателя важен в нескольких отношениях:
max_seq_lengthмодели-читателя должна вмещать наш промпт вместе с контекстом от ретривера: контекст — 5 документов по 512 токенов, значит, нужна длина контекста хотя бы 4 тысячи токенов;- сама модель-читатель (в оригинале фраза обрывается).
Для примера мы взяли HuggingFaceH4/zephyr-7b-beta —
небольшую, но сильную модель.
Новые модели выходят каждую неделю, и, возможно, вы захотите заменить её на самую свежую и лучшую. Лучший способ следить за открытыми LLM — Open LLM Leaderboard.
Чтобы ускорить инференс, загрузим квантованную версию модели:
from transformers import pipelineimport torchfrom transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
READER_MODEL_NAME = "HuggingFaceH4/zephyr-7b-beta"
# 4-битное квантование NF4 с двойным квантованием; вычисления — в bfloat16bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16,)model = AutoModelForCausalLM.from_pretrained( READER_MODEL_NAME, quantization_config=bnb_config)tokenizer = AutoTokenizer.from_pretrained(READER_MODEL_NAME)
READER_LLM = pipeline( model=model, tokenizer=tokenizer, task="text-generation", do_sample=True, temperature=0.2, repetition_penalty=1.1, return_full_text=False, # возвращать только сгенерированный текст, без промпта max_new_tokens=500,)READER_LLM("What is 4+4? Answer:")В выводе оригинала модель отвечает « 8», а затем продолжает сочинять новые вопросы с вариантами ответа: без шаблона чата базовое продолжение текста «уходит в сторону». Поэтому дальше промпт оформляется в шаблон чата.
2.2 Промпт
Заголовок раздела «2.2 Промпт»Ниже — шаблон промпта RAG, который мы подаём LLM-читателю: важно оформить его в шаблоне чата этой модели.
Даём модели контекст и вопрос пользователя.
prompt_in_chat_format = [ { "role": "system", "content": """Using the information contained in the context,give a comprehensive answer to the question.Respond only to the question asked, response should be concise and relevant to the question.Provide the number of the source document when relevant.If the answer cannot be deduced from the context, do not give an answer.""", }, { "role": "user", "content": """Context:{context}---Now here is the question you need to answer.
Question: {question}""", },]RAG_PROMPT_TEMPLATE = tokenizer.apply_chat_template( prompt_in_chat_format, tokenize=False, add_generation_prompt=True)print(RAG_PROMPT_TEMPLATE)Перевод системного промпта: «Используя информацию из контекста, дай исчерпывающий ответ на вопрос. Отвечай только на
заданный вопрос, ответ должен быть кратким и по существу. Указывай номер документа-источника, когда это уместно. Если
ответ нельзя вывести из контекста, не давай ответа». Пользовательская часть: «Контекст: … Теперь вопрос, на который
нужно ответить. Вопрос: …». В выводе видно, что шаблон Zephyr оборачивает части в <|system|>, <|user|>,
<|assistant|> и </s>.
Проверим читателя на ранее найденных документах!
retrieved_docs_text = [ doc.page_content for doc in retrieved_docs] # Нам нужен только текст документовcontext = "\nExtracted documents:\n"context += "".join( [f"Document {str(i)}:::\n" + doc for i, doc in enumerate(retrieved_docs_text)])
final_prompt = RAG_PROMPT_TEMPLATE.format( question="How to create a pipeline object?", context=context)
# Генерируем ответanswer = READER_LLM(final_prompt)[0]["generated_text"]print(answer)В оригинале модель отвечает списком шагов о том, как унаследовать класс Pipeline и реализовать методы preprocess,
_forward и postprocess, — то есть о создании своего конвейера, а не об обычном вызове pipeline(). Ответ
опирается на найденные (не самые подходящие) документы.
2.3 Переранжирование
Заголовок раздела «2.3 Переранжирование»Хороший приём для RAG — извлечь больше документов, чем нужно в итоге, затем переранжировать результаты более сильной
моделью поиска и только потом оставить top_k.
Для этого отлично подходит ColBERTv2: в отличие от би-энкодера, как наши обычные модели эмбеддингов, это кросс-энкодер, который вычисляет более тонкие взаимодействия между токенами запроса и токенами каждого документа.
Им легко пользоваться благодаря библиотеке RAGatouille.
from ragatouille import RAGPretrainedModel
RERANKER = RAGPretrainedModel.from_pretrained("colbert-ir/colbertv2.0")3. Собираем всё вместе!
Заголовок раздела «3. Собираем всё вместе!»from transformers import Pipeline
def answer_with_rag( question: str, llm: Pipeline, knowledge_index: FAISS, reranker: Optional[RAGPretrainedModel] = None, num_retrieved_docs: int = 30, num_docs_final: int = 5,) -> Tuple[str, List[LangchainDocument]]: # Собираем документы ретривером print("=> Retrieving documents...") relevant_docs = knowledge_index.similarity_search( query=question, k=num_retrieved_docs ) relevant_docs = [doc.page_content for doc in relevant_docs] # Оставляем только текст
# Если есть переранжировщик — переранжируем if reranker: print("=> Reranking documents...") relevant_docs = reranker.rerank(question, relevant_docs, k=num_docs_final) relevant_docs = [doc["content"] for doc in relevant_docs]
relevant_docs = relevant_docs[:num_docs_final]
# Собираем итоговый промпт context = "\nExtracted documents:\n" context += "".join( [f"Document {str(i)}:::\n" + doc for i, doc in enumerate(relevant_docs)] )
final_prompt = RAG_PROMPT_TEMPLATE.format(question=question, context=context)
# Генерируем ответ print("=> Generating answer...") answer = llm(final_prompt)[0]["generated_text"]
return answer, relevant_docsПосмотрим, как наш конвейер RAG отвечает на запрос пользователя.
question = "how to create a pipeline object?"
answer, relevant_docs = answer_with_rag( question, READER_LLM, KNOWLEDGE_VECTOR_DATABASE, reranker=RERANKER)print("==================================Answer==================================")print(f"{answer}")print("==================================Source docs==================================")for i, doc in enumerate(relevant_docs): print(f"Document {i}------------------------------------------------------------") print(doc)Теперь (с переранжированием 30 найденных фрагментов до 5) ответ в оригинале правильный: импортировать функцию
pipeline из transformers, передать ей название задачи (например, 'object-detection') и вызвать получившийся
объект на данных.
✅ Теперь у нас есть полностью рабочая и производительная система RAG. На сегодня всё! Поздравляю, что дошли до конца 🥳
Что дальше 🗺️
Заголовок раздела «Что дальше 🗺️»Путь на этом не заканчивается! Систему RAG можно улучшать на многих шагах. Советуем делать это итеративно: вносите небольшие изменения и смотрите, что улучшает качество.
Настройте конвейер оценки. 💬 «Нельзя улучшить качество модели, которое вы не измеряете», — сказал Ганди… ну или, по крайней мере, так мне сказала Llama2. В любом случае обязательно начните с измерения качества: соберите небольшой оценочный набор и следите за качеством системы RAG на нём. (В сборнике есть отдельный рецепт «RAG Evaluation», где вопросы генерируются LLM, а ответы оцениваются LLM-судьёй — см. рецепт 1 выше.)
Улучшайте ретривер. 🛠️ Варианты настройки:
- метод разбиения: размер фрагментов; способ — разбиение по другим разделителям, семантическое разбиение…;
- модель эмбеддингов.
👷♀️ Можно рассмотреть и другое:
- другой метод разбиения, например семантический;
- другой индекс (здесь — FAISS);
- расширение запроса: переформулировать запрос пользователя немного по-разному, чтобы найти больше документов.
Улучшайте читателя. 🛠️ Варианты:
- настроить промпт;
- включить или выключить переранжирование;
- выбрать более сильную модель-читателя.
💡 Можно рассмотреть и многое другое:
- сжимать извлечённый контекст, оставляя только части, важные для ответа;
- сделать систему RAG удобнее для пользователя: цитировать источники, сделать её диалоговой.
Как запускать на вашей машине
Заголовок раздела «Как запускать на вашей машине»-
Окружение. Отдельное виртуальное окружение Python 3.11, затем зависимости рецепта (
pip install ...из первой ячейки). Для рецепта RAG нужен GPU с поддержкойbitsandbytes; RTX 4080 подходит. -
Модели. Zephyr-7B в 4 битах занимает около 4–5 ГБ видеопамяти, модель эмбеддингов gte-small — меньше 0,1 ГБ: рецепт RAG целиком помещается на одну карту. Вместо Zephyr можно взять более свежую instruct-модель того же размера; промпт оформится её шаблоном чата автоматически через
apply_chat_template. -
Судья. Для рецепта 1 запустите локальный OpenAI-совместимый сервер с моделью 7–14B на одной или двух картах и замените вызовы
llm_client.text_generationфункциейgenerateиз примечания выше. -
Время. Построение индекса FAISS по 18 тысячам фрагментов на GPU занимает минуты; оценка 28 примеров судьёй — несколько минут. Без GPU рецепт RAG тоже запустится, если заменить читателя маленькой моделью (0,5–1,5B) без квантования.
Что читать дальше
Заголовок раздела «Что читать дальше»- Оглавление сборника: huggingface.co/learn/cookbook и исходники github.com/huggingface/cookbook.
- К рецепту 1 — главы о судьях в нашем переводе LLM Evaluation Guidebook.
- К рецепту 2 — рецепты «RAG Evaluation», «Agentic RAG» и «RAG with source highlighting» из того же сборника и урок 8.2.2 «Продвинутый RAG».
- К агентам — наш путеводитель по Agents Course.
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| recipe, cookbook | рецепт, кулинарная книга (сборник рецептов) |
| LLM-as-a-judge | LLM как судья |
| ground truth | «истина», эталонная разметка |
| additive scale | аддитивная шкала (баллы за критерии) |
| retriever / reader | ретривер (поиск) / читатель (генерирующая модель) |
| chunk, chunk size, chunk overlap | фрагмент, размер фрагмента, перекрытие фрагментов |
| recursive chunking, semantic chunking | рекурсивное разбиение, семантическое разбиение |
| vector database, nearest neighbor search | векторная база данных, поиск ближайших соседей |
| cosine similarity, dot product | косинусное сходство, скалярное произведение |
| reranking, bi-encoder, cross-encoder | переранжирование, би-энкодер, кросс-энкодер |
| lost in the middle | эффект «потери в середине» длинного контекста |
| query expansion | расширение (переформулировка) запроса |