Почему RAG терпит неудачу в производственных условиях: разбиение на фрагменты, эмбеддинги, поиск и оценка.
Почему RAG терпит неудачу в производственных условиях после завершения демонстрации: границы блоков, слепые зоны эмбеддингов, ограничения top-K, устаревшие данные и этап оценки, который игнорируют команды.
Источники: RAG Without the Hype: What Retrieval-Augmented Generation Actually Takes to Work in Production
Стандартное руководство по RAG выполняется по простой схеме: загружается PDF, он разбивается на фрагменты, эти фрагменты сохраняются в векторной базе данных, затем вводится вопрос пользователя, он сопоставляется с ближайшими фрагментами и вставляется в промпт. Ответ получается точным, и кажется, что самая сложная часть завершена.
На практике всё иначе. Вопросы часто охватывают несколько документов, и релевантный контекст обрезается на границах фрагментов. Система находит отрывки, близкие по смыслу к запросу, но неверные с точки зрения контекста — с правильными словами, но неправильным значением. Модель затем с уверенностью отвечает на основе устаревшей, противоречивой информации или информации, в которой отсутствует важное уточнение, находящееся всего в трех предложениях за границей фрагмента. Самое плохое — ответ звучит правильно, и ничто не указывает на то, что поиск прошёл неудачно.
Демо-версию RAG легко создать, но версию, которая надёжно работает с реальным корпусом данных и реальными пользователями, сделать сложно. Эта проблема возникает по трем причинам: способ разбиения документов на части, то, как эмбеддинги обрабатывают нюансы, и то, как механизм поиска определяет, что считается релевантным. У каждого из этих аспектов есть способы сбоев, которые легко упустить, а их устранение после запуска оказывается дорогостоящим.
Разбиение на части: где теряется информация
По умолчанию в учебнике используется рекурсивное разделение символов фиксированного размера с определённым перекрытием.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
chunks = splitter.split_documents(documents)
Этого достаточно для демонстрации. Однако метод терпит неудачу, когда структура документа несёт смысл, который игнорируется при подсчёте символов, и это происходит тремя распространёнными способами.
- Разделение контекста. В спецификации может быть указано, что максимально допустимое значение — 500, причём это ограничение действует только в режиме высокой доступности при активной конфигурации аварийного переключения. Если лимит установлен в 1 000 символов, эта цифра может оказаться в одном фрагменте, а условие — в следующем. Человек, задающий вопрос об ограничении для режима высокой доступности, получает фрагмент с указанием 500 без каких-либо уточнений. Ответ существует в корпусе данных, но разделение на фрагменты нарушило связь между ними.
- Оставшиеся без контекста заголовки. Заголовок вроде «Настройки безопасности» и первый абзац под ним, в котором говорится, что для каждого соединения требуется TLS, могут оказаться в разных фрагментах. Система поиска, находящая абзац, не имеет контекста заголовка, а структура представления текста не указывает на то, что он относится к настройкам безопасности.
- Повреждённые таблицы и списки. Разделители фиксированного размера часто разрезают таблицу посередине строки или список посередине элемента. Заголовок столбца в одном фрагменте и данные в другом делают их непонятными по отдельности.
Для создания фрагментов производственного уровня обычно используются три метода.
Разделение с учётом структуры предполагает разрезку по собственным границам документа — заголовкам, абзацам и разделам, а не по количеству символов. Markdown можно разбивать по заголовкам второго или третьего уровня, а HTML — по тегам заголовков, абзацев или разделов.
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "document_title"),
("##", "section"),
("###", "subsection"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(markdown_document)
# Each chunk now includes header metadata: {"section": "Security Configuration"}
Обогащение родительского контекста позволяет хранить вместе с каждым фрагментом путь к заголовку, ведущему к нему. При извлечении фрагмента в запросе вместе с текстом предоставляются названия разделов и подразделов, что даёт модели структуру, отсутствующую в пространстве эмбеддингов.
Разбиение на родительские и дочерние фрагменты предусматривает сохранение небольших фрагментов для повышения точности поиска и более крупных родительских фрагментов, в которых они находятся. При поиске находится небольшой фрагмент, а в запросе предоставляется родительский фрагмент. Небольшие фрагменты более точно отражают конкретные идеи, в то время как родительские фрагменты предоставляют сопутствующую информацию.
# Small child chunk for retrieval
child_chunk = "The maximum supported value is 500."
# Parent chunk injected into prompt when child is retrieved
parent_chunk = """
## High Availability Configuration
When operating in high-availability mode with primary failover enabled,
the following limits apply:
- Maximum supported value: 500
- Minimum heartbeat interval: 100ms
- Failover timeout: 30 seconds
"""
Этот подход требует больших затрат на реализацию и хранение, однако он обеспечивает гораздо более качественные ответы на вопросы, требующие контекста, что характерно для большинства реальных вопросов.
Эмбеддинги и ограничения сходства
Эмбеддинги помещают текст в векторное пространство, где похожие по смыслу элементы находятся рядом друг с другом, а при поиске возвращаются те фрагменты, которые наиболее близки к запросу. Этот метод работает тогда, когда семантическая близость может служить достаточным критерием релевантности. Он теряет эффективность, когда семантические аспекты сильно отличаются.
- Отрицание. Предложения о том, что система не поддерживает IPv6, и о том, что она его поддерживает, получают практически идентичные векторы, поскольку оба касаются поддержки IPv6. Отрицание едва влияет на вектор. Вопрос о IPv6 может привести к возвращению обоих фрагментов или неверного, и модели приходится согласовывать противоречия или отвечать на основе неверного фрагмента, не замечая этого.
- Несоответствие словарного запаса. Пользователь пишет «login», в то время как в документации используются термины «authentication» и «sign-in». Обычно эти варианты находятся достаточно близко друг к другу, чтобы поиск сработал, но возникают крайние случаи, когда терминология пользователя отличается от терминологии, приведённой в документации.
- Конкретность. Вопрос о том, как настроить пул подключений, и вопрос о значении времени ожидания по умолчанию пула находятся очень близко друг к другу, поэтому оба возвращают один и тот же набор фрагментов информации. Когда значение времени ожидания расположено в одном абзаце среди нескольких абзацев, посвящённых пулам, лучший фрагмент не всегда оказывается на первом месте.
Три решения помогают преодолеть эти ограничения.
Гибридный поиск сочетает плотный поиск, который учитывает смысл, с разреженным поиском, таким как BM25 или TF-IDF, который определяет точные термины. Для поиска номера версии, ключа конфигурации или кода ошибки более эффективно использование совпадения ключевых слов, чем семантическое сходство. Многие векторные базы данных теперь предоставляют гибридный поиск из коробки, а приведённый ниже пример демонстрирует использование комбинации разреженного и плотного поиска в Qdrant.
from qdrant_client import QdrantClient
from qdrant_client.models import SparseVector, NamedSparseVector
# Many vector databases now support hybrid search natively
# This example uses Qdrant's sparse + dense hybrid search
results = client.query_points(
collection_name="docs",
prefetch=[
models.Prefetch(
query=sparse_query_vector, # BM25 sparse vector
using="sparse",
limit=20,
),
models.Prefetch(
query=dense_query_vector, # Embedding dense vector
using="dense",
limit=20,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF), # Reciprocal rank fusion
limit=5,
)
Расширение запроса позволяет усовершенствовать вопрос перед его включением в модель. Запрос, касающийся аутентификации, может быть дополнен терминами вроде вход в систему, авторизация, проверка личности, что помещает его в более обширную область значений. Для генерации вариантов запроса может быть использован небольшой LLM, либо для обработки терминов конкретной области может подойти статическая карта синонимов.
Выбор модели эмбеддингов также заслуживает внимания. Многие команды предпочитают использовать общие модели от OpenAI, такие как text-embedding-ada-002 или text-embedding-3-small. Однако для медицинских, юридических или технических корпусов данных гораздо лучше сработают специализированные или отрегулированные под конкретную область модели. Если качество поиска остается низким даже при правильном разбиении текста на фрагменты, целесообразно сравнить различные варианты на репрезентативной выборке запросов.
Проблемы поиска, возникающие при масштабировании
Фрагментация и эмбеддинги определяют, можно ли найти контент, тогда как процесс поиска решает, был ли он найден для данного запроса. Некоторые проблемы проявляются только при использовании большого корпуса или разнообразных запросов.
Выбор K. Большинство систем извлекают K наиболее похожих фрагментов и включают их все в промпт. Малое значение K приводит к упущению важных фактов, когда ответ требует информации из нескольких фрагментов. Большое значение K заполняет окно контекста менее значимыми фрагментами, что снижает качество сигнала; к тому же в длинных промптах это может вывести по-настоящему релевантный фрагмент за пределы диапазона внимания модели.
Практический подход заключается в получении большего количества результатов — например, 20–30 лучших кандидатов — а затем их переранжировании. Кросс-кодер объединяет запрос и фрагменты текста и напрямую оценивает их релевантность, вместо того чтобы сравнивать два независимо вычисленных эмбеддинга. Этот метод медленнее, поскольку эмбеддинги фрагментов нельзя предварительно вычислить, но он гораздо лучше отделяет релевантные результаты от почти релевантных. Быстрый приближенный поиск сужает круг возможных результатов, после чего кросс-кодер выбирает окончательный набор.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# Retrieve broad candidate set
candidates = vector_store.similarity_search(query, k=20)
# Re-rank with cross-encoder
pairs = [(query, chunk.page_content) for chunk in candidates]
scores = reranker.predict(pairs)
# Sort by re-ranking score, take top 5
reranked = sorted(zip(scores, candidates), reverse=True)[:5]
final_chunks = [chunk for _, chunk in reranked]
Устаревание данных. Хранилище векторов, созданное шесть месяцев назад, хранит документы, которые с тех пор изменились, но само хранилище об этом не знает. При поиске с высокой вероятностью возвращается устаревший фрагмент, на основе которого и отвечает модель. Решением является фильтрация метаданных: необходимо хранить дату последней модификации каждого фрагмента и фильтровать их по диапазону дат до или после поиска по сходству. Для активно обслуживаемой документации фрагменты, старше установленного порога, удаляются или получают более низкий ранг.
from datetime import datetime, timedelta
# Filter to chunks updated in the last 90 days
cutoff = datetime.now() - timedelta(days=90)
results = vector_store.similarity_search_with_filter(
query=query,
filter={"last_modified": {"$gte": cutoff.isoformat()}},
k=10
)
Ложная уверенность. Модель отвечает на основе того, что ей предоставлено, даже если полученные фрагменты не связаны с вопросом. Она не осознаёт, что качество поиска было низким. Это наиболее сильно подрывает доверие пользователей, поскольку ответ кажется авторитетным, хотя на самом деле неверен. Решением является использование фильтра релевантности перед генерацией ответа. Можно оценить полученные фрагменты и прекратить генерацию, если лучший балл ниже установленного порога, или указать модели в системном промпте, что у неё недостаточно информации, после чего проверить, правильно ли она использует этот механизм избежания ошибок.
SYSTEM_PROMPT = """
You are a documentation assistant. Answer questions using only the provided context.
If the context does not contain enough information to answer the question confidently,
respond with: "I don't have sufficient information in the available documentation to
answer this question reliably."
Do not speculate or answer from general knowledge when documentation context is insufficient.
"""
Оценка, которую игнорируют команды
Система RAG без оценки имеет неизвестное качество. Она может хорошо справляться с некоторыми запросами, но плохо — с другими, и единственный способ это выяснить — это жалобы пользователей. Оценка состоит из трех частей.
Качество поиска. Для тестовых запросов с известными правильными исходными документами измеряется частота появления правильного документа среди K лучших найденных результатов. Показатель воспроизведения при 5 запросах определяет, присутствует ли правильный фрагмент среди первых пяти результатов, а показатель среднего обратного ранга показывает, на каком месте он находится. Перед запуском следует провести тестирование с набором из 100–200 репрезентативных запросов.
Адекватность ответа. Отражает ли сгенерированный ответ найденный контекст без добавления утверждений, отсутствующих в фрагментах? Модель LLM в роли судьи может оценить это, получив запрос, найденные фрагменты и ответ, а затем оценить, насколько хорошо контекст подкрепляет ответ.
FAITHFULNESS_PROMPT = """
Given the following context and answer, rate whether the answer is fully
supported by the context on a scale of 1-5.
Context: {context}
Answer: {answer}
Score (1=not supported, 5=fully supported):
"""
# Automate across your evaluation set
scores = []
for query, context, answer in eval_set:
score = judge_model.complete(
FAITHFULNESS_PROMPT.format(context=context, answer=answer)
)
scores.append(score)
print(f"Average faithfulness: {sum(scores)/len(scores):.2f}")
Полнота проверки от начала до конца. Для запросов с известными ответами дает ли система правильный результат? Это самая сложная для автоматизации, но в то же время самая важная часть. Даже набор из 50 вопросов, составленный вручную и имеющий проверенные ответы, применяемый до и после каждой изменения параметров разбиения на фрагменты, использования эмбеддингов или настроек поиска, позволяет выявить ухудшения качества.
Набор для оценки функционирует как набор тестов на обнаружение ухудшений. Новая модель эмбеддингов, другой размер фрагментов или измененный системный промпт должны каждый из себя приводить к измеримым изменениям в результатах. Когда изменение повышает средний уровень точности, но снижает точность поиска, или наоборот, такой компромисс должен быть осознанным решением, а не результатом тайного внедрения изменений.
Сам процесс обработки — это продукт
Пайплайн обучения включает четыре этапа и не предусматривает обработки ошибок. Промышленный пайплайн состоит из шести этапов, на каждом из которых осуществляется обнаружение сбоев, на протяжении всего процесса добавляются метаданные, а перед каждым выпуском производится оценка.
Сбои предсказуемы: метод разбиения на фрагменты без учета структуры приводит к потере контекста на краях документа. Метод поиска на основе эмбеддингов упускает точные совпадения и плохо справляется с отрицательными формулировками. Поиск без повторной классификации по релевантности возвращает сниженный список лучших результатов, в котором теряются наиболее подходящие фрагменты. Генерация без механизма проверки релевантности приводит к выдаче уверенных, но неверных ответов на основе слабого контекста.
Ничего из этого невозможно решить, и каждое решение имеет свою цену. Для разбиения документа с учетом структуры необходимо понимание формата документа. Гибридный поиск подразумевает использование как плотных, так и разреженных индексов. Повторная классификация по релевантности увеличивает время поиска. Для оценки требуется создание и поддержка тестового набора.
Команды, у которых системы RAG работают стабильно, принимают такие решения заранее, до запуска, а не в ответ на неверные ответы. В учебниках пропускают сложные аспекты, поскольку демо-версии ими не нуждаются, но надежной системе они необходимы.