Объединить 100 PDF в книгу локально: гайд с LlamaIndex и RAG | AiManual
AiManual Logo Ai / Manual.
01 Фев 2026 Гайд

Склейка 100 PDF в книгу: локальная LLM как редактор-структурализатор

Пошаговый гайд по склейке 100 PDF в одну структурированную книгу на своем компьютере с помощью локальных LLM (Llama, Mistral) и RAG. Без облаков и утечек данных

Когда простой склейки PDF уже недостаточно

Представьте: у вас есть сотня PDF-файлов. Технические руководства, вырезки из статей, переведенные главы, заметки по проекту. Формально их можно склеить в один файл командой pdfunite *.pdf output.pdf. Получится цифровая свалка. Оглавление из ста PDF-файлов. Дубликаты. Противоречивая информация на страницах 15 и 347. Полный хаос.

Настоящая задача не в склейке, а в синтезе. Нужна не папка, а книга. С внятной структурой, логичными главами, устраненными повторами, резюме по темам. Раньше для этого требовался редактор-человек, месяцы работы и тонны кофе. В 2026 году эту работу можно делегировать локальной LLM. Главное — не отправить конфиденциальные документы в облако OpenAI или Anthropic.

Почему это сложнее, чем кажется? LLM не видит PDF. Она видит текст, извлеченный из PDF. Качество извлечения определяет 70% успеха. Сканированные документы без OCR, таблицы, формулы, двухколоночная верстка — все это превращает текст в кашу. Сначала нужно получить чистый текст, а уже потом думать о структурировании.

Архитектура: RAG не для вопросов, а для структурирования

Обычно Retrieval-Augmented Generation (RAG) используют для вопросно-ответных систем: загрузил документы, спрашиваешь, получаешь ответ с цитатами. Мы перевернем парадигму. Мы используем RAG как мозг, который обозревает все документы сразу, находит связи, определяет иерархию и генерирует не ответы, а структуру будущей книги.

Компонент Наша задача Инструменты (актуально на 01.02.2026)
Извлечение текста Достать текст из 100 PDF с сохранением базового форматирования (заголовки, списки) Unstructured.io, Docling, pymupdf (fitz) с кастомной постобработкой
Чанкинг и эмбеддинги Разбить текст на смысловые блоки и перевести в векторное пространство для поиска связей LlamaIndex с рекурсивным сплиттером, BGE-M3 (последняя версия) или nomic-embed-text-v2.5 для эмбеддингов
Локальная LLM Анализ, структурирование, написание связующих текстов Llama 3.2 11B Vision (для документов с изображениями), Mistral-Nemo 12B, Qwen2.5 14B. Все через Ollama или llama.cpp.
Векторная БД Хранить все чанки и быстро искать тематические кластеры ChromaDB (простота), Qdrant (производительность), или просто FAISS индекс в памяти для одного запуска

Ключевая мысль: мы не загружаем все 100 PDF в контекстное окно модели (оно даже у Llama 3.2 11B ограничено 128K токенами). Мы используем RAG для итеративного "опроса" коллекции документов. Сначала модель предлагает возможную структуру книги на основе анализа заголовков и введений. Потом мы запрашиваем у RAG контент для каждой proposed главы. Модель анализирует этот контент, уточняет структуру, выявляет пробелы и дубликаты. И так по кругу, пока не получится внятный план.

1 Подготовка: вытаскиваем текст и превращаем в "понимаемые" чанки

Первая и самая скучная часть. Нужно пробежаться по всем PDF и извлечь текст. Не используйте простой pdfplumber или PyPDF2 для сложных документов — они собьют порядок чтения. Возьмите unstructured[pdf] или библиотеку Docling, которую мы разбирали в гайде по обработке длинных PDF. Они лучше справляются с колонками и таблицами.

# Установка стека для извлечения (актуально на 01.02.2026)
pip install "unstructured[pdf]" llama-index pymupdf
# Пример: извлечение текста с базовой структурой
from unstructured.partition.pdf import partition_pdf
import os

