POWRÓT_DO_BLOGA
AI & Automatyzacja 16 min

Zaawansowany RAG — chunking strategy, hybrid search i reranking w produkcji

Prosty RAG z RecursiveCharacterTextSplitterem zawodzi przy złożonych dokumentach. Jak wdrożyć chunking semantyczny, hybrid search z BM25, RRF fusion i Cross-Encoder reranking — z pełnym kodem produkcyjnym i ewaluacją RAGAS.

Prosty RAG wygląda imponująco na demo. Wrzucasz PDF, embeddujesz chunki z RecursiveCharacterTextSplitterem, odpytujesz Qdrant i dostajesz sensowną odpowiedź. Problem pojawia się w produkcji — przy dokumentach prawnych liczących 400 stron, specyfikacjach technicznych pełnych tabel i wzorów, wielojęzycznych bazach wiedzy z terminologią branżową. Metryki RAGAS spadają poniżej akceptowalnego poziomu i zaczynasz dostawać zgłoszenia: "model halucynuje", "nie znalazł oczywistej informacji", "odpowiedź jest urwana w połowie zdania".

Trzy najczęstsze przyczyny produkcyjnych awarii RAG: chunking mechanicznie tnie kontekst — zdanie podzielone między dwa chunki sprawia, że model nie ma pełnej informacji; dense retrieval zawodzi na terminologii technicznej — embeddingi słabo rozróżniają sygle norm, numery modeli i nazwy własne; brak rerankingu — LLM dostaje Top-3 z retrieval bez oceny trafności, a efekt "lost in the middle" powoduje ignorowanie środkowych fragmentów kontekstu.

/// PROSTY RAG vs ZAAWANSOWANY RAG

Demo vs architektura produkcyjna

Prosty RAG (demo)
01Fixed-size chunking (RecursiveCharacterTextSplitter)
02Single bi-encoder embedding model
03Dense retrieval only — Top-3 bezpośrednio do LLM
04Brak rerankingu i ewaluacji jakości odpowiedzi
Zaawansowany RAG (produkcja)
01Semantic / hierarchical chunking
02Multilingual-E5 embeddings
03Hybrid search: Dense + BM25 + RRF fusion
04Cross-Encoder reranking (Top-20 → Top-5) + RAGAS gate
Benchmark RAGAS — te same pytania, ta sama baza dokumentów
Context Precision51%84%
Prosty
Zaawan.
Context Recall63%82%
Prosty
Zaawan.
Faithfulness74%91%
Prosty
Zaawan.

Zaawansowany RAG to pipeline, nie jednorazowa decyzja architektoniczna. Każdy etap — od strategii podziału dokumentu przez hybrid search po Cross-Encoder reranking — ma mierzalny wpływ na jakość odpowiedzi. W tym artykule omawiam każdy etap z kodem gotowym do wdrożenia na produkcji.

Chunking — decyzja o jednostce sensu

Chunk to nie "kawałek tekstu o rozmiarze 512 tokenów" — to jednostka sensu, którą model zobaczy jako kontekst do odpowiedzi. Zbyt mały chunk: brak wystarczającego kontekstu, model "halucynuje" brakujące informacje. Zbyt duży: kontekst jest "rozcieńczony" — odpowiedź gdzieś się w nim chowa, ale model nie może jej precyzyjnie wyodrębnić. Optymalna strategia zależy od typu dokumentu i charakteru pytań użytkowników.

/// STRATEGIE CHUNKINGU — PORÓWNANIE

4 podejścia do podziału dokumentów

Fixed-sizeRecursiveCharacterTextSplitter
512 tok., overlap 64
+Szybki, prosty, bez zależności
Tnie zdania w połowie
Sentence boundaryspaCy / NLTK
Naturalne granice zdań
+Zachowuje integralność zdań
Wolniejszy preprocessing
Semanticsentence-transformers
Cosine distance > 0.3 = nowy chunk
+Najlepsza jakość kontekstu
Wymaga GPU do indeksowania
HierarchicalSmallToLarge (LlamaIndex)
128 tok. retrieval / 512 tok. ctx
+Precyzja + bogaty kontekst LLM
Złożona implementacja

