Online vs offline: место generation в RAG

RAG делится на две фазы. Indexing — офлайн: строим индекс один раз, обновляем при изменении корпуса. Generation — онлайн: выполняется при каждом запросе пользователя за миллисекунды. Именно поэтому у них разные требования: индексирование может занимать минуты, generation — должна укладываться в секунды.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Запрос пользователя Retrieval hybrid search reranking compression → top-k чанков Промпт SYSTEM роль, правила, grounding CONTEXT [1] чанк A [2] чанк B [3] чанк C USER вопрос пользователя Системный промпт (задаётся разработчиком) LLM claude-sonnet-4-6 temperature=0 max_tokens=1024 constrained generation Ответ с цитатами [1][2] Промпт = системный контракт + извлечённые знания + вопрос пользователя LLM не «знает» ответ заранее — он синтезирует его только из переданного контекста

Диаграмма показывает главное: RAG generation — это constrained generation. LLM не использует свои внутренние знания напрямую — он синтезирует ответ исключительно из трёх входов: системных инструкций, извлечённого контекста и вопроса пользователя. Ваша задача как разработчика — сформировать эти три части правильно.

Анатомия RAG-промпта: три части

Хорошо структурированный RAG-промпт всегда состоит из трёх чётко разделённых частей. Смешивать их или опускать — значит передавать LLM противоречивые сигналы.

System prompt — контракт с моделью
Ты — ассистент по технической документации компании Acme. Отвечай только на основе предоставленного контекста. Если ответ не содержится в контексте — прямо сообщи об этом. Не придумывай факты, числа или примеры кода, которых нет в источниках. Цитируй источник в формате [N] после каждого утверждения.
Context block — извлечённые знания
<context> [1] Источник: docs/httpx-guide.md Для настройки таймаутов используйте объект httpx.Timeout(connect=5.0, read=10.0). Можно указать разные значения для connect, read, write и pool. [2] Источник: docs/httpx-advanced.md По умолчанию httpx.AsyncClient использует общий таймаут 5 секунд. Передайте timeout=httpx.Timeout(None) чтобы отключить таймауты полностью. </context>
User message — вопрос пользователя
Как настроить разные таймауты для connect и read в httpx?

Каждая часть выполняет строго свою роль. Системный промпт — это контракт с моделью, задаёт правила игры один раз. Context block — знания, актуальные для этого конкретного запроса, меняется каждый раз. User message — вопрос без изменений, как пришёл от пользователя.

Системный промпт: четыре обязательных элемента

Большинство проблем с качеством RAG-ответов решается на уровне системного промпта. Плохо написанный системный промпт — и LLM начинает галлюцинировать, игнорировать источники или давать уклончивые ответы.

1
Роль и домен
«Ты — ассистент по X». Конкретность снижает вероятность того, что модель уйдёт за пределы темы. «Ты — ассистент по документации httpx» лучше, чем просто «ты — технический ассистент».
2
Grounding-инструкция: отвечай только из контекста
Самый важный элемент. Без явного ограничения LLM будет смешивать контекст со своими обучающими знаниями — и незаметно для пользователя подставлять неверные факты. Формулировка должна быть категоричной, а не мягкой просьбой.
3
Fallback: что делать, когда ответа нет в контексте
Без этой инструкции модель изобретёт ответ. Нужно явно разрешить сказать «я не нашёл информацию по этому вопросу в предоставленных материалах». Честный «не знаю» лучше уверенного неверного ответа.
4
Формат ответа и цитирование
Укажи, как ссылаться на источники — [1], [источник] или полным именем файла. Без инструкции модель либо не цитирует вовсе, либо делает это непоследовательно.
Плохой системный промпт
Ты полезный ассистент. Отвечай на вопросы пользователя. Используй предоставленный контекст.
Нет grounding-ограничения, нет fallback, нет формата цитирования. Модель ответит «из головы».
Хороший системный промпт
Ты — ассистент по документации httpx. Правила: - Отвечай ТОЛЬКО на основе контекста ниже - Если ответа нет в контексте — напиши: «Эта информация не содержится в документации» - Не добавляй факты из своих знаний - Ссылайся на источники: [1], [2], ...
Чёткая роль + категоричное ограничение + явный fallback + формат цитат.

