Strona główna / Artykuły

Rozmiar kawałków i nakładanie się w RAG: krok, limity tokenów i łączenia

Jak podzielić długą książkę na fragmenty dla RAG: jak wybrać rozmiar i stopień nakładania się, co to jest krok przesuwu, jak uniknąć przekroczenia limitu tokenów do embeddingowania, jak przechowywać metadane oraz jak zobaczyć stopień nakładania się w rzeczywistym podziale.

Wydawca: REACTAPP.TOP8 min czytania1742 słów

Źródła: Chunking is the hidden design decision in RAG

Weźmy autobiografię Benjamina Franklina z Project Gutenberg. Wklejanie całej książki do poleceń jest marnotrawstwem zasobów, nawet jeśli okno kontekstowe mogłoby ją pomieścić. Na pytanie, dlaczego Franklin chciał, aby Wielka Brytania zachowała Kanadę, odpowiedź znajduje się prawdopodobnie w dwóch lub trzech fragmentach, a pozostałe strony zajęłyby zasoby kontekstowe i ukryłyby te dowody. Do takich sytuacji służy generowanie wzbogacone wydobywaniem informacji (RAG). System znajduje fragmenty istotne dla pytania i przekazuje je jedynie modelowi językowemu. Książka pozostaje w magazynie, a system wydobywa potrzebne dowody.

Embeddingi i powód dzielenia tekstu na fragmenty

RAG zaczyna od przekształcania fragmentów tekstu w embeddingi, czyli liczbowe reprezentacje znaczenia. Fragmenty o podobnym znaczeniu znajdują się blisko siebie w wysoko wymiarowym przestrzeni wektorowej, nawet jeśli są sformułowane inaczej. Gdy pojawia się pytanie, ono również jest przekształcane w embedding, znajduje się najbliższe przechowywane wektory, a następnie są wybierane odpowiadające im fragmenty źródłowe. Wektor wskazuje na tekst, który model odczytuje.

Jeden embedding nie może reprezentować całej książki. Jeden wektor opisujący życie Franklina zmieszałby jego dzieciństwo w Bostonie, eksperymenty z drukiem i elektrycznością, „Biednego Richarda” oraz zeznania przed Parlamentem w coś, co słabo opisuje żaden z tych elementów. Dlatego tekst musi zostać podzielony na fragmenty przed przekształceniem w embeddingi.

Rozmiar, nakładanie się i krok

Rozsądny punkt wyjścia:

DEFAULT_CHUNK_SIZE = 800      # characters
DEFAULT_CHUNK_OVERLAP = 150   # characters

Ludzie często traktują je jako dwa niepowiązane pokrętła. W rzeczywistości określają one jedną rzecz – o ile okno przesuwa się do przodu przy każdym kroku:

stride = chunk_size − chunk_overlap
       = 800 − 150
       = 650 characters

To rozmiar to ilość nowego tekstu, który dodaje każdy nowy fragment.

Rozważaj rozmiar fragmentu jako maksymalną wartość, a nie cel. Narzędzia do dzielenia tekstu wolą robić to w naturalnych miejscach, czy to w akapicie, linii, zdaniu lub słowie, więc prośba o 800 znaków może skutkować fragmentami o długości 640, 731 lub dokładnie 800.

Przeplatanie się tekstów to fragmenty wspólne dla sąsiednich fragmentów i odgrywają one istotną rolę. Bez niego cięcie może pozostawić taki odosobniony fragment:

"…but left school at the age of ten."

Sama w sobie ta linia niewiele mówi. Przy nakładaniu się fragmenty zachowują wystarczająco dużo wcześniejszego kontekstu, by pozostać czytelne i łatwo dostępne. Ma to szczególne znaczenie w przypadku długich zdań oraz ściśle powiązanych argumentów. List Franklina o Kanadzie zawiera długą serię zdań, które mogą przekraczać granice, przez co jeden fragment pozostaje bez zakończenia, a następny bez założenia. Umiarkowany nakład zapewnia połączenie obu zapisów.

Ceną jest duplikacja. Sąsiednie elementy o podobnym brzmieniu mogą być wyświetlane razem, co jest normalne. Gdy duplikaty wypierają lepsze dowody, pomocny okazuje się metodę uwzględniającą różnorodność, taka jak maksymalna marginalna istotność (MMR).

Co zawiera każdy przechowywany fragment

Każdy fragment przechowuje cztery pola:

id
document
embedding
metadata

Przykładowy rekord:

id         7c2a1e90-4b11-4d3e-9f08-12a6c0e84b21

document   "His boyhood in Boston was a stern beginning of the habit
            of hard work and rigid economy which marked the man. For
            a year he went to the Latin Grammar School on School
            Street, but left off at the age of ten to help his father
            in making soap and candles."

embedding  384 floats — [-0.0412, 0.0187, 0.0621, -0.0094, 0.0330, …]
           norm = 1.0

