Устав от вечных мук впн, прокси и прочих Захотев в импортозамещение решил склепать NotebookLM на свой лад доступный всем проживающим в необъятной и не только.
Кто не знает, это сервис который позволяет загрузить кучу исследовательских материалов и задавать им вопросы на человеческом языке. PDF-ки, статьи, ютуб-лекции всё это превращается в базу знаний, по которой можно искать, получать саммари и даже генерировать флешкарточки для подготовки к экзаменам.
Со стороны выглядит просто: закинул документ, спросил и получил ответ с цитатами. Но внутри всё сильно сложнее - пайплайн из шести этапов, четыре стратегии поиска, система бюджетирования контекста и довольно нетривиальная работа с векторами. В этой статье расскажу, как всё устроено, почему выбрал именно такой стек и на какие грабли успел наступить.

А зачем вообще так усложнять, есть же ChatGPT/алиса/любая RAG
Окей, давайте честно. «Зачем мне это, если есть ChatGPT?» - нормальный вопрос. Закинул PDF, спросил, получил ответ. Работает же.
Работает пока один файл и один вопрос. А когда 15 статей для курсовой ChatGPT начинает путать, что откуда, и приписывает выводы одного автора другому. Не потому что тупой, а потому что это чат. Поговорили, закрыл вкладку и он забыл. Завтра загружай всё заново.
Есть вариант собрать RAG самому. RAG - это когда модель сначала ищет нужный кусок в твоих документах, а потом отвечает на его основе. Для этого есть готовые библиотеки: LlamaIndex, Haystack. Они нарезают текст на куски, превращают в векторы, ищут по ним. На демо с одним чистым PDF красиво. В жизни скан учебника превращается в мусор, ютуб-лекцию фреймворк не понимает, а из 40 источников поиск упорно возвращает куски из одного и того же.
Чтобы пользователям не мучаться решил сделать своё. Источники загружаются один раз и живут в блокноте постоянно. Система, которая сама решает, сколько контекста отдать модели из каждого источника. Плюс инсайты, заметки, флешкарточки, из RAG-фреймворка ты этого не получишь.
Как это выглядит для пользователя
База это блокноты. Блокнот это проект, например: «Курсовая по нейробиологии», «Подготовка к ЕГЭ», «Анализ конкурентов». Внутри уже идут источники, чат, заметки и карточки. Загрузил материалы один раз и они живут там постоянно. Через месяц открываешь, добавляешь новую статью к старым двадцати, задаёшь вопрос, Julia видит всё сразу. Не нужно каждый раз начинать с нуля.

Источники это всё, всю информацию нейронка берёт именно оттуда, не хватя мусор из своего контекста. PDF, статья по ссылке, ютуб-лекция, скан конспекта, текст из буфера обмена. Julia сама извлекает текст, делает саммари и разбивает на фрагменты для поиска.
Чат - задаёшь вопрос по своим документам, получаешь ответ с привязкой к конкретным источникам, без галлюцинаций и прочего слопа.. Из того, что ты загрузил, это важно.

