RAG для тех, кто разочаровался: почему retrieval ломается и как это починить. llm.. llm. ml.. llm. ml. python.. llm. ml. python. rag.. llm. ml. python. rag. retrieval.. llm. ml. python. rag. retrieval. Блог компании OTUS.. llm. ml. python. rag. retrieval. Блог компании OTUS. векторный поиск.. llm. ml. python. rag. retrieval. Блог компании OTUS. векторный поиск. искусственный интеллект.. llm. ml. python. rag. retrieval. Блог компании OTUS. векторный поиск. искусственный интеллект. Машинное обучение.. llm. ml. python. rag. retrieval. Блог компании OTUS. векторный поиск. искусственный интеллект. Машинное обучение. Программирование.

Вы собрали RAG-пайплайн: загрузили документы, нарезали на чанки, сгенерировали эмбеддинги, подключили векторную базу. Задаёте вопрос — модель отвечает уверенно и подробно. Показываете заказчику, тот в восторге. Потом начинается тестирование на реальных вопросах, и оказывается, что на половину из них система отвечает мимо: то находит не тот документ, то находит правильный, но не тот кусок, то вообще ничего релевантного не достаёт и модель уверенно галлюцинирует.

Каждый раз проблема не в модели (GPT-4 и Claude отвечают хорошо, если им дать правильный контекст), а в retrieval — в том, как мы ищем релевантные куски документов. Модель отвечает ровно настолько хорошо, насколько хорош контекст, который ей подсунули.

Рассмотрим три основные причины.

Проблема 1: чанки нарезаны бездумно

Стандартный подход — нарезать документ на куски по 500-1000 символов с перекрытием.

Представьте договор на 30 страниц. Пункт 7.3 про ответственность начинается на одной странице и заканчивается на другой. При нарезке по 500 символов пункт разрезается пополам: первая половина в одном чанке, вторая в другом. Пользователь спрашивает «какая ответственность по договору», retrieval находит первую половину (там есть слово «ответственность»), но не находит вторую (там уже про суммы и сроки). Модель отвечает неполно, а пользователь думает, что система не работает.

Другой пример: таблица с тарифами. При нарезке по символам заголовок таблицы попадает в один чанк, а данные в другой. Чанк с данными без заголовка бессмысленен: числа без контекста.

Первое, что стоит сделать — перейти от «нарезки по символам» к «нарезке по смыслу»:

from langchain.text_splitter import RecursiveCharacterTextSplitter

# Стандартный подход — работает, но грубо
basic_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
)

# Лучше: разделители по приоритету
# Сначала пробуем разрезать по двойному переносу строки (между параграфами),
# потом по одинарному, потом по предложению, потом по символам
smart_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["nn", "n", ". ", " ", ""],
)

RecursiveCharacterTextSplitter пробует разделители по порядку: сначала ищет двойной перенос (граница параграфа), если чанк получается слишком большой — ищет одинарный перенос, потом точку с пробелом (конец предложения). Идея в том, чтобы разрезать в естественных точках текста, а не посередине слова.

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

import re

def split_by_sections(text: str, max_chunk_size: int = 2000) -> list[dict]:
    """Нарезка по заголовкам разделов (markdown-стиль)."""
    sections = re.split(r'n(#{1,3}s+.+)n', text)
    
    chunks = []
    current_header = ""
    
    for i, section in enumerate(sections):
        if re.match(r'^#{1,3}s+', section):
            current_header = section.strip()
            continue
        
        content = section.strip()
        if not content:
            continue
        
        # Если секция слишком большая — разбиваем дальше
        if len(content) > max_chunk_size:
            sub_chunks = smart_splitter.split_text(content)
            for j, sub in enumerate(sub_chunks):
                chunks.append({
                    "text": f"{current_header}nn{sub}",
                    "metadata": {
                        "section": current_header,
                        "part": j + 1,
                    }
                })
        else:
            chunks.append({
                "text": f"{current_header}nn{content}",
                "metadata": {"section": current_header}
            })
    
    return chunks

Каждый чанк содержит заголовок раздела + текст. Даже если чанк найден по содержанию, модель видит заголовок и понимает контекст. Для таблиц аналогично: заголовок таблицы добавляется к каждому чанку с данными из неё.

Проблема 2: семантический поиск не находит то, что нужно

