RAG на практике: как я сделал поиск по внутренней документации

ragllmpythonself-hosting

RAG-пайплайн: от документов через эмбеддинги к ответу LLM

У нас на работе накопилось около 300 страниц внутренней документации: runbooks, описания сервисов, постмортемы, инструкции по онбордингу. Всё это лежало в Confluence и Notion, и каждый раз, когда нужно было найти что-то конкретное — например, как перезапустить определённый сервис или какие переменные окружения нужны для staging — приходилось тратить по 10-15 минут на поиск. Встроенный поиск Confluence отвратителен, а Notion чуть лучше, но тоже далёк от идеала.

Идея была простая: сделать «внутренний ChatGPT», который знает нашу документацию и отвечает на вопросы по ней. Это и есть RAG — Retrieval-Augmented Generation.

Что такое RAG и зачем он нужен

LLM (Large Language Models) умеют генерировать текст, но у них есть три фундаментальные проблемы:

  1. Галлюцинации. Модель уверенно выдаёт несуществующую информацию. Спросите GPT-4 про внутренний API вашей компании — он придумает правдоподобный, но полностью вымышленный ответ.
  2. Устаревшие данные. Модель обучена на данных до определённой даты. Она не знает о вчерашнем инциденте или новом сервисе, который задеплоили на прошлой неделе.
  3. Отсутствие приватных данных. Модель не видела вашу внутреннюю документацию, конфиги, постмортемы.

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-text137MСреднееХорошее
bge-m3567MОтличноеОтличное
mxbai-embed-large334MСреднееОчень хорошее

Для документации на русском языке 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("Индексация завершена")

Поиск и генерация ответов

Теперь самое интересное — собираем всё вместе. При получении вопроса пользователя:

  1. Превращаем вопрос в эмбеддинг.
  2. Ищем top-K похожих чанков в векторной базе.
  3. Формируем промпт с контекстом.
  4. Отправляем в 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% — это выбор модели, промпт-инжиниринг и параметры поиска.

© 2026 Terminal Notes. Built with SvelteKit.