text_elements = []
for pdf_file in os.listdir("./pdfs/"):
    if pdf_file.endswith(".pdf"):
        elements = partition_pdf(
            filename=os.path.join("./pdfs/", pdf_file),
            strategy="hi_res",  # Для сканов с картинками
            languages=["rus", "eng"]  # Указываем языки
        )
        for el in elements:
            # Сохраняем тип элемента (заголовок, текст) для будущего чанкинга
            text_elements.append({
                "source": pdf_file,
                "type": el.category,
                "text": el.text
            })

Ошибка №1: слепое разбиение по символам. Если резать текст каждые 1000 символов, заголовок окажется в одном чанке, а относящийся к нему текст — в другом. RAG потом не найдет связи. Используйте семантический чанкинг: разбивайте по заголовкам (H1, H2) или с помощью рекурсивного сплиттера из LlamaIndex, который учитывает структуру.

2 Загружаем в LlamaIndex и создаем "интеллектуальный" индекс

Теперь берем извлеченные тексты и загружаем их в LlamaIndex (версия 0.10.x на начало 2026 года существенно переработана). Не используйте SimpleDirectoryReader — он плохо сохраняет метаданные. Собираем кастомные документы.

from llama_index.core import Document, VectorStoreIndex
from llama_index.core.node_parser import SemanticSplitterNodeParser
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb

# 1. Создаем документы LlamaIndex с метаданными (источник PDF)
docs = []
for elem in text_elements:
    doc = Document(
        text=elem["text"],
        metadata={
            "source": elem["source"],
            "type": elem["type"]
        }
    )
    docs.append(doc)

# 2. Инициализируем модель эмбеддингов ЛОКАЛЬНО
# BGE-M3 или nomic-embed-text-v2.5 — лучший выбор на 2026 год для мультиязычности
embed_model = HuggingFaceEmbedding(
    model_name="BAAI/bge-m3",  # Или "nomic-ai/nomic-embed-text-v2.5"
    device="cuda",  # или "cpu", если нет GPU
    trust_remote_code=True
)

# 3. Парсер узлов с семантическим разделением
splitter = SemanticSplitterNodeParser(
    buffer_size=1,
    breakpoint_percentile_threshold=95,
    embed_model=embed_model
)
nodes = splitter.get_nodes_from_documents(docs)

# 4. Создаем векторную БД в памяти
chroma_client = chromadb.EphemeralClient()
chroma_collection = chroma_client.create_collection("pdf_book")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)

# 5. Собираем индекс
index = VectorStoreIndex(
    nodes=nodes,
    embed_model=embed_model,
    vector_store=vector_store
)

Теперь у нас есть индекс, где каждый "чанк" (узел) — это семантически цельный фрагмент текста с привязкой к исходному PDF. Модель сможет запрашивать у этого индекса не только по ключевым словам, но и по смыслу: "найди все, что связано с настройкой PostgreSQL", даже если в тексте слово "PostgreSQL" не встречается, а есть "инициализация кластера БД" и "конфигурация postgresql.conf".

3 Запускаем локальную LLM и начинаем диалог с документами

Самое интересное. Нужно выбрать модель, которая сможет "держать в голове" сложную задачу структурирования и генерировать длинные, связные тексты. На 01.02.2026 я рекомендую Llama 3.2 11B Vision (если в PDF есть схемы) или Qwen2.5 14B для чистого текста. Обе отлично работают с русским и английским. Устанавливаем через Ollama — это самый простой путь.

# Устанавливаем Ollama и качаем модель (пример для Llama 3.2 11B)
curl -fsSL https://ollama.ai/install.sh | sh
ollama pull llama3.2:11b  # Или qwen2.5:14b
from llama_index.llms.ollama import Ollama
from llama_index.core import PromptTemplate

# Подключаемся к локальной LLM через Ollama
llm = Ollama(
    model="llama3.2:11b",
    base_url="http://localhost:11434",
    temperature=0.1,  # Низкая температура для более детерминированных структур
    request_timeout=300.0  # Долгая генерация
)

# Создаем движок запросов к нашему индексу
query_engine = index.as_query_engine(
    llm=llm,
    similarity_top_k=10,  # Берем топ-10 релевантных чанков для анализа
    response_mode="tree_summarize"  # Режим, который суммирует информацию из нескольких чанков
)

Теперь — магия промптов. Мы не будем просто спрашивать "о чем эти документы?". Мы проведем модель через многоэтапный диалог.

