Всем привет!

Предисловие

В этой статье я хочу поделиться своим опытом создания приложения на базе Retrieval-Augmented Generation (RAG) системы, которая превращает кучу документов в удобную интерактивную базу знаний. По сути, наша система хранит документы в виде эмбеддингов в векторной базе Qdrant и позволяет задавать к ним вопросы. Когда вы что-то спрашиваете, запрос вместе с контекстом из документов отправляется языковой модели (LLM), которая формирует вам чётко структуированный ответ. Я покажу, как всё устроено изнутри: архитектуру, ключевые компоненты и «кухню», благодаря которой это работает. Если вы только начинаете знакомство с RAG или просто интересно, как может выглядеть ИИ-помощник для документов, эта статья даст наглядное представление о работе такой системы.

Содержание

Введение
1. Что такое RAG?
2. Обзор архитектуры

2.1. База данных
2.2. Бэкенд
2.3. Фронтенд
3. Обработка метаданных документов
3.1. Извлечение заголовков
3.2. Формиров��ние секций
3.3. Пример промпта (тест метаданных)
4. Сложности и возможные улучшения
5. Примечание о конфиденциальности данных
Заключение


Введение

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

В какой-то момент я понял, что должен быть лучший способ. Тогда пришла идея: а что если создать локальное приложение, которое автоматически будет находить нужные фрагменты информации из моей библиотеки документов и отправлять их вместе с моим запросом в LLM? Цель была проста: пусть ИИ берёт на себя тяжёлую работу и выдаёт мне чёткий, структурированный ответ на основе моих собственных данных.

К моему удивлению, это сработало замечательно. Система не только сэкономила огромное количество времени, но и сделала моё общение с клиентами быстрее и точнее. Воодушевлённый этими результатами, я решил поделиться своим опытом того, как я создал это приложение на основе RAG, и кратко объяснить, как оно работает «под капотом». Независимо от того, интересуетесь ли вы RAG или просто хотите собрать собственного интеллектуального помощника для работы с документами, я надеюсь, что эта история поможет вам сделать первые шаги.

1. Что такое RAG?

Представьте, что вы задаёте другу сложный вопрос, а он вместо того, чтобы гадать, просто быстро пролистывает свои заметки и сразу даёт точный ответ. Вот примерно так и работает RAG. Эта штука позволяет языковым моделям «подсматривать» информацию перед тем, как генерировать ответ. То есть вместо того чтобы работать только с тем, чему LLM уже научилась, ей предоставляется нужная информация из внешних источников — например, из документов компании или базы знаний.

Представьте офис, где все время спрашивают про HR: «Сколько у меня отпускных дней?» или «Как оформить возврат расходов?» Ассистент на RAG мгновенно ищет ответы в HR-документах, справочниках или внутреннем вики и выдаёт точные ответы. Сотрудники получают нужную инфу моментально, HR экономит кучу времени на повторяющихся вопросах. Получается, что наша система — это как суперэффективный офисный помощник, который ничего не забывает.

2. Обзор архитектуры

Вот графическая схема компонентов моего приложения, включая в себя фронтенд и бэкенд:

Графическая схема компонентов
Графическая схема компонентов

Чтобы показать, как работает приложение, я закинул туда четыре документа: три про возобновляемую энергетику и два про ИИ-агентов в форматах .txt, .docx и .pdf. Так образом система обладает базой знаний из разных областей, чтобы было наглядно видно, как она справляется с поиском информации по разным темам.

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

self.docx = ".docx"
self.xlsx = ".xlsx"
self.txt = ".txt"
self.pdf = ".pdf"  # image-only PDFs are supported through 
                   # built-in OCR (tesseract)

2.1. База данных

Qdrant — это векторная база данных, созданная для эффективного хранения и поиска эмбеддингов. В RAG-системе каждый текстовый фрагмент из документов преобразуется в высокоразмерный вектор, который отражает его смысловое содержание. В моём приложении я использую Qdrant для хранения всех загруженных частей документов, чтобы ИИ мог мгновенно их искать каждый раз, когда пользователь отправляет запрос.

Qdrant известен:

  • Быстрым поиском по схожести
    Qdrant может мгновенно сравнивать вектор запроса с миллионами векторов документов и возвращать ближайшие совпадения за миллисекунды.

  • Работой с большими объёмами данных
    Будь то сотни PDF или тысячи фрагментов документов, Qdrant легко масштабируется, обеспечивая быстрый и надёжный поиск.

  • Гибкой поддержкой метаданных
    Каждый фрагмент может хранить дополнительную информацию: заголовки, наименования файлов, которую наш сервис использует для более точной фильтрации и ранжирования результатов поиска.

  • Интеграцией с ИИ-пайплайнами
    Qdrant отлично работает с различными моделями эмбеддингов, позволяя нашему сервису создавать, хранить и искать фрагменты документов с минимальными затратами ресурсов.

