Клиент - юридическая компания - хотел внутреннего ассистента, который отвечает на вопросы по их процедурам, формам и нормативным инструкциям. Проблема с прямыми запросами к GPT-4: модель отвечает уверенно из обучающих данных, которые не включают проприетарные документы компании. И иногда выдумывает правдоподобно звучащие, но неверные юридические процедуры.
Retrieval-Augmented Generation (RAG) решает обе проблемы: модель отвечает только из найденных документов, и можно указать - из каких именно.
Проблема галлюцинаций и почему RAG помогает
Знания языковой модели заморожены на момент обучения. Документы, написанные в прошлом месяце, ей недоступны. При вопросах о неизвестном - модель часто генерирует убедительный, но неверный текст.
RAG разделяет поиск и генерацию:
Запрос пользователя
→ Найти: 3-5 наиболее релевантных документов из базы знаний
→ Дополнить: добавить найденные документы в промпт как контекст
→ Сгенерировать: LLM отвечает на основе предоставленного контекста, не обучающих данных
Если ответа в найденных документах нет - хорошо настроенная модель скажет «в предоставленных документах нет информации по этому вопросу» вместо того, чтобы выдумывать.
Архитектура системы
Документы (PDF, Word, HTML)
→ Извлечение текста
→ Разбивка на чанки (500 токенов с перекрытием)
→ Эмбеддинг (OpenAI text-embedding-ada-002)
→ Векторная база Pinecone
Конвейер запроса:
Вопрос пользователя
→ Эмбеддинг вопроса (та же модель)
→ Поиск по сходству в Pinecone (топ-5 чанков)
→ Промпт: система + чанки контекста + вопрос
→ GPT-4 генерирует ответ
→ Ответ + ссылки на источники
Шаг 1: Загрузка документов
// ingestion/ingest.ts
import { OpenAI } from 'openai';
import { Pinecone } from '@pinecone-database/pinecone';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { PDFLoader } from 'langchain/document_loaders/fs/pdf';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });
// Размер чанка важен: слишком маленький - теряем контекст,
// слишком большой - шум при поиске
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 100, // Перекрытие не даёт разрезать середину предложения
separators: ['\n\n', '\n', '. ', ' ', ''],
});
async function ingestDocument(filePath: string) {
const fileName = path.basename(filePath);
// Загружаем и извлекаем текст
const loader = new PDFLoader(filePath);
const docs = await loader.load();
const text = docs.map(d => d.pageContent).join('\n\n');
// Разбиваем на чанки
const chunks = await splitter.splitText(text);
console.log(`${fileName}: ${chunks.length} чанков`);
const index = pinecone.index('company-knowledge-base');
// Эмбеддим и загружаем батчами по 100
for (let i = 0; i < chunks.length; i += 100) {
const batch = chunks.slice(i, i + 100);
const embeddingResponse = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: batch,
});
const vectors = batch.map((chunk, j) => ({
id: `${fileName}-chunk-${i + j}`,
values: embeddingResponse.data[j].embedding,
metadata: {
text: chunk,
source: fileName,
chunkIndex: i + j,
},
}));
await index.upsert(vectors);
}
}
Шаг 2: Конвейер запроса
// rag/query.ts
async function queryKnowledgeBase(question: string) {
// 1. Эмбеддим вопрос
const embedding = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: question,
});
// 2. Ищем похожие чанки в Pinecone
const index = pinecone.index('company-knowledge-base');
const results = await index.query({
vector: embedding.data[0].embedding,
topK: 5,
includeMetadata: true,
});
// Фильтруем нерелевантные результаты (порог косинусного сходства)
const relevant = results.matches.filter(m => (m.score ?? 0) > 0.75);
if (relevant.length === 0) {
return {
answer: 'В базе знаний не найдено релевантной информации по данному вопросу.',
sources: [],
};
}
// 3. Формируем контекст из найденных чанков
const context = relevant
.map((chunk, i) => `[Источник ${i + 1}: ${chunk.metadata?.source}]\n${chunk.metadata?.text}`)
.join('\n\n---\n\n');
// 4. Генерируем ответ с GPT-4
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{
role: 'system',
content: `Вы ассистент юридической компании. Отвечайте на вопросы ТОЛЬКО на основе предоставленных документов.
Если в контексте недостаточно информации для ответа - скажите об этом явно. Не используйте знания из других источников.
Всегда указывайте, из какого документа взята информация.
Контекст:
${context}`,
},
{
role: 'user',
content: question,
},
],
temperature: 0.1, // Низкая температура = детерминированность
max_tokens: 1000,
});
return {
answer: completion.choices[0].message.content ?? '',
sources: relevant.map(chunk => ({
source: chunk.metadata?.source as string,
text: chunk.metadata?.text as string,
relevance: chunk.score ?? 0,
})),
};
}
Что сделало систему надёжной в продакшене
Стратегия чанкинга важнее, чем кажется
Для структурированных юридических документов лучше резать по разделам:
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 150,
separators: [
'\n## ', // Разделы H2
'\n### ', // Подразделы H3
'\n\n', // Абзацы
'\n',
'. ',
],
});
Начальный процент попаданий (нужный чанк в топ-5 результатов) был 71%. После настройки стратегии чанкинга - 89%.
Ре-ранкинг найденных чанков
Векторное сходство не идеально коррелирует с «лучший ответ на вопрос». Cohere re-ranker переупорядочивает результаты перед формированием контекста:
import { CohereClient } from 'cohere-ai';
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY });
// Находим топ-10, потом ре-ранкером выбираем лучшие 5
const reranked = await cohere.rerank({
query: question,
documents: top10.map(c => c.metadata?.text as string),
topN: 5,
});
Добавляет ~200ms к запросу, но заметно улучшает качество ответов.
Стоимость при реальной нагрузке
Для этого клиента (200 пользователей, ~150 запросов/день):
| Компонент | Стоимость/мес |
|---|---|
| OpenAI embeddings (ada-002) | ~$2 |
| GPT-4 генерация | ~$45 |
| Pinecone Starter | $70 |
| Итого | ~$117 |
Для небольших объёмов можно заменить Pinecone на pgvector (расширение PostgreSQL) - дополнительная инфраструктура не нужна, если PostgreSQL уже используется.
Ограничения RAG
Устаревшие эмбеддинги. При обновлении документа старые векторы остаются в индексе. Нужен пайплайн: при изменении документа - удалить старые векторы, загрузить новые.
Противоречия между документами. Если два документа противоречат друг другу - модель выберет один или будет уклоняться. Решение: добавлять дату документа в метаданные и предпочитать более свежие.
Вопросы, требующие синтеза из многих источников. «Сравни все изменения в процедуре X за 3 года» - плохо работает с базовым RAG. Для этого - иерархическое суммирование или декомпозиция запроса на подзапросы.
RAG - это золотой стандарт для Q&A по корпоративной базе знаний. Быстрее, чем файнтюнинг, легче обновлять и проще аудировать - знаем точно, из каких документов сформирован ответ.