
RAG (Retrieval-Augmented Generation) — это не одна технология, а архитектурный приём: мы соединяем поиск по базе знаний (retrieval) с генерацией текста (generation).
На английском всё работает прилично, а вот на русском начинаются приключения.
Причины банальны:
Морфология. У нас «книга», «книги», «книгой», «о книгах» — это всё одно слово, но простая векторная модель не понимает, что это так. Без лемматизации теряется до 40% качества поиска.
Токенизация. Большинство моделей обучались на латинице. Кириллица часто рвётся на абсурдные куски.
Дефицит данных. Русскоязычные корпуса и бенчмарки появились недавно — ruMTEB, RusBEIR, DRAGON — но выбор всё ещё ограничен.
Если просто подключить базовый RAG через LangChain и E5, получится система, которая в лучшем случае "угадывает".
На DRAGON-бенчмарке такие модели показывают faithfulness ≈ 0.55, то есть почти половина ответов содержит галлюцинации.
Для серьёзного продакшена это неприемлемо.
Advanced RAG
Чтобы модель не врала, нужно не просто "прикрутить" поиск, а выстроить полноценный pipeline с несколькими уровнями контроля.
Ключевые компоненты:
Семантическое чанкирование.
Вместо тупого деления по 1000 символов используем sentence-aware разбиение (например, сrazdel
) и перекрытие на 150 токенов. Это даёт до +18% контекстного recall без роста времени ответа.Русскоязычные эмбеддинги.
Используем не простоmultilingual-e5
, а специализированные модели вродеBorisTM/bge-m3_en_ru
— прирост Recall@10 до 83%.
Да, они тяжелее, но выигрыш огромный.Гибридный поиск (BM25 + векторы).
Классический поиск по ключевым словам и семантический поиск должны работать вместе. Это особенно помогает при запросах с редкой терминологией.Кросс-энкодер для rerank-а.
После того как найдено 50 кандидатов, мы переоцениваем их моделью, которая "понимает" контекст.
Прирост точности по Top-1 — до 20%, а задержка — всего 30 мс на RTX 4090.RAG-Fusion для сложных (“multi-hop”) вопросов.
Когда нужно соединить несколько фактов, обычный RAG путается.
Трюк: генерируем несколько перефразов запроса, ищем по каждому и объединяем результаты с помощью Reciprocal Rank Fusion.
Faithfulness растёт ещё на 10 пунктов.
Пример из практики
Представьте себе базу знаний, содержащую предложение: «Инструкция по сборке лежит на столе». Пользователь спрашивает: «Где инструкция по сборке?». Наивная система RAG, использующая базовую многоязычную модель встраивания, может извлечь нерелевантные документы об офисной мебели, поскольку вектор для слова «столе» (на столе, предложный падеж) недостаточно близок к подразумеваемому в запросе слову «стол» (стол, именительный падеж). В этом случае система LLM, учитывая неточный контекст, вынуждена либо заявить, что не знает ответа, либо, что ещё хуже, фантазировать. Это классический пример низкого качества поиска, критического состояния сбоя в российских системах RAG.
Практическое занятие
В этом разделе представлен основной код для построения нашей системы RAG, отражающий структуру сопутствующего репозитория. Полный план проекта включает отдельные скрипты и блокноты для каждого этапа.Основа2
Прием и очистка данных
Сначала мы загружаем и очищаем наши русскоязычные документы из каталога.
# This code assumes you have installed the libraries from requirements.txt
import os
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader, UnstructuredFileLoader
from razdel import sentenize
import re
print("Libraries imported successfully.")
# Path to the directory containing your Russian knowledge base
DATA_PATH = 'data/knowledge_base'
# Using DirectoryLoader to load documents. It can be configured to handle different file types.
# For this example, we'll focus on PDFs.
loader = DirectoryLoader(DATA_PATH, glob="**/*.pdf", loader_cls=PyPDFLoader, show_progress=True)
documents = loader.load()
print(f"Loaded {len(documents)} documents from {DATA_PATH}")
# A simple text cleaning function
def clean_text(text):
# Remove excessive newlines and spaces
text = re.sub(r'\n+', '\n', text)
text = re.sub(r'\s+', ' ', text)
return text.strip()
# Clean the content of each document
for doc in documents:
doc.page_content = clean_text(doc.page_content)
Семантический фрагментатор сrazdel
Затем мы применяем нашу передовую стратегию фрагментации, чтобы разбить документы, соблюдая при этом границы предложений.
# Initialize the text splitter
# chunk_size: The maximum size of a chunk (in characters). Tune this based on your embedding model's context window.
# chunk_overlap: The number of characters to overlap between chunks. This helps maintain context across chunks.
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
length_function=len, # We use character length here, but token length can also be used.
separators=["\n\n", "\n", ". ", " ", ""] # Tries to split on paragraphs, then lines, then sentences.
)
# Split the documents into chunks
chunks = text_splitter.split_documents(documents)
print(f"Split {len(documents)} documents into {len(chunks)} chunks.")
# Let's inspect a chunk to see the result
if chunks:
print("\n--- Example Chunk ---")
print(chunks[0].page_content)
print("\n--- Metadata ---")
print(chunks[0].metadata)
Встраивание и индекс FAISS
Теперь мы выбираем нашу модель встраивания, генерируем встраивания для фрагментов и сохраняем их в хранилище векторов FAISS.
import torch
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings
# Define the device to use (GPU if available, otherwise CPU)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"Using device: {device}")
# Define the embedding model from Hugging Face
# 'intfloat/multilingual-e5-large' is a strong choice for multilingual tasks including Russian.
model_name = "intfloat/multilingual-e5-large"
# It's important to normalize embeddings for this model
model_kwargs = {'device': device}
encode_kwargs = {'normalize_embeddings': True}
embeddings = HuggingFaceEmbeddings(
model_name=model_name,
model_kwargs=model_kwargs,
encode_kwargs=encode_kwargs
)
print(f"Embedding model '{model_name}' loaded successfully.")
# Define the path to save the FAISS index
DB_FAISS_PATH = 'vectorstore/db_faiss'
# Create the FAISS vector store from the document chunks and embeddings
print("Creating FAISS vector store... This may take a while.")
vectordb = FAISS.from_documents(documents=chunks, embedding=embeddings)
# Save the vector store locally
vectordb.save_local(DB_FAISS_PATH)
print(f"FAISS index created and saved to {DB_FAISS_PATH}")
Конструкция цепи LCEL RAG
Когда компоненты готовы, мы используем язык выражений LangChain (LCEL) для построения финального конвейера.
from langchain_community.llms import LlamaCpp
from langchain.prompts import PromptTemplate
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser
# --- Load Components ---
vectordb = FAISS.load_local(DB_FAISS_PATH, embeddings, allow_dangerous_deserialization=True)
retriever = vectordb.as_retriever(search_kwargs={'k': 4}) # Retrieve top 4 chunks
llm = LlamaCpp(
model_path="/path/to/your/model.gguf", # Provide path to your GGUF model
n_gpu_layers=-1, n_ctx=4096, f16_kv=True, temperature=0.1
)
# --- Create Prompt Template ---
prompt_template_str = """
Используй следующий контекст, чтобы ответить на вопрос. Если ты не знаешь ответ, просто скажи, что не знаешь. Не пытайся выдумать ответ. Отвечай на русском языке.
Контекст: {context}
Вопрос: {question}
Ответ:
"""
prompt = PromptTemplate(template=prompt_template_str, input_variables=["context", "question"])
# --- Build RAG Chain ---
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
print("RAG chain created successfully.")
Выполнение вашего первого запроса
Наконец, давайте протестируем систему с помощью русского запроса.
# Example query in Russian
query = "Что такое эффект Доплера и где он применяется?"
print(f"\nQuerying the RAG chain with: '{query}'")
# Invoke the chain to get the answer
answer = rag_chain.invoke(query)
print("\n--- Generated Answer ---")
print(answer)
requirements.txt
torch==2.1.0
transformers==4.36.2
sentence-transformers==2.2.2
accelerate==0.25.0
langchain==0.1.0
unstructured==0.12.0
pypdf==3.17.4
razdel==0.5.0
pymorphy2==0.9.1
# Vector store
faiss-gpu==1.7.2
# LLM inference
llama-cpp-python==0.2.20
bitsandbytes==0.41.3
ragas==0.0.22
python-dotenv==1.0.0
Спасибо за прочтение!
1fid
Когда сгенерировал статью, но сам не понял о чем она.
Из 5 пунктов предложенного пайплайна только один хоть как-то связан с русским языком - использование русскоязычных эмбеддингов. Всё остальное это просто маст хев для любого RAG. Кстати, в описании bge-m3_en_ru написано, что это глобальная мультилингвальная версия bge-m3 с порезанными токенами. Это не магически натренированные для русского токены, а мультилингвальные токены из которых оставили только en и ru.