Strona główna / Artykuły

Budowa systemu pytań i odpowiedzi z cytowanymi źródłami na podstawie plików PDF banku centralnego przy użyciu RAG

Praktyczne tworzenie systemu RAG na podstawie kwartalnych raportów banku centralnego: pobieranie danych, wyszukiwanie uwzględniające aktualność, cytowane odpowiedzi oraz praktyczne błędy napotkane w trakcie pracy.

Wydawca: REACTAPP.TOP8 min czytania1827 słów

Źródła: I Learned RAG by Building a Q&A System

Większość tutoriali dotyczących RAG opiera się na uporządkowanych, sztucznie stworzonych danych. Prawdziwe dokumenty zachowują się inaczej, a różnica pomiędzy zrozumieniem techniki generacji wzbogaconej o informacje z wyszukiwania a jej faktycznym wdrożeniem pojawia się dopiero wtedy, gdy ktoś buduje cały system na bazie nieuporządkowanego korpusu tekstów. To właśnie było motywacją stworzenia bspQA – systemu do odpowiadania na pytania na podstawie kwartalnych raportów polityki pieniężnej Bangko Sentral ng Pilipinas, banku centralnego Filipin.

Korpus ten stanowi doskonały cel do nauki. Każdy raport obejmuje od 60 do 70 stron szczegółowej analizy ekonomicznej, a siedemnaście z nich pochodzi z kilku lat temu i jest dostępnych publicznie. Język użyty jest specyficzny dla danej dziedziny, istnieje wiele plików, a problem wyszukiwania jest rzeczywisty, ponieważ nikt nie może wkleić siedemnastu raportów do okna kontekstowego. Cel był skromny: zadać pytanie w stylu „Jaki był przewidywany poziom inflacji na rok 2026?” i otrzymać uzasadnioną, opartą na źródłach odpowiedź w ciągu kilku sekund, zamiast przeszukiwać pliki PDF za pomocą Ctrl+F.

Wzorzec w jednym akapicie

RAG łączy odpowiedzi modeli LLM z wybranym zestawem dokumentów, a nie z tym, co zostało przez nie przyswojone podczas szkolenia. Dokumenty są z góry przekształcane w wektorowe embeddingi. Gdy pojawia się pytanie, pobierane są najbliższe mu fragmenty, które są przekazywane modelowi jako kontekst, a model otrzymuje polecenie udzielenia odpowiedzi wyłącznie na podstawie tego kontekstu. Zaletą jest system, który unika wymyślania informacji, których dokumenty nie zawierają.

Dwa procesy, oddzielone od siebie

Projekt celowo rozdziela te dwa zadania.

  • Proces pobierania danych odbywa się offline, raz na nowy dokument. Pobiera pliki PDF, konwertuje je na tekst prosty, dzieli tekst na fragmenty, tworzy embeddingi i przechowuje wszystko w Qdrant.
  • Proces wyszukiwania działa w czasie rzeczywistym przy każdej prośbie. Embeduje pytanie, pobiera odpowiednie fragmenty i generuje odpowiedź. W tym etapie surowe pliki PDF w ogóle nie są edytowane.

Budowa procesu pobierania danych

Krok pobierania

Plik manifest.json stanowi źródło prawdy. Każdy raport ma wpis zawierający jego adres URL, etykietę w postaci kropki oraz datę publikacji. Adresy URL zostały wklejone ręcznie, co jest tak mało efektowne, jak to brzmi.

{
  "doc_id": "bsp-mpr-2023-q3",
  "title": "Monetary Policy Report Q3 2023",
  "period_label": "Q3 2023",
  "publication_date": "2023-08-17",
  "url": "https://www.bsp.gov.ph/..."
}

Program pobierający pomija pliki już znajdujące się na dysku, więc ponowne uruchomienie go nie powoduje żadnych problemów, a dodanie raportu oznacza dodanie kolejnego elementu do listy dokumentów.

Krok analizy

MarkItDown firmy Microsoft przekształca pliki PDF w tekst. W przypadku raportów politycznych zawierających dużo tekstu radzi sobie nieźle: nagłówki są zachowywane, przerwy między akapitami pojawiają się poprawnie, natomiast tabele przetrwają jedynie z trudem.

