hands-on labin your browserfree

Build a RAG Pipeline with NVIDIA NIM

Build a complete Retrieval Augmented Generation pipeline, from document chunking to vector search to an agent that answers questions from your knowledge base.

time
35 min
checked steps
7
level
Intermediate
setup
None

Freeone of 160+ labs in Pro

free · no account needed to start

Lab cockpit35 min · 7 stepsSession running
3 / 7 steps passingStore Vectors in Milvus · step 4 of 7
main.py▶ Run✓ Check
splitter = RecursiveCharacterTextSplitter(chunk_size=150, chunk_overlap=30)chunks = splitter.split_documents(documents) embedder = NVIDIAEmbeddings(base_url="http://nim-proxy.labs.svc:8080/v1", model="nvidia/llama-nemotron-embed-vl-1b-v2", dimensions=1024, api_key="not-needed")vectors = embedder.embed_documents([c.page_content for c in chunks])print(f'{len(vectors)} vectors ready (dim={len(vectors[0])})') # Step 4: Store in Milvus         
TerminalOutput
step 4 of 7, replayed: Store Vectors in Milvus. The code, the output and the check are the lab's own.

7 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Create a Knowledge Base

    Retrieval Augmented Generation (RAG) solves a fundamental LLM limitation: models only know what they were trained on.

    read this step below ↓

  2. 2

    Chunk Documents

    Embedding models have a context window, typically 512 tokens.

  3. 3

    Generate Embeddings with NIM

    An embedding is a list of numbers (a vector) that represents the meaning of a piece of text.

  4. 4

    Store Vectors in Milvus

    You have vectors, now you need somewhere to store them and search by similarity.

  5. 5

    Semantic Search

    With your vectors stored, you can now search by meaning, not keywords.

  6. 6

    Build a RAG Agent

    Now we connect everything.

  7. 7

    Test Your RAG Pipeline

    A RAG pipeline can fail at multiple points: bad chunking, poor embeddings, irrelevant retrieval, or incorrect answer generation.

The lab, step by step

The free lab’s own text, every step of it. Hints and solutions stay inside the lab.

Step 1: Create a Knowledge Base

What is RAG?

Retrieval Augmented Generation (RAG) solves a fundamental LLM limitation: models only know what they were trained on. If you ask about your company's internal docs, yesterday's news, or domain-specific data — the LLM will either hallucinate or say "I don't know."

RAG fixes this by retrieving relevant information before generating an answer:

Question → Search knowledge base → Find relevant docs → Add to prompt → LLM generates answer

The LLM's response is now grounded in real data, not just training knowledge.

What you'll build: a RAG Pipeline

In this lab, you'll build a complete Retrieval Augmented Generation (RAG) pipeline — the technique that lets LLMs answer questions from your own data instead of hallucinating.

By the end, your pipeline will:

  • Load and chunk documents into searchable pieces
  • Generate vector embeddings using NVIDIA NIM
  • Store vectors in a Milvus Lite database
  • Search by meaning (semantic search), not just keywords
  • Power a ReAct agent that retrieves knowledge before answering
  • Be evaluated for retrieval quality

Test scenarios your finished RAG agent handles:

User asksPipeline doesResult
"What is NVIDIA NIM?"Embeds query → searches Milvus → retrieves NIM docs → answersAccurate answer from knowledge base
"What is Milvus?"Retrieves vector DB docs"Open-source vector database..."
"How does RAG reduce hallucination?"Retrieves RAG overview docsGrounded answer about retrieval
A question not in the knowledge baseSearches but finds low relevanceHonest "not found" or general answer

You'll build this across 7 steps, starting with raw documents and ending with a tested RAG agent.

The RAG pipeline

Every RAG system has two phases:

Indexing (one-time):

  1. Load documents
  2. Split into chunks
  3. Generate embeddings (vectors)
  4. Store in a vector database

Querying (per question):

  1. Embed the question
  2. Search for similar chunks
  3. Add top results to the LLM prompt
  4. Generate an answer

Documents in LangChain

LangChain uses Document objects to represent text with metadata:

from langchain_core.documents import Document

doc = Document(
    page_content="NVIDIA NIM provides optimized AI inference...",
    metadata={"source": "nvidia_docs.txt", "page": 1}
)

