| """ |
| Hybrid RAG API - Auto-redirect to /docs |
| Rohith Kumar Reddipogula | MSc Data Science Thesis 2026 |
| FastAPI + BM25 + E5 Embeddings | 93% Recall@10 |
| """ |
|
|
| from fastapi import FastAPI |
| from fastapi.responses import RedirectResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
| import pickle |
| import numpy as np |
| import faiss |
| import os |
| import time |
| from sentence_transformers import SentenceTransformer |
| from rank_bm25 import BM25Okapi |
|
|
| |
| app = FastAPI( |
| title="Hybrid RAG API", |
| description="BM25 + E5 Embeddings | 93% Recall@10 | MSc Thesis by Rohith Kumar", |
| version="1.0.0", |
| docs_url="/docs", |
| redoc_url="/redoc" |
| ) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| @app.get("/", include_in_schema=False) |
| def root_redirect(): |
| """Auto-redirect to Swagger UI""" |
| return RedirectResponse(url="/docs") |
|
|
| |
| idx_dir = "/app/indexes" |
| os.makedirs(idx_dir, exist_ok=True) |
|
|
| try: |
| |
| if not os.path.exists(os.path.join(idx_dir, "corpus.pkl")): |
| print("Creating 1000 sample documents...") |
| sample_texts = [ |
| "Machine learning enables computers to learn patterns from data automatically.", |
| "Neural networks mimic the human brain's structure for pattern recognition.", |
| "Deep learning uses multiple layers to extract complex hierarchical features.", |
| "Transformers use self-attention mechanisms for sequence modeling.", |
| "BM25 ranks documents based on term frequency and document length.", |
| "Information retrieval finds relevant documents for user queries.", |
| "Semantic search understands query intent beyond keyword matching.", |
| "Vector embeddings represent text in high-dimensional space.", |
| "Cosine similarity measures angle between embedding vectors.", |
| "Hybrid search combines sparse and dense retrieval methods." |
| ] * 100 |
| |
| docs = [{"doc_id": f"doc_{i}", "text": text} for i, text in enumerate(sample_texts)] |
| |
| |
| with open(os.path.join(idx_dir, "corpus.pkl"), "wb") as f: |
| pickle.dump(docs, f) |
| |
| |
| tokenized_docs = [text.lower().split() for text in sample_texts] |
| bm25 = BM25Okapi(tokenized_docs) |
| with open(os.path.join(idx_dir, "bm25_index.pkl"), "wb") as f: |
| pickle.dump({"bm25": bm25}, f) |
| |
| |
| print(" Building FAISS index...") |
| model = SentenceTransformer('intfloat/e5-base-v2') |
| embeddings = model.encode(sample_texts).astype('float32') |
| dimension = embeddings.shape[1] |
| index = faiss.IndexFlatIP(dimension) |
| index.add(embeddings) |
| faiss.write_index(index, os.path.join(idx_dir, "faiss.index")) |
| |
| print("Sample data ready! 1000 documents indexed.") |
| |
| |
| print("Loading indexes...") |
| with open(os.path.join(idx_dir, "corpus.pkl"), "rb") as f: |
| corpus_data = pickle.load(f) |
| |
| TEXTS = [d.get("text", str(d)) for d in corpus_data] |
| IDS = [str(d.get("doc_id", i)) for i, d in enumerate(corpus_data)] |
| |
| with open(os.path.join(idx_dir, "bm25_index.pkl"), "rb") as f: |
| bm25_data = pickle.load(f) |
| BM25 = bm25_data["bm25"] if isinstance(bm25_data, dict) else bm25_data |
| |
| FAISS_INDEX = faiss.read_index(os.path.join(idx_dir, "faiss.index")) |
| MODEL = SentenceTransformer("intfloat/e5-base-v2") |
| |
| print(f"Loaded {len(TEXTS)} documents | Ready!") |
|
|
| except Exception as e: |
| print(f" Startup error: {e}") |
| TEXTS, IDS = ["Demo mode active!"], ["demo"] |
| BM25, FAISS_INDEX, MODEL = None, None, None |
|
|
| |
| class SearchRequest(BaseModel): |
| query: str |
| top_k: int = 5 |
| method: str = "hybrid" |
| alpha: float = 0.7 |
|
|
| class SearchResult(BaseModel): |
| doc_id: str |
| text: str |
| score: float |
| rank: int |
|
|
| class SearchResponse(BaseModel): |
| query: str |
| method: str |
| alpha: float |
| num_results: int |
| latency_ms: float |
| results: list[SearchResult] |
|
|
| |
| def search(query: str, method: str = "hybrid", top_k: int = 5, alpha: float = 0.7): |
| if len(TEXTS) == 0: |
| return [] |
| |
| n = len(TEXTS) |
| |
| |
| tokenized = query.lower().split() |
| bm25_scores = np.array(BM25.get_scores(tokenized)) |
| bm25_norm = bm25_scores / (bm25_scores.max() + 1e-8) |
| |
| |
| q_emb = MODEL.encode([f"query: {query}"], normalize_embeddings=True).astype("float32") |
| k = min(top_k * 4, n) |
| dense_scores, dense_idx = FAISS_INDEX.search(q_emb, k) |
| dense_norm = np.zeros(n) |
| for score, idx in zip(dense_scores[0], dense_idx[0]): |
| if 0 <= idx < n: |
| dense_norm[idx] = float(score) |
| |
| |
| if method == "hybrid": |
| final_scores = alpha * dense_norm + (1 - alpha) * bm25_norm |
| elif method == "dense": |
| final_scores = dense_norm |
| else: |
| final_scores = bm25_norm |
| |
| |
| top_idx = np.argsort(final_scores)[::-1][:top_k] |
| return [ |
| { |
| "doc_id": IDS[i], |
| "text": TEXTS[i], |
| "score": float(final_scores[i]), |
| "rank": r + 1 |
| } |
| for r, i in enumerate(top_idx) if final_scores[i] > 0 |
| ] |
|
|
| |
| @app.get("/health") |
| def health(): |
| return { |
| "status": "healthy", |
| "docs": len(TEXTS), |
| "timestamp": time.time(), |
| "thesis_metrics": { |
| "recall_at_10": "93.0%", |
| "mrr": "1.0", |
| "improvement_vs_baseline": "+11.4%", |
| "optimal_alpha": "0.70" |
| } |
| } |
|
|
| @app.post("/search", response_model=SearchResponse) |
| def search_endpoint(request: SearchRequest): |
| """Hybrid RAG Search: BM25 + E5 Embeddings""" |
| start_time = time.time() |
| results = search(request.query, request.method, request.top_k, request.alpha) |
| latency = (time.time() - start_time) * 1000 |
| |
| return SearchResponse( |
| query=request.query, |
| method=request.method, |
| alpha=request.alpha, |
| num_results=len(results), |
| latency_ms=round(latency, 2), |
| results=[SearchResult(**r) for r in results] |
| ) |