Build a Study Notes Q&A App with Claude Code

Use Claude Code's plan-first workflow to build a RAG-powered study notes app.

Introduction

30 Second Summary

You have pages of study notes scattered across text files. Finding the answer to a specific question means scrolling through everything manually.

In this project, you will build a Python app that searches through your study notes and answers your questions using AI, with citations showing exactly where each answer came from. You will plan the architecture first, then use Claude Code to build it feature by feature.

What You'll Build

Drop your study notes into a folder, ask a question in the terminal, and get back an answer pulled directly from your notes with a list of sources showing where the information came from.

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

  • A working Q&A tool where you ask a question and get a grounded answer with source citations showing exactly which passages from your notes were used.
  • A reusable CLAUDE.md file that you can apply as a template for directing Claude Code on any future project.
  • A complete RAG pipeline running from a single app.py script, covering chunking, embedding, vector search, and answer generation with the Gemini API.
  • Secret Mission: Add a confidence gate that warns when the retrieved context is too weak to answer reliably.

Are there any prerequisites?

This project assumes you have Python 3.10+, VS Code, and Claude Code installed and authenticated. Step 1 walks you through everything else.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This is where you commit to the project's purpose so every decision from here on has a clear target.

Set Up the Project and Write Your CLAUDE.md

Claude Code is powerful, but it works best when it knows exactly what you want. Without context about your project's tech stack, coding style, or architecture, Claude Code produces generic, off-target code that needs heavy revision.

A CLAUDE.md file solves this. It gives Claude Code persistent instructions about your project so every session starts aligned with your vision. In this step, you will set up the project folder, grab a free Gemini API key, and write the CLAUDE.md that will guide Claude Code for the rest of the project.

In this step, get ready to:
  • Create the project folder and install dependencies.
  • Get a free Gemini API key and set it as an environment variable.
  • Write a CLAUDE.md and create sample study notes.
Create the project folder and install dependencies

Every project needs a home. You will create a folder on your Desktop, initialize Git for version control, and set up a Python virtual environment to keep your dependencies isolated.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir study-notes-qa
cd study-notes-qa
  • Initialize a Git repository by running:
git init

You should see a message like Initialized empty Git repository in... confirming the repository was created.

Next, create a virtual environment. This keeps your project's packages separate from other Python projects on your machine.

  • Create the virtual environment by running:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated space for your Python project's dependencies. Packages you install inside it stay separate from your system Python and other projects.

This prevents version conflicts between projects. Every serious Python project uses one.

  • Activate the virtual environment:

macOS

  • Run this command to activate:
source venv/bin/activate

Windows

  • Run this command to activate:
venv\Scripts\activate

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

Don't see (venv) in your prompt?

Make sure you ran the activate command for your operating system. On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then try the activate command again.

Help me activate my Python virtual environment.

  • Install the project's three dependencies by running:
pip install chromadb sentence-transformers google-genai

This installs ChromaDB (vector store), sentence-transformers (embedding model), and google-genai (Gemini API client). The install takes a minute or two.

What do these packages do?

  • chromadb stores and searches your text chunks as vectors, so you can find relevant notes by meaning instead of exact words.
  • sentence-transformers converts text into numerical vectors (embeddings) locally on your machine. No API key needed.
  • google-genai is Google's official Python SDK for calling the Gemini API to generate answers.

You should see Successfully installed... at the end of the output with a list of packages.

Seeing install errors?

Make sure (venv) appears in your prompt. If it does not, the virtual environment is not active. Go back and run the activate command for your OS.

If you see a permissions error, do not use sudo. Activate the virtual environment first, then retry the install.

Help me fix my pip install errors.

Get your Gemini API key

The app uses the Gemini API to generate answers from your retrieved notes. You need a free API key to authenticate your requests.

  • Open your browser and go to Google AI Studio.
  • Sign in with your Google account. If you are a new user, Google AI Studio automatically creates a project and API key for you after you accept the Terms of Service.
  • Click Get API key in the left panel to open the API keys page.
  • If you already have a key listed, copy it. If not, click Create API key to generate a new one.
  • Copy the key and save it somewhere safe (like a notes app). You will not be able to see it again after leaving this page.

Is the Gemini API free?

