Введение: почему RAG стал стандартом де-факто в 2025–2026 годах
Retrieval-Augmented Generation (RAG) — пожалуй, самый обсуждаемый паттерн в мире LLM-приложений. И это неспроста. Большие языковые модели впечатляют своими знаниями, но у них есть три фундаментальных ограничения: данные обучения устаревают, модель склонна «галлюцинировать» факты, и — что особенно болезненно для бизнеса — она понятия не имеет о ваших корпоративных данных. RAG решает все три проблемы разом, подставляя релевантный контекст из внешних источников прямо в промпт.
Но вот в чём подвох.
Между демонстрационным RAG-приложением, которое можно собрать за 20 минут, и production-ready системой, обрабатывающей тысячи запросов в день, лежит настоящая пропасть. Naive RAG — просто разбить документ на чанки, положить в векторную базу и отправить результат поиска в LLM — работает для прототипов. Но стоит выйти в реальный мир, и вы столкнётесь с проблемами качества ответов, скорости, масштабирования и стоимости. Знаю по собственному опыту: первый «боевой» RAG-пайплайн, который я запускал, красиво работал на демо-данных и ужасно — на реальных документах клиента.
В этом руководстве мы пройдём весь путь: от базовой архитектуры до Advanced RAG, Agentic RAG и GraphRAG. Каждую концепцию подкрепим практическим кодом на Python. Если вы уже знакомы с Function Calling и Tool Use в LLM-агентах, считайте эту статью логическим продолжением — RAG-пайплайн часто выступает именно как инструмент, который агент вызывает для получения информации.
Архитектура RAG: три поколения
Naive RAG: основа, которую нужно перерасти
Классический RAG-пайплайн состоит из трёх этапов: индексация (Indexing), извлечение (Retrieval) и генерация (Generation). Звучит просто, и так оно и есть — в этом одновременно и плюс, и минус.
На этапе индексации документы разбиваются на чанки фиксированного размера (например, 512 токенов), для каждого чанка генерируется векторное представление (embedding), и результат сохраняется в векторной базе данных. Затем пользовательский запрос тоже превращается в вектор, выполняется поиск ближайших соседей (approximate nearest neighbor, ANN), найденные чанки подставляются в промпт, и LLM генерирует финальный ответ.
Честно говоря, для демо этого достаточно. Но в продакшене проблемы посыпятся одна за другой:
- Потеря контекста при чанкинге — разрезая документ на куски фиксированного размера, вы неизбежно рвёте логические связи
- Semantic gap — вопрос пользователя и релевантный чанк могут быть сформулированы совершенно по-разному
- Нет верификации — модель генерирует ответ на основе первых K результатов, даже не проверяя их релевантность
- Одноходовость — один запрос, один поиск, один ответ. Без возможности уточнить или переспросить
Advanced RAG: оптимизация на каждом этапе
Advanced RAG добавляет оптимизации до извлечения (pre-retrieval), во время извлечения и после (post-retrieval). Это не одна конкретная техника, а целый набор улучшений, которые можно комбинировать между собой:
- Pre-retrieval: трансформация запроса, HyDE (Hypothetical Document Embeddings), расширение запроса, классификация намерения
- Retrieval: гибридный поиск (BM25 + векторный), re-ranking, multi-query retrieval
- Post-retrieval: сжатие контекста, фильтрация, суммаризация извлечённых документов
Agentic RAG: интеллектуальная оркестрация
А вот это уже по-настоящему интересно. Agentic RAG — следующий эволюционный шаг, который к 2026 году стал рекомендуемым подходом для серьёзных AI-приложений. Вместо линейного пайплайна мы получаем автономного агента, способного:
- Декомпозировать сложный вопрос на подзадачи
- Выбирать стратегию извлечения для каждого подзапроса
- Оценивать качество полученных документов и при необходимости повторять поиск
- Проверять сгенерированный ответ на галлюцинации
- Использовать несколько источников данных — векторная база, граф знаний, SQL, внешние API
По сути, Agentic RAG объединяет паттерны Function Calling (мы разбирали их в предыдущей статье) с продвинутым извлечением информации. Агент сам «решает», когда и как искать, а не просто механически выполняет поиск по шаблону.
Фундамент: векторные базы данных и эмбеддинги
Выбор модели эмбеддингов
Качество RAG начинается с качества эмбеддингов — тут без вариантов. В 2026 году ландшафт моделей эмбеддингов существенно расширился, так что выбирать есть из чего. Вот актуальные рекомендации:
- OpenAI text-embedding-3-large — 3072 измерения, отличный баланс качества и стоимости. Классная фишка — поддержка сокращения размерности (можно задать
dimensions=1536или дажеdimensions=256для экономии) - Cohere embed-v4 — нативная поддержка мультимодальности и сжатия. Отлично работает с многоязычным контентом
- Открытые модели (BGE, E5, GTE) — можно запускать локально, не отправляя данные на внешние серверы. Критично для корпоративных сценариев с чувствительными данными
Практический совет: всегда тестируйте модель эмбеддингов на вашем конкретном домене. Модель, лидирующая в бенчмарках MTEB, запросто может уступать на ваших данных какой-нибудь более нишевой модели. Я видел это не раз.
Выбор векторной базы данных
Здесь выбор зависит от масштаба и требований:
- Chroma — лёгкая, in-process, идеальна для прототипов и малых приложений
- Qdrant — написана на Rust, высокопроизводительная, с отличной поддержкой фильтрации по метаданным
- Weaviate — встроенный гибридный поиск, GraphQL API
- Pinecone — полностью управляемый сервис, минимум DevOps
- pgvector — расширение для PostgreSQL. Если вы уже используете PostgreSQL и не хотите вводить отдельный сервис, это отличный вариант
Для production-систем в 2026 году Qdrant и pgvector — наиболее популярный выбор. Qdrant берут, когда нужна максимальная производительность векторного поиска. pgvector — когда важнее единая база данных и транзакционность.
Практическая реализация базового RAG
Давайте начнём с минимально работающего примера, чтобы увидеть полный цикл. Используем OpenAI для эмбеддингов и генерации, а ChromaDB — как векторное хранилище:
import chromadb
from openai import OpenAI
from typing import list
client = OpenAI()
chroma_client = chromadb.PersistentClient(path="./chroma_db")
# Создаём или получаем коллекцию
collection = chroma_client.get_or_create_collection(
name="knowledge_base",
metadata={"hnsw:space": "cosine"}
)
def get_embedding(text: str) -> list[float]:
"""Получение эмбеддинга через OpenAI API."""
response = client.embeddings.create(
model="text-embedding-3-large",
input=text,
dimensions=1536 # Сокращаем для экономии
)
return response.data[0].embedding
def index_documents(documents: list[dict]):
"""Индексация документов в ChromaDB."""
for i, doc in enumerate(documents):
embedding = get_embedding(doc["content"])
collection.add(
ids=[f"doc_{i}"],
embeddings=[embedding],
documents=[doc["content"]],
metadatas=[{
"source": doc.get("source", ""),
"title": doc.get("title", ""),
"category": doc.get("category", "")
}]
)
def retrieve(query: str, n_results: int = 5) -> list[str]:
"""Поиск релевантных документов."""
query_embedding = get_embedding(query)
results = collection.query(
query_embeddings=[query_embedding],
n_results=n_results
)
return results["documents"][0]
def generate_answer(query: str, context_docs: list[str]) -> str:
"""Генерация ответа с контекстом."""
context = "\n\n---\n\n".join(context_docs)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": (
"Вы — экспертный ассистент. Отвечайте на вопросы "
"ТОЛЬКО на основе предоставленного контекста. "
"Если контекст не содержит ответа, так и скажите."
)
},
{
"role": "user",
"content": f"Контекст:\n{context}\n\nВопрос: {query}"
}
],
temperature=0.1
)
return response.choices[0].message.content
# Пример использования
query = "Как настроить мониторинг RAG-пайплайна?"
docs = retrieve(query)
answer = generate_answer(query, docs)
print(answer)
Это работает, но это именно Naive RAG. Теперь давайте превратим его в нечто более серьёзное.
Стратегии чанкинга: от наивного к семантическому
Почему размер чанка — это не просто число
Чанкинг — одна из самых недооценённых частей RAG-пайплайна. И я говорю это без преувеличения. Неправильный чанкинг может убить качество даже при идеальных эмбеддингах и самой мощной LLM.
Ключевой компромисс тут такой: маленькие чанки (100–200 токенов) дают более точное попадание в поиске, но теряют контекст. Большие чанки (1000–2000 токенов) сохраняют контекст, но снижают точность поиска и рискуют «разбавить» релевантную информацию нерелевантной. Золотая середина? Зависит от вашего домена (да, я знаю, скучный ответ, но это правда).
Семантический чанкинг
Вместо разрезания по фиксированному количеству токенов мы определяем границы чанков по смысловым переходам. Идея на удивление проста: вычисляем эмбеддинги для последовательных предложений и ищем точки, где косинусное сходство резко падает — это и есть смена темы:
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def semantic_chunking(
sentences: list[str],
threshold: float = 0.75,
min_chunk_size: int = 2,
max_chunk_size: int = 15
) -> list[str]:
"""Семантический чанкинг на основе сходства эмбеддингов."""
# Получаем эмбеддинги для каждого предложения
embeddings = [get_embedding(s) for s in sentences]
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
similarity = cosine_similarity(
[embeddings[i-1]], [embeddings[i]]
)[0][0]
if (similarity < threshold
and len(current_chunk) >= min_chunk_size):
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i]]
elif len(current_chunk) >= max_chunk_size:
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i]]
else:
current_chunk.append(sentences[i])
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
Контекстные заголовки (Contextual Headers)
Мощная и, честно говоря, недооценённая техника. Суть простая: перед каждым чанком добавляется иерархический контекст — название документа, заголовок раздела, подзаголовок. Это даёт эмбеддингу куда больше информации о том, о чём конкретно этот чанк:
def add_contextual_header(
chunk: str,
doc_title: str,
section: str,
subsection: str = ""
) -> str:
"""Добавление контекстного заголовка к чанку."""
header_parts = [f"Документ: {doc_title}", f"Раздел: {section}"]
if subsection:
header_parts.append(f"Подраздел: {subsection}")
header = " | ".join(header_parts)
return f"{header}\n\n{chunk}"
# Пример
enriched_chunk = add_contextual_header(
chunk="Для настройки алертов используйте Prometheus Alertmanager...",
doc_title="Руководство по мониторингу",
section="Алертинг",
subsection="Настройка правил"
)
# Результат:
# "Документ: Руководство по мониторингу | Раздел: Алертинг |
# Подраздел: Настройка правил
#
# Для настройки алертов используйте Prometheus Alertmanager..."
Parent-Child чанкинг
Ещё одна продвинутая стратегия, которая мне особенно нравится. Идея: для поиска используются маленькие (child) чанки — они дают точное попадание. Но в контекст LLM подставляются большие (parent) чанки, которые сохраняют полный контекст. Лучшее из обоих миров:
from dataclasses import dataclass
@dataclass
class ChunkPair:
child: str # Маленький чанк для поиска
parent: str # Большой чанк для контекста
parent_id: str
def create_parent_child_chunks(
text: str,
parent_size: int = 2000,
child_size: int = 400,
overlap: int = 50
) -> list[ChunkPair]:
"""Создание пар parent-child чанков."""
words = text.split()
pairs = []
parent_id = 0
for i in range(0, len(words), parent_size):
parent_chunk = " ".join(words[i:i + parent_size])
parent_words = parent_chunk.split()
for j in range(0, len(parent_words), child_size - overlap):
child_chunk = " ".join(
parent_words[j:j + child_size]
)
if len(child_chunk.split()) > 20:
pairs.append(ChunkPair(
child=child_chunk,
parent=parent_chunk,
parent_id=f"parent_{parent_id}"
))
parent_id += 1
return pairs
Гибридный поиск: объединяем лучшее из двух миров
Зачем нужен гибридный поиск
Чисто векторный поиск плохо справляется с точными совпадениями: артикулами, кодами продуктов, именами собственными, аббревиатурами. Классический BM25 (keyword search) прекрасно работает с такими запросами, но не понимает семантику.
Гибридный поиск объединяет оба подхода через Reciprocal Rank Fusion (RRF). И знаете что? В 2026 году гибридный поиск стал фактически стандартной рекомендацией. Если вы его не используете — скорее всего, вы теряете качество.
Реализация гибридного поиска
from rank_bm25 import BM25Okapi
import numpy as np
class HybridRetriever:
"""Гибридный поиск: BM25 + векторный с RRF."""
def __init__(self, documents: list[dict], collection):
self.documents = documents
self.collection = collection
# Подготовка BM25 индекса
tokenized = [
doc["content"].lower().split()
for doc in documents
]
self.bm25 = BM25Okapi(tokenized)
def reciprocal_rank_fusion(
self,
rankings: list[list[int]],
k: int = 60
) -> list[tuple[int, float]]:
"""Объединение ранжирований через RRF."""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
if doc_id not in scores:
scores[doc_id] = 0
scores[doc_id] += 1 / (k + rank + 1)
return sorted(
scores.items(), key=lambda x: x[1], reverse=True
)
def search(
self,
query: str,
top_k: int = 5,
vector_weight: float = 0.6,
bm25_weight: float = 0.4
) -> list[dict]:
"""Гибридный поиск с настраиваемыми весами."""
# BM25 поиск
bm25_scores = self.bm25.get_scores(
query.lower().split()
)
bm25_ranking = np.argsort(bm25_scores)[::-1][:top_k * 2]
# Векторный поиск
query_embedding = get_embedding(query)
vector_results = self.collection.query(
query_embeddings=[query_embedding],
n_results=top_k * 2
)
vector_ranking = [
int(id.split("_")[1])
for id in vector_results["ids"][0]
]
# RRF слияние
fused = self.reciprocal_rank_fusion(
[list(bm25_ranking), vector_ranking]
)
# Возвращаем top_k результатов
results = []
for doc_id, score in fused[:top_k]:
results.append({
"content": self.documents[doc_id]["content"],
"score": score,
"metadata": self.documents[doc_id].get(
"metadata", {}
)
})
return results
Re-ranking: второй этап фильтрации
Зачем нужен re-ranker
Первый этап поиска (BM25, векторный или гибридный) — это, по сути, грубая фильтрация. Мы берём, скажем, топ-20 кандидатов из миллионов документов. Но ранжирование среди этих 20 может быть неточным. Вот тут на сцену выходит re-ranker — вторая модель, которая внимательнее анализирует пару «запрос + документ» и выдаёт более точную оценку релевантности.
Ключевое отличие: bi-encoder (эмбеддинг-модель) кодирует запрос и документ отдельно. А cross-encoder (re-ranker) обрабатывает их совместно, что позволяет уловить тонкие связи между ними. Разница в качестве бывает существенной.
Реализация с Cohere Rerank
import cohere
def rerank_results(
query: str,
documents: list[str],
top_n: int = 5
) -> list[dict]:
"""Re-ranking через Cohere API."""
co = cohere.Client()
response = co.rerank(
model="rerank-v3.5",
query=query,
documents=documents,
top_n=top_n,
return_documents=True
)
return [
{
"text": r.document.text,
"relevance_score": r.relevance_score,
"index": r.index
}
for r in response.results
]
# Интеграция в пайплайн
def rag_with_reranking(query: str) -> str:
"""RAG-пайплайн с re-ranking."""
# Шаг 1: грубый поиск (больше кандидатов)
candidates = retrieve(query, n_results=20)
# Шаг 2: re-ranking (оставляем лучшие)
reranked = rerank_results(query, candidates, top_n=5)
# Шаг 3: генерация с отфильтрованным контекстом
context_docs = [r["text"] for r in reranked]
return generate_answer(query, context_docs)
Трансформация запросов: HyDE, Multi-Query и Step-Back
HyDE — Hypothetical Document Embeddings
HyDE — элегантная техника для преодоления семантического разрыва между вопросом и документом. Идея такая: мы просим LLM сгенерировать гипотетический ответ на вопрос, а затем ищем документы, похожие на этот гипотетический ответ (а не на исходный вопрос). Звучит контринтуитивно? Возможно. Но работает на удивление хорошо:
def hyde_retrieval(query: str, n_results: int = 5) -> list[str]:
"""Поиск с использованием HyDE."""
# Генерируем гипотетический ответ
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Напишите подробный параграф, отвечающий "
"на вопрос пользователя. Отвечайте так, "
"как будто это фрагмент документации."
)
},
{"role": "user", "content": query}
],
temperature=0.7
)
hypothetical_doc = response.choices[0].message.content
# Ищем документы, похожие на гипотетический ответ
hyp_embedding = get_embedding(hypothetical_doc)
results = collection.query(
query_embeddings=[hyp_embedding],
n_results=n_results
)
return results["documents"][0]
Multi-Query Retrieval
Вместо одного запроса генерируем несколько вариаций, выполняем поиск по каждой и объединяем результаты. Это особенно полезно, когда запрос пользователя можно интерпретировать по-разному:
def multi_query_retrieval(
query: str,
n_variants: int = 3,
n_results: int = 5
) -> list[str]:
"""Мульти-запросный поиск."""
# Генерируем вариации запроса
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
f"Сгенерируйте {n_variants} различных "
"формулировки данного вопроса. Каждая "
"формулировка должна подходить к теме с "
"разных сторон. Верните только список "
"вопросов, по одному на строку."
)
},
{"role": "user", "content": query}
]
)
variants = response.choices[0].message.content.strip().split("\n")
all_queries = [query] + variants
# Собираем результаты со всех вариаций
all_docs = set()
for q in all_queries:
docs = retrieve(q, n_results=n_results)
all_docs.update(docs)
return list(all_docs)[:n_results * 2]
GraphRAG: когда нужны связи между сущностями
Когда векторного поиска недостаточно
Векторный поиск отлично работает для вопросов типа «Что такое X?» или «Как сделать Y?». Но попробуйте задать что-нибудь вроде: «Какие компании из портфеля фонда Z работают в области генеративного ИИ и были основаны после 2020 года?». Тут нужны структурированные связи между сущностями: фонды → портфельные компании → области деятельности → даты основания. Обычный векторный поиск с этим просто не справится.
GraphRAG строит граф знаний (knowledge graph) поверх вашего корпуса и использует его для извлечения информации. По данным на 2026 год, GraphRAG обходит чисто векторные подходы для задач, требующих сложного рассуждения, — обеспечивая более высокую точность при меньших затратах.
Архитектура GraphRAG с Neo4j
Типичный GraphRAG-пайплайн работает в четыре шага:
- Извлечение сущностей и связей — LLM анализирует документы и выделяет сущности (люди, компании, технологии) и их взаимосвязи
- Построение графа — всё это сохраняется в графовой базе данных (Neo4j)
- Запрос — пользовательский вопрос преобразуется в Cypher-запрос или комбинируется с векторным поиском
- Генерация — LLM формирует ответ на основе данных из графа
from neo4j import GraphDatabase
from neo4j_graphrag.retrievers import HybridCypherRetriever
from neo4j_graphrag.llm import OpenAILLM
from neo4j_graphrag.embeddings import OpenAIEmbeddings
from neo4j_graphrag.generation import GraphRAG
# Подключение к Neo4j
driver = GraphDatabase.driver(
"bolt://localhost:7687",
auth=("neo4j", "password")
)
# Настройка компонентов
llm = OpenAILLM(model_name="gpt-4o")
embedder = OpenAIEmbeddings(model="text-embedding-3-large")
# Гибридный ретривер: Cypher + векторный поиск
retriever = HybridCypherRetriever(
driver=driver,
vector_index_name="document_embeddings",
fulltext_index_name="document_fulltext",
embedder=embedder,
# Cypher для обогащения результатов связями из графа
retrieval_query="""
MATCH (node)-[r]->(related)
RETURN node.text AS text,
score,
collect(type(r) + ": " + related.name) AS relations
""",
top_k=10
)
# Создаём GraphRAG пайплайн
rag = GraphRAG(
retriever=retriever,
llm=llm
)
# Запрос
result = rag.search(
query_text="Какие технологии связаны с компанией X?",
retriever_config={"top_k": 5}
)
print(result.answer)
Извлечение сущностей для построения графа
Ключевой этап — автоматическое извлечение сущностей и связей из документов. Тут нам снова поможет LLM:
import json
def extract_entities_and_relations(text: str) -> dict:
"""Извлечение сущностей и связей из текста."""
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": """Извлеките сущности и связи
из текста. Верните JSON с двумя массивами:
- entities: [{name, type, description}]
- relations: [{source, target, type, description}]
Типы сущностей: Person, Company, Technology,
Product, Concept
Типы связей: WORKS_AT, FOUNDED, USES, PART_OF,
RELATED_TO, COMPETES_WITH"""
},
{"role": "user", "content": text}
],
response_format={"type": "json_object"},
temperature=0
)
return json.loads(response.choices[0].message.content)
def populate_knowledge_graph(
driver,
entities: list[dict],
relations: list[dict]
):
"""Заполнение графа знаний в Neo4j."""
with driver.session() as session:
# Создаём сущности
for entity in entities:
session.run(
"""
MERGE (e:Entity {name: $name})
SET e.type = $type,
e.description = $description
""",
name=entity["name"],
type=entity["type"],
description=entity.get("description", "")
)
# Создаём связи
for rel in relations:
session.run(
"""
MATCH (a:Entity {name: $source})
MATCH (b:Entity {name: $target})
MERGE (a)-[r:RELATED {type: $type}]->(b)
SET r.description = $description
""",
source=rel["source"],
target=rel["target"],
type=rel["type"],
description=rel.get("description", "")
)
Agentic RAG на LangGraph: полная реализация
Архитектура агентного RAG
Итак, пришло время собрать всё воедино. Объединим все описанные техники в интеллектуального агента, используя LangGraph — библиотеку для построения stateful-графов с LLM. Наш агент будет:
- Классифицировать входящий запрос
- Решать, нужно ли извлечение (или модель может ответить сама)
- Выполнять гибридный поиск
- Оценивать релевантность найденных документов
- При необходимости переформулировать запрос и повторить поиск
- Генерировать ответ и проверять его на галлюцинации
Выглядит как много шагов, но каждый из них критически важен для качества:
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator
class RAGState(TypedDict):
"""Состояние агента."""
question: str
documents: list[str]
generation: str
search_count: int
is_relevant: bool
needs_web_search: bool
def route_question(state: RAGState) -> str:
"""Маршрутизация: нужно ли извлечение?"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Определите, требует ли вопрос поиска "
"в базе знаний. Ответьте 'retrieve' "
"или 'direct'."
)
},
{"role": "user", "content": state["question"]}
],
temperature=0
)
decision = response.choices[0].message.content.strip()
return "retrieve" if "retrieve" in decision.lower() else "direct"
def retrieve_documents(state: RAGState) -> RAGState:
"""Извлечение документов с гибридным поиском."""
docs = hybrid_retriever.search(
state["question"], top_k=10
)
# Re-ranking
reranked = rerank_results(
state["question"],
[d["content"] for d in docs],
top_n=5
)
state["documents"] = [r["text"] for r in reranked]
state["search_count"] = state.get("search_count", 0) + 1
return state
def grade_documents(state: RAGState) -> RAGState:
"""Оценка релевантности найденных документов."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Оцените, содержат ли документы "
"информацию для ответа на вопрос. "
"Ответьте 'relevant' или 'not_relevant'."
)
},
{
"role": "user",
"content": (
f"Вопрос: {state['question']}\n\n"
f"Документы: {state['documents']}"
)
}
],
temperature=0
)
decision = response.choices[0].message.content.strip()
state["is_relevant"] = "relevant" in decision.lower()
return state
def transform_query(state: RAGState) -> RAGState:
"""Переформулирование запроса."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Переформулируйте вопрос для более "
"точного поиска. Верните только "
"переформулированный вопрос."
)
},
{"role": "user", "content": state["question"]}
]
)
state["question"] = (
response.choices[0].message.content.strip()
)
return state
def generate(state: RAGState) -> RAGState:
"""Генерация финального ответа."""
state["generation"] = generate_answer(
state["question"],
state["documents"]
)
return state
def check_hallucination(state: RAGState) -> str:
"""Проверка ответа на галлюцинации."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Проверьте, основан ли ответ на "
"предоставленных документах. Ответьте "
"'grounded' или 'hallucination'."
)
},
{
"role": "user",
"content": (
f"Документы: {state['documents']}\n\n"
f"Ответ: {state['generation']}"
)
}
],
temperature=0
)
decision = response.choices[0].message.content.strip()
if "grounded" in decision.lower():
return "accept"
return "retry"
def decide_after_grading(state: RAGState) -> str:
"""Решение после оценки документов."""
if state["is_relevant"]:
return "generate"
if state.get("search_count", 0) >= 3:
return "generate" # Максимум попыток
return "transform"
# Построение графа
workflow = StateGraph(RAGState)
# Добавляем узлы
workflow.add_node("retrieve", retrieve_documents)
workflow.add_node("grade", grade_documents)
workflow.add_node("transform", transform_query)
workflow.add_node("generate", generate)
# Определяем рёбра
workflow.set_conditional_entry_point(
route_question,
{"retrieve": "retrieve", "direct": "generate"}
)
workflow.add_edge("retrieve", "grade")
workflow.add_conditional_edges(
"grade",
decide_after_grading,
{"generate": "generate", "transform": "transform"}
)
workflow.add_edge("transform", "retrieve")
workflow.add_conditional_edges(
"generate",
check_hallucination,
{"accept": END, "retry": "transform"}
)
# Компилируем
app = workflow.compile()
# Запуск
result = app.invoke({
"question": "Как настроить мониторинг RAG?",
"documents": [],
"generation": "",
"search_count": 0,
"is_relevant": False,
"needs_web_search": False
})
Corrective RAG (CRAG): самокорректирующийся пайплайн
Идея CRAG
Corrective RAG добавляет петлю обратной связи перед генерацией. Система оценивает, достаточно ли хорош набор извлечённых документов. Если нет — запускается дополнительный поиск, применяются более строгие фильтры или задействуются альтернативные источники (например, веб-поиск).
CRAG выделяет три категории оценки документов:
- Correct — хотя бы один документ содержит релевантную информацию → извлекаем ключевые фрагменты и генерируем ответ
- Incorrect — ни один документ не релевантен → переключаемся на веб-поиск
- Ambiguous — не уверены → комбинируем извлечённые документы с результатами веб-поиска на всякий случай
def corrective_rag_pipeline(query: str) -> str:
"""CRAG: самокорректирующийся RAG."""
# Шаг 1: Первичное извлечение
docs = retrieve(query, n_results=10)
# Шаг 2: Оценка каждого документа
scored_docs = []
for doc in docs:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Оцените релевантность документа "
"для вопроса по шкале 1-5. Верните "
"только число."
)
},
{
"role": "user",
"content": (
f"Вопрос: {query}\n"
f"Документ: {doc}"
)
}
],
temperature=0
)
score = int(
response.choices[0].message.content.strip()
)
if score >= 3:
scored_docs.append(doc)
# Шаг 3: Решение о коррекции
if len(scored_docs) == 0:
# Incorrect: переключаемся на веб-поиск
web_results = web_search_fallback(query)
return generate_answer(query, web_results)
elif len(scored_docs) < 2:
# Ambiguous: дополняем из альтернативных источников
web_results = web_search_fallback(query)
all_docs = scored_docs + web_results[:3]
return generate_answer(query, all_docs)
else:
# Correct: генерируем на основе найденного
return generate_answer(query, scored_docs[:5])
Оценка качества RAG-пайплайна
Метрики, которые действительно имеют значение
Невозможно улучшить то, что нельзя измерить — банальность, но от этого не менее верная. Для RAG-пайплайнов существует набор специализированных метрик:
- Retrieval Precision — какая доля извлечённых документов действительно релевантна
- Retrieval Recall — какая доля всех релевантных документов была найдена
- Faithfulness — насколько ответ основан на извлечённых документах (а не на «внутренних знаниях» модели)
- Answer Relevance — насколько ответ соответствует вопросу
- Context Precision — содержится ли ответ в документах с высоким рангом
Автоматическая оценка с RAGAS
RAGAS (Retrieval Augmented Generation Assessment) — пожалуй, самый популярный фреймворк для оценки RAG в 2026 году. Он использует LLM для автоматической оценки, что избавляет от необходимости ручной разметки:
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall
)
from datasets import Dataset
def evaluate_rag(test_questions: list[dict]) -> dict:
"""Оценка RAG-пайплайна с помощью RAGAS."""
results = {
"question": [],
"answer": [],
"contexts": [],
"ground_truth": []
}
for item in test_questions:
docs = retrieve(item["question"], n_results=5)
answer = generate_answer(item["question"], docs)
results["question"].append(item["question"])
results["answer"].append(answer)
results["contexts"].append(docs)
results["ground_truth"].append(
item["expected_answer"]
)
dataset = Dataset.from_dict(results)
scores = evaluate(
dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall
]
)
return scores
Оптимизация для production
Кэширование
Многоуровневое кэширование — одна из тех вещей, которые существенно снижают и латентность, и стоимость. Три уровня, на которые стоит обратить внимание:
- Кэш эмбеддингов — не пересчитывайте эмбеддинги для одинаковых запросов (казалось бы, очевидно, но многие это упускают)
- Семантический кэш — если новый запрос семантически похож на уже кэшированный, можно вернуть кэшированный ответ
- Кэш ответов LLM — для точных совпадений запросов + контекста
import hashlib
from functools import lru_cache
class SemanticCache:
"""Семантический кэш для RAG-ответов."""
def __init__(self, similarity_threshold: float = 0.95):
self.threshold = similarity_threshold
self.cache_collection = (
chroma_client.get_or_create_collection("cache")
)
def get(self, query: str) -> str | None:
"""Поиск в кэше."""
query_emb = get_embedding(query)
results = self.cache_collection.query(
query_embeddings=[query_emb],
n_results=1
)
if (results["distances"]
and results["distances"][0]
and results["distances"][0][0] < (
1 - self.threshold)):
return results["metadatas"][0][0]["answer"]
return None
def set(self, query: str, answer: str):
"""Сохранение в кэш."""
query_emb = get_embedding(query)
cache_id = hashlib.md5(
query.encode()
).hexdigest()
self.cache_collection.add(
ids=[cache_id],
embeddings=[query_emb],
documents=[query],
metadatas=[{"answer": answer}]
)
Guardrails: входной и выходной контроль
В production-системе без защиты от некорректных запросов не обойтись. Вот минимальный набор, который стоит реализовать:
- Input guardrails: фильтрация промпт-инъекций, определение токсичности, проверка на off-topic запросы
- Output guardrails: проверка на галлюцинации, фильтрация PII (персональных данных), контроль тональности
- Access control: синхронизация прав доступа к документам с вашей IAM-системой
Мониторинг и observability
Для production RAG мониторинг каждого этапа пайплайна — это не «было бы неплохо», а необходимость. Вот что стоит отслеживать:
- Латентность каждого этапа (embedding, retrieval, re-ranking, generation)
- Качество извлечения (средний relevance score, процент пустых результатов)
- Стоимость (токены LLM, количество API-вызовов — эти цифры могут неприятно удивить)
- Обратная связь пользователей (thumbs up/down, re-ask rate)
Инструменты вроде LangSmith, LangWatch и Phoenix от Arize предоставляют готовые дашборды для трассировки RAG-пайплайнов. Настоятельно рекомендую подключить хотя бы один из них с самого начала.
Практические рекомендации: чеклист для production RAG
Давайте подведём итог. Вот чеклист, который пригодится вам при запуске RAG-системы в production:
- Чанкинг: используйте семантический чанкинг с контекстными заголовками. Оптимальный размер чанка — 400–800 токенов с перекрытием 10–15%
- Поиск: гибридный (BM25 + векторный) — стандарт в 2026 году. Добавьте re-ranking для сложных запросов
- Эмбеддинги: тестируйте несколько моделей на вашем домене. Рассмотрите fine-tuning эмбеддинг-модели на ваших данных
- Трансформация запросов: реализуйте HyDE или multi-query для сложных запросов
- Оценка: настройте автоматическую оценку через RAGAS. Создайте тестовый набор хотя бы из 50–100 пар «вопрос-ответ»
- Кэширование: семантический кэш значительно снижает стоимость и латентность
- Guardrails: входной и выходной контроль обязательны для enterprise-систем
- Мониторинг: трассируйте каждый этап пайплайна, собирайте обратную связь от пользователей
- GraphRAG: для доменов со сложными связями между сущностями рассмотрите комбинацию векторного и графового поиска
- Agentic RAG: для сложных запросов используйте агентный подход с самокоррекцией
Заключение
RAG за последние два года прошёл впечатляющий эволюционный путь — от простого «найди похожие чанки и подставь в промпт» до сложных мультимодальных систем с самокоррекцией, графами знаний и агентной оркестрацией.
Но вот что важно: не пытайтесь реализовать всё сразу. Начните с Naive RAG, измерьте качество, определите слабые места и точечно добавляйте Advanced-техники. По моему опыту, гибридный поиск и re-ranking обычно дают наибольший прирост качества при минимальных усилиях. Agentic RAG и GraphRAG — это уже для случаев, когда базовых оптимизаций действительно недостаточно.
И напоследок: лучший RAG-пайплайн — тот, который вы можете измерить, мониторить и итеративно улучшать. Инвестируйте в инфраструктуру оценки не меньше, чем в сам пайплайн, и ваша система будет стабильно расти в качестве. Удачи с вашими пайплайнами!