""" 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 # FastAPI App 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", # Swagger UI redoc_url="/redoc" # Alternative docs ) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # AUTO-REDIRECT ROOT TO /docs (YOUR REQUEST!) @app.get("/", include_in_schema=False) def root_redirect(): """Auto-redirect to Swagger UI""" return RedirectResponse(url="/docs") # SAFE Data Loading + Auto-Sample Creation idx_dir = "/app/indexes" os.makedirs(idx_dir, exist_ok=True) try: # Auto-create sample data if missing 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 # Exactly 1000 docs docs = [{"doc_id": f"doc_{i}", "text": text} for i, text in enumerate(sample_texts)] # Save corpus with open(os.path.join(idx_dir, "corpus.pkl"), "wb") as f: pickle.dump(docs, f) # BM25 index 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) # FAISS dense index 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.") # Load indexes 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 # Data Models 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] # Hybrid RAG Search def search(query: str, method: str = "hybrid", top_k: int = 5, alpha: float = 0.7): if len(TEXTS) == 0: return [] n = len(TEXTS) # Sparse: BM25 tokenized = query.lower().split() bm25_scores = np.array(BM25.get_scores(tokenized)) bm25_norm = bm25_scores / (bm25_scores.max() + 1e-8) # Dense: E5 embeddings 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) # Fusion if method == "hybrid": final_scores = alpha * dense_norm + (1 - alpha) * bm25_norm elif method == "dense": final_scores = dense_norm else: # sparse final_scores = bm25_norm # Top-K 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 ] # API Endpoints @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] )