10 мин. чтения

Как мы внедрили RAG в реальный продукт.

Честная история о том, как мы взяли модную аббревиатуру RAG и превратили её в работающую систему для реального бизнеса. Без розовых очков - рассказываем про грабли, решения и результаты.

Что случилось и зачем нам понадобился RAG

К нам обратился клиент - компания с большой базой знаний: сотни инструкций, регламентов, FAQ, описаний продуктов. Проблема была простая: сотрудники тратили кучу времени на поиск нужной информации. Кто-то гуглил, кто-то спрашивал коллег, кто-то просто догадывался. Классика.

Клиент хотел "умного бота", который знает всю документацию компании и может ответить на любой вопрос сотрудника. Что-то вроде ChatGPT, но который прочитал ВСЮ внутреннюю документацию.

Звучит просто? Мы тоже так думали. Спойлер: было непросто, но мы справились. И сейчас расскажем, как именно.

RAG за 30 секунд (для тех, кто пропустил)

RAG - это Retrieval-Augmented Generation. По-русски: "генерация с дополнительным поиском". Идея простая:

  1. Пользователь задаёт вопрос - например, "какая у нас политика отпусков?"
  2. Система ищет релевантные документы - в базе знаний находит фрагменты про отпуска
  3. AI генерирует ответ - на основе найденных документов (а не из головы!)

Ключевое отличие от обычного ChatGPT: бот отвечает на основе ваших данных, а не на основе всего интернета. Поэтому ответы точные и релевантные для вашей компании.

Архитектура: как мы это собрали

Наша система состоит из нескольких частей. Не пугайтесь - сейчас разберём каждую:

// Общая схема нашей RAG-системы:
//
// Документы компании
//       |
//       v
// [Разбивка на чанки]  -- делим большие документы на маленькие кусочки
//       |
//       v
// [Создание эмбеддингов] -- превращаем текст в числа (векторы)
//       |
//       v
// [Векторная база данных] -- храним все векторы
//       |
//       v
// Пользователь задаёт вопрос
//       |
//       v
// [Поиск похожих чанков]  -- находим релевантные кусочки
//       |
//       v
// [Генерация ответа через AI] -- GPT читает найденное и отвечает
//       |
//       v
// Ответ пользователю

Шаг 1: Разбиваем документы на кусочки (чанки)

Первая проблема: документы бывают огромными. Инструкция на 50 страниц - это нормально. Но мы не можем отправить 50 страниц в ChatGPT (у него есть лимит на размер контекста). Да и не нужно - если вопрос про отпуска, зачем отправлять раздел про командировки?

Поэтому мы разбиваем каждый документ на маленькие кусочки - чанки (chunks). Каждый чанк - это 500-1000 символов текста.

// Разбиваем документ на чанки
// Это один из самых важных шагов!

function splitIntoChunks(text, chunkSize = 800, overlap = 200) {
  // chunkSize - размер одного кусочка (в символах)
  // overlap - перекрытие между кусочками
  //
  // Зачем перекрытие? Чтобы не потерять смысл на границе.
  // Если предложение разрезалось пополам -
  // оно целиком попадёт хотя бы в один из соседних чанков.

  const chunks = []
  let start = 0

  while (start < text.length) {
    // Вырезаем кусочек
    let end = start + chunkSize

    // Стараемся не резать посередине предложения
    // Ищем ближайшую точку или перенос строки
    if (end < text.length) {
      const lastPeriod = text.lastIndexOf(".", end)
      const lastNewline = text.lastIndexOf("\n", end)
      const breakPoint = Math.max(lastPeriod, lastNewline)

      // Если нашли хорошее место для разреза - используем его
      if (breakPoint > start + chunkSize / 2) {
        end = breakPoint + 1
      }
    }

    chunks.push({
      text: text.slice(start, end).trim(),
      startIndex: start  // запоминаем, откуда этот кусочек
    })

    // Следующий чанк начинается с перекрытием
    start = end - overlap
  }

  return chunks
}

// Пример: документ из 5000 символов
// Получим примерно 7-8 чанков по 800 символов
// с перекрытием по 200 символов

Мы попробовали разные размеры чанков и пришли к оптимальному: 800 символов с перекрытием 200. Слишком маленькие чанки теряют контекст, слишком большие - снижают точность поиска.

Шаг 2: Превращаем чанки в векторы

Теперь каждый чанк нужно превратить в вектор (набор чисел), чтобы потом искать по смыслу. Для этого используем модель эмбеддингов от OpenAI:

// Создаём вектор (эмбеддинг) для каждого чанка

import OpenAI from "openai"

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })

async function createEmbedding(text) {
  // Отправляем текст в OpenAI
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",  // быстрая и дешёвая модель
    input: text
  })

  // Получаем массив из 1536 чисел
  return response.data[0].embedding
}

