ВведениеДобрый день. Сегодня я расскажу о том, как я за 2 месяца с полного нуля создал доменную RAG систему с корпусом в 20+ книг. В статье затрону проблемы парВведениеДобрый день. Сегодня я расскажу о том, как я за 2 месяца с полного нуля создал доменную RAG систему с корпусом в 20+ книг. В статье затрону проблемы пар

Как гуманитарий за 2 месяца с нуля RAG систему построил, или Парсинг PDF по-хардкору

2026/02/13 14:15
9м. чтение

Введение

Добрый день. Сегодня я расскажу о том, как я за 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-движок его читает плохо, шумно и очень неприятно для будущих векторов. Как решать эту проблему?

Рис. 1. Типичная страница из музыковедческого источника: ноты посередине с текстом внутри, сноска внизу
Рис. 1. Типичная страница из музыковедческого источника: ноты посередине с текстом внутри, сноска внизу

Создание OCR

Так как Tesseract является удобным «черным ящиком», работа с которым ограничивается image_to_pdf, с самим image все не так просто. Проблема была следующая – как толстый формат png (целых 8 бит на 4 канала RGBa!) сделать тонким, оставив самое нужное, при этом даже улучшив OCR качество (потому что чем больше оттенков серого – тем больше шума)?

Нужно было урезать ненужные биты, поэтому библиотека Pillow, а именно методы convert и point помогли тут более всего – перевод в ч/б, а затем в удобный формат CCITT Group4 не только уменьшил размер файла в 2-4 раза (в среднем), так еще и улучшил качество алгоритма Tesseract (потому что нейросети внутри него гораздо удобнее работать, когда контраст максимальный).

Так же, перед переводом в CCITT, при составлении карты пикселей, поэкспериментировал с методом Matrix библиотеки fitz – несмотря на то, что все нейронки мне «подсказывали», что «двухкратное умножение лучше всего», именно трехкратное умножение дало наиболее точный и лучший результат (по крайней мере с моим шрифтом советской печатной машинки).

Рис. 2. Разница в размерах файлов PDF без OCR и с OCR после моей обработки
Рис. 2. Разница в размерах файлов PDF без OCR и с OCR после моей обработки

Парсинг PDF

Самая трудная часть пайплайна, которая ставит перед собой цели не только «сохранить все в нормальном виде, чтобы оно хоть как-то читалось», но еще чтобы и масштабировалось на 20 книг. Да, задача непростая, и тут я попал в интереснейший мир эвристического подхода, который мне даже понравился.

Во-первых, что стоило сразу понять и признать – я никак не вывезу 95%+ чистого текста без vision-модели. Во-вторых, мне это не нужно: для адекватной работы семантических векторов, и чтобы LLM из этого всего «безобразия» слепила ответ, мне нужен хотя бы 70%+ «чистоты» текста.

Осознав это, я, путем недельных проб и ошибок, сформировал следующий набор эвристик, которые характерны для моего стека книг, и который плюс-минус переживет какое-то масштабирование без переписывания архитектуры:

Фильтр по x0

Да, вот они родные bbox: никаких page.center – полагаюсь исключительно на горизонталь, которая дает мне достаточно информации. Фильтрацией по x0, (а, точнее, median(x0)) я сразу убиваю двух зайцев. Во-первых – убираю большую часть нотного шума (см. рисунок 1). Во-вторых – убираю шум OCR: всякие волоски и крошки, попавшие на скан, не щадят OCR-движок, и он может «распознать» букву в центре строки, которой там вообще нет.

len(line) < 3 == мусор

Отличная дешевая эвристика, которая убирает шум OCR, а также оставшийся нотный шум.

Рис. 3. Как видит нотный текст Tesseract
Рис. 3. Как видит нотный текст Tesseract

Исправление косяков fitz

Fitz – хоть и классный быстрый движок, который имеет «умные» алгоритмы, иногда его ум может только мешать, особенно в контексте советской верстки. Часто fitz может разделять строки неверно. Тут помогает y0: y_tolerance и алгоритм-аккумулятор, который собирает разрозненные fitz строки в одну физическую строку.

Составив набор эвристик, написание кода парсера – вопрос времени. Вместе со спаршенным текстом страниц, я сохранял метаданные в виде номеров этих самых страниц для будущей отправки пользователю – это одно из важнейших в моей системе.

Чанкинг и создание эмбеддингов

Заключительный этап работы с данными довольно понятен. На этом этапе у меня происходит первый вызов библиотеки transformers – мне потребовалась токенизация. Единственное проблемное окно – создание оффсетов (и бинарный поиск по ним) для сохранения метаданных страниц/книги.

Деление на токены, как и будущее создание эмбеддингов, легло на e5-multilingual - довольно шустрая и понятная эмбеддинг модель для моих целей. Так как у модели ограничение на 512 токенов на чанк, пришлось делать шаг в 460 токенов + окно в 52 токена.

Создание эмбеддингов происходило путем игры в «черный ящик» - модель сама все собрала, а лишь добавил приписку "passage: " перед текстом, для более точной работы модели.

Онлайн-блок