Используя Qdrant в приложении, мы превращаем сырые документы в живую, доступную для поиска базу знаний, благодаря чему ИИ может выдавать ответы, основанные на реальном содержимом, а не только на своих предварительно обученных данных.

2.2. Бэкенд

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

В бэкенде есть пять основных эндпоинтов, которые отвечают за ключевой рабочий процесс:

2.2.1. POST /qdrant/upload-documents

Загружает несколько документов в Qdrant и сохраняет их векторные эмбеддинги для последующего поиска.

Чтобы закидывать файлы сразу пачкой, загрузка документов работает с многопоточностью. Файл читается и обрабатывается в фоне, так что сервер не тормозит на тяжёлых задачах вроде парсинга или создания эмбеддингов.

async def upload_document(self, 
                          util_service: UtilService, 
                          file: UploadFile):
    content = await file.read()
    
    def process_file():
        filename = file.filename
        logger.info(f"Uploading {filename}...")
        
        # Extracting content and chunking here
        
    await run_in_threadpool(process_file)

После того как файл прочитан, сервис извлекает из него текст и делит его на логические секции. Крупные секции дополнительно разбиваются на маленькие "чанки", чтобы эмбеддинги работали точнее, а поиск информации был эффективнее.

text = util_service.extract_text_from_file(content, filename)
headings = util_service.extract_headings(text)
sections = util_service.get_sections(text, headings, filename)

chunks = []

for section in sections:
    if len(section.page_content) > CHUNK_SIZE:
        chunks.extend(self.splitter.split_documents([section]))
    else:
        chunks.append(section)

После разбиения на "чанки" каждая секция преобразуется в векторные эмбеддинги с помощью выбранной модели и сохраняется в Qdrant. Благодаря этому система может «находить» нужный текст, когда пользователь задаёт вопрос. Каждый загруженный документ превращается в коллекцию векторов внутри Qdrant, готовую к поиску и поддержке запросов с учётом контекста.

self.embeddings_model = HuggingFaceEmbeddings(
    model_name=EMBEDDING_MODEL, # intfloat/multilingual-e5-large
    cache_folder=CACHE_FOLDER)

##

Qdrant.from_documents(
    documents=chunks,
    embedding=self.embeddings_model,
    url=self.qdrant_url,
    collection_name=filename)

2.2.2. GET /qdrant/get-documents

Возвращает список всех коллекций документов, которые сейчас хранятся в Qdrant, по сути, вашу текущую библиотеку. Это быстрый способ посмотреть, с чем ИИ-ассистент может работать и откуда брать ответы на вопросы.

2.2.3. DELETE /qdrant/delete-document

Удаляет коллекцию документов по имени, удаляя все её эмбеддинги из Qdrant (иногда нужно убрать устаревшие или тестовые документы из векторной базы).

2.2.4. PUT /ai/update-model

Обновляет языковую модель, которую сервис использует для генерации ответов. Это позволяет выбирать нужный баланс скорости и сложности, а также контролировать, насколько подробными или быстрыми будут ответы.

2.2.5. POST /ai/get-answer

Отправляет запрос (промпт) ИИ-ассистенту, который забирает релевантный контекст из Qdrant и возвращает сгенерированный LLM ответ.

Сервис использует векторные эмбеддинги, чтобы понимать смысл вашего запроса и текста в документах. Каждый запрос преобразуется в высокоразмерный вектор, отражающий его семантику. При этом каждый текстовый "чанк" в Qdrant тоже хранится в виде вектора. Система затем выполняет поиск по схожести, фактически измеряя, насколько близок вектор запроса к векторам документов в пространстве эмбеддингов.

В итоге выбираются только TOP_K наиболее релевантных разделов, которые обрабатываются LLM, чтобы ответ был основан на реальном, контекстно значимом содержании.

async def search_collection(c):
  results = await run_in_threadpool(
      self.client.search,
      collection_name=c.name,
      query_vector=query_embedding,
      limit=TOP_K * TOP_K_COEF)
  
  for r in results:
      payload = r.payload
      metadata = payload.get(self.metadata, {})
      all_results.append((
              r.score,
              payload.get(self.page_content, ""),
              metadata.get(self.heading, ""),
              metadata.get(self.filename, "")))

await asyncio.gather(*(search_collection(c) for c in collections))

# Selecting top documents here
  
return "\n".join([text for _, text, _ in top_docs])

ИИ-помощник берёт ваш запрос и добавляет к нему найденный контекст из самых подходящих кусочков документов из Qdrant, чтобы сформировать запрос локальной LLM для формирования ответа. Эти кусочки дают модели точную и нужную информацию, так что ей не приходится полагаться только на то, что она «помнит» из обучения. Благодаря этому ИИ может отвечать более точно, подробно и с учётом контекста.

