
В наши дни все, кому было интересно, уже попробовали RAG, я в том числе. И стандартная схема тривиальна: цепляем LangChain, три импорта, .from_documents() — и бот с базой знаний готов. Мой первый опыт был такой же, и это даже заработало за один вечер.
Но как это поддерживать? А что случится, если всё полетит?
Пять слоёв абстракции, и я без понятия, что происходит на любом из них. Поэтому я решил повторить свой эксперимент с контейнерами: отбросил все слои и переписал вручную на Python.
В этой статье я хочу рассказать о том, что я смог узнать о RAG, с какими подводными камнями столкнулся и как будет выглядеть итоговый продукт.
Зачем нам RAG?
По сути, языковая модель — это архив данных, сжатый в виде весов. Она знает всё, что было в датасете, на котором она обучалась, и ничего более. Это выливается в 3 проблемы, с которыми пользователи сталкиваются в процессе решения реальных задач:
Нехватка знаний: модель не знает о том, что произошло после обучения, а что ещё хуже, она не догадывается о том, что ничего не знает. Внутренние политики, заявки, спецификации, история — ничего из этого нет в её собственной базе знаний и никогда там не окажется.
Галлюцинации: модель не умеет говорить: «я не знаю», она всегда пытается угадать, через веса определяя самый вероятный вариант.
Никаких ссылок: она основывается на своих знаниях и не знает, чем именно они подкреплены, поэтому чёткого подтверждения вы не получите.
Ну и есть ещё одна специфическая проблема, которая в определённых сценариях может стрельнуть: нет никаких ограничений доступа. Если модель что-то знает, то она выдаст это любому, кто запросит.
RAG (Retrieval-Augmented Generation) решает все эти проблемы без взаимодействия с весами модели. Идея была представлена в работе Lewis et al. (FAIR, NeurIPS 2020, arXiv:2005.11401), и она безумно проста: прежде чем отвечать, ты должен поискать, выделить и приложить нужные фрагменты информации в промт.
У меня есть хорошая аналогия с инженером на заводе в закрытом контуре. Один не имеет доступа даже к документации, он заранее всё учит и работает с тем, что смог запомнить — это дорого и долго. А второй берёт с собой документацию и делает всё по ней, тем самым минуя этап предварительной подготовки. RAG — это отражение второго инженера.
Итоговый пайплайн выглядит примерно так:
запрос -> поиск по нашей базей знаний, которую мы дали модели -> -> выборка топ-N фрагментов -> "вот наш контекст, отвечай по нему" -> -> LLM -> ответ, подкрепленный цитатами
На чём держится RAG?
Перед тем как перейдём к написанию кода, разберёмся, на что мы будем опираться.
Эмбеддинг
Отдельная нейронка (энкодер) преобразует текст в массив чисел. Обычно 384, 768, 1024, иногда 4096. При этом её задача — собирать массивы так, чтобы на основе текстов со связанным смыслом генерировались похожие вектора.
Например, «Как задеплоить приложение на хосте в контейнерах» и «Порядок развёртывания сервиса на хосте через докер» почти не имеют общих слов, но при этом их вектора оказываются рядом.
Косинусная близость
Мера близости двух векторов — косинус угла между ними в диапазоне от -1 до 1. При этом после нормализации векторов косинус будет равен обычному скалярному произведению. Отсюда легко можно объяснить скорость: поиск расстояния сводится к одному умножению матриц.
Чанки
Элементарная единица поиска — проиндексированный кусок документа. Если слишком большая, то контекст будет содержать много мусора, а если слишком маленькая, то теряется весь смысл.
BM25
Классический лексический поиск: ранжирование по совпадению слов с учётом редкости каждого слова и длины документа. Довольно-таки глупый по сравнению с векторным представлением, но при этом незаменимый. Попробуйте спросить векторный поиск о коде ошибки «CR-12345», и семантика вам не поможет.
Строим RAG своими руками
Наша задача — реализовать бота, который отвечает на вопросы внутренней политики компании, без использования каких-либо фреймворков. Сам проект можно найти тут.
Шаг 1. Пилим текст на чанки
def chunk_text(text: str, size: int = 1200, overlap: int = 200) -> list[str]: """Split text into ~size-character pieces with an overlap. Nudge the boundary to the nearest separator so we don't cut mid-sentence.""" if overlap >= size: raise ValueError("overlap must be smaller than size") chunks, start, n = [], 0, len(text) while start < n: end = min(start + size, n) piece = text[start:end] if end < n: # не обрезаем последнюю часть for sep in ("\n\n", ". ", ".\n", "\n", " "): pos = piece.rfind(sep) if pos > size * 0.5: # граница найдена не слишком рано piece = piece[: pos + len(sep)] end = start + len(piece) break cleaned = piece.strip() if cleaned: chunks.append(cleaned) if end >= n: break start = max(end - overlap, start + 1) # +1 защита от бесконечного цикла return chunks
Здесь добавлено перекрытие (overlap) для ситуации, когда ответ находится ровно на границе двух чанков, так мы его не рвём пополам и не теряем из контекста.
Примерные параметры взяты: от 200 до 800 токенов в чанке и 10–20 процентов перекрытия. В этом диапазоне чанк как раз-таки не слишком большой и не слишком маленький, сохраняя свой смысл. И тут я сам попался: функция оперирует не токенами, а символами. Я замерил токенизатором e5, и получилось, что на токен приходится примерно 4,2 символа. Поэтому изначально, когда я поставил 400 токенов, я по сути был в два раза ниже границы.
Также сразу на будущее делаем отдельно класс Чанк, со ссылкой на его расположение.
@dataclass(frozen=True) class Chunk: """Единица поиска: текст плюс достаточно метаданных, чтобы сослаться на источник.""" text: str source: str # имя документа, из которого пришёл чанк position: int # порядковый номер чанка внутри этого документа @property def label(self) -> str: return f"{self.source}, фрагмент {self.position + 1}"
Шаг 2. Строим вектора по тексту
from sentence_transformers import SentenceTransformer import numpy as np ENCODER = SentenceTransformer("intfloat/multilingual-e5-large") def embed(texts: list[str], prefix: str) -> np.ndarray: """E5 wants prefixes: 'query: ' for questions, 'passage: ' for documents.""" vecs = ENCODER.encode([prefix + t for t in texts], normalize_embeddings=True, # нормируем → косинус == dot batch_size=32) return np.asarray(vecs, dtype=np.float32)
В описании функции указано про пару маркеров query и passage, без них качество пострадает. У Qwen3-Embedding похожая история с инструкциями перед промтом.
Очень важная часть — это выбор модели. Для русского языка есть специальный бенчмарк — ruMTEB. Расклад на 2026 год такой:
Модель | Параметры | ruMTEB (ср.) |
|---|---|---|
GigaEmbeddings (SberDevices) | 3B | 69.1 |
E5-mistral-7b-instruct | 7.1B | 64.9 |
multilingual-e5-large-instruct | 560M | 64.7 |
BGE-M3 | 567M | 60.8 |
multilingual-e5-large | 560M | 60.4 |
ru-en-RoSBERTa | 404M | 60.4 |
multilingual-e5-base | 278M | 57.1 |
Данные брал из первоисточников — работы про сам ruMTEB (Table 5) и статьи про GigaEmbeddings. При поиске будьте осторожнее, потому что очень часто встречаются таблицы из намешанных данных из абсолютно разных замеров.
Также нельзя не упомянуть Qwen3-Embedding. Она не включена в итоговое сравнение не просто так. В сети множество таблиц, где её сравнивают с другими моделями из списка, но я не нашёл реально подтверждённых замеров именно ruMTEB. А то, что обычно встречается, — как будто замер MTEB Multilingual (70.58 и 69.45 на 5 июня 2025).
Шаг 3. Поиск
class MiniRAG: def __init__(self, documents: dict[str, str]): self.chunks = [c for source, text in documents.items() for c in chunk_document(source, text)] self.matrix = embed([c.text for c in self.chunks], "passage: ") def vector_ranking(self, query: str, limit: int | None = None): qv = embed([query], "query: ")[0] scores = self.matrix @ qv # косинус за одно произведение ranking = sorted(enumerate(scores), key=lambda x: -x[1]) return ranking[:limit] if limit else ranking
По сути, вот эта строка self.matrix @ qv и есть наш векторный поиск.
Шаг 4. Генерируем ответ
PROMPT = """Ты отвечаешь ТОЛЬКО на основе фрагментов ниже. Если ответа в них нет — напиши: «В базе знаний нет ответа на этот вопрос». После каждого утверждения ставь номер фрагмента в квадратных скобках. <фрагменты> {context} </фрагменты> Вопрос: {question}""" def build_context(chunks: list[Chunk]) -> str: return "\n\n".join(f"[{i}] ({c.label})\n{c.text}" for i, c in enumerate(chunks, 1)) def generate(question: str, chunks: list[Chunk]) -> str: r = requests.post(f"{OLLAMA_HOST}/api/generate", timeout=300, json={ "model": MODEL, "prompt": PROMPT.format(context=build_context(chunks), question=question), "stream": False, "options": {"temperature": 0.1, "num_ctx": 8192}, }) r.raise_for_status() return strip_thinking(r.json()["response"])
Две строчки в запросе могут дать больше, чем неделя настройки ретривера:
Явное разрешение не отвечать. Мы таким образом избавляем себя от галлюцинаций.
Обязательные ссылки на источники. Без них не будет возможности проверить достоверность ответа.
Первый запуск
Я прописал в нём 12 пунктов из внутренних политик выдуманной компании: отпуск, спорт, VPN, инциденты, онбординг, техника, командировки, удалёнка, безопасность, обучение, больничный, закупки. Изначально протестировал только на BM25, потому что показалось, что этого должно быть достаточно для этой простой задачи. Протестил на разных вопросах, и всё работает. Но когда дошли до вопроса «как получить впн», начались проблемы. В ответ получил абзац о возмещении расходов на посещение тренажёрного зала.
Результат:
1. expenses.md score=2.121 2. gym.md score=2.094 3. equipment.md score=1.914 4. onboarding.md score=1.881 5. onboarding.md score=1.783 всего результатов: 5 | есть ли vpn.md: False
Правильный ответ был в vpn.md. Однако BM25 не увидела его вовсе. Это легко объясняется тем, что запись в кириллице впн, как это писали бы реальные пользователи, никак не связана с записью VPN. А вот слово получить встретилось много где.
Это отличный показатель того, что лексикографического поиска недостаточно, нам также нужен хороший эмбеддинг, который свяжет близкие по смыслу, но далёкие по написанию слова.
А если у вас уже есть внутренняя база знаний, которой хочется эффективно пользоваться через чат с LLM, то попробуйте развернуть хотя бы RAGFlow, а понимание дальнейшего вектора развития придёт со временем.
Гибридный поиск и RRF
Запускаем оба поиска параллельно и потом мерджим результат. Однако возникает вопрос, как соединить результаты разных форматов: косинус и вес. Для этого есть RRF (Reciprocal Rank Fusion). Сравниваются не веса и косинусы, а обратные ранги (числа, обратные позиции чанка в документе):
def rrf(rankings, k: int = 60): fused = {} for ranking in rankings: for rank, (doc_id, _) in enumerate(ranking, start=1): fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank) return sorted(fused.items(), key=lambda x: -x[1])
Позиции можно сравнить всегда, в отличие от весов. Таким образом, документ, который оказался третьим в обоих списках, обгоняет документ, который первый, но только в одном.
Собираем:
def hybrid_ranking(self, query: str): return rrf([ self.vector_ranking(query, limit=self.candidates), self.bm25_ranking(query, limit=self.candidates), ]) def retrieve(self, query: str, k: int = 5) -> list[Chunk]: return [self.chunks[i] for i, _ in self.hybrid_ranking(query)[:k]]
После этих правок ответ на запрос «как получить впн» стал корректным.
А оно точно работает? Какие есть метрики
Верный ответ — это ещё не показатель, мы не знаем, стал ли поиск лучше или точнее. Для проверки я собрал «золотой набор вопросов» data/golden.json, в котором отражены вопрос, расположение ответа и тип вопроса (у меня это natural и lexical).
Дальше нас интересуют метрики, которые точно могут отразить, насколько эффективно работает наш RAG:
hit@k (recall@k) показывает, есть ли в топ-k результатах интересующий нас документ. k — это количество фрагментов, которые мы кладём в промпт.
precision@k показывает, какой процент от полученных данных не мусорный.
MRR (Mean Reciprocal Rank) показывает значение, обратное позиции нужного нам документа в выдаче: чем ближе к 1, тем лучше, так как обрабатываем меньше мусора.
Замер
Я сделал замер на 12 документах и 51 вопросе. Результаты:
--- весь набор (51) --- ретривер hit@1 hit@3 hit@5 MRR@10 только BM25 92.2% 94.1% 96.1% 93.6% только вектор 90.2% 94.1% 98.0% 92.6% гибрид (RRF) 90.2% 98.0% 100.0% 94.2% --- вопросы своими словами (38) --- ретривер hit@1 hit@3 hit@5 MRR@10 только BM25 89.5% 92.1% 94.7% 91.4% только вектор 94.7% 100.0% 100.0% 96.5% гибрид (RRF) 89.5% 97.4% 100.0% 93.5% --- точные коды и числа (13) --- ретривер hit@1 hit@3 hit@5 MRR@10 только BM25 100.0% 100.0% 100.0% 100.0% только вектор 76.9% 76.9% 92.3% 81.1% гибрид (RRF) 92.3% 100.0% 100.0% 96.2%
По результатам видно, что гибрид даёт нам полное покрытие всех видов вопросов и мы не получим неожиданных галлюцинаций.
Как это реально выглядит на проде
Наша реализация полностью живёт в оперативной памяти и не особо отказоустойчивая. Индексы также пересчитываются при каждом запуске, на 24 чанках у меня это заняло 30 секунд, на тысячах это десятки минут.
В реальности есть дополнительные сервисы:
Векторная БД: если уже знакомы с Postgres, то есть pgvector. Если хочется меньше задержек и большие фильтры, то Qdrant. Если масштабы измеряются сотнями миллионов векторов, то лучше Milvus.
Reranker: достаём 50 кандидатов, прогоняем через специальную модель (например, bge-reranker-v2-m3), оставляем 5 лучших.
Права: ACL живёт прямо в payload чанка, а фильтры — в запросах к БД.
Contextual Retrieval: Anthropic описали это решение ещё в 2024 и до сих пор это одна из самых выгодных техник. Перед эмбеддингом просим дешёвую модель дописать чанкам, где именно он находится в документе.
Про «RAG умер»
В последнее время я всё чаще слышал про то, что RAG уже не актуален, у моделей стали огромные контекстные окна. И я соглашусь, это имеет смысл, если ваша база знаний до 200 тыс. токенов — Anthropic рекомендует просто положить это всё в контекст и включить кэширование промтов, не нужно городить лишние сервисы. Однако если мы говорим про огромные базы знаний, которые постоянно обновляются, на которые ещё и надо распилить права, то я пока что не вижу варианта надёжнее, чем RAG.
Если не хочется собирать руками
На рынке полно интересных решений. Одно из самых популярных — это RAGFlow. Один docker compose up -d, и вы получите web ui, в который можно загрузить документы и получить чат, который будет основываться на вашей базе знаний. Позволяет загружать множество разных форматов документов. Отличная стартовая точка, либо решение, если нет каких-то специфических запросов. Точно стоит того, если хочется понять, нужен ли вам вообще RAG, приложив минимум усилий.
Выводы
RAG ещё жив и точно найдёт своё применение в определённых сценариях. Да и инструмент не настолько сложный, чтобы не справиться с его кастомизацией.
А самое сложное оказалось не собрать сервис, а протестировать его и понять, реально ли он работает. Без метрик тут точно не обойтись.
© 2026 ООО «МТ ФИНАНС»