Форматирование контекста: как передать чанки LLM

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

Плохое форматирование
httpx.Timeout(connect=5.0, read=10.0) настраивает таймауты. httpx поддерживает HTTP/2. AsyncClient использует общий таймаут 5 секунд. requests тоже поддерживает таймауты через параметр timeout=.
Всё слито в один текст, нет разделителей, нет источников. LLM не может разграничить источники.
Хорошее форматирование
<context> [1] Источник: httpx-guide.md httpx.Timeout(connect=5.0, read=10.0) настраивает таймауты раздельно. [2] Источник: httpx-async.md AsyncClient: timeout=httpx.Timeout(None) отключает таймаут полностью. </context>
XML-теги — чёткая граница блока. Номера [N] — якоря для цитирования. Источник — проверяемость.

Несколько рекомендаций по форматированию:

XML-теги
Оберни контекстный блок в <context>…</context>. Claude и другие модели обучены на XML-разметке — такие теги прочно разграничивают секции промпта.
Номера чанков
Префикс [1], [2] перед каждым чанком даёт LLM якорь для цитирования. Без номеров модель либо не цитирует, либо пишет полное название файла посреди текста.
Имя источника
Строка «Источник: filename» или «URL: url» позволяет пользователю проверить ответ. Это не для LLM — это для человека, читающего ответ с цитатами.
Разделители
Пустая строка между чанками. Для сложных случаев — горизонтальный разделитель ---. Помогает модели не «перетекать» из одного чанка в другой при генерации.
python — Форматирование контекстного блока
from dataclasses import dataclass


@dataclass
class RetrievedChunk:
    text:   str
    source: str   # имя файла или URL
    score:  float


def format_context(chunks: list[RetrievedChunk]) -> str:
    """
    Оборачивает чанки в XML-блок с нумерацией и источниками.
    Пример вывода:
        
        [1] Источник: httpx-guide.md
        httpx.Timeout(connect=5.0, read=10.0) ...

        [2] Источник: httpx-async.md
        AsyncClient принимает тот же объект Timeout ...
        
    """
    if not chunks:
        return "\nКонтекст не найден.\n"

    parts = []
    for i, chunk in enumerate(chunks, start=1):
        source_name = chunk.source.split("/")[-1]  # только имя файла
        parts.append(f"[{i}] Источник: {source_name}\n{chunk.text.strip()}")

    inner = "\n\n".join(parts)
    return f"\n{inner}\n"


# ── Пример ─────────────────────────────────────────────────────────
chunks = [
    RetrievedChunk(
        text="httpx.Timeout(connect=5.0, read=10.0) настраивает раздельные таймауты.",
        source="docs/httpx-guide.md",
        score=0.92,
    ),
    RetrievedChunk(
        text="AsyncClient: передайте timeout=httpx.Timeout(None) чтобы отключить таймаут.",
        source="docs/httpx-async.md",
        score=0.87,
    ),
]

print(format_context(chunks))

Grounding: как LLM не выходит за пределы контекста

Grounding — это способность модели оставаться в рамках переданного контекста, не дополняя ответ собственными обучающими знаниями. Без grounding возникает knowledge mixing: LLM незаметно подмешивает «знания из интернета» к информации из ваших документов, и разграничить их невозможно.

Три техники grounding:

1
Категоричная инструкция в system prompt
«Отвечай ТОЛЬКО на основе предоставленного контекста» с заглавной ТОЛЬКО. Мягкое «постарайся использовать контекст» LLM выполняет лишь частично.
2
Явный запрет на добавление внешних знаний
«Не добавляй факты, примеры и числа, которых нет в источниках». Без этого запрета LLM охотно дополняет контекст — особенно если тема хорошо известна модели.
3
Инструкция «процитируй» вместо «объясни»
Когда просишь процитировать источник, модель вынуждена опереться на конкретный фрагмент. «Объяснение» — более свободная задача, провоцирующая привлечение внешних знаний.
Grounding — вероятностный, не детерминированный. Даже при идеальном промпте модель иногда «просочится» за пределы контекста — особенно на хорошо знакомых темах. Единственный способ гарантировать качество — RAGAS-оценка (faithfulness score) на тестовой выборке. Системный промпт снижает вероятность, но не устраняет её полностью.