Yes. The Gemini API free tier charges nothing for input, output, or context caching. No credit card is required. You only pay if you manually upgrade to the paid tier for higher rate limits.

Now set the key as an environment variable so the google-genai SDK can find it automatically.

macOS

  • In the same terminal where your virtual environment is active, set the key by running:
export GEMINI_API_KEY="your-api-key-here"

Replace your-api-key-here with the actual key you copied.

  • Verify it is set by running:
echo $GEMINI_API_KEY

You should see your key printed back in the terminal.

Windows

  • In the same PowerShell window where your virtual environment is active, set the key by running:
$env:GEMINI_API_KEY = "your-api-key-here"

Replace your-api-key-here with the actual key you copied.

  • Verify it is set by running:
echo $env:GEMINI_API_KEY

You should see your key printed back in the terminal.

This variable is session-only

The environment variable lasts only for this terminal session. If you close and reopen your terminal, you will need to activate the virtual environment and set GEMINI_API_KEY again before running the app.

Write your CLAUDE.md and create sample study notes

A CLAUDE.md file lives at the root of your project. Claude Code reads it at the start of every session to understand your tech stack, project structure, and coding preferences.

  • Open VS Code in your project folder. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the study-notes-qa folder on your Desktop, then click Open.
  • Create a new file by clicking File then New File.
  • Save it immediately as CLAUDE.md in the project root (Cmd+S on macOS, Ctrl+S on Windows).
  • Paste the following content into CLAUDE.md:
# Study Notes Q&A App

## Tech Stack
- Python 3.10+
- ChromaDB (vector store)
- sentence-transformers, model: all-MiniLM-L6-v2
- Google Gemini API, model: gemini-2.0-flash

## Project Structure
- app.py — single entry point
- data/ — .txt study note files
- chroma_db/ — ChromaDB persistent storage

## Coding Style
- Use type hints on all functions
- Write docstrings for every function
- No classes needed for this project

## Testing Command
python app.py

What does each section do?

  • Tech Stack tells Claude Code which libraries and models to use. It will not suggest alternatives.
  • Project Structure defines where files live so Claude Code creates code in the right places.
  • Coding Style enforces your preferences. Without this, Claude Code might generate classes or skip type hints.
  • Testing Command gives Claude Code a quick way to verify its own work.
  • Save CLAUDE.md.

Now create the data/ folder with sample study notes. Switch back to your terminal.

  • Create the data folder by running:
mkdir data

Now create 2-3 text files inside data/ with study notes on any topic you like. These are the notes your app will search through later.

  • In VS Code, right-click the data folder in the Explorer sidebar.
  • Click New File.
  • Name the file something descriptive, like world_war_1.txt or photosynthesis.txt.
  • Write or paste at least a few paragraphs of study notes into the file. The more content, the better your search results will be later.
  • Repeat this to create 2-3 files total, each on a different topic.
  • Save all files.

Pick topics you actually study

Use real notes from a class, textbook, or article. The app becomes genuinely useful when the content is real. Aim for at least 3-4 paragraphs per file so there is enough text to chunk and search.

  • Confirm your files are in place by running:
ls data/

You should see your .txt files listed.

Time for the final check. Before you run the command, what do you think will happen when Claude Code reads your project for the first time?

  • Launch Claude Code in your project directory by running:
claude
  • Once Claude Code starts, type this command to check what memory files are loaded:
/context

You should see CLAUDE.md listed under Memory files. This confirms Claude Code has loaded your project spec and will follow it in future sessions.

Don't see CLAUDE.md listed?

Make sure CLAUDE.md is saved in the root of your study-notes-qa folder, not inside a subfolder. Also check that the filename is exactly CLAUDE.md (all caps, .md extension).

Help me get Claude Code to recognize my CLAUDE.md file.

✔️ Awesome, I've got everything!

Great. Double check you have saved CLAUDE.md and all your study note files.

ⓧ I'd like to double check the full code

Your CLAUDE.md should look exactly like this:

# Study Notes Q&A App

## Tech Stack
- Python 3.10+
- ChromaDB (vector store)
- sentence-transformers, model: all-MiniLM-L6-v2
- Google Gemini API, model: gemini-2.0-flash

## Project Structure
- app.py — single entry point
- data/ — .txt study note files
- chroma_db/ — ChromaDB persistent storage

