- Overview
- Architecture
- Project Structure
- API Endpoints
- Models & AI Components
- Services
- Data Flow & Workflows
- Configuration
- Key Features Implementation
- Code Deep Dive
The backend is a FastAPI-based REST API that powers an offline, privacy-first AI tutor. It processes PDF documents, generates embeddings, and provides intelligent features like summarization, question-answering (using RAG), and quiz generation - all running 100% locally without any cloud services or API keys.
- Framework: FastAPI 0.109 + Uvicorn (ASGI server)
- ML/AI: PyTorch 2.1+, Transformers 4.45+, Sentence Transformers
- Vector Database: FAISS (CPU-based semantic search)
- PDF Processing: PyMuPDF, pdfplumber
- Model Optimization: bitsandbytes (4-bit quantization), PEFT
- Language: Python 3.8+
- PDF Upload & Processing - Extract, chunk, and index documents
- Semantic Search - FAISS-powered vector similarity search
- Summarization - Academic-focused text summarization (3 styles)
- Question Answering - RAG-based Q&A with context retrieval
- Quiz Generation - Automated MCQ creation from documents
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Server │
│ (main.py + routes.py) │
└─────────────────┬───────────────────────────────────────────┘
│
┌─────────┼─────────┐
│ │ │
┌────▼───┐ ┌──▼────┐ ┌─▼─────┐
│ Models │ │Services│ │ Utils │
└────┬───┘ └──┬────┘ └─┬─────┘
│ │ │
┌────▼────────▼─────────▼────┐
│ Embeddings RAG Config │
│ Phi3Sum Vector Chunk │
│ T5Quiz PDFProc Clean │
└────────────┬─────────────────┘
│
┌───────▼────────┐
│ Data Storage │
│ /data/uploads │
│ /data/vectors │
│ /data/processed│
└────────────────┘
Client Request
↓
FastAPI Endpoint (/api/v1/*)
↓
Schema Validation (Pydantic)
↓
Service Layer (pdf_processor, vector_store, rag_pipeline)
↓
Model Layer (embeddings, phi3_summarizer, t5_quiz_generator)
↓
Utilities (chunker, config, quiz_cleaner)
↓
Response JSON
↓
Client
backend/
├── main.py # FastAPI app initialization
│
├── api/
│ ├── routes.py # All 7 API endpoints
│ └── schemas.py # Pydantic request/response models
│
├── models/
│ ├── embeddings.py # BAAI/bge-small wrapper
│ ├── phi3_summarizer.py # Academic summarization model
│ └── t5_quiz_generator.py # MCQ generation model
│
├── services/
│ ├── pdf_processor.py # PDF text extraction & chunking
│ ├── vector_store.py # FAISS vector database manager
│ └── rag_pipeline.py # Retrieval-Augmented Generation
│
├── utils/
│ ├── config.py # Global configuration
│ ├── chunker.py # Smart text chunking
│ └── quiz_cleaner.py # Post-process quiz output
│
└── testing_scripts/
├── pegasustest.py # Summarization evaluation
└── evaluation_script.py # General testing utilities
http://localhost:8000/api/v1
| Endpoint | Method | Purpose | Request | Response |
|---|---|---|---|---|
/health |
GET | Check backend & model status | None | HealthResponse |
/upload |
POST | Upload PDF document | multipart/form-data |
UploadResponse |
/summarize |
POST | Generate document summary | SummarizeRequest |
SummarizeResponse |
/ask |
POST | Answer question using RAG | ChatRequest |
ChatResponse |
/generate-quiz |
POST | Generate MCQ quiz | QuizRequest |
QuizResponse |
/documents |
GET | List all uploaded documents | None | List[DocumentInfo] |
/documents/{doc_id} |
DELETE | Delete document & data | doc_id path param |
DeleteResponse |
Purpose: Check if backend is running and models are loaded
Request:
GET /api/v1/healthResponse:
{
"status": "healthy",
"models_loaded": {
"embeddings": true,
"summarizer": true,
"quiz_generator": false
},
"device": "cuda",
"memory_usage": "5.2 GB"
}Implementation: backend/api/routes.py:26
@router.get("/health", response_model=HealthResponse)
async def health_check():
"""Check backend health and model loading status"""
models_loaded = {
"embeddings": embedding_model is not None,
"summarizer": phi3_summarizer is not None,
"quiz_generator": quiz_gen is not None
}
return HealthResponse(
status="healthy",
models_loaded=models_loaded,
device=str(settings.device)
)Why it matters: Frontend uses this to show model loading status in the sidebar
Purpose: Upload PDF, extract text, chunk it, generate embeddings, create vector index
Request:
POST /api/v1/upload
Content-Type: multipart/form-data
file=@document.pdfResponse:
{
"message": "Document uploaded successfully",
"doc_id": "document_1732898765",
"file_name": "document.pdf",
"pages": 42,
"chunks": 156,
"metadata": {
"author": "John Doe",
"title": "Machine Learning Fundamentals"
}
}Implementation: backend/api/routes.py:45
Step-by-step workflow:
- Validate File
if not file.filename.endswith('.pdf'):
raise HTTPException(status_code=400, detail="Only PDF files allowed")- Generate Document ID
doc_id = f"{file.filename.rsplit('.', 1)[0]}_{int(time.time())}"- Save to Disk
file_path = settings.uploads_dir / f"{doc_id}.pdf"
with open(file_path, "wb") as f:
f.write(await file.read())- Process PDF
processor = PDFProcessor()
result = processor.process_pdf(str(file_path), doc_id)
# Returns: full_text, chunks, metadata, structure- Generate Embeddings
chunk_texts = [chunk["text"] for chunk in result["chunks"]]
embeddings = embedding_model.encode_documents(chunk_texts)- Create Vector Store
vector_store = VectorStore(embedding_dim=384)
metadata = [
{
"chunk_id": chunk["chunk_id"],
"text": chunk["text"],
"word_count": chunk["word_count"]
}
for chunk in result["chunks"]
]
vector_store.add_vectors(embeddings, metadata)
vector_store.save(settings.vectors_dir / doc_id)- Save Processed Data
processed_path = settings.processed_dir / f"{doc_id}.json"
with open(processed_path, "w") as f:
json.dump(result, f, indent=2)Key Files:
- PDF Processing:
backend/services/pdf_processor.py:45-120 - Vector Store:
backend/services/vector_store.py:15-150 - Embeddings:
backend/models/embeddings.py:25-65
Purpose: Generate summary of document in bullet, paragraph, or exam style
Request:
{
"doc_id": "document_1732898765",
"summary_style": "bullet",
"chunk_wise": false
}Response:
{
"summary": "• Machine learning is a subset of AI...\n• Key algorithms include...\n• Applications range from...",
"doc_id": "document_1732898765",
"style": "bullet",
"chunks_summarized": 156
}Implementation: backend/api/routes.py:120
Summary Styles:
bullet- Concise bullet pointsparagraph- Narrative summaryexam- Exam-focused key points
Workflow:
- Load Processed Document
processed_path = settings.processed_dir / f"{doc_id}.json"
with open(processed_path, "r") as f:
doc_data = json.load(f)- Choose Summarization Method
if chunk_wise:
# Summarize each chunk, then combine
chunk_summaries = phi3_summarizer.summarize_chunks(
chunks=doc_data["chunks"],
style=summary_style
)
final_summary = "\n\n".join(chunk_summaries)
else:
# Summarize full document
final_summary = phi3_summarizer.summarize(
text=doc_data["full_text"],
style=summary_style,
max_length=500
)- Return Summary
return SummarizeResponse(
summary=final_summary,
doc_id=doc_id,
style=summary_style,
chunks_summarized=len(doc_data["chunks"])
)Key Files:
- Summarization Model:
backend/models/phi3_summarizer.py:45-180 - Routes:
backend/api/routes.py:120-165
Purpose: Answer questions about the document using Retrieval-Augmented Generation
Request:
{
"doc_id": "document_1732898765",
"query": "What are the main types of machine learning?",
"include_sources": true
}Response:
{
"answer": "The main types of machine learning are supervised learning, unsupervised learning, and reinforcement learning. Supervised learning uses labeled data...",
"sources": [
{
"chunk_id": 12,
"text": "Machine learning can be categorized into...",
"relevance_score": 0.89
}
],
"doc_id": "document_1732898765"
}Implementation: backend/api/routes.py:180
RAG Pipeline Steps:
- Load Vector Store
vector_store_path = settings.vectors_dir / doc_id
doc_vector_store = VectorStore(embedding_dim=384)
doc_vector_store.load(vector_store_path)- Initialize RAG Pipeline
rag = RAGPipeline(
vector_store=doc_vector_store,
embedding_model=embedding_model,
llm=phi3_summarizer # Used for generation
)- Retrieve Relevant Chunks
# Inside RAGPipeline.answer()
query_embedding = self.embedding_model.encode_query(query)
similar_chunks = self.vector_store.search(
query_vector=query_embedding,
k=5 # Top 5 most relevant chunks
)- Build Context
context = "\n\n".join([
f"[Chunk {meta['chunk_id']}]: {meta['text']}"
for vec, meta in similar_chunks
])- Generate Answer
prompt = f"""Based on the following context, answer the question.
Context:
{context}
Question: {query}
Answer:"""
answer = self.llm.generate(prompt, max_length=300)- Return with Sources
return ChatResponse(
answer=answer,
sources=[
{
"chunk_id": meta["chunk_id"],
"text": meta["text"],
"relevance_score": float(distance)
}
for vec, meta in similar_chunks
] if include_sources else None,
doc_id=doc_id
)Key Files:
- RAG Pipeline:
backend/services/rag_pipeline.py:30-180 - Vector Search:
backend/services/vector_store.py:75-120
Purpose: Generate multiple-choice questions from the document
Request:
{
"doc_id": "document_1732898765",
"num_questions": 10,
"questions_per_chunk": 2
}Response:
{
"questions": [
{
"question": "What is the primary goal of supervised learning?",
"options": [
"To classify labeled data",
"To find patterns in unlabeled data",
"To maximize rewards through actions",
"To compress data dimensions"
],
"correct_answer": 0,
"explanation": "Supervised learning uses labeled data to train models..."
}
],
"doc_id": "document_1732898765",
"total_questions": 10
}Implementation: backend/api/routes.py:235
Quiz Generation Workflow:
- Load Document Chunks
processed_path = settings.processed_dir / f"{doc_id}.json"
with open(processed_path, "r") as f:
doc_data = json.load(f)
chunks = doc_data["chunks"]- Smart Chunk Sampling
# Don't process all chunks - sample intelligently
num_chunks_to_use = min(
len(chunks),
(num_questions // questions_per_chunk) + 2
)
sampled_chunks = random.sample(chunks, num_chunks_to_use)- Generate Questions per Chunk
all_questions = []
for chunk in sampled_chunks:
questions = quiz_gen.generate_quiz(
text=chunk["text"],
num_questions=questions_per_chunk
)
all_questions.extend(questions)- Clean and Validate
cleaned_questions = []
for q in all_questions:
cleaned = QuizCleaner.clean_question(q)
if QuizCleaner.validate_question(cleaned):
cleaned_questions.append(cleaned)- Limit to Requested Number
final_questions = cleaned_questions[:num_questions]Key Files:
- Quiz Generator:
backend/models/t5_quiz_generator.py:45-250 - Quiz Cleaner:
backend/utils/quiz_cleaner.py:15-100
Purpose: Get list of all uploaded documents with metadata
Request:
GET /api/v1/documentsResponse:
[
{
"doc_id": "document_1732898765",
"file_name": "ml_fundamentals.pdf",
"pages": 42,
"chunks": 156,
"upload_date": "2024-11-29T10:32:45"
},
{
"doc_id": "another_doc_1732900000",
"file_name": "deep_learning.pdf",
"pages": 58,
"chunks": 203,
"upload_date": "2024-11-29T11:00:00"
}
]Implementation: backend/api/routes.py:295
@router.get("/documents", response_model=List[DocumentInfo])
async def list_documents():
"""List all uploaded documents"""
documents = []
# Scan processed directory
for file_path in settings.processed_dir.glob("*.json"):
with open(file_path, "r") as f:
doc_data = json.load(f)
documents.append(DocumentInfo(
doc_id=doc_data["doc_id"],
file_name=doc_data["metadata"]["file_name"],
pages=doc_data["metadata"]["total_pages"],
chunks=doc_data["total_chunks"],
upload_date=file_path.stat().st_mtime
))
return documentsPurpose: Remove document and all associated data
Request:
DELETE /api/v1/documents/document_1732898765Response:
{
"message": "Document deleted successfully",
"doc_id": "document_1732898765"
}Implementation: backend/api/routes.py:330
Deletion Process:
- Remove Original PDF
pdf_path = settings.uploads_dir / f"{doc_id}.pdf"
if pdf_path.exists():
pdf_path.unlink()- Remove Vector Index
vector_index = settings.vectors_dir / f"{doc_id}.index"
vector_meta = settings.vectors_dir / f"{doc_id}.meta"
if vector_index.exists():
vector_index.unlink()
if vector_meta.exists():
vector_meta.unlink()- Remove Processed Data
processed_path = settings.processed_dir / f"{doc_id}.json"
if processed_path.exists():
processed_path.unlink()- Clear from Memory
if doc_id in loaded_vector_stores:
del loaded_vector_stores[doc_id]Model: BAAI/bge-small-en-v1.5
- Parameters: 33M
- Embedding Dimension: 384
- Purpose: Convert text to semantic vectors for similarity search
Key Methods:
class EmbeddingModel:
def __init__(self, model_name: str, device: str):
self.model = SentenceTransformer(model_name, device=device)
self.embedding_dim = 384
def encode(self, texts: Union[str, List[str]]) -> np.ndarray:
"""General encoding - handles both single and batch"""
return self.model.encode(
texts,
normalize_embeddings=True, # L2 normalization
show_progress_bar=True
)
def encode_query(self, query: str) -> np.ndarray:
"""Optimized for search queries"""
return self.encode(query)
def encode_documents(self, documents: List[str]) -> np.ndarray:
"""Batch encoding for document chunks"""
return self.encode(documents)Why this model?
- Small and fast (33M params)
- Optimized for English semantic search
- High quality embeddings in 384 dimensions
- Runs efficiently on CPU or GPU
Usage in Pipeline:
# During upload
chunk_texts = ["chunk 1 text...", "chunk 2 text..."]
embeddings = embedding_model.encode_documents(chunk_texts)
# Shape: (num_chunks, 384)
# During search
query = "What is machine learning?"
query_emb = embedding_model.encode_query(query)
# Shape: (384,)Model: RandipR/pegasus-560m-academic-sum
- Parameters: 560M
- Base: Google PEGASUS
- Fine-tuned: Academic papers and textbooks
- Architecture: Seq2Seq (encoder-decoder)
Key Features:
class Phi3Summarizer:
def __init__(self, model_name: str, device: str):
# Load model and tokenizer
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
torch_dtype=torch.float16 # Memory optimization
).to(device)
self.max_input_length = 1024
self.max_output_length = 256Method 1: Full Document Summary
def summarize(
self,
text: str,
style: str = "paragraph",
max_length: int = 150
) -> str:
"""Summarize entire document at once"""
# Clean text
text = self._clean_text(text)
# Add style instruction
if style == "bullet":
text = "Summarize in bullet points:\n" + text
elif style == "exam":
text = "Summarize key exam points:\n" + text
# Tokenize
inputs = self.tokenizer(
text,
max_length=self.max_input_length,
truncation=True,
return_tensors="pt"
).to(self.device)
# Generate
outputs = self.model.generate(
**inputs,
max_length=max_length,
num_beams=4,
early_stopping=True,
no_repeat_ngram_size=3
)
# Decode
summary = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
return self._clean_artifacts(summary)Method 2: Chunk-wise Summary
def summarize_chunks(
self,
chunks: List[Dict],
style: str = "bullet",
combine: bool = True
) -> Union[str, List[str]]:
"""Summarize each chunk separately, optionally combine"""
chunk_summaries = []
for i, chunk in enumerate(chunks):
# Summarize individual chunk
summary = self.summarize(
text=chunk["text"],
style=style,
max_length=100
)
chunk_summaries.append(summary)
# Clear GPU memory periodically
if i % 10 == 0:
torch.cuda.empty_cache()
if combine:
# Combine all chunk summaries
combined = "\n\n".join(chunk_summaries)
# Optionally: summarize the summaries
if len(combined) > 2000:
combined = self.summarize(
text=combined,
style=style,
max_length=300
)
return combined
else:
return chunk_summariesText Cleaning:
def _clean_text(self, text: str) -> str:
"""Remove noise and normalize"""
# Remove extra whitespace
text = re.sub(r'\s+', ' ', text)
# Remove special characters
text = re.sub(r'[^\w\s\.,!?-]', '', text)
# Remove URLs
text = re.sub(r'http\S+|www.\S+', '', text)
return text.strip()
def _clean_artifacts(self, text: str) -> str:
"""Remove model artifacts from output"""
# Remove incomplete sentences at end
text = re.sub(r'\s+[A-Z][a-z]*$', '', text)
# Fix spacing
text = re.sub(r'\s+([.,!?])', r'\1', text)
return text.strip()Summary Styles Implementation:
| Style | Prefix | Max Length | Output Format |
|---|---|---|---|
bullet |
"Summarize in bullet points:" | 200 | • Point 1\n• Point 2 |
paragraph |
None (default) | 150 | Narrative paragraph |
exam |
"Summarize key exam points:" | 180 | Key concepts for studying |
Why Pegasus?
- Specifically designed for abstractive summarization
- Pre-training uses gap-sentence generation
- Academic fine-tuning improves technical text handling
- Produces fluent, coherent summaries
Model: RandipR/Qwen2.5-0.5B-Instruct-MCQ-Generation
- Parameters: 500M
- Base: Qwen 2.5 (Alibaba)
- Fine-tuned: MCQ generation task
- Architecture: Decoder-only (Causal LM)
Model Loading with PEFT:
class T5QuizGenerator:
def __init__(self, model_name: str, device: str, use_4bit: bool = True):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
if use_4bit:
# 4-bit quantization for memory efficiency
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16
)
self.model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto"
)
else:
self.model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16
).to(device)
# Check for PEFT adapter
if hasattr(self.model, 'peft_config'):
print("PEFT adapter detected")Quiz Generation Process:
def generate_quiz(
self,
text: str,
num_questions: int = 5,
difficulty: str = "medium"
) -> List[MCQuestion]:
"""Generate MCQs from text chunk"""
# Build prompt
prompt = self._build_prompt(text, num_questions, difficulty)
# Tokenize
inputs = self.tokenizer(
prompt,
max_length=1024,
truncation=True,
return_tensors="pt"
).to(self.device)
# Generate
outputs = self.model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
top_p=0.9,
do_sample=True,
num_return_sequences=1
)
# Decode
generated = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
# Parse MCQs from output
questions = self._parse_mcqs(generated)
return questions[:num_questions]Prompt Engineering:
def _build_prompt(self, text: str, num_questions: int, difficulty: str) -> str:
"""Craft effective prompt for MCQ generation"""
prompt = f"""You are an expert quiz creator. Generate {num_questions} multiple-choice questions from the following text.
Requirements:
- Difficulty: {difficulty}
- 4 options per question (A, B, C, D)
- Only one correct answer
- Distractors should be plausible but incorrect
- Questions should test understanding, not memorization
Text:
{text}
Generate questions in this format:
Question 1: [Question text]
A) [Option A]
B) [Option B]
C) [Option C]
D) [Option D]
Correct Answer: [A/B/C/D]
Explanation: [Why this is correct]
Begin:
"""
return promptMCQ Parsing:
def _parse_mcqs(self, generated_text: str) -> List[MCQuestion]:
"""Extract structured MCQs from model output"""
questions = []
# Regex pattern for MCQ format
pattern = r"Question \d+:\s*(.+?)\n[A-D]\)\s*(.+?)\n[A-D]\)\s*(.+?)\n[A-D]\)\s*(.+?)\n[A-D]\)\s*(.+?)\nCorrect Answer:\s*([A-D])"
matches = re.finditer(pattern, generated_text, re.DOTALL)
for match in matches:
question_text = match.group(1).strip()
options = [
match.group(2).strip(),
match.group(3).strip(),
match.group(4).strip(),
match.group(5).strip()
]
correct = ord(match.group(6)) - ord('A') # Convert A-D to 0-3
questions.append(MCQuestion(
question=question_text,
options=options,
correct_answer=correct,
explanation=match.group(7).strip() if len(match.groups()) > 6 else ""
))
return questionsSmart Chunk Sampling:
def generate_quiz_from_document(
self,
chunks: List[Dict],
total_questions: int = 10,
questions_per_chunk: int = 2
) -> List[MCQuestion]:
"""Generate quiz by sampling chunks intelligently"""
# Don't process all chunks - sample based on need
num_chunks_needed = (total_questions // questions_per_chunk) + 2
num_chunks_needed = min(num_chunks_needed, len(chunks))
# Sample diverse chunks (not just first N)
sampled_chunks = random.sample(chunks, num_chunks_needed)
all_questions = []
for chunk in sampled_chunks:
# Generate questions from chunk
chunk_questions = self.generate_quiz(
text=chunk["text"],
num_questions=questions_per_chunk
)
all_questions.extend(chunk_questions)
# Stop if we have enough
if len(all_questions) >= total_questions:
break
return all_questions[:total_questions]Why Qwen 2.5?
- Excellent instruction following
- Good at structured output generation
- Efficient at 0.5B parameters
- Fine-tuned specifically for MCQ creation
Purpose: Extract text from PDFs, chunk it, detect structure
Key Methods:
Method 1: Text Extraction
class PDFProcessor:
def extract_text_pymupdf(self, pdf_path: str) -> Tuple[str, Dict]:
"""Fast extraction using PyMuPDF"""
doc = fitz.open(pdf_path)
full_text = ""
metadata = {
"total_pages": len(doc),
"author": doc.metadata.get("author", "Unknown"),
"title": doc.metadata.get("title", "Untitled")
}
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
full_text += text + "\n"
doc.close()
return full_text, metadata
def extract_text_pdfplumber(self, pdf_path: str) -> str:
"""Fallback extraction using pdfplumber"""
full_text = ""
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
full_text += text + "\n"
return full_textMethod 2: Text Chunking
def chunk_text(
self,
text: str,
chunk_size: int = 250, # words
overlap: int = 50 # words
) -> List[Dict]:
"""Split text into overlapping chunks"""
# Clean text first
text = self._clean_text(text)
# Split into words
words = text.split()
chunks = []
chunk_id = 0
start = 0
while start < len(words):
# Get chunk
end = min(start + chunk_size, len(words))
chunk_words = words[start:end]
# Create chunk dict
chunk = {
"chunk_id": chunk_id,
"text": " ".join(chunk_words),
"word_count": len(chunk_words),
"start_word": start,
"end_word": end
}
chunks.append(chunk)
# Move to next chunk with overlap
start += (chunk_size - overlap)
chunk_id += 1
return chunksMethod 3: Structure Detection
def detect_structure(self, text: str) -> Dict:
"""Detect headers and sections"""
lines = text.split('\n')
headers = []
sections = []
current_section = None
section_text = []
for line in lines:
line = line.strip()
# Detect headers (all caps, or numbered)
if self._is_header(line):
if current_section:
sections.append({
"header": current_section,
"text": "\n".join(section_text)
})
section_text = []
current_section = line
headers.append(line)
else:
section_text.append(line)
# Add final section
if current_section:
sections.append({
"header": current_section,
"text": "\n".join(section_text)
})
return {
"headers": headers,
"sections": sections
}
def _is_header(self, line: str) -> bool:
"""Heuristic to detect if line is a header"""
if not line:
return False
# All caps (at least 50% uppercase)
if sum(c.isupper() for c in line) / len(line) > 0.5:
return True
# Numbered (e.g., "1. Introduction")
if re.match(r'^\d+\.?\s+[A-Z]', line):
return True
# Short and title case
if len(line.split()) <= 5 and line[0].isupper():
return True
return FalseFull Processing Pipeline:
def process_pdf(self, pdf_path: str, doc_id: str) -> Dict:
"""Complete PDF processing pipeline"""
try:
# 1. Extract text
full_text, metadata = self.extract_text_pymupdf(pdf_path)
except Exception as e:
print(f"PyMuPDF failed: {e}, trying pdfplumber")
full_text = self.extract_text_pdfplumber(pdf_path)
metadata = {"total_pages": 0, "author": "Unknown", "title": "Untitled"}
# 2. Clean text
full_text = self._clean_text(full_text)
# 3. Detect structure
structure = self.detect_structure(full_text)
# 4. Chunk text
chunks = self.chunk_text(full_text)
# 5. Compile result
result = {
"doc_id": doc_id,
"metadata": metadata,
"full_text": full_text,
"chunks": chunks,
"total_chunks": len(chunks),
"structure": structure
}
return resultConfiguration:
- Default chunk size: 250 words (~500 tokens)
- Default overlap: 50 words (20%)
- Min chunk size: 150 words
- Max chunk size: 300 words
Purpose: FAISS-based semantic search with metadata
Classes:
Class 1: Single Document Vector Store
class VectorStore:
def __init__(self, embedding_dim: int = 384):
self.embedding_dim = embedding_dim
self.index = None
self.metadata = []
def add_vectors(
self,
vectors: np.ndarray,
metadata: List[Dict]
):
"""Add vectors to index"""
# Create index if doesn't exist
if self.index is None:
self.index = faiss.IndexFlatL2(self.embedding_dim)
# Ensure correct shape
if len(vectors.shape) == 1:
vectors = vectors.reshape(1, -1)
# Add to FAISS
self.index.add(vectors.astype('float32'))
# Store metadata
self.metadata.extend(metadata)
def search(
self,
query_vector: np.ndarray,
k: int = 5
) -> List[Tuple[np.ndarray, Dict]]:
"""Search for top-k similar vectors"""
if self.index is None:
return []
# Ensure query shape
if len(query_vector.shape) == 1:
query_vector = query_vector.reshape(1, -1)
# Search
distances, indices = self.index.search(
query_vector.astype('float32'),
k
)
# Combine with metadata
results = []
for dist, idx in zip(distances[0], indices[0]):
if idx < len(self.metadata):
results.append((
dist, # L2 distance (lower = more similar)
self.metadata[idx]
))
return results
def save(self, path: Path):
"""Save index and metadata to disk"""
# Save FAISS index
faiss.write_index(self.index, str(path) + ".index")
# Save metadata as pickle
with open(str(path) + ".meta", "wb") as f:
pickle.dump(self.metadata, f)
def load(self, path: Path):
"""Load index and metadata from disk"""
# Load FAISS index
self.index = faiss.read_index(str(path) + ".index")
# Load metadata
with open(str(path) + ".meta", "rb") as f:
self.metadata = pickle.load(f)Class 2: Multi-Document Manager
class DocumentVectorStore:
def __init__(self):
self.stores: Dict[str, VectorStore] = {}
def add_document(
self,
doc_id: str,
vectors: np.ndarray,
metadata: List[Dict]
):
"""Add new document"""
store = VectorStore()
store.add_vectors(vectors, metadata)
self.stores[doc_id] = store
def search_document(
self,
doc_id: str,
query_vector: np.ndarray,
k: int = 5
) -> List[Tuple[np.ndarray, Dict]]:
"""Search within specific document"""
if doc_id not in self.stores:
raise ValueError(f"Document {doc_id} not found")
return self.stores[doc_id].search(query_vector, k)
def save_all(self, base_path: Path):
"""Save all document stores"""
for doc_id, store in self.stores.items():
store.save(base_path / doc_id)
def load_all(self, base_path: Path):
"""Load all document stores from directory"""
for index_file in base_path.glob("*.index"):
doc_id = index_file.stem
store = VectorStore()
store.load(base_path / doc_id)
self.stores[doc_id] = storeWhy FAISS?
- Fast similarity search (millions of vectors)
- CPU-based (no GPU needed for search)
- L2 distance metric (Euclidean)
- Compact index files
Purpose: Retrieval-Augmented Generation for Q&A
Complete Implementation:
class RAGPipeline:
def __init__(
self,
vector_store: VectorStore,
embedding_model: EmbeddingModel,
llm: Phi3Summarizer # Reuse summarizer for generation
):
self.vector_store = vector_store
self.embedding_model = embedding_model
self.llm = llm
def answer(
self,
query: str,
k: int = 5,
include_sources: bool = False
) -> Dict:
"""Answer question using RAG"""
# 1. Encode query
query_embedding = self.embedding_model.encode_query(query)
# 2. Retrieve similar chunks
results = self.vector_store.search(query_embedding, k=k)
if not results:
return {
"answer": "No relevant information found.",
"sources": []
}
# 3. Build context from retrieved chunks
context_parts = []
sources = []
for dist, meta in results:
context_parts.append(f"[Chunk {meta['chunk_id']}]: {meta['text']}")
if include_sources:
sources.append({
"chunk_id": meta["chunk_id"],
"text": meta["text"],
"relevance_score": float(dist)
})
context = "\n\n".join(context_parts)
# 4. Build prompt
prompt = self._build_qa_prompt(query, context)
# 5. Generate answer
answer = self.llm.generate(
prompt,
max_length=300,
temperature=0.7
)
return {
"answer": answer,
"sources": sources if include_sources else None
}
def _build_qa_prompt(self, query: str, context: str) -> str:
"""Construct RAG prompt"""
prompt = f"""You are a helpful AI tutor. Answer the question based on the provided context. If the context doesn't contain enough information, say so.
Context:
{context}
Question: {query}
Answer (be concise and accurate):"""
return promptRAG Flow Diagram:
User Query: "What is supervised learning?"
↓
Encode Query → [0.23, -0.45, 0.67, ...] (384-dim vector)
↓
Search FAISS Index
↓
Top-5 Similar Chunks:
1. Chunk 12: "Supervised learning uses labeled data..." (distance: 0.15)
2. Chunk 15: "Examples include classification..." (distance: 0.22)
3. Chunk 8: "Training requires input-output pairs..." (distance: 0.28)
4. Chunk 20: "Common algorithms are SVM, Random Forest..." (distance: 0.31)
5. Chunk 3: "Machine learning has three types..." (distance: 0.35)
↓
Build Context:
"[Chunk 12]: Supervised learning uses labeled data...
[Chunk 15]: Examples include classification...
..."
↓
Build Prompt:
"Context: [...]
Question: What is supervised learning?
Answer:"
↓
Generate with Phi-3:
"Supervised learning is a type of machine learning that uses labeled
data to train models. It involves learning from input-output pairs..."
↓
Return Answer + Sources
Why RAG?
- Grounds answers in document content
- Reduces hallucination
- Provides source attribution
- Works with any document without fine-tuning
┌─────────────────────────────────────────────────────────────┐
│ 1. Client Upload │
│ POST /upload with PDF file (multipart/form-data) │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Validate & Save PDF │
│ - Check .pdf extension │
│ - Generate doc_id (filename_timestamp) │
│ - Save to /data/uploads/{doc_id}.pdf │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Extract Text (PDFProcessor) │
│ - Try PyMuPDF first (fast) │
│ - Fallback to pdfplumber if needed │
│ - Extract metadata (pages, author, title) │
│ Result: full_text string │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Clean & Structure │
│ - Remove extra whitespace │
│ - Normalize special characters │
│ - Detect headers and sections │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. Chunk Text │
│ - Split into 250-word chunks │
│ - 50-word overlap between chunks │
│ - Preserve sentence boundaries │
│ Result: List of {chunk_id, text, word_count} │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Generate Embeddings │
│ - Extract chunk texts │
│ - Batch encode with BAAI/bge-small │
│ - Normalize L2 distance │
│ Result: numpy array (num_chunks, 384) │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Create Vector Index │
│ - Initialize FAISS IndexFlatL2 │
│ - Add embeddings to index │
│ - Store metadata (chunk_id, text, word_count) │
│ - Save to /data/vectors/{doc_id}.index │
│ - Save metadata to {doc_id}.meta (pickle) │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 8. Save Processed Document │
│ - Compile: doc_id, metadata, full_text, chunks, structure │
│ - Save to /data/processed/{doc_id}.json │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 9. Return Response │
│ { │
│ "message": "Document uploaded successfully", │
│ "doc_id": "document_1732898765", │
│ "file_name": "ml_fundamentals.pdf", │
│ "pages": 42, │
│ "chunks": 156 │
│ } │
└─────────────────────────────────────────────────────────────┘
Time Estimate: ~10-30 seconds for typical document
from pydantic_settings import BaseSettings
from pathlib import Path
import torch
class Settings(BaseSettings):
# Paths
base_dir: Path = Path(__file__).parent.parent.parent
data_dir: Path = base_dir / "data"
uploads_dir: Path = data_dir / "uploads"
vectors_dir: Path = data_dir / "vectors"
processed_dir: Path = data_dir / "processed"
models_dir: Path = base_dir / "models"
# Model names
embedding_model: str = "BAAI/bge-small-en-v1.5"
summarizer_model: str = "RandipR/pegasus-560m-academic-sum"
quiz_model: str = "RandipR/Qwen2.5-0.5B-Instruct-MCQ-Generation"
# Device
device: str = "cuda" if torch.cuda.is_available() else "cpu"
# Chunking
chunk_size: int = 250 # words
chunk_overlap: int = 50 # words
min_chunk_size: int = 150
max_chunk_size: int = 300
# Vector search
default_k: int = 5 # Top-k retrieval
# Memory optimization
use_4bit: bool = True
clear_cache_freq: int = 10 # Every N operations
# API
cors_origins: list = ["*"]
max_upload_size: int = 50 * 1024 * 1024 # 50MB
class Config:
env_file = ".env"
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Create directories
self.uploads_dir.mkdir(parents=True, exist_ok=True)
self.vectors_dir.mkdir(parents=True, exist_ok=True)
self.processed_dir.mkdir(parents=True, exist_ok=True)
settings = Settings()How it works:
- Model Storage: All models downloaded to
~/models/during first use - HuggingFace Cache: Transformers library caches models locally
- No API Calls: Zero external dependencies during runtime
- Local Processing: All computation on user's machine
Implementation:
# Models check local cache first
model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
cache_dir=settings.models_dir, # Local cache
local_files_only=False # Download if not cached
)
# After first download, works offline
model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
cache_dir=settings.models_dir,
local_files_only=True # Offline mode
)Techniques Used:
- 4-bit Quantization
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat4
bnb_4bit_compute_dtype=torch.float16
)
# Reduces memory by 75% (16-bit → 4-bit)- FP16 Precision
model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
torch_dtype=torch.float16 # Half precision
)
# Reduces memory by 50% vs FP32- Cache Clearing
if batch_idx % 10 == 0:
torch.cuda.empty_cache()- Lazy Loading
# Models only load on first use
@lru_cache(maxsize=1)
def get_embedding_model():
return EmbeddingModel(settings.embedding_model, settings.device)Memory Usage:
- Embeddings (BAAI): ~130 MB
- Summarizer (Pegasus): ~1.2 GB (4-bit) or ~2.4 GB (FP16)
- Quiz Generator (Qwen): ~500 MB (4-bit) or ~1 GB (FP16)
- FAISS Index: ~1-5 MB per document
- Total: ~5-6 GB GPU / ~8-10 GB system RAM
Patterns Used:
- Try-Except with Fallbacks
try:
text, metadata = self.extract_text_pymupdf(pdf_path)
except Exception as e:
logger.warning(f"PyMuPDF failed: {e}")
text = self.extract_text_pdfplumber(pdf_path)
metadata = self._default_metadata()- HTTPException for API Errors
if not file.filename.endswith('.pdf'):
raise HTTPException(
status_code=400,
detail="Only PDF files are allowed"
)- Validation with Pydantic
class SummarizeRequest(BaseModel):
doc_id: str
summary_style: Literal["bullet", "paragraph", "exam"] = "paragraph"
chunk_wise: bool = False
@validator('doc_id')
def doc_id_not_empty(cls, v):
if not v.strip():
raise ValueError("doc_id cannot be empty")
return vUser asks: "What are the applications of machine learning?"
Step 1: Encode query
# In backend/models/embeddings.py
query = "What are the applications of machine learning?"
query_embedding = embedding_model.encode_query(query)
# Shape: (384,)
# Values: [0.234, -0.456, 0.789, ...]Step 2: Search vector store
# In backend/services/vector_store.py
results = vector_store.search(query_embedding, k=5)
# Returns: [(distance, metadata), ...]
# [
# (0.15, {"chunk_id": 42, "text": "ML applications include..."}),
# (0.22, {"chunk_id": 38, "text": "Common uses are..."}),
# ...
# ]Step 3: Build context
# In backend/services/rag_pipeline.py
context_parts = []
for dist, meta in results:
context_parts.append(f"[Chunk {meta['chunk_id']}]: {meta['text']}")
context = "\n\n".join(context_parts)
# Context is now:
# "[Chunk 42]: ML applications include healthcare, finance...
# [Chunk 38]: Common uses are image recognition...
# ..."Step 4: Create prompt
prompt = f"""You are a helpful AI tutor. Answer the question based on the provided context.
Context:
{context}
Question: {query}
Answer (be concise and accurate):"""Step 5: Generate answer
# In backend/models/phi3_summarizer.py
inputs = tokenizer(prompt, return_tensors="pt", max_length=1024, truncation=True)
outputs = model.generate(
**inputs,
max_length=300,
num_beams=4,
temperature=0.7
)
answer = tokenizer.decode(outputs[0], skip_special_tokens=True)
# Answer: "Machine learning has numerous applications including healthcare (disease diagnosis),
# finance (fraud detection), image recognition, natural language processing..."Step 6: Return response
return ChatResponse(
answer=answer,
sources=[
{"chunk_id": 42, "text": "ML applications include...", "relevance_score": 0.15},
{"chunk_id": 38, "text": "Common uses are...", "relevance_score": 0.22}
],
doc_id=doc_id
)User requests: "Summarize this document in bullet points"
Chunk-wise Approach:
Step 1: Load document
with open(f"/data/processed/{doc_id}.json", "r") as f:
doc_data = json.load(f)
chunks = doc_data["chunks"] # 156 chunksStep 2: Summarize each chunk
chunk_summaries = []
for chunk in chunks:
# Add style instruction
text = f"Summarize in bullet points:\n{chunk['text']}"
# Tokenize
inputs = tokenizer(text, max_length=1024, truncation=True, return_tensors="pt")
# Generate summary
outputs = model.generate(
**inputs,
max_length=100,
num_beams=4,
early_stopping=True
)
summary = tokenizer.decode(outputs[0], skip_special_tokens=True)
chunk_summaries.append(summary)Step 3: Combine summaries
combined = "\n\n".join(chunk_summaries)
# Too long? Summarize the summaries
if len(combined.split()) > 500:
final_summary = phi3_summarizer.summarize(
text=combined,
style="bullet",
max_length=300
)
else:
final_summary = combinedStep 4: Format as bullets
# Model output might be:
# "Machine learning is a subset of AI. It has many applications. ..."
# Post-process to bullets:
lines = final_summary.split('. ')
bullets = [f"• {line.strip()}" for line in lines if line.strip()]
final_summary = "\n".join(bullets)
# Final output:
# • Machine learning is a subset of AI
# • It has many applications in healthcare and finance
# • Common algorithms include decision trees and neural networksStep 1: Sample chunks
# Don't process all 156 chunks - sample 10
num_chunks = (10 questions // 2 per chunk) + 2 = 7
sampled = random.sample(chunks, 7)Step 2: Generate questions per chunk
all_questions = []
for chunk in sampled:
# Build prompt
prompt = f"""Generate 2 multiple-choice questions from this text:
{chunk['text']}
Format:
Question 1: [text]
A) [option]
B) [option]
C) [option]
D) [option]
Correct Answer: A
Begin:"""
# Generate
inputs = tokenizer(prompt, return_tensors="pt", max_length=1024, truncation=True)
outputs = model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
do_sample=True
)
generated = tokenizer.decode(outputs[0], skip_special_tokens=True)
# Parse MCQs
questions = parse_mcqs(generated) # Extracts structured questions
all_questions.extend(questions)Step 3: Clean and validate
cleaned = []
for q in all_questions:
# Remove artifacts
q_clean = QuizCleaner.clean_question(q)
# Validate
if (
len(q_clean['options']) == 4 and
0 <= q_clean['correct_answer'] < 4 and
len(q_clean['question']) > 10
):
cleaned.append(q_clean)
# Limit to requested number
final = cleaned[:10]The DocuMentor backend is a sophisticated, privacy-focused AI system that:
- Processes PDFs - Extracts, chunks, and indexes documents
- Generates Embeddings - Creates semantic vectors for search
- Provides RAG Q&A - Answers questions grounded in document content
- Summarizes Documents - Creates bullet, paragraph, or exam-style summaries
- Generates Quizzes - Automatically creates MCQs from content
All running 100% offline with efficient memory usage and robust error handling.
Key Technologies:
- FastAPI for REST API
- PyTorch + Transformers for ML
- FAISS for vector search
- Specialized fine-tuned models for academic text
Performance:
- Upload: 10-30 seconds
- Summarize: 5-15 seconds
- Q&A: 2-5 seconds
- Quiz: 15-30 seconds
Memory:
- 5-6 GB GPU recommended
- Can run on CPU (slower)
- 4-bit quantization reduces memory by 75%