Fixed-size (RecursiveCharacterTextSplitter z LangChain) — szybki, prosty, dobry do prototypów. Sentence boundary (spaCy) — zachowuje integralność zdań, lepszy dla dokumentów narracyjnych. Semantic chunking — grupuje zdania wg podobieństwa embeddingów, zmiana tematu to nowy chunk. Hierarchical parent-child (SmallToLarge z LlamaIndex) — małe węzły (128 tok.) do wyszukiwania, duże rodzice (512 tok.) do kontekstu LLM. Eliminuje problem urwanych odpowiedzi przy długich dokumentach technicznych.

chunking_strategies.py
from langchain.text_splitter import RecursiveCharacterTextSplitterimport spacyfrom sentence_transformers import SentenceTransformerimport numpy as np# ─── 1. Fixed-size chunking (baseline) ───────────────────────────────────────def fixed_size_chunking(text: str, chunk_size: int = 512, overlap: int = 64) -> list[str]:    splitter = RecursiveCharacterTextSplitter(        chunk_size=chunk_size,        chunk_overlap=overlap,        separators=["\n\n", "\n", ". ", " "],    )    return splitter.split_text(text)# ─── 2. Sentence boundary chunking (spaCy) ───────────────────────────────────def sentence_boundary_chunking(text: str, max_sentences: int = 5) -> list[str]:    nlp = spacy.load("pl_core_news_sm")    doc = nlp(text)    sentences = [sent.text.strip() for sent in doc.sents]    return [" ".join(sentences[i : i + max_sentences]) for i in range(0, len(sentences), max_sentences)]# ─── 3. Semantic chunking (cosine distance threshold) ────────────────────────def semantic_chunking(text: str, threshold: float = 0.3) -> list[str]:    model = SentenceTransformer("intfloat/multilingual-e5-large")    sentences = text.split(". ")    embeddings = model.encode(sentences, normalize_embeddings=True)    chunks: list[str] = []    current: list[str] = [sentences[0]]    for idx in range(1, len(sentences)):        sim = float(np.dot(embeddings[idx - 1], embeddings[idx]))        if (1 - sim) > threshold:            chunks.append(". ".join(current))            current = [sentences[idx]]        else:            current.append(sentences[idx])    if current:        chunks.append(". ".join(current))    return chunks

Kiedy używać hierarchical parent-child: dokumenty techniczne z wieloma sekcjami (specyfikacje, instrukcje, umowy), bazy FAQ, wielosekcyjne raporty. Kiedy fixed-size wystarczy: jednorodne, krótkie artykuły bez złożonej struktury.

Hybrid Search — Dense + BM25 + RRF

Dense retrieval (embeddingi + cosine similarity) świetnie rozumie semantykę i synonimy, ale zawodzi na terminach technicznych: syglach norm (ISO 27001, RODO), numerach modeli i kodach produktów. BM25 precyzyjnie trafia w dokładne frazy, ale nie rozumie parafraz. Reciprocal Rank Fusion (RRF) łączy rankingi z obu metod bez kalibrowania wag — sumuje 1/(k+rank) per dokument i sortuje malejąco.

/// HYBRID SEARCH + CROSS-ENCODER RERANKER

Od zapytania do Top-5 reranked chunks