Обработка пустого контекста

Ситуация «релевантных документов не найдено» — обязательный кейс, который нужно обработать явно. Три варианта поведения:

Честный fallback
Передать в контекст «Документов не найдено» и инструктировать модель сообщить об этом. Рекомендуется. Пользователь понимает ограничение, может переформулировать запрос.
Порог similarity
Если максимальный score чанков ниже порога (например, 0.5) — не отправлять контекст вовсе, сразу вернуть «информация не найдена». Быстро и дёшево: экономит LLM-вызов.
Ничего не делать
Передать пустой контекст без инструкций. Модель заполнит пробел собственными знаниями — это худший сценарий, галлюцинация гарантирована.
python — Обработка пустого и слабого контекста
NO_CONTEXT_REPLY = (
    "Я не нашёл информации по этому вопросу в предоставленной документации. "
    "Попробуйте переформулировать запрос или уточните тему."
)

def check_context_quality(
    chunks: list[RetrievedChunk],
    min_score: float = 0.45,
    min_chunks: int = 1,
) -> bool:
    """
    Возвращает True, если контекст достаточно релевантен для генерации.
    Два условия: хотя бы один чанк выше порога и не пустой список.
    """
    if not chunks or len(chunks) < min_chunks:
        return False
    best_score = max(ch.score for ch in chunks)
    return best_score >= min_score


def generate_answer(
    query: str,
    chunks: list[RetrievedChunk],
    system_prompt: str,
    client,           # anthropic.Anthropic
    model: str = "claude-sonnet-4-6",
    min_score: float  = 0.45,
) -> str:
    # Проверяем качество контекста до LLM-вызова
    if not check_context_quality(chunks, min_score=min_score):
        return NO_CONTEXT_REPLY

    context = format_context(chunks)
    user_message = f"{context}\n\nВопрос: {query}"

    response = client.messages.create(
        model=model,
        max_tokens=1024,
        system=system_prompt,
        messages=[{"role": "user", "content": user_message}],
    )
    return response.content[0].text

Цитирование источников

Цитирование — не украшение, а механизм доверия: пользователь может проверить ответ, перейдя к исходному документу. Без цитат RAG-ответ неотличим от обычного LLM-ответа, и вся ценность знаний из корпуса теряется.

Пример хорошо оформленного ответа с цитатами:

Для настройки разных таймаутов используйте httpx.Timeout(connect=5.0, read=10.0) — объект принимает раздельные значения для connect, read, write и pool [1]. В асинхронном клиенте синтаксис тот же: httpx.AsyncClient(timeout=httpx.Timeout(5.0)). Чтобы полностью отключить таймаут, передайте httpx.Timeout(None) [2].
[1] httpx-guide.md  ·  [2] httpx-async.md

Чтобы получить такой вывод, добавьте в системный промпт инструкцию о формате цитат и передайте шаблон ответа. Вот промпт, который производит этот результат стабильно:

python — Системный промпт с явными правилами цитирования
SYSTEM_PROMPT = """\
Ты — ассистент по технической документации.

## Правила ответа
1. Отвечай ТОЛЬКО на основе информации из блока .
2. Если ответа нет в контексте — напиши: «Эта информация не содержится в документации».
3. Не добавляй факты, примеры кода или числа, которых нет в источниках.

## Цитирование
- После каждого утверждения из источника ставь номер в квадратных скобках: [1], [2], ...
- В конце ответа добавь раздел «Источники:» со списком использованных документов.
- Если утверждение следует из нескольких источников — пиши [1][2].

## Формат
- Markdown для структуры (заголовки, списки, блоки кода)
- Блоки кода оборачивай в ``` с указанием языка
- Ответ должен быть конкретным и по существу — без «Конечно!» и прочих вводных
"""