MarkItDown to również ciężka zależność, która ściąga ze sobą wiele pakietów. Ponieważ analiza odbywa się tylko podczas pobierania danych, a nigdy podczas odpowiadania na zapytania, znajduje się ona w grupie zależności opcjonalnych, dzięki czemu backend hostowany przez Render nie musi radzić sobie z tym obciążeniem. Warto jak najszybciej przyjąć tę zasadę: zdecydować, które pakiety należą do środowiska pobierania danych, a które do środowiska serwowania, ponieważ nie muszą one się pokrywać. Pomiędzy analizą a dzieleniem na fragmenty znajduje się również krok oczyszczania, a powód jego istnienia zostanie wyjaśniony poniżej.

Podział na fragmenty, embedding i aktualizacja

Tutaj zaczynają się prawdziwe decyzje. Fragmenty pochodzą z przesuwającego się okna o długości 512 tokenów z 64 tokenami nakładania się, liczonych za pomocą tiktoken.

def chunk_text_by_tokens(text, enc, chunk_size=512, overlap=64):
    tokens = enc.encode(text)
    stride = chunk_size - overlap
    chunks = []
    start = 0
    while start < len(tokens):
        window = tokens[start : start + chunk_size]
        chunks.append(enc.decode(window))
        if start + chunk_size >= len(tokens):
            break
        start += stride
    return chunks

Przeplatanie gwarantuje, że zdanie przecięte na granicy fragmentu nadal będzie widoczne w całości przynajmniej w jednym fragmencie.

Embeddingi pochodzą z modelu OpenAI text-embedding-3-small, który jest tani i sprawdzony do obsługi gęstego tekstu opisowego. Cały projekt kosztował około dziesięciu dolarów w postaci kredytów, chociaż minimalna kwota do uzupełnienia w tym czasie wynosiła pięć dolarów, a to wystarcza na realizację niewielkiego projektu.

Każdy wektor otrzymuje identyfikator UUID5 utworzony na podstawie nazwy pliku źródłowego oraz indeksu fragmentu. Dzięki temu ponowne wprowadzanie danych jest bezpieczne, ponieważ druga próba zastępuje istniejące rekordy zamiast gromadzić duplikaty.

Każdy przechowywany pakiet zawiera plik źródłowy, indeks fragmentu, tekst, model embeddingu, etykietę okresu, znormalizowany klucz okresu, np. q3_2023, oraz datę publikacji w formacie ISO 8601. Qdrant przechowuje indeksy słów kluczowych dla pliku źródłowego i klucza okresu, a także indeks daty i godziny publikacji, dzięki czemu filtrowanie metadanych odbywa się szybko podczas wyszukiwania.

Wyszukiwanie i pułapka najnowszości

Prosta konfiguracja zwraca K najlepszych wyników na podstawie podobieństwa kosinowego. To działa, dopóki zbiór danych obejmuje kilka lat, a ktoś pyta o aktualne prognozy inflacji. Jeśli bank użył niemal identycznego sformułowania dotyczącego inflacji w I kwartale 2022 roku i w III kwartale 2024 roku, fragment z 2022 roku może uzyskać wyższą ocenę ze względu na nieco lepsze sformułowanie, mimo że czytelnik z pewnością chce najnowszej informacji.

Rozwiązaniem jest pobranie większej liczby kandydatów i ponowna ich sklasyfikacja. System pobiera trzy razy większą liczbę kandydatów, niż zostało to zapytane, a następnie ponownie ocenia każdego z nich, łącząc wskaźnik powinowactwa kosinowego z znormalizowaną wartością świeżości.

RECENCY_WEIGHT = 0.85

def combined_score(hit):
    relevance = hit.score / max_score
    recency = (pub_date - min_date).days / date_span_days  # 0.0 to 1.0
    return (1 - RECENCY_WEIGHT) * relevance + RECENCY_WEIGHT * recency

Waga aktualności wynosząca 0,85 oznacza, że nowsze dokumenty są silnie faworyzowane, chyba że starszy dokument jest znacznie bardziej istotny. W korpusie polityki, gdzie każdy raport faktycznie zastępuje poprzedni, taka równowaga wydaje się właściwa.