The metadata dict travels with the content through the entire pipeline — so when you retrieve a chunk, you know where it came from.

Your task

  1. Import Document from langchain_core.documents
  2. Create a knowledge base of 4+ documents about NVIDIA AI tools
  3. Each document needs page_content and metadata with a source field

NCP-AAI exam note: RAG is covered under Knowledge Integration (10% of the exam). The exam tests: document loading strategies, chunking approaches, embedding models, retriever types, and how RAG integrates with agents. Understanding when RAG is better than fine-tuning is a common exam question.

main.py, the file you edit12 lines
# Step 1: Create a Knowledge Base
# TODO: Import Document from langchain_core.documents


# TODO: Create a list of Document objects about NVIDIA AI tools
# Each needs: page_content (str) and metadata (dict with 'source' key)
# Include docs about: NIM, RAG, NeMo Agent Toolkit, Vector Databases
documents = ______

print(f'Knowledge base: {len(documents)} documents')
for doc in documents:
    print(f'  [{doc.metadata["source"]}] {doc.page_content[:80]}...')

Step 2: Chunk Documents

Why chunk documents?

Embedding models have a context window — typically 512 tokens. A full document might be thousands of tokens. If you embed the entire document as one vector, you lose granularity: the vector represents the "average meaning" of everything, not specific concepts.

Chunking splits documents into smaller pieces, each capturing a focused concept:

Full document (2000 tokens) → Chunk 1 (150 tokens) + Chunk 2 (150 tokens) + ...

RecursiveCharacterTextSplitter

LangChain's most popular splitter. It tries to split on natural boundaries:

  1. First tries \n\n (paragraph breaks)
  2. Then \n (line breaks)
  3. Then (spaces)
  4. Finally, individual characters
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=150,      # Max characters per chunk
    chunk_overlap=30,    # Characters shared between consecutive chunks
)

chunks = splitter.split_documents(documents)

Why overlap?

Without overlap, a sentence at a chunk boundary gets split in half. Overlap ensures the end of chunk N and the start of chunk N+1 share some text, preserving context:

Chunk 1: "...NIM supports LLMs, embedding models, and vision models"
Chunk 2: "embedding models, and vision models with an OpenAI-compatible API."
                ^--- overlap preserves context ---^

Chunk size tradeoffs

Chunk sizeProsCons
Small (100-200)Precise retrievalMay lose context
Medium (500-1000)Good balanceMight include irrelevant text
Large (1000+)Full contextPoor retrieval precision

For this lab, we use chunk_size=150 — small chunks that demonstrate the concept clearly.

Your task

Add to main.py:

  1. Import RecursiveCharacterTextSplitter from langchain_text_splitters
  2. Create a splitter with chunk_size=150 and chunk_overlap=30
  3. Split your documents and verify you get more chunks than original docs

NCP-AAI exam note: Chunking strategy is a key RAG design decision. The exam tests understanding of: chunk size vs retrieval quality tradeoff, overlap purpose, and alternative splitting strategies (token-based, semantic).

main.py, the file you edit23 lines
# Step 1: Create a Knowledge Base
from langchain_core.documents import Document

documents = [
    Document(page_content="NVIDIA NIM provides optimized AI model deployment as containerized microservices. NIM supports LLMs, embedding models, and vision models with an OpenAI-compatible API.", metadata={"source": "nvidia_nim.txt"}),
    Document(page_content="Retrieval Augmented Generation (RAG) enhances LLM responses by retrieving relevant context from external knowledge bases. RAG reduces hallucination and allows access to up-to-date information beyond training data.", metadata={"source": "rag_overview.txt"}),
    Document(page_content="NeMo Agent Toolkit is an open-source library for building AI agents. It supports ReAct agents, tool calling, and YAML workflow configuration with built-in evaluation and profiling.", metadata={"source": "nemo_toolkit.txt"}),
    Document(page_content="Vector databases store high-dimensional embeddings and enable fast similarity search. Milvus is an open-source vector database that supports both embedded (Milvus Lite) and distributed deployments.", metadata={"source": "vector_db.txt"}),
]

# Step 2: Chunk Documents
# TODO: Import RecursiveCharacterTextSplitter from langchain_text_splitters


