RAG (retrieval-augmented generation) — это схема, в которой модель отвечает не по памяти, а по фрагментам ваших документов, найденным в момент вопроса: система ищет подходящие куски текста, кладёт их в промпт и просит модель ответить только по ним, с указанием источника. Идея описана в статье Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (Lewis и др., NeurIPS 2020): параметрическая память модели дополняется внешней, обновляемой памятью. Ниже — как это устроено по шагам и рабочий пример на Python в сотню строк, который можно целиком запустить на бесплатных компонентах.
Из чего состоит ассистент с базой знаний
У любой RAG-системы две фазы: подготовка индекса (делается один раз и при обновлении документов) и ответ на вопрос (на каждый запрос).
- Загрузка и разбиение. Документы превращаются в текст и режутся на фрагменты (чанки) примерно в абзац-два.
- Эмбеддинги. Каждый чанк превращается в вектор; близкие по смыслу тексты оказываются близко в векторном пространстве.
- Индекс. Векторы и исходные тексты хранятся вместе с метаданными: файл, страница, раздел.
- Поиск. Вопрос тоже превращается в вектор, из индекса берутся k ближайших чанков.
- Сборка контекста. Найденные чанки нумеруются и вставляются в промпт вместе с инструкцией отвечать только по ним.
- Генерация и цитирование. Модель отвечает и ставит ссылки вида [1], [2]; код проверяет, что такие номера действительно были в контексте.
Шаги 1-3 подробно разобраны в статье про подготовку документов для RAG, а что делать, когда поиск промахивается, — в материале про hybrid search и reranking. Здесь — сквозная картина.
Бесплатный и недорогой стек
Для эмбеддингов и генерации не нужна платная подписка. Варианты, от самого простого к более гибкому:
| Компонент | Локально | Облако с бесплатным уровнем | Что учесть |
|---|---|---|---|
| Эмбеддинги | Ollama: ollama pull embeddinggemma, эндпоинт /api/embed | Gemini API: модели gemini-embedding-001 и gemini-embedding-2, размерность 128-3072 | Модель эмбеддингов в индексе и в запросе должна быть одной и той же |
| Генерация | Любая модель в Ollama, LM Studio или llama.cpp с OpenAI-совместимым API | Бесплатные модели через OpenRouter, Groq, NVIDIA | У облачных вариантов есть лимиты и политика данных: не отправляйте им закрытые документы |
| Хранилище | Массив numpy и JSON-файл; для тысяч и десятков тысяч чанков этого достаточно | Управляемые базы с бесплатными планами | Выбор базы разобран в статье pgvector или векторная база |
Документация Ollama называет типичную длину вектора 384-1024 и показывает вызов POST /api/embed с полем input, принимающим строку или список строк (Ollama: Embeddings). Для Gemini размерность вектора настраивается в диапазоне 128-3072, рекомендованы 768, 1536 и 3072 (Gemini API: Embeddings). Условия бесплатного уровня и лимиты у облачных провайдеров меняются, поэтому смотрите актуальные на страницах тарифов и в разборе лимитов OpenRouter. Если вы только выбираете, где запускать модель, начните с статьи про локальный запуск.
Минимальный пример на Python
Нужны Python 3.10+, пакеты numpy и requests и запущенный Ollama. Скачайте модели:
pip install numpy requests
ollama pull embeddinggemma
ollama pull llama3.1:8b
Положите несколько текстовых или markdown-файлов в папку docs/ и сохраните скрипт как rag.py:
import os, re, sys, json, glob
import numpy as np
import requests
OLLAMA = "http://localhost:11434"
EMBED_MODEL = "embeddinggemma"
LLM_BASE = os.getenv("LLM_BASE_URL", "http://localhost:11434/v1")
LLM_KEY = os.getenv("LLM_API_KEY", "ollama")
LLM_MODEL = os.getenv("LLM_MODEL", "llama3.1:8b")
def embed(texts):
r = requests.post(f"{OLLAMA}/api/embed",
json={"model": EMBED_MODEL, "input": texts}, timeout=300)
r.raise_for_status()
v = np.array(r.json()["embeddings"], dtype="float32")
return v / np.linalg.norm(v, axis=1, keepdims=True) # нормируем: cosine = скалярное произведение
def chunk(text, size=900, overlap=150):
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, cur = [], ""
for p in paras:
if cur and len(cur) + len(p) > size:
chunks.append(cur)
cur = cur[-overlap:] + "\n" + p
else:
cur = f"{cur}\n\n{p}" if cur else p
if cur:
chunks.append(cur)
return chunks
def build_index(folder="docs"):
meta, texts = [], []
for path in sorted(glob.glob(f"{folder}/**/*.*", recursive=True)):
if not path.endswith((".txt", ".md")):
continue
with open(path, encoding="utf-8") as f:
for i, c in enumerate(chunk(f.read())):
meta.append({"source": os.path.basename(path), "n": i})
texts.append(c)
vecs = np.vstack([embed(texts[i:i + 32]) for i in range(0, len(texts), 32)])
np.save("index.npy", vecs)
json.dump({"meta": meta, "texts": texts}, open("index.json", "w", encoding="utf-8"),
ensure_ascii=False)
print(f"Проиндексировано чанков: {len(texts)}")
def search(question, k=4):
vecs = np.load("index.npy")
data = json.load(open("index.json", encoding="utf-8"))
scores = vecs @ embed([question])[0]
top = np.argsort(-scores)[:k]
return [(float(scores[i]), data["meta"][i], data["texts"][i]) for i in top]
PROMPT = """Ответь на вопрос, используя только фрагменты ниже.
Ссылайся на фрагменты в квадратных скобках, например [1].
Если в фрагментах нет ответа, напиши: "В документах этого нет".
Фрагменты:
{context}
Вопрос: {question}"""
def ask(question):
hits = search(question)
context = "\n\n".join(f"[{i + 1}] ({m['source']}) {t}" for i, (_, m, t) in enumerate(hits))
r = requests.post(f"{LLM_BASE}/chat/completions",
headers={"Authorization": f"Bearer {LLM_KEY}"},
json={"model": LLM_MODEL, "temperature": 0,
"messages": [{"role": "user",
"content": PROMPT.format(context=context, question=question)}]},
timeout=300)
r.raise_for_status()
answer = r.json()["choices"][0]["message"]["content"]
cited = {int(n) for n in re.findall(r"\[(\d+)\]", answer)}
bad = [n for n in cited if not 1 <= n <= len(hits)]
print(answer)
print("\nИсточники:")
for n in sorted(cited - set(bad)):
print(f"[{n}] {hits[n - 1][1]['source']}, чанк {hits[n - 1][1]['n']}")
if bad:
print("Внимание: модель сослалась на несуществующие номера:", bad)
if __name__ == "__main__":
if sys.argv[1] == "index":
build_index()
else:
ask(" ".join(sys.argv[2:]))
Запуск:
python rag.py index
python rag.py ask "Как оформить возврат товара?"
Чтобы заменить локальную модель на облачную с OpenAI-совместимым API, достаточно переменных окружения: LLM_BASE_URL, LLM_API_KEY и LLM_MODEL с идентификатором модели из каталога. Эмбеддинги при этом остаются локальными, а значит, тексты документов покидают машину только в виде нескольких найденных чанков.
Что здесь сделано осознанно
- Нормировка векторов. После деления на длину косинусная близость равна скалярному произведению, поэтому весь поиск — одна строка
vecs @ query. На десятках тысяч чанков такой полный перебор работает без отдельной базы. - Перекрытие чанков. Хвост предыдущего фрагмента (150 символов) переходит в следующий, чтобы мысль на границе не потерялась. Абзац длиннее
sizeв этом примере остаётся одним чанком: для длинных абзацев нужен отдельный сплиттер. - Температура 0 и жёсткая инструкция. Модели явно разрешено сказать, что ответа в документах нет, — без этого она склонна заполнять пробелы своими знаниями.
- Проверка цитат кодом. Скрипт сверяет номера в ответе с реально переданными фрагментами. Это ловит выдуманные ссылки, но не доказывает, что утверждение действительно следует из процитированного текста.
Цитирование: что оно гарантирует и что нет
Ссылка вида [2] показывает, на какой фрагмент модель опиралась, и позволяет человеку быстро проверить ответ — ради этого её и делают. Но формат цитаты не делает ответ верным: модель может поставить корректный номер к неверному пересказу, смешать два фрагмента или проигнорировать нужный. Для критичных сценариев показывайте пользователю не только номер, но и сам процитированный текст с именем файла и страницей, а для ответов с высокой ценой ошибки добавляйте отдельный проход проверки: второй запрос к модели с вопросом, подтверждается ли каждое утверждение приведёнными фрагментами.
Почему RAG не гарантирует отсутствие галлюцинаций
RAG снижает риск выдуманных фактов, потому что у модели появляется опора, но не устраняет его. Ошибки возникают на разных этапах:
- Поиск не нашёл нужное: вопрос сформулирован иначе, чем в документе, или нужный фрагмент разрезан при чанкинге. Модель получает нерелевантный контекст и либо отказывается, либо додумывает.
- Нужный фрагмент найден, но потерян в длинном контексте. В работе Lost in the Middle показано, что качество ответов заметно падает, когда релевантная информация находится в середине длинного входа, а лучше всего модели используют начало и конец.
- Документы противоречат друг другу или устарели: модель добросовестно процитирует старую версию.
- Модель смешивает контекст со своими знаниями и выдаёт непроверяемую добавку рядом с цитатой.
Поэтому ассистент с базой знаний нужно измерять, а не просто запускать. Соберите 30-50 реальных вопросов с известными правильными фрагментами и смотрите отдельно, находит ли поиск нужное, и отдельно, как отвечает модель. Как это считать, показано в статьях про подготовку документов и улучшение поиска; сравнить несколько моделей на своих вопросах поможет материал про сравнение LLM на своих задачах.
Итого
- Минимальный RAG — это чанки, эмбеддинги, косинусный поиск, промпт с нумерованным контекстом и проверка ссылок; всё это работает на бесплатных локальных компонентах.
- Модель эмбеддингов фиксируйте вместе с индексом: смена модели означает полную переиндексацию.
- Главные точки отказа — чанкинг и поиск, а не генерация; чините их по метрикам, а не на глаз.
- RAG снижает, но не исключает галлюцинации: показывайте источники пользователю и разрешайте модели отвечать, что в документах ответа нет.
- Если вопрос в том, нужен ли вам RAG вообще, сравните его с промптингом и дообучением в статье промпт, RAG или fine-tuning.