Архитектура
Архитектурно всё выглядит так: Nuxt.js на фронтенде, FastAPI на бэке, SurrealDB как основная база, и несколько вспомогательных сервисов в Docker Compose.
Frontend (Nuxt.js) ↕ REST + SSE Backend (FastAPI) ├── LangGraph (оркестрация AI) ├── Фоновые команды (очередь задач) ├── SurrealDB (данные + векторы + граф) ├── Gotenberg (конвертация документов) └── MiniMax / Embedding API (LLM + векторизация)
SurrealDB наверное, самое неочевидное решение. Это мультимодальная база: реляционные таблицы, граф-связи между записями и встроенный векторный поиск всё в одном движке, что мягко говоря привлекает. Можно спорить, насколько это зрелое решение, но для моего случая это убирает необходимость держать отдельно Postgres + Redis + Pinecone. Один сервис вместо трёх явно проще деплоить и дешевле поддерживать.
Что происходит при загрузке источника
Пользователь нажимает «Добавить источник» и выбирает один из вариантов: вставить ссылку, загрузить файл или вставить текст. За кулисами запускается пайплайн из нескольких стадий.
Обработка очереди
Первое, что мы поняли на раннем этапе: обработку нельзя делать синхронно. Если пользователь загружает PDF на 200 страниц, парсинг и векторизация могут занять 30-40 секунд. Держать HTTP-соединение открытым столько времени плохая идея: таймауты, обрывы, и пользователь смотрит на спиннер, не понимая, что происходит.
Поэтому у нас два режима. Для мелких источников (короткий текст, ссылка на статью) синхронная обработка, ответ за 5-10 секунд. Для тяжёлых файлов асинхронный режим: API мгновенно возвращает command_id, источник появляется в блокноте со статусом «Обработка…», а фронтенд поллит статус раз в пару секунд.
Для очереди задач мы написали свой фреймворк поверх SurrealDB. Да, можно было взять Celery или RQ, но это ещё один Redis в стеке, ещё один процесс для мониторинга. Наш вариант хранит задачи прямо в базе данных: с ретраями (до 5 попыток), экспоненциальным бэкоффом и отслеживанием прогресса. Не самое масштабируемое решение в мире, но для нашей нагрузки работает.
Извлечение текста: каскад фолбэков
Здесь начинается самое интересное. Тип источника определяет, какой путь извлечения текста будет использоваться. И для каждого типа у нас не один метод, а каскад с фолбэками если основной способ ломается, автоматически включается запасной.
PDF и документы. Основной парсер Docling через нашу обёртку content_core. Docling хорошо справляется со структурой: заголовки, таблицы, списки всё это превращается в чистый Markdown. Для экзотики вроде PPTX или XLSX подключается Gotenberg - headless LibreOffice, который конвертирует почти что угодно.
YouTube. Вот тут был ад. YouTube активно борется с ботами, и каждый месяц что-нибудь ломается. Поэтому тут три уровня:
youtube-transcript-apiзапрашивает официальные субтитры. Работает быстро и надёжно, но только если у видео есть субтитры. Мы настроили цепочку языков: сначала русские, потом английские, потом испанские и португальские (для академического контента часто есть).pytubefixс флагом ANDROID-клиента когда субтитров нет. Притворяется приложением YouTube на Android, что снижает шанс блокировки.Firecrawl или Jina крайний случай, когда всё остальное сломалось.
Каждые пару месяцев YouTube меняет что-то в API, и один из уровней отваливается. Каскад спасает: пользователь даже не замечает, что основной путь перестал работать.
Веб-страницы. Тут аналогичная история. Playwright (headless Chromium) с полным JS-рендерингом - основной вариант. Мы рандомизируем User-Agent, viewport, подменяем navigator без этого половина сайтов отдаёт капчу или пустую страницу. Текст вытаскиваем через readability (тот же алгоритм, что Firefox использует в режиме чтения). Если Playwright не справляется, то Jinja Reader, потом простой HTTP. Опять каскад.
Изображения. Сканы и фото отправляются в vision-модель как base64. Не самый быстрый OCR, но работает с рукописным текстом и кривыми сканами хорошо.
Векторизация: нарезка, эмбеддинги и грабли
После извлечения текста нужно подготовить его для семантического поиска. Это отдельная фоновая задача vectorize_source.
Нарезка на чанки
Текст разбивается на фрагменты по ~500 токенов с перекрытием в 15%. Звучит просто, но на самом деле штука сложнее, наш сплиттер пробует разделители по приоритету: сначала двойные переносы строки (границы абзацев), потом одинарные, потом точки, запятые, пробелы. Чтобы чанк не заканчивался на середине предложения.
Почему 500 токенов? Маленькие чанки (100-200 токенов) дают лучшую точность поиска, но теряют контекст. Большие (1000+) наоборот. 500 эмпирически выбранная золотая середина для наших сценариев использования: научные статьи, конспекты, длинные лонгриды.
Четыре стратегии поиска
Когда модель вызывает инструмент search_sources, внутри запускаются четыре стратегии параллельно:
Vector search семантический поиск по эмбеддингам чанков. Находит по смыслу, даже если слова не совпадают. Запрос «проблемы со сном» найдёт чанк про «нарушения циркадных ритмов».
Text search классический BM25 по полному тексту. Находит точные совпадения ключевых слов.
Title search полнотекстовый поиск по заголовкам. Быстрый способ найти нужный источник, если пользователь примерно знает, откуда информация.
Insight search поиск по саммари и инсайтам. Полезен, когда трансформация уже выделила ключевые тезисы и они содержат ответ.
Результаты дедуплицируются: если три стратегии нашли один и тот же источник это сильный сигнал релевантности. Скор бустится на +0.05 за каждое дополнительное «попадание». Модели возвращаются топ-5 результатов.
Почему четыре стратегии, а не одна? Потому что ни одна стратегия не работает идеально для всех типов запросов. Vector search отлично находит семантически близкое, но может пропустить точное совпадение термина. BM25 наоборот. Комбинация четырёх подходов покрывает больше случаев, чем любой из них по отдельности.
Бюджет контекста: как не взорвать окно модели
Это та часть, которой не было ни в одном туториале по RAG, и которую нам пришлось придумывать самим.
Проблема: модель нашла 5 релевантных источников, но каждый по 100 страниц. Отдать всё? Не влезет в контекст. Отдать по чанку? Потеряем информацию. Мы решили через систему бюджетов.
Общий бюджет на один запрос 300 000 символов (примерно 75K токенов). Для каждого источника минимум 5 000 символов (даже если бюджет почти исчерпан) и максимум 40 000.
Дальше три сценария:
Источник короткий отдаём целиком. Простой случай.
Источник длинный, но запрос известен отдаём инсайты + релевантные чанки. Для этого ещё раз запускаем vector search, но уже внутри конкретного источника, с низким порогом (cosine similarity ≥ 0.15). Получаются самые релевантные фрагменты из длинного документа.
Источник длинный, запрос неизвестен отдаём инсайты + начало текста. Не идеально, но лучше, чем ничего.
Эта система позволяет работать с книгами на сотни страниц без того, чтобы модель захлебнулась контекстом.
Флешкарточки и интервальное повторение
Отдельная подсистема, которая мне нравится: генерация флешкарточек из источников с полноценным интервальным повторением.

