A model that runs in a notebook and a model that serves real traffic are two different engineering problems. This closing lesson covers the three things that stand between a working prototype and a system you can operate: serving it fast enough, tracing what it did, and knowing automatically whether it is still any good.
bge-m3 and a chat model — the same setup as Lesson 16.
Knowledge you need: Lessons 14 through 16 for the RAG pipeline this lesson grades. Lesson 16 for the idea of an LLM scoring relevance (the cross-encoder) — faithfulness here uses exactly that technique.
20.1 High-performance serving with vLLM
Deploying an LLM behind raw Hugging Face Transformers code makes the system extremely slow and prone to VRAM out-of-memory crashes. The cause is that the Key-Value cache holding each request's intermediate tokens grows continuously, and its memory allocation fragments.
vLLM solves this with PagedAttention, inspired by an operating system's paged virtual memory:
- Paging the KV cache: PagedAttention splits each request's KV cache into fixed-size blocks that need not be contiguous in physical VRAM.
- Page table: the system maintains a mapping from a request's logical tokens to physical blocks, which lets parallel requests share common KV cache blocks (a shared system prompt, for example).
- The payoff: by eliminating fragmentation, vLLM packs physical VRAM tightly, raising throughput by roughly 10 to 20 times.
gpu_memory_utilization = 0.90. If you deploy vLLM on a server already running other work
(an embedding service, a FastAPI app), the system will immediately crash with OOM. The fix is to set
this lower — 0.70 or 0.80 — leaving headroom for the supporting applications.
20.2 Automated RAG evaluation with Ragas
For a RAG application, paying humans to read tens of thousands of model answers and grade them is prohibitively expensive. The Ragas framework instead uses an LLM as judge, across three core metrics:
- Faithfulness: how truthful the answer is with respect to the retrieved context — the share of claims in the answer that can be derived directly from it. \[Faithfulness = \frac{\text{supported claims}}{\text{total claims in the answer}}\]
- Answer relevance: how directly the answer addresses the user's question. Measured as the mean cosine similarity between the embedding of the original question and embeddings of questions the LLM generates backwards from the answer.
- Context recall: how completely the retriever found the information present in the ground truth.
20.3 Tracing & observability with Phoenix
When an agent misbehaves or answers off-topic, it is very hard to tell which step went wrong (did RAG retrieve the wrong document, did the LLM misread the question, did the agent call the wrong tool?). You need tracing — a flight recorder for every action.
Integrating Arize Phoenix or LangSmith records automatically:
- Latency of each node.
- Token consumption at every step, for cost control.
- The raw prompt and raw response at each LLM call, for debugging.
20.4 Lesson 20 project: a RAG evaluator, and an evaluation of the evaluator
This is the final lesson of the roadmap, so the project closes on the hardest question an AI engineer has to answer: how do you know your system is actually good? And immediately after it, the harder one: how do you know your measuring instrument measures anything?
The test set has exactly two samples, deliberately: one correct answer and one that is plainly wrong (asked to define LoRA, it answers that LoRA raises the temperature setting). A metric you have never seen fail is a metric you have no reason to trust.
"""Lesson 20 project: a RAG evaluator, and an evaluation of the evaluator.
Run: python3 rag_evaluator.py
Optional: Ollama with `bge-m3` and a chat model, for the semantic metrics.
The test set deliberately contains one CORRECT answer and one that is plainly
WRONG. That is the whole design: a metric you have never seen fail is a metric
you have no reason to trust. Every score below is checked against which sample
it belongs to.
"""
import json
import math
import re
import urllib.error
import urllib.request
OLLAMA = "http://localhost:11434"
CHAT_PREFERRED = ["qwen2.5:14b-instruct", "qwen2.5:7b", "llama3.1", "llama3.2"]
EMBED_PREFERRED = ["bge-m3", "nomic-embed-text", "mxbai-embed-large"]
TEST_DATASET = [
{
"label": "correct answer",
"question": "Thuật toán PagedAttention trong vLLM dùng để làm gì?",
"context": "vLLM sử dụng thuật toán PagedAttention để phân chia bộ đệm Key-Value "
"(KV Cache) thành các khối cố định trên bộ nhớ VRAM, giúp loại bỏ hiện "
"tượng phân mảnh bộ nhớ vật lý.",
"answer": "PagedAttention dùng để phân chia bộ đệm KV Cache thành các khối bộ nhớ "
"cố định giúp tối ưu hóa VRAM và tránh phân mảnh.",
"ground_truth": "PagedAttention dùng để giải quyết vấn đề phân mảnh bộ nhớ KV Cache "
"bằng cách chia nhỏ nó thành các khối cố định trên GPU VRAM.",
},
{
"label": "WRONG answer",
"question": "Định nghĩa kỹ thuật LoRA?",
"context": "LoRA là kỹ thuật đóng băng ma trận trọng số gốc của LLM và đưa vào các "
"ma trận hạng thấp song song để huấn luyện, giúp giảm hàng trăm lần số "
"lượng tham số cập nhật.",
"answer": "LoRA là kỹ thuật tăng nhiệt độ temperature để mô hình ngôn ngữ lớn hoạt "
"động sáng tạo hơn.",
"ground_truth": "LoRA là kỹ thuật tinh chỉnh tham số hiệu quả bằng cách huấn luyện "
"các ma trận phân rã hạng thấp song song và đóng băng trọng số gốc.",
},
]
STOPWORDS = {"và", "để", "của", "là", "trong", "cho", "có", "các", "được",
"bằng", "với", "ra"}
# ---------------------------------------------------------------------------
# Keyword metrics - cheap, no dependencies, and worth measuring critically
# ---------------------------------------------------------------------------
def keywords(text):
return {w for w in re.findall(r"\b\w+\b", text.lower()) if w not in STOPWORDS}
def keyword_faithfulness(answer, context):
"""What share of the answer's words appear in the retrieved context?"""
answer_words = keywords(answer)
return len(answer_words & keywords(context)) / len(answer_words) if answer_words else 0.0
def keyword_context_recall(context, ground_truth):
"""What share of the ground truth's words the retrieved context covers."""
truth_words = keywords(ground_truth)
return len(truth_words & keywords(context)) / len(truth_words) if truth_words else 0.0
def keyword_answer_relevance(question, answer):
"""Jaccard overlap between question and answer words."""
q, a = keywords(question), keywords(answer)
return len(q & a) / len(q | a) if (q | a) else 0.0
# ---------------------------------------------------------------------------
# Semantic metrics - what Ragas actually does
# ---------------------------------------------------------------------------
def post(path, payload):
request = urllib.request.Request(
f"{OLLAMA}{path}", data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request) as response:
return json.loads(response.read())
def installed_models():
try:
with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
return [m["name"] for m in json.loads(response.read())["models"]]
except urllib.error.URLError:
return []
def pick(names, preferred):
for wanted in preferred:
for name in names:
if name == wanted or name.startswith(wanted):
return name
return None
def embed(text, model):
return post("/api/embeddings", {"model": model, "prompt": text})["embedding"]
def cosine(v1, v2):
dot = sum(a * b for a, b in zip(v1, v2))
n1 = math.sqrt(sum(a * a for a in v1))
n2 = math.sqrt(sum(b * b for b in v2))
return 0.0 if n1 == 0 or n2 == 0 else dot / (n1 * n2)
def semantic_answer_relevance(question, answer, model):
"""Cosine between the question and the answer in embedding space."""
return cosine(embed(question, model), embed(answer, model))
def llm_faithfulness(answer, context, model):
"""LLM as judge: is every claim in the answer supported by the context?"""
prompt = (
f'Ngữ cảnh: "{context}"\n'
f'Câu trả lời: "{answer}"\n\n'
"Mọi khẳng định trong câu trả lời có được suy ra trực tiếp từ ngữ cảnh không? "
"Chỉ trả về DUY NHẤT một số nguyên từ 0 đến 10, không giải thích."
)
payload = {"model": model, "stream": False, "options": {"temperature": 0.0},
"messages": [{"role": "user", "content": prompt}]}
reply = post("/api/chat", payload)["message"]["content"]
match = re.search(r"\d+", reply)
return int(match.group()) / 10 if match else 0.0
# ---------------------------------------------------------------------------
# Evaluating the evaluator
# ---------------------------------------------------------------------------
def report_table(title, rows, note=None):
print(f"=== {title} ===")
print(f" {'sample':<16}{'faithfulness':>14}{'context recall':>16}{'answer relevance':>18}")
for label, faith, recall, relevance in rows:
print(f" {label:<16}{faith:>13.2%} {recall:>15.2%} {relevance:>17.2%}")
if note:
print(f" {note}")
print()
def main():
print("The test set holds one correct answer and one that is plainly wrong.")
print("A metric that cannot tell them apart is not measuring anything.\n")
keyword_rows = [
(s["label"],
keyword_faithfulness(s["answer"], s["context"]),
keyword_context_recall(s["context"], s["ground_truth"]),
keyword_answer_relevance(s["question"], s["answer"]))
for s in TEST_DATASET
]
report_table("Metrics computed from word overlap only", keyword_rows)
good, bad = keyword_rows[0], keyword_rows[1]
print("=== Does each keyword metric separate right from wrong? ===")
# Context recall is excluded on purpose: it scores the RETRIEVER (did the
# context cover the ground truth), so it has no opinion about the answer.
for index, name in ((1, "faithfulness "), (3, "answer relevance")):
gap = good[index] - bad[index]
verdict = "ok" if gap > 0 else "INVERTED - scores the wrong answer higher"
print(f" {name}: {good[index]:.2%} vs {bad[index]:.2%}"
f" gap {gap:+.2%} {verdict}")
print(f" context recall : {good[2]:.2%} vs {bad[2]:.2%}"
f" not applicable - it grades the retriever, not the answer")
print(" Faithfulness works: the wrong answer shares almost no words with its")
print(" context. Answer relevance is inverted - the metric is broken.\n")
names = installed_models()
embed_model = pick(names, EMBED_PREFERRED)
chat_model = pick(names, CHAT_PREFERRED)
if not embed_model or not chat_model:
print("Ollama with an embedding model and a chat model is needed for the")
print("semantic metrics. Try: ollama pull bge-m3 && ollama pull qwen2.5:7b")
return
print(f"embedding model: {embed_model}\nchat model: {chat_model}\n")
print("=== The same two metrics, computed the way Ragas actually does ===")
for sample in TEST_DATASET:
relevance = semantic_answer_relevance(sample["question"], sample["answer"],
embed_model)
faithfulness = llm_faithfulness(sample["answer"], sample["context"],
chat_model)
print(f" {sample['label']:<16} relevance {relevance:.4f}"
f" faithfulness {faithfulness:.2f}")
print()
print("=== What the numbers say ===")
print(" Embedding relevance puts the correct answer back on top, but only")
print(" just. That is the right behaviour, not a weakness: the wrong answer")
print(" IS about LoRA, it is simply false. Relevance asks 'does this address")
print(" the question', never 'is this true'.")
print(" Falsity is faithfulness's job, and that is where the gap is wide.")
print(" Pick the metric that can fail on the defect you actually fear.")
if __name__ == "__main__":
main()
Three metrics computed from word overlap
=== Metrics computed from word overlap only ===
sample faithfulness context recall answer relevance
correct answer 75.00% 60.87% 8.00%
WRONG answer 17.65% 69.57% 15.79%
Looking at that table alone, everything seems fine: there are numbers, there are percentages, a report could be exported. But the right question is: which metric separates the correct answer from the wrong one?
=== Does each keyword metric separate right from wrong? ===
faithfulness : 75.00% vs 17.65% gap +57.35% ok
answer relevance: 8.00% vs 15.79% gap -7.79% INVERTED - scores the wrong answer higher
context recall : 60.87% vs 69.57% not applicable - it grades the retriever, not the answer
Faithfulness works: the wrong answer shares almost no words with its
context. Answer relevance is inverted - the metric is broken.
Deploy this evaluator to CI with a threshold of "answer relevance must exceed 10%" and you have built a gate that blocks correct answers and admits wrong ones. And it will run silently that way for months.
Context recall, meanwhile, is not inverted — it simply does not answer this question. It measures whether the retrieved context covered the information in the ground truth, so it grades the retriever, not the answer. Putting it in the same table as the other two and then averaging all three, as the first version of this project did, mixes measurements of two different components into one number.
The same two metrics, computed the way Ragas actually does
Section 20.2 defines answer relevance via cosine similarity between embeddings, and faithfulness
as the share of claims derivable from the context — which requires a model that reads, not a word count.
The project implements exactly that: bge-m3 embeddings for relevance, and an LLM judge for
faithfulness (the cross-encoder technique from Lesson 16):
=== The same two metrics, computed the way Ragas actually does ===
correct answer relevance 0.7254 faithfulness 0.80
WRONG answer relevance 0.6965 faithfulness 0.00
=== What the numbers say ===
Embedding relevance puts the correct answer back on top, but only
just. That is the right behaviour, not a weakness: the wrong answer
IS about LoRA, it is simply false. Relevance asks 'does this address
the question', never 'is this true'.
Falsity is faithfulness's job, and that is where the gap is wide.
Pick the metric that can fail on the defect you actually fear.
Detecting falsity is faithfulness's job, and there the gap is wide: $0.80$ against $0.00$. The operational lesson: do not pick a metric because it sounds impressive, pick the one that can fail on the defect you actually fear.
How to run this project on your machine
-
python3 rag_evaluator.py. The keyword metrics run immediately. For the semantic comparison, start Ollama withollama pull bge-m3and a chat model. - The keyword numbers match this lesson exactly (the algorithm is deterministic). The LLM-judged faithfulness score depends on the model — what must match is the wide gap between correct and wrong.
-
Then try breaking it three ways:
-
Add a third sample to
TEST_DATASET: an answer that is factually true but off topic (answering about PagedAttention to a question about LoRA). Watch faithfulness and relevance separate — this is the case relevance should catch and faithfulness should not. -
Remove the
STOPWORDSlist. Every keyword metric shifts immediately, because connectives start counting. A metric whose result depends on a stopword list you typed by hand should not be compared against a fixed threshold. - Change sample 2's answer to a correct one. Both metrics must move accordingly. If you change the data and the scores do not change, your metric is not reading the data at all — exactly the lesson of Lesson 12.
-
Add a third sample to
Lesson summary & the end of the roadmap
-
Achieved: understanding vLLM's PagedAttention mechanism and why the default
gpu_memory_utilizationsetting causes OOM on a shared server. - Achieved: implementing the three Ragas metrics both ways — keyword counting and genuine semantics — and comparing them on a test set with a wrong answer planted in it.
- Achieved: discovering that the Jaccard-style answer relevance metric scores the wrong answer ($15.79\%$) at twice the correct one ($8.00\%$) — and understanding why an untested evaluator is more dangerous than no evaluator at all.
- Achieved: distinguishing relevance ("is this on topic") from faithfulness ("is this true"), and choosing metrics by the failure mode you fear.
The end of the roadmap: you have worked through all 20 lessons, from your first line of Python to an AI system with RAG, agents, a fine-tuned model and automated evaluation. If there is one thing worth carrying away from the whole roadmap, it is what this final lesson just demonstrated: every number deserves to be questioned, including the ones you wrote yourself. An AI system without a test that knows how to fail is not a working system — it is a system nobody has caught being broken yet.
Download the practice code for this lesson
The Python file rag_evaluator.py — the three Ragas metrics implemented both by keyword
overlap and semantically, plus the check that grades the metrics themselves (run
python3 rag_evaluator.py):
Comments