Online vs offline: место generation в RAG
RAG делится на две фазы. Indexing — офлайн: строим индекс один раз, обновляем при изменении корпуса. Generation — онлайн: выполняется при каждом запросе пользователя за миллисекунды. Именно поэтому у них разные требования: индексирование может занимать минуты, generation — должна укладываться в секунды.
Диаграмма показывает главное: RAG generation — это constrained generation. LLM не использует свои внутренние знания напрямую — он синтезирует ответ исключительно из трёх входов: системных инструкций, извлечённого контекста и вопроса пользователя. Ваша задача как разработчика — сформировать эти три части правильно.
Анатомия RAG-промпта: три части
Хорошо структурированный RAG-промпт всегда состоит из трёх чётко разделённых частей. Смешивать их или опускать — значит передавать LLM противоречивые сигналы.
Каждая часть выполняет строго свою роль. Системный промпт — это контракт с моделью, задаёт правила игры один раз. Context block — знания, актуальные для этого конкретного запроса, меняется каждый раз. User message — вопрос без изменений, как пришёл от пользователя.
Системный промпт: четыре обязательных элемента
Большинство проблем с качеством RAG-ответов решается на уровне системного промпта. Плохо написанный системный промпт — и LLM начинает галлюцинировать, игнорировать источники или давать уклончивые ответы.
[1], [источник] или полным именем файла.
Без инструкции модель либо не цитирует вовсе, либо делает это непоследовательно.Форматирование контекста: как передать чанки LLM
LLM обрабатывает токены последовательно, слева направо. Структура контекстного блока влияет на то, насколько хорошо модель «видит» границы между чанками и их источниками. Плохо отформатированный контекст — причина смешивания информации из разных документов.
Несколько рекомендаций по форматированию:
<context>…</context>.
Claude и другие модели обучены на XML-разметке — такие теги прочно разграничивают секции промпта.[1], [2] перед каждым чанком даёт LLM якорь для цитирования.
Без номеров модель либо не цитирует, либо пишет полное название файла посреди текста.---.
Помогает модели не «перетекать» из одного чанка в другой при генерации.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:
Обработка пустого контекста
Ситуация «релевантных документов не найдено» — обязательный кейс, который нужно обработать явно. Три варианта поведения:
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].
Чтобы получить такой вывод, добавьте в системный промпт инструкцию о формате цитат и передайте шаблон ответа. Вот промпт, который производит этот результат стабильно:
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-блока. |
Полный generation pipeline
Соберём всё вместе: retrieval + форматирование + generation в единый класс.
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 секунды против мгновенного старта вывода.
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?» сам по себе не несёт смысла — он зависит от предыдущего контекста разговора. Векторный поиск по такому запросу вернёт мусор. Нужно сначала переформулировать вопрос в самодостаточную форму.
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 обмена) обычно достаточно для контекста. Более длинные диалоги
требуют суммаризации истории — отдельная задача.
Шпаргалка
• 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
Практика
- Сравните grounding. Возьмите один запрос и ответьте на него с тремя вариантами системного промпта: (а) без инструкций, (б) мягкое «постарайся использовать контекст», (в) строгое «ТОЛЬКО из контекста». Для каждого варианта вручную проверьте: какие факты в ответе есть в контексте, а каких нет.
-
Реализуйте цитирование с переходом к источнику. Расширьте
RAGResponse: добавьте методsources(), который возвращает список использованных источников, извлечённых из текста ответа по паттерну[N]. Выведите список с именами файлов рядом с ответом. -
Добавьте сохранение истории. Расширьте
MultiTurnRAG: при инициализации принимайтеsession_idи сохраняйте/восстанавливайте историю из файлаsessions/{session_id}.json. Это позволит продолжать разговор после перезапуска приложения.