File size: 8,149 Bytes
8eeeaad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16b90ff
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import os, json, re, time
import numpy as np
import gradio as gr
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi
from pinecone import Pinecone
from huggingface_hub import InferenceClient

# CONFIG
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
HF_TOKEN         = os.getenv("HF_TOKEN")
PINECONE_INDEX   = "rag-nlp-project"
LLM_MODEL        = "meta-llama/Meta-Llama-3-8B-Instruct"

# ── LOAD RESOURCES ──
print("Loading resources...")

with open("chunks_recursive.json") as f:
    ALL_CHUNKS = json.load(f)

tokenized = [c["text"].lower().split() for c in ALL_CHUNKS]
bm25 = BM25Okapi(tokenized)

embedder = SentenceTransformer("all-MiniLM-L6-v2")
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

pc = Pinecone(api_key=PINECONE_API_KEY)
pine_index = pc.Index(PINECONE_INDEX)

llm = InferenceClient(token=HF_TOKEN)
print("All ready!")

# ── RETRIEVAL ──
def semantic_search(query, namespace="recursive", top_k=20):
    qvec = embedder.encode(query).tolist()
    res = pine_index.query(vector=qvec, top_k=top_k,
                           include_metadata=True, namespace=namespace)
    return [{"id": m["id"], "text": m["metadata"]["text"],
             "title": m["metadata"]["title"], "score": m["score"]}
            for m in res["matches"]]

def bm25_search(query, top_k=20):
    tokens = query.lower().split()
    scores = bm25.get_scores(tokens)
    top_idx = np.argsort(scores)[::-1][:top_k]
    return [{"id": ALL_CHUNKS[i]["id"], "text": ALL_CHUNKS[i]["text"],
             "title": ALL_CHUNKS[i]["title"], "score": float(scores[i])}
            for i in top_idx if scores[i] > 0]

def rrf_fuse(lists_of_results, k=60):
    scores, data = {}, {}
    for results in lists_of_results:
        for rank, item in enumerate(results):
            did = item["id"]
            scores[did] = scores.get(did, 0) + 1.0 / (k + rank + 1)
            data[did] = {"text": item["text"], "title": item["title"]}
    ranked = sorted(scores, key=lambda x: scores[x], reverse=True)
    return [{"id": d, "rrf_score": scores[d], **data[d]} for d in ranked]

def cross_encoder_rerank(query, candidates, top_k=5):
    if not candidates:
        return []
    pool = candidates[:30]
    pairs = [(query, c["text"]) for c in pool]
    ce_scores = reranker.predict(pairs)
    for i, s in enumerate(ce_scores):
        pool[i]["ce_score"] = float(s)
    pool.sort(key=lambda x: x["ce_score"], reverse=True)
    return pool[:top_k]

# ── LLM ──
def call_llm(prompt, max_tokens=512, temperature=0.3):
    for model in ["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Meta-Llama-3-8B-Instruct"]:
        try:
            resp = llm.chat_completion(
                model=model,
                messages=[{"role": "user", "content": prompt}],
                max_tokens=max_tokens, temperature=temperature
            )
            return resp.choices[0].message.content.strip()
        except:
            continue
    return "[LLM Error: All models failed]"

def generate_answer(query, contexts):
    ctx = "\n\n".join([f"{i+1}. [{c['title']}] {c['text']}" for i, c in enumerate(contexts)])
    prompt = f"""Based on the following information:

{ctx}

Please provide a detailed answer to the question: {query}.
Your answer should integrate the diverse perspectives or data points provided by the retrieved passages.
If the passages are irrelevant to the question, say that you couldn't find a good response in the database."""
    return call_llm(prompt)

# ── EVALUATION ──
def eval_faithfulness(answer, contexts):
    context_str = "\n".join([c["text"] for c in contexts])[:3000]
    claims_raw = call_llm(
        f"Extract all factual claims as a numbered list.\n\nAnswer: {answer}\n\nClaims:",
        max_tokens=400, temperature=0.1
    )
    claims = [re.sub(r"^[\d]+[\.\)]\s*", "", l.strip())
              for l in claims_raw.split("\n")
              if len(re.sub(r"^[\d]+[\.\)]\s*", "", l.strip())) > 15]
    if not claims:
        return 1.0, "No claims extracted."

    supported = 0
    details = []
    for claim in claims[:8]:
        verdict = call_llm(
            f"Is this claim supported by the context? Reply ONLY 'SUPPORTED' or 'NOT SUPPORTED'.\n\n"
            f"Context: {context_str}\n\nClaim: {claim}\n\nVerdict:",
            max_tokens=10, temperature=0.1
        ).upper()
        ok = "SUPPORTED" in verdict and "NOT" not in verdict
        if ok:
            supported += 1
        details.append(f"{'[Y]' if ok else '[N]'} {claim}")
    score = supported / len(claims[:8])
    return score, "\n".join(details)

