Chat with LLMs — Python, Multimodal, Embeddings & RAG

Teaching: 10 min · Exercises: 65 min · Total: 75 min

Questions
  • How do I call NRP's managed LLMs from Python?
  • How can I send images or other non-text inputs to a model?
  • What are embeddings and how do I use them for semantic search?
  • How do I build a RAG pipeline over my own documents?
Objectives
  • Define a reusable chat() helper using the openai Python SDK.
  • Demonstrate multi-turn conversation, system prompts, and persona switching.
  • Send an image to a vision-capable model.
  • Embed text with qwen3-embedding and perform semantic similarity search.
  • Build a minimal RAG pipeline over CMS documentation.
Open the notebook in JupyterHub

▶ Open notebook in JupyterHub — clones the training repo and opens workspace/notebooks/2_chat.ipynb on uscms-af.nrp-nautilus.io.

Work through the notebook top to bottom (Shift+Enter to run each cell). The notebook covers:

StepTopic
1Setup check — verify env vars and client
2Basic chat — single completions and the chat() helper
3System prompts & personas
4Multi-turn interactive chat
5Embeddings and semantic similarity
6Multimodal — send a detector image
7RAG over CMS documentation

Prerequisites: your own personal OPENAI_API_KEY from Lesson 1, set alongside OPENAI_API_BASE (covered in the Setup Check below). A CPU-only session is sufficient for all exercises.


The content below is the notebook rendered as Markdown with example outputs. Run the live notebook on JupyterHub to execute cells and see your own results.


Available Models

Quick reference — the same catalog from Lesson 1, NRP model ID alongside the underlying HuggingFace model:

ModelHuggingFace IDParametersContextToolsVisionNotes
qwen3Qwen/Qwen3.5-397B-A17B-FP8397B (17B active MoE)1.01Mimage, videoLargest context
qwen3-smallQwen/Qwen3.6-27B27B1.01Mimage, video
gpt-ossopenai/gpt-oss-120b120B131KStrong at code
gemmagoogle/gemma-4-31B-it-qat-w4a16-ct31B262Kimage, video
gemma-smallgoogle/gemma-4-12B-it-qat-w4a16-ct12B262Kimage, videoEvaluating — fast, good default
minimax-m2MiniMaxAI/MiniMax-M2.7230B204KEvaluating — strong reasoning
glm-5nvidia/GLM-5.2-NVFP4744B300KEvaluating
deepseek-v4-flashdeepseek-ai/DeepSeek-V4-Flash-0731304B1.05MEvaluating
kimimoonshotai/Kimi-K2.7-Code1T MoE131Kimage, videoEvaluating
qwen3-embeddingQwen/Qwen3-VL-Embedding-8B8B262Kimage, videoEmbeddings only — semantic search/RAG

Evaluating models are under active testing — configuration can change without notice. This notebook defaults to gemma-small and minimax-m2, both good general-purpose choices. Check the live list or the LLM status dashboard if something isn't responding.


1. Setup Check

Verify the environment variables and OpenAI client.

Edit the OPENAI_API_KEY line in the cell below with your own personal token from Lesson 1, on the Analysis Hub or locally.

Python
import os

# The endpoint is fixed, but you always need to paste your own personal
# token below.
os.environ.setdefault("OPENAI_API_BASE", "https://ellm.nrp-nautilus.io/v1")
os.environ.setdefault("OPENAI_API_KEY", "<paste-your-token-here>")
Python
import os
from openai import OpenAI

print("OPENAI_API_BASE =", os.environ.get("OPENAI_API_BASE", "NOT SET"))
key = os.environ.get("OPENAI_API_KEY", "")
print("OPENAI_API_KEY  =", key[:8] + "..." if key else "NOT SET")

client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.environ["OPENAI_API_BASE"],
)

models = client.models.list()
print(f"\n{len(models.data)} models available:")
for m in sorted(models.data, key=lambda x: x.id):
    print(f"  {m.id}")

Example output:

OPENAI_API_BASE = https://ellm.nrp-nautilus.io/v1
OPENAI_API_KEY  = rifgnLi8...

10 models available:
  deepseek-v4-flash
  gemma
  gemma-small
  glm-5
  gpt-oss
  kimi
  minimax-m2
  qwen3
  qwen3-embedding
  qwen3-small

Optional: Set Up Jupyter AI in JupyterLab

Everything below can also be done without writing any code, using JupyterLab's built-in Jupyter AI extension — a chat sidebar backed by the same NRP models.

  1. Click the chat-bubble icon in the left sidebar to open Jupyter AI Chat, then click Start Here (or the gear icon) to open its settings.