Temperature и параметры генерации

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

Параметр Значение для RAG Почему
temperature 0 или 0.1 Детерминированность важна для factual QA. При temperature=0 модель всегда выбирает наиболее вероятный токен — наименьший шанс отклонения от контекста.
max_tokens 512–1024 Ответы на конкретные вопросы редко требуют больше. Большой лимит — риск растекания текста без добавления информации.
top_p / top_k оставить default При temperature=0 эти параметры не влияют. При temperature>0 top_p=0.9 ограничивает вариативность, не лишая гибкости.
stop sequences опционально Полезно для структурированного вывода: остановить генерацию после «Источники:» или JSON-блока.
Когда temperature > 0 имеет смысл. Для многооборотных чатов небольшая temperature (0.2–0.3) делает ответы менее роботизированными. Для суммаризации или объяснений (не точный поиск фактов) — 0.3–0.5. Для генерации вариантов переформулировки запроса (query expansion) — 0.7–1.0.

Полный generation pipeline

Соберём всё вместе: retrieval + форматирование + generation в единый класс.

python — RAGGenerationPipeline: полная сборка
from dataclasses import dataclass, field
from anthropic import Anthropic
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
from sentence_transformers import SentenceTransformer


@dataclass
class GenerationConfig:
    collection_name: str   = "documents"
    model_name:      str   = "BAAI/bge-small-en-v1.5"
    llm_model:       str   = "claude-sonnet-4-6"
    top_k:           int   = 5
    min_score:       float = 0.45
    max_tokens:      int   = 1024
    temperature:     float = 0.0
    system_prompt:   str   = SYSTEM_PROMPT


@dataclass
class RAGResponse:
    answer:  str
    chunks:  list[RetrievedChunk]
    query:   str
    skipped: bool = False   # True если контекст не прошёл проверку качества


class RAGGenerationPipeline:
    def __init__(self, config: GenerationConfig,
                 qdrant_url: str = "http://localhost:6333"):
        self.cfg    = config
        self.client = Anthropic()
        self.qdrant = QdrantClient(url=qdrant_url)
        self.embed_model = SentenceTransformer(config.model_name)

    # ── Retrieval ───────────────────────────────────────────────────
    def retrieve(
        self,
        query: str,
        filter_by: dict | None = None,
    ) -> list[RetrievedChunk]:
        """
        Выполняет векторный поиск по query.
        filter_by: {"source": "httpx-guide.md"} — метadata-фильтрация
        """
        query_vec = self.embed_model.encode(
            [query], normalize_embeddings=True
        )[0].tolist()

        qdrant_filter = None
        if filter_by:
            qdrant_filter = Filter(must=[
                FieldCondition(key=k, match=MatchValue(value=v))
                for k, v in filter_by.items()
            ])

        hits = self.qdrant.search(
            collection_name=self.cfg.collection_name,
            query_vector=query_vec,
            limit=self.cfg.top_k,
            query_filter=qdrant_filter,
            with_payload=True,
        )

        return [
            RetrievedChunk(
                text=hit.payload["text"],
                source=hit.payload.get("source", "unknown"),
                score=hit.score,
            )
            for hit in hits
        ]

    # ── Generation ──────────────────────────────────────────────────
    def generate(
        self,
        query: str,
        filter_by: dict | None = None,
    ) -> RAGResponse:
        # Шаг 1: Retrieval
        chunks = self.retrieve(query, filter_by=filter_by)

        # Шаг 2: Проверка качества контекста
        if not check_context_quality(chunks, min_score=self.cfg.min_score):
            return RAGResponse(
                answer=NO_CONTEXT_REPLY,
                chunks=chunks,
                query=query,
                skipped=True,
            )

        # Шаг 3: Форматирование контекста
        context = format_context(chunks)
        user_message = f"{context}\n\nВопрос: {query}"

        # Шаг 4: LLM generation
        response = self.client.messages.create(
            model=self.cfg.llm_model,
            max_tokens=self.cfg.max_tokens,
            temperature=self.cfg.temperature,
            system=self.cfg.system_prompt,
            messages=[{"role": "user", "content": user_message}],
        )

        return RAGResponse(
            answer=response.content[0].text,
            chunks=chunks,
            query=query,
        )