Cosine similarity между эмбеддингами запроса и чанка — стандартный способ поиска в RAG. Но он ломается в нескольких конкретных случаях.

Пользователь спрашивает «штрафы за просрочку», а в документе написано «неустойка за нарушение сроков». Семантически это одно и то же, но эмбеддинги могут быть далеко друг от друга, потому что слова разные. Особенно если эмбеддинг-модель не обучена на юридических текстах.

Другой случай: пользователь спрашивает «что нужно для оформления возврата», а модель находит чанк про «политику возврата товаров», который описывает общие принципы, а не конкретные шаги. Семантически похоже, но ответ бесполезен.

Первое улучшение видится в гибридном поиске. Кроме семантического (по эмбеддингам), добавляем keyword-поиск (BM25):

from rank_bm25 import BM25Okapi
import numpy as np

class HybridRetriever:
    def __init__(self, chunks: list[dict], embeddings_model):
        self.chunks = chunks
        self.texts = [c["text"] for c in chunks]
        
        # BM25 для keyword search
        tokenized = [text.lower().split() for text in self.texts]
        self.bm25 = BM25Okapi(tokenized)
        
        # Эмбеддинги для semantic search
        self.embeddings = embeddings_model.encode(self.texts)
        self.model = embeddings_model
    
    def search(self, query: str, top_k: int = 5, alpha: float = 0.5) -> list[dict]:
        # BM25 scores
        bm25_scores = self.bm25.get_scores(query.lower().split())
        bm25_norm = bm25_scores / (bm25_scores.max() + 1e-6)
        
        # Semantic scores
        query_emb = self.model.encode(query)
        cos_scores = np.dot(self.embeddings, query_emb) / (
            np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_emb) + 1e-6
        )
        cos_norm = (cos_scores - cos_scores.min()) / (cos_scores.max() - cos_scores.min() + 1e-6)
        
        # Комбинируем: alpha * semantic + (1-alpha) * keyword
        combined = alpha * cos_norm + (1 - alpha) * bm25_norm
        top_indices = np.argsort(combined)[::-1][:top_k]
        
        return [self.chunks[i] for i in top_indices]

alpha регулирует баланс между семантическим и keyword-поиском. При alpha=1.0 это чистый semantic, при alpha=0.0 — чистый BM25. На практике alpha=0.5-0.7 работает лучше обоих по отдельности, потому что BM25 ловит точные совпадения терминов (штраф -> штраф), а semantic ловит синонимы (штраф -> неустойка).

Второе улучшение — reranking. Первый этап (retrieval) быстрый, но грубый: находит 20 кандидатов. Второй этап (reranking) медленный, но точный: пропускает 20 кандидатов через cross-encoder и переранжирует:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def search_with_rerank(query: str, retriever: HybridRetriever, top_k: int = 5):
    # Грубый поиск: 20 кандидатов
    candidates = retriever.search(query, top_k=20)
    
    # Reranking: cross-encoder оценивает пару (query, candidate)
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    
    # Сортируем по score от reranker
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    
    return [c for c, s in ranked[:top_k]]

Cross-encoder работает иначе, чем bi-encoder (обычные эмбеддинги). Bi-encoder кодирует запрос и документ отдельно и сравнивает векторы. Cross-encoder получает пару (запрос, документ) целиком и оценивает релевантность напрямую. Это точнее, но медленнее, поэтому cross-encoder используют для reranking 20 кандидатов, а не для поиска по миллиону документов.

Гибридный поиск + reranking сильно бустит качество retrieval.

Проблема 3: вы не измеряете качество retrieval

Большинство RAG-проектов оценивают качество так: разработчик задаёт 5 вопросов, смотрит на ответы и говорит «вроде нормально». Потом приходят реальные пользователи с реальными вопросами, и оказывается, что на половину система отвечает мимо.

Нужен evaluation dataset: набор вопросов с ожидаемыми чанками (или ожидаемыми ответами). 50-100 вопросов достаточно для начала.

eval_dataset = [
    {
        "question": "Какая неустойка за просрочку поставки?",
        "expected_chunk_id": "contract_7_3",  # ID чанка с пунктом 7.3
    },
    {
        "question": "Какой срок оплаты по договору?",
        "expected_chunk_id": "contract_4_1",
    },
    # ... ещё 48–98 вопросов
]

