RAG / backend /main.py
Anoobee's picture
Add application file
3ab5c83
Raw
History Blame Contribute Delete
5.39 kB
"""
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
# Allow importing from project root (ai/ package)
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 setup
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=["*"],
)
# Schemas
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
# Startup: warm up the model
@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.")
# Endpoints
@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()
# Step 1: Embed
try:
embeddings = encode_query(req.query)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Embedding failed: {e}")
# Step 2: Retrieve
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
# Trim to top_k_context for LLM (Vespa already ranked them)
context_docs = hits[: req.top_k_context]
if not context_docs:
raise HTTPException(status_code=404, detail="No relevant documents found.")
# Step 3: Generate
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]'.
"""
# Embed + retrieve synchronously first
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:
# Stream the answer tokens
for chunk in stream_answer(req.query, context_docs):
if chunk:
# Escape newlines in chunk for proper SSE format
escaped_chunk = chunk.replace("\n", "\\n")
yield f"data: {escaped_chunk}\n\n"
# Send sources at the end
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")