EN

RAG pipeline: chunking, retrieval, úskalí

Jak postavit RAG pipeline, která vrací správnou pasáž: chunking, hybridní retrieval, reranking — a jak poznat, že přestala fungovat.

Co je RAG

RAG (retrieval-augmented generation) spojuje vyhledávání s jazykovým modelem: k otázce se nejdřív najdou relevantní pasáže z vlastních dokumentů a model odpovídá z nich. Řeší to dvě slabiny samotného LLM — nezná vaše interní data a neumí říct, odkud odpověď vzal. Tato stránka je návod, jak takovou pipeline postavit a odladit.

Co tato dovednost pokrývá

Návrh a ladění retrieval-augmented generation (RAG) pipeline: rozdělení zdrojových dokumentů na chunky, vyhledávání relevantních chunků (dense, sparse nebo hybridní), reranking výsledků před předáním modelu a hodnocení, zda je vygenerovaná odpověď skutečně podložená tím, co bylo vyhledáno. Pro základy embeddingů a vektorového vyhledávání a lokální provoz modelů viz Vývoj AI a Ollama .

Kdy ji použít

  • Při návrhu nebo ladění RAG pipeline od začátku do konce
  • Při výběru strategie chunkingu a velikosti/překryvu chunků pro sadu zdrojových dokumentů
  • Při rozhodování mezi dense, sparse (klíčová slova) nebo hybridním retrievalem pro vyhledávací funkci
  • Při přidávání kroku rerankingu, protože top-k retrieval opakovaně míjí správnou pasáž
  • Při hodnocení, zda jsou odpovědi RAG systému skutečně podložené vyhledaným kontextem

Architektura RAG

RAG pipeline má dvě fáze:

Ingesce (offline): načtení dokumentů → rozdělení na chunky → embedding každého chunku → uložení vektorů a textu do vektorové databáze.

Dotaz (online): embedding uživatelské otázky → vyhledání top-k nejrelevantnějších chunků → volitelně reranking → předání vyhledaného textu spolu s otázkou LLM → generování odpovědi.

Dokumenty → Chunking → Embedding → Vektorová databáze
                                        │
Dotaz uživatele → Embedding → Retrieval ────┘
                              │
                          Reranking (volitelný)
                              │
                    Generování LLM → Odpověď

Strategie chunkingu

Způsob dělení zdrojového textu přímo ovlivňuje kvalitu retrievalu — příliš velké chunky ředí relevanční signál, příliš malé chunky ztrácejí kontext.

Pevná velikost s překryvem:

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start += chunk_size - overlap
    return chunks

Rekurzivní dělení (zkouší postupně hranice odstavce → věty → slova, takže se chunky lámou na přirozených hranicích textu, kde je to možné):

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(document_text)

Doporučení:

  • Začněte kolem 300–800 znaků (přibližně 100–300 tokenů) s překryvem 10–20 %; hodnoty laďte podle vlastní evaluace retrievalu, ne podle pevného pravidla
  • Preferujte dělení na přirozených hranicích (odstavce, sekce, nadpisy) před dělením podle libovolného počtu znaků, pokud to formát zdroje umožňuje
  • Ke každému chunku připojte metadata (zdrojový dokument, název sekce, číslo stránky) — jsou potřeba pro citace a pro filtrování v čase dotazu

Metody retrievalu

Dense retrieval (podobnost embeddingů)

import numpy as np

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

def dense_retrieve(query_embedding, chunk_embeddings, chunks, top_k=5):
    scores = [cosine_similarity(query_embedding, emb) for emb in chunk_embeddings]
    ranked = sorted(zip(scores, chunks), key=lambda x: x[0], reverse=True)
    return ranked[:top_k]

Dobře funguje pro shodu významu a parafráze; slabší na přesné termíny, které nikdy neviděl (produktové kódy, zkratky, vzácná vlastní jména).

Sparse retrieval (klíčová slova / BM25)

from rank_bm25 import BM25Okapi

tokenized_chunks = [chunk.lower().split() for chunk in chunks]
bm25 = BM25Okapi(tokenized_chunks)

query_tokens = query.lower().split()
scores = bm25.get_scores(query_tokens)
top_indices = np.argsort(scores)[::-1][:top_k]

Dobře funguje na přesné shody termínů; míjí sémanticky related text, který nesdílí slovní zásobu.

Hybridní retrieval (reciprocal rank fusion)

Kombinace obou žebříčků místo výběru jednoho:

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

k=60 je běžně používaná výchozí hodnota z originální práce o RRF — tlumí vliv jakéhokoli jednotlivého vysokého pořadí, takže dokument, který je #1 v jednom žebříčku a nezařazený ve druhém, automaticky nedominuje.

Reranking

Cross-encoder hodnotí každou dvojici (dotaz, chunk) společně, což je přesnější než pouhá podobnost embeddingů, ale příliš pomalé na běh nad celým korpusem — proto se aplikuje jen na top-k kandidátů z retrievalu:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs = [(query, chunk) for chunk in retrieved_chunks]
scores = reranker.predict(pairs)
reranked = [chunk for _, chunk in sorted(zip(scores, retrieved_chunks), reverse=True)]

Typický vzor: levně vyhledejte top 20–50 kandidátů (dense/sparse/hybrid), poté rerankingem zúžte na top 3–5, které skutečně jdou do promptu.

End-to-End příklad

Propojení chunkingu, dense retrievalu a generování s lokálním modelem přes Ollamu (nastavení viz Ollama ):

