Strona główna / Artykuły

RAG z LangChain od zera: narzędzia do ładowania, fragmenty danych, embeddingi, ocena.

Buduj system Q&A dla dokumentów krok po kroku: ładowanie plików, dzielenie na fragmenty, embeddingi, fazy indeksowania i wyszukiwania oraz sposoby oceny wyników wyszukiwania i odpowiedzi.

Wydawca: REACTAPP.TOP8 min czytania1939 słów

Źródła: Building a RAG System with LangChain

System, który czyta twoje dokumenty i odpowiada na pytania dotyczące ich treści, to jedna z najprzydatniejszych rzeczy, jakie możesz dziś stworzyć za pomocą modeli LLM. Ten mechanizm nazywa się RAG, czyli retrieval-augmented generation. Poniżej znajdziesz opis wszystkich jego elementów, ich przeznaczenia oraz sposoby sprawdzenia, czy twoje rozwiązanie jest skuteczne.

Czym jest LangChain

LangChain nie jest modelem ani bazą danych. To framework, bliższy uniwersalnemu adapterowi. Istnieje dziesiątki dostawców modeli LLM z własnymi API, dziesiątki baz danych wektorowych oraz dziesiątki narzędzi do ładowania dokumentów. Bez takiej warstwy pośredniej korzystanie dziś z GPT-4, a jutro z Claude oznaczałoby przepisanie połowy kodu. LangChain zapewnia spójną interfejs dla wszystkich tych narzędzi.

Nazwa pochodzi od jego drugiej funkcji. Prawdziwa aplikacja oparta na LLM rzadko polega wyłącznie na jednym wezwaniu modelu. Można załadować dokument, stworzyć jego streszczenie, przeprowadzić kolejne zapytanie w oparciu o to streszczenie i sformatować wynik. Łańcuch połączeń łączy te kroki w spójny proces. W przypadku RAG Łańcuch zaczyna się od pytania użytkownika. To pytanie jest embedowane, a uzyskany embedding służy do wyszukiwania w bazie danych; najlepsze wyniki są następnie umieszczane w szablonie zapytania. LLM jest wtedy wywoływany z tym zapytaniem, jego odpowiedź jest analizowana, a uzyskana informacja wraca do użytkownika. LangChain dostarcza elementy niezbędne do realizacji każdego kroku, dzięki czemu nie trzeba ręcznie łączyć wszystkich komponentów.

Ładowanie dokumentów

Najpierw dokumenty muszą zostać dostarczone, i to nie w postaci surowych bajtów. Niezależnie od tego, czy dane to plik PDF, strona internetowa, plik CSV, plik Word czy folder z plikami markdown, narzędzie ładowania przekształca je w standardowy obiekt Document składający się z dwóch pól: page_content dla tekstu oraz metadata informującego o źródle (strona, URL, wszelkie przydatne dane). Metadane są ważne później, ponieważ umożliwiają pokazanie użytkownikom, z jakiego źródła pochodzi odpowiedź.

from langchain.document_loaders import PyPDFLoader

loader = PyPDFLoader("handbook.pdf")
documents = loader.load()

To daje jeden Document na stronę PDF, z którym przypisany jest jego tekst oraz numer strony. To nawyk, którego warto przyzwyczaić się od razu: zawsze należy zachować informację o źródle tekstu, ponieważ każdy kolejny krok – od dzielenia na fragmenty po cytowanie źródła w ostatecznej odpowiedzi – zależy od tej informacji. Jeśli zastąpi się plik innym typem, wyjście będzie miało taką samą strukturę.

Dzielenie na fragmenty

Dokument może składać się z 200 stron PDF. Nawet przy dużym oknie kontekstowym wkładanie całych książek do każdego zapytania jest marnotrawstwem zasobów, kosztowne i często gorsze niż dostarczanie modelowi skoncentrowanych fragmentów. Długie dokumenty dzieli się na części liczące kilkaset tokenów, a do modelu LLM trafiają tylko najbardziej istotne fragmenty, gdy ktoś zadaje pytanie.

Najważniejsze są twoje ustawienia. Rozmiar fragmentu określa długość każdego kawałka: jeśli jest zbyt mały, tracisz kontekst, a jeśli zbyt duży, dodajesz nieistotny tekst. Około 500 tokenów to rozsądny punkt wyjścia. Przeplatanie odnosi się do tego, jak wiele informacji dzielą ze sobą sąsiednie fragmenty. Bez przeplatania zdanie rozdzielone na dwa fragmenty traci swój sens, natomiast przy przeplataniu oba fragmenty zachowują spójność.

RecursiveCharacterTextSplitter to najczęściej wybierana opcja. Preferuje naturalne granice, najpierw rozważa akapity, potem zdania, a następnie słowa, i tylko w ostateczności dzieli tekst w środku słowa. Istnieją również specjalistyczne opcje: MarkdownHeaderTextSplitter do dokumentów z nagłówkami oraz TokenTextSplitter, gdy chcesz liczyć rzeczywiste tokeny, a nie litery.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50
)
chunks = splitter.split_documents(documents)

