A production-grade RAG (Retrieval-Augmented Generation) app:
Upload PDF or DOCX files, ask questions, and get answers grounded in the document with page citations and full retrieval transparency.
all-MiniLM-L6-v2 (via sentence-transformers, runs on CPU)rank-bm25, runs locally)ms-marco-MiniLM-L-6-v2 cross-encoder (runs on CPU)$env:GOOGLE_API_KEY="your_api_key_here"
export GOOGLE_API_KEY=your_api_key_here
You can also place the key in a .env file:
# ── LLM ───────────────────────────────────────────────────
GOOGLE_API_KEY=your_api_key_here
GEMINI_MODEL=gemini-2.0-flash-lite
# ── Observability ──────────────────────────────────────────
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com
LANGCHAIN_API_KEY=ls__...
LANGCHAIN_TRACING_V2=true
LANGCHAIN_PROJECT=hybrid-rag
# ── Embeddings ─────────────────────────────────────────────
RAG_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
Make sure your application loads the .env file using python-dotenv.
The app uses:
gemini-2.0-flash-lite
as the generation model.
Privacy note:
all-MiniLM-L6-v2embeddings, BM25 search, reranking, and injection detection all run locally. Retrieved document chunks are sent to Google's Gemini API when generating an answer. Avoid uploading sensitive or confidential documents unless this is acceptable for your use case. For fully local deployment, switch generation to Ollama (qwen3:8b).
Download from ollama.com and pull the guard model:
ollama pull llama-guard3:1b
Llama Guard acts as layer 4 of the injection guard. The system degrades gracefully if Ollama is unavailable — layers 1–3 remain active.
python -m venv .venv
.venv\Scripts\activate
source .venv/bin/activate
Install the dependencies:
pip install -r requirements.txt
The first time you run the application, the following models are downloaded from Hugging Face and cached locally:
all-MiniLM-L6-v2 — ~90 MB (embeddings)ms-marco-MiniLM-L-6-v2 — ~90 MB (reranker)deberta-v3-small-prompt-injection-v2 — ~180 MB (injection guard)streamlit run streamlit_pdf_app.py
Opens at:
http://localhost:8501
python api.py
Opens at:
http://localhost:8000
API docs (Swagger UI) at:
http://localhost:8000/docs
python test_guard_quick.py
Upload a PDF or DOCX, wait for it to be indexed, and then ask questions about its contents.
PDF / DOCX upload
│
▼
Text Extraction
├── PyPDF2 (primary — fast, accurate page numbers)
└── Docling (fallback — OCR via RapidOCR, tables, scanned PDFs)
└── python-docx (DOCX files)
│
▼
Injection Sanitization
└── Regex patterns removed from extracted text before indexing
│
▼
Structure-Aware Chunking
├── Split on headings (##, ###)
├── Tables kept whole — never split
├── Paragraphs accumulated up to 1200 chars
└── Each chunk tagged with page number + content type
│
▼
Dual Indexing
├── ChromaDB (semantic vectors via all-MiniLM-L6-v2)
└── BM25 index (keyword search via rank-bm25)
│
▼
User asks a question
│
▼
Injection Guard — user query (5 layers)
├── L1: Regex on original + normalized text
├── L2: Synonym detection
├── L3: DeBERTa v2 ML classifier (~50ms)
├── L4: Llama Guard 3 1B (~500ms, final safety net)
└── L5: Subtle pattern fallback
│
▼
Query Resolution
└── Follow-up detection → resolved question used for retrieval
│
▼
Hybrid Search (top 20 candidates)
├── Semantic search (ChromaDB cosine similarity)
├── BM25 keyword search
└── RRF fusion (60% semantic + 40% keyword)
│
▼
Injection Guard — retrieved chunks (regex only, fast)
│
▼
Cross-Encoder Reranking
└── ms-marco-MiniLM-L-6-v2 scores each (query, chunk) pair
└── Top 5 chunks selected from 20 candidates
│
▼
Relevance Gate (deterministic — no LLM call)
└── best rerank score < -2.0 → return "Not found in document."
│
▼
generate_answer()
└── Gemini 2.0 Flash-Lite with conversation history (last 5 turns)
│
▼
Injection Guard — LLM output (regex only, fast)
│
▼
Source Attribution (deterministic — not by LLM)
└── Page numbers extracted from retrieved chunks
│
▼
Grounded answer + [Sources: Pages X, Y, Z]
PDF: PyPDF2 is tried first for fast, accurate per-page extraction. If it returns no content (scanned PDFs, image-based), Docling takes over with RapidOCR and table extraction. If per-page Docling export fails, the full document is split into virtual pages of ~3000 characters at paragraph boundaries.
DOCX: python-docx extracts paragraphs with heading detection and tables as markdown pipe format, grouped into virtual pages of ~30 paragraphs.
The extracted content is split using structure-aware chunking — not fixed character counts.
##, ###) define section boundarieschunk_size=1200section, table, text)all-MiniLM-L6-v2 converts each chunk into a 384-dimensional vector.
These embeddings are stored in ChromaDB alongside a BM25 index built over the same corpus.
When the user asks a question, both search methods run in parallel:
Results are fused using Reciprocal Rank Fusion (RRF) and the top 20 candidates are returned. Each chunk is tagged semantic, keyword, or hybrid based on which search found it.
The cross-encoder ms-marco-MiniLM-L-6-v2 scores each (question, chunk) pair together — much more accurate than bi-encoder similarity alone. The top 5 chunks are selected from the 20 candidates.
Before calling the LLM, the best rerank score is checked against a threshold (-2.0). If no chunk is relevant enough, Not found in document. is returned instantly without calling the LLM — preventing hallucination.
The top 5 chunks are passed along with the user's question and conversation history to:
Gemini 2.0 Flash-Lite
Gemini generates an answer using only the retrieved document context.
The application then displays the answer along with the source pages.
| Layer | Method | Speed | Catches |
|---|---|---|---|
| L1 | Regex (original + normalized) | ~0.1ms | Explicit patterns, leet speak, zero-width chars, dot-splitting |
| L2 | Synonym detection | ~0.1ms | "discard directives", paraphrase attacks |
| L3 | DeBERTa v2 ML | ~50ms | Semantic attacks, novel phrasing, role switching |
| L4 | Llama Guard 3 1B | ~500ms | Final safety net — rare edge cases |
| L5 | Subtle pattern fallback | ~0.1ms | Last resort |
Applied at three points in every query:
Follow-up questions are resolved automatically before retrieval:
Q1: "What is the stock amount of item X?"
Q2: "What about item Y?"
↓ resolved to:
"What is the stock amount of item X? What about item Y?"
The last 5 turns are injected into the LLM context as user / assistant pairs.
chromadb.EphemeralClient()
with:
chromadb.PersistentClient(path="./chroma_db")
qwen3:8b for fully local deployment.rag_file/
├── api.py ← FastAPI REST backend
├── streamlit_pdf_app.py ← Streamlit UI
├── test_guard_quick.py ← Injection guard quick test
├── test_injection_guard.py ← Full injection guard test suite
├── requirements.txt
├── .env
└── rag_core/
├── __init__.py
├── models.py ← PageText, Chunk dataclasses
├── extract.py ← PDF/DOCX extraction
├── chunking.py ← Structure-aware chunking
├── vector_store.py ← ChromaDB + BM25 hybrid store
├── reranker.py ← Cross-encoder reranking
├── relevance_gate.py ← Deterministic relevance check
├── injection_guard.py ← 5-layer injection detection
├── generation.py ← Gemini generation + history
└── pipeline.py ← Full RAG pipeline orchestration
| Component | Technology |
|---|---|
| Frontend | Streamlit + HTML/JS |
| API | FastAPI + Uvicorn |
| Document Parsing | PyPDF2 + Docling + python-docx |
| Embeddings | all-MiniLM-L6-v2 (sentence-transformers) |
| Keyword Search | BM25 (rank-bm25) |
| Vector Database | ChromaDB |
| Reranker | ms-marco-MiniLM-L-6-v2 (cross-encoder) |
| LLM | Gemini 2.0 Flash-Lite |
| Injection Guard | DeBERTa v2 + Llama Guard 3 1B |
| Observability | Langfuse + LangSmith |
| Backend | Python 3.10+ |
| LLM API | Google Gemini API |
A production-grade RAG (Retrieval-Augmented Generation) app:
Upload PDF or DOCX files, ask questions, and get answers grounded in the document with page citations and full retrieval transparency.
all-MiniLM-L6-v2 (via sentence-transformers, runs on CPU)rank-bm25, runs locally)ms-marco-MiniLM-L-6-v2 cross-encoder (runs on CPU)$env:GOOGLE_API_KEY="your_api_key_here"
export GOOGLE_API_KEY=your_api_key_here
You can also place the key in a .env file:
# ── LLM ───────────────────────────────────────────────────
GOOGLE_API_KEY=your_api_key_here
GEMINI_MODEL=gemini-2.0-flash-lite
# ── Observability ──────────────────────────────────────────
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com
LANGCHAIN_API_KEY=ls__...
LANGCHAIN_TRACING_V2=true
LANGCHAIN_PROJECT=hybrid-rag
# ── Embeddings ─────────────────────────────────────────────
RAG_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
Make sure your application loads the .env file using python-dotenv.
The app uses:
gemini-2.0-flash-lite
as the generation model.
Privacy note:
all-MiniLM-L6-v2embeddings, BM25 search, reranking, and injection detection all run locally. Retrieved document chunks are sent to Google's Gemini API when generating an answer. Avoid uploading sensitive or confidential documents unless this is acceptable for your use case. For fully local deployment, switch generation to Ollama (qwen3:8b).
Download from ollama.com and pull the guard model:
ollama pull llama-guard3:1b
Llama Guard acts as layer 4 of the injection guard. The system degrades gracefully if Ollama is unavailable — layers 1–3 remain active.
python -m venv .venv
.venv\Scripts\activate
source .venv/bin/activate
Install the dependencies:
pip install -r requirements.txt
The first time you run the application, the following models are downloaded from Hugging Face and cached locally:
all-MiniLM-L6-v2 — ~90 MB (embeddings)ms-marco-MiniLM-L-6-v2 — ~90 MB (reranker)deberta-v3-small-prompt-injection-v2 — ~180 MB (injection guard)streamlit run streamlit_pdf_app.py
Opens at:
http://localhost:8501
python api.py
Opens at:
http://localhost:8000
API docs (Swagger UI) at:
http://localhost:8000/docs
python test_guard_quick.py
Upload a PDF or DOCX, wait for it to be indexed, and then ask questions about its contents.
PDF / DOCX upload
│
▼
Text Extraction
├── PyPDF2 (primary — fast, accurate page numbers)
└── Docling (fallback — OCR via RapidOCR, tables, scanned PDFs)
└── python-docx (DOCX files)
│
▼
Injection Sanitization
└── Regex patterns removed from extracted text before indexing
│
▼
Structure-Aware Chunking
├── Split on headings (##, ###)
├── Tables kept whole — never split
├── Paragraphs accumulated up to 1200 chars
└── Each chunk tagged with page number + content type
│
▼
Dual Indexing
├── ChromaDB (semantic vectors via all-MiniLM-L6-v2)
└── BM25 index (keyword search via rank-bm25)
│
▼
User asks a question
│
▼
Injection Guard — user query (5 layers)
├── L1: Regex on original + normalized text
├── L2: Synonym detection
├── L3: DeBERTa v2 ML classifier (~50ms)
├── L4: Llama Guard 3 1B (~500ms, final safety net)
└── L5: Subtle pattern fallback
│
▼
Query Resolution
└── Follow-up detection → resolved question used for retrieval
│
▼
Hybrid Search (top 20 candidates)
├── Semantic search (ChromaDB cosine similarity)
├── BM25 keyword search
└── RRF fusion (60% semantic + 40% keyword)
│
▼
Injection Guard — retrieved chunks (regex only, fast)
│
▼
Cross-Encoder Reranking
└── ms-marco-MiniLM-L-6-v2 scores each (query, chunk) pair
└── Top 5 chunks selected from 20 candidates
│
▼
Relevance Gate (deterministic — no LLM call)
└── best rerank score < -2.0 → return "Not found in document."
│
▼
generate_answer()
└── Gemini 2.0 Flash-Lite with conversation history (last 5 turns)
│
▼
Injection Guard — LLM output (regex only, fast)
│
▼
Source Attribution (deterministic — not by LLM)
└── Page numbers extracted from retrieved chunks
│
▼
Grounded answer + [Sources: Pages X, Y, Z]
PDF: PyPDF2 is tried first for fast, accurate per-page extraction. If it returns no content (scanned PDFs, image-based), Docling takes over with RapidOCR and table extraction. If per-page Docling export fails, the full document is split into virtual pages of ~3000 characters at paragraph boundaries.
DOCX: python-docx extracts paragraphs with heading detection and tables as markdown pipe format, grouped into virtual pages of ~30 paragraphs.
The extracted content is split using structure-aware chunking — not fixed character counts.
##, ###) define section boundarieschunk_size=1200section, table, text)all-MiniLM-L6-v2 converts each chunk into a 384-dimensional vector.
These embeddings are stored in ChromaDB alongside a BM25 index built over the same corpus.
When the user asks a question, both search methods run in parallel:
Results are fused using Reciprocal Rank Fusion (RRF) and the top 20 candidates are returned. Each chunk is tagged semantic, keyword, or hybrid based on which search found it.
The cross-encoder ms-marco-MiniLM-L-6-v2 scores each (question, chunk) pair together — much more accurate than bi-encoder similarity alone. The top 5 chunks are selected from the 20 candidates.
Before calling the LLM, the best rerank score is checked against a threshold (-2.0). If no chunk is relevant enough, Not found in document. is returned instantly without calling the LLM — preventing hallucination.
The top 5 chunks are passed along with the user's question and conversation history to:
Gemini 2.0 Flash-Lite
Gemini generates an answer using only the retrieved document context.
The application then displays the answer along with the source pages.
| Layer | Method | Speed | Catches |
|---|---|---|---|
| L1 | Regex (original + normalized) | ~0.1ms | Explicit patterns, leet speak, zero-width chars, dot-splitting |
| L2 | Synonym detection | ~0.1ms | "discard directives", paraphrase attacks |
| L3 | DeBERTa v2 ML | ~50ms | Semantic attacks, novel phrasing, role switching |
| L4 | Llama Guard 3 1B | ~500ms | Final safety net — rare edge cases |
| L5 | Subtle pattern fallback | ~0.1ms | Last resort |
Applied at three points in every query:
Follow-up questions are resolved automatically before retrieval:
Q1: "What is the stock amount of item X?"
Q2: "What about item Y?"
↓ resolved to:
"What is the stock amount of item X? What about item Y?"
The last 5 turns are injected into the LLM context as user / assistant pairs.
chromadb.EphemeralClient()
with:
chromadb.PersistentClient(path="./chroma_db")
qwen3:8b for fully local deployment.rag_file/
├── api.py ← FastAPI REST backend
├── streamlit_pdf_app.py ← Streamlit UI
├── test_guard_quick.py ← Injection guard quick test
├── test_injection_guard.py ← Full injection guard test suite
├── requirements.txt
├── .env
└── rag_core/
├── __init__.py
├── models.py ← PageText, Chunk dataclasses
├── extract.py ← PDF/DOCX extraction
├── chunking.py ← Structure-aware chunking
├── vector_store.py ← ChromaDB + BM25 hybrid store
├── reranker.py ← Cross-encoder reranking
├── relevance_gate.py ← Deterministic relevance check
├── injection_guard.py ← 5-layer injection detection
├── generation.py ← Gemini generation + history
└── pipeline.py ← Full RAG pipeline orchestration
| Component | Technology |
|---|---|
| Frontend | Streamlit + HTML/JS |
| API | FastAPI + Uvicorn |
| Document Parsing | PyPDF2 + Docling + python-docx |
| Embeddings | all-MiniLM-L6-v2 (sentence-transformers) |
| Keyword Search | BM25 (rank-bm25) |
| Vector Database | ChromaDB |
| Reranker | ms-marco-MiniLM-L-6-v2 (cross-encoder) |
| LLM | Gemini 2.0 Flash-Lite |
| Injection Guard | DeBERTa v2 + Llama Guard 3 1B |
| Observability | Langfuse + LangSmith |
| Backend | Python 3.10+ |
| LLM API | Google Gemini API |