1xMezoo's picture
Deploy News Search Engine to Hugging Face Spaces
39ff835
Raw
History Blame Contribute Delete
5.49 kB
"""FastAPI backend for the News Search Engine.
Endpoints
---------
GET /api/health -> liveness + index stats
GET /api/categories -> list of categories (for the UI filter)
GET /api/search -> run a search
The index is loaded once at startup from ``artifacts/index.pkl`` (build it with
``python scripts/build_index.py``). If that file is missing, the API builds an
index from ``data/sample_news.jsonl`` on the fly so the app still works.
In production the built React app (``frontend/dist``) is served as static files,
so the whole thing runs as a single service.
"""
from __future__ import annotations
import os
import pickle
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from news_search import InvertedIndex, SearchEngine, build_index, load_corpus # noqa: E402
from news_search.engine import METHODS # noqa: E402
INDEX_PATH = Path(os.getenv("INDEX_PATH", ROOT / "artifacts" / "index.pkl"))
SAMPLE_PATH = ROOT / "data" / "sample_news.jsonl"
DENSE_PATH = INDEX_PATH.with_name("dense.pkl")
state: dict = {}
def _load_engine() -> SearchEngine:
# NOTE: index.pkl / dense.pkl are loaded with ``pickle``, which executes
# arbitrary code on load. These artifacts are produced locally by
# ``scripts/build_index.py`` and are trusted; never point INDEX_PATH at a
# file from an untrusted source.
if INDEX_PATH.exists():
print(f"Loading index from {INDEX_PATH}")
index = InvertedIndex.load(INDEX_PATH)
else:
print(f"Index not found at {INDEX_PATH}; building from sample {SAMPLE_PATH}")
index = build_index(load_corpus(SAMPLE_PATH), verbose=False)
dense = None
if DENSE_PATH.exists():
try:
with DENSE_PATH.open("rb") as fh:
dense = pickle.load(fh)
print("Loaded dense (BERT) retriever.")
except Exception as exc: # pragma: no cover
print(f"Could not load dense retriever: {exc}")
return SearchEngine(index, dense=dense)
@asynccontextmanager
async def lifespan(app: FastAPI):
engine = _load_engine()
state["engine"] = engine
print(
f">>> Ready: {engine.index.num_docs:,} documents | "
f"{engine.index.vocabulary_size:,} terms | "
f"BERT {'ENABLED' if engine.dense is not None else 'disabled'}"
)
yield
state.clear()
app = FastAPI(title="News Search Engine API", version="1.0.0", lifespan=lifespan)
# CORS is only needed for the Vite dev server (npm run dev on :5173); the built
# app is served same-origin from :8000. Scope to localhost dev origins by default;
# override with a comma-separated CORS_ORIGINS env var if deploying elsewhere.
_CORS_ORIGINS = [
o.strip()
for o in os.getenv(
"CORS_ORIGINS", "http://localhost:5173,http://localhost:8000"
).split(",")
if o.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_methods=["GET"],
allow_headers=["*"],
)
def _engine() -> SearchEngine:
"""Return the loaded engine or a clean 503 if startup hasn't finished."""
engine = state.get("engine")
if engine is None:
raise HTTPException(503, "Search engine is not ready yet.")
return engine
@app.get("/api/health")
def health():
engine = _engine()
return {
"status": "ok",
"documents": engine.index.num_docs,
"vocabulary": engine.index.vocabulary_size,
"bert_available": engine.dense is not None,
"methods": list(METHODS),
}
@app.get("/api/categories")
def categories():
engine = _engine()
return {"categories": engine.categories}
@app.get("/api/search")
def search(
q: str = Query(..., min_length=1, max_length=512, description="Search query"),
method: Optional[str] = Query(None, description=f"One of {METHODS}; defaults to Hybrid"),
top_k: int = Query(10, ge=1, le=50),
category: Optional[str] = Query(None, max_length=100),
relevant_ids: Optional[List[int]] = Query(
None, description="Doc ids the user marked relevant (relevance feedback for 'prf')."
),
):
engine = _engine()
# Default to Hybrid (BM25 + BERT fusion) when embeddings are available, else
# BM25 so a bare request never fails on a lite (no-BERT) deployment.
if method is None:
method = "hybrid" if engine.dense is not None else "bm25"
if method not in METHODS:
raise HTTPException(400, f"Unknown method '{method}'. Choose from {list(METHODS)}.")
if method in ("bert", "hybrid") and engine.dense is None:
raise HTTPException(
400,
"Dense BERT retrieval is not available on this deployment. "
"Rebuild the index with `--bert` to enable it.",
)
return engine.search(
q, method=method, top_k=top_k, category=category, relevant_ids=relevant_ids
).to_dict()
# --- Serve the built frontend (if present) -------------------------------- #
_DIST = ROOT / "frontend" / "dist"
if _DIST.exists():
app.mount("/assets", StaticFiles(directory=_DIST / "assets"), name="assets")
@app.get("/")
def _index():
return FileResponse(_DIST / "index.html")