metadata   {
             book: "Franklin's Autobiography",
             source: "https://www.gutenberg.org/cache/epub/36151/pg36151-images.html",
             gutenberg_id: 36151,
             page_label: "5",
             chunk_index: 2
           }

Wektor embedding służy do wyszukiwania, natomiast oryginalny tekst jest przeznaczony do czytania, cytowania i odwoływania się do źródeł. Metadane również wymagają uwagi. Jeśli wydanie zawiera etykiety stron, takie jak [Pg 5], zachowaj je, aby odwołanie wskazywało na stronę, którą czytelnicy mogą znaleźć w swoim egzemplarzu.

Zanim przystąpisz do indeksowania, usuń materiały o niskiej wartości: strony tytułowe, szablony, tekst licencji, powtarzające się nagłówki oraz zestawy przypisów. Powodują one słabe wektory embedding i pogarszają efektywność wyszukiwania.

Wektory znormalizowane

Wektory embedding są zazwyczaj normalizowane według metryki L2, co sprawia, że każdy z nich ma długość 1:

‖v‖ = √(v₁² + v₂² + … + vₙ²)

Podobieństwo kosinowe porównuje dwa wektory w następujący sposób:

cos θ = (a · b) / (‖a‖ ‖b‖)

Gdy oba mają długość jednostkową, sprowadza się to do zwykłego iloczynu skalarnego:

If ‖a‖ = ‖b‖ = 1:
cos θ = a · b

Ranking odzwierciedla zatem stopień, w jakim dwa znaczenia wskazują w tym samym kierunku, a bezwzględna wartość wektora nie odgrywa żadnej roli, co zapewnia szybkie i sensowne wyszukiwanie.

Tokeny, a nie znaki

Ustawienia w oparciu o znaki mogą ukrywać sztywny limit, ponieważ modele embeddingowe liczą tokieny. Model all-MiniLM-L6-v2 przyjmuje maksymalnie 256 tokenów. Dłuższy fragment może zostać obcięty bez żadnego ostrzeżenia. Baza danych nadal przechowuje pełny tekst, ale jego embedding obejmuje tylko początkową część, a obcięty koniec staje się trudny lub niemożliwy do odzyskania. Ostrożna ocena:

maximum safe chunk size in characters
≈ model token limit × 4

Dla modelu na 256 tokenów otrzymuje się mniej więcej 1000 znaków. Użycie czterech znaków na token daje margines, ponieważ dialogi, skróty, interpunkcja oraz tekst w formacie specjalnym mogą być tokenizowane gęściej niż proza.

Nowsze modele obsługują znacznie dłuższe dane wejściowe, ale dzielenie ich na fragmenty nadal ma znaczenie. Większy zakres nie rozwiązuje problemu wydobywania informacji, ponieważ zbyt szerokie fragmenty łączą ze sobą niespowiązane tematy w jeden niejasny wektor.

Przebieg procesu

Najpierw należy podzielić załadowane strony na fragmenty:

from rag_qa.gutenberg import load_pages
from rag_qa.chunk import chunk_documents
from rag_qa.config import load_settings

s = load_settings()
print(s.summary())
# {
#   'chunk_size_chars': 800,
#   'chunk_overlap_chars': 150,
#   'stride_chars': 650,            # size − overlap; this sets chunk count
#   'model': 'all-MiniLM-L6-v2',
#   'model_max_tokens': 256,        # hard cap; overflow is silent
#   'embedding_dims': 384,
#   'max_safe_chunk_chars': 1024,   # 256 × 4
#   'book': "Franklin's Autobiography",
#   'gutenberg_id': 36151,
# }
pages = load_pages()
chunks = chunk_documents(pages, s.chunk_size, s.chunk_overlap)
print(len(pages), len(chunks), s.stride)

Po drugie, sprawdź każdy fragment pod kątem limitu tokenów modelu:

from sentence_transformers import SentenceTransformer
from rag_qa.tokens import check_chunk

m = SentenceTransformer("all-MiniLM-L6-v2")
print(m.max_seq_length)                    # 256
for c in chunks:
    r = check_chunk(c)
    if r.truncated:
        print("silent truncate:", r.n_chars, "chars /", r.n_tokens, "tokens")

Skrypt wyświetla maksymalną długość modelu, a następnie wymienia tylko te fragmenty, które zostaną cicho usunięte. Podczas zwykłego uruchomienia wyświetla się jedynie ta granica:

256

Gdy wszystko mieści się w ramach, to właśnie to widzisz. Jeśli niektóre elementy przekraczają te ramy, pojawiają się dodatkowe wiersze:

256
silent truncate: 1820 chars / 412 tokens
silent truncate: 960 chars / 301 tokens

Jak odczytać te linie:

Po trzecie, sprawdź, co zostanie zapisane:

from rag_qa.store import ingest, open_store

store = open_store()
ingest(chunks, store)
row = store.get(limit=1)
print(row["ids"][0])
print(row["documents"][0][:200])
print(len(row["embeddings"][0]), row["metadatas"][0])
# 384 floats, norm 1.0, metadata.page_label == printed [Pg N]

To fragment pokazuje pojedynczy kawałek po przetworzeniu i wyświetla trzy linie:

doc-12-p3-c0
She left school at the age of ten. The next sentence continues on the same page…
384 {'page_label': '3', 'source': 'notes.pdf'}

Czwartym krokiem jest zapytanie o to:

from rag_qa.retrieve import search, search_mmr

q = "why did Franklin want Britain to keep Canada"

for hit in search(q, k=5):
    print(f"{hit['score']:.3f}  p.{hit['metadata']['page_label']}  {hit['document'][:120]}")
# overlap makes near-duplicate hits; MMR trades a little score for diversity

for hit in search_mmr(q, k=5):
    print(hit["metadata"]["page_label"], hit["score"])

Przykład wyników:

0.812  p.7  I have long been of opinion that the foundations of the future grandeur and stability of the British empire lie in America
0.781  p.7  they are, nevertheless, broad and strong enough to support the greatest political structure that human wisdom ever yet
0.744  p.7  I am, therefore, by no means for restoring Canada. If we keep it all the country from the St. Lawrence to the Mississippi
0.691  p.8  I left England about the end of August, 1762, in company with ten sail of merchant ships
0.640  p.6  In this Autobiography Franklin tells of his own life to the year 1757, when he went to England

Najpopularniejsze wyniki często pochodzą z tej samej strony. To efekt nakładania się fragmentów, ponieważ sąsiadujące części jednego zdania idealnie do siebie pasują.

Nakładanie się pokazane na małym przykładzie

Weź krótki fragment o długości około 190 znaków z wprowadzenia książki, opisujący dzieciństwo autora w Bostonie: wczesne nawyki ciężkiej pracy i oszczędności, rok spędzony w szkole gramatycznej przy School Street oraz odejście w wieku dziesięciu lat. Podziel go, używając rozmiaru fragmentu wynoszącego 60 znaków.

Gdy nie ma nakładania się, narzędzie dzielące cięcie dokonuje dokładnie w tym miejscu:

[0] 59  His boyhood in Boston was a stern beginning of the habit of
[1] 55  hard work and rigid economy which marked the man. For a
[2] 58  year he went to the Latin Grammar School on School Street,
[3] 31  but left off at the age of ten.

Fragment 0 kończy się na „the habit of”, a fragment 1 zaczyna się od „hard work”, przy czym nic ich nie łączy. Wyrażenie „habit of hard work” nigdy nie występuje w całości w żadnym z fragmentów. Jeśli cięcie następuje w środku zdania, każdy wektor koduje połowę idei, a zapytanie wymagające pełnego zdania słabo odpowiada obu połowom.

Przy nakładaniu się 15 znaków:

[0] 59  His boyhood in Boston was a stern beginning of the habit of
[1] 57  ⟦the habit of⟧ hard work and rigid economy which marked the
[2] 55  ⟦marked the⟧ man. For a year he went to the Latin Grammar
[3] 58  ⟦Latin Grammar⟧ School on School Street, but left off at the
[4] 22  ⟦off at the⟧ age of ten.

Znak poniżej wskazuje, który tekst powtarza się z poprzedniego fragmentu:

⟦…⟧ = text repeated from the previous chunk

Żaden fragment nie osiąga 60 znaków, co ponownie pokazuje, że ta wielkość jest jedynie górną granicą. Pominięcie nakładania się fragmentów daje mniejszy indeks i brak powtórzonych wyników, ale wszystko to, co przypadkowo zostanie przecięte podczas dzielenia, jest tracione. Nakładanie się fragmentów zapewnia spójność i nie służy jako dodatkowy kontekst dla samego siebie.

Cztery zadania i narzędzia do ich wykonywania

RAG składa się z czterech zadań: ładowanie tekstu, jego dzielenie, wgrzewanie informacji oraz przechowywanie i wyszukiwanie wektorów. Żadna pojedyncza biblioteka nie jest „RAG”. Narzędzia do dzielenia tekstu i koordynacji procesów:

Modele do wgrzewania informacji:

Popularne bazy danych wektorowych uzupełniają ten zestaw narzędzi.

Literatura pokrewna

  • Chunking: Pomijana decyzja RAG stojąca za błędnymi odpowiedziami — Dlaczego niewłaściwe dzielenie dokumentów pogarsza jakość wyszukiwania, jak pomocne są podejścia uwzględniające strukturę oraz chunking typu rodzic-dziecko, oraz jak sprawdzić, czy używane przez Ciebie chunki są skuteczne.