Jupyter AI welcome panel — click Start Here to open settings

  1. Under Language model, point it at NRP instead of OpenAI's own API:
    • Completion model: choose OpenAI (general interface)... — this tells Jupyter AI to speak the OpenAI API format but let you supply your own server, instead of assuming platform.openai.com.
    • Model ID: the NRP model you want, e.g. minimax-m2.
    • Base API URL (optional): https://ellm.nrp-nautilus.io/v1 — the same OPENAI_API_BASE used everywhere else in this training.
    • Leave Organization and Proxy blank.
    • Under API Keys, paste your token into OPENAI_API_KEY — your personal token from Lesson 1.

Jupyter AI settings — Completion model, Model ID, Base API URL, and API key

  1. Close the settings panel and start chatting in the sidebar.

This is entirely optional — the rest of this notebook talks to NRP directly through the openai Python package, which works the same everywhere (JupyterLab, a script, your own machine) and doesn't depend on this extension being installed.


2. Basic Chat & the chat() Helper

The OpenAI chat API takes a list of messages, each with a role and content. You'll use three roles:

RolePurpose
systemThe system prompt — instructions that set the model's behavior/persona for the whole conversation. Sent once, usually first.
userWhat the human is asking.
assistantThe model's own previous replies — sent back on later turns so it remembers the conversation (see Section 4).

A few parameters worth knowing, including the ones our chat() helper below exposes as Python arguments:

ParameterWhat it does
modelWhich model to use — see the model table in the intro lesson.
messagesThe list of {role, content} turns described above.
system (chat() helper arg)Your system prompt as a plain string. chat() wraps it into a {"role": "system", "content": ...} message for you, so you don't have to build the messages list by hand.
max_tokensHard cap on reply length. Reasoning models (minimax-m2, qwen3, gpt-oss) spend part of this budget thinking privately before answering, so give them more room (1000+) or you may get an empty reply.
temperatureRandomness, from 0 (deterministic — same input gives the same answer) to ~1.5 (more varied/creative). 0.2 is a good default for factual or code answers.

Define a reusable helper that handles both regular and reasoning models. Reasoning models (minimax-m2, qwen3, gpt-oss) think privately before answering, so they need a larger max_tokens.

Python
def chat(prompt, model="gemma-small", system=None, max_tokens=1200):
    msgs = []
    if system:
        msgs.append({"role": "system", "content": system})
    msgs.append({"role": "user", "content": prompt})
    resp = client.chat.completions.create(
        model=model, messages=msgs, max_tokens=max_tokens, temperature=0.2,
    )
    msg = resp.choices[0].message
    if msg.content:
        return msg.content
    # Reasoning models stream thinking into a separate field
    reasoning = getattr(msg, "reasoning", None) or getattr(msg, "reasoning_content", None)
    if reasoning:
        return f"(reasoning only — increase max_tokens):\n{reasoning}"
    return "(no content returned)"
Python
print(chat(
    "What is the CMS detector and what is it used for?",
    system="Answer in two sentences for an audience of physics graduate students.",
))

Example output:

The CMS (Compact Muon Solenoid) detector is a general-purpose particle physics
detector at CERN's Large Hadron Collider, designed to observe a wide range of
particles and phenomena produced in proton-proton and heavy-ion collisions.
It is used to study the Standard Model, search for the Higgs boson and its
properties, and probe for physics beyond the Standard Model such as
supersymmetry and dark matter candidates.
Python
# Code generation — use gpt-oss which is strong at code tasks
print(chat(
    "Write a short Python snippet using uproot to open a ROOT file called 'data.root',"
    " read the TTree named 'Events', and print the number of entries.",
    model="gpt-oss",
))
Python
# Try a reasoning model for a harder question
print(chat(
    "Explain why the invariant mass of two muons is a useful observable for"
    " searching for new particles decaying to mu+mu-.",
    model="minimax-m2",
    max_tokens=2000,
))

3. System Prompts & Personas

The system prompt defines the model's role. Below: the same CMS question answered by four different roles. Swap in whatever is most useful for your workflow.

Python
QUESTION = "How do I apply a muon pT > 20 GeV selection in CMS NanoAOD with Python?"

