Deploy a RAG Chatbot Over Your PDFs and Notes with Qdrant Free Tier & Groq
What We're Building
We’re shipping a local-first RAG (Retrieval-Augmented Generation) chatbot that answers questions from a folder of PDFs and markdown notes. It returns answers with source citations—so you know exactly which file and page the information came from. The entire stack runs on free-tier tools: Groq for fast LLM inference, Qdrant’s free cloud tier for vector storage, Hugging Face Inference API for embeddings, LlamaIndex as the orchestration framework, and Streamlit for the UI.
Feature list:
- Ingest a local folder of PDFs and
.mdfiles - Chunk documents intelligently with LlamaIndex
- Generate embeddings via Hugging Face Inference API (free tier)
- Store vectors in Qdrant’s free cloud cluster
- Query with Groq’s blazing-fast LLMs (Mixtral, Llama 3, etc.)
- Display answers with clickable source citations in Streamlit
- Fully local except for API calls—your files never leave your machine except as embeddings
If you’ve built a WhatsApp customer-support agent before, you’ll find the retrieval pattern familiar—this time we’re pointing it at your personal knowledge base.
Architecture Overview
Before we write code, let’s map how data flows from your files to the answer on screen.
The ingestion pipeline (left side) loads files, chunks them, embeds each chunk via Hugging Face, and upserts into Qdrant. The query pipeline (right side) embeds the user’s question, retrieves the top-k semantically similar chunks from Qdrant, packages them as context for Groq, and streams the answer back to Streamlit.
Prerequisites
Everything here has a generous free tier. Grab these before you start:
- Python 3.10+ – python.org/downloads
- Groq API key – Sign up at console.groq.com. Free tier gives you rate-limited access to Mixtral, Llama 3, and Gemma models.
- Qdrant Cloud free tier – Create a cluster at cloud.qdrant.io. The free tier includes 1GB of storage and a single node. Copy your cluster URL and API key.
- Hugging Face API token – Go to huggingface.co/settings/tokens and create a read-access token. The free Inference API lets you call embedding models like
sentence-transformers/all-MiniLM-L6-v2without deploying anything. - Your documents – A folder containing PDFs and/or
.mdfiles. Start with 5–10 files to keep initial indexing fast.
Step 1: Project Setup and Dependencies
Create a project folder and a virtual environment:
mkdir rag-chatbot && cd rag-chatbot
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
Install the core packages:
pip install llama-index llama-index-vector-stores-qdrant llama-index-embeddings-huggingface
pip install llama-index-llms-groq streamlit qdrant-client python-dotenv
Create a .env file in the project root:
GROQ_API_KEY=gsk_your_groq_key_here
QDRANT_URL=https://your-cluster-id.us-east-1-0.aws.cloud.qdrant.io:6333
QDRANT_API_KEY=your_qdrant_api_key_here
HF_TOKEN=hf_your_huggingface_token_here
Create app.py—that’s where the whole application lives. Let’s build it section by section.
Step 2: Loading Documents with LlamaIndex
LlamaIndex’s SimpleDirectoryReader handles PDFs and markdown files out of the box. It auto-detects file types and extracts text.
# app.py
import os
from dotenv import load_dotenv
from llama_index.core import SimpleDirectoryReader, Settings
load_dotenv()
DATA_DIR = "./data" # Put your PDFs and .md files here
def load_documents():
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
print(f"Created {DATA_DIR}/ — add your PDFs and .md files there.")
return []
reader = SimpleDirectoryReader(DATA_DIR, recursive=True)
documents = reader.load_data()
print(f"Loaded {len(documents)} documents.")
return documents
The recursive=True flag picks up files in subdirectories. Each document object carries both the text content and metadata (file name, page number for PDFs).
Step 3: Generating Embeddings with Hugging Face Inference API
We’ll use sentence-transformers/all-MiniLM-L6-v2 via the free Inference API. This model produces 384-dimensional embeddings—compact enough to keep Qdrant storage low, performant enough for semantic search over notes.
LlamaIndex has a dedicated Hugging Face Inference API embedding class:
from llama_index.embeddings.huggingface import HuggingFaceInferenceAPIEmbedding
def setup_embed_model():
embed_model = HuggingFaceInferenceAPIEmbedding(
model_name="sentence-transformers/all-MiniLM-L6-v2",
token=os.getenv("HF_TOKEN"),
)
Settings.embed_model = embed_model
return embed_model
Settings.embed_model is LlamaIndex’s global configuration hook. Once set, every component that needs embeddings picks it up automatically.
Step 4: Indexing with Qdrant Free Tier
Qdrant’s free tier gives you a managed vector database. We’ll create a collection and index our documents there.
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.core import VectorStoreIndex, StorageContext
from qdrant_client import QdrantClient
def create_index(documents, embed_model):
client = QdrantClient(
url=os.getenv("QDRANT_URL"),
api_key=os.getenv("QDRANT_API_KEY"),
)
collection_name = "my_notes"
vector_store = QdrantVectorStore(
client=client,
collection_name=collection_name,
)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context,
embed_model=embed_model,
)
print(f"Indexed {len(documents)} documents into Qdrant collection '{collection_name}'.")
return index
Important: Qdrant’s free tier does not support deleting collections via the API in some configurations. If you want to re-index, use client.delete_collection(collection_name) before creating the vector store, or increment the collection name.
Step 5: Querying with Groq LLM
Groq’s LPU hardware delivers absurdly fast token generation. We’ll use llama-3.1-8b-instant for quick, free answers. Swap to mixtral-8x7b-32768 if you need heavier reasoning.
from llama_index.llms.groq import Groq
def setup_llm():
llm = Groq(
model="llama-3.1-8b-instant",
api_key=os.getenv("GROQ_API_KEY"),
temperature=0.2,
)
Settings.llm = llm
return llm
def build_query_engine(index):
return index.as_query_engine(
similarity_top_k=4,
response_mode="compact",
)
similarity_top_k=4 retrieves the four most relevant chunks. response_mode="compact" packs them into the context window efficiently—fewer tokens, lower latency. The engine automatically cites sources in the response.
Step 6: Building the Streamlit UI
We want a clean chat interface with source citations. Streamlit’s chat_message and chat_input widgets make this straightforward.
import streamlit as st
def run_ui(query_engine):
st.set_page_config(page_title="RAG Chatbot", page_icon="📚")
st.title("📚 RAG Over Your Notes")
st.caption("Ask questions about your PDFs and markdown files. Sources are cited inline.")
if "messages" not in st.session_state:
st.session_state.messages = []
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if prompt := st.chat_input("What do you want to know?"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("Searching your notes..."):
response = query_engine.query(prompt)
st.markdown(response.response)
if hasattr(response, "source_nodes") and response.source_nodes:
with st.expander("📎 Sources"):
for i, node in enumerate(response.source_nodes, 1):
file_name = node.metadata.get("file_name", "Unknown")
page = node.metadata.get("page_label", "N/A")
snippet = node.text[:300].replace("\n", " ")
st.markdown(f"**{i}. {file_name}** (page {page})")
st.caption(snippet)
st.session_state.messages.append({"role": "assistant", "content": response.response})
The source expander shows which files and pages contributed to the answer. The snippet preview lets the user gauge relevance without opening the file.
Step 7: Running the Full Application
Wire everything together in app.py:
def main():
setup_embed_model()
setup_llm()
documents = load_documents()
if not documents:
st.warning("No documents found. Add PDFs or .md files to the ./data folder and restart.")
return
index = create_index(documents, Settings.embed_model)
query_engine = build_query_engine(index)
run_ui(query_engine)
if __name__ == "__main__":
main()
Run it:
streamlit run app.py
On first run, the app indexes your documents—this may take a minute depending on file count and Hugging Face API latency. Subsequent runs can skip re-indexing by persisting the Qdrant collection. Add a check: if the collection already exists, load from Qdrant instead of re-indexing.
Lazy-loading version:
def get_or_create_index(documents, embed_model):
client = QdrantClient(
url=os.getenv("QDRANT_URL"),
api_key=os.getenv("QDRANT_API_KEY"),
)
collection_name = "my_notes"
# Check if collection exists
collections = client.get_collections().collections
exists = any(c.name == collection_name for c in collections)
vector_store = QdrantVectorStore(client=client, collection_name=collection_name)
if exists:
print("Collection exists, loading existing index...")
index = VectorStoreIndex.from_vector_store(
vector_store,
embed_model=embed_model,
)
else:
print("Creating new index...")
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context,
embed_model=embed_model,
)
return index
This avoids re-embedding and re-uploading every time you restart.
Sensible Extensions
Once the core loop works, you can layer on these improvements:
- Hybrid search: Combine keyword (BM25) and semantic search by enabling Qdrant’s
sparse_vectorsupport. LlamaIndex supports hybrid retrieval with aQueryFusionRetriever. - Multi-user sessions: Add a simple password gate in Streamlit and scope Qdrant collections per user (e.g.,
notes_{user_id}). - Chat history: Feed the last N messages into the query engine as
chat_historyfor follow-up questions. LlamaIndex’sCondenseQuestionChatEnginehandles this cleanly. - More file types:
SimpleDirectoryReaderalso handles.txt,.csv,.docx, and.epub. Drop them in and they’ll be ingested. - Local embeddings: If you hit Hugging Face rate limits, switch to
llama-index-embeddings-ollamaand runnomic-embed-textlocally. Slightly higher setup cost, zero API dependency.
If you’re enjoying this pattern, you might also like our guide on building a Gmail AI triage agent that drafts replies using the same Groq free tier—same LLM, different retrieval surface.
Common Pitfalls
1. Hugging Face 503 errors on first call The free Inference API cold-starts models. The first embedding call may time out. Retry logic helps:
import time
def embed_with_retry(embed_model, texts, max_retries=3):
for attempt in range(max_retries):
try:
return embed_model.get_text_embedding_batch(texts)
except Exception as e:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt)
2. Qdrant free-tier rate limits
The free tier throttles writes. Index large document sets in batches of 50 chunks with a short time.sleep(1) between batches.
3. PDF parsing quality
SimpleDirectoryReader uses PyPDF2 by default, which struggles with complex layouts. For better extraction, install pymupdf (pip install pymupdf) and LlamaIndex will use it automatically.
4. Groq context window limits
llama-3.1-8b-instant has a 128K context window, but packing too many chunks dilutes answer quality. Stick to similarity_top_k=4 unless you have a specific reason to go higher.
5. Environment variables not loading
Streamlit runs app.py from its own process. Ensure load_dotenv() is called at the top of the file, before any client initialization.
The skills you’re building here—stitching retrieval, LLMs, and a clean UI—are exactly what make a Forward Deployed Engineer effective in the field. Shipping a working prototype in an afternoon is the name of the game.
FAQ
Q: How many documents can the free tier handle? A: Qdrant’s free tier gives you 1GB of vector storage. With 384-dimensional embeddings, that’s roughly 2–3 million chunks. For a personal notes use case, you’ll likely never hit the limit.
Q: Can I use a local LLM instead of Groq?
A: Absolutely. Swap Groq for Ollama (pip install llama-index-llms-ollama) and point it at a local model. You’ll lose Groq’s speed but gain full offline capability.
Q: Why Hugging Face Inference API instead of local embeddings? A: It’s zero-setup—no GPU required, no model downloads. The free tier is generous for personal use. If you need offline or higher throughput, Ollama’s embedding endpoint is a drop-in replacement.
Q: How do I update the index when I add new files?
A: Use LlamaIndex’s insert() method on the index object, or re-run the ingestion script pointing only at new files. For a production setup, look into LlamaIndex’s IngestionPipeline with document tracking.
Q: Is my data secure? A: Your original files stay on your machine. Only embedding vectors (arrays of floats) are sent to Qdrant and Hugging Face. The text chunks are sent to Groq at query time. Review each provider’s privacy policy if you’re handling sensitive data.
Q: What’s a good next project after this? A: Try building a resume tailoring agent that rewrites your CV for specific job descriptions—it uses a similar retrieval pattern but flips the objective toward generation rather than Q&A.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program