// Обрабатываем все чанки
async function processDocument(documentText, documentName) {
  // 1. Разбиваем на чанки
  const chunks = splitIntoChunks(documentText)

  console.log(`Документ "${documentName}": ${chunks.length} чанков`)

  // 2. Для каждого чанка создаём эмбеддинг
  const records = []
  for (let i = 0; i < chunks.length; i++) {
    const embedding = await createEmbedding(chunks[i].text)

    records.push({
      id: `${documentName}-chunk-${i}`,  // уникальный ID
      embedding: embedding,                // вектор
      text: chunks[i].text,               // оригинальный текст
      source: documentName                 // откуда этот кусочек
    })

    // Небольшая пауза, чтобы не превысить лимит API
    if (i % 10 === 0) {
      console.log(`  Обработано ${i + 1}/${chunks.length} чанков...`)
    }
  }

  return records
}

// У нашего клиента было ~300 документов
// После разбивки получилось ~4200 чанков
// Обработка заняла около 15 минут

Шаг 3: Сохраняем в векторную базу

Мы выбрали pgvector (расширение для PostgreSQL), потому что у клиента уже был PostgreSQL, и добавлять отдельную базу данных не хотелось.

// Сохраняем все чанки в PostgreSQL с pgvector

// Создаём таблицу (один раз)
await db.query(`
  CREATE EXTENSION IF NOT EXISTS vector;

  CREATE TABLE IF NOT EXISTS knowledge_chunks (
    id TEXT PRIMARY KEY,
    content TEXT NOT NULL,           -- оригинальный текст чанка
    source TEXT NOT NULL,            -- из какого документа
    embedding vector(1536),          -- вектор (1536 чисел)
    created_at TIMESTAMP DEFAULT NOW()
  );

  -- Индекс для быстрого поиска по векторам
  -- ivfflat - это алгоритм приближённого поиска
  -- Он чуть менее точный, но НАМНОГО быстрее
  CREATE INDEX ON knowledge_chunks
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);
`)

// Загружаем все чанки
async function saveChunks(records) {
  for (const record of records) {
    await db.query(
      `INSERT INTO knowledge_chunks (id, content, source, embedding)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (id) DO UPDATE SET
         content = $2, source = $3, embedding = $4`,
      [
        record.id,
        record.text,
        record.source,
        JSON.stringify(record.embedding)  // вектор как JSON-массив
      ]
    )
  }
}

// 4200 чанков загрузились за пару минут

Теперь самое интересное - обработка вопроса:

// Пользователь задаёт вопрос - ищем релевантные чанки

async function findRelevantChunks(question, limit = 5) {
  // 1. Превращаем вопрос в вектор
  const questionEmbedding = await createEmbedding(question)

  // 2. Ищем ближайшие векторы в базе
  // <=> - оператор "косинусное расстояние" в pgvector
  // Чем меньше расстояние - тем более похожи тексты
  const result = await db.query(`
    SELECT
      content,
      source,
      1 - (embedding <=> $1::vector) as similarity
    FROM knowledge_chunks
    WHERE 1 - (embedding <=> $1::vector) > 0.3
    ORDER BY embedding <=> $1::vector
    LIMIT $2
  `, [JSON.stringify(questionEmbedding), limit])

  // Фильтр similarity > 0.3 отсекает совсем нерелевантные результаты
  // (если ничего подходящего нет - лучше честно сказать "не знаю")

  return result.rows
}

// Пример:
// Вопрос: "сколько дней отпуска положено?"
// Находит чанки из документов:
// 1. "Политика отпусков.docx" (similarity: 0.89)
// 2. "HR FAQ.docx" (similarity: 0.82)
// 3. "Трудовой регламент.docx" (similarity: 0.71)

Шаг 5: Генерация ответа через GPT

Финальный шаг - отправляем найденные чанки и вопрос в GPT:

// Собираем всё вместе: поиск + генерация ответа

async function askQuestion(question) {
  // 1. Ищем релевантные документы
  const chunks = await findRelevantChunks(question, 5)

  // 2. Если ничего не нашли - честно говорим
  if (chunks.length === 0) {
    return {
      answer: "К сожалению, я не нашёл информации по этому вопросу в базе знаний.",
      sources: []
    }
  }

  // 3. Собираем контекст из найденных чанков
  const context = chunks
    .map((c, i) => `[Документ ${i + 1}: ${c.source}]\n${c.content}`)
    .join("\n\n")

  // 4. Отправляем в GPT
  const response = await openai.chat.completions.create({
    model: "gpt-4o-mini",  // быстрая и недорогая модель
    temperature: 0.3,       // низкая "креативность" - хотим точные ответы
    messages: [
      {
        role: "system",
        content: `Ты - ассистент компании. Отвечай ТОЛЬКО на основе предоставленных документов.
Если в документах нет информации для ответа - честно скажи об этом.
Не придумывай информацию. Отвечай кратко и по делу.
Если уместно - указывай, из какого документа взята информация.

Документы:
${context}`
      },
      {
        role: "user",
        content: question
      }
    ]
  })

  return {
    answer: response.choices[0].message.content,
    sources: chunks.map(c => c.source)  // указываем источники
  }
}

