Retrieval-Augmented Generation
Как дать модели свежие и приватные знания без дообучения — конвейер «чанкинг → эмбеддинги → поиск → промпт», эмбеддинг-модели BGE и multilingual-e5, косинусный поиск и свой мини-RAG.
Зачем это нужно
Заголовок раздела «Зачем это нужно»У любой LLM три слабости, которые не лечатся размером модели:
- Знания заморожены. Модель знает мир до даты отсечкидата среза знанийEN knowledge cutoffМомент, на котором закончились обучающие данные модели. О более поздних событиях модель не знает, если ей не дать поиск. обучающих данных. О вчерашнем релизе или новом тарифе она не слышала.
- Приватных данных она не видела. Регламенты вашей компании, документация вашего продукта, переписка с клиентом не попадали в обучение — и не должны.
- Она уверенно выдумывает. Если ответа нет в «памяти», модель часто генерирует правдоподобный текст — галлюцинациягаллюцинацияEN hallucination.
генерация с дополнением извлечёнными даннымигенерация с дополнением извлечёнными данными (RAG)EN retrieval-augmented generation (RAG) решает все три проблемы одной идеей: сначала найти релевантные куски текста в вашей базе, потом передать их модели вместе с вопросом. Это экзамен с открытой книгой: модели не нужно помнить регламент, нужно уметь его прочитать. Термин RAG ввели Lewis и др. в статье 2020 года; сегодня это один из самых распространённых способов построить LLM-продукт поверх своих данных.
RAG, дообучение или длинный контекст?
Заголовок раздела «RAG, дообучение или длинный контекст?»| RAG | Дообучение (SFT, LoRA) | Всё в длинный контекст | |
|---|---|---|---|
| Свежие данные | обновить индекс — минуты | заново обучать | вставлять каждый раз |
| Приватные данные, права доступа | фильтр на уровне документов | «вшиты» в веса, не удалить | да, но всё сразу |
| Ссылки на источник | да | нет | частично |
| Цена одного запроса | промпт на несколько тысяч токенов | дёшево | огромный промпт каждый раз |
| Что даёт лучше всего | факты | стиль, формат, навыки | — |
| Главный риск | плохой поиск | галлюцинации фактов, забывание | окно, «lost in the middle» |
Простой пример масштаба. Внутренняя вики на 10 000 страниц по ~500 токенов — это 5 млн токенов. В окно на 1 млн не влезет. А если бы и влезла, при условной цене 0.30 долл. за миллион входных токенов каждый вопрос стоил бы 1.5 долл. RAG отправит в модель 3 000 токенов с пятью нужными кусками — 0.0009 долл.
Дообучение (модуль 6.2) и RAG не конкуренты: дообучением учат как отвечать (тон, формат, навык работы с документами), а через RAG дают что знать. Как выбирать ступень «промпт → RAG → дообучение → своя модель» на работе, с ценой и порогом качества, — в уроке 11.1.5.
Конвейер RAG
Заголовок раздела «Конвейер RAG»flowchart LR
subgraph IDX["Индексация — заранее"]
D["Документы"] --> C["Чанкинг"] --> E1["Эмбеддинги чанков"] --> V[("Индекс")]
end
subgraph ASK["Ответ — на каждый вопрос"]
Q["Вопрос"] --> E2["Эмбеддинг вопроса"] --> S["Поиск top-k"]
S --> P["Промпт: инструкция,<br/>источники, вопрос"] --> L["LLM"] --> A["Ответ со ссылками"]
end
V --> SИндексация выполняется один раз (и при обновлении документов), а поиск и генерация — на каждый вопрос. Разберём шаги по порядку.
Шаг 1. Загрузка и чанкинг
Заголовок раздела «Шаг 1. Загрузка и чанкинг»Документы (PDF, HTML, Markdown, таблицы) сначала превращают в чистый текст, затем режут на фрагменты — чанки. Это чанкингчанкинг (нарезка документов)EN chunkingРазбиение документов на фрагменты (чанки) для индексации. Каждый чанк получает свой эмбеддинг.. Зачем резать:
- Лимит модели эмбеддингов. Многие модели на энкодерах семейства BERT (multilingual-e5, BGE v1.5) читают до 512 токенов, а остальное отбрасывают. Напомним: BERT — encoder-onlyencoder-only (только энкодер)EN encoder-only architectureДвунаправленное внимание, обучение через маскированное языковое моделирование (BERT, RoBERTa). Сегодня — модели эмбеддингов, реранкеры, классификаторы.-трансформер без причинной маски (урок 4.5.1; устройство и дообучение — в уроке 4.5.4), он видит весь текст сразу и поэтому хорошо подходит, чтобы «сжать» текст в вектор.
- Размывание смысла. Эмбеддинг — один вектор на весь кусок. Если в чанке десять тем, вектор «усреднит» их, и ни один конкретный вопрос не окажется к нему близко.
- Бюджет промпта. В модель пойдут 3–10 чанков, а не целые документы.
Стартовое правило: чанки по 300–500 токенов с перекрытием 10–20%, чтобы фраза на границе не потерялась. К каждому чанку сохраняйте метаданные: источник, заголовок раздела, дату, ссылку. Они нужны для цитат и фильтров («только документы за 2026 год»). Стратегии чанкинга подробно — в следующем уроке.
Шаг 2. Эмбеддинги: смысл как вектор
Заголовок раздела «Шаг 2. Эмбеддинги: смысл как вектор»В уроке 4.3 вы видели эмбеддинги отдельных слов. эмбеддинг-модельэмбеддинг-модельEN embedding modelМодель, которая превращает текст целиком в один вектор так, что близкие по смыслу тексты дают близкие векторы (BGE, multilingual-e5, Qwen3-Embedding). делает то же для текста целиком: превращает чанк в вектор из сотен или тысяч чисел так, чтобы близкие по смыслу тексты давали близкие векторы, даже без общих слов. «Как сбросить пароль?» и «Забыл пароль от аккаунта» окажутся рядом, а «Как сбросить вес?» — дальше.
Такие модели учат контрастивно (контрастивное обучениеконтрастивное обучениеEN contrastive learningсближаем представления «правильных пар» и раздвигаем «неправильные»). Берут пары «запрос → подходящий фрагмент » и заставляют модель давать паре высокое сходство, а остальным фрагментам из батча — низкое:
Здесь — сходство векторов (обычно косинусное, о нём в шаге 3), — температура, небольшое число порядка 0.01–0.05. Это уже знакомая cross-entropy (урок 1.3), только «классы» — кандидаты-фрагменты, а логиты — сходства , делённые на . Правильный «класс» — подходящий фрагмент . Та же функция (InfoNCEInfoNCE (контрастивная функция потерь)EN InfoNCE losscross-entropy по матрице сходств, где правильные пары стоят на диагонали) обучает CLIP в уроке 9.1.1. Идея «притянуть пару, оттолкнуть негативы» вам знакома по негативному сэмплированию word2vec (урок 4.3.2): там пары «слово — контекст», негативы — случайные слова, и вместо softmax по кандидатам стоит сигмоида на каждую пару.
Пример с числами: пусть , сходство с подходящим фрагментом , а с двумя чужими — и . Логиты , и ; softmax даёт подходящему фрагменту вероятность , и loss . Если бы чужой фрагмент был так же близок (), вероятность упала бы до , а loss вырос бы до — градиент «раздвинет» векторы.
Запрос и документ кодируются независимо — такая схема называется биэнкодербиэнкодерEN bi-encoderЗапрос и документ кодируются независимо, сходство — скалярное произведение векторов. Векторы документов считаются заранее, поэтому поиск быстрый.. Это главное инженерное преимущество: векторы всех чанков считаются заранее, при запросе кодируется только вопрос.
Шаг 3. Косинусный поиск
Заголовок раздела «Шаг 3. Косинусный поиск»Близость векторов меряют косинусным сходствомкосинусное сходствоEN cosine similarityКосинус угла между векторами — скалярное произведение, делённое на произведение длин. Для нормированных векторов равно скалярному произведению. (урок 1.1):
Если один раз при индексации нормировать все векторы на длину 1 (и так же нормировать вектор запроса при поиске), косинус превращается в обычное скалярное произведение: знаменатель равен . Поиск по всей базе — одно умножение матрицы эмбеддингов на вектор запроса: . Дальше берём лучших.
Оценим масштаб. 1 млн чанков × 1024 числа × 4 байта (float32) ≈ 4.1 ГБ — помещается в память обычного сервера. Умножение — около миллиарда умножений-сложений на запрос, это доли секунды на CPU. Полный перебор отлично работает до миллионов чанков. Для сотен миллионов нужен приближённый поиск (приближённый поиск ближайших соседейприближённый поиск ближайших соседей (ANN)EN approximate nearest neighbor search (ANN)Поиск похожих векторов без полного перебора (HNSW, IVF, PQ) ценой небольшой потери полноты.) и векторная база данныхвекторная база данныхEN vector database — это следующий урок.
Шаг 4. Сборка промпта
Заголовок раздела «Шаг 4. Сборка промпта»Найденные чанки вставляют в промпт по правилам прошлого урока: инструкция сверху, данные в разделителях, вопрос в конце. Шаблон, который вы реализуете в практике:
Ответь на вопрос, используя только источники ниже. Если ответа в источниках нет, напиши «Не знаю».После каждого факта ставь номер источника в квадратных скобках, например [1].
Источники:[1] DeepSeek-V3 — MoE-модель: 671 млрд параметров, из них 37 млрд активны на каждый токен.[2] DeepSeek-R1 научилась рассуждать благодаря обучению с подкреплением по алгоритму GRPO.
Вопрос: Сколько параметров у DeepSeek-V3?Ответ:Три детали делают его надёжнее:
- «Только источники» + «Не знаю». Разрешение не отвечать снижает галлюцинации, когда поиск ничего не нашёл. Ещё лучше — не звать модель вовсе, если лучший score ниже порога.
- Нумерация и цитаты.
[1],[2]позволяют показать пользователю, откуда факт, и проверить ответ автоматически (метрика faithfulness — в следующем уроке). - Метаданные в источнике. Название документа и дата помогают модели разрешать противоречия («по регламенту 2026 года…»).
Лексический поиск: TF-IDF
Заголовок раздела «Лексический поиск: TF-IDF»До нейросетевых эмбеддингов поиск строили на словах — и он до сих пор полезен. TF-IDFTF-IDFEN TF-IDF (term frequency – inverse document frequency)Вес слова = частота в документе × «редкость» в корпусе. Классический разреженный (лексический) поиск. даёт каждому слову документа вес:
Здесь — сколько раз слово встречается в документе , — сколько всего слов в документе, — число документов, (document frequency) — в скольких из них встречается слово. Частое во всех документах слово («на», «от») получает маленький idf, редкое («MuonClip», «E-1042») — большой. Документ становится разреженным вектором: ненулевые веса только у его слов. Сравниваем запрос и документы тем же косинусом.
Пример с числами: в базе документов. Слово «MuonClip» есть в одном из них: . Слово «модель» есть во всех восьми: . Если в документе из 20 слов «MuonClip» встречается дважды, и вес ; у «модели» при тех же двух вхождениях вес всего . Единицы в формуле нужны, чтобы не делить на ноль и чтобы даже самое частое слово сохраняло ненулевой вес.
| TF-IDF / BM25 (лексический) | Эмбеддинги (плотный) | |
|---|---|---|
| Точные термины: «Qwen3-235B-A22B», «ошибка E-1042» | отлично | может промахнуться |
| Синонимы, перефразировки | не видит | отлично |
| Формы слов: «контексте» ↔ «контекст» | не видит (нужен стемминг или лемматизация) | отлично |
| Однокоренные слова: «Пекине» ↔ «Пекинской» | не видит | обычно видит |
| Вопрос по-русски, документ по-английски | не работает | работает (мультиязычные модели) |
| Нужна модель и GPU/CPU-инференс | нет | да |
Стемминг — обрезка окончаний по правилам («контексте» → «контекст»), лемматизация — приведение слова к словарной форме с помощью словаря («шли» → «идти»). Обе приводят формы одного слова к общему термину.
Для китайского текста есть дополнительная сложность: пробелов нет. Регулярное выражение \w+ («одна или больше
букв или цифр подряд») режет текст по пробелам и знакам препинания, поэтому re.findall(r"\w+", …) вернёт
всё китайское предложение одним «словом». Нужна сегментация словсегментация слов (в китайском тексте)EN word segmentationВ китайском письме нет пробелов, поэтому границы слов приходится находить алгоритмом. Поэтому по-китайски токенизатор часто так и называют — «сегментатор слов». (например, библиотека jieba) или
поиск по символьным биграммам.
Лучшие системы используют оба подхода — гибридный поискгибридный поискEN hybrid searchОдновременный лексический (BM25) и векторный поиск с последующим слиянием выдач, например через RRF. разберём в следующем уроке. А сейчас соберём мини-RAG на TF-IDF своими руками.
Практика
Заголовок раздела «Практика»Реализуйте на чистом Python TF-IDF-поиск по восьми документам о китайских моделях и сборку промпта с источниками. Обратите внимание на запрос «Какая погода в Пекине?»: все score равны нулю. В базе есть «Пекинской», но для TF-IDF «пекине» и «пекинской» — разные термины, а других общих слов нет. Здесь это к лучшему: про погоду в базе ничего нет, и нулевой score — честный сигнал «ответа нет». Но тот же эффект помешает, когда ответ в базе есть, а слово стоит в другой форме. Такой случай разберём в следующем уроке.
import math
import re
from collections import Counter
DOCS = [
"Qwen — семейство открытых языковых моделей от Alibaba; многие версии выходят под лицензией Apache 2.0.",
"DeepSeek-V3 — MoE-модель: 671 млрд параметров, из них 37 млрд активны на каждый токен.",
"BGE — семейство эмбеддинг-моделей от BAAI, Пекинской академии искусственного интеллекта.",
"Kimi K2 от Moonshot AI обучали оптимизатором MuonClip на 15.5 трлн токенов.",
"RAG сначала находит релевантные документы, а затем передаёт их языковой модели вместе с вопросом.",
"Косинусное сходство векторов — это скалярное произведение, делённое на произведение их длин.",
"GLM-4.5 от Zhipu AI выпущена под лицензией MIT.",
"DeepSeek-R1 научилась рассуждать благодаря обучению с подкреплением по алгоритму GRPO.",
]
INSTRUCTION = (
"Ответь на вопрос, используя только источники ниже. "
"Если ответа в источниках нет, напиши «Не знаю». "
"После каждого факта ставь номер источника в квадратных скобках, например [1]."
)
def tokenize(text):
"""Нижний регистр + слова из букв и цифр (кириллица и латиница). Уже готово."""
return re.findall(r"\w+", text.lower())
def compute_idf(docs_tokens):
"""Словарь {термин: idf} по списку токенизированных документов.
idf(t) = ln((1 + N) / (1 + df(t))) + 1,
где N — число документов, df(t) — в скольких документах встречается t
(термин считается один раз на документ, сколько бы раз он там ни был).
"""
# TODO: ваш код
...
def tfidf_vector(tokens, idf):
"""Разреженный TF-IDF-вектор {термин: вес}, нормированный на длину 1.
tf(t) = (сколько раз t в tokens) / len(tokens); вес = tf(t) * idf[t].
Нормировка: поделите каждый вес на sqrt(сумма квадратов всех весов).
Термины, которых нет в idf, пропускайте. Пустой вход -> {}.
Если ни одного термина нет в idf (длина вектора 0) -> тоже {}: делить на ноль нельзя.
"""
# TODO: ваш код
...
def cosine(a, b):
"""Косинусное сходство двух разреженных векторов-словарей. Пустой вектор -> 0.0."""
# TODO: ваш код
...
def search(query, docs, k=2):
"""Top-k документов: список пар (индекс, score) по убыванию score.
При равных score меньший индекс идёт раньше.
"""
# TODO: токенизируйте документы, посчитайте idf, векторы документов и запроса
...
def build_prompt(question, docs, hits):
"""Соберите промпт ровно в таком виде:
<INSTRUCTION>
(пустая строка)
Источники:
[1] <текст первого найденного документа>
[2] <текст второго>
(пустая строка)
Вопрос: <question>
Ответ:
"""
# TODO: ваш код
...
# Проверьте себя кнопкой «Запустить»:
hits = search("Сколько параметров у DeepSeek-V3?", DOCS, k=2)
print(hits)
print(build_prompt("Сколько параметров у DeepSeek-V3?", DOCS, hits))
Настоящие эмбеддинги локально
Заголовок раздела «Настоящие эмбеддинги локально»Тот же поиск на нейросетевых эмбеддингах — через библиотеку sentence-transformers
(pip install sentence-transformers). Код требует скачать модель с Hugging Face (multilingual-e5-small —
несколько сотен МБ, BGE-M3 — больше 2 ГБ), поэтому в среде курса мы его не запускали. Порядок результатов
у вас должен совпасть с интуицией.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small") # или "BAAI/bge-m3" (без префиксов)
chunks = [ "Чтобы сбросить пароль, откройте настройки профиля.", "Тарифы API: кэшированные токены дешевле.", "DeepSeek-V3 — MoE-модель на 671 млрд параметров.",]E = model.encode(["passage: " + c for c in chunks], normalize_embeddings=True)q = model.encode("query: забыл пароль от аккаунта", normalize_embeddings=True)
scores = E @ q # косинус = скалярное произведениеfor i in scores.argsort()[::-1]: print(f"{scores[i]:.3f} {chunks[i]}")У TF-IDF запрос «забыл пароль от аккаунта» найдёт первый чанк только по слову «пароль» и ничего не поймёт про «сбросить». Эмбеддинг-модель поставит его первым по смыслу.
Как устроен поиск по матрице, можно проверить и без скачивания — на случайных векторах (NumPy):
import time
import numpy as np
rng = np.random.default_rng(0)N, d = 100_000, 1024 # 100 тыс. чанков, размерность как у BGE-M3
E = rng.standard_normal((N, d), dtype=np.float32)E /= np.linalg.norm(E, axis=1, keepdims=True) # нормируем один раз при индексации
q = E[42] + 0.02 * rng.standard_normal(d, dtype=np.float32) # «запрос», близкий к чанку 42q /= np.linalg.norm(q)
t0 = time.perf_counter()scores = E @ q # косинус = скалярное произведениеtop = np.argpartition(-scores, 5)[:5] # 5 лучших без полной сортировкиtop = top[np.argsort(-scores[top])]dt = (time.perf_counter() - t0) * 1000
print("top-5:", top.tolist())print("score:", [round(float(s), 3) for s in scores[top]])print(f"индекс: {E.nbytes / 1e6:.0f} МБ, поиск: {dt:.0f} мс")top-5: [42, 2709, 18896, 17010, 8306]score: [0.837, 0.134, 0.127, 0.126, 0.125]индекс: 410 МБ, поиск: 35 мсВремя поиска зависит от процессора и библиотеки BLAS (низкоуровневая библиотека быстрых матричных операций,
которую NumPy вызывает внутри @, урок 1.1.2): у нас выходило от 35 до 110 мс. Индексы top-5 и score
у вас совпадут, потому что генератор зафиксирован (default_rng(0)).
Посмотрите на score «соседей»: даже лучшие из 99 999 случайных векторов набрали лишь около 0.13. В пространстве размерности 1024 случайные направления почти ортогональны: косинус двух случайных векторов в среднем равен 0, а его стандартное отклонение — . Значение 0.13 — это примерно четыре таких отклонения, редкий выброс, который среди 100 тысяч кандидатов всё же находится. У настоящих эмбеддингов «фон» выше (тексты на одном языке и на похожие темы уже немного похожи), и у разных моделей он разный. Поэтому порог «ответа нет» подбирают для конкретной модели на своих данных.
Выбор эмбеддинг-модели
Заголовок раздела «Выбор эмбеддинг-модели»| Модель | Кто | Языки | Размерность | Макс. длина | Особенности |
|---|---|---|---|---|---|
BAAI/bge-m3 | BAAI, Пекин | 100+ | 1024 | 8192 | dense, sparse и multi-vector в одной модели |
intfloat/multilingual-e5-large / -small | Microsoft | ≈100 | 1024 / 384 | 512 | префиксы query: и passage: |
Qwen/Qwen3-Embedding-0.6B (есть 4B и 8B) | Alibaba, Qwen | 100+ | 1024 (у 4B — 2560, у 8B — 4096) | 32K | инструкция в запросе; размерность можно урезать |
BAAI/bge-large-zh-v1.5 | BAAI | китайский | 1024 | 512 | инструкция для запросов |
Как выбирать:
- Начните с лидерборда MTEB (Massive Text Embedding Benchmark): там есть мультиязычные и отдельные языковые разделы, для китайского — C-MTEB.
- Проверьте на своих данных. 30–50 реальных вопросов с известными ответами быстро покажут разницу. Как считать recall@k — в следующем уроке.
- Учтите стоимость. Размерность 4096 вместо 1024 — вчетверо больше памяти индекса. Модель на 8B параметров для индексации миллиона чанков требует GPU.
Для русского языка разумная отправная точка — bge-m3 или multilingual-e5: обе мультиязычные, бесплатные
и работают на CPU.
Проверь себя
Заголовок раздела «Проверь себя»- RAG = найти релевантные куски в своей базе + передать их модели с вопросом. Он даёт свежие и приватные знания, ссылки на источник и меньше галлюцинаций.
- Конвейер: загрузка → чанкинг → эмбеддинги → индекс; на запрос — эмбеддинг вопроса → top-k → промпт с источниками.
- Эмбеддинг-модели (BGE-M3, multilingual-e5, Qwen3-Embedding) обучены контрастивно и требуют своих префиксов или инструкций. Нормируйте векторы — и косинус станет скалярным произведением.
- TF-IDF ловит точные термины, эмбеддинги — смысл. Лучше всего работают вместе.
Когда документов много и они из разных частей бизнеса, полезно делить индексы по смыслу, а не по папкам: одно и то же слово в разных отделах значит разное. Как ограниченные контексты из DDD помогают строить RAG-индексы и инструменты агента — в уроке DDD.4.4 «DDD и системы с ИИ».
Дальше: продвинутый RAG — стратегии чанкинга, BM25 и гибридный поиск, реранкеры, векторные БД и, главное, как измерить качество RAG числами.