Build a Slay the Spire Strategy Assistant

Build a local RAG pipeline that answers Slay the Spire questions.

Introduction

30 Second Summary

Imagine asking an AI for help with a tough game, and instead of a vague generic answer, it pulls from the exact strategy guides that experienced players swear by. That is the difference between a regular chatbot and one powered by a real knowledge base.

In this project, you will build a local AI-powered strategy assistant for Slay the Spire. You will feed it curated strategy documents and chat with it through a web interface, receiving answers grounded in specific card synergies, boss fight tactics, and relic tier lists.

What You'll Build

A Retrieval-Augmented Generation (RAG) pipeline that ingests strategy guides, stores them as vector embeddings, retrieves relevant context for each question, and generates grounded answers through a local LLM.

By the end of this project, you'll have:

  • A Python ingestion script that chunks and embeds strategy documents into a ChromaDB vector store.
  • A RAG query chain using LangChain that retrieves relevant context and generates answers via a local LLM running on Ollama.
  • A Streamlit chat interface for asking strategy questions and receiving grounded answers in real time.
  • Secret Mission: Add source citations so every answer shows which strategy guide it drew from.

Are there any prerequisites?

You should be comfortable with basic Python (variables, functions, running scripts). No prior experience with AI, machine learning, or RAG is needed.

Set Up Your Environment

A RAG system has several moving parts that need to work together. You need a local LLM to generate answers, an embedding model to convert text into vectors, and Python libraries to orchestrate the pipeline.

In this step, you will install Ollama (the engine that runs AI models on your machine), pull two models, and set up a Python project with all the dependencies your RAG assistant needs.

In this step, get ready to:
  • Install Ollama and pull the chat and embedding models.
  • Create a Python virtual environment with all dependencies.
  • Set up the project file structure.
Install Ollama and pull models

Ollama lets you run large language models locally on your GPU. You need two models for this project: one for generating answers and one for creating vector embeddings.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Check if Ollama is already installed by running this command:
ollama --version

✔️ I see a version number

You should see output like ollama version 0.30.10 (your version number may differ). Ollama is ready to go. Skip ahead to pulling models below.

ⓧ Command not found

Ollama is not installed yet. Follow these steps to install it.

  • Open your browser and go to https://ollama.com/download/windows.
  • Click the download button and run the installer once it finishes downloading.
  • Follow the installer prompts to complete the installation.
  • Close and reopen PowerShell so it picks up the new PATH entry.
  • Verify the installation by running:
ollama --version

You should now see a version number like ollama version 0.30.10.

What is Ollama?

Ollama is a tool that downloads, manages, and serves AI models locally on your machine. It handles the GPU memory allocation and provides a simple API that other tools (like LangChain) can connect to.

  • Pull the chat model by running this command:
ollama pull llama3.2

This downloads the llama3.2 model (approximately 2GB). It will take a few minutes depending on your internet speed.

  • Pull the embedding model by running this command:
ollama pull nomic-embed-text

This downloads nomic-embed-text (274MB), which converts text into numerical vectors for similarity search.

Why two models?

RAG systems need two different models for two different jobs. The embedding model (nomic-embed-text) turns text into numbers so you can search by meaning.

The chat model (llama3.2) reads your question plus the retrieved context and writes a human-readable answer.

  • Confirm both models are available by running:
ollama list

You should see both llama3.2 and nomic-embed-text listed in the output table with their NAME, ID, SIZE, and MODIFIED columns.

Model pull stuck or failing?

Make sure Ollama is actually running in the background. Look for the Ollama icon in your system tray (bottom-right of your taskbar). If it is not there, search for Ollama in the Start menu and launch it.

If the download stalls, press Ctrl+C to cancel and re-run the pull command. Ollama resumes from where it left off.

Still stuck? Help me troubleshoot my Ollama model pull on Windows.

Create project directory and install dependencies

Now you need a dedicated folder for your project and a Python virtual environment to keep your dependencies isolated.

  • Move to your Desktop by running this command:
cd ~\Desktop
  • Create your project folder and move into it by running these commands:
mkdir sts-rag-assistant
cd sts-rag-assistant
  • Create a Python virtual environment by running:
python -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay separate from your system Python, preventing version conflicts between projects.

  • Activate the virtual environment by running:
.\venv\Scripts\activate

You should see (venv) appear at the beginning of your PowerShell prompt. This confirms the virtual environment is active.

Seeing a script execution error?

If PowerShell says scripts are disabled, you need to allow script execution for your current session. Run this command, then try activating again:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

Need more help? Help me fix PowerShell execution policy errors when activating a Python venv.

  • Install all project dependencies by running:
pip install langchain-ollama langchain-chroma langchain-community streamlit pypdf

This installs five packages (plus their dependencies like ChromaDB). It may take a minute or two.

  • Verify the installation by running this quick import test:
python -c "from langchain_ollama import ChatOllama; print('OK')"

You should see OK printed with no errors.

Import error or module not found?

Make sure your virtual environment is still active. Look for (venv) at the start of your prompt. If it disappeared, run .\venv\Scripts\activate again before retrying.

If the error persists, try re-running the pip install command. Network interruptions can cause incomplete installs.

Still seeing errors? Help me debug my Python dependency installation for LangChain and ChromaDB.

