Spaces:
Sleeping
Sleeping
| """ | |
| Offline RAGAS baseline evaluation. | |
| Usage: py scripts/ragas_baseline.py [--test-set /path/to/test_set.json] | |
| """ | |
| import json | |
| import sys | |
| import time | |
| import argparse | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| load_dotenv(Path(__file__).parent.parent / ".env") | |
| sys.path.insert(0, str(Path(__file__).parent.parent)) | |
| from sqlmodel import Session, create_engine, select | |
| import os | |
| DATABASE_URL = os.environ["DATABASE_URL"] | |
| engine = create_engine(DATABASE_URL, echo=False) | |
| def _rag_query_with_retry(question, job_ids, user_id, db, settings, max_wait=600): | |
| """Retrieve chunks and generate answer. Bypasses engine.py to avoid importing | |
| the CrossEncoder reranker (sentence-transformers/PyTorch), which crashes when | |
| loaded in the same process as ragas + fastembed (ONNX Runtime).""" | |
| # Import retrieval components directly — do NOT import app.rag.engine or app.rag.reranker | |
| from app.rag.embedder import embed_query | |
| from app.rag.vectorstore import get_chroma_client, get_or_create_collection, search, rrf_merge | |
| from app.rag.bm25_index import load_bm25, build_bm25, search_bm25 | |
| import groq as groq_sdk | |
| q_embedding = embed_query(question, settings) | |
| client = get_chroma_client(settings) | |
| collection = get_or_create_collection(client, settings) | |
| vector_chunks = search(collection, q_embedding, top_k=settings.RAG_TOP_K * 2, job_ids=job_ids) | |
| top_vector_score = vector_chunks[0]["score"] if vector_chunks else 0.0 | |
| if top_vector_score < settings.CONFIDENCE_THRESHOLD: | |
| return "I couldn't find sufficiently relevant information in your documents to answer this question confidently.", [] | |
| index_data = load_bm25(settings) or build_bm25(collection, settings) | |
| bm25_chunks = search_bm25(index_data, question, top_k=settings.RAG_TOP_K * 2, job_ids=job_ids) | |
| rrf_chunks = rrf_merge(vector_chunks, bm25_chunks, top_k=settings.RAG_TOP_K * 2) | |
| # Use top RAG_TOP_K chunks without cross-encoder (avoids PyTorch/ONNX conflict in eval process) | |
| chunks = rrf_chunks[:settings.RAG_TOP_K] | |
| if not chunks: | |
| return "No documents found to search. Please upload and process files first.", [] | |
| full_contexts = [c["text"] for c in chunks] | |
| _MAX_CHUNK_CHARS = 1200 | |
| context_parts = [ | |
| f"[{i}] Source: {c['filename']} ({c['page_or_segment']})\n{c['text'][:_MAX_CHUNK_CHARS]}" | |
| for i, c in enumerate(chunks, 1) | |
| ] | |
| user_prompt = ( | |
| f"Context:\n{chr(10).join(context_parts)}\n\nQuestion: {question}\n\nAnswer (with [n] citation markers):" | |
| ) | |
| groq_client = groq_sdk.Groq(api_key=settings.GROQ_API_KEY) | |
| for attempt in range(5): | |
| try: | |
| resp = groq_client.chat.completions.create( | |
| model=settings.GROQ_MODEL, | |
| temperature=0, | |
| messages=[ | |
| {"role": "system", "content": ( | |
| "You are a document Q&A assistant. Answer ONLY from the numbered context excerpts provided. " | |
| "Do NOT use any knowledge from outside these excerpts.\n" | |
| "Every factual claim must be followed by a [n] citation marker. " | |
| "If the information is not in the context, say: " | |
| "'The provided documents do not contain this information.'" | |
| )}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| max_tokens=512, | |
| ) | |
| answer = resp.choices[0].message.content | |
| return answer, full_contexts | |
| except groq_sdk.RateLimitError as e: | |
| wait = 60 * (attempt + 1) | |
| print(f" [RAG 429] waiting {wait}s... ({e!s:.80})") | |
| time.sleep(wait) | |
| return "Rate limit — could not generate answer.", full_contexts | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--test-set", default="C:/tmp/ragas_test_set.json") | |
| args = parser.parse_args() | |
| test_set_path = Path(args.test_set) | |
| if not test_set_path.exists(): | |
| print(f"[ERROR] Test set not found: {test_set_path}") | |
| sys.exit(1) | |
| with open(test_set_path) as f: | |
| test_set = json.load(f) | |
| print(f"Loaded {len(test_set)} Q&A pairs from {test_set_path}") | |
| from app.config import settings | |
| from app.evaluation.ragas_eval import compute_ragas_scores | |
| results = [] | |
| col_w = 45 | |
| print(f"\n{'Question':<{col_w}} {'Faith':>6} {'AnswRel':>7} {'CtxPrec':>8} {'CtxRec':>7} {'AnsCorr':>8}") | |
| print("-" * (col_w + 42)) | |
| with Session(engine) as db: | |
| for item in test_set: | |
| question = item["question"] | |
| ground_truth = item.get("ground_truth") | |
| job_id = item.get("job_id") | |
| job_ids = [job_id] if job_id else None | |
| from app.models.db import Job, User | |
| user_id = None | |
| if job_id: | |
| job = db.get(Job, __import__("uuid").UUID(job_id)) | |
| if job: | |
| user_id = job.user_id | |
| if not user_id: | |
| user = db.exec(select(User)).first() | |
| user_id = user.id if user else None | |
| try: | |
| answer, full_contexts = _rag_query_with_retry( | |
| question, job_ids, user_id, db, settings | |
| ) | |
| if not full_contexts: | |
| full_contexts = ["(no context retrieved)"] | |
| # Truncate each context to 600 chars for RAGAS eval to stay within | |
| # 8b model's 6K TPM limit (6 chunks × 600 chars ≈ 3600 chars + overhead) | |
| ragas_contexts = [c[:600] for c in full_contexts] | |
| scores = compute_ragas_scores( | |
| question=question, | |
| answer=answer, | |
| contexts=ragas_contexts, | |
| ground_truth=ground_truth, | |
| settings=settings, | |
| ) | |
| faith = scores.get("faithfulness", float("nan")) | |
| rel = scores.get("answer_relevancy", float("nan")) | |
| prec = scores.get("context_precision", float("nan")) | |
| rec = scores.get("context_recall", float("nan")) | |
| corr = scores.get("answer_correctness", float("nan")) | |
| q_short = question[:col_w - 3] + "..." if len(question) > col_w else question | |
| print(f"{q_short:<{col_w}} {faith:>6.3f} {rel:>7.3f} {prec:>8.3f} {rec:>7.3f} {corr:>8.3f}") | |
| results.append({ | |
| "question": question, | |
| "ground_truth": ground_truth, | |
| "answer": answer, | |
| "scores": scores, | |
| }) | |
| except Exception as e: | |
| print(f"[SKIP] {question[:50]}: {e}") | |
| results.append({"question": question, "error": str(e)}) | |
| # Small gap between questions to ease rate limits | |
| time.sleep(5) | |
| metric_keys = ["faithfulness", "answer_relevancy", "context_precision", "context_recall", "answer_correctness"] | |
| sums = {k: 0.0 for k in metric_keys} | |
| counts = {k: 0 for k in metric_keys} | |
| for r in results: | |
| for k in metric_keys: | |
| v = r.get("scores", {}).get(k) | |
| if isinstance(v, float) and not __import__("math").isnan(v): | |
| sums[k] += v | |
| counts[k] += 1 | |
| avgs = {k: round(sums[k] / counts[k], 4) if counts[k] else None for k in metric_keys} | |
| print("\n" + "=" * 60) | |
| print("BASELINE AVERAGES") | |
| print("=" * 60) | |
| for k, v in avgs.items(): | |
| target = {"faithfulness": 0.8, "context_precision": 0.6}.get(k, 0.7) | |
| status = "PASS" if v and v >= target else "BELOW TARGET" | |
| print(f" {k:<25} {str(v) if v is not None else 'N/A':>8} (target >= {target}) {status}") | |
| out_path = Path("C:/tmp/ragas_baseline.json") | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(out_path, "w") as f: | |
| json.dump({"results": results, "averages": avgs}, f, indent=2) | |
| print(f"\nSaved to {out_path}") | |
| if __name__ == "__main__": | |
| main() | |