Добрый день. Сегодня я расскажу о том, как я за 2 месяца с полного нуля создал доменную RAG систему с корпусом в 20+ книг. В статье затрону проблемы парсинга данных (особенно PDF документов, с которыми приходилось иметь дело), чанкинга, создания и индексации эмбеддингов, а также самого интересного – ретривера. Расскажу о latency, трейд-оффах, и сложностях реализации подобных систем локально на ноутбуке (хоть и «игровом») без использования API LLM.
Вся система делалась мной самостоятельно без использования LangChain – это чистый пайплайн от Tesseract, Pillow, MuPDF/Fitz до e5-multilingual, FAISS (+bm25, который я затрону в статье) и Qwen3:8B в качестве LLM.
Начну с того, что я являюсь профессиональным академическим музыкантом – музыковедом, если быть точнее. Так как работа/учеба академического музыканта завязана на работе с источниками, некомпетентность нейросети тут выделяется сильнее обычного.
Отсутствие ссылок на источники
Мало того, что нейросеть галлюцинирует – это, в принципе, всем было и так очевидно. Важно другое – нейросеть не умеет давать хорошие ссылки на источники, потому что главными источниками любой нейросети с режимом web searching, помимо ее датасетов, являются… Википедия и СМИ.
Невоспроизводимость источников
Вытекающей проблемой из первого пункта является невоспроизводимость этих самых источников и ответов: нейросеть всегда отвечает по-разному, с разными акцентами, даже с разными тезисами и доказательствами - особенно сильно это касается режима web searching и работы с академическими текстами. Это явная проблема в академической области, которая требует детерминированного ответа.
PDF-формат
Так же хочу сказать пару слов о PDF-формате – он занял большую часть моего пути, потому что настолько муторные вещи в жизни я еще не делал – bbox, DPI, кривой OCR-слой и шум от Tesseract передают привет) Подробнее ниже.
Для удобства, разделю пайплайн на два слоя – оффлайн блок: от создания корпуса до создания эмбеддингов, и онлайн блок: ретривер и работа с LLM.
Так как я музыковед, мне с этим проще – я уже знаю по колледжу/универу что да как «в нашем болоте». К счастью, музыковедческое академическое учение – учение «старой закалки», а именно советской, поэтому и книги советские. Это дает мне возможность облегчить хоть немного следующий шаг, потому что советская верстка на печатной машинке – это почти всегда голый текст. НО! Корпус-то у меня музыковедческий, а значит этот текст нотный, а значит OCR-движок его читает плохо, шумно и очень неприятно для будущих векторов. Как решать эту проблему?
Так как Tesseract является удобным «черным ящиком», работа с которым ограничивается image_to_pdf, с самим image все не так просто. Проблема была следующая – как толстый формат png (целых 8 бит на 4 канала RGBa!) сделать тонким, оставив самое нужное, при этом даже улучшив OCR качество (потому что чем больше оттенков серого – тем больше шума)?
Нужно было урезать ненужные биты, поэтому библиотека Pillow, а именно методы convert и point помогли тут более всего – перевод в ч/б, а затем в удобный формат CCITT Group4 не только уменьшил размер файла в 2-4 раза (в среднем), так еще и улучшил качество алгоритма Tesseract (потому что нейросети внутри него гораздо удобнее работать, когда контраст максимальный).
Так же, перед переводом в CCITT, при составлении карты пикселей, поэкспериментировал с методом Matrix библиотеки fitz – несмотря на то, что все нейронки мне «подсказывали», что «двухкратное умножение лучше всего», именно трехкратное умножение дало наиболее точный и лучший результат (по крайней мере с моим шрифтом советской печатной машинки).
Самая трудная часть пайплайна, которая ставит перед собой цели не только «сохранить все в нормальном виде, чтобы оно хоть как-то читалось», но еще чтобы и масштабировалось на 20 книг. Да, задача непростая, и тут я попал в интереснейший мир эвристического подхода, который мне даже понравился.
Во-первых, что стоило сразу понять и признать – я никак не вывезу 95%+ чистого текста без vision-модели. Во-вторых, мне это не нужно: для адекватной работы семантических векторов, и чтобы LLM из этого всего «безобразия» слепила ответ, мне нужен хотя бы 70%+ «чистоты» текста.
Осознав это, я, путем недельных проб и ошибок, сформировал следующий набор эвристик, которые характерны для моего стека книг, и который плюс-минус переживет какое-то масштабирование без переписывания архитектуры:
Фильтр по x0
Да, вот они родные bbox: никаких page.center – полагаюсь исключительно на горизонталь, которая дает мне достаточно информации. Фильтрацией по x0, (а, точнее, median(x0)) я сразу убиваю двух зайцев. Во-первых – убираю большую часть нотного шума (см. рисунок 1). Во-вторых – убираю шум OCR: всякие волоски и крошки, попавшие на скан, не щадят OCR-движок, и он может «распознать» букву в центре строки, которой там вообще нет.
len(line) < 3 == мусор
Отличная дешевая эвристика, которая убирает шум OCR, а также оставшийся нотный шум.
Исправление косяков fitz
Fitz – хоть и классный быстрый движок, который имеет «умные» алгоритмы, иногда его ум может только мешать, особенно в контексте советской верстки. Часто fitz может разделять строки неверно. Тут помогает y0: y_tolerance и алгоритм-аккумулятор, который собирает разрозненные fitz строки в одну физическую строку.
Составив набор эвристик, написание кода парсера – вопрос времени. Вместе со спаршенным текстом страниц, я сохранял метаданные в виде номеров этих самых страниц для будущей отправки пользователю – это одно из важнейших в моей системе.
Заключительный этап работы с данными довольно понятен. На этом этапе у меня происходит первый вызов библиотеки transformers – мне потребовалась токенизация. Единственное проблемное окно – создание оффсетов (и бинарный поиск по ним) для сохранения метаданных страниц/книги.
Деление на токены, как и будущее создание эмбеддингов, легло на e5-multilingual - довольно шустрая и понятная эмбеддинг модель для моих целей. Так как у модели ограничение на 512 токенов на чанк, пришлось делать шаг в 460 токенов + окно в 52 токена.
Создание эмбеддингов происходило путем игры в «черный ящик» - модель сама все собрала, а лишь добавил приписку "passage: " перед текстом, для более точной работы модели.
Тут мы выходим из алгоритмического безумия и входим в зону «релаксации» - оркестрацию «черных ящиков» (что может быть приятнее, чем пользоваться уже готовым решением, не правда ли?).
Начнём с FAISS – до него я использовал простое, как рубанок, умножение матриц (или cosine similarity) – я умножал query (запрос пользователя) на все эмбеддинги, а потом, с помощью argsort, находил top-k чанков. FAISS позволил мне это сделать быстрее с помощью индексации – правда, сам алгоритм cosine similarity остался в виде метода IndexFlatIP, который делает, фактически, тоже самое, но внутри «черного ящика».
Еще одним решением, эксперимента ради, было использовать FAISS не как ретривер, а как реранкер. Ретривер бы при этом осуществлялся с помощью bm25. Почему bm25? Потому что имена/термины/уникальные слова ценны и редки́, а с помощью «черного ящика» (алгоритмического в этот раз) я просто нахожу те самые нужные мне фамилии/термины, а потом «полирую» это дело с помощью e5-multilingual – ну не красота ли? Не все так однозначно, как оказалось.
Дело в том, что bm25 retrieval -> FAISS rerank – это не «улучшение» обычного FAISS, а совершенно другая логика. Это означает следующее: когда я применяю первую логику с bm25, я и получаю, и жертвую тем, что мне дает обычный retrieval.
Например, система на bm25 в основе может выдать точный ответ на вопрос «кто такой x», и не найти ответ на вопрос «почему x» - это логично исходя из природы этих двух алгоритмов: bm25 – статистический, FAISS – семантический.
Основные тесты я делал, пользуясь только семантическим поиском, потому что вопросов «почему» и «как» оказалось достаточно много)
Использовал Qwen3:8B. 12B мой ноутбук не потянул (8gb vram), поэтому пришлось довольствоваться крепким минимумом на сегодняшний день, достаточного для уровня «RAG у нас дома».
Так как моя RAG-система задумывалась как «LLM интерпретирует источники, выполняя работу умного справочника», я полностью убрал накопление памяти чата, чтобы не забивать бедный attention 8B модели ненужной инфой.
Жесткий sys-prompt (в котором я раз 10 повторил нейросети частицу «НЕ» в разных контекстах) создавал модели рамки, в которых она и должна была генерировать ответ, выступая в роли интерпретатора.
Единственный нюанс в xml-тегах и «почему я не использовал их» – они хуже для 8B модели, ухудшают attention и создают ненужные проблемы.
Самая вкусная часть, и у меня нет ни малейшего желания затягивать. В качестве UI – телеграм-бот, написанный на aiogram + asyncio (сделал пару create_task-ов для презентации в аудитории).
P.S. Так как я живу в Казахстане, источники - казахская музыкальная литература
На скриншотах видно, что RAG-система показала крепкий лакончиный ответ по двум вопросам, а также привела источники – конкретных авторов, книгу и страницы.
DeepSeek же, откровенно, сгаллюцинировал (выдумал имя композитора и всю информацию про балет) – ну это и логично, потому что я не включал у него режим web searching, а на казахстанские источники у него не хватает датасетов.
По итогам теста, ретривер точно нашел нужный кусок цитаты и вставил ее, а благодаря метаданным ее легко проверить на достоверность.
ChatGPT же при этом просто выдал, как и предполагалось, в качестве источника Википедию, что не является авторитетным источником в академии. Плюс, «прямая помощь» в предложенных от GPT источниках не упоминается (там просто справочная информация), что соизмеримо галлюцинации нейросети.
Благодаря довольно крепкому корпусу книг, система может не только проанализировать разные источники, но еще и стойко переносит смысловые «опечатки» (я написал «Биржан-сал» вместо корректного названия оперы - «Биржан и Сара»).
При 250-300 num_predict, система выдает задержку перед ответом в 8-10 сек с пиками до 12 сек, если ответ большой. При этом, «узким горлышком» в latency системы является именно генерация токенов LLM: если поиск индексов занимает, в среднем, 0.05-0.1 сек, то генерация LLM – все оставшееся время.
Система, на которой это все запускалось: rtx 4060 8gb VRAM, 16gb ddr5 и i7-13650hx. Так как я, очевидно, использовал режим cuda и для эмбеддинг-модели, и для LLM, вся система умещалась в 7gb VRAM, что я считаю неплохо.
Открыт к сотрудничеству и профессиональным обсуждениям
GitHub
Telegram
Email: [email protected]
Источник