Другими словами, модель не просто угадывает, она «читает» самые релевантные куски ваших документов и использует их, чтобы дать осмысленный ответ. В этом и заключается суть RAG: объединение вашей базы знаний с рассуждениями языковой модели.

self.prompt_template = PromptTemplate(
    input_variables=["context", "prompt"],
    template=(
        "Вы — методолог. Дайте подробный ответ, строго опираясь "
        "на предоставленный контекст.\n\n"
        "Контекст:\n{context}\n\n"
        "Вопрос:\n{prompt}\n\n"
        "Если в контексте нет информации для ответа, ответьте: "
        "В предоставленных документах нет информации для ответа на этот вопрос."))

self.llm = Llama.from_pretrained(
    repo_id=LLM_MODEL, # from 'PUT /ai/update-model'
    filename=LLM_FILENAME,
    cache_dir=CACHE_FOLDER)

##

async def get_answer(self, 
                     qdrant_service: QdrantService, 
                     prompt: str) -> str:
    context = await qdrant_service.get_context(prompt)

    # LLM answer generation here 
    
    return str(raw_answer)

2.3. Фронтэнд

Созданный на React фронтенд — это «лицо» приложение, обеспечивающее плавный и интерактивный пользовательский опыт. React был выбран из-за своей скорости и компонентной архитектуры, что делает его идеальным для создания динамичных интерфейсов, которые мгновенно обновляются, когда ИИ возвращает ответы или загружаются документы. Он также предлагает богатую экосистему библиотек, повторно используемых UI-компонентов и простую интеграцию с бэкендом на FastAPI через REST API. Фронтенд отвечает за основные взаимодействия с пользователем: отправку запросов, отображение ответов ИИ, загрузку документов, переключение языковых моделей и управление базой знаний.

2.3.1. Компоненты

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

ChatPanel.jsx  
Dashboard.jsx  (container for all other components)
DocumentList.jsx  
ModelSelector.jsx  
UploadForm.jsx

2.3.2. Обработка Markdown

Для отображения ответов от LLM в удобочитаемом виде фронтенд использует рендеринг Markdown. Это позволяет модели возвращать текст с переносами строк, форматированием и даже простыми таблицами или списками, которые пользователь видит именно так, как задумано.

Для этого используются два основных пакета:

  • react-markdown: отображает содержимое Markdown как React-компоненты;

  • remark-breaks: сохраняет переносы строк без необходимости добавлять двойные пробелы.

import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks";

//

<div className="border p-3 rounded bg-gray-50 text-xl whitespace-pre-wrap">
  <ReactMarkdown remarkPlugins={[remarkBreaks]}>
    {safeResponse}
  </ReactMarkdown>
</div>

3. Обработка метаданных документов

Чтобы сделать процесс поиска информации удобнее, система разбивает каждый загруженный документ на разделы по заголовкам. Заголовки определяются автоматически с помощью набор�� регулярных выражений, которые умеют распознавать разные форматы: нумерованные списки, заголовки прописными буквами, Markdown-заголовки и даже римские цифры.

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

3.1. Извлечение заголовков

Метод extract_headings() просматривает текст построчно и проверяет, соответствует ли каждая строка одному из нескольких шаблонов заголовков. В качестве заголовков разделов считаются только те строки, которые достаточно короткие (меньше max_words_heading) и подходят под один из заранее определённых типов заголовков.

def extract_headings(self, 
                     text: str) -> List[str]:
    headings = []

    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue

        words = line.split()
        if len(words) > self.max_words_heading:
            continue

        if (self.numbered_heading.match(line)  or \
            self.uppercase_heading.match(line) or \
            self.markdown_heading.match(line)  or \
            self.roman_heading.match(line)):
            headings.append(line)

    return headings

3.2. Формирование секций

Как только заголовки определены, метод get_sections() разбивает текст документа на части, каждая из которых связана с конкретным заголовком. Если заголовки не найдены, весь текст сохраняется как один раздел.

Каждый раздел содержит метаданные, такие как исходное имя файла и путь заголовков (последовательность заголовков от верхнего уровня до текущего раздела), которые при поиске помогают показать, откуда именно взят тот или иной ответ.

def get_sections(self, 
                 text: str, 
                 headings: List[str], 
                 filename: str) -> List[Document]:
    sections = []
    
    if not headings:
        cleaned_text = self.remove_page_numbers(text.strip())
        sections.append(Document(
            page_content=cleaned_text,
            metadata={self.heading: self.full_text, 
                      self.filename: filename}))
        return sections

    # Heading-based sectioning here
    
    sections.append(Document(
        page_content=content,
        metadata={self.heading: accumulated_heading, 
                  self.filename: filename}))

    return sections