💡
Промпт — это инструкция для модели. Чем конкретнее, тем лучше. Вместо "создай структуру" пишем "проанализируй список тем ниже, сгруппируй их по 5-7 логическим разделам, каждый раздел озаглавь, для каждого раздела выдели 3-4 подтемы, отметь темы, которые встречаются в нескольких документах (возможные дубликаты)".
# Шаг 1: Получаем "сырой" список всех основных тем из документов
first_prompt = """
Ты — редактор, который анализирует коллекцию технических документов.
На основе предоставленного контента извлеки список всех основных тем, которые в них затрагиваются.
Перечисли темы в виде маркированного списка. Для каждой темы укажи, в скольких исходных документах она встречается (на основе метаданных 'source').

Контент для анализа:
{context_str}
"""

# Запускаем запрос через наш RAG движок
response = query_engine.query(
    first_prompt
)
print("Выявленные темы:\n", response)

Модель вернет что-то вроде:

  • Настройка PostgreSQL (встречается в 8 документах)
  • Основы Python для анализа данных (в 12 документах)
  • Оптимизация SQL-запросов (в 5 документах)
  • Введение в Docker (в 3 документах) ... и так далее.

4 Итеративное уточнение: от тем к оглавлению книги

Получив список тем, мы просим модель предложить структуру книги. Это итеративный процесс. После первого наброска мы запрашиваем у RAG детальное содержание для каждой proposed главы, показываем его модели и спрашиваем: "Согласен ли ты с такой группировкой? Какие подтемы упущены? Где есть избыточность?"

# Шаг 2: Генерация структуры книги
structure_prompt = """
На основе списка тем ниже предложи структуру книги для начинающих и средних специалистов.
Книга должна быть практической, от простого к сложному.
Представь структуру в формате:

Название книги: [Предложи название]

Часть I: [Название части]
  Глава 1.1: [Название главы] (основные темы: ...)
  Глава 1.2: [Название главы] (основные темы: ...)
...

Учти, что темы 'Настройка PostgreSQL' и 'Оптимизация SQL-запросов' логично объединить в один раздел.
Темы, встречающиеся менее чем в 2 документах, вынеси в приложения или исключи.

Список тем:
{themes_list}
"""

# Подставляем ответ предыдущего шага в промпт
structure_response = query_engine.query(
    structure_prompt.format(themes_list=str(response))
)

Модель сгенерирует детальный план. Но это еще не все. Теперь мы берем каждую предлагаемую главу и "скармливаем" RAG запрос: "Дай мне весь контент, относящийся к 'индексам в PostgreSQL' и 'EXPLAIN ANALYZE'". Полученные тексты мы снова отправляем модели с вопросом: "Достаточно ли этого материала для главы? Что нужно добавить? Есть ли противоречия между документами?"

Ошибка №2: слепое доверие к структуре от LLM. Модель может предложить логичную, но неполную структуру. Она не знает, чего не знает. Всегда проверяйте с помощью RAG, что для каждой главы есть достаточно исходного материала. Если для "Главы 3.5" RAG возвращает только два коротких чанка — либо объединяйте с другой главой, либо помечайте как требующую дополнения.

5 Финальная сборка: от структуры к единому документу

У нас есть утвержденная структура (оглавление) и для каждого раздела — набор релевантных текстовых фрагментов (чанков) из оригинальных PDF. Теперь задача — сгенерировать связный текст для каждой главы, используя эти фрагменты как источник. Мы не просто копируем чанки, мы просим модель переписать информацию в едином стиле, устранить противоречия, добавить плавные переходы.

# Функция для генерации текста главы
def generate_chapter_content(chapter_title, related_chunks):
    chapter_prompt = """
    Ты — технический писатель. Напиши главу книги на тему "{chapter_title}".
    Используй ТОЛЬКО информацию из предоставленных источников ниже. Не добавляй выдуманных фактов.
    Если в источниках есть противоречия, укажи на это в тексте ("В некоторых руководствах рекомендуется X, в других — Y").
    Стиль: практический, четкий, без лишней воды.
    Объем: примерно 1500-2000 слов.
    Начни с краткого введения, затем раскрой тему, закончи резюме или переходом к следующей теме.
    
    Источники:
    {sources_text}
    """
    
    sources_text = "\n---\n".join([chunk.text for chunk in related_chunks])
    
    # Используем LLM для генерации главы
    chapter_response = query_engine.query(
        chapter_prompt.format(
            chapter_title=chapter_title,
            sources_text=sources_text
        )
    )
    return chapter_response