## Coding Style
- Use type hints on all functions
- Write docstrings for every function
- No classes needed for this project

## Testing Command
python app.py

Your data/ folder should contain 2-3 .txt files with study notes on topics you chose.

Your project is set up and Claude Code knows exactly what you are building. Next up, you will use plan mode to design the app's architecture before writing a single line of code.

Use Plan Mode to Design Before Coding

Your project folder is ready with dependencies installed, a CLAUDE.md spec at the root, and sample study notes in the data/ folder.

Asking Claude Code to just "build me a RAG app" produces vague, off-target code. Plan mode lets Claude read your files and propose an implementation plan without touching anything, so you catch design misunderstandings before they become code.

In this step, get ready to:
  • Enter plan mode and ask Claude Code to propose a full implementation plan for the RAG pipeline.
  • Review and edit the plan to ensure it matches your CLAUDE.md specs before any code gets written.
Enter plan mode and request an implementation plan

Plan mode is Claude Code's read-only thinking mode. Claude reads your files and answers questions without making any changes to your project.

  • In your Claude Code session, press Shift+Tab until the status bar shows plan mode on.

What is plan mode?

Plan mode lets Claude explore your project, read files, and answer questions. It cannot create, edit, or delete anything while plan mode is active.

This is where you design your app's architecture before any code gets written. Think of it as a blueprint review with Claude as your collaborator.

  • Type the following prompt into Claude Code to request an implementation plan:
Read my CLAUDE.md and the sample files in data/. Propose a full
implementation plan for a RAG pipeline in app.py with these features:

1. Load .txt files from data/
2. Chunk them into overlapping pieces
3. Embed them with sentence-transformers
4. Store them in ChromaDB
5. Answer questions using the Gemini API

Name every function, describe its inputs and outputs, and explain
the complete data flow from raw text file to generated answer.
Include CLI flags for --index, --search, and --ask.

Claude Code reads your project files and generates a detailed implementation plan directly in the terminal.

What should I see?

Claude Code responds with a structured plan that names each function, lists its parameters and return types, and maps how the functions connect.

The plan covers the full pipeline: loading files, chunking text, generating embeddings, storing in ChromaDB, retrieving relevant chunks by query, and generating answers with Gemini.

Plan doesn't match your expectations?

  • If Claude proposes different model names or a different project structure, ask follow-up questions while still in plan mode to refine the plan.
  • If Claude made changes to your files, you may not be in plan mode. Press Shift+Tab and check that the status bar shows plan mode on before trying again.
  • Help me refine my Claude Code plan to match my CLAUDE.md specs
Review and edit the plan

Claude proposed a design. Now you decide if it matches your vision. This is where you practice being the architect, not the coder.

  • Read through the plan Claude generated.
  • Check that the plan uses all-MiniLM-L6-v2 for embeddings and gemini-2.0-flash for generation.
  • Confirm the chunk size falls between 800 and 1200 characters with overlap between chunks.
  • Verify every function has clearly described inputs and outputs.

Why review a plan Claude generated?

Claude Code is your collaborator, not your boss. The plan is a proposal. If the chunking strategy looks off, the model names are wrong, or a function is missing, you change it now.

Catching a bad design in a plan takes seconds. Catching it after code is written takes much longer.

  • Press Ctrl+G to open the plan in your text editor.
  • Edit anything that does not match your CLAUDE.md specs or the criteria you just checked.
  • Close the editor when you are satisfied with the plan.
  • Press Shift+Tab to exit plan mode and return to default mode.

Ctrl+G not opening an editor?

  • Your system may not have a default editor configured. You can review and note changes from the plan directly in the terminal output instead.
  • If you need to undo changes Claude made by mistake, press Esc twice or type /rewind to rewind to a previous checkpoint.
  • Help me edit my implementation plan in Claude Code

Before you move on, ask yourself: can you trace the full path a study note takes from a raw .txt file all the way to a generated answer in your plan?

  • Scan your final plan and confirm it covers all of these stages: loading files, chunking text, embedding chunks, storing in ChromaDB, retrieving relevant chunks by query, and generating an answer with the Gemini API.
  • Confirm you are back in default mode (the status bar no longer shows plan mode on).

Your architecture is locked in. Next up, you will turn this plan into working code by building the indexing pipeline.

