Dlaczego RAG zawodzi w warunkach produkcyjnych: dzielenie na fragmenty, wektory embeddingów, wyszukiwanie oraz ocena.
Dlaczego RAG zawodzi w produkcji po zakończeniu demonstracji: granice fragmentów, niewidoczne obszary w embeddingach, ograniczenia top-K, przestarzałe dane oraz krok oceny, którym pomijają go zespoły.
Źródła: RAG Without the Hype: What Retrieval-Augmented Generation Actually Takes to Work in Production
Standardowe instrukcje dotyczące RAG przebiegają w prosty sposób. Łączy się plik PDF i dzieli go na fragmenty, które następnie są umieszczane i przechowywane w bazie danych wektorowej. Pytanie użytkownika jest również umieszczane w tej bazie, porównywane z najbliższymi fragmentami, a następnie wklejane do promptu. Odpowiedź jest dokładna i wydaje się, że trudna część jest już za nami.
W warunkach rzeczywistych sytuacja jest inna. Pytania obejmują kilka dokumentów, a istotny kontekst zostaje przecięty na granicach fragmentów. Proces wyszukiwania zwraca fragmenty, które są bliskie zapytaniu pod względem znaczenia, ale błędne pod względem kontekstu – zawierają właściwe słowa, ale w niewłaściwym znaczeniu. Model następnie odpowiada z pewnością siebie na podstawie przestarzałych, sprzecznych informacji lub brakuujących szczegółów, które znajdowały się trzy zdania za granicą danego fragmentu. Najgorsze jest to, że wynik wydaje się poprawny, a nic nie wskazuje na to, że proces wyszukiwania nie przebiegł pomyślnie.
Demo RAG jest łatwe do stworzenia, natomiast wersja, która będzie poprawnie funkcjonować na rzeczywistym korpusie z prawdziwymi użytkownikami, nie jest taka prosta. Różnica wynika z trzech czynników: sposobu dzielenia dokumentów na fragmenty, sposobu, w jaki embeddingi radzą sobie z niuansami, oraz sposobu, w jaki system wyszukiwania określa, co uznaje za istotne. Każdy z tych elementów ma sposoby awarii, które łatwo przeoczyć, a ich naprawa po uruchomieniu jest kosztowna.
Dzielenie na fragmenty – gdzie informacje uciekają
Domyślnym rozwiązaniem w instrukcjach jest rekurencyjne dzielenie tekstu na znaki o ustalonej długości z pewnym nakładaniem się.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
chunks = splitter.split_documents(documents)
To wystarczy do demonstracji. Podejście to zawodzi, gdy struktura dokumentu niesie znaczenie, którego liczniki znaków nie uwzględniają, i to na trzy powszechne sposoby.
- Rozdzielony kontekst. Specyfikacja może stanowić, że maksymalna obsługiwana wartość to 500, a ograniczenie to obowiązuje tylko w trybie wysokiej dostępności przy aktywnej konfiguracji awaryjnego przełączenia. Przy limicie 1 000 znaków liczba ta może znaleźć się w jednym fragmencie, a warunek w następnym. Osoba pytająca o ograniczenie związane z wysoką dostępnością otrzymuje fragment z wartością 500 bez żadnych uzupełnień. Odpowiedź istnieje w korpusie, ale podział na fragmenty przerwał tę zależność.
- Samotne nagłówki. Nagłówek taki jak „Konfiguracja bezpieczeństwa” oraz pierwszy akapit poniżej niego, w którym stwierdza się, że do każdego połączenia wymagany jest TLS, mogą znaleźć się w różnych fragmentach. Narzędzie wyszukiwawcze, które znajdzie akapit, nie ma kontekstu nagłówka, a umieszczenie zdania nie wskazuje, że należy ono do ustawień bezpieczeństwa.
- Zepsute tabele i listy. Narzędzia dzielące tekst o stałej długości często przecinają tabelę w środku wiersza lub listę w środku pozycji. Nagłówek kolumny w jednym fragmencie w połączeniu z danymi w innym sprawia, że żaden z nich nie jest czytelny samodzielnie.
Fragmentowanie na poziomie produkcyjnym opiera się zazwyczaj na trzech technikach.
Dzielenie z uwzględnieniem struktury polega na dzieleniu tekstu przy naturalnych granicach dokumentu, czyli przy nagłówkach, akapitach i sekcjach, zamiast na podstawie liczby znaków. Markdown można dzielić przy nagłówkach drugiego lub trzeciego poziomu, a HTML przy jego tagach nagłówków, akapitów lub sekcji.
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"}
Wzbogacanie kontekstu nadrzędnego przechowuje wraz z każdym kawałkiem ścieżkę nagłówka, która do niego prowadzi. Gdy kawałek jest pobierany, prompt otrzymuje tytuły sekcji i podsekcji obok tekstu, co zapewnia modelowi strukturę, której brakuje w przestrzeni embeddingów.
Dzielenie na kawałki w formie nadrzędny-podporządkowany zachowuje małe kawałki dla większej precyzji pobierania oraz większe kawałki nadrzędne, które je zawierają. Proces pobierania dopasowuje się do małego fragmentu, a prompt otrzymuje kawałek nadrzędny. Małe kawałki umożliwiają dokładniejsze uwzględnienie konkretnych idei, natomiast kawałki nadrzędne dostarczają otaczających je informacji.
# 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
"""
Metoda ta wymaga większych nakładów na wdrożenie i przechowywanie, ale dostarcza znacznie lepszych odpowiedzi na pytania wymagające kontekstu otoczenia, który obejmuje większość rzeczywistych zapytań.
Embeddingi i ograniczenia podobieństwa
Embeddingi umieszczają tekst w przestrzeni wektorowej, gdzie podobne znaczenia znajdują się blisko siebie, a system wyszukiwania zwraca fragmenty najbliższe zapytaniu. Działa to wtedy, gdy semantyczna bliskość jest odpowiednim zamiennikiem istotności. Przestaje działać, gdy te dwie koncepcje się różnią.
- Negacja. Zdania mówiące, że system nie obsługuje IPv6, oraz te, że obsługuje IPv6, otrzymują niemal identyczne wektory, ponieważ oba dotyczą obsługi IPv6. Negacja ledwo wpływa na wektor. Pytanie dotyczące IPv6 może spowodować wyświetlenie obu fragmentów lub niewłaściwego, a model musi pogodzić sprzeczność lub udzielić odpowiedzi na podstawie niewłaściwego fragmentu, nie zauważając tego.
- Niespójność słownictwa. Użytkownik pisze „login”, podczas gdy w dokumentacji mowa jest o „autoryzacji” i „logowaniu”. Zazwyczaj są one na tyle blisko siebie, że wyszukiwanie działa poprawnie, ale pojawiają się wyjątki, gdy żargon używany przez użytkownika różni się od terminologii stosowanej w dokumentacji.
- Specyficzność. Pytanie dotyczące konfiguracji zbiornika połączeń i pytanie o domyślny czas timeoutu znajdują się bardzo blisko siebie, więc oba zwracają ten sam zbiór informacji. Gdy wartość timeoutu znajduje się w jednym akapicie wśród kilku opisujących zbiorniki połączeń, najlepsza informacja nie zawsze jest umieszczana na pierwszym miejscu.
Trzy rozwiązania eliminują te ograniczenia.
Poszukiwanie hybrydowe łączy intensywne wyszukiwanie, które uwzględnia znaczenie, z rzadkim wyszukiwaniem, takim jak BM25 lub TF-IDF, które uwzględnia dokładne terminy. Zapytania dotyczące numeru wersji, klucza konfiguracyjnego lub kodu błędu lepiej radzą sobie dzięki dopasowaniu słów kluczowych niż dzięki podobieństwu semantycznemu. Wiele baz danych wektorowych oferuje obecnie poszukiwanie hybrydowe w sposób wbudowany, a poniższy przykład wykorzystuje połączenie metod rzadkich i intensywnych w 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,
)
Rozszerzanie zapytania wzbogaca pytanie przed jego włączeniem do struktury. Zapytanie dotyczące autoryzacji może zostać rozszerzone o terminy takie jak login, sign-in, auth oraz weryfikacja tożsamości, co umieszcza je w bogatszej części przestrzeni znaczeniowej. Małe wezwanie modelu LLM może wygenerować różne warianty, natomiast statyczna mapa synonimów może obejmować terminy specyficzne dla danej dziedziny.
Wybór modelu do embeddingów również zasługuje na uwagę. Wiele zespołów korzysta domyślnie z ogólnych modeli OpenAI, takich jak text-embedding-ada-002 lub text-embedding-3-small. W przypadku korpusów medycznych, prawniczych lub technicznych model specyficzny dla danej dziedziny lub dostosowany pod konkretne potrzeby często daje znacznie lepsze wyniki. Gdy efektywność wyszukiwania pozostaje niska pomimo dobrego podziału tekstu na fragmenty, warto porównać różne opcje na reprezentatywnym zbiorze zapytań.
Problemy z wyszukiwaniem, które pojawiają się w dużych skali
Podział na fragmenty i embeddingi decydują o tym, czy treść może zostać znaleziona, natomiast proces wyszukiwania określa, czy zostanie ona odnaleziona dla danego zapytania. Niektóre problemy pojawiają się tylko przy dużym korpusie lub zróżnicowanych zapytaniach.
Wybór wartości K. Większość systemów pobiera najlepsze K fragmentów na podstawie podobieństwa i umieszcza je wszystkie w wiadomości wejściowej. Mała wartość K powoduje pominięcie faktów, gdy odpowiedź wymaga wsparcia z kilku fragmentów. Duża wartość K wypełnia okno kontekstowe marginalnymi fragmentami, które osłabiają istotne sygnały, a w długich wiadomościach mogą sprawić, że naprawdę istotny fragment znajdzie się poza zasięgiem efektywnej uwagi modelu.
Praktycznym podejściem jest pobranie większej liczby wyników, na przykład pierwszych 20 lub 30 kandydatów, a następnie ich ponowne posortowanie. Cross-encoder łączy zapytanie z danymi w fragmenty i bezpośrednio ocenia ich powiązanie, zamiast porównywać dwa niezależnie obliczone embeddingi. Jest to wolniejsze, ponieważ embeddingi fragmentów nie mogą być obliczone z góry, ale znacznie lepiej odróżnia treści istotne od tych prawie istotnych. Szybkie aproksymacyjne wyszukiwanie zawęża zakres wyników, a cross-encoder wybiera ostateczną listę.
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]
Zastarzenie. Magazyn wektorowy wdrożony sześć miesięcy temu przechowuje dokumenty, które od tamtej pory uległy zmianie, a sam system nie ma o tym pojęcia. Proces wyszukiwania z dużym prawdopodobieństwem zwraca przestarzałe fragmenty, na podstawie których model udziela odpowiedzi. Rozwiązaniem jest filtrowanie metadanych: należy przechowywać datę ostatniej modyfikacji każdego fragmentu i filtrować je według przedziału czasowego przed lub po wyszukiwaniu pod kątem podobieństwa. W przypadku aktywnie utrzymywanej dokumentacji fragmenty starsze niż wybrana granica są odrzucane lub otrzymują niższy ranking.
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
)
Fałszywa pewność siebie. Model odpowiada na podstawie tego, co otrzymał, nawet gdy uzyskane fragmenty nie mają związku z pytaniem. Nie wie, że jakość pozyskiwanych danych była niska. To najbardziej niszczy zaufanie użytkowników, ponieważ odpowiedź wydaje się autorytatywna, mimo że jest błędna. Rozwiązaniem jest zastosowanie bramy relewantności przed generowaniem odpowiedzi. Można albo ocenić uzyskane fragmenty i powstrzymać generowanie, gdy najlepsza ocena spadnie poniżej określonego progu, albo poinstruować model w promptzie systemowym, aby stwierdził brak wystarczających informacji, a następnie sprawdzić, czy prawidłowo korzysta z tej możliwości uniknięcia błędu.
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.
"""
Ocena – krok, który zespoły pomijają
System RAG bez oceny ma nieznaną jakość. Może dobrze radzić sobie z niektórymi zapytaniami, a źle z innymi, a jedynym sposobem na ustalenie tego jest skarga użytkownika. Ocena składa się z trzech części.
Jakość pozyskiwania informacji. Dla zapytań testowych z znanych poprawnych dokumentów źródłowych sprawdza się, jak często właściwy dokument pojawia się wśród pierwszych K wyników. Wskaźnik召回 mierzy, czy właściwy fragment znajduje się w pierwszych pięciu wynikach, natomiast średnia rangowa odwrotna pokazuje, na jak wysokim miejscu się znajduje. Przed uruchomieniem należy przeprowadzić test z 100 do 200 reprezentatywnych zapytań.
Wierność odpowiedzi. Czy generowana odpowiedź odzwierciedla pozyskany kontekst, bez dodawania twierdzeń, których fragmenty nie zawierają? Rozwiązanie z LLM jako sędzią może to ocenić, otrzymując zapytanie, pozyskane fragmenty oraz odpowiedź, i oceniając, na ile dobrze kontekst wspiera tę odpowiedź.
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}")
Prawidłowość od początku do końca. Czy system przy zapytaniach o znane odpowiedzi dostarcza właściwą? To najtrudniejsza do zautomatyzowania, a zarazem najważniejsza kwestia. Nawet ręcznie przygotowana seria 50 pytań z zweryfikowanymi odpowiedziami, wykonywana przed i po każdej zmianie w mechanizmach dzielenia na fragmenty, embeddingach lub ustawieniach wyszukiwania, pozwala wykryć regresje.
Zbiór do oceny funkcjonuje jako zestaw testów regresji. Nowy model embeddingów, inna wielkość fragmentów lub zmodyfikowany prompt systemu powinny każda powodować mierzalną zmianę w wynikach. Gdy zmiana poprawia średnią wierność, ale obniża stopień odzyskiwania informacji, lub odwrotnie, taka kompromisowa decyzja musi być świadoma, a nie wynikiem cichego wdrożenia.
Cały proces to produkt
Proces szkoleniowy składa się z czterech kroków i nie zawiera mechanizmu obsługi błędów. Proces produkcyjny ma sześć kroków, w każdym z nich znajduje się detekcja awarii, przez cały czas dołączane są metadane, a przed każdym wydaniem przeprowadzana jest ocena.
Awarie są przewidywalne. Podział na fragmenty bez uwzględnienia struktury powoduje utratę kontekstu na brzegach. Metody wyszukiwania oparte wyłącznie na embeddingach pomijają dokładne dopasowania i słabo radzą sobie z negacjami. Wyszukiwanie bez ponownego sortowania zwraca rozrzedzony zestaw najlepszych wyników, który przytłacza najlepsze fragmenty. Generowanie bez mechanizmu weryfikacji trafności dostarcza pewnych siebie, błędnych odpowiedzi na podstawie słabego kontekstu.
Żaden z tych problemów nie jest nierozwiązywalny, a każde rozwiązanie ma określoną cenę. Podział na fragmenty z uwzględnieniem struktury wymaga zrozumienia formatu dokumentu. Wyszukiwanie hybrydowe oznacza używanie zarówno indeksów gęstych, jak i rzadkich. Ponowne sortowanie zwiększa opóźnienie w procesie wyszukiwania. Ocena wymaga utworzenia i utrzymywania zestawu testowego.
Zespoły, których systemy RAG dobrze funkcjonują, podejmują te decyzje celowo przed uruchomieniem, a nie w reakcji na błędne odpowiedzi. Turytoria pomijają trudniejsze elementy, ponieważ demo ich nie wymaga, natomiast godny zaufania system tak.