GPT-5.6 и Opus ищут, как подключить принтер в розетку
GPT-5.6 и Opus ищут, как подключить принтер в розетку

В наши дни все, кому было интересно, уже попробовали RAG, я в том числе. И стандартная схема тривиальна: цепляем LangChain, три импорта, .from_documents() — и бот с базой знаний готов. Мой первый опыт был такой же, и это даже заработало за один вечер.

Но как это поддерживать? А что случится, если всё полетит?

Пять слоёв абстракции, и я без понятия, что происходит на любом из них. Поэтому я решил повторить свой эксперимент с контейнерами: отбросил все слои и переписал вручную на Python.

В этой статье я хочу рассказать о том, что я смог узнать о RAG, с какими подводными камнями столкнулся и как будет выглядеть итоговый продукт.

Зачем нам RAG?

По сути, языковая модель — это архив данных, сжатый в виде весов. Она знает всё, что было в датасете, на котором она обучалась, и ничего более. Это выливается в 3 проблемы, с которыми пользователи сталкиваются в процессе решения реальных задач:

  1. Нехватка знаний: модель не знает о том, что произошло после обучения, а что ещё хуже, она не догадывается о том, что ничего не знает. Внутренние политики, заявки, спецификации, история — ничего из этого нет в её собственной базе знаний и никогда там не окажется.

  2. Галлюцинации: модель не умеет говорить: «я не знаю», она всегда пытается угадать, через веса определяя самый вероятный вариант.

  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 ООО «МТ ФИНАНС»

Комментарии (9)


  1. danilovmy
    02.09.2026 07:50

    Ну RAG умер же не потому, что теперь контекст "бесконечен". Там же теперь KAG, sRAG и всякие другие умные буквы. Причём графы знаний и быстрее и точнее и без чанков.


  1. Ka463
    02.09.2026 07:50

    Хмм забавно куда вся индустрия привязывает пользователей, только к своим облачным сервисам, контекстное окно в миллионы токенов, которые не требуют каких либо хитрых технологий, корми модель .md она все сожрет, туда же и скилы, и вообще все что не попадя, но как только мы хотим приватности, и локальности, весь этот красивый картонный домик рушится.


  1. Fedyaration
    02.09.2026 07:50

    Без лишних слоёв и правда понятнее, особенно когда чанки надо крутить.


  1. Ioanna
    02.09.2026 07:50

    Правильный подход - начинать обучение RAG с начинки. Я начинаю его объяснять вообще с булева индекса)


  1. dibu28
    02.09.2026 07:50

    Попробуйте модель ColbertV2 через библиотеку fastembed. Мне она дала сильно лучше результаты и ответы от RAG чатбота по документам чем простые модели приведённые в статье.


  1. achekalin
    02.09.2026 07:50

    Главная ошибка статьи про борьбу с галлюцинациями — автор сам немного галлюцинирует насчёт того, что галлюцинации победил.

    Сначала утверждается, что явное разрешение модели сказать «в базе знаний нет ответа» «избавляет нас от галлюцинаций». Не избавляет: это всего лишь инструкция в prompt. Модель всё ещё может неверно интерпретировать найденный текст, приписать ему отсутствующее утверждение или просто нарушить инструкцию.

    Дальше ещё интереснее. На 12 документах и 51 вопросе гибридный поиск получает hit@5 = 100%, после чего следует вывод: «мы не получим неожиданных галлюцинаций». Но hit@5 измеряет только retriever: попал ли нужный документ в первую пятёрку. Он вообще ничего не говорит о том, что LLM, получив правильный документ, даст правильный ответ. Для этого отдельно измеряют хотя бы correctness и groundedness/faithfulness генерации.

    Да и «полное покрытие всех видов вопросов» по 51 заранее подготовленному вопросу — несколько смелая экстраполяция. Получилось показать, что на этом golden set ретривер находит нужный материал. Это хороший результат для учебного проекта, но не доказательство ни отсутствия галлюцинаций, ни полного покрытия реальных запросов.

    То же самое в начале с фразой «RAG решает все эти проблемы». RAG даёт модели внешние знания и позволяет привязать ответ к источникам. Но контроль доступа появляется только если его отдельно реализовать в retrieval, а галлюцинации становятся реже и лучше обнаруживаются — не исчезают.

    В остальном как раз хороший практический туториал: автор собрал работающий RAG, сравнил BM25, embeddings и hybrid и даже завёл golden set. Просто в момент перехода от «поиск у меня хорошо работает» к «значит, модель больше не галлюцинирует» потерялся ровно один слой системы — генеративный.


    1. peterplv
      02.09.2026 07:50

      По сути, языковая модель — это архив данных, сжатый в виде весов. Она знает всё, что было в датасете, на котором она обучалась, и ничего более.

      Добавлю сюда в догонку. Это не совсем точное объяснение LLM, которое повсеместно транслируется и вводит в заблуждение (эх, кто тот нулевой пациент, который запустил это?). Это обьяснение подразумевает что-то вроде статистической базы знаний, но LLM это не только статистика, но и обобщение. В весах не хранятся знания в явном виде, там хранятся обобщения и выученные паттерны, слой за слоем. Поэтому LLM "знает" не только то, на чем обучалась, но и может создавать новые представления, о которых ни разу не читала.


  1. pureooplover
    02.09.2026 07:50

    Подождите.

    Вы написали:

    scores = self.matrix @ qv

    Это не косинусное сходство.

    Определение косинусного сходства что-то вроде такого:

    \frac{A \cdot B}{\|A\| \cdot \|B\|}

    А у вас:

    A \cdot B

    Это скалярное произведение.

    Это лишь мое мнение, ведь может быть что я где то недочитал код.


    1. RigelGL
      02.09.2026 07:50

      Если A и B нормализованы заранее, то как раз будет косинусное сходство.