Query
Dense
Qdrant
BM25
sparse
RRF
fusion
Reranker
Cross-Encoder
Top-5
do LLM
Precision@5 — 200 pytań technicznych, baza 50k dokumentów
Dense only
67%
BM25 only
54%
Hybrid RRF
79%
Hybrid + Reranker
84%
hybrid_search_qdrant.py
from qdrant_client import QdrantClientfrom sentence_transformers import SentenceTransformerfrom rank_bm25 import BM25Okapiimport numpy as npclient = QdrantClient(url="http://localhost:6333")embedder = SentenceTransformer("intfloat/multilingual-e5-large")COLLECTION = "knowledge_base"# ─── Dense retrieval via Qdrant ──────────────────────────────────────────────def dense_search(query: str, top_k: int = 20) -> list[tuple]:    q_vec = embedder.encode(f"query: {query}", normalize_embeddings=True).tolist()    hits = client.search(collection_name=COLLECTION, query_vector=q_vec, limit=top_k, with_payload=True)    return [(r.id, r.score, r.payload["text"]) for r in hits]# ─── BM25 sparse retrieval (in-memory; dla skali → Elasticsearch) ────────────def bm25_search(query: str, corpus: list[str], top_k: int = 20) -> list[tuple]:    bm25 = BM25Okapi([t.lower().split() for t in corpus])    scores = bm25.get_scores(query.lower().split())    idxs = np.argsort(scores)[::-1][:top_k]    return [(int(i), float(scores[i])) for i in idxs]# ─── Reciprocal Rank Fusion ───────────────────────────────────────────────────def rrf_fusion(dense: list, sparse: list, k: int = 60) -> list[tuple]:    rrf: dict[int, float] = {}    for rank, (doc_id, *_) in enumerate(dense):        rrf[doc_id] = rrf.get(doc_id, 0) + 1 / (k + rank + 1)    for rank, (doc_id, *_) in enumerate(sparse):        rrf[doc_id] = rrf.get(doc_id, 0) + 1 / (k + rank + 1)    return sorted(rrf.items(), key=lambda x: x[1], reverse=True)def hybrid_search(query: str, corpus: list[str], top_k: int = 10) -> list[tuple]:    return rrf_fusion(dense_search(query), bm25_search(query, corpus))[:top_k]

Alternatywa: Qdrant 1.7+ obsługuje natywny sparse vector search (SPLADE, BM42) bez zewnętrznego silnika. Dla małych baz (<100k dokumentów) BM25 in-memory (rank_bm25) jest w pełni wystarczający.

Reranking z Cross-Encoder

Bi-encoder (embeddingi) jest szybki, bo każdy dokument jest kodowany niezależnie od pytania. Cross-encoder analizuje parę (pytanie, chunk) razem — jest 50–100× wolniejszy, ale znacznie dokładniejszy. Strategia: bi-encoder zwraca Top-20–50 kandydatów, cross-encoder rerankes je i zostawia Top-3–5 dla LLM. Latency wzrasta o ~200–400 ms na GPU — akceptowalny koszt przy mierzalnym wzroście jakości.

reranking_pipeline.py
from sentence_transformers import CrossEncoderfrom openai import OpenAI# mmarco-mMiniLMv2: wielojęzyczny reranker (PL/EN/DE/FR/…), szybki (MiniLM backbone)# alternatywa: BAAI/bge-reranker-v2-m3 — wyższa jakość, cięższy modelreranker = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1")def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:    pairs = [(query, c["text"]) for c in candidates]    scores = reranker.predict(pairs)    for i, score in enumerate(scores):        candidates[i]["rerank_score"] = float(score)    return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_k]def rag_answer(query: str, corpus: list[str], llm: OpenAI) -> str:    from hybrid_search_qdrant import hybrid_search    fused = hybrid_search(query, corpus, top_k=20)    candidates = [{"id": doc_id, "text": corpus[doc_id]} for doc_id, _ in fused]    top_docs = rerank(query, candidates, top_k=5)    context = "\n---\n".join(d["text"] for d in top_docs)    resp = llm.chat.completions.create(        model="gpt-4o",        messages=[            {"role": "system", "content": "Odpowiadaj wyłącznie na podstawie kontekstu. Jeśli odpowiedź nie wynika z kontekstu — powiedz: nie wiem."},            {"role": "user", "content": f"Kontekst:\n{context}\n\nPytanie: {query}"},        ],        temperature=0,    )    return resp.choices[0].message.content

Kluczowa zasada: nie zwiększaj Top-K dla LLM żeby "dać mu więcej". Efekt "lost in the middle" sprawia, że modele ignorują środkowe fragmenty długiego kontekstu. 3–5 zrankowanych chunków daje lepsze odpowiedzi niż 15 słabiej posortowanych. Docelowy schemat: Top-20 retrieval → Top-5 po rerankingu → LLM z ~1500 tokenów kontekstu.

Ewaluacja z RAGAS — gate jakości przed wdrożeniem

Ewaluacja RAG bez automatycznych metryk to błąd architektoniczny — nie wiesz czy zmiana chunkingu poprawiła wyniki, nie masz gate'a przed wdrożeniem nowej wersji embeddingów. RAGAS mierzy cztery wymiary: Context Precision (trafność pobranych chunków), Context Recall (kompletność informacji), Faithfulness (czy odpowiedź wynika z kontekstu, nie z wiedzy modelu) i Answer Relevancy (czy odpowiedź odpowiada na pytanie).

ragas_eval.py
from datasets import Datasetfrom ragas import evaluatefrom ragas.metrics import (    context_precision,    context_recall,    faithfulness,    answer_relevancy,)# min. 50–100 par (pytanie, ground_truth) dla rzetelnych wyników produkcyjnychtest_cases = [    {        "question": "Jakie są wymagania wstępne do certyfikacji ISO 27001?",        "answer": "ISO 27001 wymaga wdrożenia ISMS, analizy ryzyka i audytu wewnętrznego.",        "contexts": [            "ISO 27001 to norma dla systemów zarządzania bezpieczeństwem informacji.",            "Certyfikacja wymaga udokumentowania polityki bezpieczeństwa i oceny ryzyka.",        ],        "ground_truth": "Do certyfikacji ISO 27001 wymagane jest ISMS, ocena ryzyka i audyt wewnętrzny.",    },]dataset = Dataset.from_list(test_cases)results = evaluate(    dataset=dataset,    metrics=[context_precision, context_recall, faithfulness, answer_relevancy],)df = results.to_pandas()print(df[["context_precision", "context_recall", "faithfulness", "answer_relevancy"]].mean())

Docelowe progi jakości: Context Precision ≥ 0.80, Context Recall ≥ 0.75, Faithfulness ≥ 0.85, Answer Relevancy ≥ 0.80. Traktuj te wartości jako gate w CI/CD — przed mergem nowej wersji embeddingów lub strategii chunkingu uruchamiaj ewaluację na stałym zestawie 100 pytań testowych.

MetrykaProsty RAGZaawansowany RAGAkcja gdy wynik < 0.70
Context Precision0.510.84Zmień strategię chunkingu lub model embeddingowy
Context Recall0.630.82Zwiększ top-K retrieval lub popraw coverage indeksu
Faithfulness0.740.91Wzmocnij system prompt lub zmień model bazowy LLM
Answer Relevancy0.680.87Popraw query reformulation i instrukcję systemową

Uruchamiaj RAGAS co tydzień na losowej próbce rzeczywistych zapytań z logów produkcyjnych — nie tylko na syntetycznym zestawie testowym. Metryki syntetyczne bywają zawyżone. Zbieraj pary (pytanie, odpowiedź) z produkcji i stopniowo rozszerzaj zestaw ewaluacyjny.

---

Wdrażam zaawansowane pipeline RAG dla firm, które mają bazę wiedzy i chcą żeby pracownicy lub klienci dostawali precyzyjne, udokumentowane odpowiedzi — nie "halucynacje". Od audytu istniejącego systemu przez optymalizację chunkingu, hybrid search i rerankingu po monitoring jakości z RAGAS. Napisz do mnie — zaczynam od analizy Twojej bazy dokumentów i pierwszego benchmarku.

/// AUTHOR
Paweł Wiszniewski – AI & Web Engineer

Paweł Wiszniewski

Senior Full-Stack Engineer & AI Architect

8+ lat doświadczenia. Buduję systemy AI, automatyzacje i aplikacje webowe, które redukują koszty i zwiększają efektywność operacyjną firm.

Signal received?

Przerwij
Ciszę

Zainicjuj protokół. Nawiąż połączenie. Zbudujmy coś głośnego.

> OCZEKIWANIE_NA_SYGNAŁ...