Тут мы выходим из алгоритмического безумия и входим в зону «релаксации» - оркестрацию «черных ящиков» (что может быть приятнее, чем пользоваться уже готовым решением, не правда ли?).

Ретривл и Реранкинг: FAISS, bm25

FAISS

Начнём с FAISS – до него я использовал простое, как рубанок, умножение матриц (или cosine similarity) – я умножал query (запрос пользователя) на все эмбеддинги, а потом, с помощью argsort, находил top-k чанков. FAISS позволил мне это сделать быстрее с помощью индексации – правда, сам алгоритм cosine similarity остался в виде метода IndexFlatIP, который делает, фактически, тоже самое, но внутри «черного ящика».

Рис. 4. Результат FAISS ретривера с готовыми метаданными
Рис. 4. Результат FAISS ретривера с готовыми метаданными

Нюансы реализации с bm25

Еще одним решением, эксперимента ради, было использовать FAISS не как ретривер, а как реранкер. Ретривер бы при этом осуществлялся с помощью bm25. Почему bm25? Потому что имена/термины/уникальные слова ценны и редки́, а с помощью «черного ящика» (алгоритмического в этот раз) я просто нахожу те самые нужные мне фамилии/термины, а потом «полирую» это дело с помощью e5-multilingual – ну не красота ли? Не все так однозначно, как оказалось.

Дело в том, что bm25 retrieval -> FAISS rerank – это не «улучшение» обычного FAISS, а совершенно другая логика. Это означает следующее: когда я применяю первую логику с bm25, я и получаю, и жертвую тем, что мне дает обычный retrieval.

Например, система на bm25 в основе может выдать точный ответ на вопрос «кто такой x», и не найти ответ на вопрос «почему x» - это логично исходя из природы этих двух алгоритмов: bm25 – статистический, FAISS – семантический.

Основные тесты я делал, пользуясь только семантическим поиском, потому что вопросов «почему» и «как» оказалось достаточно много)

Локальная LLM и «промт-инжиниринг»

Использовал Qwen3:8B. 12B мой ноутбук не потянул (8gb vram), поэтому пришлось довольствоваться крепким минимумом на сегодняшний день, достаточного для уровня «RAG у нас дома».

Так как моя RAG-система задумывалась как «LLM интерпретирует источники, выполняя работу умного справочника», я полностью убрал накопление памяти чата, чтобы не забивать бедный attention 8B модели ненужной инфой.

Жесткий sys-prompt (в котором я раз 10 повторил нейросети частицу «НЕ» в разных контекстах) создавал модели рамки, в которых она и должна была генерировать ответ, выступая в роли интерпретатора.

Единственный нюанс в xml-тегах и «почему я не использовал их» – они хуже для 8B модели, ухудшают attention и создают ненужные проблемы.

Рис. 5. Sys-prompting «у нас дома»
Рис. 5. Sys-prompting «у нас дома»

Тесты и сравнения

Самая вкусная часть, и у меня нет ни малейшего желания затягивать. В качестве UI – телеграм-бот, написанный на aiogram + asyncio (сделал пару create_task-ов для презентации в аудитории).

P.S. Так как я живу в Казахстане, источники - казахская музыкальная литература

Простой тест на «ориентирование в пространстве»

Рис. 5.1. Ответ моей системы на на простой вопрос справочного характера
Рис. 5.1. Ответ моей системы на на простой вопрос справочного характера
Рис. 5.2. Ответ DeepSeek V3.2 на мой вопрос
Рис. 5.2. Ответ DeepSeek V3.2 на мой вопрос

На скриншотах видно, что RAG-система показала крепкий лакончиный ответ по двум вопросам, а также привела источники – конкретных авторов, книгу и страницы.

DeepSeek же, откровенно, сгаллюцинировал (выдумал имя композитора и всю информацию про балет) – ну это и логично, потому что я не включал у него режим web searching, а на казахстанские источники у него не хватает датасетов.

Тест «Приведи цитату»

Рис. 6.1. Ответ системы
Рис. 6.1. Ответ системы
Рис. 6.2. Информация легко находится засчет метаданных
Рис. 6.2. Информация легко находится засчет метаданных
Рис. 6.3. Ответ ChatGPT 5.2. (Я обрезал кучу ненужного, что GPT мне предложил в ответе)
Рис. 6.3. Ответ ChatGPT 5.2. (Я обрезал кучу ненужного, что GPT мне предложил в ответе)

По итогам теста, ретривер точно нашел нужный кусок цитаты и вставил ее, а благодаря метаданным ее легко проверить на достоверность.

ChatGPT же при этом просто выдал, как и предполагалось, в качестве источника Википедию, что не является авторитетным источником в академии. Плюс, «прямая помощь» в предложенных от GPT источниках не упоминается (там просто справочная информация), что соизмеримо галлюцинации нейросети.

Тест «Анализ разных источников»

Рис. 7. Анализ источников из корпуса
Рис. 7. Анализ источников из корпуса

Благодаря довольно крепкому корпусу книг, система может не только проанализировать разные источники, но еще и стойко переносит смысловые «опечатки» (я написал «Биржан-сал» вместо корректного названия оперы - «Биржан и Сара»).

Latency и локальная реализация

При 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]

Источник

Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу [email protected] для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.