# ── Использование ───────────────────────────────────────────────────
pipeline = RAGGenerationPipeline(
    config=GenerationConfig(collection_name="my_docs"),
    qdrant_url="http://localhost:6333",
)

result = pipeline.generate("как настроить timeout в httpx?")
print(result.answer)
print(f"\nИспользовано {len(result.chunks)} чанков")

Streaming: ответ в реальном времени

Для web-интерфейса streaming критичен: пользователь видит начало ответа немедленно, а не ждёт завершения генерации целиком. Разница в perceived latency — 2–4 секунды против мгновенного старта вывода.

python — Streaming generation с Anthropic SDK
from anthropic import Anthropic

client = Anthropic()


def generate_streaming(
    query: str,
    chunks: list[RetrievedChunk],
    system_prompt: str,
    model: str = "claude-sonnet-4-6",
):
    """
    Generator: yield токены по одному по мере генерации.
    Используй в FastAPI с StreamingResponse или в CLI.
    """
    if not check_context_quality(chunks):
        yield NO_CONTEXT_REPLY
        return

    context = format_context(chunks)
    user_message = f"{context}\n\nВопрос: {query}"

    with client.messages.stream(
        model=model,
        max_tokens=1024,
        temperature=0,
        system=system_prompt,
        messages=[{"role": "user", "content": user_message}],
    ) as stream:
        for text in stream.text_stream:
            yield text


# ── FastAPI StreamingResponse ───────────────────────────────────────
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()


@app.get("/ask")
async def ask(query: str):
    # Retrieval — отдельно (не стримим)
    chunks = pipeline.retrieve(query)

    # Generation — стрим
    def token_generator():
        for token in generate_streaming(query, chunks, SYSTEM_PROMPT):
            yield token

    return StreamingResponse(token_generator(), media_type="text/plain")


# ── CLI вывод ───────────────────────────────────────────────────────
if __name__ == "__main__":
    query  = "как настроить таймаут в httpx?"
    chunks = pipeline.retrieve(query)
    print("Ответ: ", end="", flush=True)
    for token in generate_streaming(query, chunks, SYSTEM_PROMPT):
        print(token, end="", flush=True)
    print()

Многооборотный RAG: разговор с историей

Базовый RAG отвечает на каждый вопрос независимо. В чат-интерфейсе это выглядит неестественно: пользователь задаёт уточняющий вопрос, а система «не помнит» предыдущего контекста. Многооборотный RAG добавляет историю сообщений в промпт.

Ключевая проблема: standalone question. Вопрос «А что насчёт AsyncClient?» сам по себе не несёт смысла — он зависит от предыдущего контекста разговора. Векторный поиск по такому запросу вернёт мусор. Нужно сначала переформулировать вопрос в самодостаточную форму.

python — Многооборотный RAG с query rewriting
from anthropic import Anthropic

client = Anthropic()

REWRITE_PROMPT = """\
Тебе дана история разговора и последний вопрос пользователя.
Переформулируй последний вопрос в самодостаточный, сохранив его смысл.
Ответь только переформулированным вопросом, без пояснений.

История разговора:
{history}

Последний вопрос: {question}"""


def rewrite_question(
    question: str,
    history: list[dict],
) -> str:
    """
    Переформулирует вопрос с учётом истории.
    «А что насчёт AsyncClient?» → «Как настроить таймауты в httpx AsyncClient?»
    """
    if not history:
        return question  # первый вопрос — переформулировка не нужна

    history_text = "\n".join(
        f"{'Пользователь' if m['role']=='user' else 'Ассистент'}: {m['content'][:200]}"
        for m in history[-4:]  # берём последние 2 обмена
    )

    response = client.messages.create(
        model="claude-haiku-4-5-20251001",  # дешёвая модель для rewriting
        max_tokens=128,
        messages=[{
            "role": "user",
            "content": REWRITE_PROMPT.format(
                history=history_text,
                question=question,
            ),
        }],
    )
    return response.content[0].text.strip()


