Retrieval-Augmented Generation (RAG) – это подход, в котором языковая модель отвечает на вопросы, используя предварительно извлечённый контекст из связанных документов. Для успешной реализации RAG-системы (на базе LangChain + vLLM с моделью Qwen3-32B-AWQ) критически важно правильно подготовить базу документов. Ниже описана оптимальная архитектура обработки PDF/DOC/DOCX-документов на русском языке, методы улучшения качества извлечённого текста, инструменты автоматизации, стратегии разбиения на чанки с обогащением метаданных, рекомендации по настройке индекса/ретривера, а также способы проверки качества покрытия знаний.
Архитектура пайплайна обработки документов
Оптимальный конвейер подготовки документов состоит из нескольких этапов, последовательно превращающих сырой файл в векторные представления для поиска. Главные шаги такие:
- Извлечение текста (Parsing): Загрузка документа и извлечение всего текста и структурных элементов. На этом шаге важно не просто достать символы, но и по возможности сохранить структуру документа – заголовки, абзацы, списки, таблицы. Если парсинг выполнен плохо, дальнейшая система будет страдать от принципа “garbage in – garbage out”. Для PDF-файлов используются парсеры, способные обрабатывать сложное форматирование (см. инструменты ниже). Для DOC/DOCX можно применять библиотеку для Word или конвертировать документ во временный формат (например, HTML) для более простого извлечения текста.
- Очистка и нормализация текста: Удаление мусорных или нерелевантных фрагментов (например, номеров страниц, повторяющихся колонтитулов, артефактов сканирования), исправление разрывов строк и пробелов, унификация символов. Этот этап приводит сырой текст к чистому виду, удобному для последующей разбивки. Например, unstructured предоставляет функции очистки, убирающие лишние пробелы, маркеры списков, не-ASCII символы и т.д..
- Разметка структуры (аннотация): Опциональный этап, на котором извлечённому тексту добавляются метки или структура. Например, можно пометить границы разделов, заголовки, элементы списков, таблицы. Некоторые парсеры сразу выдают структурированные элементы (как unstructured, который разбивает документ на элементы типа Title, List, NarrativeText и др.). Такая разметка поможет на этапе разбиения: мы будем знать где начинаются новые разделы, что является заголовком, а что – основным текстом.
- Разбиение на чанки (Chunking): Разделение текста на фрагменты оптимального размера. Чанк – это самостоятельный фрагмент текста (несколько предложений или абзац), который позже будет ассоциирован с одним вектором. В идеале чанки должны сохранять семантическую целостность, не смешивая несвязанные темы. Рекомендуется разбивать по естественным границам: абзацам, пунктам списка, заголовкам разделов. Если разделы большие, их можно дробить на несколько чанков, но не разрывая предложение или логическую мысль – при необходимости используйте перекрытие (overlap), чтобы часть текста дублировалась в соседних чанках для сохранения контекста. Важный момент – таблицы и похожие структуры лучше выделять в отдельные чанки целиком, чтобы не потерять их содержимое. Можно применять два подхода:
Фиксированный размер чанков – например, по ~500 слов или ~1000 символов. Это просто реализовать, но такой метод может разрезать связанную информацию некорректно.
Динамическое, семантическое разбиение – чанки по структуре документа: каждый раздел/подраздел как отдельный чанк (либо набор чанков, если раздел очень большой). Например, стратегия by_title библиотеки unstructured начинает новый чанк при обнаружении заголовка раздела, не смешивая текст разных секций. - Векторизация (Embedding): Преобразование каждого чанка в эмбеддинг – численный вектор высокой размерности, отражающий смысл текста. Выбор модели эмбеддинга должен обеспечивать хорошее понимание русского языка. Возможны варианты: мультиязычные модели Sentence Transformers (например, paraphrase-multilingual-MiniLM), модели от Sberbank или HuggingFace, специально обученные для русского, либо открытые модели типа BGE или E5 с поддержкой русского. Важны качество эмбеддинга и соответствие задачам – от этого во многом зависит точность поиска, даже больше, чем выбор конкретной базы данных. Каждый текстовый чанк прогоняется через модель, полученный вектор сохраняется.
- Индексирование векторной базы: Наконец, все векторы чанков заносятся в векторное хранилище (например, Chroma DB или FAISS). К каждому вектору привязываются метаданные – идентификатор документа, раздел, страница и прочее. Индекс строится таким образом, чтобы можно было быстро находить ближайшие по сходству векторы к вектору запроса. После этого пайплайн готов выполнять поиск: новый вопрос пользователя конвертируется в эмбеддинг (той же моделью), и ближайшие к нему по косинусному расстоянию векторы из базы находят релевантные чанки, которые передаются в LLM для генерации ответа.
В этой архитектуре каждый этап автоматизирован, без ручного труда: от чтения файлов до загрузки в индекс. Такой конвейер гарантирует, что исходные документы превращаются в пригодный для LLM контекст с минимальными потерями структуры и смысла.
Повышение качества извлечённого контента
После базового извлечения текста важно улучшить его качество, чтобы исключить искажения и шум, которые могут привести к неправильным ответам. Основные подходы здесь следующие:
- Удаление «мусора» и лишних фрагментов: Исключите из текста элементы, не несущие смысловой нагрузки для ответа на вопросы. К ним относятся номерные страницы, колонтитулы (например, повторяющиеся заголовки, имя документа на каждой странице), юридические приписки, содержание, индекс и т.п., если они не требуются в ответах. Например, распространённая проблема – парсер включает заголовок страницы или дату в тело текста, смешивая их с содержанием абзаца. Это может привести к ошибкам: LLM воспримет их как часть ответа. Решение – выявлять повторяющиеся строки в начале/конце страниц и удалять их скриптом. Также отсеивайте пустые строки, артефакты OCR (например, “ИИЛ” вместо “III” или куски разорванного текста).
- Нормализация форматирования: Приведите текст к единообразному виду. Замените специальные маркеры на единый формат: например, маркеры списков «•» или «–» можно заменить на обычный дефис или явное слово "–". Удалите лишние переносы строк внутри абзацев (склейте строки, если парсер разбил один абзац на отдельные строки). Убедитесь, что кавычки, дефисы, точки унифицированы (желательно заменить необычные символы их стандартными аналогами). Библиотека unstructured предоставляет готовые функции: удаление лишних пробелов, маркеров списка, юникодных кавычек и др. Также полезны библиотеки вроде ftfy (fix text encoding) для исправления странных символов. От нормализации текста выигрывает и этап эмбеддинга, т.к. модель получит чистые последовательности символов.
- Удаление дубликатов и повторов: Если одинаковый текстовый блок встречается несколько раз (например, документ содержит дублирующиеся разделы или в наборе документов присутствуют копии одного файла), стоит оставить только одну версию этого текста для индексирования. Дублированные чанки могут вызвать избыточное влияние одной и той же информации и снизить качество поиска. Кроме того, повтор одного и того же предложения в разных частях одного документа может привести к тому, что поисковая выдача выдаст два очень похожих фрагмента, потратив впустую слот контекста. Реализовать удаление дубликатов можно с помощью хеширования текста (например, алгоритм SimHash для приближённого совпадения) или простым сравнением строк при предварительной обработке.
- Выделение и приоритизация ключевой информации: Структурируйте контент так, чтобы важные части были легко доступны при поиске. Например, заголовки разделов и подзаголовки – ценный контекст, их лучше включать либо непосредственно в текст чанка, либо в метаданные, чтобы повышать релевантность при поиске. Если документ содержит резюме, выводы или часто запрашиваемые данные (как таблицы с результатами, важные цифры), убедитесь, что они корректно извлечены и не потерялись при очистке. Стоит поместить такие фрагменты в начало соответствующих чанков или дать им отдельные чанки. Также можно предусмотреть повышенный «вес» важным разделам: например, при индексировании добавить поле метаданных “важность” и настроить ретривер учитывать это (или просто убедиться, что ключевые слова из важных разделов сохранены, что повысит их шанс быть найденными). Структура документа должна быть максимально сохранена – это помогает LLM при генерации ответа понимать контекст (например, что данный чанк – из раздела “Результаты эксперимента” или “Заключение”).
- Обработка специальных элементов: Таблицы, формулы, изображения – их прямой текстовый экспорт часто нечитабелен. Например, таблица может распознаваться как хаотичный текст. Решение – конвертировать таблицы в структурированный вид: CSV-подобный текст или Markdown-таблицу. Существуют парсеры, которые конвертируют табличные данные в Markdown-формат, понятный для LLM. Если такие данные важны для ответов, стоит постобработкой превратить их в аккуратный текст. Для формул можно оставить местоholdeр “[formula]” и, возможно, сгенерировать описание формулы (либо вручную, либо с помощью LLM – но это уже выходит за рамки полностью безинтернетного подхода). Изображения и графики – если они содержат текст (например, скриншоты, отсканированные страницы) – нужно пропустить через OCR, иначе ответы на вопросы по ним невозможны. Опционально можно добавить краткое описание изображения (если важно для вопросов), но обычно достаточно OCR-текста. В любом случае, каждый значимый фрагмент оригинала должен присутствовать в итоговом корпусе текста, либо напрямую, либо в виде описания – иначе вопросы об этом фрагменте останутся без ответа.
Применяя эти шаги очистки, мы получаем хорошо структурированный и сфокусированный корпус данных. Это снижает риск того, что LLM запутается из-за шумовых данных или выдаст несвязный ответ. Как отмечают практики, правильный парсинг и очистка могут повысить качество ответов RAG-системы на 10–20% без иных изменений – гораздо больший выигрыш, чем тонкая настройка параметров при грязном входном тексте.
Инструменты и библиотеки для автоматизации (Python)
Для реализации описанного пайплайна существует множество готовых инструментов на Python. Правильный выбор библиотек существенно облегчает обработку больших объемов документов. Ниже перечислены проверенные средства по этапам пайплайна:
- Извлечение текста из PDF:
PyMuPDF (fitz) – быстрый и простой в использовании парсер PDF, хорошо справляется с извлечением основного текста и даже позволяет получить координаты текста. Не требует внешних зависимостей. Подходит для текстовых PDF со стандартной версткой.
PDFMiner.six / PDFPlumber – мощные библиотеки для посимвольного разбора PDF. Лучше сохраняют структуру, могут вычленять таблицы (PDFPlumber имеет методы для таблиц), но работают медленнее. Полезны для сложных макетов.
PyPDF – базовая библиотека для чтения PDF (используется и в LangChain как PyPDFLoader). Подходит для простых случаев, но с комплексной разметкой и таблицами справляется плохо. Может упустить важные элементы оформления.
Unstructured – современная библиотека, поддерживающая разные форматы (PDF, DOCX, HTML и др.) единым интерфейсом. Использует комбинацию методов (парсинг + элементный анализ разметки). Хорошо подходит для разнотипных документов; на выходе даёт список структурированных "элементов" (заголовок, абзац, список, таблица и т.п.), что удобно для дальнейшей обработки. Это более надежный вариант для сложных документов, чем чисто текстовые парсеры.
Apache Tika – универсальный парсер на Java (может вызываться из Python через tika модуль). Поддерживает огромный спектр форматов (PDF, DOC, PPT, и даже изображения с OCR при настройке). Возвращает текст и метаданные. Однако настройка Tika может быть сложнее (нужно Java окружение), и скорость ниже. Для массовой обработки, если устраивают Python-решения, обычно хватает без Tika.
OCR для PDF: Если PDF содержит отсканированные страницы (распознать можно по отсутствию извлечённого текста обычными методами), необходимо применить OCR. Tesseract OCR – популярный движок для распознавания текста на изображениях. Его можно вызвать через питоновскую оболочку (pytesseract). Альтернативы: EasyOCR, Google Vision API, ABBYY, PaddleOCR – в зависимости от требований точности и скорости. Для русского языка Tesseract имеет тренированные модели, но качество может зависеть от качества сканов. OCR следует применять постранично там, где текст не был извлечён стандартным парсером. - Извлечение из DOC/DOCX:
python-docx – библиотека для чтения DOCX (формат Office Open XML). Позволяет извлекать текст, а также стиль (жирный, курсив) и структуру (заголовки, списки) из .docx. Удобна, если требуется анализировать документ как объект Word. Недостаток – не работает с устаревшим .doc (бинарным форматом).
mammoth – инструмент для конвертации .docx в HTML или Markdown. Простой способ получить чистый текст с базовой разметкой (заголовки, списки) без написания кода.
Unstructured – как и в случае PDF, поддерживает .docx напрямую. Она под капотом может использовать либо python-docx, либо другие методы для извлечения, но отдаёт уже структурированные элементы. Это хороший выбор для унификации пайплайна: одна библиотека на все форматы.
LibreOffice (unoconv) – способ конвертировать .doc в .docx или .pdf. Если приходится обрабатывать старые .doc, можно автоматически в фоне вызывать LibreOffice в headless-режиме для конвертации в современный формат, а потом обрабатывать как .docx. Это дополнительная сложность, поэтому чаще стараются либо заранее конвертировать все .doc, либо использовать Apache Tika, умеющий читать .doc через встроенные средства. - Очистка и преобразование текста:
unstructured.cleaners – как упомянуто, предлагает ряд готовых функций для очистки текста (удаление лишних пробелов, маркеров, перевод текста в нижний регистр и др.). Можно применить их последовательно к строке или объединённому тексту документа.
Regex и Python-скрипты – для специфической очистки можно использовать регулярные выражения. Например, re.sub(r'\n{2,}', '\n\n', text) чтобы схлопнуть лишние пустые строки, или шаблоны для удаления содержимого в скобках, если оно не нужно.
ftfy – библиотека “fix text for you”, исправляет распространённые проблемы с кодировкой, заменяет нечитабельные символы на нормальные аналоги автоматически.
NLTK / spaCy – могут пригодиться для более умной обработки текста на русском: токенизация, разбиение на предложения, лемматизация (например, для приведения разных форм слова к базовой – иногда полезно при поиске ключевых слов). Однако для самой системы RAG лемматизация эмбеддингов не нужна – модель эмбеддинга сама учитывает семантику. Но можно использовать эти библиотеки для анализа текста при отладке (например, чтобы удостовериться, что предложение не разрезано). - Разбиение на чанки:
LangChain TextSplitters – LangChain предоставляет готовые классы для разбиения текста: CharacterTextSplitter, RecursiveCharacterTextSplitter и др. RecursiveCharacterTextSplitter пытается сначала разбить по большим разделителям (двойной перевод строки), затем по точкам предложений, и так далее – это помогает сохранять целостность. Его можно настроить на нужный размер чанка (например, chunk_size=500 символов и chunk_overlap=50). Это простой в использовании вариант.
unstructured.chunking – если уже используем unstructured для парсинга, логично и для чанков применять её функции. Метод chunk_elements с базовой стратегией объединит элементарные части (заголовок + параграф под ним, например) в более крупные чанки нужного размера. Стратегия by_title обеспечит, что текст разных разделов не смешается в одном чанке. max_characters параметр позволяет задать максимальный размер чанка, overlap – длину перекрытия при разбиении. Преимущество unstructured – понимание структурных элементов, например, таблицы и списки не будут склеены с обычным текстом: таблица останется отдельным чанком.
NLTK – для простой разбивки по предложениям можно использовать токенизатор предложений NLTK. А затем уже объединять несколько предложений в чанки нужного размера.
SpaCy – может разбивать текст на предложения и параграфы, используя модель для русского. Но для крупного объёма документов spaCy может быть тяжеловесным, проще часто регулярными выражениями или встроенными методами. - Векторизация (Embedding):
HuggingFace Transformers / SentenceTransformers – удобный способ загрузить готовую модель эмбеддингов. Например, через SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2') можно получить эмбеддинги размерности 384 для множества языков, включая русский. Библиотека langchain тоже имеет оболочку HuggingFaceEmbeddings для таких моделей. Для лучшего качества на русском можно рассмотреть модели из серии RuBERT/SBERT от SberDevices, либо многоязычные версии модели InstructorXL или E5-large, если они показывают высокое качество. Важный момент – модель должна выдавать одинаковую размерность векторов, совместимую с индексом, и желательно нормированные (многие модели возвращают L2-нормированные эмбеддинги, что удобно для косинусного поиска).
OpenAI Embeddings – если допускается обращение к внешним API, эмбеддинги от OpenAI (например, text-embedding-ada-002) обеспечивают высокое качество и поддерживают русский язык, но это платный вариант и требует интернета, что в нашем сценарии исключается. Поэтому упомянем только локальные решения.
Gensim / FastText – для некоторых задач можно использовать и классические векторные модели (Word2Vec/FastText) для извлечения признаков текста, но качество семантического поиска по предложениям у них намного ниже, чем у трансформеров. Их стоит рассматривать лишь если ресурсы крайне ограничены. - Векторные базы данных (Vector Stores):
ChromaDB – open-source векторная СУБД, интегрирующаяся с LangChain напрямую. Простая установка (pip install chromadb). Она хранит эмбеддинги, метаданные и предоставляет быстрый поиск по косинусному сходству. Chroma работает локально, данные хранятся в легковесной базе (DuckDB) или в памяти, что удобно для прототипирования и средних объёмов данных. С точки зрения разработчика, Chroma легко использовать: добавляем список документов через Chroma.from_documents(), потом вызываем query/similarity_search. Chroma по умолчанию поддерживает АППРОКСИМАТИВНЫЙ поиск (HNSW) для скорости, но можно настроить и точный. Хороший выбор, если нужно быстро развернуть поиск без дополнительной инфраструктуры.
FAISS – библиотека Facebook AI Similarity Search. Это встраиваемый индекс, который хранится в памяти или файлах. Через LangChain можно использовать FAISS.from_texts() для создания индекса. FAISS хорош производительностью и гибкостью: поддерживает различные метрические пространства (косинус через inner product, евклидово расстояние и др.) и множество видов индексов (точные: Flat, IVF, HNSW; сжатые: PCA, PQ). Для больших баз (сотни тысяч и более векторов) FAISS позволяет уменьшить память за счёт кластеризации (IVF) или сжатия. Недостаток – нет встроенного хранилища метаданных, но LangChain оборачивает FAISSIndex и хранит метаданные в Python-списках параллельно. Если данные помещаются в память, FAISS – отличный вариант. Для персистентности индекс можно сохранить на диск и загружать при старте приложения.
Milvus / Weaviate / ElasticSearch (векторный модуль) / etc. – более тяжеловесные решения, требующие развёртывания сервера. Их можно упомянуть, но в контексте задачи (локальная RAG-система) чаще достаточно Chroma или FAISS. Если же документы исчисляются миллионами и требуется горизонтальное масштабирование, стоит рассмотреть специализированные хранилища (Milvus, ElasticSearch+kNN, Weaviate, Qdrant и т.д.). Они также имеют Python-клиенты и совместимы с LangChain. - Оркестрация и параллелизм:
Для обработки большого числа документов на этапе препроцессинга полезно распараллеливать операции. Python предлагает concurrent.futures.ThreadPoolExecutor или ProcessPoolExecutor для одновременного чтения/парсинга нескольких файлов. Библиотека multiprocessing тоже может быть задействована. Однако учитывайте, что парсинг PDF – CPU-интенсивная задача; многопроцессный подход может дать выигрыш на 4-8 ядрах, но упирается в I/O и GIL в некоторых библиотеках.
LangChain Pipeline – LangChain неявно не выполняет параллельную загрузку, но вы можете сами читать документы параллельно, а затем скормить списком в LangChain’s DocumentLoader.
Batch Processing Tools: Если объём документов очень велик, можно использовать фреймворки для распределённой обработки: например, Apache Spark (со сторонними библиотеками для PDF), Dask или Ray для Python – они помогут масштабировать парсинг/векторизацию на несколько узлов. Но внедрение их оправдано только при действительно больших масштабах.
Мониторинг и отладка: Полезные инструменты – LangSmith (от LangChain) для отладки цепочек, OpenAI Evals или собственные скрипты для оценки качества (см. раздел про проверку качества). В процессе автоматизации стоит логировать важные события: сколько текста извлечено из каждого файла, не было ли ошибок парсинга, сколько чанков получилось и т.д., чтобы потом проще было обнаружить аномалии.
Используя комбинацию этих инструментов, можно построить полностью автоматический конвейер загрузки и индексации документов. Например, сценарий: скрипт проходит по всем файлам в указанной папке, для каждого определяет тип (расширение), выбирает соответствующий парсер (PDF -> unstructured или PyMuPDF; DOCX -> unstructured; DOC -> конвертация + unstructured; и т.д.), извлекает текст, чистит, делит на чанки (например, через unstructured.chunk_elements), получает эмбеддинги (через SentenceTransformers), складывает их в Chroma с метаданными. Такой пайплайн может быть запущен вручную или по расписанию, и не требует участия человека, даже если документов тысячи.
Стратегии разбиения на чанки и обогащения метаданными
Правильное разбиение документа на фрагменты и присвоение им метаданных – залог релевантного поиска информации. Цель – минимизировать случаи, когда нужный ответ разрезан между двумя чанками или когда в чанке слишком много не относящейся к запросу информации. Вот рекомендации по chunking и metadata enrichment:
1. Размер и границы чанков: Идеальный чанк должен быть достаточно крупным, чтобы содержать завершённую мысль, но достаточно маленьким, чтобы не перегружать контекст лишними деталями. Практика показывает, что длина в пределах 200–500 слов (примерно 1000–2000 символов или ~500 токенов) – разумный компромисс для большинства случаев. Важно опираться на семантические границы:
- Разбивайте по абзацам: один абзац текста обычно передаёт одну идею, поэтому хороший кандидат на чанк. Если абзацы очень длинные (например, юридический текст), его можно разбить по предложениям.
- Разбивайте по заголовкам: текст каждого раздела под своим заголовком желательно поместить в один или несколько последовательных чанков, но никогда не смешивать текст разных разделов в одном чанке. Например, если один чанк захватывает конец раздела "Введение" и начало "Методы", запросы о методах могут вернуть лишний текст из введения – это снизит релевантность. Стратегия by_title как раз гарантирует: новый раздел – новый чанк.
- Не разрезайте предложения: даже если цель – ~1000 символов, нельзя оборвать чанк посреди предложения. Лучше сделать чанк чуть больше, либо обрезать на предыдущем предложении и сделать перекрытие. Перекрытие (overlap) обычно в районе 50–100 символов (несколько слов из конца предыдущего чанка дублируются в начале следующего). Это помогает сохранить контекст на границах чанков. LangChain textsplitter и unstructured позволяют задавать overlap.
- Объединение мелких фрагментов: если после парсинга получились очень маленькие элементы (например, короткие подпункты списка или одиночный заголовок без текста, как часто бывает – заголовок вынесен на отдельную страницу), не стоит оставлять их отдельными чанками. Такие крохи мало что значат сами по себе и могут только засорять поиск. Объединяйте близкие маленькие элементы с соседними. В unstructured есть параметр combine_text_under_n_chars, позволяющий присоединить слишком короткий элемент к следующему. Например, заголовок-раздел, состоящий из 2 слов, логично соединить с первым абзацем этого раздела в один чанк.
- Ограничение максимального размера: установите жёсткий предел (например, 1000 или 1500 символов) – это страховка от случаев, когда парсер мог вернуть очень длинный непрерывный элемент (большая таблица, длинный список без разделителей). Если элемент превышает предел, нужно его разбить (например, таблицу – по рядам или по страницам, текст – по предложениям). Unstructured использует max_characters для этого.
- Таблицы и списки как отдельные чанки: Данные, оформленные таблично, лучше держать обособленно. Если таблица небольшая, она может быть одним чанком. Если огромная – разбейте на несколько по строкам, но не смешивайте с обычным текстом. То же касается нумерованных списков: длинный список можно поделить на несколько чанков (например, по 5–7 пунктов), но не стоит дописывать к списку ещё и абзац обычного текста.
2. Метаданные для каждого чанка: Метаданные – это сопутствующая информация о чанке, не видимая модели напрямую, но используемая на этапе поиска или для отображения ответа. Рекомендуется присвоить как можно более информативные метаданные каждому фрагменту:
- Идентификатор документа и источник: например, название файла или уникальный ID документа. Это позволит при поиске понимать, откуда взят фрагмент, и избежать возврата нескольких фрагментов с одного и того же места. Кроме того, можно настроить фильтрацию: если пользователь спрашивает “в документе X..., что говорится о Y?”, ретривер может отфильтровать только по метаданным source: X и искать ответ только в этом документе.
- Номер страницы или раздела: сохраняйте положение чанка в оригинале. Поле page особенно полезно для PDF – чтобы можно было быстро найти контекст ответа при валидации или отладке. Поле section (имя раздела) – для понимания контекста. Например, метаданные {"section": "2. Методология", "page": 15} дадут сигнал, что фрагмент – из методологии, стр.15.
- Заголовок/подзаголовок: если чанк относится к какому-то названному разделу, имеет смысл сохранить название этого раздела в метаданных (а можно и в тексте чанка в виде, например, «Раздел: Методология – ...текст...). В метаданных же можно хранить и иерархию заголовков (например, Раздел 2 > Подраздел 2.1), если важно для навигации.
- Тип содержимого: если возможно, указывайте, что это – абзац, список, таблица, заголовок и т.д. Это может помочь при отладке или при решении, как отображать ответ. Например, если у чанка content_type: "table", можно знать, что текст – это таблица (в Markdown) и, возможно, LLM должен ответить, процитировав её строку.
- Ключевые слова (опционально): для каждого чанка можно автоматически сгенерировать набор ключевых слов или тегов (например, с помощью простой TF-IDF или YAKE-алгоритма по тексту чанка). Эти ключевые слова можно записать в метаданные. В некоторых векторных базах (например, Weaviate, ElasticSearch) можно выполнять гибридный поиск – комбинируя семантический (векторы) и лексический (по словам). Метаданные-теги пригодятся для точного совпадения по терминам. В LangChain можно также при построении ретривера указать фильтрацию или boosting по метаданным. Однако, если используемый движок (Chroma, FAISS) чисто векторный, то ключевые слова в метаданных напрямую не влияют на сходство, но они могут быть использованы для ручной фильтрации результатов или логирования. В целом, метаданные не повредят, даже если не используются постоянно.
- Дополнительные поля: при необходимости можно хранить embedding целиком или ссылки (URL, если документы локальные HTML), даты, автора и т.п. – вдруг это пригодится в будущем (например, фильтрация по дате документа).
3. Использование метаданных в процессе retrieval: Хорошо настроенные метаданные позволяют реализовать более умный поиск. Например:
- Можно использовать комбинацию поиска: сначала отфильтровать кандидаты по какому-то признаку (по метаданным), затем ранжировать по векторному сходству. LangChain имеет SelfQueryRetriever, который с помощью LLM умеет интерпретировать вопрос и применять фильтр по метаданным автоматически. Это может быть актуально, если база большая и разнородная (например, содержит документы разных типов или тем, и по вопросу можно понять, что искать нужно только среди некоторых).
- MaxMarginalRelevance (MMR): – стратегия отбора чанков, которая учитывает не только близость к запросу, но и разнообразие результатов. С её помощью можно исключить ситуацию, когда ретривер возвращает почти дублирующие друг друга фрагменты одного раздела, упуская другие релевантные части. В LangChain MMR включается параметром search_type="mmr" у векторного ретривера. MMR старается покрыть разные аспекты запроса, что повышает полноту ответа.
- Количество возвращаемых чанков (k): Не всегда оптимально возвращать ровно один чанк. Часто систему настраивают на поиск top-3 или top-5 фрагментов. Это повышает шанс, что среди них будет содержаться нужная информация (особенно если вопрос комплексный и охватывает несколько разделов). В нашем случае, учитывая контекстное окно модели (у Qwen-32B контекст, возможно, 2048 или 4096 токенов), можно позволить 3–5 чанков по ~200–300 токенов без риска переполнения. Экспериментально стоит подобрать значение k: слишком большое k приведёт к большому объёму данных в prompt и может запутать модель, слишком маленькое – риск пропустить ответ. Некоторые подходы варьируют k в зависимости от уверенности (например, если топ-1 чанк имеет низкое сходство, берут больше).
- Порядок и пост-обработка чанков: После получения результатов поиска можно их отсортировать не только по убыванию косинусного сходства, но и, например, по источнику (чтобы сгруппировать близкие по контексту куски вместе) или по их метаданным (например, восстановить оригинальный порядок страниц). Иногда имеет смысл объединять подряд идущие чанки из одного документа перед подачей в LLM, если они логически связаны (например, ответ может потребовать два соседних чанка из одного раздела). Однако здесь надо быть осторожным: сшивая чанки, мы увеличиваем объем текста и можем снова затруднить модель. Поэтому часто проще поручить модели работать с раздельными фрагментами.
Правильно применённые стратегии chunking + metadata значительно улучшают релевантность retrieval. Каждый чанк представляет собой самостоятельную смысловую единицу, а метаданные позволяют системе более тонко фильтровать и интерпретировать запросы. В итоге при вопросе пользователя повышается вероятность, что нужный фрагмент будет найден и предоставлен модели, не потерявшись среди нерелевантных данных.
Настройка индекса и ретривера для высокой точности и производительности
Конфигурация векторного индекса и параметров поиска (retriever) должна быть адаптирована под размер данных и требования к скорости/точности. Рассмотрим ключевые аспекты настройки:
1. Выбор векторного хранилища: Для локальной RAG-системы, как уже отмечалось, оптимальны либо Chroma, либо FAISS – они бесплатны и легко интегрируются. Коротко рекомендации:
- ChromaDB: Хороший выбор, если нужно хранить данные на диск и иметь готовые высокоуровневые методы. Chroma самостоятельно поддерживает актуальный индекс при добавлении новых данных и обеспечивает поиск за миллисекунды на тысячах-векторах. По умолчанию Chroma использует косинусную близость (с нормализацией эмбеддингов) и метод HNSW для быстрого ANN-поиска. Настройки Chroma, которые можно учитывать: размер HNSW (M, ef), но в большинстве случаев дефолтов достаточно. Для уверенности, что поиск максимально точный (но в ущерб памяти), можно отключить approximate и использовать brute-force (но Chroma ориентирован на ANN). В LangChain Chroma.from_documents и Chroma.as_retriever позволяют настроить search_kwargs={'k': ..., 'distance_metric': 'cos'}.
- FAISS: Требует чуть больше ручной работы при настройке, но даёт гибкость. Если данных относительно немного (до десятков тысяч чанков), можно использовать простой индекс IndexFlatIP (Inner Product, эквивалентно косинус для нормированных векторов) – он будет выполнять точный поиск по всем векторам. Для больших объемов можно выбрать IndexIVFFlat (кластеризованный индекс), настроив параметр nlist (количество кластеров) и nprobe (сколько кластеров просматривать при запросе) – эти параметры влияют на компромисс точности/скорости. FAISS также поддерживает HNSW (IndexHNSWFlat) – это тоже отличное решение для быстрого поиска с высокой точностью, особенно если вы хотите контролировать память (можно задать максимальное число связей).
- Метрика расстояния: Для текстовых эмбеддингов почти всегда используется косинусная близость. FAISS не имеет косинуса напрямую, но если нормировать все эмбеддинги, можно применять внутреннее произведение (inner product) – результат будет эквивалентен косинусу. LangChain зачастую делает нормализацию автоматически, либо предоставляет опцию normalize_embeddings=True. В Chroma метрика по умолчанию cos. Убедитесь, что используемая метрика соответствует предположению модели: например, SentenceTransformer-эмбеддинги обычно сравниваются по cos. Евклидово расстояние можно использовать, но он по сути моногонален косинусу при нормировке.
- Хранение метаданных: В Chroma поддержка метаданных “из коробки” – при добавлении документов можно передать словари метаданных, и потом выполнять фильтрацию в запросах. В FAISS придётся хранить метаданные отдельно (LangChain сохраняет их в параллельных структурах, привязанных к индексу). Если необходима сложная фильтрация, Chroma будет удобнее, так как может на уровне запроса делать where по метаданным.
2. Настройка retriever (поискового модуля) в LangChain: После выбора хранилища, LangChain обычно оборачивает его своим Retriever. Параметры, влияющие на эффективность:
- k – число документов (чанков), возвращаемых на запрос. Как обсуждалось, экспериментально подбирается 3–5. Для высокой полноты ответа лучше чуть больше (например, 5), а потом можно отсечь лишнее. Но если заметили, что LLM путается с большим числом источников, можно сократить до 3.
- search_type – LangChain позволяет указать стратегию отбора. Стандартно это similarity, но есть и "mmr" (Max Marginal Relevance), упомянутая выше. MMR может быть полезна для повышения качества: она вернёт более разноплановые фрагменты, что особенно хорошо при комплексных вопросах.
- search_kwargs – сюда можно передать дополнительные настройки индекса: например, filter (условие по метаданным, типичен для Chroma), score_threshold (порог сходства; можно отбросить результаты, у которых косинусное сходство ниже, скажем, 0.3 – тем самым LLM не получит совсем нерелевантный текст). Если данных много и разные по качеству, установка минимального порога может снизить количество "шума" в выдаче.
- Комбинация сkeyword search: LangChain Retriever может комбинировать разные подходы. Например, MultiVectorRetriever или просто вручную: сделать сначала поиск по ключевым словам (BM25 через Whoosh/ElasticSearch) и по эмбеддингам, а затем объединить результаты. Это повышает надёжность: если вопрос содержит очень специфический термин, который есть в тексте, keyword-поиск найдёт прямое совпадение, а embedding-поиск – семантически близкие. Объединение результатов и последующий ранкинг (можно по среднему рангу или вручную настроить приоритет) иногда дают лучшую точность. Однако, это увеличивает сложность системы. Если корпус однородный и вопросы более-менее прямые, можно обойтись одним векторным поиском.
- Параллельный поиск по нескольким индексам: LangChain позволяет составлять объединённые ретриверы (например, вы можете хранить разные типы документов в разных векторных индексах). Если возникает потребность, модуль CompoundRetriever может опрашивать несколько источников и агрегировать. Но в нашей задаче предполагается единый корпус, так что это не первоочерёдно.
3. Баланс точности и производительности: Чтобы обеспечить быстрый ответ, нужно оптимизировать каждый шаг:
- Размер эмбеддингов: Выбор модели влияет на размер векторов. Многие трансформерные модели дают 768-1024 измерений. Есть модели поменьше (256-384). Меньший размер = быстрее сравнение и меньше памяти, но потенциально чуть хуже качество семантического соответствия. Если инфраструктура ограничена, можно попробовать 384-мерные эмбеддинги – они часто достаточны.
- Аппроксимация vs точный поиск: На средних объемах (до ~100k чанков) точный поиск (Flat index) может работать <100мс. Но на миллионах понадобится approximate (HNSW, IVF). Нужно мониторить время ответа на этапе тестирования. HNSW как в FAISS, так и в Chroma, дает хорошее ускорение с минимальной потерей качества. IVF требует тонкой настройки – можно начать с 1024 кластеров (nlist=1024) и nprobe=10% кластеров для поиска.
- Batching запросов: Если система должна отвечать на много запросов параллельно, стоит убедиться, что векторная БД поддерживает параллельные запросы или использовать асинхронные вызовы. vLLM, например, отлично масштабирует генерацию ответов параллельно, поэтому узким местом может стать retrieval. У Chroma есть асинхронный API, у FAISS – нет (но можно запускать в отдельном треде). Этот момент важен для производительности под нагрузкой.
- Кэширование: Если некоторые вопросы повторяются или близки, можно кешировать результаты поиска или даже готовые ответы. LangChain имеет встраиваемый кэш LLMChain. Но в QA-сценарии кэш не всегда полезен (вопросы уникальны). Однако, на этапе разработки, можно кэшировать эмбеддинги вопросов (если многократно тестируете одни и те же).
- Memory mapping: Если используете FAISS и индекс большой, вы можете memory-map файл индекса, чтобы не держать весь векторный массив в оперативной памяти постоянно. Это чуть снизит скорость доступа, но сильно сэкономит память и ускорит старт системы (особенно актуально для Flat индексов с миллионами векторов).
- Логгирование метрик: В продакшн-сценарии полезно собирать метрики: среднее время поиска, процент запросов без найденных контекстов (когда все сходства ниже порога), размер индекса, использование памяти. Эти данные помогут со временем подстраивать конфигурацию (например, увеличить k или изменить модель эмбеддинга).
В итоге, правильно настроенный индекс + ретривер должны обеспечивать высокую полноту и точность поиска при минимальной задержке. Главное – удостовериться, что ограничения модели (контекст длины, склонность путаться) учтены: не подавать лишнего, но и не упустить нужное. Проводите эксперименты: например, измерьте, как изменение k или порога сходства влияет на качество ответов. Благодаря встроенным средствам оценки (см. ниже) можно подобрать настройки, дающие наилучший результат.
(Примечание: как говорится в сообществе, точность RAG больше зависит от качества эмбеддинга и данных, чем от конкретной БД. Поэтому после базовой настройки индекса фокус следует сместить на улучшение данных и эмбеддингов, что мы и сделали выше.)
Проверка качества подготовки и покрытия знаний
После построения базы знаний необходимо убедиться, что она действительно содержит ответы на все предполагаемые вопросы и что RAG-система их находит. Проверка качества покрытия включает несколько подходов:
1. Создание набора контрольных вопросов (Q&A) и автоматическое тестирование: Соберите список вопросов, ответы на которые точно должны содержаться в документах. Это могут быть реальные вопросы от пользователей или составленные вами на основе содержания документов. Например, для технической документации – вопросы по определению терминов, для отчёта – ключевые цифры, для инструкции – шаги процесса. Желательно охватить все документы и разделы хотя бы одним вопросом. Затем подготовьте эталонные ответы или хотя бы ключевые фразы, которые должен содержать корректный ответ. Используя инструменты вроде LangChain + LangSmith, можно автоматически прогнать эти вопросы через вашу RAG-систему и проверить, содержится ли ожидаемая информация в ответе. В простейшем случае, можно проверять вхождение правильной цифры или факта (как в примере – вопрос про число станций осадков, ответ должен содержать "6,662"). Такой тест выявит, какие вопросы система пока не решает – либо из-за того, что нужный контент не был извлечён/индексирован, либо retrieval не находит его, либо LLM не включает его в ответ. Например, если из 50 тестовых вопросов 5 получили неверный ответ или "не знаю", следует изучить эти случаи: возможно, контент по ним расположен в таблице, которую мы не распознали, или вопрос сформулирован иначе, чем ожидает ретривер (нужна доработка эмбеддинга или добавление синонимов).
- Пример подхода: В LangSmith можно создать датасет вопросов и через метод evaluate запустить цепочку QA, получив для каждого вопроса метрику, прошёл ли он проверку. Можно и самостоятельно написать скрипт: для каждого вопроса вызвать ретривер, получить контекст и ответ LLM, а затем сравнить с эталоном (например, простым if expected_phrase in answer). Этот тест даст количественную оценку покрытия знаний.
- Если нет возможности составить много вопросов вручную, можно сгенерировать их автоматически: воспользоваться LLM, скормить ему документ и попросить придумать вопросы по содержанию (и ответы). Однако, тогда есть риск, что LLM придумает вопросы, на которые в тексте нет прямых ответов (фантазия), либо ответы будут не точными. Но для грубой проверки охвата этот метод подходит: сгенерированные вопросы затем задаём нашей системе и смотрим, вернёт ли она те же ответы.
2. Анализ полноты извлечения документов: Иногда вопросы не покрываются, потому что релевантный текст не был извлечён из исходника. Чтобы проверить, что ничего критичного не упущено:
- Сравнительный просмотр: Выберите несколько документов и вручную сравните исходный файл с обработанным текстом (можно открыть PDF рядом с результатом парсинга). Проверьте, все ли разделы на месте, не потерялись ли абзацы, таблицы, не съехала ли кодировка. Обратите внимание на нестандартные части: возможно, где-то формула опущена (и стоило бы добавить заглушку), где-то таблица распозналась плохо (и надо улучшить парсер или обработку именно для этого формата таблиц). Особый акцент на документы, по которым были провалы в автотесте из пункта 1. Например, если вопросы по документу X не отвечаются, откройте документ X – может оказаться, что он на скане (и OCR не был применён) или в нём весь текст в таблицах (а парсер их пропустил).
- Логирование размеров: Соберите статистику: сколько символов/слов было извлечено из каждого файла. Сопоставьте это с ожидаемым объёмом. Если 100-страничный PDF дал только 500 слов текста, очевидно, бóльшая часть не извлечена (возможно, это отсканированный PDF без OCR). Либо 50-страничный документ неожиданно дал 100k слов – вероятно, шум (например, текст повторился из-за ошибочного парсинга). Такие аномалии надо расследовать и исправить инструмент обработки.
- Проверка дубликатов: Убедитесь, что при объединении нескольких документов не случилось задвоения. Можно подсчитать хеши всех чанков и посмотреть, есть ли повторяющиеся. Если да – выяснить, это действительно дубли (тогда, возможно, не стоит хранить оба) или просто схожие тексты.
- Language coverage: Так как документы русские, проверьте, что модель эмбеддинга адекватно работает с русским текстом. Например, можно взять пару-тройку предложений, перевести их на английский, получить эмбеддинги и посмотреть косинусное расстояние с оригиналом – оно должно быть высоким, если модель мультилингвальна. Это не прямая метрика, но может дать понимание, нет ли сбоев (некоторые модели, хотя и заявлены мультиязычными, могут иметь bias к английскому). Если обнаружится, что эмбеддинги русского текста не отражают смысл (низкая семантическая близость для очевидно схожих фраз), нужно заменить модель эмбеддинга.
3. Оценка качества ответов вручную: Помимо автоматических метрик, важно провести ручное тестирование. Пусть несколько человек (или вы сами) позадают системе вопросы, имитируя конечных пользователей. Анализируйте ответы:
- Правильно ли цитируется факт из документа? Если ответ содержит значение или утверждение, есть ли в предоставленном контексте этот факт? Если LLM начинает "галлюцинировать" на основе общего знания – это сигнал, что retrieval вернул не то, или размер чанков слишком мал/велик, или в данных пробел.
- Посмотрите разные типы вопросов: точные факты ("какого числа произошло событие?"), описательные ("что говорится в разделе X о Y?"), сравнения, вычисления на основе данных (если применимо). Система должна справляться со всеми, основываясь только на данных. Если какой-то тип вопросов даёт сбой, возможно, стоит доработать подготовку данных именно под этот тип. Например, на вопросы "приведи цитату из документа" LLM должен легко справляться, а вот на "сравни данные из двух таблиц" – это сложнее, и может требовать, чтобы оба соответствующих фрагмента были извлечены и переданы модели одновременно.
- Оцените скорость ответа – укладывается ли система в требуемые рамки. Если ответы точны, но приходят слишком медленно, это тоже проблема качества обслуживания. Тогда возвращаемся к оптимизации: где узкое место – embedding, поиск или генерация? Для генерации можно присмотреться к vLLM настройкам (batching). Для поиска – возможно, надо упростить индекс.
4. Непокрытые вопросы и обратная связь: Если имеются примеры вопросов, на которые система не смогла ответить (либо ответ неверный), нужно определить, связано ли это с подготовкой документов:
- Ответа нет в данных: бывает, что пользователь спросил то, чего в документах нет. В таком случае RAG-система должна уверенно говорить, что ответа не найдется. Здесь на этапе подготовки данных можно мало что сделать, кроме как убедиться, что действительно такой информации нет. Но важно настроить саму LLM-часть (prompt), чтобы без контекста она не халлюцинировала ответ.
- Ответ есть, но не найден: гораздо более интересный случай – информация есть, но retrieval не вытащил нужный чанк. Тогда надо глубже посмотреть: почему не нашёл? Например, вопрос сформулирован синонимами, а эмбеддинг не понял связи. Решение: добавить эти синонимы (или вообще больше контекстных слов) в исходный текст или метаданные. Или обучить пользовательский эмбеддинг (довольно сложный путь). Проще – добавить в метаданные ключевые слова. Ещё вариант – задать LLM перефразировать запрос или расширить его (LangChain умеет генерировать альтернативные формулировки запроса, которые тоже можно поиском проверить). Но это уже выход за рамки подготовки документов, скорее улучшение самого retriever-а.
- Чанк найден, но LLM искажает ответ: то есть нужный текст в контексте есть, а модель даёт неточный ответ. Это может значить, что текст слишком сложный (например, сплошной юридический язык, и модель не сумела правильно интерпретировать). В рамках подготовки данных можно попробовать переформатировать сложный текст – например, длинное предложение разбить на короче, добавить разъясняющих слов. Однако, полностью решать это вручную нецелесообразно. Иногда помогает включение дополнительного контекста: например, если вопрос про таблицу, а в таблице заголовки сокращены, модель может не понять, о чем столбцы. Если бы метаданные хранили расшифровку этих сокращений или подпись к таблице, LLM поняла бы лучше. Поэтому при анализе ошибок надо думать: “какой дополнительный кусочек информации, будь он в контексте, помог бы модели дать правильный ответ?” – и затем обеспечить добавление этого кусочка в соответствующий чанк (либо расширить чанк, либо включить нужное в метаданные, которые потом можно подтянуть в prompt).
5. Непрерывное улучшение и мониторинг: Качество покрытия – не статичная вещь. По мере добавления новых документов или изменения вопросов пользователей нужно периодически пересматривать качество:
- Раз в N запросов (или раз в неделю) просматривайте журналы: какие вопросы задавались, находился ли контекст, какой confidence (если есть). Выявляйте новые провалы.
- При добавлении нового документа сразу добавляйте несколько тест-вопросов по нему в ваш набор и прогоняйте – это гарантирует, что вы не сломали ничего новым материалом.
- Если ваш набор тестов довольно большой, можно автоматизировать A/B тестирование изменений пайплайна. Например, попробовать другую модель эмбеддинга – и сразу запустить все вопросы, сравнить метрику (скольким процентом вопросов стали отвечать правильно). Такие регрессионные тесты не дадут ухудшить систему случайно.
В заключение, качественная подготовка документов проявляется в том, что на любую фактическую информацию из этих документов система сможет ответить корректно, опираясь на предоставленный контекст. Достичь этого можно только через итеративное тестирование и улучшение. Как заметил один из разработчиков RAG-платформы, успех во многом определяется тем, насколько точно вы представили ваши внутренние документы в векторном индексе. Поэтому имеет смысл вкладываться в отладку парсинга, чистки, чанкинга и проверку покрытии – эти усилия прямо конвертируются в повышение качества ответов конечной системы.