# TODO: Create a splitter with chunk_size=150 and chunk_overlap=30
splitter = ______

# TODO: Split the documents
chunks = ______

print(f'Original: {len(documents)} docs -> {len(chunks)} chunks')
for i, chunk in enumerate(chunks[:4]):
    print(f'  [{i}] ({chunk.metadata["source"]}) {chunk.page_content[:80]}...')

Step 3: Generate Embeddings with NIM

Embeddings: turning text into vectors

An embedding is a list of numbers (a vector) that represents the meaning of a piece of text. Texts with similar meanings have vectors that are close together in the vector space.

"What is RAG?"           → [0.12, -0.34, 0.56, ...]  (1024 numbers)
"Retrieval augmented generation" → [0.11, -0.33, 0.55, ...]  (very similar!)
"How to cook pasta"      → [-0.87, 0.21, -0.43, ...]  (very different)

This is what makes semantic search work — you compare vectors instead of matching keywords.

NVIDIA NIM embedding models

NIM provides embedding models just like LLM models. The NVIDIAEmbeddings class from LangChain handles the API calls:

from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings

embedder = NVIDIAEmbeddings(
    base_url="http://nim-proxy.labs.svc:8080/v1",
    model="nvidia/llama-nemotron-embed-vl-1b-v2",
    dimensions=1024,
    dimensions=1024,
    api_key="not-needed",
)

dimensions=1024 matters. This model emits 2048 numbers per text by default and supports shorter outputs on request. Asking for 1024 keeps every vector half the size in Milvus, and the collection you create in the next step must use the same width.

Two types of embeddings

The llama-nemotron-embed-vl-1b-v2 model is asymmetric — it generates different embeddings for documents vs queries:

  • embed_documents(texts) — for indexing your knowledge base
  • embed_query(text) — for the user's search query

Why different? Because a query like "What is NIM?" and a document like "NVIDIA NIM provides optimized inference..." express the same concept differently. Asymmetric embeddings map them closer together than symmetric ones would.

# For your knowledge base chunks
doc_vectors = embedder.embed_documents(["NVIDIA NIM provides...", "RAG reduces..."])

# For user queries
query_vector = embedder.embed_query("What is NIM?")

Your task

Add to main.py:

  1. Import NVIDIAEmbeddings from langchain_nvidia_ai_endpoints
  2. Create an embedder with the nvidia/llama-nemotron-embed-vl-1b-v2 model and dimensions=1024
  3. Embed all your chunks using embed_documents()
  4. Print the embedding dimension (should be 1024)

NCP-AAI exam note: The exam tests embedding concepts: vector dimension, similarity metrics (cosine, L2, inner product), asymmetric vs symmetric models, and how embedding quality affects retrieval. NIM serves embedding models alongside LLMs — they're both microservices.

main.py, the file you edit33 lines
# Steps 1-2: Knowledge base + Chunking
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

documents = [
    Document(page_content="NVIDIA NIM provides optimized AI model deployment as containerized microservices. NIM supports LLMs, embedding models, and vision models with an OpenAI-compatible API.", metadata={"source": "nvidia_nim.txt"}),
    Document(page_content="Retrieval Augmented Generation (RAG) enhances LLM responses by retrieving relevant context from external knowledge bases. RAG reduces hallucination and allows access to up-to-date information beyond training data.", metadata={"source": "rag_overview.txt"}),
    Document(page_content="NeMo Agent Toolkit is an open-source library for building AI agents. It supports ReAct agents, tool calling, and YAML workflow configuration with built-in evaluation and profiling.", metadata={"source": "nemo_toolkit.txt"}),
    Document(page_content="Vector databases store high-dimensional embeddings and enable fast similarity search. Milvus is an open-source vector database that supports both embedded (Milvus Lite) and distributed deployments.", metadata={"source": "vector_db.txt"}),
]

splitter = RecursiveCharacterTextSplitter(chunk_size=150, chunk_overlap=30)
chunks = splitter.split_documents(documents)
print(f'{len(chunks)} chunks ready')

# Step 3: Generate Embeddings
# TODO: Import NVIDIAEmbeddings from langchain_nvidia_ai_endpoints


