From a459ccc22ba2ae9166bdf35694003eba5c879aba Mon Sep 17 00:00:00 2001 From: Eldho Thomas Date: Tue, 1 Sep 2026 09:17:53 -0400 Subject: [PATCH 1/6] fix pdf loading and search --- .env.example | 5 + app.py | 127 ++++++++++-- rag_pipeline.py | 519 +++++++++++++++++++++++++++++------------------ requirements.txt | 219 +------------------- 4 files changed, 437 insertions(+), 433 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a28f25 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +MODEL_NAME=Qwen/Qwen2.5-0.5B-Instruct +EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 +HOST=127.0.0.1 +PORT=8888 +LOG_LEVEL=INFO diff --git a/app.py b/app.py index 61e97bb..de1bc7d 100644 --- a/app.py +++ b/app.py @@ -1,24 +1,109 @@ -from flask import Flask, render_template, request -from rag_pipeline import get_rag_instance - -app = Flask(__name__) -rag = get_rag_instance() -rag.load_documents_from_folder("docs") #folder with PDFs -#rag.chunk_documents() -rag.embed_chunks() - -@app.route("/", methods=["GET", "POST"]) -def index(): - answer = "" - summary = "" - if request.method == "POST": - if request.form.get("query"): - query = request.form["query"] - answer = rag.generate_answer(query) - if request.form.get("summarize"): +"""Flask interface for the local PDF RAG assistant.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable + +from flask import Flask, flash, redirect, render_template, request, url_for +from werkzeug.utils import secure_filename + +from rag_pipeline import Answer, RAGError, RAGSystem, get_rag_instance + +ALLOWED_EXTENSIONS = {"pdf"} +LOGGER = logging.getLogger(__name__) + + +def _is_pdf(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + +def create_app(rag_provider: Callable[[], RAGSystem] | None = None) -> Flask: + """Create the web application with an injectable RAG service.""" + app = Flask(__name__) + app.config.update( + MAX_CONTENT_LENGTH=20 * 1024 * 1024, + SECRET_KEY=os.getenv("FLASK_SECRET_KEY", "local-development-only"), + ) + provider = rag_provider or get_rag_instance + + @app.get("/") + def index(): + rag = provider() + return render_template("index.html", rag=rag) + + @app.post("/documents") + def upload_documents(): + rag = provider() + files = [file for file in request.files.getlist("documents") if file.filename] + if not files: + flash("Choose at least one PDF.", "error") + return redirect(url_for("index")) + + invalid = [file.filename for file in files if not _is_pdf(file.filename)] + if invalid: + flash("Only PDF files are supported.", "error") + return redirect(url_for("index")) + + rag.clear() + try: + for file in files: + filename = secure_filename(file.filename) or "document.pdf" + rag.add_pdf(file.stream, filename) + rag.embed_chunks() + except RAGError as exc: + rag.clear() + flash(str(exc), "error") + else: + flash( + f"Indexed {rag.document_count} document(s), {rag.page_count} page(s), " + f"and {rag.chunk_count} chunk(s).", + "success", + ) + return redirect(url_for("index")) + + @app.post("/ask") + def ask(): + rag = provider() + question = request.form.get("query", "").strip() + try: + answer: Answer = rag.generate_answer(question) + except RAGError as exc: + flash(str(exc), "error") + return render_template("index.html", rag=rag, query=question), 400 + return render_template( + "index.html", + rag=rag, + query=question, + answer=answer.text, + sources=answer.sources, + ) + + @app.post("/summarize") + def summarize(): + rag = provider() + try: summary = rag.summarize_documents() - return render_template("index.html", answer=answer, summary=summary) + except RAGError as exc: + flash(str(exc), "error") + return render_template("index.html", rag=rag), 400 + return render_template("index.html", rag=rag, summary=summary) + + @app.errorhandler(413) + def upload_too_large(_error): + flash("Upload is too large. The combined limit is 20 MB.", "error") + return redirect(url_for("index")) + + return app -if __name__ == "__main__": - app.run(debug=False,port=8888) +app = create_app() + +if __name__ == "__main__": + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + app.run( + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", "8888")), + debug=os.getenv("FLASK_DEBUG", "").lower() == "true", + ) diff --git a/rag_pipeline.py b/rag_pipeline.py index e9c4afc..9f1712e 100644 --- a/rag_pipeline.py +++ b/rag_pipeline.py @@ -1,201 +1,318 @@ -import os -import torch -import faiss -from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig -from sentence_transformers import SentenceTransformer -from sklearn.metrics.pairwise import cosine_similarity -from PyPDF2 import PdfReader - -class Document: - def __init__(self, content, title=None): - self.content = content - self.title = title or "Untitled" - - -class RAGSystem: - - - def __init__(self): - self.documents = [] - self.chunks = [] - self.embeddings = [] - self.index = None - - print("Loading SentenceTransformer for embeddings...") - self.embedding_model = SentenceTransformer("all-MiniLM-L6-v2") - - print("Loading tokenizer...") - self.tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-1.7B", trust_remote_code=True) - - print("Loading Qwen model WITHOUT quantization (CPU fallback)...") - try: - if torch.cuda.is_available(): - print("CUDA is available. Loading model on GPU with float16...") - self.model = AutoModelForCausalLM.from_pretrained( - "Qwen/Qwen3-1.7B", - device_map="auto", - torch_dtype="auto", - trust_remote_code=True, - ) - else: - print("CUDA not available. Loading model on CPU...") - self.model = AutoModelForCausalLM.from_pretrained( - "Qwen/Qwen-7B-Chat", - trust_remote_code=True - ) - except Exception as e: - print("Failed to load the Qwen model. Falling back to a smaller model like GPT2.") - from transformers import GPT2Tokenizer, GPT2LMHeadModel - self.tokenizer = GPT2Tokenizer.from_pretrained("gpt2") - self.model = GPT2LMHeadModel.from_pretrained("gpt2") - - print("Model and tokenizer successfully loaded.") - - - - def __init__(self): - self.documents = [] - self.chunks = [] - self.embeddings = [] - self.index = None - - print("Loading SentenceTransformer for embeddings...") - self.embedding_model = SentenceTransformer("all-MiniLM-L6-v2") - - # Set model name once and reuse - model_name = "Qwen/Qwen3-1.7B" - - print(f"Loading tokenizer for {model_name}...") - self.tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) - - print(f"Loading {model_name} model...") - try: - if torch.cuda.is_available(): - print("CUDA is available. Loading model on GPU with float16...") - self.model = AutoModelForCausalLM.from_pretrained( - model_name, - device_map="auto", - torch_dtype=torch.float16, - trust_remote_code=True, - ) - else: - print("CUDA not available. Loading model on CPU (may be slower)...") - self.model = AutoModelForCausalLM.from_pretrained( - model_name, - trust_remote_code=True - ) - except Exception as e: - print(f"Failed to load {model_name} due to: {e}") - print("Falling back to GPT-2...") - from transformers import GPT2Tokenizer, GPT2LMHeadModel - self.tokenizer = GPT2Tokenizer.from_pretrained("gpt2") - self.model = GPT2LMHeadModel.from_pretrained("gpt2") - - print("Model and tokenizer successfully loaded.") - - def load_documents_from_folder(self, folder_path): - for filename in os.listdir(folder_path): - if filename.endswith(".pdf"): - path = os.path.join(folder_path, filename) - with open(path, "rb") as f: - reader = PdfReader(f) - text = "" - for page in reader.pages: - text += page.extract_text() or "" - self.documents.append(Document(text, title=filename)) - - def load_pdf(self, filepath): - """Load PDF and return text.""" - reader = PdfReader(filepath) - text = "" - for page in reader.pages: - text += page.extract_text() or "" - return text - - - def add_document(self, filepath, title=None): - """Load and store document.""" - text = self.load_pdf(filepath) - doc = Document(text, title or os.path.basename(filepath)) - self.documents.append(doc) - - - def split_text(self, text, chunk_size=300): - """Split text into fixed-size chunks.""" - return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] - - - def embed_chunks(self): - """Chunk all documents and embed them.""" - self.chunks = [] - for doc in self.documents: - chunks = self.split_text(doc.content) - for chunk in chunks: - self.chunks.append((chunk, doc.title)) - - texts = [chunk[0] for chunk in self.chunks] - embeddings = self.embedding_model.encode(texts, convert_to_tensor=True).cpu().numpy() - - dim = embeddings.shape[1] - self.index = faiss.IndexFlatL2(dim) - self.index.add(embeddings) - - - def retrieve(self, query, top_k=5): - """Retrieve top-k chunks relevant to the query.""" - query_embedding = self.embedding_model.encode([query], convert_to_tensor=True).cpu().numpy() - D, I = self.index.search(query_embedding, top_k) - results = [self.chunks[i][0] for i in I[0]] - return results - - - def summarize_documents(self): - """Summarize all loaded documents.""" - summaries = [] - for doc in self.documents: - prompt = f"Summarize the following document:\n{doc.content}\nSummary:" - input_ids = self.tokenizer(prompt, return_tensors="pt", truncation=True, max_length=2048).input_ids.to(self.model.device) - - with torch.no_grad(): - output = self.model.generate( - input_ids, - max_new_tokens=1000, - do_sample=False, - temperature=0.7, - top_k=50 - ) - - summary = self.tokenizer.decode(output[0], skip_special_tokens=True) - summaries.append(f"Summary for {doc.title}:\n{summary.strip()}") - return "\n\n".join(summaries) - - - def generate_answer(self, question, top_k=5): - """Answer a question using retrieved context.""" - retrieved_chunks = self.retrieve(question, top_k=top_k) - context = "\n".join(retrieved_chunks) - - prompt = f"Answer the question based on the context below:\n\n{context}\n\nQuestion: {question}\nAnswer:" - input_ids = self.tokenizer(prompt, return_tensors="pt", truncation=True, max_length=2048).input_ids.to(self.model.device) - - with torch.no_grad(): - output = self.model.generate( - input_ids, - max_new_tokens=1000, - do_sample=False, - temperature=0.7, - top_k=50 - ) - - answer = self.tokenizer.decode(output[0], skip_special_tokens=True) - return answer.strip() - -rag = None - -def get_rag_instance(): - global rag - if rag is None: - print("Initiating shared RAGSystem Instance") - rag = RAGSystem() - return rag - +"""Local retrieval-augmented generation pipeline for PDF documents.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +import numpy as np +from pypdf import PdfReader + +LOGGER = logging.getLogger(__name__) + + +class RAGError(RuntimeError): + """Raised when the document pipeline cannot complete a user operation.""" + + +@dataclass(frozen=True) +class Page: + text: str + source: str + page_number: int + + +@dataclass(frozen=True) +class Chunk: + text: str + source: str + page_number: int + chunk_number: int + + +@dataclass(frozen=True) +class SearchResult: + chunk: Chunk + score: float + + +@dataclass(frozen=True) +class Answer: + text: str + sources: tuple[SearchResult, ...] + + +def split_text(text: str, chunk_size: int = 700, chunk_overlap: int = 100) -> list[str]: + """Split text into overlapping, whitespace-trimmed character chunks.""" + if chunk_size <= 0: + raise ValueError("chunk_size must be greater than zero") + if chunk_overlap < 0 or chunk_overlap >= chunk_size: + raise ValueError("chunk_overlap must be between zero and chunk_size") + + normalized = " ".join(text.split()) + if not normalized: + return [] + + step = chunk_size - chunk_overlap + return [ + normalized[start : start + chunk_size].strip() + for start in range(0, len(normalized), step) + if normalized[start : start + chunk_size].strip() + ] + + +class RAGSystem: + """Ingest PDFs, build a FAISS index, and answer grounded questions.""" + + def __init__( + self, + *, + model_name: str | None = None, + embedding_model_name: str | None = None, + chunk_size: int = 700, + chunk_overlap: int = 100, + top_k: int = 5, + embedding_model: object | None = None, + tokenizer: object | None = None, + model: object | None = None, + index_factory: Callable[[int], object] | None = None, + ) -> None: + self.model_name = model_name or os.getenv("MODEL_NAME", "Qwen/Qwen2.5-0.5B-Instruct") + self.embedding_model_name = embedding_model_name or os.getenv( + "EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2" + ) + self.chunk_size = chunk_size + self.chunk_overlap = chunk_overlap + self.top_k = top_k + self.pages: list[Page] = [] + self.chunks: list[Chunk] = [] + self.index: object | None = None + self._embedding_model = embedding_model + self._tokenizer = tokenizer + self._model = model + self._index_factory = index_factory + + @property + def document_count(self) -> int: + return len({page.source for page in self.pages}) + + @property + def page_count(self) -> int: + return len(self.pages) + + @property + def chunk_count(self) -> int: + return len(self.chunks) + + @property + def ready(self) -> bool: + return self.index is not None and bool(self.chunks) + + def clear(self) -> None: + """Remove all loaded documents and the current vector index.""" + self.pages.clear() + self.chunks.clear() + self.index = None + + def add_pdf(self, file: str | Path | BinaryIO, title: str | None = None) -> int: + """Extract text from a PDF and return the number of usable pages added.""" + source = title or getattr(file, "name", None) or "document.pdf" + source = Path(str(source)).name + try: + reader = PdfReader(file) + except Exception as exc: + raise RAGError(f"Could not read {source} as a PDF.") from exc + + added = 0 + for page_number, pdf_page in enumerate(reader.pages, start=1): + text = (pdf_page.extract_text() or "").strip() + if text: + self.pages.append(Page(text=text, source=source, page_number=page_number)) + added += 1 + + if not added: + raise RAGError(f"{source} contains no extractable text. Scanned PDFs require OCR.") + LOGGER.info("Loaded %s usable pages from %s", added, source) + return added + + def load_documents_from_folder(self, folder_path: str | Path) -> int: + """Load every PDF in a folder, raising a clear error for empty input.""" + folder = Path(folder_path) + if not folder.exists(): + raise RAGError(f"Document folder does not exist: {folder}") + pdf_paths = sorted(folder.glob("*.pdf")) + if not pdf_paths: + raise RAGError(f"No PDF files were found in {folder}.") + + pages_before = self.page_count + for path in pdf_paths: + self.add_pdf(path, path.name) + return self.page_count - pages_before + + def _create_chunks(self) -> list[Chunk]: + chunks: list[Chunk] = [] + for page in self.pages: + page_chunks = split_text(page.text, self.chunk_size, self.chunk_overlap) + chunks.extend( + Chunk(text, page.source, page.page_number, number) + for number, text in enumerate(page_chunks, start=1) + ) + return chunks + + def _get_embedding_model(self) -> object: + if self._embedding_model is None: + from sentence_transformers import SentenceTransformer + + LOGGER.info("Loading embedding model %s", self.embedding_model_name) + self._embedding_model = SentenceTransformer(self.embedding_model_name) + return self._embedding_model + + def _new_index(self, dimension: int) -> object: + if self._index_factory is not None: + return self._index_factory(dimension) + import faiss + + return faiss.IndexFlatIP(dimension) + + @staticmethod + def _normalize(vectors: np.ndarray) -> np.ndarray: + vectors = np.asarray(vectors, dtype="float32") + norms = np.linalg.norm(vectors, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return vectors / norms + + def embed_chunks(self) -> int: + """Create chunks, encode them, and build a cosine-similarity FAISS index.""" + if not self.pages: + raise RAGError("Upload at least one text-based PDF before indexing.") + self.chunks = self._create_chunks() + if not self.chunks: + raise RAGError("The uploaded PDFs did not produce searchable text chunks.") + + embeddings = self._get_embedding_model().encode( + [chunk.text for chunk in self.chunks], + convert_to_numpy=True, + show_progress_bar=False, + ) + embeddings = self._normalize(embeddings) + self.index = self._new_index(embeddings.shape[1]) + self.index.add(embeddings) + LOGGER.info("Indexed %s chunks", len(self.chunks)) + return len(self.chunks) + + def retrieve(self, query: str, top_k: int | None = None) -> list[SearchResult]: + """Return the most similar indexed chunks for a query.""" + query = query.strip() + if not query: + raise RAGError("Enter a question before searching.") + if not self.ready: + raise RAGError("Upload and index at least one PDF before asking a question.") + + limit = min(top_k or self.top_k, len(self.chunks)) + query_vector = self._get_embedding_model().encode( + [query], convert_to_numpy=True, show_progress_bar=False + ) + scores, indices = self.index.search(self._normalize(query_vector), limit) + return [ + SearchResult(self.chunks[int(index)], float(score)) + for score, index in zip(scores[0], indices[0], strict=True) + if index >= 0 + ] + + def _load_generator(self) -> tuple[object, object]: + if self._tokenizer is not None and self._model is not None: + return self._tokenizer, self._model + try: + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + LOGGER.info("Loading generation model %s", self.model_name) + self._tokenizer = AutoTokenizer.from_pretrained(self.model_name) + options: dict[str, object] = {"low_cpu_mem_usage": True} + if torch.cuda.is_available(): + options.update(device_map="auto", torch_dtype=torch.float16) + self._model = AutoModelForCausalLM.from_pretrained(self.model_name, **options) + except Exception as exc: + raise RAGError( + f"Could not load {self.model_name}. Check disk space, memory, and " + "your Hugging Face connection." + ) from exc + return self._tokenizer, self._model + + @staticmethod + def _format_prompt(tokenizer: object, system: str, user: str) -> str: + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ] + if hasattr(tokenizer, "apply_chat_template"): + return tokenizer.apply_chat_template( + messages, tokenize=False, add_generation_prompt=True + ) + return f"System: {system}\n\nUser: {user}\n\nAssistant:" + + def _generate(self, system: str, user: str, max_new_tokens: int = 300) -> str: + import torch + + tokenizer, model = self._load_generator() + prompt = self._format_prompt(tokenizer, system, user) + inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=3072) + device = getattr(model, "device", torch.device("cpu")) + inputs = {name: value.to(device) for name, value in inputs.items()} + with torch.inference_mode(): + output = model.generate( + **inputs, + max_new_tokens=max_new_tokens, + do_sample=False, + pad_token_id=tokenizer.eos_token_id, + ) + generated = output[0, inputs["input_ids"].shape[1] :] + text = tokenizer.decode(generated, skip_special_tokens=True).strip() + if not text: + raise RAGError("The local model returned an empty response.") + return text + + def generate_answer(self, question: str, top_k: int | None = None) -> Answer: + """Answer a question using retrieved passages and return its sources.""" + sources = tuple(self.retrieve(question, top_k)) + context = "\n\n".join( + f"[{number}] {result.chunk.source}, page {result.chunk.page_number}\n" + f"{result.chunk.text}" + for number, result in enumerate(sources, start=1) + ) + system = ( + "Answer only from the supplied document excerpts. Cite supporting excerpts " + "with bracketed source numbers such as [1]. If the excerpts do not contain " + "the answer, say: 'I could not find that in the indexed documents.'" + ) + user = f"Document excerpts:\n\n{context}\n\nQuestion: {question.strip()}" + return Answer(self._generate(system, user), sources) + + def summarize_documents(self) -> str: + """Generate a concise overview from the currently indexed content.""" + if not self.ready: + raise RAGError("Upload and index at least one PDF before summarizing.") + selected = self.chunks[: min(12, len(self.chunks))] + context = "\n\n".join( + f"{chunk.source}, page {chunk.page_number}: {chunk.text}" for chunk in selected + ) + return self._generate( + "Summarize only the supplied document text. State the major themes and key " + "facts concisely, and do not add outside information.", + context, + max_new_tokens=400, + ) + + +_shared_rag: RAGSystem | None = None + + +def get_rag_instance() -> RAGSystem: + global _shared_rag + if _shared_rag is None: + _shared_rag = RAGSystem() + return _shared_rag diff --git a/requirements.txt b/requirements.txt index 8d24751..4bf3a6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,211 +1,8 @@ -accelerate==1.8.1 -aiohappyeyeballs==2.6.1 -aiohttp==3.12.9 -aiosignal==1.3.2 -annotated-types @ file:///home/conda/feedstock_root/build_artifacts/annotated-types_1733247046149/work -anyio @ file:///croot/anyio_1745334642479/work -argon2-cffi @ file:///opt/conda/conda-bld/argon2-cffi_1645000214183/work -argon2-cffi-bindings @ file:///croot/argon2-cffi-bindings_1736182440035/work -asttokens @ file:///croot/asttokens_1743630435401/work -async-lru @ file:///croot/async-lru_1699554519285/work -async-timeout==4.0.3 -attrs @ file:///croot/attrs_1734533101012/work -babel @ file:///croot/babel_1737454360933/work -beautifulsoup4 @ file:///croot/beautifulsoup4-split_1718029820055/work -bitsandbytes==0.46.0 -bleach @ file:///croot/bleach_1732290411627/work -blinker==1.9.0 -blis @ file:///croot/cython-blis_1728909056537/work -Brotli @ file:///croot/brotli-split_1736182456865/work -catalogue @ file:///home/conda/feedstock_root/build_artifacts/catalogue_1736092400301/work -certifi @ file:///home/conda/feedstock_root/build_artifacts/certifi_1746569525376/work/certifi -cffi @ file:///croot/cffi_1736182485317/work -charset-normalizer @ file:///home/conda/feedstock_root/build_artifacts/charset-normalizer_1746214863626/work -click @ file:///home/conda/feedstock_root/build_artifacts/click_1747811314515/work -cloudpathlib @ file:///home/conda/feedstock_root/build_artifacts/cloudpathlib-meta_1747331907086/work -colorama @ file:///home/conda/feedstock_root/build_artifacts/colorama_1733218098505/work -comm @ file:///croot/comm_1709322850197/work -confection @ file:///home/conda/feedstock_root/build_artifacts/confection_1740630664356/work -cymem @ file:///croot/cymem_1736260340843/work -dataclasses-json==0.6.7 -datasets==3.6.0 -debugpy @ file:///croot/debugpy_1736267418885/work -decorator @ file:///opt/conda/conda-bld/decorator_1643638310831/work -defusedxml @ file:///tmp/build/80754af9/defusedxml_1615228127516/work -dill==0.3.8 -einops==0.8.1 -exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work -executing @ file:///opt/conda/conda-bld/executing_1646925071911/work -faiss-cpu==1.11.0 -fastjsonschema @ file:///croot/python-fastjsonschema_1731939362158/work -filelock==3.18.0 -Flask==3.1.1 -frozenlist==1.6.2 -fsspec==2025.3.0 -greenlet==3.2.3 -h11 @ file:///croot/h11_1748442006460/work -hf-xet==1.1.3 -httpcore @ file:///croot/httpcore_1748526048470/work -httpx @ file:///croot/httpx_1746747840559/work -httpx-sse==0.4.0 -huggingface-hub==0.32.4 -idna==3.10 -ipykernel @ file:///croot/ipykernel_1737660677549/work -ipython @ file:///croot/ipython_1734548052611/work -ipywidgets @ file:///croot/ipywidgets_1733504575802/work -itsdangerous==2.2.0 -jedi @ file:///croot/jedi_1733987392413/work -Jinja2 @ file:///croot/jinja2_1741710844255/work -joblib==1.5.1 -json5 @ file:///croot/json5_1730786798687/work -jsonpatch==1.33 -jsonpointer==3.0.0 -jsonschema @ file:///croot/jsonschema_1728486696720/work -jsonschema-specifications @ file:///croot/jsonschema-specifications_1699032386549/work -jupyter @ file:///croot/jupyter_1737645803214/work -jupyter-console @ file:///croot/jupyter_console_1679999630278/work -jupyter-events @ file:///croot/jupyter_events_1741184577592/work -jupyter-lsp @ file:///croot/jupyter-lsp-meta_1745827015155/work -jupyter_client @ file:///croot/jupyter_client_1737570961872/work -jupyter_core @ file:///croot/jupyter_core_1718818295206/work -jupyter_server @ file:///croot/jupyter_server_1741200034823/work -jupyter_server_terminals @ file:///croot/jupyter_server_terminals_1744706698694/work -jupyterlab @ file:///croot/jupyterlab_1737555423487/work -jupyterlab_pygments @ file:///croot/jupyterlab_pygments_1741124142640/work -jupyterlab_server @ file:///croot/jupyterlab_server_1725865349919/work -jupyterlab_widgets @ file:///croot/jupyterlab_widgets_1733440870459/work -langchain==0.3.26 -langchain-community==0.3.26 -langchain-core==0.3.66 -langchain-text-splitters==0.3.8 -langcodes @ file:///opt/conda/conda-bld/langcodes_1643477751144/work -langsmith==0.4.1 -markdown-it-py @ file:///home/conda/feedstock_root/build_artifacts/markdown-it-py_1733250460757/work -MarkupSafe @ file:///croot/markupsafe_1738584038848/work -marshmallow==3.26.1 -matplotlib-inline @ file:///opt/conda/conda-bld/matplotlib-inline_1662014470464/work -mdurl @ file:///home/conda/feedstock_root/build_artifacts/mdurl_1733255585584/work -mistune @ file:///croot/mistune_1741124011532/work -mpmath==1.3.0 -multidict==6.4.4 -multiprocess==0.70.16 -murmurhash @ file:///croot/murmurhash_1741707813353/work -mypy_extensions==1.1.0 -nbclient @ file:///croot/nbclient_1741123995822/work -nbconvert @ file:///croot/nbconvert-meta_1741184653315/work -nbformat @ file:///croot/nbformat_1728049424075/work -nest-asyncio @ file:///croot/nest-asyncio_1708532673751/work -networkx==3.4.2 -notebook @ file:///croot/notebook_1738159946465/work -notebook_shim @ file:///croot/notebook-shim_1741707758683/work -numpy @ file:///croot/numpy_and_numpy_base_1747238018250/work/dist/numpy-2.2.5-cp310-cp310-linux_x86_64.whl#sha256=473f4e2ab8742f0196d743f9e1506228ffb43543cdcb305639f06e72e2cdafc3 -nvidia-cublas-cu12==12.6.4.1 -nvidia-cuda-cupti-cu12==12.6.80 -nvidia-cuda-nvrtc-cu12==12.6.77 -nvidia-cuda-runtime-cu12==12.6.77 -nvidia-cudnn-cu12==9.5.1.17 -nvidia-cufft-cu12==11.3.0.4 -nvidia-cufile-cu12==1.11.1.6 -nvidia-curand-cu12==10.3.7.77 -nvidia-cusolver-cu12==11.7.1.2 -nvidia-cusparse-cu12==12.5.4.2 -nvidia-cusparselt-cu12==0.6.3 -nvidia-nccl-cu12==2.26.2 -nvidia-nvjitlink-cu12==12.6.85 -nvidia-nvtx-cu12==12.6.77 -orjson==3.10.18 -overrides @ file:///croot/overrides_1699371140756/work -packaging @ file:///croot/packaging_1734472117206/work -pandas==2.3.0 -pandocfilters @ file:///opt/conda/conda-bld/pandocfilters_1643405455980/work -parso @ file:///croot/parso_1733963305961/work -pexpect @ file:///tmp/build/80754af9/pexpect_1605563209008/work -pillow==11.2.1 -platformdirs @ file:///croot/platformdirs_1744273042065/work -preshed @ file:///croot/preshed_1745242201419/work -prometheus_client @ file:///croot/prometheus_client_1744271615306/work -prompt-toolkit @ file:///croot/prompt-toolkit_1704404351921/work -propcache==0.3.1 -psutil @ file:///croot/psutil_1736367091698/work -ptyprocess @ file:///tmp/build/80754af9/ptyprocess_1609355006118/work/dist/ptyprocess-0.7.0-py2.py3-none-any.whl -pure-eval @ file:///opt/conda/conda-bld/pure_eval_1646925070566/work -pyarrow==20.0.0 -pycparser @ file:///tmp/build/80754af9/pycparser_1636541352034/work -pydantic @ file:///home/conda/feedstock_root/build_artifacts/pydantic_1732286392017/work -pydantic-settings==2.9.1 -pydantic_core @ file:///croot/pydantic-core_1734726052986/work -Pygments @ file:///croot/pygments_1744664109463/work -PyMuPDF==1.26.0 -pypdf==5.6.0 -PyPDF2==3.0.1 -PyQt6==6.7.1 -PyQt6_sip @ file:///croot/pyqt-split_1744804475988/work/pyqt_sip -PySocks @ file:///home/builder/ci_310/pysocks_1640793678128/work -python-dateutil @ file:///croot/python-dateutil_1716495738603/work -python-dotenv==1.1.0 -python-json-logger @ file:///croot/python-json-logger_1734370021104/work -pytz==2025.2 -PyYAML @ file:///croot/pyyaml_1728657952215/work -pyzmq @ file:///croot/pyzmq_1734687138743/work -qtconsole @ file:///croot/qtconsole_1744633877544/work -QtPy @ file:///croot/qtpy_1700144840038/work -referencing @ file:///croot/referencing_1699012038513/work -regex==2024.11.6 -requests @ file:///croot/requests_1730999120400/work -requests-toolbelt==1.0.0 -rfc3339-validator @ file:///croot/rfc3339-validator_1683077044675/work -rfc3986-validator @ file:///croot/rfc3986-validator_1683058983515/work -rich @ file:///home/conda/feedstock_root/build_artifacts/rich_1733342254348/work/dist -rpds-py @ file:///croot/rpds-py_1736541261634/work -safetensors==0.5.3 -scikit-learn==1.7.0 -scipy==1.15.3 -Send2Trash @ file:///croot/send2trash_1736540790175/work -sentence-transformers==4.1.0 -shellingham @ file:///home/conda/feedstock_root/build_artifacts/shellingham_1733300899265/work -sip @ file:///croot/sip_1738856193618/work -six @ file:///croot/six_1744271502820/work -smart_open @ file:///home/conda/feedstock_root/build_artifacts/smart_open_1734485076778/work/dist -sniffio @ file:///croot/sniffio_1705431295498/work -soupsieve @ file:///croot/soupsieve_1696347547217/work -spacy @ file:///croot/spacy_1729067649989/work -spacy-legacy @ file:///home/conda/feedstock_root/build_artifacts/spacy-legacy_1674550301837/work -spacy-loggers @ file:///home/conda/feedstock_root/build_artifacts/spacy-loggers_1694527114282/work -SQLAlchemy==2.0.41 -srsly @ file:///croot/srsly_1741857985869/work -stack-data @ file:///opt/conda/conda-bld/stack_data_1646927590127/work -sympy==1.14.0 -tenacity==9.1.2 -terminado @ file:///croot/terminado_1671751832461/work -thinc @ file:///croot/thinc_1742231813132/work -threadpoolctl==3.6.0 -tiktoken==0.9.0 -tinycss2 @ file:///croot/tinycss2_1738337643607/work -tokenizers==0.21.1 -tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work -torch==2.7.1 -torchaudio==2.7.1 -torchvision==0.22.1 -tornado @ file:///croot/tornado_1748956929273/work -tqdm @ file:///home/conda/feedstock_root/build_artifacts/tqdm_1735661334605/work -traitlets @ file:///croot/traitlets_1718227057033/work -transformers==4.52.4 -transformers-stream-generator==0.0.5 -triton==3.3.1 -typer @ file:///home/conda/feedstock_root/build_artifacts/typer_1711217621866/work -typing-inspect==0.9.0 -typing-inspection==0.4.1 -typing_extensions @ file:///croot/typing_extensions_1734714854207/work -tzdata==2025.2 -urllib3 @ file:///croot/urllib3_1737133630106/work -wasabi @ file:///home/conda/feedstock_root/build_artifacts/wasabi_1740630716360/work -wcwidth @ file:///Users/ktietz/demo/mc3/conda-bld/wcwidth_1629357192024/work -weasel @ file:///home/conda/feedstock_root/build_artifacts/weasel_1734270156651/work -webencodings==0.5.1 -websocket-client @ file:///croot/websocket-client_1715878298792/work -Werkzeug==3.1.3 -widgetsnbextension @ file:///croot/widgetsnbextension_1733439572884/work -wrapt @ file:///croot/wrapt_1736540904746/work -xxhash==3.5.0 -yarl==1.20.0 -zstandard==0.23.0 +Flask>=3.1,<4 +faiss-cpu>=1.8,<2 +numpy>=1.26,<3 +pypdf>=5,<7 +sentence-transformers>=3.0,<6 +torch>=2.2 +transformers>=4.51,<5 +accelerate>=1.0,<2 From 5e764f45d5d7058a038fc7e5a5ca9b3967d2f408 Mon Sep 17 00:00:00 2001 From: Eldho Thomas Date: Tue, 1 Sep 2026 09:18:12 -0400 Subject: [PATCH 2/6] update app layout --- static/styles.css | 147 ++++++++++++++++++++++++++++ templates/index.html | 221 ++++++++++++++++++++----------------------- 2 files changed, 248 insertions(+), 120 deletions(-) create mode 100644 static/styles.css diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..cf8e2e3 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,147 @@ +:root { + --ink: #15201d; + --muted: #65716d; + --paper: #f4f3ed; + --card: #fffef9; + --line: #d9ddd7; + --accent: #0b6b50; + --accent-dark: #07543f; + --soft: #e6f1ec; + --error: #a83d32; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: + radial-gradient(circle at 15% 0%, #dfeee7 0, transparent 34rem), + var(--paper); + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.55; +} + +.shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; } + +.hero { + display: grid; + grid-template-columns: 1.45fr .75fr; + gap: 48px; + align-items: end; + padding: 76px 0 38px; +} + +.eyebrow, .step, .result-label { + margin: 0 0 10px; + color: var(--accent); + font-size: .72rem; + font-weight: 800; + letter-spacing: .14em; +} + +h1 { max-width: 720px; margin: 0; font-size: clamp(2.5rem, 6vw, 5.25rem); line-height: .98; letter-spacing: -.055em; } +h2 { margin: 0 0 8px; font-size: 1.25rem; letter-spacing: -.02em; } +p { margin-top: 0; } +.lede { max-width: 650px; margin: 22px 0 0; color: var(--muted); font-size: 1.1rem; } + +.status-card { + display: flex; + gap: 13px; + align-items: flex-start; + padding: 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 254, 249, .72); +} + +.status-card p { margin: 4px 0 0; color: var(--muted); font-size: .86rem; } +.status-dot { width: 10px; height: 10px; margin-top: 7px; border-radius: 50%; background: #aeb6b2; } +.status-dot.ready { background: #1aab78; box-shadow: 0 0 0 5px #dff5eb; } + +.panel { + padding: 26px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--card); + box-shadow: 0 12px 32px rgba(23, 37, 32, .05); +} + +.upload-panel, .summary-panel { + display: grid; + grid-template-columns: 1fr auto; + gap: 28px; + align-items: center; +} + +.upload-panel form { display: flex; gap: 10px; align-items: center; } +.workspace { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin: 18px 0; } +.section-heading { display: flex; justify-content: space-between; gap: 16px; } +.count { min-width: 28px; height: 28px; padding: 3px 8px; border-radius: 100px; background: var(--soft); color: var(--accent); text-align: center; font-weight: 800; } + +textarea { + width: 100%; + min-height: 104px; + resize: vertical; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fbfcf8; + color: var(--ink); + font: inherit; +} + +textarea:focus, input:focus { outline: 3px solid #bfe1d5; border-color: var(--accent); } + +button, .file-picker { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 45px; + padding: 11px 17px; + border: 1px solid var(--accent); + border-radius: 9px; + background: var(--accent); + color: white; + font: inherit; + font-weight: 750; + cursor: pointer; +} + +button:hover { background: var(--accent-dark); } +button:disabled { cursor: not-allowed; opacity: .45; } +button.secondary, .file-picker { background: transparent; color: var(--accent); } +.file-picker:hover, button.secondary:hover { background: var(--soft); } +.file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; } + +.answer { + margin-top: 24px; + padding: 19px; + border-left: 4px solid var(--accent); + border-radius: 4px 10px 10px 4px; + background: var(--soft); + white-space: pre-wrap; +} + +.answer p:last-child { margin-bottom: 0; } +.sources { margin: 18px 0 0; padding: 0; list-style: none; counter-reset: source; } +.sources li { counter-increment: source; padding: 16px 0; border-top: 1px solid var(--line); } +.sources li::before { content: "[" counter(source) "]"; float: left; width: 32px; color: var(--accent); font-weight: 800; } +.source-meta, .sources li > p { margin-left: 32px; } +.source-meta { display: flex; justify-content: space-between; gap: 12px; font-size: .82rem; } +.source-meta span, .empty-state { color: var(--muted); } +.sources li > p { margin-top: 8px; font-size: .87rem; } +.empty-state { margin-top: 20px; } + +.notice { margin-bottom: 18px; padding: 13px 16px; border-radius: 10px; background: var(--soft); } +.notice.error { background: #f7e5e2; color: var(--error); } +.summary-panel .answer { grid-column: 1 / -1; } + +footer { padding: 28px 0 42px; color: var(--muted); text-align: center; font-size: .82rem; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } + +@media (max-width: 800px) { + .hero, .workspace, .upload-panel, .summary-panel { grid-template-columns: 1fr; } + .hero { gap: 28px; padding-top: 46px; } + .upload-panel form { align-items: stretch; flex-direction: column; } +} diff --git a/templates/index.html b/templates/index.html index ecc464a..3f7694d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,134 +1,115 @@ - + - - Document QA System - + + + Local PDF RAG Assistant + - -
-