Create the project file structure

Your project needs two Python files (one for ingesting documents, one for the chat app) and a folder to hold your strategy guides.

  • Create the two Python files and the documents folder by running these commands:
New-Item ingest.py, app.py
mkdir docs
  • Confirm the files exist by running:
ls

You should see ingest.py, app.py, docs, and venv listed in the output.

What are these files for?

ingest.py will load your strategy documents, split them into chunks, and store their embeddings in a vector database.

app.py will contain the RAG chain and the Streamlit chat interface. The docs/ folder will hold your Slay the Spire strategy guides.

Your environment is fully set up with Ollama running two models, a clean virtual environment, and the project skeleton ready. Next up, you will create strategy documents and build the ingestion pipeline that turns them into searchable vectors.

Build the Knowledge Base

Your models are downloaded and your environment is ready. Now it is time to give your assistant something to be smart about.

A RAG system is only as good as the knowledge you feed it. In this step, you will create curated strategy documents, then build a script that chops them into chunks, converts them into vector embeddings, and stores them in a searchable database.

In this step, get ready to:
  • Create Slay the Spire strategy documents covering characters, bosses, and relics.
  • Write an ingestion script that chunks and embeds the documents into ChromaDB.
  • Test that semantic search retrieves relevant chunks for a sample question.
Create your strategy documents

Your RAG assistant needs a knowledge base to draw from. You will create three markdown files inside docs/, each covering a different Slay the Spire strategy topic.

  • In your code editor, create a new file called ironclad_guide.md inside the docs/ folder.
  • Paste in the following Ironclad character strategy guide:
# Ironclad Strategy Guide

## Core Philosophy

The Ironclad is Slay the Spire's starter character and excels at strength scaling and high-damage decks. His starting relic, Burning Blood, heals 6 HP at the end of each combat, which allows you to take more risks in hallway fights.

## Key Cards to Draft

### Strength Scaling
- Demon Form: The most powerful strength scaling card. Gains 2 (3 upgraded) strength every turn. Once played, your damage output snowballs rapidly. Best picked in Act 1 or early Act 2.
- Inflame: Immediate +2 strength for 1 energy. Simple and effective. Great in any deck.
- Spot Weakness: Conditional +3 strength when enemy intends to attack. Excellent against multi-attack enemies.
- Limit Break: Doubles your current strength. Combined with Demon Form, this creates exponential damage growth.

### Front-Loaded Damage
- Carnage: 20 (28 upgraded) damage for 2 energy. Ethereal but extremely efficient damage per energy.
- Bludgeon: 32 (42 upgraded) damage for 3 energy. The highest single-hit damage card. Great for finishing enemies quickly.
- Pommel Strike: 9 (10) damage and draw 1 card. Efficient cycle card that replaces itself.

### Defense
- Shrug It Off: 8 (11) block and draw 1 card. The best defensive card in Ironclad's pool because it cycles.
- Flame Barrier: 12 (16) block plus 4 (6) damage back to attackers. Excellent against multi-hit enemies.
- Impervious: 30 (40) block for 2 energy. Exhaust but provides massive burst defense for boss turns.

## Deck Archetypes

### Strength Build
Focus on Demon Form or Inflame plus Limit Break. Add Heavy Blade (14 damage, hits scale with strength x3/x5) as your primary damage dealer. Keep the deck lean so you draw Demon Form early.

### Exhaust Build
Built around Feel No Pain (3 block per exhaust), Dark Embrace (draw 1 per exhaust), and Corruption (all skills cost 0 but exhaust). Extremely powerful but requires all pieces.

### Body Slam Build
Stack block cards and use Body Slam (damage equal to current block) as your win condition. Barricade (block carries over between turns) is the key enabler. Add Entrench (double current block) for massive scaling.

Why write strategy content in natural language?

The embedding model converts text into numerical vectors based on meaning. Natural language paragraphs produce richer embeddings than bullet-only lists because the model captures relationships between concepts (e.g. "Demon Form" and "strength scaling" appearing near each other).

When someone later asks "How do I scale damage with Ironclad?", the embedding of that question will be close to the embedding of paragraphs discussing strength scaling cards.

  • Save ironclad_guide.md.
  • Create a second file called boss_strategies.md inside docs/ and paste in the following boss fight strategies:
# Boss Fight Strategies

## Act 1 Bosses

### The Guardian
- Pattern: Alternates between attacking and entering Defensive Mode (gains 9 block and Mode Shift at 30 damage absorbed).
- Strategy: Apply damage in small bursts to avoid wasting damage on Mode Shift. Front-loaded damage is king here. Avoid playing a single 40-damage card when the guardian only has 10 Mode Shift remaining.
- Key tip: In Defensive Mode, the Guardian does not attack. Use this window to set up strength, draw cards, or heal with Reaper.

### Hexaghost
- Pattern: Charges up for a massive multi-hit attack every few turns. Deals 6x(2+n) damage based on how much it has powered up.
- Strategy: Need strong block for the big attack turns. Having Flame Barrier is excellent since each of the 6 hits triggers the thorns. Kill it fast before it stacks too many Inferno charges.
- Key tip: Hexaghost always uses Activate on turn 1, giving you a free setup turn.