Build the Indexing Pipeline

In the last step, you designed an implementation plan without writing a single line of code. That's the architect mindset in action.

Now it's time to turn that plan into a working pipeline. Your app needs to load text files, chunk them into smaller passages, and store those passages as searchable vectors in ChromaDB. This is the indexing half of RAG.

In this step, get ready to:
  • Direct Claude Code to implement the file loader, chunker, and ChromaDB storage with a --index flag.
  • Add a --search flag and verify that vector search retrieves relevant chunks from your notes.
Exit plan mode and implement the indexing pipeline

Your implementation plan describes exactly which functions to build and how data flows between them. Now you'll switch Claude Code back to default mode so it can write actual code against that plan.

  • In Claude Code, press Shift+Tab to exit plan mode. The mode indicator in the status bar switches back to the default mode.
  • Tell Claude Code to implement only the indexing portion of the plan. Ask it to build the file loader, the text chunker, and the ChromaDB storage functions. Remind it to follow the CLAUDE.md specs. Tell it to add a --index CLI flag in main() that triggers the indexing pipeline.

What makes a good Claude Code prompt here?

Be specific about scope. Saying "build only the indexing pipeline" prevents Claude Code from jumping ahead and implementing the entire app at once. Mentioning CLAUDE.md reminds it to use your chosen model names, coding style, and project structure.

You're practicing the feature-by-feature workflow: implement one slice, verify it works, then move on. This catches problems early instead of debugging a massive codebase all at once.

Claude Code creates an app.py file in your project folder. Before running anything, review what it generated.

  • Switch to VS Code and open app.py from the Explorer sidebar.
  • Verify that Claude Code created these four functions: load_documents, chunk_text, index_documents, and main.

What should you check in the generated code?

  • load_documents should read .txt files from the data/ directory and return a list of dictionaries with text and source keys.
  • chunk_text should split text into overlapping chunks. Check that the default chunk size is around 1000 characters with about 200 characters of overlap.
  • index_documents should use SentenceTransformer("all-MiniLM-L6-v2") for embeddings and chromadb.PersistentClient for storage. These match the specs in your CLAUDE.md.
  • main should parse a --index flag with argparse and call index_documents when the flag is set.

Claude Code generated something unexpected?

If the code uses a different embedding model or storage approach than your CLAUDE.md specifies, tell Claude Code to re-read CLAUDE.md and fix the implementation to match. If it built the entire app instead of just the indexing portion, press Esc twice to rewind to the previous checkpoint, then re-prompt with a stricter scope.

Claude Code generated something unexpected and I can't figure out what went wrong

Run the indexing pipeline and test search

Before you run the next command, think about what you expect to see. How many chunks do you think your study notes will produce? What output should a successful indexing run print?

  • Run the indexing pipeline on your study notes by running this command:
python app.py --index

You should see output confirming each stage: loading documents, chunking, embedding, and storing. The final line reports how many chunks were indexed from how many files.

Seeing a download progress bar?

The first time you run this, sentence-transformers downloads the all-MiniLM-L6-v2 model. This is about 80 MB and only happens once. After the download, the embedding step runs locally with no network calls.

Seeing an error when you run --index?

  • If you see a "Directory not found" error, make sure your data/ folder exists in the project root and contains at least one .txt file with content.
  • If you see a ModuleNotFoundError, make sure your virtual environment is activated. You should see the environment name in parentheses at the start of your terminal prompt.
  • If you see a syntax error or unexpected behavior, check that the generated code matches the review checklist above. Tell Claude Code to fix the specific issue.

Help me debug my indexing pipeline error

After a successful index, a chroma_db/ folder appears in your project directory. This is where ChromaDB stores the embedded chunks on disk.

  • Confirm the folder was created by running:
ls

You should see app.py, CLAUDE.md, data/, and the new chroma_db/ folder in the output.

Indexing works, but you have no way to verify that the right chunks come back for a given question. You need a search feature to test the vector store before building the answer layer.

  • Return to Claude Code and ask it to add a --search flag that takes a query string, embeds it, queries ChromaDB for the top relevant chunks, and prints each result with its source file name, chunk number, and distance score.
  • After Claude Code updates app.py, verify that a search function and the --search flag were added to main().

What should the search function do?

