Dlaczego firmowy asystent RAG „nie ma tej informacji — chunkowanie strukturalne zamiast liczenia znaków
Najczęstsza przyczyna, dla której wewnętrzny asystent RAG odpowiada „nie mam tej informacji w bazie wiedzy" na pytanie o dokument, który ewidentnie w tej bazie jest, nie leży w modelu, promptcie ani w wyszukiwaniu wektorowym. Leży w chunkowaniu: dzielenie tekstu po liczbie znaków odcina nagłówek sekcji od jej treści, a fragment bez nagłówka jest dla modelu treścią bez informacji, czego dotyczy. W zmierzonym korpusie dotyczyło to 47-73% fragmentów.
Problem w jednym zdaniu
Sekcja dokumentu — osoba, temat, procedura — rozciąga się na kilka akapitów. Splitter dzielący po chunkSize wkłada nagłówek tylko do pierwszego fragmentu. Pozostałe trafiają do bazy wektorowej jako treść bez atrybucji: model dostaje „zakres obowiązków: prowadzenie korespondencji, obsługa wniosków…" bez nazwiska i nie ma żadnej możliwości przypisać tego do właściwej osoby.
Nazywam to dalej chunkiem bez atrybucji (sierotą). W dokumencie, w którym osiem osób ma podobne listy obowiązków, retrieval zwraca dziesięć nieodróżnialnych fragmentów, a model — całkowicie słusznie — odmawia odpowiedzi. To zachowanie, którego zwykle oczekujemy od dobrze skonfigurowanego RAG-a, więc na pierwszy rzut oka wygląda jak brak danych, a nie jak błąd pipeline'u.
Dlaczego to nie wychodzi w testach na jednym dokumencie
Kluczowa obserwacja, na której łatwo się przejechać: problem nie ujawnia się w małej kolekcji. Przy topK: 30 i jednym wgranym dokumencie retrieval pobiera po prostu wszystkie jego fragmenty, więc nagłówek trafia do kontekstu „przy okazji" — w innym chunku, ale w tym samym oknie. Odpowiedzi są poprawne, testy zielone, wnioski fałszywe.
Awaria pojawia się dopiero wtedy, gdy baza jest realna i topK nie obejmuje już całego dokumentu. Stąd praktyczny wniosek:
Metryka „% fragmentów bez atrybucji" jest wskaźnikiem wyprzedzającym. Odpowiedzi end-to-end na małej kolekcji — nie są.
Dlatego dalej mierzę oba, ale decyzje podejmuję na podstawie pierwszej metryki.
Pomiary
Pięć dokumentów, cztery różne kształty struktury, pipeline: Docling → chunker → Qdrant. Symulator splittera zwalidowałem względem realnej kolekcji (34 chunki w symulacji vs 35 realnych, ±1). W komórkach: liczba fragmentów i odsetek fragmentów bez atrybucji.
| dokument (typ / struktura) | 600/100 | 1000/100 | 1500/100 | 2000/100 | strukturalne |
|---|---|---|---|---|---|
docx, 13× ### | 34 — 62% | 21 — 38% | 11 — 0% | 8 — 0% | 13 — 0% |
docx, 15× ##/###, 115 punktów listy | 36 — 64% | 15 — 20% | 12 — 0% | 7 — 0% | 15 — 0% |
| docx, 0 nagłówków, 7 tytułów zrobionych pogrubieniem | 17 — 47% | 9 — 11% | 6 — 0% | 4 — 0% | 10 — 0% |
| docx, 0 sygnałów struktury w markdownie | 33 — 45% urwanych zdań | 18 | 11 | 8 | fallback (patrz warstwa 2) |
PDF, 4× ##, najdłuższa sekcja 3553 znaki | 11 — 73% | 6 — 67% | 4 — 75% | 3 — 67% | 6 — 0% |
Trzy rzeczy z tej tabeli:
- Konfiguracja 600/100 — bardzo popularne ustawienie domyślne — jest zła dla każdego przebadanego dokumentu. 47-73% fragmentów bez atrybucji.
- Podniesienie
chunkSizenie jest rozwiązaniem generalnym. Działa wyłącznie wtedy, gdychunkSize≥ najdłuższa sekcja w dokumencie. Dla PDF-a z sekcją 3553 znaków nie pomaga wcale: przy 1500 wynik jest gorszy niż przy 600. To łatka dopasowana do konkretnych plików, która cicho przestanie działać przy pierwszym dłuższym dokumencie — a nikt tego nie zauważy, bo baza nie zgłasza błędu. - Rozmiar chunku to złe pokrętło. Właściwym pokrętłem jest granica podziału.
Dodatkowa obserwacja przy 1500/100, groźniejsza niż odmowa odpowiedzi: fragmenty zaczynają przecinać granice encji. Chunk zawiera obowiązki osoby N, a na końcu — nazwisko osoby N+1. Ryzykiem przestaje być „brak odpowiedzi", a staje się „przypisanie cudzych obowiązków". W testach model sobie poradził, ale dane w bazie były jednoznacznie dwuznaczne.
Warstwa 1: chunkowanie strukturalne
Zamiast liczyć znaki, dzielę po strukturze dokumentu — nagłówkach markdown, które produkuje Docling. MAX_LEN przestaje być rozmiarem chunku, a staje się wyłącznie ograniczeniem górnym: gdy sekcja je przekracza, dzielę ją dalej, ale powtarzam ścieżkę nagłówków (breadcrumb) w każdym fragmencie. To właśnie dlatego długość sekcji przestaje mieć znaczenie i kolumna „strukturalne" ma zera również dla PDF-a z sekcją 3553 znaków.
Dwie rzeczy, które trzeba obsłużyć, żeby to działało na realnych plikach z Worda i PDF-ach:
- Tytuły zrobione pogrubieniem, nie stylem nagłówkowym. Docling zamienia je na
**tekst**, nie na#. Bez rozpoznawania tego wzorca chunker widzi zero sekcji tam, gdzie jest ich siedem. - Nagłówki rozbite przez Docling na dwie linie — zdarza się przy PDF-ach; wymagają scalenia przed podziałem.
// Chunkowanie strukturalne (header-aware).
// Każdy fragment niesie ze sobą nagłówek swojej sekcji, więc pobrany z bazy
// wektorowej fragment jest ZAWSZE przypisywalny do encji/tematu.
const MAX = 1500;
function stripMarks(l) {
let s = l.trim();
while (s.length && s[0] === '#') s = s.slice(1);
s = s.split('*').join('');
return s.trim();
}
function isHash(l) { return l.trim().startsWith('#'); }
// Samotna, krótka, pogrubiona linia to też nagłówek — tak Docling renderuje
// tytuły sekcji zrobione w Wordzie pogrubieniem zamiast stylem "Nagłówek".
function isEmph(l) {
const s = l.trim();
if (s.length < 6 || s.length > 120) return false;
if (!(s.startsWith('**') && s.endsWith('**'))) return false;
const inner = stripMarks(s);
return inner.length > 3 && inner[inner.length - 1] !== '.';
}
const out = [];
for (const item of $input.all()) {
const meta = item.json;
const text = String(meta.text || '');
// 1. Tekst -> sekcje
const secs = [];
let cur = null;
for (const l of text.split('\n')) {
if (isHash(l) || isEmph(l)) {
if (cur) secs.push(cur);
cur = { head: stripMarks(l), body: [] };
} else {
if (!cur) cur = { head: '', body: [] };
cur.body.push(l);
}
}
if (cur) secs.push(cur);
// 2. Sekcje -> fragmenty
const chunks = [];
for (const s of secs) {
const body = s.body.join('\n').trim();
if (!body && !s.head) continue;
const prefix = s.head ? s.head + '\n' : '';
if ((prefix + body).length <= MAX) {
chunks.push((prefix + body).trim());
continue;
}
// Sekcja za długa: dziel po akapitach, ale POWTARZAJ nagłówek w każdym fragmencie.
const paras = body.split('\n\n').map(p => p.trim()).filter(p => p.length > 0);
const budget = MAX - prefix.length;
let buf = [];
for (const p of paras) {
if (buf.length && (buf.join('\n\n').length + 2 + p.length) > budget) {
chunks.push((prefix + buf.join('\n\n')).trim());
buf = [];
}
buf.push(p);
}
if (buf.length) chunks.push((prefix + buf.join('\n\n')).trim());
}
const final = chunks.filter(c => c.length > 0);
if (!final.length) {
throw new Error(
'Chunkowanie nie wyprodukowało żadnego fragmentu dla "' +
(meta.source_file || '?') + '" — przerywam, nie wstawiam pustki do bazy.'
);
}
for (let i = 0; i < final.length; i++) {
out.push({
json: Object.assign({}, meta, {
text: final[i], chunk_index: i, chunk_total: final.length,
}),
});
}
}
return out;
Zwrócę uwagę na throw na końcu: pusty wynik chunkowania to sytuacja, w której najgorszą możliwą reakcją jest wstawienie do bazy niczego i przejście dalej z zielonym statusem. Ingestia do bazy wektorowej musi psuć się głośno, bo cicha awaria zostaje w bazie na miesiące.
W przepływie n8n ten węzeł ląduje między przygotowaniem tekstu z Doclinga a loaderem, i produkuje po jednym itemie na fragment. Dwie zmiany towarzyszące, bez których to nie wystarczy:
Recursive Character Text Splitter→chunkSize: 4000,chunkOverlap: 0. Zostaje wyłącznie jako siatka bezpieczeństwa dla patologicznych przypadków; normalnie nie dzieli już niczego.- W
Default Data Loadermetadane przez$json.*, a nie$('nazwa_wezla').item.json.*. Do loadera wpływa teraz wiele itemów, więc odwołania oparte na paired-item przestają być jednoznaczne. Do metadanych warto dodaćbreadcrumb,chunk_index,chunk_total— przydają się w diagnostyce.
Warstwa 2: wzbogacanie kontekstem, ale tylko tam, gdzie trzeba
Chunkowanie strukturalne wymaga struktury. Dokument, który jej nie ma — ani #, ani pogrubionych tytułów — degraduje się do pakowania akapitów, czyli do dokładnie tego samego problemu.
Dla takich plików odpala się druga warstwa: If Needs Context → Generate Chunk Context → Apply Context. LLM (temperature: 0) pisze jedno zdanie umiejscawiające fragment, na bazie tekstu poprzedzającego, i to zdanie doklejam do treści przed embedowaniem. Trzy decyzje projektowe, które uważam tu za istotniejsze od samego promptu:
- Warstwa odpala się tylko dla fragmentów bez atrybucji. Dla dokumentów z nagłówkami koszt LLM wynosi zero. To nie jest „wzbogacanie kontekstem" jako etap pipeline'u, tylko jako ratunek.
- Gdy nie da się ustalić kontekstu, model zwraca
NIEUSTALONEi fragment zostaje bez zmian. Nigdy nie zmyśla atrybucji. Fragment bez atrybucji jest bezużyteczny; fragment z wymyśloną atrybucją jest szkodliwy. - Bezpiecznik kosztowy (u mnie
CAP_LLM = 40wywołań na dokument). Jeden duży nieustrukturyzowany skan potrafiłby wygenerować tysiące wywołań — wolno, drogo, timeout egzekucji. Powyżej limitu fragmenty idą do bazy bez kontekstu, a asercja kończy się statusemok_z_ostrzeżeniemi informacją, że plik wymaga nagłówków u źródła.
Parowanie odpowiedzi LLM z fragmentami robię przez pairedItem, nie przez indeks w tablicy. Przy batchowaniu indeks nie jest bezpieczny, a błąd tutaj oznacza przypisanie kontekstu jednej osoby do treści dotyczącej innej — czyli dokładnie tę klasę błędu, którą cała zmiana miała wyeliminować.
Hardening: asercja spójności ingestii
Najbardziej opłacalny element całej zmiany kosztował kilkanaście linii kodu: Count Points In Qdrant → Assert Ingest Integrity. Liczba punktów w bazie po ingestii musi się zgadzać z liczbą fragmentów wyprodukowanych przez chunker. Jeśli się nie zgadza — twardy błąd egzekucji.
To nie jest paranoja. W tym projekcie parametr textSplittingMode na Default Data Loader trzykrotnie sam wrócił do wartości domyślnej — po czym cały skonfigurowany splitter był po cichu ignorowany, a dokumenty dzielił splitter domyślny. Za każdym razem baza psuła się bezobjawowo i za każdym razem wyszło to dopiero z jakości odpowiedzi. Asercja łapie ten regres w sekundę po ingestii, zamiast po tygodniu w rozmowie z użytkownikiem.
Ta sama asercja zwraca metrykę bez_atrybucji_pct i ostrzeżenie przy słabej strukturze dokumentu. Progu twardego nie stawiam (>20% traktuję jako sygnał „popraw plik u źródła"), bo są dokumenty, które po prostu takie są.
Higiena dokumentów źródłowych
Wniosek, który przy okazji wyjaśnił kilka wcześniejszych niepowodzeń: Docling zachowuje nagłówki tylko wtedy, gdy plik używa prawdziwych stylów nagłówkowych Worda (Nagłówek 1/2/3).
Nie wystarczy, że coś nazywa się nagłówkiem. Jeden z dokumentów miał w Wordzie styl o nazwie „Heading 2", a Docling nie wyprodukował z niego ### — plik nosił ślady wklejenia z HTML i faktycznym stylem akapitu był Normal (Web). Dopiero ustawienie stylu programowo (python-docx) dało ### w markdownie. Podobnie: samo scalenie rozbitych akapitów w jeden blok nie zmieniło nic.
Weryfikacja jest banalna i warto ją zrobić przed jakąkolwiek dyskusją o parametrach chunkowania: w markdownie z Doclinga muszą pojawić się linie zaczynające się od #. Jeśli nie ma ani ich, ani samotnych pogrubionych tytułów — problemem nie jest pipeline, tylko plik.
Czego to nie naprawia
Uczciwie o granicach, bo tu łatwo przeholować z obietnicami:
- Pytania-wyliczenia. „Podaj wszystkie osoby z działu X" przy 13 encjach i
topK: 8fizycznie nie może zwrócić kompletnej listy — retrieval semantyczny zwróci osiem z trzynastu. Żadne chunkowanie tego nie naprawi, bo to zapytanie do bazy danych, nie wyszukiwanie podobieństwa. Docelowe rozwiązanie to trzymanie danych tabelarycznych (kadry, cenniki, słowniki) w tabeli i odpytywanie ich narzędziem, a nie wektorami. topKmożna po tej zmianie obniżyć, bo fragmenty są samowystarczalne — ale dopiero po rozwiązaniu punktu wyżej, bo pytania-wyliczenia wymagajątopK≥ liczby encji.- Zmiana dotyczy tylko nowo wgrywanych dokumentów. Istniejące punkty mają stare chunkowanie i wymagają ponownego wgrania. Uwaga na deduplikację po sumie kontrolnej: pominie plik o niezmienionej treści, więc potrzebne jest albo usunięcie starych punktów, albo jawna flaga „wymuś ponowne przetworzenie" w formularzu. Bez tej flagi przeładowanie bazy przez interfejs jest po prostu niewykonalne — dopisywałem ją już w trakcie wdrożenia.
Nie sprawdziłem też zachowania na dokumentach skanowanych (OCR — markdown z Doclinga bywa tam wyraźnie gorszy, nagłówki mogą nie zostać rozpoznane wcale), na dokumentach z tabelami ani na .pptx/.xlsx.
Co z tego zabrać
Jeżeli wdrażasz RAG na dokumentach firmowych:
- Zmierz odsetek fragmentów bez nagłówka/atrybucji przed dyskusją o modelu, promptcie i rerankerze. To jedna pętla po chunkach.
- Nie testuj jakości chunkowania na jednej wgranej teczce — przy dużym
topKwszystko wygląda dobrze. - Dziel po strukturze, a
MAX_LENtraktuj jako ograniczenie, nie jako rozmiar. Przy podziale długiej sekcji powtarzaj breadcrumb. - Postaw asercję liczby punktów po ingestii. Ciche regresy w konfiguracji splitterów zdarzają się realnie i nie zgłaszają się same.
- Wzbogacanie kontekstem przez LLM trzymaj jako warstwę awaryjną z bezpiecznikiem kosztowym i jawnym „nie wiem", nie jako domyślny etap pipeline'u.
- Dane tabelaryczne trzymaj w tabeli. Wektory są do tekstu.