Obecnie RAG czasem cieszy się złą opinią: jedni uważają go za zupełnie banalny (łatwo zacząć, trudniej skalować), a inni sądzą, że wyparły go „systemy agentowe” (które po bliższym przyjrzeniu się często bardzo szybko zaczynają przypominać RAG…).
W tym artykule przedstawiamy kilka praktycznych przykładów pokazujących, jak radzimy sobie z typowymi wyzwaniami, takimi jak:
Obsługa mieszanych danych tekstowych i liczbowych oraz przyczyny, dla których zawodzą przy nich proste rozwiązania RAG: słowa kluczowe się powtarzają, a liczby nie niosą znaczenia semantycznego.
Dlaczego warto projektować osadzenia oparte na podsumowaniach: dla każdego fragmentu generujemy krótki opis, a następnie osadzamy i przeszukujemy właśnie to podsumowanie.
Jak generować podsumowania kontekstowe: uwzględniamy kontekst dokumentu nadrzędnego, aby rozróżniać podobnie wyglądające statystyki.
Kiedy polegać na kodzie i modelach Pydantic: gdy liczy się dosłowne zachowanie treści, łączymy własny kod lub model Pydantic z wywołaniami LLM, aby zapewnić niezawodność.
Podstawy
Systemy RAG napędzają różne rozwiązania — od botów obsługi klienta po wewnętrznych asystentów wiedzy.
Zwykle działają one w następujący sposób:
Dzielisz dokumenty źródłowe na fragmenty
Osadzasz każdy fragment w przestrzeni wektorowej
Wyszukujesz K najlepiej dopasowanych fragmentów podczas przetwarzania zapytania
Generujesz odpowiedź na podstawie tych fragmentów
Popularne zestawy narzędzi — LangChain, LlamaIndex i Filestore od OpenAI — sprawiają, że te kroki stają się niemal banalne. W rzeczywistych potokach napotkasz jednak dane, które nie są wyłącznie zwartym tekstem, a podstawowy RAG może sobie z nimi nie radzić. W kolejnych sekcjach pokażemy konkretne przykłady problematycznych danych i będziemy stopniowo rozwijać rozwiązanie wraz ze wzrostem złożoności.
Gdy dane nie są wyłącznie tekstem (co zdarza się całkiem często)
Rozważmy poniższy fragment danych dotyczących gry:
JSON
Osadzenia działają dzięki wyuczonym zależnościom między słowami, wynikającym z ich znaczenia semantycznego i gramatyki. Powyższe dane stanowią połączenie tekstu i liczb, przy czym poza tym konkretnym kontekstem liczby nie mają związku ze słowami. Można więc powiedzieć, że ten fragment danych to w zasadzie zestaw w miarę opisowych słów, po których następują przypadkowe liczby.
Nie byłoby to problemem, gdybyśmy mieli tylko takie dane, ponieważ nadal moglibyśmy je wyszukiwać na podstawie osadzeń kilku dostępnych słów opisowych (albo po prostu użyć rozwiązania text-to-SQL). Co jednak, jeśli ten fragment ginie wśród wielu fragmentów zwartego tekstu, w których również występują te słowa? Na przykład:
JSON
Wyobraźmy sobie, że chcemy wyszukać odpowiedź na pytanie „What is the attack range with Draconic Ascension?”Najprawdopodobniej nie uda nam się znaleźć właściwego fragmentu, ponieważ zginie on w szumie innych fragmentów zawierających te same słowa kluczowe.
Sedno problemu polega na tym, że nie potrafimy dobrze rozróżnić tych fragmentów danych, choć zawierają one różne rodzaje informacji na ten sam temat. Czy możemy jakoś wzbogacić lub ulepszyć te dane? Oczywiście, że tak :smile:
Wzbogać dane, podsumowując je — tak, dobrze czytasz
Zamiast bezpośrednio osadzać sam fragment, możemy najpierw wygenerować podsumowanie opisujące dane, a następnie osadzić i przeszukiwać właśnie to podsumowanie. Na etapie generowania nadal korzystamy z oryginalnych danych powiązanych z podsumowaniem.
Dla dwóch przedstawionych wyżej fragmentów wygenerowalibyśmy więc podsumowania w rodzaju:
Statystyki zasięgu, szybkości i obrażeń ataku (domyślne oraz z Draconic Ascension).
Opis i szczegóły Draconic Ascension, w tym warunki aktywacji, efekty wizualne i fabuła.
Następnie rozszerzamy zapytanie, aby „dopasować” je do podsumowania. Na przykład pytanie „What is the attack range with Draconic Ascension?”zmienilibyśmy na „What are the statistics of attack range with Draconic Ascension?”Jest to szczególnie ważne, gdy zapytania wyszukiwawcze pochodzą od użytkowników spoza branży technicznej, którzy posługują się ~~„swobodnym”~~ zwykłym, naturalnym językiem. Nie muszą przecież wiedzieć ani przejmować się tym, jak działa RAG i jak maksymalizuje precyzję oraz pełność wyników.