The search function takes your query, converts it to a vector with the same all-MiniLM-L6-v2 model used during indexing, and asks ChromaDB for the closest matching chunks. Using the same model for both indexing and searching is critical. If the vectors come from different models, the similarity scores are meaningless.

Before you test, think about what you expect the results to look like. If you search for a topic your notes cover well, should the distance scores be low or high?

  • Test vector search by querying a topic from your study notes:
python app.py --search "[[SEARCH_QUERY="a topic from your study notes"]]"

You should see a numbered list of chunks, each showing the source file name, chunk number, and a distance score. Lower distance means higher relevance. The top results should come from the file that covers your query topic.

What do the distance scores mean?

ChromaDB uses cosine distance by default. A distance of 0 means identical vectors. A distance near 1 means the chunk is barely related to your query. When you see your top results with distances below 1, that means the vector search is finding passages that are meaningfully similar to your question.

Search returning unexpected results?

  • If you see a collection not found error, re-run python app.py --index first. The search requires an existing ChromaDB collection.
  • If results seem irrelevant, check that the search function uses the same SentenceTransformer("all-MiniLM-L6-v2") model as index_documents. Mismatched models produce meaningless similarity scores.

Help me troubleshoot my vector search results

✔️ Awesome, I've got everything!

Great. Make sure you've saved app.py and that both --index and --search ran successfully.

ⓧ I'd like to double check the full code

Open app.py in VS Code and verify these five functions exist in this order:

  • load_documents(data_dir: str) -> list[dict] reads .txt files from the data directory. Returns a list of dictionaries with text and source keys. Exits with an error if the directory is missing or empty.
  • chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str] splits text into overlapping chunks using a sliding window.
  • index_documents(data_dir: str, db_path: str) -> None orchestrates loading, chunking, embedding with SentenceTransformer("all-MiniLM-L6-v2"), and storing in ChromaDB via PersistentClient. Deletes an existing study_notes collection before creating a fresh one.
  • search(query: str, db_path: str, top_k: int = 5) -> list[dict] embeds the query, queries ChromaDB, and returns results with text, source, chunk_index, and distance.
  • main() -> None uses argparse with --index, --search, --data-dir, --db-path, and --top-k flags. Prints help if no flag is provided.

If anything is missing or different, tell Claude Code specifically what to fix. For example: "The search function is missing. Add it following the implementation plan."

Your indexing pipeline is working and you can search through your notes as vectors. Next up, you'll connect the retrieval pipeline to the Gemini API so your app generates real answers from the search results.

Add the Question-Answering Layer

Your indexing pipeline works. You can search your notes with --search and see relevant chunks come back. But raw text chunks are not polished answers anyone would want to study from.

This step connects the retrieval pipeline to the Gemini API. When you ask a question, the app will find the most relevant chunks, pass them as context to Gemini, and generate a grounded answer. This is where the "generation" half of RAG comes together.

In this step, get ready to:
  • Tell Claude Code to implement the answer generation feature with a --ask flag.
  • Test that the app answers from your notes and refuses to answer off-topic questions.
Tell Claude Code to implement the answer generation feature

The search function you built in the last step retrieves relevant chunks. Now you need a function that takes those chunks, packages them into a prompt, and sends them to Gemini so it can synthesize a human-readable answer.

  • Switch back to your Claude Code session. Make sure you are in default mode, not plan mode. Check the status bar at the bottom of the terminal.
  • If the status bar still says plan mode, press Shift+Tab until it switches to default mode.

Why switch out of plan mode?

Plan mode only reads files and proposes ideas. Default mode lets Claude Code actually create and edit code. You need default mode for Claude Code to write the new functions in app.py.

  • Tell Claude Code to implement the answer generation feature from the plan. Ask it to take the top-K retrieved chunks, build a prompt that includes them as context with a system instruction to only answer from the provided context, call the Gemini API, and print the answer. Also ask it to add a --ask "question" CLI flag to main().
  • Remind Claude Code to follow the CLAUDE.md specs (type hints, docstrings, gemini-2.0-flash model).
  • Watch Claude Code generate the code. It will create two new functions in app.py and update main(). Review the changes it proposes and accept them.

After Claude Code finishes, open app.py in VS Code and check that it created a generate_answer function. The function should look similar to this:

def generate_answer(query: str, retrieved_chunks: list[dict]) -> str:
    """Generate an answer using the Gemini API with retrieved context."""
    from google import genai

    context_parts = []
    for i, chunk in enumerate(retrieved_chunks, start=1):
        context_parts.append(f"[Source {i}: {chunk['source']}]\n{chunk['text']}")

    context = "\n\n".join(context_parts)

    prompt = (
        "You are a study assistant. Answer the user's question using ONLY "
        "the provided context from their study notes. If the context does not "
        "contain enough information to answer, say so honestly. Do not make up "
        "information.\n\n"
        f"Context:\n{context}\n\n"
        f"Question: {query}"
    )

    client = genai.Client()
    response = client.models.generate_content(
        model="gemini-2.0-flash",
        contents=prompt,
    )

    return response.text

What does this function do?

The function takes the retrieved chunks and formats each one with a source label (like [Source 1: history.txt]) so Gemini knows where each piece of context came from.

The system prompt is the key to grounding. It tells Gemini to answer ONLY from the provided context and to admit when it does not have enough information. This prevents Gemini from making things up.

Also check that Claude Code created an ask function that ties retrieval and generation together:

def ask(query: str, db_path: str, top_k: int = 5) -> None:
    """Full RAG pipeline: retrieve relevant chunks and generate an answer."""
    print(f"\nSearching notes for: {query}\n")
    retrieved = search(query, db_path, top_k)

    if not retrieved:
        print("No relevant chunks found. Have you indexed your notes?")
        return

    answer = generate_answer(query, retrieved)

    print("Answer:")
    print(answer)

What does the ask function do?

This is the full RAG pipeline in one function. It calls search() to retrieve the most relevant chunks from ChromaDB, then passes them to generate_answer() so Gemini can synthesize a response.

The early return for empty results catches the case where the learner forgot to index their notes first.

Before you run the app, think about what you expect to happen. You have indexed notes on your chosen topic. If you ask a question those notes can answer, do you think Gemini will pull from the notes or make something up?

  • Test the new --ask flag by asking a question your notes can answer. Run this command, replacing the placeholder with a question about a topic in your notes:
python app.py --ask "[[YOUR_QUESTION="a question your notes can answer, e.g. What caused World War I?"]]"

You should see a generated answer that directly references content from your study notes.

Seeing an error about the API key?

  • Make sure the GEMINI_API_KEY environment variable is set in the same terminal where you are running the command.
  • If you opened a new terminal since setting the variable, you need to set it again in the new session.
  • Check that the google-genai package is installed in your virtual environment. Make sure the virtual environment is activated before running the command.

Still stuck? Help me debug a Gemini API key error when running my study notes Q&A app.

Test the grounding behavior

A good answer from your notes is a start, but the real test of grounding is what happens when you ask something your notes do not cover. A well-grounded app should refuse to answer instead of making things up.

Before you run this, do you think the app will admit it cannot answer, or will it generate something anyway?

  • Test with a question your notes definitely do not cover by running:
python app.py --ask "What is the recipe for chocolate lava cake?"

The app should say it cannot answer from the available notes. If it generates a recipe instead, the system prompt is not strict enough about grounding.

App hallucinating instead of refusing?

If the app generates an answer for a topic your notes do not cover, the system prompt needs to be stricter. Tell Claude Code to fix the system prompt to be more explicit about only answering from the provided context and refusing when the context is insufficient.

After Claude Code updates the prompt, re-run the off-topic question and confirm the app now declines to answer.

Need more help? Help me fix the grounding in my RAG app so it refuses to answer off-topic questions.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.py in VS Code.

ⓧ I'd like to double check the full code

Open app.py in VS Code and confirm these functions exist in order:

  • load_documents (unchanged from last step).
  • chunk_text (unchanged from last step).
  • index_documents (unchanged from last step).
  • search (unchanged from last step).
  • generate_answer (new). Should import from google import genai, build a prompt with context from retrieved chunks, call client.models.generate_content() with model gemini-2.0-flash, and return response.text.
  • ask (new). Should call search() then generate_answer() and print the result.
  • main() (updated). Should have a --ask argument and an elif args.ask: branch that calls ask(args.ask, args.db_path, args.top_k).

Scroll up in this step to compare your generate_answer and ask functions against the reference code blocks shown earlier.

