RAG не панацея
Ты собрал RAG-систему. Эмбеддинги загнал, ChromaDB поднял, GPT-4o сверху прикрутил. Подаёшь вопрос — а модель возвращает не то. Нет, она не галлюцинирует — она просто склеила релевантный кусок с мусором, и контекст пошёл вразнос. Знакомо?
В эксперименте мы выяснили: даже при идеальной индексации ответ может быть неверным из-за того, как LLM интерпретирует несколько документов. Стандартный RAG — это только 60% успеха. Остальное решают реранкеры и грамотная оценка.
В этом гайде я покажу, как собрать цепочку: гибридный поиск → реранкер → LLM-as-Judge. Без заумных слов, с реальным кодом на Python, который можно сразу запустить.
Архитектура: от простого к сложному
Начнём с базы, прокачаем до продакшна. Если у тебя уже есть простая RAG-система — пропускай шаг 1.
1 «Наивный» RAG — планка, ниже которой не опускаться
Загружаем документы, режем на чанки, считаем эмбеддинги, складываем в векторную БД. При запросе — ищем по cosine similarity топ-k, отдаём LLM.
from sentence_transformers import SentenceTransformer
import chromadb
model = SentenceTransformer('all-MiniLM-L6-v2') # 384-мерный эмбеддинг
client = chromadb.PersistentClient(path='./db')
collection = client.get_or_create_collection('docs')
def index_docs(texts, ids):
embeddings = model.encode(texts).tolist()
collection.add(ids=ids, embeddings=embeddings, documents=texts)
def search(query, k=5):
q_emb = model.encode([query]).tolist()
results = collection.query(query_embeddings=q_emb, n_results=k)
return results['documents'][0]
Ошибка: если чанки большие (больше 512 токенов) — релевантность падает. Если мелкие — теряется контекст. Рекомендую размер 256-512 токенов с перекрытием 20-50 токенов. И всегда храни метаданные (источник, заголовок).
2 Гибридный поиск — когда эмбеддинги бессильны
Эмбеддинги отлично ловят семантику, но плохо работают с точными терминами (названиями продуктов, номерами заказов). BM25 — наоборот. Вместе они непобедимы.
from bm25s import BM25
import jieba # для токенизации на русском (опционально)
# Допустим, у нас чанки в списке
corpus = [...] # list of strings
tokenized_corpus = [jieba.lcut(doc) for doc in corpus]
bm25 = BM25()
bm25.index(tokenized_corpus)
def hybrid_search(query, k=5, alpha=0.5):
# Семантический поиск (из шага 1)
sem_docs = search(query, k=k*2) # берём больше для слияния
# BM25 поиск
query_tokens = jieba.lcut(query)
scores_bm25, indices = bm25.retrieve(query_tokens, k=k*2)
bm25_docs = [corpus[i] for i in indices[0]]
# Объединяем и ранжируем по взвешенной сумме
# (упрощённый вариант — в реальности используй ReRanker)
combined = list(set(sem_docs + bm25_docs))
return combined[:k]
Совет: не мучайся с реализацией взвешивания — поставь реранкер. Он сделает единую оценку релевантности и решит, какой документ важнее. Полное руководство по RAG отлично объясняет архитектуру.
Реранкер — убийца шума
После гибридного поиска у тебя может быть 10-20 документов. Скармливать их все LLM — путь к перегрузке контекста и ошибкам. Реранкер (cross-encoder) за доли секунды переранжирует их, оставив 2-3 действительно релевантных.
Почему cosine similarity не годится для финального ранжирования?
Bi-encoder (как all-MiniLM-L6) генерирует эмбеддинг независимо для запроса и документа. Cosine similarity между ними — грубая оценка. Cross-encoder склеивает запрос и документ и пропускает через трансформер — получает точную оценку релевантности. Разница — как смотреть на фото товара против чтения отзыва с вопросом-ответом.
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
tokenizer = AutoTokenizer.from_pretrained('BAAI/bge-reranker-v2-m3')
model = AutoModelForSequenceClassification.from_pretrained('BAAI/bge-reranker-v2-m3',
torch_dtype=torch.float16)
model.eval()
def rerank(query, docs, top_k=3):
pairs = [[query, doc] for doc in docs]
inputs = tokenizer(pairs, padding=True, truncation=True, return_tensors='pt', max_length=512)
with torch.no_grad():
scores = model(**inputs).logits.squeeze(-1)
ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:top_k]]
На практике BGE-reranker-v2-m3 (китайский, но мультиязычный) или Cohere Rerank 3 (облачный) дают прирост ~15-20% к точности. Бери open-source, если важна приватность, иначе — Cohere (сслыка Cohere Rerank — пробовал, вменяемый API).
LLM-as-Judge: твой беспристрастный аудитор
Как понять, что пайплайн работает? Метрики вроде Recall@k — слишком грубые. Нужна оценка качества финального ответа. Идея: пусть сама LLM проверит ответ по трём критериям: верность (faithfulness), релевантность (relevance), полезность (helpfulness).
Подробно с подходом можно познакомиться в статье LLM-as-a-judge: как оценивать RAG-системы и находить слабые места.
Промпт для судьи
judge_prompt = """Ты — эксперт по оценке RAG-систем. Пользователь задал вопрос, а система дала ответ на основе контекста.
Контекст: {context}
Вопрос: {question}
Ответ: {answer}
Оцени ответ по шкале 1-5:
1. Верность (Faithfulness): нет ли вымысла? Не противоречит ли контексту?
2. Релевантность (Relevance): отвечает ли на вопрос?
3. Полезность (Helpfulness): даёт ли полную информацию?
Выдай JSON: {{"faithfulness": число, "relevance": число, "helpfulness": число}}.
"""
def judge(question, context, answer):
response = openai.chat.completions.create(
model='gpt-4o',
messages=[{"role": "user", "content": judge_prompt.format(context=context, question=question, answer=answer)}],
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
Предупреждение: LLM-судья может быть предвзят к модели-кандидату. Если используешь GPT-4o как судью для ответов от GPT-4o — получишь завышенные баллы (self-bias). Лучше брать другую модель (Claude 3.5 Sonnet или открытую llama-3.1-70b).
Собираем всё вместе: финальный пайплайн
Теперь соберём цепочку в программу «векторный поиск» → «гибридный поиск» → «реранкер» → «LLM-as-Judge». Пример ниже — упрощён, но отражает логику.
from openai import OpenAI
client_openai = OpenAI()
def rag_pipeline(question):
# Шаг 1: гибридный поиск (эмбеддинги + BM25)
candidate_docs = hybrid_search(question, k=15)
# Шаг 2: реранкер
top_docs = rerank(question, candidate_docs, top_k=3)
context = "\n---\n".join(top_docs)
# Шаг 3: генерация ответа
sys_prompt = "Ответь на вопрос, используя только контекст. Если ответа нет — скажи, что не знаешь."
completion = client_openai.chat.completions.create(
model='gpt-4o',
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Контекст:\n{context}\n\nВопрос: {question}"}
]
)
answer = completion.choices[0].message.content
# Шаг 4: оценка
scores = judge(question, context, answer)
return answer, scores, top_docs
Запускаешь на тестовом датасете (50-100 вопросов), собираешь метрики. Если средняя верность ниже 4 — копай в контекст: может, реранкер пропускает шум или чанки плохо нарезаны. Об этом хорошо написано в статье Самовосстанавливающийся RAG — там показано, как фиксить галлюцинации в реальном времени.
Грабли, на которые я наступал
- Забыл про метаданные. Без указания источника LLM не может проверить факты. Добавляй source в контекст — поможет и судье, и пользователю.
- Слишком большой контекст. Перегружая LLM 10 документами, ты размываешь внимание. Деградация контекста — реальная проблема, особенно при длинных диалогах.
- Дорогой судья. Каждая оценка стоит денег. Используй дешёвую модель (gpt-4o-mini или llama-3.1-8b) для предварительного фильтра, и только сложные кейсы отправляй на полную оценку.
- Не тестируешь на краевых случаях. Пустые запросы, вопросы без ответа — проверь, чтобы система не падала. Delegation Filter подскажет, когда лучше вообще не дёргать LLM.
Что дальше?
Через год-два реранкеры станут встроенной фишкой векторных БД, а LLM-as-Judge — стандартным блоком CI/CD. Но уже сейчас, добавив эти два компонента, ты поднимешь качество RAG с «оно работает» до «оно работает круто».
Не ищи серебряную пулю. Возьми код из статьи, воткни свой датасет, замерь метрики. Увидишь — hybryd + reranker + judge дают +30-40% к F1 по сравнению с голым эмбеддингом. А потом напиши мне в комментах, какие грабли встретил — дополним гайд.