Build a RAG Knowledge Platform with MCP

Build a Dockerized RAG system with an MCP server AI agents can query.

Introduction

30 Second Summary

Every organization has documentation scattered across files, wikis, and shared drives. What if both humans and AI agents could instantly search all of it and get answers with exact source citations?

In this project, you will build DocuMind, a Dockerized knowledge platform that accepts documents, answers natural language questions using Retrieval-Augmented Generation, and exposes an MCP server so external AI agents can query your knowledge base directly.

What You'll Build

A multi-container application combining a FastAPI backend, Qdrant vector database, and MCP server that both humans and AI agents can query for source-cited answers.

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

  • A complete RAG pipeline that embeds documents, stores vectors in Qdrant, and generates source-cited answers using Groq's LLM.
  • A Docker Compose configuration orchestrating backend and vector database as a multi-container application with persistent storage.
  • An MCP server that external AI agents like Codex CLI can connect to and query your knowledge base through.
  • Secret Mission: Add document version comparison so users and agents can ask "What changed between v1 and v2?"

Are there any prerequisites?

This project assumes you have Docker Desktop installed on a Windows 11 machine and a ChatGPT Go plan (which includes Codex CLI access). You do not need prior experience with RAG, MCP, or vector databases.

Set Up Your Environment

DocuMind needs three core pieces to work: Python for the backend logic, Codex CLI for AI-assisted development, and a Groq API key for LLM-powered answers.

In this step, you will install each tool, verify they work, and scaffold the project structure so you have a clean starting point for building.

In this step, get ready to:
  • Install Python 3.12 and Codex CLI.
  • Create a Groq account and set up the project directory.
  • Scaffold the project files with Codex.
Install Python, Codex CLI, and verify Docker

Python powers the entire DocuMind backend. You need version 3.12 or higher installed on your machine.

  • Open your browser and go to python.org/downloads.
  • Download the Windows installer (64-bit) for Python 3.12.
  • Run the installer.
  • On the first screen, check the box labelled Add Python to PATH before clicking anything else.
  • Click Install Now and wait for the installation to complete.

Why check Add Python to PATH?

Without this option, your terminal will not recognize the python command. PATH tells Windows where to find installed programs when you type their name.

  • Open a new terminal by pressing the Windows key, typing PowerShell, and pressing Enter.
  • Verify Python is installed by running:
python --version

✔️ I see version 3.12 or higher

Python is ready to go. Continue to the next instruction below.

ⓧ I see an older version

Your machine has a different Python version taking priority. You need to uninstall the older version or adjust your PATH so the 3.12 installation is found first.

  • Press the Windows key and search for Add or remove programs.
  • Find the older Python entry and uninstall it.
  • Close and reopen PowerShell, then run python --version again.

ⓧ Command not found or Microsoft Store opens

Windows did not find Python on your PATH. This usually means the installer's Add Python to PATH checkbox was not selected.

  • Re-run the Python installer you downloaded.
  • Select Modify.
  • On the Advanced Options screen, check Add Python to environment variables, then click Install.
  • Close and reopen PowerShell, then run python --version again.
  • If the Microsoft Store keeps opening, press the Windows key, search for Manage app execution aliases, and toggle off the App Installer entries for python.exe and python3.exe.

Next, install Codex CLI. This is OpenAI's terminal-based coding agent that you will use to scaffold and iterate on your project.

  • Install Codex CLI by running this command in PowerShell:
powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"

What does this command do?

It downloads the official Codex install script from OpenAI and executes it. The -ExecutionPolicy ByPass flag allows the script to run without needing to change your system's execution policy permanently.

  • After installation completes, close and reopen PowerShell so the new codex command is available.
  • Launch Codex by running:
codex
  • Codex will open a browser window asking you to sign in with your ChatGPT account. Complete the sign-in flow.
  • Once signed in, you will see the Codex interactive prompt in your terminal. Type /exit to close it for now.

Codex command not found?

Close PowerShell completely and reopen it. The install script adds Codex to your PATH, but existing terminal sessions do not pick up the change automatically.

If it still does not work, try the alternative npm install method by running npm install -g @openai/codex. This requires Node.js to be installed.

Help me troubleshoot Codex CLI installation on Windows

Finally, verify that Docker Desktop (which you already have installed) is running and ready.

  • Confirm Docker is working by running these two commands:
docker --version
docker compose version
  • You should see version numbers for both docker and docker compose. If you get an error, make sure Docker Desktop is running in your system tray.
Create your Groq account and project structure

Groq provides the LLM that will power DocuMind's question-answering. Their free plan never charges and does not require a credit card.

  • Open your browser and go to console.groq.com.
  • Sign up for a free account. No credit card is required.
  • Once signed in, navigate to the API Keys section in the left sidebar.
  • Click Create API Key.
  • Copy the key and save it somewhere safe. You will not be able to see it again after leaving the page.

Why Groq?

Groq runs LLM inference on custom hardware called LPUs, making it extremely fast. The free tier is generous enough for this entire project. If you exceed the rate limit, the API returns a 429 error instead of charging you.

Now create the project directory where all your DocuMind code will live.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir documind
cd documind
  • Create the .env file with your Groq API key by running this command, replacing your-key-here with the actual API key you copied from Groq:
Set-Content .env "GROQ_API_KEY=your-key-here"

What is a .env file?

A .env file stores environment variables that your application reads at startup. It keeps sensitive values like API keys out of your code so they are never accidentally committed to version control.

  • Create a Python virtual environment by running:
python -m venv .venv
  • Activate the virtual environment by running:
..\.venv\Scripts\activate
  • You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation just for this project. Packages you install here will not affect other Python projects on your machine, and vice versa.

Seeing a script execution error?

Windows PowerShell may block the activate script. Run this command first to allow it for the current session:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process

Then try the activate command again.

Help me activate my Python virtual environment on Windows

Scaffold the project with Codex

Instead of manually creating every file, you will use Codex to generate the entire project scaffold. This demonstrates the AI-assisted development workflow you will use throughout the project.

  • Make sure you are inside the documind folder with your virtual environment active.
  • Start Codex by running:
codex
  • In the Codex prompt, type the following message and press Enter:
Create the following project files with placeholder comments: main.py, document_processor.py, embeddings.py, vector_store.py, rag.py, mcp_server.py, requirements.txt, Dockerfile, docker-compose.yml, static/index.html, .env.example. For Python files use a single comment '# Scaffolded placeholder by Codex'. For requirements.txt use '# To be populated'. For HTML use '<!-- Scaffolded placeholder by Codex -->'. For .env.example use 'GROQ_API_KEY=your-groq-api-key-here'.

