Chunking dokumentów w Pythonie: fragmenty i metadane źródłowe

Piotr Dul

23-10-2026

(aktualizacja: 23-10-2026)

Jak podzielić dokument na fragmenty pod wyszukiwanie semantyczne: rozmiar, granice, overlap i metadane źródłowe. Z działającym kodem i polskimi przykładami.

Chunking dokumentów w Pythonie: fragmenty i metadane źródłowe
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:

WersjaSłówZnaków na tokenTokenów
Angielska: „The subscription costs 49 zloty per month and renews automatically.”10ok. 4,515
Polska: „Abonament kosztuje 49 złotych miesięcznie i odnawia się automatycznie.”9ok. 3,520

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:

OverlapGdzie jest „instrument płatniczy w ciągu 7 dni”
0 tokenówRozdzielone na dwa fragmenty: „…instrument płatniczy” i „w ciągu 7 dni roboczych…”
10 tokenówOba 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łuFragmentówMiniLM-L12 (384 wym.)mpnet-base (768 wym.)
Co 300 znaków63 / 55 / 5
Okna po 100 tokenów, overlap 2064 / 54 / 5
Okna po 50 tokenów, overlap 10122 / 53 / 5
Sekcje, limit 150 tokenów53 / 55 / 5
Sekcje, limit 60 tokenów85 / 55 / 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) MiniLM trafił 3 z 5, a mpnet 5 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:

PolePo co
idJednoznaczne odwołanie do fragmentu, np. regulamin.md#2.
sourcePlik lub adres, z którego pochodzi tekst.
sectionNagłówek sekcji do pokazania w wyniku.
startPozycja w oryginale, żeby wrócić do pełnego kontekstu.
tokensRozmiar 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ść:

  1. 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.
  2. Dodaj overlap (ok. 10–20%) tylko tam, gdzie tniesz „na ślepo” po tokenach lub znakach. Przy podziale po akapitach zwykle nie jest potrzebny.
  3. Przygotuj 10–20 pytań z odpowiedziami i policz trafność dla 2–3 wariantów, tak jak w tabeli wyżej.
  4. Powtórz test po zmianie modelu. Wynik dla jednego modelu nie przenosi się na inny.
  5. 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ą.

Konsultacja i warsztaty devmentor.pl

Masz pytania?

Partnerzy

Firmy, z którymi współpracujemy

To partnerzy odpowiedzialni za staże po naszych kursach. Organizują je dla najlepszych absolwentów.

Zobacz wszystkich partnerów →
  • VR Medical
  • Sophona
  • Robot Force