def evaluate_retrieval(retriever, eval_dataset, top_k=5):
    hits = 0
    for item in eval_dataset:
        results = retriever.search(item["question"], top_k=top_k)
        result_ids = [r["metadata"].get("chunk_id") for r in results]
        if item["expected_chunk_id"] in result_ids:
            hits += 1
    
    recall = hits / len(eval_dataset)
    return recall

# Базовый retrieval
recall_basic = evaluate_retrieval(basic_retriever, eval_dataset)
print(f"Basic retrieval recall@5: {recall_basic:.2%}")

# После улучшений (гибридный + reranking)
recall_improved = evaluate_retrieval(improved_retriever, eval_dataset)
print(f"Improved retrieval recall@5: {recall_improved:.2%}")

Без eval dataset вы не знаете, стало лучше или хуже после изменений. Поменяли размер чанка с 500 на 1000 — recall вырос или упал? Добавили reranking — помогло или нет? Без чисел это гадание.

Создание eval dataset-а — ручная работа. Берёте реальные вопросы от пользователей (или придумываете), находите правильный чанк для каждого, записываете. 50 вопросов — час работы. Зато потом каждое изменение в пайплайне можно оценить за минуту.

Что ещё влияет на качество

Размер чанка. Маленькие чанки (200-500 символов) дают точный retrieval, но модель получает мало контекста. Большие чанки (2000-3000) дают больше контекста, но retrieval менее точный (в большом чанке много шума). Золотой середины нет: для FAQ 300-500 нормально, для юридических документов 1000-2000, для технической документации 500-1000. Подбирайте под свои данные и измеряйте через eval dataset.

Эмбеддинг-модель. По умолчанию все берут OpenAI text-embedding-3-small или sentence-transformers all-MiniLM-L6-v2. Для русскоязычных документов multilingual-e5-large или BGE-M3 обычно работают лучше, потому что обучены на русском тексте. Замена модели эмбеддингов может дать +10-20% recall без изменения остального пайплайна.

Промпт для модели. Даже с идеальным retrieval модель может отвечать плохо, если промпт не объясняет, что делать с контекстом. «Ответь на вопрос на основе контекста» — слабо. «Ответь на вопрос на основе предоставленного контекста. Если в контексте нет информации для ответа, скажи об этом. Не додумывай. Ссылайся на конкретные пункты документа» — сильнее. Инструкция «не додумывай» снижает галлюцинации, а «ссылайся на пункты» делает ответ проверяемым.

Когда RAG не подходит

RAG хорош для вопросов по существующим документам: «что написано в договоре», «как настроить сервис по инструкции», «какая процедура возврата». RAG плох для задач, требующих рассуждения поверх данных: «сравни два договора и скажи, какой выгоднее», «проанализируй тренд в данных за квартал». Для таких задач нужны агенты с инструментами, а не retrieval.

RAG также плох, когда документов мало (меньше 10 страниц). Если весь контекст влезает в окно модели целиком, retrieval не нужен: загружайте документ полностью и спрашивайте. RAG окупается, когда данных сотни и тысячи страниц, и загрузить всё в контекст невозможно.

Retrieval — самое слабое звено в RAG-пайплайне. Модель отвечает ровно настолько хорошо, насколько хороший контекст ей подали. Нарезка по смыслу, гибридный поиск, reranking и eval dataset — четыре вещи, которые превращают демо-RAG в рабочий. Каждую из них можно внедрить за день, и каждая даёт измеримое улучшение.

Если у вас есть свои подходы к RAG, пишите в комментариях. Спасибо, что дочитали.


Если хочется чуть шире посмотреть на тему LLM, RAG и ИИ-агентов, приходите на открытые уроки OTUS. Там без обещаний «собрать идеального агента за вечер», но с разбором, что сейчас реально работает, где начинаются ограничения и как всё это применять в разработке.

  • 27 мая, 20:00. «Мифы про ИИ-агентов: что реально работает в 2026 году». Записаться

  • 15 июня, 20:00. «Интеграция ИИ-агентов в рабочую разработку: обвязка агента навыками и MCP». Записаться

Подписывайтесь на канал OTUS в Max, чтобы оставаться в курсе всех бесплатных мероприятий.

Автор: badcasedaily1

Источник