RAG-агент для PDF с цитатами: LlamaIndex, LlamaParse и Ollama — пошаговое руководство 2026

Как собрать RAG-агента для PDF, который отвечает с цитатами и показывает страницу-источник: LlamaParse, LlamaIndex CitationQueryEngine и локальная Ollama. С кодом на Python.

Вы загружаете в нейросеть отчёт на 40 страниц и спрашиваете: «Что изменилось по сравнению с прошлым кварталом?». Через несколько секунд приходит уверенный ответ с процентами. Звучит правдоподобно — но откуда взялась эта цифра? Из таблицы на 18-й странице, из подписи к графику или модель её просто «додумала»?

RAG-агент для PDF с цитатами на LlamaIndex и Ollama

RAG-агент для PDF с цитатами решает эту проблему. В этой статье разберём, как собрать RAG-агента для работы с PDF, который отвечает на вопросы по длинным документам, ставит в ответе нумерованные цитаты [1], [2] и показывает исходный текст и страницу за каждой ссылкой. Стек: LlamaParse для разбора PDF, LlamaIndex для индекса и поиска, Ollama для локальных эмбеддингов и LLM. Статья основана на открытом проекте Grounded Document Agent инженера Sumanth P (ссылки — в конце).

Почему RAG по PDF так часто ошибается

Для человека PDF выглядит структурированным: заголовки, колонки, таблицы, сноски, графики, понятный порядок чтения. Но обычное извлечение текста почти ничего из этого не сохраняет:

  • статья в две колонки читается «поперёк» обеих колонок;
  • финансовая таблица превращается в поток чисел без привязки к столбцам;
  • от графика остаётся только подпись;
  • в сканированных страницах вообще нет машиночитаемого текста.

Главная мысль проекта: многие ошибки, которые выглядят как проблемы поиска (retrieval), на самом деле возникают раньше — на этапе парсинга. Если таблица разобрана неправильно, никакие эмбеддинги не восстановят, какое значение к какому столбцу относилось. Если порядок чтения нарушен, большое контекстное окно просто передаст модели больше перепутанного текста.

Качество ответа ограничено качеством парсинга. Поэтому в проекте для разбора документов используется LlamaParse — облачный парсер от LlamaIndex, который превращает PDF в Markdown с учётом вёрстки и сохраняет метаданные страниц. Для сравнения парсеров LlamaIndex также опубликовал открытый бенчмарк ParseBench: он проверяет таблицы, графики, точность содержимого, форматирование и визуальную привязку.

Как устроен RAG-агент для PDF: парсинг в облаке, всё остальное локально

Схема проекта простая:

  1. Загрузка PDF. LlamaParse разбирает документ в Markdown с разбиением по страницам.
  2. Кеширование. Приложение считает хеш файла: если этот документ уже открывали, результат берётся из кеша, и кредиты LlamaParse повторно не тратятся.
  3. Эмбеддинги. Текст переводится в векторы локальной моделью nomic-embed-text через Ollama.
  4. Индекс. Векторы хранятся в VectorStoreIndex из LlamaIndex и сохраняются на диск, чтобы не пересобирать индекс при каждом запуске.
  5. Поиск. На вопрос пользователя находятся 4 самых релевантных фрагмента.
  6. Ответ с цитатами. CitationQueryEngine делит найденный контекст на пронумерованные источники, а локальная модель qwen3:4b-instruct в Ollama пишет ответ со ссылками [1], [2] и т.д.

В облако уходит только сам PDF на этапе парсинга. Вопросы пользователя, эмбеддинги, индекс и генерация ответов остаются на вашем компьютере. Это логичное разделение: именно разбор сложной вёрстки требует специализированных моделей, а дальше хватает локального стека. Бонус — дешёвая разработка: документ разбирается один раз, а вы сколько угодно экспериментируете с поиском, промптами и моделями.

Установка: что понадобится

  • Python 3.10 или новее;
  • Ollama, установленная и запущенная локально;
  • API-ключ LlamaCloud для LlamaParse.
pip install llama-index llama-parse llama-index-embeddings-ollama llama-index-llms-ollama

ollama pull nomic-embed-text
ollama pull qwen3:4b-instruct

export LLAMA_CLOUD_API_KEY="ваш_ключ"

Шаг 1. Парсинг PDF через LlamaParse

Интеграция с LlamaParse занимает несколько строк:

import os
from llama_parse import LlamaParse

parser = LlamaParse(
    api_key=os.environ["LLAMA_CLOUD_API_KEY"],
    result_type="markdown",
    verbose=True,
)
documents = parser.load_data("report.pdf")

Важен не только Markdown. Каждый документ на выходе сохраняет метаданные страницы, и они «путешествуют» вместе с текстом через индексацию и поиск. Без них приложение могло бы показать найденный фрагмент, но не смогло бы надёжно сказать, с какой страницы исходного PDF он взят.

Шаг 2. Индекс с кешем по хешу файла

Отправлять модели весь документ на 40–100 страниц для каждого вопроса бессмысленно: ответ обычно лежит в одной таблице или паре абзацев. Поэтому текст превращается в эмбеддинги и сохраняется в векторный индекс. Упрощённый вариант с кешированием:

import hashlib
from pathlib import Path

from llama_index.core import (
    Settings,
    StorageContext,
    VectorStoreIndex,
    load_index_from_storage,
)
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.ollama import Ollama

Settings.embed_model = OllamaEmbedding(model_name="nomic-embed-text")
Settings.llm = Ollama(model="qwen3:4b-instruct", request_timeout=120.0)


def file_hash(path: str) -> str:
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]


