| """ |
| backend/main.py |
| --------------- |
| FastAPI RAG backend. |
| |
| Endpoints: |
| POST /query → full pipeline, returns JSON answer + sources |
| POST /query/stream → streaming SSE answer |
| GET /health → liveness check |
| GET /docs → Swagger UI (auto) |
| """ |
|
|
| from __future__ import annotations |
| import sys |
| import os |
|
|
| |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|
|
| from dotenv import load_dotenv |
|
|
| load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) |
|
|
| import time |
| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import StreamingResponse |
| from pydantic import BaseModel, Field |
|
|
| from ai.embedder import encode_query, get_model |
| from ai.retriever import retrieve |
| from ai.llm import generate_answer, stream_answer |
|
|
|
|
| |
|
|
|
|
| app = FastAPI( |
| title="Nepali Document RAG API", |
| description="BGE-M3 hybrid retrieval (Vespa) + Gemini answer generation", |
| version="1.0.0", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
|
|
| |
|
|
|
|
|
|
| class QueryRequest(BaseModel): |
| query: str = Field( |
| ..., min_length=1, max_length=1000, description="User query (Nepali or English)" |
| ) |
| top_k_retrieval: int = Field( |
| default=20, ge=1, le=100, description="Docs fetched from Vespa" |
| ) |
| top_k_context: int = Field(default=5, ge=1, le=20, description="Docs passed to LLM") |
|
|
|
|
| class SourceDoc(BaseModel): |
| id: str |
| text: str |
| relevance: float |
|
|
|
|
| class QueryResponse(BaseModel): |
| query: str |
| answer: str |
| sources: list[SourceDoc] |
| retrieval_time_ms: float |
| generation_time_ms: float |
| total_time_ms: float |
|
|
|
|
|
|
| |
|
|
|
|
|
|
| @app.on_event("startup") |
| async def startup_event(): |
| """Load BGE-M3 into memory at startup so first query isn't slow.""" |
| get_model() |
| print("BGE-M3 model loaded and ready.") |
|
|
|
|
|
|
| |
|
|
|
|
|
|
| @app.get("/health") |
| def health(): |
| return {"status": "ok"} |
|
|
|
|
| @app.post("/query", response_model=QueryResponse) |
| def query_endpoint(req: QueryRequest): |
| """ |
| Full RAG pipeline: |
| 1. Embed query with BGE-M3 (dense + sparse + ColBERT) |
| 2. Retrieve top-K from Vespa (HNSW + BM25, two-phase ColBERT rerank) |
| 3. Pass top context docs to Gemini |
| 4. Return answer + sources |
| """ |
| t0 = time.perf_counter() |
|
|
| |
| try: |
| embeddings = encode_query(req.query) |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=f"Embedding failed: {e}") |
|
|
| |
| t1 = time.perf_counter() |
| try: |
| hits = retrieve(req.query, embeddings, top_k=req.top_k_retrieval) |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=f"Vespa retrieval failed: {e}") |
| retrieval_ms = (time.perf_counter() - t1) * 1000 |
|
|
| |
| context_docs = hits[: req.top_k_context] |
|
|
| if not context_docs: |
| raise HTTPException(status_code=404, detail="No relevant documents found.") |
|
|
| |
| t2 = time.perf_counter() |
| try: |
| answer = generate_answer(req.query, context_docs) |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=f"Gemini generation failed: {e}") |
| generation_ms = (time.perf_counter() - t2) * 1000 |
|
|
| total_ms = (time.perf_counter() - t0) * 1000 |
|
|
| return QueryResponse( |
| query=req.query, |
| answer=answer, |
| sources=[SourceDoc(**d) for d in context_docs], |
| retrieval_time_ms=round(retrieval_ms, 1), |
| generation_time_ms=round(generation_ms, 1), |
| total_time_ms=round(total_ms, 1), |
| ) |
|
|
|
|
| @app.post("/query/stream") |
| def query_stream(req: QueryRequest): |
| """ |
| Streaming version — returns SSE text/event-stream. |
| Sources are sent as a final JSON line prefixed with 'data: [SOURCES]'. |
| """ |
| |
| try: |
| embeddings = encode_query(req.query) |
| hits = retrieve(req.query, embeddings, top_k=req.top_k_retrieval) |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=str(e)) |
|
|
| context_docs = hits[: req.top_k_context] |
|
|
| if not context_docs: |
| raise HTTPException(status_code=404, detail="No relevant documents found.") |
|
|
| import json |
|
|
| def event_generator(): |
| try: |
| |
| for chunk in stream_answer(req.query, context_docs): |
| if chunk: |
| |
| escaped_chunk = chunk.replace("\n", "\\n") |
| yield f"data: {escaped_chunk}\n\n" |
|
|
| |
| sources_payload = json.dumps( |
| [ |
| { |
| "id": d["id"], |
| "text": d["text"][:300] + "...", |
| "relevance": d["relevance"], |
| } |
| for d in context_docs |
| ] |
| ) |
| yield f"data: [SOURCES]{sources_payload}\n\n" |
| yield "data: [DONE]\n\n" |
| except Exception as e: |
| yield f"data: [ERROR]{str(e)}\n\n" |
|
|
| return StreamingResponse(event_generator(), media_type="text/event-stream") |
|
|