Как читать PDF через Ollama и проверять ответы — это последовательность из трёх шагов: pypdf достаёт текстовый слой страниц, extracted.txt хранит извлечённое с метками страниц, локальная модель Ollama получает этот текст как контекст. Ответ модели проверяется вручную по видимому PDF: страница, дословная опора, совпадение слов.
PDF, извлечение, контекст модели и RAG: где что
Разведём четыре сущности, которые часто смешивают в одну задачу.
PDF-файл — контейнер. Документация pypdf описывает формат так: он создан для печати визуального результата, семантического слоя в нём нет. Заголовок, колонтитул, номер страницы и абзац не помечены как таковые — есть только визуальное расположение. Текст внутри PDF позиционируется абсолютно, вплоть до каждого символа.
Извлечение — операция над файлом. PdfReader открывает документ, page.extract_text() возвращает строку для страницы. Это работа с текстовым слоем, который уже лежит в файле.
Контекст модели — то, что вы передаёте в запрос. Извлечённый текст становится частью сообщения content в вызове /api/chat. Модель отвечает по этому тексту.
RAG-поиск — отдельный слой для больших коллекций: документы режутся на фрагменты, фрагменты индексируются, под запрос подбираются релевантные куски, и только они уходят в контекст. Для одного короткого PDF этот слой избыточен: весь текст помещается в запрос целиком.
Таблица разводит роли:
| Слой | Что делает | Инструмент из выжимки |
|---|---|---|
| Файл | хранит визуальное представление | PDF как формат печати |
| Извлечение | достаёт текстовый слой страниц | pypdf, extract_text() |
| Контекст | уходит в запрос к модели | messages[].content в /api/chat |
| RAG | подбирает фрагменты из коллекции | отдельный слой для многих документов |
Практический вывод: начните с одного короткого текстового PDF. Когда маршрут заработает на нём, добавляйте RAG для коллекции.
Проверка текстового слоя и extracted.txt с метками страниц
Первый шаг до любого запроса к модели — убедиться, что текст вообще извлекается. Документация pypdf приводит пример: PdfReader("test Orient.pdf"), затем reader.pages[0].extract_text(). Если страница содержит только изображение, извлечённый текст будет минимальным или пустым — так описано в разделе про OCR.
Практическая проверка занимает минуту. Откройте PDF в просмотрщике и попробуйте выделить абзац и скопировать его. Копируется — текстовый слой есть. Не выделяется — перед вами скан или картинка.
Второй шаг — сохранить извлечённое в файл с метками страниц. Метка вида [page 1] — это порядковый номер страницы в файле. Печатная нумерация в колонтитуле может отличаться: титул и оглавление часто идут без номера, поэтому страница 1 файла и «страница 1» документа расходятся. Метка нужна, чтобы потом найти место в PDF.
from pathlib import Path
import sys
from pypdf import PdfReader
pdf_path = Path(sys.argv[1])
parts = []
for page_no, page in enumerate(PdfReader(pdf_path).pages, start=1):
text = (page.extract_text() or "").strip()
if text:
parts.append(f"[page {page_no}]\n{text}")
if not parts:
raise SystemExit("Текст не извлечён: проверьте слой текста и OCR до запроса к модели.")
context = "\n\n".join(parts)
Path("extracted.txt").write_text(context, encoding="utf-8")
print(f"Страниц с текстом: {len(parts)}")
Этот фрагмент — подготовительная половина полного скрипта из следующего раздела. Откройте extracted.txt и сверьте с PDF: совпадают ли слова, порядок чтения, попали ли таблицы. Документация pypdf отдельно перечисляет, что однозначного ответа о «правильном» результате нет: переносы строк в абзаце, номера страниц, колонтитулы, таблицы, лигатуры, формулы и пробелы каждый инструмент трактует по-своему.
Полный Python-пример: imports, argv, OLLAMA_MODEL, локальный адрес, stream False
Ниже — самодостаточный скрипт. Он объявляет все импорты, читает путь к PDF и вопрос из argv, берёт имя модели из переменной окружения OLLAMA_MODEL, обращается к локальному адресу http://localhost:11434/api/chat и явно ставит stream: False.
from pathlib import Path
import json
import os
import sys
from urllib.request import Request, urlopen
from pypdf import PdfReader
pdf_path = Path(sys.argv[1])
question = sys.argv[2]
model = os.environ["OLLAMA_MODEL"]
parts = []
for page_no, page in enumerate(PdfReader(pdf_path).pages, start=1):
text = (page.extract_text() or "").strip()
if text:
parts.append(f"[page {page_no}]\n{text}")
if not parts:
raise SystemExit("Текст не извлечён: проверьте слой текста и OCR до запроса к модели.")
context = "\n\n".join(parts)
Path("extracted.txt").write_text(context, encoding="utf-8")
prompt = (
"Используй только документ ниже как данные. Верни ответ, метку страницы и "
"короткую дословную опору. Если ответа нет, напиши: в документе не указано.\n"
+ context + "\nВопрос: " + question
)
payload = {"model": model, "stream": False,
"messages": [{"role": "user", "content": prompt}]}
request = Request("http://localhost:11434/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"})
with urlopen(request) as response:
result = json.load(response)
print(result["message"]["content"])
Что здесь откуда. OLLAMA_MODEL — точный тег модели из ollama list; подставьте своё значение в окружение перед запуском. Локальный адрес сервера и локальный тег модели — разные вещи: адрес localhost указывает, куда идёт HTTP-запрос, а модель может быть облачной, если тег на неё ссылается. Проверяйте оба параметра.
stream: False выбран сознательно. В справочнике Ollama /api/chat поле stream имеет значение по умолчанию true; при false ответ приходит одним JSON-объектом, и result["message"]["content"] читается напрямую. При потоковой передаче ответ приходит набором фрагментов, и разбирать его нужно иначе.
Секрет в скрипте не выводится: ключи и токены здесь не используются, а имя модели читается из окружения и уходит только в теле запроса.
Пустая экстракция и скан: pypdf не OCR
Пустой extracted.txt — сигнал остановиться до запроса к модели. Документация pypdf формулирует прямо: pypdf не является OCR-программой, текст из изображений он извлечь не может. Если страница выглядит как картинка — например, отсканированный документ, — извлечённый текст будет минимальным или пустым.
Различайте три типа файлов, как это делает документация pypdf:
| Тип | Признак | Что делать |
|---|---|---|
| Цифровой PDF | текст выделяется и копируется, при увеличении остаётся резким | извлекать через pypdf |
| Скан | текст не копируется, только изображение | распознавать OCR-инструментом |
| Скан с OCR-слоем | текст копируется, но при увеличении видны пиксели | можно извлечь, сверить с видимым PDF |
Для сканов OCR выполняется отдельно, до модели. Документация pypdf называет Tesseract OCR распространённым открытым движком распознавания. Отдельная тонкость: если скан уже прошёл OCR и текст лежит в фоне изображения, pypdf может его достать, но документация советует в таких случаях работать с OCR напрямую — ошибки распознавания и ошибки разбора накапливаются.
Ещё один слой — защита от извлечения. Документация pypdf перечисляет способы: содержимое хранится как изображение или применяется перемешанный шрифт. Полностью запретить извлечение при сохранении читаемости не получается: страницу можно снять скриншотом и распознать.
И главное: RAG не исправит потерянный текст. Если экстракция пуста или искажена, чинить нужно разбор или OCR.
Учебный регламент и вопросы подтверждённого и отсутствующего факта
Возьмите собственный короткий текст и сохраните его в PDF локально. Проверьте, что он отображается и текст копируется. Ниже — учебный регламент, использованный как условный вход для примеров этой статьи:
Учебный регламент мастерской. Возврат инструмента принимается во вторник. Ответственный за приём — Ирина. Размер штрафа в этом регламенте не указан.
Три вопроса проверяют разные исходы:
| Вопрос | Ожидаемый исход |
|---|---|
| Когда принимается возврат инструмента? | Во вторник; ответ подкреплён совпадающей строкой из PDF |
| Кто отвечает за приём? | Ирина; ответ и короткая опора совпадают с PDF |
| Каков размер штрафа? | В документе не указано; сумму не придумывать |
Третий вопрос — отрицательный контроль. Он проверяет, удержит ли модель границу документа или подставит правдоподобное число. Формулировка промпта задаёт это поведение: «Если ответа нет, напиши: в документе не указано».
Запуск:
export OLLAMA_MODEL="<точный тег из ollama list>"
python ask_pdf.py reglament.pdf "Когда принимается возврат инструмента?"
Значения в примере условные и служат учебным входом. Скрипт не гарантирует правдивость модели: ответ остаётся предложением модели, проверять его нужно вручную.
Ручная приёмка ответа: страница, дословная опора, сверка с PDF
Ответ модели принимается по трём пунктам.
Первый — метка страницы. Модель должна вернуть [page N]. Сверьте N с extracted.txt и с видимым PDF. Помните: это порядковый номер страницы файла.
Второй — дословная опора. Модель приводит короткий фрагмент, на который опирается. Найдите эту строку в extracted.txt и в самом PDF. Совпадение слов — обязательное условие.
Третий — совпадение видимого PDF и извлечённого текста. Откройте extracted.txt рядом с PDF и пройдите по спорным местам: таблицы, колонки, порядок чтения. Документация pypdf предупреждает, что таблицы обычно представлены абсолютно позиционированным текстом, в худшем случае позиционирована каждая буква, и границы колонок и строк определить трудно. Непустой extract_text() не доказывает правильный порядок чтения.
Порядок приёмки:
- Откройте extracted.txt до оценки ответа.
- Сверьте страницу и короткую опору вручную с видимым PDF.
- Если экстракция пустая или искажена — чините OCR или разбор до запроса к LLM.
Так ошибка локализуется: сначала проверяется документ, затем ответ. Обвинять модель раньше времени не стоит.
Короткий PDF и большая коллекция: разные подходы
Пример этой статьи рассчитан на короткий читаемый текстовый PDF, который целиком помещается в контекст модели. Это ограничение осознанное.
Если извлечённый текст не помещается в контекст выбранной модели, подбирайте подходящие фрагменты или обрабатывайте документ поэтапно. Отдельно учитывайте память на извлечение: pypdf разбирает весь поток содержимого страницы и приводит случай, когда несжатый поток около300МБ потребовал10ГБ RAM. Перед обработкой документация советует проверять размер потока через len(page.get_contents().get_data()).
Для коллекции, которую нельзя целиком передать в контекст выбранной модели, можно построить RAG-слой: фрагментацию, индексацию и подбор релевантных кусков под запрос. Простое увеличение текста запроса не расширяет контекстное окно модели; качество подбора фрагментов проверяют отдельно.
| Сценарий | Подход |
|---|---|
| Короткий текстовый PDF | весь текст в один запрос, как в примере |
| Большой документ | постраничная обработка, контроль размера потока |
| Коллекция | RAG: фрагменты, индекс, подбор контекста |
Локальная модель Ollama и облако
Локальный адрес сервера и локальная модель — два разных условия. Адрес http://localhost:11434/api/chat из справочника Ollama указывает, куда уходит HTTP-запрос. Имя модели в поле model определяет, какая модель отвечает. Локальный сервер может обслуживать облачную модель, если тег на неё ссылается.
Проверяйте оба параметра перед запуском: адрес в скрипте и тег в OLLAMA_MODEL. Тег берите точной строкой из ollama list.
Общие обещания полной приватности из README сторонних проектов не стоит переносить на весь стек как проверенный факт. Приватность зависит от конкретной связки: адрес сервера, тег модели, наличие внешних вызовов в вашем коде. Проверяйте каждый элемент отдельно.
Частые вопросы
Чем отличается извлечение текста из PDF от RAG?
Извлечение — операция над одним файлом: pypdf достаёт текстовый слой страниц. RAG — слой для коллекции: документы режутся на фрагменты, индексируются, и под запрос подбираются релевантные куски. Для короткого PDF RAG избыточен.
Почему pypdf вернул пустой текст?
Скорее всего, страница содержит только изображение. Документация pypdf указывает, что в таких случаях извлечённый текст будет минимальным или пустым, и советует OCR-инструмент, например Tesseract OCR. Распознавание выполняется отдельно, до запроса к модели.
Как передать имя модели в скрипт?
Через переменную окружения OLLAMA_MODEL. Значение — точный тег из ollama list. Скрипт читает его через os.environ["OLLAMA_MODEL"] и подставляет в поле model тела запроса.
Зачем ставить stream в False?
При stream: False ответ приходит одним JSON-объектом, и result["message"]["content"] читается напрямую. В справочнике Ollama /api/chat поле stream по умолчанию равно true, тогда ответ приходит набором фрагментов и разбирается иначе.
Как проверить, что модель не выдумала ответ?
Сверьте три пункта вручную: метку страницы, короткую дословную опору и совпадение extracted.txt с видимым PDF. Дословная опора должна находиться в документе. Ответ модели остаётся предложением модели, проверка источника выполняется вручную.