Filtrzy opcjonalne umożliwiają użytkownikowi ograniczenie wyszukiwania do określonego okresu lub przedziału dat:

# scope to Q1 2023 only
retrieve_chunks(query, period_label_key="q1_2023")

# scope to a date range
retrieve_chunks(query, publication_date_from="2023-01-01", publication_date_to="2023-12-31")

Bez filtrów system funkcjonuje jako asystent „ostatniego widoku”. Z ich użyciem staje się narzędziem do badań historycznych.

Generowanie

Generator jest celowo prosty. Pobrane fragmenty są formatowane jako źródła numerowane i przekazywane do gpt-4o-mini wraz z instrukcją systemową, która mówi, aby używać wyłącznie dostarczonych źródeł, nie wprowadzać żadnej wiedzy z zewnątrz, cytować każde twierdzenie faktyczne bezpośrednio w tekście oraz zaznaczyć, gdy źródła nie zawierają wystarczających informacji.

Ta ostatnia klauzula wymaga szczególnej uwagi. Ściśle określone instrukcje stanowią najskuteczniejszą ochronę przed halucynacjami. Jeśli wyszukiwanie nie przynosi żadnych istotnych wyników, system odpowiada ustaloną wiadomością awaryjną zamiast improwizować.

Odpowiedzi API zawierają tekst odpowiedzi wraz z ustrukturyzowanym wykazem źródeł, w którym dla każdego z nich podano okres, do którego należy, datę publikacji oraz miejsce przechowywania pliku PDF. Na stronie użytkownika przekształcają się one w klikalne cytaty, dzięki czemu każde stwierdzenie jest łatwe do prześledzenia.

Problemy, które pojawiły się w praktyce

Elementy strony zakłócające strukturę fragmentów

Każda strona każdego raportu ma nagłówek z napisem Classification: GENERAL oraz stopkę w formie Monetary Policy Report - Q3 2023 | 47. Po przetworzeniu te linie pojawiają się jako zwykły tekst i trafiają do fragmentów, marnując zasoby na niepotrzebne dane. Numer strony w tej stopce mógłby faktycznie umożliwić dokładniejsze cytowanie, ale pomysł ten pojawił się dopiero późno.

Rozwiązaniem jest dodatkowe oczyszczenie przed tworzeniem fragmentów:

