Головна / Статті

Чому RAG не функціонує в реальних умовах: розділення на частини, ембеддинги, пошук та оцінка.

Чому RAG не функціонує в промисловому використанні після завершення демонстрації: межі частин даних, проблеми з вбудовуванням інформації, обмеження методу top-K, застарілі дані та крок оцінки, який ігнорують команди.

Видавець: REACTAPP.TOP10 хв читання2199 слів

Джерела: 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,
)

Розширення запиту покращує формулювання запитання перед тим, як воно буде інкорпороване. Запит про автентифікацію можна розширити додаванням термінів типу login, sign-in, auth та identity verification, що переміщує його у більш насичену частину простору. Для створення варіантів запиту може використовуватися невеликий виклик 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 найкращих результатів. Показник «recall» при 5 запитах визначає, чи є правильний фрагмент у перших п’яти результатах, а показник середнього оберненого рангу — наскільки високо він розташований. Перед запуском слід провести тестовий набір з 100–200 репрезентативних запитів.

Вірність відповіді. Чи відображає створена відповідь отриманий контекст без додавання тверджень, яких немає у фрагментах? Система з ШІ як суддею може оцінити це, отримавши запит, отримані фрагменти та відповідь, та визначивши, наскільки добре контекст підтримує відповідь.

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 функціонують належним чином, ухвалюють такі рішення навмисно ще до запуску, а не у відповідь на неправильні відповіді. У навчальних матеріалах пропускають складні частини, оскільки демо-версія їх не потребує, але надійна система — так.