class MultiTurnRAG:
    def __init__(self, pipeline: RAGGenerationPipeline):
        self.pipeline = pipeline
        self.history: list[dict] = []

    def chat(self, user_message: str) -> str:
        # Переформулируем вопрос для поиска
        search_query = rewrite_question(user_message, self.history)

        # Retrieval по переформулированному запросу
        chunks = self.pipeline.retrieve(search_query)

        # Сохраняем оригинальный вопрос в историю
        self.history.append({"role": "user", "content": user_message})

        # Generation с полной историей в messages
        context = format_context(chunks)
        messages = [
            *self.history[:-1],      # история без последнего (уже добавили выше)
            {
                "role": "user",
                "content": f"{context}\n\nВопрос: {user_message}",
            },
        ]

        response = self.pipeline.client.messages.create(
            model=self.pipeline.cfg.llm_model,
            max_tokens=1024,
            temperature=0,
            system=self.pipeline.cfg.system_prompt,
            messages=messages,
        )

        answer = response.content[0].text
        self.history.append({"role": "assistant", "content": answer})
        return answer


# ── Пример диалога ──────────────────────────────────────────────────
rag = MultiTurnRAG(pipeline)

print(rag.chat("как настроить connect timeout в httpx?"))
# → Используйте httpx.Timeout(connect=5.0) ... [1]

print(rag.chat("а как отключить таймаут полностью?"))
# rewrite → «как отключить таймаут в httpx полностью?»
# → Передайте httpx.Timeout(None) ... [2]
Ограничение истории. Передавать всю историю разговора в промпт — значит линейно наращивать число токенов. Ограничивайте: history[-6:] (последние 3 обмена) обычно достаточно для контекста. Более длинные диалоги требуют суммаризации истории — отдельная задача.

Шпаргалка

Анатомия RAG-промпта:
System — роль + grounding-инструкция + fallback + формат цитат (один раз, не меняется)
Context<context>[1] src…\n\n[2] src…</context> (каждый запрос)
User — вопрос пользователя как есть (каждый запрос)

Системный промпт — четыре элемента:
1. Роль и домен
2. «Отвечай ТОЛЬКО из контекста» (категорично)
3. Fallback: «если нет — скажи об этом»
4. Формат цитирования: [N] + раздел «Источники:»

Форматирование контекста:
• XML-теги <context>…</context>
• Нумерация [1], [2], … перед каждым чанком
• Строка «Источник: filename» под номером
• Пустая строка между чанками

Параметры LLM для RAG:
• temperature=0 для factual QA
• max_tokens=512–1024
• Пустой контекст → проверяй quality score, не передавай LLM

Многооборотный RAG:
• Query rewriting → самодостаточный вопрос для поиска
• История: последние 3–4 обмена
• Cheap model (haiku) для rewriting, main model для generation

Практика

  1. Сравните grounding. Возьмите один запрос и ответьте на него с тремя вариантами системного промпта: (а) без инструкций, (б) мягкое «постарайся использовать контекст», (в) строгое «ТОЛЬКО из контекста». Для каждого варианта вручную проверьте: какие факты в ответе есть в контексте, а каких нет.
  2. Реализуйте цитирование с переходом к источнику. Расширьте RAGResponse: добавьте метод sources(), который возвращает список использованных источников, извлечённых из текста ответа по паттерну [N]. Выведите список с именами файлов рядом с ответом.
  3. Добавьте сохранение истории. Расширьте MultiTurnRAG: при инициализации принимайте session_id и сохраняйте/восстанавливайте историю из файла sessions/{session_id}.json. Это позволит продолжать разговор после перезапуска приложения.