### Slime Boss
- Pattern: Splits into two smaller slimes when reduced to half HP. Each half attacks independently.
- Strategy: Build up strength or damage before triggering the split. The split slimes are weaker individually but attack twice per round. AOE damage is valuable after the split.
- Key tip: Try to have the boss at exactly half HP when you trigger the split. Over-damaging wastes effort.

## Act 2 Bosses

### The Champ
- Pattern: Alternates between phases. Uses Execute below 50% HP (deals massive damage). Occasionally uses Taunt to apply Vulnerable and Weak.
- Strategy: Get the Champ as close to 50% as possible, then burst through the Execute threshold in one turn. Having enough block for the Execute turn is critical.

### The Collector
- Pattern: Summons two Torch Head minions that deal 7 damage each turn. The Collector buffs them.
- Strategy: Kill the minions quickly, then focus the Collector. If you leave the minions alive too long, the damage adds up. AOE is extremely valuable here.

## Act 3 Bosses

### Awakened One
- Pattern: Phase 1 is manageable. Phase 2 gains strength every time you play a Power card.
- Strategy: In Phase 2, avoid playing Power cards if possible. Front-loaded damage and skills are better. If you must play powers, save them for Phase 1.
- Key tip: This boss punishes Demon Form builds in Phase 2. Consider whether your deck can win without leaning on powers.

### Time Eater
- Pattern: After every 12 cards played in a turn, Time Eater heals and ends your turn.
- Strategy: Play cards efficiently. Avoid playing unnecessary cards. Strength builds that hit hard with few cards excel here.
- Key tip: Count your cards carefully. You want to end each turn before hitting 12 cards played.

## The Heart

- Requires 3 keys to access (Ruby, Emerald, Sapphire).
- Pattern: Beat of Death deals 2 (A20: 2) damage every time you play a card. Multi-hit attack of 15x2. Buff removal turn.
- Strategy: You need strong block AND strong damage. The Heart has 800 HP (A20) and heals. Keep your deck tight so Beat of Death does not whittle you down. Intangibility (from Apparition or Wraith Form) is extremely powerful here.
- Key tip: The Heart always uses Buff on turn 2. Use turn 1 and turn 2 to set up since the big attacks come on turns 3+.
  • Save boss_strategies.md.
  • Create a third file called relic_tier_list.md inside docs/ and paste in the following relic tier list:
# Relic Tier List and Strategy

## S-Tier Relics (Always Take)

### Snecko Eye
- Effect: Draw 2 additional cards each turn. Randomize card costs (0-3).
- Why it is S-tier: The extra draw is enormous. On average, costs stay the same, but you now see 7 cards per turn instead of 5. High-cost cards become amazing because they frequently cost 0-1.
- Best with: Expensive cards like Demon Form, Bludgeon, Carnage. Terrible with 0-cost decks.

### Dead Branch
- Effect: Whenever you exhaust a card, add a random card to your hand.
- Why it is S-tier: Combined with Corruption (all skills cost 0, exhaust), you generate endless free cards every turn. Even without Corruption, any exhaust-heavy deck becomes incredibly powerful.

### Runic Pyramid
- Effect: No longer discard hand at end of turn.
- Why it is S-tier: Lets you save key cards for the right moment. Dramatically improves consistency. Combo pieces are always available when you need them.
- Downside: Hand can clog with Curses or Wounds. Need some exhaust to manage hand size.

## A-Tier Relics (Almost Always Take)

### Vajra
- Effect: Start every combat with +1 Strength.
- Why it is A-tier: Free permanent damage boost. Every attack benefits. Simple and universally good on Ironclad.

### Bag of Preparation
- Effect: Draw 2 additional cards on turn 1.
- Why it is A-tier: Turn 1 is often the most important turn. Seeing 7 cards on turn 1 dramatically improves your opening.

### Pen Nib
- Effect: Every 10th attack deals double damage.
- Why it is A-tier: Pairs excellently with heavy hitters like Heavy Blade or Bludgeon. Track the counter and time your big attacks.

### Anchor
- Effect: Start every combat with 10 block.
- Why it is A-tier: Free defense on turn 1 lets you play aggressively. Particularly good against Act 1 elites.

## B-Tier Relics (Situationally Good)

### Ornamental Fan
- Effect: Every 3rd attack played, gain 4 block.
- Why: Good in decks that play many small attacks per turn. Less useful in big-hit decks.

### Kunai
- Effect: Every 3rd attack played, gain +1 Dexterity.
- Why: Scaling defense through attacks. Excellent in shiv-style or multi-attack decks.

### Molten Egg
- Effect: All attack cards obtained are upgraded.
- Why: Saves precious campfire upgrades. More impactful in Act 1 when many cards are still being drafted.

## Relics to Avoid

### Ectoplasm
- Effect: Gain 1 energy but can no longer gain gold.
- Why to avoid: Gold buys shop relics, card removals, and potions. Losing gold income cripples your run long-term unless you are already in Act 3.

### Philosophy Stone
- Effect: Gain 1 energy but all enemies start with 1 Strength.
- Why to avoid: The enemy strength compounds over multi-turn fights. Boss fights become significantly harder.
  • Save relic_tier_list.md.
  • Confirm the files exist by running:
ls docs/
  • You should see boss_strategies.md, ironclad_guide.md, and relic_tier_list.md in the output.

Don't see all three files?

Make sure each file is inside the docs/ folder, not in the project root. The file names must end in .md (not .txt or no extension).

Help me figure out why my docs folder is missing files.

Write the ingestion script

The ingestion script is the first half of your RAG pipeline. It reads your markdown files, splits them into overlapping chunks, converts each chunk into a numerical vector using the nomic-embed-text model, and stores everything in a ChromaDB vector database for fast retrieval later.

  • Open ingest.py in your code editor and replace the placeholder content with the following code:
import os
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma

# Configuration constants
DOCS_DIR = "./docs"
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "sts_strategy"

def ingest():
    # Load all markdown files from the docs folder
    loader = DirectoryLoader(
        DOCS_DIR,
        glob="**/*.md",
        loader_cls=TextLoader,
        loader_kwargs={"encoding": "utf-8"},
    )
    documents = loader.load()
    print(f"Loaded {len(documents)} documents")

    # Split documents into overlapping chunks
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,
        chunk_overlap=200,
        length_function=len,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    chunks = splitter.split_documents(documents)
    print(f"Split into {len(chunks)} chunks")

    # Embed chunks and store in ChromaDB
    embeddings = OllamaEmbeddings(model="nomic-embed-text")

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_DIR,
        collection_name=COLLECTION_NAME,
    )
    print(f"Stored {len(chunks)} embeddings in ChromaDB at {CHROMA_DIR}")

if __name__ == "__main__":
    ingest()

What does this code do?

  • DirectoryLoader scans the docs/ folder and loads every .md file as a LangChain Document object.
  • RecursiveCharacterTextSplitter breaks each document into chunks of about 800 characters with 200 characters of overlap between chunks. The overlap prevents important context from being lost at chunk boundaries.
  • OllamaEmbeddings sends each chunk to the local nomic-embed-text model, which converts text into 768-dimensional vectors that capture semantic meaning.
  • Chroma.from_documents stores the vectors and original text in a persistent database at ./chroma_db. Persistent means the data survives across script runs.

What is document chunking?

Embedding models have a limited context window. Instead of embedding an entire 1000-word document as one vector, you split it into smaller chunks so each vector represents a focused topic. When a user asks a question, the system can retrieve just the relevant chunk instead of the whole document.

The 200-character overlap ensures that a strategy point spanning two paragraphs is not lost at a chunk boundary.

  • Save ingest.py.

You will not run the script yet. First, you will add a test retrieval query to verify the embeddings work correctly.

Add a test retrieval query and run the script

Before you trust the knowledge base in a real conversation, you should verify that similar questions retrieve relevant chunks. You will add a quick test at the end of the ingest() function that queries the vector store and prints the top results.

  • In ingest.py, find the line that says:
    print(f"Stored {len(chunks)} embeddings in ChromaDB at {CHROMA_DIR}")

if __name__ == "__main__":
    ingest()
  • Add the following test retrieval code between the print statement and the if __name__ block, so the end of your file looks like this:
    print(f"Stored {len(chunks)} embeddings in ChromaDB at {CHROMA_DIR}")

    # Test retrieval with a sample question
    results = vectorstore.similarity_search("What cards are good for Ironclad?", k=3)
    print("\n--- Test retrieval ---")
    for i, doc in enumerate(results):
        print(f"\nChunk {i+1} (from {doc.metadata.get('source', 'unknown')}):")
        print(doc.page_content[:200])

if __name__ == "__main__":
    ingest()

What does the test retrieval do?

similarity_search takes a question, embeds it into the same vector space as your chunks, and finds the 3 closest matches (k=3). This is the core of semantic search. If asking about Ironclad cards returns chunks from the Ironclad guide rather than the boss guide, your embeddings are working correctly.

  • Save ingest.py.
  • Run the ingestion script by running this command:
python ingest.py
  • You should see output showing 3 documents loaded, 15-30 chunks created, and test retrieval results that reference the Ironclad guide.

What should I see?

The script should print something like "Loaded 3 documents", "Split into 20 chunks" (the exact number depends on your content), and "Stored 20 embeddings in ChromaDB". The test retrieval section should show chunks from ironclad_guide.md discussing cards and strength scaling.

A chroma_db/ folder also appears in your project directory. This is the persistent vector database.

Seeing an error when running the script?

  • If you see a connection error, make sure Ollama is running. Open a separate terminal and run ollama list to verify.
  • If you see an import error for langchain_text_splitters, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • If you see "0 documents loaded", double-check that your markdown files are inside the docs/ folder and have the .md extension.

Help me debug my ingestion script error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both your strategy documents and ingest.py.

ⓧ I'd like to double check the full code

Here is the complete ingest.py file:

import os
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma

DOCS_DIR = "./docs"
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "sts_strategy"