# TODO: Create an embedder with:
#   base_url = "http://nim-proxy.labs.svc:8080/v1"
#   model = "nvidia/llama-nemotron-embed-vl-1b-v2"
#   dimensions = 1024
#   api_key = "not-needed"
embedder = ______

# TODO: Embed all chunk texts using embed_documents()
texts = [c.page_content for c in chunks]
vectors = ______

print(f'Embedded {len(vectors)} chunks')
print(f'Embedding dimension: {len(vectors[0])}')
print(f'First vector (3 values): {vectors[0][:3]}')

Step 4: Store Vectors in Milvus

Vector databases: storing and searching embeddings

You have vectors — now you need somewhere to store them and search by similarity. That's what a vector database does.

How similarity search works

Given a query vector, the database finds the stored vectors that are most similar (closest in vector space):

Query: [0.12, -0.34, 0.56, ...]

Stored vectors:
  Doc A: [0.11, -0.33, 0.55, ...]  → distance: 0.02 (very close! ✓)
  Doc B: [-0.87, 0.21, -0.43, ...] → distance: 1.45 (far away ✗)
  Doc C: [0.10, -0.35, 0.54, ...]  → distance: 0.03 (close! ✓)

Milvus Lite

Milvus is an open-source vector database used in production RAG systems. Milvus Lite is an embedded version that runs locally — no server needed, just a file:

from pymilvus import MilvusClient

client = MilvusClient("/tmp/knowledge.db")  # Local file-based database

# Create a collection (like a table)
client.create_collection(
    collection_name="knowledge",
    dimension=1024,        # Must match your embedding dimension
    auto_id=True,          # Auto-generate IDs
)

# Insert vectors with metadata
client.insert(collection_name="knowledge", data=[
    {"vector": [0.1, 0.2, ...], "text": "NVIDIA NIM provides...", "source": "nim.txt"},
    {"vector": [0.3, 0.4, ...], "text": "RAG reduces...", "source": "rag.txt"},
])

