A full-stack Retrieval-Augmented Generation (RAG) system that lets you upload documents and ask questions about them.
Under the hood, this application uses a highly optimized, multi-stage RAG architecture to ensure accuracy, context preservation, and speed.
The system strategically divides tasks across a hierarchical multi-model architecture to balance speed and intelligence:
- Metadata LLM (
llama3.2local): A fast, local model used exclusively during document ingestion to read the raw text and generate structured metadata (summaries, tags, keywords). - Embedding Model (
nomic-embed-textlocal): A specialized, high-dimensional vector model used to encode text chunks into numbers. - Generation LLM (
llama-3.3-70b-versatilecloud): A massive, highly capable model hosted on Groq that handles the final user reasoning and answer generation.
Instead of blindly reading text, the system uses format-specific parsers (like PyMuPDF for PDFs and python-docx for Word files) to extract both raw text and structured tabular data. Magic-byte validation prevents malicious file spoofing.
A naive chunking strategy splits text at fixed token counts, breaking sentences and losing context. This system uses Semantic Chunking:
- Heading Awareness: It first splits documents by markdown headings (
#), keeping sections intact. - Paragraph & Sentence Boundaries: It recursively splits large sections by paragraphs and then sentences.
- Context Overlap: Consecutive chunks include an overlap of a few sentences (configurable via
CHUNK_OVERLAP_SENTENCES) to guarantee that the context bridging two chunks is never lost.
When a document is uploaded, a local LLM (llama3.2) is immediately dispatched to read the content and generate rich metadata:
- Extracted Title and Summary
- Keyword extraction
- Categorization and tagging This metadata is attached to the vector payload in Qdrant, allowing users to apply strict pre-filters (e.g., "only search in the 'Research' category") before the vector similarity search even begins.
Standard vector search can sometimes surface conceptually similar but factually irrelevant results. To counter this:
- Dense Retrieval: We first retrieve
Top K * 2results using Cosine Similarity against thenomic-embed-textembeddings. - Keyword Reranking: A custom scoring algorithm calculates the exact keyword overlap between the user's query and the retrieved chunks. The final score is a weighted blend (70% vector score + 30% keyword score). The results are then sorted and truncated to the exact
Top Krequested.
Retrieved chunks are formatted with strict provenance tracking. The context injected into the prompt looks like this:
Document: [Title] | Page: [X] | Section: [Heading]
[Chunk Text]
This forces the generation LLM to "see" exactly where the information came from, drastically reducing hallucinations and enabling the backend to map the LLM's response to structured, clickable UI citations.
To eliminate perceived latency, the final prompt is sent to high-speed inference providers (like Groq) and the response is streamed back to the frontend character-by-character over WebSockets.
- Python 3.11+
- Node.js 20+
- Docker (for running Qdrant)
Qdrant is where the document chunks are stored. You can run it easily using Docker:
docker run -p 6333:6333 -p 6334:6334 -v $(pwd)/qdrant_storage:/qdrant/storage:z qdrant/qdrantThis will start Qdrant on localhost:6333.
We use local models for turning text into numbers (embeddings) and generating document summaries.
- Download and install Ollama.
- Once installed, open your terminal and download the required models:
ollama run nomic-embed-text
ollama run llama3.2Keep Ollama running in the background.
We use Groq's high-speed API to run large models like llama-3.3-70b-versatile for answering questions.
- Go to the Groq Console and create an account.
- Generate an API Key.
- Open the
.envfile in the root of this project and add your key:
# --- Groq LLM (Required) ---
GROQ_API_KEY=your_groq_api_key_here
# --- Ollama (Local LLM & Embeddings) ---
OLLAMA_BASE_URL=http://localhost:11434
EMBEDDING_MODEL=nomic-embed-text
LOCAL_LLM_MODEL=llama3.2
# --- Groq Model ---
GROQ_MODEL=llama-3.3-70b-versatile
# --- Qdrant Vector Database ---
QDRANT_HOST=localhost
QDRANT_PORT=6333
QDRANT_COLLECTION=rag_documents
# --- Application ---
UPLOAD_DIR=data/uploads
METADATA_DIR=data/metadataStart the Backend: Open a terminal in the root directory:
cd backend
python -m venv venv
venv\Scripts\activate # On Windows
pip install -r requirements.txt
python run.pyThe backend will run on http://localhost:8000.
Start the Frontend: Open a new terminal window:
cd frontend
npm install
npm run devThe frontend will run on http://localhost:3000.
The complete pipeline from document ingestion to answer generation consists of 8 stages. Here is the technical breakdown of each step:
Triggered by POST /api/documents/upload (file) or POST /api/documents/url (remote download).
- File Validation — Extension allowlist check → magic-byte content validation → size check (<50MB).
- Storage — File saved to
data/uploads/with a UUID4-based filename. - Parsing — Format-specific parser extracts raw text and tables.
PyMuPDFfor PDFs.python-docxfor Word documents.BeautifulSoupfor HTML/URLs.
- Metadata Generation — A local LLM (
llama3.2) analyzes the raw text to generate a title, summary, keywords, categories, and tags. This is cached indata/metadata/. - Semantic Chunking — Text is hierarchically split by markdown headings, then paragraphs, then sentences, aiming for ~300 tokens per chunk with an overlap of 2 sentences to preserve context.
- Embedding — Each chunk is embedded into a 768-dimensional vector via Ollama (
nomic-embed-text). Identical chunks are deduplicated via a disk-backed SHA256 cache. - Vector Indexing — Chunks and their embeddings are upserted into Qdrant in batches of 100.
- Query is received via
POST /api/chatorPOST /api/search. - Search parameters are extracted:
top_k, and filters (categories, tags, document_ids, domain). - The in-memory conversation history is loaded (for chat endpoints) to maintain multi-turn context.
- The user's query is embedded using the exact same Ollama embedding model (
nomic-embed-text). - A fast vector similarity search (Cosine Distance) is performed against the Qdrant database.
- If reranking is enabled, the system retrieves
top_k * 2candidates. - Qdrant
Filterconditions (must/must_not) are applied directly at the database level to exclude unmatched documents (e.g., filtering out deleted docs or specific domains).
To improve retrieval precision beyond just dense vector similarity, a hybrid keyword-overlap reranker is applied:
- Vector score contributes 70% of the final score.
- Keyword overlap (matching tokens between the user's query and the chunk) contributes 30%.
- Results are re-sorted by this combined score and truncated back to exactly
top_k.
- The retrieved chunks are sorted by their relevance score.
- A context string is built, injecting provenance data into the text so the LLM knows exactly where it came from:
Document: Title | Page: 3 | Section: Introduction [Chunk Text] - Total token count is estimated. If it exceeds the target context window, the lowest-scoring chunks are safely truncated.
- UI Citations are tracked and structured for each included chunk.
- The selected prompt template (default:
question_answering.md) is loaded from the filesystem. - Template variables are dynamically replaced:
{context}— The concatenated retrieved chunks.{query}— The user's question.{chat_history}— Previous messages in the session.
- The final rendered prompt is prepared for the LLM.
- The prompt is sent to the active provider (e.g., Groq using the
groqPython SDK). - The response is streamed token-by-token over a WebSocket connection to ensure ultra-low latency for the user.
- Token usage metrics (input/output) are tracked for analytics.
- The retrieved chunk sources are mapped to inline citations in the UI.
- The final response is delivered to the frontend, allowing the user to click any citation chip to view the exact Document, Page, Section, and snippet where the AI found the answer.

