CoolFace
Apppublic

VTdevelops/rag-vs-full-context-comparator

sourceHugging Faceupdated 10mo agoView on Hugging Face
0likes
app.py1187 linesDownload Raw Back to root
1#!/usr/bin/env python32"""RAG vs full-context comparator Gradio app."""3 4import asyncio5import json6import os7import math8import re9import uuid10import textwrap11from collections import Counter12from dataclasses import dataclass, field13from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple14 15import gradio as gr16 17try:18    from dotenv import load_dotenv19except ImportError:  # pragma: no cover - optional dependency20    def load_dotenv(*_args, **_kwargs):21        return None22 23try:24    import chromadb25    from chromadb.config import Settings26except Exception:  # pragma: no cover - optional dependency27    chromadb = None28    Settings = None29 30 31load_dotenv()32 33 34@dataclass35class Document:36    name: str37    text: str38 39 40@dataclass41class Chunk:42    text: str43    source: str44    tokens: Counter = field(default_factory=Counter)45    score: float = 0.046    embedding: Optional[List[float]] = None47    uid: Optional[str] = None48 49 50TOKEN_RE = re.compile(r"[A-Za-z0-9']+")51 52FULL_CONTEXT_LABEL = "Full-context answer"53RAG_LABEL = "RAG answer"54 55 56def _env_int(name: str, default: int) -> int:57    try:58        return int(os.getenv(name, str(default)))59    except ValueError:60        return default61 62 63FULL_CONTEXT_CHAR_LIMIT = max(0, _env_int("FULL_CONTEXT_CHAR_LIMIT", 12000))64 65 66def tokenize(text: str) -> List[str]:67    if not text:68        return []69    return TOKEN_RE.findall(text.lower())70 71 72def split_into_sentences(text: str) -> List[str]:73    if not text:74        return []75    sentences = re.split(r"(?<=[.!?])\s+", text.strip())76    return [sentence.strip() for sentence in sentences if sentence.strip()]77 78 79def cosine_similarity(vec_a: Counter, vec_b: Counter) -> float:80    if not vec_a or not vec_b:81        return 0.082    shared = set(vec_a.keys()) & set(vec_b.keys())83    if not shared:84        return 0.085    dot = sum(vec_a[token] * vec_b[token] for token in shared)86    norm_a = math.sqrt(sum(value * value for value in vec_a.values()))87    norm_b = math.sqrt(sum(value * value for value in vec_b.values()))88    if norm_a == 0.0 or norm_b == 0.0:89        return 0.090    value = dot / (norm_a * norm_b)91    return max(0.0, min(1.0, value))92 93 94def cosine_similarity_dense(vec_a: Sequence[float], vec_b: Sequence[float]) -> float:95    if not vec_a or not vec_b:96        return 0.097    if len(vec_a) != len(vec_b):98        return 0.099    dot = sum(a * b for a, b in zip(vec_a, vec_b))100    norm_a = math.sqrt(sum(a * a for a in vec_a))101    norm_b = math.sqrt(sum(b * b for b in vec_b))102    if norm_a == 0.0 or norm_b == 0.0:103        return 0.0104    value = dot / (norm_a * norm_b)105    return max(0.0, min(1.0, value))106 107 108def parse_json_block(text: str) -> Dict[str, Any]:109    candidate = (text or "").strip()110    if not candidate:111        raise ValueError("Empty response body")112    try:113        return json.loads(candidate)114    except json.JSONDecodeError:115        pass116 117    fenced = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", candidate, flags=re.IGNORECASE | re.DOTALL)118    for block in fenced:119        try:120            return json.loads(block)121        except json.JSONDecodeError:122            continue123 124    start = candidate.find("{")125    end = candidate.rfind("}")126    if start != -1 and end != -1 and end > start:127        snippet = candidate[start : end + 1]128        try:129            return json.loads(snippet)130        except json.JSONDecodeError:131            pass132 133    raise ValueError("Could not parse JSON from response")134 135 136def chunk_document(document: Document, chunk_size: int = 200, chunk_overlap: int = 40) -> List[Chunk]:137    sentences = split_into_sentences(document.text)138    if not sentences:139        stripped = (document.text or "").strip()140        if not stripped:141            return []142        tokens = tokenize(stripped)143        if not tokens:144            return []145        return [Chunk(text=stripped, source=document.name, tokens=Counter(tokens))]146 147    chunks: List[Chunk] = []148    current_sentences: List[str] = []149    current_tokens: List[List[str]] = []150    current_len = 0151 152    for sentence in sentences:153        tokens = tokenize(sentence)154        if not tokens:155            continue156        token_count = len(tokens)157        if current_len + token_count > chunk_size and current_sentences:158            chunk_text = " ".join(current_sentences).strip()159            if chunk_text:160                chunk_counter = Counter()161                for token_list in current_tokens:162                    chunk_counter.update(token_list)163                chunks.append(Chunk(text=chunk_text, source=document.name, tokens=chunk_counter))164            if chunk_overlap > 0:165                retained_sentences: List[str] = []166                retained_tokens: List[List[str]] = []167                retained_len = 0168                for prev_sentence, prev_tokens in zip(reversed(current_sentences), reversed(current_tokens)):169                    retained_sentences.insert(0, prev_sentence)170                    retained_tokens.insert(0, prev_tokens)171                    retained_len += len(prev_tokens)172                    if retained_len >= chunk_overlap:173                        break174                current_sentences = retained_sentences175                current_tokens = retained_tokens176                current_len = retained_len177            else:178                current_sentences = []179                current_tokens = []180                current_len = 0181        current_sentences.append(sentence)182        current_tokens.append(tokens)183        current_len += token_count184 185    if current_sentences:186        chunk_text = " ".join(current_sentences).strip()187        if chunk_text:188            chunk_counter = Counter()189            for token_list in current_tokens:190                chunk_counter.update(token_list)191            chunks.append(Chunk(text=chunk_text, source=document.name, tokens=chunk_counter))192 193    return chunks194 195 196def build_corpus_chunks(documents: Sequence[Document], chunk_size: int = 200, chunk_overlap: int = 40) -> List[Chunk]:197    chunks: List[Chunk] = []198    counter = 0199    for document in documents:200        for chunk in chunk_document(document, chunk_size=chunk_size, chunk_overlap=chunk_overlap):201            chunk.uid = f"{document.name}-{counter}"202            counter += 1203            chunks.append(chunk)204    return chunks205 206 207def assemble_full_context(documents: Sequence[Document], max_chars: Optional[int] = None) -> Tuple[str, bool]:208    if not documents:209        return "", False210 211    limit = max_chars if max_chars and max_chars > 0 else None212    combined_parts: List[str] = []213    running_total = 0214    truncated = False215 216    for document in documents:217        section = f"Source: {document.name}\n{document.text.strip()}\n\n"218        section_length = len(section)219        if limit is not None and running_total + section_length > limit:220            remaining = limit - running_total221            if remaining > 0:222                combined_parts.append(section[:remaining])223                running_total += remaining224            truncated = True225            break226        combined_parts.append(section)227        running_total += section_length228 229    combined_text = "".join(combined_parts).strip()230    return combined_text, truncated231 232 233def retrieve_chunks_lexical(query: str, chunks: Sequence[Chunk], top_k: int = 3) -> List[Chunk]:234    query_tokens = Counter(tokenize(query))235    if not query_tokens:236        return []237    scored: List[Chunk] = []238    for chunk in chunks:239        if not chunk.tokens:240            continue241        score = cosine_similarity(query_tokens, chunk.tokens)242        scored.append(243            Chunk(244                text=chunk.text,245                source=chunk.source,246                tokens=chunk.tokens,247                score=score,248                embedding=chunk.embedding,249                uid=chunk.uid,250            )251        )252    scored.sort(key=lambda item: item.score, reverse=True)253    return scored[:top_k]254 255 256def extract_text_from_upload(path: str, name: str) -> str:257    ext = os.path.splitext(name)[1].lower()258    if ext in {".txt", ".md", ".markdown", ".csv"}:259        with open(path, "r", encoding="utf-8", errors="ignore") as handle:260            return handle.read()261    if ext == ".json":262        import json263 264        with open(path, "r", encoding="utf-8", errors="ignore") as handle:265            data = json.load(handle)266        if isinstance(data, str):267            return data268        return json.dumps(data, indent=2)269    if ext == ".pdf":270        try:271            from pypdf import PdfReader272        except Exception as exc:273            raise RuntimeError("PDF support requires the 'pypdf' package to be installed in this environment.") from exc274        reader = PdfReader(path)275        pages = []276        for page in reader.pages:277            text = page.extract_text() or ""278            pages.append(text)279        return "\n".join(pages)280    raise RuntimeError(281        f"Unsupported file type: '{ext or 'unknown'}'. Upload text-based formats such as .txt, .md, .json, or .pdf."282    )283 284 285def read_uploaded_files(files: Optional[Sequence]) -> Tuple[List[Document], List[str]]:286    documents: List[Document] = []287    errors: List[str] = []288    if not files:289        return documents, errors290    for uploaded in files:291        path = getattr(uploaded, "name", None)292        if not path:293            errors.append("Encountered an upload without a file path.")294            continue295        display_name = getattr(uploaded, "orig_name", None) or os.path.basename(path)296        try:297            text = extract_text_from_upload(path, display_name)298            cleaned = text.strip()299            if not cleaned:300                errors.append(f"{display_name}: file was empty after stripping whitespace.")301                continue302            documents.append(Document(name=display_name, text=cleaned))303        except Exception as exc:304            errors.append(f"{display_name}: {exc}")305    return documents, errors306 307 308def compute_support_score(309    answer: str,310    corpus_embeddings: Sequence[Sequence[float]],311    embedder: "EmbeddingService",312) -> float:313    answer_sentences = split_into_sentences(answer)314    if not answer_sentences or not corpus_embeddings:315        return 0.0316    answer_embeddings = embedder.embed_texts(answer_sentences)317    if not answer_embeddings:318        return 0.0319    filtered_corpus = [vector for vector in corpus_embeddings if vector]320    if not filtered_corpus:321        return 0.0322    sentence_scores: List[float] = []323    for sentence_vector in answer_embeddings:324        if not sentence_vector:325            continue326        similarities = [cosine_similarity_dense(sentence_vector, corpus_vec) for corpus_vec in filtered_corpus]327        if similarities:328            sentence_scores.append(max(similarities))329    if not sentence_scores:330        return 0.0331    average = sum(sentence_scores) / len(sentence_scores)332    return max(0.0, min(1.0, average))333 334 335def compute_semantic_similarity(answer: str, reference: str, embedder: "EmbeddingService") -> Optional[float]:336    if not answer or not reference:337        return None338    embeddings = embedder.embed_texts([answer, reference])339    if len(embeddings) != 2:340        return None341    answer_vector, reference_vector = embeddings342    if not answer_vector or not reference_vector:343        return None344    return cosine_similarity_dense(answer_vector, reference_vector)345 346 347def compute_retrieval_effectiveness(348    question: str,349    question_embedding: Optional[Sequence[float]],350    retrieved: Sequence[Chunk],351    all_chunks: Sequence[Chunk],352) -> float:353    if question_embedding and any(chunk.embedding for chunk in retrieved):354        retrieved_scores = [355            cosine_similarity_dense(question_embedding, chunk.embedding)356            for chunk in retrieved357            if chunk.embedding358        ]359        baseline_scores = [360            cosine_similarity_dense(question_embedding, chunk.embedding)361            for chunk in all_chunks362            if chunk.embedding363        ]364        if not retrieved_scores or not baseline_scores:365            return 0.0366        top_avg = sum(retrieved_scores) / len(retrieved_scores)367        baseline_avg = sum(baseline_scores) / len(baseline_scores)368        return max(0.0, min(1.0, top_avg / (top_avg + baseline_avg + 1e-8)))369 370    # Fallback to lexical approximation when embeddings are unavailable371    query_vector = Counter(tokenize(question))372    if not query_vector or not retrieved:373        return 0.0374    retrieved_scores = [cosine_similarity(query_vector, chunk.tokens) for chunk in retrieved if chunk.tokens]375    baseline_scores = [cosine_similarity(query_vector, chunk.tokens) for chunk in all_chunks if chunk.tokens]376    if not retrieved_scores or not baseline_scores:377        return 0.0378    top_avg = sum(retrieved_scores) / len(retrieved_scores)379    baseline_avg = sum(baseline_scores) / len(baseline_scores)380    return max(0.0, min(1.0, top_avg / (top_avg + baseline_avg + 1e-8)))381 382 383def discounted_gain(score: float, rank: int) -> float:384    return (2 ** score - 1) / math.log2(rank + 1) if rank > 0 else 0.0385 386 387def compute_rank_metrics(388    retrieved: Sequence[Chunk],389    all_chunks: Sequence[Chunk],390    question_embedding: Optional[Sequence[float]],391    max_relevant: int = 5,392) -> Dict[str, float]:393    if not question_embedding:394        return {"precision": 0.0, "recall": 0.0, "mrr": 0.0, "ndcg": 0.0}395 396    scored: List[Tuple[str, float]] = []397    for chunk in all_chunks:398        if not chunk.embedding or not chunk.uid:399            continue400        score = cosine_similarity_dense(question_embedding, chunk.embedding)401        if score <= 0.0:402            continue403        scored.append((chunk.uid, score))404 405    if not scored:406        return {"precision": 0.0, "recall": 0.0, "mrr": 0.0, "ndcg": 0.0}407 408    scored.sort(key=lambda item: item[1], reverse=True)409    top_relevant = {uid for uid, _ in scored[: max_relevant]}410    relevant_scores = {uid: score for uid, score in scored}411    total_relevant = max(len(top_relevant), 1)412 413    retrieved_uids = [chunk.uid for chunk in retrieved if chunk.uid]414    if not retrieved_uids:415        return {"precision": 0.0, "recall": 0.0, "mrr": 0.0, "ndcg": 0.0}416 417    binary_relevance = [1 if uid in top_relevant else 0 for uid in retrieved_uids]418    graded_relevance = [relevant_scores.get(uid, 0.0) for uid in retrieved_uids]419 420    precision = sum(binary_relevance) / len(binary_relevance)421    recall = sum(binary_relevance) / total_relevant422 423    mrr = 0.0424    for idx, is_rel in enumerate(binary_relevance):425        if is_rel:426            mrr = 1.0 / (idx + 1)427            break428 429    dcg = 0.0430    for idx, rel in enumerate(graded_relevance):431        dcg += discounted_gain(rel, idx + 1)432 433    ideal_scores = [score for _, score in scored[: len(graded_relevance)]]434    idcg = 0.0435    for idx, rel in enumerate(ideal_scores):436        idcg += discounted_gain(rel, idx + 1)437    ndcg = dcg / idcg if idcg > 0 else 0.0438 439    return {440        "precision": max(0.0, min(1.0, precision)),441        "recall": max(0.0, min(1.0, recall)),442        "mrr": max(0.0, min(1.0, mrr)),443        "ndcg": max(0.0, min(1.0, ndcg)),444    }445 446 447def winner_label(score_full_context: Optional[float], score_rag: Optional[float], higher_is_better: bool) -> str:448    if score_full_context is None and score_rag is None:449        return "Tie"450    if score_full_context is None:451        return RAG_LABEL452    if score_rag is None:453        return FULL_CONTEXT_LABEL454    tolerance = 1e-6455    if abs(score_full_context - score_rag) <= tolerance:456        return "Tie"457    if higher_is_better:458        return RAG_LABEL if score_rag > score_full_context else FULL_CONTEXT_LABEL459    return RAG_LABEL if score_rag < score_full_context else FULL_CONTEXT_LABEL460 461 462def evaluate_answers(463    question: str,464    documents: Sequence[Document],465    all_chunks: Sequence[Chunk],466    retrieved_chunks: Sequence[Chunk],467    full_context_answer: str,468    rag_answer: str,469    reference_answer: str,470    embedder: "EmbeddingService",471    question_embedding: Optional[Sequence[float]],472) -> dict:473    corpus_sentences: List[str] = []474    for document in documents:475        corpus_sentences.extend(split_into_sentences(document.text))476    corpus_embeddings = embedder.embed_texts(corpus_sentences) if corpus_sentences else []477    support_full = compute_support_score(full_context_answer, corpus_embeddings, embedder)478    support_rag = compute_support_score(rag_answer, corpus_embeddings, embedder)479    hallucination_full = max(0.0, 1.0 - support_full)480    hallucination_rag = max(0.0, 1.0 - support_rag)481    reference_clean = reference_answer.strip()482    semantic_full = compute_semantic_similarity(full_context_answer, reference_clean, embedder) if reference_clean else None483    semantic_rag = compute_semantic_similarity(rag_answer, reference_clean, embedder) if reference_clean else None484    retrieval_score = compute_retrieval_effectiveness(question, question_embedding, retrieved_chunks, all_chunks)485    rank_metrics = compute_rank_metrics(retrieved_chunks, all_chunks, question_embedding)486    comparison = {487        "more_grounded": winner_label(support_full, support_rag, higher_is_better=True),488        "fewer_hallucinations": winner_label(hallucination_full, hallucination_rag, higher_is_better=False),489        "closest_to_reference": (490            winner_label(semantic_full, semantic_rag, higher_is_better=True)491            if reference_clean492            else "Reference answer not provided"493        ),494        "retrieval_effectiveness": retrieval_score,495    }496    return {497        "full_context": {498            "support_score": support_full,499            "hallucination_score": hallucination_full,500            "semantic_similarity": semantic_full,501        },502        "rag": {503            "support_score": support_rag,504            "hallucination_score": hallucination_rag,505            "semantic_similarity": semantic_rag,506        },507        "retrieval_effectiveness": retrieval_score,508        "retrieval_rank": rank_metrics,509        "retrieved_k": len(retrieved_chunks),510        "comparison": comparison,511    }512 513 514def format_score(score: Optional[float]) -> str:515    if score is None:516        return "—"517    return f"{score * 100:.1f}%"518 519 520def format_metrics_html(metrics: dict, reference_available: bool) -> str:521    rows = [522        (523            "Support score",524            format_score(metrics["full_context"]["support_score"]),525            format_score(metrics["rag"]["support_score"]),526            "Higher is better — measures how well each answer sentence embedding is backed by the uploaded sources.",527        ),528        (529            "Hallucination score",530            format_score(metrics["full_context"]["hallucination_score"]),531            format_score(metrics["rag"]["hallucination_score"]),532            "Lower is better — 1 minus the support score, indicating embedding-level gaps in support.",533        ),534    ]535    if reference_available:536        rows.append(537            (538                "Semantic similarity to reference",539                format_score(metrics["full_context"]["semantic_similarity"]),540                format_score(metrics["rag"]["semantic_similarity"]),541                "Higher is better — embedding similarity to the provided ground-truth answer.",542            )543        )544    rows.append(545        (546            "Retrieval effectiveness",547            "—",548            format_score(metrics["retrieval_effectiveness"]),549            "Higher is better — compares retrieved chunk embeddings to the average embedding match for this question.",550        )551    )552 553    html_parts = [554        "<div class='metric-summary'>",555        "<table class='metric-table'>",556        "<thead><tr><th>Metric</th><th>Full context</th><th>RAG</th><th>Interpretation</th></tr></thead>",557        "<tbody>",558    ]559    for metric_name, no_value, rag_value, note in rows:560        html_parts.append(561            f"<tr><td>{metric_name}</td><td>{no_value}</td><td>{rag_value}</td><td>{note}</td></tr>"562        )563    html_parts.append("</tbody></table>")564 565    comparison = metrics["comparison"]566    html_parts.append("<p class='comparison-note'>Automatic comparison</p>")567    html_parts.append("<ul>")568    html_parts.append(569        f"<li class='comparison-note'>More grounded: <strong>{comparison['more_grounded']}</strong></li>"570    )571    html_parts.append(572        f"<li class='comparison-note'>Fewer hallucinations: <strong>{comparison['fewer_hallucinations']}</strong></li>"573    )574    if reference_available:575        html_parts.append(576            f"<li class='comparison-note'>Closer to reference answer: <strong>{comparison['closest_to_reference']}</strong></li>"577        )578    else:579        html_parts.append(580            "<li>Closer to reference answer: Provide a reference answer to enable this comparison.</li>"581        )582    retrieval_percent = format_score(metrics["retrieval_effectiveness"])583    html_parts.append(584        f"<li class='comparison-note'>Retrieval effectiveness score: <strong>{retrieval_percent}</strong></li>"585    )586    html_parts.append("</ul>")587    rank_metrics = metrics.get("retrieval_rank", {})588    if rank_metrics:589        retrieved_k = metrics.get("retrieved_k") or len(rank_metrics)590        html_parts.append("<p class='comparison-note'>Retrieval ranking metrics</p>")591        html_parts.append("<ul>")592        html_parts.append(593            f"<li>Precision@{retrieved_k}: <strong>{format_score(rank_metrics.get('precision'))}</strong></li>"594        )595        html_parts.append(596            f"<li>Recall estimate: <strong>{format_score(rank_metrics.get('recall'))}</strong></li>"597        )598        html_parts.append(599            f"<li>MRR: <strong>{format_score(rank_metrics.get('mrr'))}</strong></li>"600        )601        html_parts.append(602            f"<li>nDCG: <strong>{format_score(rank_metrics.get('ndcg'))}</strong></li>"603        )604        html_parts.append("</ul>")605    judge = metrics.get("judge", {})606    if judge:607        html_parts.append("<p class='comparison-note'>LLM judge verdicts</p>")608        html_parts.append("<table class='metric-table'>")609        html_parts.append("<thead><tr><th>Criterion</th><th>Full context</th><th>RAG</th><th>Interpretation</th></tr></thead>")610        html_parts.append("<tbody>")611        judge_rows = [612            (613                "Accuracy",614                format_score(judge.get("full_context", {}).get("accuracy")),615                format_score(judge.get("rag", {}).get("accuracy")),616                "Higher means the answer aligns with ground truth/context.",617            ),618            (619                "Completeness",620                format_score(judge.get("full_context", {}).get("completeness")),621                format_score(judge.get("rag", {}).get("completeness")),622                "Higher means the answer covers all key points.",623            ),624            (625                "Relevance",626                format_score(judge.get("full_context", {}).get("relevance")),627                format_score(judge.get("rag", {}).get("relevance")),628                "Higher means the answer stays on topic for the question.",629            ),630        ]631        for metric_name, no_value, rag_value, note in judge_rows:632            html_parts.append(633                f"<tr><td>{metric_name}</td><td>{no_value}</td><td>{rag_value}</td><td>{note}</td></tr>"634            )635        html_parts.append("</tbody></table>")636        feedback_no = judge.get("full_context", {}).get("feedback")637        feedback_rag = judge.get("rag", {}).get("feedback")638        if feedback_no or feedback_rag:639            html_parts.append("<p class='metric-disclaimer'>LLM judge feedback:</p>")640            html_parts.append("<ul>")641            if feedback_no:642                html_parts.append(f"<li>Full context: {feedback_no}</li>")643            if feedback_rag:644                html_parts.append(f"<li>RAG: {feedback_rag}</li>")645            html_parts.append("</ul>")646    html_parts.append(647        "<p class='metric-disclaimer'>Scores are heuristic: embedding-based metrics use OpenAI or hashed fallback vectors, and the LLM judge relies on prompting best effort. "648        "Use them for rapid feedback rather than formal evaluation.</p>"649    )650    html_parts.append("</div>")651    return "".join(html_parts)652 653 654def format_context_markdown(retrieved: Sequence[Chunk]) -> str:655    if not retrieved:656        return "_No supporting context retrieved yet. Upload richer documents or rephrase the question._"657    parts: List[str] = []658    for idx, chunk in enumerate(retrieved, start=1):659        score_display = f"{chunk.score:.2f}"660        parts.append(661            f"**Chunk {idx} — {chunk.source} (similarity {score_display})**\n\n{chunk.text.strip()}"662        )663    return "\n\n---\n\n".join(parts)664 665 666def prepend_warnings(markdown: str, errors: Sequence[str]) -> str:667    if not errors:668        return markdown669    bullet_list = "\n".join(f"- {message}" for message in errors)670    warning_block = f"**Warnings while reading your files:**\n{bullet_list}\n\n"671    return warning_block + (markdown or "")672 673 674class EmbeddingService:675    """Utility for generating embeddings with OpenAI or a hashed fallback."""676 677    def __init__(self, existing_client: Optional[Any] = None) -> None:678        self.model = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-large")679        self.dim = max(64, int(os.getenv("FALLBACK_EMBED_DIM", "512")))680        self.client = existing_client681        if self.client is None:682            api_key = os.getenv("OPENAI_API_KEY")683            if api_key:684                try:685                    from openai import OpenAI686 687                    self.client = OpenAI(api_key=api_key)688                except Exception:689                    self.client = None690        self.fallback_mode = self.client is None691 692    def embed_texts(self, texts: Sequence[str]) -> List[List[float]]:693        cleaned = [(text or "").strip() for text in texts if text is not None]694        if not cleaned:695            return []696        normalized_inputs = [text if text else " " for text in cleaned]697        if self.client is not None:698            try:699                response = self.client.embeddings.create(model=self.model, input=normalized_inputs)700                embeddings = [record.embedding for record in response.data]701                # Reset fallback marker in case a prior request failed but this one succeeds702                self.fallback_mode = False703                return embeddings704            except Exception:705                # Deactivate the failing client to avoid repeated errors and fall back to hashing706                self.client = None707                self.fallback_mode = True708        return [self._fallback_embedding(text) for text in normalized_inputs]709 710    def embed_one(self, text: str) -> Optional[List[float]]:711        embeddings = self.embed_texts([text])712        return embeddings[0] if embeddings else None713 714    def _fallback_embedding(self, text: str) -> List[float]:715        vector = [0.0] * self.dim716        tokens = tokenize(text)717        if not tokens:718            return vector719        for token in tokens:720            idx = hash(token) % self.dim721            vector[idx] += 1.0722        norm = math.sqrt(sum(value * value for value in vector))723        if norm:724            vector = [value / norm for value in vector]725        return vector726 727 728class LLMService:729    """Simple wrapper that prefers OpenAI if configured, otherwise uses heuristics."""730 731    def __init__(self) -> None:732        self.client = None733        self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")734        self.temperature = float(os.getenv("LLM_TEMPERATURE", "0.2"))735        api_key = os.getenv("OPENAI_API_KEY")736        if api_key:737            try:738                from openai import OpenAI739 740                self.client = OpenAI(api_key=api_key)741            except Exception:742                self.client = None743 744    def answer(self, question: str, context: Optional[str] = None) -> str:745        question = (question or "").strip()746        if not question:747            return "No question provided."748        if self.client:749            system_prompt = (750                "You are an assistant that compares retrieval augmented generation to plain LLM answers. "751                "Always ground responses in the supplied context when available. If context is missing, answer from "752                "general knowledge and call out uncertainty explicitly."753            )754            user_content: List[str] = []755            if context:756                user_content.append("Context:\n" + context.strip())757            user_content.append("Question:\n" + question)758            user_content.append("Answer:")759            try:760                response = self.client.responses.create(761                    model=self.model,762                    input=[763                        {"role": "system", "content": system_prompt},764                        {"role": "user", "content": "\n\n".join(user_content)},765                    ],766                    temperature=self.temperature,767                )768                return response.output_text.strip()769            except Exception as exc:770                return f"[LLM call failed: {exc}]"771        return self._fallback_answer(question, context)772 773    def _fallback_answer(self, question: str, context: Optional[str] = None) -> str:774        if context:775            sentences = split_into_sentences(context)776            if not sentences:777                return (778                    "Context grounded answer (heuristic): The retrieved context did not contain enough readable sentences."779                )780            keywords = set(tokenize(question))781            scored: List[Tuple[int, int, str]] = []782            for sentence in sentences:783                tokens = tokenize(sentence)784                if not tokens:785                    continue786                overlap = sum(1 for token in tokens if token in keywords)787                scored.append((overlap, -len(tokens), sentence))788            if not scored:789                return "Context grounded answer (heuristic): No overlapping signals were found in the retrieved context."790            scored.sort()791            top_sentences = [entry[2] for entry in scored[-3:]]792            snippet = " ".join(top_sentences).strip()793            if not snippet:794                snippet = (795                    "The uploaded material does not include details that match the question closely, so this answer may be incomplete."796                )797            return (798                "Context grounded answer (heuristic, no external LLM configured): "799                f"{snippet}"800            )801        return (802            "Speculative answer without retrieval (heuristic): "803            f"I do not have supporting documents for '{question}', so this guess may hallucinate."804        )805 806 807LLM = LLMService()808EMBEDDER = EmbeddingService(existing_client=LLM.client)809 810 811async def async_llm_judge(812    client: Any,813    question: str,814    answer: str,815    reference: str,816    context: str,817) -> Dict[str, Any]:818    if client is None:819        return {820            "status": "unavailable",821            "accuracy": None,822            "completeness": None,823            "relevance": None,824            "feedback": "LLM judge unavailable (no API key configured).",825        }826 827    prompt = textwrap.dedent(828        f"""829        You are evaluating answers in a Retrieval-Augmented Generation (RAG) demo.830        Rate the given answer from 0 to 1 on accuracy, completeness, and relevance, using the reference answer and retrieved context.831        Return a compact JSON dictionary with keys accuracy, completeness, relevance, and a short feedback note.832 833        Context:834        {context}835 836        Reference answer (optional):837        {reference or '[not provided]'}838 839        User question:840        {question}841 842        Answer to evaluate:843        {answer}844        """845    ).strip()846 847    try:848        loop = asyncio.get_event_loop()849        response = await loop.run_in_executor(850            None,851            lambda: client.responses.create(852                model=os.getenv("OPENAI_JUDGE_MODEL", "gpt-4o-mini"),853                input=[854                    {855                        "role": "system",856                        "content": "Return valid JSON only."857                    },858                    {859                        "role": "user",860                        "content": prompt,861                    },862                ],863                temperature=0.2,864            ),865        )866        raw = response.output_text.strip()867        data = parse_json_block(raw)868        return {869            "status": "ok",870            "accuracy": float(data.get("accuracy", 0.0)),871            "completeness": float(data.get("completeness", 0.0)),872            "relevance": float(data.get("relevance", 0.0)),873            "feedback": data.get("feedback", ""),874        }875    except Exception as exc:876        raw_preview = (raw[:200] + "…") if "raw" in locals() and len(raw) > 200 else raw if "raw" in locals() else ""877        return {878            "status": "error",879            "accuracy": None,880            "completeness": None,881            "relevance": None,882            "feedback": f"Judge error: {exc}. Raw: {raw_preview}",883        }884 885 886METRIC_DETAILS_MD = """887**Hallucination Score** — 1 minus the support score. Lower is better; 0 means every answer sentence has strong embedding support in the uploaded sources.888 889**Support Score** — Average cosine similarity between each answer sentence embedding and its best matching sentence embedding from the uploaded documents. Higher is better and indicates factual grounding.890 891**Semantic Similarity** — Cosine similarity between the answer embedding and the provided reference answer embedding. Higher is better. This metric is only available when you supply a ground-truth answer.892 893**Retrieval Effectiveness** — Compares how well the retrieved chunk embeddings match the question embedding relative to the average chunk in the corpus. Higher is better; 0.5 means retrieval is performing on par with a random chunk.894 895**Precision / Recall / MRR / nDCG** — Ranking metrics derived from embedding similarity that estimate how well the retriever surfaces the most relevant chunks. Higher is better.896 897When the OpenAI embedding API is unavailable, the app falls back to hashed lexical vectors so scores remain approximate but still indicative. If the LLM judge cannot be reached, its scores are replaced with a warning.898 899The full-context baseline feeds the concatenated uploads directly to the LLM (trimmed to the `FULL_CONTEXT_CHAR_LIMIT` environment variable, default 12,000 characters) so you can see what retrieval adds over simply pasting the entire document set into the prompt.900"""901 902 903CUSTOM_CSS = """904.metric-table {905    width: 100%;906    border-collapse: collapse;907}908.metric-table th, .metric-table td {909    border: 1px solid #ddd;910    padding: 0.4rem 0.6rem;911    text-align: left;912}913.metric-table th {914    background-color: #f8f8f8;915}916.metric-summary ul {917    margin-top: 0.4rem;918}919.metric-summary li {920    margin-bottom: 0.2rem;921}922.metric-disclaimer {923    font-size: 0.85rem;924    color: #555;925}926.answer-section {927    display: flex;928    gap: 1rem;929    flex-wrap: wrap;930}931.answer-card {932    background-color: #f9fafc;933    border: 1px solid #d8dee9;934    border-radius: 8px;935    padding: 0.8rem;936    flex: 1 1 280px;937    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);938}939.answer-card h3 {940    margin-top: 0;941}942.answer-card p {943    white-space: pre-wrap;944}945.comparison-note {946    font-weight: 600;947}948"""949 950 951async def compare(question: str, files, reference_answer: str):952    question = (question or "").strip()953    if not question:954        message = "Please provide a question to run the comparison."955        return (956            message,957            message,958            "_Awaiting question and documents._",959            "<p>Enter a question and upload documents to see the comparison.</p>",960        )961 962    documents, file_errors = read_uploaded_files(files)963    warnings = list(file_errors)964    if not documents:965        no_docs_message = (966            "No documents were loaded. Upload at least one text-friendly file (.txt, .md, .json, .pdf, .csv)."967        )968        if EMBEDDER.fallback_mode:969            warnings.append(970                "OpenAI embedding API not configured; using hashed fallback vectors until an API key is provided."971            )972        warnings.append(no_docs_message)973        full_context_answer = LLM.answer(question, context=None)974        rag_answer = LLM.answer(question, context=None)975        metrics_placeholder = "<p>Upload documents to compute grounding metrics.</p>"976        context_markdown = prepend_warnings("_No context available._", warnings)977        return full_context_answer, rag_answer, context_markdown, metrics_placeholder978 979    full_context_text, truncated_full_context = assemble_full_context(documents, max_chars=FULL_CONTEXT_CHAR_LIMIT)980    if truncated_full_context and FULL_CONTEXT_CHAR_LIMIT:981        warnings.append(982            f"Full-context prompt truncated to {FULL_CONTEXT_CHAR_LIMIT} characters to stay within model limits."983        )984 985    all_chunks = build_corpus_chunks(documents)986    chunk_texts = [chunk.text for chunk in all_chunks]987    chunk_embeddings = EMBEDDER.embed_texts(chunk_texts) if chunk_texts else []988    for index, chunk in enumerate(all_chunks):989        embedding = chunk_embeddings[index] if index < len(chunk_embeddings) else None990        chunk.embedding = embedding991 992    question_embedding = EMBEDDER.embed_one(question)993    retrieved: List[Chunk] = []994    vector_retrieval_used = False995    vector_attempted = False996 997    if (998        chromadb is not None999        and Settings is not None1000        and chunk_embeddings1001        and question_embedding is not None1002    ):1003        vector_attempted = True1004        try:1005            if hasattr(chromadb, "EphemeralClient"):1006                client = chromadb.EphemeralClient()1007            else:1008                client = chromadb.Client(1009                    Settings(1010                        anonymized_telemetry=False,1011                    )1012                )1013            collection = client.create_collection(1014                name=f"rag-session-{uuid.uuid4()}",1015                metadata={"hnsw:space": "cosine"},1016            )1017            ids = [f"chunk-{idx}" for idx in range(len(all_chunks))]1018            collection.add(1019                ids=ids,1020                embeddings=chunk_embeddings,1021                documents=chunk_texts,1022                metadatas=[{"source": chunk.source} for chunk in all_chunks],1023            )1024            n_results = min(3, len(all_chunks))1025            results = collection.query(query_embeddings=[question_embedding], n_results=n_results)1026            id_lookup: Dict[str, Chunk] = {id_value: all_chunks[idx] for idx, id_value in enumerate(ids)}1027            retrieved_ids = results.get("ids", [[]])1028            for idx, chunk_id in enumerate(retrieved_ids[0] if retrieved_ids else []):1029                base_chunk = id_lookup.get(chunk_id)1030                if base_chunk is None:1031                    continue1032                score = 0.01033                if question_embedding and base_chunk.embedding:1034                    score = cosine_similarity_dense(question_embedding, base_chunk.embedding)1035                retrieved.append(1036                    Chunk(1037                        text=base_chunk.text,1038                        source=base_chunk.source,1039                        tokens=base_chunk.tokens,1040                        score=score,1041                        embedding=base_chunk.embedding,1042                        uid=base_chunk.uid,1043                    )1044                )1045            vector_retrieval_used = bool(retrieved)1046        except Exception as exc:1047            warnings.append(f"Chroma retrieval failed ({exc}); falling back to lexical search.")1048    else:1049        if chromadb is None or Settings is None:1050            warnings.append("Chroma not installed; using lexical retrieval as fallback.")1051        elif question_embedding is None:1052            warnings.append("Could not compute question embedding; using lexical retrieval as fallback.")1053        elif not chunk_embeddings:1054            warnings.append("No chunks were embedded; using lexical retrieval as fallback.")1055 1056    if not vector_retrieval_used:1057        if vector_attempted and not retrieved:1058            warnings.append("Vector retrieval returned no matches; using lexical retrieval as fallback.")1059        retrieved = retrieve_chunks_lexical(question, all_chunks, top_k=3)1060 1061    if EMBEDDER.fallback_mode:1062        warnings.append(1063            "OpenAI embedding API not configured; using hashed fallback vectors until an API key is provided."1064        )1065 1066    context_text = "\n\n".join(chunk.text for chunk in retrieved)1067    context_sections: List[str] = []1068    if context_text:1069        context_sections.append("Retrieved context:\n" + context_text)1070    if full_context_text:1071        context_sections.append("Full document context:\n" + full_context_text)1072    context_for_judge = (1073        "\n\n".join(context_sections)1074        if context_sections1075        else "No context was available for this question."1076    )1077 1078    full_context_answer = LLM.answer(1079        question,1080        context=full_context_text if full_context_text else None,1081    )1082    rag_answer = LLM.answer(question, context=context_text if context_text else None)1083 1084    reference_clean = (reference_answer or "").strip()1085    metrics = evaluate_answers(1086        question=question,1087        documents=documents,1088        all_chunks=all_chunks,1089        retrieved_chunks=retrieved,1090        full_context_answer=full_context_answer,1091        rag_answer=rag_answer,1092        reference_answer=reference_clean,1093        embedder=EMBEDDER,1094        question_embedding=question_embedding,1095    )1096    judge_tasks = [1097        asyncio.create_task(1098            async_llm_judge(1099                LLM.client,1100                question,1101                full_context_answer,1102                reference_clean,1103                context_for_judge,1104            )1105        ),1106        asyncio.create_task(1107            async_llm_judge(1108                LLM.client,1109                question,1110                rag_answer,1111                reference_clean,1112                context_for_judge,1113            )1114        ),1115    ]1116    judge_no, judge_rag = await asyncio.gather(*judge_tasks)1117    judge_results = {"full_context": judge_no, "rag": judge_rag}1118    for label, result in judge_results.items():1119        status = result.get("status")1120        if status == "unavailable":1121            if "LLM judge unavailable" not in " ".join(warnings):1122                warnings.append(1123                    "LLM judge unavailable (no API key configured); judge scores omitted."1124                )1125        elif status == "error":1126            warnings.append(result.get("feedback", "LLM judge encountered an error."))1127    metrics["judge"] = judge_results1128    metrics_html = format_metrics_html(metrics, reference_available=bool(reference_clean))1129    context_markdown = format_context_markdown(retrieved)1130    context_markdown = prepend_warnings(context_markdown, warnings)1131 1132    return full_context_answer, rag_answer, context_markdown, metrics_html1133 1134 1135with gr.Blocks(title="RAG vs Full-Context Comparator", analytics_enabled=False) as demo:1136    gr.Markdown("# RAG vs Full-Context Comparator")1137    gr.HTML(f"<style>{CUSTOM_CSS}</style>")1138    gr.Markdown(1139        "Upload documents, ask a question, and compare a full-document LLM answer against a retrieval-augmented answer. "1140        "Vector retrieval runs on OpenAI text-embedding-3-large + Chroma, with automatic evaluations highlighting grounding, hallucination risk, and retrieval quality."1141    )1142 1143    with gr.Row():1144        question_input = gr.Textbox(1145            label="Question",1146            placeholder="e.g. What are the main obligations in the contract?",1147            lines=2,1148        )1149        reference_input = gr.Textbox(1150            label="Reference answer (optional)",1151            placeholder="Provide a ground-truth answer to compare against.",1152            lines=4,1153        )1154 1155    document_input = gr.File(1156        label="Upload documents",1157        file_count="multiple",1158        file_types=[".txt", ".md", ".markdown", ".json", ".pdf", ".csv"],1159    )1160 1161    run_button = gr.Button("Run comparison", variant="primary")1162 1163    gr.Markdown("## Answer comparison")1164    with gr.Row(elem_classes=["answer-section"]):1165        with gr.Group(elem_classes=["answer-card"]):1166            gr.Markdown("### Full-context answer (entire uploads)")1167            full_context_output = gr.Markdown(show_label=False)1168        with gr.Group(elem_classes=["answer-card"]):1169            gr.Markdown("### RAG answer (with retrieved context)")1170            rag_output = gr.Markdown(show_label=False)1171 1172    context_output = gr.Markdown(label="Retrieved context")1173    metrics_output = gr.HTML(label="Evaluation metrics")1174 1175    with gr.Accordion("What do these metrics mean?", open=False):1176        gr.Markdown(METRIC_DETAILS_MD)1177 1178    run_button.click(1179        fn=compare,1180        inputs=[question_input, document_input, reference_input],1181        outputs=[full_context_output, rag_output, context_output, metrics_output],1182    )1183 1184 1185if __name__ == "__main__":1186    demo.launch()1187