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