What is Codex doing here?

Codex reads your prompt, plans the file creation, and asks for your approval before writing anything to disk. This is a safe, iterative workflow. You stay in control of what gets written.

  • Codex will show you the list of files it plans to create. Review them and approve the changes when prompted.
  • Once Codex finishes, type /exit to close the Codex session.
  • Confirm the files exist by running:
ls
  • You should see main.py, document_processor.py, embeddings.py, vector_store.py, rag.py, mcp_server.py, requirements.txt, Dockerfile, docker-compose.yml, and a static folder in the output.

Missing some files?

If Codex did not create all the files, you can start it again with codex and ask it to create the missing ones specifically. Alternatively, create them manually with New-Item (for example: New-Item static/index.html -Force).

Help me create the missing project files

✔️ Awesome, I've got everything!

Great. Make sure your virtual environment is still active (you should see (.venv) in your prompt) before moving on.

ⓧ I'd like to double check the full code

Here is what each file should contain at this point. These are all placeholders that you will fill in during the next steps.

# Scaffolded placeholder by Codex
# Scaffolded placeholder by Codex
# Scaffolded placeholder by Codex
# Scaffolded placeholder by Codex
# Scaffolded placeholder by Codex
# Scaffolded placeholder by Codex
# To be populated
# Scaffolded placeholder by Codex
# Scaffolded placeholder by Codex
<!-- Scaffolded placeholder by Codex -->
GROQ_API_KEY=your-groq-api-key-here

Your environment is fully set up. You have Python, Codex CLI, Docker, a Groq API key, and a scaffolded project structure ready to go. Next up, you will build the FastAPI backend and document processing pipeline.

Build the FastAPI Backend

Your development environment is ready, your project is scaffolded, and Codex is standing by. Now it is time to build the core engine of DocuMind: the API that accepts documents and breaks them into searchable chunks.

In this step, you will use Codex to generate a FastAPI backend with three endpoints. One handles document uploads, one lists stored documents, and one acts as a placeholder for the question-answering feature you will build later.

In this step, get ready to:
  • Generate a document processing pipeline with Codex.
  • Create FastAPI endpoints for document upload and retrieval.
  • Test the API using FastAPI's built-in Swagger UI.
Generate the FastAPI application with Codex

You will prompt Codex to generate two Python files: document_processor.py for chunking logic and main.py for the API routes. Then you will install the required packages.

  • Open Codex in your documind directory by running:
codex
  • Type this prompt into Codex to generate the document processing module:
Generate document_processor.py with a process_document function. It should accept text, filename, chunk_size (default 500), and overlap (default 50). Split the text into chunks of chunk_size characters with overlap between them. Each chunk should include metadata: document_name, chunk_index, and upload_timestamp (ISO format using datetime.utcnow). Return a list of chunk dictionaries with 'text' and 'metadata' keys.
  • Review the code Codex generates and approve it.
  • Your document_processor.py should look like this:
from datetime import datetime

def process_document(text: str, filename: str, chunk_size: int = 500, overlap: int = 50):
    chunks = []
    start = 0
    index = 0
    while start < len(text):
        end = start + chunk_size
        chunk_text = text[start:end]
        chunks.append({
            'text': chunk_text,
            'metadata': {
                'document_name': filename,
                'chunk_index': index,
                'upload_timestamp': datetime.utcnow().isoformat()
            }
        })
        start += chunk_size - overlap
        index += 1
    return chunks

What does this code do?

The process_document function takes a block of text and slices it into overlapping chunks. Each chunk is approximately 500 characters long.

  • The while loop advances by chunk_size - overlap (450 characters) each iteration, creating a 50-character overlap between consecutive chunks.
  • Each chunk gets metadata recording the source document name, its position in the sequence, and when it was uploaded.

✔️ Awesome, my code looks right!

Great. Move on to generating main.py next.

ⓧ My code looks different

Codex sometimes produces slightly different implementations. Replace the contents of document_processor.py with the exact code shown above, then save the file.

  • Now type this prompt into Codex to generate the main API file:
Generate main.py with a FastAPI app titled 'DocuMind'. It should import process_document from document_processor and load_dotenv from dotenv. Include: a POST /upload endpoint that accepts an UploadFile, decodes it to UTF-8, runs process_document, stores results in a documents_store dictionary keyed by filename, and returns filename, chunk count, and metadata list. A GET /documents endpoint returning document names. A POST /ask endpoint accepting a dict payload and returning a placeholder response with 'answer': 'not yet implemented' and empty 'citations' list.
  • Review the code Codex generates and approve it.
  • Your main.py should look like this:
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import JSONResponse
from document_processor import process_document
from dotenv import load_dotenv
import os

load_dotenv()

app = FastAPI(title='DocuMind')

documents_store = {}

@app.post('/upload')
async def upload_document(file: UploadFile = File(...)):
    content = await file.read()
    text = content.decode('utf-8')
    chunks = process_document(text, file.filename)
    documents_store[file.filename] = chunks
    return {'filename': file.filename, 'chunks': len(chunks), 'metadata': [c['metadata'] for c in chunks]}

@app.get('/documents')
def list_documents():
    return {'documents': list(documents_store.keys())}

@app.post('/ask')
def ask_question(payload: dict):
    return {'answer': 'not yet implemented', 'citations': []}

What does this code do?

This file defines the DocuMind API with three routes.

  • POST /upload reads an uploaded file, decodes it to text, runs it through the chunking pipeline, and stores the results in an in-memory dictionary.
  • GET /documents returns the names of all uploaded documents.
  • /ask is a placeholder that returns a static response. You will wire it to the RAG pipeline in a later step.
  • documents_store is a plain Python dictionary. Data lives in memory and resets when the server restarts. Qdrant will handle persistence later.

✔️ Awesome, my code looks right!

Nice. Time to set up your dependencies.

ⓧ My code looks different

Replace the contents of main.py with the exact code shown above, then save the file.

  • Update requirements.txt with the packages this step needs by replacing its contents with:
fastapi
uvicorn
python-multipart
python-dotenv
  • Save requirements.txt.
  • Exit your Codex session (type exit or press Ctrl+C).
  • Install the dependencies by running:
pip install fastapi uvicorn python-multipart python-dotenv
  • You should see Successfully installed followed by the package names in your terminal output.

Seeing a 'pip not found' error?

  • Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt.
  • If not, activate it again by running ..\.venv\Scripts\activate (Windows) from inside your documind folder.