def build_or_load_index(pdf_path: str) -> VectorStoreIndex:
    persist_dir = Path("storage") / file_hash(pdf_path)

    if persist_dir.exists():                      # документ уже обрабатывали
        storage = StorageContext.from_defaults(persist_dir=str(persist_dir))
        return load_index_from_storage(storage)

    documents = parser.load_data(pdf_path)        # платный шаг — только один раз
    index = VectorStoreIndex.from_documents(documents)
    index.storage_context.persist(persist_dir=str(persist_dir))
    return index

В оригинальном проекте кешируется сам результат парсинга, а индекс сохраняется отдельно — идея та же: один и тот же PDF никогда не отправляется в LlamaParse дважды.

Шаг 3. Ответы с цитатами через CitationQueryEngine

Слой запросов тоже компактный. CitationQueryEngine берёт найденный контекст, режет его на небольшие фрагменты, нумерует их и просит модель ссылаться на источники прямо в тексте ответа:

from llama_index.core.query_engine import CitationQueryEngine

index = build_or_load_index("report.pdf")

engine = CitationQueryEngine.from_args(
    index,
    similarity_top_k=4,        # сколько релевантных фрагментов искать
    citation_chunk_size=512,   # размер одного пронумерованного источника
)

response = engine.query("Что изменилось по сравнению с прошлым кварталом?")
print(response)

for i, src in enumerate(response.source_nodes, start=1):
    print(f"\n[{i}] релевантность: {src.score:.2f}")
    print("метаданные:", src.node.metadata)       # здесь есть информация о странице
    print(src.node.get_content()[:300])

Два ключевых параметра:

  • similarity_top_k — сколько релевантных фрагментов достаётся из индекса (в проекте по умолчанию 4);
  • citation_chunk_size — насколько мелко найденный контекст делится на источники (в проекте 512).

Как выбрать размер фрагмента для цитат

Здесь нет универсального значения:

Размер фрагментаПлюсыМинусы
Маленькийцитату легко проверить, ссылка точнаяконтекст дробится, модели сложнее рассуждать
Большойсохраняется окружающий контекстцитата менее точная, проверять дольше

Подбирайте значение под свои документы и модель: для договоров с короткими пунктами подойдут мелкие фрагменты, для научных статей с длинными рассуждениями — более крупные.

Цитата хороша настолько, насколько хороши доказательства

Это, пожалуй, самый важный вывод проекта. Представьте, что ответ лежит в финансовой таблице, а слабый парсер извлёк все числа, но приписал одно из них не тому столбцу. Поиск всё равно найдёт эту страницу, модель уверенно ответит, и система даже поставит правильную ссылку на страницу. Но ответ будет неверным.

Цитата не исправляет ошибку — она лишь показывает, откуда взяты данные. Поэтому интерфейс проекта показывает за каждым ответом:

  • номер страницы;
  • найденный текст фрагмента;
  • подсвеченные слова из запроса;
  • оценку релевантности.

Модель всё ещё может неправильно понять хорошие данные, а поиск — вернуть не тот раздел. Разница в том, что ошибка становится видимой: открыв цитату, вы поймёте, где сбой — в парсинге, поиске или генерации, — а не будете принимать уверенный ответ на веру.

Где пригодится RAG-агент для PDF

  • годовые и квартальные финансовые отчёты;
  • научные статьи и обзоры;
  • договоры и юридические документы;
  • технические руководства и документация;
  • внутренние регламенты компании — с данными, которые нельзя отправлять во внешние LLM (в облако уходит только этап парсинга, а при необходимости его можно заменить локальным парсером).

Как улучшить RAG-агент для PDF

  1. Открывать PDF на нужной странице при клике на цитату.
  2. Отказываться отвечать, если найденные фрагменты слишком слабо связаны с вопросом (порог по score).
  3. Заменить модель qwen3:4b на более крупную, если позволяет железо.
  4. Добавить реранкер после поиска, чтобы точнее отбирать фрагменты.
  5. Сделать гибридный поиск: векторы плюс BM25 по ключевым словам — это помогает с номерами, артикулами и точными терминами.

Частые ошибки при создании RAG по документам

  • Чинить поиск, когда сломан парсинг. Сначала посмотрите глазами, что получилось после извлечения текста.
  • Терять метаданные страниц. Без них невозможно показать пользователю источник.
  • Повторно парсить один и тот же файл. Кешируйте результат по хешу — это экономит деньги и время.
  • Отправлять в модель весь документ. Дорого, медленно и не повышает точность.
  • Доверять цитатам вслепую. Ссылка показывает источник, но не гарантирует правильность ответа.

Частые вопросы

Что такое RAG?

RAG (Retrieval-Augmented Generation) — подход, при котором модель перед ответом получает релевантные фрагменты из ваших документов и отвечает на их основе, а не только по своим знаниям.

Можно ли сделать всё полностью локально?

Да, если заменить LlamaParse локальным парсером. В проекте облако используется только для разбора PDF, потому что качество парсинга сложных таблиц и сканов сильнее всего влияет на результат.

Какая видеокарта нужна для qwen3:4b?

Небольшие модели размером около 4 млрд параметров в квантованном виде запускаются на обычном ноутбуке и даже на CPU, хотя с GPU ответы генерируются заметно быстрее.

Чем CitationQueryEngine отличается от обычного query engine?

Обычный движок просто генерирует ответ по найденному контексту. CitationQueryEngine дополнительно нумерует источники и заставляет модель ссылаться на них в тексте, а в response.source_nodes возвращает сами фрагменты.

Исходный код и автор

Проект Grounded Document Agent полностью открыт: подключите ключ LlamaCloud, запустите Ollama, загрузите свой PDF и проверьте работу цитат.

Больше практических разборов про LLM, RAG и Python — на UPROGER.

Поделиться:

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *