Budowanie agentów jest łatwe, chyba że budujesz go na poważnie — cz. I: Ocena RAG
2026-05-26
Co się wydarzy jeśli zadać pytanie “Czy AI aplikacje są łatwe do stworzenia” juniorowi i seniorowi?
Intuicja mi podpowiada, że odpowiedzi będą podobne z małą, ale bardzo ważną różnicą.
| Junior | Senior |
|---|---|
| Tak, są łatwe do stworzenia! | Tak, są łatwe do stworzenia, chyba, że… |
Już tym początkiem mogę urazić juniorów. Ale tym abstrakcyjnym juniorem byłem również (oczywiście) ja. Chyba nikogo nie zaskoczę, że doświadczenie m.in. uczy człowieka, że istnieje bardzo dużo uproszczeń i edge case'ów. Junior może nawet o nich wiedzieć, ale może je pomijać. Seniorowi doświadczenie podpowiada, że te edge case'y to jest to, co może bardzo psuć produkcyjny system.
Dlaczego budowa agenta jest łatwa?
A więc dlaczego budowa agenta jest łatwa? Dlatego że już teraz istnieją narzędzia, które bardzo ułatwiają ten proces. Pomijamy na razie vibe coding — skupmy się na dostępnych bibliotekach i API. OpenAI Python API, LangChain, LangGraph, CrewAI itd. Użycie OpenAI API jest na tyle intuicyjne, że dla prostych zapytań nawet użytkownik nietechniczny może stworzyć podstawową automatyzację (dodatkowo pomaga intuicyjność Pythona).
from openai import OpenAI
client = OpenAI()
response = client.responses.create(
model="gpt-4.1",
input="Tell me a three sentence bedtime story about a unicorn."
)
print(response)Z perspektywy praktykanta-juniora wciąż jest to bardzo proste, a nawet będzie dodawał kolejne klocki.
from openai import OpenAI
def summarize_text(text)
client = OpenAI()
response = client.responses.create(
model="gpt-4.1",
input=f"Your goal is to summarize text {text}"
)
return responseSpójrzmy na ten kawałek kodu
- — Czy to jest skomplikowane? Nie, nie jest.
- — Czy to jest idealny kawałek kodu? Nie, nie jest.
- — Czy wykonuje to co trzeba? Tak, wykonuje.
Następny poziom skomplikowania
Standardowy LLM use-case — LLM powinien mieć dostęp do dokumentów firmowych (np. jak złożyć wniosek urlopowy).
| Junior | Senior |
|---|---|
| Zastosuję RAG! | RAG to pierwszy pomysł, ale czy lepszy? |
RAG jest na tyle przyjemną architekturą, że koncepcja jest bardzo prosta.
- Prompt
- Baza dokumentów
- Generowanie
- Odpowiedź
Oczywiście implementacja jest troszkę bardziej skomplikowana w porównaniu do API calla, ale nie jest mocno skomplikowana.
import getpass
import os
import bs4
from langchain import hub
from langchain_community.document_loaders import WebBaseLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langgraph.graph import START, StateGraph
from typing_extensions import List, TypedDict
from langchain.chat_models import init_chat_model
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
if not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter API key for OpenAI: ")
llm = init_chat_model("gpt-4o-mini", model_provider="openai")
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
vector_store = InMemoryVectorStore(embeddings)
# Load and chunk contents of the blog
loader = WebBaseLoader(
web_paths=("https://lilianweng.github.io/posts/2023-06-23-agent/",),
bs_kwargs=dict(
parse_only=bs4.SoupStrainer(
class_=("post-content", "post-title", "post-header")
)
),
)
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_splits = text_splitter.split_documents(docs)
# Index chunks
_ = vector_store.add_documents(documents=all_splits)
# Define prompt for question-answering
prompt = hub.pull("rlm/rag-prompt")
# Define state for application
class State(TypedDict):
question: str
context: List[Document]
answer: str
# Define application steps
def retrieve(state: State):
retrieved_docs = vector_store.similarity_search(state["question"])
return {"context": retrieved_docs}
def generate(state: State):
docs_content = "\n\n".join(doc.page_content for doc in state["context"])
messages = prompt.invoke({"question": state["question"], "context": docs_content})
response = llm.invoke(messages)
return {"answer": response.content}
# Compile application and test
graph_builder = StateGraph(State).add_sequence([retrieve, generate])
graph_builder.add_edge(START, "retrieve")
graph = graph_builder.compile()
response = graph.invoke({"question": "What is Task Decomposition?"})
print(response["answer"])I już mamy naszego RAGa!
Ale czy musimy komplikować?
Każdy kto miał przyjemność implementować RAG (albo po prostu pracować z danymi) na komercyjnym projekcie wie, że:
- — Dane nie są idealne.
- — Teraz RAG staje się nie tyle jedną architekturą, ale bardziej rodziną architektur.
- — No i oczywiście — RAG jest skomplikowany.