ROLES = {
    "Teaching assistant": (
        "You are a supportive teaching assistant for CMS physicists. Explain "
        "clearly with short examples, guiding the learner toward the answer."),
    "Technical coder": (
        "You are an expert HEP software engineer. Write clean, correct Python "
        "using coffea or uproot, then briefly explain it and note edge cases."),
    "Concise expert": (
        "You are a senior CMS physicist. Answer precisely in a few sentences "
        "for a graduate-level audience. No filler."),
    "Documentation writer": (
        "You are a CMS documentation writer. Structure your answer with headings, "
        "a code block, and a note on common pitfalls."),
}

for role, system in ROLES.items():
    print(f"\n{'='*60}\n=== {role} ===")
    print(chat(QUESTION, system=system, model="gemma-small"))

4. Multi-Turn Interactive Chat

Run this cell, then type questions at the prompt. The model remembers context across turns — like office hours. Type quit to stop, reset to clear history.

Python
ROLE  = "Teaching assistant"   # change to any key in ROLES above
MODEL = "gemma-small"      # or minimax-m2, gpt-oss, qwen3

SYSTEMS = {
    "Teaching assistant": (
        "You are a supportive teaching assistant for CMS physicists. Explain "
        "clearly, build intuition with examples, and guide the learner."),
    "CMS expert": (
        "You are an expert CMS physicist. Answer precisely and technically."),
    "Technical coder": (
        "You are an expert HEP software engineer. Write clean Python and explain it briefly."),
}

history = []
print(f"Chatting as: {ROLE} ({MODEL}).  Type 'quit' to stop, 'reset' to clear.\n")
while True:
    try:
        msg = input("You: ").strip()
    except (EOFError, KeyboardInterrupt):
        print("\n(ended)"); break
    if msg.lower() in ("quit", "exit", "q", ""):
        print("Bye!"); break
    if msg.lower() == "reset":
        history.clear(); print("(conversation cleared)\n"); continue
    history.append({"role": "user", "content": msg})
    r = client.chat.completions.create(
        model=MODEL, max_tokens=1000, temperature=0.5,
        messages=[{"role": "system", "content": SYSTEMS[ROLE]}] + history)
    reply = r.choices[0].message.content or "(no reply)"
    history.append({"role": "assistant", "content": reply})
    print(f"AI: {reply}\n")

5. Embeddings & Semantic Similarity

qwen3-embedding converts text into vectors where similar meanings sit closer together. Semantic search is just a dot product between normalized vectors.

Why this matters for this audience — a few CMS-adjacent uses:

Python
import numpy as np
import matplotlib.pyplot as plt

def embed(texts):
    r = client.embeddings.create(model="qwen3-embedding", input=texts)
    v = np.array([d.embedding for d in r.data])
    return v / np.linalg.norm(v, axis=1, keepdims=True)  # normalize for cosine

docs = [
    "The Higgs boson was discovered by CMS and ATLAS in 2012 at the LHC.",
    "NanoAOD is a compact ROOT-based data format used for CMS physics analysis.",
    "Muon transverse momentum is reconstructed from tracks in the CMS tracker and muon chambers.",
    "Missing transverse energy signals the presence of undetected particles such as neutrinos.",
    "Deep neural networks are used in CMS for b-jet tagging and Level-1 trigger decisions.",
    "uproot and coffea are Python libraries widely used for CMS NanoAOD analysis.",
    "Cats like to nap in the sun.",  # deliberately unrelated
]

D = embed(docs)
query = "How does CMS measure the momentum of charged particles?"
sims = D @ embed([query])[0]

print(f"Query: {query}\n")
for i in sims.argsort()[::-1]:
    print(f"  {sims[i]:.3f}  {docs[i]}")

Example output:

Query: How does CMS measure the momentum of charged particles?

  0.712  Muon transverse momentum is reconstructed from tracks in the CMS tracker and muon chambers.
  0.634  The Higgs boson was discovered by CMS and ATLAS in 2012 at the LHC.
  0.601  Deep neural networks are used in CMS for b-jet tagging and Level-1 trigger decisions.
  0.589  uproot and coffea are Python libraries widely used for CMS NanoAOD analysis.
  0.571  Missing transverse energy signals the presence of undetected particles such as neutrinos.
  0.543  NanoAOD is a compact ROOT-based data format used for CMS physics analysis.
  0.301  Cats like to nap in the sun.
Python
# Pairwise similarity heatmap
M = D @ D.T
fig, ax = plt.subplots(figsize=(7, 5))
im = ax.imshow(M, cmap="viridis", vmin=0, vmax=1)
labels = [d[:35] + "..." for d in docs]
ax.set_xticks(range(len(docs))); ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=7)
ax.set_yticks(range(len(docs))); ax.set_yticklabels(labels, fontsize=7)
fig.colorbar(im, label="cosine similarity")
ax.set_title("CMS sentence similarity (qwen3-embedding)")
plt.tight_layout(); plt.show()