Ask for help: Why is pip not found even though I created a virtual environment?

Test the bare API

Before adding any AI features, you will confirm the API accepts files and returns structured data. This keeps debugging simple.

  • Start the development server by running:
uvicorn main:app --reload --port 8000
  • You should see output including Uvicorn running on http://127.0.0.1:8000 in your terminal.

Seeing 'Address already in use'?

Another process is using port 8000. Stop it or pick a different port by changing 8000 to 8001 in the command above.

Ask for help: How do I find and stop the process using port 8000 on Windows?

  • Open your browser and go to http://localhost:8000/docs.

You should see the FastAPI Swagger UI with three endpoints listed: /upload, /documents, and /ask.

  • Create a small test file on your Desktop. Open a new terminal tab and run:
echo "DocuMind is a knowledge platform that helps teams find answers in their documentation." > test-doc.txt
  • Back in the Swagger UI, expand the POST /upload section.
  • Click Try it out.
  • Click Choose File and select the test-doc.txt file you just created.
  • Click Execute.

The response should show the filename, chunk count, and a metadata array with document_name, chunk_index, and upload_timestamp for each chunk.

  • Expand the GET /documents section and click Try it out, then Execute.

You should see your test file name in the documents array.

  • Expand POST /ask, click Try it out, enter any JSON body, and click Execute.

This returns "answer": "not yet implemented" as expected. You will connect this to the RAG pipeline in the next step.

Understand what Codex built

Codex generated the code, but understanding it is what makes the project yours. Take a moment to read through the two files with fresh eyes.

Why overlap between chunks?

When a user asks a question, the relevant answer might span a chunk boundary. Without overlap, the system could miss context that sits right at the edge of two chunks.

The 50-character overlap ensures that sentences split across boundaries still appear in at least one chunk in full. This makes vector search more likely to find relevant results.

Why store documents in memory?

The documents_store dictionary is intentionally simple. It lets you test the upload and listing flow without adding database complexity yet.

In the next step, you will add Qdrant as a persistent vector database. At that point, Qdrant handles both storage and search.

  • Open main.py and trace how a file moves from upload to storage.
  • Open document_processor.py and follow how the while loop advances by 450 characters (500 minus 50 overlap) on each pass.

✔️ Awesome, I've got everything!

Great. Make sure both main.py and document_processor.py are saved.

ⓧ I'd like to double check the full code

Here is the complete code for each file at the end of this step.

from datetime import datetime

def process_document(text: str, filename: str, chunk_size: int = 500, overlap: int = 50):
    chunks = []
    start = 0
    index = 0
    while start < len(text):
        end = start + chunk_size
        chunk_text = text[start:end]
        chunks.append({
            'text': chunk_text,
            'metadata': {
                'document_name': filename,
                'chunk_index': index,
                'upload_timestamp': datetime.utcnow().isoformat()
            }
        })
        start += chunk_size - overlap
        index += 1
    return chunks
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import JSONResponse
from document_processor import process_document
from dotenv import load_dotenv
import os

load_dotenv()

app = FastAPI(title='DocuMind')

documents_store = {}

@app.post('/upload')
async def upload_document(file: UploadFile = File(...)):
    content = await file.read()
    text = content.decode('utf-8')
    chunks = process_document(text, file.filename)
    documents_store[file.filename] = chunks
    return {'filename': file.filename, 'chunks': len(chunks), 'metadata': [c['metadata'] for c in chunks]}

@app.get('/documents')
def list_documents():
    return {'documents': list(documents_store.keys())}

@app.post('/ask')
def ask_question(payload: dict):
    return {'answer': 'not yet implemented', 'citations': []}
fastapi
uvicorn
python-multipart
python-dotenv

Your API is live and accepting documents. Next up, you will add vector embeddings and a real AI-powered question-answering pipeline that retrieves the most relevant chunks and generates cited answers.

Build the RAG Pipeline

Your backend accepts document uploads and splits them into chunks. The problem is that it cannot actually find anything. The /ask endpoint still returns a placeholder response.

In this step, you will add a Retrieval-Augmented Generation pipeline. You will store document chunks as vector embeddings in a Qdrant database, search them by meaning, and pass the best matches to a Groq LLM that produces answers with source citations.

In this step, get ready to:
  • Run Qdrant in Docker and install vector search dependencies.
  • Build a RAG pipeline that retrieves relevant chunks and generates cited answers.
  • Add a web interface and test the full upload-ask-answer flow.
Add Qdrant and embeddings

Before you can search documents by meaning, you need two things: a vector database to store embeddings, and a model to create those embeddings from text.

  • Start a Qdrant container in Docker by running this command:
docker run -d --name qdrant -p 6333:6333 -v qdrant-storage:/qdrant/storage qdrant/qdrant

What does this command do?

  • -d runs the container in the background so you can keep using your terminal.
  • -p 6333:6333 maps the container's port to your machine so you can reach the API at localhost.
  • -v qdrant-storage:/qdrant/storage creates a named Docker volume for data persistence. Named volumes are recommended over bind mounts on Windows.
  • Verify Qdrant is running by checking the container status:
docker ps

You should see a container named qdrant with status Up.

Don't see the container running?

Make sure Docker Desktop is running before executing the docker run command. If you see a port conflict, another service may already be using port 6333.

help me troubleshoot why my Qdrant Docker container won't start

  • Install the vector search and LLM dependencies by running:
pip install qdrant-client sentence-transformers groq

What are these packages?

  • qdrant-client is the Python SDK for communicating with your Qdrant vector database.
  • sentence-transformers provides pre-trained models that turn text into numerical vectors (embeddings).
  • groq is the client for Groq's ultra-fast LLM inference API.
  • Update requirements.txt to include the new packages. Replace the file contents with:
fastapi
uvicorn
python-multipart
python-dotenv
qdrant-client
sentence-transformers
groq
  • Save requirements.txt.

Now you will generate the embeddings module. This module loads a sentence-transformer model that converts text into 384-dimension vectors.

  • In Codex, ask it to generate embeddings.py with a function that embeds a single text string and a function that embeds a batch of strings, both using the all-MiniLM-L6-v2 model.
  • Your generated embeddings.py should look like this:
from sentence_transformers import SentenceTransformer

_model = SentenceTransformer('all-MiniLM-L6-v2')

def embed_text(text: str) -> list:
    return _model.encode(text).tolist()

def embed_batch(texts: list) -> list:
    return _model.encode(texts).tolist()

What does this code do?

The model is loaded once at module level (_model) so it stays in memory across requests. Loading it is slow (~80MB download on first run), but encoding is fast.