Document QA + Summarization System

-

Ask questions or get summaries from your uploaded PDFs

-
- -
-
-

Ask a Question

-
- - +
+
+
+

LOCAL DOCUMENT INTELLIGENCE

+

Ask better questions of your PDFs.

+

+ Private, local retrieval with inspectable source passages. + Your documents never need to leave your machine. +

+
+
+ +
+ {% if rag.ready %}Index ready{% else %}Waiting for PDFs{% endif %} +

{{ rag.document_count }} documents · {{ rag.page_count }} pages · {{ rag.chunk_count }} chunks

+
+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} + +
+
+

01 · BUILD THE INDEX

+

Choose your documents

+

Upload text-based PDFs. A new upload replaces the current in-memory index.

+
+ + + - {% if answer %} -

Answer:

-
{{ answer }}
- {% endif %}
-
-

Summarize All Documents

-
- - +
+
+

02 · RETRIEVE + ANSWER

+

Ask a grounded question

+ + + + + + + {% if answer %} +
+

ANSWER

+

{{ answer }}

+
+ {% endif %} +
+ + +
+ +
+
+

03 · DOCUMENT OVERVIEW

+

Generate a concise summary

+
+
+
{% if summary %} -

Summary:

-
{{ summary }}
+

{{ summary }}

{% endif %}
-
- -
- © 2025 Document Intelligence System • Built with Flask + Transformers -
+
+ Built with Flask, SentenceTransformers, FAISS, and Qwen · Local inference, no API key +
+ - From 7d469a946f02edad4886b2bdb662572f643a2592 Mon Sep 17 00:00:00 2001 From: Eldho Thomas Date: Tue, 1 Sep 2026 09:18:34 -0400 Subject: [PATCH 3/6] add tests --- .github/workflows/test.yml | 24 ++++++++++ pyproject.toml | 9 ++++ requirements-dev.txt | 2 + tests/test_app.py | 86 +++++++++++++++++++++++++++++++++++ tests/test_pipeline.py | 93 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/test_app.py create mode 100644 tests/test_pipeline.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..66bdd8e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install model-free test dependencies + run: pip install Flask numpy pypdf -r requirements-dev.txt + - name: Lint + run: ruff check . + - name: Test + run: python -m pytest -q diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..61a58fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0cb5b91 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8,<9 +ruff>=0.9,<1 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..44a0d0d --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,86 @@ +from io import BytesIO +from types import SimpleNamespace + +from app import create_app +from rag_pipeline import Answer, RAGError + + +class FakeRAG: + def __init__(self): + self.document_count = 0 + self.page_count = 0 + self.chunk_count = 0 + self.ready = False + + def clear(self): + self.__init__() + + def add_pdf(self, _file, _filename): + self.document_count += 1 + self.page_count += 2 + + def embed_chunks(self): + self.chunk_count = 3 + self.ready = True + + def generate_answer(self, question): + if not self.ready: + raise RAGError("Upload and index at least one PDF before asking a question.") + source = SimpleNamespace( + chunk=SimpleNamespace( + source="sample.pdf", + page_number=1, + text="A supporting source passage.", + ) + ) + return Answer(f"Answer for: {question}", (source,)) + + def summarize_documents(self): + if not self.ready: + raise RAGError("Upload and index at least one PDF before summarizing.") + return "A concise summary." + + +def make_client(): + rag = FakeRAG() + app = create_app(lambda: rag) + app.config.update(TESTING=True, SECRET_KEY="test") + return app.test_client(), rag + + +def test_home_loads_without_initializing_models(): + client, _rag = make_client() + response = client.get("/") + assert response.status_code == 200 + assert b"Ask better questions of your PDFs" in response.data + + +def test_upload_indexes_documents_and_reports_counts(): + client, rag = make_client() + response = client.post( + "/documents", + data={"documents": (BytesIO(b"%PDF-test"), "sample.pdf")}, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert response.status_code == 200 + assert rag.ready + assert b"Indexed 1 document(s), 2 page(s), and 3 chunk(s)." in response.data + + +def test_question_displays_answer_and_source(): + client, rag = make_client() + rag.ready = True + rag.document_count = rag.page_count = rag.chunk_count = 1 + response = client.post("/ask", data={"query": "What happened?"}) + assert response.status_code == 200 + assert b"Answer for: What happened?" in response.data + assert b"sample.pdf" in response.data + assert b"Page 1" in response.data + + +def test_question_before_upload_is_controlled_error(): + client, _rag = make_client() + response = client.post("/ask", data={"query": "What happened?"}) + assert response.status_code == 400 + assert b"Upload and index at least one PDF" in response.data diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..f59b3a7 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,93 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest + +from rag_pipeline import Page, RAGError, RAGSystem, split_text + + +class FakeEmbeddingModel: + def encode(self, texts, **_kwargs): + vectors = [] + for text in texts: + lowered = text.lower() + vectors.append( + [ + float(lowered.count("alpha")), + float(lowered.count("beta")), + 0.1, + ] + ) + return np.asarray(vectors, dtype="float32") + + +class FakeIndex: + def __init__(self, _dimension): + self.vectors = None + + def add(self, vectors): + self.vectors = vectors + + def search(self, query, limit): + scores = query @ self.vectors.T + indices = np.argsort(-scores, axis=1)[:, :limit] + ordered_scores = np.take_along_axis(scores, indices, axis=1) + return ordered_scores, indices + + +def test_split_text_preserves_overlap_and_validates_configuration(): + chunks = split_text("abcdefghij", chunk_size=6, chunk_overlap=2) + assert chunks == ["abcdef", "efghij", "ij"] + with pytest.raises(ValueError): + split_text("text", chunk_size=5, chunk_overlap=5) + + +def test_add_pdf_stores_each_page_once(): + fake_pages = [ + SimpleNamespace(extract_text=lambda: "First page"), + SimpleNamespace(extract_text=lambda: "Second page"), + ] + with patch("rag_pipeline.PdfReader", return_value=SimpleNamespace(pages=fake_pages)): + rag = RAGSystem() + assert rag.add_pdf(object(), "report.pdf") == 2 + + assert rag.page_count == 2 + assert [page.text for page in rag.pages] == ["First page", "Second page"] + assert [page.page_number for page in rag.pages] == [1, 2] + + +def test_empty_or_scanned_pdf_has_clear_error(): + fake_pages = [SimpleNamespace(extract_text=lambda: "")] + with patch("rag_pipeline.PdfReader", return_value=SimpleNamespace(pages=fake_pages)): + with pytest.raises(RAGError, match="require OCR"): + RAGSystem().add_pdf(object(), "scan.pdf") + + +def test_retrieval_returns_relevant_chunk_with_metadata(): + rag = RAGSystem( + chunk_size=100, + chunk_overlap=10, + embedding_model=FakeEmbeddingModel(), + index_factory=FakeIndex, + ) + rag.pages = [ + Page("Alpha is the primary subject.", "alpha.pdf", 2), + Page("Beta is discussed separately.", "beta.pdf", 4), + ] + + assert rag.embed_chunks() == 2 + result = rag.retrieve("Tell me about alpha", top_k=1)[0] + + assert result.chunk.source == "alpha.pdf" + assert result.chunk.page_number == 2 + assert result.score > 0 + + +def test_indexing_requires_documents(): + rag = RAGSystem( + embedding_model=FakeEmbeddingModel(), + index_factory=FakeIndex, + ) + with pytest.raises(RAGError, match="Upload at least one"): + rag.embed_chunks() From 45d324a99a0fb8d4630fdc3edea43bad369baf99 Mon Sep 17 00:00:00 2001 From: Eldho Thomas Date: Tue, 1 Sep 2026 09:18:55 -0400 Subject: [PATCH 4/6] update readme --- README.md | 228 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 179 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 4512ea7..53fe107 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,204 @@ -# Retrieval-Augmented Generation System +# Local PDF RAG Assistant -A local RAG pipeline for document summarization and Q&A, built with: +A local document question-answering application that converts PDFs into a searchable vector index, retrieves relevant passages, and generates answers with inspectable filename and page citations. -- Retrieval-Augmented Generation (RAG) -- Hugging Face Transformers & SentenceTransformers -- FAISS vector indexing -- Qwen1.5-1.8B language model (locally loaded) -- Flask web interface +Built with Python, Flask, SentenceTransformers, FAISS, Hugging Face Transformers, and Qwen. No hosted LLM API or API key is required. -Upload PDF documents, generate summaries, and ask questions based on their content — designed to run locally without external APIs. +## Project highlights -## Features +- Complete pipeline from PDF ingestion to grounded answer generation +- Overlapping, metadata-aware chunks that retain filename and page provenance +- Cosine-similarity retrieval through a normalized FAISS index +- Local Qwen inference with lazy model loading +- Source excerpts displayed beside every generated answer +- Explicit abstention instruction for unsupported questions +- Multi-file upload, controlled errors, and a responsive Flask interface +- Model-free unit and route tests suitable for continuous integration -- Summarize multiple PDF documents -- Answer questions based on indexed content -- FAISS-powered semantic search -- Clean Flask-based web UI -- Local model loading (no API needed) -- Optimized for low-GPU-memory systems (4-bit quantization supported) +## Architecture -## Project Structure +~~~mermaid +flowchart TD + A["PDF uploads"] --> B["Text + page metadata"] + B --> C["Overlapping chunks"] + C --> D["SentenceTransformer embeddings"] + D --> E["Normalized FAISS index"] + Q["User question"] --> F["Query embedding"] + F --> E + E --> G["Top-k passages"] + G --> H["Local Qwen model"] + H --> I["Answer + source excerpts"] +~~~ -``` -├── rag_pipeline.py # Core logic: document parsing, embedding, QA -├── app.py # Flask web server -├── templates/ # HTML templates for the UI -├── requirements.txt # Python dependencies -├── .gitignore -└── README.md -``` +The embedding vectors are L2-normalized before being stored in a FAISS inner-product index. This makes the inner product equivalent to cosine similarity and keeps retrieval fast without discarding document provenance. -## Installation & Setup +## Application flow -### 1. Clone the repository +1. Upload one or more text-based PDFs. +2. The application extracts each usable page and creates overlapping chunks. +3. SentenceTransformers encodes the chunks and FAISS indexes the normalized vectors. +4. A question is embedded with the same model. +5. The top matching passages are provided to the local language model. +6. The UI returns the answer alongside the retrieved filename, page number, and text. -```bash +## Tech stack + +| Layer | Technology | +|---|---| +| Web application | Flask, Jinja, HTML, CSS | +| PDF extraction | pypdf | +| Embeddings | sentence-transformers/all-MiniLM-L6-v2 | +| Vector search | FAISS | +| Local generation | Qwen/Qwen2.5-0.5B-Instruct | +| Runtime | PyTorch, Hugging Face Transformers | +| Quality | pytest, Ruff, GitHub Actions | + +## Quick start + +### Requirements + +- Python 3.11 or 3.12 +- Git +- Internet access on the first run to download the embedding and generation models +- Enough local memory and disk space for PyTorch and the selected models + +The default 0.5B-parameter model is chosen to make local CPU use more practical. Generation will still be faster with a compatible CUDA GPU. + +### Install + +~~~bash git clone https://github.com/eldhothomas1/Retrieval-Augmented-Generation-System.git cd Retrieval-Augmented-Generation-System -``` -### 2. Install dependencies +python -m venv .venv +~~~ + +Activate the environment: + +~~~bash +# macOS or Linux +source .venv/bin/activate +~~~ -```bash +~~~powershell +# Windows PowerShell +.venv\Scripts\Activate.ps1 +~~~ + +Install the runtime dependencies: + +~~~bash +python -m pip install --upgrade pip pip install -r requirements.txt -``` +~~~ + +### Run + +~~~bash +python app.py +~~~ + +Open [http://127.0.0.1:8888](http://127.0.0.1:8888), upload one or more PDFs, and wait for the index confirmation before asking a question. + +The first indexing request loads the embedding model. The first question or summary loads the generation model. Later requests reuse both models for the life of the server process. + +## Configuration + +Copy .env.example as a reference and export any settings you want to override: -### 3. Model setup (Qwen1.5-1.8B) +| Variable | Default | Purpose | +|---|---|---| +| MODEL_NAME | Qwen/Qwen2.5-0.5B-Instruct | Hugging Face generation model | +| EMBEDDING_MODEL | sentence-transformers/all-MiniLM-L6-v2 | Sentence embedding model | +| HOST | 127.0.0.1 | Flask bind address | +| PORT | 8888 | Local server port | +| LOG_LEVEL | INFO | Python logging level | +| FLASK_DEBUG | unset | Set to true for local debugging | -This project uses the Qwen/Qwen1.5-1.8B model from Hugging Face. Due to its size, it's excluded from the repo — download it manually: +Example: -```bash -mkdir -p models/Qwen1.5-1.8B -git lfs install -git clone https://huggingface.co/Qwen/Qwen1.5-1.8B models/Qwen1.5-1.8B -``` +~~~bash +MODEL_NAME=Qwen/Qwen2.5-1.5B-Instruct PORT=5000 python app.py +~~~ -### 4. Run the app +PowerShell: -```bash +~~~powershell +$env:MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct" +$env:PORT = "5000" python app.py -``` +~~~ + +## Testing + +The test suite uses dependency injection and lightweight fakes, so it validates ingestion, chunking, retrieval, Flask routes, and error handling without downloading an LLM. + +~~~bash +pip install -r requirements-dev.txt +python -m pytest -q +ruff check . +~~~ + +GitHub Actions runs the same model-free checks on every push and pull request. + +## Project structure + +~~~text +. +├── .github/workflows/test.yml # Continuous integration +├── static/styles.css # Responsive interface styles +├── templates/index.html # Flask/Jinja interface +├── tests/ +│ ├── test_app.py # Route and upload behavior +│ └── test_pipeline.py # Ingestion, chunking, and retrieval +├── .env.example # Configuration reference +├── app.py # Web routes and application factory +├── rag_pipeline.py # RAG ingestion, retrieval, and generation +├── requirements-dev.txt +└── requirements.txt +~~~ + +## Reliability and privacy + +- Uploaded documents are read in memory and are not written to the repository. +- The active document index is process-local and is replaced by each new upload batch. +- The application rejects unsupported file extensions and limits each request to 20 MB. +- Scanned or image-only PDFs receive a clear OCR-related error. +- Model-loading failures are surfaced rather than silently falling back to a weaker model. +- Answers include the passages sent to the generator so users can inspect the evidence. + +This is a local development project, not a hardened multi-user service. Do not expose the Flask development server directly to the public internet. + +## Design decisions + +**Why FAISS instead of a database?** + +The project focuses on an in-memory, single-user local workflow. FAISS provides efficient vector search without requiring a separate service. + +**Why a smaller default Qwen model?** + +The default should be feasible for more reviewers to run. MODEL_NAME remains configurable for users with more capable hardware. + +**Why character chunks?** + +Character chunking keeps the implementation easy to inspect and test. The overlap reduces context loss across chunk boundaries. Token-aware or sentence-aware splitting would be a useful next experiment. + +**Why show retrieved text even when an answer is generated?** + +Retrieval-augmented generation is easier to evaluate when the evidence is visible. Source passages let users distinguish retrieval failures from generation failures. -Then go to `http://127.0.0.1:5000` +## Current limitations -## Files Not Included +- pypdf does not perform OCR, so scanned PDFs are not supported. +- The FAISS index is in memory and is rebuilt when documents are uploaded. +- Summarization uses a bounded selection of chunks rather than a hierarchical full-document summarizer. +- Local generation latency depends on the selected model and hardware. +- Prompt-based grounding reduces hallucinations but does not guarantee factual correctness; important answers should be checked against the displayed passages. -To keep the repo lightweight, the following are excluded via `.gitignore`: -- `models/` (language models) -- `*.pdf` files -- `__pycache__/`, `*.pyc`, `.ipynb_checkpoints/` -- FAISS indexes (`faiss_index/`, `faiss_multi_index/`) +## Next improvements -You'll need to regenerate these locally. +- Add OCR for scanned documents. +- Persist indexes and document metadata between sessions. +- Stream generated tokens to the interface. +- Add a small, versioned evaluation set for retrieval hit rate and grounded-answer quality. +- Compare character, sentence, and token-aware chunking strategies. From 9fccc9fae39c36932149f3a79308e7dc2a2685fe Mon Sep 17 00:00:00 2001 From: Eldho Thomas Date: Tue, 1 Sep 2026 09:25:48 -0400 Subject: [PATCH 5/6] simplify requirements --- .github/workflows/test.yml | 2 +- requirements-dev.txt | 2 -- requirements.txt | 2 ++ 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 requirements-dev.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 66bdd8e..ae6ea80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: python-version: "3.11" cache: pip - name: Install model-free test dependencies - run: pip install Flask numpy pypdf -r requirements-dev.txt + run: pip install Flask numpy pypdf pytest ruff - name: Lint run: ruff check . - name: Test diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 0cb5b91..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest>=8,<9 -ruff>=0.9,<1 diff --git a/requirements.txt b/requirements.txt index 4bf3a6e..2e410d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ sentence-transformers>=3.0,<6 torch>=2.2 transformers>=4.51,<5 accelerate>=1.0,<2 +pytest>=8,<9 +ruff>=0.9,<1 From dff0ba14fc601ae4b7f7e4523abb0566a4a7b0fe Mon Sep 17 00:00:00 2001 From: Eldho Thomas Date: Tue, 1 Sep 2026 09:26:09 -0400 Subject: [PATCH 6/6] update readme diagram --- README.md | 17 +++---------- docs/architecture.svg | 59 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 docs/architecture.svg diff --git a/README.md b/README.md index 53fe107..0cf7d6f 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,7 @@ Built with Python, Flask, SentenceTransformers, FAISS, Hugging Face Transformers ## Architecture -~~~mermaid -flowchart TD - A["PDF uploads"] --> B["Text + page metadata"] - B --> C["Overlapping chunks"] - C --> D["SentenceTransformer embeddings"] - D --> E["Normalized FAISS index"] - Q["User question"] --> F["Query embedding"] - F --> E - E --> G["Top-k passages"] - G --> H["Local Qwen model"] - H --> I["Answer + source excerpts"] -~~~ +![PDF question answering pipeline](docs/architecture.svg) The embedding vectors are L2-normalized before being stored in a FAISS inner-product index. This makes the inner product equivalent to cosine similarity and keeps retrieval fast without discarding document provenance. @@ -134,7 +123,7 @@ python app.py The test suite uses dependency injection and lightweight fakes, so it validates ingestion, chunking, retrieval, Flask routes, and error handling without downloading an LLM. ~~~bash -pip install -r requirements-dev.txt +pip install -r requirements.txt python -m pytest -q ruff check . ~~~ @@ -146,6 +135,7 @@ GitHub Actions runs the same model-free checks on every push and pull request. ~~~text . ├── .github/workflows/test.yml # Continuous integration +├── docs/architecture.svg # Static pipeline diagram ├── static/styles.css # Responsive interface styles ├── templates/index.html # Flask/Jinja interface ├── tests/ @@ -154,7 +144,6 @@ GitHub Actions runs the same model-free checks on every push and pull request. ├── .env.example # Configuration reference ├── app.py # Web routes and application factory ├── rag_pipeline.py # RAG ingestion, retrieval, and generation -├── requirements-dev.txt └── requirements.txt ~~~ diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..8005ab8 --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,59 @@ + + PDF question answering pipeline + PDF files are parsed, split into chunks, embedded, indexed with FAISS, retrieved for a question, and passed to a local Qwen model to produce an answer with sources. + + + + + + + + + + + PDF uploads + files and pages + + + Text chunks + with page details + + + Embeddings + SentenceTransformer + + + FAISS index + similarity search + + + + + + + Question + embedded for search + + + Top matches + retrieved passages + + + Local Qwen model + grounded response + + + Answer + with source pages + + + + + +