Your app now answers questions grounded in your actual study notes. In the next step, you will add source citations so you can see exactly which chunks of your notes each answer came from.

Add Source Citations to Answers

In the previous step, you connected the retrieval pipeline to the Gemini API. Your app now answers questions grounded in your actual study notes.

There is a problem, though. When the app returns an answer, you have no way to tell which parts of your notes it pulled from. Without citations, you are trusting a black box. In this step, you will add source references after each answer so you can see exactly which chunks were retrieved and how relevant they were.

In this step, get ready to:
  • Add a print_sources function that displays numbered citations after each answer.
  • Test the full pipeline end-to-end with a new study notes file spanning multiple sources.
Tell Claude Code to add source citations

Right now your app prints a generated answer and nothing else. You need each answer to show which chunks were retrieved, where they came from, and how closely they matched your question.

  • Switch back to your Claude Code session in your terminal.
  • Tell Claude Code to modify the answer output to include source citations. Specifically, ask it to: print a "Sources" section after the generated answer listing each retrieved chunk with its source file name, chunk number, a similarity score, and a short preview of the first 100 characters of text.
  • Review Claude Code's proposed changes before approving. It should create a new print_sources function and update the existing ask() function to call it.

What should Claude Code produce?

Claude Code should add a print_sources function that loops through the retrieved chunks and prints each one with a numbered label, the source filename, the chunk index, a similarity score, and a text preview.

ChromaDB returns a distance value (lower means more similar). The function should convert this to a similarity score using 1 - distance so higher numbers mean a better match.

  • After approving the changes, open app.py in VS Code to verify what Claude Code wrote.
  • Check that the new print_sources function looks like this:
def print_sources(retrieved_chunks: list[dict]) -> None:
    """Print source citations for the retrieved chunks."""
    print("\n--- Sources ---")
    for i, chunk in enumerate(retrieved_chunks, start=1):
        preview = chunk["text"][:100].replace("\n", " ")
        similarity = 1 - chunk["distance"]  # Convert distance to similarity
        print(f"[{i}] {chunk['source']} (chunk {chunk['chunk_index']}, "
              f"similarity: {similarity:.2f})")
        print(f"    {preview}...")
    print()

