RAG на практике: как я сделал поиск по внутренней документации
У нас на работе накопилось около 300 страниц внутренней документации: runbooks, описания сервисов, постмортемы, инструкции по онбордингу. Всё это лежало в Confluence и Notion, и каждый раз, когда нужно было найти что-то конкретное — например, как перезапустить определённый сервис или какие переменные окружения нужны для staging — приходилось тратить по 10-15 минут на поиск. Встроенный поиск Confluence отвратителен, а Notion чуть лучше, но тоже далёк от идеала.
Идея была простая: сделать «внутренний ChatGPT», который знает нашу документацию и отвечает на вопросы по ней. Это и есть RAG — Retrieval-Augmented Generation.
Что такое RAG и зачем он нужен
LLM (Large Language Models) умеют генерировать текст, но у них есть три фундаментальные проблемы:
- Галлюцинации. Модель уверенно выдаёт несуществующую информацию. Спросите GPT-4 про внутренний API вашей компании — он придумает правдоподобный, но полностью вымышленный ответ.
- Устаревшие данные. Модель обучена на данных до определённой даты. Она не знает о вчерашнем инциденте или новом сервисе, который задеплоили на прошлой неделе.
- Отсутствие приватных данных. Модель не видела вашу внутреннюю документацию, конфиги, постмортемы.
RAG решает эти проблемы элегантно: вместо того чтобы дообучать модель (что дорого и сложно), мы находим релевантные фрагменты документации и подставляем их в промпт. Модель генерирует ответ на основе конкретных фактов, а не своих «знаний».
Архитектура пайплайна
Весь RAG-пайплайн состоит из двух фаз:
Фаза индексации (выполняется один раз или по расписанию):
Документы → Загрузка → Чанкинг → Эмбеддинги → Векторная БД Фаза поиска и генерации (при каждом запросе):
Вопрос → Эмбеддинг запроса → Поиск похожих чанков → Промпт с контекстом → LLM → Ответ Разберу каждый компонент.
Загрузка документов
Первым делом нужно загрузить документы из разных источников. У нас были Markdown-файлы из Git-репозиториев, PDF с архитектурными диаграммами и экспорт из Confluence.
from langchain_community.document_loaders import (
DirectoryLoader,
UnstructuredMarkdownLoader,
PyPDFLoader,
ConfluenceLoader,
)
# Загрузка Markdown из директории
md_loader = DirectoryLoader(
"./docs",
glob="**/*.md",
loader_cls=UnstructuredMarkdownLoader,
)
# Загрузка PDF
pdf_loader = PyPDFLoader("./docs/architecture.pdf")
# Загрузка из Confluence (если нужно)
confluence_loader = ConfluenceLoader(
url="https://company.atlassian.net/wiki",
username="user@company.com",
api_key="CONFLUENCE_API_TOKEN",
space_key="DEVOPS",
)
docs = md_loader.load() + pdf_loader.load()
print(f"Загружено {len(docs)} документов") На практике с PDF всегда бывают проблемы — таблицы извлекаются криво, изображения теряются. Для PDF с таблицами лучше использовать unstructured с параметром strategy="hi_res", но это тянет за собой тяжёлые зависимости (Tesseract, poppler). Для наших целей хватило базового парсинга.
Чанкинг: разбиваем документы на фрагменты
Это самый недооценённый этап. Модель не может обработать весь документ целиком — нужно разбить его на чанки (фрагменты), которые потом будут индексироваться отдельно. От качества чанкинга напрямую зависит качество поиска.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=200,
separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(docs)
print(f"Получилось {len(chunks)} чанков") Ключевые параметры:
- chunk_size — размер чанка в символах. Я остановился на 800. Слишком большие чанки (2000+) добавляют шум — модель получает много нерелевантного контекста. Слишком маленькие (200-300) теряют контекст — предложение без окружающего абзаца бессмысленно.
- chunk_overlap — перекрытие между чанками. 200 символов помогает не потерять информацию на границах.
- separators — порядок разделителей.
RecursiveCharacterTextSplitterсначала пытается разбить по заголовкам Markdown (##,###), потом по абзацам, потом по предложениям. Это сохраняет логическую структуру документа.
Одна ошибка, которая стоила мне полдня дебага: я забыл добавить "\n## " в разделители, и чанки разрезали документ посередине секции. В итоге на вопрос «как перезапустить сервис X» модель находила нужный runbook, но нужная команда оказывалась в другом чанке.
Выбор модели эмбеддингов
Эмбеддинг — это числовой вектор, представляющий семантику текста. Два похожих по смыслу текста будут иметь близкие вектора. Для RAG критично, чтобы эмбеддинги хорошо работали на вашем языке и домене.
Я протестировал несколько моделей:
| Модель | Размер | Мультиязычность | Качество (MTEB) |
|---|---|---|---|
nomic-embed-text | 137M | Среднее | Хорошее |
bge-m3 | 567M | Отличное | Отличное |
mxbai-embed-large | 334M | Среднее | Очень хорошее |
Для документации на русском языке bge-m3 показал себя лучше всего. Он специально обучен на мультиязычных данных. nomic-embed-text тоже неплох и гораздо легче — для начала подойдёт.
from langchain_community.embeddings import OllamaEmbeddings
# Запускаем через Ollama — не нужен GPU-сервер
embeddings = OllamaEmbeddings(
model="bge-m3",
base_url="http://localhost:11434",
)
# Проверяем, что эмбеддинги работают
test_embedding = embeddings.embed_query("Как перезапустить nginx?")
print(f"Размерность: {len(test_embedding)}") # 1024 для bge-m3 Перед использованием нужно загрузить модель в Ollama:
ollama pull bge-m3 Векторная база данных
Эмбеддинги нужно где-то хранить и быстро искать по ним ближайших соседей. Для этого существуют специализированные векторные базы данных.
Я выбирал между ChromaDB и Qdrant. Оба варианта можно запустить локально:
# docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.12.4
ports:
- "6333:6333"
volumes:
- qdrant_data:/qdrant/storage
environment:
QDRANT__SERVICE__GRPC_PORT: 6334
volumes:
qdrant_data: ChromaDB проще для прототипа (работает in-process, без Docker), Qdrant лучше для продакшена — у него есть фильтрация по метаданным, шардирование, API для управления коллекциями.
Для нашего объёма (пара тысяч чанков) хватило бы ChromaDB, но я сразу взял Qdrant, чтобы потом не мигрировать.
from langchain_qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
vector_store = QdrantVectorStore.from_documents(
documents=chunks,
embedding=embeddings,
url="http://localhost:6333",
collection_name="internal_docs",
force_recreate=True, # Пересоздать коллекцию при переиндексации
)
print("Индексация завершена") Поиск и генерация ответов
Теперь самое интересное — собираем всё вместе. При получении вопроса пользователя:
- Превращаем вопрос в эмбеддинг.
- Ищем top-K похожих чанков в векторной базе.
- Формируем промпт с контекстом.
- Отправляем в LLM.
from langchain_community.chat_models import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
# Подключаем локальную LLM через Ollama
llm = ChatOllama(
model="llama3.1:8b",
base_url="http://localhost:11434",
temperature=0.1, # Низкая температура для фактических ответов
)
# Ретривер — ищет 4 наиболее релевантных чанка
retriever = vector_store.as_retriever(
search_type="mmr", # Maximum Marginal Relevance — разнообразие результатов
search_kwargs={"k": 4},
)
# Промпт с явной инструкцией
template = """Ты — помощник по внутренней документации компании. Отвечай на вопросы
ТОЛЬКО на основе предоставленного контекста. Если в контексте нет ответа,
скажи "Я не нашёл информацию по этому вопросу в документации".
Контекст:
{context}
Вопрос: {question}
Ответ:"""
prompt = ChatPromptTemplate.from_template(template)
# Собираем цепочку
def format_docs(docs):
return "\n\n---\n\n".join(
f"[Источник: {doc.metadata.get('source', 'неизвестно')}]\n{doc.page_content}"
for doc in docs
)
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# Задаём вопрос
response = chain.invoke("Как перезапустить сервис авторизации?")
print(response) Обратите внимание на search_type="mmr" — это Maximum Marginal Relevance. Обычный similarity search может вернуть 4 почти одинаковых чанка из одного места документа. MMR балансирует между релевантностью и разнообразием результатов.
Подводные камни и что я бы сделал иначе
Качество чанкинга важнее модели. Я потратил неделю на эксперименты с разными LLM (llama3 vs mistral vs qwen), а нужно было потратить её на чанкинг. Когда я перешёл с наивного разбиения на RecursiveCharacterTextSplitter с правильными разделителями для Markdown, качество ответов скакнуло вверх даже на слабой модели.
Метаданные решают. Добавляйте к каждому чанку метаданные: имя файла, заголовок секции, дату обновления. Это позволяет фильтровать результаты поиска и показывать пользователю источник ответа.
Гибридный поиск. Чисто векторный поиск иногда промахивается. Если пользователь ищет конкретный термин или название сервиса, keyword search (BM25) работает лучше. Qdrant поддерживает гибридный поиск — рекомендую включить.
Переиндексация. Документация меняется — нужен механизм переиндексации. Я сделал простой скрипт, который запускается по cron раз в сутки, подтягивает обновления из Git и пересоздаёт коллекцию. Не самый элегантный подход (лучше инкрементальная индексация), но для нашего объёма работает.
Оценка качества. Без метрик вы не поймёте, улучшается ли пайплайн. Я сделал набор из 30 пар «вопрос-ответ» и проверял на них после каждого изменения. Простая метрика — процент вопросов, на которые модель дала корректный ответ. У нас получилось выйти на 85% после нескольких итераций.
Итог
Весь пайплайн поднимается за пару часов и работает полностью локально — никакие данные не уходят наружу. Для команды из 10 человек сервер с 16 GB RAM и без GPU справляется нормально (используем llama3.1:8b — квантизованная модель не требует много ресурсов). Если нужна лучшая скорость — можно подключить внешний API (OpenAI, Anthropic) вместо Ollama, но тогда данные уходят наружу.
Самый важный вывод: RAG — это не про модель. Это про данные и их подготовку. 80% качества определяется тем, как вы загружаете, чистите и разбиваете документы. Оставшиеся 20% — это выбор модели, промпт-инжиниринг и параметры поиска.