def eval_relevancy(query, answer):
    qs_raw = call_llm(
        f"Generate exactly 3 questions that this answer directly addresses. "
        f"One per line, no numbering.\n\nAnswer: {answer}\n\nQuestions:",
        max_tokens=200, temperature=0.3
    )
    questions = [re.sub(r"^[\d]+[\.\)]\s*", "", l.strip())
                 for l in qs_raw.split("\n")
                 if len(re.sub(r"^[\d]+[\.\)]\s*", "", l.strip())) > 10][:3]
    if not questions:
        return 0.0, "Could not generate questions."

    embs = embedder.encode([query] + questions)
    q_emb = embs[0]
    sims, detail_lines = [], []
    for i, q in enumerate(questions):
        sim = float(np.dot(q_emb, embs[i+1]) /
                    (np.linalg.norm(q_emb) * np.linalg.norm(embs[i+1])))
        sims.append(sim)
        detail_lines.append(f"  Q{i+1}: {q}  (sim={sim:.3f})")
    return float(np.mean(sims)), "\n".join(detail_lines)

# ── MAIN PIPELINE ──
def run_query(query, run_eval):
    if not query.strip():
        return "Please enter a question.", "", "", ""

    t0 = time.time()
    sem = semantic_search(query)
    kw = bm25_search(query)
    fused = rrf_fuse([sem, kw])
    reranked = cross_encoder_rerank(query, fused)
    t_retrieve = time.time() - t0

    t1 = time.time()
    answer = generate_answer(query, reranked)
    t_generate = time.time() - t1

    ctx_display = ""
    for i, c in enumerate(reranked):
        ctx_display += f"**[{i+1}] {c['title']}**  (score: {c.get('ce_score', 0):.3f})\n"
        ctx_display += f"{c['text']}\n\n---\n\n"

    scores_display = ""
    t_eval = 0
    if run_eval:
        t2 = time.time()
        faith_score, faith_detail = eval_faithfulness(answer, reranked)
        rel_score, rel_detail = eval_relevancy(query, answer)
        t_eval = time.time() - t2
        scores_display = (
            f"### Faithfulness: {faith_score:.0%}\n{faith_detail}\n\n"
            f"### Relevancy: {rel_score:.0%}\n{rel_detail}"
        )
    else:
        scores_display = "*(Check the box to run evaluation)*"

    timing = (f"Retrieval: {t_retrieve:.2f}s | Generation: {t_generate:.2f}s | "
              f"Evaluation: {t_eval:.2f}s | Total: {t_retrieve + t_generate + t_eval:.2f}s")
    return answer, ctx_display, scores_display, timing

# ── GRADIO UI ──
with gr.Blocks(title="RAG Q&A β€” AI/ML Domain", theme=gr.themes.Soft()) as demo:
    gr.Markdown(
        "# RAG Question-Answering System\n"
        "*AI/ML Domain - Hybrid Search (BM25 + Semantic + RRF) - Cross-Encoder Reranking - LLM-as-a-Judge*"
    )
    with gr.Row():
        query_box = gr.Textbox(label="Your Question",
                               placeholder="e.g. What is backpropagation?", scale=4)
        eval_check = gr.Checkbox(label="Run Evaluation (slower)", value=True)
        btn = gr.Button("Ask", variant="primary", scale=1)

    with gr.Tabs():
        with gr.TabItem("Answer"):
            answer_out = gr.Markdown()
        with gr.TabItem("Retrieved Context"):
            context_out = gr.Markdown()
        with gr.TabItem("Evaluation Scores"):
            scores_out = gr.Markdown()

    timing_out = gr.Textbox(label="Timing", interactive=False)
    btn.click(fn=run_query, inputs=[query_box, eval_check],
              outputs=[answer_out, context_out, scores_out, timing_out])

    gr.Markdown("---\n*Embedding: all-MiniLM-L6-v2 | Reranker: ms-marco-MiniLM | "
                "LLM: Meta-Llama-3-8B-Instruct | Vector DB: Pinecone*")

if __name__ == "__main__":
    demo.launch()