Budżety tokenów a granice semantyczne: prawidłowe dzielenie fragmentów w RAG
Dowiedz się, w czym różni się tokenizacja od dzielenia na fragmenty, jakie miejsce zajmują one w procesie RAG oraz jak dzielić dokumenty według znaczenia, jednocześnie mierząc ich rozmiar za pomocą rzeczywistego tokenizatora.
Źródła: Chunking vs Tokenization: The Two Splits Every AI Engineer Keeps Confusing
Pipeline RAG może wydawać się sprawny na każdej widocznej warstwie – z rozsądnymi embeddingami, wiarygodnymi wynikami podobieństwa oraz dobrze wypełnionym magazynem wektorowym – a mimo to przekazywać modelowi fragmenty tekstu przerwane w połowie zdania. W wielu takich przypadkach usterka leży w kodzie do pobierania danych, gdzie procedura dzielenia tekstu na fragmenty została opracowana w oparciu o błędne założenia dotyczące sposobu przekształcania tekstu w tokeny. Ten przewodnik jasno rozdziela te dwa podejścia, pokazuje, gdzie każde z nich występuje w pipeline’u, oraz przedstawia metodę dzielenia tekstu, która najpierw uwzględnia znaczenie, zachowując jednocześnie ścisłą kontrolę nad limitem tokenów.
Inżynierowie regularnie używają wyrażeń takich jak „podziel na 512 tokenów” lub „dzielenie oparte na tokenach”, a te sformułowania łączą dwie różne operacje w jedną. Rozwiązują one różne problemy, odbywają się na różnych etapach i działają w inny sposób. Dokładna znajomość momentu, w którym jedna operacja się kończy, a druga zaczyna, pozwala analizować system wyszukiwania zamiast tylko dostosowywać parametry, aż poprawi się wynik oceny.
Dwie operacje, dwóch właścicieli
Najkrótsze i najprzydatniejsze definicje brzmią następująco:
- Tokowanie przekształca tekst w dyskretne jednostki całkowite, które faktycznie jest wykorzystywane przez model. Należy do modelu.
- Dzielenie na fragmenty dzieli dokumenty na samodzielne części, które można włączyć do systemu, zindeksować i odzyskać. Należy do twojego procesu przetwarzania oraz twoich danych.
Tokowanie odbywa się za każdym razem, gdy jakaś ciąg znaków trafia do modelu, niezależnie od tego, czy o tym myślisz, czy nie. Dzielenie na fragmenty to decyzja podejmowana z góry dotycząca sposobu strukturyzacji korpusu, zanim model go w ogóle zobaczy. Pierwsze z tych procesów jest ustalone i w większości przypadków niewidoczne; drugie to coś, co projektujesz, mierzysz i doskonalisz przez tygodnie.
Oba procesy cały czas ze sobą współdziałają. Dzielnik, który ignoruje zachowanie tokownika, będzie w cichu tworzyć słabe fragmenty. Mimo to są to odrębne kroki, a traktowanie ich jako jednej rzeczy jest przyczyną zaskakująco wielu subtelnych błędów przy wyszukiwaniu informacji.
Jak model czyta tekst
Żaden model LLM oparty na transformatorach, czy to GPT-4o, Claude, Llama czy cokolwiek innego w użyciu, nie czyta słów. Czyta sekwencję liczb całkowitych, z których każda odnosi się do ustalonego słownika nabytego podczas treningu. Tokenizer to algorytm deterministyczny, który przekształca surowy tekst w tę sekwencję. Jest wybierany raz, podczas treningu modelu, i dostarczany jako część artefaktów modelu. Nie można go zmieniać na żądanie.
Słowniki podsłówkowe: BPE, WordPiece, SentencePiece
Większość współczesnych modeli opiera się na tokenizacji podsłówkowej, zwykle wykorzystującej metody z rodzin BPE, WordPiece lub SentencePiece. Wspólną zasadą jest rozpoczęcie od znaków lub surowych bajtów oraz wielokrotne łączenie najczęściej występujących sąsiednich par w nowe pozycje słownika, aż osiągnięta zostanie pożądana wielkość słownika – zazwyczaj od 32k do 128k pozycji lub więcej.
To oznacza w codziennej pracy:
- Często występujące słowa w języku angielskim, takie jak
the,isczymodel, zazwyczaj odpowiadają po jednym tokenie. - Rzadsze słowa dzielą się na mniejsze części, więc
tokenizationmoże składać się ztokenorazization. - Niezwykłe ciągi znaków, w tym identyfikatory kodu, pisma niełacińskie, emotikony oraz wymyślone nazwy produktów, mogą skutkować wieloma tokenami na jedno słowo, a czasami nawet niemal jednym tokenem na każdy znak.
Dlatego zdanie w języku hindi lub tamilskim może wymagać od dwóch do trzech razy więcej tokenów niż zdanie w języku angielskim o tym samym znaczeniu, a identyfikator taki jak xgboost_lstm_ensemble_v2 może zużyć około ośmiu tokenów, podczas gdy planowano dwa. Dokładne proporcje zależą od narzędzia do tokenizacji, a nowsze słowniki zmniejszyły różnicę dla niektórych języków, więc należy mierzyć przy użyciu modelu, który się stosuje. Dla każdego produktu obsługującego rynki inne niż angielski, na przykład warstwy LLM, które podsumowują dokumenty z indyjskiej giełdy lub logi zamówień brokera, jest to rzeczywisty koszt i element budżetu kontekstowego, a nie tylko ciekawostka.
Dlaczego liczba tokenów ma znaczenie poza fakturą
Liczba tokenów służy nie tylko do ustalenia ceny żądania. Określają one trzy aspekty:
- Ile miejsca w oknie kontekstowym wykorzystujesz. Okno o pojemności 128k mierzone jest w tokenach, a nie w słowach czy znakach. Fragment składający się z 1000 słów nie ma stałej ceny – liczba jego tokenów zmienia się w zależności od słownictwa, języka i typu treści, ponieważ kod, proza i liczby są tokenizowane zupełnie inaczej.
- Nad czym działa uwaga modelu. Transformery biorą pod uwagę pozycje tokenów, więc sposób dzielenia tekstu na tokeny wpływa na to, w jakim stopniu model może spójnie łączyć jego części. Patologiczne podziały utrudniające zrozumienie są rzadkie, ale niektóre metody kodowania mogą je powodować.
- Gdzie następuje skracanie tekstu. Gdy dane wejściowe przekraczają rozmiar okna, są obcinate na poziomie tokenów, a nie przy granicach zdań czy fragmentów, chyba że sam to zarządzisz. Niedbale przeprowadzone skracanie może pozostawić słowo nieukończone.
Raz sprawdź swoje własne ciągi znaków
Jeśli nigdy nie przepuszczałeś swoich rzeczywistych zapytań produkcyjnych przez tiktoken od OpenAI lub tokenizer od Anthropic, warto to zrobić przynajmniej raz. Widok własnego szablonu zapytania rozłożonego na nieoczekiwane fragmenty przekształca abstrakcję w coś konkretnego. Składnia Markdown, nawiasy JSON oraz ramki kodu są szczególnie problematyczne – tokenizują one mniej efektywnie niż zwykła proza.
Poniższy fragment ładuje kodowanie cl100k_base i liczy tokeny w typowym poleceniu wiersza poleceń. Zauważ, że flagi i rozszerzenia plików zazwyczaj zajmują po dwa lub trzy tokeny, co jest dokładnie tym rodzajem treści, który zwiększa koszty bez żadnej reakcji.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("pip-compile requirements.in --output-file requirements.txt")
print(len(tokens), tokens)
# CLI flags and file extensions often split into 2-3 tokens each —
# this is exactly the kind of string that silently inflates your token budget
Jak decydujesz, co model ma zobaczyć
Chunking funkcjonuje na wyższym poziomie, w łańcuchu przetwarzania, a nie w modelu. Bierze duży dokument, takiego jak plik PDF, strona w Confluence, repozytorium kodu lub 40-stronicowy obwieszczenie regulacyjne, i dzieli go na mniejsze jednostki, które są włączane, indeksowane i wyodrębniane niezależnie. Chunks stanowią podstawę warstwy wyodrębniania informacji.
Dwie ograniczenia sprawiają, że chunking jest konieczny, a żadne z nich nie dotyczy bezpośrednio tokenizacji:
- Długie wektory embeddingów stają się niewyraźne. Jeśli umieści się zbyt wiele niepowiązanych ze sobą idei w jednym wektorze, ten staje się ich średnią i nie odpowiada dokładnie niczemu. Dobry chunks zawiera jedną spójną ideę, a nie cały dokument.
Strategie dzielenia tekstu na fragmenty i ich wady
Okna o stałej wielkości
Najprostsze podejście polega na dzieleniu tekstu co N tokenów lub znaków, zazwyczaj z pewnym nakładaniem się fragmentów sąsiadujących ze sobą. Większość zespołów zaczyna od tego rozwiązania, ponieważ wymaga jedynie kilku linijek kodu. Poniższa funkcja koduje cały tekst, dzieli listę tokenów na okna o wielkości chunk_size, dekoduje każde z tych okien z powrotem na tekst i przesuwa się o chunk_size - overlap, dzięki czemu kolejne fragmenty mają wspólną krawędź.
def fixed_chunk(text, chunk_size=512, overlap=50):
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = start + chunk_size
chunk_tokens = tokens[start:end]
chunks.append(enc.decode(chunk_tokens))
start += chunk_size - overlap
return chunks
Słabością jest to, że ta funkcja nie rozumie pojęcia zdania. Umieszcza ona granicę w środku klauzuli, oddzielając twierdzenie od danych je potwierdzających lub przykład kodu od zdania, które go wyjaśnia. Te niekompletne fragmenty zdań trafiają do wektorów znajdujących się pomiędzy tematami i słabo pasują do nich, co jest dokładnie objawem opisanym na początku. Dwie dodatkowe uwagi: overlap musi być mniejszy od chunk_size, w przeciwnym razie pętla nigdy się nie uruchomi, a dekodowanie dowolnego fragmentu tokena może przeciąć wielobajtowy znak na pół, powodując pojawienie się znaku zastępczego na krańcu fragmentu.
Rekurencyjne dzielenie na naturalne granice
Rrecesyjny dzielacz preferuje rozdzielanie na akapity, a gdy to nie działa – na zdania, a następnie na słowa; dokonuje ostatecznego podziału tylko wtedy, gdy nic innego nie pasuje. RecursiveCharacterTextSplitter z LangChain oraz parserzy węzłów w LlamaIndex postępują zgodnie z tym wzorcem. Dla korpusów bogatych w prozę jest to znacznie lepszy standard, ponieważ najpierw uwzględniana jest struktura samego dokumentu, a dopiero potem limit rozmiaru.
Chunkowanie semantyczne
Zamiast ustalać określony rozmiar, chunkowanie semantyczne umieszcza pojedyncze zdania lub małe ich grupy, a granicę wyznacza w miejscu, gdzie kosynusowa podobieństwo pomiędzy sąsiadującymi reprezentacjami spada – to właśnie tam zmienia się temat. Powstałe fragmenty łączą się ze sobą na podstawie znaczenia, a nie długości. Zaletą jest to, że podczas przetwarzania każdego dokumentu odbywa się dodatkowa operacja generowania reprezentacji.
Chunkowanie z uwzględnieniem struktury
Teksty z wbudowaną strukturą, takie jak Markdown z nagłówkami, pliki źródłowe, specyfikacje API czy dokumentacja schematów, powinny być dzielone zgodnie z tą strukturą. W kodzie oczywistym granicą jest definicja funkcji. W bazie wiedzy typu Markdown taką granicę stanowi sekcja ##. W przypadku pliku README systemu wieloagentowego dzielenie tekstu według nagłówków sekcji zapewnia znacznie lepszą możliwość odnalezienia istotnych informacji niż jakikolwiek licznik tokenów. Szersze omówienie sposobów gromadzenia wspierających dowodów znajduje się w artykule chunking that preserves evidence.
Dzielenie tekstu przy użyciu LLM
W najdroższej opcji można poprosić model językowy o przeczytanie dokumentu i samodzielne wyznaczenie granic, tak aby każdy fragment stanowił odrębną całość odpowiadającą na konkretne pytanie. Jest to proces powolny i kosztowny, ale sprawdza się przy dokumentach o wysokiej wartości, takich jak zgłoszenia regulacyjne czy umowy, gdzie błąd w wyszukiwaniu ma poważne konsekwencje.
Gdzie zderzają się te dwa podejścia
Ciekawy aspekt to punkt, w którym te koncepcje się spotykają, a można to streścić jednym zdaniem: rozmiar fragmentów zazwyczaj określa się w tokenach, ale granice tych fragmentów powinny odpowiadać znaczeniu, a te dwa cele działają w przeciwnych kierunkach.
Krojenie dokładnie na poziomie 512 tokenów przy użyciu chunk_size=512 zapewnia przestrzeń budżetową, ale ignoruje znaczenie tekstu. Dzielenie tekstu wyłącznie na podstawie przecinków paragrafowych bez żadnego limitu uwzględnia znaczenie, ale nie bierze pod uwagę faktu, że jeden paragraf może składać się z 40 tokenów, a następny z 4 000. Taki duży fragment może przekroczyć maksymalną długość sekwencji modelu embeddingowego, w wyniku czego uzyskany kontekst będzie miał drastycznie nierównomierną ilość informacji w poszczególnych fragmentach.
Dlatego też solidny narzędzie do dzielenia tekstu radzi sobie z oboma tymi problemami w określonej kolejności:
- Najpierw znajduje granice semantyczne: sekcje, paragrafy, zdania, bloki kodu.
- Łączy małe, sąsiadujące ze sobą jednostki, dopóki dodanie kolejnej nie przekroczy budżetu tokenów.
Poniższa funkcja realizuje tę zasadę. Oczekuje ona sections, które zostały już podzielone na akapity lub nagłówki. Dla każdej z nich mierzy rzeczywistą długość tokena; jeśli jego dodanie spowodowałoby przekroczenie obecnego fragmentu, zgromadzone sekcje są wypisywane jako osobny fragment. Sekcja, która sama przekracza max_tokens, jest dzielona według granic tokenów; wszystko inne jest dodawane do bieżącego fragmentu. To, co pozostanie na końcu, staje się ostatecznym fragmentem.
def chunk_by_tokens_with_semantic_bias(sections, max_tokens=512):
chunks, current, current_len = [], [], 0
for section in sections: # pre-split by paragraph/header
section_len = len(enc.encode(section))
if current_len + section_len > max_tokens and current:
chunks.append("\n".join(current))
current, current_len = [], 0
if section_len > max_tokens:
# single section too big — hard split on token boundaries, not chars
section_tokens = enc.encode(section)
for i in range(0, len(section_tokens), max_tokens):
chunks.append(enc.decode(section_tokens[i:i+max_tokens]))
else:
current.append(section)
current_len += section_len
if current:
chunks.append("\n".join(current))
return chunks
Kluczową decyzją projektową jest to, że rozmiar mierzy się za pomocą tokenizatora modelu embeddingowego docelowego, a nie szacuje się go na podstawie znaków, oraz że przymusowe rozdzielanie może odbywać się wyłącznie na granicy tokenów kontrolowanej przez kod. Jedna ulepszenie, nad którym warto się zastanowić: nowa linia użyta w "\n".join dodaje około jednego tokena na sekcję, której nie uwzględnia bieżąca liczba, dlatego należy pozostawić trochę marginesu poniżej sztywnej granicy modelu.
Miejsce każdego kroku w procesie
Z punktu widzenia struktury, dzielenie na fragmenty to kwestia związana z wcześniejszym etapem przetwarzania. Odbywa się ono podczas pobierania danych, zanim zostanie zaangażowany jakikolwiek model, a jego wynik jest przechowywany. Tokenizacja natomiast to kwestia związana z późniejszym etapem przetwarzania. Odbywa się ona w warstwie wejściowej modelu przy każdej prośbie, w tym podczas wywołań służących do tworzenia wektorów embeddingowych w trakcie pobierania danych oraz podczas generowania odpowiedzi. Mając to na uwadze, łatwiej jest określić, który kod należy otworzyć, gdy coś pójdzie nie tak.
Rozróżnianie dwóch rodzajów awarii
Gdy analizujesz słaby wynik wyszukiwania, dwa objawy wyglądają podobnie, ale wskazują na różne warstwy przetwarzania:
- Fragment uzyskany z wyszukiwania jest niekompletny lub kończy się w środku zdania. To awaria związana z dzieleniem na fragmenty. Logika określająca granice podzieliła jednostkę semantyczną, więc naprawa powinna dotyczyć mechanizmu dzielenia, a nie wyboru modelu embeddingowego.
len(text) / 4, podczas gdy rzeczywista liczba okazała się wyższa.Często obie te kwestie zrzuca się na „zły model embeddingowy”. Rzadko jest to rzeczywista przyczyna; prawie zawsze winna jest logika obsługi granic lub sposób liczenia tokenów.
Lista kontrolna przed wdrożeniem
Podczas tworzenia lub sprawdzania pipeline’u RAG lub agenta przejdź przez następujące punkty:
- Licz tokeny za pomocą rzeczywistego tokenizera używanego przez model. Dla modeli OpenAI użyj
tiktoken, a dla modeli o otwartym kodzie – tokenizera specyficznego dla danego modelu. Nigdy nie używajlen(text) // 4jako metody liczenia w produkcji. - Mierz rozmiar kawałków w tokenach modelu embeddingowego. Modele embeddingowe często używają innego tokenera niż model generujący i narzucają surowszy limit maksymalnej długości sekwencji.
- Jasno określ budżet na nakładanie się. Nakładanie się zużywa część efektywnego okna, dlatego należy je uwzględnić w obliczeniach dotyczących limitu wejściowego modelu embeddingowego, zamiast dowiadywać się o tym ze ostrzeżenia o skracaniu.
- Stawiaj strukturę ponad rozmiarem. Używaj domyślnie podziału uwzględniającego strukturę lub rekurencyjnego, a okna o stałym rozmiarze przeznaczaj wyłącznie dla naprawdę niestrukturyzowanego tekstu.
- Zapisuj liczbę tokenów oraz decyzje dotyczące granic podczas przetwarzania. Gdy jakość spada, chcesz natychmiast sprawdzić, czy kawałek został skrócony z powodu rozmiaru, czy ze względu na strukturę.
- Ponowna tokenizacja po zmianie modeli embeddingowych. Strategia dostosowana do jednego słownika nie przenosi się bezproblemowo; 512 tokenów obejmuje różną ilość tekstu przy użyciu innego tokenera.
Podsumowanie
Tokenizacja odpowiada na pytanie modelu, jak w ogóle odczytywać ciąg znaków. Dzielenie tekstu na fragmenty odpowiada na pytanie, jaka jest najmniejsza samodzielna jednostka wartą pobrania. Pierwsze z tych rozwiązań jest ustalone w momencie wyboru modelu; drugie stanowi element projektu całkowicie pod twoją kontrolą i zazwyczaj to właśnie tutaj leży prawdziwa siła inżynieryjna w systemie RAG.
Te dwa rodzaje błędów również powodują różne problemy. Błędy w obsłudze tokenów objawiają się dziwnym skracaniem tekstu i przekroczeniem budżetu, co jest przynajmniej widoczne. Błędy w dzieleniu tekstu na fragmenty powodują, że system działa bez żadnych błędów, ale mimo to pobiera niewłaściwą część poprawnego zdania, co jest znacznie trudniejsze do zauważenia. Mierz używając rzeczywistego tokenizatora, najpierw dziel tekst według znaczenia i zapisuj powody umieszczenia każdej granicy w tym konkretnym miejscu.