def ingest():
    loader = DirectoryLoader(
        DOCS_DIR,
        glob="**/*.md",
        loader_cls=TextLoader,
        loader_kwargs={"encoding": "utf-8"},
    )
    documents = loader.load()
    print(f"Loaded {len(documents)} documents")

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,
        chunk_overlap=200,
        length_function=len,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    chunks = splitter.split_documents(documents)
    print(f"Split into {len(chunks)} chunks")

    embeddings = OllamaEmbeddings(model="nomic-embed-text")

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_DIR,
        collection_name=COLLECTION_NAME,
    )
    print(f"Stored {len(chunks)} embeddings in ChromaDB at {CHROMA_DIR}")

    results = vectorstore.similarity_search("What cards are good for Ironclad?", k=3)
    print("\n--- Test retrieval ---")
    for i, doc in enumerate(results):
        print(f"\nChunk {i+1} (from {doc.metadata.get('source', 'unknown')}):")
        print(doc.page_content[:200])

if __name__ == "__main__":
    ingest()

Your knowledge base is built and searchable. Next up, you will wire this retriever to a local LLM so it can answer strategy questions using the context it finds.

Build the RAG Pipeline

Your knowledge base is embedded and sitting in ChromaDB. Right now, those vector embeddings are just numbers in a database. They cannot answer questions on their own.

In this step, you will wire a retriever to a local LLM using LangChain so your assistant can find relevant strategy chunks, feed them into a prompt, and generate grounded answers. Then you will wrap it in a Streamlit chat interface so you can talk to it like a real strategy coach.

In this step, get ready to:
  • Load the persisted vector store and create a retriever.
  • Build a RAG chain with prompt engineering for Slay the Spire expertise.
  • Create a Streamlit chat interface to interact with the assistant.
Load the vector store and create a retriever

The retriever is the bridge between a user's question and your embedded strategy documents. It takes a question, converts it to a vector, and finds the most similar chunks in ChromaDB.

  • Open app.py in your code editor.
  • Add the imports and constants by pasting this code at the top of the file:
import streamlit as st
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# Path to your persisted ChromaDB vector store
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "sts_strategy"

What does this code do?

  • The imports pull in everything you need: the LLM and embeddings from langchain_ollama, the vector store from langchain_chroma, and the chain orchestration tools from langchain_core.
  • CHROMA_DIR and COLLECTION_NAME point to the same vector store your ingestion script created in the previous step.
  • Below the constants, add the get_retriever() function that loads the persisted vector store:
@st.cache_resource
def get_retriever():
    # Load the same embedding model used during ingestion
    embeddings = OllamaEmbeddings(model="nomic-embed-text")
    # Connect to the persisted ChromaDB collection
    vectorstore = Chroma(
        persist_directory=CHROMA_DIR,
        embedding_function=embeddings,
        collection_name=COLLECTION_NAME,
    )
    # Return a retriever that fetches the top 4 most relevant chunks
    return vectorstore.as_retriever(search_kwargs={"k": 4})

What does this code do?

The @st.cache_resource decorator ensures the vector store connection is created once and reused across Streamlit reruns, instead of reconnecting on every interaction.

The retriever uses search_kwargs={"k": 4} to return the 4 most semantically similar chunks for any given question. This gives the LLM enough context without overwhelming the prompt.

  • Save app.py.
  • Verify the retriever works by running this quick test in your terminal:
python -c "from app import get_retriever; r = get_retriever(); print(r.invoke('test query'))"

You should see a list of Document objects printed to the terminal. Each document contains a chunk of strategy text from your knowledge base.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is still activated. You should see (venv) at the beginning of your terminal prompt.
  • Confirm Ollama is running in the background. The embedding model needs the Ollama server to generate vectors.

Ask for help: Why am I getting a ModuleNotFoundError when importing from app.py?

Build the RAG chain with prompt engineering

The chain orchestrates the full question-answering flow: retrieve relevant chunks, format them into a prompt, send the prompt to the LLM, and parse the response back into plain text.

  • Below get_retriever(), add the get_chain() function:
@st.cache_resource
def get_chain():
    retriever = get_retriever()

    # Configure the local LLM with low temperature for factual answers
    llm = ChatOllama(model="llama3.2", temperature=0.3)

    # Prompt template that grounds the LLM in retrieved context
    prompt = ChatPromptTemplate.from_template(
        """You are an expert Slay the Spire strategy coach. Answer the player's
question using ONLY the context provided below. If the context does not
contain enough information to answer, say "I don't have enough information
in my strategy guides to answer that."

Be specific, reference card names, relic names, and boss names where relevant.
Keep answers concise and actionable.

Context:
{context}

Question: {question}

Answer:"""
    )

    # Helper to join retrieved documents into a single context string
    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    # Wire retriever -> format -> prompt -> LLM -> parse output
    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    return chain

What does this code do?

  • ChatOllama(model="llama3.2", temperature=0.3) connects to your local Ollama server. The low temperature keeps answers factual and consistent.
  • The prompt template uses {context} and {question} placeholders. The instruction to answer ONLY from context prevents the LLM from hallucinating strategy advice.
  • The chain pipe (|) connects each step: the retriever fetches docs, format_docs joins them into text, the prompt assembles the full message, the LLM generates a response, and StrOutputParser() extracts the string.

Why LangChain for orchestration?

LangChain's pipe syntax makes each component of the RAG pipeline explicit. You can see exactly how data flows from retrieval to generation. This transparency helps when debugging or swapping components later.

The alternative (LlamaIndex) hides more of this wiring behind defaults. For learning the RAG architecture, explicit wiring teaches you what each piece does.

  • Save app.py.
  • Test the chain end-to-end by running:
python -c "from app import get_chain; chain = get_chain(); print(chain.invoke('How do I beat Act 1 bosses?'))"

You should see a coherent, strategy-focused answer that mentions specific bosses like the Guardian, Hexaghost, or Slime Boss. The LLM is drawing from your embedded documents.

Getting a connection error from Ollama?

  • Ollama must be running for the LLM to respond. Check that the Ollama icon is visible in your system tray (Windows).
  • If you restarted your computer since the last step, Ollama may need to be relaunched. Open it from your Start menu.

Ask for help: Why is my RAG chain failing to connect to Ollama?

Create the Streamlit chat interface

The chain works from the command line, but a chat interface makes it feel like a real assistant. Streamlit provides built-in chat components that handle message history, user input, and response display with minimal code.

  • Below get_chain(), add the page configuration and sidebar:
st.set_page_config(
    page_title="Slay the Spire Strategy Assistant",
    page_icon="\U0001f0cf",
    layout="centered",
)

st.title("Slay the Spire Strategy Assistant")
st.caption("Ask me anything about Slay the Spire strategy. Powered by local AI.")

# Sidebar with clickable example questions
with st.sidebar:
    st.header("Example Questions")
    examples = [
        "What's a good Ironclad deck for Act 3?",
        "Which relics should I always take?",
        "How do I beat the Heart?",
        "What are the best Ironclad cards for strength scaling?",
    ]
    for example in examples:
        if st.button(example, key=example):
            st.session_state.pending_example = example

What does this code do?

  • st.set_page_config sets the browser tab title and icon. This must be the first Streamlit command in your script.
  • The sidebar gives users example questions to click, which helps them understand what the assistant can do. Clicking a button stores the question in st.session_state.pending_example for the chat handler to pick up.
  • Below the sidebar block, add the chat message handling logic:
# Initialize chat history in session state
if "messages" not in st.session_state:
    st.session_state.messages = []

# Display all previous messages
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# Accept user input from the chat box
user_input = st.chat_input("Ask a strategy question...")

# Handle sidebar example button clicks
if "pending_example" in st.session_state:
    user_input = st.session_state.pending_example
    del st.session_state.pending_example

# Process the question through the RAG chain
if user_input:
    st.session_state.messages.append({"role": "user", "content": user_input})
    with st.chat_message("user"):
        st.markdown(user_input)

    chain = get_chain()

    with st.chat_message("assistant"):
        with st.spinner("Thinking..."):
            response = chain.invoke(user_input)
        st.markdown(response)

    st.session_state.messages.append({"role": "assistant", "content": response})

What does this code do?

  • st.session_state.messages persists chat history across Streamlit reruns. Every time the user submits a question, Streamlit re-executes the entire script. Session state preserves the conversation.
  • st.chat_message renders messages with user or assistant avatars. st.chat_input provides the text box at the bottom.
  • st.spinner("Thinking...") shows a loading indicator while the LLM generates its response. This is important because local inference takes a few seconds.
  • Save app.py.
  • Launch the app by running:
streamlit run app.py

Your browser should open automatically to http://localhost:8501. You should see the title, caption, and a sidebar with example questions.

  • Type a question like "How do I beat the Heart?" in the chat box and press Enter.
  • You should see a loading spinner, then a grounded strategy answer appear in a chat bubble.
  • Click one of the example questions in the sidebar to confirm it populates the chat and triggers a response.

App opens but no response appears?

  • Check your terminal for error messages. A common issue is Ollama not being reachable at http://localhost:11434.
  • If you see "collection not found", make sure you ran python ingest.py in the previous step and the chroma_db/ directory exists.
  • Stop the app with Ctrl+C in the terminal, fix the issue, then relaunch with streamlit run app.py.

Ask for help: Why is my Streamlit RAG app not generating responses?

Test the guardrails

Try asking about something NOT in your strategy documents, like "How do I play Minecraft?" The assistant should respond with "I don't have enough information in my strategy guides to answer that." This proves retrieval is working correctly.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and that your chat interface is responding to questions.

ⓧ I'd like to double check the full code

Here is the complete app.py at this point:

import streamlit as st
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "sts_strategy"

@st.cache_resource
def get_retriever():
    embeddings = OllamaEmbeddings(model="nomic-embed-text")
    vectorstore = Chroma(
        persist_directory=CHROMA_DIR,
        embedding_function=embeddings,
        collection_name=COLLECTION_NAME,
    )
    return vectorstore.as_retriever(search_kwargs={"k": 4})

@st.cache_resource
def get_chain():
    retriever = get_retriever()

    llm = ChatOllama(model="llama3.2", temperature=0.3)

    prompt = ChatPromptTemplate.from_template(
        """You are an expert Slay the Spire strategy coach. Answer the player's
question using ONLY the context provided below. If the context does not
contain enough information to answer, say "I don't have enough information
in my strategy guides to answer that."

Be specific, reference card names, relic names, and boss names where relevant.
Keep answers concise and actionable.

Context:
{context}

Question: {question}

Answer:"""
    )

    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    return chain

st.set_page_config(
    page_title="Slay the Spire Strategy Assistant",
    page_icon="\U0001f0cf",
    layout="centered",
)