embed_text() converts a single string to a 384-dimension vector. embed_batch() does the same for a list of strings in one pass, which is more efficient when processing many chunks at once.

  • Save embeddings.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved embeddings.py before moving on.

ⓧ I'd like to double check the full code

from sentence_transformers import SentenceTransformer

_model = SentenceTransformer('all-MiniLM-L6-v2')

def embed_text(text: str) -> list:
    return _model.encode(text).tolist()

def embed_batch(texts: list) -> list:
    return _model.encode(texts).tolist()

Next, you need a module to communicate with Qdrant. It will create a collection and provide functions to store and search vectors.

  • In Codex, ask it to generate vector_store.py that connects to Qdrant, creates a collection called documents with cosine distance and 384 dimensions, and provides store_chunks() and search() functions.
  • Your generated vector_store.py should look like this:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import os
import uuid

QDRANT_HOST = os.getenv('QDRANT_HOST', 'localhost')
client = QdrantClient(host=QDRANT_HOST, port=6333)
COLLECTION = 'documents'

def _ensure_collection():
    existing = [c.name for c in client.get_collections().collections]
    if COLLECTION not in existing:
        client.create_collection(COLLECTION, vectors_config=VectorParams(size=384, distance=Distance.COSINE))

_ensure_collection()

def store_chunks(chunks: list, embeddings: list):
    points = []
    for chunk, vector in zip(chunks, embeddings):
        points.append(PointStruct(id=str(uuid.uuid4()), vector=vector, payload=chunk['metadata'] | {'text': chunk['text']}))
    client.upsert(collection_name=COLLECTION, points=points)

def search(query_vector: list, limit: int = 3):
    results = client.search(collection_name=COLLECTION, query_vector=query_vector, limit=limit, with_payload=True)
    return results

What does this code do?

QDRANT_HOST reads from an environment variable, defaulting to localhost for local development. When you later run everything in Docker Compose, the backend will reach Qdrant via its service name instead.

_ensure_collection() runs at import time and creates the documents collection only if it does not already exist. This makes the module safe to import repeatedly without crashing.

store_chunks() pairs each chunk with its vector and inserts them as Qdrant points. search() takes an embedded query vector and returns the closest matches by cosine similarity.

  • Save vector_store.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved vector_store.py before moving on.

ⓧ I'd like to double check the full code

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import os
import uuid

QDRANT_HOST = os.getenv('QDRANT_HOST', 'localhost')
client = QdrantClient(host=QDRANT_HOST, port=6333)
COLLECTION = 'documents'

def _ensure_collection():
    existing = [c.name for c in client.get_collections().collections]
    if COLLECTION not in existing:
        client.create_collection(COLLECTION, vectors_config=VectorParams(size=384, distance=Distance.COSINE))

_ensure_collection()

def store_chunks(chunks: list, embeddings: list):
    points = []
    for chunk, vector in zip(chunks, embeddings):
        points.append(PointStruct(id=str(uuid.uuid4()), vector=vector, payload=chunk['metadata'] | {'text': chunk['text']}))
    client.upsert(collection_name=COLLECTION, points=points)

def search(query_vector: list, limit: int = 3):
    results = client.search(collection_name=COLLECTION, query_vector=query_vector, limit=limit, with_payload=True)
    return results
Implement RAG with source citations

With embeddings and vector search in place, you can now build the core RAG logic. This module will embed the user's question, retrieve the most relevant chunks, and pass them as context to a Groq LLM that generates a cited answer.

  • In Codex, ask it to generate rag.py that embeds a question, retrieves the top 3 chunks from Qdrant, formats them as context with source metadata, sends them to the Groq API using llama-3.1-8b-instant, and returns the answer with citations.
  • Your generated rag.py should look like this:
from embeddings import embed_text
from vector_store import search
from groq import Groq
import os

client = Groq(api_key=os.getenv('GROQ_API_KEY'))

def answer_question(question: str) -> dict:
    query_vector = embed_text(question)
    results = search(query_vector, limit=3)
    context_parts = []
    citations = []
    for r in results:
        context_parts.append(f"[Source: {r.payload['document_name']}, chunk {r.payload['chunk_index']}]\n{r.payload['text']}")
        citations.append({'document_name': r.payload['document_name'], 'chunk_index': r.payload['chunk_index'], 'text_snippet': r.payload['text'][:100], 'score': r.score})
    context = '\n\n'.join(context_parts)
    prompt = f"Answer the question based only on the provided context. Cite your sources.\n\nContext:\n{context}\n\nQuestion: {question}"
    response = client.chat.completions.create(model='llama-3.1-8b-instant', messages=[{'role': 'user', 'content': prompt}])
    return {'answer': response.choices[0].message.content, 'citations': citations}

What does this code do?

The function embeds the user's question into the same 384-dimension space as the stored chunks, then searches Qdrant for the 3 closest matches.

It formats those matches into a context block that includes the source document name and chunk index. The prompt explicitly tells the LLM to only use the provided context and cite its sources.

The response includes both the LLM's answer and a structured citations array with document name, chunk index, a text snippet, and the similarity score.

  • Save rag.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved rag.py before moving on.

ⓧ I'd like to double check the full code

from embeddings import embed_text
from vector_store import search
from groq import Groq
import os

client = Groq(api_key=os.getenv('GROQ_API_KEY'))

def answer_question(question: str) -> dict:
    query_vector = embed_text(question)
    results = search(query_vector, limit=3)
    context_parts = []
    citations = []
    for r in results:
        context_parts.append(f"[Source: {r.payload['document_name']}, chunk {r.payload['chunk_index']}]\n{r.payload['text']}")
        citations.append({'document_name': r.payload['document_name'], 'chunk_index': r.payload['chunk_index'], 'text_snippet': r.payload['text'][:100], 'score': r.score})
    context = '\n\n'.join(context_parts)
    prompt = f"Answer the question based only on the provided context. Cite your sources.\n\nContext:\n{context}\n\nQuestion: {question}"
    response = client.chat.completions.create(model='llama-3.1-8b-instant', messages=[{'role': 'user', 'content': prompt}])
    return {'answer': response.choices[0].message.content, 'citations': citations}

Now you need to wire the RAG pipeline into your existing API endpoints.

  • Ask Codex to update main.py so that the /upload endpoint embeds chunks and stores them in Qdrant after processing, and the /ask endpoint uses the answer_question() function from rag.py.
  • Your updated main.py should now look like this:
from fastapi import FastAPI, UploadFile, File
from document_processor import process_document
from embeddings import embed_batch, embed_text
from vector_store import store_chunks, search
from rag import answer_question
from dotenv import load_dotenv
import os

load_dotenv()

app = FastAPI(title='DocuMind')

documents_store = {}

@app.post('/upload')
async def upload_document(file: UploadFile = File(...)):
    content = await file.read()
    text = content.decode('utf-8')
    chunks = process_document(text, file.filename)
    # Embed all chunk texts and store in Qdrant
    texts = [c['text'] for c in chunks]
    embeddings = embed_batch(texts)
    store_chunks(chunks, embeddings)
    documents_store[file.filename] = chunks
    return {'filename': file.filename, 'chunks': len(chunks)}

@app.get('/documents')
def list_documents():
    return {'documents': list(documents_store.keys())}

@app.post('/ask')
def ask_question(payload: dict):
    question = payload.get('question', '')
    result = answer_question(question)
    return result

What changed in main.py?

  • The unused JSONResponse import is gone. New imports bring in embed_batch, store_chunks, and answer_question.
  • The /upload endpoint now embeds every chunk and stores the vectors in Qdrant before returning.
  • The /ask endpoint extracts the question from the request body and delegates to answer_question() in rag.py.
  • Save main.py.
Add the web interface and test end-to-end

The API works, but testing through Swagger UI is tedious. A minimal web page lets you upload documents and ask questions in one place.

  • In Codex, ask it to generate static/index.html with a file upload form, a question input, and areas to display the answer and source citations.
  • Your generated static/index.html should look like this:
<!DOCTYPE html>
<html>
<head><title>DocuMind</title></head>
<body>
<h1>DocuMind</h1>
<form id='uploadForm'>
  <input type='file' id='fileInput' accept='.txt,.md' />
  <button type='submit'>Upload</button>
</form>
<div id='uploadResult'></div>
<hr/>
<input type='text' id='questionInput' placeholder='Ask a question...' />
<button onclick='askQuestion()'>Ask</button>
<div id='answer'></div>
<div id='citations'></div>
<script>
document.getElementById('uploadForm').onsubmit = async (e) => {
  e.preventDefault();
  const formData = new FormData();
  formData.append('file', document.getElementById('fileInput').files[0]);
  const res = await fetch('/upload', {method: 'POST', body: formData});
  const data = await res.json();
  document.getElementById('uploadResult').innerText = JSON.stringify(data);
};
async function askQuestion() {
  const question = document.getElementById('questionInput').value;
  const res = await fetch('/ask', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({question})});
  const data = await res.json();
  document.getElementById('answer').innerText = data.answer;
  document.getElementById('citations').innerText = JSON.stringify(data.citations, null, 2);
}
</script>
</body>
</html>

What does this page do?

The upload form sends the selected file to /upload as multipart form data, then displays the server's response (filename and chunk count).

The askQuestion() function sends the typed question to /ask as JSON and renders the answer text plus the raw citation data.

  • Save static/index.html.

Now you need to tell FastAPI to serve that HTML file. You will mount the static directory and add a root route.

  • Ask Codex to update main.py to import HTMLResponse and StaticFiles, mount the static directory, and add a root route that serves index.html.
  • Your final main.py should now look like this:
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from document_processor import process_document
from embeddings import embed_batch, embed_text
from vector_store import store_chunks, search
from rag import answer_question
from dotenv import load_dotenv
import os

load_dotenv()

app = FastAPI(title='DocuMind')
app.mount('/static', StaticFiles(directory='static'), name='static')

documents_store = {}

@app.get('/', response_class=HTMLResponse)
def root():
    with open('static/index.html') as f:
        return f.read()

@app.post('/upload')
async def upload_document(file: UploadFile = File(...)):
    content = await file.read()
    text = content.decode('utf-8')
    chunks = process_document(text, file.filename)
    texts = [c['text'] for c in chunks]
    embeddings = embed_batch(texts)
    store_chunks(chunks, embeddings)
    documents_store[file.filename] = chunks
    return {'filename': file.filename, 'chunks': len(chunks)}

@app.get('/documents')
def list_documents():
    return {'documents': list(documents_store.keys())}

@app.post('/ask')
def ask_question(payload: dict):
    question = payload.get('question', '')
    result = answer_question(question)
    return result

What did you add?

  • HTMLResponse lets the root route return HTML directly instead of JSON.
  • app.mount('/static', ...) makes everything in the static/ folder accessible at /static/ URLs.
  • The / route reads static/index.html and returns it as the homepage.
  • Save main.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.py and static/index.html.

ⓧ I'd like to double check the full code

from fastapi import FastAPI, UploadFile, File
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from document_processor import process_document
from embeddings import embed_batch, embed_text
from vector_store import store_chunks, search
from rag import answer_question
from dotenv import load_dotenv
import os

load_dotenv()

app = FastAPI(title='DocuMind')
app.mount('/static', StaticFiles(directory='static'), name='static')

documents_store = {}

@app.get('/', response_class=HTMLResponse)
def root():
    with open('static/index.html') as f:
        return f.read()

@app.post('/upload')
async def upload_document(file: UploadFile = File(...)):
    content = await file.read()
    text = content.decode('utf-8')
    chunks = process_document(text, file.filename)
    texts = [c['text'] for c in chunks]
    embeddings = embed_batch(texts)
    store_chunks(chunks, embeddings)
    documents_store[file.filename] = chunks
    return {'filename': file.filename, 'chunks': len(chunks)}

@app.get('/documents')
def list_documents():
    return {'documents': list(documents_store.keys())}

@app.post('/ask')
def ask_question(payload: dict):
    question = payload.get('question', '')
    result = answer_question(question)
    return result
<!DOCTYPE html>
<html>
<head><title>DocuMind</title></head>
<body>
<h1>DocuMind</h1>
<form id='uploadForm'>
  <input type='file' id='fileInput' accept='.txt,.md' />
  <button type='submit'>Upload</button>
</form>
<div id='uploadResult'></div>
<hr/>
<input type='text' id='questionInput' placeholder='Ask a question...' />
<button onclick='askQuestion()'>Ask</button>
<div id='answer'></div>
<div id='citations'></div>
<script>
document.getElementById('uploadForm').onsubmit = async (e) => {
  e.preventDefault();
  const formData = new FormData();
  formData.append('file', document.getElementById('fileInput').files[0]);
  const res = await fetch('/upload', {method: 'POST', body: formData});
  const data = await res.json();
  document.getElementById('uploadResult').innerText = JSON.stringify(data);
};
async function askQuestion() {
  const question = document.getElementById('questionInput').value;
  const res = await fetch('/ask', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({question})});
  const data = await res.json();
  document.getElementById('answer').innerText = data.answer;
  document.getElementById('citations').innerText = JSON.stringify(data.citations, null, 2);
}
</script>
</body>
</html>

