A production-style hybrid Retrieval-Augmented Generation (RAG) backend built with FastAPI, PostgreSQL + pgvector, and Streamlit. The system ingests uploaded documents, extracts and chunks their text, stores vector embeddings and full-text indexes in PostgreSQL, and answers questions using a hybrid retrieval pipeline.
This project also exposes a clean HTTP API so it can be consumed by another application such as EchoPanel.
FastAPI PostgreSQL pgvector Streamlit Sentence-Transformers Groq PyMuPDF python-docx Hybrid Retrieval Vector Search Full-Text Search RAG HNSW CrossEncoder Docker ngrok
- Overview
- Key Features
- Architecture
- How the System Works
- Tech Stack
- Project Structure
- Configuration
- API Endpoints
- EchoPanel Integration
- Run Locally
- Expose with ngrok
- Current Limitations
This backend is designed to:
- accept
.pdf,.txt, and.docxuploads - automatically extract and normalize text
- split documents into retrieval-friendly chunks
- generate embeddings using
all-MiniLM-L6-v2 - store chunks and embeddings in PostgreSQL
- build PostgreSQL full-text indexes for keyword retrieval
- answer questions using hybrid retrieval:
- vector search
- PostgreSQL full-text search
- merge and dedupe
- optional reranking
- final grounded answer generation
The backend supports both:
- a local Streamlit testing UI
- API-based integration for external apps such as EchoPanel
- Multi-file upload support
- Automatic ingestion on upload
- Persistent storage of old and new documents
- Hybrid retrieval: semantic + keyword search
- Optional reranking with a local CrossEncoder
- Optional answer generation via Groq
- Exact cosine retrieval for smaller corpora
- HNSW-based vector retrieval for larger corpora
- Debug-friendly API responses
- EchoPanel-ready
/ask-docsendpoint - Optional API-key protection using
X-API-Key - Optional CORS configuration for frontend integration
flowchart TD
A["User / EchoPanel / Streamlit"] --> B["FastAPI Backend"]
B --> C["Upload Pipeline"]
C --> C1["Extract Text"]
C1 --> C2["Chunk Text"]
C2 --> C3["Generate Embeddings"]
C3 --> D["PostgreSQL + pgvector"]
A --> E["Ask Question"]
E --> B
B --> F["Embed Query"]
B --> G["Full-Text Search"]
F --> H["Vector Search"]
G --> I["Merge + Dedupe"]
H --> I
I --> J["Optional Rerank"]
J --> K["Grounded Answer Builder / Groq"]
K --> A
When a file is uploaded through POST /upload, the backend automatically:
- stores the file contents as a document record
- extracts raw text
- normalizes whitespace and formatting
- splits the text into chunks
- generates embeddings for each chunk
- inserts chunk rows into PostgreSQL
- enables both:
- vector retrieval from
embedding - full-text retrieval from
tsv
- vector retrieval from
There is no separate indexing call required. Upload and indexing happen in a single ingestion flow.
The project uses paragraph-aware character-based chunking.
Default values:
CHUNK_SIZE=900CHUNK_OVERLAP=120
Meaning:
- each chunk is roughly up to 900 characters
- the next chunk repeats about 120 characters from the previous one
- the splitter prefers paragraph and sentence boundaries when possible
This helps preserve context while keeping retrieval fast and targeted.
Embeddings are generated using:
all-MiniLM-L6-v2
The model is loaded once at startup and reused across requests.
Why embeddings are used:
- they convert text into vectors
- semantically similar text ends up close in vector space
- this allows meaning-based retrieval, even when query wording differs from document wording
When a query arrives:
- the question is embedded
- vector search runs over stored chunk embeddings
- PostgreSQL full-text search runs over
tsv - both result sets are merged
- duplicate chunks are removed
- optional reranking can reorder merged hits
- the final top chunks are passed to the answer generator
Two answer paths exist:
- local grounded answer builder
- Groq answer generation
If Groq is enabled:
- the final retrieved chunks and question are sent to Groq
- the prompt forces grounded, context-only answers
If Groq is disabled:
- the backend uses its local grounded answer builder for a faster response
- FastAPI
- Uvicorn
- SQLAlchemy Async
- asyncpg
- PostgreSQL 16
- pgvector
- PostgreSQL Full-Text Search
- sentence-transformers
all-MiniLM-L6-v2- CrossEncoder reranker
- Groq API
- PyMuPDF for PDF extraction
- python-docx for DOCX extraction
- Streamlit
- Docker Compose
- ngrok
rag_chatbot/
app/
config.py
db.py
main.py
models.py
rag.py
schemas.py
docker-compose.yml
requirements.txt
setup_db.py
seed.py
streamlit_app.py
.env.example
README.md
- app/main.py: API routes, upload flow, retrieval pipeline, answer generation
- app/rag.py: embedding model and optional reranker
- app/models.py: PostgreSQL schema
- app/config.py: environment variables and runtime settings
- streamlit_app.py: local testing UI
- docker-compose.yml: PostgreSQL + pgvector service
Copy .env.example to .env and adjust values as needed.
DATABASE_URL=postgresql+asyncpg://postgres:YOUR_PASSWORD@localhost:5433/rag_chatbot
GROQ_API_KEY=
GROQ_MODEL=llama-3.1-8b-instant
SERVICE_API_KEY=
ALLOWED_ORIGINS=*
EMBEDDING_MODEL_NAME=all-MiniLM-L6-v2
ENABLE_RERANK=false
ENABLE_GROQ_GENERATION=false
USE_HNSW_FOR_LARGE_DATA=true
RERANK_MODEL_NAME=cross-encoder/ms-marco-MiniLM-L-6-v2
CHUNK_SIZE=900
CHUNK_OVERLAP=120
VECTOR_TOP_K=6
KEYWORD_TOP_K=6
FINAL_CONTEXT_K=4
RERANK_TOP_K=4
EMBEDDING_BATCH_SIZE=96
HNSW_CHUNK_THRESHOLD=10000
HNSW_EF_SEARCH=80ENABLE_GROQ_GENERATION- enables Groq-based answer generation
ENABLE_RERANK- enables CrossEncoder reranking
SERVICE_API_KEY- optional shared secret for API clients
ALLOWED_ORIGINS- CORS origin list
USE_HNSW_FOR_LARGE_DATA- allows switching to HNSW when dataset grows
HNSW_CHUNK_THRESHOLD- chunk-count threshold for HNSW
GET /GET /healthGET /ready
POST /uploadGET /documentsPOST /resetGET /stats
POST /queryPOST /ask-docs
POST /upload now returns EchoPanel-friendly ID fields.
Example:
{
"uploaded_count": 1,
"failed_count": 0,
"processed_files": [
{
"filename": "lahore.txt",
"document_id": 24,
"stored": true,
"text_extracted_chars": 12345,
"chunks_created": 58,
"embeddings_created": 58,
"chunks_indexed": 58,
"vector_indexed": true,
"full_text_indexed": true
}
],
"document_ids": [24],
"documents": [
{
"document_id": 24,
"filename": "lahore.txt",
"chunks_indexed": 58
}
],
"errors": [],
"document_count": 24,
"chunk_count": 2174
}Example:
{
"question": "What is Lahore's position in Pakistan in terms of population?",
"document_ids": [24],
"debug": true
}Example:
{
"question": "What is Lahore's position in Pakistan in terms of population?",
"answer": "Lahore is the second-largest city in Pakistan after Karachi.",
"filenames": ["lahore.txt"],
"found_in_documents": true,
"mode": "ask_docs_local",
"groq_error": null,
"debug": {
"searched_documents": [
{
"document_id": 24,
"filename": "lahore.txt"
}
]
}
}- if
document_idsare provided:- retrieval is restricted to those documents only
- if
document_idsare not provided:- retrieval searches across all stored documents in the current backend scope
For app integrations such as EchoPanel, the safest pattern is to:
- upload file
- read
document_ids[0] - send that ID back in later
/ask-docsrequests
This backend is ready to be consumed by EchoPanel or any other external app.
X-API-Key: echopanel-demo-key
X-Client-App: EchoPanel- EchoPanel uploads a file to
POST /upload - EchoPanel reads
document_ids[0] - EchoPanel stores that ID
- EchoPanel sends questions to
POST /ask-docsusing thatdocument_id
{
"question": "What does the uploaded document say about revenue?",
"document_ids": [24],
"debug": true
}Using document_ids avoids:
- mixing older uploads with the current file
- noisy retrieval across unrelated documents
- debugging confusion in multi-upload sessions
cd C:\Users\affan.khan\Desktop\rag_chatbotpython -m venv venv
(Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned) ; (& .\venv\Scripts\Activate.ps1)pip install -r requirements.txtCopy-Item .env.example .envThen edit .env with your actual values.
docker compose up -dpython setup_db.py
python seed.py.\venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reloadAvailable at:
In another terminal:
.\venv\Scripts\python.exe -m streamlit run streamlit_app.pyUsually available at:
If another app such as EchoPanel needs to access your local RAG backend from outside your machine, expose it through ngrok.
Make sure FastAPI is already running on port 8000.
winget install ngrok.ngrokBefore using ngrok, create a free account at:
After signing in, open your dashboard and copy your personal auth token from:
ngrok config add-authtoken YOUR_NGROK_AUTH_TOKENIf ngrok is not recognized in PowerShell immediately after installation, close and reopen the terminal. If needed, run the installed executable using its full path.
ngrok http 8000ngrok will return a public URL like:
https://your-subdomain.ngrok-free.app
Example:
RAG_API_URL=https://your-subdomain.ngrok-free.app
RAG_API_KEY=echopanel-demo-key- keep the FastAPI backend running
- keep Docker/PostgreSQL running
- keep ngrok running
- free ngrok URLs may change after restart
- if your LiveKit agent is on LiveKit Cloud,
localhostalone will not work
- no OCR support
- scanned PDFs are not supported
- table extraction is limited
- current user flow behaves like a shared backend scope unless
document_idsare used - answer quality can vary depending on chunking, reranking, and Groq settings
This project is a hybrid RAG backend that combines:
- document ingestion
- chunking
- embeddings
- PostgreSQL + pgvector storage
- hybrid retrieval
- optional reranking
- optional Groq-based answer generation
It works both as:
- a local RAG application with Streamlit
- a reusable backend service for external apps such as EchoPanel