Когда RAG ломается из-за кривого парсинга — диагноз и вскрытие
RAG-системы — как тот самый друг, который умно рассуждает, но путает Шекспира с каким-то блогером. И виноват не LLM, а то, что на вход пришло. Подайте модели абзац из середины двухколоночного PDF — и она честно прочитает "продажа алкоголя запрещена" как "алкоголя запрещена продажа". Не потому, что модель глупая, а потому что колонки не разделили, и текст склеился в кашу.
Я пересмотрел десятки RAG-пайплайнов на собеседованиях и в реальных проектах. В 80% случаев проблема не в выборе LLM или эмбеддера — проблема в парсинге. Берут PyMuPDF, выгребают весь текст без разбора, режут по 512 токенов — и удивляются, почему ответы нерелевантны.
Типичная ошибка: считать PDF одной кучкой текста. На самом деле PDF — это контейнер с координатами каждого символа, линиями, изображениями, шрифтами. Игнорировать разметку — всё равно что отдавать нейросети несортированный набор букв вместо документа.
Два слоя, которые нужно разделять
Любой PDF (кроме простых текстовых) содержит два слоя:
- Текстовый слой — сам текст, его шрифты, кодировки, порядок следования (иногда неправильный).
- Визуальный слой — позиционирование элементов: колонки, таблицы, колонтитулы, изображения, подложки.
Обычные текстовые экстракторы (PyMuPDF, pdfplumber) работают с первым слоем. Они дергают строки из внутренней структуры PDF, но не понимают, где заканчивается колонка и начинается следующая. Для простых документов с одним столбцом это норм, но попробуйте скормить научную статью в две колонки — получите винегрет.
Решение — layout-анализ: мы сначала распознаём визуальную структуру (колонки, заголовки, таблицы, подписи), а потом извлекаем текст в правильном порядке. Именно так работает RAG-Anything и современный Docling.
Инструментальная карта на середину 2026 года
| Инструмент | Версия (актуальная на июнь 2026) | Сильные стороны | Слабые места |
|---|---|---|---|
| PyMuPDF (fitz) | 1.26.2 | Скорость, извлечение метаданных, работа с встроенными шрифтами | Не понимает layout, склеивает колонки |
| pdfplumber | 0.12.0 | Таблицы, точное позиционирование, отличная работа с границами | Медленный на больших файлах, не умеет распознавать колонки |
| Tesseract 5 | 5.5.0 (LSTM) | OCR для сканов, бесплатно, поддерживает 100+ языков | Чувствителен к качеству изображения, не понимает layout сам по себе |
| Docling | 2.8.0 | Семантический layout-анализ, понимание структуры, встроенный AI-анализатор | Требует GPU для лучшей работы, сложен в настройке |
| Unstructured.io | 0.16 (library) / API | Гибкость, поддержка множества форматов, хитрая логика разделения | Медленный, для серьезной точности требуется GPU или API-ключ |
| Kreuzberg (Rust) | 4.1.0 | Скорость (Rust), легковесность, хорошо для простых документов | Плохо с layout и таблицами (см. наш обзор Kreuzberg v4) |
Выбор инструмента зависит от типа документов. Если у вас однородные счета с чёткой структурой — хватит pdfplumber. Если научные статьи, контракты, отчёты — без layout-анализа (Docling, Unstructured) не обойтись.
Практика: как выглядит правильный пайплайн
Разберём на реальном примере — многостраничный отчёт с таблицами, колонтитулами и сносками. Я покажу код на Python, который можно запустить на ноутбуке (16 ГБ RAM, CPU). Полный стек описан в статье RAG на ноутбуке.
1 Подготовка и загрузка документа
Используем PyMuPDF для быстрого извлечения текстового слоя и метаданных, а pdfplumber — для таблиц. Но прежде нужно понять структуру страницы.
import fitz # PyMuPDF
import pdfplumber
from PIL import Image
import io
doc = fitz.open("report.pdf")
# Получаем количество страниц, метаданные
print(f"Страниц: {doc.page_count}, автор: {doc.metadata.get('author')}")
2 Layout-анализ: определяем колонки и блоки
Вручную писать детектор колонок — гиблое дело. Проще использовать Docling или pdf2image + Tesseract с опцией layout. Но если нужно быстрое решение — анализируем координаты текстовых блоков из PyMuPDF.
def get_text_blocks(page):
blocks = page.get_text("dict")["blocks"]
text_blocks = []
for b in blocks:
if b["type"] == 0: # текстовый блок
text_blocks.append({
"x0": b["bbox"][0],
"y0": b["bbox"][1],
"x1": b["bbox"][2],
"y1": b["bbox"][3],
"text": "".join([ span["text"] for line in b["lines"] for span in line["spans"]])
})
return text_blocks
# Простая эвристика: если на странице есть блоки, чьи x-координаты сильно смещены — две колонки
blocks = get_text_blocks(page)
x_positions = [ (b["x0"]+b["x1"])/2 for b in blocks ]
if max(x_positions) - min(x_positions) > page.rect.width * 0.4:
print("Похоже на two-column layout")
На практике лучше сразу использовать Docling. Он работает на базе AI-модели, которая натренирована на миллионах PDF и умеет выделять колонки, заголовки, сноски. В нашем гайде по Docling разобраны стратегии для больших документов.
3 Извлечение структурированных элементов: таблицы, сноски, подписи
Таблицы — главная головная боль. pdfplumber с ними справляется, но только если границы ячеек чётко прорисованы. Если таблица без линий — нужен либо OCR с детекцией таблиц, либо библиотека вроде Camelot или Table-Transformer.
with pdfplumber.open("report.pdf") as pdf:
for page_num, page in enumerate(pdf.pages):
tables = page.extract_tables()
if tables:
print(f"Страница {page_num+1}: найдено {len(tables)} таблиц")
for table in tables:
# table — список строк, каждая строка — список ячеек
for row in table:
print(row)
Совет: после извлечения таблицы не сохраняйте её как плоский текст. Лучше перевести в Markdown-таблицу или JSON. Тогда LLM сможет интерпретировать связь между столбцами. Плохая идея — склеить все ячейки через пробел.
4 OCR для сканов и плохих PDF
Если документ — скан (нет текстового слоя), без OCR не обойтись. Используем Tesseract с опцией psm 3 (автоматическое определение страницы) или, если нужно распознать колонки, psm 4.
tesseract scan.png output -l eng+rus --psm 4
Но Tesseract сам по себе не даёт структуры. Поэтому лучше сначала извлечь страницу как изображение (PyMuPDF может рендерить страницы), затем обработать через layout-анализатор (например, DocTR или Surya OCR от VikParuchuri), который выдаёт блоки текста с координатами. Эти блоки уже можно сортировать по колонкам.
5 Сборка финального документа и чанкинг
После извлечения всех элементов (текст в порядке чтения, таблицы как Markdown, подписи к изображениям) собираем единый документ. Важно сохранить иерархию: заголовки H1, H2, секции. Это поможет при чанкинге. Мы используем семантический чанкинг: соединяем родственные абзацы, не разрывая таблицы или списки.
# Пример: собираем страницу как Markdown-документ
def page_to_markdown(page, blocks, tables):
md = []
for block in blocks:
if block['type'] == 'title':
md.append(f"## {block['text']}")
elif block['type'] == 'paragraph':
md.append(block['text'])
# ... ещё блоки
for table in tables:
md.append(table_to_markdown(table))
return "\n\n".join(md)
Подробности чанкинга для RAG — в статье Почему RAG работает на демо, но ломается в проде. Если не следить за разрывом контекста — модель ответит только частью информации.
Как НЕ надо: три примера того, что убивает качество
Ошибка 1: Порядок текста при двух колонках
Типичная ситуация: PyMuPDF выдёргивает текст построчно слева направо. Если колонок две, он сначала прочитает первую строку левой колонки, потом первую строку правой — и текст смешивается. В итоге LLM получает кашу.
Правильный порядок:
[Левая колонка] Lorem ipsum dolor sit amet [Правая колонка] Consectetur adipiscing elit.
Что получаем:
Lorem ipsum dolor sit amet Consectetur adipiscing elit.
(и дальше в том же духе)
Решение: после извлечения блоков с координатами, сортируем их по x, затем по y, группируем по колонкам. Ещё лучше — использовать библиотеку с детекцией колонок, такую как Unstructured.io или Docling.
Ошибка 2: Сноски и колонтитулы
Колонтитулы (типа “Страница 3 из 10”) частенько внедряются в середину текста. Многие парсеры их пропускают, но если попадает в чанк — модель путает порядок страниц. Особенно опасно для многостраничных документов.
Решение: на основе анализа координат — блоки, которые повторяются на каждой странице в одной и той же позиции, можно исключить. Реализовать несложно: сравнить текст на всех страницах с одинаковыми y-координатами.
# Простой фильтр колонтитулов
from collections import Counter
def get_footer_candidates(pages_data):
# pages_data — список страниц, каждая содержит список блоков с координатами и текстом
footer_texts = []
for page in pages_data:
for block in page:
if block['y0'] > page_height * 0.85: # нижняя часть страницы
footer_texts.append(block['text'])
# Если один и тот же текст встречается на многих страницах — это колонтитул
counts = Counter(footer_texts)
return [text for text, count in counts.items() if count > len(pages_data) * 0.5]
Ошибка 3: Таблицы без границ
pdfplumber не видит таблицу, если нет чётких линий. В итоге текст из таблицы извлекается как обычный текст, но теряется структура — модель не может понять, что это таблица, и отвечает неверно. Выход — использовать детектор таблиц на базе компьютерного зрения (например, Table Transformer) или OCR с последующим анализом выравнивания.
Выбор подхода в зависимости от типа документа
| Тип документа | Рекомендуемый инструмент | Почему |
|---|---|---|
| Простые одноколоночные тексты (письма, статьи) | PyMuPDF + pdfplumber для таблиц | Быстро, дёшево, без зависимостей |
| Многоколоночные (научные журналы, отчёты) | Docling / Unstructured | Layout-анализ нужен обязательно |
| Сканы (без текстового слоя) | OCR (Surya / Tesseract) + layout-анализатор | Сначала изображение, потом структура |
| Документы со сложными таблицами | Camelot / Table Transformer + pdfplumber | Комбинация даёт лучшее распознавание |
Собираем всё вместе: простой, но эффективный пайплайн на Python
Ниже — готовый код, который можно взять за основу. Он делает layout-анализ через pdfplumber + PyMuPDF, детектирует таблицы, чистит колонтитулы и отдаёт чанки.
import pdfplumber
import fitz
from collections import defaultdict
class SmartPDFParser:
def __init__(self, path):
self.path = path
self.doc = fitz.open(path)
self.pages_content = []
def parse(self):
for page_num in range(len(self.doc)):
page = self.doc[page_num]
# Получаем текстовые блоки с координатами
blocks = page.get_text("dict")["blocks"]
# Фильтруем колонтитулы (упрощённо)
page_height = page.rect.height
blocks = [b for b in blocks if not self._is_header_footer(b, page_height)]
# Сортируем по вертикали, потом по горизонтали
blocks.sort(key=lambda b: (b["bbox"][1], b["bbox"][0]))
# Собираем текст
text = "\n".join([self._block_text(b) for b in blocks])
# Если есть таблицы на странице — добавляем их в формате Markdown
with pdfplumber.open(self.path) as pdf:
page_plumber = pdf.pages[page_num]
tables = page_plumber.extract_tables()
for table in tables:
md_table = self._table_to_markdown(table)
text += "\n\n" + md_table
self.pages_content.append(text)
return "\n\n---\n\n".join(self.pages_content)
def _is_header_footer(self, block, page_height):
y0, y1 = block["bbox"][1], block["bbox"][3]
top_margin = page_height * 0.05
bottom_margin = page_height * 0.95
return y1 < top_margin or y0 > bottom_margin
def _block_text(self, block):
text = ""
for line in block["lines"]:
for span in line["spans"]:
text += span["text"]
text += "\n"
return text.strip()
def _table_to_markdown(self, table):
if not table:
return ""
md_rows = []
for i, row in enumerate(table):
row_str = "|" + "|".join([cell or "" for cell in row]) + "|"
md_rows.append(row_str)
if i == 0:
separator = "|" + "|".join(["---"] * len(row)) + "|"
md_rows.append(separator)
return "\n".join(md_rows)
Важный нюанс: этот парсер не идеален. Он не умеет распознавать колонки — для настоящего продакшена используйте Docling или Unstructured. Но как стартовая точка для простых документов — вполне.
Когда один парсер — зло: как сделать адаптивную систему
Если у вас разнородные документы, не пытайтесь выудить всё одним инструментом. Лучше сделайте классификатор: быстрый анализ первых страниц (количество колонок, наличие таблиц, текстовый слой или скан) и на основе этого выбирайте стратегию. В статье Как выбрать метод RAG мы разбираем именно такой подход — от Regex до Vision моделей.
Неочевидный совет: не парсите PDF, если можно получить исходный формат
Звучит как ересь для DevOps’а, но иногда лучше отказаться от парсинга PDF в пользу другого формата. Если документы создаются в вашей организации — настаивайте на экспорте в Markdown, HTML или DOCX. Confluence2md — отличный пример: не нужно мучиться с PDF, если есть нормальные исходники. Но если PDF — единственный доступный формат, используйте многоуровневый подход, описанный выше.
И ещё: никогда не верьте, что PyMuPDF выдал идеально. Всегда проверяйте на реальных данных. Прогоняйте пайплайн на тестовой выборке, считайте метрики (например, совпадение с оригиналом по ключевым фразам). Без валидации вы просто гадаете.