- BrainTools - https://www.braintools.ru -
В наши дни все, кому было интересно, уже попробовали RAG, я в том числе. И стандартная схема тривиальна: цепляем LangChain, три импорта, .from_documents() — и бот с базой знаний готов. Мой первый опыт [1] был такой же, и это даже заработало за один вечер.
Но как это поддерживать? А что случится, если всё полетит?
Пять слоёв абстракции, и я без понятия, что происходит на любом из них. Поэтому я решил повторить свой эксперимент с контейнерами [2]: отбросил все слои и переписал вручную на Python.
В этой статье я хочу рассказать о том, что я смог узнать о RAG, с какими подводными камнями столкнулся и как будет выглядеть итоговый продукт.
По сути, языковая модель — это архив данных, сжатый в виде весов. Она знает всё, что было в датасете, на котором она обучалась, и ничего более. Это выливается в 3 проблемы, с которыми пользователи сталкиваются в процессе решения реальных задач:
Нехватка знаний: модель не знает о том, что произошло после обучения [3], а что ещё хуже, она не догадывается о том, что ничего не знает. Внутренние политики, заявки, спецификации, история — ничего из этого нет в её собственной базе знаний и никогда там не окажется.
Галлюцинации: модель не умеет говорить: «я не знаю», она всегда пытается угадать, через веса определяя самый вероятный вариант.
Никаких ссылок: она основывается на своих знаниях и не знает, чем именно они подкреплены, поэтому чёткого подтверждения вы не получите.
Ну и есть ещё одна специфическая проблема, которая в определённых сценариях может стрельнуть: нет никаких ограничений доступа. Если модель что-то знает, то она выдаст это любому, кто запросит.
RAG (Retrieval-Augmented Generation) решает все эти проблемы без взаимодействия с весами модели. Идея была представлена в работе Lewis et al. (FAIR, NeurIPS 2020, arXiv:2005.11401 [4]), и она безумно проста: прежде чем отвечать, ты должен поискать, выделить и приложить нужные фрагменты информации в промт.
У меня есть хорошая аналогия с инженером на заводе в закрытом контуре. Один не имеет доступа даже к документации, он заранее всё учит и работает с тем, что смог запомнить — это дорого и долго. А второй берёт с собой документацию и делает всё по ней, тем самым минуя этап предварительной подготовки. RAG — это отражение второго инженера.
Итоговый пайплайн выглядит примерно так:
запрос -> поиск по нашей базей знаний, которую мы дали модели ->
-> выборка топ-N фрагментов -> "вот наш контекст, отвечай по нему" ->
-> LLM -> ответ, подкрепленный цитатами
Перед тем как перейдём к написанию кода, разберёмся, на что мы будем опираться.
Отдельная нейронка (энкодер) преобразует текст в массив чисел. Обычно 384, 768, 1024, иногда 4096. При этом её задача — собирать массивы так, чтобы на основе текстов со связанным смыслом генерировались похожие вектора.
Например, «Как задеплоить приложение на хосте в контейнерах» и «Порядок развёртывания сервиса на хосте через докер» почти не имеют общих слов, но при этом их вектора оказываются рядом.
Мера близости двух векторов — косинус угла между ними в диапазоне от -1 до 1. При этом после нормализации векторов косинус будет равен обычному скалярному произведению. Отсюда легко можно объяснить скорость: поиск расстояния сводится к одному умножению матриц.
Элементарная единица поиска — проиндексированный кусок документа. Если слишком большая, то контекст будет содержать много мусора, а если слишком маленькая, то теряется весь смысл.
Классический лексический поиск: ранжирование по совпадению слов с учётом редкости каждого слова и длины документа. Довольно-таки глупый по сравнению с векторным представлением, но при этом незаменимый. Попробуйте спросить векторный поиск о коде ошибки [5] «CR-12345», и семантика вам не поможет.
Наша задача — реализовать бота, который отвечает на вопросы внутренней политики компании, без использования каких-либо фреймворков. Сам проект можно найти тут [6].
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 ("nn", ". ", ".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}"
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 [7] (Table 5) и статьи про GigaEmbeddings [8]. При поиске будьте осторожнее, потому что очень часто встречаются таблицы из намешанных данных из абсолютно разных замеров.
Также нельзя не упомянуть Qwen3-Embedding. Она не включена в итоговое сравнение не просто так. В сети множество таблиц, где её сравнивают с другими моделями из списка, но я не нашёл реально подтверждённых замеров именно ruMTEB. А то, что обычно встречается, — как будто замер MTEB Multilingual (70.58 и 69.45 на 5 июня 2025 [9]).
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 и есть наш векторный поиск.
PROMPT = """Ты отвечаешь ТОЛЬКО на основе фрагментов ниже.
Если ответа в них нет — напиши: «В базе знаний нет ответа на этот вопрос».
После каждого утверждения ставь номер фрагмента в квадратных скобках.
<фрагменты>
{context}
</фрагменты>
Вопрос: {question}"""
def build_context(chunks: list[Chunk]) -> str:
return "nn".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 [10]. Однако BM25 не увидела его вовсе. Это легко объясняется тем, что запись в кириллице впн, как это писали бы реальные пользователи, никак не связана с записью VPN. А вот слово получить встретилось много где.
Это отличный показатель того, что лексикографического поиска недостаточно, нам также нужен хороший эмбеддинг, который свяжет близкие по смыслу, но далёкие по написанию слова.
А если у вас уже есть внутренняя база знаний, которой хочется эффективно пользоваться через чат с LLM, то попробуйте развернуть хотя бы RAGFlow, а понимание дальнейшего вектора развития придёт со временем.
Запускаем оба поиска параллельно и потом мерджим результат. Однако возникает вопрос, как соединить результаты разных форматов: косинус и вес. Для этого есть 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%
По результатам видно, что гибрид даёт нам полное покрытие всех видов вопросов и мы не получим неожиданных галлюцинаций.
Наша реализация полностью живёт в оперативной памяти [11] и не особо отказоустойчивая. Индексы также пересчитываются при каждом запуске, на 24 чанках у меня это заняло 30 секунд, на тысячах это десятки минут.
В реальности есть дополнительные сервисы:
Векторная БД: если уже знакомы с Postgres, то есть pgvector. Если хочется меньше задержек и большие фильтры, то Qdrant. Если масштабы измеряются сотнями миллионов векторов, то лучше Milvus.
Reranker: достаём 50 кандидатов, прогоняем через специальную модель (например, bge-reranker-v2-m3), оставляем 5 лучших.
Права: ACL живёт прямо в payload чанка, а фильтры — в запросах к БД.
Contextual Retrieval: Anthropic описали это решение ещё в 2024 и до сих пор это одна из самых выгодных техник. Перед эмбеддингом просим дешёвую модель дописать чанкам, где именно он находится в документе.
В последнее время я всё чаще слышал про то, что RAG уже не актуален, у моделей стали огромные контекстные окна. И я соглашусь, это имеет смысл, если ваша база знаний до 200 тыс. токенов — Anthropic рекомендует просто положить это всё в контекст и включить кэширование промтов, не нужно городить лишние сервисы. Однако если мы говорим про огромные базы знаний, которые постоянно обновляются, на которые ещё и надо распилить права, то я пока что не вижу варианта надёжнее, чем RAG.
На рынке полно интересных решений. Одно из самых популярных — это RAGFlow. Один docker compose up -d, и вы получите web ui, в который можно загрузить документы и получить чат, который будет основываться на вашей базе знаний. Позволяет загружать множество разных форматов документов. Отличная стартовая точка, либо решение, если нет каких-то специфических запросов. Точно стоит того, если хочется понять, нужен ли вам вообще RAG, приложив минимум усилий.
RAG ещё жив и точно найдёт своё применение в определённых сценариях. Да и инструмент не настолько сложный, чтобы не справиться с его кастомизацией.
А самое сложное оказалось не собрать сервис, а протестировать его и понять, реально ли он работает. Без метрик тут точно не обойтись.
© 2026 ООО «МТ ФИНАНС»
Автор: net0pyr
Источник [12]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/35063
URLs in this post:
[1] опыт: http://www.braintools.ru/article/6952
[2] с контейнерами: https://habr.com/ru/articles/881428/
[3] обучения: http://www.braintools.ru/article/5125
[4] arXiv:2005.11401: https://arxiv.org/abs/2005.11401
[5] ошибки: http://www.braintools.ru/article/4192
[6] тут: https://github.com/net0pyr/handmade-RAG
[7] работы про сам ruMTEB: https://arxiv.org/abs/2408.12503
[8] статьи про GigaEmbeddings: https://arxiv.org/abs/2510.22369
[9] 70.58 и 69.45 на 5 июня 2025: https://qwenlm.github.io/blog/qwen3-embedding/
[10] vpn.md: http://vpn.md
[11] памяти: http://www.braintools.ru/article/4140
[12] Источник: https://habr.com/ru/companies/ruvds/articles/1074156/?utm_campaign=1074156&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.