st.title("Slay the Spire Strategy Assistant")
st.caption("Ask me anything about Slay the Spire strategy. Powered by local AI.")

with st.sidebar:
    st.header("Example Questions")
    examples = [
        "What's a good Ironclad deck for Act 3?",
        "Which relics should I always take?",
        "How do I beat the Heart?",
        "What are the best Ironclad cards for strength scaling?",
    ]
    for example in examples:
        if st.button(example, key=example):
            st.session_state.pending_example = example

if "messages" not in st.session_state:
    st.session_state.messages = []

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

user_input = st.chat_input("Ask a strategy question...")

if "pending_example" in st.session_state:
    user_input = st.session_state.pending_example
    del st.session_state.pending_example

if user_input:
    st.session_state.messages.append({"role": "user", "content": user_input})
    with st.chat_message("user"):
        st.markdown(user_input)

    chain = get_chain()

    with st.chat_message("assistant"):
        with st.spinner("Thinking..."):
            response = chain.invoke(user_input)
        st.markdown(response)

    st.session_state.messages.append({"role": "assistant", "content": response})

Your RAG pipeline is live. You have a working AI strategy coach that retrieves relevant knowledge from your documents and generates grounded answers. Next up, you will extend it with source citations so you can see exactly which documents powered each answer.

Build the Chat Interface

Your RAG chain can retrieve strategy documents and generate grounded answers. Now it's time to bring it to life through a web interface that feels like chatting with a real strategy coach.

Streamlit turns Python scripts into interactive web apps with zero front-end code. You already have the Streamlit UI wired up in app.py. In this step, you will launch the app and test every feature end-to-end.

In this step, get ready to:
  • Launch your Streamlit app and confirm the page title and description appear.
  • Send a strategy question and receive a grounded answer in a chat bubble.
  • Test the sidebar example questions and verify conversation history persists.
Launch the Streamlit app

Streamlit watches your Python file and renders it as a web page. Every time you save, the page refreshes automatically.

  • Make sure your virtual environment is still active in your terminal (you should see (venv) at the start of your prompt).
  • Start the Streamlit app by running this command:
streamlit run app.py

What does this command do?

Streamlit starts a local web server and opens your default browser to http://localhost:8501. The app runs your app.py script top-to-bottom, rendering each st. call as a UI element.

Your browser should open automatically. You should see the title "Slay the Spire Strategy Assistant" and the caption "Ask me anything about Slay the Spire strategy. Powered by local AI." at the top of the page.

How does the page configuration work?

The st.set_page_config call at the top of your file sets the browser tab title to "Slay the Spire Strategy Assistant" and centers the layout. It must be the first Streamlit command in the script.

The st.title and st.caption calls render the visible heading and subtitle on the page. These are separate from the browser tab title.

Browser did not open automatically?

