EN

RAG

Praktický průvodce retrieval-augmented generation: strategie chunkingu, dense/sparse/hybridní retrieval, reranking, funkční příklad pipeline a evaluace.

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.

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í