Rozbijanie tekstu na fragmenty wydaje się błahe, ale ma ogromny wpływ na jakość. To także ustawienie, które ludzie dostosowują najpóźniej, mimo że zła struktura rozbiórki w cichy sposób ogranicza wszystko, co następuje później. Jeśli fragmenty są błędne, żaden trik nie uratuje systemu.

Embeddingi

Komputery nie mogą zapytać „który fragment przypomina moje pytanie?” w odniesieniu do surowego tekstu. Model embeddingowy, nazywany także kodownikiem, przekształca tekst w wektor, czyli listę liczb, a kluczową cechą jest to, że teksty o podobnym znaczeniu uzyskują podobne wektory.

Wyobraź sobie, że każdy tekst otrzymuje adres w przestrzeni składającej się z setek lub tysięcy wymiarów. Pies szczekający i szczeniak piszczący mają adresy sąsiednie, podczas gdy kryzys na giełdzie znajduje się gdzieś daleko. Porównywanie znaczeń zamienia się w geometrię: jak bliskie są dwa punkty?

Dwie twoje wyjaśnienia są pomocne. Po pierwsze, wektory tworzy kodownik, a nie baza danych wektorowych. Chroma, Pinecone, Weaviate i Qdrant jedynie przechowują wektory i szybko je wyszukują, przy czym żaden z nich nie rozumie języka. Ludzie ciągle to mylą. Po drugie, rozmiar wektora zależy od kodownika – model text-embedding-3-small firmy OpenAI generuje wektory o 1536 wymiarach, podczas gdy model otwartego oprogramowania taki jak all-MiniLM-L6-v2 tworzy wektory o 384 wymiarach. Więcej wymiarów pozwala na uchwycenie większej ilości niuansów, ale kosztem większego zużycia pamięci i wolniejszych zapytań.

Prawdziwe embeddingi są trudne do wyobrażenia, dlatego ludzie przedstawiają je w formie 2D lub 3D. Gdy umieścimy zbiór słów w takiej przestrzeni i ją zmapujemy, pojawiają się klastry: zwierzęta razem, owoce gdzie indziej, pojazdy w innym rogu. Nikt nie poinformował modelu o tych kategoriach – on nauczył się na podstawie ogromnych ilości tekstu, że „pies” i „kot” pojawiają się w podobnych kontekstach.

To właśnie sprawia, że RAG funkcjonuje. Jeśli użytkownik pyta, co firma mówi o urlopie rodzicielskim, wektor pytania trafia w pobliże fragmentów dotyczących „korzyści z urlopu rodzinnego” lub „urlopu dla nowych rodziców”, chociaż te sformułowania się różnią.

Proces przetwarzania

Istnieją dwie fazy. Indeksowanie odbywa się raz podczas konfiguracji: ładowanie danych, dzielenie ich na fragmenty, wstawianie każdego fragmentu oraz przechowywanie wektorów. Nowe dokumenty są później po prostu wstawiane i dodawane. Wyszukiwanie odbywa się przy każdym pytaniu: pytanie jest przekształcane za pomocą tego samego kodera, znajdują się najbliższe fragmenty w bazie danych wektorowej, które następnie są umieszczane w wiadomości razem z pytaniem i wysyłane do modelu językowego, który opiera swoją odpowiedź na Twoich dokumentach, a nie na domysłach. Jedna baza danych wektorowych służy obu fazom – do zapisu podczas indeksowania, a do odczytu podczas wyszukiwania. To cała architektura. Ponieważ indeksowanie jest kosztem jednorazowym, wolna część procesu (wstawianie tysięcy fragmentów) nie wpływa na czas oczekiwania użytkownika. Wpływają na niego jedynie przekształcenie zapytania, wyszukiwanie oraz wywołanie modelu językowego.

W trakcie działania bierze udział czterech elementów: użytkownik, kod twojej aplikacji, mechanizm wyszukiwania oraz model językowy. Użytkownik wysyła pytanie do twojego kodu. Twój kod prosi mechanizm wyszukiwania o odpowiednie fragmenty danych, a ten włącza pytanie do zapytania i przeszukuje bazę danych. Zwracane są najbardziej podobne fragmenty z najwyższym rankingiem. Twój kod tworzy prompt na podstawie tych fragmentów wraz z oryginalnym pytaniem i wysyła go do modelu językowego, który generuje odpowiedź na podstawie dostarczonego kontekstu. Twój kod zwraca tę odpowiedź.

LangChain obejmuje większość tych procesów za pomocą dwóch abstrakcji. LLM (lub ChatModel) otacza twój model, natomiast Retriever obsługuje wyszukiwanie w bazie wektorowej. Połącz je ze sobą, a framework zajmie się resztą:

from langchain.chains import RetrievalQA

qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(),
    retriever=vectorstore.as_retriever(),
    return_source_documents=True
)
response = qa_chain.invoke({"query": "What is our parental leave policy?"})

To jest działający system RAG w zaledwie kilku liniach kodu, i właśnie w tym momencie napotyka się pokusę zatrzymania się. Nie rób tego. System, który potrafi odpowiadać, to jeszcze nie system, któremu można ufać.