Time to test the full flow. You will start the server, upload a document, and ask a question about it.

  • Start the FastAPI server by running:
uvicorn main:app --reload --port 8000

First run takes a moment

The first time you start the server, sentence-transformers will download the all-MiniLM-L6-v2 model (~80MB). Subsequent starts are instant because the model is cached locally.

  • Open http://localhost:8000 in your browser. You should see the DocuMind page with an upload form and a question input.
  • Create a sample text file on your Desktop called test-doc.txt with a few paragraphs of any content you like (a recipe, project notes, or a Wikipedia excerpt).
  • Click Choose File, select your test-doc.txt, then click Upload.
  • You should see a JSON response showing the filename and the number of chunks created.
  • Type a question about your document's content in the text input and click Ask.
  • You should see the LLM's answer appear, followed by citation data showing which chunks were used and their similarity scores.

Not seeing an answer?

  • Check your terminal for errors. If you see a Groq authentication error, make sure your .env file has a valid GROQ_API_KEY value.
  • If you see a connection error to Qdrant, confirm the container is still running with docker ps.
  • If the upload worked but the question returns empty citations, your document might be too short. Try uploading a file with at least a few paragraphs.

help me debug why my DocuMind RAG pipeline isn't returning answers

  • Open http://localhost:6333/dashboard in your browser to see the Qdrant Web UI.
  • You should see a documents collection listed with the number of vectors matching the chunks you uploaded.

The RAG pipeline is fully functional. Before moving on to Docker Compose, you will stop and remove the standalone Qdrant container since Docker Compose will create its own.

  • Stop the FastAPI server by pressing Ctrl+C in its terminal.
  • Stop and remove the standalone Qdrant container by running:
docker stop qdrant && docker rm qdrant

Your RAG pipeline is working end-to-end. Next up, you will containerize the entire system with Docker Compose so both the backend and Qdrant run as a single, reproducible application.

Containerize with Docker Compose

Your RAG pipeline works perfectly on your local machine. But what happens when you want to share it, deploy it, or run it on another computer? You would need to install Python, set up a virtual environment, run Qdrant separately, and hope every version lines up.

Docker Compose solves this by packaging your entire application into containers that run identically everywhere. In this step, you will create a Dockerfile for the backend, write a docker-compose.yml that orchestrates both services, and verify that uploaded documents persist across restarts.

In this step, get ready to:
  • Create a Dockerfile that packages the FastAPI backend into an image.
  • Write a Docker Compose configuration running both Qdrant and the backend.
  • Verify that named volumes persist data across container restarts.
Create the Dockerfile and .dockerignore

A Dockerfile is a recipe that tells Docker how to build an image of your application. It specifies the base operating system, installs dependencies, copies your code, and defines the command to start the server.

Before writing the Dockerfile, you need to make sure requirements.txt lists every package the container needs. You also need a .dockerignore file to keep unnecessary files out of the image.

  • Update requirements.txt to include mcp (you will need it in the next step). Replace the file contents with the following:
fastapi
uvicorn
python-multipart
python-dotenv
qdrant-client
sentence-transformers
groq
mcp

Why add mcp now?

The MCP SDK is included here so it gets baked into the Docker image. Adding it now means you will not need to rebuild the image in the next step when you create the MCP server.

  • Save requirements.txt.
  • Create a new file called .dockerignore in your documind folder with the following contents:
.venv
__pycache__
.env
qdrant-storage

What does .dockerignore do?

When Docker builds an image, it copies everything in the current directory into the build context. The .dockerignore file tells Docker to skip specific files and folders.

You are excluding .venv (the container installs its own packages), __pycache__ (compiled bytecode that might be stale), .env (secrets stay on the host and get injected at runtime), and qdrant-storage (Qdrant data lives in a Docker volume).

  • Save .dockerignore.
  • Now open Dockerfile (which currently has a placeholder comment) and replace its entire contents with:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python 3.12 image.
  • WORKDIR /app sets the working directory inside the container.
  • COPY requirements.txt . then RUN pip install installs dependencies first. This layer gets cached, so rebuilds are fast when only your code changes.
  • COPY . . copies the rest of your application code.
  • EXPOSE 8000 documents which port the app listens on.
  • CMD runs uvicorn bound to 0.0.0.0 so traffic from outside the container can reach it.
  • Save Dockerfile.

Seeing a "file not found" error later during build?

Make sure your Dockerfile has no file extension. It should be named exactly Dockerfile (capital D, no .txt or .dockerfile suffix). Also check that requirements.txt is saved in the same folder as the Dockerfile.

Help me debug my Dockerfile build error

Create docker-compose.yml

Docker Compose lets you define multiple containers in a single YAML file and start them all with one command. Your application needs two services: Qdrant for vector storage and the FastAPI backend you just containerized.

  • Open docker-compose.yml (which currently has a placeholder comment) and replace its entire contents with:
version: '3.9'
services:
  qdrant:
    image: qdrant/qdrant
    ports:
      - '6333:6333'
    volumes:
      - qdrant-data:/qdrant/storage
  backend:
    build: .
    ports:
      - '8000:8000'
    env_file:
      - .env
    environment:
      - QDRANT_HOST=qdrant
    depends_on:
      - qdrant
volumes:
  qdrant-data:

What does this configuration do?

  • The qdrant service pulls the official qdrant/qdrant image from Docker Hub, exposes port 6333, and stores data in a named volume called qdrant-data.
  • The backend service builds your Dockerfile, maps port 8000 to the host, loads your .env file for the Groq API key, and sets QDRANT_HOST=qdrant as an environment variable.
  • depends_on ensures Qdrant starts before the backend.
  • The volumes: section at the bottom declares qdrant-data as a named volume. Named volumes persist even when containers are destroyed.

How does the backend find Qdrant inside Docker?

Docker Compose creates an internal network for all services. Each service can reach another by its service name. When you set QDRANT_HOST=qdrant, the backend connects to http://qdrant:6333 inside the Docker network.

Your vector_store.py already reads this environment variable with os.getenv('QDRANT_HOST', 'localhost'). When running locally (outside Docker), it defaults to localhost. Inside Docker, the QDRANT_HOST variable overrides it to qdrant.

  • Save docker-compose.yml.

YAML indentation errors?

