RAG (Retrieval-Augmented Generation) — это не одна технология, а архитектурный приём: мы соединяем поиск по базе знаний (retrieval) с генерацией текста (generation).
На английском всё работает прилично, а вот на русском начинаются приключения.

Причины банальны:

  • Морфология. У нас «книга», «книги», «книгой», «о книгах» — это всё одно слово, но простая векторная модель не понимает, что это так. Без лемматизации теряется до 40% качества поиска.

  • Токенизация. Большинство моделей обучались на латинице. Кириллица часто рвётся на абсурдные куски.

  • Дефицит данных. Русскоязычные корпуса и бенчмарки появились недавно — ruMTEB, RusBEIR, DRAGON — но выбор всё ещё ограничен.

Если просто подключить базовый RAG через LangChain и E5, получится система, которая в лучшем случае "угадывает".
На DRAGON-бенчмарке такие модели показывают faithfulness ≈ 0.55, то есть почти половина ответов содержит галлюцинации.
Для серьёзного продакшена это неприемлемо.

Advanced RAG

Чтобы модель не врала, нужно не просто "прикрутить" поиск, а выстроить полноценный pipeline с несколькими уровнями контроля.

Ключевые компоненты:

  1. Семантическое чанкирование.
    Вместо тупого деления по 1000 символов используем sentence-aware разбиение (например, с razdel) и перекрытие на 150 токенов. Это даёт до +18% контекстного recall без роста времени ответа.

  2. Русскоязычные эмбеддинги.
    Используем не просто multilingual-e5, а специализированные модели вроде BorisTM/bge-m3_en_ru — прирост Recall@10 до 83%.
    Да, они тяжелее, но выигрыш огромный.

  3. Гибридный поиск (BM25 + векторы).
    Классический поиск по ключевым словам и семантический поиск должны работать вместе. Это особенно помогает при запросах с редкой терминологией.

  4. Кросс-энкодер для rerank-а.
    После того как найдено 50 кандидатов, мы переоцениваем их моделью, которая "понимает" контекст.
    Прирост точности по Top-1 — до 20%, а задержка — всего 30 мс на RTX 4090.

  5. 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

Спасибо за прочтение!

Комментарии (1)


  1. 1fid
    05.10.2025 19:18

    Когда сгенерировал статью, но сам не понял о чем она.

    Из 5 пунктов предложенного пайплайна только один хоть как-то связан с русским языком - использование русскоязычных эмбеддингов. Всё остальное это просто маст хев для любого RAG. Кстати, в описании bge-m3_en_ru написано, что это глобальная мультилингвальная версия bge-m3 с порезанными токенами. Это не магически натренированные для русского токены, а мультилингвальные токены из которых оставили только en и ru.