Dlaczego oceniać

Pułapką jest zadanie jednego pytania, otrzymanie wiarygodnej odpowiedzi i uznanie tego za koniec. System RAG zawodzi w subtelny sposób – mechanizm wyszukiwania może pobrać niewłaściwe fragmenty, model LLM może tworzyć halucynacje nawet przy dobrym kontekście, a dzielenie tekstu na fragmenty może przeciąć odpowiedź na pół. Ocena pokazuje, która część jest uszkodzona: proces wyszukiwania, generowanie lub obie, ponieważ każda z nich wymaga innego rozwiązania. Daje również miarę postępów, ponieważ będziesz testować różne rozmiary fragmentów, kodery i prompty, a bez pomiarów każda zmiana jest tylko domysłem.

Metryki wyszukiwania

Cztery standardowe metryki, z których każda informuje o czymś innym:

  • Recall@K: spośród dokumentów, które powinny się pojawić, ile znalazło się w pierwszych K pozycjach? Przy 4 istotnych dokumentach w korpusie, z których 3 znajdują się w pierwszych 5, Recall@5 wynosi 0,75. Wysoki recall oznacza, że niewiele zostało przeoczone.
  • Precision@K: spośród K zwróconych wyników, ile z nich było istotnych? Trzy trafne odpowiedzi na pięć dają wynik 0,60. Wysoka precyzja oznacza mało błędów.
  • MRR (Mean Reciprocal Rank): liczy się tylko pozycja pierwszej prawidłowej odpowiedzi. Pierwszy trafny wynik na pozycji 2 otrzymuje ocenę 0,50, a na pozycji 1 – 1,0. Nagradza szybkie znalezienie prawidłowej odpowiedzi.
  • nDCG (Normalized Discounted Cumulative Gain): jest to najbardziej zaawansowana metryka. Bierze pod uwagę stopień istotności każdego dokumentu oraz jego pozycję, dzięki czemu bardziej wysoko uplasowane, istotne elementy mają większą wagę; porównuje się ją również z najlepszym możliwym rankingiem.

W praktyce istnieją dwa typy ścieżek: jedna służy kompletności (Recall), a druga jakości uporządkowania (MRR lub nDCG).

Jakość odpowiedzi

Nawet doskonałe odnalezienie informacji może przynieść złą odpowiedź, dlatego ocenia się je oddzielnie. Zaczyna się od tego samego dobrze przygotowanego zestawu testowego, w którym każda pozycja łączy pytanie z fragmentami tekstu, które powinny zostać przywrócone, oraz odpowiedzią referencyjną. Cztery wymienione powyżej metryki odnajdywania obejmują pierwszą połowę oceny. Jeśli chodzi o same odpowiedzi, ocenia się je według innych kryteriów:

  • Wierność: czy opiera się na odnalezionym kontekście, czy jest wymyślona?
  • Relevancja: czy odpowiada na pytanie?
  • Prawidłowość: jak porównuje się z prawdą obiektywną?
  • Kompletność: czy obejmuje wszystko, co powinna?

Zwykłą metodą jest użycie drugiego LLM jako sędziego. Podaj mu pytanie, odpowiedź oraz prawidłową odpowiedź i poproś o ocenę w każdej z kategorii. Nie jest to doskonałe rozwiązanie, ale pozwala na skalowanie.

Zbiór testowy

Każdy z powyższych kroków oceny opiera się na jednej rzeczy: zbiorze pytań z znanych, poprawnych odpowiedzi.

To najmniej doceniany element. Bez niego nic nie może zostać ocenione. Celuj w 50 do kilkuset przykładów, z których każdy zawiera realistyczne pytanie, fragmenty zawierające odpowiedź oraz idealną reakcję. Źródłami mogą być rzeczywiste zapytania użytkowników, jeśli system jest aktywny, lub te syntetyczne: podaj swoje dokumenty do LLM, niech stworzy pary pytań i odpowiedzi, a następnie przejrzy je i dopracuje.

Broń tego zbioru. Gdy już istnieje, każda zmiana jest oceniana w jego oparciu, i właśnie w ten sposób dowiadujemy się, czy zmiana była pomocna.

Gdzie robi się trudniej

W skrócie RAG polega na dzieleniu dokumentów na fragmenty, wektorowaniu każdego z nich, przechowywaniu tych wektorów, włączaniu zapytań użytkowników, znajdowaniu najbliższych wektorów i przekazywaniu tych fragmentów modelowi LLM. LangChain dostarcza standardowe elementy dla każdego kroku, a oceny pokazują, jak dobrze to działa. Prawdziwy produkt rodzi trudniejsze pytania: jaka powinna być odpowiednia wielkość fragmentów dla dokumentów, który kodownik jest najlepszy, jak utrzymać indeks aktualny, kiedy przeprowadzić ponowną sortację, czy używać wyszukiwania hybrydowego. To decyzje odróżniają prostą demonstrację od czegoś przydatnego, ale opierają się one na powyższych podstawach.