Przed chwilą pokazaliśmy, że budować RAG jest bardzo łatwe na przykładzie, ale teraz wchodzi abstrakcyjny Senior i mówi, że jest jednak skomplikowany. A więc dlaczego? Czy mamy słonia w pokoju? Oczywiście, że mamy, a nawet kilka!
- — Ocena RAG?
- — Traceability?
- — Koszty?
💡 W pierwszej wersji tego blogposta chciałem napisać jeszcze o kilku aspektach, ale jak się okazuje nawet Ocena RAG jest obszernym tematem, więc skupiam się jedynie na ocenie.
Ocena RAG
Wiele osób, które zaczynają przygodę z LLM wiedzą, że nie można ufać modelom! Podstawowy problem to halucynacje. I to jest oczywiście prawda. Chociaż state-of-the-art LLM'y coraz mniej halucynują (na prostych use-case'ach), ale wciąż są po prostu modelami językowymi.

Ale niestety, te same osoby z jakiegoś powodu zaczynają ufać RAGowi. Dodatkowo prawie wszystkie biblioteki agentyczne pokazują jedynie implementację i nigdy nie wspominają o ważności oceny. Na przykład biblioteki klasycznego ML mają wbudowane narzędzia do oceny — nie mamy out-of-the-box takich rozwiązań w LangChain i LangGraph. A tak samo materiały edukacyjne — kursy i artykuły dla początkujących, które uczą GenAI i RAG, nie wspominają o ocenie lub nie poświęcają temu dużo uwagi.
Jakie mamy opcje?
Powtórzmy — RAG jest skomplikowany. Żeby mieć pewność, że zachowuje się zgodnie z naszymi oczekiwaniami, musimy rozumieć gdzie RAG robi dobrą robotę, a gdzie mamy miejsce do poprawy.
💡 Dla oceny czy RAG zachowuje się zgodnie z naszymi oczekiwaniami musimy najpierw zdefiniować oczekiwania, co nie jest czasami takie oczywiste.
Na szczęście mamy grupę metryk, które mogą pomóc w tym.
RAG Triad składa się z trzech metryk oceny RAG:
- — Answer Relevancy
- — Faithfulness / Groundedness
- — Contextual Relevancy
Jeśli pipeline RAG osiąga wysokie wyniki we wszystkich trzech metrykach, możemy z dużą pewnością stwierdzić, że używa on optymalnych hiperparametrów. Wynika to z faktu, że każda metryka w triadzie RAG odpowiada określonemu hiperparametrowi w pipeline RAG. Using the RAG Triad for RAG evaluation — DeepEval
Zobaczymy każdą z tych metryk.
Answer Relevancy
Answer Relevancy używa podejścia LLM-as-a-judge do oceny generatora RAGa, poprzez analizę rzeczywistej odpowiedzi LLM w porównaniu z wejściowym zapytaniem.
Na przykładzie popularnej biblioteki DeepEval przeanalizujmy obliczenie Answer Relevancy.
"""Given the text, breakdown and generate a list of statements presented.
Ambiguous statements and single words can also be considered as statements.
Example:
Example text:
Our new laptop model features a high-resolution Retina display for crystal-clear visuals.
It also includes a fast-charging battery, giving you up to 12 hours of usage on a single charge.
For security, we've added fingerprint authentication and an encrypted SSD.
Plus, every purchase comes with a one-year warranty and 24/7 customer support.
{
"statements": [
"The new laptop model has a high-resolution Retina display.",
"It includes a fast-charging battery with up to 12 hours of usage.",
"Security features include fingerprint authentication and an encrypted SSD.",
"Every purchase comes with a one-year warranty.",
"24/7 customer support is included."
]
}
===== END OF EXAMPLE ======
**
IMPORTANT: Please make sure to only return in JSON format,
with the "statements" key mapping to a list of strings.
No words or explanation is needed.
**
Text:
{actual_output}
JSON:
""""""For the provided list of statements, determine whether each statement
is relevant to address the input.
Please generate a list of JSON with two keys: 'verdict' and 'reason'.
The 'verdict' key should STRICTLY be either a 'yes', 'idk' or 'no'.
Answer 'yes' if the statement is relevant to addressing the original input,
'no' if the statement is irrelevant, and 'idk' if it is ambiguous.
The 'reason' is the reason for the verdict.
Provide a 'reason' ONLY if the answer is 'no'.
**
IMPORTANT: Please make sure to only return in JSON format,
with the 'verdicts' key mapping to a list of JSON objects.
Example input:
What features does the new laptop have?
Example statements:
[
"The new laptop model has a high-resolution Retina display.",
"It includes a fast-charging battery with up to 12 hours of usage.",
"Security features include fingerprint authentication and an encrypted SSD.",
"Every purchase comes with a one-year warranty.",
"24/7 customer support is included.",
"Pineapples taste great on pizza."
]
Example JSON:
{
"verdicts": [
{ "verdict": "yes" },
{ "verdict": "yes" },
{ "verdict": "yes" },
{ "verdict": "no", "reason": "A one-year warranty is a purchase benefit, not a feature." },
{ "verdict": "no", "reason": "Customer support is a service, not a feature of the laptop." },
{ "verdict": "no", "reason": "Pineapples on pizza is completely irrelevant." }
]
}
**
Input:
{input}
Statements:
{statements}
JSON:
"""_calculate_score, której definicja jest dość jasna:def _calculate_score(self):
number_of_verdicts = len(self.verdicts)
if number_of_verdicts == 0:
return 1
relevant_count = 0
for verdict in self.verdicts:
if verdict.verdict.strip().lower() != "no":
relevant_count += 1
score = relevant_count / number_of_verdicts
return 0 if self.strict_mode and score < self.threshold else score"""Given the answer relevancy score, the list of reasons of irrelevant statements
made in the actual output, and the input, provide a CONCISE reason for the score.
**
IMPORTANT: Please make sure to only return in JSON format,
with the 'reason' key providing the reason.
Example JSON:
{
"reason": "The score is <answer_relevancy_score> because <your_reason>."
}
**
Answer Relevancy Score:
{score}
Reasons why the score can't be higher based on irrelevant statements:
{irrelevant_statements}
Input:
{input}
JSON:
"""Faithfulness
Faithfulness również używa podejścia LLM-as-a-judge do oceny generatora RAGa, poprzez analizę rzeczywistej odpowiedzi LLM w porównaniu z kontekstem pobranym przez retriever.
DeepEval wyciąga z odpowiedzi LLM wyłącznie twierdzenia faktyczne (claims). W odróżnieniu od Answer Relevancy, gdzie wyodrębniane są wszystkie statementy (w tym niejednoznaczne), tutaj prompt jawnie wyklucza opinie i kontekst niedający się zweryfikować.
"""Based on the given text, please extract a comprehensive list of FACTUAL,
undisputed truths, that can inferred from the provided actual AI output.
These truths, MUST BE COHERENT, and CANNOT be taken out of context.
Example:
Example Text:
"Albert Einstein, the genius often associated with wild hair and mind-bending theories,
famously won the Nobel Prize in Physics—though not for his groundbreaking work on relativity,
as many assume. Instead, in 1968, he was honored for his discovery of the photoelectric effect,
a phenomenon that laid the foundation for quantum mechanics."
Example JSON:
{
"claims": [
"Einstein won the noble prize for his discovery of the photoelectric effect in 1968.",
"The photoelectric effect is a phenomenon that laid the foundation for quantum mechanics."
]
}
===== END OF EXAMPLE ======
**
IMPORTANT: Please make sure to only return in JSON format, with the "claims" key.
Only include claims that are factual, BUT IT DOESN'T MATTER IF THEY ARE FACTUALLY CORRECT.
**
AI Output:
{actual_output}
JSON:
"""Asynchronicznie DeepEval wyciąga fakty z kontekstu zwróconego przez retriever. W środku DeepEval odnosi się do tego jako “ground truths” — dane do oceny claimów.
"""Based on the given text, please generate a comprehensive list of FACTUAL,
undisputed truths, that can inferred from the provided text.
These truths, MUST BE COHERENT. They must NOT be taken out of context.
Example:
Example Text:
"Albert Einstein, the genius often associated with wild hair and mind-bending theories,
famously won the Nobel Prize in Physics—though not for his groundbreaking work on relativity,
as many assume. Instead, in 1968, he was honored for his discovery of the photoelectric effect,
a phenomenon that laid the foundation for quantum mechanics."
Example JSON:
{
"truths": [
"Einstein won the noble prize for his discovery of the photoelectric effect in 1968.",
"The photoelectric effect is a phenomenon that laid the foundation for quantum mechanics."
]
}
===== END OF EXAMPLE ======
**
IMPORTANT: Please make sure to only return in JSON format, with the "truths" key.
Only include truths that are factual, BUT IT DOESN'T MATTER IF THEY ARE FACTUALLY CORRECT.
**
Text:
{retrieval_context}
JSON:
"""Generowanie werdyktów (Claims vs. Truths)
Finalnie porównujemy claimy z prawdą i wydajemy werdykt yes / no / idk.
"""Based on the given claims, generate a list of JSON objects to indicate
whether EACH claim contradicts any facts in the retrieval context.
The 'verdict' key should STRICTLY be either 'yes', 'no', or 'idk',
which states whether the given claim agrees with the context.
Provide a 'reason' ONLY if the answer is 'no' or 'idk'.
Expected JSON format:
{
"verdicts": [
{ "verdict": "yes" },
{ "reason": "<explanation_for_contradiction>", "verdict": "no" },
{ "reason": "<explanation_for_uncertainty>", "verdict": "idk" }
]
}
**
IMPORTANT: Please make sure to only return in JSON format, with the 'verdicts' key.
Generate ONE verdict per claim.
Only use 'no' if retrieval context DIRECTLY CONTRADICTS the claim.
Use 'idk' for claims not backed up by context OR factually incorrect but non-contradictory.
**
Retrieval Contexts:
{retrieval_context}
Claims:
{claims}
JSON:
"""| Werdykt | Znaczenie |
|---|---|
| `yes` | Claim jest zgodny z retrieval context |
| `no` | Retrieval context zaprzecza claimowi (halucynacja) |
| `idk` | Claim nie jest poparty kontekstem, ale też mu nie przeczy (informacja spoza kontekstu) |
Obliczenie wyniku
Domyślnie zarówno yes jak i idk są traktowane jako niesprzeczne z kontekstem. Parametr penalize_ambiguous_claims=True zmienia to zachowanie — idk obniża licznik, penalizując claims niemających pokrycia w retrieval context.
Przykład
Retrieval context
„Restauracja otwarta jest od poniedziałku do piątku w godzinach 12:00–22:00. Menu zawiera dania kuchni włoskiej. Rezerwacje przyjmowane są telefonicznie."
Actual output
„Restauracja czynna jest siedem dni w tygodniu do północy. Serwuje kuchnię włoską i japońską. Rezerwacji można dokonać online przez stronę internetową lub telefonicznie."
| # | Claim | Truth (kontekst) | Werdykt |
|---|---|---|---|
| 1 | Restauracja czynna siedem dni w tygodniu | 'od poniedziałku do piątku' — jawna sprzeczność | `no` |
| 2 | Czynna do północy | 'do 22:00' — jawna sprzeczność | `no` |
| 3 | Serwuje kuchnię włoską | 'dania kuchni włoskiej' | `yes` |
| 4 | Serwuje kuchnię japońską | Brak wzmianki w kontekście, ale nie zaprzecza | `idk` |
| 5 | Rezerwacje online przez stronę | 'rezerwacje przyjmowane telefonicznie' — jawna sprzeczność | `no` |
| 6 | Rezerwacje telefonicznie | 'rezerwacje przyjmowane telefonicznie' | `yes` |
Contextual Relevancy
Contextual Relevancy również używa podejścia LLM-as-a-judge do oceny retrievera, poprzez analizę kontekstu zwróconego przez retriever w porównaniu z wejściowym zapytaniem.
DeepEval przetwarza każdy dokument kontekstu osobno. Dla każdego dokumentu LLM najpierw wyciąga z niego wysokopoziomowe statementy, a następnie ocenia każdy z nich pod kątem trafności względem pytania.
"""Based on the input and context, generate a JSON object to indicate whether
each statement found in the context is relevant to the provided input.
The JSON will be a list of 'verdicts', with 2 mandatory fields: 'verdict' and 'statement',
and 1 optional field: 'reason'.
First extract statements found in the context (high level information), then decide
on a verdict and optionally a reason for each statement.
The 'verdict' key should STRICTLY be either 'yes' or 'no'.
Provide a 'reason' ONLY IF verdict is no.
**
IMPORTANT: Please make sure to only return in JSON format.
Example Context: "Einstein won the Nobel Prize for his discovery of the photoelectric effect.
He won the Nobel Prize in 1968. There was a cat."
Example Input: "What were some of Einstein's achievements?"
Example:
{
"verdicts": [
{
"statement": "Einstein won the Nobel Prize for his discovery of the photoelectric effect in 1968",
"verdict": "yes"
},
{
"statement": "There was a cat.",
"reason": "Has nothing to do with Einstein's achievements.",
"verdict": "no"
}
]
}
**
Input:
{input}
Context:
{context}
JSON:
"""Input
Jak działa silnik elektryczny?
Retrieval context — 2 dokumenty
Doc 1: „Silnik elektryczny przekształca energię elektryczną w mechaniczną poprzez oddziaływanie pola magnetycznego na przewodnik z prądem. Podstawowym elementem jest stojan i wirnik. Wydajność nowoczesnych silników elektrycznych przekracza 95%."
Doc 2: „Tesla Model S osiąga przyspieszenie 0–100 km/h w 2,1 sekundy. Samochód jest dostępny w wersji Long Range z zasięgiem 650 km. Cena bazowa wynosi 89 990 euro."
| Doc | Statement | Werdykt |
|---|---|---|
| 1 | Silnik przekształca energię elektryczną w mechaniczną | `yes` |
| 1 | Podstawowym elementem jest stojan i wirnik | `yes` |
| 1 | Wydajność przekracza 95% | `yes` |
| 2 | Tesla Model S: 0–100 w 2,1 sekundy | `no` |
| 2 | Zasięg Long Range 650 km | `no` |
| 2 | Cena bazowa 89 990 euro | `no` |
Interpretacja
Oczywiście siła RAG Triad jest w interpretacji nie liczb oddzielnie, tylko razem. Podsumujmy co ocenia każda metryka i jak działa:
| Wymiar | Answer Relevancy | Faithfulness | Contextual Relevancy |
|---|---|---|---|
| Cel | Wykryć offtopicowe odpowiedzi | Wykryć halucynacje i sprzeczności z kontekstem | Wykryć nieistotne dokumenty w retrieval context |
| Co ocenia | Generator | Generator | Retriever |
| Pytanie | Czy odpowiedź jest trafna względem pytania? | Czy odpowiedź jest spójna z kontekstem RAG? | Czy pobrany kontekst jest trafny względem pytania? |
| Wejście | input + actual_output | retrieval_context + actual_output | input + retrieval_context |
| Ekstrahuje | Statementy z odpowiedzi | Claimy z odpowiedzi i prawdy z kontekstu | Statementy z każdego dokumentu |
| Werdykty | yes / no / idk | yes / no / idk | yes / no |
Po krótkim podsumowaniu możemy przeanalizować każdą możliwą kombinację i jak można podejść do naprawy (jeśli jest potrzeba).
| Scenariusz | AR | F | CR | Winowajca | Priorytet naprawy |
|---|---|---|---|---|---|
| 1 | ↑ | ↑ | ↑ | — | Brak — system działa |
| 2 | ↓ | ↑ | ↑ | Generator | Prompt generatora (skupienie na pytaniu) |
| 3 | ↑ | ↓ | ↑ | Generator | Grounding w kontekście, temperatura |
| 4 | ↑ | ↑ | ↓ | Retriever | Ryzyko ukryte — popraw retriever zanim wymagania |
| 5 | ↓ | ↓ | ↑ | Generator | Debuguj przekazywanie kontekstu do LLM |
| 6 | ↓ | ↑ | ↓ | Retriever | Popraw retriever + dodaj fallback w generatorze |
| 7 | ↑ | ↓ | ↓ | Oba | Retriever + grounding generatora |
| 8 | ↓ | ↓ | ↓ | Oba | Zacznij od retrievera, potem generator |
Nie będę opisywał każdego scenariusza, skupię się na kilku ciekawych.
Zaczynając od pozytywów — Scenariusz 1. Wszystko działa — siedzimy i mamy nadzieję, że się nie zepsuje.
🤔 A dlaczego musisz mieć nadzieję? Być może warto pomyśleć o jakimś tracingu, monitoringu? Stay tuned…
Scenariusz 4 — według mnie najgorszy scenariusz. Na powierzchni wszystko wskazuje na to, że działa dobrze, ale tutaj polega na wiedzy LLM-a, nie retrieval. To jest miejsce, w którym widzimy jasno, że klasyczny golden set jest niewystarczający.
Scenariusz 8 — nie działa nic. Uwzględniając jakie już teraz mamy do dyspozycji LLM-y, to raczej nie jest miejsce, w którym dzieje się coś drastycznego. Dziś nawet mniejsze modele są dość mądre i raczej sobie radzą z tekstem. Z kolei retriever może być problematyczny — po zmianie w retrieverze przejdź do naprawy generatora.
⚠️ Zwróć uwagę na “raczej sobie radzą z tekstem”. Wszystko zależy od use case'a, od tego jaki model miałeś/miałaś pierwotnie.
Słabe strony?
No niestety nie jest to klasyczna metryka EM, accuracy, recall itd. Polegamy na podejściu LLM-as-a-judge. W przypadku DeepEval prompty zawierają przykłady, które w przypadku mniejszych modeli mogą pomylić przykład z dostarczonym inputem (trust me… I was there). W przypadku Answer Relevancy możemy polegać na embeddingach i porównaniu similarity (tak np. działa Ragas), ale to rozwiązuje ⅓ problemu. Na dany moment nie mamy lepszego podejścia.
⚠️ Istnieje jedno podejście, które nie polega na LLM-ach, ale jego skalowalność jest wątpliwa — manualne sprawdzenie przez SME.
Czy to oznacza, że nie warto stosować tego podejścia? Oczywiście, że nie. Nawet przy możliwych halucynacjach LLM-y będą dobrze estymować te metryki.
Podsumowanie
RAG jest łatwy do zaimplementowania, ale trudny do zrobienia dobrze. Trzy metryki RAG Triad — Answer Relevancy, Faithfulness i Contextual Relevancy — dają nam konkretny język do opisania tego, gdzie pipeline szwankuje i kto jest winowajcą: generator czy retriever. LLM-as-a-judge nie jest idealny, ale jest praktyczny i na razie najlepsze co mamy. Ale tak jak było wspomniano, czasami RAG jest overkillem i być może warto zastosować inne podejścia…