Check your terminal output for a URL (usually http://localhost:8501). Copy and paste it into your browser manually.

If you see an error about Ollama not being reachable, make sure the Ollama application is still running in the background. The Streamlit app connects to Ollama at startup to load the cached resources.

Help me troubleshoot why my Streamlit app is not launching

Test the chat interface

The chat input at the bottom of the page is where you send questions to your RAG pipeline. Each message gets processed through the full chain: retrieval, prompt assembly, and LLM generation.

  • Click the text input at the bottom of the page that says "Ask a strategy question...".
  • Type How do I beat Act 1 bosses? and press Enter.

You should see a "Thinking..." spinner while the assistant generates its response. After a few seconds, a chat bubble appears with a strategy-focused answer that references specific boss names and card suggestions from your documents.

How does the chat history work?

Streamlit reruns the entire script on every interaction. Without st.session_state.messages, previous messages would disappear after each new question.

The session_state dictionary persists between reruns. Every user message and assistant response gets appended to this list, then the display loop at the top of the script renders the full history each time.

  • Send a second question, such as Which relics help with strength scaling?.
  • Confirm that both your first question and the new one appear in the conversation above the input field.

Both messages and their responses should be visible in the chat history. The assistant's answers should reference content from your strategy documents rather than making up generic advice.

Getting vague or unrelated answers?

If the assistant gives generic answers that do not mention specific cards, relics, or bosses from your documents, the retriever may not be finding relevant chunks. Make sure you ran python ingest.py successfully in Step 2 and that the chroma_db/ directory exists.

If the assistant says "I don't have enough information," try rephrasing with terms that appear in your documents (e.g., "Ironclad strength build" instead of "how to do damage").

Help me figure out why my RAG assistant gives vague answers

Test the sidebar example questions

The sidebar gives new users ready-made questions to try. Each button injects a question into the chat as if the user typed it, which makes the app feel polished and discoverable.

  • Look at the left sidebar. You should see an "Example Questions" header with four buttons.
  • Click the How do I beat the Heart? button.
  • Confirm that the question appears in the chat and the assistant generates a response referencing the Heart fight strategy from your documents.

How do the example buttons work?

Each st.button click stores the question text in st.session_state.pending_example. On the next rerun, the app checks for this pending value and uses it as the user input, then deletes it so it only fires once.

  • Try clicking another example like Which relics should I always take? to verify it adds to the existing conversation.
  • Scroll up through the chat history. All previous messages should still be visible.

Your full conversation history persists across interactions. The app is now a working strategy assistant with a clean, portfolio-ready interface.

Sidebar not visible?

On smaller screens, the sidebar may be collapsed by default. Click the arrow icon in the top-left corner of the page to expand it.

If clicking an example button does nothing, check that Ollama is still running. The app needs Ollama to generate responses.

Help me fix my Streamlit sidebar example buttons

✔️ Awesome, I've got everything!

Great. Your strategy assistant is running and responding to questions with grounded answers from your knowledge base.

ⓧ I'd like to double check the full code

Here is the complete app.py file. Compare yours against this:

import streamlit as st
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "sts_strategy"

@st.cache_resource
def get_retriever():
    embeddings = OllamaEmbeddings(model="nomic-embed-text")
    vectorstore = Chroma(
        persist_directory=CHROMA_DIR,
        embedding_function=embeddings,
        collection_name=COLLECTION_NAME,
    )
    return vectorstore.as_retriever(search_kwargs={"k": 4})

@st.cache_resource
def get_chain():
    retriever = get_retriever()

    llm = ChatOllama(model="llama3.2", temperature=0.3)

    prompt = ChatPromptTemplate.from_template(
        """You are an expert Slay the Spire strategy coach. Answer the player's
question using ONLY the context provided below. If the context does not
contain enough information to answer, say "I don't have enough information
in my strategy guides to answer that."

Be specific, reference card names, relic names, and boss names where relevant.
Keep answers concise and actionable.

Context:
{context}

Question: {question}

Answer:"""
    )

    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    return chain

st.set_page_config(
    page_title="Slay the Spire Strategy Assistant",
    page_icon="\U0001f0cf",
    layout="centered",
)

st.title("Slay the Spire Strategy Assistant")
st.caption("Ask me anything about Slay the Spire strategy. Powered by local AI.")

with st.sidebar:
    st.header("Example Questions")
    examples = [
        "What's a good Ironclad deck for Act 3?",
        "Which relics should I always take?",
        "How do I beat the Heart?",
        "What are the best Ironclad cards for strength scaling?",
    ]
    for example in examples:
        if st.button(example, key=example):
            st.session_state.pending_example = example

if "messages" not in st.session_state:
    st.session_state.messages = []

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

user_input = st.chat_input("Ask a strategy question...")

if "pending_example" in st.session_state:
    user_input = st.session_state.pending_example
    del st.session_state.pending_example

if user_input:
    st.session_state.messages.append({"role": "user", "content": user_input})
    with st.chat_message("user"):
        st.markdown(user_input)

    chain = get_chain()

    with st.chat_message("assistant"):
        with st.spinner("Thinking..."):
            response = chain.invoke(user_input)
        st.markdown(response)

    st.session_state.messages.append({"role": "assistant", "content": response})

Secret mission

Add Source Citations

Your strategy assistant gives great answers, but how do you know it's actually using your documents? Upgrade the app so every response shows exactly which strategy guides it pulled from, proving the RAG pipeline is doing its job.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder sts-rag-assistant on your Desktop (contains app.py, ingest.py, docs/, chroma_db/, and venv/).
  • Ollama models: llama3.2 (~2 GB) and nomic-embed-text (~274 MB).
  • Ollama application installed on your system.
  • Python virtual environment inside venv/ (~200 MB of installed packages).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the strategy assistant or adding more documents to your knowledge base.

  • Ollama will continue running in the background and loading models into VRAM on demand.
  • Your chroma_db/ vector store persists on disk so you can rerun the app without re-ingesting.
  • The virtual environment stays ready for future streamlit run app.py sessions.

Pause - I'll come back to this later

Shut down running processes to free up memory and VRAM while keeping all files intact on disk.

  • Stop the Streamlit server by pressing Ctrl+C in the terminal where it is running.
  • Stop the Ollama models from occupying VRAM by running:
ollama stop llama3.2
ollama stop nomic-embed-text
  • Your project files, vector store, and models remain on disk. When you come back, activate the virtual environment and run streamlit run app.py again.

Delete - I don't want to use this again

Remove all project files, models, and optionally the Ollama application itself.

  • Stop the Streamlit server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate the Python virtual environment by running:
deactivate
  • Delete the project folder by running:
cd ~/Desktop
rm -rf sts-rag-assistant
  • Remove the Ollama models by running:
ollama rm llama3.2
ollama rm nomic-embed-text
  • To uninstall Ollama itself on Windows, open Settings, go to Apps, then Add or remove programs, find Ollama, and click Uninstall.

Nice Work!

Nice Work!

You just built a fully local AI strategy assistant powered by your own GPU. No API keys, no cloud costs, no rate limits.

You've learned how to:

  • Build a complete RAG pipeline from scratch, covering document loading, chunking, embedding, vector storage, retrieval, and generation using LangChain.
  • Run a local LLM (llama3.2) and embedding model (nomic-embed-text) via Ollama on your own GPU.
  • Create a polished Streamlit chat interface with session state, sidebar navigation, and a conversational UX suitable for your portfolio.
  • Secret Mission: Added source citations that prove the system retrieves and uses relevant documents, showing users exactly which strategy guides informed each answer.

Ready to quiz yourself?