3.3. Пример промпта (тест метаданных)

Например, мы знаем заголовок и наименование файла, по которым хотим получить информацию. Рассмотрим этот фрагмент из файла ии_на_службе_бизнеса.pdf:

Фрагмент из документа
Фрагмент из документа

Давайте взглянем на логи:

INFO:     MATCH: 
5.2 бизнес и производство  5.2.2 оптимизация цепочек поставок и 
операционный менеджмент
INFO:     PROMPT:
О чем рассказывает 5.2.2 оптимизация цепочек поставок 
в ии_на_службе_бизнеса.pdf?  
INFO:     CONTEXT (truncated):
5.2.2 Оптимизация цепочек поставок и операционный менеджмент
ИИ-агенты революционизируют управление цепочками поставок, 
обеспечивая эффективное прогнозирование, контроль запасов и 
оптимизацию логистики. Сложный и динамичный характер современных 
цепочек поставок делает их идеальной областью для применения ИИ, 
поскольку такие системы способны обрабатывать огромные объемы данных, 
выявлять закономерности, предсказывать сбои и предлагать корректирующие 
действия [74]. Применение предиктивной аналитики позволяет компаниям с 
высокой точностью прогнозировать сп...
INFO:     172.18.0.1:51408 - "POST /api/ai/get-answer HTTP/1.1" 200 OK

Теперь давайте посмотрим на ответ нашего ИИ-ассистента:

Раздел 5.2.2 описывает, как ИИ-агенты трансформируют управление цепочками поставок за счёт следующих аспектов:

- Сквозная оптимизация:
Системы ИИ обрабатывают большие, сложные и динамичные данные цепочек поставок, чтобы выявлять закономерности, предсказывать сбои и предлагать корректирующие действия [74].


- Предиктивное прогнозирование спроса:
Аналитика на основе прогнозов повышает точность оценки спроса, снижая перепроизводство и дефицит товаров, что приводит к значительной экономии затрат.


- Оптимизация логистики и маршрутов:
Алгоритмы учитывают такие факторы, как трафик, погодные условия и сроки доставки, чтобы вычислять наиболее эффективные маршруты. Это снижает расход топлива и время доставки, а также способствует достижению целей устойчивого развития [75][76].


- Автоматизация складов:
Автономные роботы, использующие машинное обучение и технологии компьютерного зрения, выполняют задачи сортировки, упаковки и инвентаризации, сокращая количество ошибок, повышая производительность и улучшая общую операционную эффективность [77].

В целом, ИИ-агенты рассматриваются как незаменимый инструмент для прогнозирования, контроля запасов, оптимизации логистики и автоматизации складских операций в современных цепочках поставок.

Также стоит отметить, что совпадения и по заголовку, и по имени файла имеют более высокий приоритет, чем просто совпадения по заголовку без наименования фай��а.

4. Сложности и возможные улучшения

Создавать RAG-сервис, который объединяет FastAPI, Qdrant и React в единую рабочую цепочку, было одновременно интересно и непросто. По ходу работы возникали идеи, которые расширяли пространство для будущих улучшений.

  1. Работа с разными форматами файлов
    Поддержка разных форматов вроде .txt, .docx, .xlsx и .pdf (в том числе PDF с изображениями) оказалась сложнее, чем ожидалось. Каждому формату нужна была своя стратегия извлечения, чтобы текст для эмбеддингов был консистентным. OCR на tesseract иногда давал шум из-за качества сканов.

    Потенциальные улучшения: использование гибридного подхода OCR-LLM для более чистого извлечения текста.

  2. Разбитие на "чанки" и извлечение контекста
    Подобор подходящих размеров "чанков" оказался одной из самых сложных задач. Слишком маленькие — контекст распадается; слишком большие — эмбеддинги теряют точность. Сейчас система использует разбиение по заголовкам, что хорошо работает для структурированных отчётов.

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

  3. Пользовательский интерфейс и ответы
    Фронтенд на React сделал взаимодействие удобным и понятным, но больше обратной связи в реальном времени могло бы улучшить опыт пользователя. Например, потоковая подача частичных ответов ИИ или отображение источников при поиске.

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

5. Примечание о конфиденциальности данных

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

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

Заключение

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

Впереди ещё куча возможностей для улучшений: более умное разбиение на фрагменты, потоковые ответы, более продуманный алгоритм ранжирования и новые интеграции моделей. Но даже в нынешнем виде проект ясно показывает одно: с правильной архитектурой ИИ-агент может полностью изменить то, как мы работаем с собственной информацией.


Контакты

Telegram: @yelis_txt
Email: arselidex@yandex.ru