Что случилось и зачем нам понадобился RAG
К нам обратился клиент - компания с большой базой знаний: сотни инструкций, регламентов, FAQ, описаний продуктов. Проблема была простая: сотрудники тратили кучу времени на поиск нужной информации. Кто-то гуглил, кто-то спрашивал коллег, кто-то просто догадывался. Классика.
Клиент хотел "умного бота", который знает всю документацию компании и может ответить на любой вопрос сотрудника. Что-то вроде ChatGPT, но который прочитал ВСЮ внутреннюю документацию.
Звучит просто? Мы тоже так думали. Спойлер: было непросто, но мы справились. И сейчас расскажем, как именно.
RAG за 30 секунд (для тех, кто пропустил)
RAG - это Retrieval-Augmented Generation. По-русски: "генерация с дополнительным поиском". Идея простая:
- Пользователь задаёт вопрос - например, "какая у нас политика отпусков?"
- Система ищет релевантные документы - в базе знаний находит фрагменты про отпуска
- 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 чанков загрузились за пару минут
Шаг 4: Поиск по вопросу пользователя
Теперь самое интересное - обработка вопроса:
// Пользователь задаёт вопрос - ищем релевантные чанки
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, правда?
Советы тем, кто хочет повторить
- Начните с малого - загрузите 10-20 документов, протестируйте. Не пытайтесь загрузить всё сразу
- Тестируйте на реальных вопросах - попросите сотрудников задать 50 типичных вопросов и проверьте качество ответов
- Не экономьте на промпте - хорошая инструкция для AI - это 50% успеха
- Добавьте "не знаю" - бот, который честно признаётся в незнании, лучше бота, который врёт
- Обновляйте базу - устаревшие данные хуже, чем никаких данных
- Логируйте всё - записывайте вопросы и ответы, чтобы улучшать систему
Наш технологический стек
Для тех, кому интересны детали:
- 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 в своей компании? Мы уже прошли через все грабли и знаем, как сделать это быстро и правильно. Напишите нам - обсудим ваш проект. Первая консультация бесплатно, обещаем не кусаться.