Chunking to podział dokumentu na fragmenty, z których każdy zamieniasz na osobny embedding, bo model porównuje znaczenie krótkich tekstów, a nie całych plików. Najlepiej dzielić po granicach tekstu (nagłówkach i akapitach), mieć limit w tokenach i do każdego fragmentu zapisać źródło: plik, sekcję i pozycję w oryginale. Rozmiar i metodę podziału sprawdzasz na kilku pytaniach, na które znasz odpowiedź.
W pierwszej części cyklu zamieniałeś pojedyncze zdania na wektory i porównywałeś je w numpy. Prawdziwy dokument ma setki zdań, więc pojawia się pytanie: co dokładnie zamienić na wektor? Cały plik? Każde zdanie? A skoro wynik wyszukiwania ma być użyteczny, skąd wiadomo, z którego miejsca dokumentu pochodzi?
Odpowiedzi na oba pytania to chunking i metadane. W tym artykule podzielisz polski regulamin na fragmenty trzema sposobami, zmierzysz, który z nich znajduje właściwą odpowiedź, i zapiszesz indeks tak, żeby każdy wynik wskazywał źródło. W ostatniej części złożysz z tego wyszukiwarkę semantyczną działającą w pamięci.
Czym jest chunking i dlaczego nie wystarczy embedding całego dokumentu?
Chunking to podział dokumentu na fragmenty (chunki), z których każdy dostaje własny embedding. Robimy tak z dwóch powodów:
- Model ma limit długości. Dłuższy tekst zostanie obcięty albo odrzucony, więc część dokumentu nigdy nie trafi do wektora.
- Jeden wektor to jedno „znaczenie”. Wektor całego regulaminu to uśredniony sens wszystkich tematów naraz: płatności, haseł i danych osobowych. Pytanie o jeden z nich wypada przy takim wektorze słabo. Wektor jednej sekcji jest znacznie bliżej pytania o ten temat.
Z drugiej strony fragmenty nie mogą być za małe. Zdanie wyrwane z kontekstu („Zwrot trafia na ten sam instrument płatniczy w ciągu 7 dni roboczych”) nie mówi, czego dotyczy zwrot. Szukamy więc kompromisu, a kompromis trzeba zmierzyć.
Jak policzyć rozmiar fragmentu w tokenach, a nie w znakach?
Limity modeli i koszty API liczy się w tokenach, więc rozmiar fragmentu też warto liczyć w nich. Użyjemy tiktoken, o którym piszemy w artykule o tokenach, kontekście i kosztach w API OpenAI:
import tiktoken
ENC = tiktoken.get_encoding("o200k_base") # kodowanie modeli gpt-4o
def count_tokens(text: str) -> int:
return len(ENC.encode(text))
Polski wychodzi „drożej” niż angielski. To samo zdanie o abonamencie:
| Wersja | Słów | Znaków na token | Tokenów |
|---|---|---|---|
| Angielska: „The subscription costs 49 zloty per month and renews automatically.” | 10 | ok. 4,5 | 15 |
| Polska: „Abonament kosztuje 49 złotych miesięcznie i odnawia się automatycznie.” | 9 | ok. 3,5 | 20 |
Konsekwencja praktyczna: reguła „1 token to ok. 4 znaki”, którą znajdziesz w wielu poradnikach, dla polskiego jest zbyt optymistyczna. Cały nasz przykładowy regulamin (1514 znaków, 228 słów) to 467 tokenów, czyli ok. 3,2 znaku na token. Fragment 100-tokenowy to w nim ok. 320 znaków, nie 400. Jeśli rozmiar podasz w znakach, nie wiesz dokładnie, ile zmieści się w limicie modelu. Podziały poniżej porównujemy właśnie na tym regulaminie.
Jak podzielić tekst na fragmenty o stałej długości?
Najprostszy podział tnie tekst co N znaków. Kod jest banalny:
def split_by_chars(text: str, size: int = 300) -> list[tuple[int, str]]:
return [(i, text[i:i + size]) for i in range(0, len(text), size)]
Wada widać od razu na wyniku: fragment zaczyna się w środku zdania i potrafi obejmować końcówkę jednej sekcji i początek drugiej. Wektor takiego fragmentu miesza dwa tematy:
print(repr(split_by_chars(DOC, 300)[3][1][:120]))
# 'ocząłeś żadnego kursu. Zwrot trafia na ten sam instrument płatniczy w ciągu 7 dni roboczych.\n\n## Reset hasła\n\nHasło zres'
Wersja po tokenach jest dokładniejsza, bo trzyma się limitów modelu. Dodajemy do niej overlap, czyli nakładanie sąsiednich fragmentów:
def split_by_tokens(text: str, size: int = 100, overlap: int = 20) -> list[str]:
tokens = ENC.encode(text)
step = size - overlap
return [
ENC.decode(tokens[i:i + size])
for i in range(0, max(len(tokens) - overlap, 1), step)
]
Po co jest overlap w chunkingu i ile go potrzeba?
Overlap sprawia, że końcówka jednego fragmentu powtarza się na początku następnego. Dzięki temu zdanie przecięte granicą nie ginie. Widać to na zdaniu o zwrocie, gdy tniemy na okna po 50 tokenów:
| Overlap | Gdzie jest „instrument płatniczy w ciągu 7 dni” |
|---|---|
| 0 tokenów | Rozdzielone na dwa fragmenty: „…instrument płatniczy” i „w ciągu 7 dni roboczych…” |
| 10 tokenów | Oba w jednym fragmencie |
Bez overlapu pytanie „w jakim czasie dostanę zwrot na konto?” ma odpowiedź rozbitą na dwa wektory, z których żaden nie niesie pełnej informacji. Zwykle wystarcza overlap rzędu 10–20% rozmiaru fragmentu. Większy zwiększa liczbę fragmentów (a tym samym rozmiar indeksu i koszt embeddingów) i daje coraz mniej.
Uwaga na jedną pułapkę: tnąc po tokenach, możesz przeciąć słowo. W naszym wyniku fragment zaczął się od „ząłeś”, czyli końcówki słowa „rozpocząłeś”. Dlatego dla tekstu, który ma strukturę, lepiej dzielić tam, gdzie granice już są.
Jak podzielić dokument po nagłówkach i akapitach?
Regulamin, dokumentacja czy artykuł mają nagłówki i akapity: naturalne granice znaczenia. Dzielimy po nagłówkach ##, a w obrębie sekcji łączymy akapity, dopóki nie przekroczymy limitu tokenów. Każdy fragment dostaje na początek tytuł sekcji, żeby wektor „wiedział”, o czym jest.
Najpierw struktura fragmentu z metadanymi. Dokładnie o to chodzi w drugiej połowie artykułu:
from dataclasses import dataclass
@dataclass
class Chunk:
id: str
text: str
source: str # plik, z którego pochodzi fragment
section: str # nagłówek sekcji
start: int # pozycja pierwszego znaku sekcji w oryginale
tokens: int
Teraz sam podział:
import re
def split_by_sections(text: str, source: str, max_tokens: int = 150) -> list[Chunk]:
chunks: list[Chunk] = []
parts = re.split(r"(?m)^(## .+)$", text)
pozycja = len(parts[0])
for heading, body in zip(parts[1::2], parts[2::2]):
section = heading.removeprefix("## ").strip()
start = text.index(heading, pozycja)
pozycja = start + len(heading) + len(body)
bufor: list[str] = []
for akapit in (a.strip() for a in body.split("\n\n") if a.strip()):
kandydat = "\n\n".join(bufor + [akapit])
if bufor and count_tokens(f"{section}\n\n{kandydat}") > max_tokens:
chunks.append(_make(chunks, bufor, source, section, start))
bufor = []
bufor.append(akapit)
if bufor:
chunks.append(_make(chunks, bufor, source, section, start))
return chunks
def _make(chunks, bufor, source, section, start) -> Chunk:
tekst = f"{section}\n\n" + "\n\n".join(bufor)
return Chunk(
id=f"{source}#{len(chunks)}",
text=tekst,
source=source,
section=section,
start=start,
tokens=count_tokens(tekst),
)
Dla przykładowego regulaminu z max_tokens=150 powstaje 5 fragmentów (po jednym na sekcję), a z max_tokens=60 8, bo dłuższe sekcje są dzielone po akapitach. Żaden akapit nie zostaje przecięty w pół zdania.
Który sposób podziału znajduje właściwą odpowiedź?
Nie zgaduj, zmierz. Przygotowaliśmy 5 pytań, na które odpowiedź leży w konkretnym miejscu regulaminu (np. „Czy dostanę zwrot pieniędzy?” → fragment zawierający „14 dni”). Dla każdego sposobu podziału liczymy embeddingi fragmentów i sprawdzamy, czy najbliższy fragment zawiera odpowiedź:
import numpy as np
pytania = {
"Czy dostanę zwrot pieniędzy?": "14 dni",
"Jak długo ważny jest link do zmiany hasła?": "ważny przez godzinę",
"Ile kosztuje roczna subskrypcja?": "490 zł",
"Kiedy usuniecie moje dane?": "30 dni",
"Czy mogę dostać fakturę na firmę?": "Fakturę VAT",
}
def trafnosc(model, teksty: list[str]) -> int:
E = model.encode(teksty, normalize_embeddings=True)
trafienia = 0
for pytanie, odpowiedz in pytania.items():
q = model.encode(pytanie, normalize_embeddings=True)
trafienia += odpowiedz in teksty[int(np.argmax(E @ q))]
return trafienia
Wyniki dla dwóch modeli wielojęzycznych:
| Sposób podziału | Fragmentów | MiniLM-L12 (384 wym.) | mpnet-base (768 wym.) |
|---|---|---|---|
| Co 300 znaków | 6 | 3 / 5 | 5 / 5 |
| Okna po 100 tokenów, overlap 20 | 6 | 4 / 5 | 4 / 5 |
| Okna po 50 tokenów, overlap 10 | 12 | 2 / 5 | 3 / 5 |
| Sekcje, limit 150 tokenów | 5 | 3 / 5 | 5 / 5 |
| Sekcje, limit 60 tokenów | 8 | 5 / 5 | 5 / 5 |
Co z tego wynika:
- Najmniejsze okna nie są najlepsze. Okna po 50 tokenów wypadły najsłabiej: zdanie z odpowiedzią było rozdzielone albo oderwane od kontekstu.
- Granice mają znaczenie bardziej niż sam rozmiar. Sekcje z limitem 60 tokenów trafiły we wszystkie pytania na obu modelach. To fragmenty, w których jedna myśl nie jest rozcięta.
- Mniejszy model jest bardziej wrażliwy na podział. Z fragmentami po sekcjach (limit 150)
MiniLMtrafił 3 z 5, ampnet5 z 5. Większy model potrafi ukryć nieidealny podział, ale nie zawsze możesz sobie na niego pozwolić.
To mały eksperyment: jeden krótki dokument i pięć pytań, więc różnica jednego trafienia niczego nie dowodzi. Pokazuje jednak metodę: pytania z odpowiedzią, kilka wariantów podziału i liczba trafień. Dla własnych dokumentów przygotuj 10–20 takich pytań, zanim wybierzesz rozmiar fragmentu.
Zacznij od 5–10 pytań, na które znasz odpowiedź, i sprawdź, w którym fragmencie ta odpowiedź leży. W naszym teście tnięcie na okna po 50 tokenów trafiło w 2 z 5 pytań (na mniejszym modelu), a podział po sekcjach z limitem 60 tokenów w 5 z 5. Rozmiar fragmentu mierzysz, nie zgadujesz.
Jakie metadane zapisać razem z fragmentem?
Wynik wyszukiwania bez źródła jest mało użyteczny: użytkownik nie sprawdzi, skąd pochodzi odpowiedź, a ty nie zdiagnozujesz błędnego trafienia. W strukturze Chunk zapisujemy więc:
| Pole | Po co |
|---|---|
id | Jednoznaczne odwołanie do fragmentu, np. regulamin.md#2. |
source | Plik lub adres, z którego pochodzi tekst. |
section | Nagłówek sekcji do pokazania w wyniku. |
start | Pozycja w oryginale, żeby wrócić do pełnego kontekstu. |
tokens | Rozmiar fragmentu, przydatny przy sklejaniu kontekstu do promptu. |
Dobra reguła: zapisuj wszystko, co przyda ci się, gdy trzeba będzie wyjaśnić, dlaczego wyszukiwarka zwróciła właśnie ten fragment. Metadane są tanie, a ich brak boli dopiero przy pierwszej błędnej odpowiedzi.
Jak zapisać embeddingi razem z metadanymi na dysku?
Wektory trzymaj w pliku .npy, a metadane w JSON-ie. Oba zapisujesz w tej samej kolejności, więc wiersz i macierzy odpowiada elementowi i listy:
import json
from dataclasses import asdict
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
chunks = split_by_sections(DOC, "regulamin.md", max_tokens=60)
wektory = model.encode([c.text for c in chunks], normalize_embeddings=True)
np.save("index.npy", wektory)
with open("chunks.json", "w", encoding="utf-8") as plik:
json.dump([asdict(c) for c in chunks], plik, ensure_ascii=False, indent=2)
Dwie rzeczy, o których łatwo zapomnieć: ensure_ascii=False, żeby plik był czytelny (z polskimi znakami zamiast ł), i nazwa modelu w nazwie pliku albo osobnym polu, bo (jak w pierwszej części) wektory z różnych modeli są niezgodne.
Odczyt i wyszukiwanie ze źródłem:
wektory = np.load("index.npy")
with open("chunks.json", encoding="utf-8") as plik:
meta = json.load(plik)
q = model.encode("Czy dostanę zwrot pieniędzy?", normalize_embeddings=True)
najlepszy = meta[int(np.argmax(wektory @ q))]
print(najlepszy["source"], najlepszy["section"], najlepszy["start"])
# regulamin.md Rezygnacja i zwrot pieniędzy 631
wektory.shape to teraz (8, 384), a odpowiedź niesie ze sobą plik, sekcję i pozycję w oryginale. To wystarczy, żeby pokazać użytkownikowi źródło albo zacytować pełną sekcję.
Dlaczego tekst z PDF-a potrafi zepsuć embeddingi?
Tekst wyciągnięty z PDF-a bywa „brudny”. Jednym z problemów jest zapis polskich liter: ten sam znak „ó” może być jednym symbolem albo literą „o” z doklejonym akcentem. Dla człowieka wyglądają identycznie, dla kodu nie:
import unicodedata
s = "Zwrot pieniędzy przysługuje w ciągu 14 dni, żółć gęślą jaźń"
d = unicodedata.normalize("NFD", s) # litery z osobnymi akcentami
print(len(s), len(d)) # 59 69
print(count_tokens(s), count_tokens(d)) # 25 40
print(s == d) # False
Zapis z osobnymi akcentami ma więcej znaków i znacznie więcej tokenów (40 zamiast 25), a porównanie tekstów (==, in) przestaje działać. Wyszukiwanie po frazie nie znajdzie tego, co widzisz na ekranie. Przed podziałem na fragmenty znormalizuj tekst do jednej postaci:
tekst = unicodedata.normalize("NFC", tekst)
To jedna linijka, która oszczędza godziny debugowania. Poza tym po wyciągnięciu tekstu z PDF-a obejrzyj kilka fragmentów oczami, zanim policzysz embeddingi: rozbite wyrazy, nagłówki stron i numery wklejone w środek zdań psują wektory tak samo jak zły podział.
Jak dobrać rozmiar fragmentu dla własnych dokumentów?
Nie ma jednej wartości. Praktyczna kolejność:
- Podziel po strukturze tekstu (nagłówki, akapity), a limit w tokenach traktuj jako górną granicę. Dla krótkich odpowiedzi typu FAQ dobrze działają fragmenty rzędu kilkudziesięciu tokenów (u nas 60), dla dokumentacji technicznej częściej kilkaset.
- Dodaj overlap (ok. 10–20%) tylko tam, gdzie tniesz „na ślepo” po tokenach lub znakach. Przy podziale po akapitach zwykle nie jest potrzebny.
- Przygotuj 10–20 pytań z odpowiedziami i policz trafność dla 2–3 wariantów, tak jak w tabeli wyżej.
- Powtórz test po zmianie modelu. Wynik dla jednego modelu nie przenosi się na inny.
- Zapisuj metadane od początku. Dodawanie ich po fakcie oznacza ponowny podział i przeliczenie wszystkich embeddingów.
Zwróć uwagę, że to samo podejście dotyczy streszczania długich dokumentów, tyle że tam kluczowy jest limit okna kontekstu. Jeśli chcesz zobaczyć prostszy podział po tokenach w innym projekcie, zajrzyj do analizatora dokumentów z OpenAI.
Co dalej po podziale na fragmenty?
Masz embeddingi, fragmenty i metadane. Zostało złożenie ich w całość: wczytanie plików, zadawanie pytań z wiersza poleceń i pokazanie wyników razem ze źródłem. Zrobimy to w projekcie wyszukiwarki semantycznej w Pythonie działającej w pamięci. A gdy dokumentów będzie zbyt wiele na pamięć, czas na bazy wektorowe i RAG w Pythonie.
Jeśli chcesz przejść przez takie zagadnienia z mentorem, zobacz ścieżkę Python, w której każde zadanie i projekt jest sprawdzany przez mentora z informacją zwrotną.