Each stored item has three parts:

  • vector — the embedding (for similarity search)
  • text — the original chunk content (what you'll show to the LLM)
  • source — metadata (where the chunk came from)

Your task

Add to main.py:

  1. Import MilvusClient from pymilvus
  2. Create a client at /tmp/knowledge.db
  3. Create a collection named knowledge with dimension=1024
  4. Insert all chunks with their vectors, text, and source metadata

NCP-AAI exam note: Vector databases are critical RAG infrastructure. The exam tests: collection management, similarity metrics, index types (IVF, HNSW), and scaling strategies. In NeMo Agent Toolkit, the built-in _type: milvus_document_search wraps this functionality.

main.py, the file you edit44 lines
# Steps 1-3: Knowledge base + Chunking + Embeddings
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings

documents = [
    Document(page_content="NVIDIA NIM provides optimized AI model deployment as containerized microservices. NIM supports LLMs, embedding models, and vision models with an OpenAI-compatible API.", metadata={"source": "nvidia_nim.txt"}),
    Document(page_content="Retrieval Augmented Generation (RAG) enhances LLM responses by retrieving relevant context from external knowledge bases. RAG reduces hallucination and allows access to up-to-date information beyond training data.", metadata={"source": "rag_overview.txt"}),
    Document(page_content="NeMo Agent Toolkit is an open-source library for building AI agents. It supports ReAct agents, tool calling, and YAML workflow configuration with built-in evaluation and profiling.", metadata={"source": "nemo_toolkit.txt"}),
    Document(page_content="Vector databases store high-dimensional embeddings and enable fast similarity search. Milvus is an open-source vector database that supports both embedded (Milvus Lite) and distributed deployments.", metadata={"source": "vector_db.txt"}),
]

splitter = RecursiveCharacterTextSplitter(chunk_size=150, chunk_overlap=30)
chunks = splitter.split_documents(documents)

embedder = NVIDIAEmbeddings(base_url="http://nim-proxy.labs.svc:8080/v1", model="nvidia/llama-nemotron-embed-vl-1b-v2", dimensions=1024, api_key="not-needed")
vectors = embedder.embed_documents([c.page_content for c in chunks])
print(f'{len(vectors)} vectors ready (dim={len(vectors[0])})')

# Step 4: Store in Milvus
# TODO: Import MilvusClient from pymilvus


# TODO: Create a client at /tmp/knowledge.db
client = ______

# TODO: Create a collection named 'knowledge' with dimension=1024 and auto_id=True


# TODO: Insert data — each item needs: vector, text, source
data = []
for chunk, vec in zip(chunks, vectors):
    data.append({
        'vector': vec,
        'text': chunk.page_content,
        'source': chunk.metadata['source'],
    })

# TODO: Call client.insert(collection_name='knowledge', data=data)


print(f'Stored {len(data)} vectors in Milvus')
stats = client.get_collection_stats('knowledge')
print(f'Collection stats: {stats}')

Step 5: Semantic Search

Semantic search: finding relevant information

With your vectors stored, you can now search by meaning — not keywords. This is the retrieval step that makes RAG work.

How a search query flows

"What is NeMo Agent Toolkit?"
    ↓ embed_query()
[0.15, 0.28, -0.42, ...]          ← query vector
    ↓ similarity search
Find top 3 closest vectors in Milvus
    ↓ return
[{text: "NeMo Agent Toolkit is...", source: "nat.txt", distance: 0.85}, ...]

The search API

# Embed the query (asymmetric — different from document embeddings)
query_vector = embedder.embed_query("What is NeMo Agent Toolkit?")

# Search Milvus
results = client.search(
    collection_name="knowledge",
    data=[query_vector],           # Can search multiple queries at once
    limit=3,                       # Return top 3 results
    output_fields=["text", "source"]  # Which metadata to return
)

Each result has:

  • distance — how similar (higher = more similar for cosine/IP, lower for L2)
  • entity — the metadata fields you requested

embed_query vs embed_documents

Remember the asymmetric embeddings from step 3? This is where it matters:

# WRONG: using embed_documents for a query
query_vec = embedder.embed_documents(["What is NIM?"])[0]  # ✗ document embedding

# RIGHT: using embed_query for a query
query_vec = embedder.embed_query("What is NIM?")            # ✓ query embedding

Asymmetric models produce better retrieval results because queries and documents are phrased differently.

Your task

Create search.py that:

  1. Connects to the existing Milvus database from step 4
  2. Embeds a query using embed_query() (not embed_documents())
  3. Searches for the top 3 most similar chunks
  4. Prints results with similarity scores

NCP-AAI exam note: The exam tests retrieval strategies: semantic search (vector similarity), keyword search (BM25), hybrid search (combining both), and reranking (re-scoring results with a cross-encoder). Understanding the tradeoffs between these approaches is key.

search.py, the file you edit31 lines
# Step 5: Semantic Search
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings
from pymilvus import MilvusClient

# Connect to existing database
client = MilvusClient('/tmp/knowledge.db')
embedder = NVIDIAEmbeddings(base_url="http://nim-proxy.labs.svc:8080/v1", model="nvidia/llama-nemotron-embed-vl-1b-v2", dimensions=1024, api_key="not-needed")

# TODO: Define a query
query = "What is NeMo Agent Toolkit?"

# TODO: Embed the query using embed_query() (NOT embed_documents())
query_vector = ______

# TODO: Search Milvus for the top 3 most similar chunks
# Use client.search() with:
#   collection_name='knowledge'
#   data=[query_vector]
#   limit=3
#   output_fields=['text', 'source']
results = ______

print(f'Query: "{query}"\n')
print(f'Top {len(results[0])} results:')
for i, hit in enumerate(results[0]):
    score = hit['distance']
    text = hit['entity']['text']
    source = hit['entity']['source']
    print(f'  {i+1}. [{score:.3f}] ({source}) {text[:100]}...')

client.close()

Step 6: Build a RAG Agent

RAG + Agent: the complete pattern

Now we connect everything. Instead of manually searching and stuffing context into a prompt, we give the agent a retriever tool — it decides when and what to search.

Why agent-based RAG?

Simple RAG always retrieves, even for questions that don't need it:

User: "What is 2+2?"
Simple RAG: [searches knowledge base] → "No relevant results" → LLM guesses
Agent RAG: LLM decides → "This is math, no search needed" → "4"

An agent with a search tool is smarter — it retrieves only when the question actually needs knowledge from the database.

The retriever tool pattern

@tool
def search_knowledge_base(query: str) -> str:
    """Search the knowledge base for information about NVIDIA AI tools."""
    query_vec = embedder.embed_query(query)
    results = client.search(
        collection_name="knowledge",
        data=[query_vec],
        limit=3,
        output_fields=["text", "source"]
    )
    if not results[0]:
        return "No relevant information found."
    return "\n".join([
        f"[{hit['entity']['source']}] {hit['entity']['text']}"
        for hit in results[0]
    ])

The tool wraps the entire search pipeline: embed query → search Milvus → format results as text. The agent calls it like any other tool.

Creating the RAG agent

agent = create_agent(model=llm, tools=[search_knowledge_base], middleware=LIMITS)

result = agent.invoke({
    "messages": [{"role": "user", "content": "What is Milvus?"}]
})

The agent will:

  1. Read the question
  2. Decide to call search_knowledge_base with query "Milvus"
  3. Get back relevant chunks from your knowledge base
  4. Synthesize an answer grounded in the retrieved content

Your task

Create rag_agent.py that:

  1. Sets up the embedder + Milvus client (reuse from step 4)
  2. Creates a search_knowledge_base tool
  3. Creates a ReAct agent with this tool
  4. Tests it with a knowledge-base question

NCP-AAI exam note: Agent-based RAG is the recommended production pattern. The exam tests: when to use retrieval vs direct answer, how to evaluate RAG quality (answer relevance, context precision, faithfulness), and the architecture difference between simple RAG and agent RAG.

rag_agent.py, the file you edit39 lines
# Step 6: RAG Agent
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings, ChatNVIDIA
from langchain_core.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware
from pymilvus import MilvusClient

# An agent keeps looping while the model asks for tools. LIMITS caps the loop:
# after 6 tool calls further calls are refused, and after 10 model calls the run
# ends, so a model that keeps calling tools stops in seconds, not minutes.
LIMITS = [ToolCallLimitMiddleware(run_limit=6), ModelCallLimitMiddleware(run_limit=10, exit_behavior="end")]

# Setup
client = MilvusClient('/tmp/knowledge.db')
embedder = NVIDIAEmbeddings(base_url="http://nim-proxy.labs.svc:8080/v1", model="nvidia/llama-nemotron-embed-vl-1b-v2", dimensions=1024, api_key="not-needed")
llm = ChatNVIDIA(base_url="http://nim-proxy.labs.svc:8080/v1", model="meta/llama-3.3-70b-instruct", temperature=0)

# TODO: Define a search_knowledge_base tool using @tool
# It should:
#   1. Embed the query using embedder.embed_query()
#   2. Search Milvus for top 3 results
#   3. Return the matching text joined with newlines


# TODO: Create a ReAct agent with the search tool (pass middleware=LIMITS)
agent = ______

# TODO: Ask a question that requires the knowledge base
result = ______

# Print the conversation
for msg in result['messages']:
    role = msg.__class__.__name__
    if msg.content:
        print(f'{role}: {msg.content[:300]}')
    elif hasattr(msg, 'tool_calls') and msg.tool_calls:
        print(f'{role}: [calls {msg.tool_calls[0]["name"]}]')

client.close()

Step 7: Test Your RAG Pipeline

Evaluating RAG quality

A RAG pipeline can fail at multiple points: bad chunking, poor embeddings, irrelevant retrieval, or incorrect answer generation. Testing the final answer alone doesn't tell you where it failed.

What to evaluate

  1. Retrieval quality — Did the search return relevant chunks?
  2. Answer correctness — Does the final answer contain the right information?
  3. Groundedness — Is the answer supported by the retrieved context (not hallucinated)?

A practical evaluation approach

For this lab, we'll check answer correctness with keyword matching — does the answer contain expected information?

test_cases = [
    {"question": "What is NVIDIA NIM?", "expected": "microservice"},
    {"question": "What is Milvus?", "expected": "vector"},
]

for tc in test_cases:
    result = agent.invoke({"messages": [...]})
    answer = result["messages"][-1].content
    passed = tc["expected"].lower() in answer.lower()

This is a simplified version. Production systems use RAGAS metrics:

  • AnswerRelevance — Is the answer relevant to the question?
  • ContextPrecision — Are the retrieved chunks relevant?
  • Faithfulness — Is every claim in the answer supported by the context?

NeMo Agent Toolkit's nat eval supports these RAGAS metrics natively.

Your task

Create test_rag.py that:

  1. Recreates the RAG agent from step 6
  2. Defines at least 2 test questions about topics in your knowledge base
  3. Runs each question and checks for expected keywords
  4. Prints a pass/fail score

Note: Each agent call involves embedding + search + LLM inference, so this may take 30-60 seconds.

NCP-AAI exam note: RAG evaluation is a key exam topic. Key metrics: AnswerAccuracy, ContextRelevance, ResponseGroundedness. NeMo Agent Toolkit's nat eval system integrates with RAGAS for automated evaluation.

test_rag.py, the file you edit46 lines
# Step 7: Test RAG Pipeline
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings, ChatNVIDIA
from langchain_core.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware
from pymilvus import MilvusClient

# An agent keeps looping while the model asks for tools. LIMITS caps the loop:
# after 6 tool calls further calls are refused, and after 10 model calls the run
# ends, so a model that keeps calling tools stops in seconds, not minutes.
LIMITS = [ToolCallLimitMiddleware(run_limit=6), ModelCallLimitMiddleware(run_limit=10, exit_behavior="end")]

# Setup (same as step 6)
client = MilvusClient('/tmp/knowledge.db')
embedder = NVIDIAEmbeddings(base_url="http://nim-proxy.labs.svc:8080/v1", model="nvidia/llama-nemotron-embed-vl-1b-v2", dimensions=1024, api_key="not-needed")
llm = ChatNVIDIA(base_url="http://nim-proxy.labs.svc:8080/v1", model="meta/llama-3.3-70b-instruct", temperature=0)

@tool
def search_knowledge_base(query: str) -> str:
    """Search the knowledge base for information about NVIDIA AI tools and concepts."""
    qvec = embedder.embed_query(query)
    results = client.search(collection_name='knowledge', data=[qvec], limit=3, output_fields=['text', 'source'])
    if not results[0]: return 'No results found.'
    return '\n'.join([f'[{h["entity"]["source"]}] {h["entity"]["text"]}' for h in results[0]])

agent = create_agent(model=llm, tools=[search_knowledge_base], middleware=LIMITS)

# TODO: Define test cases (at least 2)
# Each should have 'question' and 'expected' (keyword that should appear in answer)
test_cases = [
    # TODO: Add test cases about topics in your knowledge base
]

# TODO: Run tests and track results
correct = 0
total = len(test_cases)

for i, tc in enumerate(test_cases):
    # TODO: Invoke the agent, get the final answer, check for expected keyword
    pass

print(f'\nScore: {correct}/{total}')
if total > 0:
    print(f'Accuracy: {correct/total*100:.0f}%')

client.close()

what you get

  • A hosted environment in your browser
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Basic Python (lists, dicts, functions)
  • Completed Lab 1 (ReAct Agent) or equivalent understanding
  • Understanding of what embeddings are (vectors representing text)

Exam domains covered

Knowledge Integration and Data HandlingAgent DevelopmentNVIDIA Platform Implementation

Part of the RAG and search track

Lab 1 of 14. Chunking, hybrid search and reranking, permission-aware retrieval, GraphRAG and RAG evaluation.

Questions about this lab

Why does this lab use Milvus Lite instead of a hosted vector database?

Milvus Lite is a single-file embedded build of Milvus, no server, no Docker, no network setup. It exposes the same MilvusClient API as the distributed version, so the code you write here (create_collection, insert, search with output_fields) works unchanged against production Milvus. The point of the lab is the retrieval pattern, not the deployment topology; graduating to a hosted cluster is a config swap once the pipeline logic is solid.

What's the difference between embed_documents and embed_query on an asymmetric model?

nvidia/llama-nemotron-embed-vl-1b-v2 is trained with separate prefixes for passages and queries, so the two methods produce different vectors for the same text. embed_documents is what you call during indexing, it treats the input as a passage to be retrieved. embed_query is what you call at search time, it treats the input as a short question. Using the wrong one at search time typically costs 10, 20% recall on retrieval-quality benchmarks, which is why the lab enforces the distinction.

Why give the agent a retrieval tool instead of always stuffing retrieved chunks into the prompt?

Always-retrieve RAG runs an embedding call and a vector search even on questions like "what's 2+2" where the knowledge base is irrelevant, and it dilutes the prompt with chunks that don't help. Giving the ReAct agent a search_knowledge_base tool lets the LLM decide, it calls the tool when the question needs domain knowledge and skips it when it doesn't. This is the pattern the NeMo Agent Toolkit encourages via function-group retrievers and the same shape a production agent ships with.

How is chunk_size=150 chosen, and would I use that in production?

For this lab, 150 characters is deliberately small so the splitter produces multiple chunks per document and you can see the effect of overlap on a readable scale. In production you'd usually sit in the 500, 1000 character range for general text, use token-based splitters for code or tables, and consider semantic chunkers for long-form content. The tradeoff is always: smaller chunks give more precise retrieval, larger chunks preserve more context per hit.

What models does the NIM proxy expose in this lab?

Two: nvidia/llama-nemotron-embed-vl-1b-v2 for the asymmetric embeddings, requested at 1024 dimensions with dimensions=1024, and meta/llama-3.3-70b-instruct as the LLM driving the ReAct agent. Both are reached through the same OpenAI-compatible endpoint at http://nim-proxy.labs.svc:8080/v1, which is why NVIDIAEmbeddings and ChatNVIDIA point at identical base_url strings. You don't manage keys, the proxy injects credentials on your behalf.

How is the final RAG quality actually evaluated?

Step 7 uses keyword-based correctness, for each test case, the agent runs, and the final answer is checked for an expected substring (e.g., the answer to "What is Milvus?" should contain "vector"). It's a deliberately simple harness to illustrate the pass/fail accuracy loop. In production, the same shape plugs into RAGAS metrics (AnswerRelevance, ContextPrecision, Faithfulness) via NeMo Agent Toolkit's nat eval, which the lab points you to at the end.

What you'll build in this RAG-with-NIM lab

RAG is the default architecture for every serious LLM application shipping in 2026, internal knowledge bots, documentation assistants, agentic search, customer support, because it's the only reliable way to ground a model in data it wasn't trained on. This lab takes you from raw documents to a working RAG pipeline plus a ReAct agent that decides when to retrieve, running on NVIDIA NIM endpoints we provision. You finish with a two-phase architecture in your head, a one-time indexing pipeline (load, chunk, embed, store) and a per-query retrieval pipeline (embed, search, augment, generate), plus a template that maps 1:1 onto production NeMo Agent Toolkit retrievers and RAGAS evaluation.

The technical substance is where most RAG implementations quietly break. You work through asymmetric embeddings with nvidia/llama-nemotron-embed-vl-1b-v2 and learn why embed_query versus embed_documents is worth a 10, 20% recall swing on the same corpus. You stand up Milvus Lite as a file-backed vector store and run cosine-similarity search over 1024-dim vectors, then graduate to an agent-based RAG pattern, a @tool-decorated search_knowledge_base handed to create_agent, where a ReAct loop on Llama 3.3 70B decides when to retrieve instead of always stuffing context into the prompt. You see why chunk overlap preserves sentence boundaries, why agent-based RAG beats always-retrieve on questions that don't need the knowledge base, and why the RAGAS faithfulness metric is the right signal for production drift.

Prerequisites are basic Python and a working mental model of what embeddings are; prior Milvus or LangChain experience is not assumed. The hosted environment ships with langchain-nvidia-ai-endpoints, langchain-text-splitters, pymilvus, and LangGraph preinstalled; every embedding and LLM call routes through our managed NIM proxy serving nvidia/llama-nemotron-embed-vl-1b-v2 and meta/llama-3.3-70b-instruct, same OpenAI-compatible surface, no keys to manage, no GPU pod to provision. About 35 minutes of focused work ending with a keyword-based eval harness that runs the agent on test queries and reports pass/fail accuracy, the same shape that slots into RAGAS metrics (AnswerRelevance, ContextPrecision, Faithfulness) via nat eval for real workloads.