import ollama
import numpy as np

def embed(text: str) -> np.ndarray:
    result = ollama.embed(model="mxbai-embed-large", input=text)
    return np.array(result["embeddings"][0])

def answer(question: str, chunks: list[str], top_k: int = 3) -> str:
    chunk_embeddings = [embed(c) for c in chunks]
    query_embedding = embed(question)

    ranked = dense_retrieve(query_embedding, chunk_embeddings, chunks, top_k=top_k)
    context = "\n\n".join(chunk for _, chunk in ranked)

    prompt = f"Answer the question using only the context below.\n\nContext:\n{context}\n\nQuestion: {question}"
    response = ollama.chat(model="llama3.2:1b", messages=[{"role": "user", "content": prompt}])
    return response["message"]["content"]

Evaluace

Kvalita RAG má dva nezávislé druhy selhání — špatný retrieval (správný chunk nebyl nikdy nalezen) a špatné generování (model dobrý kontext ignoroval nebo špatně interpretoval) — proto je měřte odděleně:

  • Context precision / recall: z vyhledaných chunků, kolik jich bylo skutečně relevantních, a z relevantních chunků, které existují, kolik jich bylo vyhledáno
  • Faithfulness / groundedness: obsahuje vygenerovaná odpověď pouze tvrzení podložená vyhledaným kontextem, nebo přidává nepodložená tvrzení
  • Answer relevance: řeší odpověď skutečně danou otázku, nezávisle na tom, zda je podložená

RAGAS je široce používaná open-source knihovna pro výpočet těchto metrik nad označenou evaluační sadou.

Časté chyby

ChybaProjevŘešení
Chunking podle pevného počtu znaků bez ohledu na strukturuOdpověď je rozdělená do dvou chunků a žádný z nich nestačíDělit na odstavcích a nadpisech, přidat překryv
Jen dense retrievalMíjí produktové kódy, zkratky a čísla paragrafůHybridní retrieval (BM25 + embeddingy)
Jiný embedding model při ingesci a při dotazuPodobnosti jsou náhodné, retrieval vrací nesmyslyJeden model pro obojí; při změně modelu přegenerovat celý index
Příliš velké top-k bez rerankinguRelevantní pasáž se ztratí v šumu, roste cena a latenceVyhledat 20–50 kandidátů, rerankingem zúžit na 3–5
Chybějící metadataNelze citovat zdroj ani filtrovat podle data nebo oprávněníUkládat zdroj, sekci a datum ke každému chunku
Zastaralý indexOdpovědi vycházejí z dokumentů, které už neplatíReindexace při změně zdroje, mazání chunků smazaných dokumentů
Ladění promptu místo retrievaluHodiny úprav promptu bez zlepšeníNejdřív změřit, zda se správný chunk vůbec vyhledal
Vyhledaný text jako důvěryhodný vstupInstrukce vložené do dokumentu ovlivní odpověď (nepřímá prompt injection)Oddělit kontext od instrukcí, viz Útoky na LLM

Doporučené postupy

  1. Hodnoťte chunking a retrieval odděleně od generování — špatná odpověď může znamenat, že správný chunk nebyl nikdy vyhledán, ne že model špatně uvažoval
  2. V produkci logujte vyhledané chunky spolu s každou odpovědí; bez nich nepoznáte, zda šlo o selhání retrievalu, nebo generování
  3. Velikost chunků a top-k dolaďte podle vlastní evaluační sady — výchozí hodnoty fungující pro jeden korpus se automaticky nepřenáší na jiný
  4. Cachujte embeddingy pro nezměněné zdrojové dokumenty; přegenerování embeddingů celého korpusu při každém běhu ingesce je zbytečný náklad
  5. Ke každému chunku připojte zdrojová metadata, aby odpovědi mohly citovat, odkud pocházejí

Časté otázky

Co je RAG?

RAG (retrieval-augmented generation) je postup, při kterém se k otázce nejdřív vyhledají relevantní pasáže z vlastních dokumentů a ty se spolu s otázkou předají jazykovému modelu. Model pak odpovídá z dodaného textu, ne jen z toho, co si pamatuje z tréninku.

Jaký je rozdíl mezi RAG a fine-tuningem?

RAG dodává modelu znalosti při každém dotazu z vyhledávání, takže stačí aktualizovat dokumenty a odpovědi lze doložit zdrojem. Fine-tuning mění váhy modelu a hodí se spíš na styl, formát nebo úzkou úlohu než na často se měnící fakta.

Potřebuji pro RAG vektorovou databázi?

Ne vždy. Pro stovky až tisíce chunků stačí embeddingy v paměti nebo rozšíření stávající databáze, například pgvector. Samostatná vektorová databáze se vyplatí až u velkých korpusů, filtrování podle metadat a požadavků na rychlost.

Jak velké mají být chunky?

Rozumný začátek je 300–800 znaků s překryvem 10–20 %, dělené na hranicích odstavců a nadpisů. Správnou hodnotu ale určí až vlastní evaluace retrievalu — pro tabulky, kód nebo právní text vychází jinak než pro běžnou prózu.

Proč RAG odpovídá špatně, i když dokument odpověď obsahuje?

Nejčastěji se správná pasáž vůbec nedostala do promptu: chunk je rozdělený uprostřed myšlenky, dotaz používá jiná slova než dokument, nebo je top-k příliš malé. Zalogujte vyhledané chunky a ověřte retrieval dřív, než začnete ladit prompt nebo měnit model.