A robust, modular hybrid semantic search engine built with FastAPI, PostgreSQL (sparse keyword search with GIN index), ChromaDB (dense vector search), Redis (query caching), and a Cross-Encoder model (re-ranking).
The project separates the retrieval pipeline from the generation pipeline (RAG), allowing for dedicated tuning, granular latency optimization, and standalone information retrieval evaluation.
Includes a full retrieval ablation across 45 queries (keyword, semantic, hybrid) evaluating dense-only, sparse-only, hybrid RRF, and cross-encoder re-ranking configurations. Key finding: Hybrid RRF with 1024-token chunks matches dense-only retrieval at 0.9370 MRR with 11.51 ms warm-cache latency, while the MS MARCO re-ranker slightly decreases MRR on legal semantic queries — see the Retrieval Ablation Evaluation Report for analysis and next steps.
The system consists of two primary workflows: Dual-Write Ingestion Pipeline and the Composable Search Pipeline.
graph TD
User([User]) -->|Upload PDF| API[FastAPI API]
API -->|Save Metadata & File| DB[(PostgreSQL)]
API -->|Enqueue Job| Redis[(Redis Queue / arq)]
Redis -->|Process Job| Worker[arq Background Worker]
Worker -->|Read PDF & Extract Pages| PyMuPDF[PyMuPDF Parser]
PyMuPDF -->|Split Text into Pages| Chunker[Recursive Chunker]
Chunker -->|Generate Embeddings| LLM[LiteLLM Provider / Gemini]
Worker -->|Write Vectors| Chroma[(ChromaDB Vector Store)]
Worker -->|Write Text & TSVector| DB
Worker -->|Update Status to Ready| DB
Worker -->|Invalidate Search Cache| Redis
graph TD
Client([React Frontend / Client]) -->|Search Query| API[FastAPI API]
API -->|Check Cache| Cache[Redis Cache Service]
Cache -->|Cache Hit| ReturnCached[Return Results Instantly]
Cache -->|Cache Miss| Factory[Search Pipeline Factory]
Factory -->|Build Pipeline| Pipeline[Search Pipeline]
subgraph Parallel Retrieval
Pipeline -->|Query Vector| Dense[Dense Retriever - ChromaDB]
Pipeline -->|Query Keyword| Sparse[Sparse Retriever - PostgreSQL]
end
Dense -->|Dense Scores| Fuser[RRF Fuser]
Sparse -->|Sparse Scores| Fuser[RRF Fuser]
Fuser -->|Fused Rank Score| Reranker[Cross-Encoder Reranker]
Reranker -->|Re-scored Top N| Snippet[Snippet Extractor & Highlighter]
Snippet -->|Return with Highlighted HTML| API
API -->|Write Response to Cache| Cache
API -->|Return JSON Response| Client
| Technology | Purpose |
|---|---|
| FastAPI | High-performance, async Python web framework for APIs. |
| PostgreSQL 16 | Relational storage for documents and text chunks, with custom tsvector columns and GIN indexes for sparse (BM25-style) keyword search. |
| ChromaDB | Vector database for storing and querying dense embeddings. |
| Redis 7 | Shared service for the background job queue (arq) and fast SHA-256 query caching. |
| LiteLLM | Unified interface for generating dense text embeddings (using gemini-embedding-001). |
| Sentence-Transformers | Hugging Face cross-encoder model (ms-marco-MiniLM-L-6-v2) for async re-ranking. |
| React + Vite | Premium frontend UI with debounced search, tooltips, analytics graphs, and responsive layout. |
- Docker & Docker Compose
- Python 3.12 (optional, for local script execution)
Create a .env file in the root directory (based on .env.example):
# PostgreSQL
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/semantic_search
# ChromaDB
CHROMA_HOST=localhost
CHROMA_PORT=8000
# Redis
REDIS_URL=redis://:searchredis@localhost:6379/0
# LLM / Embedding
LLM_EMBEDDING_MODEL=gemini/gemini-embedding-001
LLM_EMBEDDING_DIMENSIONS=768
GEMINI_API_KEY=your_gemini_api_key_here
# Search
SEARCH_DEFAULT_TOP_K=10
SEARCH_RRF_K=60
SEARCH_RERANK_TOP_N=20
SEARCH_CACHE_TTL_SECONDS=300Run the following command from the root directory:
docker-compose up -d --buildThis boots up the following services:
postgres: Configured with automatic healthchecks.chromadb: Host for dense vector indexing.redis: Password-protected cache and queue broker.api: FastAPI service (automatically executes Alembic migrations on startup).worker: Background ingestion worker.frontend: React client running onhttp://localhost:3000.
- Endpoint:
POST /api/v1/documents - Content-Type:
multipart/form-data - Request Body:
file: PDF file (max 25MB)
- Response (200 OK):
{ "id": "78642c60-6d91-41c0-8450-22599d92460e", "filename": "payment_agreement.pdf", "status": "pending", "page_count": 0, "chunk_count": 0, "created_at": "2026-05-20T15:00:00.000000", "updated_at": "2026-05-20T15:00:00.000000" }
- Endpoint:
GET /api/v1/documents/{doc_id} - Response (200 OK):
{ "id": "78642c60-6d91-41c0-8450-22599d92460e", "filename": "payment_agreement.pdf", "status": "ready", "page_count": 3, "chunk_count": 5, "created_at": "2026-05-20T15:00:00.000000", "updated_at": "2026-05-20T15:00:15.000000" }
- Endpoint:
DELETE /api/v1/documents/{doc_id} - Response:
204 No Content(removes database records, Chroma vectors, raw upload files, and invalidates search caches).
- Endpoint:
POST /api/v1/search - Request Body:
{ "query": "billing frequency under the payment agreement", "doc_ids": null, "top_k": 10, "use_reranker": true, "search_mode": "hybrid" } - Response (200 OK):
{ "results": [ { "chunk_id": "834c9c14-5d98-5c4d-91b7-d1a1b5c2d3e4", "doc_id": "78642c60-6d91-41c0-8450-22599d92460e", "doc_filename": "payment_agreement.pdf", "page_num": 1, "snippet": "The billing frequency and schedule under this agreement shall be strictly <mark>monthly</mark>. Invoices will be generated...", "text": "The billing frequency and schedule under this agreement shall be strictly monthly. Invoices will be generated and delivered to the client on the first day of each calendar month.", "score": 0.8923, "dense_score": 0.9123, "sparse_score": 0.6543, "rerank_score": 0.8923 } ], "query": "billing frequency under the payment agreement", "total_results": 1, "latency_ms": 14.5, "search_mode": "hybrid", "reranker_used": true }
The system implements a standalone evaluation suite measuring information retrieval accuracy. The ground-truth test suite consists of 45 test queries (divided into 15 keyword, 15 semantic, and 15 hybrid queries) evaluated against 3 legal contracts.
A full analysis of per-category accuracy breakdown and stage-level latency is available in the standalone Retrieval Ablation Evaluation Report.
Latency should be read with the report's measurement protocol. The current saved results use one warm-up run and three measured runs, so they represent warm-cache retrieval latency rather than live Gemini embedding latency.
- Mean Reciprocal Rank (MRR): Evaluates the rank position of the first relevant result.
- NDCG@10: Evaluates rank quality using graded relevance with logarithmic reduction.
- Precision@5: Evaluates the ratio of relevant results in the top 5 returned elements.
| Configuration | MRR | NDCG@10 | Precision@5 | Latency (ms) |
|---|---|---|---|---|
| Dense Only | 0.9370 | 0.9532 | 0.2000 | 10.00 ms |
| Sparse Only | 0.3778 | 0.3778 | 0.0756 | 1.06 ms |
| Hybrid RRF | 0.9370 | 0.9532 | 0.2000 | 11.51 ms |
| Hybrid + Rerank (Chunk=1024) | 0.9333 | 0.9503 | 0.2000 | 17.97 ms |
| Hybrid + Rerank (Chunk=256) | 0.7796 | 0.8072 | 0.1778 | 11.65 ms |
- Recommended configuration: Use Hybrid RRF with 1024-token chunks for this benchmark. It matches Dense Only accuracy at
0.9370MRR, keeps warm-cache latency low at11.51 ms, preserves sparse exact-match coverage as the corpus grows, and avoids the re-ranker's semantic-query regression. - Re-ranking slightly decreases overall MRR versus Hybrid RRF: Hybrid RRF reaches
0.9370MRR, while Hybrid + Rerank (Chunk=1024) reaches0.9333. The drop appears in semantic queries, which supports the hypothesis that a general MS MARCO-style re-ranker can be domain-mismatched for legal clause retrieval. - Dense retrieval dominates the aggregate set: Dense Only reaches
0.9370MRR while Sparse Only reaches0.3778, confirming the current legal queries are better served by embedding semantics than keyword overlap alone. - Hybrid RRF matches Dense Only in this run: Dense Only and Hybrid RRF both reach
0.9370MRR and0.9532NDCG@10. Sparse retrieval remains useful as an exact-match safety net, but the current corpus does not show an aggregate lift. - Precision@5 is capped by the ground truth design: Each query currently has one relevant chunk, so the best possible Precision@5 is
1 / 5 = 0.2000. The next evaluation pass should label 2-3 relevant chunks per query with graded relevance so NDCG@10 can differentiate close configurations. - The warmed local run meets the 350ms latency target: Dense Only averages
10.00 msafter one warm-up run and three measured runs. Treat this as warm-cache retrieval latency, because stage timings show cached/local embeddings rather than live Gemini network calls. - Reducing chunk size significantly decreases retrieval accuracy: Reducing chunk size from 1024 tokens (
Hybrid + Rerank (Chunk=1024)at0.9333MRR) to 256 tokens (Hybrid + Rerank (Chunk=256)at0.7796MRR) reduces MRR by-0.1537. Smaller chunk sizes restrict the semantic context of legal clauses, causing retrieval gaps.
Verify the code syntax and mock interfaces locally:
# PowerShell (Windows)
$env:PYTHONPATH="backend"
pytest backend/tests
# Bash (Linux/macOS)
PYTHONPATH=backend pytest backend/testsRun the full ablation metrics generator:
# PowerShell (Windows)
$env:PYTHONPATH="backend"
python backend/eval/runner.py
# Bash (Linux/macOS)
PYTHONPATH=backend python backend/eval/runner.pyThis regenerates backend/eval/datasets/evaluation_results.json and updates the backend/eval/report.md metrics table.
Run the full automated integration pipeline against your active Docker stack:
# PowerShell (Windows)
$env:PYTHONPATH="backend"
python backend/eval/integration_test.py
# Bash (Linux/macOS)
PYTHONPATH=backend python backend/eval/integration_test.pyThis test covers generating test PDFs, calling API upload and checking statuses, validating query highlighting, verifying Redis cache invalidation, invoking the evaluation suite, and cleaning up records via DELETE calls.