6. Multimodal — Send a Detector Image

Vision models (gemma-small, gemma, qwen3) accept images alongside text. Below we send a CMS figure and ask the model to describe it. Swap IMG_URL for any detector plot or event display you want to query.

Python
import base64, requests
from pathlib import Path
from IPython.display import Image, display

# A public CMS figure — replace with any image URL.
IMG_URL = "https://cms-results.web.cern.ch/cms-results/public-results/publications/HIG-19-004/CMS-HIG-19-004_Figure_005-b.png"
raw = requests.get(IMG_URL, timeout=30).content

# If the public URL above is rate-limited or unreachable, use the local copy
# in the workspace instead:
# raw = Path("../../images/CMS-HIG-19-004_Figure_005-b.png").read_bytes()

display(Image(data=raw, width=500))  # shrunk for display only — full-res bytes still go to the model

b64 = base64.b64encode(raw).decode()
r = client.chat.completions.create(
    model="gemma-small",
    max_tokens=300,
    messages=[{"role": "user", "content": [
        {"type": "text",
         "text": "This is a figure from a CMS physics paper. "
                 "Describe what you see: axes, distributions, and what physics measurement it likely represents."},
        {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
    ]}],
)
print("Model sees:\n", r.choices[0].message.content)

7. RAG — Answer from CMS Documentation

RAG (Retrieval-Augmented Generation) = embed your documents, retrieve the most relevant chunks for a question, then ask the LLM to answer only from that context. This keeps answers grounded and prevents hallucination on domain-specific content.

Here we point it at the LPC Physics Forum schedule — a real page that's updated regularly with upcoming talks. That's a good demonstration of RAG's real value: the LLM's own training data has a cutoff, but RAG lets it answer correctly about content added after that cutoff, as long as you retrieve it at question time.

Both the embedding model and the LLM are NRP-managed — nothing to install.

Python
import html, re, requests

# --- Corpus: load and chunk a document ---
# The LPC Physics Forum schedule — a real, frequently-updated page, and a
# good stand-in for "a live doc I want an LLM to answer from correctly."
RAW_URL = "https://lpc.fnal.gov/programs/lpcpf/index.shtml"
raw_html = requests.get(RAW_URL, timeout=30).text

# Strip tags/scripts and decode HTML entities to get plain text
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", raw_html, flags=re.S | re.I)
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n\s*\n+", "\n", text).strip()

# Chunk with slight overlap so context isn't cut mid-sentence
CHUNK_SIZE, OVERLAP = 700, 150
chunks = [text[i:i+CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE - OVERLAP)]
print(f"Loaded {len(text):,} chars → {len(chunks)} chunks.")
Python
# Embed all chunks with qwen3-embedding (one API call)
chunk_vecs = embed(chunks)
print(f"Embedded {len(chunks)} chunks → dim {chunk_vecs.shape[1]}.")

def retrieve(question, k=3):
    qv = embed([question])[0]
    scores = chunk_vecs @ qv
    top = scores.argsort()[-k:][::-1]
    return [(chunks[i], float(scores[i])) for i in top]
Python
SYSTEM_RAG = (
    "Answer the question using ONLY the provided context. "
    "If the context does not contain the answer, say so explicitly. Be concise."
)

def ask_rag(question, model="minimax-m2"):
    context = "\n\n".join(text for text, _ in retrieve(question))
    return chat(
        f"Context:\n{context}\n\nQuestion: {question}",
        system=SYSTEM_RAG, model=model,
    )

# Try a question that IS in the document
q1 = "When is the next LPC Physics Forum talk, and what is it about?"
print(f"Q: {q1}\nRetrieved chunks:")
for text, score in retrieve(q1):
    print(f"  score={score:.3f}  {text[:65].strip()}...")
print("\nAnswer:", ask_rag(q1))
Python
# Try a question that is NOT in the document — model should decline
q2 = "What is the invariant mass of the Z boson?"
print(f"Q: {q2}")
print("Answer:", ask_rag(q2))

Takeaways

Next: Agentic Workflows — point opencode at NRP's managed LLM and have it write CMS analysis code autonomously.

Key Points
  • One openai.OpenAI client, one base_url, covers chat, embeddings, and vision.
  • System prompts control model behavior without changing the user-facing interface.
  • Embeddings map text to vectors — semantic search is just a dot product.
  • RAG = embed your docs, retrieve closest chunks, answer only from that context.