# Пример: получаем чанки для конкретной главы через поиск в индексе
from llama_index.core import VectorStoreIndex
retriever = index.as_retriever(similarity_top_k=15)
relevant_nodes = retriever.retrieve("настройка PostgreSQL индексы производительность")

chapter_text = generate_chapter_content(
    "Оптимизация баз данных: индексы и анализ запросов в PostgreSQL",
    relevant_nodes
)

После того как все главы сгенерированы, остается техническая часть: собрать их в один файл. Можно вывести в Markdown (и потом конвертировать в PDF через Pandoc), или сразу в HTML, или даже использовать библиотеку типа ReportLab для генерации PDF прямо из Python. Важно сохранить сгенерированное оглавление как гиперссылки.

Что может пойти не так (и как это чинить)

  • Модель "галлюцинирует" структуру. Она предлагает главы, для которых в исходниках нет материала. Лечение: после каждого предложения модели делайте проверочный запрос к RAG: "Покажи все, что связано с предложенной главой 'Квантовые вычисления для DevOps'". Если результатов нет или они скудные — корректируйте промпт: "Предлагай только те разделы, для которых есть хотя бы 5 релевантных текстовых фрагментов".
  • Каша из языков. Если PDF на русском и английском, модель может начать миксовать языки в одной главе. Укажите в системном промпте: "Все итоговые тексты должны быть на русском языке. Английские термины приводи в скобках".
  • Потеря специфического формата. Код, команды, таблицы из оригинальных PDF могут потеряться или превратиться в простой текст. Для сохранения кода используйте специальные обработчики в Unstructured или Docling, которые выделяют блоки кода в отдельные элементы с типом Code. Затем в промпте для модели укажите: "Блоки кода сохраняй в точности как в источнике, обрамляя их тройными бэктиками ()".
  • Огромное время обработки. 100 PDF — это десятки тысяч чанков. Генерация каждой главы может занимать 2-5 минут. Общее время — часы. Оптимизация: 1) Используйте кэширование эмбеддингов (сохраните индекс на диск после создания). 2) Генерируйте главы параллельно, если RAM и VRAM позволяют запустить несколько инстансов Ollama. 3) Для чернового варианта уменьшайте similarity_top_k до 5-7.

Альтернатива: когда не хочется писать код

Если весь этот Python-пайплайн кажется слишком низкоуровневым, посмотрите на локальные альтернативы Google NotebookLM. Например, Anything LLM или PrivateGPT (который активно развивается). Это готовые десктопные приложения с GUI, которые под капотом делают примерно то же самое: загружают PDF, строят индекс, позволяют "беседовать" с документами. Вы можете использовать их режим "Summarize" или "Synthesize" для создания конспектов по группам документов, а затем вручную собрать эти конспекты в книгу. Это менее автоматизированно, но не требует программирования.

Однако у готовых решений есть ограничение: они заточены под вопросно-ответный режим. Заставить их пройти многоэтапный процесс структурирования, как в нашем гайде, сложно. Придется вручную задавать цепочку промптов. Поэтому для 100 PDF я все же рекомендую кастомный пайплайн на LlamaIndex — он дает полный контроль.

💡
Прогноз на 2026-2027: появятся специализированные локальные инструменты именно для синтеза документов, а не только для Q&A. Ожидайте что-то вроде "Local Booksmith AI", который будет принимать папку с PDF и вываливать готовую структурированную книгу в один клик. Пока же приходится собирать такой инструмент самим из кубиков LlamaIndex, локальных моделей и терпения.

Итоговый совет: начинайте с малого. Возьмите не 100 PDF, а 10. Отладьте пайплайн, поймите, как ваши конкретные документы извлекаются и индексируются. Поиграйтесь с промптами. Только потом масштабируйтесь на всю коллекцию. И помните — даже лучшая LLM не заменит редактора-человека на финальном этапе вычитки. Она создает отличный черновик, каркас. Но последнее слово, чувство стиля и проверку логики оставьте за собой.