def _clean_parsed_text(text):
    text = text.replace("\x0c", "\n\n")
    text = re.sub(r"Classification:\s*GENERAL\s*\n", "", text)
    text = re.sub(r"Monetary Policy Report\s*[--][^\n]+\|\s*\d+\s*\n", "", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()

W perspektywie czasu jest to oczywiste, ale łatwo to przeoczyć, jeśli tekst po przetworzeniu nigdy nie jest sprawdzany przed wstawieniem. Objaw był dziwny: podczas testowania odpowiedzi system wydawał się korzystać z innych fragmentów zamiast z tego o najwyższej ocenie. Nagłówki podtekstowe dzieliły akapity w trakcie formułowania myśli, co po cichu zmieniało kontekst, który reprezentował każdy fragment.

Przypadkowe usunięcie

Qdrant oferuje flagę recreate-collection, która usuwa i odbudowuje całą kolekcję. Przydatna w fazie rozwoju, katastrofalna w środowisku produkcyjnym. Obecnie mechanizm ochronny ignoruje tę flagę, gdy środowisko przypomina środowisko produkcyjne:

def _should_recreate_qdrant_collection():
    if _is_production_environment():
        return False
    explicit = os.environ.get("QDRANT_RECREATE_COLLECTION", "").lower()
    if explicit in ("1", "true", "yes"):
        return True
    app_env = (os.environ.get("ENVIRONMENT") or "").lower()
    return app_env in ("development", "dev", "local")

W przypadku projektu z jednym użytkownikiem ten mechanizm ochronny jest chyba przereklamowany, ale nie kosztuje prawie nic.

Duplikaty po ponownym włączeniu

Po edycji wpisu w manifestie i ponownym uruchomieniu procesu włączania istniały dwa zestawy fragmentów dla tego samego pliku PDF. Rozwiązaniem jest usunięcie istniejących fragmentów pliku źródłowego przed dodaniem nowych:

client.delete(
    collection_name=collection,
    points_selector=models.Filter(must=[
        models.FieldCondition(
            key="source_file",
            match=models.MatchValue(value=source_file)
        )
    ]),
)

Razem ze stabilnymi identyfikatorami UUID5 proces ponownego wprowadzania danych jest teraz idempotentny.

Uruchamianie od zera na planie darmowym

Backend działa na darmowym planie Render, który jest wyłączany w stanie bezczynności. Pierwsza prośba po uruchomieniu od zera może potrwać od 30 do 60 sekund, co wystarcza, by odwiedzający uznał, że aplikacja nie działa. Krótki endpoint /health rozwiązuje większość tych problemów: frontend wysyła do niego zapytanie przy ładowaniu strony, co budzi serwer przed wysłaniem pierwszego zapytania. Oznacza to jedno dodatkowe zapytanie HTTP na sesję. Interfejs użytkownika zawiera również krótką informację wyjaśniającą, że pierwsze zapytanie może potrwać od pół minuty do minuty, dopóki backend się nie uruchomi.

Wybór rozmiaru fragmentu

Wartość 512 tokenów została ustalona eksperymentalnie. Fragmenty składające się z 128 do 256 tokenów były bardziej precyzyjne, ale często traciły kontekst niezbędny do ich samodzielnego zinterpretowania. Fragmenty liczące 1024 tokeny lub więcej przywoływały niepowiązany tekst otaczający. Przełożenie 64 tokenów łagodzi ostre granice i usprawiedliwia dodatkową przestrzeń przechowywania.

Co zmienić następnym razem

  • Najpierw utworzyć zestaw oceny. Istnieje katalog evals, ale przed dostosowaniem wagi aktualności lub rozmiaru fragmentów nie stworzono żadnego zestawu pytań referencyjnych, więc oba wybory są jedynie przypuszczeniami opartymi na doświadczeniu. Prawdziwy zestaw oceny dałby im solidne podstawy.
  • Dodać mechanizm ponownego rankowania. Włączanie informacji o aktualności do oceny jest jedynie heurystyką. Kodownik krzyżowy, taki jak Cohere Rerank, lub model lokalny, mógłby ocenić zarówno zapytanie, jak i fragmenty razem, zapewniając bardziej wiarygodne rankowanie relevancji.
  • Spróbuj hybrydowego wyszukiwania. Gęste wektory mogą pomijać dokładne terminy. Wyrażenie takie jak 7,00% odwrotna stopa repurchase jest trudne do odnalezienia, jeśli słowa pytania są sformułowane inaczej. Połączenie dopasowywania słów kluczowych BM25 z wyszukiwaniem wektorowym mogłoby zamknąć tę lukę, a Qdrant oferuje to od razu.
  • Przesyłaj odpowiedzi strumieniowo. gpt-4o-mini potrzebuje od dwóch do pięciu sekund na udzielenie odpowiedzi. Przesyłanie tokenów w miarę ich przychodzenia sprawia, że interfejs wydaje się szybszy, nawet jeśli całkowity czas pozostaje niezmienny.

Używanie ponownie projektu

Architektura jest uniwersalna. Wystarczy zastąpić manifest innym korpusem dokumentów, zaktualizować opis domeny w instrukcjach systemu, a w rezultacie otrzymujemy skuteczny system Q&A dla dowolnego zbioru plików PDF. Trudniejsze aspekty, takie jak czyste dzielenie na fragmenty, wyszukiwanie uwzględniające aktualność, filtrowanie metadanych oraz bezpieczne ponowne włączanie danych do systemu, są już obsłużone.

Podobne materiały

  • Hybrydowe szukanie obrazów w skali masowej: SigLIP, SPLADE, YOLOv8 i opisy Gemini — Projekt systemu do wyszukiwania miliona obrazów, który łączy gęste i rzadkie embeddingi z wykrywaniem obiektów, rozpoznawaniem entytetów, bezpiecznym wyszukiwaniem oraz opisami napisanymi przez LLM.