// Пример использования:
const result = await askQuestion("Сколько дней отпуска мне положено?")
// {
//   answer: "Согласно Политике отпусков, каждому сотруднику
//            положено 28 календарных дней ежегодного оплачиваемого
//            отпуска. Дополнительные дни могут быть предоставлены
//            за выслугу лет (от 3 лет - +3 дня).",
//   sources: ["Политика отпусков.docx", "HR FAQ.docx"]
// }

Грабли, на которые мы наступили

Было бы нечестно рассказать только про успехи. Вот реальные проблемы, с которыми мы столкнулись:

Проблема 1: "Галлюцинации" AI

Иногда GPT придумывал информацию, которой не было в документах. Клиент спрашивает "какой у нас дресс-код?", а бот отвечает что-то про деловой стиль - хотя в документах ничего про это нет.

Решение: ужесточили промпт (инструкцию для AI) и добавили проверку - если similarity найденных чанков ниже 0.5, бот честно отвечает "не знаю". Лучше признаться, что не знаешь, чем выдумывать.

Проблема 2: Устаревшие документы

Клиент обновил политику отпусков, но в базе лежала старая версия. Бот отвечал по старым правилам.

Решение: сделали автоматическую синхронизацию. При изменении документа - старые чанки удаляются, новые создаются. Плюс добавили дату обновления в ответы.

Проблема 3: Таблицы и списки

Когда в документе была таблица с тарифами или график дежурств - разбивка на чанки ломала структуру. Одна строка таблицы попадала в один чанк, другая - в другой.

Решение: перед разбивкой на чанки - конвертируем таблицы в текстовый формат. Каждая строка таблицы становится отдельным "предложением" с полным контекстом.

Результаты: что получилось

После месяца работы (и примерно 20 литров кофе) мы запустили систему:

  • 300+ документов загружено в систему
  • 4200 чанков в векторной базе
  • 95% точность ответов (проверяли вручную на 500 тестовых вопросах)
  • Среднее время ответа - 3 секунды
  • Экономия ~2 часа в день у каждого сотрудника (по отзывам)
Самый приятный момент - когда HR-директор клиента написала: "Теперь новые сотрудники перестали заваливать меня одними и теми же вопросами. Бот отвечает лучше, чем я". Это было приятно.

Сколько это стоит (по-честному)

Раз уж обещали честно - давайте поговорим про деньги:

  • OpenAI эмбеддинги - ~$2 в месяц (для 4200 чанков это копейки)
  • OpenAI GPT-4o-mini - ~$30-50 в месяц (зависит от количества вопросов)
  • PostgreSQL с pgvector - бесплатно (open source)
  • Сервер - от $20/месяц (обычный VPS справляется)

Итого: $50-70 в месяц за систему, которая экономит десятки рабочих часов. Неплохой ROI, правда?

Советы тем, кто хочет повторить

  1. Начните с малого - загрузите 10-20 документов, протестируйте. Не пытайтесь загрузить всё сразу
  2. Тестируйте на реальных вопросах - попросите сотрудников задать 50 типичных вопросов и проверьте качество ответов
  3. Не экономьте на промпте - хорошая инструкция для AI - это 50% успеха
  4. Добавьте "не знаю" - бот, который честно признаётся в незнании, лучше бота, который врёт
  5. Обновляйте базу - устаревшие данные хуже, чем никаких данных
  6. Логируйте всё - записывайте вопросы и ответы, чтобы улучшать систему

Наш технологический стек

Для тех, кому интересны детали:

  • Backend: Node.js + Express
  • База знаний: PostgreSQL + pgvector
  • Эмбеддинги: OpenAI text-embedding-3-small
  • Генерация ответов: OpenAI GPT-4o-mini
  • Frontend: React + TailwindCSS
  • Деплой: Docker + обычный VPS

Итого

RAG - это не магия и не rocket science. Это понятная архитектура из нескольких простых шагов: разбил документы, превратил в векторы, сохранил, ищешь по смыслу, генерируешь ответ. Каждый шаг по отдельности - несложный. Сложность в деталях: правильный размер чанков, хороший промпт, обработка edge-кейсов.

Но результат того стоит. Когда видишь, как сотрудники получают точные ответы за секунды вместо того, чтобы рыться в документах полчаса - понимаешь, что всё было не зря.

Хотите внедрить RAG в своей компании? Мы уже прошли через все грабли и знаем, как сделать это быстро и правильно. Напишите нам - обсудим ваш проект. Первая консультация бесплатно, обещаем не кусаться.
Все статьи
Как мы внедрили RAG в реальный продукт | Enot Software