Spis treści:
- Plan minimum
- Lokalny LLM
- Baza danych
- Wymagania sprzętowe
- Instalacja
- Ingest danych
- Budowa indeksu
- Wyszukiwanie i reranking
- Promptowanie i raporty
- Szybciej i bezpieczniej
Wiele firm chce korzystać z potencjału LLM-ów, ale nie może przesyłać poufnych dokumentów do chmury. Rozwiązaniem może być lokalny RAG, którego konfigurację omówimy w tym artykule.
Retrieval-Augmented Generation to generowanie odpowiedzi przez model na podstawie informacji wydobytych z dostarczonych dokumentów. Zapewnia to prywatność, niezależność i kontrolę kosztów. Dane pozostają na naszym sprzęcie, a do tego unikamy polegania na zewnętrznych API, co oznacza, że nie istnieje ryzyko nagłej utraty dostępu czy zmian wersji. Środowisko można odtworzyć na kolejnym komputerze lub za rok, bo wszystko zależy od naszych własnych ustawień, a nie od zewnętrznego dostawcy. Koszty też są przewidywalne, gdyż nie płacimy za każde zapytanie (odpada abonament czy opłaty za tokeny).
Plan minimum
Minimalna architektura systemu RAG składa się z prostego łańcucha przetwarzania. Najpierw parsujemy dokumenty źródłowe do postaci czystego tekstu. Następnie dzielimy go na fragmenty (chunks) o odpowiedniej długości. Każdy z nich reprezentujemy jako embedding w przestrzeni semantycznej i umieszczamy w wektorowej bazie danych. W czasie zapytania retriever wyszukuje w tej bazie fragmenty najbardziej zbliżone do pytania, opcjonalnie reranker układa je od najbardziej do najmniej istotnych, a LLM generuje odpowiedź, korzystając z podanych fragmentów jako kontekstu, zgodnie z przygotowanym szablonem.
Jednocześnie nie modyfikujemy parametrów LLM-u, a „uczony” jest tylko indeks w bazie. Dzięki temu, jeśli dodamy nowe dokumenty, nie musimy ponownie trenować modelu – wystarczy zaktualizować indeks. RAG działa jak sprytny pomost między danymi a modelem. Zamiast próbować wtłoczyć całą wiedzę do LLM-u (poprzez dostrajanie), podajemy mu w locie tylko te informacje, które są potrzebne do odpowiedzi na bieżące pytanie. To sprawia, że output jest dokładniejszy i lepiej osadzony w faktach, bo model opiera się na realnych danych, które mu dostarczyliśmy.
Taka architektura jest znacznie elastyczniejsza niż tradycyjne trenowanie modeli. Fine-tuning jest kosztowny i mało odporny na zmiany wymagań, podczas gdy RAG pozwala szybko dostosować się do nowych danych i przypadków użycia. Istnieją biblioteki upraszczające budowę takiego pipeline’u (np. LlamaIndex, LangChain), ale w tym artykule skupimy się na zrozumieniu i złożeniu go od podstaw.
Lokalny LLM
Najprościej skorzystać z menedżera – w naszym przypadku będzie to Ollama, który pozwala łatwo pobierać i uruchamiać modele typu Llama czy Mistral w formacie GGUF. Ollama dba o wczytanie modelu i udostępnia go przez interfejs tekstowy lub REST. Alternatywnie można użyć bezpośrednio biblioteki llama.cpp lub ggml do wczytania modeli, ale Ollama upraszcza wiele kroków.
Nadszedł czas na wybór konkretnego LLM-u. Do generowania raportów po polsku warto rozważyć model oparty na Llama 2 lub nowy Mistral 7B, będący opcją wydajniejszą. Istotne, by LLM był dostrojony do instrukcji, bo będzie odpowiadał na polecenia użytkownika. Idąc dalej, niezbędny jest model embeddujący. Potrzebujemy takiego, który przekształci fragment tekstu w wektor liczbowy, reprezentujący jego znaczenie. Możemy to zrobić na dwa sposoby – skorzystać ponownie z Ollamy i pobrać model nomic-embed-text lub użyć biblioteki Sentence Transformers z jednym z nowoczesnych modeli embeddingowych, np. rodziny BGE (Badge General Embeddings od BAAI). Ważne, by model embeddingowy dobrze radził sobie z językiem polskim i specyfiką naszych danych. W związku z tym polecamy BGE-multilingual lub inne wielojęzykowe modele, ponieważ typowe LLM-y są trenowane głównie na angielskim.
Baza danych
Do przechowywania osadzonych wektorów i wyszukiwania najbliższych sąsiadów potrzebujemy bazy wektorowej. Dobrym wyborem jest opensource’owa Chroma DB – prosta, wbudowana baza działająca w procesie Pythona, którą można zainstalować jako pakiet chromadb.
Samo wyszukiwanie wektorowe zwraca nam listę najbardziej zbliżonych fragmentów, ale kolejność nie zawsze jest idealna semantycznie. Dlatego profesjonalne rozwiązania RAG często używają dodatkowego modelu rerankera – najczęściej cross-encodera – który bierze każdą parę (pytanie, fragment) i ocenia jej dopasowanie precyzyjniej. W naszym stosie jako rerankera możemy użyć np. modelu BGE-reranker-large. Taka sieć pozwoli przefiltrować lub reklasyfikować najlepsze wyniki z bazy. Dodanie rerankingu znacząco poprawia jakość, gdyż model generujący dostaje na wejściu najtrafniejsze fragmenty tekstu, co zmniejsza ryzyko halucynacji i zwiększa precyzję. Wadą jest oczywiście dodatkowy narzut obliczeniowy. Na szczęście BGE-reranker-large jest stosunkowo niewielki (ok. 110 mln parametrów), więc nawet na CPU sprawdzimy 10 najlepszych wyników w ułamku sekundy. Jest przy tym opcjonalny – warto go dodać, gdy zauważymy problemy z trafnością odpowiedzi.
Wymagania sprzętowe
Zacznijmy od wariantu z samym CPU. Dzięki sprytnym optymalizacjom nawet w takiej konfiguracji można uruchomić zaskakująco duże LLM-y. Rdzeniem jest technika kwantyzacji (quantization) modeli, konkretnie format GGUF obsługiwany przez llama.cpp. LLM-y, które normalnie zajmują dziesiątki GB, można sprowadzić do postaci 4-bitowej czy 5-bitowej, co drastycznie redukuje zużycie RAM-u i przyspiesza inferencję – kosztem minimalnego pogorszenia jakości. Przykładowo model 7B w 4-bitowej kwantyzacji zajmuje około 4 GB, 13B ok. 8 GB, a 30B ok. 18–20 GB. Oczywiście wydajność na CPU będzie umiarkowana – rzędu kilku tokenów na sekundę dla 7B – ale wystarczająca do prototypowania raportów. W razie potrzeby zawsze można model wymienić na mniejszy (np. 3B), choć obecnie 7B jest uznawany za minimum do sensownych odpowiedzi.
Jeśli dysponujemy zaś dedykowaną kartą graficzną, możemy znacząco przyspieszyć generowanie. Llama.cpp potrafi wykorzystywać GPU do części obliczeń – można np. załadować pewną liczbę warstw modelu do VRAM-u. W przypadku Nvidii używana jest biblioteka cuBLAS, a na Windowsie z kartami AMD lub Intela – backend Vulkan. Nawet częściowy offload może zwiększyć szybkość dwu- lub trzykrotnie. Jeśli mamy kartę z 8–12 GB VRAM, model 7B zmieści się w całości i osiągniemy naprawdę dobrej jakości działanie (zbliżone do chmurowego).
Instalacja
Skoro znamy już teorię, przejdźmy do praktyki, czyli przygotowania modelu. W pierwszym kroku musimy zainstalować Ollamę. Ze strony ollama.com pobieramy instalator dla Windowsa i uruchomiamy go. Jako że Ollama jest lokalnym serwerem modeli, to po instalacji w wierszu poleceń PowerShella powinna być dostępna komenda ollama. Teraz należy pobrać LLM-y – najpierw potrzebny jest model generujący tekst. Na potrzeby testów może to być mniejsza Llama2 7B w wersji instruktażowej. Polecenie będzie analogiczne, np. ollama pull llama2:7b. Następnie pobieramy model embeddingu – ollama pull nomic-embed-text. Teraz – komendą ollama list – możemy się upewnić, że oba modele są na liście – powinny widnieć jako Downloaded.
W trzecim kroku instalujemy bazę wektorową Chroma: pip install chromadb. W kodzie Pythona, zanim zaczniemy dodawać wektory, zainicjujemy PersistentClienta z katalogiem na dysku, np.:
import chromadb
client = chromadb.PersistentClient(path=”./ragdb”)
Spowoduje to utworzenie folderu ragdb w bieżącym katalogu, gdzie Chroma będzie przechowywać swój stan. Dzięki temu nawet po restarcie programu indeks zostanie zachowany. Teraz zapisujemy parametry połączenia (dla Chromy to po prostu obiekt).
Nadszedł czas na zainstalowanie bibliotek do przetwarzania danych – po ich dodaniu środowisko powinno być gotowe do działania. Poniżej przedstawiamy kilka narzędzi do odczytu i obróbki dokumentów:
- PyMuPDF (alias fitz) – dobrze wyciąga tekst z PDF-ów;
- python-docx – do ekstrakcji tekstu z plików docx;
- LangChain (opcjonalnie) – posłuży m.in. do dzielenia tekstów na fragmenty;
- Sentence Transformer – biblioteka ułatwiająca wczytanie i użycie embeddingów BGE;
- numPy – do ewentualnego zapisu wektorów czy operacji matematycznych (przyda się też do FAISS);
Ingest danych
Mając narzędzia, załadujmy nasze dokumenty do systemu. Zaczniemy od sparsowania plików za pomocą skryptu, który wczyta wszystkie dokumenty PDF i DOCX z wybranego folderu, a następnie wyodrębni z nich tekst. Kod dla PDF-ów:
import fitz # PyMuPDF
doc = fitz.open(pdf_path)
text = „”
for page in doc:
text += page.get_text()
W przypadku DOCX musimy wpisać:
from docx import Document
doc = Document(docx_path)
text = „\n”.join(paragraph.text for paragraph in doc.paragraphs)
Trzeba pamiętać, by zachować identyfikatory pochodzenia tekstu, np. nazwę pliku i numery stron, bo przyda się to do generowania źródeł w odpowiedzi.
Kolejnym etapem jest czyszczenie i normalizacja. Po zebraniu surowego tekstu z dokumentów usuniemy z niego elementy, które mogą przeszkadzać. W PDF-ach często powtarzają się np. nagłówki lub stopki na każdej stronie – warto je odfiltrować. Upewnijmy się przy okazji, że tekst jest w Unicode i ma poprawnie sformatowane polskie znaki. Usuwamy też wszelkie duże fragmenty bez znaczenia (m.in. numery stron) – wynikiem powinien być czysty, ciągły tekst lub podział na logiczne segmenty odpowiadające np. sekcjom dokumentu.
Przechodzimy do chunkowania – teraz podzielimy tekst na kawałki odpowiedniej długości, aby stworzyć z nich embeddingi. Idealna długość fragmentu zależy od rodzaju dokumentów i modelu. Zbyt duże chunki (np. całe strony) mogą rozmywać temat – embedding będzie uśrednieniem różnych wątków i pytanie może w niego „nie trafić”. Z kolei zbyt małe fragmenty (np. pojedyncze zdania) sprawią, że trzeba będzie pobierać bardzo dużo fragmentów dla szerszego kontekstu odpowiedzi, co zwiększa ryzyko zgubienia spójności.
Typowo przyjmuje się ok. 500–1000 tokenów na fragment, co w przybliżeniu daje 300–600 słów (zależy od języka). Dobrze jest ustawić overlap – czyli nachodzenie fragmentów na siebie (np. końcówka jednego staje się początkiem następnego) na około 10–15% długości. Zapobiega to przypadkom, gdy ważne zdanie na granicy dwóch fragmentów znalazłoby się tylko w jednym z nich. Jeśli korzystamy z LangChaina, to jest tam wygodny RecursiveCharacterTextSplitter:
from langchain.text_splitter \
import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=5000,
chunk_overlap=500)
chunks = splitter.split_text(text)
W powyższym przykładzie fragment ma 5 tys. znaków (ok. 800 słów), a overlap wynosi 500, co daje 10% nadmiaru. Można też zastosować uproszczoną metodę dzielenia tekstu – np. rozdzielać po podwójnych znakach nowej linii, a w przypadku zbyt długich akapitów dodatkowo rozbijać je na mniejsze fragmenty. Ważne, aby chunki miały sens samodzielnie (np. nie urywały się w połowie zdania).
Na koniec, jeśli mamy wiele dokumentów, trzeba sprawdzić, czy pewne bardzo podobne fragmenty się nie powtarzają (np. stopki, definicje). Indeksując duplikaty, ryzykujemy, że dostaną nieproporcjonalnie dużą wagę w wyszukiwaniu (bo wektory takich samych zdań będą identyczne i mogą zdominować wyniki). Można zaimplementować prostą deduplikację,
czyli trzymać hash fragmentu i pomijać go, jeśli już wystąpił. Alternatywą jest usuwanie duplikatów tylko wtedy, gdy pochodzą z tego samego źródła (bo powtarzające się zdanie w różnych dokumentach może akurat być istotne, jeśli pytanie dotyczy tej frazy).
Budowa indeksu
Mamy już listę fragmentów tekstu. Teraz generujemy dla nich wektory i umieszczamy w bazie wektorowej. W tym celu skorzystamy z wybranego modelu embeddingowego, czyli Ollamy (nomic-embed-text). Najpierw uruchamiamy lokalne API embeddingu:
import ollama
vector = ollama.embeddings(model=’nomic-embed-text’,
prompt=fragment)
Zwrócony wektor to np. tablica 768 liczb. Co istotne, jeśli mamy wiele fragmentów, należy wysyłać je pojedynczo lub po kilka – Ollama może mieć limit co do jednorazowego kontekstu. Wykorzystanie Sentence Transformers może być szybsze, jeśli mamy akcelerację GPU, bo batch fragmentów zostanie wówczas przetworzony równolegle. Uzyskamy listę np. 384-wymiarowych wektorów. Niezależnie od metody każdy fragment ma teraz swój embedding, czyli swoisty „odcisk palca” treści. Wektory podobnych znaczeniowo fragmentów leżą blisko siebie w przestrzeni, co umożliwia potem wyszukiwanie na podstawie odległości (np. kosinusowej).
Czas na zapis do bazy wektorowej – utrwalamy nasze wektory i dane. W Chromie tworzymy kolekcję i dodajemy dokumenty:
collection = \
client.get_or_create_collection(„knowledge_base”)
collection.add(documents=fragments, embeddings=vectors, \
metadatas=metadata_list, ids=id_list)
W powyższym kodzie metadata_list to lista słowników z metadanymi (np. {„source”: „file1.pdf”, „page”: 10-11}), a id_list zawiera unikalne identyfikatory fragmentów. Po dodaniu wszystkich danych nasza wektorowa baza jest gotowa, by odpowiadać na zapytania. Nastąpiło też automatyczne zbudowanie struktur przyspieszających wyszukiwanie (np. grafu HNSW) – parametry domyślne zwykle wystarczą, choć można je dostrajać. Upewnijmy się jeszcze , że baza została trwale zapisana. W Chromie podaliśmy persist_directory, więc jeśli zajrzymy do folderu ragdb, powinny tam być jakieś pliki (np. chroma.sqlite, vectors.bin itp.). Teraz, nawet jeśli zresetujesz program czy komputer, indeks zostanie odczytany z dysku przy ponownym uruchomieniu usługi.
Wyszukiwanie i reranking
Nadszedł moment próby – jak nasz system odpowie na pytanie użytkownika? Proces zaczyna się od wyszukiwania (retrievalu). Gdy otrzymujemy zapytanie (np. „Jaka jest wasza polityka zwrotów?”), najpierw konwertujemy je na embedding – tak samo jak robiliśmy to z dokumentami. Ważne, by użyć tego samego modelu embeddingu (jeśli indeks zbudowaliśmy modelem Nomic, to pytanie również embeddujemy za jego pomocą). Otrzymujemy wektor zapytania, po czym pytamy naszą bazę wektorową o najbliższych sąsiadów:
results = \
collection.query(query_embeddings=[query_vector], n_results=5)
To zwróci np. pięć najbardziej zbliżonych fragmentów (oraz ich metadane). W prostym ujęciu moglibyśmy już te fragmenty podać do LLM-u. Jednak warto zastosować jeszcze reranking – posortujmy te fragmenty nie na podstawie czysto wektorowej odległości (która bywa niedoskonała), ale według oceny cross-encodera. Model BGE-reranker przyjmie tekst pytania oraz fragmentu i wyrzuci skalar – im wyższy, tym lepiej fragment odpowiada pytaniu. Trzeba każdy chunk ocenić osobno, wykorzystując np. pipeline z Transformers:
from transformers \
import AutoModelForSequenceClassification, AutoTokenizer
reranker = AutoModelForSequenceClassification.from_pretrained(„BAAI/bge-reranker-base”)
tokenizer = AutoTokenizer.from_pretrained(„BAAI/bge-reranker-base”)
scores = []
for fragment in top_fragments:
inputs = tokenizer(pytanie, fragment, return_tensors=’pt’,\ truncation=True, max_length=512)
score = reranker(**inputs).logits.item()
scores.append(score)
reranked = [x for _,x in sorted(zip(scores, top_fragments), reverse=True)]
Wynikiem jest lista fragmentów – posortowana od najlepszego dopasowania do najsłabszego. Taki reranking potrafi wychwycić niuanse, np. w danych były dwie różne polityki zwrotów i wektorowo miały podobną odległość do pytania – cross-encoder może ocenić, że jedno z nich jednak lepiej pasuje do kontekstu pytania. Jak duży zysk daje reranker? Według twórców BGE połączenie wyszukiwania embedderem i późniejszego przefiltrowania cross-encoderem to obecnie najlepsza praktyka, łącząca szybkość z wysoką dokładnością. A z naszej perspektywy – zmniejsza ryzyko, że LLM dostanie „śmieciowy” kontekst i zacznie halucynować.
Promptowanie i raporty
Przechodzimy do części generatywnej. Mając LLM i kontekst (zebrane fragmenty), musimy sformułować prompt w taki sposób, aby model udzielił nam oczekiwanej odpowiedzi w pożądanym formacie. W przypadku raportów czy odpowiedzi z cytatami dobrze jest narzucić pewną strukturę. Możemy np. zdefiniować w promptcie, że odpowiedź ma zawierać krótkie podsumowanie, następnie wypunktowane szczegóły, a na końcu listę źródeł. W prostszym wariancie (dla Q&A) może to wyglądać następująco:
Oto informacje z dokumentów firmowych:
{{fragment1}}
{{fragment2}}
{{fragment3}}
Na ich podstawie odpowiedz na pytanie. Jeśli w informacji czegoś brakuje, odpowiedz, że nie brakuje danych.
Pytanie: {{user_question}}
Odpowiedź:
Istotne jest też wymuszanie cytowania źródeł. Chcemy, aby model podał, skąd wziął informacje. Najlepiej jest to uwzględnić w promptcie, można też prosić o oznaczenia, np. odnośniki w formie [1], [2], i później dopasować je do dokumentów. Prościej zażądać jednak, by odwołania były dodawane na końcu pełnej listy wykorzystanych źródeł. Ponieważ mamy metadane fragmentów, możemy je przekazać do promptu, np. zamiast czystego tekstu fragmentu wstawić: [Źródło: {{filename}}, str. {{page}}] {{fragment_text}}. Wtedy model widzi już w kontekście, że dany tekst pochodzi z konkretnego źródła. W idealnym przypadku uwzględni to w odpowiedzi. Jeżeli nie, to po wygenerowaniu outputu możemy wykryć, które fragmenty (lub ich charakterystyczne frazy) zostały użyte, i dodać stosowne przypisy.
Teraz, mając pełny prompt, przekazujemy go do modelu:
prompt = … # wygenerowany jak wyżej
response = ollama.chat(model=”llama2:7b”, messages=[{„role”:”user”,”content”:prompt}])
answer = response[„message”][„content”]
Jeśli używamy bezpośrednio llama_cpp czy transformers, to wywołujemy metodę generate z naszym promptem. Model wygeneruje odpowiedź – powinna ona już zawierać treść wykorzystującą kontekst. Jeżeli odpowiedź jest niekompletna lub model wydaje się jej nie znać, to znak, że prawdopodobnie fragmenty nie zawierały potrzebnej informacji albo zostały źle dobrane. Możemy iteracyjnie poprawiać działania systemu, co zabezpiecza przed halucynacjami.
Szybciej i bezpieczniej
Jak widać na powyższych przykładach, odpowiednie promptowanie i postprocessing pozwalają uzyskać od lokalnego modelu bardzo użyteczne raporty i odpowiedzi, które wyglądają jak przygotowane przez rzeczywistego analityka. Z tą różnicą, że nasz asystent AI robi to w kilka sekund i nie łamie przy tym poufności danych.
Autor
Grzegorz Kubera
Autor jest założycielem firmy doradczo-technologicznej. Pełnił funkcję redaktora naczelnego w magazynach i serwisach informacyjnych z branży ICT. Dziennikarz z ponad 13-letnim doświadczeniem i autor książek na temat start-upów oraz przedsiębiorczości.