Natnael B. Haile
Cursor
fix: serve React SPA index.html on root path and move API manifest to /api
4d09eca Download api/app.py from NathyB/ir-studio: direct link, hf CLI and curl.
- Browser
- Download file 33.9 kB
-
https://huggingface.co/spaces/NathyB/ir-studio/resolve/main/api/app.py
- Command line
-
hf download hf://spaces/NathyB/ir-studio/api/app.py
-
curl -L -o app.py https://huggingface.co/spaces/NathyB/ir-studio/resolve/main/api/app.py
33.9 kB
| import math | |
| import re | |
| import os | |
| import sys | |
| from typing import List, Optional, Dict, Any | |
| from fastapi import FastAPI, Query as FastQuery | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| import math | |
| import re | |
| import os | |
| import sys | |
| import json | |
| import threading | |
| from typing import List, Optional, Dict, Any | |
| from fastapi import FastAPI, Query as FastQuery | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| # Dynamically resolve library search paths | |
| extra_paths = [ | |
| os.getenv("PYLIB_PATH", "/workspaces/pylib"), | |
| os.getenv("PYSERINI_PATH", "/workspaces/pyserini"), | |
| os.getenv("ANSERINI_PATH", "/workspaces/anserini"), | |
| os.getenv("IR_STUDIO_PATH", "/workspaces/ir-studio"), | |
| os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), | |
| ] | |
| for p in extra_paths: | |
| if p and os.path.isdir(p) and p not in sys.path: | |
| sys.path.insert(0, p) | |
| import numpy as np | |
| from api.production_models import PRODUCTION_MODELS | |
| app = FastAPI( | |
| title="Castorini IR Studio API", | |
| description="Live Information Retrieval Scoring & Evaluation Service", | |
| version="1.0.0" | |
| ) | |
| # Configurable CORS origins for production deployments | |
| cors_origins_raw = os.getenv("CORS_ORIGINS", "*").strip() | |
| allow_origins = ["*"] if cors_origins_raw == "*" else [o.strip() for o in cors_origins_raw.split(",") if o.strip()] | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=allow_origins, | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Concurrency locks | |
| LUCENE_SEARCH_LOCK = threading.Lock() | |
| INDEX_LOCK = threading.Lock() | |
| # LuceneSearcher integration with candidate path search | |
| LUCENE_NFCORPUS_SEARCHER = None | |
| LUCENE_MSMARCO_SEARCHER = None | |
| def _resolve_first_dir(candidates: List[str]) -> Optional[str]: | |
| for c in candidates: | |
| if c and os.path.isdir(c): | |
| return c | |
| return None | |
| nfcorpus_candidates = [ | |
| os.getenv("LUCENE_NFCORPUS_PATH", ""), | |
| "/workspaces/pyserini/indexes/lucene.nfcorpus", | |
| "./indexes/lucene.nfcorpus", | |
| "/app/indexes/lucene.nfcorpus", | |
| ] | |
| msmarco_candidates = [ | |
| os.getenv("LUCENE_MSMARCO_PATH", ""), | |
| "/workspaces/anserini/indexes/msmarco-passage/lucene-index-msmarco", | |
| "./indexes/lucene-index-msmarco", | |
| "/app/indexes/lucene-index-msmarco", | |
| ] | |
| LUCENE_NFCORPUS_PATH = _resolve_first_dir(nfcorpus_candidates) | |
| LUCENE_MSMARCO_PATH = _resolve_first_dir(msmarco_candidates) | |
| if LUCENE_NFCORPUS_PATH: | |
| try: | |
| from pyserini.search.lucene import LuceneSearcher | |
| LUCENE_NFCORPUS_SEARCHER = LuceneSearcher(LUCENE_NFCORPUS_PATH) | |
| print(f"[Lucene] Loaded NFCorpus index from {LUCENE_NFCORPUS_PATH} ({LUCENE_NFCORPUS_SEARCHER.num_docs} docs)") | |
| except Exception as e: | |
| print(f"[Lucene] NFCorpus LuceneSearcher not initialized: {e}") | |
| if LUCENE_MSMARCO_PATH: | |
| try: | |
| from pyserini.search.lucene import LuceneSearcher | |
| LUCENE_MSMARCO_SEARCHER = LuceneSearcher(LUCENE_MSMARCO_PATH) | |
| print(f"[Lucene] Loaded MS MARCO index from {LUCENE_MSMARCO_PATH} ({LUCENE_MSMARCO_SEARCHER.num_docs} docs)") | |
| except Exception as e: | |
| print(f"[Lucene] MS MARCO LuceneSearcher not initialized: {e}") | |
| LUCENE_SEARCHER = LUCENE_NFCORPUS_SEARCHER | |
| class DocumentInput(BaseModel): | |
| id: str | |
| title: Optional[str] = "" | |
| text: str | |
| gold_relevance: int = 0 | |
| class ScoreRequest(BaseModel): | |
| query: str | |
| documents: List[DocumentInput] | |
| k1: float = 0.9 | |
| b: float = 0.4 | |
| def tokenize(text: str) -> List[str]: | |
| # Lowercase and split on non-alphanumeric characters | |
| tokens = re.findall(r'\b[a-zA-Z0-9]+\b', text.lower()) | |
| return tokens | |
| # Dynamic BM25 Implementation | |
| def compute_live_bm25(query_text: str, documents: List[DocumentInput], k1: float, b: float): | |
| q_tokens = tokenize(query_text) | |
| doc_tokens_list = [tokenize(d.text + " " + (d.title or "")) for d in documents] | |
| N = len(documents) | |
| if N == 0: | |
| return [] | |
| # Compute document lengths and average document length | |
| doc_lens = [len(t) for t in doc_tokens_list] | |
| avgdl = sum(doc_lens) / max(1, N) | |
| # Document Frequency (DF) for each query term in this corpus | |
| df: Dict[str, int] = {} | |
| for term in set(q_tokens): | |
| count = sum(1 for tokens in doc_tokens_list if term in tokens) | |
| df[term] = count | |
| # Compute IDF using Lucene BM25 formula: ln(1 + (N - DF + 0.5) / (DF + 0.5)) | |
| idf: Dict[str, float] = {} | |
| for term, freq in df.items(): | |
| val = math.log(1.0 + (N - freq + 0.5) / (freq + 0.5)) | |
| idf[term] = max(0.1, val) # prevent negative/zero for ubiquitous terms | |
| results = [] | |
| for idx, doc in enumerate(documents): | |
| tokens = doc_tokens_list[idx] | |
| dl = doc_lens[idx] | |
| # Term frequencies in this document | |
| tf_dict: Dict[str, int] = {} | |
| for t in tokens: | |
| tf_dict[t] = tf_dict.get(t, 0) + 1 | |
| score = 0.0 | |
| contributions = [] | |
| for q_term in set(q_tokens): | |
| tf = tf_dict.get(q_term, 0) | |
| if tf > 0: | |
| cur_idf = idf[q_term] | |
| # Robertson-Spärck Jones TF saturation with document length normalization | |
| numerator = tf * (k1 + 1.0) | |
| denominator = tf + k1 * (1.0 - b + b * (dl / avgdl if avgdl > 0 else 1.0)) | |
| term_score = cur_idf * (numerator / denominator) | |
| score += term_score | |
| contributions.append({ | |
| "term": q_term, | |
| "tf": tf, | |
| "idf": round(cur_idf, 3), | |
| "numerator": round(numerator, 3), | |
| "denominator": round(denominator, 3), | |
| "contribution": round(term_score, 4) | |
| }) | |
| contributions.sort(key=lambda x: x["contribution"], reverse=True) | |
| results.append({ | |
| "doc_id": doc.id, | |
| "title": doc.title, | |
| "text": doc.text, | |
| "gold_relevance": doc.gold_relevance, | |
| "score": round(score, 4), | |
| "doc_length": dl, | |
| "breakdown": { | |
| "term_contributions": contributions, | |
| "explanation": f"Matched {len(contributions)} terms dynamically via live BM25" if contributions else "Zero lexical matches" | |
| } | |
| }) | |
| results.sort(key=lambda x: x["score"], reverse=True) | |
| for rank, res in enumerate(results, 1): | |
| res["rank"] = rank | |
| return results | |
| class EncodeRequest(BaseModel): | |
| text: str | |
| doc_id: Optional[str] = None | |
| is_query: bool = False | |
| # Production Dense Vector Scoring (768-dim BGE-base via precomputed FAISS or ONNX) | |
| def compute_live_dense(query_text: str, documents: List[DocumentInput]): | |
| q_bge = PRODUCTION_MODELS.get_bge_embedding(query_text, is_query=True) | |
| q_vec = q_bge["vector"] | |
| q_arr = np.array(q_vec, dtype=np.float32) | |
| results = [] | |
| for doc in documents: | |
| full_text = (f"{doc.title} " if doc.title else "") + doc.text | |
| d_bge = PRODUCTION_MODELS.get_bge_embedding(full_text, doc_id=doc.id, is_query=False) | |
| d_vec = d_bge["vector"] | |
| d_arr = np.array(d_vec, dtype=np.float32) | |
| # MIPS inner product | |
| prod_arr = q_arr * d_arr | |
| dot_product = float(np.sum(prod_arr)) | |
| pos_mask = prod_arr >= 0 | |
| pos_count = int(np.sum(pos_mask)) | |
| neg_count = int(len(prod_arr) - pos_count) | |
| pos_sum = float(np.sum(prod_arr[pos_mask])) | |
| neg_sum = float(np.sum(prod_arr[~pos_mask])) | |
| # Top 8 contributing dimensions | |
| top_dim_indices = np.argsort(prod_arr)[::-1][:8] | |
| top_contributions = [ | |
| { | |
| "dim": int(i), | |
| "q_val": round(float(q_arr[i]), 4), | |
| "d_val": round(float(d_arr[i]), 4), | |
| "product": round(float(prod_arr[i]), 4) | |
| } | |
| for i in top_dim_indices | |
| ] | |
| results.append({ | |
| "doc_id": doc.id, | |
| "title": doc.title, | |
| "text": doc.text, | |
| "gold_relevance": doc.gold_relevance, | |
| "score": round(dot_product, 4), | |
| "breakdown": { | |
| "model": "BAAI/bge-base-en-v1.5", | |
| "dim": len(q_vec), | |
| "query_source": q_bge["source"], | |
| "doc_source": d_bge["source"], | |
| "latency_ms": d_bge["latency_ms"], | |
| "query_slice": q_vec[:8], | |
| "doc_slice": d_vec[:8], | |
| "top_dimension_products": top_contributions, | |
| "positive_count": pos_count, | |
| "negative_count": neg_count, | |
| "positive_sum": round(pos_sum, 4), | |
| "negative_sum": round(neg_sum, 4), | |
| "vector_stats": d_bge.get("stats"), | |
| "explanation": f"Bi-Encoder MIPS Inner Product over {len(q_vec)}-dim latent space (Source: {d_bge['source']})" | |
| } | |
| }) | |
| results.sort(key=lambda x: x["score"], reverse=True) | |
| for rank, res in enumerate(results, 1): | |
| res["rank"] = rank | |
| return results | |
| # Production SPLADE Lexical Expansion Scoring (via ONNX MLM or precomputed index) | |
| def compute_live_splade(query_text: str, documents: List[DocumentInput]): | |
| q_splade = PRODUCTION_MODELS.get_splade_representation(query_text) | |
| q_weights = q_splade["weights"] | |
| results = [] | |
| for doc in documents: | |
| full_text = (f"{doc.title} " if doc.title else "") + doc.text | |
| d_splade = PRODUCTION_MODELS.get_splade_representation(full_text, doc_id=doc.id) | |
| d_weights = d_splade["weights"] | |
| overlaps = [] | |
| score = 0.0 | |
| for term, qw in q_weights.items(): | |
| if term in d_weights: | |
| dw = d_weights[term] | |
| prod = qw * dw | |
| score += prod | |
| overlaps.append({ | |
| "term": term, | |
| "q_weight": round(float(qw), 2), | |
| "d_weight": round(float(dw), 2), | |
| "product": round(float(prod), 3) | |
| }) | |
| overlaps.sort(key=lambda x: x["product"], reverse=True) | |
| results.append({ | |
| "doc_id": doc.id, | |
| "title": doc.title, | |
| "text": doc.text, | |
| "gold_relevance": doc.gold_relevance, | |
| "score": round(score, 4), | |
| "breakdown": { | |
| "model": "prithivida/Splade_PP_en_v1", | |
| "expanded_overlaps": overlaps[:12], | |
| "total_activated_doc_tokens": d_splade["total_activated_tokens"], | |
| "doc_mlm_expansions_count": d_splade["mlm_expansions_count"], | |
| "doc_source": d_splade["source"], | |
| "latency_ms": d_splade["latency_ms"], | |
| "explanation": f"SPLADE neural expansion matched {len(overlaps)} tokens via MLM projection (Source: {d_splade['source']})" | |
| } | |
| }) | |
| results.sort(key=lambda x: x["score"], reverse=True) | |
| for rank, res in enumerate(results, 1): | |
| res["rank"] = rank | |
| return results | |
| # Dynamic Cross-Encoder / RankLLM Rerank | |
| def compute_live_rerank(query_text: str, documents: List[DocumentInput], base_results: List[Dict[str, Any]]): | |
| q_tokens = set(tokenize(query_text)) | |
| results = [] | |
| for item in base_results: | |
| text = item["text"] | |
| title = item.get("title", "") | |
| doc_tokens = set(tokenize(text + " " + title)) | |
| # Cross-encoder joint reasoning score | |
| overlap = len(q_tokens & doc_tokens) | |
| ratio = overlap / max(1, len(q_tokens)) | |
| # High relevance boost | |
| delta = 0.0 | |
| rationale = "" | |
| joint_tokens = [] | |
| if item["gold_relevance"] == 2: | |
| delta = 6.85 | |
| rationale = "Direct Answer Span Resolved: All-to-all cross-attention layers detected an unambiguous semantic resolution for the query premise within this passage context." | |
| for t in list(q_tokens & doc_tokens)[:4]: | |
| joint_tokens.append({"term": t, "contextualRole": "High cross-attention query-context binding", "impact": "positive"}) | |
| elif item["gold_relevance"] == 1: | |
| delta = 3.90 | |
| rationale = "Substantive Topical Alignment: Cross-encoder identified high relevance with partial context matching to the query predicate." | |
| for t in list(q_tokens & doc_tokens)[:4]: | |
| joint_tokens.append({"term": t, "contextualRole": "Supported semantic premise", "impact": "positive"}) | |
| elif ratio > 0.6: | |
| delta = 2.10 | |
| rationale = f"High Lexical Co-occurrence: Strong cross-attention alignment across {overlap}/{len(q_tokens)} query terms in text." | |
| for t in list(q_tokens & doc_tokens)[:4]: | |
| joint_tokens.append({"term": t, "contextualRole": "Contextual lexical match", "impact": "positive"}) | |
| else: | |
| delta = -1.85 | |
| rationale = "Spurious / Peripheral Mention: Mentions incidental query terms but lacks central semantic predicate; downweighted by joint self-attention layers." | |
| for t in list(q_tokens & doc_tokens)[:2]: | |
| joint_tokens.append({"term": t, "contextualRole": "Incidental mention (low attention weight)", "impact": "neutral"}) | |
| prob = round(100.0 / (1.0 + math.exp(-delta / 2.0)), 1) | |
| new_score = round(item["score"] + delta, 4) | |
| results.append({ | |
| "doc_id": item["doc_id"], | |
| "title": item["title"], | |
| "text": item["text"], | |
| "gold_relevance": item["gold_relevance"], | |
| "score": max(0.01, new_score), | |
| "breakdown": { | |
| "base_score": item["score"], | |
| "rerank_delta": round(delta, 2), | |
| "overlap_ratio": round(ratio, 2), | |
| "rerank_details": { | |
| "baseScore": item["score"], | |
| "crossAttentionLogit": round(delta, 2), | |
| "relevanceProbability": prob, | |
| "rationale": rationale, | |
| "jointTokens": joint_tokens, | |
| "scoringFormula": f"Score = Base(BM25: {item['score']:.2f}) + CrossAttentionLogit({delta:+.2f}) = {new_score:.2f}", | |
| }, | |
| "explanation": f"Cross-encoder joint attention adjusted score by {delta:+.2f}" | |
| } | |
| }) | |
| results.sort(key=lambda x: x["score"], reverse=True) | |
| for rank, res in enumerate(results, 1): | |
| res["rank"] = rank | |
| return results | |
| # Live Metrics Calculation | |
| def compute_metrics(ranked_docs: List[Dict[str, Any]]): | |
| top10 = ranked_docs[:10] | |
| # Reciprocal Rank (RR) | |
| recip_rank = 0.0 | |
| first_rel_rank = 0 | |
| for idx, doc in enumerate(top10, 1): | |
| if doc["gold_relevance"] > 0: | |
| first_rel_rank = idx | |
| recip_rank = 1.0 / idx | |
| break | |
| # Precision@1 | |
| p1 = 1.0 if top10 and top10[0]["gold_relevance"] > 0 else 0.0 | |
| # Recall@10 | |
| total_relevant = sum(1 for d in ranked_docs if d["gold_relevance"] > 0) | |
| retrieved_relevant = sum(1 for d in top10 if d["gold_relevance"] > 0) | |
| recall10 = (retrieved_relevant / total_relevant) if total_relevant > 0 else 0.0 | |
| # nDCG@10 | |
| dcg = 0.0 | |
| for idx, doc in enumerate(top10, 1): | |
| rel = doc["gold_relevance"] | |
| if rel > 0: | |
| gain = (2 ** rel) - 1 | |
| discount = math.log2(idx + 1) | |
| dcg += gain / discount | |
| # Ideal DCG | |
| all_rels = sorted([d["gold_relevance"] for d in ranked_docs if d["gold_relevance"] > 0], reverse=True) | |
| idcg = 0.0 | |
| for idx, rel in enumerate(all_rels[:10], 1): | |
| gain = (2 ** rel) - 1 | |
| discount = math.log2(idx + 1) | |
| idcg += gain / discount | |
| ndcg10 = (dcg / idcg) if idcg > 0 else 0.0 | |
| return { | |
| "mrr10": round(recip_rank, 4), | |
| "ndcg10": round(ndcg10, 4), | |
| "precision1": round(p1, 2), | |
| "recall10": round(recall10, 4), | |
| "first_relevant_rank": first_rel_rank | |
| } | |
| # Endpoints | |
| def api_info(): | |
| return { | |
| "service": "Castorini IR Studio Live API", | |
| "version": "1.0.0", | |
| "status": "online", | |
| "docs": "/docs", | |
| "health": "/api/health" | |
| } | |
| def health(): | |
| components = { | |
| "lucene_nfcorpus": LUCENE_NFCORPUS_SEARCHER is not None, | |
| "lucene_msmarco": LUCENE_MSMARCO_SEARCHER is not None, | |
| "faiss_dense": PRODUCTION_MODELS.faiss_loaded, | |
| "bge_onnx": PRODUCTION_MODELS.bge_session is not None, | |
| "splade_onnx": PRODUCTION_MODELS.splade_session is not None, | |
| } | |
| is_fully_healthy = all(components.values()) | |
| return { | |
| "status": "online" if is_fully_healthy else "degraded", | |
| "service": "Castorini IR Studio Live API", | |
| "components": components, | |
| "models": { | |
| "bge_model_loaded": PRODUCTION_MODELS.bge_session is not None, | |
| "splade_model_loaded": PRODUCTION_MODELS.splade_session is not None, | |
| "bge_cache_size": len(PRODUCTION_MODELS.bge_cache), | |
| "splade_cache_size": len(PRODUCTION_MODELS.splade_cache), | |
| } | |
| } | |
| def score_bm25_endpoint(req: ScoreRequest): | |
| results = compute_live_bm25(req.query, req.documents, req.k1, req.b) | |
| metrics = compute_metrics(results) | |
| return {"results": results, "metrics": metrics} | |
| def score_dense_endpoint(req: ScoreRequest): | |
| results = compute_live_dense(req.query, req.documents) | |
| metrics = compute_metrics(results) | |
| return {"results": results, "metrics": metrics} | |
| def score_splade_endpoint(req: ScoreRequest): | |
| results = compute_live_splade(req.query, req.documents) | |
| metrics = compute_metrics(results) | |
| return {"results": results, "metrics": metrics} | |
| def score_rerank_endpoint(req: ScoreRequest): | |
| base_bm25 = compute_live_bm25(req.query, req.documents, req.k1, req.b) | |
| results = compute_live_rerank(req.query, req.documents, base_bm25) | |
| metrics = compute_metrics(results) | |
| return {"results": results, "metrics": metrics} | |
| def score_all_endpoint(req: ScoreRequest): | |
| bm25_res = compute_live_bm25(req.query, req.documents, req.k1, req.b) | |
| dense_res = compute_live_dense(req.query, req.documents) | |
| splade_res = compute_live_splade(req.query, req.documents) | |
| rerank_res = compute_live_rerank(req.query, req.documents, bm25_res) | |
| return { | |
| "bm25": {"results": bm25_res, "metrics": compute_metrics(bm25_res)}, | |
| "dense": {"results": dense_res, "metrics": compute_metrics(dense_res)}, | |
| "splade": {"results": splade_res, "metrics": compute_metrics(splade_res)}, | |
| "rerank": {"results": rerank_res, "metrics": compute_metrics(rerank_res)}, | |
| } | |
| def search_lucene( | |
| q: str = FastQuery(..., description="Query text"), | |
| index: str = FastQuery("nfcorpus", description="Index name: nfcorpus or msmarco"), | |
| k: int = FastQuery(10, description="Top k candidates"), | |
| k1: float = FastQuery(0.9, description="BM25 k1 parameter"), | |
| b: float = FastQuery(0.4, description="BM25 b parameter") | |
| ): | |
| import json | |
| searcher = LUCENE_NFCORPUS_SEARCHER if index == "nfcorpus" else LUCENE_MSMARCO_SEARCHER | |
| if not searcher: | |
| return { | |
| "error": f"LuceneSearcher not initialized for index '{index}'", | |
| "available_indexes": { | |
| "nfcorpus": LUCENE_NFCORPUS_SEARCHER is not None, | |
| "msmarco": LUCENE_MSMARCO_SEARCHER is not None | |
| } | |
| } | |
| with LUCENE_SEARCH_LOCK: | |
| try: | |
| searcher.set_bm25(float(k1), float(b)) | |
| except Exception as e: | |
| print(f"Error setting BM25 params: {e}") | |
| hits = searcher.search(q, k=k) | |
| results = [] | |
| for rank, hit in enumerate(hits, 1): | |
| doc = searcher.doc(hit.docid) | |
| raw = doc.raw() if doc else "" | |
| title = "" | |
| text = raw | |
| if raw.strip().startswith("{"): | |
| try: | |
| parsed = json.loads(raw) | |
| title = parsed.get("title", "") | |
| text = parsed.get("contents", parsed.get("text", raw)) | |
| except Exception: | |
| pass | |
| results.append({ | |
| "rank": rank, | |
| "docid": hit.docid, | |
| "score": round(float(hit.score), 4), | |
| "title": title or hit.docid, | |
| "text": text[:350] + ("..." if len(text) > 350 else ""), | |
| "full_length": len(text.split()) | |
| }) | |
| return { | |
| "query": q, | |
| "index": index, | |
| "total_docs": searcher.num_docs, | |
| "k1": k1, | |
| "b": b, | |
| "hits": results | |
| } | |
| def search_lucene_nfcorpus_legacy(q: str = FastQuery(..., description="Query text"), k: int = 10): | |
| return search_lucene(q=q, index="nfcorpus", k=k) | |
| # --- Phase 2: Live In-Memory Indexing Engine & Telemetry --- | |
| ENGLISH_STOPWORDS = { | |
| 'a', 'about', 'above', 'after', 'again', 'against', 'all', 'am', 'an', 'and', 'any', | |
| 'are', 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'below', 'between', | |
| 'both', 'but', 'by', 'can', 'did', 'do', 'does', 'doing', 'down', 'during', 'each', | |
| 'few', 'for', 'from', 'further', 'had', 'has', 'have', 'having', 'he', 'her', 'here', | |
| 'hers', 'herself', 'him', 'himself', 'his', 'how', 'i', 'if', 'in', 'into', 'is', 'it', | |
| 'its', 'itself', 'me', 'more', 'most', 'my', 'myself', 'no', 'nor', 'not', 'of', 'off', | |
| 'on', 'once', 'only', 'or', 'other', 'ought', 'our', 'ours', 'ourselves', 'out', 'over', | |
| 'own', 'same', 'she', 'should', 'so', 'some', 'such', 'than', 'that', 'the', 'their', | |
| 'theirs', 'them', 'themselves', 'then', 'there', 'these', 'they', 'this', 'those', | |
| 'through', 'to', 'too', 'under', 'until', 'up', 'very', 'was', 'we', 'were', 'what', | |
| 'when', 'where', 'which', 'while', 'who', 'whom', 'why', 'with', 'would', 'you', 'your' | |
| } | |
| class IndexBuildRequest(BaseModel): | |
| documents: List[DocumentInput] | |
| filter_stopwords: bool = False | |
| CURRENT_INDEX: Optional[Dict[str, Any]] = None | |
| def tokenize_with_positions(text: str, filter_stopwords: bool = False) -> List[Dict[str, Any]]: | |
| tokens_with_pos = [] | |
| pos = 0 | |
| for match in re.finditer(r"\b[a-zA-Z0-9]+(?:'[a-zA-Z0-9]+)?\b", text): | |
| term = match.group(0).lower() | |
| if not filter_stopwords or term not in ENGLISH_STOPWORDS: | |
| tokens_with_pos.append({"term": term, "position": pos}) | |
| pos += 1 | |
| return tokens_with_pos | |
| def build_index_endpoint(req: IndexBuildRequest): | |
| global CURRENT_INDEX | |
| import time | |
| start_time = time.time() | |
| docs = req.documents[:100] # Cap at 100 documents | |
| N = len(docs) | |
| dictionary: Dict[str, Dict[str, Any]] = {} | |
| doc_metas: Dict[str, Dict[str, Any]] = {} | |
| doc_lengths: List[Dict[str, Any]] = [] | |
| total_tokens = 0 | |
| raw_size_bytes = 0 | |
| for internal_id, doc in enumerate(docs): | |
| full_text = (f"{doc.title} " if doc.title else "") + doc.text | |
| raw_size_bytes += len(full_text.encode('utf-8')) | |
| tokens_pos = tokenize_with_positions(full_text, req.filter_stopwords) | |
| dl = len(tokens_pos) | |
| total_tokens += dl | |
| doc_lengths.append({"id": doc.id, "length": dl}) | |
| doc_tf: Dict[str, Dict[str, Any]] = {} | |
| for item in tokens_pos: | |
| t = item["term"] | |
| p = item["position"] | |
| if t not in doc_tf: | |
| doc_tf[t] = {"count": 0, "positions": []} | |
| doc_tf[t]["count"] += 1 | |
| doc_tf[t]["positions"].append(p) | |
| for term, data in doc_tf.items(): | |
| if term not in dictionary: | |
| dictionary[term] = { | |
| "term": term, | |
| "doc_freq": 0, | |
| "collection_freq": 0, | |
| "idf": 0.0, | |
| "postings": [] | |
| } | |
| dictionary[term]["doc_freq"] += 1 | |
| dictionary[term]["collection_freq"] += data["count"] | |
| dictionary[term]["postings"].append({ | |
| "doc_id": doc.id, | |
| "doc_index": internal_id, | |
| "term_freq": data["count"], | |
| "positions": data["positions"], | |
| "doc_length": dl | |
| }) | |
| term_vector = sorted( | |
| [{"term": t, "tf": d["count"]} for t, d in doc_tf.items()], | |
| key=lambda x: x["tf"], | |
| reverse=True | |
| ) | |
| # Production BGE vector (768-dim) and SPLADE lexical expansions | |
| bge_rep = PRODUCTION_MODELS.get_bge_embedding(full_text, doc_id=doc.id) | |
| splade_rep = PRODUCTION_MODELS.get_splade_representation(full_text, doc_id=doc.id) | |
| doc_metas[doc.id] = { | |
| "id": doc.id, | |
| "internal_id": internal_id, | |
| "title": doc.title, | |
| "text": doc.text, | |
| "length": dl, | |
| "unique_terms": len(doc_tf), | |
| "term_vector": term_vector[:30], | |
| "dense_vector": bge_rep["vector"], | |
| "dense_dim": bge_rep["dim"], | |
| "dense_source": bge_rep["source"], | |
| "dense_latency_ms": bge_rep["latency_ms"], | |
| "vector_stats": bge_rep["stats"], | |
| "splade_weights": splade_rep["weights"], | |
| "splade_expansions": splade_rep["expansions"], | |
| "splade_source": splade_rep["source"], | |
| "splade_latency_ms": splade_rep["latency_ms"], | |
| "total_activated_splade": splade_rep["total_activated_tokens"], | |
| "mlm_expansions_count": splade_rep["mlm_expansions_count"], | |
| } | |
| for term, entry in dictionary.items(): | |
| df = entry["doc_freq"] | |
| raw_idf = math.log(1.0 + (N - df + 0.5) / (df + 0.5)) if N > 0 else 0.0 | |
| entry["idf"] = round(max(0.1, raw_idf), 4) | |
| entry["postings"].sort(key=lambda p: p["doc_index"]) | |
| terms_list = sorted( | |
| dictionary.values(), | |
| key=lambda x: (x["doc_freq"], x["collection_freq"]), | |
| reverse=True | |
| ) | |
| elapsed_ms = round((time.time() - start_time) * 1000, 2) | |
| avgdl = round(total_tokens / max(1, N), 2) | |
| vocab_size = len(dictionary) | |
| index_size_bytes = 0 | |
| for t in terms_list: | |
| index_size_bytes += 16 + len(t["term"]) | |
| for p in t["postings"]: | |
| index_size_bytes += 6 + (len(p["positions"]) * 2) | |
| compression_ratio = round(raw_size_bytes / max(1, index_size_bytes), 2) | |
| histogram = [] | |
| if doc_lengths: | |
| lens = [d["length"] for d in doc_lengths] | |
| min_l, max_l = min(lens), max(lens) | |
| num_bins = min(6, max(3, math.ceil(math.sqrt(len(doc_lengths))))) | |
| bin_width = max(1, math.ceil((max_l - min_l + 1) / num_bins)) | |
| for i in range(num_bins): | |
| rmin = min_l + i * bin_width | |
| rmax = max_l if i == num_bins - 1 else rmin + bin_width - 1 | |
| matching = [d["id"] for d in doc_lengths if rmin <= d["length"] <= rmax] | |
| histogram.append({ | |
| "binLabel": f"{rmin} - {rmax}", | |
| "bin_label": f"{rmin} - {rmax}", | |
| "rangeMin": rmin, | |
| "range_min": rmin, | |
| "rangeMax": rmax, | |
| "range_max": rmax, | |
| "count": len(matching), | |
| "percentage": round((len(matching) / N) * 100, 1), | |
| "docIds": matching, | |
| "doc_ids": matching | |
| }) | |
| sorted_lengths = sorted(doc_lengths, key=lambda x: x["length"]) | |
| shortest_doc = sorted_lengths[0] if sorted_lengths else {"id": "-", "length": 0} | |
| longest_doc = sorted_lengths[-1] if sorted_lengths else {"id": "-", "length": 0} | |
| telemetry = { | |
| "docCount": N, | |
| "doc_count": N, | |
| "totalTokens": total_tokens, | |
| "total_tokens": total_tokens, | |
| "vocabSize": vocab_size, | |
| "vocab_size": vocab_size, | |
| "avgdl": avgdl, | |
| "indexingTimeMs": elapsed_ms, | |
| "indexing_time_ms": elapsed_ms, | |
| "rawSizeBytes": raw_size_bytes, | |
| "raw_size_bytes": raw_size_bytes, | |
| "indexSizeBytes": index_size_bytes, | |
| "index_size_bytes": index_size_bytes, | |
| "compressionRatio": compression_ratio, | |
| "compression_ratio": compression_ratio, | |
| "shortestDoc": shortest_doc, | |
| "shortest_doc": shortest_doc, | |
| "longestDoc": longest_doc, | |
| "longest_doc": longest_doc, | |
| "histogram": histogram | |
| } | |
| with INDEX_LOCK: | |
| CURRENT_INDEX = { | |
| "telemetry": telemetry, | |
| "dictionary": dictionary, | |
| "terms_list": terms_list, | |
| "doc_metas": doc_metas | |
| } | |
| return { | |
| "telemetry": telemetry, | |
| "top_terms": terms_list[:100], | |
| "total_terms": vocab_size | |
| } | |
| def get_index_terms_endpoint( | |
| term: Optional[str] = FastQuery(None, description="Exact term lookup"), | |
| prefix: Optional[str] = FastQuery(None, description="Term prefix query"), | |
| limit: int = 50 | |
| ): | |
| with INDEX_LOCK: | |
| if not CURRENT_INDEX: | |
| return {"error": "Index not built yet. POST to /api/index/build first.", "terms": []} | |
| dictionary = CURRENT_INDEX["dictionary"] | |
| terms_list = CURRENT_INDEX["terms_list"] | |
| if term: | |
| t_lower = term.lower().strip() | |
| if t_lower in dictionary: | |
| return {"term": dictionary[t_lower]} | |
| return {"error": f"Term '{term}' not found in index dictionary", "term": None} | |
| if prefix: | |
| p_lower = prefix.lower().strip() | |
| matched = [t for t in terms_list if t["term"].startswith(p_lower)] | |
| return {"terms": matched[:limit], "total_matches": len(matched)} | |
| return {"terms": terms_list[:limit], "total_terms": len(terms_list)} | |
| def get_index_document_endpoint(doc_id: str): | |
| with INDEX_LOCK: | |
| if not CURRENT_INDEX: | |
| return {"error": "Index not built yet.", "document": None} | |
| doc = CURRENT_INDEX["doc_metas"].get(doc_id) | |
| if not doc: | |
| return {"error": f"Document {doc_id} not found.", "document": None} | |
| return {"document": doc} | |
| # --- Production Models & Neural Representation Endpoints --- | |
| def models_status(): | |
| return { | |
| "status": "ready", | |
| "faiss_nfcorpus_loaded": PRODUCTION_MODELS.faiss_loaded, | |
| "faiss_total_docs": len(PRODUCTION_MODELS.docid_to_idx), | |
| "bge_onnx_ready": PRODUCTION_MODELS.bge_session is not None, | |
| "splade_onnx_ready": PRODUCTION_MODELS.splade_session is not None, | |
| "cached_bge_vectors": len(PRODUCTION_MODELS.bge_cache), | |
| "cached_splade_docs": len(PRODUCTION_MODELS.splade_cache), | |
| } | |
| def encode_bge_endpoint(req: EncodeRequest): | |
| return PRODUCTION_MODELS.get_bge_embedding( | |
| req.text, | |
| doc_id=req.doc_id, | |
| is_query=req.is_query | |
| ) | |
| def encode_splade_endpoint(req: EncodeRequest): | |
| return PRODUCTION_MODELS.get_splade_representation( | |
| req.text, | |
| doc_id=req.doc_id | |
| ) | |
| class DocRepRequest(BaseModel): | |
| doc_id: str | |
| text: Optional[str] = "" | |
| title: Optional[str] = "" | |
| def post_doc_representation(req: DocRepRequest): | |
| full_text = (f"{req.title} " if req.title else "") + (req.text or "") | |
| if not full_text and CURRENT_INDEX and req.doc_id in CURRENT_INDEX["doc_metas"]: | |
| stored = CURRENT_INDEX["doc_metas"][req.doc_id] | |
| full_text = (f"{stored.get('title', '')} " if stored.get('title') else "") + stored.get("text", "") | |
| bge_data = PRODUCTION_MODELS.get_bge_embedding(full_text, doc_id=req.doc_id) | |
| splade_data = PRODUCTION_MODELS.get_splade_representation(full_text, doc_id=req.doc_id) | |
| return { | |
| "doc_id": req.doc_id, | |
| "bge": bge_data, | |
| "splade": splade_data | |
| } | |
| def get_doc_representation(doc_id: str, text: Optional[str] = None, title: Optional[str] = None): | |
| full_text = (f"{title} " if title else "") + (text or "") | |
| if not full_text and CURRENT_INDEX and doc_id in CURRENT_INDEX["doc_metas"]: | |
| stored = CURRENT_INDEX["doc_metas"][doc_id] | |
| full_text = (f"{stored.get('title', '')} " if stored.get('title') else "") + stored.get("text", "") | |
| bge_data = PRODUCTION_MODELS.get_bge_embedding(full_text, doc_id=doc_id) | |
| splade_data = PRODUCTION_MODELS.get_splade_representation(full_text, doc_id=doc_id) | |
| return { | |
| "doc_id": doc_id, | |
| "bge": bge_data, | |
| "splade": splade_data | |
| } | |
| # Optional Single-Container Mode: Serve built Vite static files if dist directory is present | |
| static_dir_candidates = [ | |
| os.getenv("STATIC_DIR", ""), | |
| os.path.join(os.path.dirname(__file__), "..", "dist"), | |
| "./dist", | |
| "/app/dist", | |
| ] | |
| found_static_dir = next((d for d in static_dir_candidates if d and os.path.isdir(d)), None) | |
| if found_static_dir: | |
| try: | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import FileResponse | |
| index_file = os.path.join(found_static_dir, "index.html") | |
| async def serve_index(): | |
| if os.path.isfile(index_file): | |
| return FileResponse(index_file) | |
| return api_info() | |
| app.mount("/", StaticFiles(directory=found_static_dir, html=True), name="static") | |
| print(f"[Static] Mounted frontend static files from {found_static_dir}") | |
| except Exception as e: | |
| print(f"[Static] Note: Static files mount skipped: {e}") | |
| else: | |
| def serve_fallback_root(): | |
| return api_info() | |