Работает так: пользователь выбирает источники или заметки, указывает количество карточек и модель генерирует пары «вопрос-ответ». Один факт - одна карточка. Формат строгий: JSON, каждая карточка валидируется.

Но генерация это полдела. Важнее то, что происходит после. Каждая карточка живёт в системе FSRS (Free Spaced Repetition Scheduler) это тот же алгоритм, что используют Anki-подобные приложения, но более современная реализация. Карточка запоминает свою «стабильность», «сложность» и «состояние» (новая, изучается, повторяется, переучивается). При каждом повторении пользователь ставит оценку от 1 до 4, и алгоритм пересчитывает, когда показать карточку снова.
Зачем мы это сделали, а не интегрировались с Anki? Потому что убирает шаг. Пользователь читает исследование в JuliaLM, тут же генерирует карточки и тут же их учит. Не нужно экспортировать, импортировать, переключаться между приложениями.
Поиск по базе знаний
Помимо RAG в чате, у нас есть отдельный поиск. Два режима: полнотекстовый (BM25 через встроенную функцию SurrealDB) и семантический (vector search по эмбеддингам).
Но есть и третий вариант «Спросить базу знаний». Это отдельный LangGraph-граф, который работает хитрее:
Модель анализирует вопрос и генерирует стратегию: до 5 поисковых запросов, каждый с инструкцией что именно искать и на что обращать внимание.
Все запросы выполняются параллельно, каждый возвращает до 10 результатов.
Для каждого набора результатов модель формулирует промежуточный ответ.
Финальная модель синтезирует всё в один связный ответ.
Звучит как overkill, но для сложных вопросов (типа «в чём авторы моих источников расходятся по теме X?») это даёт заметно лучшие результаты, чем одноходовый поиск.
Планы и квоты
Тарификация один из самых неочевидных инженерных челленджей. У нас три плана: Free (0₽, 20 промптов за всю жизнь аккаунта), Pro (799₽/мес, 800 промптов за 5-часовое окно), Business (1199₽/мес, 2000 промптов).
Ключевое решение скользящее 5-часовое окно вместо дневного или месячного лимита. Почему? Студенты. Типичный паттерн использования: человек садится за подготовку к экзамену и за один вечер делает 50-100 запросов, а потом неделю не заходит. Месячный лимит в 800 промптов при таком паттерне ощущается щедрым. Дневной лимит в 26 (800/30) ощущается жёстким. Пятичасовое окно компромисс: бустишь как хочешь, но не можешь утилизировать весь лимит за час и всё жди следующего окна.
Перед каждым вызовом LLM проверяется квота. Если подписка истекла автоматический даунгрейд на Free. Без сюрпризов, без скрытых списаний.
Весь путь целиком
Для наглядности полный пайплайн от PDF до ответа:
Пользователь загружает research.pdf ↓ API создаёт запись source (title="Обработка..."), ставит задачу в очередь ↓ Фоновый воркер подхватывает задачу: ├── Docling парсит PDF → чистый Markdown ├── Текст сохраняется в SurrealDB ├── Vectorize: нарезка на чанки → батчевый эмбеддинг → UPSERT └── Трансформации: параллельная генерация саммари ↓ Фронтенд видит status: "completed" ↓ Пользователь спрашивает: "Какие основные противоречия?" ↓ Собирается контекст (инсайты + текст, по настройкам пользователя) ↓ LangGraph agent: ├── Модель вызывает search_sources → 4 стратегии параллельно → топ-5 ├── Модель вызывает get_source_content → бюджетная доставка └── Модель генерирует ответ с цитатами ↓ SSE-стрим: токены в реальном времени → ai_message_complete
От загрузки PDF до ответа с цитатами 15-30 секунд на обработку плюс 5-10 секунд на генерацию ответа.
Что в итоге?
JuliaLM не обёртка над ChatGPT!!! Это система с собственным пайплайном обработки, четырёхстратегийным RAG, бюджетированием контекста и интервальным повторением. Не всё идеально, YouTube периодически ломает транскрипцию, а SurrealDB пока не Postgres по зрелости. Но для задачи «загрузил 30 статей, задал вопрос, получил ответ с цитатами» работает.

Если хотите попробовать есть бесплатный план на 20 запросов, сервис пока в бэта версии, возможны баги.