Nie wyrywaj niczego z kontekstu (to ogólnie dobra zasada życiowa)
Kolejny scenariusz dotyczy obsługi mnóstwa fragmentów danych, które wyglądają tak samo, jak poniżej:
Plain Text
Trzymając się tego samego podejścia, wyobraźmy sobie pytanie „what is character X’s attack range?”Przy wygenerowanych właśnie podsumowaniach odpowiedź byłaby loterią, ponieważ one również wyglądałyby bardzo podobnie. Jak możemy je zatem rozróżnić?
Odpowiedź jest prosta: należy dodać kontekst. Możemy po prostu dodać do fragmentu danych odwołanie do dokumentu nadrzędnego, w tym przypadku np. {„postać”: „X”}. Dzięki temu możemy precyzyjnie wyszukać właściwe dane postaci X, nawet jeśli mamy takie same dane dla postaci Y i Z.
Lepszym i bardziej uniwersalnym rozwiązaniem byłoby jednak wygenerowanie kontekstowego podsumowania fragmentu. Innymi słowy, zamiast generować podsumowanie wyłącznie samego fragmentu danych, możemy przekazać zarówno jego dokument nadrzędny, jak i sam fragment, aby utworzyć ogólne podsumowanie kontekstowe. Wyjaśniamy w nim, jak dany fragment wpisuje się w dokument nadrzędny, na przykład:
Ten fragment zawiera szczegółowe statystyki… postaci X. Fragment wpisuje się w cały dokument, pokazując przewagę X pod względem szybkości ataku…
Ten fragment zawiera szczegółowe statystyki… postaci Y. Fragment wpisuje się w cały dokument, pokazując zwiększone statystyki Y po użyciu jej specjalnej zdolności…
Ten fragment zawiera szczegółowe statystyki… postaci Z. Fragment wpisuje się w cały dokument, pokazując statystyki Z, dzięki którym świetnie sprawdza się jako tank w rozgrywkach drużynowych…
Ta metoda (częściowo inspirowana rozwiązaniem Anthropic) może wydawać się przesadą w powyższym przykładzie, ale jest bardzo skuteczna w przypadku fragmentów, które można błędnie zinterpretować „poza kontekstem”. Zapewnia też jednolite podejście do wszystkich fragmentów i pozwala zachować uporządkowany potok inżynieryjny.


Gdy trzeba być ~~maniakiem kontroli~~ skrupulatnym
Zwykle otrzymujemy kompletne dane i dzielimy je na fragmenty na potrzeby systemu RAG. W tym przykładzie pokazujemy coś nieco innego: dane podzielone na fragmenty, ale niewłaściwie. Są to przypadkowe części logicznej całości, które trzeba ponownie połączyć. Fragment logiczny to część treści, która z natury powinna tworzyć całość, na przykład podsekcja dokumentu lub spójny akapit.


Najpierw przekazaliśmy wszystkie te dane do LLM i poprosiliśmy go, aby pogrupował je według własnej oceny, a następnie zwrócił połączoną treść. LLM powinien być w tym całkiem dobry, prawda? I tak, i nie.
W tym i kilku innych przypadkach odkryliśmy, że LLM mają tendencję do chodzenia na skróty i stają się zawodne, gdy wymagamy pełnej, dokładnej treści — zwłaszcza przy długim kontekście. Co jest całkowicie zrozumiałe. W tym konkretnym zastosowaniu było to jednak nie do przyjęcia, ponieważ potrzebowaliśmy dokładnej treści słowo w słowo — bez podsumowań i pomijania jakichkolwiek fragmentów oryginału. Nie możemy pominąć żadnych szczegółów.
Odpowiedź „tak” wynikała oczywiście z tego, że LLM świetnie rozumiał semantykę i strukturę uszkodzonych fragmentów. O ile tylko nie odmawiał dokładnego przytoczenia treści. A niech to :/
Jak zatem wykorzystać mocne strony LLM, a jednocześnie uniknąć obszarów, w których jest zawodny? Zwróciliśmy się do naszego starego, dobrego przyjaciela — kodu (czytaj: niestandardowej funkcji w Pythonie). Oraz maksymalnie prostego modelu Pydantic. Oto rozwiązanie:
Przechodź kolejno przez sekcje, zachowując bieżący fragment logiczny
Przy każdej sekcji zapytaj LLM: „Czy ta sekcja należy do bieżącego fragmentu logicznego?”. Odpowiedź ma brzmieć „tak” lub „nie” zgodnie z modelem Pydantic.
Jeśli tak, dołącz sekcję do fragmentu. Jeśli nie, zapisz ukończony bieżący fragment logiczny i rozpocznij nowy od tej sekcji.


Oczywiście zużywamy tu nieco więcej tokenów niż przy jednokrotnym przetworzeniu całej treści. Jednak w tym zastosowaniu, gdzie najważniejsze było dokładne zachowanie treści, zdecydowanie warto było ponieść ten niewielki dodatkowy koszt.
To bardzo proste rozwiązanie, ale opiera się na ważnej zasadzie: gdy potrzebna jest skrupulatność, nie należy polegać wyłącznie na LLM, ponieważ z natury działają probabilistycznie.
Własny kod i funkcje oraz modele Pydantic pozwalają uzyskać przewidywalne, niezawodne wyniki, a zarazem w pełni wykorzystać możliwości LLM.
Tworzenie rozwiązania opartego na generatywnej AI jest w równym stopniu wyzwaniem inżynieryjnym, co wyzwaniem z dziedziny AI. Mamy nadzieję, że te przykłady zainspirują Cię do zmierzenia się z własnymi, nietypowymi wyzwaniami. Więcej informacji o projektowaniu rozwiązań wykorzystujących generatywną AI znajdziesz w naszym artykule o projektowaniu systemów agentowych opartych na routerach.