What does this code do?

  • It prints a --- Sources --- header to separate the citations from the answer.
  • For each retrieved chunk, it shows the source filename, chunk number, and a similarity score (converted from ChromaDB's distance value).
  • It trims each chunk's text to the first 100 characters as a preview so you can quickly identify which passage was used.
  • Now check the ask() function. Claude Code should have added a call to print_sources(retrieved) right after print(answer). Find the end of the ask() function and confirm it looks like this:
    answer = generate_answer(query, retrieved)

    print("Answer:")
    print(answer)
    print_sources(retrieved)

What changed in the ask function?

The only change is the new print_sources(retrieved) line at the bottom. After the generated answer prints, the citations print automatically so the learner can trace every answer back to specific notes.

Before you run the app, what do you expect to see differently in the output compared to Step 4?

  • Test the citations by running a question you know your notes cover:
python app.py --ask "What is [[NOTES_TOPIC="a topic from your study notes"]]?"

You should see the generated answer followed by a --- Sources --- section. Each source line shows a numbered label, the filename it came from, the chunk number, a similarity score, and a text preview.

Not seeing citations after your answer?

  • Open app.py and confirm that the ask() function calls print_sources(retrieved) after print(answer).
  • Make sure the print_sources function is defined before the ask() function in the file. Python needs to see a function definition before it can be called.
  • If you see a KeyError on distance, check that the search() function includes distances in the ChromaDB query's include list.

Still stuck? Help me debug why my source citations are not printing after the answer.

Test the full pipeline end-to-end

Citations work for your existing notes. Now you need to verify the entire pipeline handles a real-world scenario: adding new notes and asking a question that spans content from multiple files.

  • Create a new .txt file inside your data/ folder on a topic related to (but distinct from) your existing notes. For example, if your existing notes cover World War I, add a file about World War II or the Treaty of Versailles.
  • Re-index your notes to pick up the new file by running:
python app.py --index

You should see a higher chunk count than before, confirming the new file was loaded and embedded.

Chunk count did not increase?

  • Verify your new file has the .txt extension. The loader only picks up files ending in .txt.
  • Make sure the file is saved inside the data/ folder, not in the project root.
  • Confirm the file is not empty. Empty files are skipped by the loader.

Need more help? Help me figure out why my new text file is not being picked up by the indexer.

Now for the real test. Think of a question that could pull answers from both your original notes and the new file.

  • Ask a question that spans content from multiple files by running:
python app.py --ask "[[MULTI_FILE_QUESTION="a question that spans your original and new notes"]]"

Check the citations in the --- Sources --- section. You should see chunks from multiple filenames, confirming the app pulls from more than one source file to build its answer.

What do the similarity scores tell you?

A similarity score close to 1.0 means the chunk is highly relevant to your question. A score closer to 0 means it is a weak match.

ChromaDB stores cosine distance (where 0 is identical and 2 is opposite). The print_sources function flips this with 1 - distance so you can read it intuitively: higher is better.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.py and that your latest indexing and ask commands both ran successfully.

ⓧ I'd like to double check the full code

Open app.py in VS Code and confirm these sections are present, in this order:

  • Imports at the top: argparse, os, sys, Path, chromadb, and SentenceTransformer.
  • load_documents() function that reads .txt files from the data directory.
  • chunk_text() function that splits text into overlapping chunks.
  • index_documents() function that embeds and stores chunks in ChromaDB.
  • search() function that queries ChromaDB for relevant chunks.
  • generate_answer() function that calls the Gemini API with retrieved context.
  • print_sources() function (new in this step) that prints numbered citations with filenames, chunk numbers, similarity scores, and text previews.
  • ask() function that calls print_sources(retrieved) after print(answer).
  • main() function with --index, --search, and --ask flags.

Secret mission

Add a Confidence Gate

Your app answers every question thrown at it, even when the retrieved chunks are barely relevant. Can you add a confidence gate that checks similarity scores and warns users when the context is too weak to answer reliably?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, and the Gemini API free tier has no ongoing costs. Decide whether to keep your resources, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder on your Desktop containing app.py, CLAUDE.md, data/, chroma_db/, and your Python virtual environment.
  • GEMINI_API_KEY environment variable.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your Study Notes Q&A app.

  • Your project files stay on your Desktop and your API key remains available.
  • Try adding more study notes to your data/ folder, re-indexing with --index, and asking new questions.

Pause - I'll come back to this later

Your app is a CLI tool with no running server or background processes. There is nothing to stop or pause.

  • Your project files and ChromaDB data stay on your Desktop until you decide to come back or delete them.
  • When you return, activate your virtual environment and run python app.py --ask "your question" to pick up where you left off.

Delete - I don't want to use this again

Remove all project files and clean up your environment variable.

First, delete your project folder.

macOS

  • Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • Confirm the folder is gone by running:
ls ~/Desktop

Your project folder should no longer appear in the output.

Windows

  • Delete the project folder from your Desktop by running:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
  • Confirm the folder is gone by running:
ls ~\Desktop

Your project folder should no longer appear in the output.

Next, remove the GEMINI_API_KEY environment variable.

macOS

  • Unset the variable in your current terminal session by running:
unset GEMINI_API_KEY
  • If you added the export GEMINI_API_KEY=... line to your shell config file (~/.zshrc or ~/.bashrc), open that file and delete the line.

Windows

  • Remove the environment variable permanently by running this in PowerShell:
[Environment]::SetEnvironmentVariable("GEMINI_API_KEY", $null, "User")
  • Open a new terminal window for the change to take effect.

Nice Work!

Nice Work!

You just built a RAG-powered Study Notes Q&A app from scratch using Claude Code. You planned the architecture, wrote the spec, and directed Claude Code to build exactly what you wanted.

You've learned how to:

  • Build a complete RAG pipeline that chunks study notes, embeds them with sentence-transformers, stores vectors in ChromaDB, and generates grounded answers with the Gemini API.
  • Write a CLAUDE.md project spec that tells Claude Code your exact tech stack, coding style, and architecture decisions so every session starts aligned with your vision.
  • Use plan mode to design an app's architecture before writing any code, then build feature-by-feature with checkpoints to verify each piece works.
  • Secret Mission: Added a confidence gate that checks retrieval quality and warns users when answers may be unreliable.

Ready to quiz yourself?