Self-hosted RAG для вопросов по документам: LangChain, pgvector и сменные LLM
Как open-source RAG-приложение превращает PDF и DOCX в ответы: разбиение на чанки, pgvector, переключение между OpenAI и Claude одной настройкой и уроки разработки на скромном бюджете.
Документы редко сами отвечают на вопросы о себе. Регламенты, отчёты и договоры копятся в виде PDF, файлов Word и сканов, и почти ни у кого нет времени читать их от начала до конца. Система retrieval-augmented generation (RAG), которая отвечает на вопросы по загруженным файлам, — практичный способ это исправить, а open-source проект AI-DocumentIntelligence хорошо показывает, как сделать такую систему простой и заслуживающей доверия.
Какой пробел это закрывает
Одна и та же ситуация возникает в любой организации, где много бумаг. Соцработнику нужна одна строка из 40-страничного регламента. Аналитику нужна одна цифра с 22-й страницы отчёта. Файл формально доступен, но найти в нём что-либо можно целую вечность.
Обычно у команд остаётся два варианта. Первый — закрытый продукт для работы с документами на базе ИИ с непрозрачными ценами и размытыми правилами о том, что происходит с данными. Второй — продолжать искать вручную.
Открытая альтернатива, которую можно развернуть у себя, даёт третий путь. Цель не в том, чтобы сделать ещё одну тонкую обёртку над чьим-то API. Нужно что-то достаточно простое, чтобы команда, работающая с конфиденциальными документами, могла это изучить, заменить отдельные части и доверять результату.
Как это работает: загрузить, разбить, векторизовать, спросить
Приложение выполняет четыре задачи:
- Принимает файлы PDF, DOCX или TXT.
- Разбивает каждый файл на небольшие осмысленные чанки.
- Превращает чанки в эмбеддинги и сохраняет их в PostgreSQL через расширение
pgvector. - Отвечает на вопросы по документу, заданные обычным языком, с помощью OpenAI или Anthropic Claude, а переключение между ними делается одной настройкой.
Последний пункт важнее, чем кажется. Многие учебные RAG-проекты привязывают вас к одному поставщику моделей. В реальных проектах провайдера часто приходится менять из-за стоимости, требований регуляторов или доступности, и никто не хочет переписывать приложение, когда это случается. Здесь LLM_PROVIDER — обычное значение конфигурации, а не что-то, зарытое глубоко в коде.
Стек
Список технологий намеренно короткий:
- Фронтенд: React 18 с TypeScript
- Бэкенд: Node.js и Express на TypeScript
- Оркестрация: LangChain
- Векторное хранилище: PostgreSQL с pgvector
- Модели: OpenAI или Anthropic Claude на выбор
- Инструменты: Docker, ts-node и dotenv
Ничто здесь не требует от команды учиться новому способу выпуска софта. Любой, кто уже умеет запускать сервис на Node.js рядом с базой PostgreSQL, запустит и это.
Архитектура
В целом схема такая: интерфейс на React общается с API на Express, который передаёт загрузки обработчику документов:
┌────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ React UI │ ───► │ Express API │ ───► │ Document Processor │
│ (upload+chat)│ │ (TypeScript) │ │ (chunking / split) │
└────────────┘ └──────────────────┘ └────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌────────────────────┐
│ LangChain Layer │ ◄──► │ PostgreSQL + pgvector│
│ (OpenAI / Claude) │ │ (embeddings store) │
└──────────────────┘ └────────────────────┘
Разбиение и векторизация происходят один раз, при загрузке. После этого каждый вопрос идёт одним и тем же коротким маршрутом. Вопрос превращается в эмбеддинг, pgvector возвращает ближайшие чанки, эти чанки вместе с вопросом уходят в выбранную модель, а ответ возвращается вместе с историей чата по этому документу.
В такой архитектуре нет ничего экзотического, и в этом весь смысл. Ценность в том, чтобы правильно сделать простые вещи: аккуратно выбирать провайдера, разумно делить текст и корректно обрабатывать ситуацию, когда ключа или настройки не хватает.
Код, который имеет значение
Основные идеи видны всего в нескольких фрагментах.
Переключение провайдеров сводится к чтению переменной окружения:
// backend/src/llm/provider.ts (simplified)
const provider = process.env.LLM_PROVIDER; // "openai" | "anthropic"
Затем небольшая фабричная функция возвращает нужную чат-модель в зависимости от этого значения:
export function getChatModel() {
if (provider === "anthropic") {
return new ChatAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
model: process.env.ANTHROPIC_MODEL,
});
}
return new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
}
Разбиение на чанки опирается на рекурсивный сплиттер LangChain, настроенный скорее под вопросы и ответы, чем под простое хранение:
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 150,
});
const chunks = await splitter.splitDocuments(rawDocs);
Перекрытию стоит уделять больше внимания, чем обычно. Если оно слишком маленькое, ответ может разрезаться пополам между двумя чанками. Если слишком большое, вы платите за хранение и поиск одного и того же предложения трижды. Перекрытие в 150 символов при чанках по 1000 символов дало лучшие результаты на смешанном наборе тестовых файлов PDF и DOCX.
Поиск и генерация тоже разделены. Каждый этап можно тестировать отдельно, поэтому можно убедиться, что поиск находит нужные чанки, не тратя кредиты API на генерацию. На бесплатном аккаунте OpenAI такая экономия весьма заметна.
Что сработало, а что далось тяжело
Решения, которые окупились
- Сменный провайдер с первого дня. Большая часть логики поиска была написана и протестирована на бесплатных локальных эмбеддингах ещё до того, как дело дошло до платного API, поэтому расходы во время разработки оставались почти нулевыми.
- Разделение разбиения и векторизации и ответов на вопросы. Когда ответ выглядит неверным, можно быстро понять, виноват поиск или генерация.
- Docker Compose для всего стека (бэкенд, фронтенд и Postgres). Любой может клонировать репозиторий и запустить его за несколько минут, а не воевать с зависимостями полдня.
Что оказалось сложнее, чем ожидалось
- Работа без платных кредитов API. Отсутствие доступа к платному аккаунту OpenAI или Claude — реальное ограничение. Обходной путь: сначала проверить поиск на бесплатных локальных моделях эмбеддингов, а платный шаг генерации добавить позже. Это здравый совет для любого RAG-проекта с ограниченным бюджетом.
- Выбор размера чанка. Скучная и при этом критически важная вещь. Никакая настройка промптов не спасёт плохое разбиение, а его исправление дало самое большое улучшение за весь проект.
- Запуск на чужой машине. Ноутбук, где всё работает, и чистый
docker compose upна компьютере незнакомого человека — разные вещи. Понятный.env.example, явные настройки имени базы данных и описанное решение конфликтов портов заняли больше времени, чем сама логика RAG, но именно от них зависит, сможет ли кто-то ещё пользоваться проектом.
Как запустить самому
Репозиторий открытый и запускается локально парой команд:
git clone https://github.com/Srameshgitnow/AI-DocumentIntelligence.git
cd AI-DocumentIntelligence
docker compose up --build
Переменные окружения, полная инструкция по настройке и заметки по решению проблем есть в README проекта. Сообщения об ошибках, идеи насчёт подхода к переключению провайдеров и пул-реквесты — всё это можно оставлять в трекере задач.
Более общая идея таких проектов — применять оркестрацию LLM с LangChain или LangGraph к реальным рабочим задачам, вроде вопросов и ответов по регламентам для госуслуг, агентов для код-ревью или превращения голосовых заметок в пользовательские истории для Agile, и обходиться без отполированных демо, которые никогда не сталкиваются с настоящими документами. Простое, сменное и легко проверяемое обычно выигрывает у хитроумного.