YAML is sensitive to spaces. Use exactly 2 spaces per indent level. Never use tabs. If you see a parsing error when running Docker Compose, check that each nested key is indented consistently.

Help me fix a YAML syntax error in my docker-compose.yml

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: requirements.txt, .dockerignore, Dockerfile, and docker-compose.yml.

ⓧ I'd like to double check the full code

fastapi
uvicorn
python-multipart
python-dotenv
qdrant-client
sentence-transformers
groq
mcp
.venv
__pycache__
.env
qdrant-storage
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
version: '3.9'
services:
  qdrant:
    image: qdrant/qdrant
    ports:
      - '6333:6333'
    volumes:
      - qdrant-data:/qdrant/storage
  backend:
    build: .
    ports:
      - '8000:8000'
    env_file:
      - .env
    environment:
      - QDRANT_HOST=qdrant
    depends_on:
      - qdrant
volumes:
  qdrant-data:
Build, run, and verify persistence

Everything is configured. Time to build the Docker image and launch both containers together.

  • Make sure Docker Desktop is running.
  • Build and start both services by running this command:
docker compose up --build

What does this command do?

The --build flag tells Docker Compose to build the backend image from your Dockerfile before starting containers. The qdrant service does not need building because it uses a pre-built image from Docker Hub.

This command runs in the foreground, so you will see logs from both services streaming in your terminal. The first build takes a few minutes because it downloads the Python base image and installs sentence-transformers (which pulls a model file).

  • Wait for the logs to show that both services are ready. You should see Qdrant logging that it is listening on port 6333 and uvicorn logging that the application started on 0.0.0.0:8000.
  • Open your browser and go to http://localhost:8000.
  • Upload a sample text file using the upload form.
  • Ask a question about the uploaded document and verify you get a cited answer.

Backend container exits immediately?

Check the logs for the error message. A common cause is a missing .env file. Make sure .env exists in your documind folder with your GROQ_API_KEY set.

Another common issue is a port conflict. If something else is using port 8000 or 6333, Docker will fail to bind. Stop any other services on those ports and try again.

Help me debug why my Docker container is exiting

Now test that named volumes persist your data across container restarts.

  • Stop the running containers by pressing Ctrl+C in the terminal.
  • Tear down the containers by running:
docker compose down
  • Start the containers again without rebuilding by running:
docker compose up
  • Go back to http://localhost:8000 and ask the same question you asked before.
  • Verify that you still get a cited answer from the document you uploaded earlier.

Why does this work?

The docker compose down command destroys the containers but leaves named volumes intact. Your Qdrant vectors are stored in the qdrant-data volume, so they survive container restarts. This is exactly how production databases maintain data across deployments.

  • Open a new terminal tab and inspect the backend logs by running:
docker compose logs backend
  • Inspect the Qdrant logs by running:
docker compose logs qdrant
  • You should see request logs from the backend and startup/query logs from Qdrant. These logs are useful for debugging issues in production.

Your entire application is now running as a multi-container Docker Compose stack with persistent storage. Next up, you will build an MCP server that lets external AI agents query your knowledge base directly.

Build the MCP Server and Connect Codex

Your Dockerized RAG system is running and answering questions with source citations. Humans can query it through the web interface. Now you are going to open it up to AI agents.

The Model Context Protocol (MCP) is a standard that lets AI agents connect to external tools and data sources. By building an MCP server on top of your knowledge base, any MCP-compatible agent can search your documents programmatically. This is the differentiator on your resume. Most students stop before this stage.

In this step, get ready to:
  • Build an MCP server exposing your knowledge base as searchable tools.
  • Connect Codex CLI to your MCP server so it can query your documents.
  • Run a live demo of an AI agent querying your system.
Create the MCP server

The MCP server runs locally on your machine, not inside Docker. Codex CLI spawns MCP servers as subprocesses using stdio transport. Your server connects to the Qdrant container through the port Docker Compose already exposes on localhost:6333.

  • Install the MCP SDK in your virtual environment by running this command:
pip install "mcp[cli]"

Why install locally if it's already in requirements.txt?

The requirements.txt file is for the Docker container. Your MCP server runs outside Docker as a local subprocess that Codex spawns. You need the package available in your local virtual environment too.

  • Open mcp_server.py in your editor. Delete the placeholder comment and replace it with the following code:
from mcp.server.fastmcp import FastMCP
from embeddings import embed_text
from vector_store import search, client, COLLECTION
import os

# Initialize the MCP server with a descriptive name
mcp = FastMCP('DocuMind')

@mcp.tool()
def search_docs(query: str, limit: int = 3) -> list:
    """Search the knowledge base for relevant document chunks."""
    # Embed the query and find matching chunks in Qdrant
    query_vector = embed_text(query)
    results = search(query_vector, limit=limit)
    # Return structured results with source info and similarity scores
    return [{'document_name': r.payload['document_name'], 'chunk_text': r.payload['text'], 'similarity_score': r.score} for r in results]

What does this code do?

FastMCP is the high-level server class from the official MCP Python SDK. It handles the JSON-RPC protocol so you only write tool logic.

The @mcp.tool() decorator registers search_docs as a tool that agents can discover and call. The function signature becomes the tool's input schema automatically.

  • Save mcp_server.py.
  • Add the list_documents tool and the server entry point below the search_docs function:
@mcp.tool()
def list_documents() -> list:
    """List all documents in the knowledge base."""
    # Scroll through all points in Qdrant to find unique document names
    results = client.scroll(collection_name=COLLECTION, with_payload=True, limit=100)
    seen = {}
    for point in results[0]:
        name = point.payload.get('document_name')
        if name and name not in seen:
            seen[name] = {'document_name': name, 'upload_timestamp': point.payload.get('upload_timestamp')}
    return list(seen.values())

if __name__ == '__main__':
    # Start the server using stdio transport for subprocess communication
    mcp.run(transport='stdio')

What does this code do?

The list_documents tool scrolls through stored vectors in Qdrant and returns unique document names with their upload timestamps. Agents use this to discover what is in your knowledge base before searching.

The mcp.run(transport='stdio') line starts the server using stdio transport. This means the server communicates through standard input and output. Codex CLI spawns it as a subprocess and pipes messages directly.

  • Save mcp_server.py.

Seeing an import error?

Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt. If not, activate it with ..\.venv\Scripts\activate and run the install command again.

help me fix an import error in my MCP server

✔️ Awesome, I've got everything!

Great. Double check you have saved mcp_server.py before moving on.

ⓧ I'd like to double check the full code

