ilaa-chenjeri-15 commited on
Commit
2db7cb0
Β·
0 Parent(s):

Initial commit: Explainable RAG system

Browse files
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ myenv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ data/
6
+ processed/
7
+ embeddings/
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 5000
11
+
12
+ CMD ["bash", "-c", "\
13
+ if [ ! -d embeddings ]; then \
14
+ echo 'Running setup...'; \
15
+ python src/ingestion/fetch_docs.py && \
16
+ python src/ingestion/chunk.py && \
17
+ python src/retrieval/embed_store.py; \
18
+ fi && \
19
+ python app.py"]
README.md ADDED
File without changes
app.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ DEBUG = False # ← set False to hide ALL noise
5
+
6
+ os.environ["TRANSFORMERS_VERBOSITY"] = "error"
7
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
8
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
9
+ os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
10
+
11
+ import time
12
+ import numpy as np
13
+ from flask import Flask, render_template, request, jsonify
14
+ from transformers import AutoTokenizer
15
+ from sklearn.decomposition import PCA
16
+
17
+ from src.retrieval.query import (
18
+ retrieve, embed_query, bm25_index, docs_all, metas_all,
19
+ mmr_from_embs, _session
20
+ )
21
+ from src.generation.generate import generate_answer, build_prompt, build_context
22
+
23
+ class SuppressOutput:
24
+ def __enter__(self):
25
+ if not DEBUG:
26
+ self._stdout = sys.stdout
27
+ self._stderr = sys.stderr
28
+ sys.stdout = open(os.devnull, "w")
29
+ sys.stderr = open(os.devnull, "w")
30
+
31
+ def __exit__(self, *args):
32
+ if not DEBUG:
33
+ sys.stdout.close()
34
+ sys.stderr.close()
35
+ sys.stdout = self._stdout
36
+ sys.stderr = self._stderr
37
+
38
+ app = Flask(__name__)
39
+
40
+ with SuppressOutput():
41
+ _tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-small-en-v1.5")
42
+
43
+
44
+ # -----------------------------
45
+ # BM25 IDF
46
+ # -----------------------------
47
+ def _build_idf(bm25):
48
+ return {term: max(0.0, float(val)) for term, val in bm25.idf.items()}
49
+
50
+ _idf_map = _build_idf(bm25_index)
51
+ _max_idf = max(_idf_map.values()) if _idf_map else 1.0
52
+
53
+
54
+ # -----------------------------
55
+ # PCA FIT
56
+ # -----------------------------
57
+ def _fit_pca(n_components=128):
58
+ import random
59
+ from sentence_transformers import SentenceTransformer
60
+
61
+ sample = random.sample(docs_all, min(200, len(docs_all)))
62
+
63
+ with SuppressOutput():
64
+ model = SentenceTransformer("BAAI/bge-small-en-v1.5")
65
+ embs = model.encode(sample, normalize_embeddings=True)
66
+
67
+ n_comp = min(n_components, embs.shape[0], embs.shape[1])
68
+ pca = PCA(n_components=n_comp)
69
+ pca.fit(embs)
70
+ return pca
71
+
72
+
73
+ _pca = _fit_pca(128)
74
+
75
+
76
+ # -----------------------------
77
+ # ROUTES
78
+ # -----------------------------
79
+ @app.route("/")
80
+ def home():
81
+ return render_template("index.html")
82
+
83
+
84
+ @app.route("/analyze_query", methods=["POST"])
85
+ def analyze_query():
86
+ data = request.json
87
+ query = data.get("query", "")
88
+
89
+ enc = _tokenizer(query, return_offsets_mapping=True)
90
+ input_ids = enc["input_ids"]
91
+ tokens_raw = _tokenizer.convert_ids_to_tokens(input_ids)
92
+
93
+ special = set(_tokenizer.all_special_tokens)
94
+ tokens = [
95
+ {"token": t, "id": int(i)}
96
+ for t, i in zip(tokens_raw, input_ids)
97
+ if t not in special
98
+ ]
99
+
100
+ for tok in tokens:
101
+ word = tok["token"].lstrip("##").lower()
102
+ raw_idf = _idf_map.get(word, 0.0)
103
+ tok["idf"] = round(raw_idf, 4)
104
+ tok["idf_normalized"] = round(raw_idf / _max_idf, 4) if _max_idf else 0.0
105
+
106
+ idf_vals = [t["idf"] for t in tokens]
107
+ avg_idf = round(sum(idf_vals) / len(idf_vals), 4) if idf_vals else 0.0
108
+ unique_toks = len({t["token"] for t in tokens})
109
+ complexity = round(min(1.0, (len(tokens) / 20) * 0.4 + (avg_idf / _max_idf) * 0.6), 3)
110
+
111
+ q_emb = embed_query(query)
112
+ projected = _pca.transform(q_emb.reshape(1, -1))[0]
113
+ p_min, p_max = projected.min(), projected.max()
114
+
115
+ normed = (
116
+ ((projected - p_min) / (p_max - p_min) * 2 - 1).tolist()
117
+ if p_max != p_min else [0.0] * len(projected)
118
+ )
119
+
120
+ return jsonify({
121
+ "tokens": tokens,
122
+ "embedding": [round(v, 4) for v in normed],
123
+ "stats": {
124
+ "token_count": len(tokens),
125
+ "unique_tokens": unique_toks,
126
+ "avg_idf": avg_idf,
127
+ "complexity": complexity,
128
+ }
129
+ })
130
+
131
+
132
+ @app.route("/mmr_rerun", methods=["POST"])
133
+ def mmr_rerun():
134
+ if _session["query_emb"] is None:
135
+ return jsonify({"error": "No active session. Run a query first."}), 400
136
+
137
+ data = request.json
138
+ lambda_param = float(data.get("lambda", 0.7))
139
+ lambda_param = max(0.0, min(1.0, lambda_param))
140
+
141
+ selected = mmr_from_embs(
142
+ _session["query_emb"],
143
+ _session["doc_indices"],
144
+ _session["embs"],
145
+ k=10,
146
+ lambda_param=lambda_param
147
+ )
148
+
149
+ return jsonify({
150
+ "selected_indices": selected,
151
+ "selected_local": [
152
+ _session["doc_indices"].index(s) for s in selected
153
+ ],
154
+ "lambda": lambda_param,
155
+ })
156
+
157
+
158
+ @app.route("/ask", methods=["POST"])
159
+ def ask():
160
+ data = request.json
161
+ query = data.get("query")
162
+
163
+ if DEBUG:
164
+ print(f"\n[API QUERY]: {query}\n")
165
+
166
+ t0 = time.perf_counter()
167
+ results, debug = retrieve(query)
168
+ t_retrieve = time.perf_counter() - t0
169
+
170
+ results = sorted(results, key=lambda x: (
171
+ x["meta"].get("chunk_id", 0),
172
+ x["meta"].get("global_chunk_id", 0)
173
+ ))
174
+
175
+ docs = [r["text"] for r in results]
176
+ metas = [r["meta"] for r in results]
177
+ raw_scores = [float(r["rerank_score"]) for r in results]
178
+
179
+ context = build_context(docs, metas, raw_scores)
180
+
181
+ t1 = time.perf_counter()
182
+ prompt = build_prompt(query, context)
183
+ answer = generate_answer(prompt)
184
+ t_llm = time.perf_counter() - t1
185
+
186
+ sources = [
187
+ {"title": meta.get("title", "Source"), "url": meta.get("url", "")}
188
+ for meta in metas
189
+ ]
190
+
191
+ stage_timings = debug.get("timings", {})
192
+ stage_timings["llm"] = round(t_llm * 1000)
193
+ stage_timings["total"] = round((t_retrieve + t_llm) * 1000)
194
+
195
+ return jsonify({
196
+ "answer": answer,
197
+ "sources": sources,
198
+ "chunks": docs,
199
+ "scores": raw_scores,
200
+ "raw_scores": raw_scores,
201
+ "debug": debug,
202
+ "timings": stage_timings
203
+ })
204
+
205
+
206
+ # -----------------------------
207
+ # RUN
208
+ # -----------------------------
209
+ if __name__ == "__main__":
210
+ app.run(debug=DEBUG)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ numpy
3
+ scikit-learn
4
+ transformers
5
+ sentence-transformers
6
+ chromadb
7
+ rank-bm25
8
+ umap-learn
9
+ requests
10
+ beautifulsoup4
11
+ tqdm
src/generation/generate.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from src.retrieval.query import retrieve
3
+
4
+ def build_prompt(query, context):
5
+ return f"""You are a helpful assistant. Answer using ONLY the context below. Do NOT use outside knowledge.
6
+
7
+ ### Context
8
+ {context}
9
+
10
+ ### Question
11
+ {query}
12
+
13
+ ### Instructions
14
+
15
+ - Start directly with the answer. No introduction.
16
+ - Use only information from the context.
17
+ - Do NOT invent or modify commands beyond fixing formatting.
18
+
19
+ ---
20
+
21
+ ### Structure
22
+
23
+ - If the question is conceptual or descriptive (e.g. "what is X", "explain X") β†’
24
+ explain fully using all relevant information from the context. Cover features,
25
+ use cases, and design principles if present. Do NOT use methods or numbered steps.
26
+
27
+ - If there is ONE clear workflow β†’ use numbered steps.
28
+
29
+ - Only treat something as a separate method if the context explicitly presents it as a distinct approach to solving the same task.
30
+ - Do NOT create methods from examples, helper classes, or unrelated concepts.
31
+ - If the context describes only ONE workflow or approach, DO NOT create multiple methods.
32
+ - In that case, use numbered steps instead.
33
+
34
+ - If there are MULTIPLE valid ways to perform the task:
35
+ β†’ group them into separate sections using:
36
+
37
+ ### Method 1 - Name
38
+ ### Method 2 - Name
39
+
40
+ - Each method must be self-contained.
41
+ - Do NOT mix commands from different methods.
42
+ - Do NOT merge all methods into one numbered list.
43
+
44
+ ---
45
+
46
+ ### Code Rules
47
+
48
+ - All commands MUST be inside fenced code blocks.
49
+ - Use:
50
+ - ```bash for shell commands
51
+ - ```python for Python code
52
+
53
+ - Commands must be valid and executable.
54
+
55
+ - Fix formatting issues:
56
+ - Merge broken tokens (". env" β†’ ".env")
57
+ - Fix paths ("source . env /bin/activate" β†’ "source .env/bin/activate")
58
+ - Split combined commands into separate lines
59
+
60
+ - NEVER:
61
+ - put multiple commands on the same line
62
+ - leave commands outside code blocks
63
+ - use inline code blocks like ```bash command```
64
+
65
+ ---
66
+
67
+ ### Output Requirements
68
+
69
+ - Output must be clean, valid markdown.
70
+ - Code blocks must render correctly.
71
+
72
+ ### Answer
73
+ """
74
+
75
+ import requests
76
+ import os
77
+
78
+ def generate_answer(prompt, model="llama-3.1-8b-instant"):
79
+ response = requests.post(
80
+ "https://api.groq.com/openai/v1/chat/completions",
81
+ headers={
82
+ "Authorization": f"Bearer {"API_KEY"}",
83
+ "Content-Type": "application/json"
84
+ },
85
+ json={
86
+ "model": model,
87
+ "max_tokens": 2048,
88
+ "messages": [
89
+ {"role": "user", "content": prompt}
90
+ ],
91
+ "temperature": 0
92
+ }
93
+ )
94
+ data = response.json()
95
+ print("GROQ RESPONSE:", data)
96
+
97
+ return response.json()["choices"][0]["message"]["content"]
98
+
99
+ def build_context(docs, metas, scores):
100
+ context = ""
101
+
102
+ for i, (doc, meta, score) in enumerate(zip(docs, metas, scores)):
103
+ context += f"""
104
+ [Source {i+1} | Score: {round(score, 3)} | {meta.get('url', 'N/A')}]
105
+ Title: {meta.get("title", "N/A")}
106
+
107
+ {doc}
108
+
109
+ {"-"*50}
110
+ """
111
+ return context
112
+
113
+
114
+
115
+
src/ingestion/chunk.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import json
3
+ from tqdm import tqdm
4
+
5
+
6
+ CHUNK_SIZE = 300
7
+ OVERLAP = 50
8
+ MIN_CHUNK_WORDS = 30
9
+
10
+ def split_by_headings(text: str) -> list[str]:
11
+
12
+ heading_re = re.compile(r'(?:^|\n)(?=\n?[A-Z][^\n]{2,80}\n)')
13
+
14
+ candidate_positions = [m.start() for m in heading_re.finditer(text)]
15
+
16
+ safe_positions = [0]
17
+ in_fence = False
18
+ fence_re = re.compile(r'```')
19
+ fence_positions = [m.start() for m in fence_re.finditer(text)]
20
+ fence_idx = 0
21
+
22
+ for pos in candidate_positions:
23
+ if pos == 0:
24
+ continue
25
+ while fence_idx < len(fence_positions) and fence_positions[fence_idx] < pos:
26
+ in_fence = not in_fence
27
+ fence_idx += 1
28
+ if not in_fence:
29
+ safe_positions.append(pos)
30
+
31
+ safe_positions.append(len(text))
32
+ sections = []
33
+ for i in range(len(safe_positions) - 1):
34
+ chunk = text[safe_positions[i]:safe_positions[i + 1]].strip()
35
+ if chunk:
36
+ sections.append(chunk)
37
+
38
+ return sections if sections else [text.strip()]
39
+
40
+ def split_section_into_parts(section: str) -> list[dict]:
41
+
42
+ parts = []
43
+ segments = re.split(r'(```[\s\S]*?```)', section)
44
+
45
+ for seg in segments:
46
+ seg = seg.strip()
47
+ if not seg:
48
+ continue
49
+ if seg.startswith("```"):
50
+ parts.append({'type': 'code', 'content': seg})
51
+ else:
52
+ sentences = re.split(r'(?<=[.!?])\s+', seg)
53
+ for sent in sentences:
54
+ sent = sent.strip()
55
+ if sent:
56
+ parts.append({'type': 'text', 'content': sent})
57
+ return parts
58
+
59
+
60
+ def chunk_section(section: str, chunk_size: int = CHUNK_SIZE, overlap: int = OVERLAP) -> list[str]:
61
+ parts = split_section_into_parts(section)
62
+
63
+ chunks: list[str] = []
64
+ current_parts: list[dict] = []
65
+ current_words = 0
66
+
67
+ def flush(current_parts: list[dict]) -> list[str]:
68
+ text = "\n\n".join(p['content'] for p in current_parts).strip()
69
+ return text
70
+
71
+ def word_count(s: str) -> int:
72
+ return len(s.split())
73
+
74
+ for part in parts:
75
+ wc = word_count(part['content'])
76
+ if part['type'] == 'code':
77
+ if current_words + wc > chunk_size and current_parts:
78
+ chunks.append(flush(current_parts))
79
+ current_parts = []
80
+ current_words = 0
81
+
82
+ current_parts.append(part)
83
+ current_words += wc
84
+
85
+ if current_words >= chunk_size:
86
+ chunks.append(flush(current_parts))
87
+ current_parts = []
88
+ current_words = 0
89
+
90
+ continue
91
+
92
+ if current_words + wc > chunk_size and current_parts:
93
+ chunks.append(flush(current_parts))
94
+
95
+ overlap_parts: list[dict] = []
96
+ overlap_words = 0
97
+ for prev in reversed(current_parts):
98
+ if prev['type'] == 'code':
99
+ pw = word_count(prev['content'])
100
+ if overlap_words + pw > overlap:
101
+ break
102
+ overlap_parts.insert(0, prev)
103
+ overlap_words += pw
104
+
105
+ current_parts = overlap_parts
106
+ current_words = overlap_words
107
+
108
+ current_parts.append(part)
109
+ current_words += wc
110
+
111
+ if current_parts:
112
+ chunks.append(flush(current_parts))
113
+
114
+ return chunks
115
+
116
+ def hybrid_chunk(doc: dict) -> list[str]:
117
+ sections = split_by_headings(doc["text"])
118
+ final_chunks: list[str] = []
119
+
120
+ for section in sections:
121
+ chunks = chunk_section(section)
122
+ final_chunks.extend(chunks)
123
+
124
+ return final_chunks
125
+
126
+ def chunk_documents(input_path="data/docs.json", output_path="processed/chunks.json"):
127
+ print("[INFO] Loading documents...\n")
128
+
129
+ with open(input_path, "r") as f:
130
+ docs = json.load(f)
131
+
132
+ print(f"[INFO] Loaded {len(docs)} documents\n")
133
+
134
+ all_chunks = []
135
+ global_id = 0
136
+
137
+ for doc in tqdm(docs, desc="Chunking documents"):
138
+ chunks = hybrid_chunk(doc)
139
+
140
+ chunks = [c for c in chunks if len(c.split()) >= MIN_CHUNK_WORDS]
141
+
142
+ print(f"[DEBUG] {doc['metadata']['title']} β†’ {len(chunks)} chunks")
143
+
144
+ for local_id, chunk in enumerate(chunks):
145
+ text = chunk.strip()
146
+ if not text:
147
+ continue
148
+
149
+ if text.count("```") % 2 != 0:
150
+ text += "\n```"
151
+
152
+ all_chunks.append({
153
+ "text": text,
154
+ "metadata": {
155
+ **doc["metadata"],
156
+ "chunk_id": local_id,
157
+ "global_chunk_id": global_id
158
+ }
159
+ })
160
+
161
+ global_id += 1
162
+
163
+ wc_list = [len(c["text"].split()) for c in all_chunks]
164
+ print(f"\n[INFO] Total chunks created: {len(all_chunks)}")
165
+ if wc_list:
166
+ print(f"[INFO] Word count β€” min: {min(wc_list)}, max: {max(wc_list)}, "
167
+ f"mean: {sum(wc_list)//len(wc_list)}, "
168
+ f"median: {sorted(wc_list)[len(wc_list)//2]}")
169
+
170
+ with open(output_path, "w") as f:
171
+ json.dump(all_chunks, f, indent=2)
172
+
173
+ print(f"[SUCCESS] Saved to {output_path}")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ chunk_documents()
src/ingestion/clean.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+
4
+ def normalize_text(text: str) -> str:
5
+ text = re.sub(r'\r\n?', '\n', text)
6
+ text = re.sub(r'\.\s+([a-zA-Z0-9])', r'.\1', text)
7
+ text = re.sub(r'(\w)\s*/\s*(\w)', r'\1/\2', text)
8
+ text = re.sub(r'[ \t]+', ' ', text)
9
+ text = re.sub(r'\s*```\s*', '\n```\n', text)
10
+ text = re.sub(r'(?<!\n)(uv pip install)', r'\n\1', text)
11
+ text = re.sub(r'\n{3,}', '\n\n', text)
12
+ return text.strip()
13
+
14
+
15
+ def clean_text(text: str) -> str:
16
+ text = re.sub(r'[ \t]+', ' ', text)
17
+ text = re.sub(r'\n\s*\n+', '\n\n', text)
18
+ text = text.replace('\xa0', ' ')
19
+ text = re.sub(r'\n{3,}', '\n\n', text)
20
+ text = normalize_text(text)
21
+ return text.strip()
src/ingestion/fetch_docs.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, os.path.dirname(__file__))
6
+
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
+ import json
10
+ import time
11
+ from tqdm import tqdm
12
+ from clean import clean_text
13
+
14
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
15
+ DOCS_OUTPUT = PROJECT_ROOT / "data" / "docs.json"
16
+
17
+ URLS = [
18
+ "https://huggingface.co/docs/transformers/index/",
19
+ "https://huggingface.co/docs/transformers/installation/",
20
+ "https://huggingface.co/docs/transformers/quicktour/",
21
+ "https://huggingface.co/docs/transformers/weightconverter",
22
+ "https://huggingface.co/docs/transformers/models",
23
+ "https://huggingface.co/docs/transformers/custom_models",
24
+ "https://huggingface.co/docs/transformers/monkey_patching",
25
+ "https://huggingface.co/docs/transformers/fusion_mapping",
26
+ "https://huggingface.co/docs/transformers/how_to_hack_models",
27
+ "https://huggingface.co/docs/transformers/model_sharing",
28
+ "https://huggingface.co/docs/transformers/serialization"
29
+ ]
30
+
31
+
32
+ def normalize_code_block(code: str) -> str:
33
+ lines = code.split("\n")
34
+ stripped = [l.rstrip() for l in lines]
35
+
36
+ # Drop leading/trailing blank lines
37
+ while stripped and not stripped[0]:
38
+ stripped.pop(0)
39
+ while stripped and not stripped[-1]:
40
+ stripped.pop()
41
+
42
+ if not stripped:
43
+ return ""
44
+
45
+ def fix_python_spacing(s: str) -> str:
46
+ import re
47
+ s = re.sub(r'\s*\(\s*', '(', s)
48
+ s = re.sub(r'\s*\)\s*', ')', s)
49
+ s = re.sub(r'\s*\[\s*', '[', s)
50
+ s = re.sub(r'\s*\]\s*', ']', s)
51
+ s = re.sub(r'\s*,\s*', ', ', s)
52
+ s = re.sub(r'\s*:\s*', ': ', s)
53
+ s = re.sub(r'\s*=\s*', '=', s)
54
+ s = re.sub(r'([^=!<>])=([^=])', r'\1 = \2', s)
55
+ s = re.sub(r'==', ' == ', s)
56
+ s = re.sub(r'!=', ' != ', s)
57
+ s = re.sub(r' +', ' ', s)
58
+ return s.strip()
59
+
60
+ if len(stripped) <= 4 and all(len(l.split()) <= 4 for l in stripped if l):
61
+ joined = " ".join(l for l in stripped if l)
62
+ return joined
63
+
64
+ fixed = []
65
+ for line in stripped:
66
+ fixed.append(fix_python_spacing(line))
67
+ return "\n".join(fixed)
68
+
69
+
70
+ def extract_main_text(soup):
71
+ main = soup.find("main")
72
+ if not main:
73
+ return ""
74
+
75
+ for tag in main.find_all(["nav", "footer", "aside", "script", "style"]):
76
+ tag.decompose()
77
+
78
+ for tag in main.find_all(True):
79
+ if tag.get_text(" ", strip=True).startswith("and get access to the augmented"):
80
+ tag.decompose()
81
+ break
82
+
83
+ texts = []
84
+
85
+ for tag in main.find_all(["h1", "h2", "h3", "p", "li", "pre"]):
86
+
87
+ if tag.name == "pre":
88
+ code_tag = tag.find("code")
89
+ raw = (code_tag or tag).get_text("\n")
90
+
91
+ lang = ""
92
+ cls = (code_tag or tag).get("class", [])
93
+ for c in cls:
94
+ if c.startswith("language-"):
95
+ lang = c.replace("language-", "")
96
+ break
97
+
98
+ normalized = normalize_code_block(raw)
99
+ if not normalized or len(normalized.strip()) < 3:
100
+ continue
101
+
102
+ texts.append(f"\n```{lang}\n{normalized}\n```\n")
103
+ continue
104
+
105
+ content = tag.get_text(" ", strip=True)
106
+
107
+ if len(content) < 30:
108
+ continue
109
+
110
+ if tag.name in ["h1", "h2", "h3"]:
111
+ texts.append(f"\n{content}\n")
112
+ elif tag.name == "li":
113
+ texts.append(f"- {content}")
114
+ else:
115
+ texts.append(content)
116
+
117
+ return "\n\n".join(texts)
118
+
119
+
120
+ def process_url(url, retries=3):
121
+ headers = {"User-Agent": "Mozilla/5.0 (compatible; RAG-doc-fetcher/1.0)"}
122
+
123
+ for attempt in range(retries):
124
+ try:
125
+ response = requests.get(url, timeout=10, headers=headers)
126
+ response.raise_for_status()
127
+
128
+ print(f"[SUCCESS] Fetched: {url} (Attempt {attempt+1})\n")
129
+
130
+ soup = BeautifulSoup(response.text, "html.parser")
131
+
132
+ text = extract_main_text(soup)
133
+ cleaned = clean_text(text)
134
+
135
+ title = soup.title.string if soup.title else "No Title"
136
+
137
+ return {
138
+ "text": cleaned,
139
+ "metadata": {
140
+ "source": "huggingface",
141
+ "url": url,
142
+ "title": title
143
+ }
144
+ }
145
+
146
+ except requests.RequestException as e:
147
+ print(f"[Attempt {attempt+1}/{retries}] Failed: {url}")
148
+ print(f"Error: {e}")
149
+
150
+ if attempt < retries - 1:
151
+ print("Waiting 5 seconds before retry...\n")
152
+ time.sleep(5)
153
+ else:
154
+ print(f"[FAILED] All retries failed for: {url}\n")
155
+ return None
156
+
157
+
158
+ def main():
159
+ docs = []
160
+
161
+ for i, url in enumerate(tqdm(URLS, desc="Processing URLs")):
162
+ doc = process_url(url)
163
+
164
+ if doc and doc["text"].strip():
165
+ docs.append(doc)
166
+
167
+ if i < len(URLS) - 1:
168
+ time.sleep(0.75)
169
+
170
+ DOCS_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
171
+ with open(DOCS_OUTPUT, "w") as f:
172
+ json.dump(docs, f, indent=2)
173
+
174
+ print(f"Saved {len(docs)} docs to {DOCS_OUTPUT}")
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
src/retrieval/embed_store.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from tqdm import tqdm
3
+ from sentence_transformers import SentenceTransformer
4
+ from chromadb import PersistentClient
5
+
6
+ model = SentenceTransformer("BAAI/bge-small-en-v1.5")
7
+
8
+ client = PersistentClient(path="embeddings/")
9
+ collection = client.get_or_create_collection(name="rag_docs")
10
+
11
+
12
+ def embed_and_store(input_path="processed/chunks.json"):
13
+ with open(input_path, "r") as f:
14
+ chunks = json.load(f)
15
+
16
+ documents, metadatas, ids = [], [], []
17
+
18
+ for i, chunk in enumerate(tqdm(chunks)):
19
+ documents.append("passage: " + chunk["text"])
20
+ metadatas.append(chunk["metadata"])
21
+ ids.append(f"chunk_{i}")
22
+
23
+ client.delete_collection(name="rag_docs")
24
+ collection = client.get_or_create_collection(name="rag_docs")
25
+
26
+ embeddings = model.encode(
27
+ documents,
28
+ batch_size=32,
29
+ normalize_embeddings=True,
30
+ show_progress_bar=True
31
+ )
32
+
33
+ collection.add(
34
+ documents=documents,
35
+ metadatas=metadatas,
36
+ ids=ids,
37
+ embeddings=embeddings.tolist()
38
+ )
39
+
40
+
41
+ if __name__ == "__main__":
42
+ embed_and_store()
src/retrieval/preprocess.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from nltk.stem import WordNetLemmatizer
2
+ from nltk.tokenize import word_tokenize
3
+
4
+ lemmatizer = WordNetLemmatizer()
5
+
6
+ def preprocess(text):
7
+ tokens = word_tokenize(text.lower())
8
+ return [lemmatizer.lemmatize(t) for t in tokens]
src/retrieval/query.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import time
3
+ import numpy as np
4
+ from sentence_transformers import SentenceTransformer, CrossEncoder
5
+ from chromadb import PersistentClient
6
+ from functools import lru_cache
7
+ from rank_bm25 import BM25Okapi
8
+
9
+ # ------------------ MODELS ------------------
10
+ embed_model = SentenceTransformer("BAAI/bge-small-en-v1.5")
11
+ reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
12
+
13
+ # ------------------ DB ------------------
14
+ client = PersistentClient(path="embeddings/")
15
+ collection = client.get_collection(name="rag_docs")
16
+
17
+ data = collection.get(include=["documents", "metadatas"])
18
+ docs_all = data["documents"]
19
+ metas_all = data["metadatas"]
20
+ ids_all = data["ids"]
21
+
22
+ # ------------------ BM25 INDEX ------------------
23
+ def tokenize(text: str) -> list[str]:
24
+ return re.findall(r"\w+", text.lower())
25
+
26
+ bm25_corpus = [tokenize(doc) for doc in docs_all]
27
+ bm25_index = BM25Okapi(bm25_corpus)
28
+
29
+ def bm25_search(query: str, top_n: int = 25) -> list[tuple[int, float]]:
30
+ tokens = tokenize(query)
31
+ raw_scores = bm25_index.get_scores(tokens)
32
+ max_s, min_s = raw_scores.max(), raw_scores.min()
33
+ norm = (raw_scores - min_s) / (max_s - min_s) if max_s != min_s else np.zeros_like(raw_scores)
34
+ top_indices = np.argsort(norm)[::-1][:top_n]
35
+ return [(int(i), float(norm[i])) for i in top_indices]
36
+
37
+ # ------------------ EMBEDDING ------------------
38
+ @lru_cache(maxsize=128)
39
+ def embed_query(query: str):
40
+ return embed_model.encode("query: " + query, normalize_embeddings=True)
41
+
42
+ # ------------------ HYBRID FUSION ------------------
43
+ def hybrid_fusion(vector_indices, vector_scores, bm25_results, alpha=0.5, top_n=25):
44
+ vec_map = dict(zip(vector_indices, vector_scores))
45
+ bm25_map = dict(bm25_results)
46
+ fused = [
47
+ (idx, vec_map.get(idx, 0.0), bm25_map.get(idx, 0.0),
48
+ alpha * vec_map.get(idx, 0.0) + (1 - alpha) * bm25_map.get(idx, 0.0))
49
+ for idx in set(vec_map) | set(bm25_map)
50
+ ]
51
+ fused.sort(key=lambda x: x[3], reverse=True)
52
+ return fused[:top_n]
53
+
54
+ # ------------------ MMR ------------------
55
+ def mmr(query_emb, doc_indices, k=10, lambda_param=0.7):
56
+ embs = [
57
+ np.array(collection.get(ids=[ids_all[i]], include=["embeddings"])["embeddings"][0])
58
+ for i in doc_indices
59
+ ]
60
+ embs = [e / np.linalg.norm(e) for e in embs]
61
+ query_emb = query_emb / np.linalg.norm(query_emb)
62
+ sims = [np.dot(query_emb, e) for e in embs]
63
+
64
+ best_idx = int(np.argmax(sims))
65
+ selected = [doc_indices[best_idx]]
66
+ sel_idx = [best_idx]
67
+ mmr_debug = []
68
+
69
+ while len(selected) < min(k, len(doc_indices)):
70
+ scores = [
71
+ (lambda_param * sims[i] - (1 - lambda_param) * max(np.dot(embs[i], embs[j]) for j in sel_idx),
72
+ i, sims[i], max(np.dot(embs[i], embs[j]) for j in sel_idx))
73
+ for i in range(len(doc_indices)) if i not in sel_idx
74
+ ]
75
+ if not scores:
76
+ break
77
+ _, idx, rel, div = max(scores)
78
+ selected.append(doc_indices[idx])
79
+ sel_idx.append(idx)
80
+ mmr_debug.append({
81
+ "doc_index": doc_indices[idx],
82
+ "relevance": float(rel),
83
+ "diversity_penalty": float(div),
84
+ })
85
+
86
+ return selected, mmr_debug
87
+
88
+ # ------------------ MMR (rerun with cached embs) ------------------
89
+ def mmr_from_embs(query_emb, doc_indices, embs, k=10, lambda_param=0.7):
90
+ """Same as mmr() but uses pre-fetched embeddings β€” for fast lambda slider reruns."""
91
+ embs_n = [e / np.linalg.norm(e) for e in embs]
92
+ query_emb = query_emb / np.linalg.norm(query_emb)
93
+ sims = [np.dot(query_emb, e) for e in embs_n]
94
+
95
+ best_idx = int(np.argmax(sims))
96
+ selected = [doc_indices[best_idx]]
97
+ sel_idx = [best_idx]
98
+
99
+ while len(selected) < min(k, len(doc_indices)):
100
+ scores = [
101
+ (lambda_param * sims[i] - (1 - lambda_param) * max(np.dot(embs_n[i], embs_n[j]) for j in sel_idx),
102
+ i, sims[i], max(np.dot(embs_n[i], embs_n[j]) for j in sel_idx))
103
+ for i in range(len(doc_indices)) if i not in sel_idx
104
+ ]
105
+ if not scores:
106
+ break
107
+ _, idx, rel, div = max(scores)
108
+ selected.append(doc_indices[idx])
109
+ sel_idx.append(idx)
110
+
111
+ return selected
112
+
113
+ # ------------------ RERANK ------------------
114
+ def rerank(query, doc_indices, top_k=7):
115
+ docs = [docs_all[i] for i in doc_indices]
116
+ pairs = [[query, doc] for doc in docs]
117
+ scores = reranker.predict(pairs)
118
+ s_arr = np.array(scores, dtype=float)
119
+ if s_arr.max() != s_arr.min():
120
+ s_arr = (s_arr - s_arr.min()) / (s_arr.max() - s_arr.min())
121
+ else:
122
+ s_arr = np.ones_like(s_arr)
123
+ scored = sorted(zip(doc_indices, s_arr.tolist()), key=lambda x: x[1], reverse=True)
124
+ return scored[:top_k], scored
125
+
126
+ _session = {
127
+ "query_emb": None, # np.ndarray
128
+ "doc_indices": None, # list[int]
129
+ "embs": None, # list[np.ndarray] β€” raw (not normalized)
130
+ "sims": None, # list[float] query-doc cosine sims
131
+ "umap_coords": None, # list[[x,y]] β€” computed once per query
132
+ "sim_matrix": None, # NxN similarity matrix between candidates
133
+ }
134
+
135
+ def _compute_umap(embs_norm):
136
+ try:
137
+ import umap
138
+ n = len(embs_norm)
139
+ n_neighbors = min(5, n - 1)
140
+ reducer = umap.UMAP(n_components=2, n_neighbors=n_neighbors,
141
+ min_dist=0.1, random_state=42, verbose=False)
142
+ coords = reducer.fit_transform(np.array(embs_norm))
143
+ for dim in range(2):
144
+ mn, mx = coords[:, dim].min(), coords[:, dim].max()
145
+ if mx != mn:
146
+ coords[:, dim] = (coords[:, dim] - mn) / (mx - mn)
147
+ return coords.tolist()
148
+ except Exception as e:
149
+ print(f"[UMAP] Failed: {e}")
150
+ return None
151
+
152
+ def _compute_sim_matrix(embs_norm):
153
+ mat = np.array(embs_norm)
154
+ sim = mat @ mat.T
155
+ return np.clip(sim, -1, 1).tolist()
156
+
157
+ RERANK_THRESHOLD = 0.3
158
+ HYBRID_ALPHA = 0.7
159
+
160
+ def retrieve(query, top_k=7):
161
+ print(f"\nπŸ” Query: {query}")
162
+ timings = {}
163
+
164
+ # --- EMBED ---
165
+ t = time.perf_counter()
166
+ query_emb = embed_query(query)
167
+ timings["embed"] = round((time.perf_counter() - t) * 1000)
168
+
169
+ # --- VECTOR SEARCH ---
170
+ t = time.perf_counter()
171
+ results = collection.query(query_embeddings=[query_emb.tolist()], n_results=25)
172
+ vector_ids = results["ids"][0]
173
+ vector_dists = results["distances"][0]
174
+ vector_scores = [1 - d for d in vector_dists]
175
+ vector_indices = [ids_all.index(i) for i in vector_ids]
176
+ timings["vector"] = round((time.perf_counter() - t) * 1000)
177
+ print(f"[Vector Search] Retrieved: {len(vector_indices)} chunks")
178
+
179
+ # --- BM25 ---
180
+ t = time.perf_counter()
181
+ bm25_results = bm25_search(query, top_n=25)
182
+ timings["bm25"] = round((time.perf_counter() - t) * 1000)
183
+ print(f"[BM25] Retrieved: {len(bm25_results)} chunks")
184
+
185
+ # --- HYBRID FUSION ---
186
+ t = time.perf_counter()
187
+ fused = hybrid_fusion(vector_indices, vector_scores, bm25_results, alpha=HYBRID_ALPHA)
188
+ hybrid_indices = [idx for idx, _, _, _ in fused]
189
+ score_lookup = {idx: (vs, bs, hs) for idx, vs, bs, hs in fused}
190
+ timings["hybrid"] = round((time.perf_counter() - t) * 1000)
191
+ print(f"[Hybrid] Fused: {len(hybrid_indices)} chunks")
192
+
193
+ # --- FETCH EMBEDDINGS for MMR + cache ---
194
+ t = time.perf_counter()
195
+ raw_embs = [
196
+ np.array(collection.get(ids=[ids_all[i]], include=["embeddings"])["embeddings"][0])
197
+ for i in hybrid_indices
198
+ ]
199
+ embs_norm = [e / np.linalg.norm(e) for e in raw_embs]
200
+ query_emb_n = query_emb / np.linalg.norm(query_emb)
201
+ sims = [float(np.dot(query_emb_n, e)) for e in embs_norm]
202
+
203
+ # --- MMR ---
204
+ mmr_selected = mmr_from_embs(query_emb, hybrid_indices, raw_embs, k=10)
205
+ mmr_debug = []
206
+ timings["mmr"] = round((time.perf_counter() - t) * 1000)
207
+ print(f"[MMR] Selected: {len(mmr_selected)} chunks")
208
+
209
+ # --- CACHE session ---
210
+ umap_coords = _compute_umap(embs_norm)
211
+ sim_matrix = _compute_sim_matrix(embs_norm)
212
+
213
+ _session["query_emb"] = query_emb
214
+ _session["doc_indices"] = hybrid_indices
215
+ _session["embs"] = raw_embs
216
+ _session["sims"] = sims
217
+ _session["umap_coords"] = umap_coords
218
+ _session["sim_matrix"] = sim_matrix
219
+
220
+ # --- RERANK ---
221
+ t = time.perf_counter()
222
+ top_final, full_rerank = rerank(query, mmr_selected, top_k)
223
+ top_final = [(i, score) for i, score in top_final if score >= RERANK_THRESHOLD]
224
+ timings["rerank"] = round((time.perf_counter() - t) * 1000)
225
+ print(f"[Reranker] Selected: {len(top_final)} chunks (threshold: {RERANK_THRESHOLD})")
226
+
227
+ # --- OUTPUT ---
228
+ final = [
229
+ {
230
+ "text": docs_all[i].replace("passage: ", ""),
231
+ "meta": metas_all[i],
232
+ "rerank_score": float(score),
233
+ "vector_score": score_lookup.get(i, (0, 0, 0))[0],
234
+ "bm25_score": score_lookup.get(i, (0, 0, 0))[1],
235
+ "hybrid_score": score_lookup.get(i, (0, 0, 0))[2],
236
+ }
237
+ for i, score in top_final
238
+ ]
239
+
240
+ # --- MMR without diversity (pure relevance) ---
241
+ no_mmr_selected = [
242
+ hybrid_indices[i] for i in np.argsort(sims)[::-1][:10]
243
+ ]
244
+
245
+ debug_info = {
246
+ "vector_count": len(vector_indices),
247
+ "bm25_count": len(bm25_results),
248
+ "hybrid_count": len(hybrid_indices),
249
+ "mmr_count": len(mmr_selected),
250
+ "rerank_count": len(top_final),
251
+ "mmr_details": mmr_debug,
252
+ "mmr_selected": mmr_selected,
253
+ "no_mmr_selected": no_mmr_selected,
254
+ "rerank_full": full_rerank,
255
+ "score_lookup": {str(k): v for k, v in score_lookup.items()},
256
+ "timings": timings,
257
+ "umap_coords": umap_coords,
258
+ "sim_matrix": sim_matrix,
259
+ "doc_indices": hybrid_indices,
260
+ "sims": sims,
261
+ "doc_previews": [docs_all[i][:80].replace("passage: ", "") for i in hybrid_indices],
262
+ }
263
+
264
+ return final, debug_info
src/retrieval/test.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from query import retrieve
2
+
3
+ if __name__ == "__main__":
4
+ q = "how to install transformers"
5
+ results, debug = retrieve(q)
6
+
7
+ for i, r in enumerate(results):
8
+ print(f"\n--- Result {i+1} ---")
9
+ print("Score:", r["rerank_score"])
10
+ print(r["text"][:300])
11
+
12
+ print("\n--- DEBUG INFO ---")
13
+ print(debug)
static/main.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function idfColor(n) {
2
+ const stops = [
3
+ { t: 0.0, r: 148, g: 163, b: 184 }, { t: 0.3, r: 99, g: 102, b: 241 },
4
+ { t: 0.7, r: 139, g: 92, b: 246 }, { t: 1.0, r: 236, g: 72, b: 153 }
5
+ ];
6
+ const v = Math.max(0, Math.min(1, n));
7
+ let lo = stops[0], hi = stops[stops.length - 1];
8
+ for (let i = 0; i < stops.length - 1; i++) {
9
+ if (v >= stops[i].t && v <= stops[i + 1].t) { lo = stops[i]; hi = stops[i + 1]; break; }
10
+ }
11
+ const t2 = lo.t === hi.t ? 0 : (v - lo.t) / (hi.t - lo.t);
12
+ return `rgb(${Math.round(lo.r + (hi.r - lo.r) * t2)},${Math.round(lo.g + (hi.g - lo.g) * t2)},${Math.round(lo.b + (hi.b - lo.b) * t2)})`;
13
+ }
14
+
15
+ function heatmapColor(v) {
16
+ if (v >= 0) { const i = Math.round(v * 255); return `rgb(255,${255 - i},${255 - i})`; }
17
+ const i = Math.round(-v * 255); return `rgb(${255 - i},${255 - i},255)`;
18
+ }
19
+
20
+ function escHtml(s) {
21
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
22
+ }
23
+
24
+ function renderQueryPanel(d) {
25
+ const container = document.getElementById("query-panel");
26
+ if (!d) { container.innerHTML = ""; return; }
27
+
28
+ const maxIdfTok = d.tokens.reduce((best, t) => t.idf > best.idf ? t : best, d.tokens[0]);
29
+
30
+ const pills = d.tokens.map(tok => {
31
+ const bg = idfColor(tok.idf_normalized);
32
+ const alpha = (0.15 + tok.idf_normalized * 0.85).toFixed(2);
33
+ return `<div class="token-pill">
34
+ <div class="pill-text" style="background:${bg};opacity:${alpha};min-width:32px;text-align:center">${escHtml(tok.token.replace('##', 'Β·'))}</div>
35
+ <div class="pill-id">#${tok.id}</div>
36
+ <div class="pill-idf">idf ${tok.idf.toFixed(2)}</div>
37
+ </div>`;
38
+ }).join("");
39
+
40
+ const cells = d.embedding.map((v, i) =>
41
+ `<div class="heatmap-cell" style="background:${heatmapColor(v)}" title="dim ${i}: ${v.toFixed(3)}"></div>`
42
+ ).join("");
43
+
44
+ const cpx = d.stats.complexity, cpxPct = (cpx * 100).toFixed(0);
45
+ const cpxLabel = cpx < 0.33 ? "Simple" : cpx < 0.66 ? "Moderate" : "Complex";
46
+
47
+ const embNote = maxIdfTok
48
+ ? `<div class="embed-note"> <b>"${escHtml(maxIdfTok.token)}"</b> has the highest IDF (${maxIdfTok.idf.toFixed(2)}) β€” it pulls the embedding vector the most and takes maximum retrieval weight.</div>`
49
+ : "";
50
+
51
+ container.innerHTML = `
52
+ <div class="query-panel">
53
+ <div class="panel-title">πŸ” Query Analysis</div>
54
+ <div class="token-row">${pills}</div>
55
+ <div class="heatmap-label">Query Embedding β€” ${d.embedding.length}-dim PCA projection
56
+ <span style="color:#94a3b8;margin-left:8px">β–  <span style="color:#ef4444">positive</span> &nbsp;β–  <span style="color:#6366f1">negative</span></span>
57
+ </div>
58
+ <div class="heatmap-strip">${cells}</div>
59
+ ${embNote}
60
+ <div class="stats-row">
61
+ <div class="stat-chip"><div class="stat-label">Tokens</div><div class="stat-value">${d.stats.token_count}</div></div>
62
+ <div class="stat-chip"><div class="stat-label">Unique</div><div class="stat-value">${d.stats.unique_tokens}</div></div>
63
+ <div class="stat-chip"><div class="stat-label">Avg IDF</div><div class="stat-value">${d.stats.avg_idf}</div></div>
64
+ <div class="complexity-wrap">
65
+ <div class="stat-label">Complexity β€” ${cpxLabel} (${cpxPct}%)</div>
66
+ <div class="complexity-bar-bg"><div class="complexity-bar-fill" style="width:${cpxPct}%"></div></div>
67
+ </div>
68
+ </div>
69
+ </div>`;
70
+ }
71
+
72
+
73
+ const STAGES = [
74
+ { key: "embed", label: "Embed", cls: "seg-embed" },
75
+ { key: "vector", label: "Vector", cls: "seg-vector" },
76
+ { key: "bm25", label: "BM25", cls: "seg-bm25" },
77
+ { key: "hybrid", label: "Hybrid", cls: "seg-hybrid" },
78
+ { key: "mmr", label: "MMR", cls: "seg-mmr" },
79
+ { key: "rerank", label: "Rerank", cls: "seg-rerank" },
80
+ { key: "llm", label: "LLM", cls: "seg-llm" },
81
+ ];
82
+ const SEG_COLORS = {
83
+ "seg-embed": "#6366f1", "seg-vector": "#0ea5e9", "seg-bm25": "#10b981",
84
+ "seg-hybrid": "#f59e0b", "seg-mmr": "#8b5cf6", "seg-rerank": "#ef4444", "seg-llm": "#64748b"
85
+ };
86
+
87
+ function renderLatencyPanel(timings) {
88
+ const container = document.getElementById("latency-panel");
89
+ if (!timings) { container.innerHTML = ""; return; }
90
+ const total = timings.total || STAGES.reduce((s, st) => s + (timings[st.key] || 0), 0);
91
+
92
+ const segments = STAGES.map((s, idx) => {
93
+ const ms = timings[s.key] || 0, pct = total > 0 ? (ms / total) * 100 : 0;
94
+ const isLast = idx === STAGES.length - 1;
95
+ const inner = pct > 5 ? ms + "ms" : "";
96
+ return `<div class="latency-segment ${s.cls}" style="${isLast ? 'flex:1' : 'width:' + pct.toFixed(2) + '%'}"
97
+ title="${s.label}: ${ms}ms (${pct.toFixed(1)}%)">${inner}</div>`;
98
+ }).join("");
99
+
100
+ const smallLabels = STAGES.map(s => {
101
+ const ms = timings[s.key] || 0, pct = total > 0 ? (ms / total) * 100 : 0;
102
+ if (ms === 0 || pct > 5) return "";
103
+ return `<span class="latency-small-label" style="color:${SEG_COLORS[s.cls]}">${s.label} ${ms}ms</span>`;
104
+ }).filter(Boolean).join("");
105
+ const smallRow = smallLabels ? `<div class="latency-small-row">${smallLabels}</div>` : "";
106
+
107
+ const chips = STAGES.map(s => {
108
+ const ms = timings[s.key] || 0, pct = total > 0 ? ((ms / total) * 100).toFixed(1) : "0.0";
109
+ return `<div class="latency-chip">
110
+ <div class="chip-dot" style="background:${SEG_COLORS[s.cls]}"></div>
111
+ ${s.label} <span class="chip-time">${ms}ms</span>
112
+ <span style="color:#94a3b8">${pct}%</span></div>`;
113
+ }).join("");
114
+
115
+ const slowest = STAGES.reduce((b, s) => (timings[s.key] || 0) > (timings[b.key] || 0) ? s : b, STAGES[0]);
116
+ const slowestMs = timings[slowest.key] || 0;
117
+ const slowestPct = total > 0 ? Math.round((slowestMs / total) * 100) : 0;
118
+ const slowestCallout = slowestMs > 0
119
+ ? `<div class="slowest-callout"> <b>${slowest.label}</b> is taking the most time β€” <b>${slowestMs}ms</b> (${slowestPct}% of total)</div>`
120
+ : "";
121
+
122
+ const retrieval = STAGES.filter(s => s.key !== "llm");
123
+ const bottleneck = retrieval.reduce((b, s) => (timings[s.key] || 0) > (timings[b.key] || 0) ? s : b, retrieval[0]);
124
+ const bPct = total > 0 ? Math.round((timings[bottleneck.key] / total) * 100) : 0;
125
+ const retrievalCallout = bPct > 15 && slowest.key !== "llm"
126
+ ? `<div class="bottleneck-callout">⚠ Retrieval bottleneck: <b>${bottleneck.label}</b> taking <b>${bPct}%</b> of total (${timings[bottleneck.key]}ms)</div>`
127
+ : "";
128
+ const llmPct = total > 0 ? Math.round(((timings.llm || 0) / total) * 100) : 0;
129
+ const llmCallout = llmPct > 50
130
+ ? `<div class="llm-callout">πŸ’¬ LLM generation is <b>${llmPct}%</b> of total time (${timings.llm}ms) β€” retrieval is fast βœ…</div>`
131
+ : "";
132
+
133
+ container.innerHTML = `
134
+ <div class="latency-panel">
135
+ <div class="panel-title">⏱ Latency Timeline</div>
136
+ <div class="latency-bar">${segments}</div>
137
+ ${smallRow}
138
+ <div class="latency-meta">${chips}<div class="latency-total">Total: ${total}ms</div></div>
139
+ ${slowestCallout}${retrievalCallout}${llmCallout}
140
+ </div>`;
141
+ }
142
+
143
+
144
+ function scoreColor(s) {
145
+ if (s >= 0.7) return { bg: "#dcfce7", color: "#166534" };
146
+ if (s >= 0.4) return { bg: "#fef9c3", color: "#854d0e" };
147
+ return { bg: "#fee2e2", color: "#991b1b" };
148
+ }
149
+
150
+ function renderChunksPanel(chunks, scores, raw_scores, sources) {
151
+ const container = document.getElementById("chunks-panel");
152
+ let html = `<h4 style="margin-top:28px;margin-bottom:12px;color:#1e293b">πŸ“„ Context Chunks</h4>`;
153
+
154
+ const order = scores
155
+ .map((s, i) => ({ i, s }))
156
+ .sort((a, b) => b.s - a.s)
157
+ .map(x => x.i);
158
+
159
+ order.forEach(i => {
160
+ const score = scores[i];
161
+ const sc = scoreColor(score);
162
+ const src = sources[i] || {};
163
+ const url = src.url || "";
164
+ const title = src.title || url;
165
+
166
+ let displayUrl = url;
167
+ try {
168
+ const u = new URL(url);
169
+ displayUrl = u.hostname + u.pathname.replace(/\/$/, "");
170
+ } catch (_) { }
171
+
172
+ const sourceTag = url
173
+ ? `<a class="chunk-source-link" href="${escHtml(url)}" target="_blank" title="${escHtml(title)}">
174
+ <span class="chunk-source-icon">πŸ”—</span>${escHtml(displayUrl)}
175
+ </a>`
176
+ : "";
177
+
178
+ html += `
179
+ <div class="chunk-card">
180
+ <div class="chunk-card-header">
181
+ <span class="chunk-card-title">Chunk ${i + 1}</span>
182
+ <span class="chunk-score-badge" style="background:${sc.bg};color:${sc.color}">
183
+ score ${score.toFixed(3)}
184
+ </span>
185
+ ${sourceTag}
186
+ </div>
187
+ <div class="chunk-card-body">
188
+ <div class="chunk-content">${marked.parse(chunks[i])}</div>
189
+ </div>
190
+ </div>`;
191
+ });
192
+
193
+ container.innerHTML = html;
194
+ hljs.highlightAll();
195
+ }
196
+
197
+
198
+ async function ask() {
199
+ const query = document.getElementById("query").value.trim();
200
+ if (!query) return;
201
+
202
+ document.getElementById("answer").innerHTML = "<p style='color:#94a3b8'>Thinking...</p>";
203
+ document.getElementById("latency-panel").innerHTML = "";
204
+ document.getElementById("query-panel").innerHTML = "<p style='font-size:12px;color:#94a3b8;padding:12px 0'>Analyzing query...</p>";
205
+ document.getElementById("retrieval-panel").innerHTML = "";
206
+ document.getElementById("mmr-panel").innerHTML = "";
207
+ document.getElementById("chunks-panel").innerHTML = "";
208
+
209
+ const [askRes, analyzeRes] = await Promise.all([
210
+ fetch("/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) }),
211
+ fetch("/analyze_query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) })
212
+ ]);
213
+ const [data, analysis] = await Promise.all([askRes.json(), analyzeRes.json()]);
214
+
215
+ // 1. Answer
216
+ document.getElementById("answer").innerHTML =
217
+ `<h3>Answer:</h3><div class="answer-box">${marked.parse(data.answer)}</div>`;
218
+ hljs.highlightAll();
219
+
220
+ // 2. Latency
221
+ renderLatencyPanel(data.timings);
222
+
223
+ // 3. Query Analysis
224
+ renderQueryPanel(analysis);
225
+
226
+ // 4. Retrieval Comparison
227
+ renderRetrievalTable(data.comparison_rows);
228
+
229
+ // 5. MMR
230
+ renderMMRPanel(data.mmr_data);
231
+
232
+ // 6. Chunks
233
+ renderChunksPanel(data.chunks, data.scores, data.raw_scores, data.sources);
234
+ }
235
+
236
+ document.addEventListener("DOMContentLoaded", () => {
237
+ document.getElementById("query").addEventListener("keydown", e => {
238
+ if (e.key === "Enter") ask();
239
+ });
240
+ });
static/mmr.js ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let _p3 = {
2
+ coords: null, // [[x,y], ...] UMAP coords, local index
3
+ sims: null, // [float, ...] query-doc similarities
4
+ previews: null, // [str, ...] chunk previews
5
+ docIndices: null, // [int, ...] global doc indices
6
+ mmrSelected: null, // set of global doc indices β€” current MMR selection
7
+ noMmrSelected: null, // set β€” pure relevance top-10
8
+ lambda: 0.7,
9
+ sliderTimeout: null,
10
+ };
11
+
12
+ function renderMMRPanel(mmrData) {
13
+ const container = document.getElementById("mmr-panel");
14
+ if (!mmrData || !mmrData.umap_coords) {
15
+ container.innerHTML = `<div class="p3-panel"><div class="panel-title">πŸ”΅ MMR Visualization</div><p style="color:#94a3b8;font-size:13px">UMAP unavailable β€” install umap-learn</p></div>`;
16
+ return;
17
+ }
18
+
19
+ _p3.coords = mmrData.umap_coords;
20
+ _p3.sims = mmrData.sims;
21
+ _p3.previews = mmrData.doc_previews;
22
+ _p3.docIndices = mmrData.doc_indices;
23
+ _p3.mmrSelected = new Set(mmrData.mmr_selected);
24
+ _p3.noMmrSelected = new Set(mmrData.no_mmr_selected);
25
+ _p3.lambda = 0.7;
26
+
27
+ container.innerHTML = `
28
+ <div class="p3-panel">
29
+ <div class="panel-title">πŸ”΅ MMR Visualization</div>
30
+
31
+ <!-- Lambda slider -->
32
+ <div class="p3-slider-row">
33
+ <span class="p3-slider-label">Ξ» = <b id="p3-lambda-val">0.70</b></span>
34
+ <input id="p3-lambda" type="range" min="0" max="1" step="0.05" value="0.7" class="p3-slider">
35
+ <span class="p3-slider-hint">← diversity &nbsp;&nbsp; relevance β†’</span>
36
+ </div>
37
+
38
+ <!-- UMAP + side-by-side row -->
39
+ <div class="p3-top-row">
40
+
41
+ <!-- UMAP scatter -->
42
+ <div class="p3-scatter-wrap">
43
+ <div class="p3-section-label">UMAP Scatter β€” candidate chunks</div>
44
+ <canvas id="p3-umap" width="340" height="280" class="p3-canvas"></canvas>
45
+ <div class="p3-scatter-legend">
46
+ <span><span class="p3-dot-lg" style="background:#10b981"></span> MMR selected</span>
47
+ <span><span class="p3-dot-lg" style="background:#ef4444"></span> Rejected</span>
48
+ </div>
49
+ <div id="p3-tooltip" class="p3-scatter-tooltip"></div>
50
+ </div>
51
+
52
+ <!-- Side by side: No MMR vs MMR -->
53
+ <div class="p3-side-wrap">
54
+ <div class="p3-side-col">
55
+ <div class="p3-section-label" style="color:#f59e0b">Without MMR (top relevance)</div>
56
+ <div id="p3-no-mmr-list" class="p3-chunk-list"></div>
57
+ </div>
58
+ <div class="p3-side-col">
59
+ <div class="p3-section-label" style="color:#10b981">With MMR (Ξ»=<span id="p3-side-lambda">0.70</span>)</div>
60
+ <div id="p3-mmr-list" class="p3-chunk-list"></div>
61
+ </div>
62
+ </div>
63
+
64
+ </div>
65
+
66
+ <!-- Similarity matrix -->
67
+ <div class="p3-section-label" style="margin-top:18px">Similarity Matrix β€” top 10 MMR candidates</div>
68
+ <div class="p3-matrix-wrap">
69
+ <canvas id="p3-simmatrix" class="p3-canvas"></canvas>
70
+ </div>
71
+
72
+ </div>`;
73
+
74
+ _drawAll();
75
+ _drawSideBySide();
76
+ _drawSimMatrix(mmrData.sim_matrix);
77
+
78
+
79
+ document.getElementById("p3-lambda").addEventListener("input", function () {
80
+ _p3.lambda = parseFloat(this.value);
81
+ document.getElementById("p3-lambda-val").textContent = _p3.lambda.toFixed(2);
82
+ document.getElementById("p3-side-lambda").textContent = _p3.lambda.toFixed(2);
83
+ clearTimeout(_p3.sliderTimeout);
84
+ _p3.sliderTimeout = setTimeout(_rerrunMMR, 300);
85
+ });
86
+ }
87
+
88
+ function _drawAll() {
89
+ const canvas = document.getElementById("p3-umap");
90
+ if (!canvas) return;
91
+ const ctx = canvas.getContext("2d");
92
+ const W = canvas.width, H = canvas.height;
93
+ const PAD = 24;
94
+
95
+ ctx.clearRect(0, 0, W, H);
96
+
97
+
98
+ _p3.coords.forEach(([x, y], i) => {
99
+ const cx = PAD + x * (W - PAD * 2);
100
+ const cy = PAD + y * (H - PAD * 2);
101
+ const isSelected = _p3.mmrSelected.has(_p3.docIndices[i]);
102
+ const sim = _p3.sims[i] || 0;
103
+
104
+ if (isSelected) {
105
+ ctx.beginPath();
106
+ ctx.arc(cx, cy, 11, 0, Math.PI * 2);
107
+ ctx.fillStyle = "rgba(16,185,129,0.18)";
108
+ ctx.fill();
109
+ }
110
+
111
+ ctx.beginPath();
112
+ ctx.arc(cx, cy, isSelected ? 8 : 5, 0, Math.PI * 2);
113
+ ctx.fillStyle = isSelected ? "#10b981" : "#ef4444";
114
+ ctx.globalAlpha = isSelected ? 1 : 0.55 + sim * 0.45;
115
+ ctx.fill();
116
+ ctx.globalAlpha = 1;
117
+
118
+ if (isSelected) {
119
+ ctx.font = "bold 9px Arial";
120
+ ctx.fillStyle = "#065f46";
121
+ ctx.fillText(i, cx + 10, cy + 4);
122
+ }
123
+ });
124
+
125
+ canvas.onmousemove = (e) => {
126
+ const rect = canvas.getBoundingClientRect();
127
+ const mx = e.clientX - rect.left;
128
+ const my = e.clientY - rect.top;
129
+ const PAD = 24;
130
+
131
+ let hit = null;
132
+ _p3.coords.forEach(([x, y], i) => {
133
+ const cx = PAD + x * (W - PAD * 2);
134
+ const cy = PAD + y * (H - PAD * 2);
135
+ if (Math.hypot(mx - cx, my - cy) < 10) hit = i;
136
+ });
137
+
138
+ const tip = document.getElementById("p3-tooltip");
139
+ if (hit !== null) {
140
+ const isSelected = _p3.mmrSelected.has(_p3.docIndices[hit]);
141
+ tip.innerHTML = `
142
+ <b>${isSelected ? "βœ… Selected" : "❌ Rejected"}</b> β€” chunk ${hit}<br>
143
+ sim: ${(_p3.sims[hit] || 0).toFixed(3)}<br>
144
+ <span style="color:#cbd5e1">${escP3(_p3.previews[hit] || "")}</span>`;
145
+ tip.style.display = "block";
146
+ tip.style.left = (mx + 12) + "px";
147
+ tip.style.top = (my + 12) + "px";
148
+ } else {
149
+ tip.style.display = "none";
150
+ }
151
+ };
152
+ canvas.onmouseleave = () => {
153
+ const tip = document.getElementById("p3-tooltip");
154
+ if (tip) tip.style.display = "none";
155
+ };
156
+ }
157
+
158
+ function _drawSideBySide() {
159
+ const noMmrEl = document.getElementById("p3-no-mmr-list");
160
+ const mmrEl = document.getElementById("p3-mmr-list");
161
+ if (!noMmrEl || !mmrEl) return;
162
+
163
+ function makeList(selectedSet) {
164
+ return _p3.docIndices
165
+ .map((docIdx, i) => ({ docIdx, i, sim: _p3.sims[i] || 0, preview: _p3.previews[i] || "" }))
166
+ .filter(({ docIdx }) => selectedSet.has(docIdx))
167
+ .sort((a, b) => b.sim - a.sim)
168
+ .map(({ i, sim, preview }) => `
169
+ <div class="p3-side-item">
170
+ <div class="p3-side-sim">${sim.toFixed(3)}</div>
171
+ <div class="p3-side-text">${escP3(preview)}…</div>
172
+ </div>`)
173
+ .join("");
174
+ }
175
+
176
+ noMmrEl.innerHTML = makeList(_p3.noMmrSelected);
177
+ mmrEl.innerHTML = makeList(_p3.mmrSelected);
178
+ }
179
+
180
+ function _drawSimMatrix(simMatrix) {
181
+ if (!simMatrix) return;
182
+ const localSelected = _p3.docIndices
183
+ .map((d, i) => ({ d, i }))
184
+ .filter(({ d }) => _p3.mmrSelected.has(d))
185
+ .map(({ i }) => i)
186
+ .slice(0, 10);
187
+
188
+ const N = localSelected.length;
189
+ const CELL = 32;
190
+ const canvas = document.getElementById("p3-simmatrix");
191
+ if (!canvas || N === 0) return;
192
+
193
+ canvas.width = N * CELL;
194
+ canvas.height = N * CELL;
195
+ const ctx = canvas.getContext("2d");
196
+
197
+ for (let r = 0; r < N; r++) {
198
+ for (let c = 0; c < N; c++) {
199
+ const ri = localSelected[r];
200
+ const ci = localSelected[c];
201
+ const val = (simMatrix[ri] && simMatrix[ri][ci] != null) ? simMatrix[ri][ci] : 0;
202
+ ctx.fillStyle = simHeatColor(val);
203
+ ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
204
+
205
+ ctx.font = "9px Arial";
206
+ ctx.fillStyle = val > 0.6 ? "#fff" : "#334155";
207
+ ctx.textAlign = "center";
208
+ ctx.fillText(val.toFixed(2), c * CELL + CELL / 2, r * CELL + CELL / 2 + 3);
209
+ }
210
+ }
211
+
212
+ ctx.font = "bold 9px Arial";
213
+ ctx.fillStyle = "#64748b";
214
+ ctx.textAlign = "center";
215
+ for (let i = 0; i < N; i++) {
216
+ ctx.fillText(localSelected[i], i * CELL + CELL / 2, N * CELL + 12);
217
+ ctx.fillText(localSelected[i], -6, i * CELL + CELL / 2 + 3);
218
+ }
219
+ }
220
+
221
+ function simHeatColor(v) {
222
+ const t = Math.max(0, Math.min(1, v));
223
+ const r = Math.round(255 - t * (255 - 99));
224
+ const g = Math.round(255 - t * (255 - 102));
225
+ const b = Math.round(255 - t * (255 - 241));
226
+ return `rgb(${r},${g},${b})`;
227
+ }
228
+
229
+ async function _rerrunMMR() {
230
+ const res = await fetch("/mmr_rerun", {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({ lambda: _p3.lambda })
234
+ });
235
+ const data = await res.json();
236
+ if (data.error) { console.warn("MMR rerun:", data.error); return; }
237
+
238
+ _p3.mmrSelected = new Set(data.selected_indices);
239
+ _drawAll();
240
+ _drawSideBySide();
241
+ }
242
+
243
+ function escP3(s) {
244
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
245
+ }
static/rerank.js ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Store full texts in a module-level array β€” avoids HTML attribute escaping bugs
2
+ let _p2FullTexts = [];
3
+
4
+ function scoreBar(val, color) {
5
+ const pct = (val * 100).toFixed(0);
6
+ return `
7
+ <div class="p2-score-wrap" title="${val.toFixed(4)}">
8
+ <div class="p2-bar-bg">
9
+ <div class="p2-bar-fill" style="width:${pct}%;background:${color}"></div>
10
+ </div>
11
+ <span class="p2-score-num">${val.toFixed(3)}</span>
12
+ </div>`;
13
+ }
14
+
15
+ function rankArrow(delta) {
16
+ if (delta > 0) return `<span class="p2-arrow up" title="Moved up ${delta}">β–²${delta}</span>`;
17
+ if (delta < 0) return `<span class="p2-arrow down" title="Moved down ${Math.abs(delta)}">β–Ό${Math.abs(delta)}</span>`;
18
+ return `<span class="p2-arrow flat" title="No change">β€”</span>`;
19
+ }
20
+
21
+ function rowColor(rerank, passed) {
22
+ if (!passed) return "rgba(239,68,68,0.06)";
23
+ if (rerank >= 0.7) return "rgba(16,185,129,0.08)";
24
+ if (rerank >= 0.4) return "rgba(245,158,11,0.07)";
25
+ return "rgba(99,102,241,0.05)";
26
+ }
27
+
28
+ function borderColor(rerank, passed) {
29
+ if (!passed) return "#fca5a5";
30
+ if (rerank >= 0.7) return "#6ee7b7";
31
+ if (rerank >= 0.4) return "#fcd34d";
32
+ return "#c7d2fe";
33
+ }
34
+
35
+ function escHtml(s) {
36
+ return String(s)
37
+ .replace(/&/g, "&amp;")
38
+ .replace(/</g, "&lt;")
39
+ .replace(/>/g, "&gt;")
40
+ .replace(/"/g, "&quot;")
41
+ .replace(/'/g, "&#39;");
42
+ }
43
+
44
+ function renderRetrievalTable(rows) {
45
+ const container = document.getElementById("retrieval-panel");
46
+ if (!rows || !rows.length) { container.innerHTML = ""; return; }
47
+
48
+ _p2FullTexts = rows.map(r => r.text_full || "");
49
+
50
+ const topRow = [...rows].sort((a, b) => b.rerank_score - a.rerank_score)[0];
51
+
52
+ const tableRows = rows.map((r, i) => {
53
+ const isTop = r.idx === topRow.idx;
54
+ const bg = rowColor(r.rerank_score, r.passed_threshold);
55
+ const border = borderColor(r.rerank_score, r.passed_threshold);
56
+ const dropped = !r.passed_threshold ? `<span class="p2-dropped">DROPPED</span>` : "";
57
+ const winner = isTop ? `<span class="p2-winner" title="Highest combined rerank score">β˜… top</span>` : "";
58
+ const preview = escHtml((r.text_preview || "").trim());
59
+
60
+ return `
61
+ <tr class="p2-row" style="background:${bg};border-left:3px solid ${border}">
62
+ <td class="p2-td p2-rank">#${r.pre_rank + 1}</td>
63
+ <td class="p2-td p2-rank">#${r.post_rank + 1} ${winner}${dropped}</td>
64
+ <td class="p2-td p2-preview-cell" data-idx="${i}">
65
+ <div class="p2-preview">${preview}</div>
66
+ <div class="p2-tooltip"></div>
67
+ </td>
68
+ <td class="p2-td">${scoreBar(r.vector_score, "#0ea5e9")}</td>
69
+ <td class="p2-td">${scoreBar(r.bm25_score, "#10b981")}</td>
70
+ <td class="p2-td">${scoreBar(r.hybrid_score, "#f59e0b")}</td>
71
+ <td class="p2-td">${scoreBar(r.rerank_score, r.passed_threshold ? "#6366f1" : "#ef4444")}</td>
72
+ <td class="p2-td p2-delta">${rankArrow(r.rank_delta)}</td>
73
+ </tr>`;
74
+ }).join("");
75
+
76
+ container.innerHTML = `
77
+ <div class="p2-panel">
78
+ <div class="panel-title">πŸ“Š Retrieval Comparison</div>
79
+ <div class="p2-legend">
80
+ <span><span class="p2-dot" style="background:#0ea5e9"></span>Vector</span>
81
+ <span><span class="p2-dot" style="background:#10b981"></span>BM25</span>
82
+ <span><span class="p2-dot" style="background:#f59e0b"></span>Hybrid</span>
83
+ <span><span class="p2-dot" style="background:#6366f1"></span>Reranker</span>
84
+ <span class="p2-legend-sep">|</span>
85
+ <span><span class="p2-dot" style="background:#6ee7b7;border:1px solid #10b981"></span>passed</span>
86
+ <span><span class="p2-dot" style="background:#fca5a5;border:1px solid #ef4444"></span>dropped</span>
87
+ </div>
88
+ <div class="p2-table-wrap">
89
+ <table class="p2-table">
90
+ <thead>
91
+ <tr>
92
+ <th class="p2-th">Original Rank</th>
93
+ <th class="p2-th">Rerank</th>
94
+ <th class="p2-th">Chunk</th>
95
+ <th class="p2-th">Vector</th>
96
+ <th class="p2-th">BM25</th>
97
+ <th class="p2-th">Hybrid</th>
98
+ <th class="p2-th">Reranker</th>
99
+ <th class="p2-th">Ξ”</th>
100
+ </tr>
101
+ </thead>
102
+ <tbody>${tableRows}</tbody>
103
+ </table>
104
+ </div>
105
+ </div>`;
106
+
107
+ container.querySelectorAll(".p2-preview-cell").forEach(cell => {
108
+ const tooltip = cell.querySelector(".p2-tooltip");
109
+ const idx = parseInt(cell.dataset.idx);
110
+ tooltip.textContent = _p2FullTexts[idx] || "";
111
+
112
+ cell.addEventListener("mouseenter", () => tooltip.classList.add("visible"));
113
+ cell.addEventListener("mouseleave", () => tooltip.classList.remove("visible"));
114
+ cell.addEventListener("mousemove", e => {
115
+ const spaceBelow = window.innerHeight - e.clientY;
116
+ tooltip.style.left = "0px";
117
+ if (spaceBelow < 220) {
118
+ tooltip.style.bottom = "100%";
119
+ tooltip.style.top = "auto";
120
+ } else {
121
+ tooltip.style.top = "100%";
122
+ tooltip.style.bottom = "auto";
123
+ }
124
+ });
125
+ });
126
+ }
static/style.css ADDED
@@ -0,0 +1,845 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════════════
2
+ Reset & Base
3
+ ══════════════════════════════════════════════════════ */
4
+ * {
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ body {
9
+ font-family: Arial, sans-serif;
10
+ background: #f5f7fa;
11
+ margin: 0;
12
+ }
13
+
14
+ p {
15
+ line-height: 1.5;
16
+ }
17
+
18
+ /* ══════════════════════════════════════════════════════
19
+ Layout
20
+ ══════════════════════════════════════════════════════ */
21
+ .container {
22
+ max-width: 900px;
23
+ margin: 50px auto;
24
+ padding: 25px;
25
+ text-align: center;
26
+ }
27
+
28
+ h1 {
29
+ margin-bottom: 25px;
30
+ }
31
+
32
+ .input-container {
33
+ display: flex;
34
+ gap: 10px;
35
+ justify-content: center;
36
+ margin-bottom: 20px;
37
+ }
38
+
39
+ input {
40
+ padding: 12px;
41
+ font-size: 16px;
42
+ flex: 1;
43
+ border-radius: 8px;
44
+ border: 1px solid #ccc;
45
+ max-width: 600px;
46
+ }
47
+
48
+ button {
49
+ padding: 12px 16px;
50
+ font-size: 16px;
51
+ border-radius: 8px;
52
+ border: none;
53
+ background: #007bff;
54
+ color: white;
55
+ cursor: pointer;
56
+ }
57
+
58
+ button:hover {
59
+ background: #0056b3;
60
+ }
61
+
62
+ /* ══════════════════════════════════════════════════════
63
+ Answer box
64
+ ══════════════════════════════════════════════════════ */
65
+ #answer {
66
+ margin-top: 20px;
67
+ width: 100%;
68
+ text-align: left;
69
+ }
70
+
71
+ .answer-box pre {
72
+ background: #807d7d;
73
+ padding: 14px;
74
+ border-radius: 10px;
75
+ overflow-x: auto;
76
+ margin: 10px 0;
77
+ }
78
+
79
+ .answer-box pre code {
80
+ background: transparent !important;
81
+ color: #f8f8f2 !important;
82
+ font-family: "Courier New", monospace;
83
+ font-size: 14px;
84
+ display: block;
85
+ white-space: pre;
86
+ }
87
+
88
+ .answer-box code:not(pre code) {
89
+ background: #eee;
90
+ color: #333;
91
+ padding: 2px 6px;
92
+ border-radius: 4px;
93
+ }
94
+
95
+ .answer-box ul,
96
+ .answer-box ol {
97
+ text-align: left;
98
+ padding-left: 20px;
99
+ }
100
+
101
+ .query-panel,
102
+ .latency-panel,
103
+ .p2-panel,
104
+ .p3-panel {
105
+ margin-top: 28px;
106
+ border: 1px solid #e2e8f0;
107
+ border-radius: 10px;
108
+ padding: 20px 24px;
109
+ background: #f8fafc;
110
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
111
+ }
112
+
113
+ .panel-title {
114
+ margin: 0 0 16px;
115
+ font-size: 13px;
116
+ font-weight: 600;
117
+ text-transform: uppercase;
118
+ letter-spacing: 0.08em;
119
+ color: #64748b;
120
+ }
121
+
122
+ .bottleneck-callout,
123
+ .llm-callout,
124
+ .slowest-callout {
125
+ margin-top: 8px;
126
+ padding: 8px 12px;
127
+ border-left-width: 3px;
128
+ border-left-style: solid;
129
+ border-radius: 4px;
130
+ font-size: 12px;
131
+ }
132
+
133
+ .bottleneck-callout {
134
+ background: #fff7ed;
135
+ border-left-color: #ef4444;
136
+ color: #7c2d12;
137
+ margin-top: 12px;
138
+ }
139
+
140
+ .bottleneck-callout b {
141
+ color: #dc2626;
142
+ }
143
+
144
+ .llm-callout {
145
+ background: #f0f9ff;
146
+ border-left-color: #64748b;
147
+ color: #1e3a5f;
148
+ }
149
+
150
+ .llm-callout b {
151
+ color: #334155;
152
+ }
153
+
154
+ .slowest-callout {
155
+ background: #fdf4ff;
156
+ border-left-color: #a855f7;
157
+ color: #581c87;
158
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
159
+ }
160
+
161
+ .slowest-callout b {
162
+ color: #7e22ce;
163
+ }
164
+
165
+ .p2-dot,
166
+ .p3-dot-lg {
167
+ display: inline-block;
168
+ width: 10px;
169
+ height: 10px;
170
+ margin-right: 4px;
171
+ vertical-align: middle;
172
+ }
173
+
174
+ .p2-dot {
175
+ border-radius: 3px;
176
+ }
177
+
178
+ .p3-dot-lg {
179
+ border-radius: 50%;
180
+ }
181
+
182
+
183
+ .p2-winner,
184
+ .p2-dropped {
185
+ display: inline-block;
186
+ margin-left: 5px;
187
+ font-size: 10px;
188
+ border-radius: 4px;
189
+ padding: 1px 5px;
190
+ font-weight: 600;
191
+ border-width: 1px;
192
+ border-style: solid;
193
+ }
194
+
195
+ .p2-winner {
196
+ background: #fef9c3;
197
+ color: #854d0e;
198
+ border-color: #fde047;
199
+ }
200
+
201
+ .p2-dropped {
202
+ background: #fee2e2;
203
+ color: #991b1b;
204
+ border-color: #fca5a5;
205
+ }
206
+
207
+
208
+ .p2-tooltip,
209
+ .p3-scatter-tooltip {
210
+ display: none;
211
+ position: absolute;
212
+ pointer-events: none;
213
+ background: #1e293b;
214
+ color: #e2e8f0;
215
+ border-radius: 6px;
216
+ font-family: Arial, sans-serif;
217
+ }
218
+
219
+ .p2-tooltip {
220
+ top: 100%;
221
+ left: 0;
222
+ z-index: 100;
223
+ width: 380px;
224
+ max-height: 200px;
225
+ overflow-y: auto;
226
+ font-size: 12px;
227
+ line-height: 1.6;
228
+ padding: 12px 14px;
229
+ border-radius: 8px;
230
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
231
+ white-space: pre-wrap;
232
+ word-break: break-word;
233
+ }
234
+
235
+ .p3-scatter-tooltip {
236
+ font-size: 11px;
237
+ line-height: 1.5;
238
+ padding: 8px 10px;
239
+ max-width: 220px;
240
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
241
+ z-index: 50;
242
+ }
243
+
244
+ .p2-tooltip.visible,
245
+ .p3-scatter-tooltip.visible {
246
+ display: block;
247
+ }
248
+
249
+ .p2-legend,
250
+ .p3-scatter-legend {
251
+ display: flex;
252
+ flex-wrap: wrap;
253
+ gap: 14px;
254
+ align-items: center;
255
+ font-size: 12px;
256
+ color: #475569;
257
+ }
258
+
259
+ .p2-legend {
260
+ margin-bottom: 14px;
261
+ }
262
+
263
+ .p3-scatter-legend {
264
+ margin-top: 8px;
265
+ }
266
+
267
+ .p2-legend-sep {
268
+ color: #cbd5e1;
269
+ }
270
+
271
+ .p2-bar-bg,
272
+ .complexity-bar-bg {
273
+ height: 6px;
274
+ background: #e2e8f0;
275
+ border-radius: 3px;
276
+ overflow: hidden;
277
+ }
278
+
279
+ .p2-bar-fill,
280
+ .complexity-bar-fill {
281
+ height: 100%;
282
+ border-radius: 3px;
283
+ }
284
+
285
+ .p2-bar-fill {
286
+ transition: width 0.4s ease;
287
+ }
288
+
289
+ .complexity-bar-fill {
290
+ background: linear-gradient(90deg, #10b981, #f59e0b, #ef4444);
291
+ transition: width 0.5s ease;
292
+ }
293
+
294
+ /* ══════════════════════════════════════════════════════
295
+ Query Analysis
296
+ ══════════════════════════════════════════════════════ */
297
+ .token-row {
298
+ display: flex;
299
+ flex-wrap: wrap;
300
+ gap: 8px;
301
+ margin-bottom: 20px;
302
+ }
303
+
304
+ .token-pill {
305
+ display: flex;
306
+ flex-direction: column;
307
+ align-items: center;
308
+ gap: 3px;
309
+ }
310
+
311
+ .pill-text {
312
+ padding: 4px 10px;
313
+ border-radius: 20px;
314
+ font-size: 13px;
315
+ font-weight: 600;
316
+ color: #fff;
317
+ letter-spacing: 0.02em;
318
+ white-space: nowrap;
319
+ }
320
+
321
+ .pill-id {
322
+ font-size: 9px;
323
+ color: #94a3b8;
324
+ }
325
+
326
+ .pill-idf {
327
+ font-size: 9px;
328
+ color: #64748b;
329
+ font-weight: 500;
330
+ }
331
+
332
+ .heatmap-label {
333
+ font-size: 11px;
334
+ color: #64748b;
335
+ margin-bottom: 6px;
336
+ font-weight: 500;
337
+ }
338
+
339
+ .heatmap-strip {
340
+ display: flex;
341
+ height: 22px;
342
+ border-radius: 4px;
343
+ overflow: hidden;
344
+ width: 100%;
345
+ gap: 1px;
346
+ margin-bottom: 10px;
347
+ }
348
+
349
+ .heatmap-cell {
350
+ flex: 1;
351
+ border-radius: 1px;
352
+ transition: opacity 0.15s;
353
+ cursor: default;
354
+ }
355
+
356
+ .heatmap-cell:hover {
357
+ opacity: 0.7;
358
+ }
359
+
360
+ .embed-note {
361
+ font-size: 12px;
362
+ color: #475569;
363
+ background: #eff6ff;
364
+ border-left: 3px solid #6366f1;
365
+ border-radius: 4px;
366
+ padding: 7px 12px;
367
+ margin-bottom: 14px;
368
+ font-family: Arial, sans-serif;
369
+ }
370
+
371
+ .stats-row {
372
+ display: flex;
373
+ flex-wrap: wrap;
374
+ gap: 16px;
375
+ }
376
+
377
+ .stat-chip {
378
+ display: flex;
379
+ flex-direction: column;
380
+ gap: 2px;
381
+ }
382
+
383
+ .stat-label {
384
+ font-size: 10px;
385
+ text-transform: uppercase;
386
+ letter-spacing: 0.06em;
387
+ color: #94a3b8;
388
+ }
389
+
390
+ .stat-value {
391
+ font-size: 15px;
392
+ font-weight: 700;
393
+ color: #0f172a;
394
+ }
395
+
396
+ .complexity-wrap {
397
+ display: flex;
398
+ flex-direction: column;
399
+ gap: 4px;
400
+ flex: 1;
401
+ min-width: 140px;
402
+ }
403
+
404
+ /* ══════════════════════════════════════════════════════
405
+ Retrieval Comparison Table
406
+ ══════════════════════════════════════════════════════ */
407
+ .p2-panel {
408
+ width: 100vw;
409
+ max-width: 100vw;
410
+ margin-left: calc(50% - 50vw);
411
+ margin-right: calc(50% - 50vw);
412
+ }
413
+
414
+ .p2-table-wrap {
415
+ overflow-x: auto;
416
+ border-radius: 8px;
417
+ border: 1px solid #e2e8f0;
418
+ }
419
+
420
+ .p2-table {
421
+ width: 100%;
422
+ border-collapse: collapse;
423
+ font-size: 12px;
424
+ }
425
+
426
+ .p2-th {
427
+ padding: 8px 12px;
428
+ text-align: left;
429
+ font-size: 11px;
430
+ font-weight: 600;
431
+ text-transform: uppercase;
432
+ letter-spacing: 0.06em;
433
+ color: #64748b;
434
+ background: #f1f5f9;
435
+ border-bottom: 1px solid #e2e8f0;
436
+ white-space: nowrap;
437
+ }
438
+
439
+ .p2-row {
440
+ border-bottom: 1px solid #e2e8f0;
441
+ }
442
+
443
+ .p2-row:last-child {
444
+ border-bottom: none;
445
+ }
446
+
447
+ .p2-td {
448
+ padding: 10px 12px;
449
+ vertical-align: middle;
450
+ color: #1e293b;
451
+ }
452
+
453
+ .p2-rank {
454
+ font-weight: 700;
455
+ font-size: 13px;
456
+ white-space: nowrap;
457
+ min-width: 80px;
458
+ }
459
+
460
+ .p2-preview-cell {
461
+ position: relative;
462
+ max-width: 260px;
463
+ min-width: 160px;
464
+ }
465
+
466
+ .p2-preview {
467
+ font-size: 12px;
468
+ color: #334155;
469
+ white-space: nowrap;
470
+ overflow: hidden;
471
+ text-overflow: ellipsis;
472
+ max-width: 240px;
473
+ cursor: default;
474
+ font-family: Arial, sans-serif;
475
+ }
476
+
477
+ .p2-score-wrap {
478
+ display: flex;
479
+ align-items: center;
480
+ gap: 6px;
481
+ min-width: 90px;
482
+ }
483
+
484
+ .p2-bar-bg {
485
+ flex: 1;
486
+ }
487
+
488
+ .p2-score-num {
489
+ font-size: 11px;
490
+ font-weight: 600;
491
+ color: #475569;
492
+ min-width: 36px;
493
+ }
494
+
495
+ .p2-delta {
496
+ text-align: center;
497
+ min-width: 40px;
498
+ }
499
+
500
+ .p2-arrow {
501
+ font-size: 12px;
502
+ font-weight: 700;
503
+ padding: 2px 5px;
504
+ border-radius: 4px;
505
+ }
506
+
507
+ .p2-arrow.up {
508
+ color: #059669;
509
+ background: #d1fae5;
510
+ }
511
+
512
+ .p2-arrow.down {
513
+ color: #dc2626;
514
+ background: #fee2e2;
515
+ }
516
+
517
+ .p2-arrow.flat {
518
+ color: #94a3b8;
519
+ background: #f1f5f9;
520
+ }
521
+
522
+ /* ════════════════════���═════════════════════════════════
523
+ MMR Visualization
524
+ ══════════════════════════════════════════════════════ */
525
+ .p3-section-label {
526
+ font-size: 11px;
527
+ font-weight: 600;
528
+ text-transform: uppercase;
529
+ letter-spacing: 0.07em;
530
+ color: #64748b;
531
+ margin-bottom: 8px;
532
+ }
533
+
534
+ .p3-slider-row {
535
+ display: flex;
536
+ align-items: center;
537
+ gap: 12px;
538
+ margin-bottom: 18px;
539
+ flex-wrap: wrap;
540
+ }
541
+
542
+ .p3-slider-label {
543
+ font-size: 13px;
544
+ color: #334155;
545
+ min-width: 70px;
546
+ }
547
+
548
+ .p3-slider {
549
+ flex: 1;
550
+ max-width: 260px;
551
+ accent-color: #6366f1;
552
+ cursor: pointer;
553
+ }
554
+
555
+ .p3-slider-hint {
556
+ font-size: 11px;
557
+ color: #94a3b8;
558
+ }
559
+
560
+ .p3-top-row {
561
+ display: flex;
562
+ gap: 20px;
563
+ flex-wrap: wrap;
564
+ align-items: flex-start;
565
+ }
566
+
567
+ .p3-scatter-wrap {
568
+ position: relative;
569
+ flex-shrink: 0;
570
+ }
571
+
572
+ .p3-canvas {
573
+ display: block;
574
+ border-radius: 8px;
575
+ border: 1px solid #e2e8f0;
576
+ background: #fff;
577
+ }
578
+
579
+ .p3-side-wrap {
580
+ display: flex;
581
+ gap: 14px;
582
+ flex: 1;
583
+ min-width: 280px;
584
+ }
585
+
586
+ .p3-side-col {
587
+ flex: 1;
588
+ min-width: 120px;
589
+ }
590
+
591
+ .p3-chunk-list {
592
+ display: flex;
593
+ flex-direction: column;
594
+ gap: 6px;
595
+ max-height: 280px;
596
+ overflow-y: auto;
597
+ }
598
+
599
+ .p3-side-item {
600
+ display: flex;
601
+ gap: 8px;
602
+ padding: 6px 8px;
603
+ background: #fff;
604
+ border: 1px solid #e2e8f0;
605
+ border-radius: 6px;
606
+ font-size: 11px;
607
+ align-items: flex-start;
608
+ font-family: Arial, sans-serif;
609
+ }
610
+
611
+ .p3-side-sim {
612
+ font-weight: 700;
613
+ color: #6366f1;
614
+ white-space: nowrap;
615
+ flex-shrink: 0;
616
+ font-family: 'JetBrains Mono', monospace;
617
+ }
618
+
619
+ .p3-side-text {
620
+ color: #475569;
621
+ overflow: hidden;
622
+ display: -webkit-box;
623
+ -webkit-line-clamp: 2;
624
+ -webkit-box-orient: vertical;
625
+ }
626
+
627
+ .p3-matrix-wrap {
628
+ overflow-x: auto;
629
+ margin-top: 6px;
630
+ padding-bottom: 16px;
631
+ }
632
+
633
+ /* ══════════════════════════════════════════════════════
634
+ Latency Timeline
635
+ ══════════════════════════════════════════════════════ */
636
+ .latency-bar {
637
+ display: flex;
638
+ height: 28px;
639
+ border-radius: 6px;
640
+ overflow: hidden;
641
+ width: 100%;
642
+ }
643
+
644
+ .latency-segment {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: center;
648
+ font-size: 11px;
649
+ font-weight: 600;
650
+ color: #fff;
651
+ white-space: nowrap;
652
+ overflow: hidden;
653
+ transition: opacity 0.2s;
654
+ cursor: default;
655
+ }
656
+
657
+ .latency-segment:last-child {
658
+ flex: 1;
659
+ }
660
+
661
+ .latency-segment:hover {
662
+ opacity: 0.85;
663
+ }
664
+
665
+ .seg-embed {
666
+ background: #6366f1;
667
+ }
668
+
669
+ .seg-vector {
670
+ background: #0ea5e9;
671
+ }
672
+
673
+ .seg-bm25 {
674
+ background: #10b981;
675
+ }
676
+
677
+ .seg-hybrid {
678
+ background: #f59e0b;
679
+ }
680
+
681
+ .seg-mmr {
682
+ background: #8b5cf6;
683
+ }
684
+
685
+ .seg-rerank {
686
+ background: #ef4444;
687
+ }
688
+
689
+ .seg-llm {
690
+ background: #64748b;
691
+ }
692
+
693
+ .latency-small-row {
694
+ display: flex;
695
+ flex-wrap: wrap;
696
+ gap: 8px;
697
+ margin-top: 6px;
698
+ margin-bottom: 2px;
699
+ }
700
+
701
+ .latency-small-label {
702
+ font-size: 10px;
703
+ font-weight: 700;
704
+ background: #f1f5f9;
705
+ border-radius: 4px;
706
+ padding: 2px 6px;
707
+ white-space: nowrap;
708
+ }
709
+
710
+ .latency-meta {
711
+ display: flex;
712
+ flex-wrap: wrap;
713
+ gap: 12px;
714
+ margin-top: 14px;
715
+ align-items: center;
716
+ }
717
+
718
+ .latency-chip {
719
+ display: flex;
720
+ align-items: center;
721
+ gap: 6px;
722
+ font-size: 12px;
723
+ color: #334155;
724
+ }
725
+
726
+ .chip-dot {
727
+ width: 10px;
728
+ height: 10px;
729
+ border-radius: 3px;
730
+ flex-shrink: 0;
731
+ }
732
+
733
+ .chip-time {
734
+ font-weight: 700;
735
+ color: #0f172a;
736
+ }
737
+
738
+ .latency-total {
739
+ margin-left: auto;
740
+ font-size: 13px;
741
+ font-weight: 700;
742
+ color: #0f172a;
743
+ }
744
+
745
+ /* ══════════════════════════════════════════════════════
746
+ Chunk cards
747
+ ══════════════════════════════════════════════════════ */
748
+ .chunk-card {
749
+ border: 1px solid #e2e8f0;
750
+ border-radius: 10px;
751
+ background: #fff;
752
+ margin-bottom: 14px;
753
+ overflow: hidden;
754
+ }
755
+
756
+ .chunk-card-header {
757
+ display: flex;
758
+ align-items: center;
759
+ justify-content: space-between;
760
+ padding: 10px 14px;
761
+ background: #f8fafc;
762
+ border-bottom: 1px solid #e2e8f0;
763
+ gap: 10px;
764
+ flex-wrap: wrap;
765
+ }
766
+
767
+ .chunk-card-title {
768
+ font-size: 14px;
769
+ font-weight: 700;
770
+ color: #1e293b;
771
+ }
772
+
773
+ .chunk-score-badge {
774
+ font-size: 12px;
775
+ font-weight: 700;
776
+ padding: 3px 8px;
777
+ border-radius: 20px;
778
+ white-space: nowrap;
779
+ }
780
+
781
+ .chunk-source-link {
782
+ font-size: 14px;
783
+ color: #6366f1;
784
+ text-decoration: none;
785
+ display: flex;
786
+ align-items: center;
787
+ gap: 4px;
788
+ overflow: hidden;
789
+ text-overflow: ellipsis;
790
+ white-space: nowrap;
791
+ max-width: 380px;
792
+ }
793
+
794
+ .chunk-source-link:hover {
795
+ text-decoration: underline;
796
+ }
797
+
798
+ .chunk-source-icon {
799
+ font-size: 12px;
800
+ flex-shrink: 0;
801
+ }
802
+
803
+ .chunk-card-body {
804
+ padding: 14px;
805
+ }
806
+
807
+ .chunk-content {
808
+ font-size: 14px;
809
+ color: #334155;
810
+ line-height: 1.6;
811
+ text-align: left;
812
+ }
813
+
814
+ .chunk-content pre,
815
+ .chunk-content code {
816
+ background: none !important;
817
+ color: inherit !important;
818
+ padding: 0 !important;
819
+ border-radius: 0 !important;
820
+ font-family: inherit !important;
821
+ white-space: normal !important;
822
+ }
823
+
824
+ .chunk {
825
+ border: 1px solid #ddd;
826
+ padding: 12px;
827
+ margin-bottom: 12px;
828
+ border-radius: 10px;
829
+ background: white;
830
+ text-align: left;
831
+ }
832
+
833
+ .chunk pre,
834
+ .chunk code {
835
+ background: none !important;
836
+ color: inherit !important;
837
+ padding: 0 !important;
838
+ border-radius: 0 !important;
839
+ font-family: inherit !important;
840
+ white-space: normal !important;
841
+ }
842
+
843
+ .source {
844
+ margin-top: 8px;
845
+ }
templates/index.html ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <title>RAG Assistant</title>
6
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
7
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
10
+ </head>
11
+
12
+ <body>
13
+
14
+ <div class="container">
15
+
16
+ <h1>πŸ“š RAG Assistant</h1>
17
+
18
+ <div class="input-container">
19
+ <input id="query" placeholder="Ask something about Transformers...">
20
+ <button onclick="ask()">Ask</button>
21
+ </div>
22
+
23
+ <div id="answer"></div>
24
+ <div id="latency-panel"></div>
25
+ <div id="query-panel"></div>
26
+ <div id="retrieval-panel"></div>
27
+ <div id="mmr-panel"></div>
28
+ <div id="chunks-panel"></div>
29
+
30
+ </div>
31
+
32
+ <script src="{{ url_for('static', filename='rerank.js') }}"></script>
33
+ <script src="{{ url_for('static', filename='mmr.js') }}"></script>
34
+ <script src="{{ url_for('static', filename='main.js') }}"></script>
35
+
36
+ </body>
37
+
38
+ </html>