from mcp.server.fastmcp import FastMCP
from embeddings import embed_text
from vector_store import search, client, COLLECTION
import os

mcp = FastMCP('DocuMind')

@mcp.tool()
def search_docs(query: str, limit: int = 3) -> list:
    """Search the knowledge base for relevant document chunks."""
    query_vector = embed_text(query)
    results = search(query_vector, limit=limit)
    return [{'document_name': r.payload['document_name'], 'chunk_text': r.payload['text'], 'similarity_score': r.score} for r in results]

@mcp.tool()
def list_documents() -> list:
    """List all documents in the knowledge base."""
    results = client.scroll(collection_name=COLLECTION, with_payload=True, limit=100)
    seen = {}
    for point in results[0]:
        name = point.payload.get('document_name')
        if name and name not in seen:
            seen[name] = {'document_name': name, 'upload_timestamp': point.payload.get('upload_timestamp')}
    return list(seen.values())

if __name__ == '__main__':
    mcp.run(transport='stdio')

Now test that your MCP server starts correctly and exposes both tools.

  • Make sure Docker Compose is still running (your Qdrant container needs to be reachable at localhost:6333).
  • Test the MCP server with the MCP Inspector by running:
npx @modelcontextprotocol/inspector python mcp_server.py

What is the MCP Inspector?

The MCP Inspector is a debugging tool that connects to your server, lists available tools, and lets you invoke them interactively. It confirms your server speaks the MCP protocol correctly before you connect a real agent.

Look for search_docs and list_documents in the tools list. If both appear, your MCP server is working.

npx command not found?

The npx command comes with Node.js. If you do not have Node.js installed, download it from nodejs.org. After installing, close and reopen your terminal before retrying.

help me get npx working on my Windows machine

Connect Codex CLI to your MCP server

Codex CLI is an MCP client. It can connect to any MCP server at startup and use the server's tools during conversations. You are about to point Codex at the system you built.

  • Register your MCP server with Codex by running this command:
codex mcp add documind -- python mcp_server.py

What does this command do?

This tells Codex: when you start up, also spawn a subprocess running python mcp_server.py and connect to it as an MCP server named documind. Codex saves this in its config.toml so it persists across sessions.

  • Start Codex CLI by running:
codex
  • Ask Codex: "What tools do you have available?"
  • Confirm that search_docs and list_documents appear in the tool list.

Tools not showing up?

Make sure you ran the codex mcp add command from inside your documind project folder. The python mcp_server.py path needs to resolve from wherever Codex launches the subprocess.

Also verify Docker Compose is still running. The MCP server needs Qdrant at localhost:6333 to initialize the vector store connection.

help me debug why Codex cannot see my MCP tools

Run the 60-second demo

This is the moment where everything connects. The AI tool you have been working alongside is about to query the system you built.

  • Make sure Docker Compose is running and you have at least one document uploaded (check http://localhost:8000 if you need to upload one).
  • In Codex CLI, ask: "Search my documentation for" followed by a topic from your uploaded document.

Watch Codex call your search_docs tool, retrieve chunks from your Qdrant database, and present cited results. The response includes document names, chunk text, and similarity scores pulled from your vector store.

  • Take a screenshot or record your terminal showing the Codex response with results from your knowledge base.

Why this matters for your resume

You just proved three things in one demo: you built an MCP server, connected a real AI agent to it, and that agent successfully queried your knowledge system. This is what you describe in interviews.

The elevator pitch: "Built a Dockerized enterprise knowledge platform that uses RAG for document retrieval and exposes company knowledge through an MCP server, allowing external AI agents to query internal documentation with source-cited answers."

Codex not returning results?

Verify you have documents uploaded by visiting http://localhost:8000 in your browser. The documents list should show at least one entry. If it is empty, upload a text or markdown file first.

If Codex shows the tool call but returns empty results, check that the Qdrant dashboard at http://localhost:6333/dashboard shows vectors in the documents collection.

help me figure out why Codex returns no results from my MCP server

Secret mission

Add Document Version Comparison

Your knowledge base can answer questions about documents. But what happens when documentation changes? In this secret mission, you will add version-aware uploads and a comparison endpoint that answers "What changed between v1 and v2?" through both the web UI and your MCP server.

Clean Up Your Resources

Clean Up Your Resources

This project runs a multi-container Docker Compose application locally. Docker containers and volumes consume disk space while running, but there are no ongoing cloud costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker Compose services (backend and qdrant containers).
  • Docker volume qdrant-data (persists your uploaded document embeddings).
  • Docker image built for the backend service.
  • Local documind project folder on your Desktop.
  • Codex MCP server registration (documind entry in your Codex config).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with DocuMind.

  • Your Docker Compose services will keep running until you stop them or restart your machine.
  • The Codex MCP server registration stays in your config so Codex can still query your knowledge base.
  • Codex CLI remains installed on your system. The PowerShell installer does not provide a documented uninstall path at this time.

Pause - I'll come back to this later

Shut down running containers to free up system resources. Your project files and Docker volume stay intact so you can resume later.

  • Stop Docker Compose by running:
cd ~/Desktop/documind
docker compose down
  • This stops the containers but preserves the qdrant-data volume. Your uploaded documents will still be there when you run docker compose up again.
  • Deactivate your Python virtual environment by running:
deactivate

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Stop all containers and delete the Docker volume by running:
cd ~/Desktop/documind
docker compose down -v
  • Remove the Docker image built for the backend by running:
docker rmi documind-backend
  • Remove the Codex MCP server registration by running:
codex mcp remove documind
  • Delete the project folder by running:
cd ~/Desktop
Remove-Item -Recurse -Force documind
  • Confirm the folder is gone by running:
Get-ChildItem ~/Desktop | Where-Object { $_.Name -eq 'documind' }

You should see no output, confirming the folder has been deleted.

Nice Work!

Nice Work!

You just built DocuMind, a fully Dockerized knowledge platform that both humans and AI agents can query through natural language. This is a production-grade system combining RAG, vector search, and the Model Context Protocol into one cohesive application.

You learned how to:

  • Implement a complete RAG pipeline using sentence-transformers for vector embeddings, Qdrant for vector search, and Groq LLM inference to produce source-cited answers.
  • Containerize a multi-service application with Docker Compose, including service name resolution for inter-container networking and named volumes for data persistence.
  • Build an MCP server exposing knowledge base tools and connect Codex CLI to it, demonstrating a real AI agent querying your system through the standardized protocol.
  • Secret Mission: Add document version comparison, enabling "What changed between v1 and v2?" queries through both the API and MCP.

Ready to quiz yourself?